diff --git a/.gitignore b/.gitignore index 65befc209636df2085ea351d9c2d94b7d0073257..b8cbfe9966dbf7fbae0ff03f2db7bff234c9562b 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ eslint-report.html /public/uploads.* /public/uploads/ /shared/artifacts/ +/spec/examples.txt /rails_best_practices_output.html /tags /vendor/bundle/* @@ -82,3 +83,4 @@ jsdoc/ **/tmp/rubocop_cache/** .overcommit.yml .projections.json +/qa/.rakeTasks diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 630c82bcc5ceecb2f9deb677254d681ce1bc2726..36108d04e9c85812f107c646a78d872a474de2ca 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,7 @@ image: "registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.3-golang-1.11-git-2.22-chrome-73.0-node-12.x-yarn-1.16-postgresql-9.6-graphicsmagick-1.3.33" stages: + - sync - prepare - quick-test - test @@ -8,7 +9,6 @@ stages: - review - qa - post-test - - notification - pages variables: @@ -33,7 +33,6 @@ 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 @@ -42,3 +41,4 @@ include: - local: .gitlab/ci/setup.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 a02740373daa0cacc55d75ad39f63426ffd38823..c8283326533a855c8b38ade8a2ad37a842203c66 100644 --- a/.gitlab/CODEOWNERS +++ b/.gitlab/CODEOWNERS @@ -3,11 +3,12 @@ *.rake @gitlab-org/maintainers/rails-backend # Technical writing team are the default reviewers for everything in `doc/` -/doc/ @axil @marcia @eread @mikelewis +/doc/ @gl-docsteam # Frontend maintainers should see everything in `app/assets/` -app/assets/ @ClemMakesApps @fatihacet @filipa @mikegreiling @timzallmann @kushalpandya @pslaughter @wortschi @ntepluhina -*.scss @annabeldunstone @ClemMakesApps @fatihacet @filipa @mikegreiling @timzallmann @kushalpandya @pslaughter @wortschi @ntepluhina +app/assets/ @gitlab-org/maintainers/frontend +*.scss @annabeldunstone @gitlab-org/maintainers/frontend +/scripts/frontend/ @gitlab-org/maintainers/frontend # Database maintainers should review changes in `db/` db/ @gitlab-org/maintainers/database @@ -32,4 +33,5 @@ lib/gitlab/github_import/ @gitlab-org/maintainers/database /.gitlab/ci/ @gl-quality/eng-prod Dangerfile @gl-quality/eng-prod /danger/ @gl-quality/eng-prod +/lib/gitlab/danger/ @gl-quality/eng-prod /scripts/ @gl-quality/eng-prod diff --git a/.gitlab/ci/cng.gitlab-ci.yml b/.gitlab/ci/cng.gitlab-ci.yml index 35859a1ab335c8375ffce952be06d997e1b70589..bd11042eb112717fbdac286e2aa84329909f38de 100644 --- a/.gitlab/ci/cng.gitlab-ci.yml +++ b/.gitlab/ci/cng.gitlab-ci.yml @@ -1,4 +1,5 @@ cloud-native-image: + extends: .only:variables-canonical-dot-com image: ruby:2.6-alpine dependencies: [] stage: post-test @@ -12,5 +13,3 @@ cloud-native-image: only: refs: - tags - variables: - - $CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE == "gitlab-org" diff --git a/.gitlab/ci/docs.gitlab-ci.yml b/.gitlab/ci/docs.gitlab-ci.yml index 14eeebb9db9bf865796dca03ee60f24e000da03b..07375fca611ab47b227c9b36eb4c07c3cbc2c412 100644 --- a/.gitlab/ci/docs.gitlab-ci.yml +++ b/.gitlab/ci/docs.gitlab-ci.yml @@ -2,12 +2,11 @@ extends: - .default-tags - .default-retry - - .only-docs-changes + - .only:variables-canonical-dot-com + - .only:changes-docs only: refs: - merge_requests - variables: - - $CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE == "gitlab-org" image: ruby:2.6-alpine stage: review dependencies: [] @@ -50,7 +49,7 @@ docs lint: - .default-tags - .default-retry - .default-only - - .only-docs-changes + - .only:changes-docs image: "registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-docs-lint" stage: test dependencies: [] @@ -68,7 +67,7 @@ docs lint: # Check the internal anchor links - bundle exec nanoc check internal_anchors -graphql-docs-verify: +graphql-reference-verify: extends: - .only-ee - .default-tags @@ -76,10 +75,10 @@ graphql-docs-verify: - .default-cache - .default-only - .default-before_script - - .only-graphql-changes - variables: - SETUP_DB: "false" + - .only:changes-code-backstage-qa + - .use-pg9 stage: test needs: ["setup-test-env"] script: - bundle exec rake gitlab:graphql:check_docs + - bundle exec rake gitlab:graphql:check_schema diff --git a/.gitlab/ci/frontend.gitlab-ci.yml b/.gitlab/ci/frontend.gitlab-ci.yml index 2f457bc0ee2cb49920fc37db9f3338ba364bfc02..0b72461a9fd69baad002f4d490083b39892d3f73 100644 --- a/.gitlab/ci/frontend.gitlab-ci.yml +++ b/.gitlab/ci/frontend.gitlab-ci.yml @@ -12,7 +12,7 @@ - .default-only - .default-before_script - .assets-compile-cache - - .only-code-qa-changes + - .only:changes-code-backstage-qa image: registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.3-git-2.22-chrome-73.0-node-12.x-yarn-1.16-graphicsmagick-1.3.33-docker-18.06.1 stage: test dependencies: ["setup-test-env"] @@ -73,7 +73,7 @@ gitlab:assets:compile pull-cache: - .default-only - .default-before_script - .assets-compile-cache - - .only-code-qa-changes + - .only:changes-code-backstage-qa - .use-pg9 stage: prepare script: @@ -128,7 +128,7 @@ compile-assets pull-cache foss: - .default-cache - .default-only - .default-before_script - - .only-code-changes + - .only:changes-code-backstage - .use-pg9 stage: test needs: ["setup-test-env", "compile-assets pull-cache"] @@ -205,7 +205,7 @@ jest-foss: - .default-retry - .default-cache - .default-only - - .only-code-changes + - .only:changes-code-backstage stage: test dependencies: [] cache: @@ -238,7 +238,7 @@ webpack-dev-server: - .default-retry - .default-cache - .default-only - - .only-code-changes + - .only:changes-code-backstage stage: test needs: ["setup-test-env", "compile-assets pull-cache"] dependencies: ["setup-test-env", "compile-assets pull-cache"] diff --git a/.gitlab/ci/global.gitlab-ci.yml b/.gitlab/ci/global.gitlab-ci.yml index fc9b00b5d3c78e7d12d9c50ceac1372453e45a70..d746d8fe030c95ec7694e529a7d3412ebd50b24e 100644 --- a/.gitlab/ci/global.gitlab-ci.yml +++ b/.gitlab/ci/global.gitlab-ci.yml @@ -40,14 +40,97 @@ - merge_requests - tags -.only-code-changes: +.only:variables-canonical-dot-com: + only: + variables: + - $CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE =~ /^gitlab-org($|\/)/ # Matches the gitlab-org group or its subgroups + +.only:variables_refs-canonical-dot-com-schedules: + extends: .only:variables-canonical-dot-com + only: + refs: + - schedules + +.except:refs-deploy: + except: + refs: + - /^\d+-\d+-auto-deploy-\d+$/ + +.except:refs-master-tags-stable-deploy: + except: + refs: + - master + - tags + - /^[\d-]+-stable(-ee)?$/ + - /^\d+-\d+-auto-deploy-\d+$/ + +.only:kubernetes: + only: + kubernetes: active + +.only-review: + extends: + - .only:variables-canonical-dot-com + - .only:kubernetes + - .except:refs-master-tags-stable-deploy + +.only-review-schedules: + extends: + - .only:variables_refs-canonical-dot-com-schedules + - .only:kubernetes + - .except:refs-deploy + +.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/**/*" + +.backstage-patterns: &backstage-patterns + - "Dangerfile" + - "danger/**/*" + - "{,ee/}fixtures/**/*" + - "{,ee/}rubocop/**/*" + - "{,ee/}spec/**/*" + - "doc/README.md" # Some RSpec test rely on this file + +.qa-patterns: &qa-patterns + - ".dockerignore" + - "qa/**/*" + +.docs-patterns: &docs-patterns + - ".gitlab/route-map.yml" + - "doc/**/*" + - ".markdownlint.json" + +.only:changes-code: + only: + changes: *code-patterns + +.only:changes-qa: + only: + changes: *qa-patterns + +.only:changes-docs: + only: + changes: *docs-patterns + +.only:changes-code-backstage: only: changes: - ".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" - - "Dangerfile" - "Dockerfile.assets" - "*_VERSION" - "Gemfile{,.lock}" @@ -55,36 +138,43 @@ - "{babel.config,jest.config}.js" - "config.ru" - "{package.json,yarn.lock}" - - "{app,bin,config,danger,db,ee,fixtures,haml_lint,lib,locale,public,rubocop,scripts,spec,symbol,vendor}/**/*" + - "{,ee/}{app,bin,config,db,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*" + - "doc/api/graphql/**/*" + # Backstage changes + - "Dangerfile" + - "danger/**/*" + - "{,ee/}fixtures/**/*" + - "{,ee/}rubocop/**/*" + - "{,ee/}spec/**/*" - "doc/README.md" # Some RSpec test rely on this file -.only-qa-changes: +.only:changes-code-qa: only: changes: + - ".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/**/*" + # QA changes - ".dockerignore" - "qa/**/*" -.only-docs-changes: - only: - changes: - - ".gitlab/route-map.yml" - - "doc/**/*" - - ".markdownlint.json" - -.only-graphql-changes: - only: - changes: - - "{,ee/}app/graphql/**/*" - - "{,ee/}lib/gitlab/graphql/**/*" - -.only-code-qa-changes: +.only:changes-code-backstage-qa: only: changes: - ".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" - - "Dangerfile" - "Dockerfile.assets" - "*_VERSION" - "Gemfile{,.lock}" @@ -92,36 +182,19 @@ - "{babel.config,jest.config}.js" - "config.ru" - "{package.json,yarn.lock}" - - "{app,bin,config,danger,db,ee,fixtures,haml_lint,lib,locale,public,rubocop,scripts,spec,symbol,vendor}/**/*" + - "{,ee/}{app,bin,config,db,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*" + - "doc/api/graphql/**/*" + # Backstage changes + - "Dangerfile" + - "danger/**/*" + - "{,ee/}fixtures/**/*" + - "{,ee/}rubocop/**/*" + - "{,ee/}spec/**/*" - "doc/README.md" # Some RSpec test rely on this file + # QA changes - ".dockerignore" - "qa/**/*" -.only-review: - only: - variables: - - $CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE == "gitlab-org" - kubernetes: active - except: - refs: - - master - - /^\d+-\d+-auto-deploy-\d+$/ - - /^[\d-]+-stable(-ee)?$/ - -.only-review-schedules: - only: - refs: - - schedules - variables: - - $CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE == "gitlab-org" - kubernetes: active - -.only-canonical-schedules: - only: - refs: - - schedules@gitlab-org/gitlab - - schedules@gitlab-org/gitlab-foss - .use-pg9: services: - name: postgres:9.6 diff --git a/.gitlab/ci/memory.gitlab-ci.yml b/.gitlab/ci/memory.gitlab-ci.yml index 93bf87b24b2e3ad5c5c0cf9929ee5b1ca2140b24..ba14024df34f3130a4728938f493cd0b31944959 100644 --- a/.gitlab/ci/memory.gitlab-ci.yml +++ b/.gitlab/ci/memory.gitlab-ci.yml @@ -5,7 +5,7 @@ - .default-cache - .default-only - .default-before_script - - .only-code-changes + - .only:changes-code memory-static: extends: .only-code-memory-job-base diff --git a/.gitlab/ci/notifications.gitlab-ci.yml b/.gitlab/ci/notifications.gitlab-ci.yml deleted file mode 100644 index 8e00ba022d08fa3fff1ca4c02041ec9ba2ba25a9..0000000000000000000000000000000000000000 --- a/.gitlab/ci/notifications.gitlab-ci.yml +++ /dev/null @@ -1,29 +0,0 @@ -.notify: - image: alpine - stage: notification - dependencies: [] - cache: {} - before_script: - - apk update && apk add git curl bash - -schedule:package-and-qa:notify-success: - extends: - - .only-canonical-schedules - - .notify - variables: - COMMIT_NOTES_URL: "https://$CI_SERVER_HOST/$CI_PROJECT_PATH/commit/$CI_COMMIT_SHA#notes-list" - script: - - 'scripts/notify-slack qa-master ":tada: Scheduled QA against master passed! :tada: See $CI_PIPELINE_URL. For downstream pipelines, see $COMMIT_NOTES_URL" ci_passing' - needs: ["schedule:package-and-qa"] - when: on_success - -schedule:package-and-qa:notify-failure: - extends: - - .only-canonical-schedules - - .notify - variables: - COMMIT_NOTES_URL: "https://$CI_SERVER_HOST/$CI_PROJECT_PATH/commit/$CI_COMMIT_SHA#notes-list" - script: - - 'scripts/notify-slack qa-master ":skull_and_crossbones: Scheduled QA against master failed! :skull_and_crossbones: See $CI_PIPELINE_URL. For downstream pipelines, see $COMMIT_NOTES_URL" ci_failing' - needs: ["schedule:package-and-qa"] - when: on_failure diff --git a/.gitlab/ci/pages.gitlab-ci.yml b/.gitlab/ci/pages.gitlab-ci.yml index a30772d5664b4b84ae20abc1e66a9f059bf8e91e..6a2d3702bdd01b18ea5c27bf483d0079f5bf9feb 100644 --- a/.gitlab/ci/pages.gitlab-ci.yml +++ b/.gitlab/ci/pages.gitlab-ci.yml @@ -4,12 +4,11 @@ pages: - .default-retry - .default-cache - .default-only - - .only-code-qa-changes + - .only:variables-canonical-dot-com + - .only:changes-code-backstage-qa only: refs: - master - variables: - - $CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE == "gitlab-org" stage: pages dependencies: ["coverage", "karma", "gitlab:assets:compile pull-cache"] script: diff --git a/.gitlab/ci/qa.gitlab-ci.yml b/.gitlab/ci/qa.gitlab-ci.yml index 1194948a76f67bb6becfc996b196970613494f39..3cb5a40a8b5d2499c211f14aab81dd9f6bf2b1b2 100644 --- a/.gitlab/ci/qa.gitlab-ci.yml +++ b/.gitlab/ci/qa.gitlab-ci.yml @@ -3,7 +3,7 @@ - .default-tags - .default-retry - .default-only - - .only-code-qa-changes + - .only:changes-code-qa stage: test dependencies: [] cache: @@ -31,7 +31,6 @@ qa:selectors-foss: - .only-ee-as-if-foss .package-and-qa-base: - extends: .default-only image: ruby:2.6-alpine stage: qa dependencies: [] @@ -40,35 +39,31 @@ qa:selectors-foss: - source scripts/utils.sh - install_gitlab_gem - ./scripts/trigger-build omnibus - only: - variables: - - $CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE =~ /^gitlab-org($|\/)/ # Matches the gitlab-org group or its subgroups package-and-qa-manual: extends: - .package-and-qa-base - - .only-code-changes - except: - refs: - - master - - /^\d+-\d+-auto-deploy-\d+$/ + - .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 - - .only-qa-changes - except: - refs: - - master - - /^\d+-\d+-auto-deploy-\d+$/ + - .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 - - .only-code-qa-changes - - .only-canonical-schedules + - .default-only + - .only:variables_refs-canonical-dot-com-schedules 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 bf478b68765cb001a03c332d2e3664a529dc16ed..acee30867d90c39b64012abf5fedf0e3b5062998 100644 --- a/.gitlab/ci/rails.gitlab-ci.yml +++ b/.gitlab/ci/rails.gitlab-ci.yml @@ -22,7 +22,7 @@ - .default-cache - .default-only - .default-before_script - - .only-code-changes + - .only:changes-code-backstage .only-code-qa-rails-job-base: extends: @@ -31,7 +31,7 @@ - .default-cache - .default-only - .default-before_script - - .only-code-qa-changes + - .only:changes-code-backstage-qa setup-test-env: extends: @@ -239,6 +239,7 @@ static-analysis: dependencies: ["setup-test-env", "compile-assets pull-cache"] variables: SETUP_DB: "false" + parallel: 2 script: - scripts/static-analysis cache: @@ -251,13 +252,8 @@ static-analysis: downtime_check: extends: - .rake-exec - - .only-code-changes - except: - refs: - - master - - tags - variables: - - $CI_COMMIT_REF_NAME =~ /^[\d-]+-stable(-ee)?$/ + - .only:changes-code-backstage + - .except:refs-master-tags-stable-deploy stage: test needs: ["setup-test-env"] dependencies: ["setup-test-env"] diff --git a/.gitlab/ci/releases.gitlab-ci.yml b/.gitlab/ci/releases.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..1ddc4e90fcfbd8581694ddc23737261549ad3202 --- /dev/null +++ b/.gitlab/ci/releases.gitlab-ci.yml @@ -0,0 +1,22 @@ +--- + +# Syncs any changes pushed to a stable branch to the corresponding CE stable +# branch. We run this prior to any tests so that random failures don't prevent a +# sync. +sync-stable-branch: + # We don't need/want any global before/after commands, so we overwrite these + # settings. + image: alpine:edge + stage: sync + # This job should only run on EE stable branches on the canonical GitLab.com + # repository. + only: + variables: + - $CI_SERVER_HOST == "gitlab.com" + refs: + - /^[\d-]+-stable-ee$/@gitlab-org/gitlab + before_script: + - apk add --no-cache --update curl bash + 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 16c3f0e4f8c2b4d56d0e7a800bb4d5c29eebf34a..fbb7826b6f2cb7fa83e0dec33e1e025de2cedea5 100644 --- a/.gitlab/ci/reports.gitlab-ci.yml +++ b/.gitlab/ci/reports.gitlab-ci.yml @@ -11,7 +11,7 @@ code_quality: extends: - .default-retry - .default-only - - .only-code-changes + - .only:changes-code-backstage stage: test image: docker:stable allow_failure: true @@ -50,7 +50,7 @@ sast: extends: - .default-retry - .default-only - - .only-code-changes + - .only:changes-code-backstage-qa stage: test image: docker:stable variables: @@ -132,7 +132,7 @@ dependency_scanning: extends: - .default-retry - .default-only - - .only-code-changes + - .only:changes-code-backstage-qa stage: test image: docker:stable variables: @@ -195,7 +195,7 @@ dast: extends: - .default-retry - .default-only - - .only-code-qa-changes + - .only:changes-code-qa - .only-review stage: qa needs: ["review-deploy"] diff --git a/.gitlab/ci/review.gitlab-ci.yml b/.gitlab/ci/review.gitlab-ci.yml index c78c6a828154f40c1af4c5a5e7978961c8eb5d7f..4ed9ac03d0cf366b62b06f7f8a6585715be5882c 100644 --- a/.gitlab/ci/review.gitlab-ci.yml +++ b/.gitlab/ci/review.gitlab-ci.yml @@ -1,14 +1,8 @@ -.except-deploys: - except: - refs: - - /^\d+-\d+-auto-deploy-\d+$/ - .review-docker: extends: - .default-tags - .default-retry - .default-only - - .except-deploys image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-qa-alpine services: - docker:19.03.0-dind @@ -23,10 +17,9 @@ build-qa-image: extends: - .review-docker - - .only-code-qa-changes - only: - variables: - - $CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE == "gitlab-org" + - .only:variables-canonical-dot-com + - .except:refs-deploy + - .only:changes-code-qa stage: prepare script: - '[[ ! -d "ee/" ]] || export GITLAB_EDITION="ee"' @@ -35,14 +28,11 @@ build-qa-image: - echo "${CI_JOB_TOKEN}" | docker login --username gitlab-ci-token --password-stdin ${CI_REGISTRY} - time docker push ${QA_IMAGE} -schedule:review-cleanup: +.base-review-cleanup: extends: - .default-tags - .default-retry - .default-only - - .only-code-qa-changes - - .only-review-schedules - - .except-deploys stage: prepare image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-charts-build-base allow_failure: true @@ -55,11 +45,22 @@ schedule:review-cleanup: script: - ruby -rrubygems scripts/review_apps/automated_cleanup.rb +schedule:review-cleanup: + extends: + - .base-review-cleanup + - .only-review-schedules + +manual:review-cleanup: + extends: + - .base-review-cleanup + - .only:changes-code-qa + when: manual + .review-build-cng-base: extends: + - .default-tags + - .default-retry - .default-only - - .only-code-qa-changes - - .except-deploys image: ruby:2.6-alpine stage: review-prepare before_script: @@ -74,6 +75,7 @@ review-build-cng: extends: - .review-build-cng-base - .only-review + - .only:changes-code-qa needs: ["gitlab:assets:compile pull-cache"] schedule:review-build-cng: @@ -82,26 +84,30 @@ schedule:review-build-cng: - .only-review-schedules needs: ["gitlab:assets:compile pull-cache"] -.review-deploy-base: +.review-workflow-base: extends: - .default-tags - .default-retry - .default-only - - .only-code-qa-changes - - .except-deploys - stage: review image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-charts-build-base dependencies: [] - allow_failure: true variables: HOST_SUFFIX: "${CI_ENVIRONMENT_SLUG}" DOMAIN: "-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN}" - GITLAB_HELM_CHART_REF: "v2.3.7" + # v2.4.4 + two improvements: + # - Allow to pass an EE license when installing the chart: https://gitlab.com/gitlab-org/charts/gitlab/merge_requests/1008 + # - Allow to customize the livenessProbe for `gitlab-shell`: https://gitlab.com/gitlab-org/charts/gitlab/merge_requests/1021 + GITLAB_HELM_CHART_REF: "6c655ed77e60f1f7f533afb97bef8c9cb7dc61eb" GITLAB_EDITION: "ce" environment: name: review/${CI_COMMIT_REF_NAME} url: https://gitlab-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN} on_stop: review-stop + +.review-deploy-base: + extends: .review-workflow-base + stage: review + allow_failure: true before_script: - '[[ ! -d "ee/" ]] || export GITLAB_EDITION="ee"' - export GITLAB_SHELL_VERSION=$(<GITLAB_SHELL_VERSION) @@ -112,21 +118,13 @@ schedule:review-build-cng: - install_api_client_dependencies_with_apk - source scripts/review_apps/review-apps.sh script: - - date - check_kube_domain - - date - ensure_namespace - - date - install_tiller - - date - install_external_dns - - date - download_chart - date - deploy || (display_deployment_debug && exit 1) - - date - - add_license - - date artifacts: paths: [review_app_url.txt] expire_in: 2 days @@ -136,6 +134,7 @@ review-deploy: extends: - .review-deploy-base - .only-review + - .only:changes-code-qa needs: ["review-build-cng"] schedule:review-deploy: @@ -144,11 +143,11 @@ schedule:review-deploy: - .only-review-schedules needs: ["schedule:review-build-cng"] -review-stop: +.base-review-stop: extends: - - .review-deploy-base + - .review-workflow-base - .only-review - when: manual + - .only:changes-code-qa environment: action: stop variables: @@ -161,24 +160,26 @@ review-stop: - wget $CI_PROJECT_URL/raw/$CI_COMMIT_SHA/scripts/utils.sh - source utils.sh - source review-apps.sh - script: - - delete_release - artifacts: - paths: [] -review-cleanup-failed-deployment: - extends: review-stop +review-stop-failed-deployment: + extends: .base-review-stop stage: prepare - when: on_success - allow_failure: false script: - delete_failed_release +review-stop: + extends: .base-review-stop + stage: review + when: manual + allow_failure: true + script: + - delete_release + .review-qa-base: extends: - .review-docker - .only-review - - .only-code-qa-changes + - .only:changes-code-qa stage: qa allow_failure: true variables: @@ -223,9 +224,7 @@ review-qa-all: - gitlab-qa Test::Instance::Any "${QA_IMAGE}" "${CI_ENVIRONMENT_URL}" -- --format RspecJunitFormatter --out tmp/rspec-${CI_JOB_ID}.xml --format html --out tmp/rspec.htm --color --format documentation .review-performance-base: - extends: - - .review-docker - - .only-code-qa-changes + extends: .review-docker stage: qa allow_failure: true before_script: @@ -248,6 +247,7 @@ review-performance: extends: - .review-performance-base - .only-review + - .only:changes-code-qa needs: ["review-deploy"] dependencies: ["review-deploy"] before_script: @@ -277,9 +277,8 @@ parallel-spec-reports: extends: - .default-tags - .default-only - - .only-code-qa-changes - .only-review - - .except-deploys + - .only:changes-code-qa image: ruby:2.6-alpine stage: post-test dependencies: ["review-qa-all"] @@ -310,18 +309,13 @@ danger-review: - .default-retry - .default-cache - .default-only + - .except:refs-master-tags-stable-deploy image: registry.gitlab.com/gitlab-org/gitlab-build-images:danger stage: test dependencies: [] only: variables: - $DANGER_GITLAB_API_TOKEN - except: - refs: - - master - variables: - - $CI_COMMIT_REF_NAME =~ /^\d+-\d+-auto-deploy-\d+$/ - - $CI_COMMIT_REF_NAME =~ /^[\d-]+-stable(-ee)?$/ script: - git version - node --version diff --git a/.gitlab/ci/setup.gitlab-ci.yml b/.gitlab/ci/setup.gitlab-ci.yml index 861f3f1af5b4ed5880db17a405b26f46585a693f..242675843931908f393e696758bf3af57021b731 100644 --- a/.gitlab/ci/setup.gitlab-ci.yml +++ b/.gitlab/ci/setup.gitlab-ci.yml @@ -6,7 +6,8 @@ cache gems: - .default-retry - .default-cache - .default-before_script - - .only-code-qa-changes + - .only:variables-canonical-dot-com + - .only:changes-code-backstage-qa stage: test dependencies: ["setup-test-env"] needs: ["setup-test-env"] @@ -21,15 +22,13 @@ cache gems: refs: - master - tags - variables: - - $CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE == "gitlab-org" .minimal-job: extends: - .default-tags - .default-retry - .default-only - - .only-code-changes + - .only:changes-code-backstage dependencies: [] gitlab_git_test: diff --git a/.gitlab/ci/test-metadata.gitlab-ci.yml b/.gitlab/ci/test-metadata.gitlab-ci.yml index 6a7f3157d593f4afbaae20a37a713482f534f67d..21af0d373bc8fbd989e6aee03301451e2fd3cd52 100644 --- a/.gitlab/ci/test-metadata.gitlab-ci.yml +++ b/.gitlab/ci/test-metadata.gitlab-ci.yml @@ -1,7 +1,7 @@ .tests-metadata-state: extends: - .default-only - - .only-code-changes + - .only:changes-code-backstage variables: TESTS_METADATA_S3_BUCKET: "gitlab-ce-cache" before_script: @@ -48,7 +48,7 @@ flaky-examples-check: - .default-tags - .default-retry - .default-only - - .only-code-changes + - .only:changes-code-backstage image: ruby:2.6-alpine stage: post-test variables: diff --git a/.gitlab/issue_templates/Security developer workflow.md b/.gitlab/issue_templates/Security developer workflow.md index 3e634de4f0c4ae26b477289ecfec19e9f925617f..e06a6fb0cffcd0bebc412cbb53ca6b7db10ad0e0 100644 --- a/.gitlab/issue_templates/Security developer workflow.md +++ b/.gitlab/issue_templates/Security developer workflow.md @@ -29,7 +29,7 @@ Set the title to: `Description of the original issue` #### Documentation and final details -- [ ] Check the topic on #security to see when the next release is going to happen and add a link to the [links section](#links) +- [ ] 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 - [ ] 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) diff --git a/.gitlab/merge_request_templates/Documentation.md b/.gitlab/merge_request_templates/Documentation.md index a2dd79ed1abd6b9324f19ffd328135cbc94b1bb7..2a7da2a436f4171bdd95052bbc8c8a434afc1c65 100644 --- a/.gitlab/merge_request_templates/Documentation.md +++ b/.gitlab/merge_request_templates/Documentation.md @@ -34,7 +34,7 @@ All reviewers can help ensure accuracy, clarity, completeness, and adherence to **3. Maintainer** 1. [ ] Review by assigned maintainer, who can always request/require the above reviews. Maintainer's review can occur before or after a technical writer review. -1. [ ] Ensure a release milestone is set and that you merge the equivalent EE MR before the CE MR if both exist. +1. [ ] Ensure a release milestone is set. 1. [ ] If there has not been a technical writer review, [create an issue for one using the Doc Review template](https://gitlab.com/gitlab-org/gitlab/issues/new?issuable_template=Doc%20Review). /label ~documentation diff --git a/.haml-lint_todo.yml b/.haml-lint_todo.yml index 211ad359951f57e3fd4ab65d3628cd2581a3ce1e..232a87c198127bf9fb9ec09c902abbf7e47595c2 100644 --- a/.haml-lint_todo.yml +++ b/.haml-lint_todo.yml @@ -416,9 +416,6 @@ linters: - 'app/views/u2f/_register.html.haml' - 'app/views/users/_deletion_guidance.html.haml' - 'ee/app/views/admin/_namespace_plan_info.html.haml' - - 'ee/app/views/admin/application_settings/_elasticsearch_form.html.haml' - - 'ee/app/views/admin/application_settings/_slack.html.haml' - - 'ee/app/views/admin/application_settings/_snowplow.html.haml' - 'ee/app/views/admin/application_settings/_templates.html.haml' - 'ee/app/views/admin/audit_logs/index.html.haml' - 'ee/app/views/admin/dashboard/stats.html.haml' @@ -495,7 +492,6 @@ linters: - 'ee/app/views/projects/services/prometheus/_metrics.html.haml' - 'ee/app/views/projects/settings/slacks/edit.html.haml' - 'ee/app/views/shared/_additional_email_text.html.haml' - - 'ee/app/views/shared/_geo_info_modal.html.haml' - 'ee/app/views/shared/_mirror_update_button.html.haml' - 'ee/app/views/shared/_shared_runners_minutes_limit.html.haml' - 'ee/app/views/shared/audit_events/_event_table.html.haml' diff --git a/.overcommit.yml.example b/.overcommit.yml.example index 25823b9a8b34fe62f446111f9f03e4db041e8a4d..9cd04825bc252a2edbd0a43498ea393863a7d90b 100644 --- a/.overcommit.yml.example +++ b/.overcommit.yml.example @@ -16,10 +16,25 @@ # Uncomment the following lines to make the configuration take effect. PreCommit: + AuthorName: + enabled: false + EsLint: + enabled: true + # https://github.com/sds/overcommit/issues/338 + command: './node_modules/eslint/bin/eslint.js' + HamlLint: + enabled: true + MergeConflicts: + enabled: true + exclude: + - '**/conflict/file_spec.rb' + - '**/git/conflict/parser_spec.rb' + # prettier? https://github.com/sds/overcommit/issues/614 https://github.com/sds/overcommit/issues/390#issuecomment-495703284 RuboCop: enabled: true # on_warn: fail # Treat all warnings as failures -# + ScssLint: + enabled: true #PostCheckout: # ALL: # Special hook name that customizes all hooks of this type # quiet: true # Change all post-checkout hooks to only display output on failure diff --git a/.rubocop.yml b/.rubocop.yml index 049340f90d4149fa1be2c0b71e28a0f8b60abd9c..1d5cf7642c2c03c23e4ad001eaf539f5e0c65073 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -56,7 +56,7 @@ Style/FrozenStringLiteralComment: - 'qa/**/*' - 'rubocop/**/*' - 'scripts/**/*' - - 'spec/**/*' + - 'spec/lib/gitlab/**/*' RSpec/FilePath: Exclude: @@ -297,3 +297,6 @@ Graphql/Descriptions: Include: - 'app/graphql/**/*' - 'ee/app/graphql/**/*' + +RSpec/AnyInstanceOf: + Enabled: false diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 3ed7af71b4fac7a368e14cb10008864f8367c1e8..f0388ab79d209fbc50cfc4d87b41e6fb63490259 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -401,13 +401,6 @@ Rails/FilePath: Rails/HasManyOrHasOneDependent: Enabled: false -# Offense count: 40 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle. -# SupportedStyles: numeric, symbolic -Rails/HttpStatus: - Enabled: false - # Offense count: 2 # Configuration parameters: Include. # Include: app/controllers/**/*.rb diff --git a/CHANGELOG-EE.md b/CHANGELOG-EE.md index b6156f8d254fda865fff4edc1c9b2cdc85b5572f..9fa018b9719e4d914e82420be981474833a7b057 100644 --- a/CHANGELOG-EE.md +++ b/CHANGELOG-EE.md @@ -4181,7 +4181,7 @@ Please view this file on the master branch, on stable branches it's out of date. - Show hook errors for fast-forward merges. !1375 - Allow all parameters of group webhooks to be set through the UI. !1376 - Fix Elasticsearch queries when a group_id is specified. !1423 -- Check the right index mapping based on Rails environment for rake gitlab:elastic:add_feature_visiblity_levels_to_project. !1473 +- Check the right index mapping based on Rails environment for rake gitlab:elastic:add_feature_visibility_levels_to_project. !1473 - Fix issues with another milestone that has a matching list label could not be added to a board. - Only admins or group owners can set LDAP overrides. - Add support for load balancing database queries. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4de0606589fb56f3be3e1c44363176072b1510f2..fda536ae15700a1d55eb0a9f488a44d73a7948c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,6 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. -## 12.4.3 - -- No changes. - ## 12.4.2 ### Fixed (10 changes) diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 65ee0959841f14d2e0a0fc971df20d8e761d6e8d..bf480b0ade89973b4e3425ea320b574b298a9025 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -1.67.0 +ba4abcb75c7b70df662c6274df199fa261f32e11 diff --git a/GITLAB_ELASTICSEARCH_INDEXER_VERSION b/GITLAB_ELASTICSEARCH_INDEXER_VERSION index 88c5fb891dcf1d1647d2b84bac0630cf9570d213..bc80560fad66ca670bdfbd1e5c973a024d4d0325 100644 --- a/GITLAB_ELASTICSEARCH_INDEXER_VERSION +++ b/GITLAB_ELASTICSEARCH_INDEXER_VERSION @@ -1 +1 @@ -1.4.0 +1.5.0 diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION index 1cac385c6cb864bab53f6846e112f5a93fd17401..0eed1a29efd64781078b23d253073644e9234b14 100644 --- a/GITLAB_PAGES_VERSION +++ b/GITLAB_PAGES_VERSION @@ -1 +1 @@ -1.11.0 +1.12.0 diff --git a/Gemfile b/Gemfile index 920f778c053f1ad4129766047c6bc460ecdfafa5..d27bc276088c873aa70a2bec8d97e27535ac8ccb 100644 --- a/Gemfile +++ b/Gemfile @@ -8,12 +8,12 @@ gem 'bootsnap', '~> 1.4' gem 'nakayoshi_fork', '~> 0.0.4' # Responders respond_to and respond_with -gem 'responders', '~> 2.0' +gem 'responders', '~> 3.0' gem 'sprockets', '~> 3.7.0' # Default values for AR models -gem 'default_value_for', '~> 3.2.0' +gem 'default_value_for', '~> 3.3.0' # Supported DBs gem 'pg', '~> 1.1' @@ -42,7 +42,7 @@ gem 'omniauth-shibboleth', '~> 1.3.0' gem 'omniauth-twitter', '~> 1.4' gem 'omniauth_crowd', '~> 2.2.0' gem 'omniauth-authentiq', '~> 0.3.3' -gem 'omniauth_openid_connect', '~> 0.3.1' +gem 'omniauth_openid_connect', '~> 0.3.3' gem "omniauth-ultraauth", '~> 0.0.2' gem 'omniauth-salesforce', '~> 1.0.5' gem 'rack-oauth2', '~> 1.9.3' @@ -64,7 +64,7 @@ gem 'u2f', '~> 0.2.1' # GitLab Pages gem 'validates_hostname', '~> 1.0.6' -gem 'rubyzip', '~> 1.2.2', require: 'zip' +gem 'rubyzip', '~> 1.3.0', require: 'zip' # GitLab Pages letsencrypt support gem 'acme-client', '~> 2.0.2' @@ -72,7 +72,7 @@ gem 'acme-client', '~> 2.0.2' gem 'browser', '~> 2.5' # GPG -gem 'gpgme', '~> 2.0.18' +gem 'gpgme', '~> 2.0.19' # LDAP Auth # GitLab fork with several improvements to original library. For full list of changes @@ -136,7 +136,7 @@ gem 'faraday_middleware-aws-signers-v4' # Markdown and HTML processing gem 'html-pipeline', '~> 2.8' -gem 'deckar01-task_list', '2.2.0' +gem 'deckar01-task_list', '2.2.1' gem 'gitlab-markup', '~> 1.7.0' gem 'github-markup', '~> 1.7.0', require: 'github/markup' gem 'commonmarker', '~> 0.17' @@ -151,7 +151,7 @@ gem 'asciidoctor-plantuml', '0.0.9' gem 'rouge', '~> 3.11.0' gem 'truncato', '~> 0.7.11' gem 'bootstrap_form', '~> 4.2.0' -gem 'nokogiri', '~> 1.10.4' +gem 'nokogiri', '~> 1.10.5' gem 'escape_utils', '~> 1.1' # Calendar rendering @@ -159,6 +159,7 @@ gem 'icalendar' # Diffs gem 'diffy', '~> 3.1.0' +gem 'diff_match_patch', '~> 0.1.0' # Application server gem 'rack', '~> 2.0.7' @@ -175,7 +176,7 @@ group :puma do end # State machine -gem 'state_machines-activerecord', '~> 0.5.1' +gem 'state_machines-activerecord', '~> 0.6.0' # Issue tags gem 'acts-as-taggable-on', '~> 6.0' @@ -259,9 +260,6 @@ gem 'loofah', '~> 2.2' # Working with license gem 'licensee', '~> 8.9' -# Protect against bruteforcing -gem 'rack-attack', '~> 4.4.1' - # Ace editor gem 'ace-rails-ap', '~> 4.1.0' @@ -293,10 +291,13 @@ gem 'base32', '~> 0.3.0' gem "gitlab-license", "~> 1.0" +# Protect against bruteforcing +gem 'rack-attack', '~> 6.2.0' + # Sentry integration gem 'sentry-raven', '~> 2.9' -gem 'premailer-rails', '~> 1.9.7' +gem 'premailer-rails', '~> 1.10.3' # LabKit: Tracing and Correlation gem 'gitlab-labkit', '~> 0.5' @@ -331,7 +332,6 @@ group :metrics do end group :development do - gem 'foreman', '~> 0.84.0' gem 'brakeman', '~> 4.2', require: false gem 'danger', '~> 6.0', require: false @@ -388,7 +388,6 @@ group :development, :test do gem 'benchmark-ips', '~> 2.3.0', require: false - gem 'license_finder', '~> 5.4', require: false gem 'knapsack', '~> 1.17' gem 'stackprof', '~> 0.2.10', require: false @@ -398,6 +397,11 @@ group :development, :test do gem 'timecop', '~> 0.8.0' end +# Gems required in omnibus-gitlab pipeline +group :development, :test, :omnibus do + gem 'license_finder', '~> 5.4', require: false +end + group :test do gem 'shoulda-matchers', '~> 4.0.1', require: false gem 'email_spec', '~> 2.2.0' @@ -407,6 +411,7 @@ group :test do gem 'concurrent-ruby', '~> 1.1' gem 'test-prof', '~> 0.10.0' gem 'rspec_junit_formatter' + gem 'guard-rspec' end gem 'octokit', '~> 4.9' @@ -446,18 +451,18 @@ group :ed25519 do end # Gitaly GRPC protocol definitions -gem 'gitaly', '~> 1.65.0' +gem 'gitaly', '~> 1.70.0' -gem 'grpc', '~> 1.19.0' +gem 'grpc', '~> 1.24.0' -gem 'google-protobuf', '~> 3.7.1' +gem 'google-protobuf', '~> 3.8.0' gem 'toml-rb', '~> 1.0.0', require: false # Feature toggles -gem 'flipper', '~> 0.13.0' -gem 'flipper-active_record', '~> 0.13.0' -gem 'flipper-active_support_cache_store', '~> 0.13.0' +gem 'flipper', '~> 0.17.1' +gem 'flipper-active_record', '~> 0.17.1' +gem 'flipper-active_support_cache_store', '~> 0.17.1' gem 'unleash', '~> 0.1.5' # Structured logging @@ -469,3 +474,5 @@ gem 'gitlab-net-dns', '~> 0.9.1' # Countries list gem 'countries', '~> 3.0' + +gem 'retriable', '~> 3.1.2' diff --git a/Gemfile.lock b/Gemfile.lock index 18160932c56f2fcbc8db903d21219bbb17d0aa9a..15465cd6b03a1f0888c19531aa900f25e5f93d4b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -50,8 +50,8 @@ GEM i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) - acts-as-taggable-on (6.0.0) - activerecord (~> 5.0) + acts-as-taggable-on (6.5.0) + activerecord (>= 5.0, < 6.1) adamantium (0.2.0) ice_nine (~> 0.11.0) memoizable (~> 0.4.0) @@ -80,14 +80,16 @@ GEM encryptor (~> 3.0.0) attr_required (1.0.1) awesome_print (1.8.0) - aws-sdk (2.9.32) - aws-sdk-resources (= 2.9.32) - aws-sdk-core (2.9.32) + 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) jmespath (~> 1.0) - aws-sdk-resources (2.9.32) - aws-sdk-core (= 2.9.32) - aws-sigv4 (1.0.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) descendants_tracker (~> 0.0.4) ice_nine (~> 0.11.0) @@ -171,9 +173,9 @@ GEM unicode_utils (~> 1.4) crack (0.4.3) safe_yaml (~> 1.0.0) - crass (1.0.4) + crass (1.0.5) creole (0.5.0) - css_parser (1.5.0) + css_parser (1.7.0) addressable daemons (1.2.6) danger (6.0.9) @@ -192,12 +194,12 @@ GEM database_cleaner (1.7.0) debug_inspector (0.0.3) debugger-ruby_core_source (1.3.8) - deckar01-task_list (2.2.0) + deckar01-task_list (2.2.1) html-pipeline declarative (0.0.10) declarative-option (0.1.0) - default_value_for (3.2.0) - activerecord (>= 3.2.0, < 6.0) + default_value_for (3.3.0) + activerecord (>= 3.2.0, < 6.1) derailed_benchmarks (1.3.5) benchmark-ips (~> 2) get_process_mem (~> 0) @@ -222,6 +224,7 @@ GEM railties rotp (~> 2.0) diff-lcs (1.3) + diff_match_patch (0.1.0) diffy (3.1.0) discordrb-webhooks-blackst0ne (3.3.0) rest-client (~> 2.0) @@ -285,13 +288,13 @@ GEM fast_gettext (1.6.0) ffaker (2.10.0) ffi (1.11.1) - flipper (0.13.0) - flipper-active_record (0.13.0) - activerecord (>= 3.2, < 6) - flipper (~> 0.13.0) - flipper-active_support_cache_store (0.13.0) - activesupport (>= 3.2, < 6) - flipper (~> 0.13.0) + flipper (0.17.1) + flipper-active_record (0.17.1) + activerecord (>= 4.2, < 7) + flipper (~> 0.17.1) + flipper-active_support_cache_store (0.17.1) + activesupport (>= 4.2, < 7) + flipper (~> 0.17.1) flowdock (0.7.1) httparty (~> 0.7) multi_json @@ -332,10 +335,8 @@ GEM fog-xml (0.1.3) fog-core nokogiri (>= 1.5.11, < 2.0.0) - font-awesome-rails (4.7.0.4) - railties (>= 3.2, < 6.0) - foreman (0.84.0) - thor (~> 0.19.1) + font-awesome-rails (4.7.0.5) + railties (>= 3.2, < 6.1) formatador (0.2.5) fugit (1.2.1) et-orbi (~> 1.1, >= 1.1.8) @@ -358,12 +359,12 @@ GEM po_to_json (>= 1.0.0) rails (>= 3.2.0) git (1.5.0) - gitaly (1.65.0) + gitaly (1.70.0) grpc (~> 1.0) github-markup (1.7.0) - gitlab-labkit (0.5.2) - actionpack (~> 5) - activesupport (~> 5) + gitlab-labkit (0.7.0) + actionpack (>= 5.0.0, < 6.1.0) + activesupport (>= 5.0.0, < 6.1.0) grpc (~> 1.19) jaeger-client (~> 0.10) opentracing (~> 0.4) @@ -400,7 +401,7 @@ GEM mime-types (~> 3.0) representable (~> 3.0) retriable (>= 2.0, < 4.0) - google-protobuf (3.7.1) + google-protobuf (3.8.0) googleapis-common-protos-types (1.0.4) google-protobuf (~> 3.0) googleauth (0.6.6) @@ -410,7 +411,7 @@ GEM multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (~> 0.7) - gpgme (2.0.18) + gpgme (2.0.19) mini_portile2 (~> 2.3) grape (1.1.0) activesupport @@ -440,11 +441,25 @@ GEM graphql (~> 1.6) html-pipeline (~> 2.8) sass (~> 3.4) - grpc (1.19.0) - google-protobuf (~> 3.1) - googleapis-common-protos-types (~> 1.0.0) + grpc (1.24.0) + google-protobuf (~> 3.8) + googleapis-common-protos-types (~> 1.0) gssapi (1.2.0) ffi (>= 1.0.1) + guard (2.15.1) + formatador (>= 0.2.4) + listen (>= 2.7, < 4.0) + lumberjack (>= 1.0.12, < 2.0) + nenv (~> 0.1) + notiffany (~> 0.0) + pry (>= 0.9.12) + shellany (~> 0.0) + thor (>= 0.18.1) + guard-compat (1.2.1) + guard-rspec (4.7.3) + guard (~> 2.1) + guard-compat (~> 1.1) + rspec (>= 2.99.0, < 4.0) haml (5.0.4) temple (>= 0.8.0) tilt @@ -508,7 +523,7 @@ GEM atlassian-jwt multipart-post oauth (~> 0.5, >= 0.5.0) - jmespath (1.3.1) + jmespath (1.4.0) js_regex (3.1.1) character_set (~> 1.1) regexp_parser (~> 1.1) @@ -560,15 +575,20 @@ GEM xml-simple licensee (8.9.2) rugged (~> 0.24) + listen (3.1.5) + rb-fsevent (~> 0.9, >= 0.9.4) + rb-inotify (~> 0.9, >= 0.9.7) + ruby_dep (~> 1.2) locale (2.1.2) lograge (0.10.0) actionpack (>= 4) activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.3.0) + loofah (2.3.1) crass (~> 1.0.2) nokogiri (>= 1.5.9) + lumberjack (1.0.13) mail (2.7.1) mini_mime (>= 0.1.1) mail_room (0.9.1) @@ -584,7 +604,7 @@ GEM mime-types-data (3.2019.0331) mimemagic (0.3.2) mini_magick (4.9.5) - mini_mime (1.0.1) + mini_mime (1.0.2) mini_portile2 (2.4.0) minitest (5.11.3) msgpack (1.3.1) @@ -597,16 +617,20 @@ GEM mustermann (~> 1.0.0) nakayoshi_fork (0.0.4) nap (1.1.0) + nenv (0.3.0) net-ldap (0.16.0) net-ntp (2.1.3) net-ssh (5.2.0) netrc (0.11.0) nio4r (2.3.1) no_proxy_fix (0.1.2) - nokogiri (1.10.4) + nokogiri (1.10.5) mini_portile2 (~> 2.4.0) nokogumbo (1.5.0) nokogiri + notiffany (0.1.3) + nenv (~> 0.1) + shellany (~> 0.0) numerizer (0.1.1) oauth (0.5.4) oauth2 (1.4.1) @@ -675,12 +699,12 @@ GEM activesupport nokogiri (>= 1.4.4) omniauth (~> 1.0) - omniauth_openid_connect (0.3.1) + omniauth_openid_connect (0.3.3) addressable (~> 2.5) - omniauth (~> 1.3) + omniauth (~> 1.9) openid_connect (~> 1.1) open4 (1.3.4) - openid_connect (1.1.6) + openid_connect (1.1.8) activemodel attr_required (>= 1.0.0) json-jwt (>= 1.5.0) @@ -703,12 +727,12 @@ GEM pg (1.1.4) po_to_json (1.0.1) json (>= 1.6.0) - premailer (1.10.4) + premailer (1.11.1) addressable - css_parser (>= 1.4.10) + css_parser (>= 1.6.0) htmlentities (>= 4.0.0) - premailer-rails (1.9.7) - actionmailer (>= 3, < 6) + premailer-rails (1.10.3) + actionmailer (>= 3) premailer (~> 1.7, >= 1.7.9) proc_to_ast (0.1.0) coderay @@ -724,7 +748,7 @@ GEM pry (~> 0.10) pry-rails (0.3.6) pry (>= 0.10.4) - public_suffix (3.1.0) + public_suffix (3.1.1) puma (3.12.0) puma_worker_killer (0.1.0) get_process_mem (~> 0.2) @@ -734,8 +758,8 @@ GEM rack (2.0.7) rack-accept (0.4.5) rack (>= 0.4) - rack-attack (4.4.1) - rack + rack-attack (6.2.0) + rack (>= 1.0, < 3) rack-cors (1.0.2) rack-oauth2 (1.9.3) activesupport @@ -763,10 +787,10 @@ GEM bundler (>= 1.3.0) railties (= 5.2.3) sprockets-rails (>= 2.0.0) - rails-controller-testing (1.0.2) - actionpack (~> 5.x, >= 5.0.1) - actionview (~> 5.x, >= 5.0.1) - activesupport (~> 5.x) + rails-controller-testing (1.0.4) + actionpack (>= 5.0.1.x) + actionview (>= 5.0.1.x) + activesupport (>= 5.0.1.x) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) @@ -798,25 +822,25 @@ GEM recaptcha (4.13.1) json recursive-open-struct (1.1.0) - redis (4.1.2) - redis-actionpack (5.0.2) - actionpack (>= 4.0, < 6) + redis (4.1.3) + redis-actionpack (5.1.0) + actionpack (>= 4.0, < 7) redis-rack (>= 1, < 3) redis-store (>= 1.1.0, < 2) - redis-activesupport (5.0.7) - activesupport (>= 3, < 6) + redis-activesupport (5.2.0) + activesupport (>= 3, < 7) redis-store (>= 1.3, < 2) redis-namespace (1.6.0) redis (>= 3.0.4) - redis-rack (2.0.5) + redis-rack (2.0.6) rack (>= 1.5, < 3) redis-store (>= 1.2, < 2) redis-rails (5.0.2) redis-actionpack (>= 5.0, < 6) redis-activesupport (>= 5.0, < 6) redis-store (>= 1.2, < 2) - redis-store (1.6.0) - redis (>= 2.2, < 5) + redis-store (1.8.1) + redis (>= 4, < 5) regexp_parser (1.5.1) regexp_property_values (0.3.4) representable (3.0.4) @@ -824,9 +848,9 @@ GEM declarative-option (< 0.2.0) uber (< 0.2.0) request_store (1.3.1) - responders (2.4.1) - actionpack (>= 4.2.0, < 6.0) - railties (>= 4.2.0, < 6.0) + responders (3.0.0) + actionpack (>= 5.0) + railties (>= 5.0) rest-client (2.0.2) http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) @@ -897,11 +921,12 @@ GEM ruby-progressbar (1.10.1) ruby-saml (1.7.2) nokogiri (>= 1.5.10) + ruby_dep (1.5.0) ruby_parser (3.13.1) sexp_processor (~> 4.9) rubyntlm (0.6.2) rubypants (0.2.0) - rubyzip (1.2.2) + rubyzip (1.3.0) rugged (0.28.3.1) safe_yaml (1.0.4) sanitize (4.6.6) @@ -938,6 +963,7 @@ GEM faraday (>= 0.7.6, < 1.0) settingslogic (2.0.9) sexp_processor (4.12.0) + shellany (0.0.1) shoulda-matchers (4.0.1) activesupport (>= 4.2.0) sidekiq (5.2.7) @@ -978,11 +1004,11 @@ GEM sshkey (2.0.0) stackprof (0.2.10) state_machines (0.5.0) - state_machines-activemodel (0.5.1) - activemodel (>= 4.1, < 6.0) + state_machines-activemodel (0.7.1) + activemodel (>= 4.1) state_machines (>= 0.5.0) - state_machines-activerecord (0.5.1) - activerecord (>= 4.1, < 6.0) + state_machines-activerecord (0.6.0) + activerecord (>= 4.1) state_machines-activemodel (>= 0.5.0) swd (1.1.2) activesupport (>= 3) @@ -1127,12 +1153,13 @@ DEPENDENCIES creole (~> 0.5.0) danger (~> 6.0) database_cleaner (~> 1.7.0) - deckar01-task_list (= 2.2.0) - default_value_for (~> 3.2.0) + deckar01-task_list (= 2.2.1) + default_value_for (~> 3.3.0) derailed_benchmarks device_detector devise (~> 4.6) devise-two-factor (~> 3.0.0) + diff_match_patch (~> 0.1.0) diffy (~> 3.1.0) discordrb-webhooks-blackst0ne (~> 3.3) doorkeeper (~> 4.3) @@ -1149,9 +1176,9 @@ DEPENDENCIES faraday_middleware-aws-signers-v4 fast_blank ffaker (~> 2.10) - flipper (~> 0.13.0) - flipper-active_record (~> 0.13.0) - flipper-active_support_cache_store (~> 0.13.0) + flipper (~> 0.17.1) + flipper-active_record (~> 0.17.1) + flipper-active_support_cache_store (~> 0.17.1) flowdock (~> 0.7) fog-aliyun (~> 0.3) fog-aws (~> 3.5) @@ -1161,14 +1188,13 @@ DEPENDENCIES fog-openstack (~> 1.0) fog-rackspace (~> 0.1.1) font-awesome-rails (~> 4.7) - foreman (~> 0.84.0) fugit (~> 1.2.1) fuubar (~> 2.2.0) gemojione (~> 3.3) gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.3) - gitaly (~> 1.65.0) + gitaly (~> 1.70.0) github-markup (~> 1.7.0) gitlab-labkit (~> 0.5) gitlab-license (~> 1.0) @@ -1181,8 +1207,8 @@ DEPENDENCIES gitlab_omniauth-ldap (~> 2.1.1) gon (~> 6.2) google-api-client (~> 0.23) - google-protobuf (~> 3.7.1) - gpgme (~> 2.0.18) + google-protobuf (~> 3.8.0) + gpgme (~> 2.0.19) grape (~> 1.1.0) grape-entity (~> 0.7.1) grape-path-helpers (~> 1.1) @@ -1190,8 +1216,9 @@ DEPENDENCIES graphiql-rails (~> 1.4.10) graphql (~> 1.9.11) graphql-docs (~> 1.6.0) - grpc (~> 1.19.0) + grpc (~> 1.24.0) gssapi + guard-rspec haml_lint (~> 0.31.0) hamlit (~> 2.8.8) hangouts-chat (~> 0.0.5) @@ -1226,7 +1253,7 @@ DEPENDENCIES net-ldap net-ntp net-ssh (~> 5.2) - nokogiri (~> 1.10.4) + nokogiri (~> 1.10.5) oauth2 (~> 1.4) octokit (~> 4.9) omniauth (~> 1.8) @@ -1246,17 +1273,17 @@ DEPENDENCIES omniauth-twitter (~> 1.4) omniauth-ultraauth (~> 0.0.2) omniauth_crowd (~> 2.2.0) - omniauth_openid_connect (~> 0.3.1) + omniauth_openid_connect (~> 0.3.3) org-ruby (~> 0.9.12) pg (~> 1.1) - premailer-rails (~> 1.9.7) + premailer-rails (~> 1.10.3) prometheus-client-mmap (~> 0.9.10) pry-byebug (~> 3.5.1) pry-rails (~> 0.3.4) puma (~> 3.12) puma_worker_killer rack (~> 2.0.7) - rack-attack (~> 4.4.1) + rack-attack (~> 6.2.0) rack-cors (~> 1.0.0) rack-oauth2 (~> 1.9.3) rack-proxy (~> 0.6.0) @@ -1275,7 +1302,8 @@ DEPENDENCIES redis-namespace (~> 1.6.0) redis-rails (~> 5.0.2) request_store (~> 1.3) - responders (~> 2.0) + responders (~> 3.0) + retriable (~> 3.1.2) rouge (~> 3.11.0) rqrcode-rails3 (~> 0.1.7) rspec-parameterized @@ -1291,7 +1319,7 @@ DEPENDENCIES ruby-prof (~> 1.0.0) ruby-progressbar ruby_parser (~> 3.8) - rubyzip (~> 1.2.2) + rubyzip (~> 1.3.0) rugged (~> 0.28) sanitize (~> 4.6) sassc-rails (~> 2.1.0) @@ -1312,7 +1340,7 @@ DEPENDENCIES sprockets (~> 3.7.0) sshkey (~> 2.0) stackprof (~> 0.2.10) - state_machines-activerecord (~> 0.5.1) + state_machines-activerecord (~> 0.6.0) sys-filesystem (~> 1.1.6) test-prof (~> 0.10.0) thin (~> 1.7.0) diff --git a/Guardfile b/Guardfile new file mode 100644 index 0000000000000000000000000000000000000000..8a43f414ca9ecab21ffe9d0b50f1022489dd829f --- /dev/null +++ b/Guardfile @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# More info at https://github.com/guard/guard#readme + +cmd = ENV['SPRING'] ? 'spring rspec' : 'bundle exec rspec' + +guard :rspec, cmd: cmd do + require "guard/rspec/dsl" + dsl = Guard::RSpec::Dsl.new(self) + + directories %w(app ee lib spec) + + # RSpec files + rspec = dsl.rspec + watch(rspec.spec_helper) { rspec.spec_dir } + watch(rspec.spec_support) { rspec.spec_dir } + watch(rspec.spec_files) + + # Ruby files + ruby = dsl.ruby + dsl.watch_spec_files_for(ruby.lib_files) + + # Rails files + rails = dsl.rails(view_extensions: %w(erb haml slim)) + dsl.watch_spec_files_for(rails.app_files) + dsl.watch_spec_files_for(rails.views) + + watch(rails.controllers) do |m| + [ + rspec.spec.call("routing/#{m[1]}_routing"), + rspec.spec.call("controllers/#{m[1]}_controller") + ] + end + + # Rails config changes + watch(rails.spec_helper) { rspec.spec_dir } + watch(rails.routes) { "#{rspec.spec_dir}/routing" } + watch(rails.app_controller) { "#{rspec.spec_dir}/controllers" } + + # Capybara features specs + watch(rails.view_dirs) { |m| rspec.spec.call("features/#{m[1]}") } + watch(rails.layouts) { |m| rspec.spec.call("features/#{m[1]}") } +end diff --git a/PROCESS.md b/PROCESS.md index 6bff60bff0f974a55670cd4076fe2f45565a2f5b..45f28b33a63e4e6d66d753014b152cf7dff55555 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -79,7 +79,7 @@ star, smile, etc.). Some good tips about code reviews can be found in our Overview and details of feature flag processes in development of GitLab itself is described in [feature flags process documentation](https://docs.gitlab.com/ee/development/feature_flags/process.html). -Guides on how to include feature flags in your backend/frontend code while developing GitLab are described in [developing with feature flags documentation](https://docs.gitlab.com/ee/development/feature_flags/developing.html). +Guides on how to include feature flags in your backend/frontend code while developing GitLab are described in [developing with feature flags documentation](https://docs.gitlab.com/ee/development/feature_flags/development.html). Getting access and how to expose the feature to users is detailed in [controlling feature flags documentation](https://docs.gitlab.com/ee/development/feature_flags/controls.html). diff --git a/VERSION b/VERSION index 9f3bdf87a9c16a0fa4320bbff3bca6c3b85b94cc..4dd2ed8f250345efe91e39cb9b822e692b8a8ab1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -12.4.3 +12.5.0-pre diff --git a/app/assets/images/cluster_app_logos/crossplane.png b/app/assets/images/cluster_app_logos/crossplane.png new file mode 100644 index 0000000000000000000000000000000000000000..32d8175108c251cdf6330fa7a2f5d3afc1fc9c24 Binary files /dev/null and b/app/assets/images/cluster_app_logos/crossplane.png differ diff --git a/app/assets/images/cluster_app_logos/elastic_stack.png b/app/assets/images/cluster_app_logos/elastic_stack.png new file mode 100644 index 0000000000000000000000000000000000000000..69fbc6aacd072103d2946e166581cd25bc8e1d89 Binary files /dev/null and b/app/assets/images/cluster_app_logos/elastic_stack.png differ diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 908dc730aa41dd824ff1cdc28bbfca49b19f5830..aee9990bc0b1f30d24128f4ef5da3deb7bc1e884 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -2,6 +2,8 @@ import $ from 'jquery'; import _ from 'underscore'; import axios from './lib/utils/axios_utils'; import { joinPaths } from './lib/utils/url_utility'; +import flash from '~/flash'; +import { __ } from '~/locale'; const Api = { groupsPath: '/api/:version/groups.json', @@ -29,6 +31,7 @@ const Api = { usersPath: '/api/:version/users.json', userPath: '/api/:version/users/:id', userStatusPath: '/api/:version/users/:id/status', + userProjectsPath: '/api/:version/users/:id/projects', userPostStatusPath: '/api/:version/user/status', commitPath: '/api/:version/projects/:id/repository/commits', applySuggestionPath: '/api/:version/suggestions/:id/apply', @@ -110,10 +113,9 @@ const Api = { .get(url, { params: Object.assign(defaults, options), }) - .then(({ data }) => { + .then(({ data, headers }) => { callback(data); - - return data; + return { data, headers }; }); }, @@ -239,7 +241,8 @@ const Api = { .get(url, { params: Object.assign({}, defaults, options), }) - .then(({ data }) => callback(data)); + .then(({ data }) => callback(data)) + .catch(() => flash(__('Something went wrong while fetching projects'))); }, commitMultiple(id, data) { @@ -348,6 +351,20 @@ const Api = { }); }, + userProjects(userId, query, options, callback) { + const url = Api.buildUrl(Api.userProjectsPath).replace(':id', userId); + const defaults = { + search: query, + per_page: 20, + }; + return axios + .get(url, { + params: Object.assign({}, defaults, options), + }) + .then(({ data }) => callback(data)) + .catch(() => flash(__('Something went wrong while fetching projects'))); + }, + branches(id, query = '', options = {}) { const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id)); diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index a07942d87cb511994ddc5b53a8c2b094b401aba4..ca91400eac70471e98f9a779c820d0ebe6cf85f5 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var */ +/* eslint-disable func-names */ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; @@ -12,11 +12,8 @@ import { __ } from '~/locale'; // more than `x` users are referenced. // -var lastTextareaPreviewed; -var lastTextareaHeight = null; -var markdownPreview; -var previewButtonSelector; -var writeButtonSelector; +let lastTextareaHeight; +let lastTextareaPreviewed; function MarkdownPreview() {} @@ -27,14 +24,13 @@ MarkdownPreview.prototype.emptyMessage = __('Nothing to preview.'); MarkdownPreview.prototype.ajaxCache = {}; MarkdownPreview.prototype.showPreview = function($form) { - var mdText; - var preview = $form.find('.js-md-preview'); - var url = preview.data('url'); + const preview = $form.find('.js-md-preview'); + const url = preview.data('url'); if (preview.hasClass('md-preview-loading')) { return; } - mdText = $form.find('textarea.markdown-area').val(); + const mdText = $form.find('textarea.markdown-area').val(); if (mdText === undefined) { return; @@ -46,7 +42,7 @@ MarkdownPreview.prototype.showPreview = function($form) { } else { preview.addClass('md-preview-loading').text(__('Loading...')); this.fetchMarkdownPreview(mdText, url, response => { - var body; + let body; if (response.body.length > 0) { ({ body } = response); } else { @@ -91,8 +87,7 @@ MarkdownPreview.prototype.hideReferencedUsers = function($form) { }; MarkdownPreview.prototype.renderReferencedUsers = function(users, $form) { - var referencedUsers; - referencedUsers = $form.find('.referenced-users'); + const referencedUsers = $form.find('.referenced-users'); if (referencedUsers.length) { if (users.length >= this.referenceThreshold) { referencedUsers.show(); @@ -108,8 +103,7 @@ MarkdownPreview.prototype.hideReferencedCommands = function($form) { }; MarkdownPreview.prototype.renderReferencedCommands = function(commands, $form) { - var referencedCommands; - referencedCommands = $form.find('.referenced-commands'); + const referencedCommands = $form.find('.referenced-commands'); if (commands.length > 0) { referencedCommands.html(commands); referencedCommands.show(); @@ -119,15 +113,15 @@ MarkdownPreview.prototype.renderReferencedCommands = function(commands, $form) { } }; -markdownPreview = new MarkdownPreview(); +const markdownPreview = new MarkdownPreview(); -previewButtonSelector = '.js-md-preview-button'; -writeButtonSelector = '.js-md-write-button'; +const previewButtonSelector = '.js-md-preview-button'; +const writeButtonSelector = '.js-md-write-button'; lastTextareaPreviewed = null; const markdownToolbar = $('.md-header-toolbar'); $.fn.setupMarkdownPreview = function() { - var $form = $(this); + const $form = $(this); $form.find('textarea.markdown-area').on('input', () => { markdownPreview.hideReferencedUsers($form); }); @@ -188,7 +182,7 @@ $(document).on('markdown-preview:hide', (e, $form) => { }); $(document).on('markdown-preview:toggle', (e, keyboardEvent) => { - var $target; + let $target; $target = $(keyboardEvent.target); if ($target.is('textarea.markdown-area')) { $(document).triggerHandler('markdown-preview:show', [$target.closest('form')]); @@ -201,16 +195,14 @@ $(document).on('markdown-preview:toggle', (e, keyboardEvent) => { }); $(document).on('click', previewButtonSelector, function(e) { - var $form; e.preventDefault(); - $form = $(this).closest('form'); + const $form = $(this).closest('form'); $(document).triggerHandler('markdown-preview:show', [$form]); }); $(document).on('click', writeButtonSelector, function(e) { - var $form; e.preventDefault(); - $form = $(this).closest('form'); + const $form = $(this).closest('form'); $(document).triggerHandler('markdown-preview:hide', [$form]); }); diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index b371f6be268ac264c46b4914afcce79636f3ad3b..aedd8004ea541407b0682a8d5aeba158da1f9d77 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -118,8 +118,6 @@ export default class FileTemplateMediator { } }); - this.setFilename(item.name); - if (this.editor.getValue() !== '') { this.setTypeSelectorToggleText(item.name); } @@ -133,14 +131,16 @@ export default class FileTemplateMediator { selectTemplateFile(selector, query, data) { const self = this; + const { name } = selector.config; selector.renderLoading(); this.fetchFileTemplate(selector.config.type, query, data) .then(file => { this.setEditorContent(file); + this.setFilename(name); selector.renderLoaded(); - this.typeSelector.setToggleText(selector.config.name); + this.typeSelector.setToggleText(name); toast(__(`${query} template applied`), { action: { text: __('Undo'), diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 34560560756ee9981342db44c794f14065d01928..c0df8b72095ccb8e9d414ff5258c336c6afe6b8f 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -133,7 +133,7 @@ export default { if (this.board.name.length === 0) return; this.isLoading = true; if (this.isDeleteForm) { - gl.boardService + boardsStore .deleteBoard(this.currentBoard) .then(() => { visitUrl(boardsStore.rootPath); @@ -143,7 +143,7 @@ export default { this.isLoading = false; }); } else { - gl.boardService + boardsStore .createBoard(this.board) .then(resp => resp.data) .then(data => { diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 1273fcc6a917613bd17c02c035a55a8d50730ed3..b8439bc874123cbbb34b6b2e4af09bfd9a0a77d1 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -84,7 +84,8 @@ export default { this.$nextTick(() => { if ( this.scrollHeight() <= this.listHeight() && - this.list.issuesSize > this.list.issues.length + this.list.issuesSize > this.list.issues.length && + this.list.isExpanded ) { this.list.page += 1; this.list.getIssues(false).catch(() => { diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 334c162954efc9d469d3fd3a80a5371ecce98989..32491dfbcb6f643261bc17ab7f529583faa75908 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -168,7 +168,7 @@ export default { } const recentBoardsPromise = new Promise((resolve, reject) => - gl.boardService + boardsStore .recentBoards() .then(resolve) .catch(err => { @@ -184,7 +184,7 @@ export default { }), ); - Promise.all([gl.boardService.allBoards(), recentBoardsPromise]) + Promise.all([boardsStore.allBoards(), recentBoardsPromise]) .then(([allBoards, recentBoards]) => [allBoards.data, recentBoards.data]) .then(([allBoardsJson, recentBoardsJson]) => { this.loading = false; diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 40d75d53f759c7d9ffb6b1dba66ad236d86d6e26..d37e49bab46e77b9beeab2cc94c5a4cb1c1c3ed4 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -1,5 +1,6 @@ <script> import _ from 'underscore'; +import { mapState } from 'vuex'; import { GlTooltipDirective } from '@gitlab/ui'; import { sprintf, __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; @@ -63,6 +64,7 @@ export default { }; }, computed: { + ...mapState(['isShowingLabels']), numberOverLimit() { return this.issue.assignees.length - this.limitBeforeCounter; }, @@ -92,7 +94,7 @@ export default { return false; }, showLabelFooter() { - return this.issue.labels.find(l => this.showLabel(l)) !== undefined; + return this.isShowingLabels && this.issue.labels.find(this.showLabel); }, issueReferencePath() { const { referencePath, groupId } = this.issue; diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue index defa1f75ba24fcdbad06106813ef10f684ab2c1e..618c2ada1f890cb53e50f4e5ea9da270f6c435b9 100644 --- a/app/assets/javascripts/boards/components/modal/index.vue +++ b/app/assets/javascripts/boards/components/modal/index.vue @@ -1,6 +1,7 @@ <script> /* global ListIssue */ import { urlParamsToObject } from '~/lib/utils/common_utils'; +import boardsStore from '~/boards/stores/boards_store'; import ModalHeader from './header.vue'; import ModalList from './list.vue'; import ModalFooter from './footer.vue'; @@ -109,7 +110,7 @@ export default { loadIssues(clearIssues = false) { if (!this.showAddIssuesModal) return false; - return gl.boardService + return boardsStore .getBacklog({ ...urlParamsToObject(this.filter.path), page: this.page, diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index befca70eeae3eb237c6b259fe168196d8c2b4add..e76e2341dfdd31615e2fe1a178da2d36ecb9d422 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -13,6 +13,7 @@ import 'ee_else_ce/boards/models/issue'; import 'ee_else_ce/boards/models/list'; import '~/boards/models/milestone'; import '~/boards/models/project'; +import store from '~/boards/stores'; import boardsStore from '~/boards/stores/boards_store'; import ModalStore from '~/boards/stores/modal_store'; import BoardService from 'ee_else_ce/boards/services/board_service'; @@ -29,6 +30,7 @@ import { } from '~/lib/utils/common_utils'; import boardConfigToggle from 'ee_else_ce/boards/config_toggle'; import toggleFocusMode from 'ee_else_ce/boards/toggle_focus'; +import toggleLabels from 'ee_else_ce/boards/toggle_labels'; import { setPromotionState, setWeigthFetchingState, @@ -67,6 +69,7 @@ export default () => { BoardSidebar, BoardAddIssuesModal, }, + store, data: { state: boardsStore.state, loading: true, @@ -314,5 +317,6 @@ export default () => { } toggleFocusMode(ModalStore, boardsStore, $boardApp); + toggleLabels(); mountMultipleBoardsSwitcher(); }; diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 1e213c324ebbbf39e990f45e1625011d69eb2b06..bb8c8e6829720e794145754d1c054b90f4787888 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -50,8 +50,8 @@ class List { this.page = 1; this.loading = true; this.loadingMore = false; - this.issues = []; - this.issuesSize = 0; + this.issues = obj.issues || []; + this.issuesSize = obj.issuesSize ? obj.issuesSize : 0; this.defaultAvatar = defaultAvatar; if (obj.label) { diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js new file mode 100644 index 0000000000000000000000000000000000000000..4de1576099ddaac5719ffa04460577166e75b265 --- /dev/null +++ b/app/assets/javascripts/boards/stores/getters.js @@ -0,0 +1,3 @@ +export default { + getLabelToggleState: state => (state.isShowingLabels ? 'on' : 'off'), +}; diff --git a/app/assets/javascripts/boards/stores/index.js b/app/assets/javascripts/boards/stores/index.js index f70395a3771bdd18252aec0af9b825e5c8b6cbfd..471b952a212a2575e88602e8bf7abc1c73fcd76e 100644 --- a/app/assets/javascripts/boards/stores/index.js +++ b/app/assets/javascripts/boards/stores/index.js @@ -1,14 +1,18 @@ import Vue from 'vue'; import Vuex from 'vuex'; import state from 'ee_else_ce/boards/stores/state'; +import getters from 'ee_else_ce/boards/stores/getters'; import actions from 'ee_else_ce/boards/stores/actions'; import mutations from 'ee_else_ce/boards/stores/mutations'; Vue.use(Vuex); -export default () => +export const createStore = () => new Vuex.Store({ state, + getters, actions, mutations, }); + +export default createStore(); diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index dd16abb01a5c9081eb42d2398b8688a4529beca2..24f44dc562977be4001235680f1105d4f25f8dcd 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -1,3 +1,3 @@ export default () => ({ - // ... + isShowingLabels: true, }); diff --git a/app/assets/javascripts/boards/toggle_labels.js b/app/assets/javascripts/boards/toggle_labels.js new file mode 100644 index 0000000000000000000000000000000000000000..2d1ec238274a0b2c8ef0e3d63ac2fdb96190c537 --- /dev/null +++ b/app/assets/javascripts/boards/toggle_labels.js @@ -0,0 +1 @@ +export default () => {}; diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 7ea8901ecbb7ab4fa88c6db51b2798fe824d034b..75909dd9d202ec2ea703f4fff293ed9071690ac4 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -8,11 +8,12 @@ import Flash from '../flash'; import Poll from '../lib/utils/poll'; import initSettingsPanels from '../settings_panels'; import eventHub from './event_hub'; -import { APPLICATION_STATUS, INGRESS, INGRESS_DOMAIN_SUFFIX } from './constants'; +import { APPLICATION_STATUS, INGRESS, INGRESS_DOMAIN_SUFFIX, CROSSPLANE } from './constants'; import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; import Applications from './components/applications.vue'; import setupToggleButtons from '../toggle_buttons'; +import initProjectSelectDropdown from '~/project_select'; const Environments = () => import('ee_component/clusters/components/environments.vue'); @@ -37,6 +38,8 @@ export default class Clusters { installJupyterPath, installKnativePath, updateKnativePath, + installElasticStackPath, + installCrossplanePath, installPrometheusPath, managePrometheusPath, clusterEnvironmentsPath, @@ -81,11 +84,13 @@ export default class Clusters { installHelmEndpoint: installHelmPath, installIngressEndpoint: installIngressPath, installCertManagerEndpoint: installCertManagerPath, + installCrossplaneEndpoint: installCrossplanePath, installRunnerEndpoint: installRunnerPath, installPrometheusEndpoint: installPrometheusPath, installJupyterEndpoint: installJupyterPath, installKnativeEndpoint: installKnativePath, updateKnativeEndpoint: updateKnativePath, + installElasticStackEndpoint: installElasticStackPath, clusterEnvironmentsEndpoint: clusterEnvironmentsPath, }); @@ -108,8 +113,10 @@ export default class Clusters { this.ingressDomainHelpText && this.ingressDomainHelpText.querySelector('.js-ingress-domain-snippet'); + initProjectSelectDropdown(); Clusters.initDismissableCallout(); initSettingsPanels(); + const toggleButtonsContainer = document.querySelector('.js-cluster-enable-toggle-area'); if (toggleButtonsContainer) { setupToggleButtons(toggleButtonsContainer); @@ -222,6 +229,7 @@ export default class Clusters { eventHub.$on('saveKnativeDomain', data => this.saveKnativeDomain(data)); eventHub.$on('setKnativeHostname', data => this.setKnativeHostname(data)); eventHub.$on('uninstallApplication', data => this.uninstallApplication(data)); + eventHub.$on('setCrossplaneProviderStack', data => this.setCrossplaneProviderStack(data)); // Add event listener to all the banner close buttons this.addBannerCloseHandler(this.unreachableContainer, 'unreachable'); this.addBannerCloseHandler(this.authenticationFailureContainer, 'authentication_failure'); @@ -233,6 +241,7 @@ export default class Clusters { eventHub.$off('updateApplication', this.updateApplication); eventHub.$off('saveKnativeDomain'); eventHub.$off('setKnativeHostname'); + eventHub.$off('setCrossplaneProviderStack'); eventHub.$off('uninstallApplication'); } @@ -399,18 +408,33 @@ export default class Clusters { } installApplication({ id: appId, params }) { - this.store.updateAppProperty(appId, 'requestReason', null); - this.store.updateAppProperty(appId, 'statusReason', null); + return Clusters.validateInstallation(appId, params) + .then(() => { + this.store.updateAppProperty(appId, 'requestReason', null); + this.store.updateAppProperty(appId, 'statusReason', null); + this.store.installApplication(appId); + + // eslint-disable-next-line promise/no-nesting + this.service.installApplication(appId, params).catch(() => { + this.store.notifyInstallFailure(appId); + this.store.updateAppProperty( + appId, + 'requestReason', + s__('ClusterIntegration|Request to begin installing failed'), + ); + }); + }) + .catch(error => this.store.updateAppProperty(appId, 'validationError', error)); + } - this.store.installApplication(appId); + static validateInstallation(appId, params) { + return new Promise((resolve, reject) => { + if (appId === CROSSPLANE && !params.stack) { + reject(s__('ClusterIntegration|Select a stack to install Crossplane.')); + return; + } - return this.service.installApplication(appId, params).catch(() => { - this.store.notifyInstallFailure(appId); - this.store.updateAppProperty( - appId, - 'requestReason', - s__('ClusterIntegration|Request to begin installing failed'), - ); + resolve(); }); } @@ -458,6 +482,12 @@ export default class Clusters { this.store.updateAppProperty(appId, 'hostname', data.hostname); } + setCrossplaneProviderStack(data) { + const appId = data.id; + this.store.updateAppProperty(appId, 'stack', data.stack.code); + this.store.updateAppProperty(appId, 'validationError', null); + } + destroy() { this.destroyed = true; diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index b95f97077f645b4a4c330fce605a50fda6798e7e..a951a6bfeeac8db450ed019503df18bde00c7bbf 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -9,9 +9,11 @@ import jeagerLogo from 'images/cluster_app_logos/jeager.png'; import jupyterhubLogo from 'images/cluster_app_logos/jupyterhub.png'; import kubernetesLogo from 'images/cluster_app_logos/kubernetes.png'; import certManagerLogo from 'images/cluster_app_logos/cert_manager.png'; +import crossplaneLogo from 'images/cluster_app_logos/crossplane.png'; import knativeLogo from 'images/cluster_app_logos/knative.png'; import meltanoLogo from 'images/cluster_app_logos/meltano.png'; import prometheusLogo from 'images/cluster_app_logos/prometheus.png'; +import elasticStackLogo from 'images/cluster_app_logos/elastic_stack.png'; import { s__, sprintf } from '../../locale'; import applicationRow from './application_row.vue'; import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; @@ -19,6 +21,7 @@ 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'; export default { components: { @@ -27,6 +30,7 @@ export default { LoadingButton, GlLoadingIcon, KnativeDomainEditor, + CrossplaneProviderStack, }, props: { type: { @@ -88,9 +92,11 @@ export default { jupyterhubLogo, kubernetesLogo, certManagerLogo, + crossplaneLogo, knativeLogo, meltanoLogo, prometheusLogo, + elasticStackLogo, }), computed: { isProjectCluster() { @@ -114,6 +120,15 @@ export default { certManagerInstalled() { return this.applications.cert_manager.status === APPLICATION_STATUS.INSTALLED; }, + crossplaneInstalled() { + return this.applications.crossplane.status === APPLICATION_STATUS.INSTALLED; + }, + enableClusterApplicationCrossplane() { + return gon.features && gon.features.enableClusterApplicationCrossplane; + }, + enableClusterApplicationElasticStack() { + return gon.features && gon.features.enableClusterApplicationElasticStack; + }, ingressDescription() { return sprintf( _.escape( @@ -146,6 +161,24 @@ export default { false, ); }, + crossplaneDescription() { + return sprintf( + _.escape( + s__( + `ClusterIntegration|Crossplane enables declarative provisioning of managed services from your cloud of choice using %{kubectl} or %{gitlabIntegrationLink}. +Crossplane runs inside your Kubernetes cluster and supports secure connectivity and secrets management between app containers and the cloud services they depend on.`, + ), + ), + { + gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ce/user/project/integrations/crossplane.html" + target="_blank" rel="noopener noreferrer"> + ${_.escape(s__('ClusterIntegration|Gitlab Integration'))}</a>`, + kubectl: `<code>kubectl</code>`, + }, + false, + ); + }, + prometheusDescription() { return sprintf( _.escape( @@ -168,9 +201,18 @@ export default { jupyterHostname() { return this.applications.jupyter.hostname; }, + elasticStackInstalled() { + return this.applications.elastic_stack.status === APPLICATION_STATUS.INSTALLED; + }, + elasticStackKibanaHostname() { + return this.applications.elastic_stack.kibana_hostname; + }, knative() { return this.applications.knative; }, + crossplane() { + return this.applications.crossplane; + }, cloudRun() { return this.providerType === PROVIDER_TYPE.GCP && this.preInstalledKnative; }, @@ -207,6 +249,12 @@ export default { hostname, }); }, + setCrossplaneProviderStack(stack) { + eventHub.$emit('setCrossplaneProviderStack', { + id: 'crossplane', + stack, + }); + }, }, }; </script> @@ -217,7 +265,7 @@ export default { <p class="append-bottom-0"> {{ s__(`ClusterIntegration|Choose which applications to install on your Kubernetes cluster. - Helm Tiller is required to install any of the following applications.`) + Helm Tiller is required to install any of the following applications.`) }} <a :href="helpPath">{{ __('More information') }}</a> </p> @@ -242,9 +290,9 @@ export default { <div slot="description"> {{ s__(`ClusterIntegration|Helm streamlines installing - and managing Kubernetes applications. - Tiller runs inside of your Kubernetes Cluster, - and manages releases of your charts.`) + and managing Kubernetes applications. + Tiller runs inside of your Kubernetes Cluster, + and manages releases of your charts.`) }} </div> </application-row> @@ -252,7 +300,7 @@ export default { <div class="svg-container" v-html="helmInstallIllustration"></div> {{ s__(`ClusterIntegration|You must first install Helm Tiller before - installing the applications below`) + installing the applications below`) }} </div> <application-row @@ -275,8 +323,8 @@ export default { <p> {{ s__(`ClusterIntegration|Ingress gives you a way to route - requests to services based on the request host or path, - centralizing a number of services into a single entrypoint.`) + requests to services based on the request host or path, + centralizing a number of services into a single entrypoint.`) }} </p> @@ -308,8 +356,8 @@ export default { <p class="form-text text-muted"> {{ s__(`ClusterIntegration|Point a wildcard DNS to this - generated endpoint in order to access - your application after it has been deployed.`) + generated endpoint in order to access + your application after it has been deployed.`) }} <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> {{ __('More information') }} @@ -320,8 +368,8 @@ export default { <p v-if="!ingressExternalEndpoint" class="settings-message js-no-endpoint-message"> {{ s__(`ClusterIntegration|The endpoint is in - the process of being assigned. Please check your Kubernetes - cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) + the process of being assigned. Please check your Kubernetes + cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) }} <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> {{ __('More information') }} @@ -368,7 +416,7 @@ export default { <p class="form-text text-muted"> {{ s__(`ClusterIntegration|Issuers represent a certificate authority. - You must provide an email address for your Issuer. `) + You must provide an email address for your Issuer. `) }} <a href="http://docs.cert-manager.io/en/latest/reference/issuers.html?highlight=email" @@ -424,12 +472,40 @@ export default { <div slot="description"> {{ s__(`ClusterIntegration|GitLab Runner connects to the - repository and executes CI/CD jobs, - pushing results back and deploying - applications to production.`) + repository and executes CI/CD jobs, + pushing results back and deploying + applications to production.`) }} </div> </application-row> + <application-row + v-if="enableClusterApplicationCrossplane" + id="crossplane" + :logo-url="crossplaneLogo" + :title="applications.crossplane.title" + :status="applications.crossplane.status" + :status-reason="applications.crossplane.statusReason" + :request-status="applications.crossplane.requestStatus" + :request-reason="applications.crossplane.requestReason" + :installed="applications.crossplane.installed" + :install-failed="applications.crossplane.installFailed" + :uninstallable="applications.crossplane.uninstallable" + :uninstall-successful="applications.crossplane.uninstallSuccessful" + :uninstall-failed="applications.crossplane.uninstallFailed" + :install-application-request-params="{ stack: applications.crossplane.stack }" + :disabled="!helmInstalled" + title-link="https://crossplane.io" + > + <template> + <div slot="description"> + <p v-html="crossplaneDescription"></p> + <div class="form-group"> + <CrossplaneProviderStack :crossplane="crossplane" @set="setCrossplaneProviderStack" /> + </div> + </div> + </template> + </application-row> + <application-row id="jupyter" :logo-url="jupyterhubLogo" @@ -451,10 +527,10 @@ export default { <p> {{ s__(`ClusterIntegration|JupyterHub, a multi-user Hub, spawns, - manages, and proxies multiple instances of the single-user - Jupyter notebook server. JupyterHub can be used to serve - notebooks to a class of students, a corporate data science group, - or a scientific research group.`) + manages, and proxies multiple instances of the single-user + Jupyter notebook server. JupyterHub can be used to serve + notebooks to a class of students, a corporate data science group, + or a scientific research group.`) }} </p> @@ -481,7 +557,7 @@ export default { <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.`) + If you do so, point hostname to Ingress IP Address from above.`) }} <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> {{ __('More information') }} @@ -527,9 +603,9 @@ export default { <p> {{ s__(`ClusterIntegration|Knative extends Kubernetes to provide - a set of middleware components that are essential to build modern, - source-centric, and container-based applications that can run - anywhere: on premises, in the cloud, or even in a third-party data center.`) + a set of middleware components that are essential to build modern, + source-centric, and container-based applications that can run + anywhere: on premises, in the cloud, or even in a third-party data center.`) }} </p> @@ -542,6 +618,75 @@ export default { /> </div> </application-row> + <application-row + v-if="enableClusterApplicationElasticStack" + id="elastic_stack" + :logo-url="elasticStackLogo" + :title="applications.elastic_stack.title" + :status="applications.elastic_stack.status" + :status-reason="applications.elastic_stack.statusReason" + :request-status="applications.elastic_stack.requestStatus" + :request-reason="applications.elastic_stack.requestReason" + :version="applications.elastic_stack.version" + :chart-repo="applications.elastic_stack.chartRepo" + :update-available="applications.elastic_stack.updateAvailable" + :installed="applications.elastic_stack.installed" + :install-failed="applications.elastic_stack.installFailed" + :update-successful="applications.elastic_stack.updateSuccessful" + :update-failed="applications.elastic_stack.updateFailed" + :uninstallable="applications.elastic_stack.uninstallable" + :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"> + <p> + {{ + s__( + `ClusterIntegration|The elastic stack collects logs from all pods in your cluster`, + ) + }} + </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> </section> </template> diff --git a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue new file mode 100644 index 0000000000000000000000000000000000000000..966918ae6360e4af2107c0a3a8395e24ba8b7f0b --- /dev/null +++ b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue @@ -0,0 +1,93 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { s__ } from '../../locale'; + +export default { + name: 'CrossplaneProviderStack', + components: { + GlDropdown, + GlDropdownItem, + Icon, + }, + props: { + stacks: { + type: Array, + required: false, + default: () => [ + { + name: s__('Google Cloud Platform'), + code: 'gcp', + }, + { + name: s__('Amazon Web Services'), + code: 'aws', + }, + { + name: s__('Microsoft Azure'), + code: 'azure', + }, + { + name: s__('Rook'), + code: 'rook', + }, + ], + }, + crossplane: { + type: Object, + required: true, + }, + }, + computed: { + dropdownText() { + const result = this.stacks.reduce((map, obj) => { + // eslint-disable-next-line no-param-reassign + map[obj.code] = obj.name; + return map; + }, {}); + const { stack } = this.crossplane; + if (stack !== '') { + return result[stack]; + } + return s__('Select Stack'); + }, + validationError() { + return this.crossplane.validationError; + }, + }, + methods: { + selectStack(stack) { + this.$emit('set', stack); + }, + }, +}; +</script> + +<template> + <div> + <label> + {{ s__('ClusterIntegration|Enabled stack') }} + </label> + <gl-dropdown + :disabled="crossplane.installed" + :text="dropdownText" + toggle-class="dropdown-menu-toggle gl-field-error-outline" + class="w-100" + :class="{ 'gl-show-field-errors': validationError }" + > + <gl-dropdown-item v-for="stack in stacks" :key="stack.code" @click="selectStack(stack)"> + <span class="ml-1">{{ stack.name }}</span> + </gl-dropdown-item> + </gl-dropdown> + <span v-if="validationError" class="gl-field-error">{{ validationError }}</span> + <p class="form-text text-muted"> + {{ s__(`You must select a stack for configuring your cloud provider. Learn more about`) }} + <a + href="https://crossplane.io/docs/master/stacks-guide.html" + target="_blank" + rel="noopener noreferrer" + >{{ __('Crossplane') }}</a + > + </p> + </div> +</template> diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue index f1925c243f26521341b7f932b8a1d3e1ae83c785..125bcaacc1ce6ff8cd248e81338705b2b46eda9f 100644 --- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue +++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue @@ -2,7 +2,16 @@ import { GlModal } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; import trackUninstallButtonClickMixin from 'ee_else_ce/clusters/mixins/track_uninstall_button_click'; -import { HELM, INGRESS, CERT_MANAGER, PROMETHEUS, RUNNER, KNATIVE, JUPYTER } from '../constants'; +import { + HELM, + INGRESS, + CERT_MANAGER, + PROMETHEUS, + RUNNER, + KNATIVE, + JUPYTER, + ELASTIC_STACK, +} from '../constants'; const CUSTOM_APP_WARNING_TEXT = { [HELM]: sprintf( @@ -28,6 +37,7 @@ const CUSTOM_APP_WARNING_TEXT = { [JUPYTER]: s__( 'ClusterIntegration|All data not committed to GitLab will be deleted and cannot be restored.', ), + [ELASTIC_STACK]: s__('ClusterIntegration|All data will be deleted and cannot be restored.'), }; export default { diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index c6e4b7951cf90b0c6b05303bc3bae354471a1ee6..9f98f170fb0353dd3593eac1a18ddb557b38e755 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -50,8 +50,19 @@ export const JUPYTER = 'jupyter'; export const KNATIVE = 'knative'; export const RUNNER = 'runner'; export const CERT_MANAGER = 'cert_manager'; +export const CROSSPLANE = 'crossplane'; export const PROMETHEUS = 'prometheus'; +export const ELASTIC_STACK = 'elastic_stack'; -export const APPLICATIONS = [HELM, INGRESS, JUPYTER, KNATIVE, RUNNER, CERT_MANAGER, PROMETHEUS]; +export const APPLICATIONS = [ + HELM, + INGRESS, + JUPYTER, + KNATIVE, + RUNNER, + CERT_MANAGER, + PROMETHEUS, + ELASTIC_STACK, +]; export const INGRESS_DOMAIN_SUFFIX = '.nip.io'; diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js index fa12802b3de182cdc8f486065d5c883af4bd6b3d..333fb293a1506f70baaa313176550dde9093547f 100644 --- a/app/assets/javascripts/clusters/services/clusters_service.js +++ b/app/assets/javascripts/clusters/services/clusters_service.js @@ -7,10 +7,12 @@ export default class ClusterService { helm: this.options.installHelmEndpoint, ingress: this.options.installIngressEndpoint, cert_manager: this.options.installCertManagerEndpoint, + crossplane: this.options.installCrossplaneEndpoint, runner: this.options.installRunnerEndpoint, prometheus: this.options.installPrometheusEndpoint, jupyter: this.options.installJupyterEndpoint, knative: this.options.installKnativeEndpoint, + elastic_stack: this.options.installElasticStackEndpoint, }; this.appUpdateEndpointMap = { knative: this.options.updateKnativeEndpoint, diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 6464461ea0c2736ee6beae3bf2f3b7c1f4704205..35dbf95155120a3817d6c10625c0dfe04b580c26 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -5,6 +5,8 @@ import { JUPYTER, KNATIVE, CERT_MANAGER, + ELASTIC_STACK, + CROSSPLANE, RUNNER, APPLICATION_INSTALLED_STATUSES, APPLICATION_STATUS, @@ -25,6 +27,7 @@ const applicationInitialState = { uninstallable: false, uninstallFailed: false, uninstallSuccessful: false, + validationError: null, }; export default class ClusterStore { @@ -57,6 +60,11 @@ export default class ClusterStore { title: s__('ClusterIntegration|Cert-Manager'), email: null, }, + crossplane: { + ...applicationInitialState, + title: s__('ClusterIntegration|Crossplane'), + stack: null, + }, runner: { ...applicationInitialState, title: s__('ClusterIntegration|GitLab Runner'), @@ -85,6 +93,11 @@ export default class ClusterStore { updateSuccessful: false, updateFailed: false, }, + elastic_stack: { + ...applicationInitialState, + title: s__('ClusterIntegration|Elastic Stack'), + kibana_hostname: null, + }, }, environments: [], fetchingEnvironments: false, @@ -197,13 +210,15 @@ export default class ClusterStore { } else if (appId === CERT_MANAGER) { this.state.applications.cert_manager.email = this.state.applications.cert_manager.email || serverAppEntry.email; + } else if (appId === CROSSPLANE) { + this.state.applications.crossplane.stack = + this.state.applications.crossplane.stack || serverAppEntry.stack; } else if (appId === JUPYTER) { - this.state.applications.jupyter.hostname = - this.state.applications.jupyter.hostname || - serverAppEntry.hostname || - (this.state.applications.ingress.externalIp - ? `jupyter.${this.state.applications.ingress.externalIp}.nip.io` - : ''); + this.state.applications.jupyter.hostname = this.updateHostnameIfUnset( + this.state.applications.jupyter.hostname, + serverAppEntry.hostname, + 'jupyter', + ); } else if (appId === KNATIVE) { if (!this.state.applications.knative.isEditingHostName) { this.state.applications.knative.hostname = @@ -216,10 +231,26 @@ 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', + ); } }); } + updateHostnameIfUnset(current, updated, fallback) { + return ( + current || + updated || + (this.state.applications.ingress.externalIp + ? `${fallback}.${this.state.applications.ingress.externalIp}.nip.io` + : '') + ); + } + toggleFetchEnvironments(isFetching) { this.state.fetchingEnvironments = isFetching; } diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js index 6c04e0beb4d63c79c304e6f885928633a2cac168..60c2059a8760d9f0c9848cdf5677f7e990339e43 100644 --- a/app/assets/javascripts/commit/image_file.js +++ b/app/assets/javascripts/commit/image_file.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, no-else-return, consistent-return, one-var, no-return-assign, no-unused-expressions, no-sequences */ +/* eslint-disable func-names, no-var, no-else-return, consistent-return, one-var, no-return-assign */ import $ from 'jquery'; @@ -9,40 +9,29 @@ const viewModes = ['two-up', 'swipe']; export default class ImageFile { constructor(file) { this.file = file; - this.requestImageInfo( - $('.two-up.view .frame.deleted img', this.file), - (function(_this) { - return function() { - return _this.requestImageInfo($('.two-up.view .frame.added img', _this.file), () => { - _this.initViewModes(); - - // Load two-up view after images are loaded - // so that we can display the correct width and height information - const $images = $('.two-up.view img', _this.file); - - $images.waitForImages(() => { - _this.initView('two-up'); - }); - }); - }; - })(this), + this.requestImageInfo($('.two-up.view .frame.deleted img', this.file), () => + this.requestImageInfo($('.two-up.view .frame.added img', this.file), () => { + this.initViewModes(); + + // Load two-up view after images are loaded + // so that we can display the correct width and height information + const $images = $('.two-up.view img', this.file); + + $images.waitForImages(() => { + this.initView('two-up'); + }); + }), ); } initViewModes() { const viewMode = viewModes[0]; $('.view-modes', this.file).removeClass('hide'); - $('.view-modes-menu', this.file).on( - 'click', - 'li', - (function(_this) { - return function(event) { - if (!$(event.currentTarget).hasClass('active')) { - return _this.activateViewMode(event.currentTarget.className); - } - }; - })(this), - ); + $('.view-modes-menu', this.file).on('click', 'li', event => { + if (!$(event.currentTarget).hasClass('active')) { + return this.activateViewMode(event.currentTarget.className); + } + }); return this.activateViewMode(viewMode); } @@ -51,15 +40,10 @@ export default class ImageFile { .removeClass('active') .filter(`.${viewMode}`) .addClass('active'); - return $(`.view:visible:not(.${viewMode})`, this.file).fadeOut( - 200, - (function(_this) { - return function() { - $(`.view.${viewMode}`, _this.file).fadeIn(200); - return _this.initView(viewMode); - }; - })(this), - ); + return $(`.view:visible:not(.${viewMode})`, this.file).fadeOut(200, () => { + $(`.view.${viewMode}`, this.file).fadeIn(200); + return this.initView(viewMode); + }); } initView(viewMode) { @@ -103,22 +87,18 @@ export default class ImageFile { .on('touchmove', dragMove); } - prepareFrames(view) { + static prepareFrames(view) { var maxHeight, maxWidth; maxWidth = 0; maxHeight = 0; $('.frame', view) - .each( - (function() { - return function(index, frame) { - var height, width; - width = $(frame).width(); - height = $(frame).height(); - maxWidth = width > maxWidth ? width : maxWidth; - return (maxHeight = height > maxHeight ? height : maxHeight); - }; - })(this), - ) + .each((index, frame) => { + var height, width; + width = $(frame).width(); + height = $(frame).height(); + maxWidth = width > maxWidth ? width : maxWidth; + return (maxHeight = height > maxHeight ? height : maxHeight); + }) .css({ width: maxWidth, height: maxHeight, @@ -128,104 +108,95 @@ export default class ImageFile { views = { 'two-up': function() { - return $('.two-up.view .wrap', this.file).each( - (function(_this) { - return function(index, wrap) { - $('img', wrap).each(function() { - var currentWidth; - currentWidth = $(this).width(); - if (currentWidth > availWidth / 2) { - return $(this).width(availWidth / 2); - } - }); - return _this.requestImageInfo($('img', wrap), (width, height) => { - $('.image-info .meta-width', wrap).text(`${width}px`); - $('.image-info .meta-height', wrap).text(`${height}px`); - return $('.image-info', wrap).removeClass('hide'); - }); - }; - })(this), - ); + return $('.two-up.view .wrap', this.file).each((index, wrap) => { + $('img', wrap).each(function() { + var currentWidth; + currentWidth = $(this).width(); + if (currentWidth > availWidth / 2) { + return $(this).width(availWidth / 2); + } + }); + return this.requestImageInfo($('img', wrap), (width, height) => { + $('.image-info .meta-width', wrap).text(`${width}px`); + $('.image-info .meta-height', wrap).text(`${height}px`); + return $('.image-info', wrap).removeClass('hide'); + }); + }); }, swipe() { var maxHeight, maxWidth; maxWidth = 0; maxHeight = 0; - return $('.swipe.view', this.file).each( - (function(_this) { - return function(index, view) { - var $swipeWrap, $swipeBar, $swipeFrame, wrapPadding, ref; - (ref = _this.prepareFrames(view)), ([maxWidth, maxHeight] = ref); - $swipeFrame = $('.swipe-frame', view); - $swipeWrap = $('.swipe-wrap', view); - $swipeBar = $('.swipe-bar', view); - - $swipeFrame.css({ - width: maxWidth + 16, - height: maxHeight + 28, - }); - $swipeWrap.css({ - width: maxWidth + 1, - height: maxHeight + 2, - }); - // Set swipeBar left position to match image frame - $swipeBar.css({ - left: 1, - }); - - wrapPadding = parseInt($swipeWrap.css('right').replace('px', ''), 10); - - _this.initDraggable($swipeBar, wrapPadding, (e, left) => { - if (left > 0 && left < $swipeFrame.width() - wrapPadding * 2) { - $swipeWrap.width(maxWidth + 1 - left); - $swipeBar.css('left', left); - } - }); - }; - })(this), - ); + return $('.swipe.view', this.file).each((index, view) => { + var $swipeWrap, $swipeBar, $swipeFrame, wrapPadding; + const ref = ImageFile.prepareFrames(view); + [maxWidth, maxHeight] = ref; + $swipeFrame = $('.swipe-frame', view); + $swipeWrap = $('.swipe-wrap', view); + $swipeBar = $('.swipe-bar', view); + + $swipeFrame.css({ + width: maxWidth + 16, + height: maxHeight + 28, + }); + $swipeWrap.css({ + width: maxWidth + 1, + height: maxHeight + 2, + }); + // Set swipeBar left position to match image frame + $swipeBar.css({ + left: 1, + }); + + wrapPadding = parseInt($swipeWrap.css('right').replace('px', ''), 10); + + this.initDraggable($swipeBar, wrapPadding, (e, left) => { + if (left > 0 && left < $swipeFrame.width() - wrapPadding * 2) { + $swipeWrap.width(maxWidth + 1 - left); + $swipeBar.css('left', left); + } + }); + }); }, 'onion-skin': function() { var dragTrackWidth, maxHeight, maxWidth; maxWidth = 0; maxHeight = 0; dragTrackWidth = $('.drag-track', this.file).width() - $('.dragger', this.file).width(); - return $('.onion-skin.view', this.file).each( - (function(_this) { - return function(index, view) { - var $frame, $track, $dragger, $frameAdded, framePadding, ref; - (ref = _this.prepareFrames(view)), ([maxWidth, maxHeight] = ref); - $frame = $('.onion-skin-frame', view); - $frameAdded = $('.frame.added', view); - $track = $('.drag-track', view); - $dragger = $('.dragger', $track); - - $frame.css({ - width: maxWidth + 16, - height: maxHeight + 28, - }); - $('.swipe-wrap', view).css({ - width: maxWidth + 1, - height: maxHeight + 2, - }); - $dragger.css({ - left: dragTrackWidth, - }); - - $frameAdded.css('opacity', 1); - framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10); - - _this.initDraggable($dragger, framePadding, (e, left) => { - var opacity = left / dragTrackWidth; - - if (opacity >= 0 && opacity <= 1) { - $dragger.css('left', left); - $frameAdded.css('opacity', opacity); - } - }); - }; - })(this), - ); + return $('.onion-skin.view', this.file).each((index, view) => { + var $frame, $track, $dragger, $frameAdded, framePadding; + + const ref = ImageFile.prepareFrames(view); + [maxWidth, maxHeight] = ref; + $frame = $('.onion-skin-frame', view); + $frameAdded = $('.frame.added', view); + $track = $('.drag-track', view); + $dragger = $('.dragger', $track); + + $frame.css({ + width: maxWidth + 16, + height: maxHeight + 28, + }); + $('.swipe-wrap', view).css({ + width: maxWidth + 1, + height: maxHeight + 2, + }); + $dragger.css({ + left: dragTrackWidth, + }); + + $frameAdded.css('opacity', 1); + framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10); + + this.initDraggable($dragger, framePadding, (e, left) => { + var opacity = left / dragTrackWidth; + + if (opacity >= 0 && opacity <= 1) { + $dragger.css('left', left); + $frameAdded.css('opacity', opacity); + } + }); + }); }, }; @@ -235,14 +206,7 @@ export default class ImageFile { if (domImg.complete) { return callback.call(this, domImg.naturalWidth, domImg.naturalHeight); } else { - return img.on( - 'load', - (function(_this) { - return function() { - return callback.call(_this, domImg.naturalWidth, domImg.naturalHeight); - }; - })(this), - ); + return img.on('load', () => callback.call(this, domImg.naturalWidth, domImg.naturalHeight)); } } } diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js index 81ba15577fbe7bb378446bd86be57a470b634e20..a23707209dcb776e24602fe3f8771a01818ff19c 100644 --- a/app/assets/javascripts/compare_autocomplete.js +++ b/app/assets/javascripts/compare_autocomplete.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, one-var, no-var, no-else-return */ +/* eslint-disable func-names, no-else-return */ import $ from 'jquery'; import { __ } from './locale'; @@ -8,9 +8,8 @@ import { capitalizeFirstCharacter } from './lib/utils/text_utility'; export default function initCompareAutocomplete(limitTo = null, clickHandler = () => {}) { $('.js-compare-dropdown').each(function() { - var $dropdown, selected; - $dropdown = $(this); - selected = $dropdown.data('selected'); + const $dropdown = $(this); + const selected = $dropdown.data('selected'); const $dropdownContainer = $dropdown.closest('.dropdown'); const $fieldInput = $(`input[name="${$dropdown.data('fieldName')}"]`, $dropdownContainer); const $filterInput = $('input[type="search"]', $dropdownContainer); @@ -44,17 +43,16 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = ( fieldName: $dropdown.data('fieldName'), filterInput: 'input[type="search"]', renderRow(ref) { - var link; + const link = $('<a />') + .attr('href', '#') + .addClass(ref === selected ? 'is-active' : '') + .text(ref) + .attr('data-ref', ref); if (ref.header != null) { return $('<li />') .addClass('dropdown-header') .text(ref.header); } else { - link = $('<a />') - .attr('href', '#') - .addClass(ref === selected ? 'is-active' : '') - .text(ref) - .attr('data-ref', ref); return $('<li />').append(link); } }, diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue index 197a07060625c77449f4056003a9f72a8262ecb3..4fa18b19556d1ac49223a54a34fc19a92e1ea90b 100644 --- a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue +++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue @@ -41,7 +41,7 @@ export default { noForkText() { return sprintf( __( - "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private.", + "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private.", ), { link_start: `<a href="${this.newForkPath}" class="help-link">`, link_end: '</a>' }, false, diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue new file mode 100644 index 0000000000000000000000000000000000000000..7dd6b051cb4005a056b6223c766d80582826c2af --- /dev/null +++ b/app/assets/javascripts/contributors/components/contributors.vue @@ -0,0 +1,227 @@ +<script> +import { __ } from '~/locale'; +import _ from 'underscore'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; +import { getDatesInRange } from '~/lib/utils/datetime_utility'; +import { xAxisLabelFormatter, dateFormatter } from '../utils'; + +export default { + components: { + GlAreaChart, + GlLoadingIcon, + }, + props: { + endpoint: { + type: String, + required: true, + }, + branch: { + type: String, + required: true, + }, + }, + data() { + return { + masterChart: null, + individualCharts: [], + svgs: {}, + masterChartHeight: 264, + individualChartHeight: 216, + }; + }, + computed: { + ...mapState(['chartData', 'loading']), + ...mapGetters(['showChart', 'parsedData']), + masterChartData() { + const data = {}; + this.xAxisRange.forEach(date => { + data[date] = this.parsedData.total[date] || 0; + }); + return [ + { + name: __('Commits'), + data: Object.entries(data), + }, + ]; + }, + masterChartOptions() { + return { + ...this.getCommonChartOptions(true), + yAxis: { + name: __('Number of commits'), + }, + grid: { + bottom: 64, + left: 64, + right: 20, + top: 20, + }, + }; + }, + individualChartsData() { + const maxNumberOfIndividualContributorsCharts = 100; + + return Object.keys(this.parsedData.byAuthor) + .map(name => { + const author = this.parsedData.byAuthor[name]; + return { + name, + email: author.email, + commits: author.commits, + dates: [ + { + name: __('Commits'), + data: this.xAxisRange.map(date => [date, author.dates[date] || 0]), + }, + ], + }; + }) + .sort((a, b) => b.commits - a.commits) + .slice(0, maxNumberOfIndividualContributorsCharts); + }, + individualChartOptions() { + return { + ...this.getCommonChartOptions(false), + yAxis: { + name: __('Commits'), + max: this.individualChartYAxisMax, + }, + grid: { + bottom: 27, + left: 64, + right: 20, + top: 8, + }, + }; + }, + individualChartYAxisMax() { + return this.individualChartsData.reduce((acc, item) => { + const values = item.dates[0].data.map(value => value[1]); + return Math.max(acc, ...values); + }, 0); + }, + xAxisRange() { + const dates = Object.keys(this.parsedData.total).sort((a, b) => new Date(a) - new Date(b)); + + const firstContributionDate = new Date(dates[0]); + const lastContributionDate = new Date(dates[dates.length - 1]); + + return getDatesInRange(firstContributionDate, lastContributionDate, dateFormatter); + }, + firstContributionDate() { + return this.xAxisRange[0]; + }, + lastContributionDate() { + return this.xAxisRange[this.xAxisRange.length - 1]; + }, + charts() { + return _.uniq(this.individualCharts); + }, + }, + mounted() { + this.fetchChartData(this.endpoint); + }, + methods: { + ...mapActions(['fetchChartData']), + getCommonChartOptions(isMasterChart) { + return { + xAxis: { + type: 'time', + name: '', + data: this.xAxisRange, + axisLabel: { + formatter: xAxisLabelFormatter, + showMaxLabel: false, + showMinLabel: false, + }, + boundaryGap: false, + splitNumber: isMasterChart ? 24 : 18, + // 28 days + minInterval: 28 * 86400 * 1000, + min: this.firstContributionDate, + max: this.lastContributionDate, + }, + }; + }, + setSvg(name) { + return getSvgIconPathContent(name) + .then(path => { + if (path) { + this.$set(this.svgs, name, `path://${path}`); + } + }) + .catch(() => {}); + }, + onMasterChartCreated(chart) { + this.masterChart = chart; + this.setSvg('scroll-handle') + .then(() => { + this.masterChart.setOption({ + dataZoom: [ + { + type: 'slider', + handleIcon: this.svgs['scroll-handle'], + }, + ], + }); + }) + .catch(() => {}); + this.masterChart.on('datazoom', _.debounce(this.setIndividualChartsZoom, 200)); + }, + onIndividualChartCreated(chart) { + this.individualCharts.push(chart); + }, + setIndividualChartsZoom(options) { + this.charts.forEach(chart => + chart.setOption( + { + dataZoom: { + start: options.start, + end: options.end, + show: false, + }, + }, + { lazyUpdate: true }, + ), + ); + }, + }, +}; +</script> + +<template> + <div> + <div v-if="loading" class="contributors-loader text-center"> + <gl-loading-icon :inline="true" :size="4" /> + </div> + + <div v-else-if="showChart" class="contributors-charts"> + <h4>{{ __('Commits to') }} {{ branch }}</h4> + <span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span> + <div> + <gl-area-chart + :data="masterChartData" + :option="masterChartOptions" + :height="masterChartHeight" + @created="onMasterChartCreated" + /> + </div> + + <div class="row"> + <div v-for="contributor in individualChartsData" :key="contributor.name" class="col-6"> + <h4>{{ contributor.name }}</h4> + <p>{{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }})</p> + <gl-area-chart + :data="contributor.dates" + :option="individualChartOptions" + :height="individualChartHeight" + @created="onIndividualChartCreated" + /> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/contributors/index.js b/app/assets/javascripts/contributors/index.js new file mode 100644 index 0000000000000000000000000000000000000000..b6063589734f39f8eb766993a8ce4122875ed50e --- /dev/null +++ b/app/assets/javascripts/contributors/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import ContributorsGraphs from './components/contributors.vue'; +import store from './stores'; + +export default () => { + const el = document.querySelector('.js-contributors-graph'); + + if (!el) return null; + + return new Vue({ + el, + store, + + render(createElement) { + return createElement(ContributorsGraphs, { + props: { + endpoint: el.dataset.projectGraphPath, + branch: el.dataset.projectBranch, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/contributors/services/contributors_service.js b/app/assets/javascripts/contributors/services/contributors_service.js new file mode 100644 index 0000000000000000000000000000000000000000..5a8bbb66511257c60cd59a80148fc2980db42c58 --- /dev/null +++ b/app/assets/javascripts/contributors/services/contributors_service.js @@ -0,0 +1,7 @@ +import axios from '~/lib/utils/axios_utils'; + +export default { + fetchChartData(endpoint) { + return axios.get(endpoint); + }, +}; diff --git a/app/assets/javascripts/contributors/stores/actions.js b/app/assets/javascripts/contributors/stores/actions.js new file mode 100644 index 0000000000000000000000000000000000000000..4138ff24f1d5f4ef2c344bd4d48b3672bbac327e --- /dev/null +++ b/app/assets/javascripts/contributors/stores/actions.js @@ -0,0 +1,20 @@ +import flash from '~/flash'; +import { __ } from '~/locale'; +import service from '../services/contributors_service'; +import * as types from './mutation_types'; + +export const fetchChartData = ({ commit }, endpoint) => { + commit(types.SET_LOADING_STATE, true); + + return service + .fetchChartData(endpoint) + .then(res => res.data) + .then(data => { + commit(types.SET_CHART_DATA, data); + commit(types.SET_LOADING_STATE, false); + }) + .catch(() => flash(__('An error occurred while loading chart data'))); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/contributors/stores/getters.js b/app/assets/javascripts/contributors/stores/getters.js new file mode 100644 index 0000000000000000000000000000000000000000..9e02e3ed9e75e3391a78c945814c76cc7a921282 --- /dev/null +++ b/app/assets/javascripts/contributors/stores/getters.js @@ -0,0 +1,33 @@ +export const showChart = state => Boolean(!state.loading && state.chartData); + +export const parsedData = state => { + const byAuthor = {}; + const total = {}; + + state.chartData.forEach(({ date, author_name, author_email }) => { + total[date] = total[date] ? total[date] + 1 : 1; + + const authorData = byAuthor[author_name]; + + if (!authorData) { + byAuthor[author_name] = { + email: author_email.toLowerCase(), + commits: 1, + dates: { + [date]: 1, + }, + }; + } else { + authorData.commits += 1; + authorData.dates[date] = authorData.dates[date] ? authorData.dates[date] + 1 : 1; + } + }); + + return { + total, + byAuthor, + }; +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/contributors/stores/index.js b/app/assets/javascripts/contributors/stores/index.js new file mode 100644 index 0000000000000000000000000000000000000000..bc739851aa787f6c2a2d5c4c224939f4517f0c80 --- /dev/null +++ b/app/assets/javascripts/contributors/stores/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import state from './state'; +import mutations from './mutations'; +import * as getters from './getters'; +import * as actions from './actions'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + actions, + mutations, + getters, + state: state(), + }); + +export default createStore(); diff --git a/app/assets/javascripts/contributors/stores/mutation_types.js b/app/assets/javascripts/contributors/stores/mutation_types.js new file mode 100644 index 0000000000000000000000000000000000000000..62e0a51d5f80e883acc73d8e16834337e415ebfa --- /dev/null +++ b/app/assets/javascripts/contributors/stores/mutation_types.js @@ -0,0 +1,3 @@ +export const SET_CHART_DATA = 'SET_CHART_DATA'; +export const SET_LOADING_STATE = 'SET_LOADING_STATE'; +export const SET_ACTIVE_BRANCH = 'SET_ACTIVE_BRANCH'; diff --git a/app/assets/javascripts/contributors/stores/mutations.js b/app/assets/javascripts/contributors/stores/mutations.js new file mode 100644 index 0000000000000000000000000000000000000000..f1f460d072d74f14b09027c4e43089e0f8841d7a --- /dev/null +++ b/app/assets/javascripts/contributors/stores/mutations.js @@ -0,0 +1,17 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_LOADING_STATE](state, value) { + state.loading = value; + }, + [types.SET_CHART_DATA](state, chartData) { + Object.assign(state, { + chartData, + }); + }, + [types.SET_ACTIVE_BRANCH](state, branch) { + Object.assign(state, { + branch, + }); + }, +}; diff --git a/app/assets/javascripts/contributors/stores/state.js b/app/assets/javascripts/contributors/stores/state.js new file mode 100644 index 0000000000000000000000000000000000000000..1dc1a3c7b75489a849da2ded713c26fe04730a89 --- /dev/null +++ b/app/assets/javascripts/contributors/stores/state.js @@ -0,0 +1,5 @@ +export default () => ({ + loading: false, + chartData: null, + branch: 'master', +}); diff --git a/app/assets/javascripts/contributors/utils.js b/app/assets/javascripts/contributors/utils.js new file mode 100644 index 0000000000000000000000000000000000000000..7d8932ce49546c8819b6f562876a08956d71e64d --- /dev/null +++ b/app/assets/javascripts/contributors/utils.js @@ -0,0 +1,30 @@ +import { getMonthNames } from '~/lib/utils/datetime_utility'; + +/** + * Converts provided string to date and returns formatted value as a year for date in January and month name for the rest + * @param {String} + * @returns {String} - formatted value + * + * xAxisLabelFormatter('01-12-2019') will return '2019' + * xAxisLabelFormatter('02-12-2019') will return 'Feb' + * xAxisLabelFormatter('07-12-2019') will return 'Jul' + */ +export const xAxisLabelFormatter = val => { + const date = new Date(val); + const month = date.getUTCMonth(); + const year = date.getUTCFullYear(); + return month === 0 ? `${year}` : getMonthNames(true)[month]; +}; + +/** + * Formats provided date to YYYY-MM-DD format + * @param {Date} + * @returns {String} - formatted value + */ +export const dateFormatter = date => { + const year = date.getUTCFullYear(); + const month = date.getUTCMonth(); + const day = date.getUTCDate(); + + return `${year}-${`0${month + 1}`.slice(-2)}-${`0${day}`.slice(-2)}`; +}; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue index 3c6da43c4c4e5964a1744786047fb055ac5da61f..e6893c14cdaed3c34cdcbe208bd5c67ed01fed10 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue @@ -2,14 +2,19 @@ import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue'; import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; +import { GlIcon } from '@gitlab/ui'; -const findItem = (items, valueProp, value) => items.find(item => item[valueProp] === value); +const toArray = value => [].concat(value); +const itemsProp = (items, prop) => items.map(item => item[prop]); +const defaultSearchFn = (searchQuery, labelProp) => item => + item[labelProp].toLowerCase().indexOf(searchQuery) > -1; export default { components: { DropdownButton, DropdownSearchInput, DropdownHiddenInput, + GlIcon, }, props: { fieldName: { @@ -28,7 +33,7 @@ export default { default: '', }, value: { - type: [Object, String], + type: [Object, Array, String], required: false, default: () => null, }, @@ -72,6 +77,11 @@ export default { required: false, default: false, }, + multiple: { + type: Boolean, + required: false, + default: false, + }, errorMessage: { type: String, required: false, @@ -90,12 +100,11 @@ export default { searchFn: { type: Function, required: false, - default: searchQuery => item => item.name.toLowerCase().indexOf(searchQuery) > -1, + default: defaultSearchFn, }, }, data() { return { - selectedItem: findItem(this.items, this.value), searchQuery: '', }; }, @@ -109,36 +118,52 @@ export default { return this.disabledText; } - if (!this.selectedItem) { + if (!this.selectedItems.length) { return this.placeholder; } - return this.selectedItemLabel; + return this.selectedItemsLabels; }, results() { - if (!this.items) { - return []; - } - - return this.items.filter(this.searchFn(this.searchQuery)); + return this.getItemsOrEmptyList().filter(this.searchFn(this.searchQuery, this.labelProperty)); }, - selectedItemLabel() { - return this.selectedItem && this.selectedItem[this.labelProperty]; + selectedItems() { + const valueProp = this.valueProperty; + const valueList = toArray(this.value); + const items = this.getItemsOrEmptyList(); + + return items.filter(item => valueList.some(value => item[valueProp] === value)); }, - selectedItemValue() { - return (this.selectedItem && this.selectedItem[this.valueProperty]) || ''; + selectedItemsLabels() { + return itemsProp(this.selectedItems, this.labelProperty).join(', '); }, - }, - watch: { - value(value) { - this.selectedItem = findItem(this.items, this.valueProperty, value); + selectedItemsValues() { + return itemsProp(this.selectedItems, this.valueProperty).join(', '); }, }, methods: { - select(item) { - this.selectedItem = item; + getItemsOrEmptyList() { + return this.items || []; + }, + selectSingle(item) { this.$emit('input', item[this.valueProperty]); }, + selectMultiple(item) { + const value = toArray(this.value); + const itemValue = item[this.valueProperty]; + const itemValueIndex = value.indexOf(itemValue); + + if (itemValueIndex > -1) { + value.splice(itemValueIndex, 1); + } else { + value.push(itemValue); + } + + this.$emit('input', value); + }, + isSelected(item) { + return this.selectedItems.includes(item); + }, }, }; </script> @@ -146,7 +171,7 @@ export default { <template> <div> <div class="js-gcp-machine-type-dropdown dropdown"> - <dropdown-hidden-input :name="fieldName" :value="selectedItemValue" /> + <dropdown-hidden-input :name="fieldName" :value="selectedItemsValues" /> <dropdown-button :class="{ 'border-danger': hasErrors }" :is-disabled="disabled" @@ -158,15 +183,28 @@ export default { <div class="dropdown-content"> <ul> <li v-if="!results.length"> - <span class="js-empty-text menu-item"> - {{ emptyText }} - </span> + <span class="js-empty-text menu-item">{{ emptyText }}</span> </li> <li v-for="item in results" :key="item.id"> - <button class="js-dropdown-item" type="button" @click.prevent="select(item)"> - <slot name="item" :item="item"> - {{ item.name }} - </slot> + <button + v-if="multiple" + class="js-dropdown-item d-flex align-items-center" + type="button" + @click.stop.prevent="selectMultiple(item)" + > + <gl-icon + :class="[{ invisible: !isSelected(item) }, 'mr-1']" + name="mobile-issue-close" + /> + <slot name="item" :item="item">{{ item.name }}</slot> + </button> + <button + v-else + class="js-dropdown-item" + type="button" + @click.prevent="selectSingle(item)" + > + <slot name="item" :item="item">{{ item.name }}</slot> </button> </li> </ul> @@ -182,8 +220,7 @@ export default { 'text-muted': !hasErrors, }, ]" + >{{ errorMessage }}</span > - {{ errorMessage }} - </span> </div> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue index 22ee368b8e06b89f44a0f3b91db53bf6db4e3d51..3f7c2204b9f31cadc579e45735669c2bb2ebe173 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue @@ -1,4 +1,5 @@ <script> +import { mapState } from 'vuex'; import ServiceCredentialsForm from './service_credentials_form.vue'; import EksClusterConfigurationForm from './eks_cluster_configuration_form.vue'; @@ -16,14 +17,37 @@ export default { type: String, required: true, }, + accountAndExternalIdsHelpPath: { + type: String, + required: true, + }, + createRoleArnHelpPath: { + type: String, + required: true, + }, + externalLinkIcon: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['hasCredentials']), }, }; </script> <template> <div class="js-create-eks-cluster"> <eks-cluster-configuration-form + v-if="hasCredentials" :gitlab-managed-cluster-help-path="gitlabManagedClusterHelpPath" :kubernetes-integration-help-path="kubernetesIntegrationHelpPath" + :external-link-icon="externalLinkIcon" + /> + <service-credentials-form + v-else + :create-role-arn-help-path="createRoleArnHelpPath" + :account-and-external-ids-help-path="accountAndExternalIdsHelpPath" + :external-link-icon="externalLinkIcon" /> </div> </template> 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 1188cf088505206a7b24d629f101ba22c2ab1a40..57d5f4f541b088f121539c1349081b92871daa49 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 @@ -4,8 +4,8 @@ import { sprintf, s__ } from '~/locale'; import _ from 'underscore'; import { GlFormInput, GlFormCheckbox } from '@gitlab/ui'; import ClusterFormDropdown from './cluster_form_dropdown.vue'; -import RegionDropdown from './region_dropdown.vue'; import { KUBERNETES_VERSIONS } from '../constants'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; const { mapState: mapRolesState, mapActions: mapRolesActions } = createNamespacedHelpers('roles'); const { mapState: mapRegionsState, mapActions: mapRegionsActions } = createNamespacedHelpers( @@ -22,13 +22,17 @@ const { mapState: mapSecurityGroupsState, mapActions: mapSecurityGroupsActions, } = createNamespacedHelpers('securityGroups'); +const { + mapState: mapInstanceTypesState, + mapActions: mapInstanceTypesActions, +} = createNamespacedHelpers('instanceTypes'); export default { components: { ClusterFormDropdown, - RegionDropdown, GlFormInput, GlFormCheckbox, + LoadingButton, }, props: { gitlabManagedClusterHelpPath: { @@ -39,6 +43,10 @@ export default { type: String, required: true, }, + externalLinkIcon: { + type: String, + required: true, + }, }, computed: { ...mapState([ @@ -51,7 +59,10 @@ export default { 'selectedSubnet', 'selectedRole', 'selectedSecurityGroup', + 'selectedInstanceType', + 'nodeCount', 'gitlabManagedCluster', + 'isCreatingCluster', ]), ...mapRolesState({ roles: 'items', @@ -83,6 +94,11 @@ export default { isLoadingSecurityGroups: 'isLoadingItems', loadingSecurityGroupsError: 'loadingItemsError', }), + ...mapInstanceTypesState({ + instanceTypes: 'items', + isLoadingInstanceTypes: 'isLoadingItems', + loadingInstanceTypesError: 'loadingItemsError', + }), kubernetesVersions() { return KUBERNETES_VERSIONS; }, @@ -98,6 +114,27 @@ export default { securityGroupDropdownDisabled() { return !this.selectedVpc; }, + createClusterButtonDisabled() { + return ( + !this.clusterName || + !this.environmentScope || + !this.kubernetesVersion || + !this.selectedRegion || + !this.selectedKeyPair || + !this.selectedVpc || + !this.selectedSubnet || + !this.selectedRole || + !this.selectedSecurityGroup || + !this.selectedInstanceType || + !this.nodeCount || + this.isCreatingCluster + ); + }, + createClusterButtonLabel() { + return this.isCreatingCluster + ? s__('ClusterIntegration|Creating Kubernetes cluster') + : s__('ClusterIntegration|Create Kubernetes cluster'); + }, kubernetesIntegrationHelpText() { const escapedUrl = _.escape(this.kubernetesIntegrationHelpPath); @@ -115,11 +152,26 @@ 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%{endLink}.', + '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}.', + ), + { + startLink: + '<a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#role-create" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, + endLink: '</a>', + }, + false, + ); + }, + regionsDropdownHelpText() { + return sprintf( + s__( + 'ClusterIntegration|Learn more about %{startLink}Regions %{externalLinkIcon}%{endLink}.', ), { startLink: - '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">', + '<a href="https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -128,11 +180,12 @@ export default { keyPairDropdownHelpText() { return sprintf( s__( - '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%{endLink}.', + '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}.', ), { startLink: '<a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html#having-ec2-create-your-key-pair" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -141,11 +194,12 @@ export default { vpcDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{startLink}Amazon Web Services%{endLink}.', + '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}.', ), { startLink: - '<a href="https://console.aws.amazon.com/vpc/home?#vpc" target="_blank" rel="noopener noreferrer">', + '<a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#vpc-create" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -154,11 +208,12 @@ export default { subnetDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Choose the %{startLink}subnets%{endLink} in your VPC where your worker nodes will run.', + 'ClusterIntegration|Choose the %{startLink}subnets %{externalLinkIcon} %{endLink} in your VPC where your worker nodes will run.', ), { startLink: '<a href="https://console.aws.amazon.com/vpc/home?#subnets" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -167,11 +222,26 @@ export default { securityGroupDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Choose the %{startLink}security groups%{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.', + 'ClusterIntegration|Choose the %{startLink}security group %{externalLinkIcon} %{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.', ), { startLink: '<a href="https://console.aws.amazon.com/vpc/home?#securityGroups" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, + endLink: '</a>', + }, + false, + ); + }, + instanceTypesDropdownHelpText() { + return sprintf( + s__( + 'ClusterIntegration|Choose the worker node %{startLink}instance type %{externalLinkIcon} %{endLink}.', + ), + { + startLink: + '<a href="https://aws.amazon.com/ec2/instance-types" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -195,9 +265,12 @@ export default { mounted() { this.fetchRegions(); this.fetchRoles(); + this.fetchInstanceTypes(); }, methods: { ...mapActions([ + 'createCluster', + 'signOut', 'setClusterName', 'setEnvironmentScope', 'setKubernetesVersion', @@ -207,6 +280,8 @@ export default { 'setRole', 'setKeyPair', 'setSecurityGroup', + 'setInstanceType', + 'setNodeCount', 'setGitlabManagedCluster', ]), ...mapRegionsActions({ fetchRegions: 'fetchItems' }), @@ -215,15 +290,22 @@ export default { ...mapRolesActions({ fetchRoles: 'fetchItems' }), ...mapKeyPairsActions({ fetchKeyPairs: 'fetchItems' }), ...mapSecurityGroupsActions({ fetchSecurityGroups: 'fetchItems' }), + ...mapInstanceTypesActions({ fetchInstanceTypes: 'fetchItems' }), setRegionAndFetchVpcsAndKeyPairs(region) { this.setRegion({ region }); + this.setVpc({ vpc: null }); + this.setKeyPair({ keyPair: null }); + this.setSubnet({ subnet: null }); + this.setSecurityGroup({ securityGroup: null }); this.fetchVpcs({ region }); this.fetchKeyPairs({ region }); }, setVpcAndFetchSubnets(vpc) { this.setVpc({ vpc }); - this.fetchSubnets({ vpc }); - this.fetchSecurityGroups({ vpc }); + this.setSubnet({ subnet: null }); + this.setSecurityGroup({ securityGroup: null }); + this.fetchSubnets({ vpc, region: this.selectedRegion }); + this.fetchSecurityGroups({ vpc, region: this.selectedRegion }); }, }, }; @@ -233,7 +315,12 @@ export default { <h2> {{ s__('ClusterIntegration|Enter the details for your Amazon EKS Kubernetes cluster') }} </h2> - <p v-html="kubernetesIntegrationHelpText"></p> + <div class="mb-3" v-html="kubernetesIntegrationHelpText"></div> + <div class="mb-3"> + <button class="btn btn-link js-sign-out" @click.prevent="signOut()"> + {{ s__('ClusterIntegration|Select a different AWS role') }} + </button> + </div> <div class="form-group"> <label class="label-bold" for="eks-cluster-name">{{ s__('ClusterIntegration|Kubernetes cluster name') @@ -273,7 +360,7 @@ export default { <cluster-form-dropdown field-id="eks-role" field-name="eks-role" - :input="selectedRole" + :value="selectedRole" :items="roles" :loading="isLoadingRoles" :loading-text="s__('ClusterIntegration|Loading IAM Roles')" @@ -288,13 +375,21 @@ export default { </div> <div class="form-group"> <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Region') }}</label> - <region-dropdown + <cluster-form-dropdown + field-id="eks-region" + field-name="eks-region" :value="selectedRegion" - :regions="regions" - :error="loadingRegionsError" + :items="regions" :loading="isLoadingRegions" + :loading-text="s__('ClusterIntegration|Loading Regions')" + :placeholder="s__('ClusterIntergation|Select a region')" + :search-field-placeholder="s__('ClusterIntegration|Search regions')" + :empty-text="s__('ClusterIntegration|No region found')" + :has-errors="Boolean(loadingRegionsError)" + :error-message="s__('ClusterIntegration|Could not load regions from your AWS account')" @input="setRegionAndFetchVpcsAndKeyPairs($event)" /> + <p class="form-text text-muted" v-html="regionsDropdownHelpText"></p> </div> <div class="form-group"> <label class="label-bold" for="eks-key-pair">{{ @@ -303,7 +398,7 @@ export default { <cluster-form-dropdown field-id="eks-key-pair" field-name="eks-key-pair" - :input="selectedKeyPair" + :value="selectedKeyPair" :items="keyPairs" :disabled="keyPairDropdownDisabled" :disabled-text="s__('ClusterIntegration|Select a region to choose a Key Pair')" @@ -323,7 +418,7 @@ export default { <cluster-form-dropdown field-id="eks-vpc" field-name="eks-vpc" - :input="selectedVpc" + :value="selectedVpc" :items="vpcs" :loading="isLoadingVpcs" :disabled="vpcDropdownDisabled" @@ -339,11 +434,12 @@ export default { <p class="form-text text-muted" v-html="vpcDropdownHelpText"></p> </div> <div class="form-group"> - <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Subnet') }}</label> + <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Subnets') }}</label> <cluster-form-dropdown field-id="eks-subnet" field-name="eks-subnet" - :input="selectedSubnet" + multiple + :value="selectedSubnet" :items="subnets" :loading="isLoadingSubnets" :disabled="subnetDropdownDisabled" @@ -360,12 +456,12 @@ export default { </div> <div class="form-group"> <label class="label-bold" for="eks-security-group">{{ - s__('ClusterIntegration|Security groups') + s__('ClusterIntegration|Security group') }}</label> <cluster-form-dropdown field-id="eks-security-group" field-name="eks-security-group" - :input="selectedSecurityGroup" + :value="selectedSecurityGroup" :items="securityGroups" :loading="isLoadingSecurityGroups" :disabled="securityGroupDropdownDisabled" @@ -382,6 +478,39 @@ export default { /> <p class="form-text text-muted" v-html="securityGroupDropdownHelpText"></p> </div> + <div class="form-group"> + <label class="label-bold" for="eks-instance-type">{{ + s__('ClusterIntegration|Instance type') + }}</label> + <cluster-form-dropdown + field-id="eks-instance-type" + field-name="eks-instance-type" + :value="selectedInstanceType" + :items="instanceTypes" + :loading="isLoadingInstanceTypes" + :loading-text="s__('ClusterIntegration|Loading instance types')" + :placeholder="s__('ClusterIntergation|Select an instance type')" + :search-field-placeholder="s__('ClusterIntegration|Search instance types')" + :empty-text="s__('ClusterIntegration|No instance type found')" + :has-errors="Boolean(loadingInstanceTypesError)" + :error-message="s__('ClusterIntegration|Could not load instance types')" + @input="setInstanceType({ instanceType: $event })" + /> + <p class="form-text text-muted" v-html="instanceTypesDropdownHelpText"></p> + </div> + <div class="form-group"> + <label class="label-bold" for="eks-node-count">{{ + s__('ClusterIntegration|Number of nodes') + }}</label> + <gl-form-input + id="eks-node-count" + type="number" + min="1" + step="1" + :value="nodeCount" + @input="setNodeCount({ nodeCount: $event })" + /> + </div> <div class="form-group"> <gl-form-checkbox :checked="gitlabManagedCluster" @@ -390,5 +519,14 @@ export default { > <p class="form-text text-muted" v-html="gitlabManagedHelpText"></p> </div> + <div class="form-group"> + <loading-button + class="js-create-cluster btn-success" + :disabled="createClusterButtonDisabled" + :loading="isCreatingCluster" + :label="createClusterButtonLabel" + @click="createCluster()" + /> + </div> </form> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue deleted file mode 100644 index 765955305c8a9d54084b0893bbe738935e770b42..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue +++ /dev/null @@ -1,63 +0,0 @@ -<script> -import { sprintf, s__ } from '~/locale'; - -import ClusterFormDropdown from './cluster_form_dropdown.vue'; - -export default { - components: { - ClusterFormDropdown, - }, - props: { - regions: { - type: Array, - required: false, - default: () => [], - }, - loading: { - type: Boolean, - required: false, - default: false, - }, - error: { - type: Object, - required: false, - default: null, - }, - }, - computed: { - hasErrors() { - return Boolean(this.error); - }, - helpText() { - return sprintf( - s__('ClusterIntegration|Learn more about %{startLink}Regions%{endLink}.'), - { - startLink: - '<a href="https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/" target="_blank" rel="noopener noreferrer">', - endLink: '</a>', - }, - false, - ); - }, - }, -}; -</script> -<template> - <div> - <cluster-form-dropdown - field-id="eks-region" - field-name="eks-region" - :items="regions" - :loading="loading" - :loading-text="s__('ClusterIntegration|Loading Regions')" - :placeholder="s__('ClusterIntergation|Select a region')" - :search-field-placeholder="s__('ClusterIntegration|Search regions')" - :empty-text="s__('ClusterIntegration|No region found')" - :has-errors="hasErrors" - :error-message="s__('ClusterIntegration|Could not load regions from your AWS account')" - v-bind="$attrs" - v-on="$listeners" - /> - <p class="form-text text-muted" v-html="helpText"></p> - </div> -</template> 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 79029b8cfa80b1f5b6cd380f33afe0c62ea728cf..ab33e9fbc95ce27bf0f3df0b594b2d4e410c912f 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 @@ -1,3 +1,141 @@ +<script> +import { GlFormInput } from '@gitlab/ui'; +import { sprintf, s__, __ } from '~/locale'; +import _ from 'underscore'; +import { mapState, mapActions } from 'vuex'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; + +export default { + components: { + GlFormInput, + LoadingButton, + ClipboardButton, + }, + props: { + accountAndExternalIdsHelpPath: { + type: String, + required: true, + }, + createRoleArnHelpPath: { + type: String, + required: true, + }, + externalLinkIcon: { + type: String, + required: true, + }, + }, + data() { + return { + roleArn: '', + }; + }, + computed: { + ...mapState(['accountId', 'externalId', 'isCreatingRole', 'createRoleError']), + submitButtonDisabled() { + return this.isCreatingRole || !this.roleArn; + }, + submitButtonLabel() { + return this.isCreatingRole + ? __('Authenticating') + : s__('ClusterIntegration|Authenticate with AWS'); + }, + accountAndExternalIdsHelpText() { + const escapedUrl = _.escape(this.accountAndExternalIdsHelpPath); + + return sprintf( + s__( + 'ClusterIntegration|Create a provision role on %{startAwsLink}Amazon Web Services %{externalLinkIcon}%{endLink} using the account and external ID above. %{startMoreInfoLink}More information%{endLink}', + ), + { + startAwsLink: + '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">', + startMoreInfoLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`, + externalLinkIcon: this.externalLinkIcon, + endLink: '</a>', + }, + false, + ); + }, + provisionRoleArnHelpText() { + const escapedUrl = _.escape(this.createRoleArnHelpPath); + + return sprintf( + s__( + 'ClusterIntegration|The Amazon Resource Name (ARN) associated with your role. If you do not have a provision role, first create one on %{startAwsLink}Amazon Web Services %{externalLinkIcon}%{endLink} using the above account and external IDs. %{startMoreInfoLink}More information%{endLink}', + ), + { + startAwsLink: + '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">', + startMoreInfoLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`, + externalLinkIcon: this.externalLinkIcon, + endLink: '</a>', + }, + false, + ); + }, + }, + methods: { + ...mapActions(['createRole']), + }, +}; +</script> <template> - <form name="service-credentials-form"></form> + <form name="service-credentials-form" @submit.prevent="createRole({ roleArn, externalId })"> + <h2>{{ s__('ClusterIntegration|Authenticate with Amazon Web Services') }}</h2> + <p> + {{ + s__( + 'ClusterIntegration|You must grant access to your organization’s AWS resources in order to create a new EKS cluster. To grant access, create a provision role using the account and external ID below and provide us the ARN.', + ) + }} + </p> + <div v-if="createRoleError" class="js-invalid-credentials bs-callout bs-callout-danger"> + {{ createRoleError }} + </div> + <div class="form-row"> + <div class="form-group col-md-6"> + <label for="gitlab-account-id">{{ __('Account ID') }}</label> + <div class="input-group"> + <gl-form-input id="gitlab-account-id" type="text" readonly :value="accountId" /> + <div class="input-group-append"> + <clipboard-button + :text="accountId" + :title="__('Copy Account ID to clipboard')" + class="input-group-text js-copy-account-id-button" + /> + </div> + </div> + </div> + <div class="form-group col-md-6"> + <label for="eks-external-id">{{ __('External ID') }}</label> + <div class="input-group"> + <gl-form-input id="eks-external-id" type="text" readonly :value="externalId" /> + <div class="input-group-append"> + <clipboard-button + :text="externalId" + :title="__('Copy External ID to clipboard')" + class="input-group-text js-copy-external-id-button" + /> + </div> + </div> + </div> + <div class="col-12 mb-3 mt-n3"> + <p class="form-text text-muted" v-html="accountAndExternalIdsHelpText"></p> + </div> + </div> + <div class="form-group"> + <label for="eks-provision-role-arn">{{ s__('ClusterIntegration|Provision Role ARN') }}</label> + <gl-form-input id="eks-provision-role-arn" v-model="roleArn" /> + <p class="form-text text-muted" v-html="provisionRoleArnHelpText"></p> + </div> + <loading-button + class="js-submit-service-credentials btn-success" + type="submit" + :disabled="submitButtonDisabled" + :loading="isCreatingRole" + :label="submitButtonLabel" + /> + </form> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/constants.js b/app/assets/javascripts/create_cluster/eks_cluster/constants.js index 339642f991edcf0404daf869cf933630ed391529..a850ba89818767a0ee352cabc9e8ff6ae8e7961f 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/constants.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/constants.js @@ -1,7 +1,2 @@ // eslint-disable-next-line import/prefer-default-export -export const KUBERNETES_VERSIONS = [ - { name: '1.14', value: '1.14' }, - { name: '1.13', value: '1.13' }, - { name: '1.12', value: '1.12' }, - { name: '1.11', value: '1.11' }, -]; +export const KUBERNETES_VERSIONS = [{ name: '1.14', value: '1.14' }]; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/index.js b/app/assets/javascripts/create_cluster/eks_cluster/index.js index 1f595e9b2dfd8c8d5863ee8d7a438d682ff9adfa..27f859d89729521956fb4178391f1e8323d76448 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/index.js @@ -1,16 +1,54 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import { parseBoolean } from '~/lib/utils/common_utils'; import CreateEksCluster from './components/create_eks_cluster.vue'; import createStore from './store'; Vue.use(Vuex); export default el => { - const { gitlabManagedClusterHelpPath, kubernetesIntegrationHelpPath } = el.dataset; + const { + gitlabManagedClusterHelpPath, + kubernetesIntegrationHelpPath, + accountAndExternalIdsHelpPath, + createRoleArnHelpPath, + getRolesPath, + getRegionsPath, + getKeyPairsPath, + getVpcsPath, + getSubnetsPath, + getSecurityGroupsPath, + getInstanceTypesPath, + externalId, + accountId, + hasCredentials, + createRolePath, + createClusterPath, + signOutPath, + externalLinkIcon, + } = el.dataset; return new Vue({ el, - store: createStore(), + store: createStore({ + initialState: { + hasCredentials: parseBoolean(hasCredentials), + externalId, + accountId, + createRolePath, + createClusterPath, + signOutPath, + }, + apiPaths: { + getRolesPath, + getRegionsPath, + getKeyPairsPath, + getVpcsPath, + getSubnetsPath, + getSecurityGroupsPath, + getInstanceTypesPath, + }, + }), components: { CreateEksCluster, }, @@ -19,6 +57,9 @@ export default el => { props: { gitlabManagedClusterHelpPath, kubernetesIntegrationHelpPath, + accountAndExternalIdsHelpPath, + createRoleArnHelpPath, + externalLinkIcon, }, }); }, diff --git a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js index d982e4db4c16bcb3c0d7b9b48f7078f2cb948b09..21b87d525cf6ee5c953c949a0a63890ba5fa6a15 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js @@ -1,84 +1,58 @@ -import EC2 from 'aws-sdk/clients/ec2'; -import IAM from 'aws-sdk/clients/iam'; - -export const fetchRoles = () => { - const iam = new IAM(); - - return iam - .listRoles() - .promise() - .then(({ Roles: roles }) => roles.map(({ RoleName: name }) => ({ name }))); -}; - -export const fetchKeyPairs = () => { - const ec2 = new EC2(); - - return ec2 - .describeKeyPairs() - .promise() - .then(({ KeyPairs: keyPairs }) => keyPairs.map(({ RegionName: name }) => ({ name }))); -}; - -export const fetchRegions = () => { - const ec2 = new EC2(); - - return ec2 - .describeRegions() - .promise() - .then(({ Regions: regions }) => - regions.map(({ RegionName: name }) => ({ - name, - value: name, +import axios from '~/lib/utils/axios_utils'; + +export default apiPaths => ({ + fetchRoles() { + return axios + .get(apiPaths.getRolesPath) + .then(({ data: { roles } }) => + roles.map(({ role_name: name, arn: value }) => ({ name, value })), + ); + }, + fetchKeyPairs({ region }) { + return axios + .get(apiPaths.getKeyPairsPath, { params: { region } }) + .then(({ data: { key_pairs: keyPairs } }) => + keyPairs.map(({ key_name }) => ({ name: key_name, value: key_name })), + ); + }, + fetchRegions() { + return axios.get(apiPaths.getRegionsPath).then(({ data: { regions } }) => + regions.map(({ region_name }) => ({ + name: region_name, + value: region_name, })), ); -}; - -export const fetchVpcs = () => { - const ec2 = new EC2(); - - return ec2 - .describeVpcs() - .promise() - .then(({ Vpcs: vpcs }) => - vpcs.map(({ VpcId: id }) => ({ - value: id, - name: id, + }, + fetchVpcs({ region }) { + return axios.get(apiPaths.getVpcsPath, { params: { region } }).then(({ data: { vpcs } }) => + vpcs.map(({ vpc_id }) => ({ + value: vpc_id, + name: vpc_id, })), ); -}; - -export const fetchSubnets = ({ vpc }) => { - const ec2 = new EC2(); - - return ec2 - .describeSubnets({ - Filters: [ - { - Name: 'vpc-id', - Values: [vpc], - }, - ], - }) - .promise() - .then(({ Subnets: subnets }) => subnets.map(({ SubnetId: id }) => ({ id, name: id }))); -}; - -export const fetchSecurityGroups = ({ vpc }) => { - const ec2 = new EC2(); - - return ec2 - .describeSecurityGroups({ - Filters: [ - { - Name: 'vpc-id', - Values: [vpc], - }, - ], - }) - .promise() - .then(({ SecurityGroups: securityGroups }) => - securityGroups.map(({ GroupName: name, GroupId: value }) => ({ name, value })), - ); -}; - -export default () => {}; + }, + fetchSubnets({ vpc, region }) { + return axios + .get(apiPaths.getSubnetsPath, { params: { vpc_id: vpc, region } }) + .then(({ data: { subnets } }) => + subnets.map(({ subnet_id }) => ({ name: subnet_id, value: subnet_id })), + ); + }, + fetchSecurityGroups({ vpc, region }) { + return axios + .get(apiPaths.getSecurityGroupsPath, { params: { vpc_id: vpc, region } }) + .then(({ data: { security_groups: securityGroups } }) => + securityGroups.map(({ group_name: name, group_id: value }) => ({ name, value })), + ); + }, + fetchInstanceTypes() { + return axios + .get(apiPaths.getInstanceTypesPath) + .then(({ data: { instance_types: instanceTypes } }) => + instanceTypes.map(({ instance_type_name }) => ({ + name: instance_type_name, + value: instance_type_name, + })), + ); + }, +}); diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js index 917c8da6c3e8ae2cba9c3ad7e120241c1ce8dc0b..72f15263a8f83865d42c674cd551b744b4c9e3aa 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js @@ -1,4 +1,12 @@ import * as types from './mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; + +const getErrorMessage = data => { + const errorKey = Object.keys(data)[0]; + + return data[errorKey][0]; +}; export const setClusterName = ({ commit }, payload) => { commit(types.SET_CLUSTER_NAME, payload); @@ -12,6 +20,68 @@ export const setKubernetesVersion = ({ commit }, payload) => { commit(types.SET_KUBERNETES_VERSION, payload); }; +export const createRole = ({ dispatch, state: { createRolePath } }, payload) => { + dispatch('requestCreateRole'); + + return axios + .post(createRolePath, { + role_arn: payload.roleArn, + role_external_id: payload.externalId, + }) + .then(() => dispatch('createRoleSuccess')) + .catch(error => dispatch('createRoleError', { error })); +}; + +export const requestCreateRole = ({ commit }) => { + commit(types.REQUEST_CREATE_ROLE); +}; + +export const createRoleSuccess = ({ commit }) => { + commit(types.CREATE_ROLE_SUCCESS); +}; + +export const createRoleError = ({ commit }, payload) => { + commit(types.CREATE_ROLE_ERROR, payload); +}; + +export const createCluster = ({ dispatch, state }) => { + dispatch('requestCreateCluster'); + + return axios + .post(state.createClusterPath, { + name: state.clusterName, + environment_scope: state.environmentScope, + managed: state.gitlabManagedCluster, + provider_aws_attributes: { + region: state.selectedRegion, + vpc_id: state.selectedVpc, + subnet_ids: state.selectedSubnet, + role_arn: state.selectedRole, + key_name: state.selectedKeyPair, + security_group_id: state.selectedSecurityGroup, + instance_type: state.selectedInstanceType, + num_nodes: state.nodeCount, + }, + }) + .then(({ headers: { location } }) => dispatch('createClusterSuccess', location)) + .catch(({ response: { data } }) => { + dispatch('createClusterError', data); + }); +}; + +export const requestCreateCluster = ({ commit }) => { + commit(types.REQUEST_CREATE_CLUSTER); +}; + +export const createClusterSuccess = (_, location) => { + window.location.assign(location); +}; + +export const createClusterError = ({ commit }, error) => { + commit(types.CREATE_CLUSTER_ERROR, error); + createFlash(getErrorMessage(error)); +}; + export const setRegion = ({ commit }, payload) => { commit(types.SET_REGION, payload); }; @@ -40,4 +110,16 @@ export const setGitlabManagedCluster = ({ commit }, payload) => { commit(types.SET_GITLAB_MANAGED_CLUSTER, payload); }; -export default () => {}; +export const setInstanceType = ({ commit }, payload) => { + commit(types.SET_INSTANCE_TYPE, payload); +}; + +export const setNodeCount = ({ commit }, payload) => { + commit(types.SET_NODE_COUNT, payload); +}; + +export const signOut = ({ commit, state: { signOutPath } }) => + axios + .delete(signOutPath) + .then(() => commit(types.SIGN_OUT)) + .catch(({ response: { data } }) => createFlash(getErrorMessage(data))); 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 d575deafd196fc084af73b463fe675e895f3a8af..5982fc8a2fd85aee612fce068cd95eff1e017bcb 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js @@ -6,14 +6,16 @@ import state from './state'; import clusterDropdownStore from './cluster_dropdown'; -import * as awsServices from '../services/aws_services_facade'; +import awsServicesFactory from '../services/aws_services_facade'; -const createStore = () => - new Vuex.Store({ +const createStore = ({ initialState, apiPaths }) => { + const awsServices = awsServicesFactory(apiPaths); + + return new Vuex.Store({ actions, getters, mutations, - state: state(), + state: Object.assign(state(), initialState), modules: { roles: { namespaced: true, @@ -39,7 +41,12 @@ const createStore = () => namespaced: true, ...clusterDropdownStore(awsServices.fetchSecurityGroups), }, + instanceTypes: { + namespaced: true, + ...clusterDropdownStore(awsServices.fetchInstanceTypes), + }, }, }); +}; export default createStore; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js index 82eb512ac079dacc2eacce4b887f6d8427f1af46..f9204cc22071616d253da384edc4452c9f8dece3 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js @@ -7,4 +7,13 @@ export const SET_KEY_PAIR = 'SET_KEY_PAIR'; export const SET_SUBNET = 'SET_SUBNET'; export const SET_ROLE = 'SET_ROLE'; export const SET_SECURITY_GROUP = 'SET_SECURITY_GROUP'; +export const SET_INSTANCE_TYPE = 'SET_INSTANCE_TYPE'; +export const SET_NODE_COUNT = 'SET_NODE_COUNT'; export const SET_GITLAB_MANAGED_CLUSTER = 'SET_GITLAB_MANAGED_CLUSTER'; +export const REQUEST_CREATE_ROLE = 'REQUEST_CREATE_ROLE'; +export const CREATE_ROLE_SUCCESS = 'CREATE_ROLE_SUCCESS'; +export const CREATE_ROLE_ERROR = 'CREATE_ROLE_ERROR'; +export const SIGN_OUT = 'SIGN_OUT'; +export const REQUEST_CREATE_CLUSTER = 'REQUEST_CREATE_CLUSTER'; +export const CREATE_CLUSTER_SUCCESS = 'CREATE_CLUSTER_SUCCESS'; +export const CREATE_CLUSTER_ERROR = 'CREATE_CLUSTER_ERROR'; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js index 79950ac7dce2b6ceaa4f870ccfd4171cef97e778..aa04c8f707954817bf0eccf4acc3abc538b2ec90 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js @@ -28,7 +28,39 @@ export default { [types.SET_SECURITY_GROUP](state, { securityGroup }) { state.selectedSecurityGroup = securityGroup; }, + [types.SET_INSTANCE_TYPE](state, { instanceType }) { + state.selectedInstanceType = instanceType; + }, + [types.SET_NODE_COUNT](state, { nodeCount }) { + state.nodeCount = nodeCount; + }, [types.SET_GITLAB_MANAGED_CLUSTER](state, { gitlabManagedCluster }) { state.gitlabManagedCluster = gitlabManagedCluster; }, + [types.REQUEST_CREATE_ROLE](state) { + state.isCreatingRole = true; + state.createRoleError = null; + state.hasCredentials = false; + }, + [types.CREATE_ROLE_SUCCESS](state) { + state.isCreatingRole = false; + state.createRoleError = null; + state.hasCredentials = true; + }, + [types.CREATE_ROLE_ERROR](state, { error }) { + state.isCreatingRole = false; + state.createRoleError = error; + state.hasCredentials = false; + }, + [types.REQUEST_CREATE_CLUSTER](state) { + state.isCreatingCluster = true; + state.createClusterError = null; + }, + [types.CREATE_CLUSTER_ERROR](state, { error }) { + state.isCreatingCluster = false; + state.createClusterError = error; + }, + [types.SIGN_OUT](state) { + state.hasCredentials = false; + }, }; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js index bf74213bdcedc27d9c53f7956212bbe5aead4afb..2e3a05a9187c6a96616476a596a513bc7934760a 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js @@ -1,18 +1,31 @@ import { KUBERNETES_VERSIONS } from '../constants'; +const [{ value: kubernetesVersion }] = KUBERNETES_VERSIONS; + export default () => ({ - isValidatingCredentials: false, - validCredentials: false, + createRolePath: null, + + isCreatingRole: false, + roleCreated: false, + createRoleError: false, + + accountId: '', + externalId: '', clusterName: '', environmentScope: '*', - kubernetesVersion: [KUBERNETES_VERSIONS].value, + kubernetesVersion, selectedRegion: '', selectedRole: '', selectedKeyPair: '', selectedVpc: '', selectedSubnet: '', selectedSecurityGroup: '', + selectedInstanceType: 'm5.large', + nodeCount: '3', + + isCreatingCluster: false, + createClusterError: false, gitlabManagedCluster: true, }); diff --git a/app/assets/javascripts/projects/gke_cluster_namespace/index.js b/app/assets/javascripts/create_cluster/gke_cluster_namespace/index.js similarity index 100% rename from app/assets/javascripts/projects/gke_cluster_namespace/index.js rename to app/assets/javascripts/create_cluster/gke_cluster_namespace/index.js diff --git a/app/assets/javascripts/create_cluster/init_create_cluster.js b/app/assets/javascripts/create_cluster/init_create_cluster.js new file mode 100644 index 0000000000000000000000000000000000000000..7c984582fd876948e12dd11b45482fbede166a79 --- /dev/null +++ b/app/assets/javascripts/create_cluster/init_create_cluster.js @@ -0,0 +1,37 @@ +import initGkeDropdowns from './gke_cluster'; +import initGkeNamespace from './gke_cluster_namespace'; +import PersistentUserCallout from '~/persistent_user_callout'; + +const newClusterViews = [':clusters:new', ':clusters:create_gcp', ':clusters:create_user']; + +const isProjectLevelCluster = page => page.startsWith('project:clusters'); + +export default (document, gon) => { + const { page } = document.body.dataset; + const isNewClusterView = newClusterViews.some(view => page.endsWith(view)); + + if (!isNewClusterView) { + return; + } + + const callout = document.querySelector('.gcp-signup-offer'); + PersistentUserCallout.factory(callout); + + initGkeDropdowns(); + + if (gon.features.createEksClusters) { + import(/* webpackChunkName: 'eks_cluster' */ '~/create_cluster/eks_cluster') + .then(({ default: initCreateEKSCluster }) => { + const el = document.querySelector('.js-create-eks-cluster-form-container'); + + if (el) { + initCreateEKSCluster(el); + } + }) + .catch(() => {}); + } + + if (isProjectLevelCluster(page)) { + initGkeNamespace(); + } +}; diff --git a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue deleted file mode 100644 index fc6d83bf96c0cfd0a99525d7e7c3d4e904c5c79e..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue +++ /dev/null @@ -1,44 +0,0 @@ -<script> -import Icon from '~/vue_shared/components/icon.vue'; -import { GlButton } from '@gitlab/ui'; - -export default { - name: 'StageCardListItem', - components: { - Icon, - GlButton, - }, - props: { - isActive: { - type: Boolean, - required: true, - }, - canEdit: { - type: Boolean, - default: false, - required: false, - }, - }, -}; -</script> - -<template> - <div - :class="{ active: isActive }" - class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded border-color-default border-style-solid border-width-1px" - > - <slot></slot> - <div v-if="canEdit" class="dropdown"> - <gl-button - :title="__('More actions')" - class="more-actions-toggle btn btn-transparent p-0" - data-toggle="dropdown" - > - <icon class="icon" name="ellipsis_v" /> - </gl-button> - <ul class="more-actions-dropdown dropdown-menu dropdown-open-left"> - <slot name="dropdown-options"></slot> - </ul> - </div> - </div> -</template> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue index 004d335f57284694c879710262d4fdbddf3c8758..1b09fe1b3701925fd39eb23e6c0172b33e0df554 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue @@ -1,11 +1,6 @@ <script> -import StageCardListItem from './stage_card_list_item.vue'; - export default { name: 'StageNavItem', - components: { - StageCardListItem, - }, props: { isDefaultStage: { type: Boolean, @@ -40,16 +35,16 @@ export default { hasValue() { return this.value && this.value.length > 0; }, - editable() { - return this.isUserAllowed && this.canEdit; - }, }, }; </script> <template> <li @click="$emit('select')"> - <stage-card-list-item :is-active="isActive" :can-edit="editable"> + <div + :class="{ active: isActive }" + class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded border-color-default border-style-solid border-width-1px" + > <div class="stage-nav-item-cell stage-name p-0" :class="{ 'font-weight-bold': isActive }"> {{ title }} </div> @@ -62,27 +57,6 @@ export default { <span class="not-available">{{ __('Not available') }}</span> </template> </div> - <template v-slot:dropdown-options> - <template v-if="isDefaultStage"> - <li> - <button type="button" class="btn-default btn-transparent"> - {{ __('Hide stage') }} - </button> - </li> - </template> - <template v-else> - <li> - <button type="button" class="btn-default btn-transparent"> - {{ __('Edit stage') }} - </button> - </li> - <li> - <button type="button" class="btn-danger danger"> - {{ __('Remove stage') }} - </button> - </li> - </template> - </template> - </stage-card-list-item> + </div> </li> </template> diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 9a1e59ec045c5b8a68f53dbd5df56a95801432af..a5ffa84e3fbe0e70412c105b46104cf64bb703a7 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -124,8 +124,10 @@ export default { :diff-viewer-mode="diffViewerMode" :new-path="diffFile.new_path" :new-sha="diffFile.diff_refs.head_sha" + :new-size="diffFile.new_size" :old-path="diffFile.old_path" :old-sha="diffFile.diff_refs.base_sha" + :old-size="diffFile.old_size" :file-hash="diffFileHash" :project-path="projectPath" :a-mode="diffFile.a_mode" diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 2514274224d2f8ae2f69c12fc0c821a59630aa92..9236f0d53492253ae669e650ef57aa8b73ac46e4 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -54,11 +54,12 @@ export default { showLoadingIcon() { return this.isLoadingCollapsedDiff || (!this.file.renderIt && !this.isCollapsed); }, - hasDiffLines() { + hasDiff() { return ( - this.file.highlighted_diff_lines && - this.file.parallel_diff_lines && - this.file.parallel_diff_lines.length > 0 + (this.file.highlighted_diff_lines && + this.file.parallel_diff_lines && + this.file.parallel_diff_lines.length > 0) || + !this.file.blob.readable_text ); }, isFileTooLarge() { @@ -82,7 +83,7 @@ export default { }, watch: { isCollapsed: function fileCollapsedWatch(newVal, oldVal) { - if (!newVal && oldVal && !this.hasDiffLines) { + if (!newVal && oldVal && !this.hasDiff) { this.handleLoadCollapsedDiff(); } @@ -103,7 +104,7 @@ export default { 'setFileCollapsed', ]), handleToggle() { - if (!this.hasDiffLines) { + if (!this.hasDiff) { this.handleLoadCollapsedDiff(); } else { this.isCollapsed = !this.isCollapsed; diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 0ff26445a6a301a2b7bf076044092de28b6ef25c..62b390a46d716e28a01a63606c0ca2686cbccbaf 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -290,5 +290,5 @@ export default function dropzoneInput(form) { formTextarea.focus(); }); - return Dropzone.forElement($formDropzone.get(0)); + return $formDropzone.get(0) ? Dropzone.forElement($formDropzone.get(0)) : null; } diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue new file mode 100644 index 0000000000000000000000000000000000000000..37c9818f869a983962ce9357c0c9f23572423133 --- /dev/null +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -0,0 +1,141 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import dateFormat from 'dateformat'; +import { __, sprintf } from '~/locale'; +import { GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import Stacktrace from './stacktrace.vue'; +import TrackEventDirective from '~/vue_shared/directives/track_event'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { trackClickErrorLinkToSentryOptions } from '../utils'; + +export default { + components: { + GlButton, + GlLink, + GlLoadingIcon, + TooltipOnTruncate, + Icon, + Stacktrace, + }, + directives: { + TrackEvent: TrackEventDirective, + }, + mixins: [timeagoMixin], + props: { + issueDetailsPath: { + type: String, + required: true, + }, + issueStackTracePath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState('details', ['error', 'loading', 'loadingStacktrace', 'stacktraceData']), + ...mapGetters('details', ['stacktrace']), + reported() { + return sprintf( + __('Reported %{timeAgo} by %{reportedBy}'), + { + reportedBy: `<strong>${this.error.culprit}</strong>`, + timeAgo: this.timeFormated(this.stacktraceData.date_received), + }, + false, + ); + }, + firstReleaseLink() { + return `${this.error.external_base_url}/releases/${this.error.first_release_short_version}`; + }, + lastReleaseLink() { + return `${this.error.external_base_url}releases/${this.error.last_release_short_version}`; + }, + showDetails() { + return Boolean(!this.loading && this.error && this.error.id); + }, + showStacktrace() { + return Boolean(!this.loadingStacktrace && this.stacktrace && this.stacktrace.length); + }, + }, + mounted() { + this.startPollingDetails(this.issueDetailsPath); + this.startPollingStacktrace(this.issueStackTracePath); + }, + methods: { + ...mapActions('details', ['startPollingDetails', 'startPollingStacktrace']), + trackClickErrorLinkToSentryOptions, + formatDate(date) { + return `${this.timeFormated(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`; + }, + }, +}; +</script> + +<template> + <div> + <div v-if="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> + <!-- <gl-button class="my-3 ml-auto" variant="success"> + {{ __('Create Issue') }} + </gl-button>--> + </div> + <div> + <tooltip-on-truncate :title="error.title" truncate-target="child" placement="top"> + <h2 class="text-truncate">{{ error.title }}</h2> + </tooltip-on-truncate> + <h3>{{ __('Error details') }}</h3> + <ul> + <li> + <span class="bold">{{ __('Sentry event') }}:</span> + <gl-link + v-track-event="trackClickErrorLinkToSentryOptions(error.external_url)" + :href="error.external_url" + target="_blank" + > + <span class="text-truncate">{{ error.external_url }}</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) }} + <gl-link :href="firstReleaseLink" target="_blank"> + <span>{{ __('Release') }}: {{ error.first_release_short_version }}</span> + </gl-link> + </li> + <li v-if="error.last_release_short_version"> + <span class="bold">{{ __('Last seen') }}:</span> + {{ formatDate(error.last_seen) }} + <gl-link :href="lastReleaseLink" target="_blank"> + <span>{{ __('Release') }}: {{ error.last_release_short_version }}</span> + </gl-link> + </li> + <li> + <span class="bold">{{ __('Events') }}:</span> + <span>{{ error.count }}</span> + </li> + <li> + <span class="bold">{{ __('Users') }}:</span> + <span>{{ error.user_count }}</span> + </li> + </ul> + + <div v-if="loadingStacktrace" class="py-3"> + <gl-loading-icon :size="3" /> + </div> + + <template v-if="showStacktrace"> + <h3 class="my-4">{{ __('Stack trace') }}</h3> + <stacktrace :entries="stacktrace" /> + </template> + </div> + </div> + </div> +</template> 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 cd298e2c6928e5e1bfa64282af9e957f8cc9aeb5..88139ce740381577bfb2e80eb34f8ef06d4e4a8e 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -1,11 +1,19 @@ <script> -import { mapActions, mapState } from 'vuex'; -import { GlEmptyState, GlButton, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import { + GlEmptyState, + GlButton, + GlLink, + GlLoadingIcon, + GlTable, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { visitUrl } from '~/lib/utils/url_utility'; import Icon from '~/vue_shared/components/icon.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { __ } from '~/locale'; import TrackEventDirective from '~/vue_shared/directives/track_event'; -import { trackViewInSentryOptions, trackClickErrorLinkToSentryOptions } from '../utils'; +import { trackViewInSentryOptions } from '../utils'; export default { fields: [ @@ -20,6 +28,7 @@ export default { GlLink, GlLoadingIcon, GlTable, + GlSearchBoxByType, Icon, TimeAgo, }, @@ -48,8 +57,17 @@ export default { required: true, }, }, + data() { + return { + errorSearchQuery: '', + }; + }, computed: { - ...mapState(['errors', 'externalUrl', 'loading']), + ...mapState('list', ['errors', 'externalUrl', 'loading']), + ...mapGetters('list', ['filterErrorsByTitle']), + filteredErrors() { + return this.errorSearchQuery ? this.filterErrorsByTitle(this.errorSearchQuery) : this.errors; + }, }, created() { if (this.errorTrackingEnabled) { @@ -57,9 +75,11 @@ export default { } }, methods: { - ...mapActions(['startPolling', 'restartPolling']), + ...mapActions('list', ['startPolling', 'restartPolling']), trackViewInSentryOptions, - trackClickErrorLinkToSentryOptions, + viewDetails(errorId) { + visitUrl(`error_tracking/${errorId}/details`); + }, }, }; </script> @@ -71,10 +91,17 @@ export default { <gl-loading-icon :size="3" /> </div> <div v-else> - <div class="d-flex justify-content-end"> + <div class="d-flex flex-row justify-content-around bg-secondary border"> + <gl-search-box-by-type + v-model="errorSearchQuery" + class="col-lg-10 m-3 p-0" + :placeholder="__('Search or filter results...')" + type="search" + autofocus + /> <gl-button v-track-event="trackViewInSentryOptions(externalUrl)" - class="my-3 ml-auto" + class="m-3" variant="primary" :href="externalUrl" target="_blank" @@ -84,7 +111,14 @@ export default { </gl-button> </div> - <gl-table :items="errors" :fields="$options.fields" :show-empty="true" fixed stacked="sm"> + <gl-table + class="mt-3" + :items="filteredErrors" + :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> @@ -94,13 +128,11 @@ export default { <template slot="error" slot-scope="errors"> <div class="d-flex flex-column"> <gl-link - v-track-event="trackClickErrorLinkToSentryOptions(errors.item.externalUrl)" - :href="errors.item.externalUrl" class="d-flex text-dark" target="_blank" + @click="viewDetails(errors.item.id)" > <strong class="text-truncate">{{ errors.item.title.trim() }}</strong> - <icon name="external-link" class="ml-1 flex-shrink-0" /> </gl-link> <span class="text-secondary text-truncate"> {{ errors.item.culprit }} diff --git a/app/assets/javascripts/error_tracking/components/stacktrace.vue b/app/assets/javascripts/error_tracking/components/stacktrace.vue new file mode 100644 index 0000000000000000000000000000000000000000..6b71967624f38c25192a4356b4088c3515a0c7f8 --- /dev/null +++ b/app/assets/javascripts/error_tracking/components/stacktrace.vue @@ -0,0 +1,33 @@ +<script> +import StackTraceEntry from './stacktrace_entry.vue'; + +export default { + components: { + StackTraceEntry, + }, + props: { + entries: { + type: Array, + required: true, + }, + }, + methods: { + isFirstEntry(index) { + return index === 0; + }, + }, +}; +</script> + +<template> + <div class="stacktrace"> + <stack-trace-entry + v-for="(entry, index) in entries" + :key="`stacktrace-entry-${index}`" + :lines="entry.context" + :file-path="entry.filename" + :error-line="entry.lineNo" + :expanded="isFirstEntry(index)" + /> + </div> +</template> diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue new file mode 100644 index 0000000000000000000000000000000000000000..ad542c579a916fc5404bcf6c3d90a7d98794a5fa --- /dev/null +++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue @@ -0,0 +1,110 @@ +<script> +import { GlTooltip } from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + ClipboardButton, + FileIcon, + Icon, + }, + directives: { + GlTooltip, + }, + props: { + lines: { + type: Array, + required: true, + }, + filePath: { + type: String, + required: true, + }, + errorLine: { + type: Number, + required: true, + }, + expanded: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isExpanded: this.expanded, + }; + }, + computed: { + linesLength() { + return this.lines.length; + }, + collapseIcon() { + return this.isExpanded ? 'chevron-down' : 'chevron-right'; + }, + }, + methods: { + isHighlighted(lineNum) { + return lineNum === this.errorLine; + }, + toggle() { + this.isExpanded = !this.isExpanded; + }, + lineNum(line) { + return line[0]; + }, + lineCode(line) { + return line[1]; + }, + }, + userColorScheme: window.gon.user_color_scheme, +}; +</script> + +<template> + <div class="file-holder"> + <div ref="header" class="file-title file-title-flex-parent"> + <div class="file-header-content "> + <div class="d-inline-block cursor-pointer" @click="toggle()"> + <icon :name="collapseIcon" :size="16" aria-hidden="true" class="append-right-5" /> + </div> + <div class="d-inline-block append-right-4"> + <file-icon + :file-name="filePath" + :size="18" + aria-hidden="true" + css-classes="append-right-5" + /> + <strong v-gl-tooltip :title="filePath" class="file-title-name" data-container="body"> + {{ filePath }} + </strong> + </div> + + <clipboard-button + :title="__('Copy file path')" + :text="filePath" + css-class="btn-default btn-transparent btn-clipboard" + /> + </div> + </div> + + <table v-if="isExpanded" :class="$options.userColorScheme" class="code js-syntax-highlight"> + <tbody> + <template v-for="(line, index) in lines"> + <tr :key="`stacktrace-line-${index}`" class="line_holder"> + <td class="diff-line-num" :class="{ old: isHighlighted(lineNum(line)) }"> + {{ lineNum(line) }} + </td> + <td + class="line_content" + :class="{ old: isHighlighted(lineNum(line)) }" + v-html="lineCode(line)" + ></td> + </tr> + </template> + </tbody> + </table> + </div> +</template> diff --git a/app/assets/javascripts/error_tracking/details.js b/app/assets/javascripts/error_tracking/details.js new file mode 100644 index 0000000000000000000000000000000000000000..b9b51a6539fa85acb43874e1d5b16e2e797f3532 --- /dev/null +++ b/app/assets/javascripts/error_tracking/details.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import store from './store'; +import ErrorDetails from './components/error_details.vue'; + +export default () => { + // eslint-disable-next-line no-new + new Vue({ + el: '#js-error_details', + components: { + ErrorDetails, + }, + store, + render(createElement) { + const domEl = document.querySelector(this.$options.el); + const { issueDetailsPath, issueStackTracePath } = domEl.dataset; + + return createElement('error-details', { + props: { + issueDetailsPath, + issueStackTracePath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/error_tracking/index.js b/app/assets/javascripts/error_tracking/list.js similarity index 100% rename from app/assets/javascripts/error_tracking/index.js rename to app/assets/javascripts/error_tracking/list.js diff --git a/app/assets/javascripts/error_tracking/services/index.js b/app/assets/javascripts/error_tracking/services/index.js index ab89521dc466d905663d409836a01727d44ca7b1..68988296cc263e280ce195d66676ea35a710f80b 100644 --- a/app/assets/javascripts/error_tracking/services/index.js +++ b/app/assets/javascripts/error_tracking/services/index.js @@ -1,7 +1,7 @@ import axios from '~/lib/utils/axios_utils'; export default { - getErrorList({ endpoint }) { + getSentryData({ endpoint }) { return axios.get(endpoint); }, }; diff --git a/app/assets/javascripts/error_tracking/store/details/actions.js b/app/assets/javascripts/error_tracking/store/details/actions.js new file mode 100644 index 0000000000000000000000000000000000000000..0390bca71750e255f2b453a81a6f5bd55d6c777d --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/details/actions.js @@ -0,0 +1,63 @@ +import service from '../../services'; +import * as types from './mutation_types'; +import createFlash from '~/flash'; +import Poll from '~/lib/utils/poll'; +import { __ } from '~/locale'; + +let stackTracePoll; +let detailPoll; + +const stopPolling = poll => { + if (poll) poll.stop(); +}; + +export function startPollingDetails({ commit }, endpoint) { + detailPoll = new Poll({ + resource: service, + method: 'getSentryData', + data: { endpoint }, + successCallback: ({ data }) => { + if (!data) { + detailPoll.restart(); + return; + } + + commit(types.SET_ERROR, data.error); + commit(types.SET_LOADING, false); + + stopPolling(detailPoll); + }, + errorCallback: () => { + commit(types.SET_LOADING, false); + createFlash(__('Failed to load error details from Sentry.')); + }, + }); + + detailPoll.makeRequest(); +} + +export function startPollingStacktrace({ commit }, endpoint) { + stackTracePoll = new Poll({ + resource: service, + method: 'getSentryData', + data: { endpoint }, + successCallback: ({ data }) => { + if (!data) { + stackTracePoll.restart(); + return; + } + commit(types.SET_STACKTRACE_DATA, data.error); + commit(types.SET_LOADING_STACKTRACE, false); + + stopPolling(stackTracePoll); + }, + errorCallback: () => { + commit(types.SET_LOADING_STACKTRACE, false); + createFlash(__('Failed to load stacktrace.')); + }, + }); + + stackTracePoll.makeRequest(); +} + +export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/details/getters.js b/app/assets/javascripts/error_tracking/store/details/getters.js new file mode 100644 index 0000000000000000000000000000000000000000..7d13439d72124a50e70e5bc0a7d2f2464335ab99 --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/details/getters.js @@ -0,0 +1,3 @@ +export const stacktrace = state => state.stacktraceData.stack_trace_entries.reverse(); + +export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/details/mutation_types.js b/app/assets/javascripts/error_tracking/store/details/mutation_types.js new file mode 100644 index 0000000000000000000000000000000000000000..a2592253a2dc05cfd64d385a9b50ce61af6d489f --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/details/mutation_types.js @@ -0,0 +1,4 @@ +export const SET_ERROR = 'SET_ERRORS'; +export const SET_LOADING = 'SET_LOADING'; +export const SET_LOADING_STACKTRACE = 'SET_LOADING_STACKTRACE'; +export const SET_STACKTRACE_DATA = 'SET_STACKTRACE_DATA'; diff --git a/app/assets/javascripts/error_tracking/store/details/mutations.js b/app/assets/javascripts/error_tracking/store/details/mutations.js new file mode 100644 index 0000000000000000000000000000000000000000..6f4720444e0f8f71c5966bd1795c6f08a3207b0c --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/details/mutations.js @@ -0,0 +1,16 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_ERROR](state, data) { + state.error = data; + }, + [types.SET_LOADING](state, loading) { + state.loading = loading; + }, + [types.SET_LOADING_STACKTRACE](state, data) { + state.loadingStacktrace = data; + }, + [types.SET_STACKTRACE_DATA](state, data) { + state.stacktraceData = data; + }, +}; diff --git a/app/assets/javascripts/error_tracking/store/details/state.js b/app/assets/javascripts/error_tracking/store/details/state.js new file mode 100644 index 0000000000000000000000000000000000000000..95fb0ba055833465feaf15e2dc557b514261ed23 --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/details/state.js @@ -0,0 +1,6 @@ +export default () => ({ + error: {}, + stacktraceData: {}, + loading: true, + loadingStacktrace: true, +}); diff --git a/app/assets/javascripts/error_tracking/store/index.js b/app/assets/javascripts/error_tracking/store/index.js index 3136682fb64cbb33d62ea4df16e5a537548d471e..941c752e96acfd293f8e69420e02d517894719ef 100644 --- a/app/assets/javascripts/error_tracking/store/index.js +++ b/app/assets/javascripts/error_tracking/store/index.js @@ -1,19 +1,36 @@ 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'; +import * as listGetters from './list/getters'; + +import * as detailsActions from './details/actions'; +import detailsMutations from './details/mutations'; +import detailsState from './details/state'; +import * as detailsGetters from './details/getters'; Vue.use(Vuex); export const createStore = () => new Vuex.Store({ - state: { - errors: [], - externalUrl: '', - loading: true, + modules: { + list: { + namespaced: true, + state: listState(), + actions: listActions, + mutations: listMutations, + getters: listGetters, + }, + details: { + namespaced: true, + state: detailsState(), + actions: detailsActions, + mutations: detailsMutations, + getters: detailsGetters, + }, }, - actions, - mutations, }); export default createStore(); diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js similarity index 95% rename from app/assets/javascripts/error_tracking/store/actions.js rename to app/assets/javascripts/error_tracking/store/list/actions.js index 1e754a4f54ff1f7d12f405a0d66c3fef5dd9758c..18c6e5e9695e590c487f54e0bcfec857014ecef3 100644 --- a/app/assets/javascripts/error_tracking/store/actions.js +++ b/app/assets/javascripts/error_tracking/store/list/actions.js @@ -1,4 +1,4 @@ -import Service from '../services'; +import Service from '../../services'; import * as types from './mutation_types'; import createFlash from '~/flash'; import Poll from '~/lib/utils/poll'; @@ -9,7 +9,7 @@ let eTagPoll; export function startPolling({ commit, dispatch }, endpoint) { eTagPoll = new Poll({ resource: Service, - method: 'getErrorList', + method: 'getSentryData', data: { endpoint }, successCallback: ({ data }) => { if (!data) { diff --git a/app/assets/javascripts/error_tracking/store/list/getters.js b/app/assets/javascripts/error_tracking/store/list/getters.js new file mode 100644 index 0000000000000000000000000000000000000000..1a2ec62f79fb037df1a8d595663f2d9ebc0161e7 --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/list/getters.js @@ -0,0 +1,4 @@ +export const filterErrorsByTitle = state => errorQuery => + state.errors.filter(error => error.title.match(new RegExp(`${errorQuery}`, 'i'))); + +export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/mutation_types.js b/app/assets/javascripts/error_tracking/store/list/mutation_types.js similarity index 100% rename from app/assets/javascripts/error_tracking/store/mutation_types.js rename to app/assets/javascripts/error_tracking/store/list/mutation_types.js diff --git a/app/assets/javascripts/error_tracking/store/mutations.js b/app/assets/javascripts/error_tracking/store/list/mutations.js similarity index 100% rename from app/assets/javascripts/error_tracking/store/mutations.js rename to app/assets/javascripts/error_tracking/store/list/mutations.js diff --git a/app/assets/javascripts/error_tracking/store/list/state.js b/app/assets/javascripts/error_tracking/store/list/state.js new file mode 100644 index 0000000000000000000000000000000000000000..d371350ef0ed556895e83ec7e2b02ed96395e1d5 --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/list/state.js @@ -0,0 +1,5 @@ +export default () => ({ + errors: [], + externalUrl: '', + loading: true, +}); diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue index 50eb3e63b7c0b12d7ab6bfd38cfdc56d200a2e7c..786abc8ce492403760b0f895867110c94201b9de 100644 --- a/app/assets/javascripts/error_tracking_settings/components/app.vue +++ b/app/assets/javascripts/error_tracking_settings/components/app.vue @@ -43,16 +43,7 @@ export default { 'isProjectInvalid', 'projectSelectionLabel', ]), - ...mapState([ - 'apiHost', - 'connectError', - 'connectSuccessful', - 'enabled', - 'projects', - 'selectedProject', - 'settingsLoading', - 'token', - ]), + ...mapState(['enabled', 'projects', 'selectedProject', 'settingsLoading', 'token']), }, created() { this.setInitialState({ @@ -65,15 +56,7 @@ export default { }); }, methods: { - ...mapActions([ - 'fetchProjects', - 'setInitialState', - 'updateApiHost', - 'updateEnabled', - 'updateSelectedProject', - 'updateSettings', - 'updateToken', - ]), + ...mapActions(['setInitialState', 'updateEnabled', 'updateSelectedProject', 'updateSettings']), handleSubmit() { this.updateSettings(); }, @@ -95,15 +78,7 @@ export default { s__('ErrorTracking|Active') }}</label> </div> - <error-tracking-form - :api-host="apiHost" - :connect-error="connectError" - :connect-successful="connectSuccessful" - :token="token" - @handle-connect="fetchProjects" - @update-api-host="updateApiHost" - @update-token="updateToken" - /> + <error-tracking-form /> <div class="form-group"> <project-dropdown :has-projects="hasProjects" diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue index a734e8527dd0f1c63a6702077f43915a1b686073..d86116aa315c56bf34407b4b0d393475b19ca9f9 100644 --- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue +++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue @@ -1,32 +1,20 @@ <script> -import { GlButton, GlFormInput } from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; +import { GlFormInput } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; export default { - components: { GlButton, GlFormInput, Icon }, - props: { - apiHost: { - type: String, - required: true, - }, - connectError: { - type: Boolean, - required: true, - }, - connectSuccessful: { - type: Boolean, - required: true, - }, - token: { - type: String, - required: true, - }, - }, + components: { GlFormInput, Icon, LoadingButton }, computed: { + ...mapState(['apiHost', 'connectError', 'connectSuccessful', 'isLoadingProjects', 'token']), tokenInputState() { return this.connectError ? false : null; }, }, + methods: { + ...mapActions(['fetchProjects', 'updateApiHost', 'updateToken']), + }, }; </script> @@ -40,8 +28,9 @@ export default { <gl-form-input id="error-tracking-api-host" :value="apiHost" + :disabled="isLoadingProjects" placeholder="https://mysentryserver.com" - @input="$emit('update-api-host', $event)" + @input="updateApiHost" /> <!-- eslint-enable @gitlab/vue-i18n/no-bare-attribute-strings --> </div> @@ -60,15 +49,17 @@ export default { id="error-tracking-token" :value="token" :state="tokenInputState" - @input="$emit('update-token', $event)" + :disabled="isLoadingProjects" + @input="updateToken" /> </div> <div class="col-4 col-md-3 gl-pl-0"> - <gl-button - class="js-error-tracking-connect prepend-left-5" - @click="$emit('handle-connect')" - >{{ __('Connect') }}</gl-button - > + <loading-button + class="js-error-tracking-connect prepend-left-5 d-inline-flex" + :label="isLoadingProjects ? __('Connecting') : __('Connect')" + :loading="isLoadingProjects" + @click="fetchProjects" + /> <icon v-show="connectSuccessful" class="js-error-tracking-connect-success prepend-left-5 text-success align-middle" diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js index 95105797807431e298a5fe425802f64fd51e2993..6b540ea7dfd9f2863c318e36cb6e7a3fbd31a1ad 100644 --- a/app/assets/javascripts/error_tracking_settings/store/actions.js +++ b/app/assets/javascripts/error_tracking_settings/store/actions.js @@ -6,17 +6,20 @@ import { transformFrontendSettings } from '../utils'; import * as types from './mutation_types'; export const requestProjects = ({ commit }) => { + commit(types.SET_PROJECTS_LOADING, true); commit(types.RESET_CONNECT); }; export const receiveProjectsSuccess = ({ commit }, projects) => { commit(types.UPDATE_CONNECT_SUCCESS); commit(types.RECEIVE_PROJECTS, projects); + commit(types.SET_PROJECTS_LOADING, false); }; export const receiveProjectsError = ({ commit }) => { commit(types.UPDATE_CONNECT_ERROR); commit(types.CLEAR_PROJECTS); + commit(types.SET_PROJECTS_LOADING, false); }; export const fetchProjects = ({ dispatch, state }) => { diff --git a/app/assets/javascripts/error_tracking_settings/store/mutation_types.js b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js index b4f8a237947b844e7e1df6a073f5c64c6a85f682..bf3df383ddc96496d3a5aac70b43ef83cd03ad76 100644 --- a/app/assets/javascripts/error_tracking_settings/store/mutation_types.js +++ b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js @@ -9,3 +9,4 @@ export const UPDATE_ENABLED = 'UPDATE_ENABLED'; export const UPDATE_SELECTED_PROJECT = 'UPDATE_SELECTED_PROJECT'; export const UPDATE_SETTINGS_LOADING = 'UPDATE_SETTINGS_LOADING'; export const UPDATE_TOKEN = 'UPDATE_TOKEN'; +export const SET_PROJECTS_LOADING = 'SET_PROJECTS_LOADING'; diff --git a/app/assets/javascripts/error_tracking_settings/store/mutations.js b/app/assets/javascripts/error_tracking_settings/store/mutations.js index 4089d1ee94e314198601b3c2eb37c34710499e20..133f25264b98674911a36c20d646ee561abedf64 100644 --- a/app/assets/javascripts/error_tracking_settings/store/mutations.js +++ b/app/assets/javascripts/error_tracking_settings/store/mutations.js @@ -58,4 +58,7 @@ export default { state.connectSuccessful = false; state.connectError = true; }, + [types.SET_PROJECTS_LOADING](state, loading) { + state.isLoadingProjects = loading; + }, }; diff --git a/app/assets/javascripts/error_tracking_settings/store/state.js b/app/assets/javascripts/error_tracking_settings/store/state.js index 98219d33f4df8d1c8b2893136498cf335482a55b..ab616f11e83fbbe17ee28bac1d85a4ff71895fef 100644 --- a/app/assets/javascripts/error_tracking_settings/store/state.js +++ b/app/assets/javascripts/error_tracking_settings/store/state.js @@ -3,6 +3,7 @@ export default () => ({ enabled: false, token: '', projects: [], + isLoadingProjects: false, selectedProject: null, settingsLoading: false, connectSuccessful: false, diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index f280f3cd26c8993e7a4fba9b730d8c8af46ed8f5..5fa07045d5ec4f17995ba525b075fdf528a82fb5 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -13,6 +13,7 @@ export default class AvailableDropdownMappings { runnerTagsEndpoint, labelsEndpoint, milestonesEndpoint, + releasesEndpoint, groupsOnly, includeAncestorGroups, includeDescendantGroups, @@ -21,6 +22,7 @@ export default class AvailableDropdownMappings { this.runnerTagsEndpoint = runnerTagsEndpoint; this.labelsEndpoint = labelsEndpoint; this.milestonesEndpoint = milestonesEndpoint; + this.releasesEndpoint = releasesEndpoint; this.groupsOnly = groupsOnly; this.includeAncestorGroups = includeAncestorGroups; this.includeDescendantGroups = includeDescendantGroups; @@ -70,6 +72,19 @@ export default class AvailableDropdownMappings { }, element: this.container.querySelector('#js-dropdown-milestone'), }, + release: { + reference: null, + gl: DropdownNonUser, + extraArguments: { + endpoint: this.getReleasesEndpoint(), + symbol: '', + + // The DropdownNonUser class is hardcoded to look for and display a + // "title" property, so we need to add this property to each release object + preprocessing: releases => releases.map(r => ({ ...r, title: r.tag })), + }, + element: this.container.querySelector('#js-dropdown-release'), + }, label: { reference: null, gl: DropdownNonUser, @@ -130,6 +145,10 @@ export default class AvailableDropdownMappings { return `${this.milestonesEndpoint}.json`; } + getReleasesEndpoint() { + return `${this.releasesEndpoint}.json`; + } + getLabelsEndpoint() { let endpoint = `${this.labelsEndpoint}.json?`; 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 835d3bf8a53f3b6b88e9f5ddae1c23f16b18e7ed..5ff95f45be46cf58ba295670cf54949786cc58d8 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -11,6 +11,7 @@ export default class FilteredSearchDropdownManager { runnerTagsEndpoint = '', labelsEndpoint = '', milestonesEndpoint = '', + releasesEndpoint = '', tokenizer, page, isGroup, @@ -18,10 +19,13 @@ export default class FilteredSearchDropdownManager { isGroupDecendent, filteredSearchTokenKeys, }) { + const removeTrailingSlash = url => url.replace(/\/$/, ''); + this.container = FilteredSearchContainer.container; - this.runnerTagsEndpoint = runnerTagsEndpoint.replace(/\/$/, ''); - this.labelsEndpoint = labelsEndpoint.replace(/\/$/, ''); - this.milestonesEndpoint = milestonesEndpoint.replace(/\/$/, ''); + this.runnerTagsEndpoint = removeTrailingSlash(runnerTagsEndpoint); + this.labelsEndpoint = removeTrailingSlash(labelsEndpoint); + this.milestonesEndpoint = removeTrailingSlash(milestonesEndpoint); + this.releasesEndpoint = removeTrailingSlash(releasesEndpoint); this.tokenizer = tokenizer; this.filteredSearchTokenKeys = filteredSearchTokenKeys || FilteredSearchTokenKeys; this.filteredSearchInput = this.container.querySelector('.filtered-search'); @@ -54,6 +58,7 @@ export default class FilteredSearchDropdownManager { this.runnerTagsEndpoint, this.labelsEndpoint, this.milestonesEndpoint, + this.releasesEndpoint, this.groupsOnly, this.includeAncestorGroups, this.includeDescendantGroups, diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index fd335362e5b9a4813c7a178e483991c87804e204..5c2d32f4e8586a4f068b52809557d896161a48bb 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -89,6 +89,7 @@ export default class FilteredSearchManager { this.filteredSearchInput.getAttribute('data-runner-tags-endpoint') || '', labelsEndpoint: this.filteredSearchInput.getAttribute('data-labels-endpoint') || '', milestonesEndpoint: this.filteredSearchInput.getAttribute('data-milestones-endpoint') || '', + releasesEndpoint: this.filteredSearchInput.getAttribute('data-releases-endpoint') || '', tokenizer: this.tokenizer, page: this.page, isGroup: this.isGroup, 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 6c3d9e33420584df588cbd4b2bdfa8314258bd2e..414bcf186a3b4a538dfe3fbac77333a9f938c56f 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,7 +1,9 @@ import FilteredSearchTokenKeys from './filtered_search_token_keys'; import { __ } from '~/locale'; -export const tokenKeys = [ +export const tokenKeys = []; + +tokenKeys.push( { key: 'author', type: 'string', @@ -26,15 +28,27 @@ export const tokenKeys = [ icon: 'clock', tag: '%milestone', }, - { - key: 'label', - type: 'array', - param: 'name[]', - symbol: '~', - icon: 'labels', - tag: '~label', - }, -]; +); + +if (gon && gon.features && gon.features.releaseSearchFilter) { + tokenKeys.push({ + 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', +}); if (gon.current_user_id) { // Appending tokenkeys only logged-in @@ -88,6 +102,16 @@ export const conditions = [ 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', diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index fc9c5827ed49e11cad267c228a92185e3ed62284..2c3320b5e794a27d61402c9ca229eb6426c22e75 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -37,7 +37,7 @@ const createAction = config => ` `; const createFlashEl = (message, type) => ` - <div class="flash-content flash-${type} rounded"> + <div class="flash-${type}"> <div class="flash-text"> ${_.escape(message)} <div class="close-icon-wrapper js-close-icon"> diff --git a/app/assets/javascripts/frequent_items/store/mutations.js b/app/assets/javascripts/frequent_items/store/mutations.js index 41b660a243f7f6cdb782d6a7ded09a6a2c404837..92ac3a2c94d1e6eb325e93de01d8c0c280e703b5 100644 --- a/app/assets/javascripts/frequent_items/store/mutations.js +++ b/app/assets/javascripts/frequent_items/store/mutations.js @@ -47,7 +47,8 @@ export default { hasSearchQuery: true, }); }, - [types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, rawItems) { + [types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, results) { + const rawItems = results.data; Object.assign(state, { items: rawItems.map(rawItem => ({ id: rawItem.id, diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 4e1b4f2652c287f623db94c52f5726575994ac02..045f77af7ea527a2b10520795a4ac5cb11a63ba3 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -617,7 +617,7 @@ GitLabDropdown = (function() { GitLabDropdown.prototype.hidden = function(e) { var $input; this.resetRows(); - this.removeArrayKeyEvent(); + this.removeArrowKeyEvent(); $input = this.dropdown.find('.dropdown-input-field'); if (this.options.filterable) { $input.blur(); @@ -900,7 +900,7 @@ GitLabDropdown = (function() { ); }; - GitLabDropdown.prototype.removeArrayKeyEvent = function() { + GitLabDropdown.prototype.removeArrowKeyEvent = function() { return $('body').off('keydown'); }; diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue new file mode 100644 index 0000000000000000000000000000000000000000..bd504d95ee2ee16cb004487d756bdd6d25b87dfd --- /dev/null +++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue @@ -0,0 +1,103 @@ +<script> +import { GlButton, GlFormGroup, GlFormInput, GlFormCheckbox, GlLink } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { mapState, mapActions } from 'vuex'; + +export default { + components: { + GlButton, + GlFormCheckbox, + GlFormGroup, + GlFormInput, + GlLink, + Icon, + }, + data() { + return { placeholderUrl: 'https://my-url.grafana.net/' }; + }, + computed: { + ...mapState(['operationsSettingsEndpoint', 'grafanaToken', 'grafanaUrl', 'grafanaEnabled']), + integrationEnabled: { + get() { + return this.grafanaEnabled; + }, + set(grafanaEnabled) { + this.setGrafanaEnabled(grafanaEnabled); + }, + }, + localGrafanaToken: { + get() { + return this.grafanaToken; + }, + set(token) { + this.setGrafanaToken(token); + }, + }, + localGrafanaUrl: { + get() { + return this.grafanaUrl; + }, + set(url) { + this.setGrafanaUrl(url); + }, + }, + }, + methods: { + ...mapActions([ + 'setGrafanaUrl', + 'setGrafanaToken', + 'setGrafanaEnabled', + 'updateGrafanaIntegration', + ]), + }, +}; +</script> + +<template> + <section id="grafana" class="settings no-animate js-grafana-integration"> + <div class="settings-header"> + <h4 class="js-section-header"> + {{ s__('GrafanaIntegration|Grafana Authentication') }} + </h4> + <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> + <p class="js-section-sub-header"> + {{ s__('GrafanaIntegration|Embed Grafana charts in GitLab issues.') }} + </p> + </div> + <div class="settings-content"> + <form> + <gl-form-checkbox + id="grafana-integration-enabled" + v-model="integrationEnabled" + class="mb-4" + > + {{ s__('GrafanaIntegration|Active') }} + </gl-form-checkbox> + <gl-form-group + :label="s__('GrafanaIntegration|Grafana URL')" + label-for="grafana-url" + :description="s__('GrafanaIntegration|Enter the base URL of the Grafana instance.')" + > + <gl-form-input id="grafana-url" v-model="localGrafanaUrl" :placeholder="placeholderUrl" /> + </gl-form-group> + <gl-form-group :label="s__('GrafanaIntegration|API Token')" label-for="grafana-token"> + <gl-form-input id="grafana-token" v-model="localGrafanaToken" /> + <p class="form-text text-muted"> + {{ s__('GrafanaIntegration|Enter the Grafana API Token.') }} + <a + href="https://grafana.com/docs/http_api/auth/#create-api-token" + target="_blank" + rel="noopener noreferrer" + > + {{ __('More information') }} + <icon name="external-link" class="vertical-align-middle" /> + </a> + </p> + </gl-form-group> + <gl-button variant="success" @click="updateGrafanaIntegration"> + {{ __('Save Changes') }} + </gl-button> + </form> + </div> + </section> +</template> diff --git a/app/assets/javascripts/grafana_integration/index.js b/app/assets/javascripts/grafana_integration/index.js new file mode 100644 index 0000000000000000000000000000000000000000..a93edab4388c3cb3d78b1cc8451e4582951b1cb9 --- /dev/null +++ b/app/assets/javascripts/grafana_integration/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import store from './store'; +import GrafanaIntegration from './components/grafana_integration.vue'; + +export default () => { + const el = document.querySelector('.js-grafana-integration'); + return new Vue({ + el, + store: store(el.dataset), + render(createElement) { + return createElement(GrafanaIntegration); + }, + }); +}; diff --git a/app/assets/javascripts/grafana_integration/store/actions.js b/app/assets/javascripts/grafana_integration/store/actions.js new file mode 100644 index 0000000000000000000000000000000000000000..d83f1e0831c9623ae0425c0989c79f1ac666136d --- /dev/null +++ b/app/assets/javascripts/grafana_integration/store/actions.js @@ -0,0 +1,42 @@ +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import createFlash from '~/flash'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import * as mutationTypes from './mutation_types'; + +export const setGrafanaUrl = ({ commit }, url) => commit(mutationTypes.SET_GRAFANA_URL, url); + +export const setGrafanaToken = ({ commit }, token) => + commit(mutationTypes.SET_GRAFANA_TOKEN, token); + +export const setGrafanaEnabled = ({ commit }, enabled) => + commit(mutationTypes.SET_GRAFANA_ENABLED, enabled); + +export const updateGrafanaIntegration = ({ state, dispatch }) => + axios + .patch(state.operationsSettingsEndpoint, { + project: { + grafana_integration_attributes: { + grafana_url: state.grafanaUrl, + token: state.grafanaToken, + enabled: state.grafanaEnabled, + }, + }, + }) + .then(() => dispatch('receiveGrafanaIntegrationUpdateSuccess')) + .catch(error => dispatch('receiveGrafanaIntegrationUpdateError', error)); + +export const receiveGrafanaIntegrationUpdateSuccess = () => { + /** + * The operations_controller currently handles successful requests + * by creating a flash banner messsage to notify the user. + */ + refreshCurrentPage(); +}; + +export const receiveGrafanaIntegrationUpdateError = (_, error) => { + const { response } = error; + const message = response.data && response.data.message ? response.data.message : ''; + + createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert'); +}; diff --git a/app/assets/javascripts/grafana_integration/store/index.js b/app/assets/javascripts/grafana_integration/store/index.js new file mode 100644 index 0000000000000000000000000000000000000000..e96bb1e8aad15da63c9f0a4aeba7d77aa2be94d9 --- /dev/null +++ b/app/assets/javascripts/grafana_integration/store/index.js @@ -0,0 +1,16 @@ +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({ + state: createState(initialState), + actions, + mutations, + }); + +export default createStore; diff --git a/app/assets/javascripts/grafana_integration/store/mutation_types.js b/app/assets/javascripts/grafana_integration/store/mutation_types.js new file mode 100644 index 0000000000000000000000000000000000000000..314c3a4039a3b8a395b68ce289bbb8f0f7520fe0 --- /dev/null +++ b/app/assets/javascripts/grafana_integration/store/mutation_types.js @@ -0,0 +1,3 @@ +export const SET_GRAFANA_URL = 'SET_GRAFANA_URL'; +export const SET_GRAFANA_TOKEN = 'SET_GRAFANA_TOKEN'; +export const SET_GRAFANA_ENABLED = 'SET_GRAFANA_ENABLED'; diff --git a/app/assets/javascripts/grafana_integration/store/mutations.js b/app/assets/javascripts/grafana_integration/store/mutations.js new file mode 100644 index 0000000000000000000000000000000000000000..0992030d404ad65725242bd6f725719524309ea5 --- /dev/null +++ b/app/assets/javascripts/grafana_integration/store/mutations.js @@ -0,0 +1,13 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_GRAFANA_URL](state, url) { + state.grafanaUrl = url; + }, + [types.SET_GRAFANA_TOKEN](state, token) { + state.grafanaToken = token; + }, + [types.SET_GRAFANA_ENABLED](state, enabled) { + state.grafanaEnabled = enabled; + }, +}; diff --git a/app/assets/javascripts/grafana_integration/store/state.js b/app/assets/javascripts/grafana_integration/store/state.js new file mode 100644 index 0000000000000000000000000000000000000000..a912eb58327a9273af74313e85347cfdfc1bff3b --- /dev/null +++ b/app/assets/javascripts/grafana_integration/store/state.js @@ -0,0 +1,8 @@ +import { parseBoolean } from '~/lib/utils/common_utils'; + +export default (initialState = {}) => ({ + operationsSettingsEndpoint: initialState.operationsSettingsEndpoint, + grafanaToken: initialState.grafanaIntegrationToken || '', + grafanaUrl: initialState.grafanaIntegrationUrl || '', + grafanaEnabled: parseBoolean(initialState.grafanaIntegrationEnabled) || false, +}); diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js index 460174caf4dab7aed2446c2ac3d5fa7ffed5df6c..eda0f5d1d2377ff0ae937ad2e24b86a867e935ae 100644 --- a/app/assets/javascripts/group.js +++ b/app/assets/javascripts/group.js @@ -1,15 +1,23 @@ import $ from 'jquery'; import { slugify } from './lib/utils/text_utility'; +import fetchGroupPathAvailability from '~/pages/groups/new/fetch_group_path_availability'; +import flash from '~/flash'; +import { __ } from '~/locale'; export default class Group { constructor() { this.groupPath = $('#group_path'); this.groupName = $('#group_name'); + this.parentId = $('#group_parent_id'); this.updateHandler = this.update.bind(this); this.resetHandler = this.reset.bind(this); + this.updateGroupPathSlugHandler = this.updateGroupPathSlug.bind(this); if (this.groupName.val() === '') { this.groupName.on('keyup', this.updateHandler); this.groupPath.on('keydown', this.resetHandler); + if (!this.parentId.val()) { + this.groupName.on('blur', this.updateGroupPathSlugHandler); + } } } @@ -21,5 +29,21 @@ export default class Group { reset() { this.groupName.off('keyup', this.updateHandler); this.groupPath.off('keydown', this.resetHandler); + this.groupName.off('blur', this.checkPathHandler); + } + + updateGroupPathSlug() { + const slug = this.groupPath.val() || slugify(this.groupName.val()); + if (!slug) return; + + fetchGroupPathAvailability(slug) + .then(({ data }) => data) + .then(data => { + if (data.exists && data.suggests.length > 0) { + const suggestedSlug = data.suggests[0]; + this.groupPath.val(suggestedSlug); + } + }) + .catch(() => flash(__('An error occurred while checking group path'))); } } diff --git a/app/assets/javascripts/helpers/monitor_helper.js b/app/assets/javascripts/helpers/monitor_helper.js index 2c2a04d5b5e46e1c0e80346ab2bbf19b6cc968c5..d172aa8a444c03b37a28736af149ce4c3a065eee 100644 --- a/app/assets/javascripts/helpers/monitor_helper.js +++ b/app/assets/javascripts/helpers/monitor_helper.js @@ -1,17 +1,31 @@ -/* eslint-disable import/prefer-default-export */ - +/** + * @param {Array} queryResults - Array of Result objects + * @param {Object} defaultConfig - Default chart config values (e.g. lineStyle, name) + * @returns {Array} The formatted values + */ +// eslint-disable-next-line import/prefer-default-export export const makeDataSeries = (queryResults, defaultConfig) => - queryResults.reduce((acc, result) => { - const data = result.values.filter(([, value]) => !Number.isNaN(value)); - if (!data.length) { - return acc; - } - const relevantMetric = defaultConfig.name.toLowerCase().replace(' ', '_'); - const name = result.metric[relevantMetric]; - const series = { data }; - if (name) { - series.name = `${defaultConfig.name}: ${name}`; - } + queryResults + .map(result => { + const data = result.values.filter(([, value]) => !Number.isNaN(value)); + if (!data.length) { + return null; + } + const relevantMetric = defaultConfig.name.toLowerCase().replace(' ', '_'); + const name = result.metric[relevantMetric]; + const series = { data }; + if (name) { + series.name = `${defaultConfig.name}: ${name}`; + } else { + series.name = defaultConfig.name; + Object.keys(result.metric).forEach(templateVar => { + const value = result.metric[templateVar]; + const regex = new RegExp(`{{\\s*${templateVar}\\s*}}`, 'g'); + + series.name = series.name.replace(regex, value); + }); + } - return acc.concat({ ...defaultConfig, ...series }); - }, []); + return { ...defaultConfig, ...series }; + }) + .filter(series => series !== null); diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue index 9ad9d4455b5d6f49f30af3dc69e9c19fab16cef6..52ca61c06b09d1561165a7c3b06c9498d247e79a 100644 --- a/app/assets/javascripts/ide/components/jobs/stage.vue +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -58,6 +58,7 @@ export default { <template> <div class="ide-stage card prepend-top-default"> <div + ref="cardHeader" :class="{ 'border-bottom-0': stage.isCollapsed, }" @@ -79,7 +80,7 @@ export default { </div> <icon :name="collapseIcon" class="ide-stage-collapse-icon" /> </div> - <div v-show="!stage.isCollapsed" class="card-body"> + <div v-show="!stage.isCollapsed" ref="jobList" class="card-body"> <gl-loading-icon v-if="showLoadingIcon" /> <template v-else> <item v-for="job in stage.jobs" :key="job.id" :job="job" @clickViewLog="clickViewLog" /> diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue index 6999746f1159978ed43cbc2820b953b265d9627d..beb179d041130ada53c76a005d53862bbc8feb2f 100644 --- a/app/assets/javascripts/ide/components/preview/clientside.vue +++ b/app/assets/javascripts/ide/components/preview/clientside.vue @@ -92,6 +92,7 @@ export default { }, methods: { ...mapActions(['getFileData', 'getRawFileData']), + ...mapActions('clientside', ['pingUsage']), loadFileContent(path) { return this.getFileData({ path, makeFileActive: false }).then(() => this.getRawFileData({ path }), @@ -100,6 +101,8 @@ export default { initPreview() { if (!this.mainEntry) return null; + this.pingUsage(); + return this.loadFileContent(this.mainEntry) .then(() => this.$nextTick()) .then(() => { diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 3bf8308ccea006aadbfff29c9cadd60beffb9f71..08b3e8a34d69d8b730ba76b16c975d887f4b4ad6 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -301,6 +301,7 @@ export default { v-if="showContentViewer" :content="file.content || file.raw" :path="file.rawPath || file.path" + :file-path="file.path" :file-size="file.size" :project-path="file.projectId" :type="fileType" diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index ba33b6826d672a8ae31a9de5908c69863cbb7e35..f6ad2f9c7d19787461a8cf6f6c705a1c92d8ba1e 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -1,4 +1,6 @@ import axios from '~/lib/utils/axios_utils'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { escapeFileUrl } from '../stores/utils'; import Api from '~/api'; export default { @@ -23,18 +25,25 @@ export default { .then(({ data }) => data); }, getBaseRawFileData(file, sha) { - if (file.tempFile) { - return Promise.resolve(file.baseRaw); - } + if (file.tempFile || file.baseRaw) return Promise.resolve(file.baseRaw); - if (file.baseRaw) { - return Promise.resolve(file.baseRaw); - } + // if files are renamed, their base path has changed + const filePath = + file.mrChange && file.mrChange.renamed_file ? file.mrChange.old_path : file.path; return axios - .get(file.rawPath.replace(`/raw/${file.branchId}/${file.path}`, `/raw/${sha}/${file.path}`), { - transformResponse: [f => f], - }) + .get( + joinPaths( + gon.relative_url_root || '/', + file.projectId, + 'raw', + sha, + escapeFileUrl(filePath), + ), + { + transformResponse: [f => f], + }, + ) .then(({ data }) => data); }, getProjectData(namespace, project) { @@ -58,8 +67,8 @@ export default { commit(projectId, payload) { return Api.commitMultiple(projectId, payload); }, - getFiles(projectUrl, branchId) { - const url = `${projectUrl}/files/${branchId}`; + getFiles(projectUrl, ref) { + const url = `${projectUrl}/files/${ref}`; return axios.get(url, { params: { format: 'json' } }); }, lastCommitPipelines({ getters }) { diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 59445afc7a4408851d26467400663de76a928a42..9af0b50d1a564569f9ff772c1b46b7f87ebf1ff5 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -1,11 +1,10 @@ import { joinPaths } from '~/lib/utils/url_utility'; -import { normalizeHeaders } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import eventHub from '../../eventhub'; import service from '../../services'; import * as types from '../mutation_types'; import router from '../../ide_router'; -import { setPageTitle, replaceFileUrl } from '../utils'; +import { escapeFileUrl, addFinalNewlineIfNeeded, setPageTitleForFile } from '../utils'; import { viewerTypes, stageKeys } from '../../constants'; export const closeFile = ({ commit, state, dispatch }, file) => { @@ -58,7 +57,7 @@ export const setFileActive = ({ commit, state, getters, dispatch }, path) => { }; export const getFileData = ( - { state, commit, dispatch }, + { state, commit, dispatch, getters }, { path, makeFileActive = true, openFile = makeFileActive }, ) => { const file = state.entries[path]; @@ -67,15 +66,18 @@ export const getFileData = ( commit(types.TOGGLE_LOADING, { entry: file }); - const url = file.prevPath ? replaceFileUrl(file.url, file.path, file.prevPath) : file.url; + const url = joinPaths( + gon.relative_url_root || '/', + state.currentProjectId, + file.type, + getters.lastCommit && getters.lastCommit.id, + escapeFileUrl(file.prevPath || file.path), + ); return service - .getFileData(joinPaths(gon.relative_url_root || '', url.replace('/-/', '/'))) - .then(({ data, headers }) => { - const normalizedHeaders = normalizeHeaders(headers); - let title = normalizedHeaders['PAGE-TITLE']; - title = file.prevPath ? title.replace(file.prevPath, file.path) : title; - setPageTitle(decodeURI(title)); + .getFileData(url) + .then(({ data }) => { + setPageTitleForFile(state, file); if (data) commit(types.SET_FILE_DATA, { data, file }); if (openFile) commit(types.TOGGLE_FILE_OPEN, path); @@ -140,7 +142,10 @@ export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) = export const changeFileContent = ({ commit, dispatch, state }, { path, content }) => { const file = state.entries[path]; - commit(types.UPDATE_FILE_CONTENT, { path, content }); + commit(types.UPDATE_FILE_CONTENT, { + path, + content: addFinalNewlineIfNeeded(content), + }); const indexOfChangedFile = state.changedFiles.findIndex(f => f.path === path); diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index 1273e375859246263a364ff8d541e93a06214241..6790c0fbdaa62a5b508411fa4eca8e95a2341a97 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -152,15 +152,17 @@ export const openMergeRequest = ( .then(mr => { dispatch('setCurrentBranchId', mr.source_branch); - dispatch('getBranchData', { + // 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, - }); - - return dispatch('getFiles', { - projectId, - branchId: mr.source_branch, - }); + }).then(() => + dispatch('getFiles', { + projectId, + branchId: mr.source_branch, + }), + ); }) .then(() => dispatch('getMergeRequestVersions', { diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index 75511574d3e1edd6cfccfa6c82b5f4001f8356d1..72cd099c5a53d2581f65383e016c109063b383e1 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -46,7 +46,7 @@ export const setDirectoryData = ({ state, commit }, { projectId, branchId, treeL }); }; -export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = {}) => +export const getFiles = ({ state, commit, dispatch, getters }, { projectId, branchId } = {}) => new Promise((resolve, reject) => { if ( !state.trees[`${projectId}/${branchId}`] || @@ -54,10 +54,11 @@ export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = state.trees[`${projectId}/${branchId}`].tree.length === 0) ) { const selectedProject = state.projects[projectId]; - commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); + const selectedBranch = getters.findBranch(projectId, branchId); + commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); service - .getFiles(selectedProject.web_url, branchId) + .getFiles(selectedProject.web_url, selectedBranch.commit.id) .then(({ data }) => { const { entries, treeList } = decorateFiles({ data, diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index 85fd45358be0febd1d5583ea88b88b574133aa4b..a176fd0aca8dc33073fadd1e742d6c961337d94f 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -34,7 +34,9 @@ export const currentMergeRequest = state => { return null; }; -export const currentProject = state => state.projects[state.currentProjectId]; +export const findProject = state => projectId => state.projects[projectId]; + +export const currentProject = (state, getters) => getters.findProject(state.currentProjectId); export const emptyRepo = state => state.projects[state.currentProjectId] && state.projects[state.currentProjectId].empty_repo; @@ -94,8 +96,14 @@ export const lastCommit = (state, getters) => { return branch ? branch.commit : null; }; +export const findBranch = (state, getters) => (projectId, branchId) => { + const project = getters.findProject(projectId); + + return project && project.branches[branchId]; +}; + export const currentBranch = (state, getters) => - getters.currentProject && getters.currentProject.branches[state.currentBranchId]; + getters.findBranch(state.currentProjectId, state.currentBranchId); export const branchName = (_state, getters) => getters.currentBranch && getters.currentBranch.name; diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js index f1f544b52b22932f4fb89840fbf9311b4288526c..85550578e943aba31dc19d16667d874eef0362a4 100644 --- a/app/assets/javascripts/ide/stores/index.js +++ b/app/assets/javascripts/ide/stores/index.js @@ -10,6 +10,7 @@ import mergeRequests from './modules/merge_requests'; import branches from './modules/branches'; import fileTemplates from './modules/file_templates'; import paneModule from './modules/pane'; +import clientsideModule from './modules/clientside'; Vue.use(Vuex); @@ -26,6 +27,7 @@ export const createStore = () => branches, fileTemplates: fileTemplates(), rightPane: paneModule(), + clientside: clientsideModule(), }, }); diff --git a/app/assets/javascripts/ide/stores/modules/clientside/actions.js b/app/assets/javascripts/ide/stores/modules/clientside/actions.js new file mode 100644 index 0000000000000000000000000000000000000000..eb3bcdff2ae275a3ea7fe8953614f1dda9e316d0 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/clientside/actions.js @@ -0,0 +1,12 @@ +import axios from '~/lib/utils/axios_utils'; + +export const pingUsage = ({ rootGetters }) => { + const { web_url: projectUrl } = rootGetters.currentProject; + + const url = `${projectUrl}/usage_ping/web_ide_clientside_preview`; + + return axios.post(url); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/clientside/index.js b/app/assets/javascripts/ide/stores/modules/clientside/index.js new file mode 100644 index 0000000000000000000000000000000000000000..b28f7b935a8611a0059d51d6cbb16ee435ddb66b --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/clientside/index.js @@ -0,0 +1,6 @@ +import * as actions from './actions'; + +export default () => ({ + namespaced: true, + actions, +}); diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index a8d8ff31afe78e4af9896d075f4b1b73c051f025..be7ee80656f3578fe5be406d51adbed5250711d9 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -113,6 +113,11 @@ export const setPageTitle = title => { document.title = title; }; +export const setPageTitleForFile = (state, file) => { + const title = [file.path, state.currentBranchId, state.currentProjectId, 'GitLab'].join(' · '); + setPageTitle(title); +}; + export const commitActionForFile = file => { if (file.prevPath) { return commitActionTypes.move; @@ -269,3 +274,7 @@ export const pathsAreEqual = (a, b) => { return cleanA === cleanB; }; + +// if the contents of a file dont end with a newline, this function adds a newline +export const addFinalNewlineIfNeeded = content => + content.charAt(content.length - 1) !== '\n' ? `${content}\n` : content; diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js index 74150ce3a8bb7dfe3c66b5bd2dbdfb28b4329f15..bd6e8433544186c31a788114f129e72b2604452e 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js @@ -1,11 +1,13 @@ /* eslint-disable class-methods-use-this, no-new */ import $ from 'jquery'; +import { property } from 'underscore'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import MilestoneSelect from './milestone_select'; import issueStatusSelect from './issue_status_select'; import subscriptionSelect from './subscription_select'; import LabelsSelect from './labels_select'; +import issueableEventHub from './issuables_list/eventhub'; const HIDDEN_CLASS = 'hidden'; const DISABLED_CONTENT_CLASS = 'disabled-content'; @@ -14,6 +16,8 @@ const SIDEBAR_COLLAPSED_CLASS = 'right-sidebar-collapsed issuable-bulk-update-si export default class IssuableBulkUpdateSidebar { constructor() { + this.vueIssuablesListFeature = property(['gon', 'features', 'vueIssuablesList'])(window); + this.initDomElements(); this.bindEvents(); this.initDropdowns(); @@ -41,6 +45,17 @@ export default class IssuableBulkUpdateSidebar { this.$issuesList.on('change', () => this.updateFormState()); this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit()); this.$checkAllContainer.on('click', () => this.updateFormState()); + + if (this.vueIssuablesListFeature) { + issueableEventHub.$on('issuables:updateBulkEdit', () => { + // Danger! Strong coupling ahead! + // The bulk update sidebar and its dropdowns look for .selected-issuable checkboxes, and get data on which issue + // is selected by inspecting the DOM. Ideally, we would pass the selected issuable IDs and their properties + // explicitly, but this component is used in too many places right now to refactor straight away. + + this.updateFormState(); + }); + } } initDropdowns() { @@ -73,6 +88,8 @@ export default class IssuableBulkUpdateSidebar { toggleBulkEdit(e, enable) { e.preventDefault(); + issueableEventHub.$emit('issuables:toggleBulkEdit', enable); + this.toggleSidebarDisplay(enable); this.toggleBulkEditButtonDisabled(enable); this.toggleOtherFiltersDisabled(enable); @@ -106,7 +123,7 @@ export default class IssuableBulkUpdateSidebar { } toggleCheckboxDisplay(show) { - this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show); + this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show || this.vueIssuablesListFeature); this.$issueChecks.toggleClass(HIDDEN_CLASS, !show); } diff --git a/app/assets/javascripts/issuables_list/components/issuable.vue b/app/assets/javascripts/issuables_list/components/issuable.vue new file mode 100644 index 0000000000000000000000000000000000000000..eb924609a8a22ba1b86e73fa1b5d4e5eb5166567 --- /dev/null +++ b/app/assets/javascripts/issuables_list/components/issuable.vue @@ -0,0 +1,335 @@ +<script> +/* + * This is tightly coupled to projects/issues/_issue.html.haml, + * any changes done to the haml need to be reflected here. + */ +import { escape, isNumber } from 'underscore'; +import { GlLink, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { + dateInWords, + formatDate, + getDayDifference, + getTimeago, + timeFor, + newDateAsLocaleTime, +} from '~/lib/utils/datetime_utility'; +import { sprintf, __ } from '~/locale'; +import initUserPopovers from '~/user_popovers'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import Icon from '~/vue_shared/components/icon.vue'; +import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; + +const ISSUE_TOKEN = '#'; + +export default { + components: { + Icon, + IssueAssignees, + GlLink, + }, + directives: { + GlTooltip, + }, + props: { + issuable: { + type: Object, + required: true, + }, + isBulkEditing: { + type: Boolean, + required: false, + default: false, + }, + selected: { + type: Boolean, + required: false, + default: false, + }, + baseUrl: { + type: String, + required: false, + default() { + return window.location.href; + }, + }, + }, + computed: { + milestoneLink() { + const { title } = this.issuable.milestone; + + return this.issuableLink({ milestone_title: title }); + }, + hasLabels() { + return Boolean(this.issuable.labels && this.issuable.labels.length); + }, + hasWeight() { + return isNumber(this.issuable.weight); + }, + dueDate() { + return this.issuable.due_date ? newDateAsLocaleTime(this.issuable.due_date) : undefined; + }, + dueDateWords() { + return this.dueDate ? dateInWords(this.dueDate, true) : undefined; + }, + hasNoComments() { + return !this.userNotesCount; + }, + isOverdue() { + return this.dueDate ? this.dueDate < new Date() : false; + }, + isClosed() { + return this.issuable.state === 'closed'; + }, + issueCreatedToday() { + return getDayDifference(new Date(this.issuable.created_at), new Date()) < 1; + }, + labelIdsString() { + return JSON.stringify(this.issuable.labels.map(l => l.id)); + }, + milestoneDueDate() { + const { due_date: dueDate } = this.issuable.milestone || {}; + + return dueDate ? newDateAsLocaleTime(dueDate) : undefined; + }, + milestoneTooltipText() { + if (this.milestoneDueDate) { + return sprintf(__('%{primary} (%{secondary})'), { + primary: formatDate(this.milestoneDueDate, 'mmm d, yyyy'), + secondary: timeFor(this.milestoneDueDate), + }); + } + return __('Milestone'); + }, + openedAgoByString() { + const { author, created_at } = this.issuable; + + return sprintf( + __('opened %{timeAgoString} by %{user}'), + { + timeAgoString: escape(getTimeago().format(created_at)), + user: `<a href="${escape(author.web_url)}" + data-user-id=${escape(author.id)} + data-username=${escape(author.username)} + data-name=${escape(author.name)} + data-avatar-url="${escape(author.avatar_url)}"> + ${escape(author.name)} + </a>`, + }, + false, + ); + }, + referencePath() { + // TODO: The API should return the reference path (it doesn't now) https://gitlab.com/gitlab-org/gitlab/issues/31301 + return `${ISSUE_TOKEN}${this.issuable.iid}`; + }, + updatedDateString() { + return formatDate(new Date(this.issuable.updated_at), 'mmm d, yyyy h:MMtt'); + }, + updatedDateAgo() { + // snake_case because it's the same i18n string as the HAML view + return sprintf(__('updated %{time_ago}'), { + time_ago: escape(getTimeago().format(this.issuable.updated_at)), + }); + }, + userNotesCount() { + return this.issuable.user_notes_count; + }, + issuableMeta() { + return [ + { + key: 'merge-requests', + value: this.issuable.merge_requests_count, + title: __('Related merge requests'), + class: 'js-merge-requests', + icon: 'merge-request', + }, + { + key: 'upvotes', + value: this.issuable.upvotes, + title: __('Upvotes'), + class: 'js-upvotes', + faicon: 'fa-thumbs-up', + }, + { + key: 'downvotes', + value: this.issuable.downvotes, + title: __('Downvotes'), + class: 'js-downvotes', + faicon: 'fa-thumbs-down', + }, + ]; + }, + }, + mounted() { + // TODO: Refactor user popover to use its own component instead of + // spawning event listeners on Vue-rendered elements. + initUserPopovers([this.$refs.openedAgoByContainer.querySelector('a')]); + }, + methods: { + labelStyle(label) { + return { + backgroundColor: label.color, + color: label.text_color, + }; + }, + issuableLink(params) { + return mergeUrlParams(params, this.baseUrl); + }, + labelHref({ name }) { + return this.issuableLink({ 'label_name[]': name }); + }, + onSelect(ev) { + this.$emit('select', { + issuable: this.issuable, + selected: ev.target.checked, + }); + }, + }, + + confidentialTooltipText: __('Confidential'), +}; +</script> +<template> + <li + :id="`issue_${issuable.id}`" + class="issue" + :class="{ today: issueCreatedToday, closed: isClosed }" + :data-id="issuable.id" + :data-labels="labelIdsString" + :data-url="issuable.web_url" + > + <div class="d-flex"> + <!-- Bulk edit checkbox --> + <div v-if="isBulkEditing" class="mr-2"> + <input + :checked="selected" + class="selected-issuable" + type="checkbox" + :data-id="issuable.id" + @input="onSelect" + /> + </div> + + <!-- Issuable info container --> + <!-- Issuable main info --> + <div class="flex-grow-1"> + <div class="title"> + <span class="issue-title-text"> + <i + v-if="issuable.confidential" + v-gl-tooltip + class="fa fa-eye-slash" + :title="$options.confidentialTooltipText" + :aria-label="$options.confidentialTooltipText" + ></i> + <gl-link :href="issuable.web_url">{{ issuable.title }}</gl-link> + </span> + <span v-if="issuable.has_tasks" class="ml-1 task-status d-none d-sm-inline-block">{{ + issuable.task_status + }}</span> + </div> + + <div class="issuable-info"> + <span>{{ referencePath }}</span> + + <span class="d-none d-sm-inline-block mr-1"> + · + <span ref="openedAgoByContainer" v-html="openedAgoByString"></span> + </span> + + <gl-link + v-if="issuable.milestone" + v-gl-tooltip + class="d-none d-sm-inline-block mr-1 js-milestone" + :href="milestoneLink" + :title="milestoneTooltipText" + > + <i class="fa fa-clock-o"></i> + {{ issuable.milestone.title }} + </gl-link> + + <span + v-if="dueDate" + v-gl-tooltip + class="d-none d-sm-inline-block mr-1 js-due-date" + :class="{ cred: isOverdue }" + :title="__('Due date')" + > + <i class="fa fa-calendar"></i> + {{ dueDateWords }} + </span> + + <span v-if="hasLabels" class="js-labels"> + <gl-link + v-for="label in issuable.labels" + :key="label.id" + class="label-link mr-1" + :href="labelHref(label)" + > + <span + v-gl-tooltip + class="badge color-label" + :style="labelStyle(label)" + :title="label.description" + >{{ label.name }}</span + > + </gl-link> + </span> + + <span + v-if="hasWeight" + v-gl-tooltip + :title="__('Weight')" + class="d-none d-sm-inline-block js-weight" + > + <icon name="weight" class="align-text-bottom" /> + {{ issuable.weight }} + </span> + </div> + </div> + + <!-- Issuable meta --> + <div class="flex-shrink-0 d-flex flex-column align-items-end justify-content-center"> + <div class="controls d-flex"> + <span v-if="isClosed" class="issuable-status">{{ __('CLOSED') }}</span> + + <issue-assignees + :assignees="issuable.assignees" + class="align-items-center d-flex ml-2" + :icon-size="16" + img-css-classes="mr-1" + :max-visible="4" + /> + + <template v-for="meta in issuableMeta"> + <span + v-if="meta.value" + :key="meta.key" + v-gl-tooltip + :class="['d-none d-sm-inline-block ml-2', meta.class]" + :title="meta.title" + > + <icon v-if="meta.icon" :name="meta.icon" /> + <i v-else :class="['fa', meta.faicon]"></i> + {{ meta.value }} + </span> + </template> + + <gl-link + v-gl-tooltip + class="ml-2 js-notes" + :href="`${issuable.web_url}#notes`" + :title="__('Comments')" + :class="{ 'no-comments': hasNoComments }" + > + <i class="fa fa-comments"></i> + {{ userNotesCount }} + </gl-link> + </div> + <div v-gl-tooltip class="issuable-updated-at" :title="updatedDateString"> + {{ updatedDateAgo }} + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/issuables_list/components/issuables_list_app.vue b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue new file mode 100644 index 0000000000000000000000000000000000000000..6b6a8bd4068b6499d3fc6f3ef85a0a68806c317e --- /dev/null +++ b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue @@ -0,0 +1,277 @@ +<script> +import { omit } from 'underscore'; +import { GlEmptyState, GlPagination, GlSkeletonLoading } from '@gitlab/ui'; +import flash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { scrollToElement, urlParamsToObject } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import initManualOrdering from '~/manual_ordering'; +import Issuable from './issuable.vue'; +import { + sortOrderMap, + RELATIVE_POSITION, + PAGE_SIZE, + PAGE_SIZE_MANUAL, + LOADING_LIST_ITEMS_LENGTH, +} from '../constants'; +import issueableEventHub from '../eventhub'; + +export default { + LOADING_LIST_ITEMS_LENGTH, + components: { + GlEmptyState, + GlPagination, + GlSkeletonLoading, + Issuable, + }, + props: { + canBulkEdit: { + type: Boolean, + required: false, + default: false, + }, + createIssuePath: { + type: String, + required: false, + default: '', + }, + emptySvgPath: { + type: String, + required: false, + default: '', + }, + endpoint: { + type: String, + required: true, + }, + sortKey: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + filters: {}, + isBulkEditing: false, + issuables: [], + loading: false, + page: 1, + selection: {}, + totalItems: 0, + }; + }, + computed: { + allIssuablesSelected() { + // WARNING: Because we are only keeping track of selected values + // this works, we will need to rethink this if we start tracking + // [id]: false for not selected values. + return this.issuables.length === Object.keys(this.selection).length; + }, + emptyState() { + if (this.issuables.length) { + return {}; // Empty state shouldn't be shown here + } else if (this.hasFilters) { + return { + title: __('Sorry, your filter produced no results'), + description: __('To widen your search, change or remove filters above'), + }; + } else if (this.filters.state === 'opened') { + return { + title: __('There are no open issues'), + description: __('To keep this project going, create a new issue'), + primaryLink: this.createIssuePath, + primaryText: __('New issue'), + }; + } else if (this.filters.state === 'closed') { + return { + title: __('There are no closed issues'), + }; + } + + return { + title: __('There are no issues to show'), + 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.', + ), + }; + }, + hasFilters() { + const ignored = ['utf8', 'state', 'scope', 'order_by', 'sort']; + return Object.keys(omit(this.filters, ignored)).length > 0; + }, + isManualOrdering() { + return this.sortKey === RELATIVE_POSITION; + }, + itemsPerPage() { + return this.isManualOrdering ? PAGE_SIZE_MANUAL : PAGE_SIZE; + }, + baseUrl() { + return window.location.href.replace(/(\?.*)?(#.*)?$/, ''); + }, + }, + watch: { + selection() { + // We need to call nextTick here to wait for all of the boxes to be checked and rendered + // before we query the dom in issuable_bulk_update_actions.js. + this.$nextTick(() => { + issueableEventHub.$emit('issuables:updateBulkEdit'); + }); + }, + issuables() { + this.$nextTick(() => { + initManualOrdering(); + }); + }, + }, + mounted() { + if (this.canBulkEdit) { + this.unsubscribeToggleBulkEdit = issueableEventHub.$on('issuables:toggleBulkEdit', val => { + this.isBulkEditing = val; + }); + } + this.fetchIssuables(); + }, + beforeDestroy() { + issueableEventHub.$off('issuables:toggleBulkEdit'); + }, + methods: { + isSelected(issuableId) { + return Boolean(this.selection[issuableId]); + }, + setSelection(ids) { + ids.forEach(id => { + this.select(id, true); + }); + }, + clearSelection() { + this.selection = {}; + }, + select(id, isSelect = true) { + if (isSelect) { + this.$set(this.selection, id, true); + } else { + this.$delete(this.selection, id); + } + }, + fetchIssuables(pageToFetch) { + this.loading = true; + + this.clearSelection(); + + this.setFilters(); + + return axios + .get(this.endpoint, { + params: { + ...this.filters, + + with_labels_details: true, + page: pageToFetch || this.page, + per_page: this.itemsPerPage, + }, + }) + .then(response => { + this.loading = false; + this.issuables = response.data; + this.totalItems = Number(response.headers['x-total']); + this.page = Number(response.headers['x-page']); + }) + .catch(() => { + this.loading = false; + return flash(__('An error occurred while loading issues')); + }); + }, + getQueryObject() { + return urlParamsToObject(window.location.search); + }, + onPaginate(newPage) { + if (newPage === this.page) return; + + scrollToElement('#content-body'); + this.fetchIssuables(newPage); + }, + onSelectAll() { + if (this.allIssuablesSelected) { + this.selection = {}; + } else { + this.setSelection(this.issuables.map(({ id }) => id)); + } + }, + onSelectIssuable({ issuable, selected }) { + if (!this.canBulkEdit) return; + + this.select(issuable.id, selected); + }, + setFilters() { + const { + label_name: labels, + milestone_title: milestoneTitle, + ...filters + } = this.getQueryObject(); + + if (milestoneTitle) { + filters.milestone = milestoneTitle; + } + if (Array.isArray(labels)) { + filters.labels = labels.join(','); + } + if (!filters.state) { + filters.state = 'opened'; + } + + Object.assign(filters, sortOrderMap[this.sortKey]); + + this.filters = filters; + }, + }, +}; +</script> + +<template> + <ul v-if="loading" class="content-list"> + <li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue"> + <gl-skeleton-loading /> + </li> + </ul> + <div v-else-if="issuables.length"> + <div v-if="isBulkEditing" class="issue px-3 py-3 border-bottom border-light"> + <input type="checkbox" :checked="allIssuablesSelected" class="mr-2" @click="onSelectAll" /> + <strong>{{ __('Select all') }}</strong> + </div> + <ul + class="content-list issuable-list issues-list" + :class="{ 'manual-ordering': isManualOrdering }" + > + <issuable + v-for="issuable in issuables" + :key="issuable.id" + class="pr-3" + :class="{ 'user-can-drag': isManualOrdering }" + :issuable="issuable" + :is-bulk-editing="isBulkEditing" + :selected="isSelected(issuable.id)" + :base-url="baseUrl" + @select="onSelectIssuable" + /> + </ul> + <div class="mt-3"> + <gl-pagination + v-if="totalItems" + :value="page" + :per-page="itemsPerPage" + :total-items="totalItems" + class="justify-content-center" + @input="onPaginate" + /> + </div> + </div> + <gl-empty-state + v-else + :title="emptyState.title" + :description="emptyState.description" + :svg-path="emptySvgPath" + :primary-button-link="emptyState.primaryLink" + :primary-button-text="emptyState.primaryText" + /> +</template> diff --git a/app/assets/javascripts/issuables_list/constants.js b/app/assets/javascripts/issuables_list/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..71b9c52c7031c804682c644fd2a44673ea6e254e --- /dev/null +++ b/app/assets/javascripts/issuables_list/constants.js @@ -0,0 +1,33 @@ +// Maps sort order as it appears in the URL query to API `order_by` and `sort` params. +const PRIORITY = 'priority'; +const ASC = 'asc'; +const DESC = 'desc'; +const CREATED_AT = 'created_at'; +const UPDATED_AT = 'updated_at'; +const DUE_DATE = 'due_date'; +const MILESTONE_DUE = 'milestone_due'; +const POPULARITY = 'popularity'; +const WEIGHT = 'weight'; +const LABEL_PRIORITY = 'label_priority'; +export const RELATIVE_POSITION = 'relative_position'; +export const LOADING_LIST_ITEMS_LENGTH = 8; +export const PAGE_SIZE = 20; +export const PAGE_SIZE_MANUAL = 100; + +export const sortOrderMap = { + priority: { order_by: PRIORITY, sort: ASC }, // asc and desc are flipped for some reason + created_date: { order_by: CREATED_AT, sort: DESC }, + created_asc: { order_by: CREATED_AT, sort: ASC }, + updated_desc: { order_by: UPDATED_AT, sort: DESC }, + updated_asc: { order_by: UPDATED_AT, sort: ASC }, + milestone_due_desc: { order_by: MILESTONE_DUE, sort: DESC }, + milestone: { order_by: MILESTONE_DUE, sort: ASC }, + due_date_desc: { order_by: DUE_DATE, sort: DESC }, + due_date: { order_by: DUE_DATE, sort: ASC }, + popularity: { order_by: POPULARITY, sort: DESC }, + popularity_asc: { order_by: POPULARITY, sort: ASC }, + label_priority: { order_by: LABEL_PRIORITY, sort: ASC }, // asc and desc are flipped + relative_position: { order_by: RELATIVE_POSITION, sort: ASC }, + weight_desc: { order_by: WEIGHT, sort: DESC }, + weight: { order_by: WEIGHT, sort: ASC }, +}; diff --git a/app/assets/javascripts/issuables_list/eventhub.js b/app/assets/javascripts/issuables_list/eventhub.js new file mode 100644 index 0000000000000000000000000000000000000000..d1601a7d8f3196c6ecc95417fa3d3780a70758e5 --- /dev/null +++ b/app/assets/javascripts/issuables_list/eventhub.js @@ -0,0 +1,5 @@ +import Vue from 'vue'; + +const issueablesEventBus = new Vue(); + +export default issueablesEventBus; diff --git a/app/assets/javascripts/issuables_list/index.js b/app/assets/javascripts/issuables_list/index.js new file mode 100644 index 0000000000000000000000000000000000000000..9fc7fa837ffedb84db3cc0c0b50c9ae6e7c93f9f --- /dev/null +++ b/app/assets/javascripts/issuables_list/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import IssuablesListApp from './components/issuables_list_app.vue'; + +export default function initIssuablesList() { + if (!gon.features || !gon.features.vueIssuablesList) { + return; + } + + document.querySelectorAll('.js-issuables-list').forEach(el => { + const { canBulkEdit, ...data } = el.dataset; + + const props = { + ...data, + canBulkEdit: Boolean(canBulkEdit), + }; + + return new Vue({ + el, + render(createElement) { + return createElement(IssuablesListApp, { props }); + }, + }); + }); +} diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index a9e086fade8850e600554db389cd91866956e86f..9136a47d5429912503baa5f8712aa5d397ee3c24 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -1,4 +1,4 @@ -/* eslint-disable no-var, one-var, consistent-return */ +/* eslint-disable consistent-return */ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; @@ -91,18 +91,17 @@ export default class Issue { 'click', '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen', e => { - var $button, shouldSubmit, url; e.preventDefault(); e.stopImmediatePropagation(); - $button = $(e.currentTarget); - shouldSubmit = $button.hasClass('btn-comment'); + const $button = $(e.currentTarget); + const shouldSubmit = $button.hasClass('btn-comment'); if (shouldSubmit) { Issue.submitNoteForm($button.closest('form')); } this.disableCloseReopenButton($button); - url = $button.attr('href'); + const url = $button.attr('href'); return axios .put(url) .then(({ data }) => { @@ -139,16 +138,14 @@ export default class Issue { } static submitNoteForm(form) { - var noteText; - noteText = form.find('textarea.js-note-text').val(); + const noteText = form.find('textarea.js-note-text').val(); if (noteText && noteText.trim().length > 0) { return form.submit(); } } static initRelatedBranches() { - var $container; - $container = $('#related-branches'); + const $container = $('#related-branches'); return axios .get($container.data('url')) .then(({ data }) => { diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue index ef126166e8bd9c1520d4d1466425c0b5ef414b7b..03a697d11ed5d4504e4419c8fb6378b2032a7a4d 100644 --- a/app/assets/javascripts/jobs/components/log/log.vue +++ b/app/assets/javascripts/jobs/components/log/log.vue @@ -11,11 +11,35 @@ export default { computed: { ...mapState(['traceEndpoint', 'trace', 'isTraceComplete']), }, + updated() { + this.$nextTick(() => { + this.handleScrollDown(); + }); + }, + mounted() { + this.$nextTick(() => { + this.handleScrollDown(); + }); + }, methods: { - ...mapActions(['toggleCollapsibleLine']), + ...mapActions(['toggleCollapsibleLine', 'scrollBottom']), handleOnClickCollapsibleLine(section) { this.toggleCollapsibleLine(section); }, + /** + * The job log is sent in HTML, which means we need to use `v-html` to render it + * Using the updated hook with $nextTick is not enough to wait for the DOM to be updated + * in this case because it runs before `v-html` has finished running, since there's no + * Vue binding. + * In order to scroll the page down after `v-html` has finished, we need to use setTimeout + */ + handleScrollDown() { + if (this.isScrolledToBottomBeforeReceivingTrace) { + setTimeout(() => { + this.scrollBottom(); + }, 0); + } + }, }, }; </script> diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js index 58e49f54d96ed7178fe00788ea18a46eeb0719a2..179d0bc4e0fd830074795d6419d42e822b2e9edd 100644 --- a/app/assets/javascripts/jobs/store/utils.js +++ b/app/assets/javascripts/jobs/store/utils.js @@ -17,7 +17,7 @@ export const parseLine = (line = {}, lineNumber) => ({ * @param Number lineNumber */ export const parseHeaderLine = (line = {}, lineNumber) => ({ - isClosed: true, + isClosed: false, isHeader: true, line: parseLine(line, lineNumber), lines: [], diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 72de3b5d726238653e4c20c7f4df8504631cde58..6abf723be9ac873edc55cf3ea6dd8b7586362b6c 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -1,4 +1,4 @@ -/* eslint-disable no-useless-return, func-names, no-var, no-underscore-dangle, one-var, no-new, consistent-return, no-shadow, no-param-reassign, vars-on-top, no-lonely-if, no-else-return, dot-notation, no-empty */ +/* eslint-disable no-useless-return, func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, no-else-return, dot-notation, no-empty */ /* global Issuable */ /* global ListLabel */ @@ -15,63 +15,39 @@ import { isScopedLabel } from '~/lib/utils/common_utils'; export default class LabelsSelect { constructor(els, options = {}) { - var _this, $els; - _this = this; + const _this = this; - $els = $(els); + let $els = $(els); if (!els) { $els = $('.js-label-select'); } $els.each((i, dropdown) => { - var $block, - $dropdown, - $form, - $loading, - $selectbox, - $sidebarCollapsedValue, - $value, - $dropdownMenu, - abilityName, - defaultLabel, - issueUpdateURL, - labelUrl, - namespacePath, - projectPath, - saveLabelData, - selectedLabel, - showAny, - showNo, - $sidebarLabelTooltip, - initialSelected, - fieldName, - showMenuAbove, - $dropdownContainer; - $dropdown = $(dropdown); - $dropdownContainer = $dropdown.closest('.labels-filter'); - namespacePath = $dropdown.data('namespacePath'); - projectPath = $dropdown.data('projectPath'); - issueUpdateURL = $dropdown.data('issueUpdate'); - selectedLabel = $dropdown.data('selected'); + const $dropdown = $(dropdown); + const $dropdownContainer = $dropdown.closest('.labels-filter'); + const namespacePath = $dropdown.data('namespacePath'); + const projectPath = $dropdown.data('projectPath'); + const issueUpdateURL = $dropdown.data('issueUpdate'); + let selectedLabel = $dropdown.data('selected'); if (selectedLabel != null && !$dropdown.hasClass('js-multiselect')) { selectedLabel = selectedLabel.split(','); } - showNo = $dropdown.data('showNo'); - showAny = $dropdown.data('showAny'); - showMenuAbove = $dropdown.data('showMenuAbove'); - defaultLabel = $dropdown.data('defaultLabel') || __('Label'); - abilityName = $dropdown.data('abilityName'); - $selectbox = $dropdown.closest('.selectbox'); - $block = $selectbox.closest('.block'); - $form = $dropdown.closest('form, .js-issuable-update'); - $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); - $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip'); - $value = $block.find('.value'); - $dropdownMenu = $dropdown.parent().find('.dropdown-menu'); - $loading = $block.find('.block-loading').fadeOut(); - fieldName = $dropdown.data('fieldName'); - initialSelected = $selectbox + const showNo = $dropdown.data('showNo'); + const showAny = $dropdown.data('showAny'); + const showMenuAbove = $dropdown.data('showMenuAbove'); + const defaultLabel = $dropdown.data('defaultLabel') || __('Label'); + const abilityName = $dropdown.data('abilityName'); + const $selectbox = $dropdown.closest('.selectbox'); + const $block = $selectbox.closest('.block'); + const $form = $dropdown.closest('form, .js-issuable-update'); + const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); + const $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip'); + const $value = $block.find('.value'); + const $dropdownMenu = $dropdown.parent().find('.dropdown-menu'); + const $loading = $block.find('.block-loading').fadeOut(); + const fieldName = $dropdown.data('fieldName'); + let initialSelected = $selectbox .find(`input[name="${$dropdown.data('fieldName')}"]`) .map(function() { return this.value; @@ -90,9 +66,8 @@ export default class LabelsSelect { ); } - saveLabelData = function() { - var data, selected; - selected = $dropdown + const saveLabelData = function() { + const selected = $dropdown .closest('.selectbox') .find(`input[name='${fieldName}']`) .map(function() { @@ -103,7 +78,7 @@ export default class LabelsSelect { if (_.isEqual(initialSelected, selected)) return; initialSelected = selected; - data = {}; + const data = {}; data[abilityName] = {}; data[abilityName].label_ids = selected; if (!selected.length) { @@ -114,12 +89,13 @@ export default class LabelsSelect { axios .put(issueUpdateURL, data) .then(({ data }) => { - var labelCount, template, labelTooltipTitle, labelTitles; + let labelTooltipTitle; + let template; $loading.fadeOut(); $dropdown.trigger('loaded.gl.dropdown'); $selectbox.hide(); data.issueUpdateURL = issueUpdateURL; - labelCount = 0; + let labelCount = 0; if (data.labels.length && issueUpdateURL) { template = LabelsSelect.getLabelTemplate({ labels: _.sortBy(data.labels, 'title'), @@ -174,7 +150,7 @@ export default class LabelsSelect { $sidebarCollapsedValue.text(labelCount); if (data.labels.length) { - labelTitles = data.labels.map(label => label.title); + let labelTitles = data.labels.map(label => label.title); if (labelTitles.length > 5) { labelTitles = labelTitles.slice(0, 5); @@ -199,13 +175,13 @@ export default class LabelsSelect { $dropdown.glDropdown({ showMenuAbove, data(term, callback) { - labelUrl = $dropdown.attr('data-labels'); + const labelUrl = $dropdown.attr('data-labels'); axios .get(labelUrl) .then(res => { let { data } = res; if ($dropdown.hasClass('js-extra-options')) { - var extraData = []; + const extraData = []; if (showNo) { extraData.unshift({ id: 0, @@ -232,22 +208,14 @@ export default class LabelsSelect { .catch(() => flash(__('Error fetching labels.'))); }, renderRow(label) { - var linkEl, - listItemEl, - colorEl, - indeterminate, - removesAll, - selectedClass, - i, - marked, - dropdownValue; - - selectedClass = []; - removesAll = label.id <= 0 || label.id == null; + let colorEl; + + const selectedClass = []; + const removesAll = label.id <= 0 || label.id == null; if ($dropdown.hasClass('js-filter-bulk-update')) { - indeterminate = $dropdown.data('indeterminate') || []; - marked = $dropdown.data('marked') || []; + const indeterminate = $dropdown.data('indeterminate') || []; + const marked = $dropdown.data('marked') || []; if (indeterminate.indexOf(label.id) !== -1) { selectedClass.push('is-indeterminate'); @@ -255,7 +223,7 @@ export default class LabelsSelect { if (marked.indexOf(label.id) !== -1) { // Remove is-indeterminate class if the item will be marked as active - i = selectedClass.indexOf('is-indeterminate'); + const i = selectedClass.indexOf('is-indeterminate'); if (i !== -1) { selectedClass.splice(i, 1); } @@ -263,7 +231,7 @@ export default class LabelsSelect { } } else { if (this.id(label)) { - dropdownValue = this.id(label) + const dropdownValue = this.id(label) .toString() .replace(/'/g, "\\'"); @@ -287,7 +255,7 @@ export default class LabelsSelect { colorEl = ''; } - linkEl = document.createElement('a'); + const linkEl = document.createElement('a'); linkEl.href = '#'; // We need to identify which items are actually labels @@ -300,7 +268,7 @@ export default class LabelsSelect { linkEl.className = selectedClass.join(' '); linkEl.innerHTML = `${colorEl} ${_.escape(label.title)}`; - listItemEl = document.createElement('li'); + const listItemEl = document.createElement('li'); listItemEl.appendChild(linkEl); return listItemEl; @@ -312,12 +280,12 @@ export default class LabelsSelect { filterable: true, selected: $dropdown.data('selected') || [], toggleLabel(selected, el) { - var $dropdownParent = $dropdown.parent(); - var $dropdownInputField = $dropdownParent.find('.dropdown-input-field'); - var isSelected = el !== null ? el.hasClass('is-active') : false; + const $dropdownParent = $dropdown.parent(); + const $dropdownInputField = $dropdownParent.find('.dropdown-input-field'); + const isSelected = el !== null ? el.hasClass('is-active') : false; - var title = selected ? selected.title : null; - var selectedLabels = this.selected; + const title = selected ? selected.title : null; + const selectedLabels = this.selected; if ($dropdownInputField.length && $dropdownInputField.val().length) { $dropdownParent.find('.dropdown-input-clear').trigger('click'); @@ -329,7 +297,7 @@ export default class LabelsSelect { } else if (isSelected) { this.selected.push(title); } else if (!isSelected && title) { - var index = this.selected.indexOf(title); + const index = this.selected.indexOf(title); this.selected.splice(index, 1); } @@ -359,10 +327,9 @@ export default class LabelsSelect { } }, hidden() { - var isIssueIndex, isMRIndex, page; - page = $('body').attr('data-page'); - isIssueIndex = page === 'projects:issues:index'; - isMRIndex = page === 'projects:merge_requests:index'; + const page = $('body').attr('data-page'); + const isIssueIndex = page === 'projects:issues:index'; + const isMRIndex = page === 'projects:merge_requests:index'; $selectbox.hide(); // display:block overrides the hide-collapse rule $value.removeAttr('style'); @@ -393,14 +360,13 @@ export default class LabelsSelect { const { $el, e, isMarking } = clickEvent; const label = clickEvent.selectedObj; - var isIssueIndex, isMRIndex, page, boardsModel; - var fadeOutLoader = () => { + const fadeOutLoader = () => { $loading.fadeOut(); }; - page = $('body').attr('data-page'); - isIssueIndex = page === 'projects:issues:index'; - isMRIndex = page === 'projects:merge_requests:index'; + const page = $('body').attr('data-page'); + const isIssueIndex = page === 'projects:issues:index'; + const isMRIndex = page === 'projects:merge_requests:index'; if ($dropdown.parent().find('.is-active:not(.dropdown-clear-active)').length) { $dropdown @@ -419,6 +385,7 @@ export default class LabelsSelect { return; } + let boardsModel; if ($dropdown.closest('.add-issues-modal').length) { boardsModel = ModalStore.store.filter; } @@ -450,7 +417,7 @@ export default class LabelsSelect { }), ); } else { - var { labels } = boardsStore.detail.issue; + let { labels } = boardsStore.detail.issue; labels = labels.filter(selectedLabel => selectedLabel.id !== label.id); boardsStore.detail.issue.labels = labels; } @@ -578,16 +545,14 @@ export default class LabelsSelect { } // eslint-disable-next-line class-methods-use-this setDropdownData($dropdown, isMarking, value) { - var i, markedIds, unmarkedIds, indeterminateIds; - - markedIds = $dropdown.data('marked') || []; - unmarkedIds = $dropdown.data('unmarked') || []; - indeterminateIds = $dropdown.data('indeterminate') || []; + const markedIds = $dropdown.data('marked') || []; + const unmarkedIds = $dropdown.data('unmarked') || []; + const indeterminateIds = $dropdown.data('indeterminate') || []; if (isMarking) { markedIds.push(value); - i = indeterminateIds.indexOf(value); + let i = indeterminateIds.indexOf(value); if (i > -1) { indeterminateIds.splice(i, 1); } @@ -598,7 +563,7 @@ export default class LabelsSelect { } } else { // If marked item (not common) is unmarked - i = markedIds.indexOf(value); + const i = markedIds.indexOf(value); if (i > -1) { markedIds.splice(i, 1); } diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index c05db4a5c718d549609f64abe0e6e7bdf897b6db..2c5278d16aed375f4929f4e23e42cc4c1a666f27 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -26,7 +26,11 @@ export default (resolvers = {}, config = {}) => { createUploadLink(httpOptions), new BatchHttpLink(httpOptions), ), - cache: new InMemoryCache(config.cacheConfig), + cache: new InMemoryCache({ + ...config.cacheConfig, + freezeResults: config.assumeImmutableResults, + }), resolvers, + assumeImmutableResults: config.assumeImmutableResults, }); }; diff --git a/app/assets/javascripts/lib/utils/chart_utils.js b/app/assets/javascripts/lib/utils/chart_utils.js index 0f78756aac843e20a127a32762e5efa5c5c67cb3..4a1e6c5d68cdc4a0f8bcde181ee7863c6a6580d8 100644 --- a/app/assets/javascripts/lib/utils/chart_utils.js +++ b/app/assets/javascripts/lib/utils/chart_utils.js @@ -81,3 +81,20 @@ export const lineChartOptions = ({ width, numberOfPoints, shouldAdjustFontSize } }, }, }); + +/** + * Takes a dataset and returns an array containing the y-values of it's first and last entry. + * (e.g., [['xValue1', 'yValue1'], ['xValue2', 'yValue2'], ['xValue3', 'yValue3']] will yield ['yValue1', 'yValue3']) + * + * @param {Array} data + * @returns {[*, *]} + */ +export const firstAndLastY = data => { + const [firstEntry] = data; + const [lastEntry] = data.slice(-1); + + const firstY = firstEntry[1]; + const lastY = lastEntry[1]; + + return [firstY, lastY]; +}; diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 37b0215f6f97836cd3b1dc02ebc07ee5b2a41656..28143859e4cfd30c50bac3364e9377963e66a358 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -78,11 +78,11 @@ export const getDayName = date => * @param {date} datetime * @returns {String} */ -export const formatDate = datetime => { +export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z') => { if (_.isString(datetime) && datetime.match(/\d+-\d+\d+ /)) { throw new Error(__('Invalid date')); } - return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z'); + return dateFormat(datetime, format); }; /** @@ -541,7 +541,7 @@ export const stringifyTime = (timeObject, fullNameFormat = false) => { * The result cannot become negative. * * @param endDate date string that the time difference is calculated for - * @return {number} number of milliseconds remaining until the given date + * @return {Number} number of milliseconds remaining until the given date */ export const calculateRemainingMilliseconds = endDate => { const remainingMilliseconds = new Date(endDate).getTime() - Date.now(); @@ -552,15 +552,53 @@ export const calculateRemainingMilliseconds = endDate => { * Subtracts a given number of days from a given date and returns the new date. * * @param {Date} date the date that we will substract days from - * @param {number} daysInPast number of days that are subtracted from a given date - * @returns {String} Date string in ISO format + * @param {Number} daysInPast number of days that are subtracted from a given date + * @returns {Date} Date in past as Date object */ -export const getDateInPast = (date, daysInPast) => { - const dateClone = newDate(date); - return new Date( - dateClone.setTime(dateClone.getTime() - daysInPast * 24 * 60 * 60 * 1000), - ).toISOString(); +export const getDateInPast = (date, daysInPast) => + new Date(newDate(date).setDate(date.getDate() - daysInPast)); + +/* + * 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 + * be consistent with the "edit issue -> due date" UI. + */ + +export const newDateAsLocaleTime = date => { + const suffix = 'T00:00:00'; + return new Date(`${date}${suffix}`); }; export const beginOfDayTime = 'T00:00:00Z'; export const endOfDayTime = 'T23:59:59Z'; + +/** + * @param {Date} d1 + * @param {Date} d2 + * @param {Function} formatter + * @return {Any[]} an array of formatted dates between 2 given dates (including start&end date) + */ +export const getDatesInRange = (d1, d2, formatter = x => x) => { + if (!(d1 instanceof Date) || !(d2 instanceof Date)) { + return []; + } + let startDate = d1.getTime(); + const endDate = d2.getTime(); + const oneDay = 24 * 3600 * 1000; + const range = [d1]; + + while (startDate < endDate) { + startDate += oneDay; + range.push(new Date(startDate)); + } + + return range.map(formatter); +}; + +/** + * Converts the supplied number of seconds to milliseconds. + * + * @param {Number} seconds + * @return {Number} number of milliseconds + */ +export const secondsToMilliseconds = seconds => seconds * 1000; diff --git a/app/assets/javascripts/lib/utils/notify.js b/app/assets/javascripts/lib/utils/notify.js index cd509a131932aada15ab1b1509a69cd459641d2e..8db08099b3fd7cefb09ae2dbadd25f585e53426a 100644 --- a/app/assets/javascripts/lib/utils/notify.js +++ b/app/assets/javascripts/lib/utils/notify.js @@ -1,8 +1,7 @@ -/* eslint-disable no-var, consistent-return, no-return-assign */ +/* eslint-disable consistent-return, no-return-assign */ function notificationGranted(message, opts, onclick) { - var notification; - notification = new Notification(message, opts); + const notification = new Notification(message, opts); setTimeout( () => // Hide the notification after X amount of seconds @@ -21,8 +20,7 @@ function notifyPermissions() { } function notifyMe(message, body, icon, onclick) { - var opts; - opts = { + const opts = { body, icon, }; diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index 0f2cc57b1f94cc205f057d146db228276a977ae9..bc87232f40b60283d846ce01fce6d7685dec5ccc 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -117,3 +117,36 @@ export const median = arr => { const sorted = arr.sort((a, b) => a - b); return arr.length % 2 !== 0 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; }; + +/** + * Computes the change from one value to the other as a percentage. + * @param {Number} firstY + * @param {Number} lastY + * @returns {Number} + */ +export const changeInPercent = (firstY, lastY) => { + if (firstY === lastY) { + return 0; + } + + return Math.round(((lastY - firstY) / Math.abs(firstY)) * 100); +}; + +/** + * Computes and formats the change from one value to the other as a percentage. + * Prepends the computed percentage with either "+" or "-" to indicate an in- or decrease and + * returns a given string if the result is not finite (for example, if the first value is "0"). + * @param firstY + * @param lastY + * @param nonFiniteResult + * @returns {String} + */ +export const formattedChangeInPercent = (firstY, lastY, { nonFiniteResult = '-' } = {}) => { + const change = changeInPercent(firstY, lastY); + + if (!Number.isFinite(change)) { + return nonFiniteResult; + } + + return `${change >= 0 ? '+' : ''}${change}%`; +}; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index d13fbeb5fc743634b455cb0f05de389841339639..0c194d67bce1b2f4caf712b12c62b810d5ce4dba 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -36,25 +36,26 @@ export const humanize = string => export const dasherize = str => str.replace(/[_\s]+/g, '-'); /** - * Replaces whitespaces with hyphens, convert to lower case and remove non-allowed special characters - * @param {String} str + * Replaces whitespace and non-sluggish characters with a given separator + * @param {String} str - The string to slugify + * @param {String=} separator - The separator used to separate words (defaults to "-") * @returns {String} */ -export const slugify = str => { +export const slugify = (str, separator = '-') => { const slug = str .trim() .toLowerCase() - .replace(/[^a-zA-Z0-9_.-]+/g, '-'); + .replace(/[^a-zA-Z0-9_.-]+/g, separator); - return slug === '-' ? '' : slug; + return slug === separator ? '' : slug; }; /** - * Replaces whitespaces with underscore and converts to lower case + * Replaces whitespace and non-sluggish characters with underscores * @param {String} str * @returns {String} */ -export const slugifyWithUnderscore = str => str.toLowerCase().replace(/\s+/g, '_'); +export const slugifyWithUnderscore = str => slugify(str, '_'); /** * Truncates given text @@ -138,6 +139,14 @@ export const stripHtml = (string, replace = '') => { */ export const convertToCamelCase = string => string.replace(/(_\w)/g, s => s[1].toUpperCase()); +/** + * Converts camelCase string to snake_case + * + * @param {*} string + */ +export const convertToSnakeCase = string => + slugifyWithUnderscore(string.match(/([a-zA-Z][^A-Z]*)/g).join(' ')); + /** * Converts a sentence to lower case from the second word onwards * e.g. Hello World => Hello world diff --git a/app/assets/javascripts/lib/utils/tick_formats.js b/app/assets/javascripts/lib/utils/tick_formats.js deleted file mode 100644 index af3ca7144008fec9a8a491fadc1d660a6e1be857..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/lib/utils/tick_formats.js +++ /dev/null @@ -1,39 +0,0 @@ -import { createDateTimeFormat } from '../../locale'; - -let dateTimeFormats; - -export const initDateFormats = () => { - const dayFormat = createDateTimeFormat({ month: 'short', day: 'numeric' }); - const monthFormat = createDateTimeFormat({ month: 'long' }); - const yearFormat = createDateTimeFormat({ year: 'numeric' }); - - dateTimeFormats = { - dayFormat, - monthFormat, - yearFormat, - }; -}; - -initDateFormats(); - -/** - Formats a localized date in way that it can be used for d3.js axis.tickFormat(). - - That is, it displays - - 4-digit for first of January - - full month name for first of every month - - day and abbreviated month otherwise - - see also https://github.com/d3/d3-3.x-api-reference/blob/master/SVG-Axes.md#tickFormat - */ -export const dateTickFormat = date => { - if (date.getDate() !== 1) { - return dateTimeFormats.dayFormat.format(date); - } - - if (date.getMonth() > 0) { - return dateTimeFormats.monthFormat.format(date); - } - - return dateTimeFormats.yearFormat.format(date); -}; diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js index b6b96fe7bd5749f094253ceb3dc01a04e7514182..dd868bb9f4cc2d16077caf49a924c9e16016b82f 100644 --- a/app/assets/javascripts/line_highlighter.js +++ b/app/assets/javascripts/line_highlighter.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, no-underscore-dangle, no-param-reassign, consistent-return, one-var, no-else-return */ +/* eslint-disable func-names, no-underscore-dangle, no-param-reassign, consistent-return, no-else-return */ import $ from 'jquery'; @@ -82,13 +82,13 @@ LineHighlighter.prototype.highlightHash = function(newHash) { }; LineHighlighter.prototype.clickHandler = function(event) { - var current, lineNumber, range; + let range; event.preventDefault(); this.clearHighlight(); - lineNumber = $(event.target) + const lineNumber = $(event.target) .closest('a') .data('lineNumber'); - current = this.hashToRange(this._hash); + const current = this.hashToRange(this._hash); if (!(current[0] && event.shiftKey)) { // If there's no current selection, or there is but Shift wasn't held, // treat this like a single-line selection. @@ -121,12 +121,11 @@ LineHighlighter.prototype.clearHighlight = function() { // // Returns an Array LineHighlighter.prototype.hashToRange = function(hash) { - var first, last, matches; // ?L(\d+)(?:-(\d+))?$/) - matches = hash.match(/^#?L(\d+)(?:-(\d+))?$/); + const matches = hash.match(/^#?L(\d+)(?:-(\d+))?$/); if (matches && matches.length) { - first = parseInt(matches[1], 10); - last = matches[2] ? parseInt(matches[2], 10) : null; + const first = parseInt(matches[1], 10); + const last = matches[2] ? parseInt(matches[2], 10) : null; return [first, last]; } else { return [null, null]; @@ -160,7 +159,7 @@ LineHighlighter.prototype.highlightRange = function(range) { // Set the URL hash string LineHighlighter.prototype.setHash = function(firstLineNumber, lastLineNumber) { - var hash; + let hash; if (lastLineNumber) { hash = `#L${firstLineNumber}-${lastLineNumber}`; } else { diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index c19a845eb69d88a4b5efa8480915422e6fb3788f..465c9a362ba4c41d5c352f25c5f18cb9876f98fc 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -37,7 +37,6 @@ import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; import { initUserTracking } from './tracking'; import { __ } from './locale'; -import initPrivacyPolicyUpdateCallout from './privacy_policy_update_callout'; import 'ee_else_ce/main_ee'; @@ -97,7 +96,6 @@ function deferredInitialisation() { initUsagePingConsent(); initUserPopovers(); initUserTracking(); - initPrivacyPolicyUpdateCallout(); if (document.querySelector('.search')) initSearchAutocomplete(); @@ -162,24 +160,6 @@ function deferredInitialisation() { }); loadAwardsHandler(); - - /** - * Toggle Canary Badge - * - * For GitLab.com only, when the user is using canary - * we render a Next badge and hide the option to switch - * to canay - */ - if (Cookies.get('gitlab_canary') && Cookies.get('gitlab_canary') === 'true') { - const canaryBadge = document.querySelector('.js-canary-badge'); - const canaryLink = document.querySelector('.js-canary-link'); - if (canaryBadge) { - canaryBadge.classList.remove('hidden'); - } - if (canaryLink) { - canaryLink.classList.add('hidden'); - } - } } document.addEventListener('DOMContentLoaded', () => { diff --git a/app/assets/javascripts/manual_ordering.js b/app/assets/javascripts/manual_ordering.js index 29a0e5a904a27ffa9af76ab5c0d0b38d1c755e55..f93dbcd4c4795a4dce12fd5531eb404bff9905ab 100644 --- a/app/assets/javascripts/manual_ordering.js +++ b/app/assets/javascripts/manual_ordering.js @@ -18,7 +18,7 @@ const updateIssue = (url, issueList, { move_before_id, move_after_id }) => createFlash(s__("ManualOrdering|Couldn't save the order of the issues")); }); -const initManualOrdering = () => { +const initManualOrdering = (draggableSelector = 'li.issue') => { const issueList = document.querySelector('.manual-ordering'); if (!issueList || !(gon.current_user_id > 0)) { @@ -34,14 +34,14 @@ const initManualOrdering = () => { group: { name: 'issues', }, - draggable: 'li.issue', + draggable: draggableSelector, onStart: () => { sortableStart(); }, onUpdate: event => { const el = event.item; - const url = el.getAttribute('url'); + const url = el.getAttribute('url') || el.dataset.url; const prev = el.previousElementSibling; const next = el.nextElementSibling; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 7223b5c0d43be177287952958b11d05e28c606e1..3a7ade5ad94efcac4b449ec642e71fbb6eabfaac 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, no-underscore-dangle, one-var, consistent-return */ +/* eslint-disable func-names, no-underscore-dangle, consistent-return */ import $ from 'jquery'; import { __ } from '~/locale'; @@ -17,14 +17,7 @@ function MergeRequest(opts) { this.opts = opts != null ? opts : {}; this.submitNoteForm = this.submitNoteForm.bind(this); this.$el = $('.merge-request'); - this.$('.show-all-commits').on( - 'click', - (function(_this) { - return function() { - return _this.showAllCommits(); - }; - })(this), - ); + this.$('.show-all-commits').on('click', () => this.showAllCommits()); this.initTabs(); this.initMRBtnListeners(); @@ -71,12 +64,10 @@ MergeRequest.prototype.showAllCommits = function() { }; MergeRequest.prototype.initMRBtnListeners = function() { - var _this; - _this = this; + const _this = this; return $('a.btn-close, a.btn-reopen').on('click', function(e) { - var $this, shouldSubmit; - $this = $(this); - shouldSubmit = $this.hasClass('btn-comment'); + const $this = $(this); + const shouldSubmit = $this.hasClass('btn-comment'); if (shouldSubmit && $this.data('submitted')) { return; } @@ -95,8 +86,7 @@ MergeRequest.prototype.initMRBtnListeners = function() { }; MergeRequest.prototype.submitNoteForm = function(form, $button) { - var noteText; - noteText = form.find('textarea.js-note-text').val(); + const noteText = form.find('textarea.js-note-text').val(); if (noteText.trim().length > 0) { form.submit(); $button.data('submitted', true); @@ -106,7 +96,7 @@ MergeRequest.prototype.submitNoteForm = function(form, $button) { MergeRequest.prototype.initCommitMessageListeners = function() { $(document).on('click', 'a.js-with-description-link', e => { - var textarea = $('textarea.js-commit-message'); + const textarea = $('textarea.js-commit-message'); e.preventDefault(); textarea.val(textarea.data('messageWithDescription')); @@ -115,7 +105,7 @@ MergeRequest.prototype.initCommitMessageListeners = function() { }); $(document).on('click', 'a.js-without-description-link', e => { - var textarea = $('textarea.js-commit-message'); + const textarea = $('textarea.js-commit-message'); e.preventDefault(); textarea.val(textarea.data('messageWithoutDescription')); diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue new file mode 100644 index 0000000000000000000000000000000000000000..8eeac737a11fd90a8a90777f21471cd409a7a9ce --- /dev/null +++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue @@ -0,0 +1,227 @@ +<script> +import { flatten, isNumber } from 'underscore'; +import { GlLineChart, 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'; +import { graphDataValidatorForAnomalyValues } from '../../utils'; +import MonitorTimeSeriesChart from './time_series.vue'; + +/** + * Series indexes + */ +const METRIC = 0; +const UPPER = 1; +const LOWER = 2; + +/** + * Boundary area appearance + */ +const AREA_COLOR = colorValues.anomalyAreaColor; +const AREA_OPACITY = areaOpacityValues.default; +const AREA_COLOR_RGBA = `rgba(${hexToRgb(AREA_COLOR).join(',')},${AREA_OPACITY})`; + +/** + * The anomaly component highlights when a metric shows + * some anomalous behavior. + * + * It shows both a metric line and a boundary band in a + * time series chart, the boundary band shows the normal + * range of values the metric should take. + * + * This component accepts 3 queries, which contain the + * "metric", "upper" limit and "lower" limit. + * + * The upper and lower series are "stacked areas" visually + * to create the boundary band, and if any "metric" value + * is outside this band, it is highlighted to warn users. + * + * The boundary band stack must be painted above the 0 line + * so the area is shown correctly. If any of the values of + * the data are negative, the chart data is shifted to be + * above 0 line. + * + * The data passed to the time series is will always be + * positive, but reformatted to show the original values of + * data. + * + */ +export default { + components: { + GlLineChart, + GlChartSeriesLabel, + MonitorTimeSeriesChart, + }, + inheritAttrs: false, + props: { + graphData: { + type: Object, + required: true, + validator: graphDataValidatorForAnomalyValues, + }, + }, + computed: { + series() { + return this.graphData.queries.map(query => { + const values = query.result[0] ? query.result[0].values : []; + return { + label: query.label, + data: values.filter(([, value]) => !Number.isNaN(value)), + }; + }); + }, + /** + * If any of the values of the data is negative, the + * chart data is shifted to the lowest value + * + * This offset is the lowest value. + */ + yOffset() { + const values = flatten(this.series.map(ser => ser.data.map(([, y]) => y))); + const min = values.length ? Math.floor(Math.min(...values)) : 0; + return min < 0 ? -min : 0; + }, + metricData() { + const originalMetricQuery = this.graphData.queries[0]; + + const metricQuery = { ...originalMetricQuery }; + metricQuery.result[0].values = metricQuery.result[0].values.map(([x, y]) => [ + x, + y + this.yOffset, + ]); + return { + ...this.graphData, + type: 'line-chart', + queries: [metricQuery], + }; + }, + metricSeriesConfig() { + return { + type: 'line', + symbol: 'circle', + symbolSize: (val, params) => { + if (this.isDatapointAnomaly(params.dataIndex)) { + return symbolSizes.anomaly; + } + // 0 causes echarts to throw an error, use small number instead + // see https://gitlab.com/gitlab-org/gitlab-ui/issues/423 + return 0.001; + }, + showSymbol: true, + itemStyle: { + color: params => { + if (this.isDatapointAnomaly(params.dataIndex)) { + return colorValues.anomalySymbol; + } + return colorValues.primaryColor; + }, + }, + }; + }, + chartOptions() { + const [, upperSeries, lowerSeries] = this.series; + const calcOffsetY = (data, offsetCallback) => + data.map((value, dataIndex) => { + const [x, y] = value; + return [x, y + offsetCallback(dataIndex)]; + }); + + const yAxisWithOffset = { + name: this.yAxisLabel, + axisLabel: { + formatter: num => roundOffFloat(num - this.yOffset, 3).toString(), + }, + }; + + /** + * Boundary is rendered by 2 series: An invisible + * series (opacity: 0) stacked on a visible one. + * + * Order is important, lower boundary is stacked + * *below* the upper boundary. + */ + const boundarySeries = []; + + if (upperSeries.data.length && lowerSeries.data.length) { + // Lower boundary, plus the offset if negative values + boundarySeries.push( + this.makeBoundarySeries({ + name: this.formatLegendLabel(lowerSeries), + data: calcOffsetY(lowerSeries.data, () => this.yOffset), + }), + ); + // Upper boundary, minus the lower boundary + boundarySeries.push( + this.makeBoundarySeries({ + name: this.formatLegendLabel(upperSeries), + data: calcOffsetY(upperSeries.data, i => -this.yValue(LOWER, i)), + areaStyle: { + color: AREA_COLOR, + opacity: AREA_OPACITY, + }, + }), + ); + } + return { yAxis: yAxisWithOffset, series: boundarySeries }; + }, + }, + methods: { + formatLegendLabel(query) { + return query.label; + }, + yValue(seriesIndex, dataIndex) { + const d = this.series[seriesIndex].data[dataIndex]; + return d && d[1]; + }, + yValueFormatted(seriesIndex, dataIndex) { + const y = this.yValue(seriesIndex, dataIndex); + return isNumber(y) ? y.toFixed(3) : ''; + }, + isDatapointAnomaly(dataIndex) { + const yVal = this.yValue(METRIC, dataIndex); + const yUpper = this.yValue(UPPER, dataIndex); + const yLower = this.yValue(LOWER, dataIndex); + return (isNumber(yUpper) && yVal > yUpper) || (isNumber(yLower) && yVal < yLower); + }, + makeBoundarySeries(series) { + const stackKey = 'anomaly-boundary-series-stack'; + return { + type: 'line', + stack: stackKey, + lineStyle: { + width: 0, + color: AREA_COLOR_RGBA, // legend color + }, + color: AREA_COLOR_RGBA, // tooltip color + symbol: 'none', + ...series, + }; + }, + }, +}; +</script> + +<template> + <monitor-time-series-chart + v-bind="$attrs" + :graph-data="metricData" + :option="chartOptions" + :series-config="metricSeriesConfig" + > + <slot></slot> + <template v-slot:tooltipContent="slotProps"> + <div + v-for="(content, seriesIndex) in slotProps.tooltip.content" + :key="seriesIndex" + class="d-flex justify-content-between" + > + <gl-chart-series-label :color="content.color"> + {{ content.name }} + </gl-chart-series-label> + <div class="prepend-left-32"> + {{ yValueFormatted(seriesIndex, content.dataIndex) }} + </div> + </div> + </template> + </monitor-time-series-chart> +</template> diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue new file mode 100644 index 0000000000000000000000000000000000000000..b8158247e499cfe9c764363f6b1dee6a7c1ffea5 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue @@ -0,0 +1,73 @@ +<script> +import { GlHeatmap } from '@gitlab/ui/dist/charts'; +import dateformat from 'dateformat'; +import PrometheusHeader from '../shared/prometheus_header.vue'; +import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue'; +import { graphDataValidatorForValues } from '../../utils'; + +export default { + components: { + GlHeatmap, + ResizableChartContainer, + PrometheusHeader, + }, + props: { + graphData: { + type: Object, + required: true, + validator: graphDataValidatorForValues.bind(null, false), + }, + containerWidth: { + type: Number, + required: true, + }, + }, + computed: { + chartData() { + return this.queries.result.reduce( + (acc, result, i) => [...acc, ...result.values.map((value, j) => [i, j, value[1]])], + [], + ); + }, + xAxisName() { + return this.graphData.x_label || ''; + }, + yAxisName() { + return this.graphData.y_label || ''; + }, + xAxisLabels() { + return this.queries.result.map(res => Object.values(res.metric)[0]); + }, + yAxisLabels() { + return this.result.values.map(val => { + const [yLabel] = val; + + return dateformat(new Date(yLabel), 'HH:MM:ss'); + }); + }, + result() { + return this.queries.result[0]; + }, + queries() { + return this.graphData.queries[0]; + }, + }, +}; +</script> +<template> + <div class="prometheus-graph col-12 col-lg-6"> + <prometheus-header :graph-title="graphData.title" /> + <resizable-chart-container> + <gl-heatmap + ref="heatmapChart" + v-bind="$attrs" + :data-series="chartData" + :x-axis-name="xAxisName" + :y-axis-name="yAxisName" + :x-axis-labels="xAxisLabels" + :y-axis-labels="yAxisLabels" + :width="containerWidth" + /> + </resizable-chart-container> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 78fe575717ac45e113434f6f135803cf02fc1813..6a88c8a5ee3bcd75b1e10ebd6641d87c3f96d4c3 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -1,17 +1,23 @@ <script> import { s__, __ } from '~/locale'; -import { GlLink, GlButton, GlTooltip } from '@gitlab/ui'; +import _ from 'underscore'; +import { GlLink, GlButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui'; import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; -import { debounceByAnimationFrame, roundOffFloat } from '~/lib/utils/common_utils'; +import { roundOffFloat } from '~/lib/utils/common_utils'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import Icon from '~/vue_shared/components/icon.vue'; -import { chartHeight, graphTypes, lineTypes, symbolSizes, dateFormats } from '../../constants'; +import { + chartHeight, + graphTypes, + lineTypes, + lineWidths, + symbolSizes, + dateFormats, +} from '../../constants'; import { makeDataSeries } from '~/helpers/monitor_helper'; import { graphDataValidatorForValues } from '../../utils'; -let debouncedResize; - export default { components: { GlAreaChart, @@ -22,6 +28,9 @@ export default { GlLink, Icon, }, + directives: { + GlResizeObserverDirective, + }, inheritAttrs: false, props: { graphData: { @@ -29,9 +38,15 @@ export default { required: true, validator: graphDataValidatorForValues.bind(null, false), }, - containerWidth: { - type: Number, - required: true, + option: { + type: Object, + required: false, + default: () => ({}), + }, + seriesConfig: { + type: Object, + required: false, + default: () => ({}), }, deploymentData: { type: Array, @@ -99,29 +114,35 @@ export default { const lineWidth = appearance && appearance.line && appearance.line.width ? appearance.line.width - : undefined; + : lineWidths.default; const areaStyle = { opacity: appearance && appearance.area && typeof appearance.area.opacity === 'number' ? appearance.area.opacity : undefined, }; - const series = makeDataSeries(query.result, { name: this.formatLegendLabel(query), lineStyle: { type: lineType, width: lineWidth, + color: this.primaryColor, }, showSymbol: false, areaStyle: this.graphData.type === 'area-chart' ? areaStyle : undefined, + ...this.seriesConfig, }); return acc.concat(series); }, []); }, + chartOptionSeries() { + return (this.option.series || []).concat(this.scatterSeries ? [this.scatterSeries] : []); + }, chartOptions() { + const option = _.omit(this.option, 'series'); return { + series: this.chartOptionSeries, xAxis: { name: __('Time'), type: 'time', @@ -138,8 +159,8 @@ export default { formatter: num => roundOffFloat(num, 3).toString(), }, }, - series: this.scatterSeries, dataZoom: [this.dataZoomConfig], + ...option, }; }, dataZoomConfig() { @@ -147,6 +168,14 @@ export default { return handleIcon ? { handleIcon } : {}; }, + /** + * This method returns the earliest time value in all series of a chart. + * Takes a chart data with data to populate a timeseries. + * data should be an array of data points [t, y] where t is a ISO formatted date, + * and is sorted by t (time). + * @returns {(String|null)} earliest x value from all series, or null when the + * chart series data is empty. + */ earliestDatapoint() { return this.chartData.reduce((acc, series) => { const { data } = series; @@ -206,21 +235,13 @@ export default { return `${this.graphData.y_label}`; }, }, - watch: { - containerWidth: 'onResize', - }, mounted() { const graphTitleEl = this.$refs.graphTitle; if (graphTitleEl && graphTitleEl.scrollWidth > graphTitleEl.offsetWidth) { this.showTitleTooltip = true; } }, - beforeDestroy() { - window.removeEventListener('resize', debouncedResize); - }, created() { - debouncedResize = debounceByAnimationFrame(this.onResize); - window.addEventListener('resize', debouncedResize); this.setSvg('rocket'); this.setSvg('scroll-handle'); }, @@ -241,10 +262,11 @@ export default { this.tooltip.sha = deploy.sha.substring(0, 8); this.tooltip.commitUrl = deploy.commitUrl; } else { - const { seriesName, color } = dataPoint; + const { seriesName, color, dataIndex } = dataPoint; const value = yVal.toFixed(3); this.tooltip.content.push({ name: seriesName, + dataIndex, value, color, }); @@ -276,7 +298,7 @@ export default { </script> <template> - <div class="prometheus-graph"> + <div v-gl-resize-observer-directive="onResize" class="prometheus-graph"> <div class="prometheus-graph-header"> <h5 ref="graphTitle" @@ -317,23 +339,27 @@ export default { </template> <template v-else> <template slot="tooltipTitle"> - <div class="text-nowrap"> - {{ tooltip.title }} - </div> + <slot name="tooltipTitle"> + <div class="text-nowrap"> + {{ tooltip.title }} + </div> + </slot> </template> <template slot="tooltipContent"> - <div - v-for="(content, key) in tooltip.content" - :key="key" - class="d-flex justify-content-between" - > - <gl-chart-series-label :color="isMultiSeries ? content.color : ''"> - {{ content.name }} - </gl-chart-series-label> - <div class="prepend-left-32"> - {{ content.value }} + <slot name="tooltipContent" :tooltip="tooltip"> + <div + v-for="(content, key) in tooltip.content" + :key="key" + class="d-flex justify-content-between" + > + <gl-chart-series-label :color="isMultiSeries ? content.color : ''"> + {{ content.name }} + </gl-chart-series-label> + <div class="prepend-left-32"> + {{ content.value }} + </div> </div> - </div> + </slot> </template> </template> </component> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index b4ea415bb5174333b53f67203a663fd35508f8bc..26e2c2568c1d104c50c95d36065353aa85f22cbb 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -11,7 +11,7 @@ import { GlModalDirective, GlTooltipDirective, } from '@gitlab/ui'; -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; import createFlash from '~/flash'; import Icon from '~/vue_shared/components/icon.vue'; import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; @@ -22,12 +22,9 @@ import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; -import { sidebarAnimationDuration } from '../constants'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import { getTimeDiff, isValidDate, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; -let sidebarMutationObserver; - export default { components: { VueDraggable, @@ -167,10 +164,10 @@ export default { data() { return { state: 'gettingStarted', - elWidth: 0, formIsValid: null, selectedTimeWindow: {}, isRearrangingPanels: false, + hasValidDates: true, }; }, computed: { @@ -178,7 +175,7 @@ export default { return this.customMetricsAvailable && this.customMetricsPath.length; }, ...mapState('monitoringDashboard', [ - 'groups', + 'dashboard', 'emptyState', 'showEmptyState', 'environments', @@ -189,10 +186,15 @@ export default { 'additionalPanelTypesEnabled', ]), firstDashboard() { - return this.allDashboards[0] || {}; + return this.environmentsEndpoint.length > 0 && this.allDashboards.length > 0 + ? this.allDashboards[0] + : {}; + }, + selectedDashboard() { + return this.allDashboards.find(d => d.path === this.currentDashboard) || this.firstDashboard; }, selectedDashboardText() { - return this.currentDashboard || this.firstDashboard.display_name; + return this.selectedDashboard.display_name; }, showRearrangePanelsBtn() { return !this.showEmptyState && this.rearrangePanelsAvailable; @@ -200,8 +202,13 @@ export default { addingMetricsAvailable() { return IS_EE && this.canAddMetrics && !this.showEmptyState; }, - alertWidgetAvailable() { - return IS_EE && this.prometheusAlertsAvailable && this.alertsEndpoint; + hasHeaderButtons() { + return ( + this.addingMetricsAvailable || + this.showRearrangePanelsBtn || + this.selectedDashboard.can_edit || + this.externalDashboardUrl.length + ); }, }, created() { @@ -214,11 +221,6 @@ export default { projectPath: this.projectPath, }); }, - beforeDestroy() { - if (sidebarMutationObserver) { - sidebarMutationObserver.disconnect(); - } - }, mounted() { if (!this.hasMetrics) { this.setGettingStartedEmptyState(); @@ -235,17 +237,12 @@ export default { this.selectedTimeWindow = range; if (!isValidDate(start) || !isValidDate(end)) { + this.hasValidDates = false; this.showInvalidDateError(); } else { + this.hasValidDates = true; this.fetchData(range); } - - sidebarMutationObserver = new MutationObserver(this.onSidebarMutation); - sidebarMutationObserver.observe(document.querySelector('.layout-page'), { - attributes: true, - childList: false, - subtree: false, - }); } }, methods: { @@ -253,43 +250,25 @@ export default { 'fetchData', 'setGettingStartedEmptyState', 'setEndpoints', - 'setDashboardEnabled', + 'setPanelGroupMetrics', ]), chartsWithData(charts) { - if (!this.useDashboardEndpoint) { - return charts; - } return charts.filter(chart => chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)), ); }, - csvText(graphData) { - const chartData = graphData.queries[0].result[0].values; - const yLabel = graphData.y_label; - const header = `timestamp,${yLabel}\r\n`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings - return chartData.reduce((csv, data) => { - const row = data.join(','); - return `${csv}${row}\r\n`; - }, header); - }, - downloadCsv(graphData) { - const data = new Blob([this.csvText(graphData)], { type: 'text/plain' }); - return window.URL.createObjectURL(data); - }, - // TODO: BEGIN, Duplicated code with panel_type until feature flag is removed - // Issue number: https://gitlab.com/gitlab-org/gitlab-foss/issues/63845 - getGraphAlerts(queries) { - if (!this.allAlerts) return {}; - const metricIdsForChart = queries.map(q => q.metricId); - return _.pick(this.allAlerts, alert => metricIdsForChart.includes(alert.metricId)); - }, - getGraphAlertValues(queries) { - return Object.values(this.getGraphAlerts(queries)); - }, - showToast() { - this.$toast.show(__('Link copied')); - }, - // TODO: END + updateMetrics(key, metrics) { + this.setPanelGroupMetrics({ + metrics, + key, + }); + }, + removeMetric(key, metrics, graphIndex) { + this.setPanelGroupMetrics({ + metrics: metrics.filter((v, i) => i !== graphIndex), + key, + }); + }, removeGraph(metrics, graphIndex) { // At present graphs will not be removed, they should removed using the vuex store // See https://gitlab.com/gitlab-org/gitlab/issues/27835 @@ -306,11 +285,6 @@ export default { hideAddMetricModal() { this.$refs.addMetricModal.hide(); }, - onSidebarMutation() { - setTimeout(() => { - this.elWidth = this.$el.clientWidth; - }, sidebarAnimationDuration); - }, toggleRearrangingPanels() { this.isRearrangingPanels = !this.isRearrangingPanels; }, @@ -389,7 +363,7 @@ export default { </gl-form-group> <gl-form-group - v-if="!showEmptyState" + v-if="hasValidDates" :label="s__('Metrics|Show last')" label-size="sm" label-for="monitor-time-window-dropdown" @@ -403,7 +377,7 @@ export default { </template> <gl-form-group - v-if="addingMetricsAvailable || showRearrangePanelsBtn || externalDashboardUrl.length" + v-if="hasHeaderButtons" label-for="prometheus-graphs-dropdown-buttons" class="dropdown-buttons col-md d-md-flex col-lg d-lg-flex align-items-end" > @@ -450,6 +424,14 @@ export default { </div> </gl-modal> + <gl-button + v-if="selectedDashboard.can_edit" + class="mt-1 js-edit-link" + :href="selectedDashboard.project_blob_path" + > + {{ __('Edit dashboard') }} + </gl-button> + <gl-button v-if="externalDashboardUrl.length" class="mt-1 js-external-dashboard-link" @@ -468,116 +450,46 @@ export default { <div v-if="!showEmptyState"> <graph-group - v-for="(groupData, index) in groups" + v-for="(groupData, index) in dashboard.panel_groups" :key="`${groupData.group}.${groupData.priority}`" :name="groupData.group" :show-panels="showPanels" :collapse-group="groupHasData(groupData)" > - <template v-if="additionalPanelTypesEnabled"> - <vue-draggable - :list="groupData.metrics" - group="metrics-dashboard" - :component-data="{ attrs: { class: 'row mx-0 w-100' } }" - :disabled="!isRearrangingPanels" + <vue-draggable + :value="groupData.metrics" + group="metrics-dashboard" + :component-data="{ attrs: { class: 'row mx-0 w-100' } }" + :disabled="!isRearrangingPanels" + @input="updateMetrics(groupData.key, $event)" + > + <div + v-for="(graphData, graphIndex) in groupData.metrics" + :key="`panel-type-${graphIndex}`" + class="col-12 col-lg-6 px-2 mb-2 draggable" + :class="{ 'draggable-enabled': isRearrangingPanels }" > - <div - v-for="(graphData, graphIndex) in groupData.metrics" - :key="`panel-type-${graphIndex}`" - class="col-12 col-lg-6 px-2 mb-2 draggable" - :class="{ 'draggable-enabled': isRearrangingPanels }" - > - <div class="position-relative draggable-panel js-draggable-panel"> - <div - v-if="isRearrangingPanels" - class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" - @click="removeGraph(groupData.metrics, graphIndex)" - > - <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')" - ><icon name="close" - /></a> - </div> - - <panel-type - :clipboard-text=" - generateLink(groupData.group, graphData.title, graphData.y_label) - " - :graph-data="graphData" - :dashboard-width="elWidth" - :alerts-endpoint="alertsEndpoint" - :prometheus-alerts-available="prometheusAlertsAvailable" - :index="`${index}-${graphIndex}`" - /> + <div class="position-relative draggable-panel js-draggable-panel"> + <div + v-if="isRearrangingPanels" + class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" + @click="removeGraph(groupData.metrics, graphIndex)" + > + <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')" + ><icon name="close" + /></a> </div> - </div> - </vue-draggable> - </template> - <template v-else> - <monitor-time-series-chart - v-for="(graphData, graphIndex) in chartsWithData(groupData.metrics)" - :key="graphIndex" - class="col-12 col-lg-6 pb-3" - :graph-data="graphData" - :deployment-data="deploymentData" - :thresholds="getGraphAlertValues(graphData.queries)" - :container-width="elWidth" - :project-path="projectPath" - group-id="monitor-time-series-chart" - > - <div - class="d-flex align-items-center" - :class="alertWidgetAvailable ? 'justify-content-between' : 'justify-content-end'" - > - <alert-widget - v-if="alertWidgetAvailable && graphData" - :modal-id="`alert-modal-${index}-${graphIndex}`" + + <panel-type + :clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)" + :graph-data="graphData" :alerts-endpoint="alertsEndpoint" - :relevant-queries="graphData.queries" - :alerts-to-manage="getGraphAlerts(graphData.queries)" - @setAlerts="setAlerts" + :prometheus-alerts-available="prometheusAlertsAvailable" + :index="`${index}-${graphIndex}`" /> - <gl-dropdown - v-gl-tooltip - class="ml-2 mr-3" - toggle-class="btn btn-transparent border-0" - :right="true" - :no-caret="true" - :title="__('More actions')" - > - <template slot="button-content"> - <icon name="ellipsis_v" class="text-secondary" /> - </template> - <gl-dropdown-item - v-track-event="downloadCSVOptions(graphData.title)" - :href="downloadCsv(graphData)" - download="chart_metrics.csv" - > - {{ __('Download CSV') }} - </gl-dropdown-item> - <gl-dropdown-item - v-track-event=" - generateLinkToChartOptions( - generateLink(groupData.group, graphData.title, graphData.y_label), - ) - " - class="js-chart-link" - :data-clipboard-text=" - generateLink(groupData.group, graphData.title, graphData.y_label) - " - @click="showToast" - > - {{ __('Generate link to chart') }} - </gl-dropdown-item> - <gl-dropdown-item - v-if="alertWidgetAvailable" - v-gl-modal="`alert-modal-${index}-${graphIndex}`" - > - {{ __('Alerts') }} - </gl-dropdown-item> - </gl-dropdown> </div> - </monitor-time-series-chart> - </template> + </div> + </vue-draggable> </graph-group> </div> <empty-state 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 4616a767295aaffb6bc48c80a320ad98579d96ca..8749019c5cd32508bdc7e4e12b70a73217e00240 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 @@ -55,17 +55,13 @@ export default { }; }, }, + watch: { + selectedTimeWindow() { + this.verifyTimeRange(); + }, + }, mounted() { - 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); - } + this.verifyTimeRange(); }, methods: { activeTimeWindow(key) { @@ -87,6 +83,18 @@ export default { 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); + } + }, }, }; </script> diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue index 7857aaa6ecc02a7f31cc0657f4c640542e40f40d..f75839c7c6b5301f324989ae6b709ff0047a25a8 100644 --- a/app/assets/javascripts/monitoring/components/embed.vue +++ b/app/assets/javascripts/monitoring/components/embed.vue @@ -35,9 +35,9 @@ export default { }; }, computed: { - ...mapState('monitoringDashboard', ['groups', 'metricsWithData']), + ...mapState('monitoringDashboard', ['dashboard', 'metricsWithData']), charts() { - const groupWithMetrics = this.groups.find(group => + const groupWithMetrics = this.dashboard.panel_groups.find(group => group.metrics.find(chart => this.chartHasData(chart)), ) || { metrics: [] }; @@ -78,9 +78,6 @@ export default { }, sidebarAnimationDuration); }, setInitialState() { - this.setFeatureFlags({ - prometheusEndpointEnabled: true, - }); this.setEndpoints({ dashboardEndpoint: removeParams(['start', 'end'], this.dashboardUrl), }); diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue index ee3a2bae79b743eb87bc5c44da51ed2571c671cf..3cb6ccb64b135f5556ee3cb6dc04287c7d3b0e8a 100644 --- a/app/assets/javascripts/monitoring/components/graph_group.vue +++ b/app/assets/javascripts/monitoring/components/graph_group.vue @@ -45,7 +45,7 @@ export default { <div v-if="showPanels" class="card prometheus-panel"> <div class="card-header d-flex align-items-center"> <h4 class="flex-grow-1">{{ name }}</h4> - <a role="button" @click="collapse"> + <a role="button" class="js-graph-group-toggle" @click="collapse"> <icon :size="16" :aria-label="__('Toggle collapse')" :name="caretIcon" /> </a> </div> diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue index 1a14d06f4c87b5092294db897fd093a1932020f2..cafb4b0b479f25484ba8bb2ab0b2ae7e13b8ee43 100644 --- a/app/assets/javascripts/monitoring/components/panel_type.vue +++ b/app/assets/javascripts/monitoring/components/panel_type.vue @@ -11,7 +11,9 @@ import { } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import MonitorTimeSeriesChart from './charts/time_series.vue'; +import MonitorAnomalyChart from './charts/anomaly.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; +import MonitorHeatmapChart from './charts/heatmap.vue'; import MonitorEmptyChart from './charts/empty_chart.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import { downloadCSVOptions, generateLinkToChartOptions } from '../utils'; @@ -19,7 +21,7 @@ import { downloadCSVOptions, generateLinkToChartOptions } from '../utils'; export default { components: { MonitorSingleStatChart, - MonitorTimeSeriesChart, + MonitorHeatmapChart, MonitorEmptyChart, Icon, GlDropdown, @@ -40,10 +42,6 @@ export default { type: Object, required: true, }, - dashboardWidth: { - type: Number, - required: true, - }, index: { type: String, required: false, @@ -71,6 +69,12 @@ export default { const data = new Blob([this.csvText], { type: 'text/plain' }); return window.URL.createObjectURL(data); }, + monitorChartComponent() { + if (this.isPanelType('anomaly-chart')) { + return MonitorAnomalyChart; + } + return MonitorTimeSeriesChart; + }, }, methods: { getGraphAlerts(queries) { @@ -97,14 +101,19 @@ export default { v-if="isPanelType('single-stat') && graphDataHasMetrics" :graph-data="graphData" /> - <monitor-time-series-chart + <monitor-heatmap-chart + v-else-if="isPanelType('heatmap') && graphDataHasMetrics" + :graph-data="graphData" + :container-width="dashboardWidth" + /> + <component + :is="monitorChartComponent" v-else-if="graphDataHasMetrics" :graph-data="graphData" :deployment-data="deploymentData" :project-path="projectPath" :thresholds="getGraphAlertValues(graphData.queries)" - :container-width="dashboardWidth" - group-id="monitor-area-chart" + group-id="panel-type-chart" > <div class="d-flex align-items-center"> <alert-widget @@ -146,6 +155,6 @@ export default { </gl-dropdown-item> </gl-dropdown> </div> - </monitor-time-series-chart> + </component> <monitor-empty-chart v-else :graph-title="graphData.title" /> </template> diff --git a/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue b/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue new file mode 100644 index 0000000000000000000000000000000000000000..153c8f389dba664eef6120c76c2f51cfdb8bf6f2 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue @@ -0,0 +1,15 @@ +<script> +export default { + props: { + graphTitle: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div class="prometheus-graph-header"> + <h5 class="prometheus-graph-title js-graph-title">{{ graphTitle }}</h5> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 2836fe4fc26ffd10f71881d8a317747552b5ad77..1a1fcdd0e6602c45908db24a271fc2f1c6683400 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -14,13 +14,28 @@ export const graphTypes = { }; export const symbolSizes = { + anomaly: 8, default: 14, }; +export const areaOpacityValues = { + default: 0.2, +}; + +export const colorValues = { + primaryColor: '#1f78d1', // $blue-500 (see variables.scss) + anomalySymbol: '#db3b21', + anomalyAreaColor: '#1f78d1', +}; + export const lineTypes = { default: 'solid', }; +export const lineWidths = { + default: 2, +}; + export const timeWindows = { thirtyMinutes: __('30 minutes'), threeHours: __('3 hours'), diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index 6aa1fb5e9c6027da9972669a3298ba6408d09ab2..a14145d480befb08010be4c091a7aefd88619b1d 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -11,13 +11,6 @@ export default (props = {}) => { const el = document.getElementById('prometheus-graphs'); if (el && el.dataset) { - if (gon.features) { - store.dispatch('monitoringDashboard/setFeatureFlags', { - prometheusEndpointEnabled: gon.features.environmentMetricsUsePrometheusEndpoint, - additionalPanelTypesEnabled: gon.features.environmentMetricsAdditionalPanelTypes, - }); - } - const [currentDashboard] = getParameterValues('dashboard'); // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 2cf34ddb45b6f15e167b1b3d62856d4e46125f1e..6a8e3cc82f5b9c32f1eed63a11b46ea2c889672b 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -7,7 +7,7 @@ import { s__, __ } from '../../locale'; const MAX_REQUESTS = 3; -function backOffRequest(makeRequestCallback) { +export function backOffRequest(makeRequestCallback) { let requestCounter = 0; return backOff((next, stop) => { makeRequestCallback() @@ -35,14 +35,6 @@ export const setEndpoints = ({ commit }, endpoints) => { commit(types.SET_ENDPOINTS, endpoints); }; -export const setFeatureFlags = ( - { commit }, - { prometheusEndpointEnabled, additionalPanelTypesEnabled }, -) => { - commit(types.SET_DASHBOARD_ENABLED, prometheusEndpointEnabled); - commit(types.SET_ADDITIONAL_PANEL_TYPES_ENABLED, additionalPanelTypesEnabled); -}; - export const setShowErrorBanner = ({ commit }, enabled) => { commit(types.SET_SHOW_ERROR_BANNER, enabled); }; @@ -79,29 +71,7 @@ export const fetchData = ({ dispatch }, params) => { dispatch('fetchEnvironmentsData'); }; -export const fetchMetricsData = ({ state, dispatch }, params) => { - if (state.useDashboardEndpoint) { - return dispatch('fetchDashboard', params); - } - - dispatch('requestMetricsData'); - - return backOffRequest(() => axios.get(state.metricsEndpoint, { params })) - .then(resp => resp.data) - .then(response => { - if (!response || !response.data || !response.success) { - dispatch('receiveMetricsDataFailure', null); - createFlash(s__('Metrics|Unexpected metrics data response from prometheus endpoint')); - } - dispatch('receiveMetricsDataSuccess', response.data); - }) - .catch(error => { - dispatch('receiveMetricsDataFailure', error); - if (state.setShowErrorBanner) { - createFlash(s__('Metrics|There was an error while retrieving metrics')); - } - }); -}; +export const fetchMetricsData = ({ dispatch }, params) => dispatch('fetchDashboard', params); export const fetchDashboard = ({ state, dispatch }, params) => { dispatch('requestMetricsDashboard'); @@ -111,11 +81,13 @@ export const fetchDashboard = ({ state, dispatch }, params) => { params.dashboard = state.currentDashboard; } - return axios - .get(state.dashboardEndpoint, { params }) + return backOffRequest(() => axios.get(state.dashboardEndpoint, { params })) .then(resp => resp.data) .then(response => { - dispatch('receiveMetricsDashboardSuccess', { response, params }); + dispatch('receiveMetricsDashboardSuccess', { + response, + params, + }); }) .catch(error => { dispatch('receiveMetricsDashboardFailure', error); @@ -166,7 +138,7 @@ export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => { commit(types.REQUEST_METRICS_DATA); const promises = []; - state.groups.forEach(group => { + state.dashboard.panel_groups.forEach(group => { group.panels.forEach(panel => { panel.metrics.forEach(metric => { promises.push(dispatch('fetchPrometheusMetric', { metric, params })); @@ -221,5 +193,15 @@ export const fetchEnvironmentsData = ({ state, dispatch }) => { }); }; +/** + * Set a new array of metrics to a panel group + * @param {*} data An object containing + * - `key` with a unique panel key + * - `metrics` with the metrics array + */ +export const setPanelGroupMetrics = ({ commit }, data) => { + commit(types.SET_PANEL_GROUP_METRICS, data); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 9c546427c6ef27c28e640d2a29259db462f9aaf6..fa15a2ba800017c0acfce8b27c7250aa21da17d3 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -9,10 +9,9 @@ export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCC export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAILURE'; export const SET_QUERY_RESULT = 'SET_QUERY_RESULT'; export const SET_TIME_WINDOW = 'SET_TIME_WINDOW'; -export const SET_DASHBOARD_ENABLED = 'SET_DASHBOARD_ENABLED'; -export const SET_ADDITIONAL_PANEL_TYPES_ENABLED = 'SET_ADDITIONAL_PANEL_TYPES_ENABLED'; export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS'; export const SET_ENDPOINTS = 'SET_ENDPOINTS'; export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'; export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE'; export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER'; +export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 320b33d3d697e01bc4c96e67c6d500852596f722..696af5aed75a1641f0225d82dfc347cd67eb878a 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,6 +1,7 @@ import Vue from 'vue'; +import { slugify } from '~/lib/utils/text_utility'; import * as types from './mutation_types'; -import { normalizeMetrics, sortMetrics, normalizeMetric, normalizeQueryResult } from './utils'; +import { normalizeMetrics, normalizeMetric, normalizeQueryResult } from './utils'; const normalizePanel = panel => panel.metrics.map(normalizeMetric); @@ -10,10 +11,12 @@ export default { state.showEmptyState = true; }, [types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) { - state.groups = groupData.map(group => { + state.dashboard.panel_groups = groupData.map((group, i) => { + const key = `${slugify(group.group || 'default')}-${i}`; let { metrics = [], panels = [] } = group; // each panel has metric information that needs to be normalized + panels = panels.map(panel => ({ ...panel, metrics: normalizePanel(panel), @@ -22,24 +25,21 @@ export default { // for backwards compatibility, and to limit Vue template changes: // for each group alias panels to metrics // for each panel alias metrics to queries - if (state.useDashboardEndpoint) { - metrics = panels.map(panel => ({ - ...panel, - queries: panel.metrics, - })); - } + metrics = panels.map(panel => ({ + ...panel, + queries: panel.metrics, + })); return { ...group, panels, - metrics: normalizeMetrics(sortMetrics(metrics)), + key, + metrics: normalizeMetrics(metrics), }; }); - if (!state.groups.length) { + if (!state.dashboard.panel_groups.length) { state.emptyState = 'noData'; - } else { - state.showEmptyState = false; } }, [types.RECEIVE_METRICS_DATA_FAILURE](state, error) { @@ -65,7 +65,7 @@ export default { state.showEmptyState = false; - state.groups.forEach(group => { + state.dashboard.panel_groups.forEach(group => { group.metrics.forEach(metric => { metric.queries.forEach(query => { if (query.metric_id === metricId) { @@ -86,9 +86,6 @@ export default { state.currentDashboard = endpoints.currentDashboard; state.projectPath = endpoints.projectPath; }, - [types.SET_DASHBOARD_ENABLED](state, enabled) { - state.useDashboardEndpoint = enabled; - }, [types.SET_GETTING_STARTED_EMPTY_STATE](state) { state.emptyState = 'gettingStarted'; }, @@ -97,12 +94,13 @@ export default { state.emptyState = 'noData'; }, [types.SET_ALL_DASHBOARDS](state, dashboards) { - state.allDashboards = dashboards; - }, - [types.SET_ADDITIONAL_PANEL_TYPES_ENABLED](state, enabled) { - state.additionalPanelTypesEnabled = enabled; + state.allDashboards = dashboards || []; }, [types.SET_SHOW_ERROR_BANNER](state, enabled) { state.showErrorBanner = enabled; }, + [types.SET_PANEL_GROUP_METRICS](state, payload) { + const panelGroup = state.dashboard.panel_groups.find(pg => payload.key === pg.key); + panelGroup.metrics = payload.metrics; + }, }; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index e894e988f6ae05944ff206a5b8fd803ab8cc0790..87e94311176b1d60a7a983df73ac37c4c8560c51 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -7,12 +7,12 @@ export default () => ({ environmentsEndpoint: null, deploymentsEndpoint: null, dashboardEndpoint: invalidUrl, - useDashboardEndpoint: false, - additionalPanelTypesEnabled: false, emptyState: 'gettingStarted', showEmptyState: true, showErrorBanner: true, - groups: [], + dashboard: { + panel_groups: [], + }, deploymentData: [], environments: [], metricsWithData: [], diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index a19829f0c65983c3ebbea42361aab217af6fabb8..8a396b15a314d9cae18654dad881b0a3c1eafc02 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -82,12 +82,6 @@ export const normalizeMetric = (metric = {}) => 'id', ); -export const sortMetrics = metrics => - _.chain(metrics) - .sortBy('title') - .sortBy('weight') - .value(); - export const normalizeQueryResult = timeSeries => { let normalizedResult = {}; diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 00f188c1d5a6f261c14f10ea532afbddd0e1c3c7..2ae1647011df93d7f22004d5002c22a1eeccf51b 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -1,7 +1,6 @@ import dateformat from 'dateformat'; import { secondsIn, dateTimePickerRegex, dateFormats } from './constants'; - -const secondsToMilliseconds = seconds => seconds * 1000; +import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; export const getTimeDiff = timeWindow => { const end = Math.floor(Date.now() / 1000); // convert milliseconds to seconds @@ -131,4 +130,20 @@ export const downloadCSVOptions = title => { return { category, action, label: 'Chart title', property: title }; }; +/** + * This function validates the graph data contains exactly 3 queries plus + * value validations from graphDataValidatorForValues. + * @param {Object} isValues + * @param {Object} graphData the graph data response from a prometheus request + * @returns {boolean} true if the data is valid + */ +export const graphDataValidatorForAnomalyValues = graphData => { + const anomalySeriesCount = 3; // metric, upper, lower + return ( + graphData.queries && + graphData.queries.length === anomalySeriesCount && + graphDataValidatorForValues(false, graphData) + ); +}; + export default {}; diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js index a0ba2193d90f0cfee9f9e2cea109b26279310172..c301c304409bb10934a6049709486c7892d96656 100644 --- a/app/assets/javascripts/network/branch_graph.js +++ b/app/assets/javascripts/network/branch_graph.js @@ -1,12 +1,12 @@ -/* eslint-disable func-names, no-var, one-var, no-loop-func, consistent-return, camelcase */ +/* eslint-disable func-names, consistent-return, camelcase */ import $ from 'jquery'; import { __ } from '../locale'; import axios from '../lib/utils/axios_utils'; import Raphael from './raphael'; -export default (function() { - function BranchGraph(element1, options1) { +export default class BranchGraph { + constructor(element1, options1) { this.element = element1; this.options = options1; this.scrollTop = this.scrollTop.bind(this); @@ -28,7 +28,7 @@ export default (function() { this.load(); } - BranchGraph.prototype.load = function() { + load() { axios .get(this.options.url) .then(({ data }) => { @@ -37,21 +37,23 @@ export default (function() { this.buildGraph(); }) .catch(() => __('Error fetching network graph.')); - }; + } - BranchGraph.prototype.prepareData = function(days, commits) { - var c, ch, cw, j, len, ref; + prepareData(days, commits) { + let c = 0; + let j = 0; + let len = 0; this.days = days; this.commits = commits; this.collectParents(); this.graphHeight = $(this.element).height(); this.graphWidth = $(this.element).width(); - ch = Math.max(this.graphHeight, this.offsetY + this.unitTime * this.mtime + 150); - cw = Math.max(this.graphWidth, this.offsetX + this.unitSpace * this.mspace + 300); + const ch = Math.max(this.graphHeight, this.offsetY + this.unitTime * this.mtime + 150); + const cw = Math.max(this.graphWidth, this.offsetX + this.unitSpace * this.mspace + 300); this.r = Raphael(this.element.get(0), cw, ch); this.top = this.r.set(); this.barHeight = Math.max(this.graphHeight, this.unitTime * this.days.length + 320); - ref = this.commits; + const ref = this.commits; for (j = 0, len = ref.length; j < len; j += 1) { c = ref[j]; if (c.id in this.parents) { @@ -61,37 +63,34 @@ export default (function() { this.markCommit(c); } return this.collectColors(); - }; + } - BranchGraph.prototype.collectParents = function() { - var c, j, len, p, ref, results; - ref = this.commits; - results = []; + collectParents() { + let j = 0; + let l = 0; + let len = 0; + let len1 = 0; + const ref = this.commits; + const results = []; for (j = 0, len = ref.length; j < len; j += 1) { - c = ref[j]; + const c = ref[j]; this.mtime = Math.max(this.mtime, c.time); this.mspace = Math.max(this.mspace, c.space); - results.push( - function() { - var l, len1, ref1, results1; - ref1 = c.parents; - results1 = []; - for (l = 0, len1 = ref1.length; l < len1; l += 1) { - p = ref1[l]; - this.parents[p[0]] = true; - results1.push((this.mspace = Math.max(this.mspace, p[1]))); - } - return results1; - }.call(this), - ); + const ref1 = c.parents; + const results1 = []; + for (l = 0, len1 = ref1.length; l < len1; l += 1) { + const p = ref1[l]; + this.parents[p[0]] = true; + results1.push((this.mspace = Math.max(this.mspace, p[1]))); + } + results.push(results1); } return results; - }; + } - BranchGraph.prototype.collectColors = function() { - var k, results; - k = 0; - results = []; + collectColors() { + let k = 0; + const results = []; while (k < this.mspace) { this.colors.push(Raphael.getColor(0.8)); // Skipping a few colors in the spectrum to get more contrast between colors @@ -100,23 +99,24 @@ export default (function() { results.push((k += 1)); } return results; - }; + } - BranchGraph.prototype.buildGraph = function() { - var cuday, cumonth, day, len, mm, ref; + buildGraph() { + let mm = 0; + let len = 0; + let cuday = 0; + let cumonth = ''; const { r } = this; - cuday = 0; - cumonth = ''; r.rect(0, 0, 40, this.barHeight).attr({ fill: '#222', }); r.rect(40, 0, 30, this.barHeight).attr({ fill: '#444', }); - ref = this.days; + const ref = this.days; for (mm = 0, len = ref.length; mm < len; mm += 1) { - day = ref[mm]; + const day = ref[mm]; if (cuday !== day[0] || cumonth !== day[1]) { // Dates r.text(55, this.offsetY + this.unitTime * mm, day[0]).attr({ @@ -138,29 +138,28 @@ export default (function() { } this.renderPartialGraph(); return this.bindEvents(); - }; + } - BranchGraph.prototype.renderPartialGraph = function() { - var commit, end, i, isGraphEdge, start, x, y; - start = Math.floor((this.element.scrollTop() - this.offsetY) / this.unitTime) - 10; + renderPartialGraph() { + const isGraphEdge = true; + let i = 0; + let start = Math.floor((this.element.scrollTop() - this.offsetY) / this.unitTime) - 10; if (start < 0) { - isGraphEdge = true; start = 0; } - end = start + 40; + let end = start + 40; if (this.commits.length < end) { - isGraphEdge = true; end = this.commits.length; } if (this.prev_start === -1 || Math.abs(this.prev_start - start) > 10 || isGraphEdge) { i = start; this.prev_start = start; while (i < end) { - commit = this.commits[i]; + const commit = this.commits[i]; i += 1; if (commit.hasDrawn !== true) { - x = this.offsetX + this.unitSpace * (this.mspace - commit.space); - y = this.offsetY + this.unitTime * commit.time; + const x = this.offsetX + this.unitSpace * (this.mspace - commit.space); + const y = this.offsetY + this.unitTime * commit.time; this.drawDot(x, y, commit); this.drawLines(x, y, commit); this.appendLabel(x, y, commit); @@ -170,70 +169,62 @@ export default (function() { } return this.top.toFront(); } - }; + } - BranchGraph.prototype.bindEvents = function() { + bindEvents() { const { element } = this; - return $(element).scroll( - (function(_this) { - return function() { - return _this.renderPartialGraph(); - }; - })(this), - ); - }; + return $(element).scroll(() => this.renderPartialGraph()); + } - BranchGraph.prototype.scrollDown = function() { + scrollDown() { this.element.scrollTop(this.element.scrollTop() + 50); return this.renderPartialGraph(); - }; + } - BranchGraph.prototype.scrollUp = function() { + scrollUp() { this.element.scrollTop(this.element.scrollTop() - 50); return this.renderPartialGraph(); - }; + } - BranchGraph.prototype.scrollLeft = function() { + scrollLeft() { this.element.scrollLeft(this.element.scrollLeft() - 50); return this.renderPartialGraph(); - }; + } - BranchGraph.prototype.scrollRight = function() { + scrollRight() { this.element.scrollLeft(this.element.scrollLeft() + 50); return this.renderPartialGraph(); - }; + } - BranchGraph.prototype.scrollBottom = function() { + scrollBottom() { return this.element.scrollTop(this.element.find('svg').height()); - }; + } - BranchGraph.prototype.scrollTop = function() { + scrollTop() { return this.element.scrollTop(0); - }; - - BranchGraph.prototype.appendLabel = function(x, y, commit) { - var label, rect, shortrefs, text, textbox; + } + appendLabel(x, y, commit) { if (!commit.refs) { return; } const { r } = this; - shortrefs = commit.refs; + let shortrefs = commit.refs; // Truncate if longer than 15 chars if (shortrefs.length > 17) { shortrefs = `${shortrefs.substr(0, 15)}…`; } - text = r.text(x + 4, y, shortrefs).attr({ + const text = r.text(x + 4, y, shortrefs).attr({ 'text-anchor': 'start', font: '10px Monaco, monospace', fill: '#FFF', title: commit.refs, }); - textbox = text.getBBox(); + const textbox = text.getBBox(); // Create rectangle based on the size of the textbox - rect = r.rect(x, y - 7, textbox.width + 5, textbox.height + 5, 4).attr({ + const rect = r.rect(x, y - 7, textbox.width + 5, textbox.height + 5, 4).attr({ fill: '#000', 'fill-opacity': 0.5, stroke: 'none', @@ -244,13 +235,13 @@ export default (function() { 'fill-opacity': 0.5, stroke: 'none', }); - label = r.set(rect, text); + const label = r.set(rect, text); label.transform(['t', -rect.getBBox().width - 15, 0]); // Set text to front return text.toFront(); - }; + } - BranchGraph.prototype.appendAnchor = function(x, y, commit) { + appendAnchor(x, y, commit) { const { r, top, options } = this; const anchor = r .circle(x, y, 10) @@ -270,9 +261,9 @@ export default (function() { }, ); return top.push(anchor); - }; + } - BranchGraph.prototype.drawDot = function(x, y, commit) { + drawDot(x, y, commit) { const { r } = this; r.circle(x, y, 3).attr({ fill: this.colors[commit.space], @@ -293,20 +284,24 @@ export default (function() { 'text-anchor': 'start', font: '14px Monaco, monospace', }); - }; + } - BranchGraph.prototype.drawLines = function(x, y, commit) { - var arrow, color, i, len, offset, parent, parentCommit, parentX1, parentX2, parentY, route; + drawLines(x, y, commit) { + let i = 0; + let len = 0; + let arrow = ''; + let offset = []; + let color = []; const { r } = this; const ref = commit.parents; const results = []; for (i = 0, len = ref.length; i < len; i += 1) { - parent = ref[i]; - parentCommit = this.preparedCommits[parent[0]]; - parentY = this.offsetY + this.unitTime * parentCommit.time; - parentX1 = this.offsetX + this.unitSpace * (this.mspace - parentCommit.space); - parentX2 = this.offsetX + this.unitSpace * (this.mspace - parent[1]); + const parent = ref[i]; + const parentCommit = this.preparedCommits[parent[0]]; + const parentY = this.offsetY + this.unitTime * parentCommit.time; + const parentX1 = this.offsetX + this.unitSpace * (this.mspace - parentCommit.space); + const parentX2 = this.offsetX + this.unitSpace * (this.mspace - parent[1]); // Set line color if (parentCommit.space <= commit.space) { color = this.colors[commit.space]; @@ -325,7 +320,7 @@ export default (function() { arrow = 'l-5,0,2,4,3,-4,-4,2'; } // Start point - route = ['M', x + offset[0], y + offset[1]]; + const route = ['M', x + offset[0], y + offset[1]]; // Add arrow if not first parent if (i > 0) { route.push(arrow); @@ -344,9 +339,9 @@ export default (function() { ); } return results; - }; + } - BranchGraph.prototype.markCommit = function(commit) { + markCommit(commit) { if (commit.id === this.options.commit_id) { const { r } = this; const x = this.offsetX + this.unitSpace * (this.mspace - commit.space); @@ -359,7 +354,5 @@ export default (function() { // Displayed in the center return this.element.scrollTop(y - this.graphHeight / 2); } - }; - - return BranchGraph; -})(); + } +} diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js index 9f9db21d65b0b497b83e50d118a280cfa0691b0e..918c6e408a2f6722a4b6c38aae7d8d2e60f36a0b 100644 --- a/app/assets/javascripts/new_branch_form.js +++ b/app/assets/javascripts/new_branch_form.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, one-var, consistent-return, no-return-assign, no-shadow, no-else-return, @gitlab/i18n/no-non-i18n-strings */ +/* eslint-disable func-names, consistent-return, no-return-assign, no-else-return, @gitlab/i18n/no-non-i18n-strings */ import $ from 'jquery'; import RefSelectDropdown from './ref_select_dropdown'; @@ -26,23 +26,22 @@ export default class NewBranchForm { } setupRestrictions() { - var endsWith, invalid, single, startsWith; - startsWith = { + const startsWith = { pattern: /^(\/|\.)/g, prefix: "can't start with", conjunction: 'or', }; - endsWith = { + const endsWith = { pattern: /(\/|\.|\.lock)$/g, prefix: "can't end in", conjunction: 'or', }; - invalid = { + const invalid = { pattern: /(\s|~|\^|:|\?|\*|\[|\\|\.\.|@\{|\/{2,}){1}/g, prefix: "can't contain", conjunction: ', ', }; - single = { + const single = { pattern: /^@+$/g, prefix: "can't be", conjunction: 'or', @@ -51,19 +50,17 @@ export default class NewBranchForm { } validate() { - var errorMessage, errors, formatter, unique, validator; const { indexOf } = []; this.branchNameError.empty(); - unique = function(values, value) { + const unique = function(values, value) { if (indexOf.call(values, value) === -1) { values.push(value); } return values; }; - formatter = function(values, restriction) { - var formatted; - formatted = values.map(value => { + const formatter = function(values, restriction) { + const formatted = values.map(value => { switch (false) { case !/\s/.test(value): return 'spaces'; @@ -75,20 +72,17 @@ export default class NewBranchForm { }); return `${restriction.prefix} ${formatted.join(restriction.conjunction)}`; }; - validator = (function(_this) { - return function(errors, restriction) { - var matched; - matched = _this.name.val().match(restriction.pattern); - if (matched) { - return errors.concat(formatter(matched.reduce(unique, []), restriction)); - } else { - return errors; - } - }; - })(this); - errors = this.restrictions.reduce(validator, []); + const validator = (errors, restriction) => { + const matched = this.name.val().match(restriction.pattern); + if (matched) { + return errors.concat(formatter(matched.reduce(unique, []), restriction)); + } else { + return errors; + } + }; + const errors = this.restrictions.reduce(validator, []); if (errors.length > 0) { - errorMessage = $('<span/>').text(errors.join(', ')); + const errorMessage = $('<span/>').text(errors.join(', ')); return this.branchNameError.append(errorMessage); } } diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js index b142f212eb0cbaa41be36c13582784174b55ab6e..037be8467cb947d88864515fb8aed2ec7e2f9ca1 100644 --- a/app/assets/javascripts/new_commit_form.js +++ b/app/assets/javascripts/new_commit_form.js @@ -1,4 +1,4 @@ -/* eslint-disable no-var, no-return-assign */ +/* eslint-disable no-return-assign */ export default class NewCommitForm { constructor(form) { this.form = form; @@ -11,8 +11,7 @@ export default class NewCommitForm { this.renderDestination(); } renderDestination() { - var different; - different = this.branchName.val() !== this.originalBranch.val(); + const different = this.branchName.val() !== this.originalBranch.val(); if (different) { this.createMergeRequestContainer.show(); if (!this.wasDifferent) { diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 3715a91d599bde95d9c9de36057a77936289cf05..defa278c0892541b67300a11d07f797da1871013 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1,8 +1,8 @@ -/* eslint-disable no-restricted-properties, func-names, no-var, camelcase, +/* eslint-disable no-restricted-properties, no-var, camelcase, no-unused-expressions, one-var, default-case, -consistent-return, no-alert, no-return-assign, -no-param-reassign, no-else-return, vars-on-top, -no-shadow, no-useless-escape, class-methods-use-this */ +consistent-return, no-alert, no-param-reassign, no-else-return, +vars-on-top, no-shadow, no-useless-escape, +class-methods-use-this */ /* global ResolveService */ @@ -281,14 +281,7 @@ export default class Notes { if (Notes.interval) { clearInterval(Notes.interval); } - return (Notes.interval = setInterval( - (function(_this) { - return function() { - return _this.refresh(); - }; - })(this), - this.pollingInterval, - )); + Notes.interval = setInterval(() => this.refresh(), this.pollingInterval); } refresh() { @@ -847,57 +840,52 @@ export default class Notes { var noteElId, $note; $note = $(e.currentTarget).closest('.note'); noteElId = $note.attr('id'); - $(`.note[id="${noteElId}"]`).each( - (function() { - // A same note appears in the "Discussion" and in the "Changes" tab, we have - // to remove all. Using $('.note[id='noteId']') ensure we get all the notes, - // where $('#noteId') would return only one. - return function(i, el) { - var $note, $notes; - $note = $(el); - $notes = $note.closest('.discussion-notes'); - const discussionId = $('.notes', $notes).data('discussionId'); - - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - if (gl.diffNoteApps[noteElId]) { - gl.diffNoteApps[noteElId].$destroy(); - } - } - - $note.remove(); + $(`.note[id="${noteElId}"]`).each((i, el) => { + // A same note appears in the "Discussion" and in the "Changes" tab, we have + // to remove all. Using $('.note[id='noteId']') ensure we get all the notes, + // where $('#noteId') would return only one. + const $note = $(el); + const $notes = $note.closest('.discussion-notes'); + const discussionId = $('.notes', $notes).data('discussionId'); + + if (typeof gl.diffNotesCompileComponents !== 'undefined') { + if (gl.diffNoteApps[noteElId]) { + gl.diffNoteApps[noteElId].$destroy(); + } + } - // check if this is the last note for this line - if ($notes.find('.note').length === 0) { - var notesTr = $notes.closest('tr'); + $note.remove(); - // "Discussions" tab - $notes.closest('.timeline-entry').remove(); + // check if this is the last note for this line + if ($notes.find('.note').length === 0) { + const notesTr = $notes.closest('tr'); - $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue'); + // "Discussions" tab + $notes.closest('.timeline-entry').remove(); - // The notes tr can contain multiple lists of notes, like on the parallel diff - // notesTr does not exist for image diffs - if (notesTr.find('.discussion-notes').length > 1 || notesTr.length === 0) { - const $diffFile = $notes.closest('.diff-file'); - if ($diffFile.length > 0) { - const removeBadgeEvent = new CustomEvent('removeBadge.imageDiff', { - detail: { - // badgeNumber's start with 1 and index starts with 0 - badgeNumber: $notes.index() + 1, - }, - }); + $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue'); - $diffFile[0].dispatchEvent(removeBadgeEvent); - } + // The notes tr can contain multiple lists of notes, like on the parallel diff + // notesTr does not exist for image diffs + if (notesTr.find('.discussion-notes').length > 1 || notesTr.length === 0) { + const $diffFile = $notes.closest('.diff-file'); + if ($diffFile.length > 0) { + const removeBadgeEvent = new CustomEvent('removeBadge.imageDiff', { + detail: { + // badgeNumber's start with 1 and index starts with 0 + badgeNumber: $notes.index() + 1, + }, + }); - $notes.remove(); - } else if (notesTr.length > 0) { - notesTr.remove(); - } + $diffFile[0].dispatchEvent(removeBadgeEvent); } - }; - })(this), - ); + + $notes.remove(); + } else if (notesTr.length > 0) { + notesTr.remove(); + } + } + }); Notes.checkMergeRequestStatus(); return this.updateNotesCount(-1); diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue new file mode 100644 index 0000000000000000000000000000000000000000..4c9075912eeb79ea82d7eed6dff63d2225ecfbf0 --- /dev/null +++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue @@ -0,0 +1,133 @@ +<script> +import { mapActions } from 'vuex'; +import _ from 'underscore'; + +import { s__, __, sprintf } from '~/locale'; +import { truncateSha } from '~/lib/utils/text_utility'; + +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import noteEditedText from './note_edited_text.vue'; +import noteHeader from './note_header.vue'; + +export default { + name: 'DiffDiscussionHeader', + components: { + userAvatarLink, + noteEditedText, + noteHeader, + }, + props: { + discussion: { + type: Object, + required: true, + }, + }, + computed: { + notes() { + return this.discussion.notes; + }, + firstNote() { + return this.notes[0]; + }, + lastNote() { + return this.notes[this.notes.length - 1]; + }, + author() { + return this.firstNote.author; + }, + resolvedText() { + return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved'); + }, + lastUpdatedBy() { + return this.notes.length > 1 ? this.lastNote.author : null; + }, + lastUpdatedAt() { + return this.notes.length > 1 ? this.lastNote.created_at : null; + }, + headerText() { + const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`; + const linkEnd = '</a>'; + + const { commit_id: commitId } = this.discussion; + let commitDisplay = commitId; + + if (commitId) { + commitDisplay = `<span class="commit-sha">${truncateSha(commitId)}</span>`; + } + + const { + for_commit: isForCommit, + diff_discussion: isDiffDiscussion, + active: isActive, + } = this.discussion; + + let text = s__('MergeRequests|started a thread'); + if (isForCommit) { + text = s__( + 'MergeRequests|started a thread on commit %{linkStart}%{commitDisplay}%{linkEnd}', + ); + } else if (isDiffDiscussion && commitId) { + text = isActive + ? s__('MergeRequests|started a thread on commit %{linkStart}%{commitDisplay}%{linkEnd}') + : s__( + 'MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitDisplay}%{linkEnd}', + ); + } else if (isDiffDiscussion) { + text = isActive + ? s__('MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}') + : s__( + 'MergeRequests|started a thread on %{linkStart}an old version of the diff%{linkEnd}', + ); + } + + return sprintf(text, { commitDisplay, linkStart, linkEnd }, false); + }, + }, + methods: { + ...mapActions(['toggleDiscussion']), + toggleDiscussionHandler() { + this.toggleDiscussion({ discussionId: this.discussion.id }); + }, + }, +}; +</script> + +<template> + <div class="discussion-header note-wrapper"> + <div v-once class="timeline-icon align-self-start flex-shrink-0"> + <user-avatar-link + v-if="author" + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + /> + </div> + <div class="timeline-content w-100"> + <note-header + :author="author" + :created-at="firstNote.created_at" + :note-id="firstNote.id" + :include-toggle="true" + :expanded="discussion.expanded" + @toggleHandler="toggleDiscussionHandler" + > + <span v-html="headerText"></span> + </note-header> + <note-edited-text + v-if="discussion.resolved" + :edited-at="discussion.resolved_at" + :edited-by="discussion.resolved_by" + :action-text="resolvedText" + class-name="discussion-headline-light js-discussion-headline" + /> + <note-edited-text + v-else-if="lastUpdatedAt" + :edited-at="lastUpdatedAt" + :edited-by="lastUpdatedBy" + :action-text="__('Last updated')" + class-name="discussion-headline-light js-discussion-headline" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 3158e086f6cf1fe032dd7f4a17e7ca8b69aa3aaa..e4f09492d9cfa49ac6b8861e87583f9ee3b3577b 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -101,6 +101,7 @@ export default { <time-ago-tooltip :time="createdAt" tooltip-placement="bottom" /> </a> </template> + <slot name="extra-controls"></slot> <i class="fa fa-spinner fa-spin editing-spinner" :aria-label="__('Comment is being updated')" diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index cb1975a8962999366ca390a0e80188f933b3a6bf..47ec740b63a5290518299e180eaaf28ef529ab98 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -1,18 +1,15 @@ <script> -import _ from 'underscore'; import { mapActions, mapGetters } from 'vuex'; import { GlTooltipDirective } from '@gitlab/ui'; -import { truncateSha } from '~/lib/utils/text_utility'; -import { s__, __, sprintf } from '~/locale'; +import { s__, __ } from '~/locale'; import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave'; import icon from '~/vue_shared/components/icon.vue'; import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; -import noteHeader from './note_header.vue'; +import diffDiscussionHeader from './diff_discussion_header.vue'; import noteSignedOutWidget from './note_signed_out_widget.vue'; -import noteEditedText from './note_edited_text.vue'; import noteForm from './note_form.vue'; import diffWithNote from './diff_with_note.vue'; import noteable from '../mixins/noteable'; @@ -27,9 +24,8 @@ export default { components: { icon, userAvatarLink, - noteHeader, + diffDiscussionHeader, noteSignedOutWidget, - noteEditedText, noteForm, DraftNote: () => import('ee_component/batch_comments/components/draft_note.vue'), TimelineEntryItem, @@ -92,9 +88,6 @@ export default { currentUser() { return this.getUserData; }, - author() { - return this.firstNote.author; - }, autosaveKey() { return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id); }, @@ -104,27 +97,6 @@ export default { firstNote() { return this.discussion.notes.slice(0, 1)[0]; }, - lastUpdatedBy() { - const { notes } = this.discussion; - - if (notes.length > 1) { - return notes[notes.length - 1].author; - } - - return null; - }, - lastUpdatedAt() { - const { notes } = this.discussion; - - if (notes.length > 1) { - return notes[notes.length - 1].created_at; - } - - return null; - }, - resolvedText() { - return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved'); - }, shouldShowJumpToNextDiscussion() { return this.showJumpToNextDiscussion(this.discussionsByDiffOrder ? 'diff' : 'discussion'); }, @@ -150,40 +122,6 @@ export default { shouldHideDiscussionBody() { return this.shouldRenderDiffs && !this.isExpanded; }, - actionText() { - const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`; - const linkEnd = '</a>'; - - let { commit_id: commitId } = this.discussion; - if (commitId) { - commitId = `<span class="commit-sha">${truncateSha(commitId)}</span>`; - } - - const { - for_commit: isForCommit, - diff_discussion: isDiffDiscussion, - active: isActive, - } = this.discussion; - - let text = s__('MergeRequests|started a thread'); - if (isForCommit) { - text = s__('MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}'); - } else if (isDiffDiscussion && commitId) { - text = isActive - ? s__('MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}') - : s__( - 'MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitId}%{linkEnd}', - ); - } else if (isDiffDiscussion) { - text = isActive - ? s__('MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}') - : s__( - 'MergeRequests|started a thread on %{linkStart}an old version of the diff%{linkEnd}', - ); - } - - return sprintf(text, { commitId, linkStart, linkEnd }, false); - }, diffLine() { if (this.line) { return this.line; @@ -208,16 +146,11 @@ export default { methods: { ...mapActions([ 'saveNote', - 'toggleDiscussion', 'removePlaceholderNotes', 'toggleResolveNote', 'expandDiscussion', 'removeConvertedDiscussion', ]), - truncateSha, - toggleDiscussionHandler() { - this.toggleDiscussion({ discussionId: this.discussion.id }); - }, showReplyForm() { this.isReplying = true; }, @@ -311,43 +244,7 @@ export default { class="discussion js-discussion-container" data-qa-selector="discussion_content" > - <div v-if="shouldRenderDiffs" class="discussion-header note-wrapper"> - <div v-once class="timeline-icon align-self-start flex-shrink-0"> - <user-avatar-link - v-if="author" - :link-href="author.path" - :img-src="author.avatar_url" - :img-alt="author.name" - :img-size="40" - /> - </div> - <div class="timeline-content w-100"> - <note-header - :author="author" - :created-at="firstNote.created_at" - :note-id="firstNote.id" - :include-toggle="true" - :expanded="discussion.expanded" - @toggleHandler="toggleDiscussionHandler" - > - <span v-html="actionText"></span> - </note-header> - <note-edited-text - v-if="discussion.resolved" - :edited-at="discussion.resolved_at" - :edited-by="discussion.resolved_by" - :action-text="resolvedText" - class-name="discussion-headline-light js-discussion-headline" - /> - <note-edited-text - v-else-if="lastUpdatedAt" - :edited-at="lastUpdatedAt" - :edited-by="lastUpdatedBy" - action-text="Last updated" - class-name="discussion-headline-light js-discussion-headline" - /> - </div> - </div> + <diff-discussion-header v-if="shouldRenderDiffs" :discussion="discussion" /> <div v-if="!shouldHideDiscussionBody" class="discussion-body"> <component :is="wrapperComponent" diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index c6c97489e5efeca92ebb431ed6fbed9db210a990..9d1de4ef8a085a631e6aa57814e5f0f5f85295a3 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -122,6 +122,8 @@ export default { this.toggleAward({ awardName, noteId }); }); } + + window.addEventListener('hashchange', this.handleHashChanged); }, updated() { this.$nextTick(() => { @@ -131,6 +133,7 @@ export default { }, beforeDestroy() { this.stopPolling(); + window.removeEventListener('hashchange', this.handleHashChanged); }, methods: { ...mapActions([ @@ -138,7 +141,6 @@ export default { 'fetchDiscussions', 'poll', 'toggleAward', - 'scrollToNoteIfNeeded', 'setNotesData', 'setNoteableData', 'setUserData', @@ -151,6 +153,13 @@ export default { 'convertToDiscussion', 'stopPolling', ]), + handleHashChanged() { + const noteId = this.checkLocationHash(); + + if (noteId) { + this.setTargetNoteHash(getLocationHash()); + } + }, fetchNotes() { if (this.isFetching) return null; @@ -194,6 +203,8 @@ export default { this.expandDiscussion({ discussionId: discussion.id }); } } + + return noteId; }, startReplying(discussionId) { return this.convertToDiscussion(discussionId) diff --git a/app/assets/javascripts/notes/mixins/description_version_history.js b/app/assets/javascripts/notes/mixins/description_version_history.js new file mode 100644 index 0000000000000000000000000000000000000000..12d80f3faa25df753f0dd668d7c162518bf0586b --- /dev/null +++ b/app/assets/javascripts/notes/mixins/description_version_history.js @@ -0,0 +1,12 @@ +// Placeholder for GitLab FOSS +// Actual implementation: ee/app/assets/javascripts/notes/mixins/description_version_history.js +export default { + computed: { + canSeeDescriptionVersion() {}, + shouldShowDescriptionVersion() {}, + descriptionVersionToggleIcon() {}, + }, + methods: { + toggleDescriptionVersion() {}, + }, +}; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 004035ea1d4b3c56d490c9fd047a8cfd51ace25e..82c291379ec028a96626cd1b733caee44ff1a186 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -12,6 +12,7 @@ import service from '../services/notes_service'; import loadAwardsHandler from '../../awards_handler'; import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils'; +import { mergeUrlParams } from '../../lib/utils/url_utility'; import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub'; import { __ } from '~/locale'; import Api from '~/api'; @@ -475,5 +476,20 @@ export const convertToDiscussion = ({ commit }, noteId) => export const removeConvertedDiscussion = ({ commit }, noteId) => commit(types.REMOVE_CONVERTED_DISCUSSION, noteId); +export const fetchDescriptionVersion = (_, { endpoint, startingVersion }) => { + let requestUrl = endpoint; + + if (startingVersion) { + requestUrl = mergeUrlParams({ start_version_id: startingVersion }, requestUrl); + } + + return axios + .get(requestUrl) + .then(res => res.data) + .catch(() => { + Flash(__('Something went wrong while fetching description changes. Please try again.')); + }); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/collapse_utils.js b/app/assets/javascripts/notes/stores/collapse_utils.js index bee6d4f032956116910ed559d4f5588affc70cbf..3cdcc7a05b8b313ba4895f81073b0678e68fdb5a 100644 --- a/app/assets/javascripts/notes/stores/collapse_utils.js +++ b/app/assets/javascripts/notes/stores/collapse_utils.js @@ -1,34 +1,9 @@ -import { n__, s__, sprintf } from '~/locale'; import { DESCRIPTION_TYPE } from '../constants'; -/** - * Changes the description from a note, returns 'changed the description n number of times' - */ -export const changeDescriptionNote = (note, descriptionChangedTimes, timeDifferenceMinutes) => { - const descriptionNote = Object.assign({}, note); - - descriptionNote.note_html = sprintf( - s__(`MergeRequest| - %{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}`), - { - paragraphStart: '<p dir="auto">', - paragraphEnd: '</p>', - descriptionChangedTimes, - timeDifferenceMinutes: n__('within %d minute ', 'within %d minutes ', timeDifferenceMinutes), - }, - false, - ); - - descriptionNote.times_updated = descriptionChangedTimes; - - return descriptionNote; -}; - /** * Checks the time difference between two notes from their 'created_at' dates * returns an integer */ - export const getTimeDifferenceMinutes = (noteBeggining, noteEnd) => { const descriptionNoteBegin = new Date(noteBeggining.created_at); const descriptionNoteEnd = new Date(noteEnd.created_at); @@ -57,7 +32,6 @@ export const isDescriptionSystemNote = note => note.system && note.note === DESC export const collapseSystemNotes = notes => { let lastDescriptionSystemNote = null; let lastDescriptionSystemNoteIndex = -1; - let descriptionChangedTimes = 1; return notes.slice(0).reduce((acc, currentNote) => { const note = currentNote.notes[0]; @@ -70,32 +44,24 @@ export const collapseSystemNotes = notes => { } else if (lastDescriptionSystemNote) { const timeDifferenceMinutes = getTimeDifferenceMinutes(lastDescriptionSystemNote, note); - // are they less than 10 minutes apart? - if (timeDifferenceMinutes > 10) { - // reset counter - descriptionChangedTimes = 1; + // are they less than 10 minutes apart from the same user? + if (timeDifferenceMinutes > 10 || note.author.id !== lastDescriptionSystemNote.author.id) { // update the previous system note lastDescriptionSystemNote = note; lastDescriptionSystemNoteIndex = acc.length; } else { - // increase counter - descriptionChangedTimes += 1; + // set the first version to fetch grouped system note versions + note.start_description_version_id = lastDescriptionSystemNote.description_version_id; // delete the previous one acc.splice(lastDescriptionSystemNoteIndex, 1); - // replace the text of the current system note with the collapsed note. - currentNote.notes.splice( - 0, - 1, - changeDescriptionNote(note, descriptionChangedTimes, timeDifferenceMinutes), - ); - // update the previous system note index lastDescriptionSystemNoteIndex = acc.length; } } } + acc.push(currentNote); return acc; }, []); diff --git a/app/assets/javascripts/pages/admin/abuse_reports/index.js b/app/assets/javascripts/pages/admin/abuse_reports/index.js index d76b1f174fc0ede284d9b0f9fa5b8135b6551888..d97e24d9e0b2bc9443589275f18af053a294306a 100644 --- a/app/assets/javascripts/pages/admin/abuse_reports/index.js +++ b/app/assets/javascripts/pages/admin/abuse_reports/index.js @@ -1,3 +1,8 @@ +/* eslint-disable no-new */ import AbuseReports from './abuse_reports'; +import UsersSelect from '~/users_select'; -document.addEventListener('DOMContentLoaded', () => new AbuseReports()); +document.addEventListener('DOMContentLoaded', () => { + new AbuseReports(); + new UsersSelect(); +}); diff --git a/app/assets/javascripts/pages/admin/clusters/index.js b/app/assets/javascripts/pages/admin/clusters/index.js index 43992938d07c041415b1affbf18990fcc4389a91..4d04c37caa74b2311fd7c86bfc7eee3b16bb8de6 100644 --- a/app/assets/javascripts/pages/admin/clusters/index.js +++ b/app/assets/javascripts/pages/admin/clusters/index.js @@ -1,21 +1,5 @@ -import PersistentUserCallout from '~/persistent_user_callout'; -import initGkeDropdowns from '~/create_cluster/gke_cluster'; - -function initGcpSignupCallout() { - const callout = document.querySelector('.gcp-signup-offer'); - PersistentUserCallout.factory(callout); -} +import initCreateCluster from '~/create_cluster/init_create_cluster'; document.addEventListener('DOMContentLoaded', () => { - const { page } = document.body.dataset; - const newClusterViews = [ - 'admin:clusters:new', - 'admin:clusters:create_gcp', - 'admin:clusters:create_user', - ]; - - if (newClusterViews.indexOf(page) > -1) { - initGcpSignupCallout(); - initGkeDropdowns(); - } + initCreateCluster(document, gon); }); diff --git a/app/assets/javascripts/pages/groups/index.js b/app/assets/javascripts/pages/groups/index.js index a33d242908b530056c89f8c1e9ca434d646a188c..4d04c37caa74b2311fd7c86bfc7eee3b16bb8de6 100644 --- a/app/assets/javascripts/pages/groups/index.js +++ b/app/assets/javascripts/pages/groups/index.js @@ -1,21 +1,5 @@ -import PersistentUserCallout from '~/persistent_user_callout'; -import initGkeDropdowns from '~/create_cluster/gke_cluster'; - -function initGcpSignupCallout() { - const callout = document.querySelector('.gcp-signup-offer'); - PersistentUserCallout.factory(callout); -} +import initCreateCluster from '~/create_cluster/init_create_cluster'; document.addEventListener('DOMContentLoaded', () => { - const { page } = document.body.dataset; - const newClusterViews = [ - 'groups:clusters:new', - 'groups:clusters:create_gcp', - 'groups:clusters:create_user', - ]; - - if (newClusterViews.indexOf(page) > -1) { - initGcpSignupCallout(); - initGkeDropdowns(); - } + initCreateCluster(document, gon); }); diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index dcdee77a8abfa391183cbf0449af82aacc5c6a88..090e1a2bc6d9452bc48c3421ccb5471192008344 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -1,3 +1,4 @@ +import initIssuablesList from '~/issuables_list'; import projectSelect from '~/project_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar'; @@ -11,6 +12,8 @@ document.addEventListener('DOMContentLoaded', () => { IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX); + initIssuablesList(); + initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, isGroupDecendent: true, diff --git a/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js b/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js new file mode 100644 index 0000000000000000000000000000000000000000..1d68ccd724d199b5bb4cbb2c17832785b1849fee --- /dev/null +++ b/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js @@ -0,0 +1,7 @@ +import axios from '~/lib/utils/axios_utils'; + +const rootUrl = gon.relative_url_root; + +export default function fetchGroupPathAvailability(groupPath) { + return axios.get(`${rootUrl}/users/${groupPath}/suggests`); +} diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js new file mode 100644 index 0000000000000000000000000000000000000000..2021ad117e8554d7b525f83e4cc1cf596cde6176 --- /dev/null +++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js @@ -0,0 +1,91 @@ +import InputValidator from '~/validators/input_validator'; + +import _ from 'underscore'; +import fetchGroupPathAvailability from './fetch_group_path_availability'; +import flash from '~/flash'; +import { __ } from '~/locale'; + +const debounceTimeoutDuration = 1000; +const invalidInputClass = 'gl-field-error-outline'; +const successInputClass = 'gl-field-success-outline'; +const successMessageSelector = '.validation-success'; +const pendingMessageSelector = '.validation-pending'; +const unavailableMessageSelector = '.validation-error'; +const suggestionsMessageSelector = '.gl-path-suggestions'; + +export default class GroupPathValidator extends InputValidator { + constructor(opts = {}) { + super(); + + const container = opts.container || ''; + const validateElements = document.querySelectorAll(`${container} .js-validate-group-path`); + + this.debounceValidateInput = _.debounce(inputDomElement => { + GroupPathValidator.validateGroupPathInput(inputDomElement); + }, debounceTimeoutDuration); + + validateElements.forEach(element => + element.addEventListener('input', this.eventHandler.bind(this)), + ); + } + + eventHandler(event) { + const inputDomElement = event.target; + + GroupPathValidator.resetInputState(inputDomElement); + this.debounceValidateInput(inputDomElement); + } + + static validateGroupPathInput(inputDomElement) { + const groupPath = inputDomElement.value; + + if (inputDomElement.checkValidity() && groupPath.length > 0) { + GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector); + + fetchGroupPathAvailability(groupPath) + .then(({ data }) => data) + .then(data => { + GroupPathValidator.setInputState(inputDomElement, !data.exists); + GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector, false); + GroupPathValidator.setMessageVisibility( + inputDomElement, + data.exists ? unavailableMessageSelector : successMessageSelector, + ); + + if (data.exists) { + GroupPathValidator.showSuggestions(inputDomElement, data.suggests); + } + }) + .catch(() => flash(__('An error occurred while validating group path'))); + } + } + + static showSuggestions(inputDomElement, suggestions) { + const messageElement = inputDomElement.parentElement.parentElement.querySelector( + suggestionsMessageSelector, + ); + const textSuggestions = suggestions && suggestions.length > 0 ? suggestions.join(', ') : 'none'; + messageElement.textContent = textSuggestions; + } + + static setMessageVisibility(inputDomElement, messageSelector, isVisible = true) { + const messageElement = inputDomElement.parentElement.parentElement.querySelector( + messageSelector, + ); + messageElement.classList.toggle('hide', !isVisible); + } + + static setInputState(inputDomElement, success = true) { + inputDomElement.classList.toggle(successInputClass, success); + inputDomElement.classList.toggle(invalidInputClass, !success); + } + + static resetInputState(inputDomElement) { + GroupPathValidator.setMessageVisibility(inputDomElement, successMessageSelector, false); + GroupPathValidator.setMessageVisibility(inputDomElement, unavailableMessageSelector, false); + + if (inputDomElement.checkValidity()) { + inputDomElement.classList.remove(successInputClass, invalidInputClass); + } + } +} diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js index 57b53eb9e5d2a4f8369d7de824868b17deacb1bc..0710fefe70cf6bb612ec3fd3c8fc6ad388661818 100644 --- a/app/assets/javascripts/pages/groups/new/index.js +++ b/app/assets/javascripts/pages/groups/new/index.js @@ -1,8 +1,14 @@ +import $ from 'jquery'; import BindInOut from '~/behaviors/bind_in_out'; import Group from '~/group'; import initAvatarPicker from '~/avatar_picker'; +import GroupPathValidator from './group_path_validator'; document.addEventListener('DOMContentLoaded', () => { + const parentId = $('#group_parent_id'); + if (!parentId.val()) { + new GroupPathValidator(); // eslint-disable-line no-new + } BindInOut.initAll(); new Group(); // eslint-disable-line no-new initAvatarPicker(); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 84e5bb3c46e271c8639014cb4b3f27af72e815fd..aee67899ca217d7667a97b7539ec4df6e160b410 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -3,6 +3,7 @@ import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_sta import BlobViewer from '~/blob/viewer/index'; import initBlob from '~/pages/projects/init_blob'; import GpgBadges from '~/gpg_badges'; +import '~/sourcegraph/load'; document.addEventListener('DOMContentLoaded', () => { new BlobViewer(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/clusters/new/index.js b/app/assets/javascripts/pages/projects/clusters/new/index.js deleted file mode 100644 index 14d5ab215550bc18d0f018700ce95ef8ccdc3c9a..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/pages/projects/clusters/new/index.js +++ /dev/null @@ -1,13 +0,0 @@ -document.addEventListener('DOMContentLoaded', () => { - if (gon.features.createEksClusters) { - import(/* webpackChunkName: 'eks_cluster' */ '~/create_cluster/eks_cluster') - .then(({ default: initCreateEKSCluster }) => { - const el = document.querySelector('.js-create-eks-cluster-form-container'); - - if (el) { - initCreateEKSCluster(el); - } - }) - .catch(() => {}); - } -}); diff --git a/app/assets/javascripts/pages/projects/clusters/show/index.js b/app/assets/javascripts/pages/projects/clusters/show/index.js index f091c01fc98de3a6caa03f655e2a67696284ee8a..397f9faf6fec088256312b4034e7bc02593a2cfa 100644 --- a/app/assets/javascripts/pages/projects/clusters/show/index.js +++ b/app/assets/javascripts/pages/projects/clusters/show/index.js @@ -1,5 +1,5 @@ import ClustersBundle from '~/clusters/clusters_bundle'; -import initGkeNamespace from '~/projects/gke_cluster_namespace'; +import initGkeNamespace from '~/create_cluster/gke_cluster_namespace'; document.addEventListener('DOMContentLoaded', () => { new ClustersBundle(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index 5aa4734244e702fed6e8b073e3003522b43084b8..0eb6f2318398a3ea2599354c5edb165b13cffbf5 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -9,6 +9,7 @@ import initNotes from '~/init_notes'; import initChangesDropdown from '~/init_changes_dropdown'; import initDiffNotes from '~/diff_notes/diff_notes_bundle'; import { fetchCommitMergeRequests } from '~/commit_merge_requests'; +import '~/sourcegraph/load'; document.addEventListener('DOMContentLoaded', () => { const hasPerfBar = document.querySelector('.with-performance-bar'); diff --git a/app/assets/javascripts/pages/projects/error_tracking/details/index.js b/app/assets/javascripts/pages/projects/error_tracking/details/index.js new file mode 100644 index 0000000000000000000000000000000000000000..25d1c744e1bb80a8485baf0126a761a07a4f476d --- /dev/null +++ b/app/assets/javascripts/pages/projects/error_tracking/details/index.js @@ -0,0 +1,5 @@ +import ErrorTrackingDetails from '~/error_tracking/details'; + +document.addEventListener('DOMContentLoaded', () => { + ErrorTrackingDetails(); +}); diff --git a/app/assets/javascripts/pages/projects/error_tracking/index.js b/app/assets/javascripts/pages/projects/error_tracking/index.js deleted file mode 100644 index 5a8fe137e9a2ab53caad766229d694f3dbde6dde..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/pages/projects/error_tracking/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import ErrorTracking from '~/error_tracking'; - -document.addEventListener('DOMContentLoaded', () => { - ErrorTracking(); -}); diff --git a/app/assets/javascripts/pages/projects/error_tracking/index/index.js b/app/assets/javascripts/pages/projects/error_tracking/index/index.js new file mode 100644 index 0000000000000000000000000000000000000000..ead81cd5d2ddb8fe8707bd19047e7fe65b2d7c3f --- /dev/null +++ b/app/assets/javascripts/pages/projects/error_tracking/index/index.js @@ -0,0 +1,5 @@ +import ErrorTrackingList from '~/error_tracking/list'; + +document.addEventListener('DOMContentLoaded', () => { + ErrorTrackingList(); +}); diff --git a/app/assets/javascripts/pages/projects/graphs/show/index.js b/app/assets/javascripts/pages/projects/graphs/show/index.js index f79c386b59e80832edd7e1cc0a76a532a20ffb29..09d9c78c446de4f3929d66e1974a722c8022222d 100644 --- a/app/assets/javascripts/pages/projects/graphs/show/index.js +++ b/app/assets/javascripts/pages/projects/graphs/show/index.js @@ -1,25 +1,3 @@ -import $ from 'jquery'; -import flash from '~/flash'; -import { __ } from '~/locale'; -import axios from '~/lib/utils/axios_utils'; -import ContributorsStatGraph from './stat_graph_contributors'; +import initContributorsGraphs from '~/contributors'; -document.addEventListener('DOMContentLoaded', () => { - const url = document.querySelector('.js-graphs-show').dataset.projectGraphPath; - - axios - .get(url) - .then(({ data }) => { - const graph = new ContributorsStatGraph(); - graph.init(data); - - $('#brush_change').change(() => { - graph.change_date_header(); - graph.redraw_authors(); - }); - - $('.stat-graph').fadeIn(); - $('.loading-graph').hide(); - }) - .catch(() => flash(__('Error fetching contributors data.'))); -}); +document.addEventListener('DOMContentLoaded', initContributorsGraphs); diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js deleted file mode 100644 index 5b873e6b909ba487e62a19220cc210358d88d8bc..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js +++ /dev/null @@ -1,140 +0,0 @@ -/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign */ - -import $ from 'jquery'; -import _ from 'underscore'; -import { n__, s__, createDateTimeFormat, sprintf } from '~/locale'; -import { - ContributorsGraph, - ContributorsAuthorGraph, - ContributorsMasterGraph, -} from './stat_graph_contributors_graph'; -import ContributorsStatGraphUtil from './stat_graph_contributors_util'; - -export default (function() { - function ContributorsStatGraph() { - this.dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' }); - } - - ContributorsStatGraph.prototype.init = function(log) { - var author_commits, total_commits; - this.parsed_log = ContributorsStatGraphUtil.parse_log(log); - this.set_current_field('commits'); - total_commits = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field); - author_commits = ContributorsStatGraphUtil.get_author_data(this.parsed_log, this.field); - this.add_master_graph(total_commits); - this.add_authors_graph(author_commits); - return this.change_date_header(); - }; - - ContributorsStatGraph.prototype.add_master_graph = function(total_data) { - this.master_graph = new ContributorsMasterGraph(total_data); - return this.master_graph.draw(); - }; - - ContributorsStatGraph.prototype.add_authors_graph = function(author_data) { - var limited_author_data; - this.authors = []; - limited_author_data = author_data.slice(0, 100); - return _.each( - limited_author_data, - (function(_this) { - return function(d) { - var author_graph, author_header; - author_header = _this.create_author_header(d); - $('.contributors-list').append(author_header); - - author_graph = new ContributorsAuthorGraph(d.dates); - _this.authors[d.author_name] = author_graph; - return author_graph.draw(); - }; - })(this), - ); - }; - - ContributorsStatGraph.prototype.format_author_commit_info = function(author) { - var commits; - commits = $('<span/>', { - class: 'graph-author-commits-count', - }); - commits.text(n__('%d commit', '%d commits', author.commits)); - return $('<span/>').append(commits); - }; - - ContributorsStatGraph.prototype.create_author_header = function(author) { - var author_commit_info, author_commit_info_span, author_email, author_name, list_item; - list_item = $('<li/>', { - class: 'person', - style: 'display: block;', - }); - author_name = $(`<h4>${author.author_name}</h4>`); - author_email = $(`<p class="graph-author-email">${author.author_email}</p>`); - author_commit_info_span = $('<span/>', { - class: 'commits', - }); - author_commit_info = this.format_author_commit_info(author); - author_commit_info_span.html(author_commit_info); - list_item.append(author_name); - list_item.append(author_email); - list_item.append(author_commit_info_span); - return list_item; - }; - - ContributorsStatGraph.prototype.redraw_master = function() { - var total_data; - total_data = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field); - this.master_graph.set_data(total_data); - return this.master_graph.redraw(); - }; - - ContributorsStatGraph.prototype.redraw_authors = function() { - $('ol').html(''); - - const { x_domain } = ContributorsGraph.prototype; - const author_commits = ContributorsStatGraphUtil.get_author_data( - this.parsed_log, - this.field, - x_domain, - ); - - return _.each( - author_commits, - (function(_this) { - return function(d) { - _this.redraw_author_commit_info(d); - if (_this.authors[d.author_name] != null) { - $(_this.authors[d.author_name].list_item).appendTo('ol'); - _this.authors[d.author_name].set_data(d.dates); - return _this.authors[d.author_name].redraw(); - } - return ''; - }; - })(this), - ); - }; - - ContributorsStatGraph.prototype.set_current_field = function(field) { - return (this.field = field); - }; - - ContributorsStatGraph.prototype.change_date_header = function() { - const { x_domain } = ContributorsGraph.prototype; - const formattedDateRange = sprintf(s__('ContributorsPage|%{startDate} – %{endDate}'), { - startDate: this.dateFormat.format(new Date(x_domain[0])), - endDate: this.dateFormat.format(new Date(x_domain[1])), - }); - return $('#date_header').text(formattedDateRange); - }; - - ContributorsStatGraph.prototype.redraw_author_commit_info = function(author) { - var author_commit_info, author_list_item, $author; - $author = this.authors[author.author_name]; - if ($author != null) { - author_list_item = $(this.authors[author.author_name].list_item); - author_commit_info = this.format_author_commit_info(author); - return author_list_item.find('span').html(author_commit_info); - } - return ''; - }; - - return ContributorsStatGraph; -})(); diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js deleted file mode 100644 index 86794800f87cf74b16359d5600305c20120351d6..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js +++ /dev/null @@ -1,379 +0,0 @@ -/* eslint-disable func-names, no-restricted-syntax, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, no-return-assign, no-else-return, no-shadow */ - -import $ from 'jquery'; -import _ from 'underscore'; -import { extent, max } from 'd3-array'; -import { select, event as d3Event } from 'd3-selection'; -import { scaleTime, scaleLinear } from 'd3-scale'; -import { axisLeft, axisBottom } from 'd3-axis'; -import { area } from 'd3-shape'; -import { brushX } from 'd3-brush'; -import { timeParse } from 'd3-time-format'; -import { dateTickFormat } from '~/lib/utils/tick_formats'; - -const d3 = { - extent, - max, - select, - scaleTime, - scaleLinear, - axisLeft, - axisBottom, - area, - brushX, - timeParse, -}; - -const hasProp = {}.hasOwnProperty; -const extend = function(child, parent) { - for (const key in parent) { - if (hasProp.call(parent, key)) child[key] = parent[key]; - } - function ctor() { - this.constructor = child; - } - ctor.prototype = parent.prototype; - child.prototype = new ctor(); - child.__super__ = parent.prototype; - return child; -}; - -export const ContributorsGraph = (function() { - function ContributorsGraph() {} - - ContributorsGraph.prototype.MARGIN = { - top: 20, - right: 10, - bottom: 30, - left: 40, - }; - - ContributorsGraph.prototype.x_domain = null; - - ContributorsGraph.prototype.y_domain = null; - - ContributorsGraph.prototype.dates = []; - - ContributorsGraph.prototype.determine_width = function(baseWidth, $parentElement) { - const parentPaddingWidth = - parseFloat($parentElement.css('padding-left')) + - parseFloat($parentElement.css('padding-right')); - const marginWidth = this.MARGIN.left + this.MARGIN.right; - return baseWidth - parentPaddingWidth - marginWidth; - }; - - ContributorsGraph.set_x_domain = function(data) { - return (ContributorsGraph.prototype.x_domain = data); - }; - - ContributorsGraph.set_y_domain = function(data) { - return (ContributorsGraph.prototype.y_domain = [ - 0, - d3.max(data, d => (d.commits = d.commits || d.additions || d.deletions)), - ]); - }; - - ContributorsGraph.init_x_domain = function(data) { - return (ContributorsGraph.prototype.x_domain = d3.extent(data, d => d.date)); - }; - - ContributorsGraph.init_y_domain = function(data) { - return (ContributorsGraph.prototype.y_domain = [ - 0, - d3.max(data, d => (d.commits = d.commits || d.additions || d.deletions)), - ]); - }; - - ContributorsGraph.init_domain = function(data) { - ContributorsGraph.init_x_domain(data); - return ContributorsGraph.init_y_domain(data); - }; - - ContributorsGraph.set_dates = function(data) { - return (ContributorsGraph.prototype.dates = data); - }; - - ContributorsGraph.prototype.set_x_domain = function() { - return this.x.domain(this.x_domain); - }; - - ContributorsGraph.prototype.set_y_domain = function() { - return this.y.domain(this.y_domain); - }; - - ContributorsGraph.prototype.set_domain = function() { - this.set_x_domain(); - return this.set_y_domain(); - }; - - ContributorsGraph.prototype.create_scale = function(width, height) { - this.x = d3 - .scaleTime() - .range([0, width]) - .clamp(true); - return (this.y = d3 - .scaleLinear() - .range([height, 0]) - .nice()); - }; - - ContributorsGraph.prototype.draw_x_axis = function() { - return this.svg - .append('g') - .attr('class', 'x axis') - .attr('transform', `translate(0, ${this.height})`) - .call(this.x_axis); - }; - - ContributorsGraph.prototype.draw_y_axis = function() { - return this.svg - .append('g') - .attr('class', 'y axis') - .call(this.y_axis); - }; - - ContributorsGraph.prototype.set_data = function(data) { - return (this.data = data); - }; - - return ContributorsGraph; -})(); - -export const ContributorsMasterGraph = (function(superClass) { - extend(ContributorsMasterGraph, superClass); - - function ContributorsMasterGraph(data1) { - const $parentElement = $('#contributors-master'); - - this.data = data1; - this.update_content = this.update_content.bind(this); - this.width = this.determine_width($('.js-graphs-show').width(), $parentElement); - this.height = 200; - this.x = null; - this.y = null; - this.x_axis = null; - this.y_axis = null; - this.area = null; - this.svg = null; - this.brush = null; - this.x_max_domain = null; - } - - ContributorsMasterGraph.prototype.process_dates = function(data) { - const dates = this.get_dates(data); - this.parse_dates(data); - return ContributorsGraph.set_dates(dates); - }; - - ContributorsMasterGraph.prototype.get_dates = function(data) { - return _.pluck(data, 'date'); - }; - - ContributorsMasterGraph.prototype.parse_dates = function(data) { - const parseDate = d3.timeParse('%Y-%m-%d'); - return data.forEach(d => (d.date = parseDate(d.date))); - }; - - ContributorsMasterGraph.prototype.create_scale = function() { - return ContributorsMasterGraph.__super__.create_scale.call(this, this.width, this.height); - }; - - ContributorsMasterGraph.prototype.create_axes = function() { - this.x_axis = d3 - .axisBottom() - .scale(this.x) - .tickFormat(dateTickFormat); - return (this.y_axis = d3 - .axisLeft() - .scale(this.y) - .ticks(5)); - }; - - ContributorsMasterGraph.prototype.create_svg = function() { - this.svg = d3 - .select('#contributors-master') - .append('svg') - .attr('width', this.width + this.MARGIN.left + this.MARGIN.right) - .attr('height', this.height + this.MARGIN.top + this.MARGIN.bottom) - .attr('class', 'tint-box') - .append('g') - .attr('transform', `translate(${this.MARGIN.left},${this.MARGIN.top})`); - return this.svg; - }; - - ContributorsMasterGraph.prototype.create_area = function(x, y) { - return (this.area = d3 - .area() - .x(d => x(d.date)) - .y0(this.height) - .y1(d => { - d.commits = d.commits || d.additions || d.deletions; - return y(d.commits); - })); - }; - - ContributorsMasterGraph.prototype.create_brush = function() { - return (this.brush = d3 - .brushX(this.x) - .extent([[this.x.range()[0], 0], [this.x.range()[1], this.height]]) - .on('end', this.update_content)); - }; - - ContributorsMasterGraph.prototype.draw_path = function(data) { - return this.svg - .append('path') - .datum(data) - .attr('class', 'area') - .attr('d', this.area); - }; - - ContributorsMasterGraph.prototype.add_brush = function() { - return this.svg - .append('g') - .attr('class', 'selection') - .call(this.brush) - .selectAll('rect') - .attr('height', this.height); - }; - - ContributorsMasterGraph.prototype.update_content = function() { - // d3Event.selection replaces the function brush.empty() calls - if (d3Event.selection != null) { - ContributorsGraph.set_x_domain(d3Event.selection.map(this.x.invert)); - } else { - ContributorsGraph.set_x_domain(this.x_max_domain); - } - return $('#brush_change').trigger('change'); - }; - - ContributorsMasterGraph.prototype.draw = function() { - this.process_dates(this.data); - this.create_scale(); - this.create_axes(); - ContributorsGraph.init_domain(this.data); - this.x_max_domain = this.x_domain; - this.set_domain(); - this.create_area(this.x, this.y); - this.create_svg(); - this.create_brush(); - this.draw_path(this.data); - this.draw_x_axis(); - this.draw_y_axis(); - return this.add_brush(); - }; - - ContributorsMasterGraph.prototype.redraw = function() { - this.process_dates(this.data); - ContributorsGraph.set_y_domain(this.data); - this.set_y_domain(); - this.svg.select('path').datum(this.data); - this.svg.select('path').attr('d', this.area); - return this.svg.select('.y.axis').call(this.y_axis); - }; - - return ContributorsMasterGraph; -})(ContributorsGraph); - -export const ContributorsAuthorGraph = (function(superClass) { - extend(ContributorsAuthorGraph, superClass); - - function ContributorsAuthorGraph(data1) { - const $parentElements = $('.person'); - - this.data = data1; - // Don't split graph size in half for mobile devices. - if ($(window).width() < 790) { - this.width = this.determine_width($('.js-graphs-show').width(), $parentElements); - } else { - this.width = this.determine_width($('.js-graphs-show').width() / 2, $parentElements); - } - this.height = 200; - this.x = null; - this.y = null; - this.x_axis = null; - this.y_axis = null; - this.area = null; - this.svg = null; - this.list_item = null; - } - - ContributorsAuthorGraph.prototype.create_scale = function() { - return ContributorsAuthorGraph.__super__.create_scale.call(this, this.width, this.height); - }; - - ContributorsAuthorGraph.prototype.create_axes = function() { - this.x_axis = d3 - .axisBottom() - .scale(this.x) - .ticks(8) - .tickFormat(dateTickFormat); - return (this.y_axis = d3 - .axisLeft() - .scale(this.y) - .ticks(5)); - }; - - ContributorsAuthorGraph.prototype.create_area = function(x, y) { - return (this.area = d3 - .area() - .x(d => { - const parseDate = d3.timeParse('%Y-%m-%d'); - return x(parseDate(d)); - }) - .y0(this.height) - .y1( - (function(_this) { - return function(d) { - if (_this.data[d] != null) { - return y(_this.data[d]); - } else { - return y(0); - } - }; - })(this), - )); - }; - - ContributorsAuthorGraph.prototype.create_svg = function() { - const persons = document.querySelectorAll('.person'); - this.list_item = persons[persons.length - 1]; - this.svg = d3 - .select(this.list_item) - .append('svg') - .attr('width', this.width + this.MARGIN.left + this.MARGIN.right) - .attr('height', this.height + this.MARGIN.top + this.MARGIN.bottom) - .attr('class', 'spark') - .append('g') - .attr('transform', `translate(${this.MARGIN.left},${this.MARGIN.top})`); - return this.svg; - }; - - ContributorsAuthorGraph.prototype.draw_path = function(data) { - return this.svg - .append('path') - .datum(data) - .attr('class', 'area-contributor') - .attr('d', this.area); - }; - - ContributorsAuthorGraph.prototype.draw = function() { - this.create_scale(); - this.create_axes(); - this.set_domain(); - this.create_area(this.x, this.y); - this.create_svg(); - this.draw_path(this.dates); - this.draw_x_axis(); - return this.draw_y_axis(); - }; - - ContributorsAuthorGraph.prototype.redraw = function() { - this.set_domain(); - this.svg.select('path').datum(this.dates); - this.svg.select('path').attr('d', this.area); - this.svg.select('.x.axis').call(this.x_axis); - return this.svg.select('.y.axis').call(this.y_axis); - }; - - return ContributorsAuthorGraph; -})(ContributorsGraph); diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js deleted file mode 100644 index a89a13fe37a6d81489fdea223c8ba30e1d98c44c..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js +++ /dev/null @@ -1,143 +0,0 @@ -/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign, consistent-return, no-cond-assign, no-else-return */ -import _ from 'underscore'; - -export default { - parse_log(log) { - var by_author, by_email, data, entry, i, len, total, normalized_email; - total = {}; - by_author = {}; - by_email = {}; - for (i = 0, len = log.length; i < len; i += 1) { - entry = log[i]; - if (total[entry.date] == null) { - this.add_date(entry.date, total); - } - normalized_email = entry.author_email.toLowerCase(); - data = by_author[entry.author_name] || by_email[normalized_email]; - if (data == null) { - data = this.add_author(entry, by_author, by_email); - } - if (!data[entry.date]) { - this.add_date(entry.date, data); - } - this.store_data(entry, total[entry.date], data[entry.date]); - } - total = _.toArray(total); - by_author = _.toArray(by_author); - return { - total, - by_author, - }; - }, - add_date(date, collection) { - collection[date] = {}; - return (collection[date].date = date); - }, - add_author(author, by_author, by_email) { - var data, normalized_email; - data = {}; - data.author_name = author.author_name; - data.author_email = author.author_email; - normalized_email = author.author_email.toLowerCase(); - by_author[author.author_name] = data; - by_email[normalized_email] = data; - return data; - }, - store_data(entry, total, by_author) { - this.store_commits(total, by_author); - this.store_additions(entry, total, by_author); - return this.store_deletions(entry, total, by_author); - }, - store_commits(total, by_author) { - this.add(total, 'commits', 1); - return this.add(by_author, 'commits', 1); - }, - add(collection, field, value) { - if (collection[field] == null) { - collection[field] = 0; - } - return (collection[field] += value); - }, - store_additions(entry, total, by_author) { - if (entry.additions == null) { - entry.additions = 0; - } - this.add(total, 'additions', entry.additions); - return this.add(by_author, 'additions', entry.additions); - }, - store_deletions(entry, total, by_author) { - if (entry.deletions == null) { - entry.deletions = 0; - } - this.add(total, 'deletions', entry.deletions); - return this.add(by_author, 'deletions', entry.deletions); - }, - get_total_data(parsed_log, field) { - var log, total_data; - log = parsed_log.total; - total_data = this.pick_field(log, field); - return _.sortBy(total_data, d => d.date); - }, - pick_field(log, field) { - var total_data; - total_data = []; - _.each(log, d => total_data.push(_.pick(d, [field, 'date']))); - return total_data; - }, - get_author_data(parsed_log, field, date_range) { - var author_data, log; - if (date_range == null) { - date_range = null; - } - log = parsed_log.by_author; - author_data = []; - _.each( - log, - (function(_this) { - return function(log_entry) { - var parsed_log_entry; - parsed_log_entry = _this.parse_log_entry(log_entry, field, date_range); - if (!_.isEmpty(parsed_log_entry.dates)) { - return author_data.push(parsed_log_entry); - } - }; - })(this), - ); - return _.sortBy(author_data, d => d[field]).reverse(); - }, - parse_log_entry(log_entry, field, date_range) { - var parsed_entry; - parsed_entry = {}; - - parsed_entry.author_name = log_entry.author_name; - parsed_entry.author_email = log_entry.author_email; - parsed_entry.dates = {}; - - parsed_entry.commits = 0; - parsed_entry.additions = 0; - parsed_entry.deletions = 0; - - _.each( - _.omit(log_entry, 'author_name', 'author_email'), - (function(_this) { - return function(value) { - if (_this.in_range(value.date, date_range)) { - parsed_entry.dates[value.date] = value[field]; - parsed_entry.commits += value.commits; - parsed_entry.additions += value.additions; - return (parsed_entry.deletions += value.deletions); - } - }; - })(this), - ); - return parsed_entry; - }, - in_range(date, date_range) { - var ref; - if (date_range === null || (date_range[0] <= (ref = new Date(date)) && ref <= date_range[1])) { - return true; - } else { - return false; - } - }, -}; diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index 196798a9076d13b78504cd3907895285c935a91d..190d0806c28cd3e1f122bd0767adeb723cc5255e 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,24 +1,9 @@ -import initGkeDropdowns from '~/create_cluster/gke_cluster'; -import initGkeNamespace from '~/projects/gke_cluster_namespace'; -import PersistentUserCallout from '../../persistent_user_callout'; import Project from './project'; import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation'; +import initCreateCluster from '~/create_cluster/init_create_cluster'; document.addEventListener('DOMContentLoaded', () => { - const { page } = document.body.dataset; - const newClusterViews = [ - 'projects:clusters:new', - 'projects:clusters:create_gcp', - 'projects:clusters:create_user', - ]; - - if (newClusterViews.indexOf(page) > -1) { - const callout = document.querySelector('.gcp-signup-offer'); - PersistentUserCallout.factory(callout); - - initGkeDropdowns(); - initGkeNamespace(); - } + initCreateCluster(document, gon); new Project(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index fa1de1f13cb630f9a3e3b0986a294212951395a7..16034313af20d14d144546a7ed82ab94fa9a53fb 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -5,6 +5,7 @@ import { handleLocationHash } from '~/lib/utils/common_utils'; import howToMerge from '~/how_to_merge'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle'; +import initSourcegraph from '~/sourcegraph'; import initWidget from '../../../vue_merge_request_widget'; export default function() { @@ -19,4 +20,5 @@ export default function() { handleLocationHash(); howToMerge(); initWidget(); + initSourcegraph(); } diff --git a/app/assets/javascripts/pages/projects/network/network.js b/app/assets/javascripts/pages/projects/network/network.js index 43417fa9702ab180e2c3843d03d24ee4d859ed51..5f2014f1631d02f26232400b95f0ce0574316293 100644 --- a/app/assets/javascripts/pages/projects/network/network.js +++ b/app/assets/javascripts/pages/projects/network/network.js @@ -1,22 +1,19 @@ -/* eslint-disable func-names, no-var */ - import $ from 'jquery'; import BranchGraph from '../../../network/branch_graph'; -export default (function() { - function Network(opts) { - var vph; - $('#filter_ref').click(function() { - return $(this) - .closest('form') - .submit(); - }); - this.branch_graph = new BranchGraph($('.network-graph'), opts); - vph = $(window).height() - 250; - $('.network-graph').css({ - height: `${vph}px`, - }); +const vph = $(window).height() - 250; + +export default class Network { + constructor(opts) { + this.opts = opts; + this.filter_ref = $('#filter_ref'); + this.network_graph = $('.network-graph'); + this.filter_ref.click(() => this.submit()); + this.branch_graph = new BranchGraph(this.network_graph, this.opts); + this.network_graph.css({ height: `${vph}px` }); } - return Network; -})(); + submit() { + return this.filter_ref.closest('form').submit(); + } +} diff --git a/app/assets/javascripts/pages/projects/pages_domains/form.js b/app/assets/javascripts/pages/projects/pages_domains/form.js index cef8e92610cf70360bcd57f8eee398964e6024db..ae5368179b12b6e762c6678a3ddc29f1ad10efbc 100644 --- a/app/assets/javascripts/pages/projects/pages_domains/form.js +++ b/app/assets/javascripts/pages/projects/pages_domains/form.js @@ -1,17 +1,23 @@ import setupToggleButtons from '~/toggle_buttons'; +function updateVisibility(selector, isVisible) { + Array.from(document.querySelectorAll(selector)).forEach(el => { + if (isVisible) { + el.classList.remove('d-none'); + } else { + el.classList.add('d-none'); + } + }); +} + export default () => { const toggleContainer = document.querySelector('.js-auto-ssl-toggle-container'); if (toggleContainer) { const onToggleButtonClicked = isAutoSslEnabled => { - Array.from(document.querySelectorAll('.js-shown-unless-auto-ssl')).forEach(el => { - if (isAutoSslEnabled) { - el.classList.add('d-none'); - } else { - el.classList.remove('d-none'); - } - }); + updateVisibility('.js-shown-unless-auto-ssl', !isAutoSslEnabled); + + updateVisibility('.js-shown-if-auto-ssl', isAutoSslEnabled); Array.from(document.querySelectorAll('.js-enabled-unless-auto-ssl')).forEach(el => { if (isAutoSslEnabled) { diff --git a/app/assets/javascripts/pages/projects/pipelines/test_report/index.js b/app/assets/javascripts/pages/projects/pipelines/test_report/index.js new file mode 100644 index 0000000000000000000000000000000000000000..7e69983c2ed1d1602723ac3d3a029b38415771bc --- /dev/null +++ b/app/assets/javascripts/pages/projects/pipelines/test_report/index.js @@ -0,0 +1,2 @@ +// /test_report is an alias for show +import '../show/index'; diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index 435e87058039adc15f9179a23728ce7b8180c5bc..01acfca158f979bc767409c03053d7e13a93f9f8 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -40,11 +40,6 @@ export default class Project { $label.text(activeText); }); - $('#modal-geo-info').data({ - cloneUrlSecondary: $this.attr('href'), - cloneUrlPrimary: $this.data('primaryUrl') || '', - }); - if (mobileCloneField) { mobileCloneField.dataset.clipboardText = url; } else { diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js index 98e19705976c0473402a566b63b27479add34fe6..a32c188909ce79865d3fa11061a8ca3dce0f493c 100644 --- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js @@ -1,9 +1,11 @@ import mountErrorTrackingForm from '~/error_tracking_settings'; import mountOperationSettings from '~/operation_settings'; +import mountGrafanaIntegration from '~/grafana_integration'; import initSettingsPanels from '~/settings_panels'; document.addEventListener('DOMContentLoaded', () => { mountErrorTrackingForm(); mountOperationSettings(); + mountGrafanaIntegration(); initSettingsPanels(); }); 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 89cac42abae0fc467a8a3035a9bbe58a9b345995..4802cc2ad25d3fde9080643cb951a64921600599 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 @@ -1,7 +1,6 @@ <script> -/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin'; -import { __ } from '~/locale'; +import { s__ } from '~/locale'; import projectFeatureSetting from './project_feature_setting.vue'; import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue'; import projectSettingRow from './project_setting_row.vue'; @@ -13,7 +12,7 @@ import { } from '../constants'; import { toggleHiddenClassBySelector } from '../external'; -const PAGE_FEATURE_ACCESS_LEVEL = __('Everyone'); +const PAGE_FEATURE_ACCESS_LEVEL = s__('ProjectSettings|Everyone'); export default { components: { @@ -207,7 +206,10 @@ export default { <template> <div> <div class="project-visibility-setting"> - <project-setting-row :help-path="visibilityHelpPath" label="Project visibility"> + <project-setting-row + :help-path="visibilityHelpPath" + :label="s__('ProjectSettings|Project visibility')" + > <div class="project-feature-controls"> <div class="select-wrapper"> <select @@ -220,17 +222,17 @@ export default { <option :value="visibilityOptions.PRIVATE" :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)" - >{{ __('Private') }}</option + >{{ s__('ProjectSettings|Private') }}</option > <option :value="visibilityOptions.INTERNAL" :disabled="!visibilityAllowed(visibilityOptions.INTERNAL)" - >{{ __('Internal') }}</option + >{{ s__('ProjectSettings|Internal') }}</option > <option :value="visibilityOptions.PUBLIC" :disabled="!visibilityAllowed(visibilityOptions.PUBLIC)" - >{{ __('Public') }}</option + >{{ s__('ProjectSettings|Public') }}</option > </select> <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i> @@ -243,14 +245,15 @@ export default { type="hidden" name="project[request_access_enabled]" /> - <input v-model="requestAccessEnabled" type="checkbox" /> Allow users to request access + <input v-model="requestAccessEnabled" type="checkbox" /> + {{ s__('ProjectSettings|Allow users to request access') }} </label> </project-setting-row> </div> <div :class="{ 'highlight-changes': highlightChangesClass }" class="project-feature-settings"> <project-setting-row - label="Issues" - help-text="Lightweight issue tracking system for this project" + :label="s__('ProjectSettings|Issues')" + :help-text="s__('ProjectSettings|Lightweight issue tracking system for this project')" > <project-feature-setting v-model="issuesAccessLevel" @@ -258,7 +261,10 @@ export default { name="project[project_feature_attributes][issues_access_level]" /> </project-setting-row> - <project-setting-row label="Repository" help-text="View and edit files in this project"> + <project-setting-row + :label="s__('ProjectSettings|Repository')" + :help-text="s__('ProjectSettings|View and edit files in this project')" + > <project-feature-setting v-model="repositoryAccessLevel" :options="featureAccessLevelOptions" @@ -267,8 +273,8 @@ export default { </project-setting-row> <div class="project-feature-setting-group"> <project-setting-row - label="Merge requests" - help-text="Submit changes to be merged upstream" + :label="s__('ProjectSettings|Merge requests')" + :help-text="s__('ProjectSettings|Submit changes to be merged upstream')" > <project-feature-setting v-model="mergeRequestsAccessLevel" @@ -277,7 +283,10 @@ export default { name="project[project_feature_attributes][merge_requests_access_level]" /> </project-setting-row> - <project-setting-row label="Pipelines" help-text="Build, test, and deploy your changes"> + <project-setting-row + :label="s__('ProjectSettings|Pipelines')" + :help-text="s__('ProjectSettings|Build, test, and deploy your changes')" + > <project-feature-setting v-model="buildsAccessLevel" :options="repoFeatureAccessLevelOptions" @@ -288,11 +297,17 @@ export default { <project-setting-row v-if="registryAvailable" :help-path="registryHelpPath" - label="Container registry" - help-text="Every project can have its own space to store its Docker images" + :label="s__('ProjectSettings|Container registry')" + :help-text=" + s__('ProjectSettings|Every project can have its own space to store its Docker images') + " > <div v-if="showContainerRegistryPublicNote" class="text-muted"> - {{ __('Note: the container registry is always visible when a project is public') }} + {{ + s__( + 'ProjectSettings|Note: the container registry is always visible when a project is public', + ) + }} </div> <project-feature-toggle v-model="containerRegistryEnabled" @@ -303,8 +318,10 @@ export default { <project-setting-row v-if="lfsAvailable" :help-path="lfsHelpPath" - label="Git Large File Storage" - help-text="Manages large files such as audio, video, and graphics files" + :label="s__('ProjectSettings|Git Large File Storage')" + :help-text=" + s__('ProjectSettings|Manages large files such as audio, video, and graphics files') + " > <project-feature-toggle v-model="lfsEnabled" @@ -315,8 +332,10 @@ export default { <project-setting-row v-if="packagesAvailable" :help-path="packagesHelpPath" - label="Packages" - help-text="Every project can have its own space to store its packages" + :label="s__('ProjectSettings|Packages')" + :help-text=" + s__('ProjectSettings|Every project can have its own space to store its packages') + " > <project-feature-toggle v-model="packagesEnabled" @@ -325,7 +344,10 @@ export default { /> </project-setting-row> </div> - <project-setting-row label="Wiki" help-text="Pages for project documentation"> + <project-setting-row + :label="s__('ProjectSettings|Wiki')" + :help-text="s__('ProjectSettings|Pages for project documentation')" + > <project-feature-setting v-model="wikiAccessLevel" :options="featureAccessLevelOptions" @@ -333,8 +355,8 @@ export default { /> </project-setting-row> <project-setting-row - label="Snippets" - help-text="Share code pastes with others out of Git repository" + :label="s__('ProjectSettings|Snippets')" + :help-text="s__('ProjectSettings|Share code pastes with others out of Git repository')" > <project-feature-setting v-model="snippetsAccessLevel" @@ -345,8 +367,10 @@ export default { <project-setting-row v-if="pagesAvailable && pagesAccessControlEnabled" :help-path="pagesHelpPath" - label="Pages access control" - help-text="Access control for the project's static website" + :label="s__('ProjectSettings|Pages')" + :help-text=" + s__('ProjectSettings|With GitLab Pages you can host your static websites on GitLab') + " > <project-feature-setting v-model="pagesAccessLevel" @@ -358,10 +382,13 @@ export default { <project-setting-row v-if="canDisableEmails" class="mb-3"> <label class="js-emails-disabled"> <input :value="emailsDisabled" type="hidden" name="project[emails_disabled]" /> - <input v-model="emailsDisabled" type="checkbox" /> {{ __('Disable email notifications') }} + <input v-model="emailsDisabled" type="checkbox" /> + {{ s__('ProjectSettings|Disable email notifications') }} </label> <span class="form-text text-muted">{{ - __('This setting will override user notification preferences for all project members.') + s__( + 'ProjectSettings|This setting will override user notification preferences for all project members.', + ) }}</span> </project-setting-row> </div> diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index 6aa41d0825ba2f3fa2cbec994623fb411f0fc0d8..370f3c6e7a2b0d1e70f958f83ff587fc0abcb583 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -48,7 +48,7 @@ document.addEventListener('DOMContentLoaded', () => { leaveByUrl('project'); if (document.getElementById('js-tree-list')) { - import('~/repository') + import('ee_else_ce/repository') .then(m => m.default()) .catch(e => { throw e; diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js index 7b90a3a4f6ea8aa0ee218df946431f05da8000bf..16d71379e31758034fd8197b9d3c3f16f0cb9a92 100644 --- a/app/assets/javascripts/pages/projects/tree/show/index.js +++ b/app/assets/javascripts/pages/projects/tree/show/index.js @@ -42,7 +42,7 @@ document.addEventListener('DOMContentLoaded', () => { GpgBadges.fetch(); if (document.getElementById('js-tree-list')) { - import('~/repository') + import('ee_else_ce/repository') .then(m => m.default()) .catch(e => { throw e; diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js index 55bc93a2b13ca181599f4c88c7d4fa210ac8eeaa..66ee2d9303fcaf133775dbd7076c560ff7eaf70f 100644 --- a/app/assets/javascripts/pages/sessions/new/index.js +++ b/app/assets/javascripts/pages/sessions/new/index.js @@ -5,6 +5,7 @@ import NoEmojiValidator from '../../../emoji/no_emoji_validator'; import SigninTabsMemoizer from './signin_tabs_memoizer'; import OAuthRememberMe from './oauth_remember_me'; import preserveUrlFragment from './preserve_url_fragment'; +import Tracking from '~/tracking'; document.addEventListener('DOMContentLoaded', () => { new UsernameValidator(); // eslint-disable-line no-new @@ -20,3 +21,16 @@ document.addEventListener('DOMContentLoaded', () => { // redirected to sign-in after attempting to access a protected URL that included a fragment. preserveUrlFragment(window.location.hash); }); + +export default function trackData() { + if (gon.tracking_data) { + const tab = document.querySelector(".new-session-tabs a[href='#register-pane']"); + const { category, action, ...data } = gon.tracking_data; + + tab.addEventListener('click', () => { + Tracking.event(category, action, data); + }); + } +} + +trackData(); diff --git a/app/assets/javascripts/performance_bar/components/add_request.vue b/app/assets/javascripts/performance_bar/components/add_request.vue new file mode 100644 index 0000000000000000000000000000000000000000..54bca8a1b67408e0903de01e97ab51b4ab9e2a2d --- /dev/null +++ b/app/assets/javascripts/performance_bar/components/add_request.vue @@ -0,0 +1,48 @@ +import { __ } from '~/locale'; + +<script> +export default { + data() { + return { + inputEnabled: false, + urlOrRequestId: '', + }; + }, + methods: { + toggleInput() { + this.inputEnabled = !this.inputEnabled; + }, + addRequest() { + this.$emit('add-request', this.urlOrRequestId); + this.clearForm(); + }, + clearForm() { + this.urlOrRequestId = ''; + this.toggleInput(); + }, + }, +}; +</script> +<template> + <div id="peek-view-add-request" class="view"> + <form class="form-inline" @submit.prevent> + <button + class="btn-blank btn-link bold" + type="button" + :title="__(`Add request manually`)" + @click="toggleInput" + > + + + </button> + <input + v-if="inputEnabled" + v-model="urlOrRequestId" + type="text" + :placeholder="__(`URL or request ID`)" + class="form-control form-control-sm d-inline-block ml-1" + @keyup.enter="addRequest" + @keyup.esc="clearForm" + /> + </form> + </div> +</template> 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 3b07eba02b7ad77eadc35d5d60c47dc53ea2a2c7..8ce653bf1fbe37e7c364901b9979ad0745183c4d 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -1,12 +1,14 @@ <script> import { glEmojiTag } from '~/emoji'; +import AddRequest from './add_request.vue'; import DetailedMetric from './detailed_metric.vue'; import RequestSelector from './request_selector.vue'; import { s__ } from '~/locale'; export default { components: { + AddRequest, DetailedMetric, RequestSelector, }, @@ -118,6 +120,7 @@ export default { > <a :href="currentRequest.details.tracing.tracing_url">{{ s__('PerformanceBar|trace') }}</a> </div> + <add-request v-on="$listeners" /> <request-selector v-if="currentRequest" :current-request="currentRequest" diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js index 1ae9487f391a755ed9fa5bcd4f532d8497edf9dd..735c9d804ee1b4922e451b6a787029f52f46f14e 100644 --- a/app/assets/javascripts/performance_bar/index.js +++ b/app/assets/javascripts/performance_bar/index.js @@ -1,4 +1,6 @@ import Vue from 'vue'; +import axios from '~/lib/utils/axios_utils'; + import PerformanceBarService from './services/performance_bar_service'; import PerformanceBarStore from './stores/performance_bar_store'; @@ -32,6 +34,15 @@ export default ({ container }) => PerformanceBarService.removeInterceptor(this.interceptor); }, methods: { + addRequestManually(urlOrRequestId) { + if (urlOrRequestId.startsWith('https://') || urlOrRequestId.startsWith('http://')) { + // We don't need to do anything with the response, we just + // want to trace the request. + axios.get(urlOrRequestId); + } else { + this.loadRequestDetails(urlOrRequestId, urlOrRequestId); + } + }, loadRequestDetails(requestId, requestUrl) { if (!this.store.canTrackRequest(requestUrl)) { return; @@ -58,6 +69,9 @@ export default ({ container }) => peekUrl: this.peekUrl, profileUrl: this.profileUrl, }, + on: { + 'add-request': this.addRequestManually, + }, }); }, }); diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index 3c85bb61ce8f3df7c40c7342c531cbf0bef428fe..fd59a5809613444af90b275b5583145caa904424 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -88,7 +88,7 @@ export default { :title="tooltipText" :class="cssClass" :disabled="isDisabled" - class="js-ci-action btn btn-blank btn-transparent ci-action-icon-container ci-action-icon-wrapper" + class="js-ci-action btn btn-blank btn-transparent ci-action-icon-container ci-action-icon-wrapper d-flex align-items-center justify-content-center" @click="onClickAction" > <gl-loading-icon v-if="isLoading" class="js-action-icon-loading" /> diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue index 5275de3bc8b5449ec64db08150f286c281a3de74..afb8439511fddcb331b4c2660e39d094d9b293d0 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue @@ -265,7 +265,11 @@ export default { <div class="table-section section-10 commit-link"> <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Status') }}</div> <div class="table-mobile-content"> - <ci-badge :status="pipelineStatus" :show-text="!isChildView" /> + <ci-badge + :status="pipelineStatus" + :show-text="!isChildView" + data-qa-selector="pipeline_commit_status" + /> </div> </div> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue new file mode 100644 index 0000000000000000000000000000000000000000..388b300b39d3240b7930d760db7b01ad8595ed99 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue @@ -0,0 +1,81 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import TestSuiteTable from './test_suite_table.vue'; +import TestSummary from './test_summary.vue'; +import TestSummaryTable from './test_summary_table.vue'; +import store from '~/pipelines/stores/test_reports'; + +export default { + name: 'TestReports', + components: { + GlLoadingIcon, + TestSuiteTable, + TestSummary, + TestSummaryTable, + }, + store, + computed: { + ...mapState(['isLoading', 'selectedSuite', 'testReports']), + showSuite() { + return this.selectedSuite.total_count > 0; + }, + showTests() { + return this.testReports.total_count > 0; + }, + }, + methods: { + ...mapActions(['setSelectedSuite', 'removeSelectedSuite']), + summaryBackClick() { + this.removeSelectedSuite(); + }, + summaryTableRowClick(suite) { + this.setSelectedSuite(suite); + }, + beforeEnterTransition() { + document.documentElement.style.overflowX = 'hidden'; + }, + afterLeaveTransition() { + document.documentElement.style.overflowX = ''; + }, + }, +}; +</script> + +<template> + <div v-if="isLoading"> + <gl-loading-icon size="lg" class="prepend-top-default js-loading-spinner" /> + </div> + + <div + v-else-if="!isLoading && showTests" + ref="container" + class="tests-detail position-relative js-tests-detail" + > + <transition + name="slide" + @before-enter="beforeEnterTransition" + @after-leave="afterLeaveTransition" + > + <div v-if="showSuite" key="detail" class="w-100 position-absolute slide-enter-to-element"> + <test-summary :report="selectedSuite" show-back @on-back-click="summaryBackClick" /> + + <test-suite-table /> + </div> + + <div v-else key="summary" class="w-100 position-absolute slide-enter-from-element"> + <test-summary :report="testReports" /> + + <test-summary-table @row-click="summaryTableRowClick" /> + </div> + </transition> + </div> + + <div v-else> + <div class="row prepend-top-default"> + <div class="col-12"> + <p class="js-no-tests-to-show">{{ s__('TestReports|There are no tests to show.') }}</p> + </div> + </div> + </div> +</template> 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 new file mode 100644 index 0000000000000000000000000000000000000000..28b2c706320c0b79cfb7dabb5f2ea822b8268468 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue @@ -0,0 +1,108 @@ +<script> +import { mapGetters } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import store from '~/pipelines/stores/test_reports'; +import { __ } from '~/locale'; + +export default { + name: 'TestsSuiteTable', + components: { + Icon, + }, + store, + props: { + heading: { + type: String, + required: false, + default: __('Tests'), + }, + }, + computed: { + ...mapGetters(['getSuiteTests']), + hasSuites() { + return this.getSuiteTests.length > 0; + }, + }, +}; +</script> + +<template> + <div> + <div class="row prepend-top-default"> + <div class="col-12"> + <h4>{{ heading }}</h4> + </div> + </div> + + <div v-if="hasSuites" class="test-reports-table 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') }} + </div> + <div role="rowheader" class="table-section section-20"> + {{ __('Name') }} + </div> + <div role="rowheader" class="table-section section-10 text-center"> + {{ __('Status') }} + </div> + <div role="rowheader" class="table-section flex-grow-1"> + {{ __('Trace'), }} + </div> + <div role="rowheader" class="table-section section-10 text-right"> + {{ __('Duration') }} + </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" + > + <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 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> + </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> + </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> + </div> + </div> + </div> + + <div v-else> + <p class="js-no-test-cases">{{ s__('TestReports|There are no test cases to display.') }}</p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue new file mode 100644 index 0000000000000000000000000000000000000000..dce8b020d6f236241e3ab433c36bfdfaf46a8626 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue @@ -0,0 +1,116 @@ +<script> +import { GlButton, GlLink, GlProgressBar } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { formatTime, secondsToMilliseconds } from '~/lib/utils/datetime_utility'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + name: 'TestSummary', + components: { + GlButton, + GlLink, + GlProgressBar, + Icon, + }, + props: { + report: { + type: Object, + required: true, + }, + showBack: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + heading() { + return this.report.name || __('Summary'); + }, + successPercentage() { + return Math.round((this.report.success_count / this.report.total_count) * 100) || 0; + }, + formattedDuration() { + return formatTime(secondsToMilliseconds(this.report.total_time)); + }, + progressBarVariant() { + if (this.successPercentage < 33) { + return 'danger'; + } + + if (this.successPercentage >= 33 && this.successPercentage < 66) { + return 'warning'; + } + + if (this.successPercentage >= 66 && this.successPercentage < 90) { + return 'primary'; + } + + return 'success'; + }, + }, + methods: { + onBackClick() { + this.$emit('on-back-click'); + }, + }, +}; +</script> + +<template> + <div> + <div class="row"> + <div class="col-12 d-flex prepend-top-8 align-items-center"> + <gl-button + v-if="showBack" + size="sm" + class="append-right-default js-back-button" + @click="onBackClick" + > + <icon name="angle-left" /> + </gl-button> + + <h4>{{ heading }}</h4> + </div> + </div> + + <div class="row mt-2"> + <div class="col-4 col-md"> + <span class="js-total-tests">{{ + sprintf(s__('TestReports|%{count} jobs'), { count: report.total_count }) + }}</span> + </div> + + <div class="col-4 col-md text-center text-md-center"> + <span class="js-failed-tests">{{ + sprintf(s__('TestReports|%{count} failures'), { count: report.failed_count }) + }}</span> + </div> + + <div class="col-4 col-md text-right text-md-center"> + <span class="js-errored-tests">{{ + sprintf(s__('TestReports|%{count} errors'), { count: report.error_count }) + }}</span> + </div> + + <div class="col-6 mt-3 col-md mt-md-0 text-md-center"> + <span class="js-success-rate">{{ + sprintf(s__('TestReports|%{rate}%{sign} success rate'), { + rate: successPercentage, + sign: '%', + }) + }}</span> + </div> + + <div class="col-6 mt-3 col-md mt-md-0 text-right"> + <span class="js-duration">{{ formattedDuration }}</span> + </div> + </div> + + <div class="row mt-3"> + <div class="col-12"> + <gl-progress-bar :value="successPercentage" :variant="progressBarVariant" height="10px" /> + </div> + </div> + </div> +</template> 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 new file mode 100644 index 0000000000000000000000000000000000000000..96177512e35c87e231c691cc6ac98786d48ae7cf --- /dev/null +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue @@ -0,0 +1,129 @@ +<script> +import { mapGetters } from 'vuex'; +import { s__ } from '~/locale'; +import store from '~/pipelines/stores/test_reports'; + +export default { + name: 'TestsSummaryTable', + store, + props: { + heading: { + type: String, + required: false, + default: s__('TestReports|Test suites'), + }, + }, + computed: { + ...mapGetters(['getTestSuites']), + hasSuites() { + return this.getTestSuites.length > 0; + }, + }, + methods: { + tableRowClick(suite) { + this.$emit('row-click', suite); + }, + }, +}; +</script> + +<template> + <div> + <div class="row prepend-top-default"> + <div class="col-12"> + <h4>{{ heading }}</h4> + </div> + </div> + + <div v-if="hasSuites" class="test-reports-table 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') }} + </div> + <div role="rowheader" class="table-section section-25"> + {{ __('Duration') }} + </div> + <div role="rowheader" class="table-section section-10 text-center"> + {{ __('Failed') }} + </div> + <div role="rowheader" class="table-section section-10 text-center"> + {{ __('Errors'), }} + </div> + <div role="rowheader" class="table-section section-10 text-center"> + {{ __('Skipped'), }} + </div> + <div role="rowheader" class="table-section section-10 text-center"> + {{ __('Passed'), }} + </div> + <div role="rowheader" class="table-section section-10 pr-3 text-right"> + {{ __('Total') }} + </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)" + > + <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 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-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-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-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-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-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> + </div> + + <div v-else> + <p class="js-no-tests-suites">{{ s__('TestReports|There are no test suites to show.') }}</p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index d27829db50c6330ff391d15423e92224f9174afc..c9655d18a040ee2fa513d48401bba8303d14a36f 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -1,3 +1,9 @@ export const CANCEL_REQUEST = 'CANCEL_REQUEST'; export const PIPELINES_TABLE = 'PIPELINES_TABLE'; export const LAYOUT_CHANGE_DELAY = 300; + +export const TestStatus = { + FAILED: 'failed', + SKIPPED: 'skipped', + SUCCESS: 'success', +}; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index b6f8716d37d7776f251cf2e2b77a76dad123ced9..d8dbc3c2454d6ef7f8322d299f58132832fbd6bf 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -7,6 +7,8 @@ import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import PipelinesMediator from './pipeline_details_mediator'; import pipelineHeader from './components/header_component.vue'; import eventHub from './event_hub'; +import TestReports from './components/test_reports/test_reports.vue'; +import testReportsStore from './stores/test_reports'; Vue.use(Translate); @@ -17,7 +19,7 @@ export default () => { mediator.fetchPipeline(); - // eslint-disable-next-line + // eslint-disable-next-line no-new new Vue({ el: '#js-pipeline-graph-vue', components: { @@ -47,7 +49,7 @@ export default () => { }, }); - // eslint-disable-next-line + // eslint-disable-next-line no-new new Vue({ el: '#js-pipeline-header-vue', components: { @@ -81,4 +83,23 @@ export default () => { }); }, }); + + const testReportsEnabled = + window.gon && window.gon.features && window.gon.features.junitPipelineView; + + if (testReportsEnabled) { + testReportsStore.dispatch('setEndpoint', dataset.testReportEndpoint); + testReportsStore.dispatch('fetchReports'); + + // eslint-disable-next-line no-new + new Vue({ + el: '#js-pipeline-tests-detail', + components: { + TestReports, + }, + render(createElement) { + return createElement('test-reports'); + }, + }); + } }; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js new file mode 100644 index 0000000000000000000000000000000000000000..71d875c1a833ab49399da25f10a3eaabe5105bb5 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js @@ -0,0 +1,30 @@ +import axios from '~/lib/utils/axios_utils'; +import * as types from './mutation_types'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; + +export const setEndpoint = ({ commit }, data) => commit(types.SET_ENDPOINT, data); + +export const fetchReports = ({ state, commit, dispatch }) => { + dispatch('toggleLoading'); + + return axios + .get(state.endpoint) + .then(response => { + const { data } = response; + commit(types.SET_REPORTS, data); + }) + .catch(() => { + createFlash(s__('TestReports|There was an error fetching the test reports.')); + }) + .finally(() => { + dispatch('toggleLoading'); + }); +}; + +export const setSelectedSuite = ({ commit }, data) => commit(types.SET_SELECTED_SUITE, data); +export const removeSelectedSuite = ({ commit }) => commit(types.SET_SELECTED_SUITE, {}); +export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/getters.js b/app/assets/javascripts/pipelines/stores/test_reports/getters.js new file mode 100644 index 0000000000000000000000000000000000000000..788c1d32987f7e559b35905df9a46f09d18565a1 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/getters.js @@ -0,0 +1,23 @@ +import { addIconStatus, formattedTime, sortTestCases } from './utils'; + +export const getTestSuites = state => { + const { test_suites: testSuites = [] } = state.testReports; + + return testSuites.map(suite => ({ + ...suite, + formattedTime: formattedTime(suite.total_time), + })); +}; + +export const getSuiteTests = state => { + const { selectedSuite } = state; + + if (selectedSuite.test_cases) { + return selectedSuite.test_cases.sort(sortTestCases).map(addIconStatus); + } + + return []; +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/index.js b/app/assets/javascripts/pipelines/stores/test_reports/index.js new file mode 100644 index 0000000000000000000000000000000000000000..318dff5bcb26f44e04d43fbae03dfa2ac0924c93 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/index.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import state from './state'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + actions, + getters, + mutations, + state, +}); diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js new file mode 100644 index 0000000000000000000000000000000000000000..832e45cf7a1c8d83afa98c53069574af4ffe8d59 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js @@ -0,0 +1,4 @@ +export const SET_ENDPOINT = 'SET_ENDPOINT'; +export const SET_REPORTS = 'SET_REPORTS'; +export const SET_SELECTED_SUITE = 'SET_SELECTED_SUITE'; +export const TOGGLE_LOADING = 'TOGGLE_LOADING'; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js new file mode 100644 index 0000000000000000000000000000000000000000..349e6ec04690a17e3dcd3e3bf22323f71324a95e --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js @@ -0,0 +1,19 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_ENDPOINT](state, endpoint) { + Object.assign(state, { endpoint }); + }, + + [types.SET_REPORTS](state, testReports) { + Object.assign(state, { testReports }); + }, + + [types.SET_SELECTED_SUITE](state, selectedSuite) { + Object.assign(state, { selectedSuite }); + }, + + [types.TOGGLE_LOADING](state) { + Object.assign(state, { isLoading: !state.isLoading }); + }, +}; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/state.js b/app/assets/javascripts/pipelines/stores/test_reports/state.js new file mode 100644 index 0000000000000000000000000000000000000000..80a0c2a46a061a3b146606cfaaa303d8ffd7821d --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/state.js @@ -0,0 +1,6 @@ +export default () => ({ + endpoint: '', + testReports: {}, + selectedSuite: {}, + isLoading: false, +}); diff --git a/app/assets/javascripts/pipelines/stores/test_reports/utils.js b/app/assets/javascripts/pipelines/stores/test_reports/utils.js new file mode 100644 index 0000000000000000000000000000000000000000..95466587d6b4a471848d6f19311fdfc3e8e5475d --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/utils.js @@ -0,0 +1,36 @@ +import { TestStatus } from '~/pipelines/constants'; +import { formatTime, secondsToMilliseconds } from '~/lib/utils/datetime_utility'; + +function iconForTestStatus(status) { + switch (status) { + case 'success': + return 'status_success_borderless'; + case 'failed': + return 'status_failed_borderless'; + default: + return 'status_skipped_borderless'; + } +} + +export const formattedTime = timeInSeconds => formatTime(secondsToMilliseconds(timeInSeconds)); + +export const addIconStatus = testCase => ({ + ...testCase, + icon: iconForTestStatus(testCase.status), + formattedTime: formattedTime(testCase.execution_time), +}); + +export const sortTestCases = (a, b) => { + if (a.status === b.status) { + return 0; + } + + switch (b.status) { + case TestStatus.SUCCESS: + return -1; + case TestStatus.FAILED: + return 1; + default: + return 0; + } +}; diff --git a/app/assets/javascripts/privacy_policy_update_callout.js b/app/assets/javascripts/privacy_policy_update_callout.js deleted file mode 100644 index 97f41deb30f466d707a569a9372cb076f23e1397..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/privacy_policy_update_callout.js +++ /dev/null @@ -1,8 +0,0 @@ -import PersistentUserCallout from '~/persistent_user_callout'; - -function initPrivacyPolicyUpdateCallout() { - const callout = document.querySelector('.js-privacy-policy-update'); - PersistentUserCallout.factory(callout); -} - -export default initPrivacyPolicyUpdateCallout; diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js index 44bc2d9f5f829a89f2e3fc97bcfff406cd6d0686..880e1a889757e4342c3e8d07b712e597b3f71c29 100644 --- a/app/assets/javascripts/profile/gl_crop.js +++ b/app/assets/javascripts/profile/gl_crop.js @@ -1,4 +1,4 @@ -/* eslint-disable no-useless-escape, no-var, no-underscore-dangle, func-names, no-return-assign, one-var, consistent-return, class-methods-use-this */ +/* eslint-disable no-useless-escape, no-underscore-dangle, func-names, no-return-assign, consistent-return, class-methods-use-this */ import $ from 'jquery'; import 'cropper'; @@ -59,8 +59,7 @@ import _ from 'underscore'; } bindEvents() { - var _this; - _this = this; + const _this = this; this.fileInput.on('change', function(e) { _this.onFileInputChange(e, this); this.value = null; @@ -70,8 +69,7 @@ import _ from 'underscore'; this.modalCrop.on('hidden.bs.modal', this.onModalHide); this.uploadImageBtn.on('click', this.onUploadImageBtnClick); this.cropActionsBtn.on('click', function() { - var btn; - btn = this; + const btn = this; return _this.onActionBtnClick(btn); }); return (this.croppedImageBlob = null); @@ -82,8 +80,7 @@ import _ from 'underscore'; } onModalShow() { - var _this; - _this = this; + const _this = this; return this.modalCropImg.cropper({ viewMode: 1, center: false, @@ -128,8 +125,7 @@ import _ from 'underscore'; } onActionBtnClick(btn) { - var data; - data = $(btn).data(); + const data = $(btn).data(); if (this.modalCropImg.data('cropper') && data.method) { return this.modalCropImg.cropper(data.method, data.option); } @@ -140,9 +136,8 @@ import _ from 'underscore'; } readFile(input) { - var _this, reader; - _this = this; - reader = new FileReader(); + const _this = this; + const reader = new FileReader(); reader.onload = () => { _this.modalCropImg.attr('src', reader.result); return _this.modalCrop.modal('show'); @@ -151,9 +146,10 @@ import _ from 'underscore'; } dataURLtoBlob(dataURL) { - var array, binary, i, len; - binary = atob(dataURL.split(',')[1]); - array = []; + let i = 0; + let len = 0; + const binary = atob(dataURL.split(',')[1]); + const array = []; for (i = 0, len = binary.length; i < len; i += 1) { array.push(binary.charCodeAt(i)); @@ -164,9 +160,8 @@ import _ from 'underscore'; } setPreview() { - var filename; + const filename = this.fileInput.val().replace(FILENAMEREGEX, ''); this.previewImage.attr('src', this.dataURL); - filename = this.fileInput.val().replace(FILENAMEREGEX, ''); return this.filename.text(filename); } diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js index 2c375b39c1f4774e2100d8551fcecefa562154b6..031c54d2336c684851a5fe1a5d69de9a92d9e0d3 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/project_find_file.js @@ -1,16 +1,20 @@ -/* eslint-disable func-names, no-var, consistent-return, one-var, no-cond-assign, no-return-assign */ +/* eslint-disable func-names, consistent-return, no-return-assign */ import $ from 'jquery'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import axios from '~/lib/utils/axios_utils'; import flash from '~/flash'; import { __ } from '~/locale'; +import sanitize from 'sanitize-html'; // highlight text(awefwbwgtc -> <b>a</b>wefw<b>b</b>wgt<b>c</b> ) const highlighter = function(element, text, matches) { - var j, lastIndex, len, matchIndex, matchedChars, unmatched; - lastIndex = 0; - matchedChars = []; + let j = 0; + let len = 0; + let lastIndex = 0; + let matchedChars = []; + let matchIndex = matches[j]; + let unmatched = text.substring(lastIndex, matchIndex); for (j = 0, len = matches.length; j < len; j += 1) { matchIndex = matches[j]; unmatched = text.substring(lastIndex, matchIndex); @@ -54,10 +58,10 @@ export default class ProjectFindFile { 'keyup', (function(_this) { return function(event) { - var oldValue, ref, target, value; - target = $(event.target); - value = target.val(); - oldValue = (ref = target.data('oldValue')) != null ? ref : ''; + 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(); @@ -73,9 +77,8 @@ export default class ProjectFindFile { } findFile() { - var result, searchText; - searchText = this.inputElement.val(); - result = + const searchText = sanitize(this.inputElement.val()); + const result = searchText.length > 0 ? fuzzaldrinPlus.filter(this.filePaths, searchText) : this.filePaths; return this.renderList(result, searchText); // find file @@ -100,20 +103,21 @@ export default class ProjectFindFile { // render result renderList(filePaths, searchText) { - var blobItemUrl, filePath, html, i, len, matches, results; + let i = 0; + let len = 0; + let matches = []; + const results = []; this.element.find('.tree-table > tbody').empty(); - results = []; - for (i = 0, len = filePaths.length; i < len; i += 1) { - filePath = filePaths[i]; + const filePath = filePaths[i]; if (i === 20) { break; } if (searchText) { matches = fuzzaldrinPlus.match(filePath, searchText); } - blobItemUrl = `${this.options.blobUrlTemplate}/${encodeURIComponent(filePath)}`; - html = ProjectFindFile.makeHtml(filePath, matches, blobItemUrl); + const blobItemUrl = `${this.options.blobUrlTemplate}/${encodeURIComponent(filePath)}`; + const html = ProjectFindFile.makeHtml(filePath, matches, blobItemUrl); results.push(this.element.find('.tree-table > tbody').append(html)); } @@ -124,8 +128,7 @@ export default class ProjectFindFile { // make tbody row html static makeHtml(filePath, matches, blobItemUrl) { - var $tr; - $tr = $( + const $tr = $( "<tr class='tree-item'><td class='tree-item-file-name link-container'><a><i class='fa fa-file-text-o fa-fw'></i><span class='str-truncated'></span></a></td></tr>", ); if (matches) { @@ -140,9 +143,9 @@ export default class ProjectFindFile { } selectRow(type) { - var next, rows, selectedRow; - rows = this.element.find('.files-slider tr.tree-item'); - selectedRow = this.element.find('.files-slider tr.tree-item.selected'); + const rows = this.element.find('.files-slider tr.tree-item'); + let selectedRow = this.element.find('.files-slider tr.tree-item.selected'); + let next = selectedRow.prev(); if (rows && rows.length > 0) { if (selectedRow && selectedRow.length > 0) { if (type === 'UP') { @@ -174,7 +177,7 @@ export default class ProjectFindFile { } goToBlob() { - var $link = this.element.find('.tree-item.selected .tree-item-file-name a'); + const $link = this.element.find('.tree-item.selected .tree-item-file-name a'); if ($link.length) { $link.get(0).click(); diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index 0fbb7e5ca42f058b98bbb1f0f440bfd839db7e51..66ce1ab5659ff0de4d8dee8545ff3045e744ee63 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, one-var, no-else-return */ +/* eslint-disable func-names, no-else-return */ import $ from 'jquery'; import Api from './api'; @@ -7,9 +7,11 @@ import { s__ } from './locale'; const projectSelect = () => { $('.ajax-project-select').each(function(i, select) { - var placeholder; + let placeholder; const simpleFilter = $(select).data('simpleFilter') || false; + const isInstantiated = $(select).data('select2'); this.groupId = $(select).data('groupId'); + this.userId = $(select).data('userId'); this.includeGroups = $(select).data('includeGroups'); this.allProjects = $(select).data('allProjects') || false; this.orderBy = $(select).data('orderBy') || 'id'; @@ -28,55 +30,62 @@ const projectSelect = () => { $(select).select2({ placeholder, minimumInputLength: 0, - query: (function(_this) { - return function(query) { - var finalCallback, projectsCallback; - finalCallback = function(projects) { - var data; - data = { - results: projects, - }; - return query.callback(data); + query: query => { + let projectsCallback; + const finalCallback = function(projects) { + const data = { + results: projects, }; - if (_this.includeGroups) { - projectsCallback = function(projects) { - var groupsCallback; - groupsCallback = function(groups) { - var data; - data = groups.concat(projects); - return finalCallback(data); - }; - return Api.groups(query.term, {}, groupsCallback); - }; - } else { - projectsCallback = finalCallback; - } - if (_this.groupId) { - return Api.groupProjects( - _this.groupId, - query.term, - { - with_issues_enabled: _this.withIssuesEnabled, - with_merge_requests_enabled: _this.withMergeRequestsEnabled, - with_shared: _this.withShared, - include_subgroups: _this.includeProjectsInSubgroups, - }, - projectsCallback, - ); - } else { - return Api.projects( - query.term, - { - order_by: _this.orderBy, - with_issues_enabled: _this.withIssuesEnabled, - with_merge_requests_enabled: _this.withMergeRequestsEnabled, - membership: !_this.allProjects, - }, - projectsCallback, - ); - } + return query.callback(data); }; - })(this), + if (this.includeGroups) { + projectsCallback = function(projects) { + const groupsCallback = function(groups) { + const data = groups.concat(projects); + return finalCallback(data); + }; + return Api.groups(query.term, {}, groupsCallback); + }; + } else { + projectsCallback = finalCallback; + } + if (this.groupId) { + return Api.groupProjects( + this.groupId, + query.term, + { + with_issues_enabled: this.withIssuesEnabled, + with_merge_requests_enabled: this.withMergeRequestsEnabled, + with_shared: this.withShared, + include_subgroups: this.includeProjectsInSubgroups, + }, + projectsCallback, + ); + } else if (this.userId) { + return Api.userProjects( + this.userId, + query.term, + { + with_issues_enabled: this.withIssuesEnabled, + with_merge_requests_enabled: this.withMergeRequestsEnabled, + with_shared: this.withShared, + include_subgroups: this.includeProjectsInSubgroups, + }, + projectsCallback, + ); + } else { + return Api.projects( + query.term, + { + order_by: this.orderBy, + with_issues_enabled: this.withIssuesEnabled, + with_merge_requests_enabled: this.withMergeRequestsEnabled, + membership: !this.allProjects, + }, + projectsCallback, + ); + } + }, id(project) { if (simpleFilter) return project.id; return JSON.stringify({ @@ -96,7 +105,7 @@ const projectSelect = () => { dropdownCssClass: 'ajax-project-dropdown', }); - if (simpleFilter) return select; + if (isInstantiated || simpleFilter) return select; return new ProjectSelectComboButton(select); }); }; diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 9066844f687545f5738cbfa09741213ea7eed838..2429da9c061e115b465c8fd9c7a933bf5ade837e 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -182,6 +182,10 @@ const bindEvents = () => { text: s__('ProjectTemplates|Netlify/Hexo'), icon: '.template-option .icon-netlify', }, + serverless_framework: { + text: s__('ProjectTemplates|Serverless Framework/JS'), + icon: '.template-option .icon-serverless_framework', + }, }; const selectedTemplate = templates[value]; diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue index 95f8270b5d0d2689bfc29218b1f1544f0c7d7b9c..5a6f9370564561ae74c023bfe29983ae49dd64b7 100644 --- a/app/assets/javascripts/registry/components/collapsible_container.vue +++ b/app/assets/javascripts/registry/components/collapsible_container.vue @@ -8,12 +8,13 @@ import { GlModalDirective, GlEmptyState, } from '@gitlab/ui'; -import createFlash from '../../flash'; -import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import Icon from '../../vue_shared/components/icon.vue'; +import createFlash from '~/flash'; +import Tracking from '~/tracking'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; import TableRegistry from './table_registry.vue'; -import { errorMessages, errorMessagesTypes } from '../constants'; -import { __ } from '../../locale'; +import { DELETE_REPO_ERROR_MESSAGE } from '../constants'; +import { __ } from '~/locale'; export default { name: 'CollapsibeContainerRegisty', @@ -30,6 +31,7 @@ export default { GlTooltip: GlTooltipDirective, GlModal: GlModalDirective, }, + mixins: [Tracking.mixin({})], props: { repo: { type: Object, @@ -40,6 +42,10 @@ export default { return { isOpen: false, modalId: `confirm-repo-deletion-modal-${this.repo.id}`, + tracking: { + category: document.body.dataset.page, + label: 'registry_repository_delete', + }, }; }, computed: { @@ -61,15 +67,13 @@ export default { } }, handleDeleteRepository() { + this.track('confirm_delete', {}); return this.deleteItem(this.repo) .then(() => { createFlash(__('This container registry has been scheduled for deletion.'), 'notice'); this.fetchRepos(); }) - .catch(() => this.showError(errorMessagesTypes.DELETE_REPO)); - }, - showError(message) { - createFlash(errorMessages[message]); + .catch(() => createFlash(DELETE_REPO_ERROR_MESSAGE)); }, }, }; @@ -97,10 +101,9 @@ export default { v-gl-modal="modalId" :title="s__('ContainerRegistry|Remove repository')" :aria-label="s__('ContainerRegistry|Remove repository')" - data-track-event="click_button" - data-track-label="registry_repository_delete" class="js-remove-repo btn-inverted" variant="danger" + @click="track('click_button', {})" > <icon name="remove" /> </gl-button> @@ -124,7 +127,13 @@ export default { class="mx-auto my-0" /> </div> - <gl-modal :modal-id="modalId" ok-variant="danger" @ok="handleDeleteRepository"> + <gl-modal + ref="deleteModal" + :modal-id="modalId" + ok-variant="danger" + @ok="handleDeleteRepository" + @cancel="track('cancel_delete', {})" + > <template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template> <p v-html=" diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue index 8470fbc2b596ac9ae38505065bc3eb7b0d887ba0..caa5fd4ff4ee3a9c577a82acc5c7ebd4bf8e2a5d 100644 --- a/app/assets/javascripts/registry/components/table_registry.vue +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -1,20 +1,15 @@ <script> import { mapActions, mapGetters } from 'vuex'; -import { - GlButton, - GlFormCheckbox, - GlTooltipDirective, - GlModal, - GlModalDirective, -} from '@gitlab/ui'; -import { n__, s__, sprintf } from '../../locale'; -import createFlash from '../../flash'; -import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import TablePagination from '../../vue_shared/components/pagination/table_pagination.vue'; -import Icon from '../../vue_shared/components/icon.vue'; -import timeagoMixin from '../../vue_shared/mixins/timeago'; -import { errorMessages, errorMessagesTypes } from '../constants'; -import { numberToHumanSize } from '../../lib/utils/number_utils'; +import { GlButton, GlFormCheckbox, GlTooltipDirective, GlModal } from '@gitlab/ui'; +import Tracking from '~/tracking'; +import { n__, s__, sprintf } from '~/locale'; +import createFlash from '~/flash'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { FETCH_REGISTRY_ERROR_MESSAGE, DELETE_REGISTRY_ERROR_MESSAGE } from '../constants'; export default { components: { @@ -27,7 +22,6 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - GlModal: GlModalDirective, }, mixins: [timeagoMixin], props: { @@ -65,12 +59,21 @@ export default { this.itemsToBeDeleted.length === 0 ? 1 : this.itemsToBeDeleted.length, ); }, - }, - mounted() { - this.$refs.deleteModal.$refs.modal.$on('hide', this.removeModalEvents); + isMultiDelete() { + return this.itemsToBeDeleted.length > 1; + }, + tracking() { + return { + property: this.repo.name, + label: this.isMultiDelete ? 'bulk_registry_tag_delete' : 'registry_tag_delete', + }; + }, }, methods: { ...mapActions(['fetchList', 'deleteItem', 'multiDeleteItems']), + track(action) { + Tracking.event(document.body.dataset.page, action, this.tracking); + }, setModalDescription(itemIndex = -1) { if (itemIndex === -1) { this.modalDescription = sprintf( @@ -92,17 +95,11 @@ export default { formatSize(size) { return numberToHumanSize(size); }, - removeModalEvents() { - this.$refs.deleteModal.$refs.modal.$off('ok'); - }, deleteSingleItem(index) { this.setModalDescription(index); this.itemsToBeDeleted = [index]; - - this.$refs.deleteModal.$refs.modal.$once('ok', () => { - this.removeModalEvents(); - this.handleSingleDelete(this.repo.list[index]); - }); + this.track('click_button'); + this.$refs.deleteModal.show(); }, deleteMultipleItems() { this.itemsToBeDeleted = [...this.selectedItems]; @@ -111,17 +108,14 @@ export default { } else if (this.selectedItems.length > 1) { this.setModalDescription(); } - - this.$refs.deleteModal.$refs.modal.$once('ok', () => { - this.removeModalEvents(); - this.handleMultipleDelete(); - }); + this.track('click_button'); + this.$refs.deleteModal.show(); }, handleSingleDelete(itemToDelete) { this.itemsToBeDeleted = []; this.deleteItem(itemToDelete) .then(() => this.fetchList({ repo: this.repo })) - .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); + .catch(() => createFlash(DELETE_REGISTRY_ERROR_MESSAGE)); }, handleMultipleDelete() { const { itemsToBeDeleted } = this; @@ -134,19 +128,16 @@ export default { items: itemsToBeDeleted.map(x => this.repo.list[x].tag), }) .then(() => this.fetchList({ repo: this.repo })) - .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); + .catch(() => createFlash(DELETE_REGISTRY_ERROR_MESSAGE)); } else { - this.showError(errorMessagesTypes.DELETE_REGISTRY); + createFlash(DELETE_REGISTRY_ERROR_MESSAGE); } }, onPageChange(pageNumber) { this.fetchList({ repo: this.repo, page: pageNumber }).catch(() => - this.showError(errorMessagesTypes.FETCH_REGISTRY), + createFlash(FETCH_REGISTRY_ERROR_MESSAGE), ); }, - showError(message) { - createFlash(errorMessages[message]); - }, onSelectAllChange() { if (this.selectAllChecked) { this.deselectAll(); @@ -179,6 +170,15 @@ export default { canDeleteRow(item) { return item && item.canDelete && !this.isDeleteDisabled; }, + onDeletionConfirmed() { + this.track('confirm_delete'); + if (this.isMultiDelete) { + this.handleMultipleDelete(); + } else { + const index = this.itemsToBeDeleted[0]; + this.handleSingleDelete(this.repo.list[index]); + } + }, }, }; </script> @@ -202,12 +202,10 @@ export default { <th> <gl-button v-if="canDeleteRepo" + ref="bulkDeleteButton" v-gl-tooltip - v-gl-modal="modalId" :disabled="!selectedItems || selectedItems.length === 0" - class="js-delete-registry float-right" - data-track-event="click_button" - data-track-label="bulk_registry_tag_delete" + class="float-right" variant="danger" :title="s__('ContainerRegistry|Remove selected tags')" :aria-label="s__('ContainerRegistry|Remove selected tags')" @@ -259,11 +257,8 @@ export default { <td class="content action-buttons"> <gl-button v-if="canDeleteRow(item)" - v-gl-modal="modalId" :title="s__('ContainerRegistry|Remove tag')" :aria-label="s__('ContainerRegistry|Remove tag')" - data-track-event="click_button" - data-track-label="registry_tag_delete" variant="danger" class="js-delete-registry-row float-right btn-inverted btn-border-color btn-icon" @click="deleteSingleItem(index)" @@ -282,7 +277,13 @@ export default { class="js-registry-pagination" /> - <gl-modal ref="deleteModal" :modal-id="modalId" ok-variant="danger"> + <gl-modal + ref="deleteModal" + :modal-id="modalId" + ok-variant="danger" + @ok="onDeletionConfirmed" + @cancel="track('cancel_delete')" + > <template v-slot:modal-title>{{ modalAction }}</template> <template v-slot:modal-ok>{{ modalAction }}</template> <p v-html="modalDescription"></p> diff --git a/app/assets/javascripts/registry/constants.js b/app/assets/javascripts/registry/constants.js index 712b0fade3dca169cec655bfc662dc880e727154..db798fb88ac75002d733f2f3b3f81bea94d3cec6 100644 --- a/app/assets/javascripts/registry/constants.js +++ b/app/assets/javascripts/registry/constants.js @@ -1,15 +1,8 @@ import { __ } from '../locale'; -export const errorMessagesTypes = { - FETCH_REGISTRY: 'FETCH_REGISTRY', - FETCH_REPOS: 'FETCH_REPOS', - DELETE_REPO: 'DELETE_REPO', - DELETE_REGISTRY: 'DELETE_REGISTRY', -}; - -export const errorMessages = { - [errorMessagesTypes.FETCH_REGISTRY]: __('Something went wrong while fetching the registry list.'), - [errorMessagesTypes.FETCH_REPOS]: __('Something went wrong while fetching the projects.'), - [errorMessagesTypes.DELETE_REPO]: __('Something went wrong on our end.'), - [errorMessagesTypes.DELETE_REGISTRY]: __('Something went wrong on our end.'), -}; +export const FETCH_REGISTRY_ERROR_MESSAGE = __( + 'Something went wrong while fetching the registry list.', +); +export const FETCH_REPOS_ERROR_MESSAGE = __('Something went wrong while fetching the projects.'); +export const DELETE_REPO_ERROR_MESSAGE = __('Something went wrong on our end.'); +export const DELETE_REGISTRY_ERROR_MESSAGE = __('Something went wrong on our end.'); diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js index 2121f518a7a130448ace0257badddd7aa6abd5c1..6afba6184863f975c000d5637fcf1c61453e4959 100644 --- a/app/assets/javascripts/registry/stores/actions.js +++ b/app/assets/javascripts/registry/stores/actions.js @@ -1,7 +1,7 @@ import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; import * as types from './mutation_types'; -import { errorMessages, errorMessagesTypes } from '../constants'; +import { FETCH_REPOS_ERROR_MESSAGE, FETCH_REGISTRY_ERROR_MESSAGE } from '../constants'; export const fetchRepos = ({ commit, state }) => { commit(types.TOGGLE_MAIN_LOADING); @@ -14,7 +14,7 @@ export const fetchRepos = ({ commit, state }) => { }) .catch(() => { commit(types.TOGGLE_MAIN_LOADING); - createFlash(errorMessages[errorMessagesTypes.FETCH_REPOS]); + createFlash(FETCH_REPOS_ERROR_MESSAGE); }); }; @@ -30,7 +30,7 @@ export const fetchList = ({ commit }, { repo, page }) => { }) .catch(() => { commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); - createFlash(errorMessages[errorMessagesTypes.FETCH_REGISTRY]); + createFlash(FETCH_REGISTRY_ERROR_MESSAGE); }); }; diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js index ea5925247d17ba7fefed3731c5568759e600afde..419de8488837d839542dc17e2802fea19af46c2f 100644 --- a/app/assets/javascripts/registry/stores/mutations.js +++ b/app/assets/javascripts/registry/stores/mutations.js @@ -1,33 +1,31 @@ import * as types from './mutation_types'; -import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; export default { [types.SET_MAIN_ENDPOINT](state, endpoint) { - Object.assign(state, { endpoint }); + state.endpoint = endpoint; }, [types.SET_IS_DELETE_DISABLED](state, isDeleteDisabled) { - Object.assign(state, { isDeleteDisabled }); + state.isDeleteDisabled = isDeleteDisabled; }, [types.SET_REPOS_LIST](state, list) { - Object.assign(state, { - repos: list.map(el => ({ - canDelete: Boolean(el.destroy_path), - destroyPath: el.destroy_path, - id: el.id, - isLoading: false, - list: [], - location: el.location, - name: el.path, - tagsPath: el.tags_path, - projectId: el.project_id, - })), - }); + state.repos = list.map(el => ({ + canDelete: Boolean(el.destroy_path), + destroyPath: el.destroy_path, + id: el.id, + isLoading: false, + list: [], + location: el.location, + name: el.path, + tagsPath: el.tags_path, + projectId: el.project_id, + })); }, [types.TOGGLE_MAIN_LOADING](state) { - Object.assign(state, { isLoading: !state.isLoading }); + state.isLoading = !state.isLoading; }, [types.SET_REGISTRY_LIST](state, { repo, resp, headers }) { diff --git a/app/assets/javascripts/releases/detail/components/app.vue b/app/assets/javascripts/releases/detail/components/app.vue index 54a441de8866a34b2de1f8b23012157da1556b9e..073cfcd7694df99d385ff4883fc4925da8318825 100644 --- a/app/assets/javascripts/releases/detail/components/app.vue +++ b/app/assets/javascripts/releases/detail/components/app.vue @@ -1,6 +1,7 @@ <script> import { mapState, mapActions } from 'vuex'; import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; +import _ from 'underscore'; import { __, sprintf } from '~/locale'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; @@ -23,6 +24,7 @@ export default { 'markdownDocsPath', 'markdownPreviewPath', 'releasesPagePath', + 'updateReleaseApiDocsPath', ]), showForm() { return !this.isFetchingRelease && !this.fetchError; @@ -42,6 +44,20 @@ export default { tagName() { return this.$store.state.release.tagName; }, + tagNameHintText() { + return sprintf( + __( + 'Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}', + ), + { + linkStart: `<a href="${_.escape( + this.updateReleaseApiDocsPath, + )}" target="_blank" rel="noopener noreferrer">`, + linkEnd: '</a>', + }, + false, + ); + }, releaseTitle: { get() { return this.$store.state.release.name; @@ -77,22 +93,22 @@ export default { <div class="d-flex flex-column"> <p class="pt-3 js-subtitle-text" v-html="subtitleText"></p> <form v-if="showForm" @submit.prevent="updateRelease()"> - <div class="row"> - <gl-form-group class="col-md-6 col-lg-5 col-xl-4"> - <label for="git-ref">{{ __('Tag name') }}</label> - <gl-form-input - id="git-ref" - v-model="tagName" - type="text" - class="form-control" - aria-describedby="tag-name-help" - disabled - /> - <div id="tag-name-help" class="form-text text-muted"> - {{ __('Choose an existing tag, or create a new one') }} + <gl-form-group> + <div class="row"> + <div class="col-md-6 col-lg-5 col-xl-4"> + <label for="git-ref">{{ __('Tag name') }}</label> + <gl-form-input + id="git-ref" + v-model="tagName" + type="text" + class="form-control" + aria-describedby="tag-name-help" + disabled + /> </div> - </gl-form-group> - </div> + </div> + <div id="tag-name-help" class="form-text text-muted" v-html="tagNameHintText"></div> + </gl-form-group> <gl-form-group> <label for="release-title">{{ __('Release title') }}</label> <gl-form-input diff --git a/app/assets/javascripts/releases/detail/index.js b/app/assets/javascripts/releases/detail/index.js index 3da971e6d9033d9e6ccdbef7ae4a231ef5165330..0dab90a1ede362efdfc653b25bfe8018fe5b9359 100644 --- a/app/assets/javascripts/releases/detail/index.js +++ b/app/assets/javascripts/releases/detail/index.js @@ -5,7 +5,7 @@ import createStore from './store'; export default () => { const el = document.getElementById('js-edit-release-page'); - const store = createStore(el.dataset); + const store = createStore(); store.dispatch('setInitialState', el.dataset); return new Vue({ diff --git a/app/assets/javascripts/releases/detail/store/state.js b/app/assets/javascripts/releases/detail/store/state.js index ff98e2bed78af1cbed9d854973a8f8dcd586aad8..7e3d975f1aea5d4f4af0090aa3735631383b2329 100644 --- a/app/assets/javascripts/releases/detail/store/state.js +++ b/app/assets/javascripts/releases/detail/store/state.js @@ -4,6 +4,7 @@ export default () => ({ releasesPagePath: null, markdownDocsPath: null, markdownPreviewPath: null, + updateReleaseApiDocsPath: null, release: null, diff --git a/app/assets/javascripts/releases/list/components/release_block.vue b/app/assets/javascripts/releases/list/components/release_block.vue index 8d4b32e9dc0f72de390ea6deb6747e91522fac63..2b6aa6aeff985c0529f2a765c26448568dbfe146 100644 --- a/app/assets/javascripts/releases/list/components/release_block.vue +++ b/app/assets/javascripts/releases/list/components/release_block.vue @@ -10,6 +10,7 @@ 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'; export default { name: 'ReleaseBlock', @@ -19,6 +20,7 @@ export default { GlButton, Icon, UserAvatarLink, + ReleaseBlockFooter, }, directives: { GlTooltip: GlTooltipDirective, @@ -76,9 +78,12 @@ export default { }, shouldShowEditButton() { return Boolean( - this.glFeatures.releaseEditPage && this.release._links && this.release._links.edit, + this.glFeatures.releaseEditPage && this.release._links && this.release._links.edit_url, ); }, + shouldShowFooter() { + return this.glFeatures.releaseIssueSummary; + }, }, mounted() { const hash = getLocationHash(); @@ -108,7 +113,7 @@ export default { v-gl-tooltip class="btn btn-default js-edit-button ml-2" :title="__('Edit this release')" - :href="release._links.edit" + :href="release._links.edit_url" > <icon name="pencil" /> </gl-link> @@ -164,7 +169,7 @@ export default { by <user-avatar-link class="prepend-left-4" - :link-href="author.path" + :link-href="author.web_url" :img-src="author.avatar_url" :img-alt="userImageAltDescription" :tooltip-text="author.username" @@ -216,5 +221,16 @@ export default { <div v-html="release.description_html"></div> </div> </div> + + <release-block-footer + v-if="shouldShowFooter" + class="card-footer" + :commit="release.commit" + :commit-path="release.commit_path" + :tag-name="release.tag_name" + :tag-path="release.tag_path" + :author="release.author" + :released-at="release.released_at" + /> </div> </template> diff --git a/app/assets/javascripts/releases/list/components/release_block_footer.vue b/app/assets/javascripts/releases/list/components/release_block_footer.vue new file mode 100644 index 0000000000000000000000000000000000000000..5659f0e530b80faa5fde14d97f2a17d584a56a9e --- /dev/null +++ b/app/assets/javascripts/releases/list/components/release_block_footer.vue @@ -0,0 +1,112 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { __, sprintf } from '~/locale'; + +export default { + name: 'ReleaseBlockFooter', + components: { + Icon, + GlLink, + UserAvatarLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [timeagoMixin], + props: { + commit: { + type: Object, + required: false, + default: null, + }, + commitPath: { + type: String, + required: false, + default: '', + }, + tagName: { + type: String, + required: false, + default: '', + }, + tagPath: { + type: String, + required: false, + default: '', + }, + author: { + type: Object, + required: false, + default: null, + }, + releasedAt: { + type: String, + required: false, + default: '', + }, + }, + computed: { + releasedAtTimeAgo() { + return this.timeFormated(this.releasedAt); + }, + userImageAltDescription() { + return this.author && this.author.username + ? sprintf(__("%{username}'s avatar"), { username: this.author.username }) + : null; + }, + }, +}; +</script> +<template> + <div> + <div v-if="commit" class="float-left mr-3 d-flex align-items-center js-commit-info"> + <icon ref="commitIcon" name="commit" class="mr-1" /> + <div v-gl-tooltip.bottom :title="commit.title"> + <gl-link v-if="commitPath" :href="commitPath"> + {{ commit.short_id }} + </gl-link> + <span v-else>{{ commit.short_id }}</span> + </div> + </div> + + <div v-if="tagName" class="float-left mr-3 d-flex align-items-center js-tag-info"> + <icon name="tag" class="mr-1" /> + <div v-gl-tooltip.bottom :title="__('Tag')"> + <gl-link v-if="tagPath" :href="tagPath"> + {{ tagName }} + </gl-link> + <span v-else>{{ tagName }}</span> + </div> + </div> + + <div + v-if="releasedAt || author" + class="float-left d-flex align-items-center js-author-date-info" + > + <span class="text-secondary">{{ __('Created') }} </span> + <template v-if="releasedAt"> + <span + v-gl-tooltip.bottom + :title="tooltipTitle(releasedAt)" + class="text-secondary flex-shrink-0" + > + {{ releasedAtTimeAgo }} + </span> + </template> + + <div v-if="author" class="d-flex"> + <span class="text-secondary">{{ __('by') }} </span> + <user-avatar-link + :link-href="author.web_url" + :img-src="author.avatar_url" + :img-alt="userImageAltDescription" + :tooltip-text="author.username" + tooltip-placement="bottom" + /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/reports/components/issue_status_icon.vue b/app/assets/javascripts/reports/components/issue_status_icon.vue index 386653b94448646bcb77e97cf1cd3c89a0e4151e..62a9338b864dcc70c70545042cff9b59d23c06a7 100644 --- a/app/assets/javascripts/reports/components/issue_status_icon.vue +++ b/app/assets/javascripts/reports/components/issue_status_icon.vue @@ -50,6 +50,6 @@ export default { }" class="report-block-list-icon" > - <icon :name="iconName" :size="statusIconSize" /> + <icon :name="iconName" :size="statusIconSize" :data-qa-selector="`status_${status}_icon`" /> </div> </template> diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue index f3f7d2648a8f6788bec9c6294e2ff080bc3d400e..3c8a9e6ebef22209fabc425f8adb030bdbf1b51a 100644 --- a/app/assets/javascripts/reports/components/report_item.vue +++ b/app/assets/javascripts/reports/components/report_item.vue @@ -46,6 +46,7 @@ export default { <li :class="{ 'is-dismissed': issue.isDismissed }" class="report-block-list-issue align-items-center" + data-qa-selector="report_item_row" > <issue-status-icon v-if="showReportSectionStatusIcon" diff --git a/app/assets/javascripts/repository/components/directory_download_links.vue b/app/assets/javascripts/repository/components/directory_download_links.vue new file mode 100644 index 0000000000000000000000000000000000000000..dffadade082b5dac14fb43f1ff4493c49f48dfa4 --- /dev/null +++ b/app/assets/javascripts/repository/components/directory_download_links.vue @@ -0,0 +1,47 @@ +<script> +import { GlLink } from '@gitlab/ui'; + +export default { + components: { + GlLink, + }, + props: { + currentPath: { + type: String, + required: false, + default: null, + }, + links: { + type: Array, + required: true, + }, + }, + computed: { + normalizedLinks() { + return this.links.map(link => ({ + text: link.text, + path: `${link.path}?path=${this.currentPath}`, + })); + }, + }, +}; +</script> + +<template> + <section class="border-top pt-1 mt-1"> + <h5 class="m-0 dropdown-bold-header">{{ __('Download this directory') }}</h5> + <div class="dropdown-menu-content"> + <div class="btn-group ml-0 w-100"> + <gl-link + v-for="(link, index) in normalizedLinks" + :key="index" + :href="link.path" + :class="{ 'btn-primary': index === 0 }" + class="btn btn-xs" + > + {{ link.text }} + </gl-link> + </div> + </div> + </section> +</template> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 19a2db2db25348e356830557bab6d8deed6ffb6b..70678b0db37f390fb994feea011eacaaff423d5e 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -1,5 +1,6 @@ <script> import { GlTooltipDirective, GlLink, GlButton, GlLoadingIcon } from '@gitlab/ui'; +import defaultAvatarUrl from 'images/no_avatar.png'; import { sprintf, s__ } from '~/locale'; import Icon from '../../vue_shared/components/icon.vue'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -38,7 +39,14 @@ export default { path: this.currentPath.replace(/^\//, ''), }; }, - update: data => data.project.repository.tree.lastCommit, + update: data => { + const pipelines = data.project.repository.tree.lastCommit.pipelines.edges; + + return { + ...data.project.repository.tree.lastCommit, + pipeline: pipelines.length && pipelines[0].node, + }; + }, context: { isSingleRequest: true, }, @@ -61,7 +69,7 @@ export default { computed: { statusTitle() { return sprintf(s__('Commits|Commit: %{commitText}'), { - commitText: this.commit.latestPipeline.detailedStatus.text, + commitText: this.commit.pipeline.detailedStatus.text, }); }, isLoading() { @@ -76,12 +84,13 @@ export default { this.showDescription = !this.showDescription; }, }, + defaultAvatarUrl, }; </script> <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="mx-auto" /> + <gl-loading-icon v-if="isLoading" size="md" class="m-auto" /> <template v-else> <user-avatar-link v-if="commit.author" @@ -90,6 +99,9 @@ export default { :img-size="40" class="avatar-cell" /> + <span v-else class="avatar-cell user-avatar-link"> + <img :src="$options.defaultAvatarUrl" width="40" height="40" class="avatar s40" /> + </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"> @@ -102,7 +114,7 @@ export default { class="text-expander" @click="toggleShowDescription" > - <icon name="ellipsis_h" /> + <icon name="ellipsis_h" :size="10" /> </gl-button> <div class="committer"> <gl-link @@ -112,12 +124,15 @@ export default { > {{ commit.author.name }} </gl-link> + <template v-else> + {{ commit.authorName }} + </template> {{ s__('LastCommit|authored') }} <timeago-tooltip :time="commit.authoredDate" tooltip-placement="bottom" /> </div> <pre v-if="commit.description" - v-show="showDescription" + :class="{ 'd-block': showDescription }" class="commit-row-description append-bottom-8" > {{ commit.description }} @@ -125,19 +140,20 @@ export default { </div> <div class="commit-actions flex-row"> <div v-if="commit.signatureHtml" v-html="commit.signatureHtml"></div> - <gl-link - v-if="commit.latestPipeline" - v-gl-tooltip - :href="commit.latestPipeline.detailedStatus.detailsPath" - :title="statusTitle" - class="js-commit-pipeline" - > - <ci-icon - :status="commit.latestPipeline.detailedStatus" - :size="24" - :aria-label="statusTitle" - /> - </gl-link> + <div v-if="commit.pipeline" class="ci-status-link"> + <gl-link + v-gl-tooltip.left + :href="commit.pipeline.detailedStatus.detailsPath" + :title="statusTitle" + class="js-commit-pipeline" + > + <ci-icon + :status="commit.pipeline.detailedStatus" + :size="24" + :aria-label="statusTitle" + /> + </gl-link> + </div> <div class="commit-sha-group d-flex"> <div class="label label-monospace monospace"> {{ showCommitId }} @@ -153,3 +169,9 @@ export default { </template> </div> </template> + +<style scoped> +.commit { + min-height: 4.75rem; +} +</style> diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..7f974838359b44ed94119197df02f70d4ef211cb --- /dev/null +++ b/app/assets/javascripts/repository/components/preview/index.vue @@ -0,0 +1,49 @@ +<script> +import { GlLink, GlLoadingIcon } from '@gitlab/ui'; +import getReadmeQuery from '../../queries/getReadme.query.graphql'; + +export default { + apollo: { + readme: { + query: getReadmeQuery, + variables() { + return { + url: this.blob.webUrl, + }; + }, + loadingKey: 'loading', + }, + }, + components: { + GlLink, + GlLoadingIcon, + }, + props: { + blob: { + type: Object, + required: true, + }, + }, + data() { + return { + readme: null, + loading: 0, + }; + }, +}; +</script> + +<template> + <article class="file-holder limited-width-container readme-holder"> + <div class="file-title"> + <i aria-hidden="true" class="fa fa-file-text-o fa-fw"></i> + <gl-link :href="blob.webUrl"> + <strong>{{ blob.name }}</strong> + </gl-link> + </div> + <div class="blob-viewer"> + <gl-loading-icon v-if="loading > 0" size="md" class="my-4 mx-auto" /> + <div v-else-if="readme" v-html="readme.html"></div> + </div> + </article> +</template> diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 610c7e8d99e12d40b1ea792ca3c5238d5ef8892f..8f2e9264bcab1166242d5c741f021e48a3a38357 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -1,19 +1,15 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { GlSkeletonLoading } from '@gitlab/ui'; import { sprintf, __ } from '../../../locale'; import getRefMixin from '../../mixins/get_ref'; -import getFiles from '../../queries/getFiles.query.graphql'; import getProjectPath from '../../queries/getProjectPath.query.graphql'; import TableHeader from './header.vue'; import TableRow from './row.vue'; import ParentRow from './parent_row.vue'; -const PAGE_SIZE = 100; - export default { components: { - GlLoadingIcon, + GlSkeletonLoading, TableHeader, TableRow, ParentRow, @@ -29,86 +25,39 @@ export default { type: String, required: true, }, + entries: { + type: Object, + required: false, + default: () => ({}), + }, + isLoading: { + type: Boolean, + required: true, + }, }, data() { return { projectPath: '', - nextPageCursor: '', - entries: { - trees: [], - submodules: [], - blobs: [], - }, - isLoadingFiles: false, }; }, computed: { tableCaption() { + if (this.isLoading) { + return sprintf( + __( + 'Loading files, directories, and submodules in the path %{path} for commit reference %{ref}', + ), + { path: this.path, ref: this.ref }, + ); + } + return sprintf( __('Files, directories, and submodules in the path %{path} for commit reference %{ref}'), { path: this.path, ref: this.ref }, ); }, showParentRow() { - return !this.isLoadingFiles && ['', '/'].indexOf(this.path) === -1; - }, - }, - watch: { - $route: function routeChange() { - this.entries.trees = []; - this.entries.submodules = []; - this.entries.blobs = []; - this.nextPageCursor = ''; - this.fetchFiles(); - }, - }, - mounted() { - // We need to wait for `ref` and `projectPath` to be set - this.$nextTick(() => this.fetchFiles()); - }, - methods: { - fetchFiles() { - this.isLoadingFiles = true; - - return this.$apollo - .query({ - query: getFiles, - variables: { - projectPath: this.projectPath, - ref: this.ref, - path: this.path || '/', - nextPageCursor: this.nextPageCursor, - pageSize: PAGE_SIZE, - }, - }) - .then(({ data }) => { - if (!data) return; - - const pageInfo = this.hasNextPage(data.project.repository.tree); - - this.isLoadingFiles = false; - this.entries = Object.keys(this.entries).reduce( - (acc, key) => ({ - ...acc, - [key]: this.normalizeData(key, data.project.repository.tree[key].edges), - }), - {}, - ); - - if (pageInfo && pageInfo.hasNextPage) { - this.nextPageCursor = pageInfo.endCursor; - this.fetchFiles(); - } - }) - .catch(() => createFlash(__('An error occurred while fetching folder content.'))); - }, - normalizeData(key, data) { - return this.entries[key].concat(data.map(({ node }) => node)); - }, - hasNextPage(data) { - return [] - .concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo) - .find(({ hasNextPage }) => hasNextPage); + return !this.isLoading && ['', '/'].indexOf(this.path) === -1; }, }, }; @@ -117,12 +66,7 @@ export default { <template> <div class="tree-content-holder"> <div class="table-holder bordered-box"> - <table class="table tree-table qa-file-tree" aria-live="polite"> - <caption class="sr-only"> - {{ - tableCaption - }} - </caption> + <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" /> @@ -131,6 +75,7 @@ export default { v-for="entry in val" :id="entry.id" :key="`${entry.flatPath}-${entry.id}`" + :sha="entry.sha" :project-path="projectPath" :current-path="path" :name="entry.name" @@ -141,9 +86,15 @@ export default { :lfs-oid="entry.lfsOid" /> </template> + <template v-if="isLoading"> + <tr v-for="i in 5" :key="i" aria-hidden="true"> + <td><gl-skeleton-loading :lines="1" class="h-auto" /></td> + <td><gl-skeleton-loading :lines="1" class="h-auto" /></td> + <td><gl-skeleton-loading :lines="1" class="ml-auto h-auto w-50" /></td> + </tr> + </template> </tbody> </table> - <gl-loading-icon v-show="isLoadingFiles" class="my-3" size="md" /> </div> </div> </template> diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 171841178a3755d381529c872700f0baa931417b..cf0457a2abfbb4e7ebb2ae27de10ca891ff7dae7 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -1,7 +1,8 @@ <script> -import { GlBadge, GlLink, GlSkeletonLoading } from '@gitlab/ui'; +import { GlBadge, GlLink, GlSkeletonLoading, GlTooltipDirective } 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'; import { getIconName } from '../../utils/icon'; import getRefMixin from '../../mixins/get_ref'; import getCommit from '../../queries/getCommit.query.graphql'; @@ -12,6 +13,10 @@ export default { GlLink, GlSkeletonLoading, TimeagoTooltip, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, }, apollo: { commit: { @@ -32,6 +37,10 @@ export default { type: String, required: true, }, + sha: { + type: String, + required: true, + }, projectPath: { type: String, required: true, @@ -93,15 +102,20 @@ export default { return this.path.replace(new RegExp(`^${this.currentPath}/`), ''); }, shortSha() { - return this.id.slice(0, 8); + return this.sha.slice(0, 8); + }, + hasLockLabel() { + return this.commit && this.commit.lockLabel; }, }, methods: { - openRow() { - if (this.isFolder) { + openRow(e) { + if (e.target.tagName === 'A') return; + + if (this.isFolder && !e.metaKey) { this.$router.push(this.routerLinkTo); } else { - visitUrl(this.url); + visitUrl(this.url, e.metaKey); } }, }, @@ -120,15 +134,28 @@ export default { <template v-if="isSubmodule"> @ <gl-link :href="submoduleTreeUrl" class="commit-sha">{{ shortSha }}</gl-link> </template> + <icon + v-if="hasLockLabel" + v-gl-tooltip + :title="commit.lockLabel" + name="lock" + :size="12" + class="ml-2 vertical-align-middle" + /> </td> <td class="d-none d-sm-table-cell tree-commit"> - <gl-link v-if="commit" :href="commit.commitPath" class="str-truncated-100 tree-commit-link"> + <gl-link + v-if="commit" + :href="commit.commitPath" + :title="commit.message" + class="str-truncated-100 tree-commit-link" + > {{ commit.message }} </gl-link> <gl-skeleton-loading v-else :lines="1" class="h-auto" /> </td> <td class="tree-time-ago text-right"> - <timeago-tooltip v-if="commit" :time="commit.committedDate" tooltip-placement="bottom" /> + <timeago-tooltip v-if="commit" :time="commit.committedDate" /> <gl-skeleton-loading v-else :lines="1" class="ml-auto h-auto w-50" /> </td> </tr> diff --git a/app/assets/javascripts/repository/components/tree_action_link.vue b/app/assets/javascripts/repository/components/tree_action_link.vue new file mode 100644 index 0000000000000000000000000000000000000000..72764f3ccc9c2f89b991b6019e971ab751aea68e --- /dev/null +++ b/app/assets/javascripts/repository/components/tree_action_link.vue @@ -0,0 +1,28 @@ +<script> +import { GlLink } from '@gitlab/ui'; + +export default { + components: { + GlLink, + }, + props: { + path: { + type: String, + required: true, + }, + text: { + type: String, + required: true, + }, + cssClass: { + type: String, + required: false, + default: null, + }, + }, +}; +</script> + +<template> + <gl-link :href="path" :class="cssClass" class="btn">{{ text }}</gl-link> +</template> diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue new file mode 100644 index 0000000000000000000000000000000000000000..949e653fc8f38c4123685adfd408cfbac33c5732 --- /dev/null +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -0,0 +1,115 @@ +<script> +import createFlash from '~/flash'; +import { __ } from '../../locale'; +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 FilePreview from './preview/index.vue'; +import { readmeFile } from '../utils/readme'; + +const PAGE_SIZE = 100; + +export default { + components: { + FileTable, + FilePreview, + }, + mixins: [getRefMixin], + apollo: { + projectPath: { + query: getProjectPath, + }, + }, + props: { + path: { + type: String, + required: false, + default: '/', + }, + }, + data() { + return { + projectPath: '', + nextPageCursor: '', + entries: { + trees: [], + submodules: [], + blobs: [], + }, + isLoadingFiles: false, + }; + }, + computed: { + readme() { + return readmeFile(this.entries.blobs); + }, + }, + + watch: { + $route: function routeChange() { + this.entries.trees = []; + this.entries.submodules = []; + this.entries.blobs = []; + this.nextPageCursor = ''; + this.fetchFiles(); + }, + }, + mounted() { + // We need to wait for `ref` and `projectPath` to be set + this.$nextTick(() => this.fetchFiles()); + }, + methods: { + fetchFiles() { + this.isLoadingFiles = true; + + return this.$apollo + .query({ + query: getFiles, + variables: { + projectPath: this.projectPath, + ref: this.ref, + path: this.path || '/', + nextPageCursor: this.nextPageCursor, + pageSize: PAGE_SIZE, + }, + }) + .then(({ data }) => { + if (!data) return; + + const pageInfo = this.hasNextPage(data.project.repository.tree); + + this.isLoadingFiles = false; + this.entries = Object.keys(this.entries).reduce( + (acc, key) => ({ + ...acc, + [key]: this.normalizeData(key, data.project.repository.tree[key].edges), + }), + {}, + ); + + if (pageInfo && pageInfo.hasNextPage) { + this.nextPageCursor = pageInfo.endCursor; + this.fetchFiles(); + } + }) + .catch(() => createFlash(__('An error occurred while fetching folder content.'))); + }, + normalizeData(key, data) { + return this.entries[key].concat(data.map(({ node }) => node)); + }, + hasNextPage(data) { + return [] + .concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo) + .find(({ hasNextPage }) => hasNextPage); + }, + }, +}; +</script> + +<template> + <div> + <file-table :path="path" :entries="entries" :is-loading="isLoadingFiles" /> + <file-preview v-if="readme" :blob="readme" /> + </div> +</template> diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js index 6cb253c8169e012021692925e99a20bcfd91284c..6936c08d8527a5c1c6ae9ec716455f3f382f2077 100644 --- a/app/assets/javascripts/repository/graphql.js +++ b/app/assets/javascripts/repository/graphql.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; +import axios from '~/lib/utils/axios_utils'; import createDefaultClient from '~/lib/graphql'; import introspectionQueryResultData from './fragmentTypes.json'; import { fetchLogsTree } from './log_tree'; @@ -27,6 +28,11 @@ const defaultClient = createDefaultClient( }); }); }, + readme(_, { url }) { + return axios + .get(url, { params: { viewer: 'rich', format: 'json' } }) + .then(({ data }) => ({ ...data, __typename: 'ReadmeFile' })); + }, }, }, { diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index f97279600405c6a6f2edc94cfb67af3c5409e12a..d826f209815b9ec859b2546be33b3c2a3d00162b 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -3,13 +3,18 @@ import createRouter from './router'; import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; import LastCommit from './components/last_commit.vue'; +import TreeActionLink from './components/tree_action_link.vue'; +import DirectoryDownloadLinks from './components/directory_download_links.vue'; import apolloProvider from './graphql'; import { setTitle } from './utils/title'; import { parseBoolean } from '../lib/utils/common_utils'; +import { webIDEUrl } from '../lib/utils/url_utility'; +import { __ } from '../locale'; export default function setupVueRepositoryList() { const el = document.getElementById('js-tree-list'); - const { projectPath, projectShortPath, ref, fullName } = el.dataset; + const { dataset } = el; + const { projectPath, projectShortPath, ref, fullName } = dataset; const router = createRouter(projectPath, ref); apolloProvider.clients.defaultClient.cache.writeData({ @@ -22,19 +27,7 @@ export default function setupVueRepositoryList() { }); router.afterEach(({ params: { pathMatch } }) => { - const isRoot = pathMatch === undefined || pathMatch === '/'; - setTitle(pathMatch, ref, fullName); - - if (!isRoot) { - document - .querySelectorAll('.js-keep-hidden-on-navigation') - .forEach(elem => elem.classList.add('hidden')); - } - - document - .querySelectorAll('.js-hide-on-navigation') - .forEach(elem => elem.classList.toggle('hidden', !isRoot)); }); const breadcrumbEl = document.getElementById('js-repo-breadcrumb'); @@ -88,7 +81,68 @@ export default function setupVueRepositoryList() { }, }); - return new Vue({ + const treeHistoryLinkEl = document.getElementById('js-tree-history-link'); + const { historyLink } = treeHistoryLinkEl.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el: treeHistoryLinkEl, + router, + render(h) { + return h(TreeActionLink, { + props: { + path: historyLink + (this.$route.params.pathMatch || '/'), + text: __('History'), + }, + }); + }, + }); + + const webIdeLinkEl = document.getElementById('js-tree-web-ide-link'); + + if (webIdeLinkEl) { + // eslint-disable-next-line no-new + new Vue({ + el: webIdeLinkEl, + router, + render(h) { + return h(TreeActionLink, { + props: { + path: webIDEUrl(`/${projectPath}/edit/${ref}/-${this.$route.params.pathMatch || '/'}`), + text: __('Web IDE'), + cssClass: 'qa-web-ide-button', + }, + }); + }, + }); + } + + const directoryDownloadLinks = document.getElementById('js-directory-downloads'); + + if (directoryDownloadLinks) { + // eslint-disable-next-line no-new + new Vue({ + el: directoryDownloadLinks, + router, + render(h) { + const currentPath = this.$route.params.pathMatch || '/'; + + if (currentPath !== '/') { + return h(DirectoryDownloadLinks, { + props: { + currentPath: currentPath.replace(/^\//, ''), + links: JSON.parse(directoryDownloadLinks.dataset.links), + }, + }); + } + + return null; + }, + }); + } + + // eslint-disable-next-line no-new + new Vue({ el, router, apolloProvider, @@ -96,4 +150,6 @@ export default function setupVueRepositoryList() { return h(App); }, }); + + return { router, data: dataset }; } diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js index 2c19aca23970a6870ae475c250ccacbd497eb31d..5bf30e625a05f14897279ee82717a2833b0c89be 100644 --- a/app/assets/javascripts/repository/log_tree.js +++ b/app/assets/javascripts/repository/log_tree.js @@ -1,4 +1,5 @@ import axios from '~/lib/utils/axios_utils'; +import { normalizeData } from 'ee_else_ce/repository/utils/commit'; import getCommits from './queries/getCommits.query.graphql'; import getProjectPath from './queries/getProjectPath.query.graphql'; import getRef from './queries/getRef.query.graphql'; @@ -6,18 +7,6 @@ import getRef from './queries/getRef.query.graphql'; let fetchpromise; let resolvers = []; -export function normalizeData(data) { - return data.map(d => ({ - sha: d.commit.id, - message: d.commit.message, - committedDate: d.commit.committed_date, - commitPath: d.commit_path, - fileName: d.file_name, - type: d.type, - __typename: 'LogTreeCommit', - })); -} - export function resolveCommit(commits, { resolve, entry }) { const commit = commits.find(c => c.fileName === entry.name && c.type === entry.type); @@ -37,9 +26,12 @@ export function fetchLogsTree(client, path, offset, resolver = null) { const { ref } = client.readQuery({ query: getRef }); fetchpromise = axios - .get(`${gon.gitlab_url}/${projectPath}/refs/${ref}/logs_tree${path ? `/${path}` : ''}`, { - params: { format: 'json', offset }, - }) + .get( + `${gon.relative_url_root}/${projectPath}/refs/${ref}/logs_tree/${path.replace(/^\//, '')}`, + { + params: { format: 'json', offset }, + }, + ) .then(({ data, headers }) => { const headerLogsOffset = headers['more-logs-offset']; const { commits } = client.readQuery({ query: getCommits }); diff --git a/app/assets/javascripts/repository/pages/index.vue b/app/assets/javascripts/repository/pages/index.vue index 2d92e9174ca52443534f3e0bf835c9f9b0b72819..29786bf4ec8e0e1e9af493fd7e04ef29a0ec2b91 100644 --- a/app/assets/javascripts/repository/pages/index.vue +++ b/app/assets/javascripts/repository/pages/index.vue @@ -1,18 +1,25 @@ <script> -import FileTable from '../components/table/index.vue'; +import TreePage from './tree.vue'; +import { updateElementsVisibility } from '../utils/dom'; export default { components: { - FileTable, + TreePage, }, - data() { - return { - ref: '', - }; + mounted() { + this.updateProjectElements(true); + }, + beforeDestroy() { + this.updateProjectElements(false); + }, + methods: { + updateProjectElements(isShow) { + updateElementsVisibility('.js-show-on-project-root', isShow); + }, }, }; </script> <template> - <file-table path="/" /> + <tree-page path="/" /> </template> diff --git a/app/assets/javascripts/repository/pages/tree.vue b/app/assets/javascripts/repository/pages/tree.vue index 3b898d1aa9199131f2291b96b97e924dc5151c9a..dd4d437f4ddf9ccb074bd5c905945a348ed59f9e 100644 --- a/app/assets/javascripts/repository/pages/tree.vue +++ b/app/assets/javascripts/repository/pages/tree.vue @@ -1,9 +1,10 @@ <script> -import FileTable from '../components/table/index.vue'; +import TreeContent from '../components/tree_content.vue'; +import { updateElementsVisibility } from '../utils/dom'; export default { components: { - FileTable, + TreeContent, }, props: { path: { @@ -12,9 +13,26 @@ export default { default: '/', }, }, + computed: { + isRoot() { + return this.path === '/'; + }, + }, + watch: { + isRoot: { + immediate: true, + handler: 'updateElements', + }, + }, + methods: { + updateElements(isRoot) { + updateElementsVisibility('.js-show-on-root', isRoot); + updateElementsVisibility('.js-hide-on-root', !isRoot); + }, + }, }; </script> <template> - <file-table :path="path" /> + <tree-content :path="path" /> </template> diff --git a/app/assets/javascripts/repository/queries/commit.fragment.graphql b/app/assets/javascripts/repository/queries/commit.fragment.graphql new file mode 100644 index 0000000000000000000000000000000000000000..9bb13c475c77ebde75495fc62403ce949f98363c --- /dev/null +++ b/app/assets/javascripts/repository/queries/commit.fragment.graphql @@ -0,0 +1,8 @@ +fragment TreeEntryCommit on LogTreeCommit { + sha + message + committedDate + commitPath + fileName + type +} diff --git a/app/assets/javascripts/repository/queries/getCommit.query.graphql b/app/assets/javascripts/repository/queries/getCommit.query.graphql index e2a2d831e4792de4b49b40a06147dc4695377caf..e4aeaaff8fe565fcb5d133f0e03208978372f15f 100644 --- a/app/assets/javascripts/repository/queries/getCommit.query.graphql +++ b/app/assets/javascripts/repository/queries/getCommit.query.graphql @@ -1,10 +1,7 @@ +#import "ee_else_ce/repository/queries/commit.fragment.graphql" + query getCommit($fileName: String!, $type: String!, $path: String!) { commit(path: $path, fileName: $fileName, type: $type) @client { - sha - message - committedDate - commitPath - fileName - type + ...TreeEntryCommit } } diff --git a/app/assets/javascripts/repository/queries/getCommits.query.graphql b/app/assets/javascripts/repository/queries/getCommits.query.graphql index df9e67cc440e1cb09d25f1e2786441daf9d282a4..0976b8f32d7c6921485418481fcafb516ac92aca 100644 --- a/app/assets/javascripts/repository/queries/getCommits.query.graphql +++ b/app/assets/javascripts/repository/queries/getCommits.query.graphql @@ -1,10 +1,7 @@ +#import "ee_else_ce/repository/queries/commit.fragment.graphql" + query getCommits { commits @client { - sha - message - committedDate - commitPath - fileName - type + ...TreeEntryCommit } } diff --git a/app/assets/javascripts/repository/queries/getFiles.query.graphql b/app/assets/javascripts/repository/queries/getFiles.query.graphql index c4814f8e63a6b843bea94224f61a2647b8103e2f..2aaf5066b4ac07ffaac866e5b814414bf9924e03 100644 --- a/app/assets/javascripts/repository/queries/getFiles.query.graphql +++ b/app/assets/javascripts/repository/queries/getFiles.query.graphql @@ -2,6 +2,7 @@ fragment TreeEntry on Entry { id + sha name flatPath type diff --git a/app/assets/javascripts/repository/queries/getReadme.query.graphql b/app/assets/javascripts/repository/queries/getReadme.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..cf05633013347eb0aa750712e4eee0e0442f28f2 --- /dev/null +++ b/app/assets/javascripts/repository/queries/getReadme.query.graphql @@ -0,0 +1,5 @@ +query getReadme($url: String!) { + readme(url: $url) @client { + html + } +} diff --git a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql index 71c1bf1274929bb40bdb1699339a6ee0f43182eb..9be025afe39bff6176c62f56d800192caca2d067 100644 --- a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql +++ b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql @@ -5,22 +5,27 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) { lastCommit { sha title - message + description webUrl authoredDate + authorName author { name avatarUrl webUrl } signatureHtml - latestPipeline { - detailedStatus { - detailsPath - icon - tooltip - text - group + pipelines(ref: $ref, first: 1) { + edges { + node { + detailedStatus { + detailsPath + icon + tooltip + text + group + } + } } } } diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js index 9322c81ab97e95ee839532f9e6864e184bf8f13a..ebf0a7091eadb0645a65c158cdb4a36ba9fe4884 100644 --- a/app/assets/javascripts/repository/router.js +++ b/app/assets/javascripts/repository/router.js @@ -16,7 +16,7 @@ export default function createRouter(base, baseRef) { name: 'treePath', component: TreePage, props: route => ({ - path: route.params.pathMatch && route.params.pathMatch.replace(/^\//, ''), + path: route.params.pathMatch && (route.params.pathMatch.replace(/^\//, '') || '/'), }), }, { diff --git a/app/assets/javascripts/repository/utils/commit.js b/app/assets/javascripts/repository/utils/commit.js new file mode 100644 index 0000000000000000000000000000000000000000..6c204b57b37bc5df987f952f956c7c2bc6d37060 --- /dev/null +++ b/app/assets/javascripts/repository/utils/commit.js @@ -0,0 +1,13 @@ +// eslint-disable-next-line import/prefer-default-export +export function normalizeData(data, extra = () => {}) { + return data.map(d => ({ + sha: d.commit.id, + message: d.commit.message, + committedDate: d.commit.committed_date, + commitPath: d.commit_path, + fileName: d.file_name, + type: d.type, + __typename: 'LogTreeCommit', + ...extra(d), + })); +} diff --git a/app/assets/javascripts/repository/utils/dom.js b/app/assets/javascripts/repository/utils/dom.js new file mode 100644 index 0000000000000000000000000000000000000000..963e6fc0bc4dba0d9a0d9af86329e2409c8e0610 --- /dev/null +++ b/app/assets/javascripts/repository/utils/dom.js @@ -0,0 +1,4 @@ +// eslint-disable-next-line import/prefer-default-export +export const updateElementsVisibility = (selector, isVisible) => { + document.querySelectorAll(selector).forEach(elem => elem.classList.toggle('hidden', !isVisible)); +}; diff --git a/app/assets/javascripts/repository/utils/readme.js b/app/assets/javascripts/repository/utils/readme.js new file mode 100644 index 0000000000000000000000000000000000000000..e43b2bdc33a30b4516978f785e1e43307add1210 --- /dev/null +++ b/app/assets/javascripts/repository/utils/readme.js @@ -0,0 +1,21 @@ +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'); + +// eslint-disable-next-line import/prefer-default-export +export const readmeFile = blobs => { + const readMeFiles = blobs.filter(f => f.name.search(FILE_REGEXP) !== -1); + + const previewableReadme = readMeFiles.find(f => f.name.search(EXTENSIONS_REGEXP) !== -1); + const plainReadme = readMeFiles.find(f => f.name.search(PLAIN_FILE_REGEXP) !== -1); + + return previewableReadme || plainReadme; +}; diff --git a/app/assets/javascripts/repository/utils/title.js b/app/assets/javascripts/repository/utils/title.js index 87d54c012002bc38dfc0667f7adcba5bc4ebad2e..ff16fbdd420ffef457ba0f2de3f1eb1fa109de43 100644 --- a/app/assets/javascripts/repository/utils/title.js +++ b/app/assets/javascripts/repository/utils/title.js @@ -1,10 +1,14 @@ +const DEFAULT_TITLE = '· GitLab'; // eslint-disable-next-line import/prefer-default-export export const setTitle = (pathMatch, ref, project) => { - if (!pathMatch) return; + if (!pathMatch) { + document.title = `${project} ${DEFAULT_TITLE}`; + return; + } const path = pathMatch.replace(/^\//, ''); const isEmpty = path === ''; /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project}`; + document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project} ${DEFAULT_TITLE}`; }; diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 87454ee056ff198b4d41a9744f9091ed67876e33..fa5649679d747bac38be944e68c2fd96d23d215f 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, consistent-return, one-var, no-else-return, no-param-reassign */ +/* eslint-disable func-names, consistent-return, no-else-return, no-param-reassign */ import $ from 'jquery'; import _ from 'underscore'; @@ -44,12 +44,11 @@ Sidebar.prototype.addEventListeners = function() { }; Sidebar.prototype.sidebarToggleClicked = function(e, triggered) { - var $allGutterToggleIcons, $this, isExpanded, tooltipLabel; + const $this = $(this); + const isExpanded = $this.find('i').hasClass('fa-angle-double-right'); + const tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar'); + const $allGutterToggleIcons = $('.js-sidebar-toggle i'); e.preventDefault(); - $this = $(this); - isExpanded = $this.find('i').hasClass('fa-angle-double-right'); - tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar'); - $allGutterToggleIcons = $('.js-sidebar-toggle i'); if (isExpanded) { $allGutterToggleIcons.removeClass('fa-angle-double-right').addClass('fa-angle-double-left'); @@ -77,15 +76,9 @@ Sidebar.prototype.sidebarToggleClicked = function(e, triggered) { }; Sidebar.prototype.toggleTodo = function(e) { - var $this, ajaxType, url; - $this = $(e.currentTarget); - ajaxType = $this.data('deletePath') ? 'delete' : 'post'; - - if ($this.data('deletePath')) { - url = String($this.data('deletePath')); - } else { - url = String($this.data('createPath')); - } + const $this = $(e.currentTarget); + const ajaxType = $this.data('deletePath') ? 'delete' : 'post'; + const url = String($this.data('deletePath') || $this.data('createPath')); $this.tooltip('hide'); @@ -141,13 +134,12 @@ Sidebar.prototype.todoUpdateDone = function(data) { }; Sidebar.prototype.sidebarDropdownLoading = function() { - var $loading, $sidebarCollapsedIcon, i, img; - $sidebarCollapsedIcon = $(this) + const $sidebarCollapsedIcon = $(this) .closest('.block') .find('.sidebar-collapsed-icon'); - img = $sidebarCollapsedIcon.find('img'); - i = $sidebarCollapsedIcon.find('i'); - $loading = $('<i class="fa fa-spinner fa-spin"></i>'); + const img = $sidebarCollapsedIcon.find('img'); + const i = $sidebarCollapsedIcon.find('i'); + const $loading = $('<i class="fa fa-spinner fa-spin"></i>'); if (img.length) { img.before($loading); return img.hide(); @@ -158,13 +150,12 @@ Sidebar.prototype.sidebarDropdownLoading = function() { }; Sidebar.prototype.sidebarDropdownLoaded = function() { - var $sidebarCollapsedIcon, i, img; - $sidebarCollapsedIcon = $(this) + const $sidebarCollapsedIcon = $(this) .closest('.block') .find('.sidebar-collapsed-icon'); - img = $sidebarCollapsedIcon.find('img'); + const img = $sidebarCollapsedIcon.find('img'); $sidebarCollapsedIcon.find('i.fa-spin').remove(); - i = $sidebarCollapsedIcon.find('i'); + const i = $sidebarCollapsedIcon.find('i'); if (img.length) { return img.show(); } else { @@ -173,19 +164,17 @@ Sidebar.prototype.sidebarDropdownLoaded = function() { }; Sidebar.prototype.sidebarCollapseClicked = function(e) { - var $block, sidebar; if ($(e.currentTarget).hasClass('dont-change-state')) { return; } - sidebar = e.data; + const sidebar = e.data; e.preventDefault(); - $block = $(this).closest('.block'); + const $block = $(this).closest('.block'); return sidebar.openDropdown($block); }; Sidebar.prototype.openDropdown = function(blockOrName) { - var $block; - $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName; + const $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName; if (!this.isOpen()) { this.setCollapseAfterUpdate($block); this.toggleSidebar('open'); @@ -204,10 +193,9 @@ Sidebar.prototype.setCollapseAfterUpdate = function($block) { }; Sidebar.prototype.onSidebarDropdownHidden = function(e) { - var $block, sidebar; - sidebar = e.data; + const sidebar = e.data; e.preventDefault(); - $block = $(e.target).closest('.block'); + const $block = $(e.target).closest('.block'); return sidebar.sidebarDropdownHidden($block); }; diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index f6722ff7bcafe160d967ac513e41fab9862b4cfd..8d888a574d8962310ae4fad7f63038eb3852ecfc 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -1,4 +1,4 @@ -/* eslint-disable no-return-assign, one-var, no-var, consistent-return, class-methods-use-this, no-lonely-if, vars-on-top */ +/* eslint-disable no-return-assign, consistent-return, class-methods-use-this */ import $ from 'jquery'; import { escape, throttle } from 'underscore'; @@ -29,14 +29,14 @@ const KEYCODE = { }; function setSearchOptions() { - var $projectOptionsDataEl = $('.js-search-project-options'); - var $groupOptionsDataEl = $('.js-search-group-options'); - var $dashboardOptionsDataEl = $('.js-search-dashboard-options'); + const $projectOptionsDataEl = $('.js-search-project-options'); + const $groupOptionsDataEl = $('.js-search-group-options'); + const $dashboardOptionsDataEl = $('.js-search-dashboard-options'); if ($projectOptionsDataEl.length) { gl.projectOptions = gl.projectOptions || {}; - var projectPath = $projectOptionsDataEl.data('projectPath'); + const projectPath = $projectOptionsDataEl.data('projectPath'); gl.projectOptions[projectPath] = { name: $projectOptionsDataEl.data('name'), @@ -49,7 +49,7 @@ function setSearchOptions() { if ($groupOptionsDataEl.length) { gl.groupOptions = gl.groupOptions || {}; - var groupPath = $groupOptionsDataEl.data('groupPath'); + const groupPath = $groupOptionsDataEl.data('groupPath'); gl.groupOptions[groupPath] = { name: $groupOptionsDataEl.data('name'), @@ -95,10 +95,9 @@ export class SearchAutocomplete { this.createAutocomplete(); } - this.searchInput.addClass('disabled'); - this.saveTextLength(); this.bindEvents(); this.dropdownToggle.dropdown(); + this.searchInput.addClass('js-autocomplete-disabled'); } // Finds an element inside wrapper element @@ -107,7 +106,7 @@ export class SearchAutocomplete { this.onClearInputClick = this.onClearInputClick.bind(this); this.onSearchInputFocus = this.onSearchInputFocus.bind(this); this.onSearchInputKeyUp = this.onSearchInputKeyUp.bind(this); - this.onSearchInputKeyDown = this.onSearchInputKeyDown.bind(this); + this.onSearchInputChange = this.onSearchInputChange.bind(this); this.setScrollFade = this.setScrollFade.bind(this); } getElement(selector) { @@ -118,10 +117,6 @@ export class SearchAutocomplete { return (this.originalState = this.serializeState()); } - saveTextLength() { - return (this.lastTextLength = this.searchInput.val().length); - } - createAutocomplete() { return this.searchInput.glDropdown({ filterInputBlur: false, @@ -318,12 +313,16 @@ export class SearchAutocomplete { } bindEvents() { - this.searchInput.on('keydown', this.onSearchInputKeyDown); + this.searchInput.on('input', this.onSearchInputChange); this.searchInput.on('keyup', this.onSearchInputKeyUp); this.searchInput.on('focus', this.onSearchInputFocus); this.searchInput.on('blur', this.onSearchInputBlur); this.clearInput.on('click', this.onClearInputClick); this.dropdownContent.on('scroll', throttle(this.setScrollFade, 250)); + + this.searchInput.on('click', e => { + e.stopPropagation(); + }); } enableAutocomplete() { @@ -338,47 +337,23 @@ export class SearchAutocomplete { if (!this.dropdown.hasClass('show')) { this.loadingSuggestions = false; this.dropdownToggle.dropdown('toggle'); - return this.searchInput.removeClass('disabled'); + return this.searchInput.removeClass('js-autocomplete-disabled'); } } - // Saves last length of the entered text - onSearchInputKeyDown() { - return this.saveTextLength(); + onSearchInputChange() { + this.enableAutocomplete(); } onSearchInputKeyUp(e) { switch (e.keyCode) { - case KEYCODE.BACKSPACE: - // When removing the last character and no badge is present - if (this.lastTextLength === 1) { - this.disableAutocomplete(); - } - // When removing any character from existin value - if (this.lastTextLength > 1) { - this.enableAutocomplete(); - } - break; case KEYCODE.ESCAPE: this.restoreOriginalState(); break; case KEYCODE.ENTER: this.disableAutocomplete(); break; - case KEYCODE.UP: - case KEYCODE.DOWN: - return; default: - // Handle the case when deleting the input value other than backspace - // e.g. Pressing ctrl + backspace or ctrl + x - if (this.searchInput.val() === '') { - this.disableAutocomplete(); - } else { - // We should display the menu only when input is not empty - if (e.keyCode !== KEYCODE.ENTER) { - this.enableAutocomplete(); - } - } } this.wrap.toggleClass('has-value', Boolean(e.target.value)); } @@ -412,36 +387,33 @@ export class SearchAutocomplete { } restoreOriginalState() { - var i, input, inputs, len; - inputs = Object.keys(this.originalState); - for (i = 0, len = inputs.length; i < len; i += 1) { - input = inputs[i]; + const inputs = Object.keys(this.originalState); + for (let i = 0, len = inputs.length; i < len; i += 1) { + const input = inputs[i]; this.getElement(`#${input}`).val(this.originalState[input]); } } resetSearchState() { - var i, input, inputs, len, results; - inputs = Object.keys(this.originalState); - results = []; - for (i = 0, len = inputs.length; i < len; i += 1) { - input = inputs[i]; + const inputs = Object.keys(this.originalState); + const results = []; + for (let i = 0, len = inputs.length; i < len; i += 1) { + const input = inputs[i]; results.push(this.getElement(`#${input}`).val('')); } return results; } disableAutocomplete() { - if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('show')) { - this.searchInput.addClass('disabled'); - this.dropdown.removeClass('show').trigger('hidden.bs.dropdown'); + if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) { + this.searchInput.addClass('js-autocomplete-disabled'); + this.dropdown.dropdown('toggle'); this.restoreMenu(); } } restoreMenu() { - var html; - html = `<ul><li class="dropdown-menu-empty-item"><a>${__('Loading...')}</a></li></ul>`; + const html = `<ul><li class="dropdown-menu-empty-item"><a>${__('Loading...')}</a></li></ul>`; return this.dropdownContent.html(html); } diff --git a/app/assets/javascripts/raven/index.js b/app/assets/javascripts/sentry/index.js similarity index 76% rename from app/assets/javascripts/raven/index.js rename to app/assets/javascripts/sentry/index.js index 4dd0175e5288422943d575449a76051200fcf3da..06e4e0aa507ab289b7804e28f7b6ef4135d01ac6 100644 --- a/app/assets/javascripts/raven/index.js +++ b/app/assets/javascripts/sentry/index.js @@ -1,8 +1,8 @@ -import RavenConfig from './raven_config'; +import SentryConfig from './sentry_config'; const index = function index() { - RavenConfig.init({ - sentryDsn: gon.sentry_dsn, + SentryConfig.init({ + dsn: gon.sentry_dsn, currentUserId: gon.current_user_id, whitelistUrls: process.env.NODE_ENV === 'production' @@ -15,7 +15,7 @@ const index = function index() { }, }); - return RavenConfig; + return SentryConfig; }; index(); diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/sentry/sentry_config.js similarity index 63% rename from app/assets/javascripts/raven/raven_config.js rename to app/assets/javascripts/sentry/sentry_config.js index 7259e0df104fcd5540ee50e38415104e8e70915e..bc3b2f16a6af69e5322331d7c4ba7bce8906b662 100644 --- a/app/assets/javascripts/raven/raven_config.js +++ b/app/assets/javascripts/sentry/sentry_config.js @@ -1,4 +1,4 @@ -import Raven from 'raven-js'; +import * as Sentry from '@sentry/browser'; import $ from 'jquery'; import { __ } from '~/locale'; @@ -26,7 +26,7 @@ const IGNORE_ERRORS = [ 'conduitPage', ]; -const IGNORE_URLS = [ +const BLACKLIST_URLS = [ // Facebook flakiness /graph\.facebook\.com/i, // Facebook blocked @@ -43,62 +43,62 @@ const IGNORE_URLS = [ /metrics\.itunes\.apple\.com\.edgesuite\.net\//i, ]; -const SAMPLE_RATE = 95; +const SAMPLE_RATE = 0.95; -const RavenConfig = { +const SentryConfig = { IGNORE_ERRORS, - IGNORE_URLS, + BLACKLIST_URLS, SAMPLE_RATE, init(options = {}) { this.options = options; this.configure(); - this.bindRavenErrors(); + this.bindSentryErrors(); if (this.options.currentUserId) this.setUser(); }, configure() { - Raven.config(this.options.sentryDsn, { - release: this.options.release, - tags: this.options.tags, - whitelistUrls: this.options.whitelistUrls, - environment: this.options.environment, - ignoreErrors: this.IGNORE_ERRORS, - ignoreUrls: this.IGNORE_URLS, - shouldSendCallback: this.shouldSendSample.bind(this), - }).install(); + const { dsn, release, tags, whitelistUrls, environment } = this.options; + Sentry.init({ + dsn, + release, + tags, + whitelistUrls, + environment, + ignoreErrors: this.IGNORE_ERRORS, // TODO: Remove in favor of https://gitlab.com/gitlab-org/gitlab/issues/35144 + blacklistUrls: this.BLACKLIST_URLS, + sampleRate: SAMPLE_RATE, + }); }, setUser() { - Raven.setUserContext({ + Sentry.setUser({ id: this.options.currentUserId, }); }, - bindRavenErrors() { - $(document).on('ajaxError.raven', this.handleRavenErrors); + bindSentryErrors() { + $(document).on('ajaxError.sentry', this.handleSentryErrors); }, - handleRavenErrors(event, req, config, err) { + handleSentryErrors(event, req, config, err) { const error = err || req.statusText; - const responseText = req.responseText || __('Unknown response text'); + const { responseText = __('Unknown response text') } = req; + const { type, url, data } = config; + const { status } = req; - Raven.captureMessage(error, { + Sentry.captureMessage(error, { extra: { - type: config.type, - url: config.url, - data: config.data, - status: req.status, + type, + url, + data, + status, response: responseText, error, event, }, }); }, - - shouldSendSample() { - return Math.random() * 100 <= this.SAMPLE_RATE; - }, }; -export default RavenConfig; +export default SentryConfig; diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue index 95a2c8cce6eefde323aa1aefc13dc069aba09f52..91fe5fc50a99a3b889a3983addc6cee0712885c6 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue @@ -33,6 +33,8 @@ export default { <div class="block subscriptions"> <subscriptions :loading="store.isFetching.subscriptions" + :project-emails-disabled="store.projectEmailsDisabled" + :subscribe-disabled-description="store.subscribeDisabledDescription" :subscribed="store.subscribed" @toggleSubscription="onToggleSubscription" /> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue index ea5edb3ce3f8056da3ab7153e0d2da44d82c6d2f..0e489b28593aaee3fbebef860136a1509a2eb689 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -26,6 +26,16 @@ export default { required: false, default: false, }, + projectEmailsDisabled: { + type: Boolean, + required: false, + default: false, + }, + subscribeDisabledDescription: { + type: String, + required: false, + default: '', + }, subscribed: { type: Boolean, required: false, @@ -42,11 +52,23 @@ export default { return this.subscribed === null; }, notificationIcon() { + if (this.projectEmailsDisabled) { + return ICON_OFF; + } return this.subscribed ? ICON_ON : ICON_OFF; }, notificationTooltip() { + if (this.projectEmailsDisabled) { + return this.subscribeDisabledDescription; + } return this.subscribed ? LABEL_ON : LABEL_OFF; }, + notificationText() { + if (this.projectEmailsDisabled) { + return this.subscribeDisabledDescription; + } + return __('Notifications'); + }, }, methods: { /** @@ -81,6 +103,7 @@ export default { <template> <div> <span + ref="tooltip" v-tooltip class="sidebar-collapsed-icon" :title="notificationTooltip" @@ -96,8 +119,9 @@ export default { class="sidebar-item-icon is-active" /> </span> - <span class="issuable-header-text hide-collapsed float-left"> {{ __('Notifications') }} </span> + <span class="issuable-header-text hide-collapsed float-left"> {{ notificationText }} </span> <toggle-button + v-if="!projectEmailsDisabled" ref="toggleButton" :is-loading="showLoadingState" :value="subscribed" diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index 63c4a2a3f84bed19fd32eeeceeccd9a02b44b9cd..66f7f9e3c663d05e37cc50de8993495babacf7ca 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -28,6 +28,8 @@ export default class SidebarStore { this.moveToProjectId = 0; this.isLockDialogOpen = false; this.participants = []; + this.projectEmailsDisabled = false; + this.subscribeDisabledDescription = ''; this.subscribed = null; SidebarStore.singleton = this; @@ -53,6 +55,8 @@ export default class SidebarStore { } setSubscriptionsData(data) { + this.projectEmailsDisabled = data.project_emails_disabled || false; + this.subscribeDisabledDescription = data.subscribe_disabled_description; this.isFetching.subscriptions = false; this.subscribed = data.subscribed || false; } diff --git a/app/assets/javascripts/sourcegraph/index.js b/app/assets/javascripts/sourcegraph/index.js new file mode 100644 index 0000000000000000000000000000000000000000..796e90bf08e2659c3926f677af5f8a9b8cf4fd6e --- /dev/null +++ b/app/assets/javascripts/sourcegraph/index.js @@ -0,0 +1,28 @@ +function loadScript(path) { + const script = document.createElement('script'); + script.type = 'application/javascript'; + script.src = path; + script.defer = true; + document.head.appendChild(script); +} + +/** + * Loads the Sourcegraph integration for support for Sourcegraph extensions and + * code intelligence. + */ +export default function initSourcegraph() { + const { url } = gon.sourcegraph || {}; + + if (!url) { + return; + } + + const assetsUrl = new URL('/assets/webpack/sourcegraph/', window.location.href); + const scriptPath = new URL('scripts/integration.bundle.js', assetsUrl).href; + + window.SOURCEGRAPH_ASSETS_URL = assetsUrl.href; + window.SOURCEGRAPH_URL = url; + window.SOURCEGRAPH_INTEGRATION = 'gitlab-integration'; + + loadScript(scriptPath); +} diff --git a/app/assets/javascripts/sourcegraph/load.js b/app/assets/javascripts/sourcegraph/load.js new file mode 100644 index 0000000000000000000000000000000000000000..f9491505d4262dea53a4d0ec232372ef9dd6ade9 --- /dev/null +++ b/app/assets/javascripts/sourcegraph/load.js @@ -0,0 +1,6 @@ +import initSourcegraph from './index'; + +/** + * Load sourcegraph in it's own listener so that it's isolated from failures. + */ +document.addEventListener('DOMContentLoaded', initSourcegraph); diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js index 69b3d20914a9a229e02b366065f7b24647a7c8ee..a530c4a99e272f46d2258606e4608bdb3762aa86 100644 --- a/app/assets/javascripts/tree.js +++ b/app/assets/javascripts/tree.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, consistent-return, no-var, one-var, no-else-return, class-methods-use-this */ +/* eslint-disable func-names, consistent-return, one-var, no-else-return, class-methods-use-this */ import $ from 'jquery'; import { visitUrl } from './lib/utils/url_utility'; @@ -9,9 +9,8 @@ export default class TreeView { // Code browser tree slider // Make the entire tree-item row clickable, but not if clicking another link (like a commit message) $('.tree-content-holder .tree-item').on('click', function(e) { - var $clickedEl, path; - $clickedEl = $(e.target); - path = $('.tree-item-file-name a', this).attr('href'); + const $clickedEl = $(e.target); + const path = $('.tree-item-file-name a', this).attr('href'); if (!$clickedEl.is('a') && !$clickedEl.is('.str-truncated')) { if (e.metaKey || e.which === 2) { e.preventDefault(); @@ -26,11 +25,10 @@ export default class TreeView { } initKeyNav() { - var li, liSelected; - li = $('tr.tree-item'); - liSelected = null; + const li = $('tr.tree-item'); + let liSelected = null; return $('body').keydown(e => { - var next, path; + let next, path; if ($('input:focus').length > 0 && (e.which === 38 || e.which === 40)) { return false; } diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index c0b7587be10d45d440eef93b271d23e345fc4f57..7d6a725b30f75cd087e8f7f0d575f0017390dafd 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -73,9 +73,14 @@ const handleUserPopoverMouseOver = event => { location: userData.location, bio: userData.bio, organization: userData.organization, + status: userData.status, loaded: true, }); + if (userData.status) { + return Promise.resolve(); + } + return UsersCache.retrieveStatusById(userId); }) .then(status => { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index 339e154affc2d57c1a3bb55ef3ec164c8f7c7fba..57be97855e38b420be14c73d58074a32fcfdb174 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -65,9 +65,13 @@ export default { simplePoll(this.checkRebaseStatus); }) .catch(error => { - this.rebasingError = error.merge_error; this.isMakingRequest = false; - Flash(__('Something went wrong. Please try again.')); + + if (error.response && error.response.data && error.response.data.merge_error) { + this.rebasingError = error.response.data.merge_error; + } else { + Flash(__('Something went wrong. Please try again.')); + } }); }, checkRebaseStatus(continuePolling, stopPolling) { diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue index 1e6f4c376c1c6e3aa4bdc6f14d295ce2018a2eee..66155ddcdd97766c8469618be644a55c94e7f816 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue @@ -18,6 +18,11 @@ export default { required: false, default: 0, }, + filePath: { + type: String, + required: false, + default: '', + }, projectPath: { type: String, required: false, @@ -52,6 +57,7 @@ export default { <component :is="viewer" :path="path" + :file-path="filePath" :file-size="fileSize" :project-path="projectPath" :content="content" diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue index 655f0054887a9ae927fe43a15b58ff3ce411beb3..c50304f057dd92c5de51cda053e785730f8c7eaa 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -16,6 +16,11 @@ export default { type: String, required: true, }, + filePath: { + type: String, + required: false, + default: '', + }, projectPath: { type: String, required: true, @@ -48,6 +53,7 @@ export default { this.isLoading = true; const postBody = { text: this.content, + path: this.filePath, }; const postOptions = { cancelToken: axiosSource.token, diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue index ebb253ff422233663ffbfb11a509c77085d1602d..b874bedab367bd431f88c00c0c1d13f8d1fb1c32 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue @@ -23,6 +23,11 @@ export default { type: String, required: true, }, + newSize: { + type: Number, + required: false, + default: 0, + }, oldPath: { type: String, required: true, @@ -31,6 +36,11 @@ export default { type: String, required: true, }, + oldSize: { + type: Number, + required: false, + default: 0, + }, projectPath: { type: String, required: false, @@ -85,6 +95,8 @@ export default { :diff-mode="diffMode" :new-path="fullNewPath" :old-path="fullOldPath" + :old-size="oldSize" + :new-size="newSize" :project-path="projectPath" :a-mode="aMode" :b-mode="bMode" diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue index a17fc0221950615b94f8235e41de3d032449b169..4dbfdb6d79c4f9878defe15e6914fb875c860028 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue @@ -14,6 +14,16 @@ export default { type: String, required: true, }, + newSize: { + type: Number, + required: false, + default: 0, + }, + oldSize: { + type: Number, + required: false, + default: 0, + }, }, }; </script> @@ -22,12 +32,14 @@ export default { <div class="two-up view d-flex"> <image-viewer :path="oldPath" + :file-size="oldSize" :render-info="true" inner-css-classes="frame deleted" class="wrap w-50" /> <image-viewer :path="newPath" + :file-size="newSize" :render-info="true" :inner-css-classes="['frame', 'added']" class="wrap w-50" diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue index cab92297ca7036411f9a336b23c5f6859c04728a..e30871b66fcce5113d5f9a9c6add5d493052e9b7 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue @@ -22,6 +22,16 @@ export default { type: String, required: true, }, + newSize: { + type: Number, + required: false, + default: 0, + }, + oldSize: { + type: Number, + required: false, + default: 0, + }, }, data() { return { diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 341c95347637e4ff132ab32127efbd012254473c..611001df32f9b7c7ef7929b4505ffda7502f5a6a 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -218,7 +218,7 @@ export default { display: inline-block; flex: 1; max-width: inherit; - height: 18px; + height: 19px; line-height: 16px; text-overflow: ellipsis; white-space: nowrap; diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue index 73f4dfef062ba452754fc7ab0e6816ab4de35446..80908cbbc9ca61c9641b2aa3d1b55aff244f586e 100644 --- a/app/assets/javascripts/vue_shared/components/icon.vue +++ b/app/assets/javascripts/vue_shared/components/icon.vue @@ -61,7 +61,7 @@ export default { </script> <template> - <svg :class="[iconSizeClass, iconTestClass]" aria-hidden="true"> + <svg :class="[iconSizeClass, iconTestClass]" aria-hidden="true" v-on="$listeners"> <use v-bind="{ 'xlink:href': spriteHref }" /> </svg> </template> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue index 715cf97f0ac7b58ed99fd1a160f1e59cdd241f32..1524b313f9f2548125d68ecee7e09745e4af1bea 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue @@ -1,7 +1,6 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; - import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; export default { @@ -16,44 +15,47 @@ export default { type: Array, required: true, }, + iconSize: { + type: Number, + required: false, + default: 24, + }, + imgCssClasses: { + type: String, + required: false, + default: '', + }, + maxVisible: { + type: Number, + required: false, + default: 3, + }, }, data() { return { - maxVisibleAssignees: 2, - maxAssigneeAvatars: 3, maxAssignees: 99, }; }, computed: { - countOverLimit() { - return this.assignees.length - this.maxVisibleAssignees; - }, assigneesToShow() { - if (this.assignees.length > this.maxAssigneeAvatars) { - return this.assignees.slice(0, this.maxVisibleAssignees); - } - return this.assignees; + const numShownAssignees = this.assignees.length - this.numHiddenAssignees; + return this.assignees.slice(0, numShownAssignees); }, assigneesCounterTooltip() { - const { countOverLimit, maxAssignees } = this; - const count = countOverLimit > maxAssignees ? maxAssignees : countOverLimit; - - return sprintf(__('%{count} more assignees'), { count }); + return sprintf(__('%{count} more assignees'), { count: this.numHiddenAssignees }); }, - shouldRenderAssigneesCounter() { - const assigneesCount = this.assignees.length; - if (assigneesCount <= this.maxAssigneeAvatars) { - return false; + numHiddenAssignees() { + if (this.assignees.length > this.maxVisible) { + return this.assignees.length - this.maxVisible + 1; } - - return assigneesCount > this.countOverLimit; + return 0; }, assigneeCounterLabel() { - if (this.countOverLimit > this.maxAssignees) { + if (this.numHiddenAssignees > this.maxAssignees) { return `${this.maxAssignees}+`; } - return `+${this.countOverLimit}`; + return `+${this.numHiddenAssignees}`; }, }, methods: { @@ -81,8 +83,9 @@ export default { :key="assignee.id" :link-href="webUrl(assignee)" :img-alt="avatarUrlTitle(assignee)" + :img-css-classes="imgCssClasses" :img-src="avatarUrl(assignee)" - :img-size="24" + :img-size="iconSize" class="js-no-trigger" tooltip-placement="bottom" > @@ -92,7 +95,7 @@ export default { </span> </user-avatar-link> <span - v-if="shouldRenderAssigneesCounter" + v-if="numHiddenAssignees > 0" v-gl-tooltip :title="assigneesCounterTooltip" class="avatar-counter" diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 4d27d1c9179397e4a95caf4b5bf84f7f4904e1c8..af4ac024e4f21d3d4e7cca5e76009c9d701840f7 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -124,7 +124,7 @@ export default { :cursor-offset="4" :tag-content="lineContent" icon="doc-code" - class="qa-suggestion-btn js-suggestion-btn" + class="js-suggestion-btn" @click="handleSuggestDismissed" /> <gl-popover @@ -168,7 +168,7 @@ export default { :prepend="true" tag="* [ ] " :button-title="__('Add a task list')" - icon="task-done" + icon="list-task" /> <toolbar-button :tag="mdTable" diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index 12de3671477d77d92d090ff5fa972e6b4ecd08a1..cc700440a23115bab695c49e17164a25493e0309 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -55,7 +55,7 @@ export default { <gl-button v-else-if="canApply" v-gl-tooltip.viewport="__('This also resolves the discussion')" - class="btn-inverted qa-apply-btn js-apply-btn" + class="btn-inverted js-apply-btn" :disabled="isApplying" variant="success" @click="applySuggestion" diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index d6dfe9eded816eda666500cdcf19a461d7bc4d81..f8e010c4f429d058e87d42c10daad3f527f25d2a 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -17,9 +17,11 @@ * /> */ import $ from 'jquery'; -import { mapGetters } from 'vuex'; +import { mapGetters, mapActions } from 'vuex'; +import { GlSkeletonLoading } from '@gitlab/ui'; import noteHeader from '~/notes/components/note_header.vue'; import Icon from '~/vue_shared/components/icon.vue'; +import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; import TimelineEntryItem from './timeline_entry_item.vue'; import { spriteIcon } from '../../../lib/utils/common_utils'; import initMRPopovers from '~/mr_popover/'; @@ -32,7 +34,9 @@ export default { Icon, noteHeader, TimelineEntryItem, + GlSkeletonLoading, }, + mixins: [descriptionVersionHistoryMixin], props: { note: { type: Object, @@ -75,13 +79,16 @@ export default { mounted() { initMRPopovers(this.$el.querySelectorAll('.gfm-merge_request')); }, + methods: { + ...mapActions(['fetchDescriptionVersion']), + }, }; </script> <template> <timeline-entry-item :id="noteAnchorId" - :class="{ target: isTargetNote }" + :class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }" class="note system-note note-wrapper" > <div class="timeline-icon" v-html="iconHtml"></div> @@ -89,14 +96,18 @@ export default { <div class="note-header"> <note-header :author="note.author" :created-at="note.created_at" :note-id="note.id"> <span v-html="actionTextHtml"></span> + <template v-if="canSeeDescriptionVersion" slot="extra-controls"> + · + <button type="button" class="btn-blank btn-link" @click="toggleDescriptionVersion"> + {{ __('Compare with previous version') }} + <icon :name="descriptionVersionToggleIcon" :size="12" class="append-left-5" /> + </button> + </template> </note-header> </div> <div class="note-body"> <div - :class="{ - 'system-note-commit-list': hasMoreCommits, - 'hide-shade': expanded, - }" + :class="{ 'system-note-commit-list': hasMoreCommits, 'hide-shade': expanded }" class="note-text md" v-html="note.note_html" ></div> @@ -106,6 +117,12 @@ export default { <span>{{ __('Toggle commit list') }}</span> </div> </div> + <div v-if="shouldShowDescriptionVersion" class="description-version pt-2"> + <pre v-if="isLoadingDescriptionVersion" class="loading-state"> + <gl-skeleton-loading /> + </pre> + <pre v-else class="wrapper mt-2" v-html="descriptionVersion"></pre> + </div> </div> </div> </timeline-entry-item> diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue index 478e44d104c808f6718c1d7592ce81639ed2ef1d..f984a0a620372f741233d69ae8f228cc88e671b5 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue @@ -1,6 +1,6 @@ <script> import _ from 'underscore'; -import { GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import { GlLoadingIcon, GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui'; import ProjectListItem from './project_list_item.vue'; const SEARCH_INPUT_TIMEOUT_MS = 500; @@ -10,6 +10,7 @@ export default { components: { GlLoadingIcon, GlSearchBoxByType, + GlInfiniteScroll, ProjectListItem, }, props: { @@ -41,6 +42,11 @@ export default { required: false, default: false, }, + totalResults: { + type: Number, + required: false, + default: 0, + }, }, data() { return { @@ -51,6 +57,9 @@ export default { projectClicked(project) { this.$emit('projectClicked', project); }, + bottomReached() { + this.$emit('bottomReached'); + }, isSelected(project) { return Boolean(_.find(this.selectedProjects, { id: project.id })); }, @@ -71,18 +80,25 @@ export default { @input="onInput" /> <div class="d-flex flex-column"> - <gl-loading-icon v-if="showLoadingIndicator" :size="2" class="py-2 px-4" /> - <div v-if="!showLoadingIndicator" class="d-flex flex-column"> - <project-list-item - v-for="project in projectSearchResults" - :key="project.id" - :selected="isSelected(project)" - :project="project" - :matcher="searchQuery" - class="js-project-list-item" - @click="projectClicked(project)" - /> - </div> + <gl-loading-icon v-if="showLoadingIndicator" :size="1" class="py-2 px-4" /> + <gl-infinite-scroll + :max-list-height="402" + :fetched-items="projectSearchResults.length" + :total-items="totalResults" + @bottomReached="bottomReached" + > + <div v-if="!showLoadingIndicator" slot="items" class="d-flex flex-column"> + <project-list-item + v-for="project in projectSearchResults" + :key="project.id" + :selected="isSelected(project)" + :project="project" + :matcher="searchQuery" + class="js-project-list-item" + @click="projectClicked(project)" + /> + </div> + </gl-infinite-scroll> <div v-if="showNoResultsMessage" class="text-muted ml-2 js-no-results-message"> {{ __('Sorry, no projects matched your search') }} </div> diff --git a/app/assets/javascripts/vue_shared/components/slot_switch.vue b/app/assets/javascripts/vue_shared/components/slot_switch.vue new file mode 100644 index 0000000000000000000000000000000000000000..67726f0174486376d9c8c08c142ba4844b3389b4 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/slot_switch.vue @@ -0,0 +1,35 @@ +<script> +/** + * Allows to toggle slots based on an array of slot names. + */ +export default { + name: 'SlotSwitch', + + props: { + activeSlotNames: { + type: Array, + required: true, + }, + + tagName: { + type: String, + required: false, + default: 'div', + }, + }, + + computed: { + allSlotNames() { + return Object.keys(this.$slots); + }, + }, +}; +</script> + +<template> + <component :is="tagName"> + <template v-for="slotName in allSlotNames"> + <slot v-if="activeSlotNames.includes(slotName)" :name="slotName"></slot> + </template> + </component> +</template> diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue new file mode 100644 index 0000000000000000000000000000000000000000..f7dc00a345c13b685be51386470568aa5c6a195e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/split_button.vue @@ -0,0 +1,76 @@ +<script> +import _ from 'underscore'; + +import { GlDropdown, GlDropdownDivider, GlDropdownItem } from '@gitlab/ui'; + +const isValidItem = item => + _.isString(item.eventName) && _.isString(item.title) && _.isString(item.description); + +export default { + components: { + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + }, + + props: { + actionItems: { + type: Array, + required: true, + validator(value) { + return value.length > 1 && value.every(isValidItem); + }, + }, + menuClass: { + type: String, + required: false, + default: '', + }, + }, + + data() { + return { + selectedItem: this.actionItems[0], + }; + }, + + computed: { + dropdownToggleText() { + return this.selectedItem.title; + }, + }, + + methods: { + triggerEvent() { + this.$emit(this.selectedItem.eventName); + }, + }, +}; +</script> + +<template> + <gl-dropdown + :menu-class="`dropdown-menu-selectable ${menuClass}`" + split + :text="dropdownToggleText" + v-bind="$attrs" + @click="triggerEvent" + > + <template v-for="(item, itemIndex) in actionItems"> + <gl-dropdown-item + :key="item.eventName" + :active="selectedItem === item" + active-class="is-active" + @click="selectedItem = item" + > + <strong>{{ item.title }}</strong> + <div>{{ item.description }}</div> + </gl-dropdown-item> + + <gl-dropdown-divider + v-if="itemIndex < actionItems.length - 1" + :key="`${item.eventName}-divider`" + /> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue index 8bcad7ac7655f105d759f585a250a171079d6409..43935cf31d5d40ade038c34eeb30287b2a493002 100644 --- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue +++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue @@ -32,7 +32,7 @@ export default { </script> <template> <time - v-gl-tooltip="{ placement: tooltipPlacement }" + v-gl-tooltip.viewport="{ placement: tooltipPlacement }" :class="cssClass" :title="tooltipTitle(time)" v-text="timeFormated(time)" 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 7c7d46ee759c2c1ab849595e14e59c84637e2c3e..4a72cca5f021e7491ce3d5500431bf5231710184 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 @@ -51,7 +51,7 @@ export default { </script> <template> - <gl-popover :target="target" boundary="viewport" placement="top" show> + <gl-popover :target="target" boundary="viewport" placement="top" offset="0, 1" show> <div class="user-popover d-flex"> <div class="p-1 flex-shrink-1"> <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="mr-2" /> @@ -90,7 +90,7 @@ export default { name="location" class="category-icon flex-shrink-0" /> - <span class="ml-1">{{ user.location }}</span> + <span v-if="user.location" class="ml-1">{{ user.location }}</span> <gl-skeleton-loading v-if="locationIsLoading" :lines="1" diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js index 7a60ab1380f9002d95a62bd557978ece07a8d912..044d703630eb3fedd5f4353decdb1f91b3ef4541 100644 --- a/app/assets/javascripts/zen_mode.js +++ b/app/assets/javascripts/zen_mode.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, consistent-return, camelcase, class-methods-use-this */ +/* eslint-disable consistent-return, camelcase, class-methods-use-this */ // Zen Mode (full screen) textarea // @@ -47,26 +47,16 @@ export default class ZenMode { e.preventDefault(); return $(e.currentTarget).trigger('zen_mode:leave'); }); - $(document).on( - 'zen_mode:enter', - (function(_this) { - return function(e) { - return _this.enter( - $(e.target) - .closest('.md-area') - .find('.zen-backdrop'), - ); - }; - })(this), - ); - $(document).on( - 'zen_mode:leave', - (function(_this) { - return function() { - return _this.exit(); - }; - })(this), - ); + $(document).on('zen_mode:enter', e => { + this.enter( + $(e.target) + .closest('.md-area') + .find('.zen-backdrop'), + ); + }); + $(document).on('zen_mode:leave', () => { + this.exit(); + }); $(document).on('keydown', e => { // Esc if (e.keyCode === 27) { diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index c9b00e5ff27bd607599c6d7a4af61ed2c76667ff..885e9ac6667b6828a987ba0b822caf2739970bba 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -282,8 +282,7 @@ pre code { white-space: pre-wrap; } -.alert, -.flash-notice { +.alert { border-radius: 0; } @@ -310,12 +309,10 @@ pre code { .alert-success, .alert-info, .alert-warning, -.alert-danger, -.flash-notice { +.alert-danger { color: $white-light; h4, - a:not(.btn), .alert-link { color: $white-light; } diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 56a88ca44dbaea26ae0614e41d5d05b193dbe8ce..249e9a24b17e7b946bce73f53726be6923f997ca 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -2,7 +2,7 @@ @import 'framework/variables_overrides'; @import 'framework/mixins'; -@import '@gitlab/ui/scss/gitlab_ui'; +@import '@gitlab/ui/src/scss/gitlab_ui'; @import 'bootstrap_migration'; @import 'framework/layout'; diff --git a/app/assets/stylesheets/framework/ci_variable_list.scss b/app/assets/stylesheets/framework/ci_variable_list.scss index 28d7492b99c2ba4311260b9d5115bc1da4afdb17..cae7b9b5e46dd06c0088b926dd6207259ecebe16 100644 --- a/app/assets/stylesheets/framework/ci_variable_list.scss +++ b/app/assets/stylesheets/framework/ci_variable_list.scss @@ -99,3 +99,13 @@ color: $gl-text-color-disabled; } } + +.group-variable-list { + color: $gray-700; + + .table-section:not(:first-child) { + @include media-breakpoint-down(sm) { + border-top: hidden; + } + } +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 4b89a2f2b0485d48994a311bf9913f1e94acbae4..31ea59df4c5401b9ad2781fadf84e24004a8f1fc 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -562,4 +562,20 @@ img.emoji { } .gl-font-size-small { font-size: $gl-font-size-small; } +.gl-font-size-large { font-size: $gl-font-size-large; } + .gl-line-height-24 { line-height: $gl-line-height-24; } + +.gl-font-size-12 { font-size: $gl-font-size-12; } +.gl-font-size-14 { font-size: $gl-font-size-14; } +.gl-font-size-16 { font-size: $gl-font-size-16; } +.gl-font-size-20 { font-size: $gl-font-size-20; } +.gl-font-size-28 { font-size: $gl-font-size-28; } +.gl-font-size-42 { font-size: $gl-font-size-42; } + +.border-section { + @include gl-py-6; + @include gl-m-0; + + border-top: 1px solid $border-color; +} diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index ce74aa6ed027a48cedfd5d9daa2a78ccddffd4a2..d53a4c1286c504f68534e177678f55dce34a3f36 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -506,7 +506,8 @@ .dropdown-menu-selectable { li { a, - button { + button, + .dropdown-item { padding: 8px 40px; position: relative; diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 487fbf0fcff84af5e7c9daf4fa52b9e8d2c97e23..4938215b2e7c65bce0981639004d4b51997113dd 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -376,6 +376,10 @@ span.idiff { float: none; } + .file-actions .ide-edit-button { + z-index: 2; + } + @include media-breakpoint-down(xs) { display: block; diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 5984efd1cf8dbdf1721ada639e0f04d9c9419c0a..2d826064569df9fcb19b1005c208157ef4f3cc4f 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -249,7 +249,7 @@ } .filtered-search-input-dropdown-menu { - max-height: $dropdown-max-height; + max-height: $dropdown-max-height-lg; max-width: 280px; overflow: auto; @@ -357,12 +357,18 @@ } } - .filter-dropdown-container > div { - margin: 0; + .filter-dropdown-container { + > div { + margin: 0; - > .btn { - margin: 0 0 10px; - width: 100%; + > .btn { + margin: 0 0 10px; + width: 100%; + } + } + + .board-labels-toggle-wrapper { + margin-bottom: $gl-input-padding; } } diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index 8fc2fd5f53bd8751e945a07aa2cc96088e32d79c..d604d97d2701cd1818b25c909d086b15cb54be78 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -12,17 +12,22 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); position: -webkit-sticky; top: $flash-container-top; z-index: 251; + } - .flash-content { - box-shadow: 0 2px 4px 0 $notification-box-shadow-color; - } + &.flash-container-page { + margin-bottom: 0; + } + + &:empty { + margin: 0; } .close-icon-wrapper { - padding: ($gl-btn-padding + $gl-padding-4) $gl-padding $gl-btn-padding; + padding: ($gl-padding + $gl-padding-4) $gl-padding $gl-padding; position: absolute; right: 0; top: 0; + bottom: 0; cursor: pointer; .close-icon { @@ -31,13 +36,12 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); } } - .flash-notice, .flash-alert, + .flash-notice, .flash-success, .flash-warning { - border-radius: $border-radius-default; - color: $white-light; - padding-right: $gl-padding * 2; + padding: $gl-padding $gl-padding-32 $gl-padding ($gl-padding + $gl-padding-4); + margin-top: 10px; .container-fluid, .container-fluid.container-limited { @@ -45,75 +49,31 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); } } - .flash-notice { - @extend .alert; - background-color: $blue-500; - margin: 0; - - &.flash-notice-persistent { - background-color: $blue-100; - color: $gl-text-color; + .flash-alert { + background-color: $red-100; + color: $red-700; + } - a { - color: $blue-600; + .flash-notice { + background-color: $blue-100; + color: $blue-700; + } - &:hover { - color: $blue-800; - text-decoration: none; - } - } - } + .flash-success { + background-color: $theme-green-100; + color: $green-700; } .flash-warning { - @extend .alert; background-color: $orange-100; - color: $orange-900; + color: $orange-800; cursor: default; - margin: 0; } .flash-text, .flash-action { display: inline-block; } - - .flash-alert { - @extend .alert; - background-color: $red-500; - margin: 0; - - .flash-action { - margin-left: 5px; - text-decoration: none; - font-weight: $gl-font-weight-normal; - border-bottom: 1px solid; - - &:hover { - border-color: transparent; - } - } - } - - .flash-success { - @extend .alert; - background-color: $green-500; - margin: 0; - } - - &.flash-container-page { - margin-bottom: 0; - - .flash-notice, - .flash-alert, - .flash-success { - border-radius: 0; - } - } - - &:empty { - margin: 0; - } } @include media-breakpoint-down(sm) { diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 7205324e86f0c31c77e980725f4f74d9c1c7da7b..8038a367fb94d315094cd4aa3e39034128afbf94 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -33,7 +33,8 @@ body { &.limit-container-width { .flash-container.sticky { max-width: $limited-layout-width; - margin: 0 auto; + margin-right: auto; + margin-left: auto; } } } diff --git a/app/assets/stylesheets/framework/memory_graph.scss b/app/assets/stylesheets/framework/memory_graph.scss index 81cdf6b59e436f45b4eec17716811c7816964196..c84010c6f1074d597f0db6af6db7e4008567976b 100644 --- a/app/assets/stylesheets/framework/memory_graph.scss +++ b/app/assets/stylesheets/framework/memory_graph.scss @@ -1,11 +1,7 @@ .memory-graph-container { svg { background: $white-light; - cursor: pointer; - - &:hover { - box-shadow: 0 0 4px $gray-darkest inset; - } + border: 1px solid $gray-200; } path { diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index 9c924559135d98f4bebfe93d55aed759c855869b..757264add93fa84982d26e74b540b9243c8491dc 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -49,7 +49,7 @@ line-height: $line-height-base; position: relative; min-height: $modal-body-height; - padding: #{2 * $grid-size} #{6 * $grid-size} #{2 * $grid-size} #{2 * $grid-size}; + padding: #{2 * $grid-size}; text-align: left; white-space: normal; @@ -70,9 +70,9 @@ margin: 0; } - .btn + .btn:not(.dropdown-toggle-split), .btn + .btn-group, - .btn-group + .btn { + .btn-group + .btn, + .btn-group + .btn-group { margin-left: $grid-size; } @@ -83,13 +83,6 @@ @include media-breakpoint-down(xs) { flex-direction: column; - .btn + .btn:not(.dropdown-toggle-split), - .btn + .btn-group, - .btn-group + .btn { - margin-left: 0; - margin-top: $grid-size; - } - .btn-group .btn + .btn { margin-left: -1px; margin-top: 0; diff --git a/app/assets/stylesheets/framework/responsive_tables.scss b/app/assets/stylesheets/framework/responsive_tables.scss index fd6f80e26cbf8862efd9ffde2398121a291a3038..1878fac1c60a688234c54f022afedd2e3be5b8c7 100644 --- a/app/assets/stylesheets/framework/responsive_tables.scss +++ b/app/assets/stylesheets/framework/responsive_tables.scss @@ -20,6 +20,17 @@ @extend .gl-responsive-table-row-layout; margin-top: 10px; border: 1px solid $border-color; + color: $gray-700; + + &.gl-responsive-table-row-clickable { + &:hover { + background-color: $gray-light; + + .underline { + text-decoration: underline; + } + } + } @include media-breakpoint-up(md) { margin: 0; diff --git a/app/assets/stylesheets/framework/snippets.scss b/app/assets/stylesheets/framework/snippets.scss index f57b1d9f3514a692651cdacbadfd04c30a4eb8c7..404f60f17ee3f81d18cafba1005abb3ebbf14d8e 100644 --- a/app/assets/stylesheets/framework/snippets.scss +++ b/app/assets/stylesheets/framework/snippets.scss @@ -4,7 +4,12 @@ } .snippet-filename { - padding: 0 2px; + color: $gl-text-color-secondary; + font-weight: normal; + } + + .snippet-info { + color: $gl-text-color-secondary; } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index dfc39d8e03b4c6a25f7ce4e67b0b49929891c653..0f77c451facade1ed10475385b2192a7c1e12d35 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -20,6 +20,27 @@ $spacing-scale: ( 5: #{4 * $grid-size} ); +/* + * Why another sizing scale??? + * Great question, friend! + * This size scale is a "backport" of the equivalent set of "named" sizes + * (e.g. `xl` versus `70`) that came from the following design document as of 2019-10-23: + * + * https://gitlab-org.gitlab.io/gitlab-design/hosted/design-gitlab-specs/forms-spec-previews/ + * + * (See `input-` items at the bottom) + * + * The presumption here is that these sizes will be standardized in GitLab UI and thus will be + * broadly useful here in the GitLab product when not using the GitLab UI components. + */ +$size-scale: ( + 'xs': #{10 * $grid-size}, + 's': #{20 * $grid-size}, + 'm': #{30 * $grid-size}, + 'l': #{40 * $grid-size}, + 'xl': #{70 * $grid-size} +); + /* * Color schema */ @@ -304,6 +325,12 @@ $gl-grayish-blue: #7f8fa4; $gl-gray-dark: #313236; $gl-gray-light: #5c5c5c; $gl-header-color: #4c4e54; +$gl-font-size-12: 12px; +$gl-font-size-14: 14px; +$gl-font-size-16: 16px; +$gl-font-size-20: 20px; +$gl-font-size-28: 28px; +$gl-font-size-42: 42px; $type-scale: ( 1: 12px, diff --git a/app/assets/stylesheets/framework/vue_transitions.scss b/app/assets/stylesheets/framework/vue_transitions.scss index e3bdc0b0199071dddd015bf40b319cf405075535..a082cd25abe67867a1899289e7213c71aec58305 100644 --- a/app/assets/stylesheets/framework/vue_transitions.scss +++ b/app/assets/stylesheets/framework/vue_transitions.scss @@ -11,3 +11,27 @@ .fade-leave-to { opacity: 0; } + +.slide-enter-from-element { + &.slide-enter, + &.slide-leave-to { + transform: translateX(-150%); + } +} + +.slide-enter-to-element { + &.slide-enter, + &.slide-leave-to { + transform: translateX(150%); + } +} + +.slide-enter-active, +.slide-leave-active { + transition: transform 300ms ease-out; +} + +.slide-enter-to, +.slide-leave { + transform: translateX(0); +} diff --git a/app/assets/stylesheets/mailer.scss b/app/assets/stylesheets/mailer.scss new file mode 100644 index 0000000000000000000000000000000000000000..f7d93870a257041a887ba5b0d8de8838cf35d3e4 --- /dev/null +++ b/app/assets/stylesheets/mailer.scss @@ -0,0 +1,117 @@ +@import 'framework/variables'; + +// Do not use 3-letter hex codes, bgcolor vs css background-color is problematic in emails +// See https://stackoverflow.com/questions/28551981/why-are-3-digit-hex-color-code-values-interpreted-differently-in-internet-explor +// +// stylelint-disable color-hex-length + +$mailer-font: 'Helvetica Neue', Helvetica, Arial, sans-serif; +$mailer-text-color: #333333; +$mailer-bg-color: #fafafa; +$mailer-link-color: #3777b0; +$mailer-link-muted-color: #333333; +$mailer-line-cell-bg-color: #6b4fbb; +$mailer-wrapper-cell-bg-color: #ffffff; +$mailer-wrapper-cell-border-color: #ededed; +$mailer-header-footer-text-color: #5c5c5c; + +body { + margin: 0 !important; + background-color: $mailer-bg-color; + padding: 0; + text-align: center; + min-width: 640px; + width: 100%; + height: 100%; + font-family: $mailer-font; +} + +table#body { + background-color: $mailer-bg-color; + margin: 0; + padding: 0; + text-align: center; + min-width: 640px; + width: 100%; +} + +a { + color: $mailer-link-color; + text-decoration: none; + + &.muted { + color: $mailer-link-muted-color; + } +} + +.highlight { + font-weight: 500; +} + +tr td { + font-family: $mailer-font; +} + +tr.line td { + font-family: $mailer-font; + background-color: $mailer-line-cell-bg-color; + height: 4px; + font-size: 4px; + line-height: 4px; +} + +tr.header td, +tr.footer td, +td.footer-message { + font-family: $mailer-font; + padding: 25px 0; + font-size: 13px; + line-height: 1.6; + color: $mailer-header-footer-text-color; +} + +table.wrapper { + width: 640px; + margin: 0 auto; + border-collapse: separate; + border-spacing: 0; + + td.wrapper-cell { + font-family: $mailer-font; + background-color: $mailer-wrapper-cell-bg-color; + text-align: left; + padding: 18px 25px; + border: 1px solid $mailer-wrapper-cell-border-color; + border-radius: 3px; + overflow: hidden; + } +} + +table.content { + width: 100%; + border-collapse: separate; + border-spacing: 0; + + td.text-content { + font-family: $mailer-font; + color: $mailer-text-color; + font-size: 15px; + font-weight: 400; + line-height: 1.4; + padding: 15px 5px; + text-align: center; + } +} + +tr.footer td { + img { + display: block; + margin: 0 auto 1em; + } + + .mng-notif-link, + .help-link { + color: $mailer-link-color; + text-decoration: none; + } +} diff --git a/app/assets/stylesheets/mailer_client_specific.scss b/app/assets/stylesheets/mailer_client_specific.scss new file mode 100644 index 0000000000000000000000000000000000000000..41bedecf90f3cb78ca49e558e56e8de4d1d2749d --- /dev/null +++ b/app/assets/stylesheets/mailer_client_specific.scss @@ -0,0 +1,65 @@ +/* CLIENT-SPECIFIC STYLES */ + +// These are client-specific rules, ignore some linting rules +// +// stylelint-disable property-no-vendor-prefix, property-no-unknown, length-zero-no-unit +// scss-lint:disable PropertySpelling, ZeroUnit + +body, +table, +td, +a { + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; +} + +table, +td { + mso-table-lspace: 0pt; + mso-table-rspace: 0pt; +} + +img { + -ms-interpolation-mode: bicubic; +} + +.hidden { + display: none !important; + visibility: hidden !important; +} + +/* iOS BLUE LINKS */ +a[x-apple-data-detectors] { + color: inherit !important; + text-decoration: none !important; + font-size: inherit !important; + font-family: inherit !important; + font-weight: inherit !important; + line-height: inherit !important; +} + +/* ANDROID MARGIN HACK */ +div[style*='margin: 16px 0'] { + margin: 0 !important; +} + +@media only screen and (max-width: 639px) { + body, + #body { + min-width: 320px !important; + } + + table.wrapper { + width: 100% !important; + min-width: 320px !important; + } + + table.wrapper td.wrapper-cell { + border-left: 0 !important; + border-right: 0 !important; + border-radius: 0 !important; + padding-left: 10px !important; + padding-right: 10px !important; + } +} + diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 2a7a53d8bd71f45b84cb32963832cce59c0efced..d26979bc17487fe26f084246ba59af4347ef9c21 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -497,7 +497,7 @@ .add-issues-footer-to-list { padding-left: $gl-vert-padding; padding-right: $gl-vert-padding; - line-height: 34px; + line-height: $input-height; } .issue-card-selected { @@ -545,3 +545,11 @@ .board-issue-path.js-show-tooltip { cursor: help; } + +.board-labels-toggle-wrapper { + /** + * Make the wrapper the same height as a button so it aligns properly when the + * filtered-search-box input element increases in size on Linux smaller breakpoints + */ + height: $input-height; +} diff --git a/app/assets/stylesheets/pages/error_details.scss b/app/assets/stylesheets/pages/error_details.scss new file mode 100644 index 0000000000000000000000000000000000000000..0515db914e9180e1c8b58529f60158131a4998a2 --- /dev/null +++ b/app/assets/stylesheets/pages/error_details.scss @@ -0,0 +1,18 @@ +.error-details { + li { + @include gl-line-height-32; + } +} + +.stacktrace { + .file-title { + svg { + vertical-align: middle; + top: -1px; + } + } + + .line_content.old::before { + content: none !important; + } +} diff --git a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss index 8b1ec1ced35f4881bdaa8c21f285abe9ce15f796..5a80ea79600176750c3dcf60f7257da1c1075c8b 100644 --- a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss +++ b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss @@ -23,6 +23,7 @@ .signup-heading h2 { font-weight: $gl-font-weight-bold; + padding: 0 $gl-padding; @include media-breakpoint-down(md) { font-size: $gl-font-size-large; diff --git a/app/assets/stylesheets/pages/graph.scss b/app/assets/stylesheets/pages/graph.scss index 3febf4cf826ee36622affd9eda02a0be9339a39c..a8de8303a19a8f5a389e6a452f3f08ba6e0c912c 100644 --- a/app/assets/stylesheets/pages/graph.scss +++ b/app/assets/stylesheets/pages/graph.scss @@ -17,21 +17,6 @@ } } -.graphs { - .graph-author-email { - float: right; - color: $gl-gray-500; - } - - .graph-additions { - color: $green-600; - } - - .graph-deletions { - color: $red-500; - } -} - .svg-graph-container { width: 100%; diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss index 00d84df1650960f905d0b1da3e402a383ab5bd98..b399662997c48587a94202147302fd9dd3789b06 100644 --- a/app/assets/stylesheets/pages/milestone.scss +++ b/app/assets/stylesheets/pages/milestone.scss @@ -30,7 +30,8 @@ $status-box-line-height: 26px; margin-bottom: $gl-padding-4; } - .milestone-progress { + .milestone-progress, + .milestone-release-links { a { color: $blue-600; } @@ -238,10 +239,6 @@ $status-box-line-height: 26px; } } -.milestone-range { - color: $gl-text-color-tertiary; -} - @include media-breakpoint-down(xs) { .milestone-banner-text, .milestone-banner-link { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 21a9f1430395106f0f457ad155dbcb113980d452..1da9f691639f8290e34b7354b314801cf2c8b254 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -310,6 +310,17 @@ $note-form-margin-left: 72px; .note-body { overflow: hidden; + .description-version { + pre { + max-height: $dropdown-max-height-lg; + white-space: pre-wrap; + + &.loading-state { + height: 94px; + } + } + } + .system-note-commit-list-toggler { color: $blue-600; padding: 10px 0 0; diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 1b2af932733e5fe9ff8792290cd332c7507f71c6..364fe3da71eca3184b8223d947575db92a738f88 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -871,7 +871,7 @@ button.mini-pipeline-graph-dropdown-toggle { height: $ci-action-dropdown-svg-size; fill: $gl-text-color-secondary; position: relative; - top: 1px; + top: auto; vertical-align: initial; } } @@ -1082,3 +1082,13 @@ button.mini-pipeline-graph-dropdown-toggle { .legend-success { color: $green-500; } + +.test-reports-table { + .build-trace { + @include build-trace(); + } +} + +.progress-bar.bg-primary { + background-color: $blue-500 !important; +} diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index b2c1d0b6dc5e26f0d3d82fddc81c4c78b2af77d9..d96cc16373831ede55890c7e53868fb369bdb061 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -252,6 +252,7 @@ .fa-caret-down { margin-left: 3px; + line-height: 0; &.dropdown-btn-icon { margin-left: 0; @@ -273,6 +274,12 @@ height: 24px; } + .git-clone-holder { + .btn { + height: auto; + } + } + .dropdown-toggle, .clone-dropdown-btn { .fa { diff --git a/app/assets/stylesheets/pages/reports.scss b/app/assets/stylesheets/pages/reports.scss index 0fbf7033aa5fac1e28c1f4deedc9735f90ccfab3..390ebd48685991199c25d7e64c523064135d2e8c 100644 --- a/app/assets/stylesheets/pages/reports.scss +++ b/app/assets/stylesheets/pages/reports.scss @@ -131,7 +131,6 @@ .modal-security-report-dast { .modal-dialog { - width: $modal-lg; max-width: $modal-lg; } diff --git a/app/assets/stylesheets/pages/stat_graph.scss b/app/assets/stylesheets/pages/stat_graph.scss deleted file mode 100644 index 31ccdacbc027b8cdbfb1bb13e7ff30d2bbf35472..0000000000000000000000000000000000000000 --- a/app/assets/stylesheets/pages/stat_graph.scss +++ /dev/null @@ -1,62 +0,0 @@ -.tint-box { - background: $stat-graph-common-bg; - position: relative; - margin-bottom: 10px; -} - -.area { - fill: $green-500; - fill-opacity: 0.5; -} - -.axis { - font-size: 10px; -} - -#contributors-master { - @include media-breakpoint-up(md) { - @include make-col-ready(); - @include make-col(12); - } -} - -#contributors { - flex: 1; - - .contributors-list { - margin: 0 0 10px; - list-style: none; - padding: 0; - } - - .person { - @include media-breakpoint-up(md) { - @include make-col-ready(); - @include make-col(6); - } - - margin-top: 10px; - - @include media-breakpoint-down(xs) { - width: 100%; - } - - .spark { - display: block; - background: $stat-graph-common-bg; - width: 100%; - } - - .area-contributor { - fill: $orange-500; - } - } -} - -.selection rect { - fill-opacity: 0.1; - stroke-width: 1px; - stroke-opacity: 0.4; - shape-rendering: crispedges; - stroke-dasharray: 3 3; -} diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss index ad7d87f0bf674ec356e1e6c766d1eaab70f73607..87e650c76599b160319ca8de50e1e6ad398ec554 100644 --- a/app/assets/stylesheets/performance_bar.scss +++ b/app/assets/stylesheets/performance_bar.scss @@ -18,6 +18,11 @@ width: 200px; } + input { + color: $gl-gray-400; + width: $input-short-width - 60px; + } + &.disabled { display: none; } @@ -25,7 +30,8 @@ &.production { background-color: $perf-bar-production; - select { + select, + input { background: $perf-bar-production; } } @@ -33,7 +39,8 @@ &.staging { background-color: $perf-bar-staging; - select { + select, + input { background: $perf-bar-staging; } } @@ -41,7 +48,8 @@ &.development { background-color: $perf-bar-development; - select { + select, + input { background: $perf-bar-development; } } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index d2906ce0780795f3a79624954252773063f5d202..3b3a2778b233dc3a2cd529fde800f2951eb7d1d2 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -16,8 +16,18 @@ } } +@each $index, $size in $size-scale { + #{'.mw-#{$index}'} { + max-width: $size; + } +} + .border-width-1px { border-width: 1px; } .border-style-dashed { border-style: dashed; } .border-style-solid { border-style: solid; } .border-color-blue-300 { border-color: $blue-300; } .border-color-default { border-color: $border-color; } +.box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; } + +.gl-w-64 { width: px-to-rem($grid-size * 8); } +.gl-h-64 { height: px-to-rem($grid-size * 8); } diff --git a/app/controllers/admin/abuse_reports_controller.rb b/app/controllers/admin/abuse_reports_controller.rb index d5537023b2684fde479d3442213b5b2b87cb5b9e..31d825c235bc177f27fc99d04c1f69673cbf2d99 100644 --- a/app/controllers/admin/abuse_reports_controller.rb +++ b/app/controllers/admin/abuse_reports_controller.rb @@ -1,12 +1,9 @@ # frozen_string_literal: true class Admin::AbuseReportsController < Admin::ApplicationController - # rubocop: disable CodeReuse/ActiveRecord def index - @abuse_reports = AbuseReport.order(id: :desc).page(params[:page]) - @abuse_reports.includes(:reporter, :user) + @abuse_reports = AbuseReportsFinder.new(params).execute end - # rubocop: enable CodeReuse/ActiveRecord def destroy abuse_report = AbuseReport.find(params[:id]) diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index 22e629ccf59a9f4ed24feff568774f17688da749..907b295870db0814e3d2b749f9a9a9ad46ba1eb7 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -44,7 +44,7 @@ class Admin::ApplicationsController < Admin::ApplicationController def destroy @application.destroy - redirect_to admin_applications_url, status: 302, notice: _('Application was successfully destroyed.') + redirect_to admin_applications_url, status: :found, notice: _('Application was successfully destroyed.') end private diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index 85a37fcd43e73996e9a8f3ff5c3ebabb1a42b91f..5455cefdc8e03f7dc00c95f39526c32027826582 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -69,7 +69,7 @@ class Admin::GroupsController < Admin::ApplicationController Groups::DestroyService.new(@group, current_user).async_execute redirect_to admin_groups_path, - status: 302, + status: :found, alert: _('Group %{group_name} was scheduled for deletion.') % { group_name: @group.name } end diff --git a/app/controllers/admin/identities_controller.rb b/app/controllers/admin/identities_controller.rb index f518f7a657f99b45195d46999ebc78bd07999899..8f2e34a6294fa816295f05c5d842e9d75042aa35 100644 --- a/app/controllers/admin/identities_controller.rb +++ b/app/controllers/admin/identities_controller.rb @@ -38,9 +38,9 @@ class Admin::IdentitiesController < Admin::ApplicationController def destroy if @identity.destroy RepairLdapBlockedUserService.new(@user).execute - redirect_to admin_user_identities_path(@user), status: 302, notice: _('User identity was successfully removed.') + redirect_to admin_user_identities_path(@user), status: :found, notice: _('User identity was successfully removed.') else - redirect_to admin_user_identities_path(@user), status: 302, alert: _('Failed to remove user identity.') + redirect_to admin_user_identities_path(@user), status: :found, alert: _('Failed to remove user identity.') end end diff --git a/app/controllers/admin/keys_controller.rb b/app/controllers/admin/keys_controller.rb index 340eecd76321650d650f9c383934508f2ae199a0..58ea19d12108cf0c2bceefb502816cc8b9adf58b 100644 --- a/app/controllers/admin/keys_controller.rb +++ b/app/controllers/admin/keys_controller.rb @@ -17,9 +17,9 @@ class Admin::KeysController < Admin::ApplicationController respond_to do |format| if key.destroy - format.html { redirect_to keys_admin_user_path(user), status: 302, notice: _('User key was successfully removed.') } + format.html { redirect_to keys_admin_user_path(user), status: :found, notice: _('User key was successfully removed.') } else - format.html { redirect_to keys_admin_user_path(user), status: 302, alert: _('Failed to remove user key.') } + format.html { redirect_to keys_admin_user_path(user), status: :found, alert: _('Failed to remove user key.') } end end end diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb index 90c1694fd2ead82ca7080df036fd983e8a05273e..6cb206c1686aefacece971fda5fcf398769900a2 100644 --- a/app/controllers/admin/labels_controller.rb +++ b/app/controllers/admin/labels_controller.rb @@ -43,7 +43,7 @@ class Admin::LabelsController < Admin::ApplicationController respond_to do |format| format.html do - redirect_to admin_labels_path, status: 302, notice: _('Label was removed') + redirect_to admin_labels_path, status: :found, notice: _('Label was removed') end format.js end diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb index 0e8c69eb7d6f7e73cabc1b424fa0067589b61846..cdedc34e63452fea6062dd8e7e558712382fa14a 100644 --- a/app/controllers/admin/projects_controller.rb +++ b/app/controllers/admin/projects_controller.rb @@ -41,7 +41,7 @@ class Admin::ProjectsController < Admin::ApplicationController redirect_to admin_projects_path, status: :found rescue Projects::DestroyService::DestroyError => ex - redirect_to admin_projects_path, status: 302, alert: ex.message + redirect_to admin_projects_path, status: :found, alert: ex.message end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index 45cf0d3207e8cd9d2ec9cc86947c0f8eb4249844..a41d8a2265051efcc8258941a749a0c9f8244449 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -13,7 +13,7 @@ class Admin::SpamLogsController < Admin::ApplicationController if params[:remove_user] spam_log.remove_user(deleted_by: current_user) redirect_to admin_spam_logs_path, - status: 302, + status: :found, notice: _('User %{username} was successfully removed.') % { username: spam_log.user.username } else spam_log.destroy diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 4c1ac8f206a0d2c3167434d4b462670058887981..9fbfc59f630e790a42070c8f6d2ca2d62bc6e10c 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -169,7 +169,7 @@ class Admin::UsersController < Admin::ApplicationController user.delete_async(deleted_by: current_user, params: params.permit(:hard_delete)) respond_to do |format| - format.html { redirect_to admin_users_path, status: 302, notice: _("The user is being deleted.") } + format.html { redirect_to admin_users_path, status: :found, notice: _("The user is being deleted.") } format.json { head :ok } end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 27e88ae569e9feeeb3014f009a285a86c5d704ef..25c1d80b11717f064578c3eac9841ea0d4527864 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base include EnforcesTwoFactorAuthentication include WithPerformanceBar include SessionlessAuthentication + include SessionsHelper include ConfirmEmailWarning include Gitlab::Tracking::ControllerConcern include Gitlab::Experimentation::ControllerConcern @@ -29,13 +30,13 @@ class ApplicationController < ActionController::Base before_action :active_user_check, unless: :devise_controller? before_action :set_usage_stats_consent_flag before_action :check_impersonation_availability - before_action :require_role + before_action :required_signup_info around_action :set_locale around_action :set_session_storage after_action :set_page_title_header, if: :json_request? - after_action :limit_unauthenticated_session_times + after_action :limit_session_time, if: -> { !current_user } protect_from_forgery with: :exception, prepend: true @@ -57,7 +58,7 @@ class ApplicationController < ActionController::Base rescue_from Encoding::CompatibilityError do |exception| log_exception(exception) - render "errors/encoding", layout: "errors", status: 500 + render "errors/encoding", layout: "errors", status: :internal_server_error end rescue_from ActiveRecord::RecordNotFound do |exception| @@ -103,24 +104,6 @@ class ApplicationController < ActionController::Base end end - # By default, all sessions are given the same expiration time configured in - # the session store (e.g. 1 week). However, unauthenticated users can - # generate a lot of sessions, primarily for CSRF verification. It makes - # sense to reduce the TTL for unauthenticated to something much lower than - # the default (e.g. 1 hour) to limit Redis memory. In addition, Rails - # creates a new session after login, so the short TTL doesn't even need to - # be extended. - def limit_unauthenticated_session_times - return if current_user - - # Rack sets this header, but not all tests may have it: https://github.com/rack/rack/blob/fdcd03a3c5a1c51d1f96fc97f9dfa1a9deac0c77/lib/rack/session/abstract/id.rb#L251-L259 - return unless request.env['rack.session.options'] - - # This works because Rack uses these options every time a request is handled: - # https://github.com/rack/rack/blob/fdcd03a3c5a1c51d1f96fc97f9dfa1a9deac0c77/lib/rack/session/abstract/id.rb#L342 - request.env['rack.session.options'][:expire_after] = Settings.gitlab['unauthenticated_session_expire_delay'] - end - def render(*args) super.tap do # Set a header for custom error pages to prevent them from being intercepted by gitlab-workhorse @@ -214,25 +197,29 @@ class ApplicationController < ActionController::Base end def git_not_found! - render "errors/git_not_found.html", layout: "errors", status: 404 + render "errors/git_not_found.html", layout: "errors", status: :not_found end def render_403 respond_to do |format| format.any { head :forbidden } - format.html { render "errors/access_denied", layout: "errors", status: 403 } + format.html { render "errors/access_denied", layout: "errors", status: :forbidden } end end def render_404 respond_to do |format| - format.html { render "errors/not_found", layout: "errors", status: 404 } + format.html { render "errors/not_found", layout: "errors", status: :not_found } # Prevent the Rails CSRF protector from thinking a missing .js file is a JavaScript file format.js { render json: '', status: :not_found, content_type: 'application/json' } format.any { head :not_found } end end + def respond_201 + head :created + end + def respond_422 head :unprocessable_entity end @@ -551,10 +538,13 @@ class ApplicationController < ActionController::Base @current_user_mode ||= Gitlab::Auth::CurrentUserMode.new(current_user) end - # A user requires a role when they are part of the experimental signup flow (executed by the Growth team). Users - # are redirected to the welcome page when their role is required and the experiment is enabled for the current user. - def require_role - return unless current_user && current_user.role_required? && experiment_enabled?(:signup_flow) + # A user requires a role and have the setup_for_company attribute set when they are part of the experimental signup + # flow (executed by the Growth team). Users are redirected to the welcome page when their role is required and the + # experiment is enabled for the current user. + def required_signup_info + return unless current_user + return unless current_user.role_required? + return unless experiment_enabled?(:signup_flow) store_location_for :user, request.fullpath diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb index 9894dd7d18016e2c8dfd144db2b1a9ea2a6909b9..1298b33471bb775861112bc7a35dc5c5b2544b7f 100644 --- a/app/controllers/boards/issues_controller.rb +++ b/app/controllers/boards/issues_controller.rb @@ -13,7 +13,7 @@ module Boards requires_cross_project_access if: -> { board&.group_board? } - before_action :whitelist_query_limiting, only: [:index, :update, :bulk_move] + before_action :whitelist_query_limiting, only: [:bulk_move] before_action :authorize_read_issue, only: [:index] before_action :authorize_create_issue, only: [:create] before_action :authorize_update_issue, only: [:update] @@ -130,8 +130,7 @@ module Boards end def whitelist_query_limiting - # Also see https://gitlab.com/gitlab-org/gitlab-foss/issues/42439 - Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42428') + Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/issues/35174') end def validate_id_list diff --git a/app/controllers/clusters/applications_controller.rb b/app/controllers/clusters/applications_controller.rb index 16c2365f85d78bc39475884a8464d0e74e01e745..be68d0d0a1dbc00889a484d3fa3c732e5bba3343 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, :email) + params.permit(:application, :hostname, :kibana_hostname, :email, :stack) end def cluster_application_destroy_params diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index 993aba661f3f83b8c2647b795332c97da1ab00b4..9a539cf7c2426be1b185ca597d275081e381f6e4 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -3,18 +3,22 @@ class Clusters::ClustersController < Clusters::BaseController include RoutableActions - before_action :cluster, except: [:index, :new, :create_gcp, :create_user] + before_action :cluster, only: [:cluster_status, :show, :update, :destroy] before_action :generate_gcp_authorize_url, only: [:new] before_action :validate_gcp_token, only: [:new] before_action :gcp_cluster, only: [:new] before_action :user_cluster, only: [:new] - before_action :authorize_create_cluster!, only: [:new] + before_action :authorize_create_cluster!, only: [:new, :authorize_aws_role, :revoke_aws_role, :aws_proxy] before_action :authorize_update_cluster!, only: [:update] before_action :authorize_admin_cluster!, only: [:destroy] before_action :update_applications_status, only: [:cluster_status] before_action only: [:new, :create_gcp] do push_frontend_feature_flag(:create_eks_clusters) end + 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 @@ -40,10 +44,13 @@ class Clusters::ClustersController < Clusters::BaseController def new return unless Feature.enabled?(:create_eks_clusters) - @gke_selected = params[:provider] == 'gke' - @eks_selected = params[:provider] == 'eks' + if params[:provider] == 'aws' + @aws_role = current_user.aws_role || Aws::Role.new + @aws_role.ensure_role_external_id! - return redirect_to @authorize_url if @gke_selected && @authorize_url && !@valid_gcp_token + elsif params[:provider] == 'gcp' + redirect_to @authorize_url if @authorize_url && !@valid_gcp_token + end end # Overridding ActionController::Metal#status is NOT a good idea @@ -86,13 +93,12 @@ class Clusters::ClustersController < Clusters::BaseController end def destroy - if cluster.destroy - flash[:notice] = _('Kubernetes cluster integration was successfully removed.') - redirect_to clusterable.index_path, status: :found - else - flash[:notice] = _('Kubernetes cluster integration was not removed.') - render :show - end + response = Clusters::DestroyService + .new(current_user, destroy_params) + .execute(cluster) + + flash[:notice] = response[:message] + redirect_to clusterable.index_path, status: :found end def create_gcp @@ -112,6 +118,19 @@ class Clusters::ClustersController < Clusters::BaseController end end + def create_aws + @aws_cluster = ::Clusters::CreateService + .new(current_user, create_aws_cluster_params) + .execute + .present(current_user: current_user) + + if @aws_cluster.persisted? + head :created, location: @aws_cluster.show_path + else + render status: :unprocessable_entity, json: @aws_cluster.errors + end + end + def create_user @user_cluster = ::Clusters::CreateService .new(current_user, create_user_cluster_params) @@ -129,8 +148,37 @@ class Clusters::ClustersController < Clusters::BaseController end end + def authorize_aws_role + role = current_user.build_aws_role(create_role_params) + + role.save ? respond_201 : respond_422 + end + + def revoke_aws_role + current_user.aws_role&.destroy + + head :no_content + end + + def aws_proxy + response = Clusters::Aws::ProxyService.new( + current_user.aws_role, + params: params + ).execute + + render json: response.body, status: response.status + end + private + def destroy_params + # To be uncomented on https://gitlab.com/gitlab-org/gitlab/merge_requests/16954 + # This MR got split into other since it was too big. + # + # params.permit(:cleanup) + {} + end + def update_params if cluster.provided_by_user? params.require(:cluster).permit( @@ -139,6 +187,7 @@ class Clusters::ClustersController < Clusters::BaseController :environment_scope, :managed, :base_domain, + :management_project_id, platform_kubernetes_attributes: [ :api_url, :token, @@ -152,6 +201,7 @@ class Clusters::ClustersController < Clusters::BaseController :environment_scope, :managed, :base_domain, + :management_project_id, platform_kubernetes_attributes: [ :namespace ] @@ -179,6 +229,28 @@ class Clusters::ClustersController < Clusters::BaseController ) end + def create_aws_cluster_params + params.require(:cluster).permit( + :enabled, + :name, + :environment_scope, + :managed, + provider_aws_attributes: [ + :key_name, + :role_arn, + :region, + :vpc_id, + :instance_type, + :num_nodes, + :security_group_id, + subnet_ids: [] + ]).merge( + provider_type: :aws, + platform_type: :kubernetes, + clusterable: clusterable.subject + ) + end + def create_user_cluster_params params.require(:cluster).permit( :enabled, @@ -198,6 +270,10 @@ class Clusters::ClustersController < Clusters::BaseController ) end + def create_role_params + params.require(:cluster).permit(:role_arn, :role_external_id) + end + def generate_gcp_authorize_url params = Feature.enabled?(:create_eks_clusters) ? { provider: :gke } : {} state = generate_session_key_redirect(clusterable.new_path(params).to_s) diff --git a/app/controllers/concerns/confirm_email_warning.rb b/app/controllers/concerns/confirm_email_warning.rb index 5a4b5897a4fa4bf07366db724d3ec32af9766148..86df0010665367e70f473ce63d48efd1bc4c82d7 100644 --- a/app/controllers/concerns/confirm_email_warning.rb +++ b/app/controllers/concerns/confirm_email_warning.rb @@ -16,7 +16,7 @@ module ConfirmEmailWarning email = current_user.unconfirmed_email || current_user.email - flash.now[:warning] = _("Please check your email (%{email}) to verify that you own this address. Didn't receive it? %{resend_link}. Wrong email address? %{update_link}.").html_safe % { + flash.now[:warning] = _("Please check your email (%{email}) to verify that you own this address and unlock the power of CI/CD. Didn't receive it? %{resend_link}. Wrong email address? %{update_link}.").html_safe % { email: email, resend_link: view_context.link_to(_('Resend it'), user_confirmation_path(user: { email: email }), method: :post), update_link: view_context.link_to(_('Update it'), profile_path) diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index c9a8de0b290a87c462ed9363d1264c6775aa6ada..5aa00af8910aa63fc7d4ee866dc359278f43e406 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -148,7 +148,7 @@ module IssuableCollections when 'Issue' common_attributes + [:project, project: :namespace] when 'MergeRequest' - common_attributes + [:target_project, source_project: :route, head_pipeline: :project, target_project: :namespace, latest_merge_request_diff: :merge_request_diff_commits] + common_attributes + [:target_project, :latest_merge_request_diff, source_project: :route, head_pipeline: :project, target_project: :namespace] end end # rubocop:enable Gitlab/ModuleWithInstanceVariables diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb index 417bb169f3935397d780e64ad8896d8776f9bc02..61072eec5359d6b90818c9f90d2e96f01129e2d1 100644 --- a/app/controllers/concerns/lfs_request.rb +++ b/app/controllers/concerns/lfs_request.rb @@ -56,7 +56,7 @@ module LfsRequest documentation_url: help_url }, content_type: CONTENT_TYPE, - status: 403 + status: :forbidden ) end @@ -67,7 +67,7 @@ module LfsRequest documentation_url: help_url }, content_type: CONTENT_TYPE, - status: 404 + status: :not_found ) end diff --git a/app/controllers/concerns/metrics_dashboard.rb b/app/controllers/concerns/metrics_dashboard.rb index 62efdacb71054cd4c2580f3933f9a3cdd3968c7f..dc392147cb8e6b85fa5998c842969a8722e3ea44 100644 --- a/app/controllers/concerns/metrics_dashboard.rb +++ b/app/controllers/concerns/metrics_dashboard.rb @@ -3,21 +3,26 @@ # Provides an action which fetches a metrics dashboard according # to the parameters specified by the controller. module MetricsDashboard + include RenderServiceResults + include ChecksCollaboration + extend ActiveSupport::Concern def metrics_dashboard result = dashboard_finder.find( project_for_dashboard, current_user, - metrics_dashboard_params + metrics_dashboard_params.to_h.symbolize_keys ) - if include_all_dashboards? - result[:all_dashboards] = dashboard_finder.find_all_paths(project_for_dashboard) + if include_all_dashboards? && result + result[:all_dashboards] = all_dashboards end respond_to do |format| - if result[:status] == :success + if result.nil? + format.json { continue_polling_response } + elsif result[:status] == :success format.json { render dashboard_success_response(result) } else format.json { render dashboard_error_response(result) } @@ -27,6 +32,30 @@ module MetricsDashboard private + def all_dashboards + dashboards = dashboard_finder.find_all_paths(project_for_dashboard) + dashboards.map do |dashboard| + amend_dashboard(dashboard) + end + end + + def amend_dashboard(dashboard) + project_dashboard = project_for_dashboard && !dashboard[:system_dashboard] + + dashboard[:can_edit] = project_dashboard ? can_edit?(dashboard) : false + dashboard[:project_blob_path] = project_dashboard ? dashboard_project_blob_path(dashboard) : nil + + dashboard + end + + def dashboard_project_blob_path(dashboard) + project_blob_path(project_for_dashboard, File.join(project_for_dashboard.default_branch, dashboard.fetch(:path, ""))) + end + + def can_edit?(dashboard) + can_collaborate_with_project?(project_for_dashboard, ref: project_for_dashboard.default_branch) + end + # Override in class to provide arguments to the finder. def metrics_dashboard_params {} @@ -56,7 +85,7 @@ module MetricsDashboard def dashboard_error_response(result) { - status: result[:http_status], + status: result[:http_status] || :bad_request, json: result.slice(:all_dashboards, :message, :status) } end diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb index 672d31ec779c1ae54e84c32db0250c52f51989c4..dbc575a14879a6c2f993ea01b5b2768213b7c7f7 100644 --- a/app/controllers/concerns/milestone_actions.rb +++ b/app/controllers/concerns/milestone_actions.rb @@ -53,12 +53,10 @@ module MilestoneActions # rubocop:disable Gitlab/ModuleWithInstanceVariables def milestone_redirect_path - if @project - project_milestone_path(@project, @milestone) - elsif @group - group_milestone_path(@group, @milestone.safe_title, title: @milestone.title) + if @milestone.global_milestone? + url_for(action: :show, title: @milestone.title) else - dashboard_milestone_path(@milestone.safe_title, title: @milestone.title) + url_for(action: :show) end end # rubocop:enable Gitlab/ModuleWithInstanceVariables diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index 2a9729b6ffd7d9e7ca3366db08e9d070dada5d34..c7c9f2e9b7013f37d215843efe4513f3eedb4f87 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -5,19 +5,10 @@ module PreviewMarkdown # rubocop:disable Gitlab/ModuleWithInstanceVariables def preview_markdown - result = PreviewMarkdownService.new(@project, current_user, params).execute - - markdown_params = - case controller_name - when 'wikis' then { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] } - when 'snippets' then { skip_project_check: true } - when 'groups' then { group: group } - when 'projects' then projects_filter_params - else {} - end + result = PreviewMarkdownService.new(@project, current_user, markdown_service_params).execute render json: { - body: view_context.markdown(result[:text], markdown_params), + body: view_context.markdown(result[:text], markdown_context_params), references: { users: result[:users], suggestions: SuggestionSerializer.new.represent_diff(result[:suggestions]), @@ -26,11 +17,28 @@ module PreviewMarkdown } end + private + def projects_filter_params { issuable_state_filter_enabled: true, suggestions_filter_enabled: params[:preview_suggestions].present? } end + + def markdown_service_params + params + end + + def markdown_context_params + case controller_name + when 'wikis' then { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] } + when 'snippets' then { skip_project_check: true } + when 'groups' then { group: group } + when 'projects' then projects_filter_params + else {} + end.merge(requested_path: params[:path]) + end + # rubocop:enable Gitlab/ModuleWithInstanceVariables end diff --git a/app/controllers/concerns/redirects_for_missing_path_on_tree.rb b/app/controllers/concerns/redirects_for_missing_path_on_tree.rb new file mode 100644 index 0000000000000000000000000000000000000000..085afbf3975cf74d07f3f543967345544637d707 --- /dev/null +++ b/app/controllers/concerns/redirects_for_missing_path_on_tree.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module RedirectsForMissingPathOnTree + def redirect_to_tree_root_for_missing_path(project, ref, path) + redirect_to project_tree_path(project, ref), notice: missing_path_on_ref(path, ref) + end + + private + + def missing_path_on_ref(path, ref) + _('"%{path}" did not exist on "%{ref}"') % { path: truncate_path(path), ref: ref } + end + + def truncate_path(path) + path.reverse.truncate(60, separator: "/").reverse + end +end diff --git a/app/controllers/concerns/renders_commits.rb b/app/controllers/concerns/renders_commits.rb index ed9b898a2a3fa94fc772fc79b1ba686817066fb0..826fae834fa9e5a535ee4de07fec39cee4590b3d 100644 --- a/app/controllers/concerns/renders_commits.rb +++ b/app/controllers/concerns/renders_commits.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true module RendersCommits - def limited_commits(commits) - if commits.size > MergeRequestDiff::COMMITS_SAFE_SIZE + def limited_commits(commits, commits_count) + if commits_count > MergeRequestDiff::COMMITS_SAFE_SIZE [ commits.first(MergeRequestDiff::COMMITS_SAFE_SIZE), - commits.size - MergeRequestDiff::COMMITS_SAFE_SIZE + commits_count - MergeRequestDiff::COMMITS_SAFE_SIZE ] else [commits, 0] @@ -14,9 +14,10 @@ module RendersCommits # This is used as a helper method in a controller. # rubocop: disable Gitlab/ModuleWithInstanceVariables - def set_commits_for_rendering(commits) - @total_commit_count = commits.size - limited, @hidden_commit_count = limited_commits(commits) + def set_commits_for_rendering(commits, commits_count: nil) + @total_commit_count = commits_count || commits.size + limited, @hidden_commit_count = limited_commits(commits, @total_commit_count) + commits.each(&:lazy_author) # preload authors prepare_commits_for_rendering(limited) end # rubocop: enable Gitlab/ModuleWithInstanceVariables diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb index 45f9888a0405cff40e6ed6ec73993b86c7b38081..1b2e6461dee4c7fd09bb5f1f33e2597026fa676c 100644 --- a/app/controllers/concerns/routable_actions.rb +++ b/app/controllers/concerns/routable_actions.rb @@ -47,7 +47,7 @@ module RoutableActions canonical_path = routable.full_path if canonical_path != requested_full_path - if canonical_path.casecmp(requested_full_path) != 0 + if !request.xhr? && request.format.html? && canonical_path.casecmp(requested_full_path) != 0 flash[:notice] = "#{routable.class.to_s.titleize} '#{requested_full_path}' was moved to '#{canonical_path}'. Please update any links and bookmarks that may still have the old path." end diff --git a/app/controllers/concerns/sourcegraph_gon.rb b/app/controllers/concerns/sourcegraph_gon.rb new file mode 100644 index 0000000000000000000000000000000000000000..ab4abd734fb9f9394fb023a0800b9f90adfbded0 --- /dev/null +++ b/app/controllers/concerns/sourcegraph_gon.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module SourcegraphGon + extend ActiveSupport::Concern + + included do + before_action :push_sourcegraph_gon, unless: :json_request? + end + + private + + def push_sourcegraph_gon + return unless sourcegraph_enabled? + + gon.push({ + sourcegraph: { url: Gitlab::CurrentSettings.sourcegraph_url } + }) + end + + def sourcegraph_enabled? + Gitlab::CurrentSettings.sourcegraph_enabled && sourcegraph_enabled_for_project? && current_user&.sourcegraph_enabled + end + + def sourcegraph_enabled_for_project? + return false unless project && Gitlab::Sourcegraph.feature_enabled?(project) + return project.public? if Gitlab::CurrentSettings.sourcegraph_public_only + + true + end +end diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 80c0a0d88a8a4781e765cafe55501a68c928bbff..ebee8e9094edba37fe320a598a122e9391c05449 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -22,7 +22,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController respond_to do |format| format.html do redirect_to dashboard_todos_path, - status: 302, + status: :found, notice: _('To-do item successfully marked as done.') end format.js { head :ok } @@ -34,7 +34,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController updated_ids = TodoService.new.mark_todos_as_done(@todos, current_user) respond_to do |format| - format.html { redirect_to dashboard_todos_path, status: 302, notice: _('Everything on your to-do list is marked as done.') } + format.html { redirect_to dashboard_todos_path, status: :found, notice: _('Everything on your to-do list is marked as done.') } format.js { head :ok } format.json { render json: todos_counts.merge(updated_ids: updated_ids) } end diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index 3c86f3108ab45be825b60b924914ef574ab4c66f..8c9bf17f0174a5fe9c532e67ee27d5208a472443 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -6,7 +6,7 @@ class Groups::BoardsController < Groups::ApplicationController before_action :assign_endpoint_vars before_action do - push_frontend_feature_flag(:multi_select_board) + push_frontend_feature_flag(:multi_select_board, default_enabled: true) end private diff --git a/app/controllers/groups/group_links_controller.rb b/app/controllers/groups/group_links_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..7965311c5f1d7a66912bfe86242b6774db4fced6 --- /dev/null +++ b/app/controllers/groups/group_links_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class Groups::GroupLinksController < Groups::ApplicationController + before_action :check_feature_flag! + before_action :authorize_admin_group! + + def create + shared_with_group = Group.find(params[:shared_with_group_id]) if params[:shared_with_group_id].present? + + if shared_with_group + result = Groups::GroupLinks::CreateService + .new(shared_with_group, current_user, group_link_create_params) + .execute(group) + + return render_404 if result[:http_status] == 404 + + flash[:alert] = result[:message] if result[:status] == :error + else + flash[:alert] = _('Please select a group.') + end + + redirect_to group_group_members_path(group) + end + + private + + def group_link_create_params + params.permit(:shared_group_access, :expires_at) + end + + def check_feature_flag! + render_404 unless Feature.enabled?(:share_group_with_group) + end +end diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb index 26768c628ca1dd9e51e977d517fd5abeb42ae935..1034ca6cd7b4f835844fd6ba22c2fc25212f7c04 100644 --- a/app/controllers/groups/labels_controller.rb +++ b/app/controllers/groups/labels_controller.rb @@ -63,7 +63,7 @@ class Groups::LabelsController < Groups::ApplicationController respond_to do |format| format.html do - redirect_to group_labels_path(@group), status: 302, notice: "#{@label.name} deleted permanently" + redirect_to group_labels_path(@group), status: :found, notice: "#{@label.name} deleted permanently" end format.js end diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb index e09a9e6eb21f120526688ddba776439047065356..cfddd8a3ba93b4132442a1d5fd44fb2fc8bbcff7 100644 --- a/app/controllers/groups/registry/repositories_controller.rb +++ b/app/controllers/groups/registry/repositories_controller.rb @@ -16,7 +16,7 @@ module Groups render json: ContainerRepositoriesSerializer .new(current_user: current_user) - .represent(@images) + .represent_read_only(@images) end end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 35e364abba35b0b7042629ae309e45074a942605..755d97b091c867e99885861ab5d76bfe0e3b1d4b 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -6,6 +6,7 @@ class GroupsController < Groups::ApplicationController include ParamsBackwardCompatibility include PreviewMarkdown include RecordUserLastActivity + extend ::Gitlab::Utils::Override respond_to :html @@ -24,6 +25,10 @@ class GroupsController < Groups::ApplicationController before_action :user_actions, only: [:show] + before_action do + push_frontend_feature_flag(:vue_issuables_list, @group) + end + skip_cross_project_access_check :index, :new, :create, :edit, :update, :destroy, :projects # When loading show as an atom feed, we render events that could leak cross @@ -111,7 +116,7 @@ class GroupsController < Groups::ApplicationController def destroy Groups::DestroyService.new(@group, current_user).async_execute - redirect_to root_path, status: 302, alert: "Group '#{@group.name}' was scheduled for deletion." + redirect_to root_path, status: :found, alert: "Group '#{@group.name}' was scheduled for deletion." end # rubocop: disable CodeReuse/ActiveRecord @@ -233,6 +238,11 @@ class GroupsController < Groups::ApplicationController @group.self_and_descendants.public_or_visible_to_user(current_user) end end + + override :markdown_service_params + def markdown_service_params + params.merge(group: group) + end end GroupsController.prepend_if_ee('EE::GroupsController') diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index efd5f0fc6079fa5cf750deeecc377fb116eec2a7..c6a0225089669ff90b082f84fc1789c47e7b2ae1 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -5,6 +5,11 @@ class HealthController < ActionController::Base include RequiresWhitelistedMonitoringClient CHECKS = [ + Gitlab::HealthChecks::MasterCheck + ].freeze + + ALL_CHECKS = [ + *CHECKS, Gitlab::HealthChecks::DbCheck, Gitlab::HealthChecks::Redis::RedisCheck, Gitlab::HealthChecks::Redis::CacheCheck, @@ -14,8 +19,9 @@ class HealthController < ActionController::Base ].freeze def readiness - # readiness check is a collection with all above application-level checks - render_checks(*CHECKS) + # readiness check is a collection of application-level checks + # and optionally all service checks + render_checks(params[:all] ? ALL_CHECKS : CHECKS) end def liveness @@ -25,7 +31,7 @@ class HealthController < ActionController::Base private - def render_checks(*checks) + def render_checks(checks = []) result = Gitlab::HealthChecks::Probes::Collection .new(*checks) .execute diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index a58235790adcf170afbb74a771c43872c4d9ef79..97895d6461ce7acc134aad314b7234d8052e4f30 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -36,7 +36,7 @@ class HelpController < ApplicationController render 'show.html.haml' else # Force template to Haml - render 'errors/not_found.html.haml', layout: 'errors', status: 404 + render 'errors/not_found.html.haml', layout: 'errors', status: :not_found end end diff --git a/app/controllers/ldap/omniauth_callbacks_controller.rb b/app/controllers/ldap/omniauth_callbacks_controller.rb index 4d8875937eb00ecf8d57500f4d4fdd9ff6b57cd3..71a88bf3395fc64ffaa7cd1fabfebc226c454e8e 100644 --- a/app/controllers/ldap/omniauth_callbacks_controller.rb +++ b/app/controllers/ldap/omniauth_callbacks_controller.rb @@ -4,7 +4,7 @@ class Ldap::OmniauthCallbacksController < OmniauthCallbacksController extend ::Gitlab::Utils::Override def self.define_providers! - return unless Gitlab::Auth::LDAP::Config.enabled? + return unless Gitlab::Auth::LDAP::Config.sign_in_enabled? Gitlab::Auth::LDAP::Config.available_servers.each do |server| alias_method server['provider_name'], :ldap @@ -14,6 +14,8 @@ class Ldap::OmniauthCallbacksController < OmniauthCallbacksController # We only find ourselves here # if the authentication to LDAP was successful. def ldap + return unless Gitlab::Auth::LDAP::Config.sign_in_enabled? + sign_in_user_flow(Gitlab::Auth::LDAP::User) end diff --git a/app/controllers/notification_settings_controller.rb b/app/controllers/notification_settings_controller.rb index c97fec0a6ee36523af344cb90f7dc0362246432a..e5d4a4bb07335e5656eb56a97a526a664b4b3f20 100644 --- a/app/controllers/notification_settings_controller.rb +++ b/app/controllers/notification_settings_controller.rb @@ -16,12 +16,7 @@ class NotificationSettingsController < ApplicationController @notification_setting = current_user.notification_settings.find(params[:id]) @saved = @notification_setting.update(notification_setting_params_for(@notification_setting.source)) - if params[:hide_label].present? - btn_class = params[:project_id].present? ? 'btn-xs' : '' - render_response("shared/notifications/_new_button", btn_class) - else - render_response - end + render_response end private @@ -42,7 +37,16 @@ class NotificationSettingsController < ApplicationController can?(current_user, ability_name, resource) end - def render_response(response_template = "shared/notifications/_button", btn_class = nil) + def render_response + btn_class = nil + + if params[:hide_label].present? + btn_class = 'btn-xs' if params[:project_id].present? + response_template = 'shared/notifications/_new_button' + else + response_template = 'shared/notifications/_button' + end + render json: { html: view_to_html_string(response_template, notification_setting: @notification_setting, btn_class: btn_class), saved: @saved diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index 12dc2d1af1ceb7f84364e88a9246e4caf879e197..8dd51ce1d64262c93c6c5d10d02aadbfef127509 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -57,7 +57,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController end rescue_from ActiveRecord::RecordNotFound do |exception| - render "errors/not_found", layout: "errors", status: 404 + render "errors/not_found", layout: "errors", status: :not_found end def create_application_params diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb index a59ade559b3f647af06d3ad6d59beb8cb78a3ada..9cfa57c53a5a0193481804fed053639808a5eab9 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -13,7 +13,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio end redirect_to applications_profile_url, - status: 302, + status: :found, notice: I18n.t(:notice, scope: [:doorkeeper, :flash, :authorized_applications, :destroy]) end end diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index b992972dfb85bef6e7bfd38848c255f2943b090d..eca58748cc589923390e95a054751e0c2c468538 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -47,7 +47,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController def omniauth_error @provider = params[:provider] @error = params[:error] - render 'errors/omniauth_error', layout: "oauth_error", status: 422 + render 'errors/omniauth_error', layout: "oauth_error", status: :unprocessable_entity end def cas3 diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 42d4d785174396c7e13b391ef450e582d860d26b..214640a5295f782e932f8629aab83c07411c0c86 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -47,7 +47,8 @@ class Profiles::PreferencesController < Profiles::ApplicationController :preferred_language, :time_display_relative, :time_format_in_24h, - :show_whitespace_in_diffs + :show_whitespace_in_diffs, + :sourcegraph_enabled ] end end diff --git a/app/controllers/profiles/u2f_registrations_controller.rb b/app/controllers/profiles/u2f_registrations_controller.rb index 866c4dee6e20c1caaa8598175e4cf97bc407c492..84ce4a56e645d78291bd1209b206865e0ae07c38 100644 --- a/app/controllers/profiles/u2f_registrations_controller.rb +++ b/app/controllers/profiles/u2f_registrations_controller.rb @@ -4,6 +4,6 @@ class Profiles::U2fRegistrationsController < Profiles::ApplicationController def destroy u2f_registration = current_user.u2f_registrations.find(params[:id]) u2f_registration.destroy - redirect_to profile_two_factor_auth_path, status: 302, notice: _("Successfully deleted U2F device.") + redirect_to profile_two_factor_auth_path, status: :found, notice: _("Successfully deleted U2F device.") end end diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb index 9076bdb9f0440ec9b154d59c73551bcd21050b82..92655d593dde7e29ec28ba055f632ecef077cf58 100644 --- a/app/controllers/projects/blame_controller.rb +++ b/app/controllers/projects/blame_controller.rb @@ -3,6 +3,7 @@ # Controller for viewing a file's blame class Projects::BlameController < Projects::ApplicationController include ExtractsPath + include RedirectsForMissingPathOnTree before_action :require_non_empty_project before_action :assign_ref_vars @@ -11,7 +12,9 @@ class Projects::BlameController < Projects::ApplicationController def show @blob = @repository.blob_at(@commit.id, @path) - return render_404 unless @blob + unless @blob + return redirect_to_tree_root_for_missing_path(@project, @ref, @path) + end environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 7c3d43fb49a8c6ec8356426204b3f755a47ed2c9..7c97f771a70e0d96969bb53e1328fec0ce7c04b8 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -7,6 +7,9 @@ class Projects::BlobController < Projects::ApplicationController include RendersBlob include NotesHelper include ActionView::Helpers::SanitizeHelper + include RedirectsForMissingPathOnTree + include SourcegraphGon + prepend_before_action :authenticate_user!, only: [:edit] around_action :allow_gitaly_ref_name_caching, only: [:show] @@ -119,7 +122,7 @@ class Projects::BlobController < Projects::ApplicationController end end - return render_404 + return redirect_to_tree_root_for_missing_path(@project, @ref, @path) end end diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb index 3b335fa4af4bd5915084079d251685d1116ef9e2..db05da0bb7f9ac2b3b0532a55abf7c76c5735073 100644 --- a/app/controllers/projects/boards_controller.rb +++ b/app/controllers/projects/boards_controller.rb @@ -8,7 +8,7 @@ class Projects::BoardsController < Projects::ApplicationController before_action :authorize_read_board!, only: [:index, :show] before_action :assign_endpoint_vars before_action do - push_frontend_feature_flag(:multi_select_board) + push_frontend_feature_flag(:multi_select_board, default_enabled: true) end private diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 939a09d4fd27a376d785ed7caff5f030b6ff995d..afb670b687bc00d3f4a9ff803482e8372bb1facd 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -8,6 +8,7 @@ class Projects::CommitController < Projects::ApplicationController include CreatesCommit include DiffForPath include DiffHelper + include SourcegraphGon # Authorize before_action :require_non_empty_project diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index c053ca19a94c6478d353df052400bd6ebca27440..4562296cea041e0fd968d837bf21ee1826e60569 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -13,8 +13,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :expire_etag_cache, only: [:index] before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do - push_frontend_feature_flag(:environment_metrics_use_prometheus_endpoint, default_enabled: true) - push_frontend_feature_flag(:environment_metrics_additional_panel_types) push_frontend_feature_flag(:prometheus_computed_alerts) end @@ -133,7 +131,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController if environment redirect_to environment_metrics_path(environment) else - render :empty + render :empty_metrics end end @@ -199,8 +197,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController def metrics_dashboard_params params - .permit(:embedded, :group, :title, :y_label) - .to_h.symbolize_keys + .permit(:embedded, :group, :title, :y_label, :dashboard_path, :environment) .merge(dashboard_path: params[:dashboard], environment: environment) end diff --git a/app/controllers/projects/error_tracking_controller.rb b/app/controllers/projects/error_tracking_controller.rb index 88d0755f41f5cc513296eb29c1ad92e46c04317a..9dea6b663ea95c933258e9dacf124f15f43a443a 100644 --- a/app/controllers/projects/error_tracking_controller.rb +++ b/app/controllers/projects/error_tracking_controller.rb @@ -15,6 +15,23 @@ class Projects::ErrorTrackingController < Projects::ApplicationController end end + def details + respond_to do |format| + format.html + format.json do + 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 list_projects respond_to do |format| format.json do @@ -29,10 +46,7 @@ class Projects::ErrorTrackingController < Projects::ApplicationController service = ErrorTracking::ListIssuesService.new(project, current_user) result = service.execute - unless result[:status] == :success - return render json: { message: result[:message] }, - status: result[:http_status] || :bad_request - end + return if handle_errors(result) render json: { errors: serialize_errors(result[:issues]), @@ -40,6 +54,28 @@ class Projects::ErrorTrackingController < Projects::ApplicationController } end + def render_issue_detail_json + service = ErrorTracking::IssueDetailsService.new(project, current_user, issue_details_params) + result = service.execute + + return if handle_errors(result) + + render json: { + error: serialize_detailed_error(result[:issue]) + } + 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) + + render json: { + error: serialize_error_event(result[:latest_event]) + } + end + def render_project_list_json service = ErrorTracking::ListProjectsService.new( project, @@ -62,10 +98,21 @@ class Projects::ErrorTrackingController < Projects::ApplicationController end end + def handle_errors(result) + unless result[:status] == :success + render json: { message: result[:message] }, + status: result[:http_status] || :bad_request + end + end + def list_projects_params params.require(:error_tracking_setting).permit([:api_host, :token]) end + def issue_details_params + params.permit(:issue_id) + end + def set_polling_interval Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) end @@ -76,6 +123,18 @@ class Projects::ErrorTrackingController < Projects::ApplicationController .represent(errors) end + def serialize_detailed_error(error) + ErrorTracking::DetailedErrorSerializer + .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) diff --git a/app/controllers/projects/grafana_api_controller.rb b/app/controllers/projects/grafana_api_controller.rb index 4bdf4c12cac5552ef6031cf91f2ecd2d989ea4e2..380a18818ab068d903319df2012dcd7f3bbff204 100644 --- a/app/controllers/projects/grafana_api_controller.rb +++ b/app/controllers/projects/grafana_api_controller.rb @@ -2,6 +2,7 @@ class Projects::GrafanaApiController < Projects::ApplicationController include RenderServiceResults + include MetricsDashboard def proxy result = ::Grafana::ProxyService.new( @@ -19,6 +20,10 @@ class Projects::GrafanaApiController < Projects::ApplicationController private + def metrics_dashboard_params + params.permit(:embedded, :grafana_url) + end + def query_params params.permit(:query, :start, :end, :step) end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 96cb400950ba87f9016d76dba37f32a8c6b01de5..009765702abe910c0d6f85e680651b1a290be13b 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -44,6 +44,7 @@ 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) end respond_to :html diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index 386a1f00bd2c02920780819adfb20541145f097b..b7aeab8f5ff0de802e2a0fb7573a5723b600f87c 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -76,7 +76,7 @@ class Projects::LabelsController < Projects::ApplicationController @labels = find_labels redirect_to project_labels_path(@project), - status: 302, + status: :found, notice: 'Label was removed' end diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb index a1983bc5462bb859e0d0a5d909e91af396895ed7..1273c55b83a91ee13dd483839632c739bf8e6952 100644 --- a/app/controllers/projects/lfs_api_controller.rb +++ b/app/controllers/projects/lfs_api_controller.rb @@ -109,7 +109,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController message: lfs_read_only_message }, content_type: LfsRequest::CONTENT_TYPE, - status: 403 + status: :forbidden ) end end diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 808265634da018429b4b6fd09c1449b7abfb9a57..78dc196b08e2d642872ec1737618f4806a6bdd79 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -109,7 +109,13 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap @target_project = @merge_request.target_project @source_project = @merge_request.source_project - @commits = set_commits_for_rendering(@merge_request.commits) + + @commits = + set_commits_for_rendering( + @merge_request.recent_commits.with_latest_pipeline(@merge_request.source_branch), + commits_count: @merge_request.commits_count + ) + @commit = @merge_request.diff_head_commit # FIXME: We have to assign a presenter to another instance variable diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 4a37dfe5c19ffc3d46bb71a053635266ca8b60a4..42f9c0522a3417f3052bfb27c36834f57df3a342 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -31,6 +31,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic options = { merge_request: @merge_request, + diff_view: diff_view, pagination_data: diffs.pagination_data } @@ -60,7 +61,9 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic render: ->(partial, locals) { view_to_html_string(partial, locals) } } - render json: DiffsSerializer.new(request).represent(@diffs, additional_attributes) + options = additional_attributes.merge(diff_view: diff_view) + + render json: DiffsSerializer.new(request).represent(@diffs, options) end def define_diff_vars diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index ff199e05e99009deeef6a9f67678fc252357f43d..766ec1e33f3eacee5235d065a57a42f80979bb9a 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -9,11 +9,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo include ToggleAwardEmoji include IssuableCollections include RecordUserLastActivity + include SourcegraphGon skip_before_action :merge_request, only: [:index, :bulk_update] before_action :whitelist_query_limiting, only: [:assign_related_issues, :update] before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort] - before_action :authorize_test_reports!, only: [:test_reports] + before_action :authorize_read_actual_head_pipeline!, only: [:test_reports, :exposed_artifacts] before_action :set_issuables_index, only: [:index] before_action :authenticate_user!, only: [:assign_related_issues] before_action :check_user_can_push_to_source_branch!, only: [:rebase] @@ -23,6 +24,7 @@ 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) end around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions] @@ -89,7 +91,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo # Get commits from repository # or from cache if already merged @commits = - set_commits_for_rendering(@merge_request.commits.with_latest_pipeline) + set_commits_for_rendering( + @merge_request.recent_commits.with_latest_pipeline(@merge_request.source_branch), + commits_count: @merge_request.commits_count + ) render json: { html: view_to_html_string('projects/merge_requests/_commits') } end @@ -115,6 +120,14 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo reports_response(@merge_request.compare_test_reports) end + def exposed_artifacts + if @merge_request.has_exposed_artifacts? + reports_response(@merge_request.find_exposed_artifacts) + else + head :no_content + end + end + def edit define_edit_vars end @@ -218,6 +231,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @merge_request.rebase_async(current_user.id) head :ok + rescue MergeRequest::RebaseLockTimeout => e + render json: { merge_error: e.message }, status: :conflict end def discussions @@ -241,7 +256,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def merge_params_attributes - [:should_remove_source_branch, :commit_message, :squash_commit_message, :squash, :auto_merge_strategy] + MergeRequest::KNOWN_MERGE_PARAMS end def auto_merge_requested? @@ -281,7 +296,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo return :sha_mismatch if params[:sha] != @merge_request.diff_head_sha - @merge_request.update(merge_error: nil, squash: merge_params.fetch(:squash, false)) + @merge_request.update(merge_error: nil, squash: params.fetch(:squash, false)) if auto_merge_requested? if merge_request.auto_merge_enabled? @@ -353,12 +368,11 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo when :error render json: { status_reason: report_comparison[:status_reason] }, status: :bad_request else - render json: { status_reason: 'Unknown error' }, status: :internal_server_error + raise "Failed to build comparison response as comparison yielded unknown status '#{report_comparison[:status]}'" end end - def authorize_test_reports! - # MergeRequest#actual_head_pipeline is the pipeline accessed in MergeRequest#compare_reports. + def authorize_read_actual_head_pipeline! return render_404 unless can?(current_user, :read_build, merge_request.actual_head_pipeline) end end diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb index 73e629ab7c3659c274f7a126d87fc3eaba889c1b..722fc30b3ff91b5a95580e1494446d794b487038 100644 --- a/app/controllers/projects/pages_controller.rb +++ b/app/controllers/projects/pages_controller.rb @@ -21,7 +21,7 @@ class Projects::PagesController < Projects::ApplicationController respond_to do |format| format.html do redirect_to project_pages_path(@project), - status: 302, + status: :found, notice: 'Pages were removed' end end diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb index c287e440db06447076f37817a4b2fcd4cb3def3d..b693642981e77e8dbb3aaa139a0b51f59c9a3a8e 100644 --- a/app/controllers/projects/pages_domains_controller.rb +++ b/app/controllers/projects/pages_domains_controller.rb @@ -8,6 +8,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController before_action :domain, except: [:new, :create] def show + redirect_to edit_project_pages_domain_path(@project, @domain) end def new @@ -23,7 +24,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController flash[:alert] = 'Failed to verify domain ownership' end - redirect_to project_pages_domain_path(@project, @domain) + redirect_to edit_project_pages_domain_path(@project, @domain) end def edit @@ -33,7 +34,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController @domain = @project.pages_domains.create(create_params) if @domain.valid? - redirect_to project_pages_domain_path(@project, @domain) + redirect_to edit_project_pages_domain_path(@project, @domain) else render 'new' end @@ -42,7 +43,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController def update if @domain.update(update_params) redirect_to project_pages_path(@project), - status: 302, + status: :found, notice: 'Domain was updated' else render 'edit' @@ -55,13 +56,21 @@ class Projects::PagesDomainsController < Projects::ApplicationController respond_to do |format| format.html do redirect_to project_pages_path(@project), - status: 302, + status: :found, notice: 'Domain was removed' end format.js end end + def clean_certificate + unless @domain.update(user_provided_certificate: nil, user_provided_key: nil) + flash[:alert] = @domain.errors.full_messages.join(', ') + end + + redirect_to edit_project_pages_domain_path(@project, @domain) + end + private def create_params @@ -69,7 +78,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController end def update_params - params.require(:pages_domain).permit(:user_provided_key, :user_provided_certificate, :auto_ssl_enabled) + params.fetch(:pages_domain, {}).permit(:user_provided_key, :user_provided_certificate, :auto_ssl_enabled) end def domain diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 106ef1b72c1fe88277b566e5620a399a26870a43..4d35353d5f5e392039fde9074dbddbc8ef1f4eb7 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -12,6 +12,7 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action do push_frontend_feature_flag(:hide_dismissed_vulnerabilities) + push_frontend_feature_flag(:junit_pipeline_view) end around_action :allow_gitaly_ref_name_caching, only: [:index, :show] @@ -156,14 +157,21 @@ class Projects::PipelinesController < Projects::ApplicationController def test_report return unless Feature.enabled?(:junit_pipeline_view, project) - if pipeline_test_report == :error - render json: { status: :error_parsing_report } - return - end + respond_to do |format| + format.html do + render 'show' + end - render json: TestReportSerializer - .new(current_user: @current_user) - .represent(pipeline_test_report) + format.json do + if pipeline_test_report == :error + render json: { status: :error_parsing_report } + else + render json: TestReportSerializer + .new(current_user: @current_user) + .represent(pipeline_test_report) + end + end + end end private diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index 717df9f09e0396d67319c77e97c464e3013ffe12..72c82aec31de913e4ef1eb1d6233e60e79fc92b6 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -2,12 +2,48 @@ class Projects::ReleasesController < Projects::ApplicationController # Authorize - before_action :require_non_empty_project + before_action :require_non_empty_project, except: [:index] + before_action :release, only: %i[edit update] before_action :authorize_read_release! before_action do - push_frontend_feature_flag(:release_edit_page, project) + push_frontend_feature_flag(:release_edit_page, project, default_enabled: true) + push_frontend_feature_flag(:release_issue_summary, project) end + before_action :authorize_update_release!, only: %i[edit update] def index + respond_to do |format| + format.html do + require_non_empty_project + end + format.json { render json: releases } + end + end + + protected + + def releases + ReleasesFinder.new(@project, current_user).execute + end + + def edit + respond_to do |format| + format.html { render 'edit' } + end + end + + private + + def authorize_update_release! + access_denied! unless Feature.enabled?(:release_edit_page, project, default_enabled: true) + access_denied! unless can?(current_user, :update_release, release) + end + + def release + @release ||= project.releases.find_by_tag!(sanitized_tag_name) + end + + def sanitized_tag_name + CGI.unescape(params[:tag]) end end diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb index 5bf3618b3891cec01754e5be3dfa38e2842d0949..1571cb8cd343bc00f9dd6ca272b0068146d405b5 100644 --- a/app/controllers/projects/settings/operations_controller.rb +++ b/app/controllers/projects/settings/operations_controller.rb @@ -70,7 +70,7 @@ module Projects project: [:slug, :name, :organization_slug, :organization_name] ], - grafana_integration_attributes: [:token, :grafana_url] + grafana_integration_attributes: [:token, :grafana_url, :enabled] } end end diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index 7d9387b1d94e62c57538759e83e7159150a3e723..c89bfd110c4bbf6add665071c0b40b18ddc56e1e 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -84,7 +84,7 @@ class Projects::TagsController < Projects::ApplicationController format.html do redirect_to project_tags_path(@project), - alert: @error, status: 303 + alert: @error, status: :see_other end format.js do diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index 7509cc29a76e8a41dce685f44736c17b2b844daa..eec89afe354a95540f1148311f3a7a63bd08adc5 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -5,6 +5,7 @@ class Projects::TreeController < Projects::ApplicationController include ExtractsPath include CreatesCommit include ActionView::Helpers::SanitizeHelper + include RedirectsForMissingPathOnTree around_action :allow_gitaly_ref_name_caching, only: [:show] @@ -19,12 +20,9 @@ class Projects::TreeController < Projects::ApplicationController if tree.entries.empty? if @repository.blob_at(@commit.id, @path) - return redirect_to( - project_blob_path(@project, - File.join(@ref, @path)) - ) + return redirect_to project_blob_path(@project, File.join(@ref, @path)) elsif @path.present? - return render_404 + return redirect_to_tree_root_for_missing_path(@project, @ref, @path) end end diff --git a/app/controllers/projects/usage_ping_controller.rb b/app/controllers/projects/usage_ping_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..ebdf28bd59c4dc78224fdfcdf5062c8e0db08ac1 --- /dev/null +++ b/app/controllers/projects/usage_ping_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Projects::UsagePingController < Projects::ApplicationController + before_action :authenticate_user! + + def web_ide_clientside_preview + return render_404 unless Gitlab::CurrentSettings.web_ide_clientside_preview_enabled? + + Gitlab::UsageDataCounters::WebIdeCounter.increment_previews_count + + head(200) + end +end diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index b187fdb27232fb15c079797c2325f616626c373b..fb06299676c37f1c46a9b199954feb17f463a21a 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -110,7 +110,7 @@ class Projects::WikisController < Projects::ApplicationController WikiPages::DestroyService.new(@project, current_user).execute(@page) redirect_to project_wiki_path(@project, :home), - status: 302, + status: :found, notice: _("Page was successfully deleted") rescue Gitlab::Git::Wiki::OperationError => e @error = e diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index abd19df9a3d653d509e70f00ddb7de6b0f67a6d6..e5dea031bb578326ce13feb23d7da4a5bb4e5424 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -154,7 +154,7 @@ class ProjectsController < Projects::ApplicationController redirect_to dashboard_projects_path, status: :found rescue Projects::DestroyService::DestroyError => ex - redirect_to edit_project_path(@project), status: 302, alert: ex.message + redirect_to edit_project_path(@project), status: :found, alert: ex.message end def new_issuable_address @@ -371,6 +371,7 @@ class ProjectsController < Projects::ApplicationController :path, :printing_merge_request_link_enabled, :public_builds, + :remove_source_branch_after_merge, :request_access_enabled, :runners_token, :tag_list, diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 4a746fc915d934e20faf8cadddce6a47e258df8e..5fc7f5c84f05c346447816fe4b5e455bfd6f68cf 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -8,7 +8,7 @@ class RegistrationsController < Devise::RegistrationsController layout :choose_layout - skip_before_action :require_role, only: [:welcome, :update_role] + skip_before_action :required_signup_info, only: [:welcome, :update_registration] prepend_before_action :check_captcha, only: :create before_action :whitelist_query_limiting, only: [:destroy] before_action :ensure_terms_accepted, @@ -16,6 +16,7 @@ class RegistrationsController < Devise::RegistrationsController def new if experiment_enabled?(:signup_flow) + track_experiment_event(:signup_flow, 'start') # We want this event to be tracked when the user is _in_ the experimental group @resource = build_resource else redirect_to new_user_session_path(anchor: 'register-pane') @@ -23,6 +24,8 @@ class RegistrationsController < Devise::RegistrationsController end def create + track_experiment_event(:signup_flow, 'end') unless experiment_enabled?(:signup_flow) # We want this event to be tracked when the user is _in_ the control group + accept_pending_invitations super do |new_user| @@ -42,29 +45,30 @@ class RegistrationsController < Devise::RegistrationsController if destroy_confirmation_valid? current_user.delete_async(deleted_by: current_user) session.try(:destroy) - redirect_to new_user_session_path, status: 303, notice: s_('Profiles|Account scheduled for removal.') + redirect_to new_user_session_path, status: :see_other, notice: s_('Profiles|Account scheduled for removal.') else - redirect_to profile_account_path, status: 303, alert: destroy_confirmation_failure_message + redirect_to profile_account_path, status: :see_other, alert: destroy_confirmation_failure_message end end def welcome return redirect_to new_user_registration_path unless current_user - return redirect_to stored_location_or_dashboard_or_almost_there_path(current_user) if current_user.role.present? + return redirect_to stored_location_or_dashboard_or_almost_there_path(current_user) if current_user.role.present? && !current_user.setup_for_company.nil? - current_user.name = nil + current_user.name = nil if current_user.name == current_user.username render layout: 'devise_experimental_separate_sign_up_flow' end - def update_role - user_params = params.require(:user).permit(:name, :role) - result = ::Users::UpdateService.new(current_user, user_params.merge(user: current_user)).execute + def update_registration + user_params = params.require(:user).permit(:name, :role, :setup_for_company) + result = ::Users::SignupService.new(current_user, user_params).execute if result[:status] == :success + track_experiment_event(:signup_flow, 'end') # We want this event to be tracked when the user is _in_ the experimental group set_flash_message! :notice, :signed_up redirect_to stored_location_or_dashboard_or_almost_there_path(current_user) else - redirect_to users_sign_up_welcome_path, alert: result[:message] + render :welcome, layout: 'devise_experimental_separate_sign_up_flow' end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 1c506065b563df0ccd79c601f5ab13ce76e95d44..0007d5826ba0eef538184053393502512abaea62 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -24,6 +24,7 @@ class SessionsController < Devise::SessionsController before_action :store_unauthenticated_sessions, only: [:new] before_action :save_failed_login, if: :action_new_and_failed_login? before_action :load_recaptcha + before_action :frontend_tracking_data, only: [:new] after_action :log_failed_login, if: :action_new_and_failed_login? @@ -269,7 +270,13 @@ class SessionsController < Devise::SessionsController end def ldap_servers - @ldap_servers ||= Gitlab::Auth::LDAP::Config.available_servers + @ldap_servers ||= begin + if Gitlab::Auth::LDAP::Config.sign_in_enabled? + Gitlab::Auth::LDAP::Config.available_servers + else + [] + end + end end def unverified_anonymous_user? @@ -293,6 +300,11 @@ class SessionsController < Devise::SessionsController "standard" end end + + def frontend_tracking_data + # We want tracking data pushed to the frontend when the user is _in_ the control group + frontend_experimentation_tracking_data(:signup_flow, 'start') unless experiment_enabled?(:signup_flow) + end end SessionsController.prepend_if_ee('EE::SessionsController') diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index c3c227b08c523f9a486924a83b2dc7169277547f..06374736dcf504276df767c33aed622045003077 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -16,7 +16,7 @@ class UsersController < ApplicationController skip_before_action :authenticate_user! prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } - before_action :user, except: [:exists] + before_action :user, except: [:exists, :suggests] before_action :authorize_read_user_profile!, only: [:calendar, :calendar_activities, :groups, :projects, :contributed_projects, :starred_projects, :snippets] @@ -114,6 +114,14 @@ class UsersController < ApplicationController render json: { exists: !!Namespace.find_by_path_or_name(params[:username]) } end + def suggests + namespace_path = params[:username] + exists = !!Namespace.find_by_path_or_name(namespace_path) + suggestions = exists ? [Namespace.clean_path(namespace_path)] : [] + + render json: { exists: exists, suggests: suggestions } + end + private def user diff --git a/app/finders/abuse_reports_finder.rb b/app/finders/abuse_reports_finder.rb new file mode 100644 index 0000000000000000000000000000000000000000..04043f36426c25901b7a9749c4c4288f7289af4e --- /dev/null +++ b/app/finders/abuse_reports_finder.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class AbuseReportsFinder + attr_reader :params + + def initialize(params = {}) + @params = params + end + + def execute + reports = AbuseReport.all + reports = reports.by_user(params[:user_id]) if params[:user_id].present? + + reports.with_order_id_desc + .with_users + .page(params[:page]) + end +end diff --git a/app/finders/admin/projects_finder.rb b/app/finders/admin/projects_finder.rb index e2b9b0b44c1658618b90f8f9e85a7e55ed9e95e6..53dbf65c43abdfb602b32334fd3f2e99ed721708 100644 --- a/app/finders/admin/projects_finder.rb +++ b/app/finders/admin/projects_finder.rb @@ -12,7 +12,7 @@ class Admin::ProjectsFinder def execute items = Project.without_deleted.with_statistics.with_route items = by_namespace_id(items) - items = by_visibilty_level(items) + items = by_visibility_level(items) items = by_with_push(items) items = by_abandoned(items) items = by_last_repository_check_failed(items) @@ -31,7 +31,7 @@ class Admin::ProjectsFinder end # rubocop: disable CodeReuse/ActiveRecord - def by_visibilty_level(items) + def by_visibility_level(items) params[:visibility_level].present? ? items.where(visibility_level: params[:visibility_level]) : items end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/branches_finder.rb b/app/finders/branches_finder.rb index 291a24c1405dcf5b38f9194d0c5b5acc9accb17b..8001c70a9b2badf9c9c773bf3c87ccf932d5449d 100644 --- a/app/finders/branches_finder.rb +++ b/app/finders/branches_finder.rb @@ -1,9 +1,8 @@ # frozen_string_literal: true -class BranchesFinder +class BranchesFinder < GitRefsFinder def initialize(repository, params = {}) - @repository = repository - @params = params + super(repository, params) end def execute @@ -15,56 +14,10 @@ class BranchesFinder private - attr_reader :repository, :params - def names @params[:names].presence end - def search - @params[:search].presence - end - - def sort - @params[:sort].presence || 'name' - end - - def by_search(branches) - return branches unless search - - case search - when ->(v) { v.starts_with?('^') } - filter_branches_with_prefix(branches, search.slice(1..-1).upcase) - when ->(v) { v.ends_with?('$') } - filter_branches_with_suffix(branches, search.chop.upcase) - else - matches = filter_branches_by_name(branches, search.upcase) - set_exact_match_as_first_result(matches, search) - end - end - - def filter_branches_with_prefix(branches, prefix) - branches.select { |branch| branch.name.upcase.starts_with?(prefix) } - end - - def filter_branches_with_suffix(branches, suffix) - branches.select { |branch| branch.name.upcase.ends_with?(suffix) } - end - - def filter_branches_by_name(branches, term) - branches.select { |branch| branch.name.upcase.include?(term) } - end - - def set_exact_match_as_first_result(matches, term) - exact_match_index = find_exact_match_index(matches, term) - matches.insert(0, matches.delete_at(exact_match_index)) if exact_match_index - matches - end - - def find_exact_match_index(matches, term) - matches.index { |branch| branch.name.casecmp(term) == 0 } - end - def by_names(branches) return branches unless names diff --git a/app/finders/container_repositories_finder.rb b/app/finders/container_repositories_finder.rb index eb91d7f825b2598da6cebca9bb6954136c6b7d1a..34921df840bbe25a1655d4499779300721c263f2 100644 --- a/app/finders/container_repositories_finder.rb +++ b/app/finders/container_repositories_finder.rb @@ -1,34 +1,38 @@ # frozen_string_literal: true class ContainerRepositoriesFinder - # id: group or project id - # container_type: :group or :project - def initialize(id:, container_type:) - @id = id - @type = container_type.to_sym + VALID_SUBJECTS = [Group, Project].freeze + + def initialize(user:, subject:) + @user = user + @subject = subject end def execute - if project_type? - project.container_repositories - else - group.container_repositories - end + raise ArgumentError, "invalid subject_type" unless valid_subject_type? + return unless authorized? + + return project_repositories if @subject.is_a?(Project) + return group_repositories if @subject.is_a?(Group) end private - attr_reader :id, :type + def valid_subject_type? + VALID_SUBJECTS.include?(@subject.class) + end + + def project_repositories + return unless @subject.container_registry_enabled - def project_type? - type == :project + @subject.container_repositories end - def project - Project.find(id) + def group_repositories + ContainerRepository.for_group_and_its_subgroups(@subject) end - def group - Group.find(id) + def authorized? + Ability.allowed?(@user, :read_container_image, @subject) end end diff --git a/app/finders/git_refs_finder.rb b/app/finders/git_refs_finder.rb new file mode 100644 index 0000000000000000000000000000000000000000..2289b34e562bb7d1cbe355747ac3e5719f4c0d19 --- /dev/null +++ b/app/finders/git_refs_finder.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class GitRefsFinder + def initialize(repository, params = {}) + @repository = repository + @params = params + end + + protected + + attr_reader :repository, :params + + def search + @params[:search].presence + end + + def sort + @params[:sort].presence || 'name' + end + + def by_search(refs) + return refs unless search + + case search + when ->(v) { v.starts_with?('^') } + filter_refs_with_prefix(refs, search.slice(1..-1)) + when ->(v) { v.ends_with?('$') } + filter_refs_with_suffix(refs, search.chop) + else + matches = filter_refs_by_name(refs, search) + set_exact_match_as_first_result(matches, search) + end + end + + def filter_refs_with_prefix(refs, prefix) + refs.select { |ref| ref.name.upcase.starts_with?(prefix.upcase) } + end + + def filter_refs_with_suffix(refs, suffix) + refs.select { |ref| ref.name.upcase.ends_with?(suffix.upcase) } + end + + def filter_refs_by_name(refs, term) + refs.select { |ref| ref.name.upcase.include?(term.upcase) } + end + + def set_exact_match_as_first_result(matches, term) + exact_match_index = find_exact_match_index(matches, term) + matches.insert(0, matches.delete_at(exact_match_index)) if exact_match_index + matches + end + + def find_exact_match_index(matches, term) + matches.index { |ref| ref.name.casecmp(term) == 0 } + end +end diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb index 4e489a9c930e7ac5a2ec5f63ea218f93da44f8a3..1f6829a97d60264d976f6eb7bfd7a16e13b8f03f 100644 --- a/app/finders/group_descendants_finder.rb +++ b/app/finders/group_descendants_finder.rb @@ -80,7 +80,7 @@ class GroupDescendantsFinder if current_user authorized_groups = GroupsFinder.new(current_user, all_available: false) - .execute.as('authorized') + .execute.arel.as('authorized') authorized_to_user = groups_table.project(1).from(authorized_groups) .where(authorized_groups[:id].eq(groups_table[:id])) .exists diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 477093ddadfb96493e2e3adb06b4c9e67394e1c7..dfddd32d7dfbfbe0c88d8582ad149ac86689d7f2 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -385,6 +385,9 @@ class IssuableFinder end def count_key(value) + # value may be an array if the finder used in `count_by_state` added an + # additional `group by`. Anyway we are sure that state will be always the + # last item because it's added as the last one to the query. value = Array(value).last klass.available_states.key(value) end @@ -483,6 +486,7 @@ class IssuableFinder # rubocop: disable CodeReuse/ActiveRecord def by_search(items) return items unless search + return items if items.is_a?(ActiveRecord::NullRelation) if use_cte_for_search? cte = Gitlab::SQL::RecursiveCTE.new(klass.table_name) diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index df06e68c941004a013e72e59850e7fa1b637979b..42a15234e57813fc0938112d5278347c884cae7d 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -110,7 +110,10 @@ class ProjectsFinder < UnionFinder # rubocop: disable CodeReuse/ActiveRecord def by_ids(items) - project_ids_relation ? items.where(id: project_ids_relation) : items + items = items.where(id: project_ids_relation) if project_ids_relation + items = items.where('id > ?', params[:id_after]) if params[:id_after] + items = items.where('id < ?', params[:id_before]) if params[:id_before] + items end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/prometheus_metrics_finder.rb b/app/finders/prometheus_metrics_finder.rb new file mode 100644 index 0000000000000000000000000000000000000000..84a071abbd54e66976c91c7213af1e8eabd60f74 --- /dev/null +++ b/app/finders/prometheus_metrics_finder.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +class PrometheusMetricsFinder + ACCEPTED_PARAMS = [ + :project, + :group, + :title, + :y_label, + :identifier, + :id, + :common, + :ordered + ].freeze + + # Cautiously preferring a memoized class method over a constant + # so that the DB connection is accessed after the class is loaded. + def self.indexes + @indexes ||= PrometheusMetric + .connection + .indexes(:prometheus_metrics) + .map { |index| index.columns.map(&:to_sym) } + end + + def initialize(params = {}) + @params = params.slice(*ACCEPTED_PARAMS) + end + + # @return [PrometheusMetric, PrometheusMetric::ActiveRecord_Relation] + def execute + validate_params! + + metrics = by_project(::PrometheusMetric.all) + metrics = by_group(metrics) + metrics = by_title(metrics) + metrics = by_y_label(metrics) + metrics = by_common(metrics) + metrics = by_ordered(metrics) + metrics = by_identifier(metrics) + metrics = by_id(metrics) + + metrics + end + + private + + attr_reader :params + + def by_project(metrics) + return metrics unless params[:project] + + metrics.for_project(params[:project]) + end + + def by_group(metrics) + return metrics unless params[:group] + + metrics.for_group(params[:group]) + end + + def by_title(metrics) + return metrics unless params[:title] + + metrics.for_title(params[:title]) + end + + def by_y_label(metrics) + return metrics unless params[:y_label] + + metrics.for_y_label(params[:y_label]) + end + + def by_common(metrics) + return metrics unless params[:common] + + metrics.common + end + + def by_ordered(metrics) + return metrics unless params[:ordered] + + metrics.ordered + end + + def by_identifier(metrics) + return metrics unless params[:identifier] + + metrics.for_identifier(params[:identifier]) + end + + def by_id(metrics) + return metrics unless params[:id] + + metrics.id_in(params[:id]) + end + + def validate_params! + validate_params_present! + validate_id_params! + validate_indexes! + end + + # Ensure all provided params are supported + def validate_params_present! + raise ArgumentError, "Please provide one or more of: #{ACCEPTED_PARAMS}" if params.blank? + end + + # Protect against the caller "finding" the wrong metric + def validate_id_params! + raise ArgumentError, 'Only one of :identifier, :id is permitted' if params[:identifier] && params[:id] + raise ArgumentError, ':identifier must be scoped to a :project or :common' if params[:identifier] && !(params[:project] || params[:common]) + end + + # Protect against unaccounted-for, complex/slow queries. + # This is not a hard and fast rule, but is meant to encourage + # mindful inclusion of new queries. + def validate_indexes! + indexable_params = params.except(:ordered, :id, :project).keys + indexable_params << :project_id if params[:project] + indexable_params.sort! + + raise ArgumentError, "An index should exist for params: #{indexable_params}" unless appropriate_index?(indexable_params) + end + + def appropriate_index?(indexable_params) + return true if indexable_params.blank? + + self.class.indexes.any? { |index| (index - indexable_params).empty? } + end +end diff --git a/app/finders/releases_finder.rb b/app/finders/releases_finder.rb index 59e84198fdeb971aedd39cd7cf8002d762d8f6f3..72bf968c8ec122beda224c4e944f2528184da647 100644 --- a/app/finders/releases_finder.rb +++ b/app/finders/releases_finder.rb @@ -6,9 +6,11 @@ class ReleasesFinder @current_user = current_user end - def execute + def execute(preload: true) return Release.none unless Ability.allowed?(@current_user, :read_release, @project) - @project.releases.sorted + releases = @project.releases + releases = releases.preloaded if preload + releases.sorted end end diff --git a/app/finders/tags_finder.rb b/app/finders/tags_finder.rb index 2ffd46245e962f78deadffdc97fd3ed913a2c015..fd58f478b456feb39c2c408ffe067f382ade90f0 100644 --- a/app/finders/tags_finder.rb +++ b/app/finders/tags_finder.rb @@ -1,31 +1,13 @@ # frozen_string_literal: true -class TagsFinder +class TagsFinder < GitRefsFinder def initialize(repository, params) - @repository = repository - @params = params + super(repository, params) end def execute - tags = @repository.tags_sorted_by(sort) - filter_by_name(tags) - end - - private - - def sort - @params[:sort].presence - end - - def search - @params[:search].presence - end - - def filter_by_name(tags) - if search - tags.select { |tag| tag.name.include?(search) } - else - tags - end + tags = repository.tags_sorted_by(sort) + tags = by_search(tags) + tags end end diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index 2b46e51290f31d8d8a2ec37f335fcceb600b35a6..e56009be33d6e86ac257af0ed9bf870b4f104456 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -23,10 +23,16 @@ class TodosFinder NONE = '0' - TODO_TYPES = Set.new(%w(Issue MergeRequest Epic)).freeze + TODO_TYPES = Set.new(%w(Issue MergeRequest)).freeze attr_accessor :current_user, :params + class << self + def todo_types + TODO_TYPES + end + end + def initialize(current_user, params = {}) @current_user = current_user @params = params @@ -111,12 +117,6 @@ class TodosFinder params[:group_id].present? end - def project - strong_memoize(:project) do - Project.find_without_deleted(params[:project_id]) if project? - end - end - def group strong_memoize(:group) do Group.find(params[:group_id]) @@ -124,7 +124,7 @@ class TodosFinder end def type? - type.present? && TODO_TYPES.include?(type) + type.present? && self.class.todo_types.include?(type) end def type @@ -175,7 +175,7 @@ class TodosFinder def by_project(items) if project? - items.for_project(project) + items.for_undeleted_projects.for_project(params[:project_id]) else items end @@ -188,11 +188,9 @@ class TodosFinder end def by_state(items) - if params[:state].to_s == 'done' - items.done - else - items.pending - end + return items.pending if params[:state].blank? + + items.with_states(params[:state]) end def by_type(items) @@ -203,3 +201,5 @@ class TodosFinder end end end + +TodosFinder.prepend_if_ee('EE::TodosFinder') diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index 1899278ff3c93768be3deac9dfdabbfff9f985cf..a5ddf31657215d94e901af5330b49182789a0072 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -46,7 +46,7 @@ class GitlabSchema < GraphQL::Schema super(query_str, **kwargs) end - def id_from_object(object) + def id_from_object(object, _type = nil, _ctx = nil) unless object.respond_to?(:to_global_id) # This is an error in our schema and needs to be solved. So raise a # more meaningful error message @@ -57,7 +57,7 @@ class GitlabSchema < GraphQL::Schema object.to_global_id end - def object_from_id(global_id) + def object_from_id(global_id, _ctx = nil) gid = GlobalID.parse(global_id) unless gid diff --git a/app/graphql/mutations/merge_requests/set_assignees.rb b/app/graphql/mutations/merge_requests/set_assignees.rb new file mode 100644 index 0000000000000000000000000000000000000000..8f0025f0a58b2192efb18b0769f35f8dbac58870 --- /dev/null +++ b/app/graphql/mutations/merge_requests/set_assignees.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class SetAssignees < Base + graphql_name 'MergeRequestSetAssignees' + + argument :assignee_usernames, + [GraphQL::STRING_TYPE], + required: true, + description: <<~DESC + The usernames to assign to the merge request. Replaces existing assignees by default. + DESC + + argument :operation_mode, + Types::MutationOperationModeEnum, + required: false, + description: <<~DESC + The operation to perform. Defaults to REPLACE. + DESC + + def resolve(project_path:, iid:, assignee_usernames:, operation_mode: Types::MutationOperationModeEnum.enum[:replace]) + Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/issues/36098') + + merge_request = authorized_find!(project_path: project_path, iid: iid) + project = merge_request.project + + assignee_ids = [] + assignee_ids += merge_request.assignees.map(&:id) if Types::MutationOperationModeEnum.enum.values_at(:remove, :append).include?(operation_mode) + user_ids = UsersFinder.new(current_user, username: assignee_usernames).execute.map(&:id) + + if operation_mode == Types::MutationOperationModeEnum.enum[:remove] + assignee_ids -= user_ids + else + assignee_ids |= user_ids + end + + ::MergeRequests::UpdateService.new(project, current_user, assignee_ids: assignee_ids) + .execute(merge_request) + + { + merge_request: merge_request, + errors: merge_request.errors.full_messages + } + end + end + end +end diff --git a/app/graphql/mutations/merge_requests/set_labels.rb b/app/graphql/mutations/merge_requests/set_labels.rb new file mode 100644 index 0000000000000000000000000000000000000000..71f7a353bc9bdf1668b3f7f6f48dd77fe05847bc --- /dev/null +++ b/app/graphql/mutations/merge_requests/set_labels.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class SetLabels < Base + graphql_name 'MergeRequestSetLabels' + + argument :label_ids, + [GraphQL::ID_TYPE], + required: true, + description: <<~DESC + The Label IDs to set. Replaces existing labels by default. + DESC + + argument :operation_mode, + Types::MutationOperationModeEnum, + required: false, + description: <<~DESC + Changes the operation mode. Defaults to REPLACE. + DESC + + def resolve(project_path:, iid:, label_ids:, operation_mode: Types::MutationOperationModeEnum.enum[:replace]) + merge_request = authorized_find!(project_path: project_path, iid: iid) + project = merge_request.project + + label_ids = label_ids + .select(&method(:label_descendant?)) + .map { |gid| GlobalID.parse(gid).model_id } # MergeRequests::UpdateService expects integers + + attribute_name = case operation_mode + when Types::MutationOperationModeEnum.enum[:append] + :add_label_ids + when Types::MutationOperationModeEnum.enum[:remove] + :remove_label_ids + else + :label_ids + end + + ::MergeRequests::UpdateService.new(project, current_user, attribute_name => label_ids) + .execute(merge_request) + + { + merge_request: merge_request, + errors: merge_request.errors.full_messages + } + end + + def label_descendant?(gid) + GlobalID.parse(gid)&.model_class&.ancestors&.include?(Label) + end + end + end +end diff --git a/app/graphql/mutations/merge_requests/set_locked.rb b/app/graphql/mutations/merge_requests/set_locked.rb new file mode 100644 index 0000000000000000000000000000000000000000..09aaa0b39aa997e9245bb1152a982efce22088ed --- /dev/null +++ b/app/graphql/mutations/merge_requests/set_locked.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class SetLocked < Base + graphql_name 'MergeRequestSetLocked' + + argument :locked, + GraphQL::BOOLEAN_TYPE, + required: true, + description: <<~DESC + Whether or not to lock the merge request. + DESC + + def resolve(project_path:, iid:, locked:) + merge_request = authorized_find!(project_path: project_path, iid: iid) + project = merge_request.project + + ::MergeRequests::UpdateService.new(project, current_user, discussion_locked: locked) + .execute(merge_request) + + { + merge_request: merge_request, + errors: merge_request.errors.full_messages + } + end + end + end +end diff --git a/app/graphql/mutations/merge_requests/set_milestone.rb b/app/graphql/mutations/merge_requests/set_milestone.rb new file mode 100644 index 0000000000000000000000000000000000000000..707d66779527e07503652ab277e9eec5a6dcead6 --- /dev/null +++ b/app/graphql/mutations/merge_requests/set_milestone.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class SetMilestone < Base + graphql_name 'MergeRequestSetMilestone' + + argument :milestone_id, + GraphQL::ID_TYPE, + required: false, + loads: Types::MilestoneType, + description: <<~DESC + The milestone to assign to the merge request. + DESC + + def resolve(project_path:, iid:, milestone: nil) + merge_request = authorized_find!(project_path: project_path, iid: iid) + project = merge_request.project + + ::MergeRequests::UpdateService.new(project, current_user, milestone: milestone) + .execute(merge_request) + + { + merge_request: merge_request, + errors: merge_request.errors.full_messages + } + end + end + end +end diff --git a/app/graphql/mutations/merge_requests/set_subscription.rb b/app/graphql/mutations/merge_requests/set_subscription.rb new file mode 100644 index 0000000000000000000000000000000000000000..86750152775251a3fe622f7694ab4be285a3c872 --- /dev/null +++ b/app/graphql/mutations/merge_requests/set_subscription.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class SetSubscription < Base + graphql_name 'MergeRequestSetSubscription' + + argument :subscribed_state, + GraphQL::BOOLEAN_TYPE, + required: true, + description: 'The desired state of the subscription' + + def resolve(project_path:, iid:, subscribed_state:) + merge_request = authorized_find!(project_path: project_path, iid: iid) + project = merge_request.project + + merge_request.set_subscription(current_user, subscribed_state, project) + + { + merge_request: merge_request, + errors: merge_request.errors.full_messages + } + end + end + end +end diff --git a/app/graphql/mutations/todos/base.rb b/app/graphql/mutations/todos/base.rb new file mode 100644 index 0000000000000000000000000000000000000000..b6c7b320be16e0c4b85f9f5da73c6172289b4eb5 --- /dev/null +++ b/app/graphql/mutations/todos/base.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Mutations + module Todos + class Base < ::Mutations::BaseMutation + private + + def find_object(id:) + GitlabSchema.object_from_id(id) + end + + def to_global_id(id) + ::URI::GID.build(app: GlobalID.app, model_name: Todo.name, model_id: id, params: nil).to_s + end + end + end +end diff --git a/app/graphql/mutations/todos/mark_done.rb b/app/graphql/mutations/todos/mark_done.rb new file mode 100644 index 0000000000000000000000000000000000000000..5483708b5c60f213ca7e8b240ff514f5f1dcd9c8 --- /dev/null +++ b/app/graphql/mutations/todos/mark_done.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Mutations + module Todos + class MarkDone < ::Mutations::Todos::Base + graphql_name 'TodoMarkDone' + + authorize :update_todo + + argument :id, + GraphQL::ID_TYPE, + required: true, + description: 'The global id of the todo to mark as done' + + field :todo, Types::TodoType, + null: false, + description: 'The requested todo' + + # rubocop: disable CodeReuse/ActiveRecord + def resolve(id:) + todo = authorized_find!(id: id) + mark_done(Todo.where(id: todo.id)) unless todo.done? + + { + todo: todo.reset, + errors: errors_on_object(todo) + } + end + # rubocop: enable CodeReuse/ActiveRecord + + private + + def mark_done(todo) + TodoService.new.mark_todos_as_done(todo, current_user) + end + end + end +end diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index 5b7eb57841c9fe3ef899aa8458edaafe3c586a29..85d6b3779347ce007c0208c117e280012fd3a62f 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -10,6 +10,14 @@ module Resolvers end end + def self.last + @last ||= Class.new(self) do + def resolve(**args) + super.last + end + end + end + def self.resolver_complexity(args, child_complexity:) complexity = 1 complexity += 1 if args[:sort] diff --git a/app/graphql/resolvers/commit_pipelines_resolver.rb b/app/graphql/resolvers/commit_pipelines_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..92a835235934eb6f4a67f8851435e8c1551657ca --- /dev/null +++ b/app/graphql/resolvers/commit_pipelines_resolver.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Resolvers + class CommitPipelinesResolver < BaseResolver + include ::ResolvesPipelines + + alias_method :commit, :object + + def resolve(**args) + resolve_pipelines(commit.project, args.merge!({ sha: commit.sha })) + end + end +end diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb index cf43fea45e6dcfe34a249de5f0cca6b9ff73034f..94f6c47e8762b22c9ea6685f56b96746b145a4df 100644 --- a/app/graphql/types/base_enum.rb +++ b/app/graphql/types/base_enum.rb @@ -2,5 +2,18 @@ module Types class BaseEnum < GraphQL::Schema::Enum + class << self + def value(*args, **kwargs, &block) + enum[args[0].downcase] = kwargs[:value] || args[0] + + super(*args, **kwargs, &block) + end + + # Returns an indifferent access hash with the key being the downcased name of the attribute + # and the value being the Ruby value (either the explicit `value` passed or the same as the value attr). + def enum + @enum_values ||= {}.with_indifferent_access + end + end end end diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb index fe71791f41333b6951b695df08109834c7680dc5..87f84ec576fc6e2953c3da5c73a3d1fddca5a59f 100644 --- a/app/graphql/types/commit_type.rb +++ b/app/graphql/types/commit_type.rb @@ -8,25 +8,39 @@ module Types present_using CommitPresenter - field :id, type: GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :sha, type: GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :title, type: GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :description, type: GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :message, type: GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :authored_date, type: Types::TimeType, null: true # rubocop:disable Graphql/Descriptions - field :web_url, type: GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :signature_html, type: GraphQL::STRING_TYPE, - null: true, calls_gitaly: true, description: 'Rendered html for the commit signature' + field :id, type: GraphQL::ID_TYPE, null: false, + description: 'ID (global ID) of the commit' + field :sha, type: GraphQL::STRING_TYPE, null: false, + description: 'SHA1 ID of the commit' + field :title, type: GraphQL::STRING_TYPE, null: true, + description: 'Title of the commit message' + field :description, type: GraphQL::STRING_TYPE, null: true, + description: 'Description of the commit message' + field :message, type: GraphQL::STRING_TYPE, null: true, + description: 'Raw commit message' + field :authored_date, type: Types::TimeType, null: true, + description: 'Timestamp of when the commit was authored' + field :web_url, type: GraphQL::STRING_TYPE, null: false, + description: 'Web URL of the commit' + field :signature_html, type: GraphQL::STRING_TYPE, null: true, calls_gitaly: true, + description: 'Rendered HTML of the commit signature' + field :author_name, type: GraphQL::STRING_TYPE, null: true, + description: 'Commit authors name' # models/commit lazy loads the author by email - field :author, type: Types::UserType, null: true # rubocop:disable Graphql/Descriptions + field :author, type: Types::UserType, null: true, + description: 'Author of the commit' + + field :pipelines, Types::Ci::PipelineType.connection_type, + null: true, + description: 'Pipelines of the commit ordered latest first', + resolver: Resolvers::CommitPipelinesResolver field :latest_pipeline, type: Types::Ci::PipelineType, null: true, - description: "Latest pipeline for this commit", - resolve: -> (obj, ctx, args) do - Gitlab::Graphql::Loaders::PipelineForShaLoader.new(obj.project, obj.sha).find_last - end + description: "Latest pipeline of the commit", + deprecation_reason: 'use pipelines', + resolver: Resolvers::CommitPipelinesResolver.last end end diff --git a/app/graphql/types/extended_issue_type.rb b/app/graphql/types/extended_issue_type.rb deleted file mode 100644 index e007c1109a388da95879009b1db637f5260c9a8b..0000000000000000000000000000000000000000 --- a/app/graphql/types/extended_issue_type.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -module Types - class ExtendedIssueType < IssueType - graphql_name 'ExtendedIssue' - - authorize :read_issue - expose_permissions Types::PermissionTypes::Issue - present_using IssuePresenter - - 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' - end -end diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 1e52c0cb147197732cf77ebdedebaae689909bfb..386ae6ed4a3b7b84c4d767459c20e68dc39a84c8 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -8,14 +8,17 @@ module Types expose_permissions Types::PermissionTypes::Group - field :web_url, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :web_url, GraphQL::STRING_TYPE, null: false, + description: 'Web URL of the group' - field :avatar_url, GraphQL::STRING_TYPE, null: true, resolve: -> (group, args, ctx) do # rubocop:disable Graphql/Descriptions - group.avatar_url(only_path: false) - end + field :avatar_url, GraphQL::STRING_TYPE, null: true, + description: 'Avatar URL of the group', + resolve: -> (group, args, ctx) do + group.avatar_url(only_path: false) + end - field :parent, GroupType, # rubocop:disable Graphql/Descriptions - null: true, + field :parent, GroupType, null: true, + description: 'Parent group', resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.parent_id).find } end end diff --git a/app/graphql/types/issue_sort_enum.rb b/app/graphql/types/issue_sort_enum.rb index ad919b55481d410001f5a6f4a11003300624de4a..48ff581928627536fea7bc84ffba70af2775a471 100644 --- a/app/graphql/types/issue_sort_enum.rb +++ b/app/graphql/types/issue_sort_enum.rb @@ -5,6 +5,10 @@ module Types class IssueSortEnum < IssuableSortEnum graphql_name 'IssueSort' description 'Values for sorting issues' + + value 'DUE_DATE_ASC', 'Due date by ascending order', value: 'due_date_asc' + value 'DUE_DATE_DESC', 'Due date by descending order', value: 'due_date_desc' + value 'RELATIVE_POSITION_ASC', 'Relative position by ascending order', value: 'relative_position_asc' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 4965601fe6586d332c3c1d268ff1480365afa1e3..4cbb849da3a3f0c9d4da9b6045aa917dfb9cb8c9 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -12,53 +12,79 @@ module Types present_using IssuePresenter - field :iid, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :title, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :iid, GraphQL::ID_TYPE, null: false, + description: "Internal ID of the issue" + field :title, GraphQL::STRING_TYPE, null: false, + description: 'Title of the issue' markdown_field :title_html, null: true - field :description, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions + field :description, GraphQL::STRING_TYPE, null: true, + description: 'Description of the issue' markdown_field :description_html, null: true - field :state, IssueStateEnum, null: false # rubocop:disable Graphql/Descriptions - - field :reference, GraphQL::STRING_TYPE, null: false, method: :to_reference do # rubocop:disable Graphql/Descriptions - argument :full, GraphQL::BOOLEAN_TYPE, required: false, default_value: false # rubocop:disable Graphql/Descriptions + field :state, IssueStateEnum, null: false, + description: 'State of the issue' + + field :reference, GraphQL::STRING_TYPE, null: false, + description: 'Internal reference of the issue. Returned in shortened format by default', + method: :to_reference do + argument :full, GraphQL::BOOLEAN_TYPE, required: false, default_value: false, + description: 'Boolean option specifying whether the reference should be returned in full' end - field :author, Types::UserType, # rubocop:disable Graphql/Descriptions - null: false, + field :author, Types::UserType, null: false, + description: 'User that created the issue', resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find } # Remove complexity when BatchLoader is used - field :assignees, Types::UserType.connection_type, null: true, complexity: 5 # rubocop:disable Graphql/Descriptions + field :assignees, Types::UserType.connection_type, null: true, complexity: 5, + description: 'Assignees of the issue' # Remove complexity when BatchLoader is used - field :labels, Types::LabelType.connection_type, null: true, complexity: 5 # rubocop:disable Graphql/Descriptions - field :milestone, Types::MilestoneType, # rubocop:disable Graphql/Descriptions - null: true, + field :labels, Types::LabelType.connection_type, null: true, complexity: 5, + description: 'Labels of the issue' + field :milestone, Types::MilestoneType, null: true, + description: 'Milestone of the issue', resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find } - field :due_date, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions - field :confidential, GraphQL::BOOLEAN_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :discussion_locked, GraphQL::BOOLEAN_TYPE, # rubocop:disable Graphql/Descriptions - null: false, + field :due_date, Types::TimeType, null: true, + description: 'Due date of the issue' + field :confidential, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Indicates the issue is confidential' + field :discussion_locked, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Indicates discussion is locked on the issue', resolve: -> (obj, _args, _ctx) { !!obj.discussion_locked } - field :upvotes, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :downvotes, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :user_notes_count, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :web_path, GraphQL::STRING_TYPE, null: false, method: :issue_path # rubocop:disable Graphql/Descriptions - field :web_url, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :relative_position, GraphQL::INT_TYPE, null: true # rubocop:disable Graphql/Descriptions - - field :participants, Types::UserType.connection_type, null: true, complexity: 5, description: 'List of participants for the issue' - field :time_estimate, GraphQL::INT_TYPE, null: false, description: 'The time estimate on the issue' - field :total_time_spent, GraphQL::INT_TYPE, null: false, description: 'Total time reported as spent on the issue' - - field :closed_at, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions - - field :created_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions - field :updated_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions - - field :task_completion_status, Types::TaskCompletionStatus, null: false # rubocop:disable Graphql/Descriptions + field :upvotes, GraphQL::INT_TYPE, null: false, + description: 'Number of upvotes the issue has received' + field :downvotes, GraphQL::INT_TYPE, null: false, + description: 'Number of downvotes the issue has received' + field :user_notes_count, GraphQL::INT_TYPE, null: false, + description: 'Number of user notes of the issue' + field :web_path, GraphQL::STRING_TYPE, null: false, method: :issue_path, + description: 'Web path of the issue' + field :web_url, GraphQL::STRING_TYPE, null: false, + description: 'Web URL of the issue' + field :relative_position, GraphQL::INT_TYPE, null: true, + description: 'Relative position of the issue (used for positioning in epic tree and issue boards)' + + 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' + field :time_estimate, GraphQL::INT_TYPE, null: false, + description: 'Time estimate of the issue' + field :total_time_spent, GraphQL::INT_TYPE, null: false, + description: 'Total time reported as spent on the issue' + + field :closed_at, Types::TimeType, null: true, + description: 'Timestamp of when the issue was closed' + + field :created_at, Types::TimeType, null: false, + description: 'Timestamp of when the issue was created' + field :updated_at, Types::TimeType, null: false, + description: 'Timestamp of when the issue was last updated' + + field :task_completion_status, Types::TaskCompletionStatus, null: false, + description: 'Task completion status of the issue' end end diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb index 384a27df563a0e2c585f3dfd2ff4978adfd345a7..d0bcf2068b706e81cd230e27ad0c2bc849df5746 100644 --- a/app/graphql/types/label_type.rb +++ b/app/graphql/types/label_type.rb @@ -6,10 +6,16 @@ module Types authorize :read_label - field :description, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions + field :id, GraphQL::ID_TYPE, null: false, + description: 'Label ID' + field :description, GraphQL::STRING_TYPE, null: true, + description: 'Description of the label (markdown rendered as HTML for caching)' markdown_field :description_html, null: true - field :title, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :color, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :text_color, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :title, GraphQL::STRING_TYPE, null: false, + description: 'Content of the label' + field :color, GraphQL::STRING_TYPE, null: false, + description: 'Background color of the label' + field :text_color, GraphQL::STRING_TYPE, null: false, + description: 'Text color of the label' end end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 71a65dc67132663b3a963ee9e61fe6ffb001cf69..278a95fe3cadb9f01c9682bde58ec0e59ef2523a 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -12,70 +12,116 @@ module Types present_using MergeRequestPresenter - field :id, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :iid, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :title, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the merge request' + field :iid, GraphQL::STRING_TYPE, null: false, + description: 'Internal ID of the merge request' + field :title, GraphQL::STRING_TYPE, null: false, + description: 'Title of the merge request' markdown_field :title_html, null: true - field :description, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions + field :description, GraphQL::STRING_TYPE, null: true, + description: 'Description of the merge request (markdown rendered as HTML for caching)' markdown_field :description_html, null: true - field :state, MergeRequestStateEnum, null: false # rubocop:disable Graphql/Descriptions - field :created_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions - field :updated_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions - field :source_project, Types::ProjectType, null: true # rubocop:disable Graphql/Descriptions - field :target_project, Types::ProjectType, null: false # rubocop:disable Graphql/Descriptions - field :diff_refs, Types::DiffRefsType, null: true # rubocop:disable Graphql/Descriptions - # Alias for target_project - field :project, Types::ProjectType, null: false # rubocop:disable Graphql/Descriptions - field :project_id, GraphQL::INT_TYPE, null: false, method: :target_project_id # rubocop:disable Graphql/Descriptions - field :source_project_id, GraphQL::INT_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :target_project_id, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :source_branch, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :target_branch, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :work_in_progress, GraphQL::BOOLEAN_TYPE, method: :work_in_progress?, null: false # rubocop:disable Graphql/Descriptions - field :merge_when_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :diff_head_sha, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :merge_commit_sha, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :user_notes_count, GraphQL::INT_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :should_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :should_remove_source_branch?, null: true # rubocop:disable Graphql/Descriptions - field :force_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :force_remove_source_branch?, null: true # rubocop:disable Graphql/Descriptions - field :merge_status, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :in_progress_merge_commit_sha, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :merge_error, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :allow_collaboration, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :should_be_rebased, GraphQL::BOOLEAN_TYPE, method: :should_be_rebased?, null: false # rubocop:disable Graphql/Descriptions - field :rebase_commit_sha, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :rebase_in_progress, GraphQL::BOOLEAN_TYPE, method: :rebase_in_progress?, null: false, calls_gitaly: true # rubocop:disable Graphql/Descriptions - # rubocop:disable Graphql/Descriptions - field :merge_commit_message, GraphQL::STRING_TYPE, method: :default_merge_commit_message, null: true, deprecation_reason: "Renamed to defaultMergeCommitMessage" - # rubocop:enable Graphql/Descriptions - field :default_merge_commit_message, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :merge_ongoing, GraphQL::BOOLEAN_TYPE, method: :merge_ongoing?, null: false # rubocop:disable Graphql/Descriptions - field :source_branch_exists, GraphQL::BOOLEAN_TYPE, method: :source_branch_exists?, null: false # rubocop:disable Graphql/Descriptions - field :mergeable_discussions_state, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :web_url, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :upvotes, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :downvotes, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :state, MergeRequestStateEnum, null: false, + description: 'State of the merge request' + field :created_at, Types::TimeType, null: false, + description: 'Timestamp of when the merge request was created' + field :updated_at, Types::TimeType, null: false, + description: 'Timestamp of when the merge request was last updated' + field :source_project, Types::ProjectType, null: true, + description: 'Source project of the merge request' + field :target_project, Types::ProjectType, null: false, + description: 'Target project of the merge request' + field :diff_refs, Types::DiffRefsType, null: true, + description: 'References of the base SHA, the head SHA, and the start SHA for this merge request' + field :project, Types::ProjectType, null: false, + description: 'Alias for target_project' + field :project_id, GraphQL::INT_TYPE, null: false, method: :target_project_id, + description: 'ID of the merge request project' + field :source_project_id, GraphQL::INT_TYPE, null: true, + description: 'ID of the merge request source project' + field :target_project_id, GraphQL::INT_TYPE, null: false, + description: 'ID of the merge request target project' + field :source_branch, GraphQL::STRING_TYPE, null: false, + description: 'Source branch of the merge request' + field :target_branch, GraphQL::STRING_TYPE, null: false, + description: 'Target branch of the merge request' + field :work_in_progress, GraphQL::BOOLEAN_TYPE, method: :work_in_progress?, null: false, + description: 'Indicates if the merge request is a work in progress (WIP)' + field :merge_when_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS)' + field :diff_head_sha, GraphQL::STRING_TYPE, null: true, + description: 'Diff head SHA of the merge request' + field :merge_commit_sha, GraphQL::STRING_TYPE, null: true, + description: 'SHA of the merge request commit (set once merged)' + field :user_notes_count, GraphQL::INT_TYPE, null: true, + description: 'User notes count of the merge request' + field :should_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :should_remove_source_branch?, null: true, + description: 'Indicates if the source branch of the merge request will be deleted after merge' + field :force_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :force_remove_source_branch?, null: true, + description: 'Indicates if the project settings will lead to source branch deletion after merge' + field :merge_status, GraphQL::STRING_TYPE, null: true, + description: 'Status of the merge request' + field :in_progress_merge_commit_sha, GraphQL::STRING_TYPE, null: true, + description: 'Commit SHA of the merge request if merge is in progress' + field :merge_error, GraphQL::STRING_TYPE, null: true, + description: 'Error message due to a merge error' + field :allow_collaboration, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if members of the target project can push to the fork' + field :should_be_rebased, GraphQL::BOOLEAN_TYPE, method: :should_be_rebased?, null: false, + description: 'Indicates if the merge request will be rebased' + field :rebase_commit_sha, GraphQL::STRING_TYPE, null: true, + description: 'Rebase commit SHA of the merge request' + field :rebase_in_progress, GraphQL::BOOLEAN_TYPE, method: :rebase_in_progress?, null: false, calls_gitaly: true, + description: 'Indicates if there is a rebase currently in progress for the merge request' + field :merge_commit_message, GraphQL::STRING_TYPE, method: :default_merge_commit_message, null: true, deprecation_reason: "Renamed to defaultMergeCommitMessage", + description: 'Deprecated - renamed to defaultMergeCommitMessage' + field :default_merge_commit_message, GraphQL::STRING_TYPE, null: true, + description: 'Default merge commit message of the merge request' + field :merge_ongoing, GraphQL::BOOLEAN_TYPE, method: :merge_ongoing?, null: false, + description: 'Indicates if a merge is currently occurring' + field :source_branch_exists, GraphQL::BOOLEAN_TYPE, method: :source_branch_exists?, null: false, + description: 'Indicates if the source branch of the merge request exists' + field :mergeable_discussions_state, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if all discussions in the merge request have been resolved, allowing the merge request to be merged' + field :web_url, GraphQL::STRING_TYPE, null: true, + description: 'Web URL of the merge request' + field :upvotes, GraphQL::INT_TYPE, null: false, + description: 'Number of upvotes for the merge request' + field :downvotes, GraphQL::INT_TYPE, null: false, + description: 'Number of downvotes for the merge request' - field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline # rubocop:disable Graphql/Descriptions - field :pipelines, Types::Ci::PipelineType.connection_type, # rubocop:disable Graphql/Descriptions + field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline, + description: 'The pipeline running on the branch HEAD of the merge request' + field :pipelines, Types::Ci::PipelineType.connection_type, + description: 'Pipelines for the merge request', resolver: Resolvers::MergeRequestPipelinesResolver - field :milestone, Types::MilestoneType, description: 'The milestone this merge request is linked to', - null: true, + field :milestone, Types::MilestoneType, null: true, + description: 'The milestone of the merge request', resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find } - field :assignees, Types::UserType.connection_type, null: true, complexity: 5, description: 'The list of assignees for the merge request' - field :participants, Types::UserType.connection_type, null: true, complexity: 5, description: 'The list of participants on the merge request' + field :assignees, Types::UserType.connection_type, null: true, complexity: 5, + description: 'Assignees of the merge request' + field :participants, Types::UserType.connection_type, null: true, complexity: 5, + description: 'Participants in the merge request' 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 MR' - field :labels, Types::LabelType.connection_type, null: true, complexity: 5, description: 'The list of labels on the merge request' - field :discussion_locked, GraphQL::BOOLEAN_TYPE, description: 'Boolean flag determining if comments on the merge request are locked to members only', + description: 'Indicates if the currently logged in user is subscribed to this merge request' + field :labels, Types::LabelType.connection_type, null: true, complexity: 5, + description: 'Labels of the merge request' + field :discussion_locked, GraphQL::BOOLEAN_TYPE, + description: 'Indicates if comments on the merge request are locked to members only', null: false, resolve: -> (obj, _args, _ctx) { !!obj.discussion_locked } - field :time_estimate, GraphQL::INT_TYPE, null: false, description: 'The time estimate for the merge request' - field :total_time_spent, GraphQL::INT_TYPE, null: false, description: 'Total time reported as spent on the merge request' - field :reference, GraphQL::STRING_TYPE, null: false, method: :to_reference, description: 'Internal merge request reference. Returned in shortened format by default' do - argument :full, GraphQL::BOOLEAN_TYPE, required: false, default_value: false, description: 'Boolean option specifying whether the reference should be returned in full' + field :time_estimate, GraphQL::INT_TYPE, null: false, + description: 'Time estimate of the merge request' + field :total_time_spent, GraphQL::INT_TYPE, null: false, + description: 'Total time reported as spent on the merge request' + field :reference, GraphQL::STRING_TYPE, null: false, method: :to_reference, + description: 'Internal reference of the merge request. Returned in shortened format by default' do + argument :full, GraphQL::BOOLEAN_TYPE, required: false, default_value: false, + description: 'Boolean option specifying whether the reference should be returned in full' end - field :task_completion_status, Types::TaskCompletionStatus, null: false # rubocop:disable Graphql/Descriptions + field :task_completion_status, Types::TaskCompletionStatus, null: false, + description: Types::TaskCompletionStatus.description end end diff --git a/app/graphql/types/metadata_type.rb b/app/graphql/types/metadata_type.rb index bfcb929f5ace2d76235e4d8f9010b3f7de78416a..1998b036a53b41b7a7789f21bf9b9b30dfec7e1f 100644 --- a/app/graphql/types/metadata_type.rb +++ b/app/graphql/types/metadata_type.rb @@ -6,7 +6,9 @@ module Types authorize :read_instance_metadata - field :version, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :revision, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :version, GraphQL::STRING_TYPE, null: false, + description: 'Version' + field :revision, GraphQL::STRING_TYPE, null: false, + description: 'Revision' end end diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb index 78d0a8220ec1ba1312115fd5daa1132915f8c827..9c3afb28674e357e5e9f61f55891f5571275f05f 100644 --- a/app/graphql/types/milestone_type.rb +++ b/app/graphql/types/milestone_type.rb @@ -6,14 +6,23 @@ module Types authorize :read_milestone - field :description, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :title, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :state, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the milestone' + field :description, GraphQL::STRING_TYPE, null: true, + description: 'Description of the milestone' + field :title, GraphQL::STRING_TYPE, null: false, + description: 'Title of the milestone' + field :state, GraphQL::STRING_TYPE, null: false, + description: 'State of the milestone' - field :due_date, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions - field :start_date, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions + field :due_date, Types::TimeType, null: true, + description: 'Timestamp of the milestone due date' + field :start_date, Types::TimeType, null: true, + description: 'Timestamp of the milestone start date' - field :created_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions - field :updated_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions + field :created_at, Types::TimeType, null: false, + description: 'Timestamp of milestone creation' + field :updated_at, Types::TimeType, null: false, + description: 'Timestamp of last milestone update' end end diff --git a/app/graphql/types/mutation_operation_mode_enum.rb b/app/graphql/types/mutation_operation_mode_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..90a29d2b0e51dd7124265bd990e065568f1bd9d6 --- /dev/null +++ b/app/graphql/types/mutation_operation_mode_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + class MutationOperationModeEnum < BaseEnum + graphql_name 'MutationOperationMode' + description 'Different toggles for changing mutator behavior.' + + # Suggested param name for the enum: `operation_mode` + + value 'REPLACE', 'Performs a replace operation' + value 'APPEND', 'Performs an append operation' + value 'REMOVE', 'Performs a removal operation' + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 17f922a5e5405d0c8df527c97dbccd495b67958a..b3c7c162bb3686d166655b2d6d8ad9d378d17723 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -9,12 +9,18 @@ module Types mount_mutation Mutations::AwardEmojis::Add mount_mutation Mutations::AwardEmojis::Remove mount_mutation Mutations::AwardEmojis::Toggle + mount_mutation Mutations::MergeRequests::SetLabels + mount_mutation Mutations::MergeRequests::SetLocked + mount_mutation Mutations::MergeRequests::SetMilestone + mount_mutation Mutations::MergeRequests::SetSubscription mount_mutation Mutations::MergeRequests::SetWip, calls_gitaly: true + mount_mutation Mutations::MergeRequests::SetAssignees mount_mutation Mutations::Notes::Create::Note, calls_gitaly: true mount_mutation Mutations::Notes::Create::DiffNote, calls_gitaly: true mount_mutation Mutations::Notes::Create::ImageDiffNote, calls_gitaly: true mount_mutation Mutations::Notes::Update mount_mutation Mutations::Notes::Destroy + mount_mutation Mutations::Todos::MarkDone end end diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb index cc1d06b19e1ba86bb2516fb86bf8e380255c6622..1714284a5cf3174e003137489335e7e06f3284cd 100644 --- a/app/graphql/types/namespace_type.rb +++ b/app/graphql/types/namespace_type.rb @@ -6,27 +6,35 @@ module Types authorize :read_namespace - field :id, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the namespace' - field :name, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :path, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :full_name, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :full_path, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :name, GraphQL::STRING_TYPE, null: false, + description: 'Name of the namespace' + field :path, GraphQL::STRING_TYPE, null: false, + description: 'Path of the namespace' + field :full_name, GraphQL::STRING_TYPE, null: false, + description: 'Full name of the namespace' + field :full_path, GraphQL::ID_TYPE, null: false, + description: 'Full path of the namespace' - field :description, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions + field :description, GraphQL::STRING_TYPE, null: true, + description: 'Description of the namespace' markdown_field :description_html, null: true - field :visibility, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled? # rubocop:disable Graphql/Descriptions - field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions + field :visibility, GraphQL::STRING_TYPE, null: true, + description: 'Visibility of the namespace' + field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled?, + description: 'Indicates if Large File Storage (LFS) is enabled for namespace' + field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if users can request access to namespace' field :root_storage_statistics, Types::RootStorageStatisticsType, null: true, - description: 'The aggregated storage statistics. Only available for root namespaces', + description: 'Aggregated storage statistics of the namespace. Only available for root namespaces', resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader.new(obj.id).find } - field :projects, # rubocop:disable Graphql/Descriptions - Types::ProjectType.connection_type, - null: false, + field :projects, Types::ProjectType.connection_type, null: false, + description: 'Projects within this namespace', resolver: ::Resolvers::NamespaceProjectsResolver end end diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb index 5045471a75b4685f85f35277f783cebed1a95a47..c46410df6c078cf237c755582ac4630217a77da1 100644 --- a/app/graphql/types/project_statistics_type.rb +++ b/app/graphql/types/project_statistics_type.rb @@ -6,13 +6,20 @@ module Types authorize :read_statistics - field :commit_count, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :commit_count, GraphQL::INT_TYPE, null: false, + description: 'Commit count of the project' - field :storage_size, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :repository_size, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :lfs_objects_size, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :build_artifacts_size, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :packages_size, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :wiki_size, GraphQL::INT_TYPE, null: true # rubocop:disable Graphql/Descriptions + field :storage_size, GraphQL::INT_TYPE, null: false, + description: 'Storage size of the project' + field :repository_size, GraphQL::INT_TYPE, null: false, + description: 'Repository size of the project' + field :lfs_objects_size, GraphQL::INT_TYPE, null: false, + description: 'Large File Storage (LFS) object size of the project' + field :build_artifacts_size, GraphQL::INT_TYPE, null: false, + description: 'Build artifacts size of the project' + field :packages_size, GraphQL::INT_TYPE, null: false, + description: 'Packages size of the project' + field :wiki_size, GraphQL::INT_TYPE, null: true, + description: 'Wiki size of the project' end end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 5663f833b7a11ffd4d4ff9fba039c335bd7f0eba..732550211196d961f36fae32d9271d33ecf9f4c4 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -8,97 +8,142 @@ module Types expose_permissions Types::PermissionTypes::Project - field :id, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the project' - field :full_path, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :path, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :full_path, GraphQL::ID_TYPE, null: false, + description: 'Full path of the project' + field :path, GraphQL::STRING_TYPE, null: false, + description: 'Path of the project' - field :name_with_namespace, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :name, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :name_with_namespace, GraphQL::STRING_TYPE, null: false, + description: 'Full name of the project with its namespace' + field :name, GraphQL::STRING_TYPE, null: false, + description: 'Name of the project (without namespace)' - field :description, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions + field :description, GraphQL::STRING_TYPE, null: true, + description: 'Short description of the project' markdown_field :description_html, null: true - field :tag_list, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - - field :ssh_url_to_repo, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :http_url_to_repo, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :web_url, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - - field :star_count, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :forks_count, GraphQL::INT_TYPE, null: false, calls_gitaly: true # 4 times # rubocop:disable Graphql/Descriptions - - field :created_at, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions - field :last_activity_at, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions - - field :archived, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - - field :visibility, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - - field :container_registry_enabled, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :shared_runners_enabled, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :merge_requests_ff_only_enabled, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - - field :avatar_url, GraphQL::STRING_TYPE, null: true, calls_gitaly: true, resolve: -> (project, args, ctx) do # rubocop:disable Graphql/Descriptions - project.avatar_url(only_path: false) - end + field :tag_list, GraphQL::STRING_TYPE, null: true, + description: 'List of project tags' + + field :ssh_url_to_repo, GraphQL::STRING_TYPE, null: true, + description: 'URL to connect to the project via SSH' + field :http_url_to_repo, GraphQL::STRING_TYPE, null: true, + description: 'URL to connect to the project via HTTPS' + field :web_url, GraphQL::STRING_TYPE, null: true, + description: 'Web URL of the project' + + field :star_count, GraphQL::INT_TYPE, null: false, + description: 'Number of times the project has been starred' + field :forks_count, GraphQL::INT_TYPE, null: false, calls_gitaly: true, # 4 times + description: 'Number of times the project has been forked' + + field :created_at, Types::TimeType, null: true, + description: 'Timestamp of the project creation' + field :last_activity_at, Types::TimeType, null: true, + description: 'Timestamp of the project last activity' + + field :archived, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Archived status of the project' + + field :visibility, GraphQL::STRING_TYPE, null: true, + description: 'Visibility of the project' + + field :container_registry_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if the project stores Docker container images in a container registry' + field :shared_runners_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if shared runners are enabled on the project' + field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if the project has Large File Storage (LFS) enabled' + field :merge_requests_ff_only_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if no merge commits should be created and all merges should instead be fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.' + + field :avatar_url, GraphQL::STRING_TYPE, null: true, calls_gitaly: true, + description: 'URL to avatar image file of the project', + resolve: -> (project, args, ctx) do + project.avatar_url(only_path: false) + end %i[issues merge_requests wiki snippets].each do |feature| - field "#{feature}_enabled", GraphQL::BOOLEAN_TYPE, null: true, resolve: -> (project, args, ctx) do # rubocop:disable Graphql/Descriptions - project.feature_available?(feature, ctx[:current_user]) - end + field "#{feature}_enabled", GraphQL::BOOLEAN_TYPE, null: true, + description: "(deprecated) Does this project have #{feature} enabled?. Use `#{feature}_access_level` instead", + resolve: -> (project, args, ctx) do + project.feature_available?(feature, ctx[:current_user]) + end end - field :jobs_enabled, GraphQL::BOOLEAN_TYPE, null: true, resolve: -> (project, args, ctx) do # rubocop:disable Graphql/Descriptions - project.feature_available?(:builds, ctx[:current_user]) - end - - field :public_jobs, GraphQL::BOOLEAN_TYPE, method: :public_builds, null: true # rubocop:disable Graphql/Descriptions - - field :open_issues_count, GraphQL::INT_TYPE, null: true, resolve: -> (project, args, ctx) do # rubocop:disable Graphql/Descriptions - project.open_issues_count if project.feature_available?(:issues, ctx[:current_user]) - end - - field :import_status, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - - field :only_allow_merge_if_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :printing_merge_request_link_enabled, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - - field :namespace, Types::NamespaceType, null: true # rubocop:disable Graphql/Descriptions - field :group, Types::GroupType, null: true # rubocop:disable Graphql/Descriptions - - field :statistics, Types::ProjectStatisticsType, # rubocop:disable Graphql/Descriptions + field :jobs_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: '(deprecated) Enable jobs for this project. Use `builds_access_level` instead', + resolve: -> (project, args, ctx) do + project.feature_available?(:builds, ctx[:current_user]) + end + + field :public_jobs, GraphQL::BOOLEAN_TYPE, method: :public_builds, null: true, + description: 'Indicates if there is public access to pipelines and job details of the project, including output logs and artifacts' + + field :open_issues_count, GraphQL::INT_TYPE, null: true, + description: 'Number of open issues for the project', + resolve: -> (project, args, ctx) do + project.open_issues_count if project.feature_available?(:issues, ctx[:current_user]) + end + + field :import_status, GraphQL::STRING_TYPE, null: true, + description: 'Status of project import background job of the project' + + field :only_allow_merge_if_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if merge requests of the project can only be merged with successful jobs' + field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if users can request member access to the project' + field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if merge requests of the project can only be merged when all the discussions are resolved' + field :printing_merge_request_link_enabled, GraphQL::BOOLEAN_TYPE, null: true, + 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 :namespace, Types::NamespaceType, null: true, + description: 'Namespace of the project' + field :group, Types::GroupType, null: true, + description: 'Group of the project' + + field :statistics, Types::ProjectStatisticsType, null: true, + description: 'Statistics of the project', resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader.new(obj.id).find } - field :repository, Types::RepositoryType, null: true # rubocop:disable Graphql/Descriptions + field :repository, Types::RepositoryType, null: true, + description: 'Git repository of the project' - field :merge_requests, # rubocop:disable Graphql/Descriptions + field :merge_requests, Types::MergeRequestType.connection_type, null: true, + description: 'Merge requests of the project', resolver: Resolvers::MergeRequestsResolver - field :merge_request, # rubocop:disable Graphql/Descriptions + field :merge_request, Types::MergeRequestType, null: true, + description: 'A single merge request of the project', resolver: Resolvers::MergeRequestsResolver.single - field :issues, # rubocop:disable Graphql/Descriptions + field :issues, Types::IssueType.connection_type, null: true, + description: 'Issues of the project', resolver: Resolvers::IssuesResolver - field :issue, # rubocop:disable Graphql/Descriptions - Types::ExtendedIssueType, + field :issue, + Types::IssueType, null: true, + description: 'A single issue of the project', resolver: Resolvers::IssuesResolver.single - field :pipelines, # rubocop:disable Graphql/Descriptions + field :pipelines, Types::Ci::PipelineType.connection_type, null: true, + description: 'Build pipelines of the project', resolver: Resolvers::ProjectPipelinesResolver end end diff --git a/app/graphql/types/repository_type.rb b/app/graphql/types/repository_type.rb index 9ecd336b41dccd193495e35e11dcc7be61c8698a..f0c25e13a268426f2ab28d0953d1dd5932938497 100644 --- a/app/graphql/types/repository_type.rb +++ b/app/graphql/types/repository_type.rb @@ -6,9 +6,13 @@ module Types authorize :download_code - field :root_ref, GraphQL::STRING_TYPE, null: true, calls_gitaly: true # rubocop:disable Graphql/Descriptions - field :empty, GraphQL::BOOLEAN_TYPE, null: false, method: :empty?, calls_gitaly: true # rubocop:disable Graphql/Descriptions - field :exists, GraphQL::BOOLEAN_TYPE, null: false, method: :exists? # rubocop:disable Graphql/Descriptions - field :tree, Types::Tree::TreeType, null: true, resolver: Resolvers::TreeResolver, calls_gitaly: true # rubocop:disable Graphql/Descriptions + field :root_ref, GraphQL::STRING_TYPE, null: true, calls_gitaly: true, + description: 'Default branch of the repository' + field :empty, GraphQL::BOOLEAN_TYPE, null: false, method: :empty?, calls_gitaly: true, + description: 'Indicates repository has no visible content' + field :exists, GraphQL::BOOLEAN_TYPE, null: false, method: :exists?, + description: 'Indicates a corresponding Git repository exists on disk' + field :tree, Types::Tree::TreeType, null: true, resolver: Resolvers::TreeResolver, calls_gitaly: true, + description: 'Tree of the repository' end end diff --git a/app/graphql/types/task_completion_status.rb b/app/graphql/types/task_completion_status.rb index 0aa8fc60a7c0795eb749e82222bfc32a8ed8cd8c..73a8b4f3020564b0b80afff8e56a22352992fe58 100644 --- a/app/graphql/types/task_completion_status.rb +++ b/app/graphql/types/task_completion_status.rb @@ -8,8 +8,10 @@ module Types graphql_name 'TaskCompletionStatus' description 'Completion status of tasks' - field :count, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :completed_count, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :count, GraphQL::INT_TYPE, null: false, + description: 'Number of total tasks' + field :completed_count, GraphQL::INT_TYPE, null: false, + description: 'Number of completed tasks' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/todo_target_enum.rb b/app/graphql/types/todo_target_enum.rb index 9a7391dcd9970bae1491826fa9a9f61838843727..8358a86b35c7582a0523db8336e1d152cdb5d7b7 100644 --- a/app/graphql/types/todo_target_enum.rb +++ b/app/graphql/types/todo_target_enum.rb @@ -2,8 +2,10 @@ module Types class TodoTargetEnum < BaseEnum - value 'Issue' - value 'MergeRequest' - value 'Epic' + value 'COMMIT', value: 'Commit', description: 'A Commit' + value 'ISSUE', value: 'Issue', description: 'An Issue' + value 'MERGEREQUEST', value: 'MergeRequest', description: 'A MergeRequest' end end + +Types::TodoTargetEnum.prepend_if_ee('::EE::Types::TodoTargetEnum') diff --git a/app/graphql/types/todo_type.rb b/app/graphql/types/todo_type.rb index d36daaf7dec1266fd232af789eefc3b055ff0def..5ce5093c55e371705a3dabf6520727dae70cd44b 100644 --- a/app/graphql/types/todo_type.rb +++ b/app/graphql/types/todo_type.rb @@ -40,7 +40,8 @@ module Types field :body, GraphQL::STRING_TYPE, description: 'Body of the todo', - null: false + null: false, + calls_gitaly: true # TODO This is only true when `target_type` is `Commit`. See https://gitlab.com/gitlab-org/gitlab/issues/34757#note_234752665 field :state, Types::TodoStateEnum, description: 'State of the todo', diff --git a/app/graphql/types/tree/entry_type.rb b/app/graphql/types/tree/entry_type.rb index 10c2ad8815e95585658b20acc6139aab9d4e48c0..87a3eced896cf98e32d3c480f7e9f0511d45c2d8 100644 --- a/app/graphql/types/tree/entry_type.rb +++ b/app/graphql/types/tree/entry_type.rb @@ -5,6 +5,7 @@ module Types 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 diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index 1ba37927b409273b264726ff40d0c661caa28e4d..b45c7893e75cb92f91c5a8f46dd99ca24193060f 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -8,12 +8,16 @@ module Types present_using UserPresenter - field :name, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :username, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :avatar_url, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :web_url, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :name, GraphQL::STRING_TYPE, null: false, + description: 'Human-readable name of the user' + field :username, GraphQL::STRING_TYPE, null: false, + description: 'Username of the user. Unique within this instance of GitLab' + field :avatar_url, GraphQL::STRING_TYPE, null: false, + description: "URL of the user's avatar" + field :web_url, GraphQL::STRING_TYPE, null: false, + description: 'Web URL of the user' field :todos, Types::TodoType.connection_type, null: false, resolver: Resolvers::TodoResolver, - description: 'Todos of this user' + description: 'Todos of the user' end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index ecaeb7060c87273948012c5d8b9797086154dbc5..3ae804ff23141e8610799af9d6de2c84b2096f0f 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -324,6 +324,15 @@ module ApplicationHelper } end + def asset_to_string(name) + app = Rails.application + if Rails.configuration.assets.compile + app.assets.find_asset(name).to_s + else + controller.view_context.render(file: Rails.root.join('public/assets', app.assets_manifest.assets[name]).to_s) + end + end + private def appearance diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index df17b82412fb4972a343b2fd4f284f3ff939638a..a011209375e21ed5b64ad619e0b9eb9dca5361ae 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -3,11 +3,11 @@ module ApplicationSettingsHelper extend self - delegate :allow_signup?, - :gravatar_enabled?, - :password_authentication_enabled_for_web?, - :akismet_enabled?, - to: :'Gitlab::CurrentSettings.current_application_settings' + delegate :allow_signup?, + :gravatar_enabled?, + :password_authentication_enabled_for_web?, + :akismet_enabled?, + to: :'Gitlab::CurrentSettings.current_application_settings' def user_oauth_applications? Gitlab::CurrentSettings.user_oauth_applications @@ -176,6 +176,7 @@ module ApplicationSettingsHelper :container_registry_token_expire_delay, :default_artifacts_expire_in, :default_branch_protection, + :default_ci_config_path, :default_group_visibility, :default_project_creation, :default_project_visibility, @@ -193,6 +194,10 @@ module ApplicationSettingsHelper :dsa_key_restriction, :ecdsa_key_restriction, :ed25519_key_restriction, + :eks_integration_enabled, + :eks_account_id, + :eks_access_key_id, + :eks_secret_access_key, :email_author_in_body, :enabled_git_access_protocol, :enforce_terms, @@ -254,6 +259,9 @@ module ApplicationSettingsHelper :shared_runners_text, :sign_in_text, :signup_enabled, + :sourcegraph_enabled, + :sourcegraph_url, + :sourcegraph_public_only, :terminal_max_session_time, :terms, :throttle_authenticated_api_enabled, @@ -289,7 +297,8 @@ module ApplicationSettingsHelper :snowplow_collector_hostname, :snowplow_cookie_domain, :snowplow_enabled, - :snowplow_site_id, + :snowplow_app_id, + :snowplow_iglu_registry_url, :push_event_hooks_limit, :push_event_activities_limit, :custom_http_clone_url_root @@ -312,6 +321,10 @@ module ApplicationSettingsHelper Rails.env.test? end + def integration_expanded?(substring) + @application_setting.errors.any? { |k| k.to_s.start_with?(substring) } + end + def instance_clusters_enabled? can?(current_user, :read_cluster, Clusters::Instance.new) end diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 9e6fcf6a2677d6f18e5cbdf9d3f0e6db6393d02c..a9c4cfe7dccdde37285dfa1a8532173de33a365b 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -8,6 +8,10 @@ module AuthHelper Gitlab::Auth::LDAP::Config.enabled? end + def ldap_sign_in_enabled? + Gitlab::Auth::LDAP::Config.sign_in_enabled? + end + def omniauth_enabled? Gitlab::Auth.omniauth_enabled? end @@ -56,6 +60,16 @@ module AuthHelper auth_providers.select { |provider| form_based_provider?(provider) } end + def any_form_based_providers_enabled? + form_based_providers.any? { |provider| form_enabled_for_sign_in?(provider) } + end + + def form_enabled_for_sign_in?(provider) + return true unless provider.to_s.match?(LDAP_PROVIDER) + + ldap_sign_in_enabled? + end + def crowd_enabled? auth_providers.include? :crowd end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 5c24b0e1704e31089c0891ce6b7e155eef701225..912f0b619786f737a479f6ce77df63dff527428d 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -47,7 +47,7 @@ module BlobHelper def edit_blob_button(project = @project, ref = @ref, path = @path, options = {}) return unless blob = readable_blob(options, path, project, ref) - common_classes = "btn js-edit-blob #{options[:extra_class]}" + common_classes = "btn btn-primary js-edit-blob #{options[:extra_class]}" edit_button_tag(blob, common_classes, @@ -62,7 +62,7 @@ module BlobHelper return unless blob = readable_blob(options, path, project, ref) edit_button_tag(blob, - 'btn btn-default', + 'btn btn-inverted btn-primary ide-edit-button', _('Web IDE'), ide_edit_path(project, ref, path, options), project, @@ -108,7 +108,7 @@ module BlobHelper path, label: _("Delete"), action: "delete", - btn_class: "remove", + btn_class: "default", modal_type: "remove" ) end @@ -141,11 +141,7 @@ module BlobHelper if @build && @entry raw_project_job_artifacts_url(@project, @build, path: @entry.path, **kwargs) elsif @snippet - if @snippet.project_id - raw_project_snippet_url(@project, @snippet, **kwargs) - else - raw_snippet_url(@snippet, **kwargs) - end + reliable_raw_snippet_url(@snippet) elsif @blob project_raw_url(@project, @id, **kwargs) end diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb index a5fe6bb8f0799237edf6f18a6c55d3732b6d1c1c..2def34881843ea4670cae454ab145f028ccd38fb 100644 --- a/app/helpers/builds_helper.rb +++ b/app/helpers/builds_helper.rb @@ -4,12 +4,12 @@ module BuildsHelper def build_summary(build, skip: false) if build.has_trace? if skip - link_to _("View job trace"), pipeline_job_url(build.pipeline, build) + link_to _("View job log"), pipeline_job_url(build.pipeline, build) else build.trace.html(last_lines: 10).html_safe end else - _("No job trace") + _("No job log") end end diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index 7ca509873ccba2b0b9b864ac149597eb285f6ae3..0037c49f134f8e4cfbaea40d891d1c96a2e04b31 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -6,6 +6,28 @@ module ClustersHelper false end + def create_new_cluster_label(provider: nil) + case provider + when 'aws' + s_('ClusterIntegration|Create new Cluster on EKS') + when 'gcp' + s_('ClusterIntegration|Create new Cluster on GKE') + else + s_('ClusterIntegration|Create new Cluster') + end + end + + def new_cluster_partial(provider: nil) + case provider + when 'aws' + 'clusters/clusters/aws/new' + when 'gcp' + 'clusters/clusters/gcp/new' + else + 'clusters/clusters/cloud_providers/cloud_provider_selector' + end + end + def render_gcp_signup_offer return if Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers? return unless show_gcp_signup_offer? @@ -18,7 +40,7 @@ module ClustersHelper def has_rbac_enabled?(cluster) return cluster.platform_kubernetes_rbac? if cluster.platform_kubernetes - !cluster.provider.legacy_abac? + cluster.provider.has_rbac_enabled? end end diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb index 518cb7c97142dd0be64aefc9b60c81775456f346..679622897aaa5459ce09efba3460a97671188fb5 100644 --- a/app/helpers/dashboard_helper.rb +++ b/app/helpers/dashboard_helper.rb @@ -27,16 +27,25 @@ module DashboardHelper false end - def feature_entry(title, href: nil, enabled: true) + def feature_entry(title, href: nil, enabled: true, doc_href: nil) enabled_text = enabled ? 'on' : 'off' label = "#{title}: status #{enabled_text}" link_or_title = href && enabled ? tag.a(title, href: href) : title tag.p(aria: { label: label }) do concat(link_or_title) + concat(tag.span(class: ['light', 'float-right']) do - concat(boolean_to_icon(enabled)) + boolean_to_icon(enabled) end) + + if doc_href.present? + link_to_doc = link_to(sprite_icon('question', size: 16), doc_href, + class: 'prepend-left-5', title: _('Documentation'), + target: '_blank', rel: 'noopener noreferrer') + + concat(link_to_doc) + end end end diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index c642a64ad611551ec94aa2b5fc883c3e76421688..f57d0fa19d412302578c68e2d8525c84d8e83fd5 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -34,6 +34,7 @@ module EnvironmentsHelper "project-path" => project_path(project), "tags-path" => project_tags_path(project), "has-metrics" => "#{environment.has_metrics?}", + "prometheus-status" => "#{environment.prometheus_status}", "external-dashboard-url" => project.metrics_setting_external_dashboard_url } end diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 4f31cc67cccaa8ab151633bcc4fe142dc37ad2a8..404ea7b00d4ae19a54777c23ff26bac19d661c83 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -66,7 +66,7 @@ module GitlabRoutingHelper end def preview_markdown_path(parent, *args) - return group_preview_markdown_path(parent) if parent.is_a?(Group) + return group_preview_markdown_path(parent, *args) if parent.is_a?(Group) if @snippet.is_a?(PersonalSnippet) preview_markdown_snippets_path diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index df9d19332717654a35639691cec138530215f439..3c72f41a4c91c502501dfc6b9d0fb742d2576fe6 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -281,10 +281,7 @@ module IssuablesHelper } data[:hasClosingMergeRequest] = issuable.merge_requests_count(current_user) != 0 if issuable.is_a?(Issue) - - zoom_links = Gitlab::ZoomLinkExtractor.new(issuable.description).links - - data[:zoomMeetingUrl] = zoom_links.last if zoom_links.any? + data[:zoomMeetingUrl] = ZoomMeeting.canonical_meeting_url(issuable) if issuable.is_a?(Issue) if parent.is_a?(Group) data[:groupPath] = parent.path diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index e2524938e10f161d3023ba653b3cf65c81c7d018..e1e756c2f4ce6c79a22c328bb850dd02287a485c 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -51,12 +51,15 @@ module MarkupHelper text = fragment.children[0].text fragment.children[0].replace(link_to(text, url, html_options)) else - # Traverse the fragment's first generation of children looking for pure - # text, wrapping anything found in the requested link + # Traverse the fragment's first generation of children looking for + # either pure text or emojis, wrapping anything found in the + # requested link fragment.children.each do |node| - next unless node.text? - - node.replace(link_to(node.text, url, html_options)) + if node.text? + node.replace(link_to(node.text, url, html_options)) + elsif node.name == 'gl-emoji' + node.replace(link_to(node.to_html.html_safe, url, html_options)) + end end end diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index e769734f27bb365f33f166b8d1e0f0ad3df3915f..b12b39073ef451d2e0fada08708dc16b5dfb2f4e 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -4,6 +4,18 @@ module MilestonesHelper include EntityDateHelper include Gitlab::Utils::StrongMemoize + def milestone_status_string(milestone) + if milestone.closed? + _('Closed') + elsif milestone.expired? + _('Past due') + elsif milestone.upcoming? + _('Upcoming') + else + _('Open') + end + end + def milestones_filter_path(opts = {}) if @project project_milestones_path(@project, opts) @@ -170,6 +182,23 @@ module MilestonesHelper content.join('<br />').html_safe end + def milestone_releases_tooltip_text(milestone) + count = milestone.releases.count + + return _("Releases") if count.zero? + + n_("%{releases} release", "%{releases} releases", count) % { releases: count } + end + + def recent_releases_with_counts(milestone) + total_count = milestone.releases.size + return [[], 0, 0] if total_count == 0 + + recent_releases = milestone.releases.recent.to_a + more_count = total_count - recent_releases.size + [recent_releases, total_count, more_count] + end + def milestone_tooltip_due_date(milestone) if milestone.due_date "#{milestone.due_date.to_s(:medium)} (#{remaining_days_in_words(milestone.due_date, milestone.start_date)})" @@ -196,33 +225,19 @@ module MilestonesHelper end end - def milestone_merge_request_tab_path(milestone) - if @project - merge_requests_project_milestone_path(@project, milestone, format: :json) - elsif @group - merge_requests_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json) - else - merge_requests_dashboard_milestone_path(milestone, title: milestone.title, format: :json) - end - end - - def milestone_participants_tab_path(milestone) - if @project - participants_project_milestone_path(@project, milestone, format: :json) - elsif @group - participants_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json) + def milestone_tab_path(milestone, tab) + if milestone.global_milestone? + url_for(action: tab, title: milestone.title, format: :json) else - participants_dashboard_milestone_path(milestone, title: milestone.title, format: :json) + url_for(action: tab, format: :json) end end - def milestone_labels_tab_path(milestone) - if @project - labels_project_milestone_path(@project, milestone, format: :json) - elsif @group - labels_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json) + def update_milestone_path(milestone, params = {}) + if milestone.project_milestone? + project_milestone_path(milestone.project, milestone, milestone: params) else - labels_dashboard_milestone_path(milestone, title: milestone.title, format: :json) + group_milestone_route(milestone, params) end end @@ -247,6 +262,14 @@ module MilestonesHelper milestone_path(milestone.milestone, params) end + def edit_milestone_path(milestone) + if milestone.group_milestone? + edit_group_milestone_path(milestone.group, milestone) + elsif milestone.project_milestone? + edit_project_milestone_path(milestone.project, milestone) + end + end + def can_admin_project_milestones? strong_memoize(:can_admin_project_milestones) do can?(current_user, :admin_milestone, @project) diff --git a/app/helpers/projects/error_tracking_helper.rb b/app/helpers/projects/error_tracking_helper.rb index fd1222a1dfba1ba54b17bf95586417ad11a47fae..2f5f612ed4c66a37cc8f48b06af5a00393777de2 100644 --- a/app/helpers/projects/error_tracking_helper.rb +++ b/app/helpers/projects/error_tracking_helper.rb @@ -13,4 +13,13 @@ module Projects::ErrorTrackingHelper 'illustration-path' => image_path('illustrations/cluster_popover.svg') } end + + def error_details_data(project, issue) + opts = [project, issue, { format: :json }] + + { + 'issue-details-path' => details_namespace_project_error_tracking_index_path(*opts), + 'issue-stack-trace-path' => stack_trace_namespace_project_error_tracking_index_path(*opts) + } + end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 16360c7139aa788f1c2f7bf023e9b6c4f2f37ab2..47214ac4ee2ad5117f09aebe90c69d3bdc65e07e 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -362,6 +362,10 @@ module ProjectsHelper @project.grafana_integration&.token end + def grafana_integration_enabled? + @project.grafana_integration&.enabled? + end + private def get_project_nav_tabs(project, current_user) diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb index 68a19152d8f8d7e65f9f7fb4720fdfe62a7fc3cf..c4fe40a087568d1e403bdfcfc6b5c05f97e85c36 100644 --- a/app/helpers/releases_helper.rb +++ b/app/helpers/releases_helper.rb @@ -26,7 +26,8 @@ module ReleasesHelper tag_name: @release.tag, markdown_preview_path: preview_markdown_path(@project), markdown_docs_path: help_page_path('user/markdown'), - releases_page_path: project_releases_path(@project, anchor: @release.tag) + releases_page_path: project_releases_path(@project, anchor: @release.tag), + update_release_api_docs_path: help_page_path('api/releases/index.md', anchor: 'update-a-release') } end end diff --git a/app/helpers/repository_languages_helper.rb b/app/helpers/repository_languages_helper.rb index cf7eee7fff3a4078aabd6ba4b43c8e0548dcd928..7834e86adab66795cc19bdc270c8de3f68af3ace 100644 --- a/app/helpers/repository_languages_helper.rb +++ b/app/helpers/repository_languages_helper.rb @@ -4,7 +4,7 @@ module RepositoryLanguagesHelper def repository_languages_bar(languages) return if languages.none? - content_tag :div, class: 'progress repository-languages-bar' do + content_tag :div, class: 'progress repository-languages-bar js-show-on-project-root' do safe_join(languages.map { |lang| language_progress(lang) }) end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 9a19758b4e8bdce124d4373a891f79bf26db7283..777fe82e4c04d65ca2058abfe27eeee5d8f6e41b 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module SearchHelper + SEARCH_PERMITTED_PARAMS = [:search, :scope, :project_id, :group_id, :repository_ref, :snippets].freeze + def search_autocomplete_opts(term) return unless current_user @@ -96,8 +98,9 @@ module SearchHelper result end - def search_blob_title(project, filename) - filename + # Overriden in EE + def search_blob_title(project, path) + path end def search_service @@ -199,7 +202,7 @@ module SearchHelper search_params = params .merge(search) .merge({ scope: scope }) - .permit(:search, :scope, :project_id, :group_id, :repository_ref, :snippets) + .permit(SEARCH_PERMITTED_PARAMS) if @scope == scope li_class = 'active' @@ -235,6 +238,7 @@ module SearchHelper opts[:data]['project-id'] = @project.id opts[:data]['labels-endpoint'] = project_labels_path(@project) opts[:data]['milestones-endpoint'] = project_milestones_path(@project) + opts[:data]['releases-endpoint'] = project_releases_path(@project) elsif @group.present? opts[:data]['group-id'] = @group.id opts[:data]['labels-endpoint'] = group_labels_path(@group) @@ -272,7 +276,7 @@ module SearchHelper sanitize(html, tags: %w(a p ol ul li pre code)) end - def search_tabs?(tab) + def show_user_search_tab? return false if Feature.disabled?(:users_search, default_enabled: true) if @project diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb index ea7c7af72d38f23339c98ae627e7ac47db5f8cf7..19a27ba349957c8681b788ccfcdc3248794aa66f 100644 --- a/app/helpers/services_helper.rb +++ b/app/helpers/services_helper.rb @@ -32,7 +32,7 @@ module ServicesHelper end def service_save_button(service) - button_tag(class: 'btn btn-success', type: 'submit', disabled: service.deprecated?) do + button_tag(class: 'btn btn-success', type: 'submit', disabled: service.deprecated?, data: { qa_selector: 'save_changes_button' }) do icon('spinner spin', class: 'hidden js-btn-spinner') + content_tag(:span, 'Save changes', class: 'js-btn-label') end diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb index af98a611b8b23dc8e292501b6aa646e26f1d2a64..ef737b25bc784acf5be4adad09d54cf59c255326 100644 --- a/app/helpers/sessions_helper.rb +++ b/app/helpers/sessions_helper.rb @@ -4,4 +4,20 @@ module SessionsHelper def unconfirmed_email? flash[:alert] == t(:unconfirmed, scope: [:devise, :failure]) end + + # By default, all sessions are given the same expiration time configured in + # the session store (e.g. 1 week). However, unauthenticated users can + # generate a lot of sessions, primarily for CSRF verification. It makes + # sense to reduce the TTL for unauthenticated to something much lower than + # the default (e.g. 1 hour) to limit Redis memory. In addition, Rails + # creates a new session after login, so the short TTL doesn't even need to + # be extended. + def limit_session_time + # Rack sets this header, but not all tests may have it: https://github.com/rack/rack/blob/fdcd03a3c5a1c51d1f96fc97f9dfa1a9deac0c77/lib/rack/session/abstract/id.rb#L251-L259 + return unless request.env['rack.session.options'] + + # This works because Rack uses these options every time a request is handled: + # https://github.com/rack/rack/blob/fdcd03a3c5a1c51d1f96fc97f9dfa1a9deac0c77/lib/rack/session/abstract/id.rb#L342 + request.env['rack.session.options'][:expire_after] = Settings.gitlab['unauthenticated_session_expire_delay'] + end end diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index 6ccc1fb2ed1ffb2562f5692c9d6fa8a4a1f02206..10e31fb8888994a5bc06712a0da0f4a362948da2 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -11,22 +11,40 @@ module SnippetsHelper end end - def reliable_snippet_path(snippet, opts = nil) + def reliable_snippet_path(snippet, opts = {}) + reliable_snippet_url(snippet, opts.merge(only_path: true)) + end + + def reliable_raw_snippet_path(snippet, opts = {}) + reliable_raw_snippet_url(snippet, opts.merge(only_path: true)) + end + + def reliable_snippet_url(snippet, opts = {}) if snippet.project_id? - project_snippet_path(snippet.project, snippet, opts) + project_snippet_url(snippet.project, snippet, nil, opts) else - snippet_path(snippet, opts) + snippet_url(snippet, nil, opts) end end - def download_snippet_path(snippet) - if snippet.project_id - raw_project_snippet_path(@project, snippet, inline: false) + def reliable_raw_snippet_url(snippet, opts = {}) + if snippet.project_id? + raw_project_snippet_url(snippet.project, snippet, nil, opts) else - raw_snippet_path(snippet, inline: false) + raw_snippet_url(snippet, nil, opts) end end + def download_raw_snippet_button(snippet) + link_to(icon('download'), + reliable_raw_snippet_path(snippet, inline: false), + target: '_blank', + rel: 'noopener noreferrer', + class: "btn btn-sm has-tooltip", + title: 'Download', + data: { container: 'body' }) + end + # Return the path of a snippets index for a user or for a project # # @returns String, path to snippet index @@ -114,30 +132,45 @@ module SnippetsHelper { snippet_object: snippet, snippet_chunks: snippet_chunks } end - def snippet_embed - "<script src=\"#{url_for(only_path: false, overwrite_params: nil)}.js\"></script>" + def snippet_embed_tag(snippet) + content_tag(:script, nil, src: reliable_snippet_url(snippet, format: :js, only_path: false)) + end + + def snippet_badge(snippet) + return unless attrs = snippet_badge_attributes(snippet) + + css_class, text = attrs + tag.span(class: ['badge', 'badge-gray']) do + concat(tag.i(class: ['fa', css_class])) + concat(' ') + concat(text) + end + end + + def snippet_badge_attributes(snippet) + if snippet.private? + ['fa-lock', _('private')] + end end - def embedded_snippet_raw_button + def embedded_raw_snippet_button blob = @snippet.blob return if blob.empty? || blob.binary? || blob.stored_externally? - snippet_raw_url = if @snippet.is_a?(PersonalSnippet) - raw_snippet_url(@snippet) - else - raw_project_snippet_url(@snippet.project, @snippet) - end - - link_to external_snippet_icon('doc-code'), snippet_raw_url, class: 'btn', target: '_blank', rel: 'noopener noreferrer', title: 'Open raw' + link_to(external_snippet_icon('doc-code'), + reliable_raw_snippet_url(@snippet), + class: 'btn', + target: '_blank', + rel: 'noopener noreferrer', + title: 'Open raw') end def embedded_snippet_download_button - download_url = if @snippet.is_a?(PersonalSnippet) - raw_snippet_url(@snippet, inline: false) - else - raw_project_snippet_url(@snippet.project, @snippet, inline: false) - end - - link_to external_snippet_icon('download'), download_url, class: 'btn', target: '_blank', title: 'Download', rel: 'noopener noreferrer' + link_to(external_snippet_icon('download'), + reliable_raw_snippet_url(@snippet, inline: false), + class: 'btn', + target: '_blank', + title: 'Download', + rel: 'noopener noreferrer') end end diff --git a/app/helpers/sourcegraph_helper.rb b/app/helpers/sourcegraph_helper.rb new file mode 100644 index 0000000000000000000000000000000000000000..cc5a5c77e9a3810c93345e68f2fb526e01a9d188 --- /dev/null +++ b/app/helpers/sourcegraph_helper.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module SourcegraphHelper + def sourcegraph_url_message + link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: Gitlab::CurrentSettings.sourcegraph_url } + link_end = "#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}</a>".html_safe + + message = + if Gitlab::CurrentSettings.sourcegraph_url_is_com? + s_('SourcegraphPreferences|Uses %{link_start}Sourcegraph.com%{link_end}.').html_safe + else + s_('SourcegraphPreferences|Uses a custom %{link_start}Sourcegraph instance%{link_end}.').html_safe + end + + message % { link_start: link_start, link_end: link_end } + end + + def sourcegraph_experimental_message + if Gitlab::Sourcegraph.feature_conditional? + s_("SourcegraphPreferences|This feature is experimental and currently limited to certain projects.") + elsif Gitlab::CurrentSettings.sourcegraph_public_only + s_("SourcegraphPreferences|This feature is experimental and limited to public projects.") + else + s_("SourcegraphPreferences|This feature is experimental.") + end + end +end diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb index 53739cb63e3a7859d41a640c3d507d1bb2efbd86..58edb327be028930babac5832fe057b1914a38c1 100644 --- a/app/helpers/tab_helper.rb +++ b/app/helpers/tab_helper.rb @@ -108,16 +108,6 @@ module TabHelper current_controller?(c) && current_action?(a) end - def project_tab_class - if controller.controller_path.start_with?('projects') - return 'active' - end - - if %w(services hooks deploy_keys protected_branches).include? controller.controller_name - "active" - end - end - def branches_tab_class if current_controller?(:protected_branches) || current_controller?(:branches) || @@ -125,14 +115,6 @@ module TabHelper 'active' end end - - def profile_tab_class - if controller.controller_path.start_with?('profiles') - return 'active' - end - - 'active' if current_controller?('oauth/applications') - end end TabHelper.prepend_if_ee('EE::TabHelper') diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index afa057421e03733e57ec1c570ac0dec878b22e90..fc25b78da933c3ed9f531095aaaf55baefc2ff67 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -186,6 +186,24 @@ module TreeHelper attrs end + + def vue_file_list_data(project, ref) + { + project_path: project.full_path, + project_short_path: project.path, + ref: ref, + full_name: project.name_with_namespace + } + end + + def directory_download_links(project, ref, archive_prefix) + Gitlab::Workhorse::ARCHIVE_FORMATS.map do |fmt| + { + text: fmt, + path: project_archive_path(project, id: tree_join(ref, archive_prefix), format: fmt) + } + end + end end TreeHelper.prepend_if_ee('::EE::TreeHelper') diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 4ff25d021fbe141b6b70d5d1c85601b142a2b527..ef0cb8b4bcb5e784b7ea93a641b78cf4e54d8d31 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -95,6 +95,14 @@ module UsersHelper tabs end + def trials_link_url + 'https://about.gitlab.com/free-trial/' + end + + def trials_allowed?(user) + false + end + def get_current_user_menu_items items = [] @@ -105,6 +113,7 @@ module UsersHelper items << :help items << :profile if can?(current_user, :read_user, current_user) items << :settings if can?(current_user, :update_user, current_user) + items << :start_trial if trials_allowed?(current_user) items end diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb index 2bd803c0177e32ec014d9563a8c51100c9418c77..a36de5dc548289f8a04df1e30e1a6e1b8f33899e 100644 --- a/app/helpers/visibility_level_helper.rb +++ b/app/helpers/visibility_level_helper.rb @@ -201,9 +201,9 @@ module VisibilityLevelHelper def visibility_level_errors_for_group(group, level_name) group_name = link_to group.name, group_path(group) - change_visiblity = link_to 'change the visibility', edit_group_path(group) + change_visibility = link_to 'change the visibility', edit_group_path(group) { reason: "the visibility of #{group_name} is #{group.visibility}", - instruction: " To make this group #{level_name}, you must first #{change_visiblity} of the parent group." } + instruction: " To make this group #{level_name}, you must first #{change_visibility} of the parent group." } end end diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index ea8032324aac169747e370d28b697e8367635f43..06d2219d6a9d886a0cfb7348fb54687df2bfcd3f 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -15,16 +15,18 @@ module Emails user = User.find(recipient_id) - mail(to: user.notification_email_for(notification_group), - subject: subject("Request to join the #{member_source.human_name} #{member_source.model_name.singular}")) + member_email_with_layout( + to: user.notification_email_for(notification_group), + subject: subject("Request to join the #{member_source.human_name} #{member_source.model_name.singular}")) end def member_access_granted_email(member_source_type, member_id) @member_source_type = member_source_type @member_id = member_id - mail(to: member.user.notification_email_for(notification_group), - subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was granted")) + member_email_with_layout( + to: member.user.notification_email_for(notification_group), + subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was granted")) end def member_access_denied_email(member_source_type, source_id, user_id) @@ -33,8 +35,9 @@ module Emails user = User.find(user_id) - mail(to: user.notification_email_for(notification_group), - subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was denied")) + member_email_with_layout( + to: user.notification_email_for(notification_group), + subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was denied")) end def member_invited_email(member_source_type, member_id, token) @@ -42,8 +45,9 @@ module Emails @member_id = member_id @token = token - mail(to: member.invite_email, - subject: subject("Invitation to join the #{member_source.human_name} #{member_source.model_name.singular}")) + member_email_with_layout( + to: member.invite_email, + subject: subject("Invitation to join the #{member_source.human_name} #{member_source.model_name.singular}")) end def member_invite_accepted_email(member_source_type, member_id) @@ -51,8 +55,9 @@ module Emails @member_id = member_id return unless member.created_by - mail(to: member.created_by.notification_email_for(notification_group), - subject: subject('Invitation accepted')) + member_email_with_layout( + to: member.created_by.notification_email_for(notification_group), + subject: subject('Invitation accepted')) end def member_invite_declined_email(member_source_type, source_id, invite_email, created_by_id) @@ -64,8 +69,9 @@ module Emails user = User.find(created_by_id) - mail(to: user.notification_email_for(notification_group), - subject: subject('Invitation declined')) + member_email_with_layout( + to: user.notification_email_for(notification_group), + subject: subject('Invitation declined')) end def member @@ -85,5 +91,12 @@ module Emails def member_source_class @member_source_type.classify.constantize end + + def member_email_with_layout(to:, subject:) + mail(to: to, subject: subject) do |format| + format.html { render layout: 'mailer' } + format.text { render layout: 'mailer' } + end + end end end diff --git a/app/mailers/emails/pipelines.rb b/app/mailers/emails/pipelines.rb index 34e12a5fa6d0382fe68fe9666604c0fb5a8bc89f..95bb52d8f97b17ea753292738383ce9a36dc3ae9 100644 --- a/app/mailers/emails/pipelines.rb +++ b/app/mailers/emails/pipelines.rb @@ -18,12 +18,11 @@ module Emails @merge_request = pipeline.all_merge_requests.first add_headers - # We use bcc here because we don't want to generate this emails for a + # We use bcc here because we don't want to generate these emails for a # thousand times. This could be potentially expensive in a loop, and # recipients would contain all project watchers so it could be a lot. mail(bcc: recipients, - subject: pipeline_subject(status), - skip_premailer: true) do |format| + subject: pipeline_subject(status)) do |format| format.html { render layout: 'mailer' } format.text { render layout: 'mailer' } end diff --git a/app/mailers/emails/releases.rb b/app/mailers/emails/releases.rb index 137858d31e85a8330a4ad048d23b586a44ddabd5..c9c77ab93336481e8e52535735785c3e087cab86 100644 --- a/app/mailers/emails/releases.rb +++ b/app/mailers/emails/releases.rb @@ -21,7 +21,13 @@ module Emails private def release_email_subject - release_info = [@release.name, @release.tag].select(&:presence).join(' - ') + release_info = + if @release.name == @release.tag + @release.tag + else + [@release.name, @release.tag].select(&:presence).join(' - ') + end + "New release: #{release_info}" end end diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index 3d42423ba4633d50bd7ba45ef20eb9122e98bcf9..381a4f54d9e8e70cfcd172ca1313a25932fa5972 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -77,7 +77,7 @@ class NotifyPreview < ActionMailer::Preview end def import_issues_csv_email - Notify.import_issues_csv_email(user, project, { success: 3, errors: [5, 6, 7], valid_file: true }) + Notify.import_issues_csv_email(user.id, project.id, { success: 3, errors: [5, 6, 7], valid_file: true }) end def closed_merge_request_email @@ -109,11 +109,11 @@ class NotifyPreview < ActionMailer::Preview end def member_access_requested_email - Notify.member_access_requested_email('group', user.id, user.id).message + Notify.member_access_requested_email(member.source_type, member.id, user.id).message end def member_invite_accepted_email - Notify.member_invite_accepted_email('project', user.id).message + Notify.member_invite_accepted_email(member.source_type, member.id).message end def member_invite_declined_email diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index a3a1748142fc03507f72362ada7017c077b5507c..7cfebf0473f2b25c6f405d0919a0e32255bd715b 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -2,6 +2,7 @@ class AbuseReport < ApplicationRecord include CacheMarkdownField + include Sortable cache_markdown_field :message, pipeline: :single_line @@ -13,6 +14,9 @@ class AbuseReport < ApplicationRecord validates :message, presence: true validates :user_id, uniqueness: { message: 'has already been reported' } + scope :by_user, -> (user) { where(user_id: user) } + scope :with_users, -> { includes(:reporter, :user) } + # For CacheMarkdownField alias_method :author, :reporter diff --git a/app/models/analytics/cycle_analytics/project_stage.rb b/app/models/analytics/cycle_analytics/project_stage.rb index 23f0db0829b58149a96437e69df9d9d2d33ff830..b2c16444a2a76aa32d09ce00bd47b5a7f61a9b04 100644 --- a/app/models/analytics/cycle_analytics/project_stage.rb +++ b/app/models/analytics/cycle_analytics/project_stage.rb @@ -10,6 +10,25 @@ module Analytics alias_attribute :parent, :project alias_attribute :parent_id, :project_id + + delegate :group, to: :project + + validate :validate_project_group_for_label_events, if: -> { start_event_label_based? || end_event_label_based? } + + def self.relative_positioning_query_base(stage) + where(project_id: stage.project_id) + end + + def self.relative_positioning_parent_column + :project_id + end + + private + + # Project should belong to a group when the stage has Label based events since only GroupLabels are allowed. + def validate_project_group_for_label_events + errors.add(:project, s_('CycleAnalyticsStage|should be under a group')) unless project.group + end end end end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index a07933d4975cbed96ba0271a3a0c268963844f84..4028d711fd1aebd1e559791d114340b2f023036d 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -6,6 +6,12 @@ class ApplicationSetting < ApplicationRecord include TokenAuthenticatable include ChronicDurationAttribute + # Only remove this >= %12.6 and >= 2019-12-01 + self.ignored_columns += %i[ + pendo_enabled + pendo_url + ] + add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption, default_enabled: true) ? :optional : :required } add_authentication_token_field :health_check_access_token add_authentication_token_field :static_objects_external_storage_auth_token @@ -18,12 +24,6 @@ class ApplicationSetting < ApplicationRecord # fix a lot of tests using allow_any_instance_of include ApplicationSettingImplementation - attr_encrypted :asset_proxy_secret_key, - mode: :per_attribute_iv, - insecure_mode: true, - key: Settings.attr_encrypted_db_key_base_truncated, - algorithm: 'aes-256-cbc' - serialize :restricted_visibility_levels # rubocop:disable Cop/ActiveRecordSerialize serialize :import_sources # rubocop:disable Cop/ActiveRecordSerialize serialize :disabled_oauth_sign_in_sources, Array # rubocop:disable Cop/ActiveRecordSerialize @@ -99,11 +99,20 @@ class ApplicationSetting < ApplicationRecord presence: true, if: :plantuml_enabled + validates :sourcegraph_url, + presence: true, + if: :sourcegraph_enabled + validates :snowplow_collector_hostname, presence: true, hostname: true, if: :snowplow_enabled + validates :snowplow_iglu_registry_url, + addressable_url: true, + allow_blank: true, + if: :snowplow_enabled + validates :max_attachment_size, presence: true, numericality: { only_integer: true, greater_than: 0 } @@ -270,12 +279,40 @@ class ApplicationSetting < ApplicationRecord presence: true, if: :lets_encrypt_terms_of_service_accepted? + validates :eks_integration_enabled, + inclusion: { in: [true, false] } + + validates :eks_account_id, + format: { with: Gitlab::Regex.aws_account_id_regex, + message: Gitlab::Regex.aws_account_id_message }, + if: :eks_integration_enabled? + + validates :eks_access_key_id, + length: { in: 16..128 }, + if: :eks_integration_enabled? + + validates :eks_secret_access_key, + presence: true, + if: :eks_integration_enabled? + validates_with X509CertificateCredentialsValidator, certificate: :external_auth_client_cert, pkey: :external_auth_client_key, pass: :external_auth_client_key_pass, if: -> (setting) { setting.external_auth_client_cert.present? } + validates :default_ci_config_path, + format: { without: %r{(\.{2}|\A/)}, + message: N_('cannot include leading slash or directory traversal.') }, + length: { maximum: 255 }, + allow_blank: true + + attr_encrypted :asset_proxy_secret_key, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-cbc', + insecure_mode: true + attr_encrypted :external_auth_client_key, mode: :per_attribute_iv, key: Settings.attr_encrypted_db_key_base_truncated, @@ -294,6 +331,12 @@ class ApplicationSetting < ApplicationRecord algorithm: 'aes-256-gcm', encode: true + attr_encrypted :eks_secret_access_key, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-gcm', + encode: true + before_validation :ensure_uuid! before_save :ensure_runners_registration_token @@ -304,6 +347,10 @@ class ApplicationSetting < ApplicationRecord end after_commit :expire_performance_bar_allowed_user_ids_cache, if: -> { previous_changes.key?('performance_bar_allowed_group_id') } + def sourcegraph_url_is_com? + !!(sourcegraph_url =~ /\Ahttps:\/\/(www\.)?sourcegraph\.com/) + end + def self.create_from_defaults transaction(requires_new: true) do super diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 0c0ffb67c9ab43228641ca4a470244762acb6e6e..7bb89f0d1e20c043e91d0e150a91ae0bf5955673 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -42,6 +42,7 @@ module ApplicationSettingImplementation container_registry_token_expire_delay: 5, default_artifacts_expire_in: '30 days', default_branch_protection: Settings.gitlab['default_branch_protection'], + default_ci_config_path: nil, default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'], default_project_creation: Settings.gitlab['default_project_creation'], default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'], @@ -54,6 +55,10 @@ module ApplicationSettingImplementation dsa_key_restriction: 0, ecdsa_key_restriction: 0, ed25519_key_restriction: 0, + eks_integration_enabled: false, + eks_account_id: nil, + eks_access_key_id: nil, + eks_secret_access_key: nil, first_day_of_week: 0, gitaly_timeout_default: 55, gitaly_timeout_fast: 10, @@ -97,6 +102,9 @@ module ApplicationSettingImplementation shared_runners_text: nil, sign_in_text: nil, signup_enabled: Settings.gitlab['signup_enabled'], + sourcegraph_enabled: false, + sourcegraph_url: nil, + sourcegraph_public_only: true, terminal_max_session_time: 0, throttle_authenticated_api_enabled: false, throttle_authenticated_api_period_in_seconds: 3600, @@ -128,8 +136,10 @@ module ApplicationSettingImplementation snowplow_collector_hostname: nil, snowplow_cookie_domain: nil, snowplow_enabled: false, - snowplow_site_id: nil, - custom_http_clone_url_root: nil + snowplow_app_id: nil, + snowplow_iglu_registry_url: nil, + custom_http_clone_url_root: nil, + productivity_analytics_start_date: Time.now } end diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index 24fcb97db6e6a408defcb38bb246a03453671e3c..5a33a8f89df947c90e91a0f99365dbade26fab29 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -6,11 +6,14 @@ class AwardEmoji < ApplicationRecord include Participable include GhostUser + include Importable belongs_to :awardable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :user - validates :awardable, :user, presence: true + validates :user, presence: true + validates :awardable, presence: true, unless: :importing? + validates :name, presence: true, inclusion: { in: Gitlab::Emoji.emojis_names } validates :name, uniqueness: { scope: [:user, :awardable_type, :awardable_id] }, unless: :ghost_user? diff --git a/app/models/aws/role.rb b/app/models/aws/role.rb index 836107435ad8bd50d4364217460ae897a147f2a7..54132be749db1d380088e9ea2c75ace48e4b09ea 100644 --- a/app/models/aws/role.rb +++ b/app/models/aws/role.rb @@ -13,5 +13,11 @@ module Aws with: Gitlab::Regex.aws_arn_regex, message: Gitlab::Regex.aws_arn_regex_message } + + before_validation :ensure_role_external_id!, on: :create + + def ensure_role_external_id! + self.role_external_id ||= SecureRandom.hex(20) + end end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index c48ab28ce735ebede78073a92259028bec774ae0..59a2c09bd2845e0e2473062206c4e353af1c9036 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -35,10 +35,13 @@ module Ci refspecs: -> (build) { build.merge_request_ref? } }.freeze + DEFAULT_RETRIES = { + scheduler_failure: 2 + }.freeze + has_one :deployment, as: :deployable, class_name: 'Deployment' has_many :trace_sections, class_name: 'Ci::BuildTraceSection' has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id - has_many :needs, class_name: 'Ci::BuildNeed', foreign_key: :build_id, inverse_of: :build has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy, inverse_of: :job # rubocop:disable Cop/ActiveRecordDependent has_many :job_variables, class_name: 'Ci::JobVariable', foreign_key: :job_id @@ -52,7 +55,6 @@ module Ci accepts_nested_attributes_for :runner_session accepts_nested_attributes_for :job_variables - accepts_nested_attributes_for :needs delegate :url, to: :runner_session, prefix: true, allow_nil: true delegate :terminal_specification, to: :runner_session, allow_nil: true @@ -118,6 +120,11 @@ module Ci scope :eager_load_job_artifacts, -> { includes(:job_artifacts) } + scope :with_exposed_artifacts, -> do + joins(:metadata).merge(Ci::BuildMetadata.with_exposed_artifacts) + .includes(:metadata, :job_artifacts_metadata) + end + scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) } scope :with_expired_artifacts, ->() { with_artifacts_archive.where('artifacts_expire_at < ?', Time.now) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } @@ -367,18 +374,25 @@ module Ci pipeline.builds.retried.where(name: self.name).count end - def retries_max - normalized_retry.fetch(:max, 0) + def retry_failure? + max_allowed_retries = nil + max_allowed_retries ||= options_retry_max if retry_on_reason_or_always? + max_allowed_retries ||= DEFAULT_RETRIES.fetch(failure_reason.to_sym, 0) + + max_allowed_retries > 0 && retries_count < max_allowed_retries end - def retry_when - normalized_retry.fetch(:when, ['always']) + def options_retry_max + options_retry[:max] end - def retry_failure? - return false if retries_max.zero? || retries_count >= retries_max + def options_retry_when + options_retry.fetch(:when, ['always']) + end - retry_when.include?('always') || retry_when.include?(failure_reason.to_s) + def retry_on_reason_or_always? + options_retry_when.include?(failure_reason.to_s) || + options_retry_when.include?('always') end def latest? @@ -595,6 +609,14 @@ module Ci update_column(:trace, nil) end + def artifacts_expose_as + options.dig(:artifacts, :expose_as) + end + + def artifacts_paths + options.dig(:artifacts, :paths) + end + def needs_touch? Time.now - updated_at > 15.minutes.to_i end @@ -818,6 +840,13 @@ module Ci :creating end + # Consider this object to have a structural integrity problems + def doom! + update_columns( + status: :failed, + failure_reason: :data_integrity_failure) + end + private def successful_deployment_status @@ -862,19 +891,13 @@ module Ci # format, but builds created before GitLab 11.5 and saved in database still # have the old integer only format. This method returns the retry option # normalized as a hash in 11.5+ format. - def normalized_retry - strong_memoize(:normalized_retry) do + def options_retry + strong_memoize(:options_retry) do value = options&.dig(:retry) value = value.is_a?(Integer) ? { max: value } : value.to_h value.with_indifferent_access end end - - def build_attributes_from_config - return {} unless pipeline.config_processor - - pipeline.config_processor.build_attributes(name) - end end end diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb index 3097e40dd3b7df4761dcc57651e9c0ab7471c42d..0df5ebfe843be0a13cb2577f605c3cd136d1bd1f 100644 --- a/app/models/ci/build_metadata.rb +++ b/app/models/ci/build_metadata.rb @@ -27,6 +27,7 @@ module Ci scope :scoped_build, -> { where('ci_builds_metadata.build_id = ci_builds.id') } scope :with_interruptible, -> { where(interruptible: true) } + scope :with_exposed_artifacts, -> { where(has_exposed_artifacts: true) } enum timeout_source: { unknown_timeout_source: 1, diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 3bf19399cec074089634820960dfe1cfd7379430..f730b949ee9421055585b5c46acc0c2aab38b05b 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -405,7 +405,7 @@ module Ci .where('stage=sg.stage').failed_but_allowed.to_sql stages_with_statuses = CommitStatus.from(stages_query, :sg) - .pluck('sg.stage', status_sql, "(#{warnings_sql})") + .pluck('sg.stage', Arel.sql(status_sql), Arel.sql("(#{warnings_sql})")) stages_with_statuses.map do |stage| Ci::LegacyStage.new(self, Hash[%i[name status warnings].zip(stage)]) @@ -551,23 +551,6 @@ module Ci end end - def stage_seeds - return [] unless config_processor - - strong_memoize(:stage_seeds) do - seeds = config_processor.stages_attributes.inject([]) do |previous_stages, attributes| - seed = Gitlab::Ci::Pipeline::Seed::Stage.new(self, attributes, previous_stages) - previous_stages + [seed] - end - - seeds.select(&:included?) - end - end - - def seeds_size - stage_seeds.sum(&:size) - end - def has_kubernetes_active? project.deployment_platform&.active? end @@ -587,56 +570,14 @@ module Ci end end - def set_config_source - if ci_yaml_from_repo - self.config_source = :repository_source - elsif implied_ci_yaml_file - self.config_source = :auto_devops_source - end - end - - ## - # TODO, setting yaml_errors should be moved to the pipeline creation chain. - # - def config_processor - return unless ci_yaml_file - return @config_processor if defined?(@config_processor) - - @config_processor ||= begin - ::Gitlab::Ci::YamlProcessor.new(ci_yaml_file, { project: project, sha: sha, user: user }) - rescue Gitlab::Ci::YamlProcessor::ValidationError => e - self.yaml_errors = e.message - nil - rescue - self.yaml_errors = 'Undefined error' - nil - end - end - - def ci_yaml_file_path + # TODO: this logic is duplicate with Pipeline::Chain::Config::Content + # we should persist this is `ci_pipelines.config_path` + def config_path return unless repository_source? || unknown_source? project.ci_config_path.presence || '.gitlab-ci.yml' end - def ci_yaml_file - return @ci_yaml_file if defined?(@ci_yaml_file) - - @ci_yaml_file = - if auto_devops_source? - implied_ci_yaml_file - else - ci_yaml_from_repo - end - - if @ci_yaml_file - @ci_yaml_file - else - self.yaml_errors = "Failed to load CI/CD config file for #{sha}" - nil - end - end - def has_yaml_errors? yaml_errors.present? end @@ -705,7 +646,7 @@ module Ci def predefined_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'CI_PIPELINE_IID', value: iid.to_s) - variables.append(key: 'CI_CONFIG_PATH', value: ci_yaml_file_path) + variables.append(key: 'CI_CONFIG_PATH', value: config_path) variables.append(key: 'CI_PIPELINE_SOURCE', value: source.to_s) variables.append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s) variables.append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s) @@ -783,6 +724,10 @@ module Ci end end + def has_exposed_artifacts? + complete? && builds.latest.with_exposed_artifacts.exists? + end + def branch_updated? strong_memoize(:branch_updated) do push_details.branch_updated? @@ -896,24 +841,6 @@ module Ci private - def ci_yaml_from_repo - return unless project - return unless sha - return unless ci_yaml_file_path - - project.repository.gitlab_ci_yml_for(sha, ci_yaml_file_path) - rescue GRPC::NotFound, GRPC::Internal - nil - end - - def implied_ci_yaml_file - return unless project - - if project.auto_devops_enabled? - Gitlab::Template::GitlabCiYmlTemplate.find('Auto-DevOps').content - end - end - def pipeline_data Gitlab::DataBuilder::Pipeline.build(self) end diff --git a/app/models/clusters/applications/cert_manager.rb b/app/models/clusters/applications/cert_manager.rb index 18cbf827a67709482a715006bb33a76fcd87d1b3..7ba04d1a2de55add435f3fb6d1cf2fbf39053cfa 100644 --- a/app/models/clusters/applications/cert_manager.rb +++ b/app/models/clusters/applications/cert_manager.rb @@ -65,7 +65,7 @@ module Clusters end def retry_command(command) - "for i in $(seq 1 30); do #{command} && s=0 && break || s=$?; sleep 1s; echo \"Retrying ($i)...\"; done; (exit $s)" + "for i in $(seq 1 90); do #{command} && s=0 && break || s=$?; sleep 1s; echo \"Retrying ($i)...\"; done; (exit $s)" end def post_delete_script diff --git a/app/models/clusters/applications/crossplane.rb b/app/models/clusters/applications/crossplane.rb new file mode 100644 index 0000000000000000000000000000000000000000..36246b26066c187d795fb71c7409314c3997b851 --- /dev/null +++ b/app/models/clusters/applications/crossplane.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class Crossplane < ApplicationRecord + VERSION = '0.4.1' + + self.table_name = 'clusters_applications_crossplane' + + include ::Clusters::Concerns::ApplicationCore + include ::Clusters::Concerns::ApplicationStatus + include ::Clusters::Concerns::ApplicationVersion + include ::Clusters::Concerns::ApplicationData + + default_value_for :version, VERSION + + default_value_for :stack do |crossplane| + '' + end + + validates :stack, presence: true + + def chart + 'crossplane/crossplane' + end + + def repository + 'https://charts.crossplane.io/alpha' + end + + def install_command + Gitlab::Kubernetes::Helm::InstallCommand.new( + name: 'crossplane', + repository: repository, + version: VERSION, + rbac: cluster.platform_kubernetes_rbac?, + chart: chart, + files: files + ) + end + + def values + crossplane_values.to_yaml + end + + private + + def crossplane_values + { + "clusterStacks" => { + self.stack => { + "deploy" => true, + "version" => "alpha" + } + } + } + end + end + end +end diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb new file mode 100644 index 0000000000000000000000000000000000000000..8589f8c00cbc8e4335f26d7911f37e632069b41c --- /dev/null +++ b/app/models/clusters/applications/elastic_stack.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class ElasticStack < ApplicationRecord + VERSION = '1.8.0' + + ELASTICSEARCH_PORT = 9200 + + self.table_name = 'clusters_applications_elastic_stacks' + + include ::Clusters::Concerns::ApplicationCore + include ::Clusters::Concerns::ApplicationStatus + include ::Clusters::Concerns::ApplicationVersion + 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? + + ingress = cluster.application_ingress + self.status = status_states[:installable] if ingress.external_ip_or_hostname? + end + + def chart + 'stable/elastic-stack' + end + + def values + content_values.to_yaml + end + + def install_command + Gitlab::Kubernetes::Helm::InstallCommand.new( + name: 'elastic-stack', + version: VERSION, + rbac: cluster.platform_kubernetes_rbac?, + chart: chart, + files: files + ) + end + + def uninstall_command + Gitlab::Kubernetes::Helm::DeleteCommand.new( + name: 'elastic-stack', + rbac: cluster.platform_kubernetes_rbac?, + files: files, + postdelete: post_delete_script + ) + end + + def elasticsearch_client + strong_memoize(:elasticsearch_client) do + next unless kube_client + + proxy_url = kube_client.proxy_url('service', 'elastic-stack-elasticsearch-client', ::Clusters::Applications::ElasticStack::ELASTICSEARCH_PORT, Gitlab::Kubernetes::Helm::NAMESPACE) + + Elasticsearch::Client.new(url: proxy_url) do |faraday| + # ensures headers containing auth data are appended to original client options + faraday.headers.merge!(kube_client.headers) + # ensure TLS certs are properly verified + faraday.ssl[:verify] = kube_client.ssl_options[:verify_ssl] + faraday.ssl[:cert_store] = kube_client.ssl_options[:cert_store] + end + + rescue Kubeclient::HttpError => error + # 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. + # We check for a nil client in downstream use and behaviour is equivalent to an empty state + log_exception(error, :failed_to_create_elasticsearch_client) + end + end + + 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") + ].compact + end + + def kube_client + cluster&.kubeclient&.core_client + end + end + end +end diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index 885e4ff71974eb0d98ba14c1b86fde453e5769b2..d140649af3c10872639ddb2723227f43ba14c4ec 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -21,6 +21,7 @@ module Clusters } FETCH_IP_ADDRESS_DELAY = 30.seconds + MODSEC_SIDECAR_INITIAL_DELAY_SECONDS = 10 state_machine :status do after_transition any => [:installed] do |application| @@ -40,7 +41,7 @@ module Clusters end def allowed_to_uninstall? - external_ip_or_hostname? && application_jupyter_nil_or_installable? + external_ip_or_hostname? && application_jupyter_nil_or_installable? && application_elastic_stack_nil_or_installable? end def install_command @@ -78,12 +79,74 @@ module Clusters "controller" => { "config" => { "enable-modsecurity" => "true", - "enable-owasp-modsecurity-crs" => "true" - } + "enable-owasp-modsecurity-crs" => "true", + "modsecurity.conf" => modsecurity_config_content + }, + "extraContainers" => [ + { + "name" => "modsecurity-log", + "image" => "busybox", + "args" => [ + "/bin/sh", + "-c", + "tail -f /var/log/modsec/audit.log" + ], + "volumeMounts" => [ + { + "name" => "modsecurity-log-volume", + "mountPath" => "/var/log/modsec", + "readOnly" => true + } + ], + "startupProbe" => { + "exec" => { + "command" => ["ls", "/var/log/modsec"] + }, + "initialDelaySeconds" => MODSEC_SIDECAR_INITIAL_DELAY_SECONDS + } + } + ], + "extraVolumeMounts" => [ + { + "name" => "modsecurity-template-volume", + "mountPath" => "/etc/nginx/modsecurity/modsecurity.conf", + "subPath" => "modsecurity.conf" + }, + { + "name" => "modsecurity-log-volume", + "mountPath" => "/var/log/modsec" + } + ], + "extraVolumes" => [ + { + "name" => "modsecurity-template-volume", + "configMap" => { + "name" => "ingress-nginx-ingress-controller", + "items" => [ + { + "key" => "modsecurity.conf", + "path" => "modsecurity.conf" + } + ] + } + }, + { + "name" => "modsecurity-log-volume", + "emptyDir" => {} + } + ] } } end + def modsecurity_config_content + File.read(modsecurity_config_file_path) + end + + def modsecurity_config_file_path + Rails.root.join('vendor', 'ingress', 'modsecurity.conf') + end + def content_values YAML.load_file(chart_values_file).deep_merge!(specification) end @@ -91,6 +154,10 @@ 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/runner.rb b/app/models/clusters/applications/runner.rb index 954046c143b63e37985f6026a2382d20f5818e01..37ba8a7c97ee31c21f1c1feabc9b8c759e9c7b16 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.9.0' + VERSION = '0.10.1' self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index d6f5d7c3f93b3217f23d990c5d86dd42026da959..f522f3f2fdb2a245cfadc14d1f7553d250d0a5c4 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -6,20 +6,21 @@ module Clusters include Gitlab::Utils::StrongMemoize include FromUnion include ReactiveCaching + include AfterCommitQueue self.table_name = 'clusters' - PROJECT_ONLY_APPLICATIONS = { - }.freeze APPLICATIONS = { Applications::Helm.application_name => Applications::Helm, Applications::Ingress.application_name => Applications::Ingress, Applications::CertManager.application_name => Applications::CertManager, + Applications::Crossplane.application_name => Applications::Crossplane, Applications::Prometheus.application_name => Applications::Prometheus, Applications::Runner.application_name => Applications::Runner, Applications::Jupyter.application_name => Applications::Jupyter, - Applications::Knative.application_name => Applications::Knative - }.merge(PROJECT_ONLY_APPLICATIONS).freeze + Applications::Knative.application_name => Applications::Knative, + Applications::ElasticStack.application_name => Applications::ElasticStack + }.freeze DEFAULT_ENVIRONMENT = '*' KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN' @@ -47,14 +48,17 @@ module Clusters has_one_cluster_application :helm has_one_cluster_application :ingress has_one_cluster_application :cert_manager + has_one_cluster_application :crossplane has_one_cluster_application :prometheus has_one_cluster_application :runner has_one_cluster_application :jupyter has_one_cluster_application :knative + has_one_cluster_application :elastic_stack has_many :kubernetes_namespaces accepts_nested_attributes_for :provider_gcp, update_only: true + accepts_nested_attributes_for :provider_aws, update_only: true accepts_nested_attributes_for :platform_kubernetes, update_only: true validates :name, cluster_name: true @@ -72,6 +76,7 @@ module Clusters delegate :status, to: :provider, allow_nil: true delegate :status_reason, to: :provider, allow_nil: true delegate :on_creation?, to: :provider, allow_nil: true + delegate :knative_pre_installed?, to: :provider, allow_nil: true delegate :active?, to: :platform_kubernetes, prefix: true, allow_nil: true delegate :rbac?, to: :platform_kubernetes, prefix: true, allow_nil: true @@ -115,6 +120,8 @@ module Clusters scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) } + scope :for_project_namespace, -> (namespace_id) { joins(:projects).where(projects: { namespace_id: namespace_id }) } + def self.ancestor_clusters_for_clusterable(clusterable, hierarchy_order: :asc) return [] if clusterable.is_a?(Instance) @@ -124,7 +131,55 @@ module Clusters hierarchy_groups.flat_map(&:clusters) + Instance.new.clusters end + state_machine :cleanup_status, initial: :cleanup_not_started do + state :cleanup_not_started, value: 1 + state :cleanup_uninstalling_applications, value: 2 + state :cleanup_removing_project_namespaces, value: 3 + state :cleanup_removing_service_account, value: 4 + state :cleanup_errored, value: 5 + + event :start_cleanup do |cluster| + transition [:cleanup_not_started, :cleanup_errored] => :cleanup_uninstalling_applications + end + + event :continue_cleanup do + transition( + cleanup_uninstalling_applications: :cleanup_removing_project_namespaces, + cleanup_removing_project_namespaces: :cleanup_removing_service_account) + end + + event :make_cleanup_errored do + transition any => :cleanup_errored + end + + before_transition any => [:cleanup_errored] do |cluster, transition| + status_reason = transition.args.first + cluster.cleanup_status_reason = status_reason if status_reason + end + + after_transition [:cleanup_not_started, :cleanup_errored] => :cleanup_uninstalling_applications do |cluster| + cluster.run_after_commit do + Clusters::Cleanup::AppWorker.perform_async(cluster.id) + end + end + + after_transition cleanup_uninstalling_applications: :cleanup_removing_project_namespaces do |cluster| + cluster.run_after_commit do + Clusters::Cleanup::ProjectNamespaceWorker.perform_async(cluster.id) + end + end + + after_transition cleanup_removing_project_namespaces: :cleanup_removing_service_account do |cluster| + cluster.run_after_commit do + Clusters::Cleanup::ServiceAccountWorker.perform_async(cluster.id) + end + end + end + def status_name + return cleanup_status_name if cleanup_errored? + return :cleanup_ongoing unless cleanup_not_started? + provider&.status_name || connection_status.presence || :created end @@ -207,10 +262,6 @@ module Clusters end end - def knative_pre_installed? - provider&.knative_pre_installed? - end - private def unique_management_project_environment_scope diff --git a/app/models/clusters/clusters_hierarchy.rb b/app/models/clusters/clusters_hierarchy.rb index a906eb2888be904ecb9e88880ca17289911bff5e..c9c18d8c96a625568782e76faaa461a6993c5dff 100644 --- a/app/models/clusters/clusters_hierarchy.rb +++ b/app/models/clusters/clusters_hierarchy.rb @@ -20,7 +20,7 @@ module Clusters .with .recursive(cte.to_arel) .from(cte_alias) - .order(DEPTH_COLUMN => :asc) + .order(depth_order_clause) end private @@ -40,7 +40,7 @@ module Clusters end if clusterable.is_a?(::Project) && include_management_project - cte << management_clusters_query + cte << same_namespace_management_clusters_query end cte << base_query @@ -49,13 +49,42 @@ module Clusters cte end + # Returns project-level clusters where the project is the management project + # for the cluster. The management project has to be in the same namespace / + # group as the cluster's project. + # + # Support for management project in sub-groups is planned in + # https://gitlab.com/gitlab-org/gitlab/issues/34650 + # + # NB: group_parent_id is un-used but we still need to match the same number of + # columns as other queries in the CTE. + def same_namespace_management_clusters_query + clusterable.management_clusters + .project_type + .select([clusters_star, 'NULL AS group_parent_id', "0 AS #{DEPTH_COLUMN}"]) + .for_project_namespace(clusterable.namespace_id) + end + # Management clusters should be first in the hierarchy so we use 0 for the # depth column. # - # group_parent_id is un-used but we still need to match the same number of - # columns as other queries in the CTE. - def management_clusters_query - clusterable.management_clusters.select([clusters_star, 'NULL AS group_parent_id', "0 AS #{DEPTH_COLUMN}"]) + # Only applicable if the clusterable is a project (most especially when + # requesting project.deployment_platform). + def depth_order_clause + return { DEPTH_COLUMN => :asc } unless clusterable.is_a?(::Project) && include_management_project + + order = <<~SQL + (CASE clusters.management_project_id + WHEN :project_id THEN 0 + ELSE #{DEPTH_COLUMN} + END) ASC + SQL + + values = { + project_id: clusterable.id + } + + model.sanitize_sql_array([Arel.sql(order), values]) end def group_clusters_base_query diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb index 979cf0645f512051ccef88eb0ac157b8e59fe7a9..21b98534808bb10af23776901b7409240d512dc0 100644 --- a/app/models/clusters/concerns/application_core.rb +++ b/app/models/clusters/concerns/application_core.rb @@ -60,6 +60,24 @@ module Clusters # Override if your application needs any action after # being uninstalled by Helm end + + def logger + @logger ||= Gitlab::Kubernetes::Logger.build + end + + def log_exception(error, event) + logger.error({ + exception: error.class.name, + status_code: error.error_code, + cluster_id: cluster&.id, + application_id: id, + class_name: self.class.name, + event: event, + message: error.message + }) + + Gitlab::Sentry.track_acceptable_exception(error, extra: { cluster_id: cluster&.id, application_id: id }) + end end end end diff --git a/app/models/clusters/instance.rb b/app/models/clusters/instance.rb index f21dbdf7f26b3847ecc3cde1069c487c8e5ccde5..8c9d9ab9ab1b95d3ae53f1a57c3e3834a28500af 100644 --- a/app/models/clusters/instance.rb +++ b/app/models/clusters/instance.rb @@ -9,5 +9,9 @@ module Clusters def feature_available?(feature) ::Feature.enabled?(feature, default_enabled: true) end + + def flipper_id + self.class.to_s + end end end diff --git a/app/models/clusters/providers/aws.rb b/app/models/clusters/providers/aws.rb index ae4156896bc5adf7d09b783c074af43463ec4f95..78eb75ddcc00795bcd4de14ef950cb62db3d8c7e 100644 --- a/app/models/clusters/providers/aws.rb +++ b/app/models/clusters/providers/aws.rb @@ -3,12 +3,12 @@ module Clusters module Providers class Aws < ApplicationRecord + include Gitlab::Utils::StrongMemoize include Clusters::Concerns::ProviderStatus self.table_name = 'cluster_providers_aws' belongs_to :cluster, inverse_of: :provider_aws, class_name: 'Clusters::Cluster' - belongs_to :created_by_user, class_name: 'User' default_value_for :region, 'us-east-1' default_value_for :num_nodes, 3 @@ -42,6 +42,30 @@ module Clusters session_token: nil ) end + + def api_client + strong_memoize(:api_client) do + ::Aws::CloudFormation::Client.new(credentials: credentials, region: region) + end + end + + def credentials + strong_memoize(:credentials) do + ::Aws::Credentials.new(access_key_id, secret_access_key, session_token) + end + end + + def has_rbac_enabled? + true + end + + def knative_pre_installed? + false + end + + def created_by_user + cluster.user + end end end end diff --git a/app/models/clusters/providers/gcp.rb b/app/models/clusters/providers/gcp.rb index f871674676f81916d6d1d755b6b5be8657b0ef53..2ca7d0249dc5fab931b7b052eb43aabd452359bb 100644 --- a/app/models/clusters/providers/gcp.rb +++ b/app/models/clusters/providers/gcp.rb @@ -54,6 +54,10 @@ module Clusters assign_attributes(operation_id: operation_id) end + def has_rbac_enabled? + !legacy_abac + end + def knative_pre_installed? cloud_run? end diff --git a/app/models/commit_status_enums.rb b/app/models/commit_status_enums.rb index a540e29199052350cdfd49cb4873c5c399786003..2ca6d15e6426998d56a8bb5906222c33315fdff0 100644 --- a/app/models/commit_status_enums.rb +++ b/app/models/commit_status_enums.rb @@ -15,7 +15,9 @@ module CommitStatusEnums stale_schedule: 7, job_execution_timeout: 8, archived_failure: 9, - unmet_prerequisites: 10 + unmet_prerequisites: 10, + scheduler_failure: 11, + data_integrity_failure: 12 } end end diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb index 54e9a13d1eae3e663682e887203891dd9e4438aa..0e07806dd6f1158ee4b53f1c4e347a42268558dd 100644 --- a/app/models/concerns/analytics/cycle_analytics/stage.rb +++ b/app/models/concerns/analytics/cycle_analytics/stage.rb @@ -4,19 +4,28 @@ module Analytics module CycleAnalytics module Stage extend ActiveSupport::Concern + include RelativePositioning + include Gitlab::Utils::StrongMemoize included do + belongs_to :start_event_label, class_name: 'GroupLabel', optional: true + belongs_to :end_event_label, class_name: 'GroupLabel', optional: true + validates :name, presence: true validates :name, exclusion: { in: Gitlab::Analytics::CycleAnalytics::DefaultStages.names }, if: :custom? validates :start_event_identifier, presence: true validates :end_event_identifier, presence: true + validates :start_event_label, presence: true, if: :start_event_label_based? + validates :end_event_label, presence: true, if: :end_event_label_based? validate :validate_stage_event_pairs + validate :validate_labels enum start_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents.to_enum, _prefix: :start_event_identifier enum end_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents.to_enum, _prefix: :end_event_identifier alias_attribute :custom_stage?, :custom scope :default_stages, -> { where(custom: false) } + scope :ordered, -> { order(:relative_position, :id) } end def parent=(_) @@ -28,19 +37,41 @@ module Analytics end def start_event - Gitlab::Analytics::CycleAnalytics::StageEvents[start_event_identifier].new(params_for_start_event) + strong_memoize(:start_event) do + Gitlab::Analytics::CycleAnalytics::StageEvents[start_event_identifier].new(params_for_start_event) + end end def end_event - Gitlab::Analytics::CycleAnalytics::StageEvents[end_event_identifier].new(params_for_end_event) + strong_memoize(:end_event) do + Gitlab::Analytics::CycleAnalytics::StageEvents[end_event_identifier].new(params_for_end_event) + end + end + + def start_event_label_based? + start_event_identifier && start_event.label_based? + end + + def end_event_label_based? + end_event_identifier && end_event.label_based? + end + + def start_event_identifier=(identifier) + clear_memoization(:start_event) + super + end + + def end_event_identifier=(identifier) + clear_memoization(:end_event) + super end def params_for_start_event - {} + start_event_label.present? ? { label: start_event_label } : {} end def params_for_end_event - {} + end_event_label.present? ? { label: end_event_label } : {} end def default_stage? @@ -58,19 +89,44 @@ module Analytics end_event_identifier.to_s.eql?(stage_params[:end_event_identifier].to_s) end + def find_with_same_parent!(id) + parent.cycle_analytics_stages.find(id) + end + private def validate_stage_event_pairs return if start_event_identifier.nil? || end_event_identifier.nil? unless pairing_rules.fetch(start_event.class, []).include?(end_event.class) - errors.add(:end_event, :not_allowed_for_the_given_start_event) + errors.add(:end_event, s_('CycleAnalytics|not allowed for the given start event')) end end def pairing_rules Gitlab::Analytics::CycleAnalytics::StageEvents.pairing_rules end + + def validate_labels + validate_label_within_group(:start_event_label, start_event_label_id) if start_event_label_id_changed? + validate_label_within_group(:end_event_label, end_event_label_id) if end_event_label_id_changed? + end + + def validate_label_within_group(association_name, label_id) + return unless label_id + return unless group + + unless label_available_for_group?(label_id) + errors.add(association_name, s_('CycleAnalyticsStage|is not available for the selected group')) + end + end + + def label_available_for_group?(label_id) + LabelsFinder.new(nil, { group_id: group.id, include_ancestor_groups: true, only_group_labels: true }) + .execute(skip_authorization: true) + .by_ids(label_id) + .exists? + end end end end diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb index a0ca8a34c6d255e2dd0d3a8c10191cc3e95a4298..17d431bacf2808399010b2c90ad8da2df6f26e4f 100644 --- a/app/models/concerns/ci/metadatable.rb +++ b/app/models/concerns/ci/metadatable.rb @@ -16,6 +16,7 @@ module Ci delegate :timeout, to: :metadata, prefix: true, allow_nil: true delegate :interruptible, to: :metadata, prefix: false, allow_nil: true + delegate :has_exposed_artifacts?, to: :metadata, prefix: false, allow_nil: true before_create :ensure_metadata end @@ -45,6 +46,9 @@ module Ci def options=(value) write_metadata_attribute(:options, :config_options, value) + + # Store presence of exposed artifacts in build metadata to make it easier to query + ensure_metadata.has_exposed_artifacts = value&.dig(:artifacts, :expose_as).present? end def yaml_variables=(value) diff --git a/app/models/concerns/ci/processable.rb b/app/models/concerns/ci/processable.rb index 268fa8ec692feff4eb309c1e08a8b1f373044200..ed0087f34d48fd611a7ed0dd99be359aeefd707d 100644 --- a/app/models/concerns/ci/processable.rb +++ b/app/models/concerns/ci/processable.rb @@ -8,6 +8,14 @@ module Ci # # 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 + end + def schedulable? raise NotImplementedError end diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb index fe8e9609820f0d1d04d92f427a3f48cec5b352a0..3b893a56bd62e95ff21e03b06fee48cb619040f8 100644 --- a/app/models/concerns/deployment_platform.rb +++ b/app/models/concerns/deployment_platform.rb @@ -12,7 +12,7 @@ module DeploymentPlatform private def cluster_management_project_enabled? - Feature.enabled?(:cluster_management_project, default_enabled: true) + Feature.enabled?(:cluster_management_project, self, default_enabled: true) end def find_deployment_platform(environment) diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 852576dbbc2ff0fe0db40ebb96d969f33958c523..01cd1e0224b1b9aa85f1309b31f3a938943806ae 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -118,8 +118,8 @@ module Issuable # 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('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC') } - scope :order_milestone_due_asc, -> { left_joins_milestones.reorder('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC') } + 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_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) } @@ -137,6 +137,26 @@ module Issuable strip_attributes :title + # The state_machine gem will reset the value of state_id unless it + # is a raw attribute passed in here: + # https://gitlab.com/gitlab-org/gitlab/issues/35746#note_241148787 + # + # This assumes another initialize isn't defined. Otherwise this + # method may need to be prepended. + def initialize(attributes = nil) + if attributes.is_a?(Hash) + attr = attributes.symbolize_keys + + if attr.key?(:state) && !attr.key?(:state_id) + value = attr.delete(:state) + state_id = self.class.available_states[value] + attributes[:state_id] = state_id if state_id + end + end + + super(attributes) + end + # We want to use optimistic lock for cases when only title or description are involved # http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html def locking_enabled? diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index 42b370990ac1bfdc6e8bf366274fc47a5f4bb63e..b1a7d7ec819f883b382fcda2bfdd51553d47bd5d 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -101,6 +101,10 @@ module Milestoneish false end + def global_milestone? + false + end + def total_issue_time_spent @total_issue_time_spent ||= issues.joins(:timelogs).sum(:time_spent) end diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index 3065e0ba6c5885939a317acefedc241f2ed4628a..19f2daa1b0198db56d152e7712499a1a6dfe6f49 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -108,10 +108,6 @@ module Noteable discussions_resolvable? && resolvable_discussions.none?(&:to_be_resolved?) end - def discussions_to_be_resolved? - discussions_resolvable? && !discussions_resolved? - end - def discussions_to_be_resolved @discussions_to_be_resolved ||= resolvable_discussions.select(&:to_be_resolved?) end diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb index ebacc459cb572826b141f4df3e510e47238bc167..d9a7f0a96dc65796adcb138c16b14d5284cd9ef0 100644 --- a/app/models/concerns/protected_ref.rb +++ b/app/models/concerns/protected_ref.rb @@ -39,8 +39,8 @@ module ProtectedRef end end - def developers_can?(action, ref) - access_levels_for_ref(ref, action: action).any? do |access_level| + def developers_can?(action, ref, protected_refs: nil) + access_levels_for_ref(ref, action: action, protected_refs: protected_refs).any? do |access_level| access_level.access_level == Gitlab::Access::DEVELOPER end end diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb index 78544405c495bae65d016135538943d4b820bcf8..9c2b0372d54db26707a9981989a7d6dc05552951 100644 --- a/app/models/concerns/storage/legacy_namespace.rb +++ b/app/models/concerns/storage/legacy_namespace.rb @@ -55,20 +55,22 @@ module Storage def move_repositories # Move the namespace directory in all storages used by member projects - repository_storages.each do |repository_storage| + repository_storages(legacy_only: true).each do |repository_storage| # Ensure old directory exists before moving it - gitlab_shell.add_namespace(repository_storage, full_path_before_last_save) + Gitlab::GitalyClient::NamespaceService.allow do + gitlab_shell.add_namespace(repository_storage, full_path_before_last_save) - # Ensure new directory exists before moving it (if there's a parent) - gitlab_shell.add_namespace(repository_storage, parent.full_path) if parent + # Ensure new directory exists before moving it (if there's a parent) + gitlab_shell.add_namespace(repository_storage, parent.full_path) if parent - unless gitlab_shell.mv_namespace(repository_storage, full_path_before_last_save, full_path) + unless gitlab_shell.mv_namespace(repository_storage, full_path_before_last_save, full_path) - Rails.logger.error "Exception moving path #{repository_storage} from #{full_path_before_last_save} to #{full_path}" # rubocop:disable Gitlab/RailsLogger + Rails.logger.error "Exception moving path #{repository_storage} from #{full_path_before_last_save} to #{full_path}" # rubocop:disable Gitlab/RailsLogger - # if we cannot move namespace directory we should rollback - # db changes in order to prevent out of sync between db and fs - raise Gitlab::UpdatePathError.new('namespace directory cannot be moved') + # if we cannot move namespace directory we should rollback + # db changes in order to prevent out of sync between db and fs + raise Gitlab::UpdatePathError.new('namespace directory cannot be moved') + end end end end @@ -77,12 +79,14 @@ module Storage @old_repository_storage_paths ||= repository_storages end - def repository_storages + def repository_storages(legacy_only: false) # We need to get the storage paths for all the projects, even the ones that are # pending delete. Unscoping also get rids of the default order, which causes # problems with SELECT DISTINCT. Project.unscoped do - all_projects.select('distinct(repository_storage)').to_a.map(&:repository_storage) + namespace_projects = all_projects + namespace_projects = namespace_projects.without_storage_feature(:repository) if legacy_only + namespace_projects.pluck(Arel.sql('distinct(repository_storage)')) end end @@ -93,13 +97,15 @@ module Storage # We will remove it later async new_path = "#{full_path}+#{id}+deleted" - if gitlab_shell.mv_namespace(repository_storage, full_path, new_path) - Gitlab::AppLogger.info %Q(Namespace directory "#{full_path}" moved to "#{new_path}") + Gitlab::GitalyClient::NamespaceService.allow do + if gitlab_shell.mv_namespace(repository_storage, full_path, new_path) + Gitlab::AppLogger.info %Q(Namespace directory "#{full_path}" moved to "#{new_path}") - # Remove namespace directory async with delay so - # GitLab has time to remove all projects first - run_after_commit do - GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage, new_path) + # Remove namespace directory async with delay so + # GitLab has time to remove all projects first + run_after_commit do + GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage, new_path) + end end end end diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb index 92a5c1112af99b1c69af5925114fd612bcfc8dd8..33e9e0e38fbfea62221e02288e836d048fe71966 100644 --- a/app/models/concerns/subscribable.rb +++ b/app/models/concerns/subscribable.rb @@ -59,6 +59,14 @@ module Subscribable .update(subscribed: false) end + def set_subscription(user, desired_state, project = nil) + if desired_state + subscribe(user, project) + else + unsubscribe(user, project) + end + end + private def unsubscribe_from_other_levels(user, project) diff --git a/app/models/concerns/worker_attributes.rb b/app/models/concerns/worker_attributes.rb index af40e9e3b19fed9425badcf3bdce4c2b03e9d0ab..506215ca9edb61e8427d79aa348bbf2d121f2307 100644 --- a/app/models/concerns/worker_attributes.rb +++ b/app/models/concerns/worker_attributes.rb @@ -3,6 +3,10 @@ module WorkerAttributes extend ActiveSupport::Concern + # Resource boundaries that workers can declare through the + # `worker_resource_boundary` attribute + VALID_RESOURCE_BOUNDARIES = [:memory, :cpu, :unknown].freeze + class_methods do def feature_category(value) raise "Invalid category. Use `feature_category_not_owned!` to mark a worker as not owned" if value == :not_owned @@ -24,6 +28,48 @@ module WorkerAttributes get_worker_attribute(:feature_category) == :not_owned end + # This should be set for jobs that need to be run immediately, or, if + # they are delayed, risk creating inconsistencies in the application + # that could being perceived by the user as incorrect behavior + # (ie, a bug) + # See doc/development/sidekiq_style_guide.md#Latency-Sensitive-Jobs + # for details + def latency_sensitive_worker! + worker_attributes[:latency_sensitive] = true + end + + # Returns a truthy value if the worker is latency sensitive. + # See doc/development/sidekiq_style_guide.md#Latency-Sensitive-Jobs + # for details + def latency_sensitive_worker? + worker_attributes[:latency_sensitive] + end + + # Set this attribute on a job when it will call to services outside of the + # application, such as 3rd party applications, other k8s clusters etc See + # doc/development/sidekiq_style_guide.md#Jobs-with-External-Dependencies for + # details + def worker_has_external_dependencies! + worker_attributes[:external_dependencies] = true + end + + # Returns a truthy value if the worker has external dependencies. + # See doc/development/sidekiq_style_guide.md#Jobs-with-External-Dependencies + # for details + def worker_has_external_dependencies? + worker_attributes[:external_dependencies] + end + + def worker_resource_boundary(boundary) + raise "Invalid boundary" unless VALID_RESOURCE_BOUNDARIES.include? boundary + + worker_attributes[:resource_boundary] = boundary + end + + def get_worker_resource_boundary + worker_attributes[:resource_boundary] || :unknown + end + protected # Returns a worker attribute declared on this class or its parent class. diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index 27bb76835c73599ff21dd42e89dccd5d4088c903..152aa7b3218e39230c7d262e17f647d6b4093997 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -11,7 +11,10 @@ class ContainerRepository < ApplicationRecord delegate :client, to: :registry scope :ordered, -> { order(:name) } - scope :with_api_entity_associations, -> { preload(:project) } + scope :with_api_entity_associations, -> { preload(project: [:route, { namespace: :route }]) } + scope :for_group_and_its_subgroups, ->(group) do + where(project_id: Project.for_group_and_its_subgroups(group).with_container_registry.select(:id)) + end # rubocop: disable CodeReuse/ServiceClass def registry diff --git a/app/models/dashboard_group_milestone.rb b/app/models/dashboard_group_milestone.rb index ec52f1ed370ef74e18650a0ff45f6477dfc7c278..cf6094682f3059472377b42888a56d8885fb2bdc 100644 --- a/app/models/dashboard_group_milestone.rb +++ b/app/models/dashboard_group_milestone.rb @@ -18,4 +18,8 @@ class DashboardGroupMilestone < GlobalMilestone milestones = milestones.search_title(params[:search_title]) if params[:search_title].present? Milestone.filter_by_state(milestones, params[:state]).map { |m| new(m) } end + + def dashboard_milestone? + true + end end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 7ccd5e98360d17f1fe8a2712a32226131c821502..4a38912db9bd33a05f52d06b13755fab9b7e42aa 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -10,6 +10,10 @@ class Deployment < ApplicationRecord belongs_to :cluster, class_name: 'Clusters::Cluster', optional: true belongs_to :user belongs_to :deployable, polymorphic: true, optional: true # rubocop:disable Cop/PolymorphicAssociations + has_many :deployment_merge_requests + + has_many :merge_requests, + through: :deployment_merge_requests has_internal_id :iid, scope: :project, init: ->(s) do Deployment.where(project: s.project).maximum(:iid) if s&.project @@ -75,6 +79,11 @@ class Deployment < ApplicationRecord find(ids) end + def self.distinct_on_environment + order('environment_id, deployments.id DESC') + .select('DISTINCT ON (environment_id) deployments.*') + end + def self.find_successful_deployment!(iid) success.find_by!(iid: iid) end @@ -144,6 +153,18 @@ class Deployment < ApplicationRecord project.deployments.joins(:environment) .where(environments: { name: self.environment.name }, ref: self.ref) .where.not(id: self.id) + .order(id: :desc) + .take + end + + def previous_environment_deployment + project + .deployments + .success + .joins(:environment) + .where(environments: { name: environment.name }) + .where.not(id: self.id) + .order(id: :desc) .take end @@ -176,6 +197,18 @@ class Deployment < ApplicationRecord deployable&.user || user end + def link_merge_requests(relation) + select = relation.select(['merge_requests.id', id]).to_sql + + # We don't use `Gitlab::Database.bulk_insert` here so that we don't need to + # first pluck lots of IDs into memory. + DeploymentMergeRequest.connection.execute(<<~SQL) + INSERT INTO #{DeploymentMergeRequest.table_name} + (merge_request_id, deployment_id) + #{select} + SQL + end + private def ref_path diff --git a/app/models/deployment_merge_request.rb b/app/models/deployment_merge_request.rb new file mode 100644 index 0000000000000000000000000000000000000000..ff4d9f662021d342104f3c681e66f877be520576 --- /dev/null +++ b/app/models/deployment_merge_request.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class DeploymentMergeRequest < ApplicationRecord + belongs_to :deployment, optional: false + belongs_to :merge_request, optional: false +end diff --git a/app/models/description_version.rb b/app/models/description_version.rb index abab7f9421208a16e725d323639301d2a46f7e3a..05362a2f90be56484f6199e7a8f4a86c3921f65d 100644 --- a/app/models/description_version.rb +++ b/app/models/description_version.rb @@ -10,6 +10,10 @@ class DescriptionVersion < ApplicationRecord %i(issue merge_request).freeze end + def issuable + issue || merge_request + end + private def exactly_one_issuable diff --git a/app/models/environment.rb b/app/models/environment.rb index af0c219d9a01e22bc8c4b453baf1b34a63c2e0a4..327b1e594d78e75b5f1f591c641722eba6b1cf84 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -4,12 +4,20 @@ class Environment < ApplicationRecord include Gitlab::Utils::StrongMemoize include ReactiveCaching + self.reactive_cache_refresh_interval = 1.minute + self.reactive_cache_lifetime = 55.seconds + belongs_to :project, required: true has_many :deployments, -> { visible }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :successful_deployments, -> { success }, class_name: 'Deployment' has_one :last_deployment, -> { success.order('deployments.id DESC') }, class_name: 'Deployment' + has_one :last_deployable, through: :last_deployment, source: 'deployable', source_type: 'CommitStatus' + has_one :last_pipeline, through: :last_deployable, source: 'pipeline' + has_one :last_visible_deployment, -> { visible.distinct_on_environment }, inverse_of: :environment, class_name: 'Deployment' + has_one :last_visible_deployable, through: :last_visible_deployment, source: 'deployable', source_type: 'CommitStatus' + has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline' before_validation :nullify_external_url before_validation :generate_slug, if: ->(env) { env.slug.blank? } @@ -60,6 +68,10 @@ class Environment < ApplicationRecord scope :for_project, -> (project) { where(project_id: project) } scope :with_deployment, -> (sha) { where('EXISTS (?)', Deployment.select(1).where('deployments.environment_id = environments.id').where(sha: sha)) } + scope :unfoldered, -> { where(environment_type: nil) } + scope :with_rank, -> do + select('environments.*, rank() OVER (PARTITION BY project_id ORDER BY id DESC)') + end state_machine :state, initial: :available do event :start do @@ -188,6 +200,10 @@ class Environment < ApplicationRecord prometheus_adapter.query(:environment, self) if has_metrics? end + def prometheus_status + deployment_platform&.cluster&.application_prometheus&.status_name + end + def additional_metrics(*args) return unless has_metrics? diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb index 0b4fef5eac12e6595877c3b3db5ebd94cc5aa592..2aa058a243f1da35291282bfbcbba43da2fd4704 100644 --- a/app/models/error_tracking/project_error_tracking_setting.rb +++ b/app/models/error_tracking/project_error_tracking_setting.rb @@ -7,6 +7,7 @@ module ErrorTracking SENTRY_API_ERROR_TYPE_MISSING_KEYS = 'missing_keys_in_sentry_response' SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE = 'non_20x_response_from_sentry' + SENTRY_API_ERROR_INVALID_SIZE = 'invalid_size_of_sentry_response' API_URL_PATH_REGEXP = %r{ \A @@ -87,15 +88,37 @@ module ErrorTracking { projects: sentry_client.list_projects } end + def issue_details(opts = {}) + with_reactive_cache('issue_details', opts.stringify_keys) do |result| + result + end + end + + def issue_latest_event(opts = {}) + with_reactive_cache('issue_latest_event', opts.stringify_keys) do |result| + result + end + end + def calculate_reactive_cache(request, opts) case request when 'list_issues' { 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) + } 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 } end # http://HOST/api/0/projects/ORG/PROJECT diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb index 7d766e1f25c81745e53ed9da453a76179d926afe..65fd5c1b35a34ffdfff034ded7b5629af2b9f2ea 100644 --- a/app/models/global_milestone.rb +++ b/app/models/global_milestone.rb @@ -11,7 +11,7 @@ class GlobalMilestone delegate :title, :state, :due_date, :start_date, :participants, :project, :group, :expires_at, :closed?, :iid, :group_milestone?, :safe_title, - :milestoneish_id, :resource_parent, to: :milestone + :milestoneish_id, :resource_parent, :releases, to: :milestone def to_hash { @@ -100,4 +100,8 @@ class GlobalMilestone def labels @labels ||= GlobalLabel.build_collection(milestone.labels).sort_by!(&:title) end + + def global_milestone? + true + end end diff --git a/app/models/grafana_integration.rb b/app/models/grafana_integration.rb index 51cc398394d7320a3131ede182c812ca26821bbb..ed4c279965aa7455bbdef84324ebd0a04dc273c5 100644 --- a/app/models/grafana_integration.rb +++ b/app/models/grafana_integration.rb @@ -14,7 +14,13 @@ class GrafanaIntegration < ApplicationRecord validates :token, :project, presence: true + validates :enabled, inclusion: { in: [true, false] } + + scope :enabled, -> { where(enabled: true) } + def client + return unless enabled? + @client ||= ::Grafana::Client.new(api_url: grafana_url.chomp('/'), token: token) end end diff --git a/app/models/group.rb b/app/models/group.rb index 042201ffa1451b32e7619cafb2b3a189b7646867..8289d4f099c0b61dd2daa1041937b65cb7f192c2 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -30,6 +30,10 @@ class Group < Namespace has_many :members_and_requesters, as: :source, class_name: 'GroupMember' has_many :milestones + has_many :shared_group_links, foreign_key: :shared_with_group_id, class_name: 'GroupGroupLink' + has_many :shared_with_group_links, foreign_key: :shared_group_id, class_name: 'GroupGroupLink' + has_many :shared_groups, through: :shared_group_links, source: :shared_group + has_many :shared_with_groups, through: :shared_with_group_links, source: :shared_with_group has_many :project_group_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :shared_projects, through: :project_group_links, source: :project @@ -51,6 +55,8 @@ class Group < Namespace has_many :todos + has_one :import_export_upload + accepts_nested_attributes_for :variables, allow_destroy: true validate :visibility_level_allowed_by_projects @@ -120,7 +126,7 @@ class Group < Namespace def visible_to_user_arel(user) groups_table = self.arel_table - authorized_groups = user.authorized_groups.as('authorized') + authorized_groups = user.authorized_groups.arel.as('authorized') groups_table.project(1) .from(authorized_groups) @@ -259,8 +265,8 @@ class Group < Namespace members_with_parents.maintainers.exists?(user_id: user) end - def has_container_repositories? - container_repositories.exists? + def has_container_repository_including_subgroups? + ::ContainerRepository.for_group_and_its_subgroups(self).exists? end # @deprecated @@ -376,11 +382,12 @@ class Group < Namespace return GroupMember::OWNER if user.admin? - members_with_parents - .where(user_id: user) - .reorder(access_level: :desc) - .first&. - access_level || GroupMember::NO_ACCESS + max_member_access = members_with_parents.where(user_id: user) + .reorder(access_level: :desc) + .first + &.access_level + + max_member_access || max_member_access_for_user_from_shared_groups(user) || GroupMember::NO_ACCESS end def mattermost_team_params @@ -444,6 +451,14 @@ class Group < Namespace false end + def export_file_exists? + export_file&.file + end + + def export_file + import_export_upload&.export_file + end + private def update_two_factor_requirement @@ -474,6 +489,26 @@ class Group < Namespace errors.add(:visibility_level, "#{visibility} is not allowed since there are sub-groups with higher visibility.") end + def max_member_access_for_user_from_shared_groups(user) + return unless Feature.enabled?(:share_group_with_group) + + group_group_link_table = GroupGroupLink.arel_table + group_member_table = GroupMember.arel_table + + group_group_links_query = GroupGroupLink.where(shared_group_id: self_and_ancestors_ids) + cte = Gitlab::SQL::CTE.new(:group_group_links_cte, group_group_links_query) + + link = GroupGroupLink + .with(cte.to_arel) + .from([group_member_table, cte.alias_to(group_group_link_table)]) + .where(group_member_table[:user_id].eq(user.id)) + .where(group_member_table[:source_id].eq(group_group_link_table[:shared_with_group_id])) + .reorder(Arel::Nodes::Descending.new(group_group_link_table[:group_access])) + .first + + link&.group_access + end + def self.groups_including_descendants_by(group_ids) Gitlab::ObjectHierarchy .new(Group.where(id: group_ids)) diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb new file mode 100644 index 0000000000000000000000000000000000000000..4b279b7af5bd3f6a25a17b149ae9a0d9c426de65 --- /dev/null +++ b/app/models/group_group_link.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class GroupGroupLink < ApplicationRecord + include Expirable + + belongs_to :shared_group, class_name: 'Group', foreign_key: :shared_group_id + belongs_to :shared_with_group, class_name: 'Group', foreign_key: :shared_with_group_id + + validates :shared_group, presence: true + validates :shared_group_id, uniqueness: { scope: [:shared_with_group_id], + message: _('The group has already been shared with this group') } + validates :shared_with_group, presence: true + validates :group_access, inclusion: { in: Gitlab::Access.values }, + presence: true + + def self.access_options + Gitlab::Access.options + end + + def self.default_access + Gitlab::Access::DEVELOPER + end +end diff --git a/app/models/import_export_upload.rb b/app/models/import_export_upload.rb index 60f5491849ac738810bd118f8ac747c709fb7c97..7d73fd281f1a5907a220e88e8b85de9108af2499 100644 --- a/app/models/import_export_upload.rb +++ b/app/models/import_export_upload.rb @@ -5,6 +5,7 @@ class ImportExportUpload < ApplicationRecord include ObjectStorage::BackgroundMove belongs_to :project + belongs_to :group # These hold the project Import/Export archives (.tar.gz files) mount_uploader :import_file, ImportExportUploader diff --git a/app/models/issue.rb b/app/models/issue.rb index b9b481ac29be9d7b40b507c637bc866b158db270..948cadc34e54c6473fdc30b8fc31800d4702de31 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -40,6 +40,7 @@ class Issue < ApplicationRecord has_many :issue_assignees has_many :assignees, class_name: "User", through: :issue_assignees + has_many :zoom_meetings validates :project, presence: true @@ -54,9 +55,9 @@ class Issue < ApplicationRecord scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) } scope :due_tomorrow, -> { where(due_date: Date.tomorrow) } - scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') } - scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') } - scope :order_closest_future_date, -> { reorder('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC') } + scope :order_due_date_asc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'ASC')) } + scope :order_due_date_desc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'DESC')) } + scope :order_closest_future_date, -> { reorder(Arel.sql('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC')) } scope :order_relative_position_asc, -> { reorder(::Gitlab::Database.nulls_last_order('relative_position', 'ASC')) } scope :preload_associations, -> { preload(:labels, project: :namespace) } @@ -65,6 +66,8 @@ class Issue < ApplicationRecord scope :public_only, -> { where(confidential: false) } scope :confidential_only, -> { where(confidential: true) } + scope :counts_by_state, -> { reorder(nil).group(:state).count } + after_commit :expire_etag_cache after_save :ensure_metrics, unless: :imported? @@ -137,8 +140,8 @@ class Issue < ApplicationRecord def self.sort_by_attribute(method, excluded_labels: []) case method.to_s when 'closest_future_date', 'closest_future_date_asc' then order_closest_future_date - when 'due_date', 'due_date_asc' then order_due_date_asc - when 'due_date_desc' then order_due_date_desc + when 'due_date', 'due_date_asc' then order_due_date_asc.with_order_id_desc + when 'due_date_desc' then order_due_date_desc.with_order_id_desc when 'relative_position', 'relative_position_asc' then order_relative_position_asc.with_order_id_desc else super @@ -206,7 +209,16 @@ class Issue < ApplicationRecord if self.confidential? "#{iid}-confidential-issue" else - "#{iid}-#{title.parameterize}" + branch_name = "#{iid}-#{title.parameterize}" + + if branch_name.length > 100 + truncated_string = branch_name[0, 100] + # Delete everything dangling after the last hyphen so as not to risk + # existence of unintended words in the branch name due to mid-word split. + branch_name = truncated_string[0, truncated_string.rindex("-")] + end + + branch_name end end diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index 535c3cf2ba146573757e84406226a870bd8f5831..48c971194c6a5378b6f1a82850c619edabb22ab3 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -18,6 +18,11 @@ class LfsObject < ApplicationRecord after_save :update_file_store, if: :saved_change_to_file? + def self.not_linked_to_project(project) + where('NOT EXISTS (?)', + project.lfs_objects_projects.select(1).where('lfs_objects_projects.lfs_object_id = lfs_objects.id')) + end + def update_file_store # The file.object_store is set during `uploader.store!` # which happens after object is inserted/updated diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 32741046f396a6c23e656eebf27318db74a65e67..7e1898e714226cc6044bda6d31ac17cf2d1a44d4 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -16,6 +16,9 @@ class MergeRequest < ApplicationRecord include ReactiveCaching include FromUnion include DeprecatedAssignee + include ShaAttribute + + sha_attribute :squash_commit_sha self.reactive_cache_key = ->(model) { [model.project.id, model.iid] } self.reactive_cache_refresh_interval = 10.minutes @@ -65,6 +68,7 @@ class MergeRequest < ApplicationRecord has_many :cached_closes_issues, through: :merge_requests_closing_issues, source: :issue has_many :pipelines_for_merge_request, foreign_key: 'merge_request_id', class_name: 'Ci::Pipeline' has_many :suggestions, through: :notes + has_many :unresolved_notes, -> { unresolved }, as: :noteable, class_name: 'Note' has_many :merge_request_assignees has_many :assignees, class_name: "User", through: :merge_request_assignees @@ -202,11 +206,14 @@ class MergeRequest < ApplicationRecord scope :by_commit_sha, ->(sha) do where('EXISTS (?)', MergeRequestDiff.select(1).where('merge_requests.latest_merge_request_diff_id = merge_request_diffs.id').by_commit_sha(sha)).reorder(nil) end + scope :by_merge_commit_sha, -> (sha) do + where(merge_commit_sha: sha) + end scope :join_project, -> { joins(:target_project) } scope :references_project, -> { references(:target_project) } scope :with_api_entity_associations, -> { - preload(:assignees, :author, :notes, :labels, :milestone, :timelogs, - latest_merge_request_diff: [:merge_request_diff_commits], + preload(:assignees, :author, :unresolved_notes, :labels, :milestone, + :timelogs, :latest_merge_request_diff, metrics: [:latest_closed_by, :merged_by], target_project: [:route, { namespace: :route }], source_project: [:route, { namespace: :route }]) @@ -217,17 +224,27 @@ class MergeRequest < ApplicationRecord scope :by_target_branch, ->(branch_name) { where(target_branch: branch_name) } scope :preload_source_project, -> { preload(:source_project) } - scope :with_open_merge_when_pipeline_succeeds, -> do - with_state(:opened).where(merge_when_pipeline_succeeds: true) + scope :with_auto_merge_enabled, -> do + with_state(:opened).where(auto_merge_enabled: true) end after_save :keep_around_commit alias_attribute :project, :target_project alias_attribute :project_id, :target_project_id + + # Currently, `merge_when_pipeline_succeeds` column is used as a flag + # to check if _any_ auto merge strategy is activated on the merge request. + # Today, we have multiple strategies and MWPS is one of them. + # we'd eventually rename the column for avoiding confusions, but in the mean time + # please use `auto_merge_enabled` alias instead of `merge_when_pipeline_succeeds`. alias_attribute :auto_merge_enabled, :merge_when_pipeline_succeeds alias_method :issuing_parent, :target_project + 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 @@ -357,11 +374,12 @@ class MergeRequest < ApplicationRecord "#{project.to_reference(from, full: full)}#{reference}" end - def commits - return merge_request_diff.commits if persisted? + def commits(limit: nil) + return merge_request_diff.commits(limit: limit) if persisted? commits_arr = if compare_commits - compare_commits.reverse + reversed_commits = compare_commits.reverse + limit ? reversed_commits.take(limit) : reversed_commits else [] end @@ -369,6 +387,10 @@ class MergeRequest < ApplicationRecord CommitCollection.new(source_project, commits_arr, source_branch) end + def recent_commits + commits(limit: MergeRequestDiff::COMMITS_SAFE_SIZE) + end + def commits_count if persisted? merge_request_diff.commits_count @@ -379,14 +401,17 @@ class MergeRequest < ApplicationRecord end end - def commit_shas - if persisted? - merge_request_diff.commit_shas - elsif compare_commits - compare_commits.to_a.reverse.map(&:sha) - else - Array(diff_head_sha) - end + def commit_shas(limit: nil) + return merge_request_diff.commit_shas(limit: limit) if persisted? + + shas = + if compare_commits + compare_commits.to_a.reverse.map(&:sha) + else + Array(diff_head_sha) + end + + limit ? shas.take(limit) : shas end # Returns true if there are commits that match at least one commit SHA. @@ -417,9 +442,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) - transaction do - lock! - + with_rebase_lock do raise ActiveRecord::StaleObjectError if !open? || rebase_in_progress? # Although there is a race between setting rebase_jid here and clearing it @@ -782,6 +805,8 @@ class MergeRequest < ApplicationRecord end def check_mergeability + return if Feature.enabled?(:merge_requests_conditional_mergeability_check, default_enabled: true) && !recheck_merge_status? + MergeRequests::MergeabilityCheckService.new(self).execute(retry_lease: false) end # rubocop: enable CodeReuse/ServiceClass @@ -896,7 +921,7 @@ class MergeRequest < ApplicationRecord def commit_notes # Fetch comments only from last 100 commits - commit_ids = commit_shas.take(100) + commit_ids = commit_shas(limit: 100) Note .user @@ -907,7 +932,7 @@ class MergeRequest < ApplicationRecord def mergeable_discussions_state? return true unless project.only_allow_merge_if_all_discussions_are_resolved? - !discussions_to_be_resolved? + unresolved_notes.none?(&:to_be_resolved?) end def for_fork? @@ -1087,7 +1112,7 @@ class MergeRequest < ApplicationRecord return true unless project.only_allow_merge_if_pipeline_succeeds? return false unless actual_head_pipeline - actual_head_pipeline.success? || actual_head_pipeline.skipped? + actual_head_pipeline.success? end def environments_for(current_user) @@ -1263,6 +1288,27 @@ class MergeRequest < ApplicationRecord compare_reports(Ci::CompareTestReportsService) end + def has_exposed_artifacts? + return false unless Feature.enabled?(:ci_expose_arbitrary_artifacts_in_mr, default_enabled: true) + + actual_head_pipeline&.has_exposed_artifacts? + end + + # TODO: this method and compare_test_reports use the same + # result type, which is handled by the controller's #reports_response. + # we should minimize mistakes by isolating the common parts. + # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 + def find_exposed_artifacts + unless has_exposed_artifacts? + return { status: :error, status_reason: 'This merge request does not have exposed artifacts' } + end + + compare_reports(Ci::GenerateExposedArtifactsReportService) + end + + # TODO: consider renaming this as with exposed artifacts we generate reports, + # not always compare + # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 def compare_reports(service_class, current_user = nil) with_reactive_cache(service_class.name, current_user&.id) do |data| unless service_class.new(project, current_user) @@ -1277,6 +1323,8 @@ class MergeRequest < ApplicationRecord def calculate_reactive_cache(identifier, current_user_id = nil, *args) service_class = identifier.constantize + # TODO: the type check should change to something that includes exposed artifacts service + # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 raise NameError, service_class unless service_class < Ci::CompareReportsBaseService current_user = User.find_by(id: current_user_id) @@ -1453,6 +1501,30 @@ class MergeRequest < ApplicationRecord private + def with_rebase_lock + if Feature.enabled?(:merge_request_rebase_nowait_lock, default_enabled: true) + with_retried_nowait_lock { yield } + else + with_lock(true) { yield } + end + end + + # If the merge request is idle in transaction or has a SELECT FOR + # UPDATE, we don't want to block indefinitely or this could cause a + # queue of SELECT FOR UPDATE calls. Instead, try to get the lock for + # 5 s before raising an error to the user. + def with_retried_nowait_lock + # Try at most 0.25 + (1.5 * .25) + (1.5^2 * .25) ... (1.5^5 * .25) = 5.2 s to get the lock + Retriable.retriable(on: ActiveRecord::LockWaitTimeout, tries: 6, base_interval: 0.25) do + with_lock('FOR UPDATE NOWAIT') do + yield + end + end + rescue ActiveRecord::LockWaitTimeout => e + Gitlab::Sentry.track_acceptable_exception(e) + raise RebaseLockTimeout, REBASE_LOCK_MESSAGE + end + def source_project_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| break variables unless source_project diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 735ad046f2287615ccd72b0bb17a933ad55bd809..70ce4df56785227982b110fb67d56ceab278b3f8 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -213,12 +213,14 @@ class MergeRequestDiff < ApplicationRecord end end - def commits - @commits ||= load_commits + def commits(limit: nil) + strong_memoize(:"commits_#{limit || 'all'}") do + load_commits(limit: limit) + end end def last_commit_sha - commit_shas.first + commit_shas(limit: 1).first end def first_commit @@ -247,8 +249,8 @@ class MergeRequestDiff < ApplicationRecord project.commit_by(oid: head_commit_sha) end - def commit_shas - merge_request_diff_commits.map(&:sha) + def commit_shas(limit: nil) + merge_request_diff_commits.limit(limit).pluck(:sha) end def commits_by_shas(shas) @@ -529,8 +531,9 @@ class MergeRequestDiff < ApplicationRecord end end - def load_commits - commits = merge_request_diff_commits.map { |commit| Commit.from_hash(commit.to_hash, project) } + def load_commits(limit: nil) + commits = merge_request_diff_commits.limit(limit) + .map { |commit| Commit.from_hash(commit.to_hash, project) } CommitCollection .new(merge_request.source_project, commits, merge_request.source_branch) diff --git a/app/models/milestone.rb b/app/models/milestone.rb index a9f4cdec9018dfd4034d9b361c59ec726209a333..d0be54eed0236a180b15807c650a61db41df01f9 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -60,6 +60,7 @@ class Milestone < ApplicationRecord validates :group, presence: true, unless: :project validates :project, presence: true, unless: :group + validates :title, presence: true validate :uniqueness_of_title, if: :title_changed? validate :milestone_type_check @@ -330,6 +331,6 @@ class Milestone < ApplicationRecord end def issues_finder_params - { project_id: project_id } + { project_id: project_id, group_id: group_id }.compact end end diff --git a/app/models/notification_reason.rb b/app/models/notification_reason.rb index 6856d3974139a93ae8840ee94c949b1b19b6f246..a79672394179b40cf077f2eccbe8a9a358cf7973 100644 --- a/app/models/notification_reason.rb +++ b/app/models/notification_reason.rb @@ -6,12 +6,14 @@ class NotificationReason OWN_ACTIVITY = 'own_activity' ASSIGNED = 'assigned' MENTIONED = 'mentioned' + SUBSCRIBED = 'subscribed' # Priority list for selecting which reason to return in the notification REASON_PRIORITY = [ OWN_ACTIVITY, ASSIGNED, - MENTIONED + MENTIONED, + SUBSCRIBED ].freeze # returns the priority of a reason as an integer diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 7903a2182ddb7337d3038edbba02752892aed1d1..3869d86b667c4af9de3b5b56d29ed55688d7ed2c 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -24,6 +24,8 @@ class PagesDomain < ApplicationRecord validate :validate_matching_key, if: ->(domain) { domain.certificate.present? || domain.key.present? } validate :validate_intermediates, if: ->(domain) { domain.certificate.present? && domain.certificate_changed? } + default_value_for(:auto_ssl_enabled, allow_nil: false) { ::Gitlab::LetsEncrypt.enabled? } + attr_encrypted :key, mode: :per_attribute_iv_and_salt, insecure_mode: true, diff --git a/app/models/project.rb b/app/models/project.rb index 74da042d5a550100a9f23e8255e6aa62fe81b20e..f4aa336fbcd8e7cdbbf7e1141cbf6035b26e7969 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -76,6 +76,10 @@ class Project < ApplicationRecord delegate :no_import?, to: :import_state, allow_nil: true + # TODO: remove once GitLab 12.5 is released + # https://gitlab.com/gitlab-org/gitlab/issues/34638 + self.ignored_columns += %i[merge_requests_require_code_owner_approval] + default_value_for :archived, false default_value_for :resolve_outdated_diff_discussions, false default_value_for :container_registry_enabled, gitlab_config_features.container_registry @@ -87,6 +91,8 @@ class Project < ApplicationRecord default_value_for :wiki_enabled, gitlab_config_features.wiki 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(: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 } @@ -281,6 +287,7 @@ class Project < ApplicationRecord has_many :variables, class_name: 'Ci::Variable' has_many :triggers, class_name: 'Ci::Trigger' has_many :environments + has_many :environments_for_dashboard, -> { from(with_rank.unfoldered.available, :environments).where('rank <= 3') }, class_name: 'Environment' has_many :deployments has_many :pipeline_schedules, class_name: 'Ci::PipelineSchedule' has_many :project_deploy_tokens @@ -390,6 +397,7 @@ class Project < ApplicationRecord scope :with_project_feature, -> { joins('LEFT JOIN project_features ON projects.id = project_features.project_id') } scope :with_statistics, -> { includes(:statistics) } scope :with_shared_runners, -> { where(shared_runners_enabled: true) } + scope :with_container_registry, -> { where(container_registry_enabled: true) } scope :inside_path, ->(path) do # We need routes alias rs for JOIN so it does not conflict with # includes(:route) which we use in ProjectsFinder. @@ -456,13 +464,6 @@ class Project < ApplicationRecord # Used by Projects::CleanupService to hold a map of rewritten object IDs mount_uploader :bfg_object_map, AttachmentUploader - # Returns a project, if it is not about to be removed. - # - # id - The ID of the project to retrieve. - def self.find_without_deleted(id) - without_deleted.find_by_id(id) - end - def self.eager_load_namespace_and_owner includes(namespace: :owner) end @@ -656,6 +657,11 @@ class Project < ApplicationRecord end end + def preload_protected_branches + preloader = ActiveRecord::Associations::Preloader.new + preloader.preload(self, protected_branches: [:push_access_levels, :merge_access_levels]) + end + # returns all ancestor-groups upto but excluding the given namespace # when no namespace is given, all ancestors upto the top are returned def ancestors_upto(top = nil, hierarchy_order: nil) @@ -1906,7 +1912,7 @@ class Project < ApplicationRecord end def default_environment - production_first = "(CASE WHEN name = 'production' THEN 0 ELSE 1 END), id ASC" + production_first = Arel.sql("(CASE WHEN name = 'production' THEN 0 ELSE 1 END), id ASC") environments .with_state(:available) @@ -1961,27 +1967,6 @@ class Project < ApplicationRecord (auto_devops || build_auto_devops)&.predefined_variables end - def append_or_update_attribute(name, value) - if Project.reflect_on_association(name).try(:macro) == :has_many - # if this is 1-to-N relation, update the parent object - value.each do |item| - item.update!( - Project.reflect_on_association(name).foreign_key => id) - end - - # force to drop relation cache - public_send(name).reset # rubocop:disable GitlabSecurity/PublicSend - - # succeeded - true - else - # if this is another relation or attribute, update just object - update_attribute(name, value) - end - rescue ActiveRecord::RecordInvalid => e - raise e, "Failed to set #{name}: #{e.message}" - end - # Tries to set repository as read_only, checking for existing Git transfers in progress beforehand # # @return [Boolean] true when set to read_only or false when an existing git transfer is in progress diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb index a495d34c07c5a9854ebdd984ada9f7bf38a0efd3..d089a004d3df3863d2000d368f86e4b36739cd71 100644 --- a/app/models/project_ci_cd_setting.rb +++ b/app/models/project_ci_cd_setting.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class ProjectCiCdSetting < ApplicationRecord + # TODO: remove once GitLab 12.7 is released + # https://gitlab.com/gitlab-org/gitlab/issues/36651 + self.ignored_columns += %i[merge_trains_enabled] 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_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb index a3793d9937b47241761a2394f734b121b7f87244..46fe894cfc36bfdc71da721c1d04c0bf510b3983 100644 --- a/app/models/project_services/chat_message/pipeline_message.rb +++ b/app/models/project_services/chat_message/pipeline_message.rb @@ -75,11 +75,11 @@ module ChatMessage def activity { - title: s_("ChatMessage|Pipeline %{pipeline_link} of %{ref_type} %{branch_link} by %{user_combined_name} %{humanized_status}") % + title: s_("ChatMessage|Pipeline %{pipeline_link} of %{ref_type} %{ref_link} by %{user_combined_name} %{humanized_status}") % { pipeline_link: pipeline_link, ref_type: ref_type, - branch_link: branch_link, + ref_link: ref_link, user_combined_name: user_combined_name, humanized_status: humanized_status }, @@ -123,7 +123,7 @@ module ChatMessage fields = [ { title: ref_type == "tag" ? s_("ChatMessage|Tag") : s_("ChatMessage|Branch"), - value: Slack::Notifier::LinkFormatter.format(ref_name_link), + value: Slack::Notifier::LinkFormatter.format(ref_link), short: true }, { @@ -141,12 +141,12 @@ module ChatMessage end def message - s_("ChatMessage|%{project_link}: Pipeline %{pipeline_link} of %{ref_type} %{branch_link} by %{user_combined_name} %{humanized_status} in %{duration}") % + s_("ChatMessage|%{project_link}: Pipeline %{pipeline_link} of %{ref_type} %{ref_link} by %{user_combined_name} %{humanized_status} in %{duration}") % { project_link: project_link, pipeline_link: pipeline_link, ref_type: ref_type, - branch_link: branch_link, + ref_link: ref_link, user_combined_name: user_combined_name, humanized_status: humanized_status, duration: pretty_duration(duration) @@ -193,12 +193,16 @@ module ChatMessage end end - def branch_url - "#{project_url}/commits/#{ref}" + def ref_url + if ref_type == 'tag' + "#{project_url}/-/tags/#{ref}" + else + "#{project_url}/commits/#{ref}" + end end - def branch_link - "[#{ref}](#{branch_url})" + def ref_link + "[#{ref}](#{ref_url})" end def project_url @@ -266,14 +270,6 @@ module ChatMessage "[#{commit.title}](#{commit_url})" end - def commits_page_url - "#{project_url}/commits/#{ref}" - end - - def ref_name_link - "[#{ref}](#{commits_page_url})" - end - def author_url return unless user && committer diff --git a/app/models/project_services/chat_message/push_message.rb b/app/models/project_services/chat_message/push_message.rb index 8163fca33a27ba8fa58f532d75c4c26264ae52b2..07622f570c22a74912c6d25267906793a48adfbf 100644 --- a/app/models/project_services/chat_message/push_message.rb +++ b/app/models/project_services/chat_message/push_message.rb @@ -82,16 +82,20 @@ module ChatMessage Gitlab::Git.blank_ref?(after) end - def branch_url - "#{project_url}/commits/#{ref}" + def ref_url + if ref_type == 'tag' + "#{project_url}/-/tags/#{ref}" + else + "#{project_url}/commits/#{ref}" + end end def compare_url "#{project_url}/compare/#{before}...#{after}" end - def branch_link - "[#{ref}](#{branch_url})" + def ref_link + "[#{ref}](#{ref_url})" end def project_link @@ -104,11 +108,11 @@ module ChatMessage def compose_action_details if new_branch? - ['pushed new', branch_link, "to #{project_link}"] + ['pushed new', ref_link, "to #{project_link}"] elsif removed_branch? ['removed', ref, "from #{project_link}"] else - ['pushed to', branch_link, "of #{project_link} (#{compare_link})"] + ['pushed to', ref_link, "of #{project_link} (#{compare_link})"] end end diff --git a/app/models/project_services/data_fields.rb b/app/models/project_services/data_fields.rb index cffb493d569e1b357daf210c51df5146528abe15..cf406a784ce6d9e7342839cfe9d491cd959f07f1 100644 --- a/app/models/project_services/data_fields.rb +++ b/app/models/project_services/data_fields.rb @@ -50,7 +50,7 @@ module DataFields end def data_fields_present? - data_fields.persisted? + data_fields.present? rescue NotImplementedError false end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 6eff2ea2e3a04d5bfecd661f3389a94c7a5b6be4..a0273fe0e5a685d822b321a8d60aa084f8e770c1 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -7,8 +7,15 @@ class PrometheusService < MonitoringService prop_accessor :api_url boolean_accessor :manual_configuration + # We need to allow the self-monitoring project to connect to the internal + # Prometheus instance. + # Since the internal Prometheus instance is usually a localhost URL, we need + # to allow localhost URLs when the following conditions are true: + # 1. project is the self-monitoring project. + # 2. api_url is the internal Prometheus URL. with_options presence: true, if: :manual_configuration? do - validates :api_url, public_url: true + validates :api_url, public_url: true, unless: proc { |object| object.allow_local_api_url? } + validates :api_url, url: true, if: proc { |object| object.allow_local_api_url? } end before_save :synchronize_service_state @@ -82,12 +89,28 @@ class PrometheusService < MonitoringService project.clusters.enabled.any? { |cluster| cluster.application_prometheus_available? } end + def allow_local_api_url? + self_monitoring_project? && internal_prometheus_url? + end + private + def self_monitoring_project? + project && project.id == current_settings.instance_administration_project_id + end + + def internal_prometheus_url? + api_url.present? && api_url == ::Gitlab::Prometheus::Internal.uri + end + def should_return_client? api_url.present? && manual_configuration? && active? && valid? end + def current_settings + Gitlab::CurrentSettings.current_application_settings + end + def synchronize_service_state self.active = prometheus_available? || manual_configuration? diff --git a/app/models/project_snippet.rb b/app/models/project_snippet.rb index b3585c4cf4c582cc3366fec930f9a58ffe66cb5b..e732c1bd86fe62b54450191a5da13b012f26b16e 100644 --- a/app/models/project_snippet.rb +++ b/app/models/project_snippet.rb @@ -2,13 +2,6 @@ class ProjectSnippet < Snippet belongs_to :project - belongs_to :author, class_name: "User" validates :project, presence: true - - # Scopes - scope :fresh, -> { order("created_at DESC") } - - participant :author - participant :notes_with_associations end diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index bb222ac7629776496b7dc558f83d06b8854fa567..f02ccd9e55e8ccacced293df6876d3903d3510f5 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -160,12 +160,6 @@ class ProjectWiki update_project_activity end - def page_formatted_data(page) - page_title, page_dir = page_title_and_dir(page.title) - - wiki.page_formatted_data(title: page_title, dir: page_dir, version: page.version) - end - def page_title_and_dir(title) return unless title diff --git a/app/models/prometheus_metric.rb b/app/models/prometheus_metric.rb index 08f4df7ea0165cad6041b4d9ffc6e3fc3336fe9f..d0dc31476fffabe2971e507feca4a76a2c812fb1 100644 --- a/app/models/prometheus_metric.rb +++ b/app/models/prometheus_metric.rb @@ -14,7 +14,13 @@ class PrometheusMetric < ApplicationRecord validates :project, presence: true, unless: :common? validates :project, absence: true, if: :common? + scope :for_project, -> (project) { where(project: project) } + scope :for_group, -> (group) { where(group: group) } + scope :for_title, -> (title) { where(title: title) } + scope :for_y_label, -> (y_label) { where(y_label: y_label) } + scope :for_identifier, -> (identifier) { where(identifier: identifier) } scope :common, -> { where(common: true) } + scope :ordered, -> { reorder(created_at: :asc) } def priority group_details(group).fetch(:priority) diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 1857a59e01c86d8d0190fc666f0ec0afc0e82542..735e2bdea81bcef671a8c4018bbaeea1b04e55ea 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -38,7 +38,7 @@ class ProtectedBranch < ApplicationRecord end def self.protected_refs(project) - project.protected_branches.select(:name) + project.protected_branches end def self.branch_requires_code_owner_approval?(project, branch_name) diff --git a/app/models/release.rb b/app/models/release.rb index 5a7bfe2d49576e7967077267fa627625e1092863..401e8359f47c1e94b7fb4d43726d4887cef30084 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Release < ApplicationRecord + include Presentable include CacheMarkdownField include Gitlab::Utils::StrongMemoize @@ -26,13 +27,21 @@ class Release < ApplicationRecord validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") } scope :sorted, -> { order(released_at: :desc) } + scope :preloaded, -> { includes(project: :namespace) } scope :with_project_and_namespace, -> { includes(project: :namespace) } + scope :recent, -> { sorted.limit(MAX_NUMBER_TO_DISPLAY) } delegate :repository, to: :project after_commit :create_evidence!, on: :create after_commit :notify_new_release, on: :create + MAX_NUMBER_TO_DISPLAY = 3 + + def to_param + CGI.escape(tag) + end + def commit strong_memoize(:commit) do repository.commit(actual_sha) @@ -60,6 +69,10 @@ class Release < ApplicationRecord released_at.present? && released_at > Time.zone.now end + def name + self.read_attribute(:name) || tag + end + private def actual_sha diff --git a/app/models/releases/source.rb b/app/models/releases/source.rb index 4d3d54457afa8ec3e7c433c3a87280a13b8dde2e..2f00d25d768a7a9b2857e7195359330b6bc9b151 100644 --- a/app/models/releases/source.rb +++ b/app/models/releases/source.rb @@ -6,11 +6,9 @@ module Releases attr_accessor :project, :tag_name, :format - FORMATS = %w(zip tar.gz tar.bz2 tar).freeze - class << self def all(project, tag_name) - Releases::Source::FORMATS.map do |format| + Gitlab::Workhorse::ARCHIVE_FORMATS.map do |format| Releases::Source.new(project: project, tag_name: tag_name, format: format) diff --git a/app/models/service.rb b/app/models/service.rb index 305cf7b78a201973fcfdb2c313781d4635b1dbf3..6d5b974dd31cadd9ea952c5b0d961559dc1a08ed 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -40,6 +40,7 @@ class Service < ApplicationRecord scope :external_wikis, -> { where(type: 'ExternalWikiService').active } scope :active, -> { where(active: true) } scope :without_defaults, -> { where(default: false) } + scope :by_type, -> (type) { where(type: type) } scope :push_hooks, -> { where(push_events: true, active: true) } scope :tag_push_hooks, -> { where(tag_push_events: true, active: true) } diff --git a/app/models/todo.rb b/app/models/todo.rb index 1927b54510ecbde729f3e2709f976c26bf005e27..f217c942e8e79e901bb37faad2fd2cc0b58394e5 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -55,7 +55,8 @@ class Todo < ApplicationRecord scope :done, -> { with_state(:done) } scope :for_action, -> (action) { where(action: action) } scope :for_author, -> (author) { where(author: author) } - scope :for_project, -> (project) { where(project: project) } + scope :for_project, -> (projects) { where(project: projects) } + scope :for_undeleted_projects, -> { joins(:project).merge(Project.without_deleted) } scope :for_group, -> (group) { where(group: group) } scope :for_type, -> (type) { where(target_type: type) } scope :for_target, -> (id) { where(target_id: id) } @@ -160,6 +161,10 @@ class Todo < ApplicationRecord action == ASSIGNED end + def done? + state == 'done' + end + def action_name ACTION_NAMES[action] end diff --git a/app/models/user.rb b/app/models/user.rb index eec8ad6edbb5db29d7c5d95a169b8b3c0c6b56ff..d0e758b0055fff17675d67c5d4bc8745f088997e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -56,9 +56,6 @@ class User < ApplicationRecord BLOCKED_MESSAGE = "Your account has been blocked. Please contact your GitLab " \ "administrator if you think this is an error." - # Removed in GitLab 12.3. Keep until after 2019-09-22. - self.ignored_columns += %i[support_bot] - MINIMUM_INACTIVE_DAYS = 180 # Override Devise::Models::Trackable#update_tracked_fields! @@ -243,6 +240,8 @@ class User < ApplicationRecord delegate :time_display_relative, :time_display_relative=, to: :user_preference delegate :time_format_in_24h, :time_format_in_24h=, to: :user_preference 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 accepts_nested_attributes_for :user_preference, update_only: true @@ -1423,14 +1422,13 @@ class User < ApplicationRecord # flow means we don't call that automatically (and can't conveniently do so). # # See: - # <https://github.com/plataformatec/devise/blob/v4.0.0/lib/devise/models/lockable.rb#L92> + # <https://github.com/plataformatec/devise/blob/v4.7.1/lib/devise/models/lockable.rb#L104> # # rubocop: disable CodeReuse/ServiceClass def increment_failed_attempts! return if ::Gitlab::Database.read_only? - self.failed_attempts ||= 0 - self.failed_attempts += 1 + increment_failed_attempts if attempts_exceeded? lock_access! unless access_locked? @@ -1458,7 +1456,7 @@ class User < ApplicationRecord # Does the user have access to all private groups & projects? # Overridden in EE to also check auditor? def full_private_access? - admin? + can?(:read_all_resources) end def update_two_factor_requirement diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 68241d2bd95f384d57574efcf09cff0396c5f6d3..f9c562364cbe420f0be8f80f16cecaaafae5d7d5 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -112,11 +112,6 @@ class WikiPage wiki.page_title_and_dir(slug)&.last.to_s end - # The processed/formatted content of this page. - def formatted_content - @attributes[:formatted_content] ||= @wiki.page_formatted_data(@page) - end - # The markup format for the page. def format @attributes[:format] || :markdown diff --git a/app/models/zoom_meeting.rb b/app/models/zoom_meeting.rb new file mode 100644 index 0000000000000000000000000000000000000000..a7ecd1e6a2ccaaada25a7a5859488ed49882b19e --- /dev/null +++ b/app/models/zoom_meeting.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class ZoomMeeting < ApplicationRecord + belongs_to :project, optional: false + belongs_to :issue, optional: false + + validates :url, presence: true, length: { maximum: 255 }, zoom_url: true + validates :issue, same_project_association: true + + enum issue_status: { + added: 1, + removed: 2 + } + + scope :added_to_issue, -> { where(issue_status: :added) } + scope :removed_from_issue, -> { where(issue_status: :removed) } + scope :canonical, -> (issue) { where(issue: issue).added_to_issue } + + def self.canonical_meeting(issue) + canonical(issue)&.take + end + + def self.canonical_meeting_url(issue) + canonical_meeting(issue)&.url + end +end diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index 18c23cbd13a0b313cd8fd859978d57ce12bdf44f..8f5c6957a204972b5008ccb1b5eed8f3a0a58860 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -21,10 +21,6 @@ class BasePolicy < DeclarativePolicy::Base with_options scope: :user, score: 0 condition(:deactivated) { @user&.deactivated? } - desc "User has access to all private groups & projects" - with_options scope: :user, score: 0 - condition(:full_private_access) { @user&.full_private_access? } - with_options scope: :user, score: 0 condition(:external_user) { @user.nil? || @user.external? } @@ -40,10 +36,12 @@ class BasePolicy < DeclarativePolicy::Base ::Gitlab::ExternalAuthorization.perform_check? end - rule { external_authorization_enabled & ~full_private_access }.policy do + rule { external_authorization_enabled & ~can?(:read_all_resources) }.policy do prevent :read_cross_project end + rule { admin }.enable :read_all_resources + rule { default }.enable :read_cross_project end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 13e5b4ae41a8ccd8ac8301dc040edc083ddf1ff5..1cd400e4dfa45f54aa0cc63d5aa12bc30ba7f551 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -44,6 +44,7 @@ class GroupPolicy < BasePolicy rule { public_group }.policy do enable :read_group + enable :read_package end rule { logged_in_viewable }.enable :read_group @@ -70,7 +71,10 @@ class GroupPolicy < BasePolicy rule { has_access }.enable :read_namespace - rule { developer }.enable :admin_milestone + rule { developer }.policy do + enable :admin_milestone + enable :read_package + end rule { reporter }.policy do enable :read_container_image diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb index 40dd49b4afd829f28ef9280f0aad904d945dd295..91a8f3a713387c2e3726cb7536c0335e866678a1 100644 --- a/app/policies/personal_snippet_policy.rb +++ b/app/policies/personal_snippet_policy.rb @@ -10,7 +10,7 @@ class PersonalSnippetPolicy < BasePolicy enable :create_note end - rule { is_author }.policy do + rule { is_author | admin }.policy do enable :read_personal_snippet enable :update_personal_snippet enable :destroy_personal_snippet @@ -30,5 +30,5 @@ class PersonalSnippetPolicy < BasePolicy rule { can?(:create_note) }.enable :award_emoji - rule { full_private_access }.enable :read_personal_snippet + rule { can?(:read_all_resources) }.enable :read_personal_snippet end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index ea2be37d7e644f12c5ce336ad5e1263c760e6ae2..ff70c6e6aeb74c862fbb0fcb0846f7a0fb896a7a 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -117,6 +117,10 @@ class ProjectPolicy < BasePolicy !@subject.builds_enabled? end + condition(:user_confirmed?) do + @user && @user.confirmed? + end + features = %w[ merge_requests issues @@ -249,10 +253,7 @@ class ProjectPolicy < BasePolicy enable :update_commit_status enable :create_build enable :update_build - enable :create_pipeline - enable :update_pipeline enable :read_pipeline_schedule - enable :create_pipeline_schedule enable :create_merge_request_from enable :create_wiki enable :push_code @@ -267,6 +268,12 @@ class ProjectPolicy < BasePolicy enable :update_release end + rule { can?(:developer_access) & user_confirmed? }.policy do + enable :create_pipeline + enable :update_pipeline + enable :create_pipeline_schedule + end + rule { can?(:maintainer_access) }.policy do enable :admin_board enable :push_to_delete_protected_branch @@ -418,7 +425,7 @@ class ProjectPolicy < BasePolicy # These rules are included to allow maintainers of projects to push to certain # to run pipelines for the branches they have access to. - rule { can?(:public_access) & has_merge_requests_allowing_pushes }.policy do + rule { can?(:public_access) & has_merge_requests_allowing_pushes & user_confirmed? }.policy do enable :create_build enable :create_pipeline end diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb index 2a3e4ca174bbf3c9a6224e6686cac8ebe1de1fb3..d9d09eb04cdeecf5ab5d6f66851aa86499bccb7d 100644 --- a/app/policies/project_snippet_policy.rb +++ b/app/policies/project_snippet_policy.rb @@ -28,7 +28,7 @@ class ProjectSnippetPolicy < BasePolicy all?(private_snippet | (internal_snippet & external_user), ~project.guest, ~is_author, - ~full_private_access) + ~can?(:read_all_resources)) end.prevent :read_project_snippet rule { internal_snippet & ~is_author & ~admin }.policy do diff --git a/app/policies/todo_policy.rb b/app/policies/todo_policy.rb index f8644217f043445f74c24c9e9793c713d0b1889b..d01a046c34398b11fbdb8b4ef5b9b985806551e4 100644 --- a/app/policies/todo_policy.rb +++ b/app/policies/todo_policy.rb @@ -7,4 +7,5 @@ class TodoPolicy < BasePolicy end rule { own_todo }.enable :read_todo + rule { own_todo }.enable :update_todo end diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb index 34dffbf40fdd8d102e2f288b62ce398e81c428ab..2306f55f1f458beb8b7e506c22bab7d5bbd32f44 100644 --- a/app/presenters/clusterable_presenter.rb +++ b/app/presenters/clusterable_presenter.rb @@ -29,6 +29,18 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated new_polymorphic_path([clusterable, :cluster], options) end + def aws_api_proxy_path(resource) + polymorphic_path([clusterable, :clusters], action: :aws_proxy, resource: resource) + end + + def authorize_aws_role_path + polymorphic_path([clusterable, :clusters], action: :authorize_aws_role) + end + + def revoke_aws_role_path + polymorphic_path([clusterable, :clusters], action: :revoke_aws_role) + end + def create_user_clusters_path polymorphic_path([clusterable, :clusters], action: :create_user) end @@ -37,6 +49,10 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated polymorphic_path([clusterable, :clusters], action: :create_gcp) end + def create_aws_clusters_path + polymorphic_path([clusterable, :clusters], action: :create_aws) + end + def cluster_status_cluster_path(cluster, params = {}) raise NotImplementedError end diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb index f1182ec26f41f5e6b6656a6287b28f1962dfa105..66ae840a619ff9e1b78263984ce8f2d53a610fda 100644 --- a/app/presenters/commit_status_presenter.rb +++ b/app/presenters/commit_status_presenter.rb @@ -11,7 +11,9 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated stale_schedule: 'Delayed job could not be executed by some reason, please try again', job_execution_timeout: 'The script exceeded the maximum execution time set for the job', archived_failure: 'The job is archived and cannot be run', - unmet_prerequisites: 'The job failed to complete prerequisite tasks' + unmet_prerequisites: 'The job failed to complete prerequisite tasks', + scheduler_failure: 'The scheduler failed to assign job to the runner, please try again or contact system administrator', + data_integrity_failure: 'There has been a structural integrity problem detected, please contact system administrator' }.freeze private_constant :CALLOUT_FAILURE_MESSAGES @@ -33,6 +35,6 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated end def unrecoverable? - script_failure? || missing_dependency_failure? || archived_failure? + script_failure? || missing_dependency_failure? || archived_failure? || scheduler_failure? || data_integrity_failure? end end diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb index 908cd17678dfedf074d9740bed97303ca16af6a4..c6572e8ce71e3bdc9cf838a092fba7b6bb9f6ee0 100644 --- a/app/presenters/instance_clusterable_presenter.rb +++ b/app/presenters/instance_clusterable_presenter.rb @@ -52,6 +52,26 @@ class InstanceClusterablePresenter < ClusterablePresenter create_gcp_admin_clusters_path end + override :create_aws_clusters_path + def create_aws_clusters_path + create_aws_admin_clusters_path + end + + override :authorize_aws_role_path + def authorize_aws_role_path + authorize_aws_role_admin_clusters_path + end + + override :revoke_aws_role_path + def revoke_aws_role_path + revoke_aws_role_admin_clusters_path + end + + override :aws_api_proxy_path + def aws_api_proxy_path(resource) + aws_proxy_admin_clusters_path(resource: resource) + end + override :empty_state_help_text def empty_state_help_text s_('ClusterIntegration|Adding an integration will share the cluster across all projects.') diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 6d370f6241cacc70e6c8f770e52475228003a755..81018398d5d361a42747d738c7ceffccfcca811f 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -21,7 +21,6 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def statistics_anchors(show_auto_devops_callout:) [ - license_anchor_data, commits_anchor_data, branches_anchor_data, tags_anchor_data, @@ -32,6 +31,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def statistics_buttons(show_auto_devops_callout:) [ readme_anchor_data, + license_anchor_data, changelog_anchor_data, contribution_guide_anchor_data, autodevops_anchor_data(show_auto_devops_callout: show_auto_devops_callout), @@ -41,15 +41,14 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end def empty_repo_statistics_anchors - [ - license_anchor_data - ].compact.select { |item| item.is_link } + [] end def empty_repo_statistics_buttons [ new_file_anchor_data, readme_anchor_data, + license_anchor_data, changelog_anchor_data, contribution_guide_anchor_data, gitlab_ci_anchor_data @@ -227,17 +226,18 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated icon = statistic_icon('scale') if repository.license_blob.present? - AnchorData.new(true, - icon + content_tag(:strong, license_short_name, class: 'project-stat-value'), - license_path) + AnchorData.new(false, + icon + content_tag(:span, license_short_name, class: 'project-stat-value'), + license_path, + 'default') else if current_user && can_current_user_push_to_default_branch? - AnchorData.new(true, - content_tag(:span, icon + _('Add license'), class: 'add-license-link d-flex'), + AnchorData.new(false, + content_tag(:span, statistic_icon + _('Add LICENSE'), class: 'add-license-link d-flex'), add_license_path) else - AnchorData.new(true, - icon + content_tag(:strong, _('No license. All rights reserved'), class: 'project-stat-value'), + AnchorData.new(false, + icon + content_tag(:span, _('No license. All rights reserved'), class: 'project-stat-value'), nil) end end diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb new file mode 100644 index 0000000000000000000000000000000000000000..42463d6dbdabe48fcdbfa2df1d34ccfbf9fcd419 --- /dev/null +++ b/app/presenters/release_presenter.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class ReleasePresenter < Gitlab::View::Presenter::Delegated + include ActionView::Helpers::UrlHelper + + presents :release + + delegate :project, :tag, to: :release + + def commit_path + return unless release.commit && can_download_code? + + project_commit_path(project, release.commit.id) + end + + def tag_path + return unless can_download_code? + + project_tag_path(project, release.tag) + end + + def merge_requests_url + return unless release_mr_issue_urls_available? + + project_merge_requests_url(project, params_for_issues_and_mrs) + end + + def issues_url + return unless release_mr_issue_urls_available? + + project_issues_url(project, params_for_issues_and_mrs) + end + + def edit_url + return unless release_edit_page_available? + + edit_project_release_url(project, release) + end + + private + + def can_download_code? + can?(current_user, :download_code, project) + end + + def params_for_issues_and_mrs + { scope: 'all', state: 'opened', release_tag: release.tag } + end + + def release_mr_issue_urls_available? + ::Feature.enabled?(:release_mr_issue_urls, project) + end + + def release_edit_page_available? + ::Feature.enabled?(:release_edit_page, project, default_enabled: true) + end +end diff --git a/app/presenters/todo_presenter.rb b/app/presenters/todo_presenter.rb index b57fc712c5a12832854872deb8e8d5db38595c7f..291be7848e25ac02e1f10c89fe3af761d92b4c91 100644 --- a/app/presenters/todo_presenter.rb +++ b/app/presenters/todo_presenter.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true class TodoPresenter < Gitlab::View::Presenter::Delegated - include GlobalID::Identification - presents :todo end diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb index 2a916b13f521dd886a536f3dd29f4f4706887fbd..218bdd21e37720605e75e0c077b85c09f5bbed4c 100644 --- a/app/serializers/cluster_application_entity.rb +++ b/app/serializers/cluster_application_entity.rb @@ -8,7 +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 :update_available?, as: :update_available, if: -> (e, _) { e.respond_to?(:update_available?) } expose :can_uninstall?, as: :can_uninstall end diff --git a/app/serializers/container_repositories_serializer.rb b/app/serializers/container_repositories_serializer.rb index e1ce3c7b3ae0dea76ccee4b925c7a4e20dc4e836..bc35a67ff24997261b21d32307f44649dc6d202c 100644 --- a/app/serializers/container_repositories_serializer.rb +++ b/app/serializers/container_repositories_serializer.rb @@ -2,4 +2,8 @@ class ContainerRepositoriesSerializer < BaseSerializer entity ContainerRepositoryEntity + + def represent_read_only(resource) + represent(resource, except: [:destroy_path]) + end end diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb index ee68b4b98e0e6462eaa7bd8e51d69331c07268b9..302fe3d7c67a96fc73f96044e404b89178d32f05 100644 --- a/app/serializers/diff_file_base_entity.rb +++ b/app/serializers/diff_file_base_entity.rb @@ -89,6 +89,14 @@ class DiffFileBaseEntity < Grape::Entity expose :viewer, using: DiffViewerEntity + expose :old_size do |diff_file| + diff_file.old_blob&.raw_size + end + + expose :new_size do |diff_file| + diff_file.new_blob&.raw_size + end + private def memoized_submodule_links(diff_file, options) diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb index 2a5121a22662043340b79e10d0545b1cc450e8ce..af7d1172f1761512b8596aec1cd7bd2fddc2e4cd 100644 --- a/app/serializers/diff_file_entity.rb +++ b/app/serializers/diff_file_entity.rb @@ -53,7 +53,7 @@ class DiffFileEntity < DiffFileBaseEntity end # Used for inline diffs - expose :highlighted_diff_lines, using: DiffLineEntity, if: -> (diff_file, _) { diff_file.text? } do |diff_file| + expose :highlighted_diff_lines, using: DiffLineEntity, if: -> (diff_file, options) { inline_diff_view?(options) && diff_file.text? } do |diff_file| diff_file.diff_lines_for_serializer end @@ -62,5 +62,21 @@ class DiffFileEntity < DiffFileBaseEntity end # Used for parallel diffs - expose :parallel_diff_lines, using: DiffLineParallelEntity, if: -> (diff_file, _) { diff_file.text? } + expose :parallel_diff_lines, using: DiffLineParallelEntity, if: -> (diff_file, options) { parallel_diff_view?(options) && diff_file.text? } + + private + + def parallel_diff_view?(options) + return true unless Feature.enabled?(:single_mr_diff_view) + + # If we're not rendering inline, we must be rendering parallel + !inline_diff_view?(options) + end + + def inline_diff_view?(options) + return true unless Feature.enabled?(:single_mr_diff_view) + + # If nothing is present, inline will be the default. + options.fetch(:diff_view, :inline).to_sym == :inline + end end diff --git a/app/serializers/error_tracking/detailed_error_entity.rb b/app/serializers/error_tracking/detailed_error_entity.rb new file mode 100644 index 0000000000000000000000000000000000000000..8f08f84aa4115e7e29fdf78ec1818d05b5d31e34 --- /dev/null +++ b/app/serializers/error_tracking/detailed_error_entity.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ErrorTracking + class DetailedErrorEntity < Grape::Entity + expose :count, + :culprit, + :external_base_url, + :external_url, + :first_release_last_commit, + :first_release_short_version, + :first_seen, + :frequency, + :id, + :last_release_last_commit, + :last_release_short_version, + :last_seen, + :message, + :project_id, + :project_name, + :project_slug, + :short_id, + :status, + :title, + :type, + :user_count + end +end diff --git a/app/serializers/error_tracking/detailed_error_serializer.rb b/app/serializers/error_tracking/detailed_error_serializer.rb new file mode 100644 index 0000000000000000000000000000000000000000..201da16a1ae2cba81aba004044607916572eec70 --- /dev/null +++ b/app/serializers/error_tracking/detailed_error_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ErrorTracking + class DetailedErrorSerializer < BaseSerializer + entity DetailedErrorEntity + end +end diff --git a/app/serializers/error_tracking/error_event_entity.rb b/app/serializers/error_tracking/error_event_entity.rb new file mode 100644 index 0000000000000000000000000000000000000000..6cf0e6e3ae25e845ff62fbee41fb6e5a394e13f2 --- /dev/null +++ b/app/serializers/error_tracking/error_event_entity.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ErrorTracking + class ErrorEventEntity < Grape::Entity + expose :issue_id, :date_received, :stack_trace_entries + end +end diff --git a/app/serializers/error_tracking/error_event_serializer.rb b/app/serializers/error_tracking/error_event_serializer.rb new file mode 100644 index 0000000000000000000000000000000000000000..bc4eae1636822adba358caaf1318709d2b1a24fd --- /dev/null +++ b/app/serializers/error_tracking/error_event_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ErrorTracking + class ErrorEventSerializer < BaseSerializer + entity ErrorEventEntity + end +end diff --git a/app/serializers/issuable_sidebar_extras_entity.rb b/app/serializers/issuable_sidebar_extras_entity.rb index fb35b7522c51a1b2d027b1d0554a03f27688b8b2..0e1fcc58d7ae5ab07e29b54473c03a36679e03b1 100644 --- a/app/serializers/issuable_sidebar_extras_entity.rb +++ b/app/serializers/issuable_sidebar_extras_entity.rb @@ -3,11 +3,20 @@ class IssuableSidebarExtrasEntity < Grape::Entity include RequestAwareEntity include TimeTrackableEntity + include NotificationsHelper expose :participants, using: ::API::Entities::UserBasic do |issuable| issuable.participants(request.current_user) end + expose :project_emails_disabled do |issuable| + issuable.project.emails_disabled? + end + + expose :subscribe_disabled_description do |issuable| + notification_description(:owner_disabled) + end + expose :subscribed do |issuable| issuable.subscribed?(request.current_user, issuable.project) end diff --git a/app/serializers/issue_board_entity.rb b/app/serializers/issue_board_entity.rb index b8f799a7456a84573f1f72b52d3f7f48498d3e9b..13897279815542dd45d6a9f49311a5646e6fde6a 100644 --- a/app/serializers/issue_board_entity.rb +++ b/app/serializers/issue_board_entity.rb @@ -2,7 +2,6 @@ class IssueBoardEntity < Grape::Entity include RequestAwareEntity - include TimeTrackableEntity expose :id expose :iid diff --git a/app/serializers/job_artifact_report_entity.rb b/app/serializers/job_artifact_report_entity.rb index 4280351a6b0f2cffecf6e400e44aac88a2c59bd3..bdab8f647852d0e1e260cd857db47606329a6e8d 100644 --- a/app/serializers/job_artifact_report_entity.rb +++ b/app/serializers/job_artifact_report_entity.rb @@ -8,6 +8,6 @@ class JobArtifactReportEntity < Grape::Entity expose :size expose :download_path do |artifact| - download_project_job_artifacts_path(artifact.job.project, artifact.job, file_type: artifact.file_format) + download_project_job_artifacts_path(artifact.job.project, artifact.job, file_type: artifact.file_type) end end diff --git a/app/serializers/merge_request_diff_entity.rb b/app/serializers/merge_request_diff_entity.rb index 7e3053e58810de60713d7f3b97486b1f45cb62c9..5c79b165ee94aed4a511bc74e4616ec4f019ecf1 100644 --- a/app/serializers/merge_request_diff_entity.rb +++ b/app/serializers/merge_request_diff_entity.rb @@ -21,6 +21,8 @@ class MergeRequestDiffEntity < Grape::Entity expose :latest?, as: :latest expose :short_commit_sha do |merge_request_diff| + next unless merge_request_diff.head_commit_sha + short_sha(merge_request_diff.head_commit_sha) end diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb index 854349e85075fcb7c620ed7e1a8d8d54ae4206f1..2a61187a85630473c5d410792a5a471033f733e0 100644 --- a/app/serializers/merge_request_poll_widget_entity.rb +++ b/app/serializers/merge_request_poll_widget_entity.rb @@ -65,6 +65,12 @@ class MergeRequestPollWidgetEntity < IssuableEntity end end + expose :exposed_artifacts_path do |merge_request| + if merge_request.has_exposed_artifacts? + exposed_artifacts_project_merge_request_path(merge_request.project, merge_request, format: :json) + end + end + expose :create_issue_to_resolve_discussions_path do |merge_request| presenter(merge_request).create_issue_to_resolve_discussions_path end diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index 1d3b59eb1b702839ecae590b95e9af8ecc0a101a..c49dec2a93c27af3ec716d279aee9dde607e1e47 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -79,3 +79,5 @@ class NoteEntity < API::Entities::Note request.current_user end end + +NoteEntity.prepend_if_ee('EE::NoteEntity') diff --git a/app/serializers/projects/serverless/service_entity.rb b/app/serializers/projects/serverless/service_entity.rb index a1e0bf02d118f9d9dff079882896b12e035ad984..10360e575bbc409039a2461584e773a9f9471b8c 100644 --- a/app/serializers/projects/serverless/service_entity.rb +++ b/app/serializers/projects/serverless/service_entity.rb @@ -44,28 +44,52 @@ module Projects end expose :url do |service| - service.dig('status', 'url') || "http://#{service.dig('status', 'domain')}" + knative_06_07_url(service) || knative_05_url(service) end expose :description do |service| + knative_07_description(service) || knative_05_06_description(service) + end + + expose :image do |service| service.dig( 'spec', 'runLatest', 'configuration', - 'revisionTemplate', + 'build', + 'template', + 'name') + end + + private + + def knative_07_description(service) + service.dig( + 'spec', + 'template', 'metadata', 'annotations', - 'Description') + 'Description' + ) end - expose :image do |service| + def knative_05_url(service) + "http://#{service.dig('status', 'domain')}" + end + + def knative_06_07_url(service) + service.dig('status', 'url') + end + + def knative_05_06_description(service) service.dig( 'spec', 'runLatest', 'configuration', - 'build', - 'template', - 'name') + 'revisionTemplate', + 'metadata', + 'annotations', + 'Description') end end end diff --git a/app/services/base_service.rb b/app/services/base_service.rb index c39edd5c11448325d66c743cc79b482a440ffa01..bc0b968f516a1507abafb554a787dd224b9e2410 100644 --- a/app/services/base_service.rb +++ b/app/services/base_service.rb @@ -50,16 +50,24 @@ class BaseService private - def error(message, http_status = nil) + # Return a Hash with an `error` status + # + # message - Error message to include in the Hash + # http_status - Optional HTTP status code override (default: nil) + # pass_back - Additional attributes to be included in the resulting Hash + def error(message, http_status = nil, pass_back: {}) result = { message: message, status: :error - } + }.reverse_merge(pass_back) result[:http_status] = http_status if http_status result end + # Return a Hash with a `success` status + # + # pass_back - Additional attributes to be included in the resulting Hash def success(pass_back = {}) pass_back[:status] = :success pass_back diff --git a/app/services/ci/compare_reports_base_service.rb b/app/services/ci/compare_reports_base_service.rb index 5b76e1824e4734a76e1f337ea833f072d5f52b4f..83ba70e84372c237c65f5a2d71c284a046377fbe 100644 --- a/app/services/ci/compare_reports_base_service.rb +++ b/app/services/ci/compare_reports_base_service.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true module Ci + # TODO: when using this class with exposed artifacts we see that there are + # 2 responsibilities: + # 1. reactive caching interface (same in all cases) + # 2. data generator (report comparison in most of the case but not always) + # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 class CompareReportsBaseService < ::BaseService def execute(base_pipeline, head_pipeline) comparer = comparer_class.new(get_report(base_pipeline), get_report(head_pipeline)) diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index eb4176035d338ddb126cacda03a8b6d42c58bc70..5778a48bce6c1ab87b2dfe5b49dd70d3515a1960 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -7,11 +7,14 @@ module Ci CreateError = Class.new(StandardError) SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build, - Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs, Gitlab::Ci::Pipeline::Chain::Validate::Abilities, Gitlab::Ci::Pipeline::Chain::Validate::Repository, - Gitlab::Ci::Pipeline::Chain::Validate::Config, + Gitlab::Ci::Pipeline::Chain::Config::Content, + Gitlab::Ci::Pipeline::Chain::Config::Process, + Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs, Gitlab::Ci::Pipeline::Chain::Skip, + Gitlab::Ci::Pipeline::Chain::EvaluateWorkflowRules, + Gitlab::Ci::Pipeline::Chain::Seed, Gitlab::Ci::Pipeline::Chain::Limit::Size, Gitlab::Ci::Pipeline::Chain::Populate, Gitlab::Ci::Pipeline::Chain::Create, diff --git a/app/services/ci/find_exposed_artifacts_service.rb b/app/services/ci/find_exposed_artifacts_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..5c75af294bf79ac6a10b082c6cc0842282c199e7 --- /dev/null +++ b/app/services/ci/find_exposed_artifacts_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Ci + # This class loops through all builds with exposed artifacts and returns + # basic information about exposed artifacts for given jobs for the frontend + # to display them as custom links in the merge request. + # + # This service must be used with care. + # Looking for exposed artifacts is very slow and should be done asynchronously. + class FindExposedArtifactsService < ::BaseService + include Gitlab::Routing + + MAX_EXPOSED_ARTIFACTS = 10 + + def for_pipeline(pipeline, limit: MAX_EXPOSED_ARTIFACTS) + results = [] + + pipeline.builds.latest.with_exposed_artifacts.find_each do |job| + if job_exposed_artifacts = for_job(job) + results << job_exposed_artifacts + end + + break if results.size >= limit + end + + results + end + + def for_job(job) + return unless job.has_exposed_artifacts? + + metadata_entries = first_2_metadata_entries_for_artifacts_paths(job) + return if metadata_entries.empty? + + { + text: job.artifacts_expose_as, + url: path_for_entries(metadata_entries, job), + job_path: project_job_path(project, job), + job_name: job.name + } + end + + private + + # we don't need to fetch all artifacts entries for a job because + # it could contain many. We only need to know whether it has 1 or more + # artifacts, so fetching the first 2 would be sufficient. + def first_2_metadata_entries_for_artifacts_paths(job) + job.artifacts_paths + .lazy + .map { |path| job.artifacts_metadata_entry(path, recursive: true) } + .select { |entry| entry.exists? } + .first(2) + end + + def path_for_entries(entries, job) + return if entries.empty? + + if single_artifact?(entries) + file_project_job_artifacts_path(project, job, entries.first.path) + else + browse_project_job_artifacts_path(project, job) + end + end + + def single_artifact?(entries) + entries.size == 1 && entries.first.file? + end + end +end diff --git a/app/services/ci/generate_exposed_artifacts_report_service.rb b/app/services/ci/generate_exposed_artifacts_report_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..b9bf580bcbc4e9a78d930466357c27964c05e670 --- /dev/null +++ b/app/services/ci/generate_exposed_artifacts_report_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Ci + # TODO: a couple of points with this approach: + # + reuses existing architecture and reactive caching + # - it's not a report comparison and some comparing features must be turned off. + # see CompareReportsBaseService for more notes. + # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 + class GenerateExposedArtifactsReportService < CompareReportsBaseService + def execute(base_pipeline, head_pipeline) + data = FindExposedArtifactsService.new(project, current_user).for_pipeline(head_pipeline) + { + status: :parsed, + key: key(base_pipeline, head_pipeline), + data: data + } + rescue => e + Gitlab::Sentry.track_acceptable_exception(e, extra: { project_id: project.id }) + { + status: :error, + key: key(base_pipeline, head_pipeline), + status_reason: _('An error occurred while fetching exposed artifacts.') + } + end + + def latest?(base_pipeline, head_pipeline, data) + data&.fetch(:key, nil) == key(base_pipeline, head_pipeline) + end + end +end diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index d8f32ff88cef0dffbcffb5e9ba3a21982d94a560..30e2a66e04a4d240a44ad56b6d1fd373c402fb69 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -42,26 +42,16 @@ module Ci end builds.each do |build| - next unless runner.can_pick?(build) - - begin - # In case when 2 runners try to assign the same build, second runner will be declined - # with StateMachines::InvalidTransition or StaleObjectError when doing run! or save method. - if assign_runner!(build, params) - register_success(build) - - return Result.new(build, true) - end - rescue StateMachines::InvalidTransition, ActiveRecord::StaleObjectError - # We are looping to find another build that is not conflicting - # It also indicates that this build can be picked and passed to runner. - # If we don't do it, basically a bunch of runners would be competing for a build - # and thus we will generate a lot of 409. This will increase - # the number of generated requests, also will reduce significantly - # how many builds can be picked by runner in a unit of time. - # In case we hit the concurrency-access lock, - # we still have to return 409 in the end, - # to make sure that this is properly handled by runner. + result = process_build(build, params) + next unless result + + if result.valid? + register_success(result.build) + + return result + else + # The usage of valid: is described in + # handling of ActiveRecord::StaleObjectError valid = false end end @@ -73,6 +63,35 @@ module Ci private + def process_build(build, params) + return unless runner.can_pick?(build) + + # In case when 2 runners try to assign the same build, second runner will be declined + # with StateMachines::InvalidTransition or StaleObjectError when doing run! or save method. + if assign_runner!(build, params) + Result.new(build, true) + end + rescue StateMachines::InvalidTransition, ActiveRecord::StaleObjectError + # We are looping to find another build that is not conflicting + # It also indicates that this build can be picked and passed to runner. + # If we don't do it, basically a bunch of runners would be competing for a build + # and thus we will generate a lot of 409. This will increase + # the number of generated requests, also will reduce significantly + # how many builds can be picked by runner in a unit of time. + # In case we hit the concurrency-access lock, + # we still have to return 409 in the end, + # to make sure that this is properly handled by runner. + Result.new(nil, false) + rescue => ex + raise ex unless Feature.enabled?(:ci_doom_build, default_enabled: true) + + scheduler_failure!(build) + track_exception_for_build(ex, build) + + # skip, and move to next one + nil + end + def assign_runner!(build, params) build.runner_id = runner.id build.runner_session_attributes = params[:session] if params[:session].present? @@ -96,6 +115,28 @@ module Ci true end + def scheduler_failure!(build) + Gitlab::OptimisticLocking.retry_lock(build, 3) do |subject| + subject.drop!(:scheduler_failure) + end + rescue => ex + build.doom! + + # This requires extra exception, otherwise we would loose information + # why we cannot perform `scheduler_failure` + track_exception_for_build(ex, build) + end + + def track_exception_for_build(ex, build) + Gitlab::Sentry.track_acceptable_exception(ex, extra: { + build_id: build.id, + build_name: build.name, + build_stage: build.stage, + pipeline_id: build.pipeline_id, + project_id: build.project_id + }) + end + # rubocop: disable CodeReuse/ActiveRecord def builds_for_shared_runner new_builds. @@ -108,7 +149,7 @@ module Ci # this returns builds that are ordered by number of running builds # we prefer projects that don't use shared runners at all joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.project_id=project_builds.project_id") - .order('COALESCE(project_builds.running_builds, 0) ASC', 'ci_builds.id ASC') + .order(Arel.sql('COALESCE(project_builds.running_builds, 0) ASC'), 'ci_builds.id ASC') end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/clusters/applications/base_service.rb b/app/services/clusters/applications/base_service.rb index 67fb3ac835569eb435658ff046d397843d82bc40..c9f7917938fda3bc29879750f9b93455e7d35b3f 100644 --- a/app/services/clusters/applications/base_service.rb +++ b/app/services/clusters/applications/base_service.rb @@ -19,10 +19,18 @@ 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 + if application.has_attribute?(:stack) + application.stack = params[:stack] + end + if application.respond_to?(:oauth_application) application.oauth_application = create_oauth_application(application, request) end @@ -60,19 +68,13 @@ module Clusters end def invalid_application? - unknown_application? || (!cluster.project_type? && project_only_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)) end def unknown_application? Clusters::Cluster::APPLICATIONS.keys.exclude?(application_name) end - # These applications will need extra configuration to enable them to work - # with groups of projects - def project_only_application? - Clusters::Cluster::PROJECT_ONLY_APPLICATIONS.include?(application_name) - end - def application_name params[:application] end diff --git a/app/services/clusters/aws/fetch_credentials_service.rb b/app/services/clusters/aws/fetch_credentials_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..2724d4b657ba585126bf05020028bcbcdee3187c --- /dev/null +++ b/app/services/clusters/aws/fetch_credentials_service.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Clusters + module Aws + class FetchCredentialsService + attr_reader :provision_role + + MissingRoleError = Class.new(StandardError) + + def initialize(provision_role, region:, provider: nil) + @provision_role = provision_role + @region = region + @provider = provider + end + + def execute + raise MissingRoleError.new('AWS provisioning role not configured') unless provision_role.present? + + ::Aws::AssumeRoleCredentials.new( + client: client, + role_arn: provision_role.role_arn, + role_session_name: session_name, + external_id: provision_role.role_external_id + ).credentials + end + + private + + attr_reader :provider, :region + + def client + ::Aws::STS::Client.new(credentials: gitlab_credentials, region: region) + end + + def gitlab_credentials + ::Aws::Credentials.new(access_key_id, secret_access_key) + end + + def access_key_id + Gitlab::CurrentSettings.eks_access_key_id + end + + def secret_access_key + Gitlab::CurrentSettings.eks_secret_access_key + end + + def session_name + if provider.present? + "gitlab-eks-cluster-#{provider.cluster_id}-user-#{provision_role.user_id}" + else + "gitlab-eks-autofill-user-#{provision_role.user_id}" + end + end + end + end +end diff --git a/app/services/clusters/aws/finalize_creation_service.rb b/app/services/clusters/aws/finalize_creation_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..54f07e1d44c52a4916b1899d8d6898b640549e47 --- /dev/null +++ b/app/services/clusters/aws/finalize_creation_service.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +module Clusters + module Aws + class FinalizeCreationService + include Gitlab::Utils::StrongMemoize + + attr_reader :provider + + delegate :cluster, to: :provider + + def execute(provider) + @provider = provider + + configure_provider + create_gitlab_service_account! + configure_platform_kubernetes + configure_node_authentication! + + cluster.save! + rescue ::Aws::CloudFormation::Errors::ServiceError => e + log_service_error(e.class.name, provider.id, e.message) + provider.make_errored!(s_('ClusterIntegration|Failed to fetch CloudFormation stack: %{message}') % { message: e.message }) + rescue Kubeclient::HttpError => e + log_service_error(e.class.name, provider.id, e.message) + provider.make_errored!(s_('ClusterIntegration|Failed to run Kubeclient: %{message}') % { message: e.message }) + rescue ActiveRecord::RecordInvalid => e + log_service_error(e.class.name, provider.id, e.message) + provider.make_errored!(s_('ClusterIntegration|Failed to configure EKS provider: %{message}') % { message: e.message }) + end + + private + + def create_gitlab_service_account! + Clusters::Kubernetes::CreateOrUpdateServiceAccountService.gitlab_creator( + kube_client, + rbac: true + ).execute + end + + def configure_provider + provider.status_event = :make_created + end + + def configure_platform_kubernetes + cluster.build_platform_kubernetes( + api_url: cluster_endpoint, + ca_cert: cluster_certificate, + token: request_kubernetes_token) + end + + def request_kubernetes_token + Clusters::Kubernetes::FetchKubernetesTokenService.new( + kube_client, + Clusters::Kubernetes::GITLAB_ADMIN_TOKEN_NAME, + Clusters::Kubernetes::GITLAB_SERVICE_ACCOUNT_NAMESPACE + ).execute + end + + def kube_client + @kube_client ||= build_kube_client!( + cluster_endpoint, + cluster_certificate + ) + end + + def build_kube_client!(api_url, ca_pem) + raise "Incomplete settings" unless api_url + + Gitlab::Kubernetes::KubeClient.new( + api_url, + auth_options: kubeclient_auth_options, + ssl_options: kubeclient_ssl_options(ca_pem), + http_proxy_uri: ENV['http_proxy'] + ) + end + + def kubeclient_auth_options + { bearer_token: Kubeclient::AmazonEksCredentials.token(provider.credentials, cluster.name) } + end + + def kubeclient_ssl_options(ca_pem) + opts = { verify_ssl: OpenSSL::SSL::VERIFY_PEER } + + if ca_pem.present? + opts[:cert_store] = OpenSSL::X509::Store.new + opts[:cert_store].add_cert(OpenSSL::X509::Certificate.new(ca_pem)) + end + + opts + end + + def cluster_stack + @cluster_stack ||= provider.api_client.describe_stacks(stack_name: provider.cluster.name).stacks.first + end + + def stack_output_value(key) + cluster_stack.outputs.detect { |output| output.output_key == key }.output_value + end + + def node_instance_role_arn + stack_output_value('NodeInstanceRole') + end + + def cluster_endpoint + strong_memoize(:cluster_endpoint) do + stack_output_value('ClusterEndpoint') + end + end + + def cluster_certificate + strong_memoize(:cluster_certificate) do + Base64.decode64(stack_output_value('ClusterCertificate')) + end + end + + def configure_node_authentication! + kube_client.create_config_map(node_authentication_config) + end + + def node_authentication_config + Gitlab::Kubernetes::ConfigMaps::AwsNodeAuth.new(node_instance_role_arn).generate + end + + def logger + @logger ||= Gitlab::Kubernetes::Logger.build + end + + def log_service_error(exception, provider_id, message) + logger.error( + exception: exception.class.name, + service: self.class.name, + provider_id: provider_id, + message: message + ) + end + end + end +end diff --git a/app/services/clusters/aws/provision_service.rb b/app/services/clusters/aws/provision_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..35fe8433b4d77e3d5785a0ff9eaefb341817b75d --- /dev/null +++ b/app/services/clusters/aws/provision_service.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Clusters + module Aws + class ProvisionService + attr_reader :provider + + def execute(provider) + @provider = provider + + configure_provider_credentials + provision_cluster + + if provider.make_creating + WaitForClusterCreationWorker.perform_in( + Clusters::Aws::VerifyProvisionStatusService::INITIAL_INTERVAL, + provider.cluster_id + ) + else + provider.make_errored!("Failed to update provider record; #{provider.errors.full_messages}") + end + rescue Clusters::Aws::FetchCredentialsService::MissingRoleError + provider.make_errored!('Amazon role is not configured') + rescue ::Aws::Errors::MissingCredentialsError + provider.make_errored!('Amazon credentials are not configured') + rescue ::Aws::STS::Errors::ServiceError => e + provider.make_errored!("Amazon authentication failed; #{e.message}") + rescue ::Aws::CloudFormation::Errors::ServiceError => e + provider.make_errored!("Amazon CloudFormation request failed; #{e.message}") + end + + private + + def provision_role + provider.created_by_user&.aws_role + end + + def credentials + @credentials ||= Clusters::Aws::FetchCredentialsService.new( + provision_role, + provider: provider, + region: provider.region + ).execute + end + + def configure_provider_credentials + provider.update!( + access_key_id: credentials.access_key_id, + secret_access_key: credentials.secret_access_key, + session_token: credentials.session_token + ) + end + + def provision_cluster + provider.api_client.create_stack( + stack_name: provider.cluster.name, + template_body: stack_template, + parameters: parameters, + capabilities: ["CAPABILITY_IAM"] + ) + end + + def parameters + [ + parameter('ClusterName', provider.cluster.name), + parameter('ClusterRole', provider.role_arn), + parameter('ClusterControlPlaneSecurityGroup', provider.security_group_id), + parameter('VpcId', provider.vpc_id), + parameter('Subnets', provider.subnet_ids.join(',')), + parameter('NodeAutoScalingGroupDesiredCapacity', provider.num_nodes.to_s), + parameter('NodeInstanceType', provider.instance_type), + parameter('KeyName', provider.key_name) + ] + end + + def parameter(key, value) + { parameter_key: key, parameter_value: value } + end + + def stack_template + File.read(Rails.root.join('vendor', 'aws', 'cloudformation', 'eks_cluster.yaml')) + end + end + end +end diff --git a/app/services/clusters/aws/proxy_service.rb b/app/services/clusters/aws/proxy_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..df8fc48000593be750b11d3cd6c4db0075ef924b --- /dev/null +++ b/app/services/clusters/aws/proxy_service.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +module Clusters + module Aws + class ProxyService + DEFAULT_REGION = 'us-east-1' + + BadRequest = Class.new(StandardError) + Response = Struct.new(:status, :body) + + def initialize(role, params:) + @role = role + @params = params + end + + def execute + api_response = request_from_api! + + Response.new(:ok, api_response.to_hash) + rescue *service_errors + Response.new(:bad_request, {}) + end + + private + + attr_reader :role, :params + + def request_from_api! + case requested_resource + when 'key_pairs' + ec2_client.describe_key_pairs + + when 'instance_types' + instance_types + + when 'roles' + iam_client.list_roles + + when 'regions' + ec2_client.describe_regions + + when 'security_groups' + raise BadRequest unless vpc_id.present? + + ec2_client.describe_security_groups(vpc_filter) + + when 'subnets' + raise BadRequest unless vpc_id.present? + + ec2_client.describe_subnets(vpc_filter) + + when 'vpcs' + ec2_client.describe_vpcs + + else + raise BadRequest + end + end + + def requested_resource + params[:resource] + end + + def vpc_id + params[:vpc_id] + end + + def region + params[:region] || DEFAULT_REGION + end + + def vpc_filter + { + filters: [{ + name: "vpc-id", + values: [vpc_id] + }] + } + end + + ## + # Unfortunately the EC2 API doesn't provide a list of + # possible instance types. There is a workaround, using + # the Pricing API, but instead of requiring the + # user to grant extra permissions for this we use the + # values that validate the CloudFormation template. + def instance_types + { + instance_types: cluster_stack_instance_types.map { |type| Hash(instance_type_name: type) } + } + end + + def cluster_stack_instance_types + YAML.safe_load(stack_template).dig('Parameters', 'NodeInstanceType', 'AllowedValues') + end + + def stack_template + File.read(Rails.root.join('vendor', 'aws', 'cloudformation', 'eks_cluster.yaml')) + end + + def ec2_client + ::Aws::EC2::Client.new(client_options) + end + + def iam_client + ::Aws::IAM::Client.new(client_options) + end + + def credentials + Clusters::Aws::FetchCredentialsService.new(role, region: region).execute + end + + def client_options + { + credentials: credentials, + region: region, + http_open_timeout: 5, + http_read_timeout: 10 + } + end + + def service_errors + [ + BadRequest, + Clusters::Aws::FetchCredentialsService::MissingRoleError, + ::Aws::Errors::MissingCredentialsError, + ::Aws::EC2::Errors::ServiceError, + ::Aws::IAM::Errors::ServiceError, + ::Aws::STS::Errors::ServiceError + ] + end + end + end +end diff --git a/app/services/clusters/aws/verify_provision_status_service.rb b/app/services/clusters/aws/verify_provision_status_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..99532662bc41268e74590454d9ecdbea51cc687e --- /dev/null +++ b/app/services/clusters/aws/verify_provision_status_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Clusters + module Aws + class VerifyProvisionStatusService + attr_reader :provider + + INITIAL_INTERVAL = 5.minutes + POLL_INTERVAL = 1.minute + TIMEOUT = 30.minutes + + def execute(provider) + @provider = provider + + case cluster_stack.stack_status + when 'CREATE_IN_PROGRESS' + continue_creation + when 'CREATE_COMPLETE' + finalize_creation + else + provider.make_errored!("Unexpected status; #{cluster_stack.stack_status}") + end + rescue ::Aws::CloudFormation::Errors::ServiceError => e + provider.make_errored!("Amazon CloudFormation request failed; #{e.message}") + end + + private + + def cluster_stack + @cluster_stack ||= provider.api_client.describe_stacks(stack_name: provider.cluster.name).stacks.first + end + + def continue_creation + if timeout_threshold.future? + WaitForClusterCreationWorker.perform_in(POLL_INTERVAL, provider.cluster_id) + else + provider.make_errored!(_('Kubernetes cluster creation time exceeds timeout; %{timeout}') % { timeout: TIMEOUT }) + end + end + + def timeout_threshold + cluster_stack.creation_time + TIMEOUT + end + + def finalize_creation + Clusters::Aws::FinalizeCreationService.new.execute(provider) + end + end + end +end diff --git a/app/services/clusters/destroy_service.rb b/app/services/clusters/destroy_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..a8de04683fa687216c12e9a4abc376738c5c797f --- /dev/null +++ b/app/services/clusters/destroy_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Clusters + class DestroyService + attr_reader :current_user, :params + + def initialize(user = nil, params = {}) + @current_user, @params = user, params.dup + @response = {} + end + + def execute(cluster) + cleanup? ? start_cleanup!(cluster) : destroy_cluster!(cluster) + + @response + end + + private + + def cleanup? + Gitlab::Utils.to_boolean(params[:cleanup]) + end + + def start_cleanup!(cluster) + cluster.start_cleanup! + @response[:message] = _('Kubernetes cluster integration and resources are being removed.') + end + + def destroy_cluster!(cluster) + cluster.destroy! + @response[:message] = _('Kubernetes cluster integration was successfully removed.') + end + end +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 8b8ad924b64556b904c684cf11b15c2ee01b9241..d798dcdcfd3242fc2a999deebb1ec9e2045b5e8a 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 @@ -49,6 +49,8 @@ module Clusters create_or_update_knative_serving_role create_or_update_knative_serving_role_binding + create_or_update_crossplane_database_role + create_or_update_crossplane_database_role_binding end private @@ -78,6 +80,14 @@ module Clusters kubeclient.update_role_binding(knative_serving_role_binding_resource) end + def create_or_update_crossplane_database_role + kubeclient.update_role(crossplane_database_role_resource) + end + + def create_or_update_crossplane_database_role_binding + kubeclient.update_role_binding(crossplane_database_role_binding_resource) + end + def service_account_resource Gitlab::Kubernetes::ServiceAccount.new( service_account_name, @@ -134,6 +144,28 @@ module Clusters service_account_name: service_account_name ).generate end + + def crossplane_database_role_resource + Gitlab::Kubernetes::Role.new( + name: Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_NAME, + namespace: service_account_namespace, + rules: [{ + apiGroups: %w(database.crossplane.io), + resources: %w(postgresqlinstances), + verbs: %w(get list create watch) + }] + ).generate + end + + def crossplane_database_role_binding_resource + Gitlab::Kubernetes::RoleBinding.new( + name: Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_BINDING_NAME, + role_name: Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_NAME, + role_kind: :Role, + namespace: service_account_namespace, + service_account_name: service_account_name + ).generate + end end end end diff --git a/app/services/clusters/kubernetes/kubernetes.rb b/app/services/clusters/kubernetes/kubernetes.rb index 7d5d0c2c1d614e2d7ac9ce81556eb42d37db644d..d29519999b22be064418f09fece1a530109ad45f 100644 --- a/app/services/clusters/kubernetes/kubernetes.rb +++ b/app/services/clusters/kubernetes/kubernetes.rb @@ -10,5 +10,7 @@ module Clusters PROJECT_CLUSTER_ROLE_NAME = 'edit' GITLAB_KNATIVE_SERVING_ROLE_NAME = 'gitlab-knative-serving-role' GITLAB_KNATIVE_SERVING_ROLE_BINDING_NAME = 'gitlab-knative-serving-rolebinding' + GITLAB_CROSSPLANE_DATABASE_ROLE_NAME = 'gitlab-crossplane-database-role' + GITLAB_CROSSPLANE_DATABASE_ROLE_BINDING_NAME = 'gitlab-crossplane-database-rolebinding' end end diff --git a/app/services/clusters/update_service.rb b/app/services/clusters/update_service.rb index 25d26e761b1b1e61266df80fbf948178a1895514..8cb77040b143563fc469fb0fabe28dcdfe79b23e 100644 --- a/app/services/clusters/update_service.rb +++ b/app/services/clusters/update_service.rb @@ -9,7 +9,55 @@ module Clusters end def execute(cluster) - cluster.update(params) + if validate_params(cluster) + cluster.update(params) + else + false + end + end + + private + + def can_admin_pipeline_for_project?(project) + Ability.allowed?(current_user, :admin_pipeline, project) + end + + def validate_params(cluster) + if params[:management_project_id].present? + management_project = management_project_scope(cluster).find_by_id(params[:management_project_id]) + + unless management_project + cluster.errors.add(:management_project_id, _('Project does not exist or you don\'t have permission to perform this action')) + + return false + end + + unless can_admin_pipeline_for_project?(management_project) + # Use same message as not found to prevent enumeration + cluster.errors.add(:management_project_id, _('Project does not exist or you don\'t have permission to perform this action')) + + return false + end + end + + true + end + + def management_project_scope(cluster) + return ::Project.all if cluster.instance_type? + + group = + if cluster.group_type? + cluster.first_group + elsif cluster.project_type? + cluster.first_project&.namespace + end + + # Prevent users from selecting nested projects until + # https://gitlab.com/gitlab-org/gitlab/issues/34650 is resolved + include_subgroups = cluster.group_type? + + ::GroupProjectsFinder.new(group: group, current_user: current_user, options: { only_owned: true, include_subgroups: include_subgroups }).execute end end end diff --git a/app/services/cohorts_service.rb b/app/services/cohorts_service.rb index 97fbb70f350a26698c92082b02ec917b10bdd350..dbbe89ef2603b810dd4043b103ab4f9a61f8c838 100644 --- a/app/services/cohorts_service.rb +++ b/app/services/cohorts_service.rb @@ -88,7 +88,7 @@ class CohortsService User .where('created_at > ?', MONTHS_INCLUDED.months.ago.end_of_month) .group(created_at_month, last_activity_on_month) - .reorder("#{created_at_month} ASC", "#{last_activity_on_month} ASC") + .reorder(Arel.sql("#{created_at_month} ASC, #{last_activity_on_month} ASC")) .count end end diff --git a/app/services/commits/change_service.rb b/app/services/commits/change_service.rb index fbf71f02837dcd1a892be7e2948cb61272961a73..661e654406ea4c4d1249cd06e7610e32c879c6a3 100644 --- a/app/services/commits/change_service.rb +++ b/app/services/commits/change_service.rb @@ -23,14 +23,15 @@ module Commits message, start_project: @start_project, start_branch_name: @start_branch) - rescue Gitlab::Git::Repository::CreateTreeError + rescue Gitlab::Git::Repository::CreateTreeError => ex act = action.to_s.dasherize type = @commit.change_type_title(current_user) error_msg = "Sorry, we cannot #{act} this #{type} automatically. " \ "This #{type} may already have been #{act}ed, or a more recent " \ "commit may have updated some of its content." - raise ChangeError, error_msg + + raise ChangeError.new(error_msg, ex.error_code) end end end diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb index b5401a8ea3726ed2b363cd4ac733af07dfd9377d..b42494563b291d71f614322c28d9d364ee81addc 100644 --- a/app/services/commits/create_service.rb +++ b/app/services/commits/create_service.rb @@ -3,7 +3,15 @@ module Commits class CreateService < ::BaseService ValidationError = Class.new(StandardError) - ChangeError = Class.new(StandardError) + class ChangeError < StandardError + attr_reader :error_code + + def initialize(message, error_code = nil) + super(message) + + @error_code = error_code + end + end def initialize(*args) super @@ -21,8 +29,9 @@ module Commits new_commit = create_commit! success(result: new_commit) + rescue ChangeError => ex + error(ex.message, pass_back: { error_code: ex.error_code }) rescue ValidationError, - ChangeError, Gitlab::Git::Index::IndexError, Gitlab::Git::CommitError, Gitlab::Git::PreReceiveError, diff --git a/app/services/concerns/git/logger.rb b/app/services/concerns/git/logger.rb new file mode 100644 index 0000000000000000000000000000000000000000..7c036212e664ec7e810e44f521da5f60028abe7e --- /dev/null +++ b/app/services/concerns/git/logger.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Git + module Logger + def log_error(message, save_message_on_model: false) + Gitlab::GitLogger.error("#{self.class.name} error (#{merge_request.to_reference(full: true)}): #{message}") + merge_request.update(merge_error: message) if save_message_on_model + end + end +end diff --git a/app/services/create_branch_service.rb b/app/services/create_branch_service.rb index 110e589e30d22a2d5d74bcb7dd495b8c5a89e512..d58cb0f9e2bab30dde66d0a523f7f6a7df1f8462 100644 --- a/app/services/create_branch_service.rb +++ b/app/services/create_branch_service.rb @@ -14,7 +14,7 @@ class CreateBranchService < BaseService if new_branch success(new_branch) else - error('Invalid reference name') + error("Invalid reference name: #{branch_name}") end rescue Gitlab::Git::PreReceiveError => ex error(ex.message) diff --git a/app/services/deployments/after_create_service.rb b/app/services/deployments/after_create_service.rb index 2572802e6a15c0f015284a89e907a48c3fdf4bf2..e0a4e5419cc71e66a2990627c9781f83aac989b3 100644 --- a/app/services/deployments/after_create_service.rb +++ b/app/services/deployments/after_create_service.rb @@ -33,12 +33,21 @@ 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 new file mode 100644 index 0000000000000000000000000000000000000000..71186659290129c906eb90721957528dfc6fdaad --- /dev/null +++ b/app/services/deployments/link_merge_requests_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Deployments + # Service class for linking merge requests to deployments. + class LinkMergeRequestsService + attr_reader :deployment + + # The number of commits per query for which to find merge requests. + COMMITS_PER_QUERY = 5_000 + + def initialize(deployment) + @deployment = deployment + end + + def execute + return unless deployment.success? + + if (prev = deployment.previous_environment_deployment) + link_merge_requests_for_range(prev.sha, deployment.sha) + else + # When no previous deployment is found we fall back to linking all merge + # requests merged into the deployed branch. This will not always be + # accurate, but it's better than having no data. + # + # We can't use the first commit in the repository as a base to compare + # to, as this will not scale to large repositories. For example, GitLab + # itself has over 150 000 commits. + link_all_merged_merge_requests + end + end + + def link_merge_requests_for_range(from, to) + commits = project + .repository + .commits_between(from, to) + .map(&:id) + + # For some projects the list of commits to deploy may be very large. To + # ensure we do not end up running SQL queries with thousands of WHERE IN + # values, we run one query per a certain number of commits. + # + # In most cases this translates to only a single query. For very large + # deployment we may end up running a handful of queries to get and insert + # the data. + commits.each_slice(COMMITS_PER_QUERY) do |slice| + merge_requests = + project.merge_requests.merged.by_merge_commit_sha(slice) + + deployment.link_merge_requests(merge_requests) + end + end + + def link_all_merged_merge_requests + merge_requests = + project.merge_requests.merged.by_target_branch(deployment.ref) + + deployment.link_merge_requests(merge_requests) + end + + private + + def project + deployment.project + end + end +end diff --git a/app/services/deployments/update_service.rb b/app/services/deployments/update_service.rb index 7c8215d28f2a8a3fb793677a3faab6651d554029..97b233f16a792a288399d069663be6a9590a733f 100644 --- a/app/services/deployments/update_service.rb +++ b/app/services/deployments/update_service.rb @@ -10,7 +10,22 @@ module Deployments end def execute - deployment.update(status: params[:status]) + # A regular update() does not trigger the state machine transitions, which + # we need to ensure merge requests are linked when changing the status to + # success. To work around this we use this case statment, using the right + # event methods to trigger the transition hooks. + case params[:status] + when 'running' + deployment.run + when 'success' + deployment.succeed + when 'failed' + deployment.drop + when 'canceled' + deployment.cancel + else + false + end end end end diff --git a/app/services/error_tracking/base_service.rb b/app/services/error_tracking/base_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..430d99523323e568f62a17e546ed827f638bab81 --- /dev/null +++ b/app/services/error_tracking/base_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module ErrorTracking + class BaseService < ::BaseService + def execute + unauthorized = check_permissions + return unauthorized if unauthorized + + begin + response = fetch + rescue Sentry::Client::Error => e + return error(e.message, :bad_request) + rescue Sentry::Client::MissingKeysError => e + return error(e.message, :internal_server_error) + end + + errors = parse_errors(response) + return errors if errors + + success(parse_response(response)) + end + + private + + def fetch + raise NotImplementedError, + "#{self.class} does not implement #{__method__}" + end + + def parse_response(response) + raise NotImplementedError, + "#{self.class} does not implement #{__method__}" + end + + def check_permissions + return error('Error Tracking is not enabled') unless enabled? + return error('Access denied', :unauthorized) unless can_read? + end + + def parse_errors(response) + return error('Not ready. Try again later', :no_content) unless response + return error(response[:error], http_status_for(response[:error_type])) if response[:error].present? + end + + def http_status_for(error_type) + case error_type + when ErrorTracking::ProjectErrorTrackingSetting::SENTRY_API_ERROR_TYPE_MISSING_KEYS + :internal_server_error + else + :bad_request + end + end + + def project_error_tracking_setting + project.error_tracking_setting + end + + def enabled? + project_error_tracking_setting&.enabled? + end + + def can_read? + can?(current_user, :read_sentry_issue, project) + end + end +end diff --git a/app/services/error_tracking/issue_details_service.rb b/app/services/error_tracking/issue_details_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..368cd4517fcc4f5922c7a3b06b27783524fc2a8b --- /dev/null +++ b/app/services/error_tracking/issue_details_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ErrorTracking + class IssueDetailsService < ErrorTracking::BaseService + private + + def fetch + project_error_tracking_setting.issue_details(issue_id: params[:issue_id]) + end + + def parse_response(response) + { issue: response[:issue] } + end + end +end diff --git a/app/services/error_tracking/issue_latest_event_service.rb b/app/services/error_tracking/issue_latest_event_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..b6ad8f8028b3c5d8db5f6b1f4d4b5554e303eb01 --- /dev/null +++ b/app/services/error_tracking/issue_latest_event_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ErrorTracking + class IssueLatestEventService < ErrorTracking::BaseService + private + + def fetch + project_error_tracking_setting.issue_latest_event(issue_id: params[:issue_id]) + end + + def parse_response(response) + { latest_event: response[:latest_event] } + end + end +end diff --git a/app/services/error_tracking/list_issues_service.rb b/app/services/error_tracking/list_issues_service.rb index 86ab21fa865d0f3b709baa234483cdf09b8f6019..2e8c401b8efad3a3eab1ef222038e6e5150d8c98 100644 --- a/app/services/error_tracking/list_issues_service.rb +++ b/app/services/error_tracking/list_issues_service.rb @@ -1,46 +1,22 @@ # frozen_string_literal: true module ErrorTracking - class ListIssuesService < ::BaseService + class ListIssuesService < ErrorTracking::BaseService DEFAULT_ISSUE_STATUS = 'unresolved' DEFAULT_LIMIT = 20 - def execute - return error('Error Tracking is not enabled') unless enabled? - return error('Access denied', :unauthorized) unless can_read? - - result = project_error_tracking_setting - .list_sentry_issues(issue_status: issue_status, limit: limit) - - # our results are not yet ready - unless result - return error('Not ready. Try again later', :no_content) - end - - if result[:error].present? - return error(result[:error], http_status_from_error_type(result[:error_type])) - end - - success(issues: result[:issues]) - end - def external_url project_error_tracking_setting&.sentry_external_url end private - def http_status_from_error_type(error_type) - case error_type - when ErrorTracking::ProjectErrorTrackingSetting::SENTRY_API_ERROR_TYPE_MISSING_KEYS - :internal_server_error - else - :bad_request - end + def fetch + project_error_tracking_setting.list_sentry_issues(issue_status: issue_status, limit: limit) end - def project_error_tracking_setting - project.error_tracking_setting + def parse_response(response) + { issues: response[:issues] } end def issue_status @@ -50,13 +26,5 @@ module ErrorTracking def limit params[:limit] || DEFAULT_LIMIT end - - def enabled? - project_error_tracking_setting&.enabled? - end - - def can_read? - can?(current_user, :read_sentry_issue, project) - end end end diff --git a/app/services/error_tracking/list_projects_service.rb b/app/services/error_tracking/list_projects_service.rb index 92d4ef85ecf67c9c9331e365e524c1f8228792df..09a0b952e84982590b4b49949e3f506fea868b61 100644 --- a/app/services/error_tracking/list_projects_service.rb +++ b/app/services/error_tracking/list_projects_service.rb @@ -1,44 +1,38 @@ # frozen_string_literal: true module ErrorTracking - class ListProjectsService < ::BaseService + class ListProjectsService < ErrorTracking::BaseService def execute - return error('access denied') unless can_read? - - setting = project_error_tracking_setting - - unless setting.valid? - return error(setting.errors.full_messages.join(', '), :bad_request) + unless project_error_tracking_setting.valid? + return error(project_error_tracking_setting.errors.full_messages.join(', '), :bad_request) end - begin - result = setting.list_sentry_projects - rescue Sentry::Client::Error => e - return error(e.message, :bad_request) - rescue Sentry::Client::MissingKeysError => e - return error(e.message, :internal_server_error) - end - - success(projects: result[:projects]) + super end private - def project_error_tracking_setting - (project.error_tracking_setting || project.build_error_tracking_setting).tap do |setting| - setting.api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from( - api_host: params[:api_host], - organization_slug: 'org', - project_slug: 'proj' - ) - - setting.token = token(setting) - setting.enabled = true - end + def fetch + project_error_tracking_setting.list_sentry_projects + end + + def parse_response(response) + { projects: response[:projects] } end - def can_read? - can?(current_user, :read_sentry_issue, project) + def project_error_tracking_setting + @project_error_tracking_setting ||= begin + (super || project.build_error_tracking_setting).tap do |setting| + setting.api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from( + api_host: params[:api_host], + organization_slug: 'org', + project_slug: 'proj' + ) + + setting.token = token(setting) + setting.enabled = true + end + end end def token(setting) diff --git a/app/services/groups/group_links/create_service.rb b/app/services/groups/group_links/create_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..2ce53fcfe4a795227dfc32dc0397a0fdebc7d087 --- /dev/null +++ b/app/services/groups/group_links/create_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Groups + module GroupLinks + class CreateService < BaseService + def execute(shared_group) + unless group && shared_group && + can?(current_user, :admin_group, shared_group) && + can?(current_user, :read_group, group) + return error('Not Found', 404) + end + + link = GroupGroupLink.new( + shared_group: shared_group, + shared_with_group: group, + group_access: params[:shared_group_access], + expires_at: params[:expires_at] + ) + + if link.save + group.refresh_members_authorized_projects + success(link: link) + else + error(link.errors.full_messages.to_sentence, 409) + end + end + end + end +end diff --git a/app/services/groups/group_links/destroy_service.rb b/app/services/groups/group_links/destroy_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..29aa8de4e68a5943a84c0de9bc18c461974026af --- /dev/null +++ b/app/services/groups/group_links/destroy_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Groups + module GroupLinks + class DestroyService < BaseService + def execute(one_or_more_links) + links = Array(one_or_more_links) + + GroupGroupLink.transaction do + GroupGroupLink.delete(links) + + groups_to_refresh = links.map(&:shared_with_group) + groups_to_refresh.uniq.each do |group| + group.refresh_members_authorized_projects + end + + Gitlab::AppLogger.info("GroupGroupLinks with ids: #{links.map(&:id)} have been deleted.") + rescue => ex + Gitlab::AppLogger.error(ex) + + raise + end + end + end + end +end diff --git a/app/services/groups/import_export/export_service.rb b/app/services/groups/import_export/export_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..26886fc67dcdfbef4d307f717797ffe68605e00b --- /dev/null +++ b/app/services/groups/import_export/export_service.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Groups + module ImportExport + class ExportService + def initialize(group:, user:, params: {}) + @group = group + @current_user = user + @params = params + @shared = @params[:shared] || Gitlab::ImportExport::Shared.new(@group) + end + + def execute + save! + end + + private + + attr_accessor :shared + + def save! + if savers.all?(&:save) + notify_success + else + cleanup_and_notify_error! + end + end + + def savers + [tree_exporter, file_saver] + end + + def tree_exporter + Gitlab::ImportExport::GroupTreeSaver.new(group: @group, current_user: @current_user, shared: @shared, params: @params) + end + + def file_saver + Gitlab::ImportExport::Saver.new(exportable: @group, shared: @shared) + end + + def cleanup_and_notify_error + FileUtils.rm_rf(shared.export_path) + + notify_error + end + + def cleanup_and_notify_error! + cleanup_and_notify_error + + raise Gitlab::ImportExport::Error.new(shared.errors.to_sentence) + end + + def notify_success + @shared.logger.info( + group_id: @group.id, + group_name: @group.name, + message: 'Group Import/Export: Export succeeded' + ) + end + + def notify_error + @shared.logger.error( + group_id: @group.id, + group_name: @group.name, + error: @shared.errors.join(', '), + message: 'Group Import/Export: Export failed' + ) + end + end + end +end diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index 6902b7bd5296d0ff85b48492dce40c2d999cab41..24813f6ddf9e1afe3ad44b30289e059a971b5cf0 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -13,7 +13,7 @@ module Groups TransferError = Class.new(StandardError) - attr_reader :error + attr_reader :error, :new_parent_group def initialize(group, user, params = {}) super @@ -75,7 +75,7 @@ module Groups # rubocop: enable CodeReuse/ActiveRecord def group_projects_contain_registry_images? - @group.has_container_repositories? + @group.has_container_repository_including_subgroups? end def update_group_attributes @@ -115,3 +115,5 @@ module Groups end end end + +Groups::TransferService.prepend_if_ee('EE::Groups::TransferService') diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index be7502a193e2ef7a6cc4f50336c9b07a8b050933..8635b82461b9bc10285da56e008dadfeaeadb632 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -43,8 +43,9 @@ module Groups def renaming_group_with_container_registry_images? new_path = params[:path] - new_path && new_path != group.path && - group.has_container_repositories? + new_path && + new_path != group.path && + group.has_container_repository_including_subgroups? end def container_images_error diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 3e17d75c02c26a52615c1fed6f5cb96ac630cba0..8a79c5f889d4eaee346ef57e71bf17d797a1fdb2 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -355,7 +355,7 @@ class IssuableBaseService < BaseService associations = { labels: issuable.labels.to_a, - mentioned_users: issuable.mentioned_users.to_a, + mentioned_users: issuable.mentioned_users(current_user).to_a, assignees: issuable.assignees.to_a } associations[:total_time_spent] = issuable.total_time_spent if issuable.respond_to?(:total_time_spent) diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 528b1ea61b342b1bc0549ea55710602db5f4a9a9..b98a4d2567f4849d62079e3a5b390f32ae87ad45 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -61,8 +61,6 @@ module Issues if added_mentions.present? notification_service.async.new_mentions_in_issue(issue, added_mentions, current_user) end - - ZoomNotesService.new(issue, project, current_user, old_description: old_associations[:description]).execute end def handle_task_changes(issuable) diff --git a/app/services/issues/zoom_link_service.rb b/app/services/issues/zoom_link_service.rb index 561c86475e59bd13cec75af4080c10525752d19a..023d7080e88a638783ee247e1573001b76fd66fe 100644 --- a/app/services/issues/zoom_link_service.rb +++ b/app/services/issues/zoom_link_service.rb @@ -6,32 +6,37 @@ module Issues super(issue.project, user) @issue = issue + @added_meeting = ZoomMeeting.canonical_meeting(@issue) end def add_link(link) if can_add_link? && (link = parse_link(link)) - track_meeting_added_event - success(_('Zoom meeting added'), append_to_description(link)) + begin + add_zoom_meeting(link) + success(_('Zoom meeting added')) + rescue ActiveRecord::RecordNotUnique + error(_('Failed to add a Zoom meeting')) + end else error(_('Failed to add a Zoom meeting')) end end - def can_add_link? - can? && !link_in_issue_description? - end - def remove_link if can_remove_link? - track_meeting_removed_event - success(_('Zoom meeting removed'), remove_from_description) + remove_zoom_meeting + success(_('Zoom meeting removed')) else error(_('Failed to remove a Zoom meeting')) end end + def can_add_link? + can_update_issue? && !@added_meeting + end + def can_remove_link? - can? && link_in_issue_description? + can_update_issue? && !!@added_meeting end def parse_link(link) @@ -42,10 +47,6 @@ module Issues attr_reader :issue - def issue_description - issue.description || '' - end - def track_meeting_added_event ::Gitlab::Tracking.event('IncidentManagement::ZoomIntegration', 'add_zoom_meeting', label: 'Issue ID', value: issue.id) end @@ -54,39 +55,33 @@ module Issues ::Gitlab::Tracking.event('IncidentManagement::ZoomIntegration', 'remove_zoom_meeting', label: 'Issue ID', value: issue.id) end - def success(message, description) - ServiceResponse - .success(message: message, payload: { description: description }) - end - - def error(message) - ServiceResponse.error(message: message) + def add_zoom_meeting(link) + ZoomMeeting.create( + issue: @issue, + project: @issue.project, + issue_status: :added, + url: link + ) + track_meeting_added_event + SystemNoteService.zoom_link_added(@issue, @project, current_user) end - def append_to_description(link) - "#{issue_description}\n\n#{link}" + def remove_zoom_meeting + @added_meeting.update(issue_status: :removed) + track_meeting_removed_event + SystemNoteService.zoom_link_removed(@issue, @project, current_user) end - def remove_from_description - link = parse_link(issue_description) - return issue_description unless link - - issue_description.delete_suffix(link).rstrip + def success(message) + ServiceResponse.success(message: message) end - def link_in_issue_description? - link = extract_link_from_issue_description - return unless link - - Gitlab::ZoomLinkExtractor.new(link).match? - end - - def extract_link_from_issue_description - issue_description[/(\S+)\z/, 1] + def error(message) + ServiceResponse.error(message: message) end - def can? - current_user.can?(:update_issue, project) + def can_update_issue? + can?(current_user, :update_issue, project) end end end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index aacc3d6831e6e5555a392264ad922be4ef725d5b..00bf69739adb7291c3724e53a574a609a4edcdd1 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -29,6 +29,19 @@ module MergeRequests .execute_for_merge_request(merge_request) end + def source_project + @source_project ||= merge_request.source_project + end + + def target_project + @target_project ||= merge_request.target_project + end + + # Don't try to print expensive instance variables. + def inspect + "#<#{self.class} #{merge_request.to_reference(full: true)}>" + end + private def create(merge_request) diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index bf4da01723b660bb04eaa072290575c73e74eba0..456cc589477947bd52f046ddf3da5e23dea7cf74 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -16,6 +16,14 @@ module MergeRequests merge_request.source_project = find_source_project merge_request.target_project = find_target_project + # Source project sets the default source branch removal setting + merge_request.merge_params['force_remove_source_branch'] = + if params.key?(:force_remove_source_branch) + params.delete(:force_remove_source_branch) + else + merge_request.source_project.remove_source_branch_after_merge? + end + self.params = assign_allowed_merge_params(merge_request, params) filter_params(merge_request) diff --git a/app/services/merge_requests/ff_merge_service.rb b/app/services/merge_requests/ff_merge_service.rb index 479e0fe6699fe265616b863bcb6299a6027b449c..6f1fa607ef9ed00c786d150e7d6570a75507de5f 100644 --- a/app/services/merge_requests/ff_merge_service.rb +++ b/app/services/merge_requests/ff_merge_service.rb @@ -11,10 +11,16 @@ module MergeRequests private def commit - repository.ff_merge(current_user, - source, - merge_request.target_branch, - merge_request: merge_request) + ff_merge = repository.ff_merge(current_user, + source, + merge_request.target_branch, + merge_request: merge_request) + + if merge_request.squash + merge_request.update_column(:squash_commit_sha, merge_request.in_progress_merge_commit_sha) + end + + ff_merge rescue Gitlab::Git::PreReceiveError => e raise MergeError, e.message rescue StandardError => e diff --git a/app/services/merge_requests/merge_base_service.rb b/app/services/merge_requests/merge_base_service.rb index 3f7f8bcdcbf50029ec887da46d2fb74bfd7fb82a..27b5e31faab1be4ea7371bac680c047696e57901 100644 --- a/app/services/merge_requests/merge_base_service.rb +++ b/app/services/merge_requests/merge_base_service.rb @@ -19,10 +19,12 @@ module MergeRequests end def source - if merge_request.squash - squash_sha! - else - merge_request.diff_head_sha + strong_memoize(:source) do + if merge_request.squash + squash_sha! + else + merge_request.diff_head_sha + end end end @@ -58,16 +60,14 @@ module MergeRequests end def squash_sha! - strong_memoize(:squash_sha) do - params[:merge_request] = merge_request - squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute - - case squash_result[:status] - when :success - squash_result[:squash_sha] - when :error - raise ::MergeRequests::MergeService::MergeError, squash_result[:message] - end + params[:merge_request] = merge_request + squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute + + case squash_result[:status] + when :success + squash_result[:squash_sha] + when :error + raise ::MergeRequests::MergeService::MergeError, squash_result[:message] end end end diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 6309052244de771c443b6f9a8175146d2fcd6bcb..a45b4f1142ea361725a6b52de04c1e3e79655878 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -37,6 +37,7 @@ module MergeRequests def validate! authorization_check! error_check! + updated_check! end def authorization_check! @@ -60,6 +61,15 @@ module MergeRequests raise_error(error) if error end + def updated_check! + return unless Feature.enabled?(:validate_merge_sha, merge_request.target_project, default_enabled: false) + + unless source_matches? + raise_error('Branch has been updated since the merge was requested. '\ + 'Please review the changes.') + end + end + def commit log_info("Git merge started on JID #{merge_jid}") commit_id = try_merge @@ -125,5 +135,11 @@ module MergeRequests def merge_request_info merge_request.to_reference(full: true) end + + def source_matches? + # params-keys are symbols coming from the controller, but when they get + # loaded from the database they're strings + params.with_indifferent_access[:sha] == merge_request.diff_head_sha + end end end diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb index 4d36dd4feae367c3caf47b0bb0a9e6d529d0a6e3..7e9442c0c7cca995b6c82fe90f2fee498ea1cf12 100644 --- a/app/services/merge_requests/rebase_service.rb +++ b/app/services/merge_requests/rebase_service.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true module MergeRequests - class RebaseService < MergeRequests::WorkingCopyBaseService + class RebaseService < MergeRequests::BaseService + include Git::Logger + REBASE_ERROR = 'Rebase failed. Please rebase locally' + attr_reader :merge_request + def execute(merge_request) @merge_request = merge_request diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index b32499629ff25fe600453c702eeb93ee5341ac92..bd3fcf85a620951b1977c387667103baa5cbb46e 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -152,7 +152,8 @@ module MergeRequests def abort_ff_merge_requests_with_when_pipeline_succeeds return unless @project.ff_merge_must_be_possible? - requests_with_auto_merge_enabled_to(@push.branch_name).each do |merge_request| + merge_requests_with_auto_merge_enabled_to(@push.branch_name).each do |merge_request| + next unless merge_request.auto_merge_strategy == AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS next unless merge_request.should_be_rebased? abort_auto_merge_with_todo(merge_request, 'target branch was updated') @@ -167,11 +168,11 @@ module MergeRequests todo_service.merge_request_became_unmergeable(merge_request) end - def requests_with_auto_merge_enabled_to(target_branch) + def merge_requests_with_auto_merge_enabled_to(target_branch) @project .merge_requests .by_target_branch(target_branch) - .with_open_merge_when_pipeline_succeeds + .with_auto_merge_enabled end def mark_pending_todos_done diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb index 88ca3b4f5a81665787affc56879df145bd841078..d25997c925e26c738d000d17cad64c4cf3b89fc6 100644 --- a/app/services/merge_requests/squash_service.rb +++ b/app/services/merge_requests/squash_service.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true module MergeRequests - class SquashService < MergeRequests::WorkingCopyBaseService + class SquashService < MergeRequests::BaseService + include Git::Logger + def execute # If performing a squash would result in no change, then # immediately return a success message without performing a squash diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 7c9abb12b6e41db3a4ac9c1e18799ce14223e572..8a6a7119508001aee639887695de34ddac4a8b34 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -88,9 +88,9 @@ module MergeRequests merge_request.update(merge_error: nil) if merge_request.head_pipeline && merge_request.head_pipeline.active? - AutoMergeService.new(project, current_user).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) + AutoMergeService.new(project, current_user, { sha: last_diff_sha }).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) else - merge_request.merge_async(current_user.id, {}) + merge_request.merge_async(current_user.id, { sha: last_diff_sha }) end end diff --git a/app/services/merge_requests/working_copy_base_service.rb b/app/services/merge_requests/working_copy_base_service.rb deleted file mode 100644 index 2d2be1f4c250cf5f950f4ba5fb111b9c2ca29df0..0000000000000000000000000000000000000000 --- a/app/services/merge_requests/working_copy_base_service.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module MergeRequests - class WorkingCopyBaseService < MergeRequests::BaseService - attr_reader :merge_request - - def source_project - @source_project ||= merge_request.source_project - end - - def target_project - @target_project ||= merge_request.target_project - end - - def log_error(message, save_message_on_model: false) - Gitlab::GitLogger.error("#{self.class.name} error (#{merge_request.to_reference(full: true)}): #{message}") - - merge_request.update(merge_error: message) if save_message_on_model - end - - # Don't try to print expensive instance variables. - def inspect - "#<#{self.class} #{merge_request.to_reference(full: true)}>" - end - end -end diff --git a/app/services/metrics/dashboard/custom_metric_embed_service.rb b/app/services/metrics/dashboard/custom_metric_embed_service.rb index 50f070989fcc6ec4d62747418e1dc3c4eea4b19e..79a556b1695d6798d363ba2c225b4cd1520f9a8e 100644 --- a/app/services/metrics/dashboard/custom_metric_embed_service.rb +++ b/app/services/metrics/dashboard/custom_metric_embed_service.rb @@ -40,7 +40,7 @@ module Metrics # All custom metrics are displayed on the system dashboard. # Nil is acceptable as we'll default to the system dashboard. def valid_dashboard?(dashboard) - dashboard.nil? || SystemDashboardService.system_dashboard?(dashboard) + dashboard.nil? || ::Metrics::Dashboard::SystemDashboardService.system_dashboard?(dashboard) end end @@ -77,15 +77,14 @@ module Metrics # There may be multiple metrics, but they should be # displayed in a single panel/chart. # @return [ActiveRecord::AssociationRelation<PromtheusMetric>] - # rubocop: disable CodeReuse/ActiveRecord def metrics - project.prometheus_metrics.where( + PrometheusMetricsFinder.new( + project: project, group: group_key, title: title, y_label: y_label - ) + ).execute end - # rubocop: enable CodeReuse/ActiveRecord # Returns a symbol representing the group that # the dashboard's group title belongs to. diff --git a/app/services/metrics/dashboard/grafana_metric_embed_service.rb b/app/services/metrics/dashboard/grafana_metric_embed_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..60591e9a6f351f3113fbcdbc1c2153d359355c05 --- /dev/null +++ b/app/services/metrics/dashboard/grafana_metric_embed_service.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +# Responsible for returning a gitlab-compatible dashboard +# containing info based on a grafana dashboard and datasource. +# +# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards. +module Metrics + module Dashboard + class GrafanaMetricEmbedService < ::Metrics::Dashboard::BaseService + include ReactiveCaching + + SEQUENCE = [ + ::Gitlab::Metrics::Dashboard::Stages::GrafanaFormatter + ].freeze + + self.reactive_cache_key = ->(service) { service.cache_key } + self.reactive_cache_lease_timeout = 30.seconds + self.reactive_cache_refresh_interval = 30.minutes + self.reactive_cache_lifetime = 30.minutes + self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } + + class << self + # Determines whether the provided params are sufficient + # to uniquely identify a grafana dashboard. + def valid_params?(params) + [ + params[:embedded], + params[:grafana_url] + ].all? + end + + def from_cache(project_id, user_id, grafana_url) + project = Project.find(project_id) + user = User.find(user_id) + + new(project, user, grafana_url: grafana_url) + end + end + + def get_dashboard + with_reactive_cache(*cache_key) { |result| result } + end + + # Inherits the primary logic from the parent class and + # maintains the service's API while including ReactiveCache + def calculate_reactive_cache(*) + # This is called with explicit parentheses to prevent + # the params passed to #calculate_reactive_cache from + # being passed to #get_dashboard (which accepts none) + ::Metrics::Dashboard::BaseService + .instance_method(:get_dashboard) + .bind(self) + .call() # rubocop:disable Style/MethodCallWithoutArgsParentheses + end + + def cache_key(*args) + [project.id, current_user.id, grafana_url] + end + + # Required for ReactiveCaching; Usage overridden by + # self.reactive_cache_worker_finder + def id + nil + end + + private + + def get_raw_dashboard + raise MissingIntegrationError unless client + + grafana_dashboard = fetch_dashboard + datasource = fetch_datasource(grafana_dashboard) + + params.merge!(grafana_dashboard: grafana_dashboard, datasource: datasource) + + {} + end + + def fetch_dashboard + uid = GrafanaUidParser.new(grafana_url, project).parse + raise DashboardProcessingError.new('Dashboard uid not found') unless uid + + response = client.get_dashboard(uid: uid) + + parse_json(response.body) + end + + def fetch_datasource(dashboard) + name = DatasourceNameParser.new(grafana_url, dashboard).parse + raise DashboardProcessingError.new('Datasource name not found') unless name + + response = client.get_datasource(name: name) + + parse_json(response.body) + end + + def grafana_url + params[:grafana_url] + end + + def client + project.grafana_integration&.client + end + + def allowed? + Ability.allowed?(current_user, :read_project, project) + end + + def sequence + SEQUENCE + end + + def parse_json(json) + JSON.parse(json, symbolize_names: true) + rescue JSON::ParserError + raise DashboardProcessingError.new('Grafana response contains invalid json') + end + end + + # Identifies the uid of the dashboard based on url format + class GrafanaUidParser + def initialize(grafana_url, project) + @grafana_url, @project = grafana_url, project + end + + def parse + @grafana_url.match(uid_regex) { |m| m.named_captures['uid'] } + end + + private + + # URLs are expected to look like https://domain.com/d/:uid/other/stuff + def uid_regex + base_url = @project.grafana_integration.grafana_url.chomp('/') + + %r{(#{Regexp.escape(base_url)}\/d\/(?<uid>\w+)\/)}x + end + end + + # Identifies the name of the datasource for a dashboard + # based on the panelId query parameter found in the url + class DatasourceNameParser + def initialize(grafana_url, grafana_dashboard) + @grafana_url, @grafana_dashboard = grafana_url, grafana_dashboard + end + + def parse + @grafana_dashboard[:dashboard][:panels] + .find { |panel| panel[:id].to_s == query_params[:panelId] } + .try(:[], :datasource) + end + + private + + def query_params + Gitlab::Metrics::Dashboard::Url.parse_query(@grafana_url) + end + end + end +end diff --git a/app/services/metrics/dashboard/project_dashboard_service.rb b/app/services/metrics/dashboard/project_dashboard_service.rb index 756d387c0e6cf8b7d59f10a2226fc34016d5deb1..b0d54ee9347f6355fee30e27dca42342d2b6f3e7 100644 --- a/app/services/metrics/dashboard/project_dashboard_service.rb +++ b/app/services/metrics/dashboard/project_dashboard_service.rb @@ -16,7 +16,8 @@ module Metrics { path: filepath, display_name: name_for_path(filepath), - default: false + default: false, + system_dashboard: false } end end diff --git a/app/services/metrics/dashboard/system_dashboard_service.rb b/app/services/metrics/dashboard/system_dashboard_service.rb index ccfd9db8746bb09fee91269beee48a3050a04900..f8dbb8a705c4c40a2f8d559de1fbf7221b929658 100644 --- a/app/services/metrics/dashboard/system_dashboard_service.rb +++ b/app/services/metrics/dashboard/system_dashboard_service.rb @@ -20,7 +20,8 @@ module Metrics [{ path: SYSTEM_DASHBOARD_PATH, display_name: SYSTEM_DASHBOARD_NAME, - default: true + default: true, + system_dashboard: true }] end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index c136803ef3bea95441ca04593425846cf033c368..9e6cbfa06fef980adc02f74ded23c8dc3ea7da49 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -42,6 +42,10 @@ module Notes clear_noteable_diffs_cache(note) Suggestions::CreateService.new(note).execute increment_usage_counter(note) + + if Feature.enabled?(:notes_create_service_tracking, project) + Gitlab::Tracking.event('Notes::CreateService', 'execute', tracking_data_for(note)) + end end if quick_actions_service.commands_executed_count.to_i > 0 @@ -59,5 +63,16 @@ module Notes note end + + private + + def tracking_data_for(note) + label = Gitlab.ee? && note.author == User.visual_review_bot ? 'anonymous_visual_review_note' : 'note' + + { + label: label, + value: note.id + } + end end end diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb index 48722cc2a79970c7f76bc8104e9a496b9a746c3f..53b3b57f4af0c71008d5ab352618b9f617216cb2 100644 --- a/app/services/notes/post_process_service.rb +++ b/app/services/notes/post_process_service.rb @@ -35,3 +35,5 @@ module Notes end end end + +Notes::PostProcessService.prepend_if_ee('EE::Notes::PostProcessService') diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb index 9afbb678f5d8fee6da1115f082945779ddb2c42f..0bdf6a0e6bcc7bbbe2cff1452b1261319d2a6eda 100644 --- a/app/services/notification_recipient_service.rb +++ b/app/services/notification_recipient_service.rb @@ -181,7 +181,7 @@ module NotificationRecipientService def add_subscribed_users return unless target.respond_to? :subscribers - add_recipients(target.subscribers(project), :subscription, nil) + add_recipients(target.subscribers(project), :subscription, NotificationReason::SUBSCRIBED) end # rubocop: disable CodeReuse/ActiveRecord @@ -240,7 +240,7 @@ module NotificationRecipientService return unless target.respond_to? :labels (labels || target.labels).each do |label| - add_recipients(label.subscribers(project), :subscription, nil) + add_recipients(label.subscribers(project), :subscription, NotificationReason::SUBSCRIBED) end end end diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb index 2b4c4ae68e2ef9de003a4f2f633a2ee076689edf..afe2651b11a39fe0738ca74d4d2310c5eded1d28 100644 --- a/app/services/preview_markdown_service.rb +++ b/app/services/preview_markdown_service.rb @@ -16,8 +16,12 @@ class PreviewMarkdownService < BaseService private + def quick_action_types + %w(Issue MergeRequest Commit) + end + def explain_quick_actions(text) - return text, [] unless %w(Issue MergeRequest Commit).include?(target_type) + return text, [] unless quick_action_types.include?(target_type) quick_actions_service = QuickActions::InterpretService.new(project, current_user) quick_actions_service.explain(text, find_commands_target) @@ -51,7 +55,7 @@ class PreviewMarkdownService < BaseService def find_commands_target QuickActions::TargetService - .new(project, current_user) + .new(project, current_user, group: params[:group]) .execute(target_type, target_id) end @@ -63,3 +67,5 @@ class PreviewMarkdownService < BaseService params[:target_id] end end + +PreviewMarkdownService.prepend_if_ee('EE::PreviewMarkdownService') diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb index 5129e2269a8c11fe2944b01d83b260260e34eeec..48bd9394dc578f11a334bae07ee1405323d52b39 100644 --- a/app/services/projects/container_repository/delete_tags_service.rb +++ b/app/services/projects/container_repository/delete_tags_service.rb @@ -9,25 +9,11 @@ module Projects tag_names = params[:tags] return error('not tags specified') if tag_names.blank? - if can_use? - smart_delete(container_repository, tag_names) - else - unsafe_delete(container_repository, tag_names) - end + smart_delete(container_repository, tag_names) end private - def unsafe_delete(container_repository, tag_names) - deleted_tags = tag_names.select do |tag_name| - container_repository.tag(tag_name).unsafe_delete - end - - return error('could not delete tags') if deleted_tags.empty? - - success(deleted: deleted_tags) - end - # Replace a tag on the registry with a dummy tag. # This is a hack as the registry doesn't support deleting individual # tags. This code effectively pushes a dummy image and assigns the tag to it. @@ -36,10 +22,18 @@ module Projects def smart_delete(container_repository, tag_names) # generates the blobs for the dummy image dummy_manifest = container_repository.client.generate_empty_manifest(container_repository.path) + return error('could not generate manifest') if dummy_manifest.nil? # update the manifests of the tags with the new dummy image - tag_digests = tag_names.map do |name| - container_repository.client.put_tag(container_repository.path, name, dummy_manifest) + deleted_tags = [] + tag_digests = [] + + tag_names.each do |name| + digest = container_repository.client.put_tag(container_repository.path, name, dummy_manifest) + next unless digest + + deleted_tags << name + tag_digests << digest end # make sure the digests are the same (it should always be) @@ -51,16 +45,12 @@ module Projects # Deletes the dummy image # All created tag digests are the same since they all have the same dummy image. # a single delete is sufficient to remove all tags with it - if container_repository.delete_tag_by_digest(tag_digests.first) - success(deleted: tag_names) + if tag_digests.any? && container_repository.delete_tag_by_digest(tag_digests.first) + success(deleted: deleted_tags) else error('could not delete tags') end end - - def can_use? - Feature.enabled?(:container_registry_smart_delete, project, default_enabled: true) - end end end end diff --git a/app/services/projects/hashed_storage/base_attachment_service.rb b/app/services/projects/hashed_storage/base_attachment_service.rb index 828ab616babeef0a0db85ac78c91fa5fddb04884..f8852c206e30ecf5eb8d0497de9d4fdfcaff4b3d 100644 --- a/app/services/projects/hashed_storage/base_attachment_service.rb +++ b/app/services/projects/hashed_storage/base_attachment_service.rb @@ -16,6 +16,12 @@ module Projects # Returns the logger currently in use attr_reader :logger + def initialize(project:, old_disk_path:, logger: nil) + @project = project + @old_disk_path = old_disk_path + @logger = logger || Rails.logger # rubocop:disable Gitlab/RailsLogger + end + # Return whether this operation was skipped or not # # @return [Boolean] true if skipped of false otherwise @@ -23,6 +29,14 @@ module Projects @skipped end + # Check if target path has discardable content + # + # @param [String] new_path + # @return [Boolean] whether we can discard the target path or not + def target_path_discardable?(new_path) + false + end + protected def move_folder!(old_path, new_path) @@ -34,8 +48,13 @@ module Projects end if File.exist?(new_path) - logger.error("Cannot move attachments from '#{old_path}' to '#{new_path}', target path already exist (PROJECT_ID=#{project.id})") - raise AttachmentCannotMoveError, "Target path '#{new_path}' already exists" + if target_path_discardable?(new_path) + discard_path!(new_path) + else + logger.error("Cannot move attachments from '#{old_path}' to '#{new_path}', target path already exist (PROJECT_ID=#{project.id})") + + raise AttachmentCannotMoveError, "Target path '#{new_path}' already exists" + end end # Create base path folder on the new storage layout @@ -46,6 +65,16 @@ module Projects true end + + # Rename a path adding a suffix in order to prevent data-loss. + # + # @param [String] new_path + def discard_path!(new_path) + discarded_path = "#{new_path}-#{Time.now.utc.to_i}" + + logger.info("Moving existing empty attachments folder from '#{new_path}' to '#{discarded_path}', (PROJECT_ID=#{project.id})") + FileUtils.mv(new_path, discarded_path) + end end end end diff --git a/app/services/projects/hashed_storage/base_repository_service.rb b/app/services/projects/hashed_storage/base_repository_service.rb index b7e9d3e87917eeb951e5714cba184d97af73a0f3..8b1bcaf17b7882125729a9ce2eddf9a126530c92 100644 --- a/app/services/projects/hashed_storage/base_repository_service.rb +++ b/app/services/projects/hashed_storage/base_repository_service.rb @@ -10,7 +10,7 @@ module Projects attr_reader :old_disk_path, :new_disk_path, :old_wiki_disk_path, :old_storage_version, :logger, :move_wiki - def initialize(project, old_disk_path, logger: nil) + def initialize(project:, old_disk_path:, logger: nil) @project = project @logger = logger || Gitlab::AppLogger @old_disk_path = old_disk_path diff --git a/app/services/projects/hashed_storage/migrate_attachments_service.rb b/app/services/projects/hashed_storage/migrate_attachments_service.rb index 0cbff2831028c6410b9dc1e128a0fda3bfca4c40..3d9d03c4a95ea52829512ffac04630303e9d1828 100644 --- a/app/services/projects/hashed_storage/migrate_attachments_service.rb +++ b/app/services/projects/hashed_storage/migrate_attachments_service.rb @@ -3,18 +3,19 @@ module Projects module HashedStorage class MigrateAttachmentsService < BaseAttachmentService - def initialize(project, old_disk_path, logger: nil) - @project = project - @logger = logger || Rails.logger # rubocop:disable Gitlab/RailsLogger - @old_disk_path = old_disk_path + extend ::Gitlab::Utils::Override + + # List of paths that can be excluded while evaluation if a target can be discarded + DISCARDABLE_PATHS = %w(tmp tmp/cache tmp/work).freeze + + def initialize(project:, old_disk_path:, logger: nil) + super + @skipped = false end def execute - origin = FileUploader.absolute_base_dir(project) - # It's possible that old_disk_path does not match project.disk_path. - # For example, that happens when we rename a project - origin.sub!(/#{Regexp.escape(project.full_path)}\z/, old_disk_path) + origin = find_old_attachments_path(project) project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:attachments] target = FileUploader.absolute_base_dir(project) @@ -27,13 +28,38 @@ module Projects project.save!(validate: false) yield if block_given? - else - # Rollback changes - project.rollback! end result end + + override :target_path_discardable? + # Check if target path has discardable content + # + # @param [String] new_path + # @return [Boolean] whether we can discard the target path or not + def target_path_discardable?(new_path) + return false unless File.directory?(new_path) + + found = Dir.glob(File.join(new_path, '**', '**')) + + (found - discardable_paths(new_path)).empty? + end + + private + + def discardable_paths(new_path) + DISCARDABLE_PATHS.collect { |path| File.join(new_path, path) } + end + + def find_old_attachments_path(project) + origin = FileUploader.absolute_base_dir(project) + + # It's possible that old_disk_path does not match project.disk_path. + # For example, that happens when we rename a project + # + origin.sub(/#{Regexp.escape(project.full_path)}\z/, old_disk_path) + end end end end diff --git a/app/services/projects/hashed_storage/migration_service.rb b/app/services/projects/hashed_storage/migration_service.rb index f132dca61c93d052000d8440bdceb794a4acf8d3..57a775a8f9e800fa31c1e3aa56263cd2d74ff92b 100644 --- a/app/services/projects/hashed_storage/migration_service.rb +++ b/app/services/projects/hashed_storage/migration_service.rb @@ -14,12 +14,12 @@ module Projects def execute # Migrate repository from Legacy to Hashed Storage unless project.hashed_storage?(:repository) - return false unless migrate_repository + return false unless migrate_repository_service.execute end # Migrate attachments from Legacy to Hashed Storage unless project.hashed_storage?(:attachments) - return false unless migrate_attachments + return false unless migrate_attachments_service.execute end true @@ -27,12 +27,12 @@ module Projects private - def migrate_repository - HashedStorage::MigrateRepositoryService.new(project, old_disk_path, logger: logger).execute + def migrate_repository_service + HashedStorage::MigrateRepositoryService.new(project: project, old_disk_path: old_disk_path, logger: logger) end - def migrate_attachments - HashedStorage::MigrateAttachmentsService.new(project, old_disk_path, logger: logger).execute + def migrate_attachments_service + HashedStorage::MigrateAttachmentsService.new(project: project, old_disk_path: old_disk_path, logger: logger) end end end diff --git a/app/services/projects/hashed_storage/rollback_attachments_service.rb b/app/services/projects/hashed_storage/rollback_attachments_service.rb index fb09eaa4586a99431f75719abc20fef324345a40..4bb8cb605a37eed1269c44af81f4f06bc94c5493 100644 --- a/app/services/projects/hashed_storage/rollback_attachments_service.rb +++ b/app/services/projects/hashed_storage/rollback_attachments_service.rb @@ -3,14 +3,9 @@ module Projects module HashedStorage class RollbackAttachmentsService < BaseAttachmentService - def initialize(project, logger: nil) - @project = project - @logger = logger || Rails.logger # rubocop:disable Gitlab/RailsLogger - @old_disk_path = project.disk_path - end - def execute origin = FileUploader.absolute_base_dir(project) + project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository] target = FileUploader.absolute_base_dir(project) diff --git a/app/services/projects/hashed_storage/rollback_service.rb b/app/services/projects/hashed_storage/rollback_service.rb index ee41aae64a5b35755a3340c91fa3cee2aaffa046..c437001c44015a77e52f17fd915debf6b57225e2 100644 --- a/app/services/projects/hashed_storage/rollback_service.rb +++ b/app/services/projects/hashed_storage/rollback_service.rb @@ -5,32 +5,26 @@ module Projects class RollbackService < BaseService attr_reader :logger, :old_disk_path - def initialize(project, old_disk_path, logger: nil) - @project = project - @old_disk_path = old_disk_path - @logger = logger || Rails.logger # rubocop:disable Gitlab/RailsLogger - end - def execute # Rollback attachments from Hashed Storage to Legacy if project.hashed_storage?(:attachments) - return false unless rollback_attachments + return false unless rollback_attachments_service.execute end # Rollback repository from Hashed Storage to Legacy if project.hashed_storage?(:repository) - rollback_repository + rollback_repository_service.execute end end private - def rollback_attachments - HashedStorage::RollbackAttachmentsService.new(project, logger: logger).execute + def rollback_attachments_service + HashedStorage::RollbackAttachmentsService.new(project: project, old_disk_path: old_disk_path, logger: logger) end - def rollback_repository - HashedStorage::RollbackRepositoryService.new(project, old_disk_path, logger: logger).execute + def rollback_repository_service + HashedStorage::RollbackRepositoryService.new(project: project, old_disk_path: old_disk_path, logger: logger) end end end diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb index d3638c57552b78f729d441e8f079d4ba0d2ae84e..8344397f67d1658246b6eb600aa09b9a5f9130b2 100644 --- a/app/services/projects/import_export/export_service.rb +++ b/app/services/projects/import_export/export_service.rb @@ -24,7 +24,7 @@ module Projects def save_all! if save_exporters - Gitlab::ImportExport::Saver.save(project: project, shared: shared) + Gitlab::ImportExport::Saver.save(exportable: project, shared: shared) notify_success else cleanup_and_notify_error! diff --git a/app/services/projects/lfs_pointers/lfs_link_service.rb b/app/services/projects/lfs_pointers/lfs_link_service.rb index 38de2af9c1e91c030ea2f2f77174c0b470aad008..a05c76f5e854085ec17e72a39accee8469327659 100644 --- a/app/services/projects/lfs_pointers/lfs_link_service.rb +++ b/app/services/projects/lfs_pointers/lfs_link_service.rb @@ -4,6 +4,9 @@ module Projects module LfsPointers class LfsLinkService < BaseService + TooManyOidsError = Class.new(StandardError) + + MAX_OIDS = 100_000 BATCH_SIZE = 1000 # Accept an array of oids to link @@ -12,6 +15,10 @@ module Projects def execute(oids) return [] unless project&.lfs_enabled? + if oids.size > MAX_OIDS + raise TooManyOidsError, 'Too many LFS object ids to link, please push them manually' + end + # Search and link existing LFS Object link_existing_lfs_objects(oids) end @@ -20,22 +27,27 @@ module Projects # rubocop: disable CodeReuse/ActiveRecord def link_existing_lfs_objects(oids) - all_existing_objects = [] + linked_existing_objects = [] iterations = 0 - LfsObject.where(oid: oids).each_batch(of: BATCH_SIZE) do |existent_lfs_objects| + oids.each_slice(BATCH_SIZE) do |oids_batch| + # Load all existing LFS Objects immediately so we don't issue an extra + # query for the `.any?` + existent_lfs_objects = LfsObject.where(oid: oids_batch).load next unless existent_lfs_objects.any? + rows = existent_lfs_objects + .not_linked_to_project(project) + .map { |existing_lfs_object| { project_id: project.id, lfs_object_id: existing_lfs_object.id } } + Gitlab::Database.bulk_insert(:lfs_objects_projects, rows) iterations += 1 - not_linked_lfs_objects = existent_lfs_objects.where.not(id: project.all_lfs_objects) - project.all_lfs_objects << not_linked_lfs_objects - all_existing_objects += existent_lfs_objects.pluck(:oid) + linked_existing_objects += existent_lfs_objects.map(&:oid) end - log_lfs_link_results(all_existing_objects.count, iterations) + log_lfs_link_results(linked_existing_objects.count, iterations) - all_existing_objects + linked_existing_objects end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 525fc18b312c6ca93b7a64b1d9200fa86ab82a25..718416a03d40ce2537fd8b9502f7dcd138a70297 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -13,6 +13,8 @@ module Projects include Gitlab::ShellAdapter TransferError = Class.new(StandardError) + attr_reader :new_namespace + def execute(new_namespace) @new_namespace = new_namespace diff --git a/app/services/quick_actions/target_service.rb b/app/services/quick_actions/target_service.rb index 69464c3c1ae54693ba77269174acc027ce65ce9d..4273acfbf8b7abe0bc1085677227674fe98b309f 100644 --- a/app/services/quick_actions/target_service.rb +++ b/app/services/quick_actions/target_service.rb @@ -32,3 +32,5 @@ module QuickActions end end end + +QuickActions::TargetService.prepend_if_ee('EE::QuickActions::TargetService') diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index b3eee01ea7a44b6f6d232db8ff2fc1fd26fbe111..25e3282d3fbc5d180e0d4c8d76da28ff1965b032 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -128,82 +128,37 @@ module SystemNoteService # Called when 'merge when pipeline succeeds' is executed def merge_when_pipeline_succeeds(noteable, project, author, sha) - body = "enabled an automatic merge when the pipeline for #{sha} succeeds" - - create_note(NoteSummary.new(noteable, project, author, body, action: 'merge')) + ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).merge_when_pipeline_succeeds(sha) end # Called when 'merge when pipeline succeeds' is canceled def cancel_merge_when_pipeline_succeeds(noteable, project, author) - body = 'canceled the automatic merge' - - create_note(NoteSummary.new(noteable, project, author, body, action: 'merge')) + ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).cancel_merge_when_pipeline_succeeds end # Called when 'merge when pipeline succeeds' is aborted def abort_merge_when_pipeline_succeeds(noteable, project, author, reason) - body = "aborted the automatic merge because #{reason}" - - ## - # TODO: Abort message should be sent by the system, not a particular user. - # See https://gitlab.com/gitlab-org/gitlab-foss/issues/63187. - create_note(NoteSummary.new(noteable, project, author, body, action: 'merge')) + ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).abort_merge_when_pipeline_succeeds(reason) end def handle_merge_request_wip(noteable, project, author) - prefix = noteable.work_in_progress? ? "marked" : "unmarked" - - body = "#{prefix} as a **Work In Progress**" - - create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) + ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).handle_merge_request_wip end def add_merge_request_wip_from_commit(noteable, project, author, commit) - body = "marked as a **Work In Progress** from #{commit.to_reference(project)}" - - create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) + ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).add_merge_request_wip_from_commit(commit) end def resolve_all_discussions(merge_request, project, author) - body = "resolved all threads" - - create_note(NoteSummary.new(merge_request, project, author, body, action: 'discussion')) + ::SystemNotes::MergeRequestsService.new(noteable: merge_request, project: project, author: author).resolve_all_discussions end def discussion_continued_in_issue(discussion, project, author, issue) - body = "created #{issue.to_reference} to continue this discussion" - note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body) - - note = Note.create(note_attributes.merge(system: true, created_at: issue.system_note_timestamp)) - note.system_note_metadata = SystemNoteMetadata.new(action: 'discussion') - - note + ::SystemNotes::MergeRequestsService.new(project: project, author: author).discussion_continued_in_issue(discussion, issue) end def diff_discussion_outdated(discussion, project, author, change_position) - merge_request = discussion.noteable - diff_refs = change_position.diff_refs - version_index = merge_request.merge_request_diffs.viewable.count - position_on_text = change_position.on_text? - text_parts = ["changed this #{position_on_text ? 'line' : 'file'} in"] - - if version_params = merge_request.version_params_for(diff_refs) - repository = project.repository - anchor = position_on_text ? change_position.line_code(repository) : change_position.file_hash - url = url_helpers.diffs_project_merge_request_path(project, merge_request, version_params.merge(anchor: anchor)) - - text_parts << "[version #{version_index} of the diff](#{url})" - else - text_parts << "version #{version_index} of the diff" - end - - body = text_parts.join(' ') - note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body) - - note = Note.create(note_attributes.merge(system: true)) - note.system_note_metadata = SystemNoteMetadata.new(action: 'outdated') - - note + ::SystemNotes::MergeRequestsService.new(project: project, author: author).diff_discussion_outdated(discussion, change_position) end def change_title(noteable, project, author, old_title) @@ -233,9 +188,7 @@ module SystemNoteService # # Returns the created Note object def change_branch(noteable, project, author, branch_type, old_branch, new_branch) - body = "changed #{branch_type} branch from `#{old_branch}` to `#{new_branch}`" - - create_note(NoteSummary.new(noteable, project, author, body, action: 'branch')) + ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).change_branch(branch_type, old_branch, new_branch) end # Called when a branch in Noteable is added or deleted @@ -253,16 +206,7 @@ module SystemNoteService # # Returns the created Note object def change_branch_presence(noteable, project, author, branch_type, branch, presence) - verb = - if presence == :add - 'restored' - else - 'deleted' - end - - body = "#{verb} #{branch_type} branch `#{branch}`" - - create_note(NoteSummary.new(noteable, project, author, body, action: 'branch')) + ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).change_branch_presence(branch_type, branch, presence) end # Called when a branch is created from the 'new branch' button on a issue @@ -270,18 +214,11 @@ module SystemNoteService # # "created branch `201-issue-branch-button`" def new_issue_branch(issue, project, author, branch, branch_project: nil) - branch_project ||= project - link = url_helpers.project_compare_path(branch_project, from: branch_project.default_branch, to: branch) - - body = "created branch [`#{branch}`](#{link}) to address this issue" - - create_note(NoteSummary.new(issue, project, author, body, action: 'branch')) + ::SystemNotes::MergeRequestsService.new(noteable: issue, project: project, author: author).new_issue_branch(branch, branch_project: branch_project) end def new_merge_request(issue, project, author, merge_request) - body = "created merge request #{merge_request.to_reference(project)} to address this issue" - - create_note(NoteSummary.new(issue, project, author, body, action: 'merge')) + ::SystemNotes::MergeRequestsService.new(noteable: issue, project: project, author: author).new_merge_request(merge_request) end def cross_reference(noteable, mentioner, author) diff --git a/app/services/system_notes/merge_requests_service.rb b/app/services/system_notes/merge_requests_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..1d17f0ded5775c0a65ec9058a5493d7ef9d9ab91 --- /dev/null +++ b/app/services/system_notes/merge_requests_service.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +module SystemNotes + class MergeRequestsService < ::SystemNotes::BaseService + # Called when 'merge when pipeline succeeds' is executed + def merge_when_pipeline_succeeds(sha) + body = "enabled an automatic merge when the pipeline for #{sha} succeeds" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'merge')) + end + + # Called when 'merge when pipeline succeeds' is canceled + def cancel_merge_when_pipeline_succeeds + body = 'canceled the automatic merge' + + create_note(NoteSummary.new(noteable, project, author, body, action: 'merge')) + end + + # Called when 'merge when pipeline succeeds' is aborted + def abort_merge_when_pipeline_succeeds(reason) + body = "aborted the automatic merge because #{reason}" + + ## + # TODO: Abort message should be sent by the system, not a particular user. + # See https://gitlab.com/gitlab-org/gitlab-foss/issues/63187. + create_note(NoteSummary.new(noteable, project, author, body, action: 'merge')) + end + + def handle_merge_request_wip + prefix = noteable.work_in_progress? ? "marked" : "unmarked" + + body = "#{prefix} as a **Work In Progress**" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) + end + + def add_merge_request_wip_from_commit(commit) + body = "marked as a **Work In Progress** from #{commit.to_reference(project)}" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) + end + + def resolve_all_discussions + body = "resolved all threads" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'discussion')) + end + + def discussion_continued_in_issue(discussion, issue) + body = "created #{issue.to_reference} to continue this discussion" + note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body) + + Note.create(note_attributes.merge(system: true, created_at: issue.system_note_timestamp)).tap do |note| + note.system_note_metadata = SystemNoteMetadata.new(action: 'discussion') + end + end + + def diff_discussion_outdated(discussion, change_position) + merge_request = discussion.noteable + diff_refs = change_position.diff_refs + version_index = merge_request.merge_request_diffs.viewable.count + position_on_text = change_position.on_text? + text_parts = ["changed this #{position_on_text ? 'line' : 'file'} in"] + + if version_params = merge_request.version_params_for(diff_refs) + repository = project.repository + anchor = position_on_text ? change_position.line_code(repository) : change_position.file_hash + url = url_helpers.diffs_project_merge_request_path(project, merge_request, version_params.merge(anchor: anchor)) + + text_parts << "[version #{version_index} of the diff](#{url})" + else + text_parts << "version #{version_index} of the diff" + end + + body = text_parts.join(' ') + note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body) + + Note.create(note_attributes.merge(system: true)).tap do |note| + note.system_note_metadata = SystemNoteMetadata.new(action: 'outdated') + end + end + + # Called when a branch in Noteable is changed + # + # branch_type - 'source' or 'target' + # old_branch - old branch name + # new_branch - new branch name + # + # Example Note text: + # + # "changed target branch from `Old` to `New`" + # + # Returns the created Note object + def change_branch(branch_type, old_branch, new_branch) + body = "changed #{branch_type} branch from `#{old_branch}` to `#{new_branch}`" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'branch')) + end + + # Called when a branch in Noteable is added or deleted + # + # branch_type - :source or :target + # branch - branch name + # presence - :add or :delete + # + # Example Note text: + # + # "restored target branch `feature`" + # + # Returns the created Note object + def change_branch_presence(branch_type, branch, presence) + verb = + if presence == :add + 'restored' + else + 'deleted' + end + + body = "#{verb} #{branch_type} branch `#{branch}`" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'branch')) + end + + # Called when a branch is created from the 'new branch' button on a issue + # Example note text: + # + # "created branch `201-issue-branch-button`" + def new_issue_branch(branch, branch_project: nil) + branch_project ||= project + link = url_helpers.project_compare_path(branch_project, from: branch_project.default_branch, to: branch) + + body = "created branch [`#{branch}`](#{link}) to address this issue" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'branch')) + end + + def new_merge_request(merge_request) + body = "created merge request #{merge_request.to_reference(project)} to address this issue" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'merge')) + end + end +end + +SystemNotes::MergeRequestsService.prepend_if_ee('::EE::SystemNotes::MergeRequestsService') diff --git a/app/services/users/signup_service.rb b/app/services/users/signup_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..1031cec44cb1d3ba8b2fa564be53fb208c18ba70 --- /dev/null +++ b/app/services/users/signup_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Users + class SignupService < BaseService + def initialize(current_user, params = {}) + @user = current_user + @params = params.dup + end + + def execute + assign_attributes + inject_validators + + if @user.save + success + else + error(@user.errors.full_messages.join('. ')) + end + end + + private + + def assign_attributes + @user.assign_attributes(params) unless params.empty? + end + + def inject_validators + class << @user + validates :role, presence: true + validates :setup_for_company, inclusion: { in: [true, false], message: :blank } + end + end + end +end diff --git a/app/services/zoom_notes_service.rb b/app/services/zoom_notes_service.rb deleted file mode 100644 index 983a7fcacd185ba2423224f04b4ac3702dc1b148..0000000000000000000000000000000000000000 --- a/app/services/zoom_notes_service.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -class ZoomNotesService - def initialize(issue, project, current_user, old_description: nil) - @issue = issue - @project = project - @current_user = current_user - @old_description = old_description - end - - def execute - return if @issue.description == @old_description - - if zoom_link_added? - zoom_link_added_notification - elsif zoom_link_removed? - zoom_link_removed_notification - end - end - - private - - def zoom_link_added? - has_zoom_link?(@issue.description) && !has_zoom_link?(@old_description) - end - - def zoom_link_removed? - !has_zoom_link?(@issue.description) && has_zoom_link?(@old_description) - end - - def has_zoom_link?(text) - Gitlab::ZoomLinkExtractor.new(text).match? - end - - def zoom_link_added_notification - SystemNoteService.zoom_link_added(@issue, @project, @current_user) - end - - def zoom_link_removed_notification - SystemNoteService.zoom_link_removed(@issue, @project, @current_user) - end -end diff --git a/app/validators/same_project_association_validator.rb b/app/validators/same_project_association_validator.rb new file mode 100644 index 0000000000000000000000000000000000000000..2af2a21fa9ae2a75c6b1e6615666db29bb5f1d15 --- /dev/null +++ b/app/validators/same_project_association_validator.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# SameProjectAssociationValidator +# +# Custom validator to validate that the same project associated with +# the record is also associated with the value +# +# Example: +# class ZoomMeeting < ApplicationRecord +# belongs_to :project, optional: false +# belongs_to :issue, optional: false + +# validates :issue, same_project_association: true +# end +class SameProjectAssociationValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + return if record.project == value&.project + + record.errors[attribute] << 'must associate the same project' + end +end diff --git a/app/validators/zoom_url_validator.rb b/app/validators/zoom_url_validator.rb new file mode 100644 index 0000000000000000000000000000000000000000..dc4ca6b95015dfb36c82900303a86034c3ebdbf9 --- /dev/null +++ b/app/validators/zoom_url_validator.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# ZoomUrlValidator +# +# Custom validator for zoom urls +# +class ZoomUrlValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + return if Gitlab::ZoomLinkExtractor.new(value).links.size == 1 + + record.errors.add(:url, 'must contain one valid Zoom URL') + end +end diff --git a/app/views/admin/abuse_reports/index.html.haml b/app/views/admin/abuse_reports/index.html.haml index cc29657a439893448d6a7040e32503a3a19223b6..e3d78b3058f0153480525abe81fd00e151b28119 100644 --- a/app/views/admin/abuse_reports/index.html.haml +++ b/app/views/admin/abuse_reports/index.html.haml @@ -1,6 +1,18 @@ -- page_title 'Abuse Reports' -%h3.page-title Abuse Reports -%hr +- page_title _('Abuse Reports') + +%h3.page-title= _('Abuse Reports') + +.row-content-block.second-block + = form_tag admin_abuse_reports_path, method: :get, class: 'filter-form' do + .filter-categories.flex-fill + .filter-item.inline + = dropdown_tag(user_dropdown_label(params[:user_id], 'User'), + options: { toggle_class: 'js-filter-submit js-user-search', + title: _('Filter by user'), filter: true, filterInput: 'input#user-search', + dropdown_class: 'dropdown-menu-selectable dropdown-menu-user js-filter-submit', + placeholder: _('Search users'), + data: { current_user: true, field_name: 'user_id' }}) + .abuse-reports - if @abuse_reports.present? .table-holder diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index 1f5bce19bc6769e4215284ddd17434dc76975476..9806090c1a649236ce2f40a40daa70c5dadf1f4e 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -53,5 +53,11 @@ = s_('AdminSettings|Environment variables are protected by default') .form-text.text-muted = s_('AdminSettings|When creating a new environment variable it will be protected by default.') + .form-group + = f.label :ci_config_path, _('Default CI configuration path'), class: 'label-bold' + = f.text_field :default_ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml' + %p.form-text.text-muted + = _("The default CI configuration path for new projects.").html_safe + = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'custom-ci-config-path'), target: '_blank' = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..b1f7ed762814bc44d4c085ab699be1736031329e --- /dev/null +++ b/app/views/admin/application_settings/_eks.html.haml @@ -0,0 +1,31 @@ +- expanded = integration_expanded?('eks_') +%section.settings.as-eks.no-animate#js-eks-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Amazon EKS') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Amazon EKS integration allows you to provision EKS clusters from GitLab.') + + .settings-content + = form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-eks-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .form-check + = f.check_box :eks_integration_enabled, class: 'form-check-input' + = f.label :eks_integration_enabled, class: 'form-check-label' do + Enable Amazon EKS integration + .form-group + = f.label :eks_account_id, 'Account ID', class: 'label-bold' + = f.text_field :eks_account_id, class: 'form-control' + .form-group + = f.label :eks_access_key_id, 'Access key ID', class: 'label-bold' + = f.text_field :eks_access_key_id, class: 'form-control' + .form-group + = f.label :eks_secret_access_key, 'Secret access key', class: 'label-bold' + = f.password_field :eks_secret_access_key, value: @application_setting.eks_secret_access_key, class: 'form-control' + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml index ad26f52aea79da9a7e43c390def9467da95e753a..42528f40123de34d32f0df09b24ca433da31a836 100644 --- a/app/views/admin/application_settings/_outbound.html.haml +++ b/app/views/admin/application_settings/_outbound.html.haml @@ -4,7 +4,7 @@ %fieldset .form-group .form-check - = f.check_box :allow_local_requests_from_web_hooks_and_services, class: 'form-check-input' + = f.check_box :allow_local_requests_from_web_hooks_and_services, class: 'form-check-input', data: { qa_selector: 'allow_requests_from_services_checkbox' } = f.label :allow_local_requests_from_web_hooks_and_services, class: 'form-check-label' do = _('Allow requests to the local network from web hooks and services') .form-check @@ -27,4 +27,4 @@ %span.form-text.text-muted = _('Resolves IP addresses once and uses them to submit requests') - = f.submit 'Save changes', class: "btn btn-success" + = f.submit 'Save changes', class: "btn btn-success", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml index 86dc289dd7c6087b68865ce95aa8e6442e132f2a..d35774d330d1daee87bd7884c7f358616b4f1724 100644 --- a/app/views/admin/application_settings/_plantuml.html.haml +++ b/app/views/admin/application_settings/_plantuml.html.haml @@ -1,18 +1,27 @@ -= form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-plantuml-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting) +- expanded = integration_expanded?('plantuml_') +%section.settings.as-plantuml.no-animate#js-plantuml-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('PlantUML') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = _('Allow rendering of PlantUML diagrams in Asciidoc documents.') + .settings-content + = form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-plantuml-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) if expanded - %fieldset - .form-group - .form-check - = f.check_box :plantuml_enabled, class: 'form-check-input' - = f.label :plantuml_enabled, class: 'form-check-label' do - Enable PlantUML - .form-group - = f.label :plantuml_url, 'PlantUML URL', class: 'label-bold' - = f.text_field :plantuml_url, class: 'form-control', placeholder: 'http://gitlab.your-plantuml-instance.com:8080' - .form-text.text-muted - Allow rendering of - = link_to "PlantUML", "http://plantuml.com" - diagrams in Asciidoc documents using an external PlantUML service. + %fieldset + .form-group + .form-check + = f.check_box :plantuml_enabled, class: 'form-check-input' + = f.label :plantuml_enabled, _('Enable PlantUML'), class: 'form-check-label' + .form-group + = f.label :plantuml_url, 'PlantUML URL', class: 'label-bold' + = f.text_field :plantuml_url, class: 'form-control', placeholder: 'http://gitlab.your-plantuml-instance.com:8080' + .form-text.text-muted + Allow rendering of + = link_to "PlantUML", "http://plantuml.com" + diagrams in Asciidoc documents using an external PlantUML service. - = f.submit 'Save changes', class: "btn btn-success" + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml index 362f4a42464cab02cefecceabb0e070854522f78..6e5fa6eb62ca8e98b98e346356b5933cd29e7b47 100644 --- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml +++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml @@ -7,9 +7,9 @@ .form-check = f.check_box :mirror_available, class: 'form-check-input' = f.label :mirror_available, class: 'form-check-label' do - = _('Allow mirrors to be set up for projects') + = _('Allow repository mirroring to be configured by project maintainers') %span.form-text.text-muted - = _('If disabled, only admins will be able to set up mirrors in projects.') + = _('If disabled, only admins will be able to configure repository mirroring.') = link_to icon('question-circle'), help_page_path('workflow/repository_mirroring') = render_if_exists 'admin/application_settings/mirror_settings', form: f diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml index 31fd12d191e76c3316a496de118de602b5537b9b..a2597433270791f4b9a2fec3a79608607d99349d 100644 --- a/app/views/admin/application_settings/_snowplow.html.haml +++ b/app/views/admin/application_settings/_snowplow.html.haml @@ -1,4 +1,4 @@ -- expanded = true if !@application_setting.valid? && @application_setting.errors.any? { |k| k.to_s.start_with?('snowplow_') } +- expanded = integration_expanded?('snowplow_') %section.settings.as-snowplow.no-animate#js-snowplow-settings{ class: ('expanded' if expanded) } .settings-header %h4 @@ -10,7 +10,7 @@ .settings-content = form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-snowplow-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting) + = form_errors(@application_setting) if expanded %fieldset .form-group @@ -21,10 +21,13 @@ = f.label :snowplow_collector_hostname, _('Collector hostname'), class: 'label-light' = f.text_field :snowplow_collector_hostname, class: 'form-control', placeholder: 'snowplow.example.com' .form-group - = f.label :snowplow_site_id, _('Site ID'), class: 'label-light' - = f.text_field :snowplow_site_id, class: 'form-control' + = f.label :snowplow_app_id, _('App ID'), class: 'label-light' + = f.text_field :snowplow_app_id, class: 'form-control' .form-group = f.label :snowplow_cookie_domain, _('Cookie domain'), class: 'label-light' = f.text_field :snowplow_cookie_domain, class: 'form-control' + .form-group + = f.label :snowplow_iglu_registry_url, _('Iglu registry URL (optional)'), class: 'label-light' + = f.text_field :snowplow_iglu_registry_url, class: 'form-control' = f.submit _('Save changes'), class: 'btn btn-success' diff --git a/app/views/admin/application_settings/_sourcegraph.html.haml b/app/views/admin/application_settings/_sourcegraph.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..23cda0334a2d596e7cb2078198371cc901138792 --- /dev/null +++ b/app/views/admin/application_settings/_sourcegraph.html.haml @@ -0,0 +1,38 @@ +- return unless Gitlab::Sourcegraph.feature_available? +- expanded = integration_expanded?('sourcegraph_') + +%section.settings.as-sourcegraph.no-animate#js-sourcegraph-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Sourcegraph') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://sourcegraph.com/' } + - link_end = "#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}</a>".html_safe + = s_('SourcegraphAdmin|Enable code intelligence powered by %{link_start}Sourcegraph%{link_end} on your GitLab instance\'s code views and merge requests.').html_safe % { link_start: link_start, link_end: link_end } + %span + = link_to s_('SourcegraphAdmin|More information'), help_page_path('integration/sourcegraph.md'), target: '_blank' + + + .settings-content + = form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-sourcegraph-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .form-check + = f.check_box :sourcegraph_enabled, class: 'form-check-input' + = f.label :sourcegraph_enabled, s_('SourcegraphAdmin|Enable Sourcegraph'), class: 'form-check-label' + .form-group + .form-check + = f.check_box :sourcegraph_public_only, class: 'form-check-input' + = f.label :sourcegraph_public_only, s_('SourcegraphAdmin|Block on private and internal projects'), class: 'form-check-label' + .form-text.text-muted + = s_('SourcegraphAdmin|If checked, only public projects will have code intelligence and communicate with Sourcegraph.') + .form-group + = f.label :sourcegraph_url, s_('SourcegraphAdmin|Sourcegraph URL'), class: 'label-bold' + = f.text_field :sourcegraph_url, class: 'form-control', placeholder: s_('SourcegraphAdmin|e.g. https://sourcegraph.example.com') + .form-text.text-muted + = s_('SourcegraphAdmin|Configure the URL to a Sourcegraph instance which can read your GitLab projects.') + = f.submit s_('SourcegraphAdmin|Save changes'), class: 'btn btn-success' diff --git a/app/views/admin/application_settings/_third_party_offers.html.haml b/app/views/admin/application_settings/_third_party_offers.html.haml index adde09f75e4f2d34495244051ef892ef9f711aa7..256b1f74bfaa22c31e5d16a42884a5e8ee00b10e 100644 --- a/app/views/admin/application_settings/_third_party_offers.html.haml +++ b/app/views/admin/application_settings/_third_party_offers.html.haml @@ -1,13 +1,21 @@ -- application_setting = local_assigns.fetch(:application_setting) +- expanded = integration_expanded?('hide_third_party_') +%section.settings.as-third-party-offers.no-animate#js-third-party-offers-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Third party offers') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = _('Control the display of third party offers.') + .settings-content -= form_for application_setting, url: integrations_admin_application_settings_path(anchor: 'js-third-party-offers-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(application_setting) + = form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-third-party-offers-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) if expanded - %fieldset - .form-group - .form-check - = f.check_box :hide_third_party_offers, class: 'form-check-input' - = f.label :hide_third_party_offers, class: 'form-check-label' do - Do not display offers from third parties within GitLab + %fieldset + .form-group + .form-check + = f.check_box :hide_third_party_offers, class: 'form-check-input' + = f.label :hide_third_party_offers, _('Do not display offers from third parties within GitLab'), class: 'form-check-label' - = f.submit 'Save changes', class: "btn btn-success" + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/integrations.html.haml b/app/views/admin/application_settings/integrations.html.haml index 310e86b1377556aac7d4cee46e6eafbcdf3145b7..0aa833e49a84f72341c46d8421ecc06f18888ad0 100644 --- a/app/views/admin/application_settings/integrations.html.haml +++ b/app/views/admin/application_settings/integrations.html.haml @@ -2,30 +2,11 @@ - page_title _("Integrations") - @content_class = "limit-container-width" unless fluid_layout -= render_if_exists 'admin/application_settings/elasticsearch_form', expanded: expanded_by_default? += render_if_exists 'admin/application_settings/elasticsearch_form' += render 'admin/application_settings/plantuml' += render 'admin/application_settings/sourcegraph' += render_if_exists 'admin/application_settings/slack' += render 'admin/application_settings/third_party_offers' += render 'admin/application_settings/snowplow' += render 'admin/application_settings/eks' if Feature.enabled?(:create_eks_clusters) -%section.settings.as-plantuml.no-animate#js-plantuml-settings{ class: ('expanded' if expanded_by_default?) } - .settings-header - %h4 - = _('PlantUML') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } - = expanded_by_default? ? _('Collapse') : _('Expand') - %p - = _('Allow rendering of PlantUML diagrams in Asciidoc documents.') - .settings-content - = render 'plantuml' - -= render_if_exists 'admin/application_settings/slack', expanded: expanded_by_default? - -%section.settings.as-third-party-offers.no-animate#js-third-party-offers-settings{ class: ('expanded' if expanded_by_default?) } - .settings-header - %h4 - = _('Third party offers') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } - = expanded_by_default? ? _('Collapse') : _('Expand') - %p - = _('Control the display of third party offers.') - .settings-content - = render 'third_party_offers', application_setting: @application_setting - -= render_if_exists 'admin/application_settings/snowplow', expanded: expanded_by_default? diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml index 092834b993c0b394a34872359ab8dd4100c4454a..7bd511721952349afdb141573834dcbfebbdcb69 100644 --- a/app/views/admin/application_settings/network.html.haml +++ b/app/views/admin/application_settings/network.html.haml @@ -24,7 +24,7 @@ .settings-content = render 'ip_limits' -%section.settings.as-outbound.no-animate#js-outbound-settings{ class: ('expanded' if expanded_by_default?) } +%section.settings.as-outbound.no-animate#js-outbound-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'outbound_requests_section' } } .settings-header %h4 = _('Outbound requests') diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml index 25f8b6541b5a522e81ce2c69e03c2e87b2fd59e4..b0934a9d9fb5cfb76181258a4ea2d02a36299fb1 100644 --- a/app/views/admin/application_settings/repository.html.haml +++ b/app/views/admin/application_settings/repository.html.haml @@ -5,11 +5,11 @@ %section.settings.as-mirror.no-animate#js-mirror-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 - = _('Repository mirror') + = _('Repository mirroring') %button.btn.js-settings-toggle{ type: 'button' } = expanded_by_default? ? 'Collapse' : 'Expand' %p - = _('Configure push mirrors.') + = _('Configure repository mirroring.') .settings-content = render partial: 'repository_mirrors_form' diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 41147950c40028cecd890500b1ccf213c4583bf8..e5a3c0df9bf488630ba4e66fd6a3f2d235f7b0cc 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -41,17 +41,38 @@ .info-well .well-segment.admin-well.admin-well-features %h4 Features - = feature_entry(_('Sign up'), href: admin_application_settings_path(anchor: 'js-signup-settings'), enabled: allow_signup?) - = feature_entry(_('LDAP'), enabled: Gitlab.config.ldap.enabled) - = feature_entry(_('Gravatar'), href: admin_application_settings_path(anchor: 'js-account-settings'), enabled: gravatar_enabled?) - = feature_entry(_('OmniAuth'), href: admin_application_settings_path(anchor: 'js-signin-settings'), enabled: Gitlab::Auth.omniauth_enabled?) - = feature_entry(_('Reply by email'), enabled: Gitlab::IncomingEmail.enabled?) + = feature_entry(_('Sign up'), + href: admin_application_settings_path(anchor: 'js-signup-settings'), + enabled: allow_signup?) + + = feature_entry(_('LDAP'), + enabled: Gitlab.config.ldap.enabled) + + = feature_entry(_('Gravatar'), + href: admin_application_settings_path(anchor: 'js-account-settings'), + enabled: gravatar_enabled?) + + = feature_entry(_('OmniAuth'), + href: admin_application_settings_path(anchor: 'js-signin-settings'), + enabled: Gitlab::Auth.omniauth_enabled?) + + = feature_entry(_('Reply by email'), + enabled: Gitlab::IncomingEmail.enabled?) = render_if_exists 'admin/dashboard/elastic_and_geo' - = feature_entry(_('Container Registry'), href: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), enabled: Gitlab.config.registry.enabled) - = feature_entry(_('Gitlab Pages'), href: help_instance_configuration_url, enabled: Gitlab.config.pages.enabled) - = feature_entry(_('Shared Runners'), href: admin_runners_path, enabled: Gitlab.config.gitlab_ci.shared_runners_enabled) + = feature_entry(_('Container Registry'), + href: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), + enabled: Gitlab.config.registry.enabled, + doc_href: help_page_path('user/packages/container_registry/index')) + + = feature_entry(_('Gitlab Pages'), + enabled: Gitlab.config.pages.enabled, + doc_href: help_instance_configuration_url) + + = feature_entry(_('Shared Runners'), + href: admin_runners_path, + enabled: Gitlab.config.gitlab_ci.shared_runners_enabled) .col-md-4 .info-well .well-segment.admin-well diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml index 55aea0296e7ed2984753f4169d7dd53c0ff7ec33..3d77a439d615970124823404e16ad0b03b77d2e0 100644 --- a/app/views/admin/sessions/_new_base.html.haml +++ b/app/views/admin/sessions/_new_base.html.haml @@ -4,4 +4,4 @@ = password_field_tag :password, nil, class: 'form-control', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' } .submit-container.move-submit-down - = submit_tag _('Enter admin mode'), class: 'btn btn-success', data: { qa_selector: 'sign_in_button' } + = submit_tag _('Enter Admin Mode'), class: 'btn btn-success', data: { qa_selector: 'sign_in_button' } diff --git a/app/views/admin/sessions/_signin_box.html.haml b/app/views/admin/sessions/_signin_box.html.haml index 69baa76060efc6de4b04634765738c6b82d0ed9a..1d19915d3c5f4a42f7c0d40f72d5d39c14656db2 100644 --- a/app/views/admin/sessions/_signin_box.html.haml +++ b/app/views/admin/sessions/_signin_box.html.haml @@ -1,4 +1,4 @@ -- if form_based_providers.any? +- if any_form_based_providers_enabled? - if password_authentication_enabled_for_web? .login-box.tab-pane{ id: 'login-pane', role: 'tabpanel' } diff --git a/app/views/admin/sessions/_tabs_normal.html.haml b/app/views/admin/sessions/_tabs_normal.html.haml index f5dedb5ad760ea64b9ba996dcff31eb8366669b4..20830051d316ed35215340d00f24ebf5c160d24c 100644 --- a/app/views/admin/sessions/_tabs_normal.html.haml +++ b/app/views/admin/sessions/_tabs_normal.html.haml @@ -1,3 +1,3 @@ %ul.nav-links.new-session-tabs.nav-tabs.nav{ role: 'tablist' } %li.nav-item{ role: 'presentation' } - %a.nav-link.active{ href: '#login-pane', data: { toggle: 'tab', qa_selector: 'sign_in_tab' }, role: 'tab' }= _('Enter admin mode') + %a.nav-link.active{ href: '#login-pane', data: { toggle: 'tab', qa_selector: 'sign_in_tab' }, role: 'tab' }= _('Enter Admin Mode') diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml index ee06b4a17415cf06f90d5a81191fbc18498ba312..73028e78ea5763e3c622341fd47c142b03d5631a 100644 --- a/app/views/admin/sessions/new.html.haml +++ b/app/views/admin/sessions/new.html.haml @@ -1,5 +1,5 @@ - @hide_breadcrumbs = true -- page_title _('Enter admin mode') +- page_title _('Enter Admin Mode') .row.justify-content-center .col-6.new-session-forms-container diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 706fa033c51e321c1064faa6e48a128f2b48a2bd..cd07fee8e59350d3c6119b05322cefe8dfe1d5c7 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -152,7 +152,7 @@ - email = " (#{@user.unconfirmed_email})" %p This user has an unconfirmed email address#{email}. You may force a confirmation. %br - = link_to 'Confirm user', confirm_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?' } + = link_to 'Confirm user', confirm_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?', qa_selector: 'confirm_user_button' } = render_if_exists 'admin/users/user_detail_note' diff --git a/app/views/ci/group_variables/_content.html.haml b/app/views/ci/group_variables/_content.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..db5f1021f57b36c0dc78b93701a0d5525497fdf8 --- /dev/null +++ b/app/views/ci/group_variables/_content.html.haml @@ -0,0 +1 @@ += _("These variables are configured in the parent group settings, and will be active in the current project in addition to the project variables.") diff --git a/app/views/ci/group_variables/_header.html.haml b/app/views/ci/group_variables/_header.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..71d123ec9f2cf595395428d63dc253506fb90b71 --- /dev/null +++ b/app/views/ci/group_variables/_header.html.haml @@ -0,0 +1,5 @@ +%h5 + = _('Group variables (inherited)') + +%p + = render "ci/group_variables/content" diff --git a/app/views/ci/group_variables/_index.html.haml b/app/views/ci/group_variables/_index.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..c350ba5caf7207d95266b93dbcefa81ba0941187 --- /dev/null +++ b/app/views/ci/group_variables/_index.html.haml @@ -0,0 +1,13 @@ +- variables = @project.group.self_and_ancestors.map(&:variables).flatten + +.row + .col-lg-12 + .group-variable-list + = render 'ci/group_variables/variable_header' + - variables.each do |variable| + .group-variable-row.d-flex.w-100.border-bottom.pt-2.pb-2 + .table-section.section-40.append-right-10.key + = variable.key + .table-section.section-40.append-right-10 + %a.group-origin-link{ href: group_settings_ci_cd_path(variable.group) } + = variable.group.name diff --git a/app/views/ci/group_variables/_variable_header.html.haml b/app/views/ci/group_variables/_variable_header.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..1a3168cf7819ace8bd78e1bf8c3dc40828ad6184 --- /dev/null +++ b/app/views/ci/group_variables/_variable_header.html.haml @@ -0,0 +1,5 @@ +.group-variable-keys.d-flex.w-100.align-items-center.pb-2.border-bottom + .bold.table-section.section-40.append-right-10 + = s_('Key') + .bold.table-section.section-40.append-right-10 + = s_('Origin') diff --git a/app/views/ci/variables/_header.html.haml b/app/views/ci/variables/_header.html.haml index dbfa0a9e5a1a2aba500c238b90a5375c734d94d9..ce4dd5a4877e8c7e54002793f08f343dad444eed 100644 --- a/app/views/ci/variables/_header.html.haml +++ b/app/views/ci/variables/_header.html.haml @@ -7,5 +7,5 @@ %button.btn.btn-default.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') -%p.append-bottom-0 +%p = render "ci/variables/content" diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index 94102b4dcd0b7dc3db3d33c2c01bc328c7a75a93..7ae5c48b93cdf6eb563fc7a8c7520cfeba5f6e80 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -24,3 +24,8 @@ = n_('Hide value', 'Hide values', @variables.size) - else = n_('Reveal value', 'Reveal values', @variables.size) + - if !@group && @project.group + .settings-header.border-top.prepend-top-20 + = render 'ci/group_variables/header' + .settings-content.pr-0 + = render 'ci/group_variables/index' diff --git a/app/views/ci/variables/_url_query_variable_row.html.haml b/app/views/ci/variables/_url_query_variable_row.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..6672a8e5ea099ce85614f271c8f5e05d2a44ef47 --- /dev/null +++ b/app/views/ci/variables/_url_query_variable_row.html.haml @@ -0,0 +1,28 @@ +- form_field = local_assigns.fetch(:form_field, nil) +- variable = local_assigns.fetch(:variable, nil) + +- key = variable[0] +- value = variable[1] +- variable_type = variable[2] || "env_var" + +- destroy_input_name = "#{form_field}[variables_attributes][][_destroy]" +- variable_type_input_name = "#{form_field}[variables_attributes][][variable_type]" +- key_input_name = "#{form_field}[variables_attributes][][key]" +- value_input_name = "#{form_field}[variables_attributes][][secret_value]" + +%li.js-row.ci-variable-row + .ci-variable-row-body.border-bottom + %input.js-ci-variable-input-destroy{ type: "hidden", name: destroy_input_name } + %select.js-ci-variable-input-variable-type.ci-variable-body-item.form-control.select-control.custom-select.table-section.section-15{ name: variable_type_input_name } + = options_for_select(ci_variable_type_options, variable_type) + %input.js-ci-variable-input-key.ci-variable-body-item.qa-ci-variable-input-key.form-control.table-section.section-15{ type: "text", + name: key_input_name, + value: key, + placeholder: s_('CiVariables|Input variable key') } + .ci-variable-body-item.gl-show-field-errors.table-section.section-15.border-top-0.p-0 + %textarea.js-ci-variable-input-value.js-secret-value.qa-ci-variable-input-value.form-control{ rows: 1, + name: value_input_name, + placeholder: s_('CiVariables|Input variable value') } + = value + %button.js-row-remove-button.ci-variable-row-remove-button.table-section.section-5.border-top-0{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') } + = icon('minus-circle') diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml index 8005dcbf65fb97de1158e8918e38217be5cfd4ae..493d7a0085495341b1d4daaff717c56eb62105a7 100644 --- a/app/views/clusters/clusters/_advanced_settings.html.haml +++ b/app/views/clusters/clusters/_advanced_settings.html.haml @@ -1,3 +1,9 @@ +- group_id = @cluster.group.id if @cluster.group_type? + +- if @cluster.project_type? + - group_id = @cluster.project.group.id if @cluster.project.group + - user_id = @cluster.project.namespace.owner_id unless group_id + - if can?(current_user, :admin_cluster, @cluster) - unless @cluster.provided_by_user? .append-bottom-20 @@ -7,6 +13,21 @@ - link_gke = link_to(s_('ClusterIntegration|Google Kubernetes Engine'), @cluster.gke_cluster_url, target: '_blank', rel: 'noopener noreferrer') = s_('ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke } + = form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'cluster_management_form' } do |field| + + %h5 + = s_('ClusterIntegration|Cluster management project (alpha)') + + .form-group + .form-text.text-muted + = project_select_tag('cluster[management_project_id]', class: 'hidden-filter-value', toggle_class: 'js-project-search js-project-filter js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit', + placeholder: _('Select project'), idAttribute: 'id', data: { order_by: 'last_activity_at', idattribute: 'id', simple_filter: true, allow_clear: true, include_groups: false, include_projects_in_subgroups: true, group_id: group_id, user_id: user_id }, value: @cluster.management_project_id) + .text-muted + = s_('ClusterIntegration|A cluster management project can be used to run deployment jobs with Kubernetes <code>cluster-admin</code> privileges.').html_safe + = link_to _('More information'), help_page_path('user/clusters/management_project.md'), target: '_blank' + .form-group + = field.submit _('Save changes'), class: 'btn btn-success qa-save-domain' + .sub-section.form-group %h4.text-danger = s_('ClusterIntegration|Remove Kubernetes cluster integration') diff --git a/app/views/clusters/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml index 4b4278075a6c844518e3a0bc4400443df25d5519..7d97aaccbcfb297960d3b90811b61589bedea1e2 100644 --- a/app/views/clusters/clusters/_banner.html.haml +++ b/app/views/clusters/clusters/_banner.html.haml @@ -1,10 +1,10 @@ .hidden.js-cluster-error.bs-callout.bs-callout-danger{ role: 'alert' } - = s_('ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine') + = s_('ClusterIntegration|Something went wrong while creating your Kubernetes cluster') %p.js-error-reason .hidden.js-cluster-creating.bs-callout.bs-callout-info{ role: 'alert' } %span.spinner.spinner-dark.spinner-sm{ 'aria-label': 'Loading' } - %span.prepend-left-4= s_('ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine...') + %span.prepend-left-4= s_('ClusterIntegration|Kubernetes cluster is being created...') .hidden.row.js-cluster-api-unreachable.bs-callout.bs-callout-warning{ role: 'alert' } .col-11 @@ -19,4 +19,4 @@ %button.js-close-banner.close.cluster-application-banner-close.h-100.m-0= "×" .hidden.js-cluster-success.bs-callout.bs-callout-success{ role: 'alert' } - = s_("ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine.") + = s_("ClusterIntegration|Kubernetes cluster was successfully created.") diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml index a9299af8d78681ec0a4482d1d128543ebc99d50f..617e5d1d5d322e1e23174b099c5a110cc2905dbb 100644 --- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml @@ -7,6 +7,6 @@ .gcp-signup-offer--copy %h4= s_('ClusterIntegration|Did you know?') %p= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link } - %a.btn.btn-default{ href: 'https://goo.gl/AaJzRW', target: '_blank', rel: 'noopener noreferrer' } + %a.btn.btn-default{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' } = s_("ClusterIntegration|Apply for credit") diff --git a/app/views/clusters/clusters/aws/_new.html.haml b/app/views/clusters/clusters/aws/_new.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..48467f88f5220e82d053f6cb3d21f171b33849ed --- /dev/null +++ b/app/views/clusters/clusters/aws/_new.html.haml @@ -0,0 +1,20 @@ +- if !Gitlab::CurrentSettings.eks_integration_enabled? + - documentation_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path("integration/amazon") } + = s_('Amazon authentication is not %{link_start}correctly configured%{link_end}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_start: documentation_link_start, link_end: '<a/>'.html_safe } +- else + .js-create-eks-cluster-form-container{ data: { 'gitlab-managed-cluster-help-path' => help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), + 'create-role-path' => clusterable.authorize_aws_role_path, + 'sign-out-path' => clusterable.revoke_aws_role_path, + 'create-cluster-path' => clusterable.create_aws_clusters_path, + 'get-roles-path' => clusterable.aws_api_proxy_path('roles'), + 'get-regions-path' => clusterable.aws_api_proxy_path('regions'), + 'get-key-pairs-path' => clusterable.aws_api_proxy_path('key_pairs'), + 'get-vpcs-path' => clusterable.aws_api_proxy_path('vpcs'), + 'get-subnets-path' => clusterable.aws_api_proxy_path('subnets'), + 'get-security-groups-path' => clusterable.aws_api_proxy_path('security_groups'), + 'get-instance-types-path' => clusterable.aws_api_proxy_path('instance_types'), + 'account-id' => Gitlab::CurrentSettings.eks_account_id, + 'external-id' => @aws_role.role_external_id, + 'kubernetes-integration-help-path' => help_page_path('user/project/clusters/index'), + 'external-link-icon' => icon('external-link'), + 'has-credentials' => @aws_role.role_arn.present?.to_s } } diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml index d4999798c1931989cb1aa794941639f03c8b05ef..56d46580b9e176173fca114f7d494ecaed2bc3d2 100644 --- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml +++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml @@ -1,8 +1,10 @@ - provider = local_assigns.fetch(:provider) - logo_path = local_assigns.fetch(:logo_path) - label = local_assigns.fetch(:label) +- last = local_assigns.fetch(:last, false) +- classes = ['btn btn-light btn-outline flex-fill d-inline-flex flex-column justify-content-center align-items-center', ('mr-3' unless last)] -= link_to clusterable.new_path(provider: provider), class: 'btn gl-button btn-outline flex-fill d-inline-flex flex-column mr-3 justify-content-center align-items-center' do - .svg-content= image_tag logo_path, alt: label, class: 'gl-w-13 gl-h-13' += link_to clusterable.new_path(provider: provider), class: classes do + .svg-content.p-2= image_tag logo_path, alt: label, class: 'gl-w-64 gl-h-64' %span = label diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml index 7a93a7604f5ad8012964bd6d1ef46168f1a4126e..91925f5f96f6d787c9d064e94fa3ae19c2ce31ad 100644 --- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml +++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml @@ -2,10 +2,10 @@ - eks_label = s_('ClusterIntegration|Amazon EKS') - create_cluster_label = s_('ClusterIntegration|Create cluster on') .d-flex.flex-column - %h5 + %h5.mb-3 = create_cluster_label .d-flex = render partial: 'clusters/clusters/cloud_providers/cloud_provider_button', - locals: { provider: 'eks', label: eks_label, logo_path: 'illustrations/logos/amazon_eks.svg' } + locals: { provider: 'aws', label: eks_label, logo_path: 'illustrations/logos/amazon_eks.svg' } = render partial: 'clusters/clusters/cloud_providers/cloud_provider_button', - locals: { provider: 'gke', label: gke_label, logo_path: 'illustrations/logos/google_gke.svg' } + locals: { provider: 'gcp', label: gke_label, logo_path: 'illustrations/logos/google_gke.svg', last: true } diff --git a/app/views/clusters/clusters/eks/_index.html.haml b/app/views/clusters/clusters/eks/_index.html.haml deleted file mode 100644 index db64698a7f2a67c85d5d85fcec07ca391093e3f2..0000000000000000000000000000000000000000 --- a/app/views/clusters/clusters/eks/_index.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -.js-create-eks-cluster-form-container{ data: { 'gitlab-managed-cluster-help-path' => help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), -'kubernetes-integration-help-path' => help_page_path('user/project/clusters/index') } } diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml index cca16ce7eda3a27f8b2a25dfd957e20ce8137505..95670a2ec8752bf55c231296378e141ae212f13e 100644 --- a/app/views/clusters/clusters/gcp/_form.html.haml +++ b/app/views/clusters/clusters/gcp/_form.html.haml @@ -64,12 +64,13 @@ %p.form-text.text-muted = s_('ClusterIntegration|Learn more about %{help_link_start_machine_type}machine types%{help_link_end} and %{help_link_start_pricing}pricing%{help_link_end}.').html_safe % { help_link_start_machine_type: help_link_start % { url: machine_type_link_url }, help_link_start_pricing: help_link_start % { url: pricing_link_url }, help_link_end: help_link_end } - .form-group - = provider_gcp_field.check_box :cloud_run, { label: s_('ClusterIntegration|Enable Cloud Run on GKE (beta)'), - label_class: 'label-bold' } - .form-text.text-muted - = s_('ClusterIntegration|Uses the Cloud Run, Istio, and HTTP Load Balancing addons for this cluster.') - = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'cloud-run-on-gke'), target: '_blank' + - if Feature.enabled?(:create_cloud_run_clusters, clusterable) + .form-group + = provider_gcp_field.check_box :cloud_run, { label: s_('ClusterIntegration|Enable Cloud Run on GKE (beta)'), + label_class: 'label-bold' } + .form-text.text-muted + = s_('ClusterIntegration|Uses the Cloud Run, Istio, and HTTP Load Balancing addons for this cluster.') + = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'cloud-run-on-gke'), target: '_blank' .form-group = field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'), diff --git a/app/views/clusters/clusters/gcp/_new.html.haml b/app/views/clusters/clusters/gcp/_new.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..3d47f4bf2c391e42de72fd7e24097bc1a9ca8bc1 --- /dev/null +++ b/app/views/clusters/clusters/gcp/_new.html.haml @@ -0,0 +1,7 @@ += render 'clusters/clusters/gcp/header' +- if @valid_gcp_token + = render 'clusters/clusters/gcp/form' +- elsif @authorize_url + = render 'clusters/clusters/gcp/signin_with_google_button' +- else + = render 'clusters/clusters/gcp/gcp_not_configured' diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml index 9bab3bf56aa5295ba9b0d92b776b60e76b6450d1..049010cadf4fd8d123c3feb0fd0645b45287b3cc 100644 --- a/app/views/clusters/clusters/index.html.haml +++ b/app/views/clusters/clusters/index.html.haml @@ -16,7 +16,7 @@ .bs-callout.bs-callout-info = s_('ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters.') %strong - = link_to _('More information'), help_page_path('user/group/clusters/', anchor: 'cluster-precedence') + = link_to _('More information'), help_page_path('user/group/clusters/index', anchor: 'cluster-precedence') .clusters-table.js-clusters-list .gl-responsive-table-row.table-row-header{ role: "row" } diff --git a/app/views/clusters/clusters/new.html.haml b/app/views/clusters/clusters/new.html.haml index 2c23426aaf90280ccd3ff16985df8a212c8f51a9..cb8cbe4e6f2640084c8fe6eeebdbacf03bea7c74 100644 --- a/app/views/clusters/clusters/new.html.haml +++ b/app/views/clusters/clusters/new.html.haml @@ -2,9 +2,6 @@ - page_title _('Kubernetes Cluster') - create_eks_enabled = Feature.enabled?(:create_eks_clusters) - active_tab = local_assigns.fetch(:active_tab, 'create') -- create_on_gke_tab_label = s_('ClusterIntegration|Create new Cluster on GKE') -- create_on_eks_tab_label = s_('ClusterIntegration|Create new Cluster on EKS') -- create_new_cluster_label = s_('ClusterIntegration|Create new Cluster') = javascript_include_tag 'https://apis.google.com/js/api.js' = render_gcp_signup_offer @@ -18,14 +15,9 @@ %a.nav-link{ href: '#create-cluster-pane', id: 'create-cluster-tab', class: active_when(active_tab == 'create'), data: { toggle: 'tab' }, role: 'tab' } %span - if create_eks_enabled - - if @gke_selected - = create_on_gke_tab_label - - elsif @eks_selected - = create_on_eks_tab_label - - else - = create_new_cluster_label + = create_new_cluster_label(provider: params[:provider]) - else - = create_on_gke_tab_label + = create_new_cluster_label(provider: 'gcp') %li.nav-item{ role: 'presentation' } %a.nav-link{ href: '#add-cluster-pane', id: 'add-cluster-tab', class: active_when(active_tab == 'add'), data: { toggle: 'tab' }, role: 'tab' } %span Add existing cluster @@ -33,27 +25,10 @@ .tab-content.gitlab-tab-content - if create_eks_enabled .tab-pane{ id: 'create-cluster-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' } - - if @gke_selected - = render 'clusters/clusters/gcp/header' - - if @valid_gcp_token - = render 'clusters/clusters/gcp/form' - - elsif @authorize_url - = render 'clusters/clusters/gcp/signin_with_google_button' - - else - = render 'clusters/clusters/gcp/gcp_not_configured' - - elsif @eks_selected - = render 'clusters/clusters/eks/index' - - else - = render 'clusters/clusters/cloud_providers/cloud_provider_selector' + = render new_cluster_partial(provider: params[:provider]) - else .tab-pane{ id: 'create-cluster-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' } - = render 'clusters/clusters/gcp/header' - - if @valid_gcp_token - = render 'clusters/clusters/gcp/form' - - elsif @authorize_url - = render 'clusters/clusters/gcp/signin_with_google_button' - - else - = render 'clusters/clusters/gcp/gcp_not_configured' + = render new_cluster_partial(provider: 'gcp') .tab-pane{ id: 'add-cluster-pane', class: active_when(active_tab == 'add'), role: 'tabpanel' } = render 'clusters/clusters/user/header' diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 31d5f592d752fe205bfc102b62b893ba05df5ed0..5beeaf7259a83f3f46fcae7c4da48950d3549ac0 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -12,11 +12,13 @@ install_helm_path: clusterable.install_applications_cluster_path(@cluster, :helm), install_ingress_path: clusterable.install_applications_cluster_path(@cluster, :ingress), install_cert_manager_path: clusterable.install_applications_cluster_path(@cluster, :cert_manager), + install_crossplane_path: clusterable.install_applications_cluster_path(@cluster, :crossplane), install_prometheus_path: clusterable.install_applications_cluster_path(@cluster, :prometheus), install_runner_path: clusterable.install_applications_cluster_path(@cluster, :runner), install_jupyter_path: clusterable.install_applications_cluster_path(@cluster, :jupyter), install_knative_path: clusterable.install_applications_cluster_path(@cluster, :knative), update_knative_path: clusterable.update_applications_cluster_path(@cluster, :knative), + install_elastic_stack_path: clusterable.install_applications_cluster_path(@cluster, :elastic_stack), cluster_environments_path: cluster_environments_path, toggle_status: @cluster.enabled? ? 'true': 'false', has_rbac: has_rbac_enabled?(@cluster) ? 'true': 'false', diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml index a6acf948ed46edb89a78fce1ee01fbc6764aa9e3..39b6d74d9f926297cc59026389b1ed8ba7d51d3b 100644 --- a/app/views/clusters/clusters/user/_form.html.haml +++ b/app/views/clusters/clusters/user/_form.html.haml @@ -1,7 +1,7 @@ -- more_info_link = link_to _('More information'), help_page_path('user/project/clusters/index.md', - anchor: 'add-existing-kubernetes-cluster'), target: '_blank' -- rbac_help_link = link_to _('More information'), help_page_path('user/project/clusters/index.md', - anchor: 'role-based-access-control-rbac-core-only'), target: '_blank' +- more_info_link = link_to _('More information'), help_page_path('user/project/clusters/add_remove_clusters.md', + anchor: 'add-existing-cluster'), target: '_blank' +- rbac_help_link = link_to _('More information'), help_page_path('user/project/clusters/add_remove_clusters.md', + anchor: 'access-controls'), target: '_blank' - api_url_help_text = s_('ClusterIntegration|The URL used to access the Kubernetes API.') - ca_cert_help_text = s_('ClusterIntegration|The Kubernetes certificate used to authenticate to the cluster.') diff --git a/app/views/clusters/clusters/user/_header.html.haml b/app/views/clusters/clusters/user/_header.html.haml index 3b9ceaa2b8a04cec35b32e27881a57dd0778acc5..b0a24ee464f76716b823ad2117e48600fdc41e5b 100644 --- a/app/views/clusters/clusters/user/_header.html.haml +++ b/app/views/clusters/clusters/user/_header.html.haml @@ -1,5 +1,5 @@ %h4 = s_('ClusterIntegration|Enter the details for your Kubernetes cluster') %p - - link_to_help_page = link_to(s_('ClusterIntegration|documentation'), help_page_path('user/project/clusters/index', anchor: 'add-existing-kubernetes-cluster'), target: '_blank', rel: 'noopener noreferrer') + - link_to_help_page = link_to(s_('ClusterIntegration|documentation'), help_page_path('user/project/clusters/add_remove_clusters', anchor: 'add-existing-cluster'), target: '_blank', rel: 'noopener noreferrer') = s_('ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes').html_safe % { link_to_help_page: link_to_help_page } diff --git a/app/views/dashboard/projects/_zero_authorized_projects.html.haml b/app/views/dashboard/projects/_zero_authorized_projects.html.haml index a2b1f0d9298efa410765a695458c069b9a5db962..b5f5025b5813e47222db25cf88cb5e350cbad958 100644 --- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml +++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml @@ -3,7 +3,7 @@ .container.section-body .row .blank-state-welcome.w-100 - %h2.blank-state-welcome-title + %h2.blank-state-welcome-title{ data: { qa_selector: 'welcome_title_content' } } = _('Welcome to GitLab') %p.blank-state-text = _('Faster releases. Better code. Less pain.') diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml index 8f6c3ecbe584ddef5c79625f58a04d4f52c7cc50..fd6d8f3f76981a66df8988043f185a2cc52fc028 100644 --- a/app/views/devise/sessions/new.html.haml +++ b/app/views/devise/sessions/new.html.haml @@ -1,13 +1,13 @@ - page_title "Sign in" #signin-container - - if form_based_providers.any? + - if any_form_based_providers_enabled? = render 'devise/shared/tabs_ldap' - else - unless experiment_enabled?(:signup_flow) = render 'devise/shared/tabs_normal' .tab-content - - if password_authentication_enabled_for_web? || ldap_enabled? || crowd_enabled? + - if password_authentication_enabled_for_web? || ldap_sign_in_enabled? || crowd_enabled? = render 'devise/shared/signin_box' -# Signup only makes sense if you can also sign-in @@ -15,7 +15,7 @@ = render 'devise/shared/signup_box' -# Show a message if none of the mechanisms above are enabled - - if !password_authentication_enabled_for_web? && !ldap_enabled? && !(omniauth_enabled? && devise_mapping.omniauthable?) + - if !password_authentication_enabled_for_web? && !ldap_sign_in_enabled? && !(omniauth_enabled? && devise_mapping.omniauthable?) %div No authentication methods configured. diff --git a/app/views/devise/shared/_signin_box.html.haml b/app/views/devise/shared/_signin_box.html.haml index 746d43edbadb765f145d05811b5aa80c3658880c..6ddb7e1ac4832913a5b29bc378c6fc67bafd1600 100644 --- a/app/views/devise/shared/_signin_box.html.haml +++ b/app/views/devise/shared/_signin_box.html.haml @@ -1,4 +1,4 @@ -- if form_based_providers.any? +- if any_form_based_providers_enabled? - if crowd_enabled? .login-box.tab-pane{ id: "crowd", role: 'tabpanel', class: active_when(form_based_auth_provider_has_active_class?(:crowd)) } .login-body diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml index db54c166a53ea1947e3a4ae97256664323b8023b..b8f0cd2a91a8f528099d0f4a94852d4c6470f0e9 100644 --- a/app/views/devise/shared/_tabs_ldap.html.haml +++ b/app/views/devise/shared/_tabs_ldap.html.haml @@ -1,4 +1,4 @@ -%ul.nav-links.new-session-tabs.nav-tabs.nav{ class: ('custom-provider-tabs' if form_based_providers.any?) } +%ul.nav-links.new-session-tabs.nav-tabs.nav{ class: ('custom-provider-tabs' if any_form_based_providers_enabled?) } - if crowd_enabled? %li.nav-item = link_to "Crowd", "#crowd", class: "nav-link #{active_when(form_based_auth_provider_has_active_class?(:crowd))}", 'data-toggle' => 'tab' diff --git a/app/views/errors/not_found.html.haml b/app/views/errors/not_found.html.haml index ae055f398ac181731c85f2732406f2913d564c9e..13f07e2f5d5db045a3bbac3d0fd3870c81c23dec 100644 --- a/app/views/errors/not_found.html.haml +++ b/app/views/errors/not_found.html.haml @@ -11,5 +11,5 @@ = form_tag search_path, method: :get, class: 'form-inline-flex' do |f| .field = search_field_tag :search, '', placeholder: _('Search for projects, issues, etc.'), class: 'form-control' - = button_tag 'Search', class: 'btn btn-success', name: nil, type: 'submit' + = button_tag _('Search'), class: 'btn btn-sm btn-success', name: nil, type: 'submit' = render 'errors/footer' diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index bf077eb09d20a31d726078744bf4b89d16cd0d73..1cb1cc45bdb68ea4466878bef55c39fc1e6acf25 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -22,4 +22,10 @@ - if @can_bulk_update = render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues - = render 'shared/issues' + - if Feature.enabled?(:vue_issuables_list, @group) + .js-issuables-list{ data: { endpoint: expose_url(api_v4_groups_issues_path(id: @group.id)), + 'can-bulk-edit': @can_bulk_update.to_json, + 'empty-svg-path': image_path('illustrations/issues.svg'), + 'sort-key': @sort } } + - else + = render 'shared/issues' diff --git a/app/views/groups/milestones/show.html.haml b/app/views/groups/milestones/show.html.haml index 23b1a22240f32be5c5aa162a4b0fa85756535a82..33e68bc766edc4c7fc181f20f1a6f8efd47bba53 100644 --- a/app/views/groups/milestones/show.html.haml +++ b/app/views/groups/milestones/show.html.haml @@ -1,4 +1,4 @@ = render "header_title" = render 'shared/milestones/top', milestone: @milestone, group: @group -= render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true if @milestone.legacy_group_milestone? += render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true = render 'shared/milestones/sidebar', milestone: @milestone, affix_offset: 102 diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index a3f35b72cc62fd2d4e46701d5e085aad80d95523..81bd15ed2871ab35eb47b1fb90e2d262da0607ae 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -31,7 +31,8 @@ %button.btn.btn-default.js-settings-toggle{ type: "button" } = expanded ? _('Collapse') : _('Expand') %p - = _('Register and see your runners for this group.') + = _("Runners are processes that pick up and execute jobs for GitLab. Here you can register and see your Runners for this project.") + = link_to s_('More information'), help_page_path('ci/runners/README') .settings-content = render 'groups/runners/index' diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml index f1ba804f9207afadc3f79741887a39273779538a..5f8f2333e40d00c03e5db6ea08d0e357bb87fe48 100644 --- a/app/views/help/_shortcuts.html.haml +++ b/app/views/help/_shortcuts.html.haml @@ -218,7 +218,7 @@ %tr %td.shortcut %kbd esc - %td= _('Go back (while searching for files') + %td= _('Go back (while searching for files)') %tr %td.shortcut %kbd y diff --git a/app/views/import/manifest/_form.html.haml b/app/views/import/manifest/_form.html.haml index 78c7fadb01938d9e6ac8dfe4e2408a5283270d5c..b515ce084e46908bb17b7df968b143a3f2998c27 100644 --- a/app/views/import/manifest/_form.html.haml +++ b/app/views/import/manifest/_form.html.haml @@ -13,7 +13,7 @@ .form-group = label_tag :manifest, class: 'label-bold' do = _('Manifest') - = file_field_tag :manifest, class: 'form-control-file', required: true + = file_field_tag :manifest, class: 'form-control-file w-auto', required: true .form-text.text-muted = _('Import multiple repositories by uploading a manifest file.') = link_to icon('question-circle'), help_page_path('user/project/import/manifest') diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml index 92572f0308c5fdff0e68b49f19c2ffff461204f1..a0b030fa3b242e8b90a4f89abe2fdbce1f72a221 100644 --- a/app/views/layouts/_flash.html.haml +++ b/app/views/layouts/_flash.html.haml @@ -3,7 +3,7 @@ - flash.each do |key, value| -# Don't show a flash message if the message is nil - if value - %div{ class: "flash-content flash-#{key} rounded" } + %div{ class: "flash-#{key} mb-2" } %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 b8c9f0ae1e821d534c544d2ca879439891d4878a..0060d8323b03e983630e4b9757d96658f730c29e 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -57,7 +57,7 @@ = yield :library_javascripts = javascript_include_tag locale_path unless I18n.locale == :en - = webpack_bundle_tag "raven" if Gitlab.config.sentry.enabled + = webpack_bundle_tag "sentry" if Gitlab.config.sentry.enabled - if content_for?(:page_specific_javascripts) = yield :page_specific_javascripts @@ -89,4 +89,4 @@ = render 'layouts/google_analytics' if extra_config.has_key?('google_analytics_id') = render 'layouts/piwik' if extra_config.has_key?('piwik_url') && extra_config.has_key?('piwik_site_id') - = render_if_exists 'layouts/snowplow' + = render 'layouts/snowplow' diff --git a/app/views/layouts/_mailer.html.haml b/app/views/layouts/_mailer.html.haml index 6e8294d6adce3298e3dc1232f73a1d97e3e660c2..24b8138078d456e12a7bc47dc5c473c2ae1edce9 100644 --- a/app/views/layouts/_mailer.html.haml +++ b/app/views/layouts/_mailer.html.haml @@ -1,80 +1,48 @@ -<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> %html{ lang: "en" } %head %meta{ content: "text/html; charset=UTF-8", "http-equiv" => "Content-Type" }/ %meta{ content: "width=device-width, initial-scale=1", name: "viewport" }/ %meta{ content: "IE=edge", "http-equiv" => "X-UA-Compatible" }/ %title= message.subject - :css - /* CLIENT-SPECIFIC STYLES */ - body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; } - table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; } - img { -ms-interpolation-mode: bicubic; } - .hidden { - display: none !important; - visibility: hidden !important; - } - /* iOS BLUE LINKS */ - a[x-apple-data-detectors] { - color: inherit !important; - text-decoration: none !important; - font-size: inherit !important; - font-family: inherit !important; - font-weight: inherit !important; - line-height: inherit !important; - } + -# Avoid premailer processing of client-specific styles (@media tag not supported) + -# We need to inline the contents here because mail clients (e.g. iOS Mail, Outlook) + -# do not support linked stylesheets. + %style{ type: 'text/css', 'data-premailer': 'ignore' } + = asset_to_string('mailer_client_specific.css').html_safe - /* ANDROID MARGIN HACK */ - body { margin:0 !important; } - div[style*="margin: 16px 0"] { margin:0 !important; } - - @media only screen and (max-width: 639px) { - body, #body { - min-width: 320px !important; - } - table.wrapper { - width: 100% !important; - min-width: 320px !important; - } - table.wrapper > tbody > tr > td { - border-left: 0 !important; - border-right: 0 !important; - border-radius: 0 !important; - padding-left: 10px !important; - padding-right: 10px !important; - } - } - %body{ style: "background-color:#fafafa;margin:0;padding:0;text-align:center;min-width:640px;width:100%;height:100%;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" } - %table#body{ border: "0", cellpadding: "0", cellspacing: "0", style: "background-color:#fafafa;margin:0;padding:0;text-align:center;min-width:640px;width:100%;" } + = stylesheet_link_tag 'mailer.css' + %body + %table#body{ border: "0", cellpadding: "0", cellspacing: "0" } %tbody %tr.line - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#6b4fbb;height:4px;font-size:4px;line-height:4px;" } + %td %tr.header - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } + %td = html_header_message = header_logo %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" } - %table.wrapper{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:640px;margin:0 auto;border-collapse:separate;border-spacing:0;" } + %td + %table.wrapper{ border: "0", cellpadding: "0", cellspacing: "0" } %tbody %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#ffffff;text-align:left;padding:18px 25px;border:1px solid #ededed;border-radius:3px;overflow:hidden;" } - %table.content{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;border-collapse:separate;border-spacing:0;" } + %td.wrapper-cell + %table.content{ border: "0", cellpadding: "0", cellspacing: "0" } %tbody = yield = render_if_exists 'layouts/mailer/additional_text' %tr.footer - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } - %img{ alt: "GitLab", height: "33", src: image_url('mailers/gitlab_footer_logo.gif'), style: "display:block;margin:0 auto 1em;", width: "90" }/ + %td + %img{ alt: "GitLab", height: "33", width: "90", src: image_url('mailers/gitlab_footer_logo.gif') } %div - - manage_notifications_link = link_to(_("Manage all notifications"), profile_notifications_url, style: "color:#3777b0;text-decoration:none;") - - help_link = link_to(_("Help"), help_url, style: "color:#3777b0;text-decoration:none;") + - manage_notifications_link = link_to(_("Manage all notifications"), profile_notifications_url, class: 'mng-notif-link') + - help_link = link_to(_("Help"), help_url, class: 'help-link') = _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} · %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link } = yield :additional_footer %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } + %td.footer-message = html_footer_message diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 6cdb85456c3106e0be9bd286f4a6f985a03991a2..443a73f5cce065e9e702ae10b7df2c98e4342d43 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -17,6 +17,4 @@ %div{ class: "#{(container_class unless @no_container)} #{@content_class}" } .content{ id: "content-body" } = render "layouts/flash", extra_flash_class: 'limit-container-width' - - if Gitlab.com? - = render_if_exists "layouts/privacy_policy_update_callout" = yield diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index efe74ddd902c9d2d1a55e710f242a242a17ad505..d15f0ae322876158b149ad5fb6c61c55438d59b4 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -18,6 +18,11 @@ - if current_user_menu?(:profile) %li = link_to s_("CurrentUser|Profile"), current_user, class: 'profile-link', data: { user: current_user.username } + - if current_user_menu?(:start_trial) + %li + %a.profile-link{ href: trials_link_url } + = s_("CurrentUser|Start a Gold trial") + = emoji_icon('rocket') - if current_user_menu?(:settings) %li = link_to s_("CurrentUser|Settings"), profile_path, data: { qa_selector: 'settings_link' } @@ -35,8 +40,8 @@ %li.d-md-none = render 'shared/user_dropdown_contributing_link' = render_if_exists 'shared/user_dropdown_instance_review' - - if Gitlab.com? - %li.js-canary-link.d-md-none + - if Gitlab.com_but_not_canary? + %li.d-md-none = link_to _("Switch to GitLab Next"), "https://next.gitlab.com/" - if current_user_menu?(:sign_out) diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index d8697be7f7a73dc73124fa280be17cec3fe8c6d6..5719fb24b89ee1ad2b1f40773946bee5aaa6d04c 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -18,8 +18,8 @@ - if logo_text.present? %span.logo-text.d-none.d-lg-block.prepend-left-8 = logo_text - - if Gitlab.com? - = link_to 'https://next.gitlab.com', class: 'label-link js-canary-badge canary-badge bg-transparent hidden', target: :_blank do + - if Gitlab.com_and_canary? + = link_to 'https://next.gitlab.com', class: 'label-link canary-badge bg-transparent', target: :_blank do %span.color-label.has-tooltip.badge.badge-pill.green-badge = _('Next') diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index 71977b23481e0c5976eeef4e2aa93eb7876887eb..93854c212df0ae68cf6d775d8bb8838ad137a218 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -12,6 +12,6 @@ %li = render 'shared/user_dropdown_contributing_link' = render_if_exists 'shared/user_dropdown_instance_review' - - if Gitlab.com? - %li.js-canary-link + - if Gitlab.com_but_not_canary? + %li = link_to _("Switch to GitLab Next"), "https://next.gitlab.com/" diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 5122c2517aa30554d843d66e107b1dacef1a8391..d339751848b929f2a550c4e7d46205e90c914723 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -55,15 +55,15 @@ = nav_link(controller: 'admin/dashboard') do = link_to admin_root_path, class: 'admin-icon qa-admin-area-link d-xl-none' do = _('Admin Area') - - 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 - = _('Leave admin mode') - - elsif current_user.admin? - = nav_link(controller: 'admin/sessions') do - = link_to new_admin_session_path, class: 'd-lg-none lock-icon' do - = _('Enter admin mode') + - 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 + = _('Leave Admin Mode') + - elsif current_user.admin? + = nav_link(controller: 'admin/sessions') do + = link_to new_admin_session_path, class: 'd-lg-none lock-icon' do + = _('Enter Admin Mode') - if Gitlab::Sherlock.enabled? %li = link_to sherlock_transactions_path, class: 'admin-icon' do @@ -74,6 +74,15 @@ = 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 = sprite_icon('admin', size: 18) + - if Feature.enabled?(:user_mode_in_session) + - if header_link?(:admin_mode) + = nav_link(controller: 'admin/sessions', html_options: { class: "d-none d-lg-block d-xl-block"}) do + = link_to destroy_admin_session_path, title: _('Leave Admin Mode'), aria: { label: _('Leave Admin Mode') }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do + = sprite_icon('lock-open', size: 18) + - elsif current_user.admin? + = nav_link(controller: 'admin/sessions', html_options: { class: "d-none d-lg-block d-xl-block"}) do + = link_to new_admin_session_path, title: _('Enter Admin Mode'), aria: { label: _('Enter Admin Mode') }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do + = sprite_icon('lock', size: 18) -# Shortcut to Dashboard > Projects - if dashboard_nav_link?(:projects) diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 4930c6cf5f776bfaf86be9a53c5f4972e0917bdd..a6d2c89418547595bc5e28bb36fe257aec49dad9 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -16,13 +16,19 @@ .nav-icon-container = sprite_icon('home') %span.nav-item-name - = _('Overview') + - if @group.subgroup? + = _('Subgroup overview') + - else + = _('Group overview') %ul.sidebar-sub-level-items = nav_link(path: ['groups#show', 'groups#details', 'groups#activity', 'groups#subgroups'], html_options: { class: "fly-out-top-item" } ) do = link_to group_path(@group) do %strong.fly-out-top-item-name - = _('Overview') + - if @group.subgroup? + = _('Subgroup overview') + - else + = _('Group overview') %li.divider.fly-out-top-item = nav_link(path: ['groups#show', 'groups#details', 'groups#subgroups'], html_options: { class: 'home' }) do diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index c84bc0b5cd4b271cae5076b76627deb5b569a49f..9b3ad05d0c096de5ec12b990b17bc28a40e1665b 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -13,13 +13,13 @@ .nav-icon-container = sprite_icon('home') %span.nav-item-name - = _('Project') + = _('Project overview') %ul.sidebar-sub-level-items = nav_link(path: 'projects#show', html_options: { class: "fly-out-top-item" } ) do = link_to project_path(@project) do %strong.fly-out-top-item-name - = _('Project') + = _('Project overview') %li.divider.fly-out-top-item = nav_link(path: 'projects#show') do = link_to project_path(@project), title: _('Project details'), class: 'shortcuts-project' do @@ -163,7 +163,7 @@ - if project_nav_tab? :pipelines = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts]) do - = link_to project_pipelines_path(@project), class: 'shortcuts-pipelines qa-link-pipelines rspec-link-pipelines' do + = link_to project_pipelines_path(@project), class: 'shortcuts-pipelines qa-link-pipelines rspec-link-pipelines', data: { qa_selector: 'ci_cd_link' } do .nav-icon-container = sprite_icon('rocket') %span.nav-item-name#js-onboarding-pipelines-link @@ -247,6 +247,8 @@ %span = _('Serverless') + = render_if_exists 'layouts/nav/sidebar/pod_logs_link' # EE-specific + - if project_nav_tab? :clusters - show_cluster_hint = show_gke_cluster_integration_callout?(@project) = nav_link(controller: [:clusters, :user, :gcp]) do @@ -264,7 +266,7 @@ dismiss_endpoint: user_callouts_path } } - if show_cluster_hint .feature-highlight-popover-content - = image_tag 'illustrations/cluster_popover.svg', class: 'feature-highlight-illustration' + = image_tag 'illustrations/cluster_popover.svg', class: 'feature-highlight-illustration', lazy: false, alt: _('Kubernetes popover') .feature-highlight-popover-sub-content %p= _('Allows you to add and manage Kubernetes clusters.') %p @@ -347,7 +349,7 @@ = _('Members') - if can_edit = nav_link(controller: [:integrations, :services, :hooks, :hook_logs]) do - = link_to project_settings_integrations_path(@project), title: _('Integrations') do + = link_to project_settings_integrations_path(@project), title: _('Integrations'), data: { qa_selector: 'integrations_settings_link' } do %span = _('Integrations') = nav_link(controller: :repository) do diff --git a/app/views/notify/member_access_denied_email.html.haml b/app/views/notify/member_access_denied_email.html.haml index 71c9c50071a0ada253a2a16775e22c623e62c5a3..11661a423ddf5887ed076d58f6949d949618c825 100644 --- a/app/views/notify/member_access_denied_email.html.haml +++ b/app/views/notify/member_access_denied_email.html.haml @@ -1,4 +1,7 @@ -%p - Your request to join the - #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular} - has been denied. +%tr + %td.text-content + %p + Your request to join the + #{link_to member_source.human_name, member_source.web_url, class: :highlight} #{member_source.model_name.singular} + has been #{content_tag :span, 'denied', class: :highlight}. + diff --git a/app/views/notify/member_access_granted_email.html.haml b/app/views/notify/member_access_granted_email.html.haml index 1c50dba9c9700300d530f562d816e9ee2321de0a..e28a10a243f0b4e68838296da7c3df0b8e4ef022 100644 --- a/app/views/notify/member_access_granted_email.html.haml +++ b/app/views/notify/member_access_granted_email.html.haml @@ -1,10 +1,14 @@ - link_end = '</a>'.html_safe - source_type = member_source.model_name.singular - leave_link = polymorphic_url([member_source], leave: 1) -- source_link = link_to(member_source.human_name, member_source.web_url, target: '_blank', rel: 'noopener noreferrer') +- source_link = link_to(member_source.human_name, member_source.web_url, target: '_blank', rel: 'noopener noreferrer', class: :highlight) +- access_level = content_tag(:span, member.human_access, class: :highlight) + +%tr + %td.text-content + %p + = _('You have been granted %{access_level} access to the %{source_link} %{source_type}.').html_safe % { access_level: access_level, source_link: source_link, source_type: source_type } + %p + - leave_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: leave_link } + = _('If this was a mistake you can %{leave_link_start}leave the %{source_type}%{link_end}.').html_safe % { source_type: source_type, leave_link_start: leave_link_start, link_end: link_end } -%p - = _('You have been granted %{access_level} access to the %{source_link} %{source_type}.').html_safe % { access_level: member.human_access, source_link: source_link, source_type: source_type } -%p - - leave_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: leave_link } - = _('If this was a mistake you can %{leave_link_start}leave the %{source_type}%{link_end}.').html_safe % { source_type: source_type, leave_link_start: leave_link_start, link_end: link_end } diff --git a/app/views/notify/member_access_requested_email.html.haml b/app/views/notify/member_access_requested_email.html.haml index 76f1f08a0cbfe9fdbcd2e2b9072ea13e1c0a83f4..43f25af3dba292194d88a4f6eced71e817140c3a 100644 --- a/app/views/notify/member_access_requested_email.html.haml +++ b/app/views/notify/member_access_requested_email.html.haml @@ -1,3 +1,6 @@ -%p - #{link_to member.user.name, member.user} requested #{member.human_access} - access to the #{link_to member_source.human_name, polymorphic_url([member_source, :members])} #{member_source.model_name.singular}. +%tr + %td.text-content + %p + #{link_to member.user.name, member.user, class: :highlight} requested #{content_tag :span, member.human_access, class: :highlight} + access to the #{link_to member_source.human_name, polymorphic_url([member_source, :members]), class: :highlight} #{member_source.model_name.singular}. + diff --git a/app/views/notify/member_invite_accepted_email.html.haml b/app/views/notify/member_invite_accepted_email.html.haml index 2d1d40881ebc7f98660d7bbfdcf82d783c7ebbb0..0abb79000e048dc7cfdc509ad0d0bc29ec41dab4 100644 --- a/app/views/notify/member_invite_accepted_email.html.haml +++ b/app/views/notify/member_invite_accepted_email.html.haml @@ -1,5 +1,8 @@ -%p - #{member.invite_email}, now known as - #{link_to member.user.name, user_url(member.user)}, - has accepted your invitation to join the - #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}. +%tr + %td.text-content + %p + #{content_tag :span, member.invite_email, class: :highlight}, now known as + #{link_to member.user.name, user_url(member.user)}, + has accepted your invitation to join the + #{link_to member_source.human_name, member_source.web_url, class: :highlight} #{member_source.model_name.singular}. + diff --git a/app/views/notify/member_invite_declined_email.html.haml b/app/views/notify/member_invite_declined_email.html.haml index aa1b373d1a630d5bc97c23c1c2b13c7981cd7d32..5e62676723508e8250c259b32d2b596ca6c65124 100644 --- a/app/views/notify/member_invite_declined_email.html.haml +++ b/app/views/notify/member_invite_declined_email.html.haml @@ -1,4 +1,7 @@ -%p - #{@invite_email} - has declined your invitation to join the - #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}. +%tr + %td.text-content + %p + #{content_tag :span, @invite_email, class: :highlight} + has #{content_tag :span, 'declined', class: :highlight} your invitation to join the + #{link_to member_source.human_name, member_source.web_url, class: :highlight} #{member_source.model_name.singular}. + diff --git a/app/views/notify/member_invited_email.html.haml b/app/views/notify/member_invited_email.html.haml index 6730172242bd9585c8165bc674cc4639a15877f5..ae3fecf404a8162fc9f1443bd4ca6c62f8742ab6 100644 --- a/app/views/notify/member_invited_email.html.haml +++ b/app/views/notify/member_invited_email.html.haml @@ -1,13 +1,16 @@ -%p - You have been invited - - if member.created_by - by - = link_to member.created_by.name, user_url(member.created_by) - to join the - = link_to member_source.human_name, member_source.public? ? member_source.web_url : invite_url(@token) - #{member_source.model_name.singular} as #{member.human_access}. +%tr + %td.text-content + %p + You have been invited + - if member.created_by + by + = link_to member.created_by.name, user_url(member.created_by) + to join the + = link_to member_source.human_name, member_source.public? ? member_source.web_url : invite_url(@token), class: :highlight + #{member_source.model_name.singular} as #{content_tag :span, member.human_access, class: :highlight}. + + %p + = link_to 'Accept invitation', invite_url(@token) + or + = link_to 'decline', decline_invite_url(@token) -%p - = link_to 'Accept invitation', invite_url(@token) - or - = link_to 'decline', decline_invite_url(@token) diff --git a/app/views/profiles/preferences/_sourcegraph.html.haml b/app/views/profiles/preferences/_sourcegraph.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..20a904694ca98ad9207bf76e85523d44878cde68 --- /dev/null +++ b/app/views/profiles/preferences/_sourcegraph.html.haml @@ -0,0 +1,26 @@ +- return unless Gitlab::Sourcegraph::feature_available? && Gitlab::CurrentSettings.sourcegraph_enabled +- sourcegraph_url = Gitlab::CurrentSettings.sourcegraph_url + +.col-sm-12 + %hr + +.col-lg-4.profile-settings-sidebar + %h4.prepend-top-0 + = s_('Preferences|Integrations') + %p + = s_('Preferences|Customize integrations with third party services.') + = succeed '.' do + = link_to _('Learn more'), help_page_path('user/profile/preferences.md', anchor: 'integrations'), target: '_blank' +.col-lg-8 + %label.label-bold + = s_('Preferences|Sourcegraph') + = link_to icon('question-circle'), help_page_path('user/profile/preferences.md', anchor: 'sourcegraph'), target: '_blank', class: 'has-tooltip', title: _('More information') + .form-group.form-check + = f.check_box :sourcegraph_enabled, class: 'form-check-input' + = f.label :sourcegraph_enabled, class: 'form-check-label' do + - link_start = '<a href="%{url}">'.html_safe % { url: sourcegraph_url } + - link_end = '</a>'.html_safe + = s_('Preferences|Enable integrated code intelligence on code views').html_safe % { link_start: link_start, link_end: link_end } + .form-text.text-muted + = sourcegraph_url_message + = sourcegraph_experimental_message diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 84657592cd822b5b1bd529fc39402880d7e732af..bf76b7379dd362022c8370d88db7c5b31dcadff8 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -111,6 +111,9 @@ = time_display_label .form-text.text-muted = s_('Preferences|For example: 30 mins ago.') + + = render 'sourcegraph', f: f + .col-lg-4.profile-settings-sidebar .col-lg-8 .form-group diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 68b7efc6fb4735c5f23bfe62cd058ca7f85e509d..cfad274f91db53156d09e8e1c6aa527edceb13aa 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -94,7 +94,7 @@ - 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") = 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] }, {}, class: 'input-md' + = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, { prompt: _('Select your role') }, required: true, class: 'input-md' = render_if_exists 'profiles/email_settings', form: f = f.text_field :skype, class: 'input-md', placeholder: s_("Profiles|username") diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index e4129a91daf30c6357df3d0e3309cb5b70e7d420..2e00632892b79f23274c51976a5383fa237b87c4 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -14,7 +14,7 @@ %li= desc %p= _('The following items will NOT be exported:') %ul - %li= _('Job traces and artifacts') + %li= _('Job logs and artifacts') %li= _('Container registry images') %li= _('CI variables') %li= _('Webhooks') diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 95fdad125a79b77786a4af2c076ca07a89e092e8..20d4084f42848bdcf9cb9533420fd86860988c77 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -15,15 +15,13 @@ = render 'shared/commit_well', commit: commit, ref: ref, project: project - if is_project_overview - .project-buttons.append-bottom-default{ class: ("js-keep-hidden-on-navigation" if vue_file_list_enabled?) } + .project-buttons.append-bottom-default{ class: ("js-show-on-project-root" if vue_file_list_enabled?) } = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout) - if vue_file_list_enabled? - #js-tree-list{ data: { project_path: @project.full_path, project_short_path: @project.path, ref: ref, full_name: @project.name_with_namespace } } + #js-tree-list{ data: vue_file_list_data(project, ref) } - if can_edit_tree? = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post = render 'projects/blob/new_dir' - - if @tree.readme - = render "projects/tree/readme", readme: @tree.readme - else = render 'projects/tree/tree_content', tree: @tree, content_url: content_url diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 4783b10cf6da0c322c39d1ee8e91eb90eb0b2138..b7c4114d48589b11e099e98151b0a9090bf85f49 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -3,7 +3,7 @@ - max_project_topic_length = 15 - emails_disabled = @project.emails_disabled? -.project-home-panel{ class: [("empty-project" if empty_repo), ("js-keep-hidden-on-navigation" if vue_file_list_enabled?)] } +.project-home-panel{ class: [("empty-project" if empty_repo), ("js-show-on-project-root" if vue_file_list_enabled?)] } .row.append-bottom-8 .home-panel-title-row.col-md-12.col-lg-6.d-flex .avatar-container.rect-avatar.s64.home-panel-avatar.append-right-default.float-none diff --git a/app/views/projects/_merge_request_merge_options_settings.html.haml b/app/views/projects/_merge_request_merge_options_settings.html.haml index 5ab475822de056a2eec79c36dfaf4722d0c43390..047b4dafbfc40fe406c5dc13f58232febcc3f514 100644 --- a/app/views/projects/_merge_request_merge_options_settings.html.haml +++ b/app/views/projects/_merge_request_merge_options_settings.html.haml @@ -12,3 +12,9 @@ = form.check_box :printing_merge_request_link_enabled, class: 'form-check-input' = form.label :printing_merge_request_link_enabled, class: 'form-check-label' do = s_('ProjectSettings|Show link to create/view merge request when pushing from the command line') + .form-check.mb-2 + = form.check_box :remove_source_branch_after_merge, class: 'form-check-input' + = form.label :remove_source_branch_after_merge, class: 'form-check-label' do + = s_("ProjectSettings|Enable 'Delete source branch' option by default") + .descr.text-secondary + = s_('ProjectSettings|Existing merge requests and protected branches are not affected') diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index f2215765974f26abc4056ccaf8244669f7d5a1fc..30fe5622ebdb9f8f0f16ddbaa9b28974c93ce5f4 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -19,7 +19,7 @@ = author_avatar(commit, size: 36, has_tooltip: false) .commit-row-title %span.item-title.str-truncated-100 - = link_to_markdown commit.title, project_commit_path(@project, commit.id), class: "cdark", title: commit.title + = 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" diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index 84ccd816d802326949fd89d6eee5e65ca088dd95..772451147728a0c101f5b6cae3ac519f11144fdd 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -6,17 +6,18 @@ = render 'projects/blob/viewer_switcher', blob: blob unless blame .btn-group{ role: "group" }< - = copy_blob_source_button(blob) unless blame - = open_raw_blob_button(blob) - = download_blob_button(blob) - = view_on_environment_button(@commit.sha, @path, @environment) if @environment - .btn-group{ role: "group" }< - = render_if_exists 'projects/blob/header_file_locks_link' = edit_blob_button = ide_edit_button + .btn-group{ role: "group" }< + = render_if_exists 'projects/blob/header_file_locks_link' - if current_user = replace_blob_link = delete_blob_link + .btn-group{ role: "group" }< + = copy_blob_source_button(blob) unless blame + = open_raw_blob_button(blob) + = download_blob_button(blob) + = view_on_environment_button(@commit.sha, @path, @environment) if @environment = render 'projects/fork_suggestion' = render_if_exists 'projects/blob/header_file_locks', project: @project, path: @path diff --git a/app/views/projects/blob/_markdown_buttons.html.haml b/app/views/projects/blob/_markdown_buttons.html.haml index 28d1ff978257c2384e49e2084ea248bdd613f800..44ec2fa69cbdf11f14453f12971af1ec762691a3 100644 --- a/app/views/projects/blob/_markdown_buttons.html.haml +++ b/app/views/projects/blob/_markdown_buttons.html.haml @@ -6,7 +6,7 @@ = markdown_toolbar_button({ icon: "link", data: { "md-tag" => "[{text}](url)", "md-select" => "url" }, title: _("Add a link") }) = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "* ", "md-prepend" => true }, title: _("Add a bullet list") }) = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: _("Add a numbered list") }) - = markdown_toolbar_button({ icon: "task-done", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: _("Add a task list") }) + = markdown_toolbar_button({ icon: "list-task", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: _("Add a task list") }) = markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |", "md-prepend" => true }, title: _("Add a table") }) - if show_fullscreen_button %button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: _("Go full screen"), data: { container: "body" } } diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index abef33ca01ce81811a9dd8a67960c0aa171619ce..ed22573b23ec9bf126e6e4560d7fc1cb5af6e8c8 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -25,5 +25,3 @@ = clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard") = render_if_exists 'projects/buttons/geo' = render_if_exists 'projects/buttons/kerberos_clone_field' - -= render_if_exists 'shared/geo_info_modal', project: project diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index 96df3cd18fecac680989ffd8e8378bd60f61ef40..e8aff58b505cb064778bc718d3079d53de9c4373 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -12,11 +12,14 @@ %h5.m-0.dropdown-bold-header= _('Download source code') .dropdown-menu-content = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: nil - - if directory? && Feature.enabled?(:git_archive_path, default_enabled: true) - %section.border-top.pt-1.mt-1 - %h5.m-0.dropdown-bold-header= _('Download this directory') - .dropdown-menu-content - = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path + - if Feature.enabled?(:git_archive_path, default_enabled: true) + - if vue_file_list_enabled? + #js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } } + - elsif directory? + %section.border-top.pt-1.mt-1 + %h5.m-0.dropdown-bold-header= _('Download this directory') + .dropdown-menu-content + = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path - if pipeline && pipeline.latest_builds_with_artifacts.any? %section.border-top.pt-1.mt-1 %h5.m-0.dropdown-bold-header= _('Download artifacts') diff --git a/app/views/projects/buttons/_download_links.html.haml b/app/views/projects/buttons/_download_links.html.haml index b256d94065bc485feb2d1cfab4219e7be114563f..990f3ff526bac8c0e5a00463e1a6dea7eff98214 100644 --- a/app/views/projects/buttons/_download_links.html.haml +++ b/app/views/projects/buttons/_download_links.html.haml @@ -1,6 +1,4 @@ -- formats = [['zip', 'btn-primary'], ['tar.gz'], ['tar.bz2'], ['tar']] - .btn-group.ml-0.w-100 - - formats.each do |(fmt, extra_class)| + - Gitlab::Workhorse::ARCHIVE_FORMATS.each_with_index do |fmt, index| - archive_path = project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: fmt) - = link_to fmt, external_storage_url_or_path(archive_path), rel: 'nofollow', download: '', class: "btn btn-xs #{extra_class}" + = link_to fmt, external_storage_url_or_path(archive_path), rel: 'nofollow', download: '', class: "btn btn-xs #{"btn-primary" if index == 0}" diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml index 9744d293c8bcf58556a3488393da182a34c417bb..c8c96297672025f669d8424cbfb3c9e955d60024 100644 --- a/app/views/projects/compare/_form.html.haml +++ b/app/views/projects/compare/_form.html.haml @@ -8,8 +8,9 @@ .input-group-text = s_("CompareBranches|Source") = hidden_field_tag :to, params[:to] - = button_tag type: 'button', title: params[:to], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip monospace", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do - .dropdown-toggle-text.str-truncated= params[:to] || _("Select branch/tag") + = button_tag type: 'button', title: params[:to], class: "btn form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do + .dropdown-toggle-text.str-truncated.monospace.float-left= params[:to] || _("Select branch/tag") + = sprite_icon('arrow-down', size: 16, css_class: 'float-right') = render 'shared/ref_dropdown' .compare-ellipsis.inline ... .form-group.dropdown.compare-form-group.from.js-compare-from-dropdown @@ -18,8 +19,9 @@ .input-group-text = s_("CompareBranches|Target") = hidden_field_tag :from, params[:from] - = button_tag type: 'button', title: params[:from], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip monospace", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do - .dropdown-toggle-text.str-truncated= params[:from] || _("Select branch/tag") + = button_tag type: 'button', title: params[:from], class: "btn form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do + .dropdown-toggle-text.str-truncated.monospace.float-left= params[:from] || _("Select branch/tag") + = sprite_icon('arrow-down', size: 16, css_class: 'float-right') = render 'shared/ref_dropdown' = button_tag s_("CompareBranches|Compare"), class: "btn btn-success commits-compare-btn" diff --git a/app/views/projects/deployments/_confirm_rollback_modal.html.haml b/app/views/projects/deployments/_confirm_rollback_modal.html.haml index ff40e404e5ffac38ffdf3713b17cf78fab24be14..9162827b501329fea5aea195a7893da9c897026c 100644 --- a/app/views/projects/deployments/_confirm_rollback_modal.html.haml +++ b/app/views/projects/deployments/_confirm_rollback_modal.html.haml @@ -13,7 +13,7 @@ %p= s_('Environments|This action will relaunch the job for commit %{commit_id}, putting the environment in a previous version. Are you sure you want to continue?').html_safe % {commit_id: commit_sha} - else %p - = s_('Environments|This action will run the job defined by staging for commit %{commit_id}, putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?').html_safe % {commit_id: commit_sha} + = s_('Environments|This action will run the job defined by %{environment_name} for commit %{commit_id}, putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?').html_safe % {commit_id: commit_sha, environment_name: @environment.name} .modal-footer = button_tag _('Cancel'), type: 'button', class: 'btn btn-cancel', data: { dismiss: 'modal' } = link_to [:retry, @project.namespace.becomes(Namespace), @project, deployment.deployable], method: :post, class: 'btn btn-danger' do diff --git a/app/views/projects/environments/empty_logs.html.haml b/app/views/projects/environments/empty_logs.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..602dc908b751f7fd1c35950c6c31b69b590d6210 --- /dev/null +++ b/app/views/projects/environments/empty_logs.html.haml @@ -0,0 +1,14 @@ +- page_title _('Pod logs') + +.row.empty-state + .col-sm-12 + .svg-content + = image_tag 'illustrations/operations_log_pods_empty.svg' + .col-12 + .text-content + %h4.text-center + = s_('Environments|No deployed environments') + %p.state-description.text-center + = s_('Logs|To see the pod logs, deploy your code to an environment.') + .text-center + = link_to s_('Environments|Learn about environments'), help_page_path('ci/environments'), class: 'btn btn-success' diff --git a/app/views/projects/environments/empty.html.haml b/app/views/projects/environments/empty_metrics.html.haml similarity index 64% rename from app/views/projects/environments/empty.html.haml rename to app/views/projects/environments/empty_metrics.html.haml index 129dbbf4e56275282bebfdc2ebc88016a7425728..dad93290fbdfa18cc147f2a4805209a564b75743 100644 --- a/app/views/projects/environments/empty.html.haml +++ b/app/views/projects/environments/empty_metrics.html.haml @@ -7,8 +7,8 @@ .col-12 .text-content %h4.text-center - = s_('Metrics|No deployed environments') + = s_('Environments|No deployed environments') %p.state-description = s_('Metrics|Check out the CI/CD documentation on deploying to an environment') .text-center - = link_to s_("Metrics|Learn about environments"), help_page_path('ci/environments'), class: 'btn btn-success' + = link_to s_("Environments|Learn about environments"), help_page_path('ci/environments'), class: 'btn btn-success' diff --git a/app/views/projects/error_tracking/details.html.haml b/app/views/projects/error_tracking/details.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..640746ad8f69fa144367e461960811b68571cd02 --- /dev/null +++ b/app/views/projects/error_tracking/details.html.haml @@ -0,0 +1,4 @@ +- page_title _('Error Details') +- add_to_breadcrumbs 'Errors', project_error_tracking_index_path(@project) + +#js-error_details{ data: error_details_data(@current_user, @project) } diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml index 6e5e4607232967774b8ee1aa21b55f11988a1a38..a952db0eea3664d1b41b53a98ba6d8c1a98167a1 100644 --- a/app/views/projects/graphs/show.html.haml +++ b/app/views/projects/graphs/show.html.haml @@ -1,26 +1,8 @@ - page_title _('Contributors') -.js-graphs-show{ 'data-project-graph-path': project_graph_path(@project, current_ref, format: :json) } - .sub-header-block - .tree-ref-holder.inline.vertical-align-middle - = render 'shared/ref_switcher', destination: 'graphs' - = link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn' +.sub-header-block.bg-gray-light.gl-p-3 + .tree-ref-holder.inline.vertical-align-middle + = render 'shared/ref_switcher', destination: 'graphs' + = link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn' - .loading-graph - .center - %h3.page-title - %i.fa.fa-spinner.fa-spin - = s_('ContributorsPage|Building repository graph.') - %p.slead - = s_('ContributorsPage|Please wait a moment, this page will automatically refresh when ready.') - - .stat-graph.hide - .header.clearfix - %h3#date_header.page-title - %p.light - = s_('ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits.') % { branch_name: @ref } - %input#brush_change{ :type => "hidden" } - .graphs.row - #contributors-master.svg-w-100 - #contributors.clearfix - %ol.contributors-list.svg-w-100.row +.js-contributors-graph{ class: container_class, 'data-project-graph-path': project_graph_path(@project, current_ref, format: :json),'data-project-branch': current_ref } diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 367b8c1138ed5ce9e09520c7e08671550f6a63a4..c8ab47888d0c86a641f1eafc2a15e9a1a1b631d6 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -1,4 +1,5 @@ -%li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id } } +-# DANGER: Any changes to this file need to be reflected in issuables_list/components/issuable.vue! +%li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id, qa_selector: 'issue', qa_issue_title: issue.title } } .issue-box - if @can_bulk_update .issue-check.hidden diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 0328751c68cf52647be124bff4837efc52d510fa..0373e37818df114839e43cfb3f462af2431ab076 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -26,7 +26,7 @@ = render partial: 'shared/label', collection: @prioritized_labels, as: :label, locals: { force_priority: true, subject: @project } - elsif search.present? .nothing-here-block - = _('No prioritised labels with such name or description') + = _('No prioritized labels with such name or description') - if @labels.present? .other-labels diff --git a/app/views/projects/merge_requests/_how_to_merge.html.haml b/app/views/projects/merge_requests/_how_to_merge.html.haml index 57205682bdad9715edf0b70db76837bbe6dbc263..9cdbbe7204b87c8798b1e4898478c2814875aea8 100644 --- a/app/views/projects/merge_requests/_how_to_merge.html.haml +++ b/app/views/projects/merge_requests/_how_to_merge.html.haml @@ -12,8 +12,8 @@ = clipboard_button(target: "pre#merge-info-1", title: _("Copy commands")) %pre.dark#merge-info-1 - if @merge_request.for_fork? + -# All repo/branch refs have been quoted to allow support for special characters (such as #my-branch) :preserve - -# All repo/branch refs have been quoted to allow support for special characters (such as #my-branch) git fetch "#{h default_url_to_repo(@merge_request.source_project)}" "#{h @merge_request.source_branch}" git checkout -b "#{h @merge_request.source_project_path}-#{h @merge_request.source_branch}" FETCH_HEAD - else diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml index 543441b947939722908256026e7d9efb1c99ff5b..15c83f92474d8aed71e91b7907c418f1fd8f6c97 100644 --- a/app/views/projects/merge_requests/creations/_new_submit.html.haml +++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml @@ -1,15 +1,5 @@ %h3.page-title New Merge Request -%p.slead - - source_title, target_title = format_mr_branch_names(@merge_request) - From - %strong.ref-name= source_title - %span into - %strong.ref-name= target_title - - %span.float-right - = link_to 'Change branches', mr_change_branches_path(@merge_request) -%hr = form_for [@project.namespace.becomes(Namespace), @project, @merge_request], html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f| = render 'shared/issuable/form', f: f, issuable: @merge_request, commits: @commits, presenter: @mr_presenter = f.hidden_field :source_project_id diff --git a/app/views/projects/merge_requests/edit.html.haml b/app/views/projects/merge_requests/edit.html.haml index 03159f123f3764b5413392e3442d012e70952844..318c9d809c1c43d609f69623608edfd4ed90df02 100644 --- a/app/views/projects/merge_requests/edit.html.haml +++ b/app/views/projects/merge_requests/edit.html.haml @@ -2,5 +2,4 @@ %h3.page-title Edit Merge Request #{@merge_request.to_reference} -%hr = render 'form' diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index 49d3039d0c97c85b95907142ed88a23439b19e56..5f244d3a6c306f7a463ca050164ea9936cdf535a 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -3,57 +3,8 @@ - page_title @milestone.title, _('Milestones') - page_description @milestone.description -.detail-page-header.milestone-page-header - .status-box{ class: status_box_class(@milestone) } - - if @milestone.closed? - = _('Closed') - - elsif @milestone.expired? - = _('Past due') - - elsif @milestone.upcoming? - = _('Upcoming') - - else - = _('Open') - .header-text-content - %span.identifier - %strong - = _('Milestone') - - if @milestone.due_date || @milestone.start_date - = milestone_date_range(@milestone) - .milestone-buttons - - if can?(current_user, :admin_milestone, @project) - = link_to edit_project_milestone_path(@project, @milestone), class: 'btn btn-grouped btn-nr' do - = _('Edit') - - - if @project.group - %button.js-promote-project-milestone-button.btn.btn-grouped{ data: { toggle: 'modal', - target: '#promote-milestone-modal', - milestone_title: @milestone.title, - group_name: @project.group.name, - url: promote_project_milestone_path(@milestone.project, @milestone), - container: 'body' }, - disabled: true, - type: 'button' } - = _('Promote') - #promote-milestone-modal - - - if @milestone.active? - = link_to _('Close milestone'), project_milestone_path(@project, @milestone, milestone: {state_event: :close }), method: :put, class: 'btn btn-close btn-nr btn-grouped' - - else - = link_to _('Reopen milestone'), project_milestone_path(@project, @milestone, milestone: {state_event: :activate }), method: :put, class: 'btn btn-reopen btn-nr btn-grouped' - - = render 'shared/milestones/delete_button' - - %a.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ href: '#' } - = icon('angle-double-left') - -.detail-page-description.milestone-detail - %h2.title.qa-milestone-title - = markdown_field(@milestone, :title) - - %div - - if @milestone.description.present? - .description.md - = markdown_field(@milestone, :description) += render 'shared/milestones/header', milestone: @milestone += render 'shared/milestones/description', milestone: @milestone = render_if_exists 'shared/milestones/burndown', milestone: @milestone, project: @project diff --git a/app/views/projects/mirrors/_authentication_method.html.haml b/app/views/projects/mirrors/_authentication_method.html.haml index ee82d68d398c346ae8918ec216971b1b6753ae57..a6978cba495fc236ba2338da3d018de18f692949 100644 --- a/app/views/projects/mirrors/_authentication_method.html.haml +++ b/app/views/projects/mirrors/_authentication_method.html.haml @@ -3,10 +3,12 @@ .form-group = f.label :auth_method, _('Authentication method'), class: 'label-bold' - = f.select :auth_method, - options_for_select(auth_options, mirror.auth_method), - {}, { class: "form-control js-mirror-auth-type qa-authentication-method" } - = f.hidden_field :auth_method, value: "password", class: "js-hidden-mirror-auth-type" + .select-wrapper + = f.select :auth_method, + options_for_select(auth_options, mirror.auth_method), + {}, { class: "form-control select-control js-mirror-auth-type qa-authentication-method" } + = icon('chevron-down') + = f.hidden_field :auth_method, value: "password", class: "js-hidden-mirror-auth-type" .form-group .collapse.js-well-changing-auth diff --git a/app/views/projects/mirrors/_mirror_repos_form.html.haml b/app/views/projects/mirrors/_mirror_repos_form.html.haml index b49f1d9315ef8fb131da1a7330bdb68411419bf6..dd794e03f4870e78aceab35fea41f67627f32bb6 100644 --- a/app/views/projects/mirrors/_mirror_repos_form.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_form.html.haml @@ -1,5 +1,7 @@ .form-group = label_tag :mirror_direction, _('Mirror direction'), class: 'label-light' - = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control js-mirror-direction qa-mirror-direction', disabled: true + .select-wrapper + = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control select-control js-mirror-direction qa-mirror-direction', disabled: true + = icon('chevron-down') = render partial: "projects/mirrors/mirror_repos_push", locals: { f: f } diff --git a/app/views/projects/pages/_access.html.haml b/app/views/projects/pages/_access.html.haml index 178f0acc5b965ba27fbd2d8136412a5cf782f099..08dcba2afd7259fb45946ac0650f3ecf15499143 100644 --- a/app/views/projects/pages/_access.html.haml +++ b/app/views/projects/pages/_access.html.haml @@ -13,5 +13,11 @@ - @project.pages_domains.each do |domain| %p = external_link(domain.url, domain.url) + - unless @project.public_pages? + .card-footer.alert-warning + - help_page = help_page_path('/user/project/pages/pages_access_control') + - link_start = '<a href="%{url}" target="_blank" class="alert-link" rel="noopener noreferrer">'.html_safe % { url: help_page } + - link_end = '</a>'.html_safe + = s_('GitLabPages|Access Control is enabled for this Pages website; only authorized users will be able to access it. To make your website publicly available, navigate to your project\'s %{strong_start}Settings > General > Visibility%{strong_end} and select %{strong_start}Everyone%{strong_end} in pages section. Read the %{link_start}documentation%{link_end} for more information.').html_safe % { link_start: link_start, link_end: link_end, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } .card-footer.alert-primary = s_('GitLabPages|It may take up to 30 minutes before the site is available after the first deployment.') diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml index b05491f2c6e7d8a20970d4b10fdf3de9d726a6c0..4676c7399f1a3d8f66546a9b9c7adff2c796edb4 100644 --- a/app/views/projects/pages/_list.html.haml +++ b/app/views/projects/pages/_list.html.haml @@ -21,11 +21,11 @@ %span.badge.badge-danger = s_('GitLabPages|Expired') %div - = link_to s_('GitLabPages|Details'), project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped" + = link_to s_('GitLabPages|Edit'), edit_project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped btn-success btn-inverted" = link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn btn-remove btn-sm btn-grouped" - if verification_enabled && domain.unverified? %li.list-group-item.bs-callout-warning - - details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe + - details_link_start = "<a href='#{edit_project_pages_domain_path(@project, domain)}'>".html_safe - details_link_end = '</a>'.html_safe = s_('GitLabPages|%{domain} is not verified. To learn how to verify ownership, visit your %{link_start}domain details%{link_end}.').html_safe % { domain: domain.domain, link_start: details_link_start, diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml index 0e1f281410a35995b2587495edc86f959faa25c4..3ec875978495cd9bc024738d4511c481b41a9fe2 100644 --- a/app/views/projects/pages/show.html.haml +++ b/app/views/projects/pages/show.html.haml @@ -1,23 +1,27 @@ - page_title 'Pages' -%h3.page-title.with-button - = s_('GitLabPages|Pages') +- if @project.pages_enabled? + %h3.page-title.with-button + = s_('GitLabPages|Pages') - - if can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https) - = link_to new_project_pages_domain_path(@project), class: 'btn btn-success float-right', title: s_('GitLabPages|New Domain') do - = s_('GitLabPages|New Domain') + - if can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https) + = link_to new_project_pages_domain_path(@project), class: 'btn btn-success float-right', title: s_('GitLabPages|New Domain') do + = s_('GitLabPages|New Domain') -%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' + %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' -%hr.clearfix + %hr.clearfix -= render 'access' -= render 'use' -- if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https - = render 'list' + = render 'access' + = render 'use' + - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https + = render 'list' + - else + = render 'no_domains' + = render 'destroy' - else - = render 'no_domains' -= render 'destroy' + .bs-callout.bs-callout-warning + = s_('GitLabPages|GitLab Pages are disabled for this project. You can enable them on your project\'s %{strong_start}Settings > General > Visibility%{strong_end} page.').html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } diff --git a/app/views/projects/pages_domains/_certificate.html.haml b/app/views/projects/pages_domains/_certificate.html.haml index 42631fca5e872f9a60bf8e20b471d10f0e6dae34..92d30e0b056b89d7108611bd2561dba86f12590c 100644 --- a/app/views/projects/pages_domains/_certificate.html.haml +++ b/app/views/projects/pages_domains/_certificate.html.haml @@ -1,18 +1,63 @@ -- if @domain.auto_ssl_enabled? - - if @domain.enabled? - - if @domain.certificate_text - %pre - = @domain.certificate_text - - else - .bs-callout.bs-callout-info - = _("GitLab is obtaining a Let's Encrypt SSL certificate for this domain. This process can take some time. Please try again later.") +- auto_ssl_available = ::Gitlab::LetsEncrypt.enabled? +- auto_ssl_enabled = @domain.auto_ssl_enabled? +- auto_ssl_available_and_enabled = auto_ssl_available && auto_ssl_enabled +- has_user_defined_certificate = @domain.certificate && @domain.certificate_user_provided? + +- if auto_ssl_available + .form-group.border-section + .row + .col-sm-2 + = _('Certificate') + .col-sm-10.js-auto-ssl-toggle-container + %label{ for: "pages_domain_auto_ssl_enabled_button" } + - lets_encrypt_link_url = "https://letsencrypt.org/" + - lets_encrypt_link_start = "<a href=\"%{lets_encrypt_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { lets_encrypt_link_url: lets_encrypt_link_url } + - lets_encrypt_link_end = "</a>".html_safe + = _("Automatic certificate management using %{lets_encrypt_link_start}Let's Encrypt%{lets_encrypt_link_end}").html_safe % { lets_encrypt_link_start: lets_encrypt_link_start, lets_encrypt_link_end: lets_encrypt_link_end } + %button{ type: "button", id: "pages_domain_auto_ssl_enabled_button", + class: "js-project-feature-toggle project-feature-toggle mt-2 #{"is-checked" if auto_ssl_available_and_enabled}", + "aria-label": _("Automatic certificate management using Let's Encrypt") } + = f.hidden_field :auto_ssl_enabled?, class: "js-project-feature-toggle-input" + %span.toggle-icon + = sprite_icon("status_success_borderless", size: 16, css_class: "toggle-icon-svg toggle-status-checked") + = sprite_icon("status_failed_borderless", size: 16, css_class: "toggle-icon-svg toggle-status-unchecked") + %p.text-secondary.mt-3 + - docs_link_url = help_page_path("user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md") + - docs_link_start = "<a href=\"%{docs_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { docs_link_url: docs_link_url } + - docs_link_end = "</a>".html_safe + = _("Let's Encrypt is a free, automated, and open certificate authority (CA) that gives digital certificates in order to enable HTTPS (SSL/TLS) for websites. Learn more about Let's Encrypt configuration by following the %{docs_link_start}documentation on GitLab Pages%{docs_link_end}.").html_safe % { docs_link_url: docs_link_url, docs_link_start: docs_link_start, docs_link_end: docs_link_end } + +.form-group.border-section.js-shown-unless-auto-ssl{ class: ("d-none" if auto_ssl_available_and_enabled) } + - if has_user_defined_certificate + .row + .col-sm-10.offset-sm-2 + .card + .card-header + = _('Certificate') + .d-flex.justify-content-between.align-items-center.p-3 + %span + = @domain.subject || _('missing') + = link_to _('Remove'), + clean_certificate_project_pages_domain_path(@project, @domain), + data: { confirm: _('Are you sure?') }, + class: 'btn btn-remove btn-sm', + method: :delete - else - .bs-callout.bs-callout-warning - = _("A Let's Encrypt SSL certificate can not be obtained until your domain is verified.") -- else - - if @domain.certificate_text - %pre - = @domain.certificate_text - - else - .light - = _("missing") + .row + .col-sm-10.offset-sm-2 + = f.label :user_provided_certificate, _("Certificate (PEM)") + = f.text_area :user_provided_certificate, + rows: 5, + class: "form-control js-enabled-unless-auto-ssl", + disabled: auto_ssl_available_and_enabled + %span.help-inline.text-muted= _("Upload a certificate for your domain with all intermediates") + .row + .col-sm-10.offset-sm-2 + = f.label :user_provided_key, _("Key (PEM)") + = f.text_area :user_provided_key, + rows: 5, + class: "form-control js-enabled-unless-auto-ssl", + disabled: auto_ssl_available_and_enabled + %span.help-inline.text-muted= _("Upload a private key for your certificate") + += render 'lets_encrypt_callout', auto_ssl_available_and_enabled: auto_ssl_available_and_enabled diff --git a/app/views/projects/pages_domains/_dns.html.haml b/app/views/projects/pages_domains/_dns.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..e4e590f0a98ac6d8b2cf0ff09d476af55450f3f2 --- /dev/null +++ b/app/views/projects/pages_domains/_dns.html.haml @@ -0,0 +1,33 @@ +- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? +- dns_record = "#{@domain.domain} CNAME #{@domain.project.pages_subdomain}.#{Settings.pages.host}." + +.form-group.border-section + .row + .col-sm-2 + = _("DNS") + .col-sm-10 + .input-group + = text_field_tag :domain_dns, dns_record , class: "monospace js-select-on-focus form-control", readonly: true + .input-group-append + = clipboard_button(target: '#domain_dns', class: 'btn-default input-group-text d-none d-sm-block') + %p.form-text.text-muted + = _("To access this domain create a new DNS record") +- if verification_enabled + - verification_record = "#{@domain.verification_domain} TXT #{@domain.keyed_verification_code}" + .form-group.border-section + .row + .col-sm-2 + = _("Verification status") + .col-sm-10 + .status-badge + - text, status = @domain.unverified? ? [_('Unverified'), 'badge-danger'] : [_('Verified'), 'badge-success'] + .badge{ class: status } + = text + = link_to sprite_icon("redo"), verify_project_pages_domain_path(@project, @domain), method: :post, class: "btn has-tooltip", title: _("Retry verification") + .input-group + = text_field_tag :domain_verification, verification_record, class: "monospace js-select-on-focus form-control", readonly: true + .input-group-append + = clipboard_button(target: '#domain_verification', class: 'btn-default d-none d-sm-block') + %p.form-text.text-muted + - link_to_help = link_to(_('verify ownership'), help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: '4-verify-the-domains-ownership')) + = _("To %{link_to_help} of your domain, add the above key to a TXT record within to your DNS configuration.").html_safe % { link_to_help: link_to_help } diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml index 4aa1e574d937a84ba6d3a4ff883fa10553fd997b..e06dab9be06cedd6c8426f11818486b50ed33f20 100644 --- a/app/views/projects/pages_domains/_form.html.haml +++ b/app/views/projects/pages_domains/_form.html.haml @@ -3,62 +3,25 @@ - @domain.errors.full_messages.each do |msg| = msg -.form-group.row - .col-sm-2.col-form-label - = f.label :domain, _("Domain") - .col-sm-10 - = f.text_field :domain, required: true, autocomplete: "off", class: "form-control", disabled: @domain.persisted? - -- if Gitlab.config.pages.external_https - - - auto_ssl_available = ::Gitlab::LetsEncrypt.enabled? - - auto_ssl_enabled = @domain.auto_ssl_enabled? - - auto_ssl_available_and_enabled = auto_ssl_available && auto_ssl_enabled - - - if auto_ssl_available - .form-group.row - .col-sm-2.col-form-label - %label{ for: "pages_domain_auto_ssl_enabled_button" } - - lets_encrypt_link_url = "https://letsencrypt.org/" - - lets_encrypt_link_start = "<a href=\"%{lets_encrypt_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { lets_encrypt_link_url: lets_encrypt_link_url } - - lets_encrypt_link_end = "</a>".html_safe - = _("Automatic certificate management using %{lets_encrypt_link_start}Let's Encrypt%{lets_encrypt_link_end}").html_safe % { lets_encrypt_link_start: lets_encrypt_link_start, lets_encrypt_link_end: lets_encrypt_link_end } - - .col-sm-10.js-auto-ssl-toggle-container - %button{ type: "button", id: "pages_domain_auto_ssl_enabled_button", - class: "js-project-feature-toggle project-feature-toggle mt-2 #{"is-checked" if auto_ssl_available_and_enabled}", - "aria-label": _("Automatic certificate management using Let's Encrypt") } - = f.hidden_field :auto_ssl_enabled?, class: "js-project-feature-toggle-input" - %span.toggle-icon - = sprite_icon("status_success_borderless", size: 16, css_class: "toggle-icon-svg toggle-status-checked") - = sprite_icon("status_failed_borderless", size: 16, css_class: "toggle-icon-svg toggle-status-unchecked") - %p.text-secondary.mt-3 - - docs_link_url = help_page_path("user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md") - - docs_link_start = "<a href=\"%{docs_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { docs_link_url: docs_link_url } - - docs_link_end = "</a>".html_safe - = _("Let's Encrypt is a free, automated, and open certificate authority (CA) that gives digital certificates in order to enable HTTPS (SSL/TLS) for websites. Learn more about Let's Encrypt configuration by following the %{docs_link_start}documentation on GitLab Pages%{docs_link_end}.").html_safe % { docs_link_url: docs_link_url, docs_link_start: docs_link_start, docs_link_end: docs_link_end } - - .js-shown-unless-auto-ssl{ class: ("d-none" if auto_ssl_available_and_enabled) } - .form-group.row - .col-sm-2.col-form-label - = f.label :user_provided_certificate, _("Certificate (PEM)") +.form-group.border-section + .row + - if @domain.persisted? + .col-sm-2 + = _("Domain") .col-sm-10 - = f.text_area :user_provided_certificate, - rows: 5, - class: "form-control js-enabled-unless-auto-ssl", - disabled: auto_ssl_available_and_enabled - %span.help-inline.text-muted= _("Upload a certificate for your domain with all intermediates") - - .form-group.row - .col-sm-2.col-form-label - = f.label :user_provided_key, _("Key (PEM)") + = external_link(@domain.url, @domain.url) + - else + .col-sm-2 + = f.label :domain, _("Domain") .col-sm-10 - = f.text_area :user_provided_key, - rows: 5, - class: "form-control js-enabled-unless-auto-ssl", - disabled: auto_ssl_available_and_enabled - %span.help-inline.text-muted= _("Upload a private key for your certificate") + .input-group + = f.text_field :domain, required: true, autocomplete: "off", class: "form-control" +- if @domain.persisted? + = render 'dns' + +- if Gitlab.config.pages.external_https + = render 'certificate', f: f - else - .nothing-here-block + .border-section.nothing-here-block = _("Support for custom certificates is disabled. Ask your system's administrator to enable it.") diff --git a/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml b/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..d6406a78fca4105addb49f63c785621578790623 --- /dev/null +++ b/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml @@ -0,0 +1,13 @@ +- if @domain.enabled? + - if @domain.auto_ssl_enabled && !@domain.certificate + .form-group.border-section.js-shown-if-auto-ssl{ class: ("d-none" unless auto_ssl_available_and_enabled) } + .row + .col-sm-10.offset-sm-2 + .bs-callout.bs-callout-info.mt-0 + = _("GitLab is obtaining a Let's Encrypt SSL certificate for this domain. This process can take some time. Please try again later.") +- else + .form-group.border-section.js-shown-if-auto-ssl{ class: ("d-none" unless auto_ssl_available_and_enabled) } + .row + .col-sm-10.offset-sm-2 + .bs-callout.bs-callout-warning.mt-0 + = _("A Let's Encrypt SSL certificate can not be obtained until your domain is verified.") diff --git a/app/views/projects/pages_domains/edit.html.haml b/app/views/projects/pages_domains/edit.html.haml index 7c0777e54961ae04699ddbb2e5f4550397ed495b..a08be65d7e4be6d28d4a1df4c20faed23843d2c9 100644 --- a/app/views/projects/pages_domains/edit.html.haml +++ b/app/views/projects/pages_domains/edit.html.haml @@ -1,12 +1,21 @@ - add_to_breadcrumbs _("Pages"), project_pages_path(@project) - breadcrumb_title @domain.domain - page_title @domain.domain + +- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? + +- if verification_enabled && @domain.unverified? + = content_for :flash_message do + .alert.alert-warning + .container-fluid.container-limited + = _("This domain is not verified. You will need to verify ownership before access is enabled.") + %h3.page-title - = @domain.domain + = _('Pages Domain') = render 'projects/pages_domains/helper_text' -%hr.clearfix %div = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'fieldset-form' } do |f| = render 'form', { f: f } - .form-actions + .form-actions.d-flex.justify-content-between = f.submit _('Save Changes'), class: "btn btn-success" + = link_to _('Cancel'), project_pages_path(@project), class: 'btn btn-default btn-inverse' diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml index e23ccb5d4c6984494fbb1528259d7647918d9313..3210bfe92318d381b2a0bf08220d79004f4c8e7d 100644 --- a/app/views/projects/pages_domains/new.html.haml +++ b/app/views/projects/pages_domains/new.html.haml @@ -3,7 +3,6 @@ %h3.page-title = _("New Pages Domain") = render 'projects/pages_domains/helper_text' -%hr.clearfix %div = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'fieldset-form' } do |f| = render 'form', { f: f } diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml index 33837e21c8d6b649d811cb40e994799779198638..8eec3d51835fdab7a0ef5ffb8c13f7a032ac2879 100644 --- a/app/views/projects/pages_domains/show.html.haml +++ b/app/views/projects/pages_domains/show.html.haml @@ -58,4 +58,4 @@ %td = _("Certificate") %td - = render 'certificate' + = render 'lets_encrypt_callout', auto_ssl_available_and_enabled: false diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 8c3518e3a29c6cdfcffa612b062a0dd4c1b67220..4d8cba5168d972b4de2562d2cb62b554d1669a75 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -1,3 +1,5 @@ +- test_reports_enabled = Feature.enabled?(:junit_pipeline_view) + .tabs-holder %ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator.nav.nav-tabs %li.js-pipeline-tab-link @@ -12,6 +14,11 @@ = link_to failures_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do = _('Failed Jobs') %span.badge.badge-pill.js-failures-counter= @pipeline.failed_builds.count + - if test_reports_enabled + %li.js-tests-tab-link + = link_to test_report_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-tests', action: 'test_report', toggle: 'tab' }, class: 'test-tab' do + = s_('TestReports|Tests') + %span.badge.badge-pill= pipeline.test_reports.total_count = render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project .tab-content @@ -32,10 +39,6 @@ %th = render partial: "projects/stage/stage", collection: pipeline.legacy_stages, as: :stage - - elsif pipeline.project.builds_enabled? && !pipeline.ci_yaml_file - .bs-callout.bs-callout-warning - = _("%{gitlab_ci_yml} not found in this commit") % { gitlab_ci_yml: ".gitlab-ci.yml" } - - if @pipeline.failed_builds.present? #js-tab-failures.build-failures.tab-pane.build-page %table.table.responsive-table.ci-table.responsive-table-sm-rounded @@ -71,4 +74,7 @@ %pre.build-trace.build-trace-rounded %code.bash.js-build-output = build_summary(build) + + #js-tab-tests.tab-pane + #js-pipeline-tests-detail = render_if_exists "projects/pipelines/tabs_content", pipeline: @pipeline, project: @project diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index bfcaa09ae8c2ef88892a498597749b8f995d6294..a3e46a0939cd7c4ebd008229da362b2c5d385db1 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -23,6 +23,13 @@ %label = s_('Pipeline|Variables') %ul.ci-variable-list + - if params[:var] + - params[:var].each do |variable| + = render 'ci/variables/url_query_variable_row', form_field: 'pipeline', variable: variable + - if params[:file_var] + - params[:file_var].each do |variable| + - variable.push("file") + = render 'ci/variables/url_query_variable_row', form_field: 'pipeline', variable: variable = render 'ci/variables/variable_row', form_field: 'pipeline', only_key_value: true .form-text.text-muted = (s_("Pipeline|Specify variable values to be used in this run. The values specified in %{settings_link} will be used by default.") % {settings_link: settings_link}).html_safe diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 2b2133b8296279862be4a9ce7f07604e0b586960..f0b3ab24ea05df6ed4b58df3c917cf72935eec3c 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -20,4 +20,5 @@ - else = render "projects/pipelines/with_tabs", pipeline: @pipeline -.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json) } } +.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json), + test_report_endpoint: test_report_project_pipeline_path(@project, @pipeline, format: :json) } } diff --git a/app/views/projects/protected_branches/shared/_branches_list.html.haml b/app/views/projects/protected_branches/shared/_branches_list.html.haml index 9dff251101b25e65639b8a5ee4c5c02c7e3356f6..f07de81d7fdb804037e2e6a5128d249c97189f0e 100644 --- a/app/views/projects/protected_branches/shared/_branches_list.html.haml +++ b/app/views/projects/protected_branches/shared/_branches_list.html.haml @@ -5,6 +5,7 @@ %p.settings-message.text-center = s_("ProtectedBranch|There are currently no protected branches, protect a branch with the form above.") - else + .flash-container %table.table.table-bordered %colgroup %col{ width: "20%" } @@ -27,8 +28,6 @@ - if can_admin_project %th %tbody - %tr - %td.flash-container{ colspan: 5 } = yield = paginate @protected_branches, theme: 'gitlab' diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml index 959a2423e0240e3170b6dd713f4b39d2a4e42b83..582f3d6fce48b982e34241ae21aeb415ba546c0f 100644 --- a/app/views/projects/services/_form.html.haml +++ b/app/views/projects/services/_form.html.haml @@ -6,7 +6,6 @@ - hide_class = 'd-none' if @service.activated? != value %span.js-service-active-status{ class: hide_class, data: { value: value.to_s } } = boolean_to_icon value - %p= #{@service.description}. - if @service.respond_to?(:detailed_description) %p= @service.detailed_description diff --git a/app/views/projects/services/_index.html.haml b/app/views/projects/services/_index.html.haml index 7748a7a6a8eb6423be32f71515c2aae03e0596d4..3f33d72d3ec42b379611de66644bcd38cb981290 100644 --- a/app/views/projects/services/_index.html.haml +++ b/app/views/projects/services/_index.html.haml @@ -21,7 +21,7 @@ %td{ "aria-label" => (service.activated? ? s_("ProjectService|%{service_title}: status on") : s_("ProjectService|%{service_title}: status off")) % { service_title: service.title } } = boolean_to_icon service.activated? %td - = link_to edit_project_service_path(@project, service.to_param) do + = link_to edit_project_service_path(@project, service.to_param), { data: { qa_selector: "#{service.title.downcase.gsub(/[\s\(\)]/,'_')}_link" } } do %strong= service.title %td.d-none.d-sm-block = service.description diff --git a/app/views/projects/services/edit.html.haml b/app/views/projects/services/edit.html.haml index fc20bc52d1c0be35e6309b65628d6a6df6afc0b4..1e7903535c6cebf52baaaced2fa37aafb86bd999 100644 --- a/app/views/projects/services/edit.html.haml +++ b/app/views/projects/services/edit.html.haml @@ -1,6 +1,7 @@ -- breadcrumb_title s_("ProjectService|Integrations") +- breadcrumb_title @service.title - page_title @service.title, s_("ProjectService|Services") - add_to_breadcrumbs(s_("ProjectService|Settings"), edit_project_path(@project)) +- add_to_breadcrumbs(s_("ProjectService|Integrations"), namespace_project_settings_integrations_path) = render 'deprecated_message' if @service.deprecation_message diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index 66ed1cadf6ad03e3166df991496b1043561564d8..ea815be23c1781b1d32c8fc337d717c2b84dc008 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -98,7 +98,7 @@ %span.input-group-append .input-group-text / %p.form-text.text-muted - = _("A regular expression that will be used to find the test coverage output in the job trace. Leave blank to disable") + = _("A regular expression that will be used to find the test coverage output in the job log. Leave blank to disable") = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'test-coverage-parsing'), target: '_blank' .bs-callout.bs-callout-info %p= _("Below are examples of regex for existing tools:") diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 87000e8270b6cc10d2c543bbc98e72159700d35a..862db23e856bba1eb60b95830ebc4812859f078f 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -37,7 +37,8 @@ %button.btn.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p - = _("Register and see your runners for this project.") + = _("Runners are processes that pick up and execute jobs for GitLab. Here you can register and see your Runners for this project.") + = link_to s_('More information'), help_page_path('ci/runners/README') .settings-content = render 'projects/runners/index' diff --git a/app/views/projects/settings/operations/_grafana_integration.html.haml b/app/views/projects/settings/operations/_grafana_integration.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..cd5b5abd9cefd34cb9caa44a135b5edca8b2db36 --- /dev/null +++ b/app/views/projects/settings/operations/_grafana_integration.html.haml @@ -0,0 +1,2 @@ +.js-grafana-integration{ data: { operations_settings_endpoint: project_settings_operations_path(@project), + grafana_integration: { url: grafana_integration_url, token: grafana_integration_token, enabled: grafana_integration_enabled?.to_s } } } diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml index 0a7a155bc1239a713de1b35b564a03f7857d0db3..3c955e5f5580bb865471acb1a13c92801ceb1c6e 100644 --- a/app/views/projects/settings/operations/show.html.haml +++ b/app/views/projects/settings/operations/show.html.haml @@ -5,4 +5,5 @@ = render_if_exists 'projects/settings/operations/incidents' = render 'projects/settings/operations/error_tracking' = render 'projects/settings/operations/external_dashboard' += render 'projects/settings/operations/grafana_integration' = render_if_exists 'projects/settings/operations/tracing' diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index b58af545439eb4358b2e47002af683d180b7f18a..c5653c3dd5acc7a784cd5c3d44570d127529c091 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -6,7 +6,7 @@ = render partial: 'flash_messages', locals: { project: @project } -- if !@project.empty_repo? && can?(current_user, :download_code, @project) +- if !@project.empty_repo? && can?(current_user, :download_code, @project) && !vue_file_list_enabled? - signatures_path = project_signatures_path(@project, @project.default_branch) .js-signature-container{ data: { 'signatures-path': signatures_path } } diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml index 4f6c7e1f9a6bdd46a98a662a2314a44f429f1caa..fef019e1b6991fb10dab1ae5b49c7741b88f6c09 100644 --- a/app/views/projects/tree/_readme.html.haml +++ b/app/views/projects/tree/_readme.html.haml @@ -1,5 +1,5 @@ - if readme.rich_viewer - %article.file-holder.readme-holder{ id: 'readme', class: [("limited-width-container" unless fluid_layout), ("js-hide-on-navigation" if vue_file_list_enabled?)] } + %article.file-holder.readme-holder{ id: 'readme', class: [("limited-width-container" unless fluid_layout), ("js-show-on-root" if vue_file_list_enabled?)] } .js-file-title.file-title = blob_icon readme.mode, readme.name = link_to project_blob_path(@project, tree_join(@ref, readme.path)) do diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 38422d4533d92941178e34d952efbbc88ce7113b..127734ddfd7a313ec1fc4b413942faad06f479fe 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -77,15 +77,21 @@ .tree-controls = render_if_exists 'projects/tree/lock_link' - = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' + - if vue_file_list_enabled? + #js-tree-history-link.d-inline-block{ data: { history_link: project_commits_path(@project, @ref) } } + - else + = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' = render 'projects/find_file_link' - if can_create_mr_from_fork = succeed " " do - if can_collaborate || current_user&.already_forked?(@project) - = link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do - = _('Web IDE') + - if vue_file_list_enabled? + #js-tree-web-ide-link.d-inline-block + - else + = link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do + = _('Web IDE') - else = link_to '#modal-confirm-fork', class: 'btn btn-default qa-web-ide-button', data: { target: '#modal-confirm-fork', toggle: 'modal'} do = _('Web IDE') diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index 39b29a20df6232310e98a0847811b7327dbeeb12..65f5bc31d2e1341b86a294c596237c79bf64cebf 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -6,7 +6,8 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_commits_url(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits") -.js-signature-container{ data: { 'signatures-path': signatures_path } } +- unless vue_file_list_enabled? + .js-signature-container{ data: { 'signatures-path': signatures_path } } = render 'projects/last_push' = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id) diff --git a/app/views/registrations/welcome.html.haml b/app/views/registrations/welcome.html.haml index 02ab974ecc0a90d69671f4ebd2a1e34bdd0022a5..7b92f5070df0a2697cfe888d6c4a3284988f1355 100644 --- a/app/views/registrations/welcome.html.haml +++ b/app/views/registrations/welcome.html.haml @@ -1,10 +1,10 @@ -- content_for(:page_title, _('Welcome to GitLab<br>%{username}!' % { username: html_escape(current_user.username) }).html_safe) +- content_for(:page_title, _('Welcome to GitLab @%{username}!') % { username: current_user.username }) - max_name_length = 128 .text-center.mb-3 - = _('In order to tailor your experience with GitLab<br>we would like to know a bit more about you.').html_safe + = _('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 .signup-body - = form_for(current_user, url: users_sign_up_update_role_path, html: { class: 'new_new_user gl-show-field-errors', 'aria-live' => 'assertive' }) do |f| + = 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 @@ -13,5 +13,14 @@ .form-group = f.label :role, _('Role'), class: 'label-bold' = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, {}, class: 'form-control' + .form-group + = f.label :setup_for_company, _('Are you setting up GitLab for a company?'), class: 'label-bold' + .d-flex.justify-content-center + .w-25 + = f.radio_button :setup_for_company, true + = f.label :setup_for_company, _('Yes'), value: 'true' + .w-25 + = f.radio_button :setup_for_company, false + = f.label :setup_for_company, _('No'), value: 'false' .submit-container.mt-3 = f.submit _('Get started!'), class: 'btn-register btn btn-block mb-0 p-2' diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml index eae2a491ceb1f0539d3b320a799564489a65b0cd..84198489e41cb32149e63b1a2cf5b2ea51788923 100644 --- a/app/views/search/_category.html.haml +++ b/app/views/search/_category.html.haml @@ -1,5 +1,5 @@ - users = capture_haml do - - if search_tabs?(:members) + - if show_user_search_tab? = search_filter_link 'users', _("Users") .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index de9947528cf0cf83ee96340124b8e7a94d159936..629a5a045b1787a0c8679f642a2bcb842853d0e9 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -1,6 +1,7 @@ - if @search_objects.to_a.empty? = render partial: "search/results/empty" = render_if_exists 'shared/promotions/promote_advanced_search' + = render_if_exists 'search/form_revert_to_basic' - else .row-content-block.d-md-flex.text-left.align-items-center - unless @search_objects.is_a?(Kaminari::PaginatableWithoutCount) diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml index bdad07f36d1208a726ee2ff8ed51f92a565441ff..4fb72b26955508d08ce59fe3be623c6480ca757e 100644 --- a/app/views/search/results/_blob.html.haml +++ b/app/views/search/results/_blob.html.haml @@ -2,6 +2,6 @@ - return unless project - blob = parse_search_result(blob) -- blob_link = project_blob_path(project, tree_join(blob.ref, blob.filename)) +- blob_link = project_blob_path(project, tree_join(blob.ref, blob.path)) -= render partial: 'search/results/blob_data', locals: { blob: blob, project: project, file_name: blob.filename, blob_link: blob_link } += render partial: 'search/results/blob_data', locals: { blob: blob, project: project, path: blob.path, blob_link: blob_link } diff --git a/app/views/search/results/_blob_data.html.haml b/app/views/search/results/_blob_data.html.haml index 36b6ea7bd37c103087b8b4e42bbb10db779b7dc8..01e42224428f32284de9084af57db805be2cc7fd 100644 --- a/app/views/search/results/_blob_data.html.haml +++ b/app/views/search/results/_blob_data.html.haml @@ -4,7 +4,7 @@ = link_to blob_link do %i.fa.fa-file %strong - = search_blob_title(project, file_name) + = search_blob_title(project, path) - if blob.data .file-content.code.term{ data: { qa_selector: 'file_text_content' } } = render 'shared/file_highlight', blob: blob, first_line_number: blob.startline diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml index f17dae0a94ccc69562e9ce4869d06c7f61f06236..37f4efee9d2a54e06f14fc38080c752fece16d1f 100644 --- a/app/views/search/results/_snippet_blob.html.haml +++ b/app/views/search/results/_snippet_blob.html.haml @@ -1,6 +1,7 @@ - snippet_blob = chunk_snippet(snippet_blob, @search_term) - snippet = snippet_blob[:snippet_object] - snippet_chunks = snippet_blob[:snippet_chunks] +- snippet_path = reliable_snippet_path(snippet) .search-result-row %span @@ -11,7 +12,6 @@ = snippet.author_name %span.light= time_ago_with_tooltip(snippet.created_at) %h4.snippet-title - - snippet_path = reliable_snippet_path(snippet) .file-holder .js-file-title.file-title = link_to snippet_path do diff --git a/app/views/search/results/_snippet_title.html.haml b/app/views/search/results/_snippet_title.html.haml index 1e01088d9e69369ea5b0e740529c77dfd8504de8..7280146720efa88671b99f8418386924cdb75ab5 100644 --- a/app/views/search/results/_snippet_title.html.haml +++ b/app/views/search/results/_snippet_title.html.haml @@ -2,10 +2,7 @@ %h4.snippet-title.term = link_to reliable_snippet_path(snippet_title) do = truncate(snippet_title.title, length: 60) - - if snippet_title.private? - %span.badge.badge-gray - %i.fa.fa-lock - = _("private") + = snippet_badge(snippet_title) %span.cgray.monospace.tiny.float-right.term = snippet_title.file_name diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml index b351ecd4edf3fbc868159f0f960b34131491dd81..9afed2bbecc2dd86493032c060d1617c56447020 100644 --- a/app/views/search/results/_wiki_blob.html.haml +++ b/app/views/search/results/_wiki_blob.html.haml @@ -2,4 +2,4 @@ - wiki_blob = parse_search_result(wiki_blob) - wiki_blob_link = project_wiki_path(project, wiki_blob.basename) -= render partial: 'search/results/blob_data', locals: { blob: wiki_blob, project: project, file_name: wiki_blob.filename, blob_link: wiki_blob_link } += render partial: 'search/results/blob_data', locals: { blob: wiki_blob, project: project, path: wiki_blob.path, blob_link: wiki_blob_link } diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index cb83487827609ead21af706767f09807514850de..3e805189055d7c991c6e0bc8ff46681e3579b257 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -22,6 +22,3 @@ .input-group-append = clipboard_button(target: '#project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard") - = render_if_exists 'shared/geo_modal_button' - -= render_if_exists 'shared/geo_modal', project: project diff --git a/app/views/shared/_field.html.haml b/app/views/shared/_field.html.haml index 606d0f241aa5311e77b7bdf6a3f35fe623e78086..a7ad6d6f2c4140939f9a19fbcc91f814a108d8fe 100644 --- a/app/views/shared/_field.html.haml +++ b/app/views/shared/_field.html.haml @@ -16,7 +16,7 @@ = form.label name, title, class: "col-form-label col-sm-2" .col-sm-10 - if type == 'text' - = form.text_field name, class: "form-control", placeholder: placeholder, required: required, disabled: disabled + = form.text_field name, class: "form-control", placeholder: placeholder, required: required, disabled: disabled, data: { qa_selector: "#{name.downcase.gsub('\s', '')}_field" } - elsif type == 'textarea' = form.text_area name, rows: 5, class: "form-control", placeholder: placeholder, required: required, disabled: disabled - elsif type == 'checkbox' @@ -24,6 +24,6 @@ - elsif type == 'select' = form.select name, options_for_select(choices, value ? value : default_choice), {}, { class: "form-control", disabled: disabled} - elsif type == 'password' - = form.password_field name, autocomplete: "new-password", placeholder: placeholder, class: "form-control", required: value.blank? && required, disabled: disabled + = form.password_field name, autocomplete: "new-password", placeholder: placeholder, class: "form-control", required: value.blank? && required, disabled: disabled, data: { qa_selector: "#{name.downcase.gsub('\s', '')}_field" } - if help %span.form-text.text-muted= help diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml index 959792718ca32998d059f4b53c75c4c63fbb771e..9a65981ed581d5dbb92a35cd7f5249eed77ed6ef 100644 --- a/app/views/shared/_group_form.html.haml +++ b/app/views/shared/_group_form.html.haml @@ -22,11 +22,16 @@ - if parent %strong= parent.full_path + '/' = f.hidden_field :parent_id - = f.text_field :path, placeholder: 'my-awesome-group', class: 'form-control', + = f.text_field :path, placeholder: 'my-awesome-group', class: 'form-control js-validate-group-path', autofocus: local_assigns[:autofocus] || false, required: true, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, title: _('Please choose a group URL with no special characters.'), "data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}" + %p.validation-error.gl-field-error.field-validation.hide + = _('Group path is already taken. Suggestions: ') + %span.gl-path-suggestions + %p.validation-success.gl-field-success.field-validation.hide= _('Group path is available.') + %p.validation-pending.gl-field-error-ignore.field-validation.hide= _('Checking group path availability...') - if @group.persisted? .alert.alert-warning.prepend-top-10 diff --git a/app/views/shared/_mobile_clone_panel.html.haml b/app/views/shared/_mobile_clone_panel.html.haml index 1e6b6f7c79b3f8213cd2dd28949c571fd5c24ab0..2887acf7cd7e7dead57f84f03c987f1dfc8fa73d 100644 --- a/app/views/shared/_mobile_clone_panel.html.haml +++ b/app/views/shared/_mobile_clone_panel.html.haml @@ -4,7 +4,7 @@ .btn-group.mobile-git-clone.js-mobile-git-clone.btn-block = clipboard_button(button_text: default_clone_label, text: default_url_to_repo(project), hide_button_icon: true, class: "btn-primary flex-fill bold justify-content-center input-group-text clone-dropdown-btn js-clone-dropdown-label") - %button.btn.btn-primary.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { toggle: "dropdown" } } + %button.btn.btn-primary.dropdown-toggle.js-dropdown-toggle.flex-grow-0.d-flex-center{ type: "button", data: { toggle: "dropdown" } } = sprite_icon("arrow-down", css_class: "dropdown-btn-icon icon") %ul.dropdown-menu.dropdown-menu-selectable.dropdown-menu-right.clone-options-dropdown{ data: { dropdown: true } } - if ssh_enabled? diff --git a/app/views/shared/_personal_access_tokens_form.html.haml b/app/views/shared/_personal_access_tokens_form.html.haml index 1d96feda3b024a6eb96d79e9864e876a5880a078..ca0b473addf003af1521d4a0e43ebcd51b368bce 100644 --- a/app/views/shared/_personal_access_tokens_form.html.haml +++ b/app/views/shared/_personal_access_tokens_form.html.haml @@ -19,7 +19,6 @@ = f.label :expires_at, _('Expires at'), class: 'label-bold' .input-icon-wrapper = f.text_field :expires_at, class: "datepicker form-control", placeholder: 'YYYY-MM-DD' - = icon('calendar', { class: 'input-icon-right' }) .form-group = f.label :scopes, _('Scopes'), class: 'label-bold' diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml index 6fa61c15493fe7794a92ffc53eecc4af37cbb75c..627a1eb6eae01057661e3091a96e281de0d16e8f 100644 --- a/app/views/shared/_service_settings.html.haml +++ b/app/views/shared/_service_settings.html.haml @@ -12,7 +12,7 @@ .form-group.row = form.label :active, "Active", class: "col-form-label col-sm-2" .col-sm-10 - = form.check_box :active, disabled: disable_fields_service?(@service) + = form.check_box :active, disabled: disable_fields_service?(@service), data: { qa_selector: 'active_checkbox' } - if @service.configurable_events.present? .form-group.row diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml index be78fd0ccfb172d80f89739e95cd8b5b63db3c26..9db6184ebca6d301c66798e30c3eea5eb9735a8d 100644 --- a/app/views/shared/form_elements/_description.html.haml +++ b/app/views/shared/form_elements/_description.html.haml @@ -2,6 +2,7 @@ - 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? - if supports_quick_actions @@ -16,7 +17,7 @@ = 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', - placeholder: "Write a comment or drag your files here…", + placeholder: placeholder, supports_quick_actions: supports_quick_actions = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions .clearfix diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 5e2b5f95ee3dc63e1f9edfd7a0f194082083398a..0fb23adc31f7b14929604aea7d65386d7b8e57af 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -12,6 +12,8 @@ = link_to "the #{issuable.class.model_name.human.downcase}", polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), target: "_blank", rel: 'noopener noreferrer' and make sure your changes will not unintentionally remove theirs += render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form + .form-group.row = form.label :title, class: 'col-form-label col-sm-2' @@ -34,8 +36,6 @@ = render_if_exists 'shared/issuable/approvals', issuable: issuable, presenter: presenter, form: form -= render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form - = render 'shared/issuable/form/merge_params', issuable: issuable = render 'shared/issuable/form/contribution', issuable: issuable, form: form diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 9d580930fb803a1cc953e20bc6ea6dd561bbc3f8..d341520e4a2116d9bfb0f56c3ca7a951411e1e86 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -5,155 +5,170 @@ - user_can_admin_list = board && can?(current_user, :admin_list, board.resource_parent) .issues-filters{ class: ("w-100" if type == :boards_modal) } - .issues-details-filters.filtered-search-block.d-flex.flex-column.flex-md-row{ class: block_css_class, "v-pre" => type == :boards_modal } - - if type == :boards - = render "shared/boards/switcher", board: board - = form_tag page_filter_path, method: :get, class: 'filter-form js-filter-form w-100' do - - if params[:search].present? - = hidden_field_tag :search, params[:search] - - if @can_bulk_update - .check-all-holder.d-none.d-sm-block.hidden - = check_box_tag "check-all-issues", nil, false, class: "check-all-issues left" - .issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row - .filtered-search-box - - if type != :boards_modal && type != :boards - = dropdown_tag(_('Recent searches'), - options: { wrapper_class: "filtered-search-history-dropdown-wrapper", - toggle_class: "filtered-search-history-dropdown-toggle-button", - dropdown_class: "filtered-search-history-dropdown", - content_class: "filtered-search-history-dropdown-content" }) do - .js-filtered-search-history-dropdown{ data: { full_path: search_history_storage_prefix } } - .filtered-search-box-input-container.droplab-dropdown - .scroll-container - %ul.tokens-container.list-unstyled - %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 - %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}} - #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu - - if current_user + .issues-details-filters.filtered-search-block.d-flex.flex-column.flex-lg-row{ class: block_css_class, "v-pre" => type == :boards_modal } + .d-flex.flex-column.flex-md-row.flex-grow-1.mb-lg-0.mb-md-2.mb-sm-0 + - if type == :boards + = render "shared/boards/switcher", board: board + = form_tag page_filter_path, method: :get, class: 'filter-form js-filter-form w-100' do + - if params[:search].present? + = hidden_field_tag :search, params[:search] + - if @can_bulk_update + .check-all-holder.d-none.d-sm-block.hidden + = check_box_tag "check-all-issues", nil, false, class: "check-all-issues left" + .issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row + .filtered-search-box + - if type != :boards_modal && type != :boards + = dropdown_tag(_('Recent searches'), + options: { wrapper_class: "filtered-search-history-dropdown-wrapper", + toggle_class: "filtered-search-history-dropdown-toggle-button", + dropdown_class: "filtered-search-history-dropdown", + content_class: "filtered-search-history-dropdown-content" }) do + .js-filtered-search-history-dropdown{ data: { full_path: search_history_storage_prefix } } + .filtered-search-box-input-container.droplab-dropdown + .scroll-container + %ul.tokens-container.list-unstyled + %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 } } - = render 'shared/issuable/user_dropdown_item', - user: current_user - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - = render 'shared/issuable/user_dropdown_item', - user: User.new(username: '{{username}}', name: '{{name}}'), - avatar: { lazy: true, url: '{{avatar_url}}' } - #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'None' } } - %button.btn.btn-link{ type: 'button' } - = _('None') - %li.filter-dropdown-item{ data: { value: 'Any' } } - %button.btn.btn-link{ type: 'button' } - = _('Any') - %li.divider.droplab-item-ignore + %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 + %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}} + #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu - if current_user + %ul{ data: { dropdown: true } } + = render 'shared/issuable/user_dropdown_item', + user: current_user + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + = render 'shared/issuable/user_dropdown_item', + user: User.new(username: '{{username}}', name: '{{name}}'), + avatar: { lazy: true, url: '{{avatar_url}}' } + #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'None' } } + %button.btn.btn-link{ type: 'button' } + = _('None') + %li.filter-dropdown-item{ data: { value: 'Any' } } + %button.btn.btn-link{ type: 'button' } + = _('Any') + %li.divider.droplab-item-ignore + - if current_user + = render 'shared/issuable/user_dropdown_item', + user: current_user + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } = render 'shared/issuable/user_dropdown_item', - user: current_user - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - = render 'shared/issuable/user_dropdown_item', - user: User.new(username: '{{username}}', name: '{{name}}'), - avatar: { lazy: true, url: '{{avatar_url}}' } - = render_if_exists 'shared/issuable/approver_dropdown' - #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'None' } } - %button.btn.btn-link{ type: 'button' } - = _('None') - %li.filter-dropdown-item{ data: { value: 'Any' } } - %button.btn.btn-link{ type: 'button' } - = _('Any') - %li.filter-dropdown-item{ data: { value: 'Upcoming' } } - %button.btn.btn-link{ type: 'button' } - = _('Upcoming') - %li.filter-dropdown-item{ data: { value: 'Started' } } - %button.btn.btn-link{ type: 'button' } - = _('Started') - %li.divider.droplab-item-ignore - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link.js-data-value{ type: 'button' } - {{title}} - #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'None' } } - %button.btn.btn-link{ type: 'button' } - = _('None') - %li.filter-dropdown-item{ data: { value: 'Any' } } - %button.btn.btn-link{ type: 'button' } - = _('Any') - %li.divider.droplab-item-ignore - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link{ type: 'button' } - %span.dropdown-label-box{ style: 'background: {{color}}' } - %span.label-title.js-data-value + user: User.new(username: '{{username}}', name: '{{name}}'), + avatar: { lazy: true, url: '{{avatar_url}}' } + = render_if_exists 'shared/issuable/approver_dropdown' + #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'None' } } + %button.btn.btn-link{ type: 'button' } + = _('None') + %li.filter-dropdown-item{ data: { value: 'Any' } } + %button.btn.btn-link{ type: 'button' } + = _('Any') + %li.filter-dropdown-item{ data: { value: 'Upcoming' } } + %button.btn.btn-link{ type: 'button' } + = _('Upcoming') + %li.filter-dropdown-item{ data: { value: 'Started' } } + %button.btn.btn-link{ type: 'button' } + = _('Started') + %li.divider.droplab-item-ignore + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.js-data-value{ type: 'button' } + {{title}} + #js-dropdown-release.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'None' } } + %button.btn.btn-link{ type: 'button' } + = _('None') + %li.filter-dropdown-item{ data: { value: 'Any' } } + %button.btn.btn-link{ type: 'button' } + = _('Any') + %li.divider.droplab-item-ignore + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.js-data-value{ type: 'button' } + {{title}} + #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'None' } } + %button.btn.btn-link{ type: 'button' } + = _('None') + %li.filter-dropdown-item{ data: { value: 'Any' } } + %button.btn.btn-link{ type: 'button' } + = _('Any') + %li.divider.droplab-item-ignore + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link{ type: 'button' } + %span.dropdown-label-box{ style: 'background: {{color}}' } + %span.label-title.js-data-value + {{title}} + #js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'None' } } + %button.btn.btn-link{ type: 'button' } + = _('None') + %li.filter-dropdown-item{ data: { value: 'Any' } } + %button.btn.btn-link{ type: 'button' } + = _('Any') + %li.divider.droplab-item-ignore + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link{ type: 'button' } + %gl-emoji + %span.js-data-value.prepend-left-10 + {{name}} + #js-dropdown-wip.filtered-search-input-dropdown-menu.dropdown-menu + %ul.filter-dropdown{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } } + %button.btn.btn-link{ type: 'button' } + = _('Yes') + %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } } + %button.btn.btn-link{ type: 'button' } + = _('No') + #js-dropdown-confidential.filtered-search-input-dropdown-menu.dropdown-menu + %ul.filter-dropdown{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } } + %button.btn.btn-link{ type: 'button' } + = _('Yes') + %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } } + %button.btn.btn-link{ type: 'button' } + = _('No') + #js-dropdown-target-branch.filtered-search-input-dropdown-menu.dropdown-menu + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.js-data-value.monospace {{title}} - #js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'None' } } - %button.btn.btn-link{ type: 'button' } - = _('None') - %li.filter-dropdown-item{ data: { value: 'Any' } } - %button.btn.btn-link{ type: 'button' } - = _('Any') - %li.divider.droplab-item-ignore - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link{ type: 'button' } - %gl-emoji - %span.js-data-value.prepend-left-10 - {{name}} - #js-dropdown-wip.filtered-search-input-dropdown-menu.dropdown-menu - %ul.filter-dropdown{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } } - %button.btn.btn-link{ type: 'button' } - = _('Yes') - %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } } - %button.btn.btn-link{ type: 'button' } - = _('No') - #js-dropdown-confidential.filtered-search-input-dropdown-menu.dropdown-menu - %ul.filter-dropdown{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } } - %button.btn.btn-link{ type: 'button' } - = _('Yes') - %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } } - %button.btn.btn-link{ type: 'button' } - = _('No') - #js-dropdown-target-branch.filtered-search-input-dropdown-menu.dropdown-menu - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link.js-data-value.monospace - {{title}} - = render_if_exists 'shared/issuable/filter_weight', type: type + = render_if_exists 'shared/issuable/filter_weight', type: type - %button.clear-search.hidden{ type: 'button' } - = icon('times') - .filter-dropdown-container.d-flex.flex-column.flex-md-row - - if type == :boards - .js-board-config{ data: { can_admin_list: user_can_admin_list, has_scope: board.scoped? } } - - if user_can_admin_list - = render 'shared/issuable/board_create_list_dropdown', board: board - - 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 - = render 'shared/issuable/sort_dropdown' + %button.clear-search.hidden{ type: 'button' } + = icon('times') + .filter-dropdown-container.d-flex.flex-column.flex-md-row + #js-board-labels-toggle + - if type == :boards + .js-board-config{ data: { can_admin_list: user_can_admin_list, has_scope: board.scoped? } } + - if user_can_admin_list + = render 'shared/issuable/board_create_list_dropdown', board: board + - 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 + = render 'shared/issuable/sort_dropdown' diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index c8b2adcf084633959467648859773c1d282b49ee..2170b88c7c387e724b1a70950ac649389ff1d91a 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -141,13 +141,7 @@ .js-sidebar-participants-entry-point - if signed_in - - if issuable_sidebar[:project_emails_disabled] - .block.js-emails-disabled - .sidebar-collapsed-icon.has-tooltip{ title: notification_description(:owner_disabled), data: { placement: "left", container: "body", boundary: 'viewport' } } - = notification_setting_icon - .hide-collapsed= notification_description(:owner_disabled) - - else - .js-sidebar-subscriptions-entry-point + .js-sidebar-subscriptions-entry-point - project_ref = issuable_sidebar[:reference] .block.project-reference diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml index fbc96baa0f741072ec8dfb76b55ac01c51dfdc29..29ac17c43b9450380b432917c39a75c389c3cc0b 100644 --- a/app/views/shared/issuable/form/_branch_chooser.html.haml +++ b/app/views/shared/issuable/form/_branch_chooser.html.haml @@ -4,21 +4,20 @@ - return unless issuable.is_a?(MergeRequest) - return if issuable.closed_without_fork? -%hr -- if issuable.new_record? - .form-group.row - = form.label :source_branch, class: 'col-form-label col-sm-2' - .col-sm-10 - .issuable-form-select-holder - = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2 ref-name', disabled: true }) -.form-group.row - = form.label :target_branch, class: 'col-form-label col-sm-2' - .col-sm-10.target-branch-select-dropdown-container - .issuable-form-select-holder - = form.hidden_field(:target_branch, - { class: 'target_branch js-target-branch-select ref-name', - disabled: issuable.new_record?, - data: { placeholder: "Select branch", endpoint: refs_project_path(@project, sort: 'updated_desc', find: 'branches') }}) +- source_title, target_title = format_mr_branch_names(@merge_request) + +.form-group.row.d-flex.gl-pl-3.gl-pr-3.branch-selector + .align-self-center + %span= s_('From %{source_title} into').html_safe % { source_title: "<code>#{source_title}</code>".html_safe } - if issuable.new_record? + %code= target_title - = link_to 'Change branches', mr_change_branches_path(issuable) + = link_to _('Change branches'), mr_change_branches_path(issuable) + - elsif issuable.for_fork? + %code= issuable.target_project_path + ":" + - unless issuable.new_record? + %span.dropdown.prepend-left-5.d-inline-block + = form.hidden_field(:target_branch, + { class: 'target_branch js-target-branch-select ref-name mw-xl', + data: { placeholder: _('Select branch'), endpoint: refs_project_path(@project, sort: 'updated_desc', find: 'branches') }}) +%hr diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml index f0c4acdd07fe624adbebd5308c1942a0cd46870a..1b557214e0204ed3bc9e192d5ec5039b0808673d 100644 --- a/app/views/shared/issuable/form/_merge_params.html.haml +++ b/app/views/shared/issuable/form/_merge_params.html.haml @@ -3,17 +3,17 @@ - return unless issuable.is_a?(MergeRequest) - return if issuable.closed_without_fork? -- if issuable.can_remove_source_branch?(current_user) - .form-group.row - .col-sm-10.offset-sm-2 - .form-check +.form-group.row + .col-sm-2.col-form-label.pt-sm-0 + %label + = _('Merge options') + .col-sm-10 + - if issuable.can_remove_source_branch?(current_user) + .form-check.append-bottom-default = hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil = check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch?, class: 'form-check-input' = label_tag 'merge_request[force_remove_source_branch]', class: 'form-check-label' do Delete source branch when merge request is accepted. - -.form-group.row - .col-sm-10.offset-sm-2 .form-check = hidden_field_tag 'merge_request[squash]', '0', id: nil = check_box_tag 'merge_request[squash]', '1', issuable.squash, class: 'form-check-input' diff --git a/app/views/shared/members/_access_request_links.html.haml b/app/views/shared/members/_access_request_links.html.haml index eac743b520629a8dd1212aada8085f78f0c37ee3..b4b06640bd9ddb8dd588c8c9bdc02f6e0ae7f82a 100644 --- a/app/views/shared/members/_access_request_links.html.haml +++ b/app/views/shared/members/_access_request_links.html.haml @@ -4,7 +4,7 @@ - link_text = source.is_a?(Group) ? _('Leave group') : _('Leave project') = link_to link_text, polymorphic_path([:leave, source, :members]), method: :delete, - data: { confirm: leave_confirmation_message(source) }, + data: { confirm: leave_confirmation_message(source), qa_selector: 'leave_group_link' }, class: 'access-request-link js-leave-link' - elsif requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord = link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]), diff --git a/app/views/shared/milestones/_description.html.haml b/app/views/shared/milestones/_description.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..5ff110bf94b7f3293229df50ac6effe720940235 --- /dev/null +++ b/app/views/shared/milestones/_description.html.haml @@ -0,0 +1,8 @@ +.detail-page-description.milestone-detail + %h2.title + = markdown_field(milestone, :title) + + - if milestone.try(:description).present? + %div + .description.md + = markdown_field(milestone, :description) diff --git a/app/views/shared/milestones/_header.html.haml b/app/views/shared/milestones/_header.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..2da857261d166bee9b35386ad0e4a3ec3f0e591d --- /dev/null +++ b/app/views/shared/milestones/_header.html.haml @@ -0,0 +1,38 @@ +.detail-page-header.milestone-page-header + .status-box{ class: status_box_class(milestone) } + = milestone_status_string(milestone) + + .header-text-content + %span.identifier + %strong + = _('Milestone') + - if milestone.due_date || milestone.start_date + = milestone_date_range(milestone) + + .milestone-buttons + - if can?(current_user, :admin_milestone, @group || @project) + - unless milestone.legacy_group_milestone? + = link_to _('Edit'), edit_milestone_path(milestone), class: 'btn btn-grouped' + + - if milestone.project_milestone? && milestone.project.group + %button.js-promote-project-milestone-button.btn.btn-grouped{ data: { toggle: 'modal', + target: '#promote-milestone-modal', + milestone_title: milestone.title, + group_name: milestone.project.group.name, + url: promote_project_milestone_path(milestone.project, milestone), + container: 'body' }, + disabled: true, + type: 'button' } + = _('Promote') + #promote-milestone-modal + + - if milestone.active? + = link_to _('Close milestone'), update_milestone_path(milestone, { state_event: :close }), method: :put, class: 'btn btn-grouped btn-close' + - else + = link_to _('Reopen milestone'), update_milestone_path(milestone, { state_event: :activate }), method: :put, class: 'btn btn-grouped btn-reopen' + + - unless milestone.legacy_group_milestone? + = render 'shared/milestones/delete_button' + + %button.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ type: 'button' } + = icon('angle-double-left') diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index e99aa3f1ee443d0cdf4930a61c5a40a54baa5f3a..b324f35c338a2fc7351fb55404b647a3d1d4de71 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -12,8 +12,20 @@ - if @project || milestone.is_a?(GlobalMilestone) || milestone.group_milestone? - if milestone.due_date || milestone.start_date - .milestone-range.append-bottom-5 + .text-tertiary.append-bottom-5 = milestone_date_range(milestone) + - recent_releases, total_count, more_count = recent_releases_with_counts(milestone) + - unless total_count.zero? + .text-tertiary.append-bottom-5.milestone-release-links + = icon('rocket') + = n_('Release', 'Releases', total_count) + - recent_releases.each do |release| + = link_to release.name, project_releases_path(release.project, anchor: release.tag) + - unless release == recent_releases.last + • + - if total_count > recent_releases.count + • + = link_to n_('%{count} more release', '%{count} more releases', more_count) % { count: more_count }, project_releases_path(milestone.project) %div = render('shared/milestone_expired', milestone: milestone) - if milestone.group_milestone? diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index 22a6d5e33f09e0e6bde67c440bacf599d5c43aa1..b6656e6283c54facf8104ab62481a8bbea8d0bd9 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -138,6 +138,27 @@ Merged: = milestone.merge_requests.merged.count + - if project + - recent_releases, total_count, more_count = recent_releases_with_counts(milestone) + .block.releases + .sidebar-collapsed-icon.has-tooltip{ title: milestone_releases_tooltip_text(milestone), data: { container: 'body', placement: 'left', boundary: 'viewport' } } + %strong + = icon('rocket') + %span= total_count + .title.hide-collapsed= n_('Release', 'Releases', total_count) + .hide-collapsed + - if total_count.zero? + .no-value= _('None') + - else + .font-weight-bold + - recent_releases.each do |release| + = link_to release.name, project_releases_path(project, :anchor => release.tag) + - unless release == recent_releases.last + %span.font-weight-normal • + - if more_count > 0 + %span.font-weight-normal • + = link_to n_('%{count} more release', '%{count} more releases', more_count) % { count: more_count }, project_releases_path(project), class: 'font-weight-normal' + - milestone_ref = milestone.try(:to_reference, full: true) - if milestone_ref.present? .block.reference diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml index b877f66c71ee9458e4e56f283e5cb6f00cc8403f..f718c5767d10835c3ac1c2c6d334422b03712143 100644 --- a/app/views/shared/milestones/_tabs.html.haml +++ b/app/views/shared/milestones/_tabs.html.haml @@ -1,30 +1,22 @@ -- issues_accessible = milestone.is_a?(GlobalMilestone) || can?(current_user, :read_issue, @project) - .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller .fade-left= icon('angle-left') .fade-right= icon('angle-right') %ul.nav-links.scrolling-tabs.js-milestone-tabs.nav.nav-tabs - - if issues_accessible - %li.nav-item - = link_to '#tab-issues', class: 'nav-link active', 'data-toggle' => 'tab', 'data-show' => '.tab-issues-buttons' do - Issues - %span.badge.badge-pill= milestone.issues_visible_to_user(current_user).size - %li.nav-item - = link_to '#tab-merge-requests', class: 'nav-link', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do - Merge Requests - %span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size - - else - %li.nav-item - = link_to '#tab-merge-requests', class: 'nav-link active', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do - Merge Requests - %span.badge.badge-pill= milestone.merge_requests.size %li.nav-item - = link_to '#tab-participants', class: 'nav-link', 'data-toggle' => 'tab', 'data-endpoint': milestone_participants_tab_path(milestone) do - Participants + = link_to '#tab-issues', class: 'nav-link active', data: { toggle: 'tab', show: '.tab-issues-buttons' } do + = _('Issues') + %span.badge.badge-pill= milestone.issues_visible_to_user(current_user).size + %li.nav-item + = link_to '#tab-merge-requests', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'merge_requests') } do + = _('Merge Requests') + %span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size + %li.nav-item + = link_to '#tab-participants', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'participants') } do + = _('Participants') %span.badge.badge-pill= milestone.issue_participants_visible_by_user(current_user).count %li.nav-item - = link_to '#tab-labels', class: 'nav-link', 'data-toggle' => 'tab', 'data-endpoint': milestone_labels_tab_path(milestone) do - Labels + = link_to '#tab-labels', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'labels') } do + = _('Labels') %span.badge.badge-pill= milestone.issue_labels_visible_by_user(current_user).count - issues = milestone.sorted_issues(current_user) @@ -32,16 +24,11 @@ - show_full_project_name = local_assigns.fetch(:show_full_project_name, false) .tab-content.milestone-content - - if issues_accessible - .tab-pane.active#tab-issues{ data: { sort_endpoint: (sort_issues_project_milestone_path(@project, @milestone) if @project && current_user) } } - = render 'shared/milestones/issues_tab', issues: issues, show_project_name: show_project_name, show_full_project_name: show_full_project_name - .tab-pane#tab-merge-requests - -# loaded async - = render "shared/milestones/tab_loading" - - else - .tab-pane.active#tab-merge-requests - -# loaded async - = render "shared/milestones/tab_loading" + .tab-pane.active#tab-issues{ data: { sort_endpoint: (sort_issues_project_milestone_path(@project, @milestone) if @project && current_user) } } + = render 'shared/milestones/issues_tab', issues: issues, show_project_name: show_project_name, show_full_project_name: show_full_project_name + .tab-pane#tab-merge-requests + -# loaded async + = render "shared/milestones/tab_loading" .tab-pane#tab-participants -# loaded async = render "shared/milestones/tab_loading" diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml index fd3317341f6f363f5c621a2a3755e46aaf56aa87..12575b30a6c4440368da666478a4461e14e74dbf 100644 --- a/app/views/shared/milestones/_top.html.haml +++ b/app/views/shared/milestones/_top.html.haml @@ -4,54 +4,15 @@ - group = local_assigns[:group] - is_dynamic_milestone = milestone.legacy_group_milestone? || milestone.dashboard_milestone? -.detail-page-header.milestone-page-header - .status-box{ class: "status-box-#{milestone.closed? ? 'closed' : 'open'}" } - - if milestone.closed? - Closed - - elsif milestone.expired? - Expired - - else - Open - - .header-text-content - %span.identifier - Milestone #{milestone.title} - - if milestone.due_date || milestone.start_date - %span.creator - · - = milestone_date_range(milestone) - - .milestone-buttons - - if group - - if can?(current_user, :admin_milestone, group) - - if milestone.group_milestone? - = link_to edit_group_milestone_path(group, milestone), class: "btn btn btn-grouped" do - Edit - - if milestone.active? - = link_to 'Close Milestone', group_milestone_route(milestone, {state_event: :close }), method: :put, class: "btn btn-grouped btn-close" - - else - = link_to 'Reopen Milestone', group_milestone_route(milestone, {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen" - - - unless is_dynamic_milestone - = render 'shared/milestones/delete_button' - - %a.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ href: "#" } - = icon('angle-double-left') - += render 'shared/milestones/header', milestone: milestone = render 'shared/milestones/deprecation_message' if is_dynamic_milestone - -.detail-page-description.milestone-detail - %h2.title - = markdown_field(milestone, :title) - - if milestone.group_milestone? && milestone.description.present? - %div - .description.md - = markdown_field(milestone, :description) += render 'shared/milestones/description', milestone: milestone - if milestone.complete?(current_user) && milestone.active? .alert.alert-success.prepend-top-default - - close_msg = group ? 'You may close the milestone now.' : 'Navigate to the project to close the milestone.' - %span All issues for this milestone are closed. #{close_msg} + %span + = _('All issues for this milestone are closed.') + = group ? _('You may close the milestone now.') : _('Navigate to the project to close the milestone.') = render_if_exists 'shared/milestones/burndown', milestone: milestone, project: @project @@ -77,10 +38,3 @@ Open %td = milestone.expires_at -- elsif milestone.group_milestone? - %br - View - = link_to 'Issues', issues_group_path(@group, milestone_title: milestone.title) - or - = link_to 'Merge Requests', merge_requests_group_path(@group, milestone_title: milestone.title) - in this milestone diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml index 441abd57334fb83421d2309945c59cd26fa35c3e..2b3e986a841ca578680a1753baf75e4d2dcd3d0e 100644 --- a/app/views/shared/notifications/_button.html.haml +++ b/app/views/shared/notifications/_button.html.haml @@ -17,14 +17,14 @@ .js-notification-toggle-btns %div{ class: ("btn-group" if notification_setting.custom?) } - if notification_setting.custom? - %button.dropdown-new.btn.btn-default.btn-xs.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } = icon("bell", class: "js-notification-loading") = notification_title(notification_setting.level) - %button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } + %button.btn.dropdown-toggle.d-flex{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } = icon('caret-down') .sr-only Toggle dropdown - else - %button.dropdown-new.btn.btn-default.btn-xs.has-tooltip.notifications-btn#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } .float-left = icon("bell", class: "js-notification-loading") = notification_title(notification_setting.level) diff --git a/app/views/shared/notifications/_custom_notifications.html.haml b/app/views/shared/notifications/_custom_notifications.html.haml index 43a87fd8397c6db732dfacae1ea257aac420e2c1..1fef43c0c3752aa3876825d68f025f632d6bf1d9 100644 --- a/app/views/shared/notifications/_custom_notifications.html.haml +++ b/app/views/shared/notifications/_custom_notifications.html.haml @@ -1,3 +1,5 @@ +- hide_label = local_assigns.fetch(:hide_label, false) + .modal.fade{ tabindex: "-1", role: "dialog", id: notifications_menu_identifier("modal", notification_setting), "aria-labelledby": "custom-notifications-title" } .modal-dialog .modal-content @@ -11,6 +13,7 @@ .container-fluid = form_for notification_setting, html: { class: "custom-notifications-form" } do |f| = hidden_setting_source_input(notification_setting) + = hidden_field_tag("hide_label", true) if hide_label .row .col-lg-4 %h4.prepend-top-0= _('Notification events') diff --git a/app/views/shared/notifications/_new_button.html.haml b/app/views/shared/notifications/_new_button.html.haml index 3c8cc023848233a24aa5f3aa1f63c8f7840dc0b3..363053b5e35c97e577a04979e12c5f7e93294bfd 100644 --- a/app/views/shared/notifications/_new_button.html.haml +++ b/app/views/shared/notifications/_new_button.html.haml @@ -31,4 +31,4 @@ = render "shared/notifications/notification_dropdown", notification_setting: notification_setting = content_for :scripts_body do - = render "shared/notifications/custom_notifications", notification_setting: notification_setting + = render "shared/notifications/custom_notifications", notification_setting: notification_setting, hide_label: true diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml index d70a1631010ad21fb09e1dd5ebd90e465020e05e..59b4facdbe59cef51257d9dbc6393fc5c00ed435 100644 --- a/app/views/shared/projects/_list.html.haml +++ b/app/views/shared/projects/_list.html.haml @@ -32,7 +32,7 @@ - explore_groups_button_label = _('Explore groups') - explore_groups_button_link = explore_groups_path -.js-projects-list-holder +.js-projects-list-holder{ data: { qa_selector: 'projects_list' } } - if any_projects?(projects) - load_pipeline_status(projects) if pipeline_status %ul.projects-list{ class: css_classes } diff --git a/app/views/shared/runners/_runner_description.html.haml b/app/views/shared/runners/_runner_description.html.haml index 5935750ca06049c3d22d0d16c11b9c1604f18fc3..a47bbd5532516996e0a7a14514253103c1b9d6dc 100644 --- a/app/views/shared/runners/_runner_description.html.haml +++ b/app/views/shared/runners/_runner_description.html.haml @@ -1,6 +1,6 @@ .light.prepend-top-default %p - = _("A 'Runner' is a process which runs a job. You can set up as many Runners as you need.") + = _("You can set up as many Runners as you need to run your jobs.") %br = _('Runners can be placed on separate users, servers, and even on your local machine.') diff --git a/app/views/shared/snippets/_blob.html.haml b/app/views/shared/snippets/_blob.html.haml index 2132fcbccc57e899abc65024c58379a130c28df4..6a5e777706c868f7a202ec981678851bc025da74 100644 --- a/app/views/shared/snippets/_blob.html.haml +++ b/app/views/shared/snippets/_blob.html.haml @@ -8,7 +8,6 @@ .btn-group{ role: "group" }< = copy_blob_source_button(blob) = open_raw_blob_button(blob) - - = link_to icon('download'), download_snippet_path(@snippet), target: '_blank', class: "btn btn-sm has-tooltip", title: 'Download', data: { container: 'body' } + = download_raw_snippet_button(@snippet) = render 'projects/blob/content', blob: blob diff --git a/app/views/shared/snippets/_embed.html.haml b/app/views/shared/snippets/_embed.html.haml index c7f0511d1def7013edfb2cef64000f08c4eb6f42..d2e35511b32558d629cd7e99e6c67336f5cc811d 100644 --- a/app/views/shared/snippets/_embed.html.haml +++ b/app/views/shared/snippets/_embed.html.haml @@ -17,7 +17,7 @@ .file-actions.d-none.d-sm-block .btn-group{ role: "group" }< - = embedded_snippet_raw_button + = embedded_raw_snippet_button = embedded_snippet_download_button %article.file-holder.snippet-file-content diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index 8d94a87a77518966dcd0010af9d44ec5592eb7c3..67f177288f0cdd2bd39eae3a5fc9e0c374d01a59 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -44,7 +44,7 @@ %li %button.js-share-btn.btn.btn-transparent{ type: 'button' } %strong.embed-toggle-list-item= _("Share") - %input.js-snippet-url-area.snippet-embed-input.form-control{ type: "text", autocomplete: 'off', value: snippet_embed } + %input.js-snippet-url-area.snippet-embed-input.form-control{ type: "text", autocomplete: 'off', value: snippet_embed_tag(@snippet) } .input-group-append = clipboard_button(title: _('Copy'), class: 'js-clipboard-btn snippet-clipboard-btn btn btn-default', target: '.js-snippet-url-area') .clearfix diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index 0ef626868a2e622ee2a7f7c0bf8fd8ccf7ecaff8..5602ea37b5c552e2db871f12c34284603be1fe74 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -7,8 +7,9 @@ .title = link_to reliable_snippet_path(snippet) do = snippet.title - - if snippet.file_name - %span.snippet-filename.monospace.d-none.d-sm-inline-block + - if snippet.file_name.present? + %span.snippet-filename.d-none.d-sm-inline-block.ml-2 + = sprite_icon('doc-code', size: 16, css_class: 'file-icon align-text-bottom') = snippet.file_name %ul.controls diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index b161cc65602bb509b8dd46cb54e19fe40e3fcfc8..66b5214cfcbbcf87cfa769014cb711ca2828071f 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -45,6 +45,9 @@ - gcp_cluster:cluster_project_configure - gcp_cluster:clusters_applications_wait_for_uninstall_app - gcp_cluster:clusters_applications_uninstall +- gcp_cluster:clusters_cleanup_app +- gcp_cluster:clusters_cleanup_project_namespace +- gcp_cluster:clusters_cleanup_service_account - github_import_advance_stage - github_importer:github_import_import_diff_note @@ -176,3 +179,4 @@ - import_issues_csv - project_daily_statistics - create_evidence +- group_export diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb index 577c439f4a25002ac946bd02d1ceac201fc6b271..9492cfe217c42bc4b34010945901ea7ee6c79510 100644 --- a/app/workers/authorized_projects_worker.rb +++ b/app/workers/authorized_projects_worker.rb @@ -5,6 +5,7 @@ class AuthorizedProjectsWorker prepend WaitableWorker feature_category :authentication_and_authorization + latency_sensitive_worker! # This is a workaround for a Ruby 2.3.7 bug. rspec-mocks cannot restore the # visibility of prepended modules. See https://github.com/rspec/rspec-mocks/issues/1231 diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb index e95b6b38d2850017794045f2c445a442740208fd..e61f37ddce1f7db990ed2833a28164c623f7a0b7 100644 --- a/app/workers/build_finished_worker.rb +++ b/app/workers/build_finished_worker.rb @@ -5,6 +5,8 @@ class BuildFinishedWorker include PipelineQueue queue_namespace :pipeline_processing + latency_sensitive_worker! + worker_resource_boundary :cpu # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) diff --git a/app/workers/build_hooks_worker.rb b/app/workers/build_hooks_worker.rb index 15b31acf3e5e916b44aa9f070c3cbf9db3d4904a..fa55769e486921a2bbf0980971d7672612adba04 100644 --- a/app/workers/build_hooks_worker.rb +++ b/app/workers/build_hooks_worker.rb @@ -6,6 +6,7 @@ class BuildHooksWorker queue_namespace :pipeline_hooks feature_category :continuous_integration + latency_sensitive_worker! # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) diff --git a/app/workers/build_queue_worker.rb b/app/workers/build_queue_worker.rb index 6584fba4c65761a2aa6617be9a36af79d7a7f66f..6f75f403e6e98bd5e250426e8e95afb37356e3f4 100644 --- a/app/workers/build_queue_worker.rb +++ b/app/workers/build_queue_worker.rb @@ -6,6 +6,8 @@ class BuildQueueWorker queue_namespace :pipeline_processing feature_category :continuous_integration + latency_sensitive_worker! + worker_resource_boundary :cpu # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb index ac947f3cf3890ceb80b2cafd8440b0edd53e246e..b7dbd367fee465e011160d93c85cdadb587faf5e 100644 --- a/app/workers/build_success_worker.rb +++ b/app/workers/build_success_worker.rb @@ -5,6 +5,7 @@ class BuildSuccessWorker include PipelineQueue queue_namespace :pipeline_processing + latency_sensitive_worker! # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) diff --git a/app/workers/chat_notification_worker.rb b/app/workers/chat_notification_worker.rb index 3bc2edad62ca944050cdfeb70a9545968136259d..42a23cd472a9a06efcdd9a75f7aa351f489d47da 100644 --- a/app/workers/chat_notification_worker.rb +++ b/app/workers/chat_notification_worker.rb @@ -4,6 +4,11 @@ class ChatNotificationWorker include ApplicationWorker feature_category :chatops + latency_sensitive_worker! + # TODO: break this into multiple jobs + # as the `responder` uses external dependencies + # See https://gitlab.com/gitlab-com/gl-infra/scalability/issues/34 + # worker_has_external_dependencies! RESCHEDULE_INTERVAL = 2.seconds diff --git a/app/workers/ci/build_schedule_worker.rb b/app/workers/ci/build_schedule_worker.rb index f22ec4c78102cb8eae9b8a390f31efd06f9140cd..e34f16f46c2124fe9e88c4f006f7a874f9341f9d 100644 --- a/app/workers/ci/build_schedule_worker.rb +++ b/app/workers/ci/build_schedule_worker.rb @@ -7,6 +7,7 @@ module Ci queue_namespace :pipeline_processing feature_category :continuous_integration + worker_resource_boundary :cpu def perform(build_id) ::Ci::Build.find_by_id(build_id).try do |build| diff --git a/app/workers/cluster_install_app_worker.rb b/app/workers/cluster_install_app_worker.rb index 32e2ea7996c4d6c7b3c2e32ffa698fa6df744783..0e075b295dd16fef72e6b011a5354375686c91a9 100644 --- a/app/workers/cluster_install_app_worker.rb +++ b/app/workers/cluster_install_app_worker.rb @@ -5,6 +5,8 @@ class ClusterInstallAppWorker include ClusterQueue include ClusterApplications + worker_has_external_dependencies! + def perform(app_name, app_id) find_application(app_name, app_id) do |app| Clusters::Applications::InstallService.new(app).execute diff --git a/app/workers/cluster_patch_app_worker.rb b/app/workers/cluster_patch_app_worker.rb index 0549e81ed05ce128500b442ecb6b2fc6dc7356d3..3f95a764567402bc5f24ea42cf3b621a2fc83885 100644 --- a/app/workers/cluster_patch_app_worker.rb +++ b/app/workers/cluster_patch_app_worker.rb @@ -5,6 +5,8 @@ class ClusterPatchAppWorker include ClusterQueue include ClusterApplications + worker_has_external_dependencies! + def perform(app_name, app_id) find_application(app_name, app_id) do |app| Clusters::Applications::PatchService.new(app).execute diff --git a/app/workers/cluster_project_configure_worker.rb b/app/workers/cluster_project_configure_worker.rb index ad2437a77e9c50e0adaff63b76bc1086ab44090d..614029c2b5c9c84cdda9945df51da3897e95bb2d 100644 --- a/app/workers/cluster_project_configure_worker.rb +++ b/app/workers/cluster_project_configure_worker.rb @@ -4,6 +4,8 @@ class ClusterProjectConfigureWorker include ApplicationWorker include ClusterQueue + worker_has_external_dependencies! + def perform(project_id) # Scheduled for removal in https://gitlab.com/gitlab-org/gitlab-foss/issues/59319 end diff --git a/app/workers/cluster_provision_worker.rb b/app/workers/cluster_provision_worker.rb index 59de7903c1cdbb830cee602c62bdb13f3d1cd4d6..c34284319dd4f091cbd910ebc2cd18060fd1cf75 100644 --- a/app/workers/cluster_provision_worker.rb +++ b/app/workers/cluster_provision_worker.rb @@ -4,10 +4,16 @@ class ClusterProvisionWorker include ApplicationWorker include ClusterQueue + worker_has_external_dependencies! + def perform(cluster_id) Clusters::Cluster.find_by_id(cluster_id).try do |cluster| cluster.provider.try do |provider| - Clusters::Gcp::ProvisionService.new.execute(provider) if cluster.gcp? + if cluster.gcp? + Clusters::Gcp::ProvisionService.new.execute(provider) + elsif cluster.aws? + Clusters::Aws::ProvisionService.new.execute(provider) + end end end end diff --git a/app/workers/cluster_upgrade_app_worker.rb b/app/workers/cluster_upgrade_app_worker.rb index d1a538859b49cd960f9649876c572b608202f060..cd06f0a2224f922271c837bf645fc6185358f486 100644 --- a/app/workers/cluster_upgrade_app_worker.rb +++ b/app/workers/cluster_upgrade_app_worker.rb @@ -5,6 +5,8 @@ class ClusterUpgradeAppWorker include ClusterQueue include ClusterApplications + worker_has_external_dependencies! + def perform(app_name, app_id) find_application(app_name, app_id) do |app| Clusters::Applications::UpgradeService.new(app).execute diff --git a/app/workers/cluster_wait_for_app_installation_worker.rb b/app/workers/cluster_wait_for_app_installation_worker.rb index e8d7e52f70f9658a7d7dded787fd44f2ea745e9b..7155dc6f83555fae7242a4240fd94a7cf4dde660 100644 --- a/app/workers/cluster_wait_for_app_installation_worker.rb +++ b/app/workers/cluster_wait_for_app_installation_worker.rb @@ -8,6 +8,9 @@ class ClusterWaitForAppInstallationWorker INTERVAL = 10.seconds TIMEOUT = 20.minutes + worker_has_external_dependencies! + worker_resource_boundary :cpu + def perform(app_name, app_id) find_application(app_name, app_id) do |app| Clusters::Applications::CheckInstallationProgressService.new(app).execute diff --git a/app/workers/cluster_wait_for_ingress_ip_address_worker.rb b/app/workers/cluster_wait_for_ingress_ip_address_worker.rb index 6865384df44755b559423dd6cf930b755bffe4a4..14b1651cc72a9b2a234917ab21c5d9f4296a5cf5 100644 --- a/app/workers/cluster_wait_for_ingress_ip_address_worker.rb +++ b/app/workers/cluster_wait_for_ingress_ip_address_worker.rb @@ -5,6 +5,8 @@ class ClusterWaitForIngressIpAddressWorker include ClusterQueue include ClusterApplications + worker_has_external_dependencies! + def perform(app_name, app_id) find_application(app_name, app_id) do |app| Clusters::Applications::CheckIngressIpAddressService.new(app).execute diff --git a/app/workers/clusters/applications/uninstall_worker.rb b/app/workers/clusters/applications/uninstall_worker.rb index 85e8ecc4ad593d33b9ed72f0dc4e3496b6950594..6180998c8d9f412802d7f926ee5842436eada257 100644 --- a/app/workers/clusters/applications/uninstall_worker.rb +++ b/app/workers/clusters/applications/uninstall_worker.rb @@ -7,6 +7,8 @@ module Clusters include ClusterQueue include ClusterApplications + worker_has_external_dependencies! + def perform(app_name, app_id) find_application(app_name, app_id) do |app| Clusters::Applications::UninstallService.new(app).execute diff --git a/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb index 163c99d3c3ca9feff537e11352c7d496e1cb6421..7907aa8dfff1d07e9bc3fbde14a5ff3a12283b8d 100644 --- a/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb +++ b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb @@ -10,6 +10,9 @@ module Clusters INTERVAL = 10.seconds TIMEOUT = 20.minutes + worker_has_external_dependencies! + worker_resource_boundary :cpu + def perform(app_name, app_id) find_application(app_name, app_id) do |app| Clusters::Applications::CheckUninstallProgressService.new(app).execute diff --git a/app/workers/clusters/cleanup/app_worker.rb b/app/workers/clusters/cleanup/app_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..1eedf510ba1d0e4298a134cbafd032322d18253d --- /dev/null +++ b/app/workers/clusters/cleanup/app_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Clusters + module Cleanup + class AppWorker + include ApplicationWorker + include ClusterQueue + include ClusterApplications + + # TODO: Merge with https://gitlab.com/gitlab-org/gitlab/merge_requests/16954 + # We're splitting the above MR in smaller chunks to facilitate reviews + def perform + end + end + end +end diff --git a/app/workers/clusters/cleanup/project_namespace_worker.rb b/app/workers/clusters/cleanup/project_namespace_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..09f2abf5d8a347e71e1e41908ed2bd36a96f234a --- /dev/null +++ b/app/workers/clusters/cleanup/project_namespace_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Clusters + module Cleanup + class ProjectNamespaceWorker + include ApplicationWorker + include ClusterQueue + include ClusterApplications + + # TODO: Merge with https://gitlab.com/gitlab-org/gitlab/merge_requests/16954 + # We're splitting the above MR in smaller chunks to facilitate reviews + def perform + end + end + end +end diff --git a/app/workers/clusters/cleanup/service_account_worker.rb b/app/workers/clusters/cleanup/service_account_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..fab6318a8070d7646f092973dd7e1df97a34c704 --- /dev/null +++ b/app/workers/clusters/cleanup/service_account_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Clusters + module Cleanup + class ServiceAccountWorker + include ApplicationWorker + include ClusterQueue + include ClusterApplications + + # TODO: Merge with https://gitlab.com/gitlab-org/gitlab/merge_requests/16954 + # We're splitting the above MR in smaller chunks to facilitate reviews + def perform + end + end + end +end diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb index b856a9329dde0dd556e3ad4ac58a6dfca5cf62e4..bd0b566658e189f0616e9c92bc1635d6af73dab3 100644 --- a/app/workers/concerns/gitlab/github_import/object_importer.rb +++ b/app/workers/concerns/gitlab/github_import/object_importer.rb @@ -14,6 +14,7 @@ module Gitlab include NotifyUponDeath feature_category :importers + worker_has_external_dependencies! end # project - An instance of `Project` to import the data into. diff --git a/app/workers/create_pipeline_worker.rb b/app/workers/create_pipeline_worker.rb index 70412ffd0951dc6c95c405c6dbfb803d935bbfb2..a75cc643038242494dc891f0d0f86eb1ec521e50 100644 --- a/app/workers/create_pipeline_worker.rb +++ b/app/workers/create_pipeline_worker.rb @@ -6,6 +6,8 @@ class CreatePipelineWorker queue_namespace :pipeline_creation feature_category :continuous_integration + latency_sensitive_worker! + worker_resource_boundary :cpu def perform(project_id, user_id, ref, source, params = {}) project = Project.find(project_id) diff --git a/app/workers/deployments/finished_worker.rb b/app/workers/deployments/finished_worker.rb index 79a1caccc92cfd8b8393c1c25fe0f2dbaf20553a..90bbc193651736b11246faa8fec8026b8a44f50a 100644 --- a/app/workers/deployments/finished_worker.rb +++ b/app/workers/deployments/finished_worker.rb @@ -6,6 +6,7 @@ module Deployments queue_namespace :deployment feature_category :continuous_delivery + worker_resource_boundary :cpu def perform(deployment_id) Deployment.find_by_id(deployment_id).try(:execute_hooks) diff --git a/app/workers/deployments/success_worker.rb b/app/workers/deployments/success_worker.rb index f6520307186121273d3b946e3880d7896d10a53d..4a29f1aef525419a62bc62a689b780b63b23818c 100644 --- a/app/workers/deployments/success_worker.rb +++ b/app/workers/deployments/success_worker.rb @@ -6,6 +6,7 @@ module Deployments queue_namespace :deployment feature_category :continuous_delivery + worker_resource_boundary :cpu def perform(deployment_id) Deployment.find_by_id(deployment_id).try do |deployment| diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb index c82728be3299da226203d460514af02b207b90c7..b56bf4ed8339f7a6925fae10fc3a301d88b94614 100644 --- a/app/workers/email_receiver_worker.rb +++ b/app/workers/email_receiver_worker.rb @@ -4,6 +4,7 @@ class EmailReceiverWorker include ApplicationWorker feature_category :issue_tracking + latency_sensitive_worker! def perform(raw) return unless Gitlab::IncomingEmail.enabled? diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb index 2231c91a720f68459e9768b70c88836f6b4a3e3f..f523f5953e192b745080147e0a330c88ff8109e6 100644 --- a/app/workers/emails_on_push_worker.rb +++ b/app/workers/emails_on_push_worker.rb @@ -6,6 +6,8 @@ class EmailsOnPushWorker attr_reader :email, :skip_premailer feature_category :source_code_management + latency_sensitive_worker! + worker_resource_boundary :cpu def perform(project_id, recipients, push_data, options = {}) options.symbolize_keys! diff --git a/app/workers/expire_build_artifacts_worker.rb b/app/workers/expire_build_artifacts_worker.rb index 9545227fa31e2e1e4ef998279c06018108657f6c..383fd30e0981c2494629f7141a81b3531ea9d83c 100644 --- a/app/workers/expire_build_artifacts_worker.rb +++ b/app/workers/expire_build_artifacts_worker.rb @@ -7,25 +7,6 @@ class ExpireBuildArtifactsWorker feature_category :continuous_integration def perform - if Feature.enabled?(:ci_new_expire_job_artifacts_service, default_enabled: true) - perform_efficient_artifacts_removal - else - perform_legacy_artifacts_removal - end - end - - def perform_efficient_artifacts_removal Ci::DestroyExpiredJobArtifactsService.new.execute end - - # rubocop: disable CodeReuse/ActiveRecord - def perform_legacy_artifacts_removal - Rails.logger.info 'Scheduling removal of build artifacts' # rubocop:disable Gitlab/RailsLogger - - build_ids = Ci::Build.with_expired_artifacts.pluck(:id) - build_ids = build_ids.map { |build_id| [build_id] } - - ExpireBuildInstanceArtifactsWorker.bulk_perform_async(build_ids) - end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/expire_job_cache_worker.rb b/app/workers/expire_job_cache_worker.rb index b09d0a5d1215c314214a0a9ab86967e63f45c78c..0363429587e5fafd3f9129c0703b5eca1f8e24c8 100644 --- a/app/workers/expire_job_cache_worker.rb +++ b/app/workers/expire_job_cache_worker.rb @@ -5,6 +5,7 @@ class ExpireJobCacheWorker include PipelineQueue queue_namespace :pipeline_cache + latency_sensitive_worker! # rubocop: disable CodeReuse/ActiveRecord def perform(job_id) diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb index 78e68d7bf4668d73440a6a2cb7d25628805b5a3d..ab57c59ffdae8fdb15747b2c5bf7dc416a21335d 100644 --- a/app/workers/expire_pipeline_cache_worker.rb +++ b/app/workers/expire_pipeline_cache_worker.rb @@ -5,6 +5,8 @@ class ExpirePipelineCacheWorker include PipelineQueue queue_namespace :pipeline_cache + latency_sensitive_worker! + worker_resource_boundary :cpu # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) diff --git a/app/workers/gitlab_shell_worker.rb b/app/workers/gitlab_shell_worker.rb index 9766331cf4b03b736dd4cf7bef833e6377d7b413..57e64570c09c462deabf306e432cbe88d98c88be 100644 --- a/app/workers/gitlab_shell_worker.rb +++ b/app/workers/gitlab_shell_worker.rb @@ -5,8 +5,11 @@ class GitlabShellWorker include Gitlab::ShellAdapter feature_category :source_code_management + latency_sensitive_worker! def perform(action, *arg) - gitlab_shell.__send__(action, *arg) # rubocop:disable GitlabSecurity/PublicSend + Gitlab::GitalyClient::NamespaceService.allow do + gitlab_shell.__send__(action, *arg) # rubocop:disable GitlabSecurity/PublicSend + end end end diff --git a/app/workers/group_export_worker.rb b/app/workers/group_export_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..51dbdc95661b1bd81860102ddefd2409ffef6b44 --- /dev/null +++ b/app/workers/group_export_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class GroupExportWorker + include ApplicationWorker + include ExceptionBacktrace + + feature_category :source_code_management + + def perform(current_user_id, group_id, params = {}) + current_user = User.find(current_user_id) + group = Group.find(group_id) + + ::Groups::ImportExport::ExportService.new(group: group, user: current_user, params: params).execute + end +end diff --git a/app/workers/hashed_storage/project_migrate_worker.rb b/app/workers/hashed_storage/project_migrate_worker.rb index f00a459a097f24a3bf86ba99c0b308b28570b008..8c0ec97638fd5968c005305e5eb7e470ec7b85d3 100644 --- a/app/workers/hashed_storage/project_migrate_worker.rb +++ b/app/workers/hashed_storage/project_migrate_worker.rb @@ -16,7 +16,7 @@ module HashedStorage project = Project.without_deleted.find_by(id: project_id) break unless project - old_disk_path ||= project.disk_path + old_disk_path ||= Storage::LegacyProject.new(project).disk_path ::Projects::HashedStorage::MigrationService.new(project, old_disk_path, logger: logger).execute end diff --git a/app/workers/import_issues_csv_worker.rb b/app/workers/import_issues_csv_worker.rb index d98343203183c769f895ea3d92cb0b8dbd9096cf..d2733dc5f56956b9eee0e760e69b732b845bb0d0 100644 --- a/app/workers/import_issues_csv_worker.rb +++ b/app/workers/import_issues_csv_worker.rb @@ -4,6 +4,7 @@ class ImportIssuesCsvWorker include ApplicationWorker feature_category :issue_tracking + worker_resource_boundary :cpu sidekiq_retries_exhausted do |job| Upload.find(job['args'][2]).destroy diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb index 0d06dab3b2ef2bdd1f1a466ef28b495ddcd35b7c..4130ce25878dfc69b58ce773e9aad14beaf176b3 100644 --- a/app/workers/mail_scheduler/notification_service_worker.rb +++ b/app/workers/mail_scheduler/notification_service_worker.rb @@ -8,6 +8,7 @@ module MailScheduler include MailSchedulerQueue feature_category :issue_tracking + worker_resource_boundary :cpu def perform(meth, *args) check_arguments!(args) diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb index 70b909afea8ac0d198b0b47e1c947b6c5d0afa0c..ed88c57e8d4d783717bcdb1ea694e6dc91239bce 100644 --- a/app/workers/merge_worker.rb +++ b/app/workers/merge_worker.rb @@ -4,6 +4,7 @@ class MergeWorker include ApplicationWorker feature_category :source_code_management + latency_sensitive_worker! def perform(merge_request_id, current_user_id, params) params = params.with_indifferent_access diff --git a/app/workers/namespaces/prune_aggregation_schedules_worker.rb b/app/workers/namespaces/prune_aggregation_schedules_worker.rb index 16259ffbfa6e5d5ea25a9eb21ac4470f9b05e367..9a5f533fe9a49d92bf0398c07eebddf1be2418f0 100644 --- a/app/workers/namespaces/prune_aggregation_schedules_worker.rb +++ b/app/workers/namespaces/prune_aggregation_schedules_worker.rb @@ -6,6 +6,7 @@ module Namespaces include CronjobQueue feature_category :source_code_management + worker_resource_boundary :cpu # Worker to prune pending rows on Namespace::AggregationSchedule # It's scheduled to run once a day at 1:05am. diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb index 1b0fec597e70397b0209c45b8c96bfe85a40babb..af9ca332d3c76ba02446d8dd377e25bf0306961b 100644 --- a/app/workers/new_issue_worker.rb +++ b/app/workers/new_issue_worker.rb @@ -5,6 +5,8 @@ class NewIssueWorker include NewIssuable feature_category :issue_tracking + latency_sensitive_worker! + worker_resource_boundary :cpu def perform(issue_id, user_id) return unless objects_found?(issue_id, user_id) diff --git a/app/workers/new_merge_request_worker.rb b/app/workers/new_merge_request_worker.rb index 0a5b2f8633178de930083597c1f7dfdd3b10d868..aa3f85c157b0002121eebe0393c3b3221bb05f9e 100644 --- a/app/workers/new_merge_request_worker.rb +++ b/app/workers/new_merge_request_worker.rb @@ -5,6 +5,8 @@ class NewMergeRequestWorker include NewIssuable feature_category :source_code_management + latency_sensitive_worker! + worker_resource_boundary :cpu def perform(merge_request_id, user_id) return unless objects_found?(merge_request_id, user_id) diff --git a/app/workers/new_note_worker.rb b/app/workers/new_note_worker.rb index d0d2a5637388aad9b448ca7fe947c81ea535c301..2a5988a7e32dcb11bfda979bcff86fdb1b7b8f75 100644 --- a/app/workers/new_note_worker.rb +++ b/app/workers/new_note_worker.rb @@ -4,6 +4,8 @@ class NewNoteWorker include ApplicationWorker feature_category :issue_tracking + latency_sensitive_worker! + worker_resource_boundary :cpu # Keep extra parameter to preserve backwards compatibility with # old `NewNoteWorker` jobs (can remove later) diff --git a/app/workers/new_release_worker.rb b/app/workers/new_release_worker.rb index 28d2517238e488193479dedf269ae56d1bf8c3b6..a3a882f9343ef28bc2d4b58542720be36f9968da 100644 --- a/app/workers/new_release_worker.rb +++ b/app/workers/new_release_worker.rb @@ -7,7 +7,7 @@ class NewReleaseWorker feature_category :release_orchestration def perform(release_id) - release = Release.with_project_and_namespace.find_by_id(release_id) + release = Release.preloaded.find_by_id(release_id) return unless release NotificationService.new.send_new_release_notifications(release) diff --git a/app/workers/object_pool/join_worker.rb b/app/workers/object_pool/join_worker.rb index 9c5161fd55a89fb4e93071c4dfa50af8cf27df5d..ddd002eabb895deda3984535972e21f84980d268 100644 --- a/app/workers/object_pool/join_worker.rb +++ b/app/workers/object_pool/join_worker.rb @@ -5,6 +5,8 @@ module ObjectPool include ApplicationWorker include ObjectPoolQueue + worker_resource_boundary :cpu + # The use of pool id is deprecated. Keeping the argument allows old jobs to # still be performed. def perform(_pool_id, project_id) diff --git a/app/workers/pages_domain_removal_cron_worker.rb b/app/workers/pages_domain_removal_cron_worker.rb index 25e747c78d0ed949941db09e23d800cf515a0713..b1506831056a3950a2a12a83778d15014d3a0b89 100644 --- a/app/workers/pages_domain_removal_cron_worker.rb +++ b/app/workers/pages_domain_removal_cron_worker.rb @@ -5,6 +5,7 @@ class PagesDomainRemovalCronWorker include CronjobQueue feature_category :pages + worker_resource_boundary :cpu def perform PagesDomain.for_removal.find_each do |domain| diff --git a/app/workers/pipeline_hooks_worker.rb b/app/workers/pipeline_hooks_worker.rb index eae1115e60cb1799b92fcf820175c2b1ab964a9e..04abc9c88fd11ffff29c7bc4848040a52ec0cf25 100644 --- a/app/workers/pipeline_hooks_worker.rb +++ b/app/workers/pipeline_hooks_worker.rb @@ -5,6 +5,8 @@ class PipelineHooksWorker include PipelineQueue queue_namespace :pipeline_hooks + latency_sensitive_worker! + worker_resource_boundary :cpu # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) diff --git a/app/workers/pipeline_metrics_worker.rb b/app/workers/pipeline_metrics_worker.rb index 0ddad43b8d539e16f2cc227432f4945b7afbee86..3830522aaa1e85273605122e3092204d99579de6 100644 --- a/app/workers/pipeline_metrics_worker.rb +++ b/app/workers/pipeline_metrics_worker.rb @@ -4,6 +4,8 @@ class PipelineMetricsWorker include ApplicationWorker include PipelineQueue + latency_sensitive_worker! + # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) Ci::Pipeline.find_by(id: pipeline_id).try do |pipeline| diff --git a/app/workers/pipeline_notification_worker.rb b/app/workers/pipeline_notification_worker.rb index e4a18573d20eea32c23d4aa1e07880d1da6d50ec..62ecbc8a047b1fa88a84bf7801414be7954ef3a4 100644 --- a/app/workers/pipeline_notification_worker.rb +++ b/app/workers/pipeline_notification_worker.rb @@ -4,6 +4,9 @@ class PipelineNotificationWorker include ApplicationWorker include PipelineQueue + latency_sensitive_worker! + worker_resource_boundary :cpu + # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id, recipients = nil) pipeline = Ci::Pipeline.find_by(id: pipeline_id) diff --git a/app/workers/pipeline_process_worker.rb b/app/workers/pipeline_process_worker.rb index 96f3725dbbeda1b32df7b009021fe883d9a7fe4b..2a36ab992e96033fc12ddc2979a91e6cd632a578 100644 --- a/app/workers/pipeline_process_worker.rb +++ b/app/workers/pipeline_process_worker.rb @@ -6,6 +6,7 @@ class PipelineProcessWorker queue_namespace :pipeline_processing feature_category :continuous_integration + latency_sensitive_worker! # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id, build_ids = nil) diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb index f500ea0835349dea7684662a96071ad7c4c31694..19c3c5fcc2fe20c7c8e68d445935e4fe62b262c3 100644 --- a/app/workers/pipeline_schedule_worker.rb +++ b/app/workers/pipeline_schedule_worker.rb @@ -5,6 +5,7 @@ class PipelineScheduleWorker include CronjobQueue feature_category :continuous_integration + worker_resource_boundary :cpu def perform Ci::PipelineSchedule.runnable_schedules.preloaded.find_in_batches do |schedules| diff --git a/app/workers/pipeline_success_worker.rb b/app/workers/pipeline_success_worker.rb index 666331e6cd48d99e8572eb2d8f3f385904ca8b1f..5c24f00e0c37fa81794392ff63d589619e4bdc55 100644 --- a/app/workers/pipeline_success_worker.rb +++ b/app/workers/pipeline_success_worker.rb @@ -5,6 +5,7 @@ class PipelineSuccessWorker include PipelineQueue queue_namespace :pipeline_processing + latency_sensitive_worker! def perform(pipeline_id) # no-op diff --git a/app/workers/pipeline_update_worker.rb b/app/workers/pipeline_update_worker.rb index 13a748e155162dd9c104dcee9c3ee27fae4b2f43..5b742461f7a0d11746550733aad321498e6d59d1 100644 --- a/app/workers/pipeline_update_worker.rb +++ b/app/workers/pipeline_update_worker.rb @@ -5,6 +5,7 @@ class PipelineUpdateWorker include PipelineQueue queue_namespace :pipeline_processing + latency_sensitive_worker! # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index a3bc7e5b9c99400d88fa67dfa9726547ed7b8cb9..334a98a0017c397a5099223afa2044a862751a27 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -4,6 +4,8 @@ class PostReceive include ApplicationWorker feature_category :source_code_management + latency_sensitive_worker! + worker_resource_boundary :cpu def perform(gl_repository, identifier, changes, push_options = {}) project, repo_type = Gitlab::GlRepository.parse(gl_repository) diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index 1e4561fc6eabd1d87fcddab625fde5bf15c2dd40..8b4d66ae49342b005ff06d4355e62da8c24c48ca 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -11,6 +11,7 @@ class ProcessCommitWorker include ApplicationWorker feature_category :source_code_management + latency_sensitive_worker! # project_id - The ID of the project this commit belongs to. # user_id - The ID of the user that pushed the commit. diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb index 57a01c0dd8eacb421ddd662760aa7eaf7c86b476..ae1d57aa1244e876dc3dec78040a87bedb92ca2e 100644 --- a/app/workers/project_cache_worker.rb +++ b/app/workers/project_cache_worker.rb @@ -3,6 +3,9 @@ # Worker for updating any project specific caches. class ProjectCacheWorker include ApplicationWorker + + latency_sensitive_worker! + LEASE_TIMEOUT = 15.minutes.to_i feature_category :source_code_management diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb index bbcf3b72718728bdb95098bf500446569bb69691..11f3fed82cdeb6a4243dfbd4e541a5ad95bb3f01 100644 --- a/app/workers/project_export_worker.rb +++ b/app/workers/project_export_worker.rb @@ -6,6 +6,7 @@ class ProjectExportWorker sidekiq_options retry: 3 feature_category :source_code_management + worker_resource_boundary :memory def perform(current_user_id, project_id, after_export_strategy = {}, params = {}) current_user = User.find(current_user_id) diff --git a/app/workers/project_service_worker.rb b/app/workers/project_service_worker.rb index 8041404fc7114d3a4d63cd04df43d40d2e7d3625..38a2a7414a56e878d2d8d76a0093e582b9ec8311 100644 --- a/app/workers/project_service_worker.rb +++ b/app/workers/project_service_worker.rb @@ -5,6 +5,7 @@ class ProjectServiceWorker sidekiq_options dead: false feature_category :integrations + worker_has_external_dependencies! def perform(hook_id, data) data = data.with_indifferent_access diff --git a/app/workers/reactive_caching_worker.rb b/app/workers/reactive_caching_worker.rb index af4a3def0624ce9be3a0016de5c68239561fe934..f3a83e0e8d472c35f32dab2c49dd5af8debfa13e 100644 --- a/app/workers/reactive_caching_worker.rb +++ b/app/workers/reactive_caching_worker.rb @@ -5,6 +5,14 @@ class ReactiveCachingWorker feature_category_not_owned! + # TODO: The reactive caching worker should be split into + # two different workers, one for latency_sensitive jobs without external dependencies + # and another worker without latency_sensitivity, but with external dependencies + # https://gitlab.com/gitlab-com/gl-infra/scalability/issues/34 + # This worker should also have `worker_has_external_dependencies!` enabled + latency_sensitive_worker! + worker_resource_boundary :cpu + def perform(class_name, id, *args) klass = begin class_name.constantize diff --git a/app/workers/remove_expired_group_links_worker.rb b/app/workers/remove_expired_group_links_worker.rb index 147b412b7721564d0fe4664dd7da059636c01907..a43e6fd11d53c9bfffc779aedc41f2142968f58d 100644 --- a/app/workers/remove_expired_group_links_worker.rb +++ b/app/workers/remove_expired_group_links_worker.rb @@ -8,5 +8,9 @@ class RemoveExpiredGroupLinksWorker def perform ProjectGroupLink.expired.destroy_all # rubocop: disable DestroyAll + + GroupGroupLink.expired.find_in_batches do |link_batch| + Groups::GroupLinks::DestroyService.new(nil, nil).execute(link_batch) + end end end diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb index 75f06fd9f6b5fd4b7742d5b71a9a662cbcc467d1..bf209fcec9fa16988d0efac1704ab4d002b89737 100644 --- a/app/workers/remove_expired_members_worker.rb +++ b/app/workers/remove_expired_members_worker.rb @@ -5,6 +5,7 @@ class RemoveExpiredMembersWorker include CronjobQueue feature_category :authentication_and_authorization + worker_resource_boundary :cpu def perform Member.expired.find_each do |member| diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index bc2d0366fddde8333a8cb9d6d599fe3693b37e46..15677fb0a95671db58e5b56709870830c9d65189 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -7,6 +7,7 @@ class RepositoryImportWorker include ProjectImportOptions feature_category :importers + worker_has_external_dependencies! # technical debt: https://gitlab.com/gitlab-org/gitlab/issues/33991 sidekiq_options memory_killer_memory_growth_kb: ENV.fetch('MEMORY_KILLER_REPOSITORY_IMPORT_WORKER_MEMORY_GROWTH_KB', 50).to_i diff --git a/app/workers/repository_update_remote_mirror_worker.rb b/app/workers/repository_update_remote_mirror_worker.rb index b4d96546fa49791550970d6d1b3d6385c390b5d7..d1dec4cb7323ef1a508ee12f8bee613bbe576da8 100644 --- a/app/workers/repository_update_remote_mirror_worker.rb +++ b/app/workers/repository_update_remote_mirror_worker.rb @@ -6,6 +6,8 @@ class RepositoryUpdateRemoteMirrorWorker include ApplicationWorker include Gitlab::ExclusiveLeaseHelpers + worker_has_external_dependencies! + sidekiq_options retry: 3, dead: false feature_category :source_code_management diff --git a/app/workers/stage_update_worker.rb b/app/workers/stage_update_worker.rb index ea587789d033998c5183eb07df3399a78906c1e2..de2454128f65fbd7396b99a90ce745d1e0d208d5 100644 --- a/app/workers/stage_update_worker.rb +++ b/app/workers/stage_update_worker.rb @@ -5,6 +5,7 @@ class StageUpdateWorker include PipelineQueue queue_namespace :pipeline_processing + latency_sensitive_worker! # rubocop: disable CodeReuse/ActiveRecord def perform(stage_id) diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb index 971edb1f14f9ffa7c35280802a6d694bf786a80a..b116965d10508b90fe8dfaf14d50599ecfc2539f 100644 --- a/app/workers/stuck_ci_jobs_worker.rb +++ b/app/workers/stuck_ci_jobs_worker.rb @@ -5,6 +5,7 @@ class StuckCiJobsWorker include CronjobQueue feature_category :continuous_integration + worker_resource_boundary :cpu EXCLUSIVE_LEASE_KEY = 'stuck_ci_builds_worker_lease' @@ -72,5 +73,19 @@ class StuckCiJobsWorker Gitlab::OptimisticLocking.retry_lock(build, 3) do |b| b.drop(reason) end + rescue => ex + build.doom! + + track_exception_for_build(ex, build) + end + + def track_exception_for_build(ex, build) + Gitlab::Sentry.track_acceptable_exception(ex, extra: { + build_id: build.id, + build_name: build.name, + build_stage: build.stage, + pipeline_id: build.pipeline_id, + project_id: build.project_id + }) end end diff --git a/app/workers/stuck_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb index 4993cd1220c832e5c2e23cb31b48065d3ae28b4f..d9a9a613ca9af84864900e6b5e3556349088702f 100644 --- a/app/workers/stuck_import_jobs_worker.rb +++ b/app/workers/stuck_import_jobs_worker.rb @@ -5,6 +5,7 @@ class StuckImportJobsWorker include CronjobQueue feature_category :importers + worker_resource_boundary :cpu IMPORT_JOBS_EXPIRATION = 15.hours.to_i diff --git a/app/workers/update_head_pipeline_for_merge_request_worker.rb b/app/workers/update_head_pipeline_for_merge_request_worker.rb index 77859abfea416140bc0fddd2525f557309e64877..e069b16eb902def2f1b38c69f1b121b2a660d95d 100644 --- a/app/workers/update_head_pipeline_for_merge_request_worker.rb +++ b/app/workers/update_head_pipeline_for_merge_request_worker.rb @@ -6,6 +6,8 @@ class UpdateHeadPipelineForMergeRequestWorker queue_namespace :pipeline_processing feature_category :continuous_integration + latency_sensitive_worker! + worker_resource_boundary :cpu def perform(merge_request_id) MergeRequest.find_by_id(merge_request_id).try do |merge_request| diff --git a/app/workers/update_merge_requests_worker.rb b/app/workers/update_merge_requests_worker.rb index 8e1703cdd0b954212054b12ef25378c01bf46174..acb953539831ac7aa7584a573864ad4d76fd1236 100644 --- a/app/workers/update_merge_requests_worker.rb +++ b/app/workers/update_merge_requests_worker.rb @@ -4,6 +4,8 @@ class UpdateMergeRequestsWorker include ApplicationWorker feature_category :source_code_management + latency_sensitive_worker! + worker_resource_boundary :cpu LOG_TIME_THRESHOLD = 90 # seconds diff --git a/app/workers/wait_for_cluster_creation_worker.rb b/app/workers/wait_for_cluster_creation_worker.rb index 8aa1d9290fd3b0201e284270d0d145926df3aeaa..621125c8503b7f852aeaba4387987a5af435a7bd 100644 --- a/app/workers/wait_for_cluster_creation_worker.rb +++ b/app/workers/wait_for_cluster_creation_worker.rb @@ -4,10 +4,16 @@ class WaitForClusterCreationWorker include ApplicationWorker include ClusterQueue + worker_has_external_dependencies! + def perform(cluster_id) Clusters::Cluster.find_by_id(cluster_id).try do |cluster| cluster.provider.try do |provider| - Clusters::Gcp::VerifyProvisionStatusService.new.execute(provider) if cluster.gcp? + if cluster.gcp? + Clusters::Gcp::VerifyProvisionStatusService.new.execute(provider) + elsif cluster.aws? + Clusters::Aws::VerifyProvisionStatusService.new.execute(provider) + end end end end diff --git a/app/workers/web_hook_worker.rb b/app/workers/web_hook_worker.rb index fd7ca93683e5a5db8d276c5f5ae22dbb404ed281..c3fa3162c1401580cd4b8eba95c1637641b26c72 100644 --- a/app/workers/web_hook_worker.rb +++ b/app/workers/web_hook_worker.rb @@ -4,6 +4,8 @@ class WebHookWorker include ApplicationWorker feature_category :integrations + worker_has_external_dependencies! + sidekiq_options retry: 4, dead: false def perform(hook_id, data, hook_name) diff --git a/bin/secpick b/bin/secpick index a44867846d08c1d679d7baea5fcfecbcbea4b559..963172987f47f200347caf01a5ff4a82abad7730 100755 --- a/bin/secpick +++ b/bin/secpick @@ -103,7 +103,7 @@ module Secpick options[:branch] = branch end - opts.on('-s', '--sha abcd', 'SHA to cherry pick') do |sha| + opts.on('-s', '--sha abcd', 'SHA or SHA range to cherry pick') do |sha| options[:sha] = sha end diff --git a/changelogs/unreleased/10242-move-old-vulns-api-to-vuln-findings.yml b/changelogs/unreleased/10242-move-old-vulns-api-to-vuln-findings.yml new file mode 100644 index 0000000000000000000000000000000000000000..08e22948adddcb8a030e17453fd7c2102859670e --- /dev/null +++ b/changelogs/unreleased/10242-move-old-vulns-api-to-vuln-findings.yml @@ -0,0 +1,5 @@ +--- +title: Rename Vulnerabilities API to Vulnerability Findings API +merge_request: 19029 +author: +type: changed diff --git a/changelogs/unreleased/11137-propagate-all-env-vars-to-sast-containers.yml b/changelogs/unreleased/11137-propagate-all-env-vars-to-sast-containers.yml new file mode 100644 index 0000000000000000000000000000000000000000..e9096ac1d72e0c1a44dad076bc2adad2a4648ec5 --- /dev/null +++ b/changelogs/unreleased/11137-propagate-all-env-vars-to-sast-containers.yml @@ -0,0 +1,5 @@ +--- +title: Propagate custom environment variables to SAST analyzers +merge_request: 18193 +author: +type: changed diff --git a/changelogs/unreleased/11930-handle-multiple-entries-dast-report.yml b/changelogs/unreleased/11930-handle-multiple-entries-dast-report.yml new file mode 100644 index 0000000000000000000000000000000000000000..5fd6b539bcd16d2908c8f563e70cd711f73f9c24 --- /dev/null +++ b/changelogs/unreleased/11930-handle-multiple-entries-dast-report.yml @@ -0,0 +1,5 @@ +--- +title: The Security Dashboard displays DAST vulnerabilities for all the scanned sites, not just the first +merge_request: 17779 +author: +type: added diff --git a/changelogs/unreleased/12769-milestone-blank-name-causes-issues.yml b/changelogs/unreleased/12769-milestone-blank-name-causes-issues.yml new file mode 100644 index 0000000000000000000000000000000000000000..44ec08e611b0ab135aff14bedc1874440ec4f7da --- /dev/null +++ b/changelogs/unreleased/12769-milestone-blank-name-causes-issues.yml @@ -0,0 +1,5 @@ +--- +title: Ensure milestone titles are never empty +merge_request: 19985 +author: +type: fixed diff --git a/changelogs/unreleased/12881-security-dashboard-leveraging-sparklines-to-show-vulnerability-tren.yml b/changelogs/unreleased/12881-security-dashboard-leveraging-sparklines-to-show-vulnerability-tren.yml new file mode 100644 index 0000000000000000000000000000000000000000..15839300343da1f16f27f9f389e3711270b7ba6e --- /dev/null +++ b/changelogs/unreleased/12881-security-dashboard-leveraging-sparklines-to-show-vulnerability-tren.yml @@ -0,0 +1,5 @@ +--- +title: Vulnerabilities history chart - use sparklines +merge_request: 19745 +author: +type: changed diff --git a/changelogs/unreleased/13055-show-the-sync-information-for-design-repositories.yml b/changelogs/unreleased/13055-show-the-sync-information-for-design-repositories.yml new file mode 100644 index 0000000000000000000000000000000000000000..2f807170f172af530ba157a411214e9a57a8c259 --- /dev/null +++ b/changelogs/unreleased/13055-show-the-sync-information-for-design-repositories.yml @@ -0,0 +1,5 @@ +--- +title: 'Geo: Add resigns-related fields to Geo Node Status table' +merge_request: 18379 +author: +type: other diff --git a/changelogs/unreleased/13135-call-to-validate_query-in-custom-metrics-form-is-not-retried.yml b/changelogs/unreleased/13135-call-to-validate_query-in-custom-metrics-form-is-not-retried.yml new file mode 100644 index 0000000000000000000000000000000000000000..2728d5213c13f5de7602eb96938880b2268c7f52 --- /dev/null +++ b/changelogs/unreleased/13135-call-to-validate_query-in-custom-metrics-form-is-not-retried.yml @@ -0,0 +1,5 @@ +--- +title: Fix query validation in custom metrics form +merge_request: 18769 +author: +type: fixed diff --git a/changelogs/unreleased/13401-close-gitlab-issue-on-recovery-alerts-from-prometheus.yml b/changelogs/unreleased/13401-close-gitlab-issue-on-recovery-alerts-from-prometheus.yml new file mode 100644 index 0000000000000000000000000000000000000000..466dfd2f50bf27297600bd06a7a8b15be395580f --- /dev/null +++ b/changelogs/unreleased/13401-close-gitlab-issue-on-recovery-alerts-from-prometheus.yml @@ -0,0 +1,5 @@ +--- +title: Close issues on Prometheus alert recovery +merge_request: 18431 +author: +type: added diff --git a/changelogs/unreleased/13492-design-comments-width.yml b/changelogs/unreleased/13492-design-comments-width.yml new file mode 100644 index 0000000000000000000000000000000000000000..0929483a37d955258f9efce90588a8cd59b8a6fd --- /dev/null +++ b/changelogs/unreleased/13492-design-comments-width.yml @@ -0,0 +1,5 @@ +--- +title: Smaller width for design comments layout, truncate image title +merge_request: 17547 +author: +type: fixed diff --git a/changelogs/unreleased/13539-license-compliance-approval-required.yml b/changelogs/unreleased/13539-license-compliance-approval-required.yml new file mode 100644 index 0000000000000000000000000000000000000000..8fa91775dac1f33a3549d7068127afb018ca2b68 --- /dev/null +++ b/changelogs/unreleased/13539-license-compliance-approval-required.yml @@ -0,0 +1,5 @@ +--- +title: Show approval required status in license compliance +merge_request: 19114 +author: +type: changed diff --git a/changelogs/unreleased/14707-add-modsec-logging-sidecar-to-ingress-controller.yml b/changelogs/unreleased/14707-add-modsec-logging-sidecar-to-ingress-controller.yml new file mode 100644 index 0000000000000000000000000000000000000000..2ed6c45b5e354fcab390dbfc56a252887e1bb02c --- /dev/null +++ b/changelogs/unreleased/14707-add-modsec-logging-sidecar-to-ingress-controller.yml @@ -0,0 +1,5 @@ +--- +title: Add modsecurity logging sidecar to ingress controller +merge_request: 19600 +author: +type: added diff --git a/changelogs/unreleased/14770-ignore-deprecated-column-on-projects.yml b/changelogs/unreleased/14770-ignore-deprecated-column-on-projects.yml new file mode 100644 index 0000000000000000000000000000000000000000..26bcf121f57248290790a566095a1c33437b2078 --- /dev/null +++ b/changelogs/unreleased/14770-ignore-deprecated-column-on-projects.yml @@ -0,0 +1,5 @@ +--- +title: Ignore deprecated column and remove references to it +merge_request: 18911 +author: +type: deprecated 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 new file mode 100644 index 0000000000000000000000000000000000000000..c576b57f7cb6043276a534ca850eb66605f58f4b --- /dev/null +++ b/changelogs/unreleased/18126-change-tag-url-for-tag-push-events-in-chat-msg-integration.yaml @@ -0,0 +1,5 @@ +--- +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/18797-support-for-crossplane-as-a-managed-app.yml b/changelogs/unreleased/18797-support-for-crossplane-as-a-managed-app.yml new file mode 100644 index 0000000000000000000000000000000000000000..1f8d8093fdc919a1cf49509158a3fe17b343bba1 --- /dev/null +++ b/changelogs/unreleased/18797-support-for-crossplane-as-a-managed-app.yml @@ -0,0 +1,5 @@ +--- +title: Support for Crossplane as a managed app +merge_request: 18797 +author: Mahendra Bagul +type: added diff --git a/changelogs/unreleased/18986-allow-to-use-commit-sha-in-cache-key.yml b/changelogs/unreleased/18986-allow-to-use-commit-sha-in-cache-key.yml new file mode 100644 index 0000000000000000000000000000000000000000..3e08b80282a13ccce8f088184926eacb423a8216 --- /dev/null +++ b/changelogs/unreleased/18986-allow-to-use-commit-sha-in-cache-key.yml @@ -0,0 +1,5 @@ +--- +title: Build CI cache key from commit SHAs that changed given files +merge_request: 19392 +author: +type: added diff --git a/changelogs/unreleased/19054-upgrade-helm.yml b/changelogs/unreleased/19054-upgrade-helm.yml new file mode 100644 index 0000000000000000000000000000000000000000..cb4f887a6ed2e75f1a2650fdd599af36cdd39fc3 --- /dev/null +++ b/changelogs/unreleased/19054-upgrade-helm.yml @@ -0,0 +1,5 @@ +--- +title: 'Updated Auto-DevOps to kubectl v1.13.12 and helm v2.15.1' +merge_request: 19054 +author: Leo Antunes +type: changed diff --git a/changelogs/unreleased/19445-native-group-milestone-page-needs-same-info-as-project-milestones.yml b/changelogs/unreleased/19445-native-group-milestone-page-needs-same-info-as-project-milestones.yml new file mode 100644 index 0000000000000000000000000000000000000000..0e804e1322f1eb85bf636d7b9362cd32f112ce3c --- /dev/null +++ b/changelogs/unreleased/19445-native-group-milestone-page-needs-same-info-as-project-milestones.yml @@ -0,0 +1,5 @@ +--- +title: Add issues, MRs, participants, and labels tabs in group milestone page +merge_request: 18818 +author: +type: added diff --git a/changelogs/unreleased/20081-add-mb-2-class-to-global-alerts.yml b/changelogs/unreleased/20081-add-mb-2-class-to-global-alerts.yml new file mode 100644 index 0000000000000000000000000000000000000000..2aea916402edd0de86b53809590a0eb6f2ec58cf --- /dev/null +++ b/changelogs/unreleased/20081-add-mb-2-class-to-global-alerts.yml @@ -0,0 +1,5 @@ +--- +title: Add mb-2 class to global alerts +merge_request: 20081 +author: 2knal +type: other diff --git a/changelogs/unreleased/22392-add-eks-clusters-to-usage-data.yml b/changelogs/unreleased/22392-add-eks-clusters-to-usage-data.yml new file mode 100644 index 0000000000000000000000000000000000000000..cac10925020a394550099f2c0839ddcd633ce963 --- /dev/null +++ b/changelogs/unreleased/22392-add-eks-clusters-to-usage-data.yml @@ -0,0 +1,5 @@ +--- +title: Add EKS cluster count to usage data +merge_request: 17059 +author: +type: other diff --git a/changelogs/unreleased/22392-capture-aws-role-details.yml b/changelogs/unreleased/22392-capture-aws-role-details.yml new file mode 100644 index 0000000000000000000000000000000000000000..51b70d94f6a2a6e17713e995b40a73f964526f8b --- /dev/null +++ b/changelogs/unreleased/22392-capture-aws-role-details.yml @@ -0,0 +1,5 @@ +--- +title: Add ApplicationSetting entries for EKS integration +merge_request: 18307 +author: +type: other diff --git a/changelogs/unreleased/22392-eks-create-cluster-fe.yml b/changelogs/unreleased/22392-eks-create-cluster-fe.yml new file mode 100644 index 0000000000000000000000000000000000000000..133154de03f4f881611aea47520d7ff722eb5f64 --- /dev/null +++ b/changelogs/unreleased/22392-eks-create-cluster-fe.yml @@ -0,0 +1,5 @@ +--- +title: Create AWS EKS cluster +merge_request: 19578 +author: +type: added diff --git a/changelogs/unreleased/24082-double-escaping-in-tableflip-quick-action.yml b/changelogs/unreleased/24082-double-escaping-in-tableflip-quick-action.yml new file mode 100644 index 0000000000000000000000000000000000000000..b7a2a41c93eef8bb568cada350a5551a577ab3ac --- /dev/null +++ b/changelogs/unreleased/24082-double-escaping-in-tableflip-quick-action.yml @@ -0,0 +1,5 @@ +--- +title: Fix double escaping in /tableflip quick action +merge_request: 19271 +author: Brian T +type: fixed diff --git a/changelogs/unreleased/24146-query-string-params.yml b/changelogs/unreleased/24146-query-string-params.yml new file mode 100644 index 0000000000000000000000000000000000000000..80cab30e2554a2ac7d2ce7db5d95f923e37e13ca --- /dev/null +++ b/changelogs/unreleased/24146-query-string-params.yml @@ -0,0 +1,5 @@ +--- +title: Populate new pipeline CI vars from params +merge_request: 19023 +author: +type: added diff --git a/changelogs/unreleased/24172-group-vars.yml b/changelogs/unreleased/24172-group-vars.yml new file mode 100644 index 0000000000000000000000000000000000000000..14f604de1a4568902ea84dbda1190a9fc98768a1 --- /dev/null +++ b/changelogs/unreleased/24172-group-vars.yml @@ -0,0 +1,5 @@ +--- +title: Show inherited group variables in project view +merge_request: 18759 +author: +type: added diff --git a/changelogs/unreleased/24792-artifact-based-view-for-junit-xml.yml b/changelogs/unreleased/24792-artifact-based-view-for-junit-xml.yml new file mode 100644 index 0000000000000000000000000000000000000000..9deb1f23d2a715cd6b594b20857b623615d34e98 --- /dev/null +++ b/changelogs/unreleased/24792-artifact-based-view-for-junit-xml.yml @@ -0,0 +1,6 @@ +--- +title: Added Tests tab to pipeline detail that contains a UI for browsing test reports + produced by JUnit +merge_request: 18255 +author: +type: added diff --git a/changelogs/unreleased/25188-unable-to-expand-collapse-files-in-merge-request-by-clicking-caret.yml b/changelogs/unreleased/25188-unable-to-expand-collapse-files-in-merge-request-by-clicking-caret.yml new file mode 100644 index 0000000000000000000000000000000000000000..59a0efee01309c94addb1f3bcfe18949b985ec1e --- /dev/null +++ b/changelogs/unreleased/25188-unable-to-expand-collapse-files-in-merge-request-by-clicking-caret.yml @@ -0,0 +1,5 @@ +--- +title: Fix unable to expand or collapse files in merge request by clicking caret +merge_request: 19222 +author: Brian T +type: fixed diff --git a/changelogs/unreleased/26138-replace-raven-js-with-sentry-browser.yml b/changelogs/unreleased/26138-replace-raven-js-with-sentry-browser.yml new file mode 100644 index 0000000000000000000000000000000000000000..87db6917ab7cbbcfaee72b73670df163fda69cf0 --- /dev/null +++ b/changelogs/unreleased/26138-replace-raven-js-with-sentry-browser.yml @@ -0,0 +1,5 @@ +--- +title: Replace raven-js with @sentry/browser +merge_request: 17715 +author: +type: changed diff --git a/changelogs/unreleased/26207-issue-board-loading-infinite-if-closing-the-closed-row.yml b/changelogs/unreleased/26207-issue-board-loading-infinite-if-closing-the-closed-row.yml new file mode 100644 index 0000000000000000000000000000000000000000..f1b7e8a948e90164974050fd093607b7900f4014 --- /dev/null +++ b/changelogs/unreleased/26207-issue-board-loading-infinite-if-closing-the-closed-row.yml @@ -0,0 +1,5 @@ +--- +title: Fix closed board list loading issue +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/26380-personal-snippets.yml b/changelogs/unreleased/26380-personal-snippets.yml new file mode 100644 index 0000000000000000000000000000000000000000..6ea38a4623c8fdf5878c25da54d0fbe3c7a9b6ea --- /dev/null +++ b/changelogs/unreleased/26380-personal-snippets.yml @@ -0,0 +1,5 @@ +--- +title: Allow admins to administer personal snippets +merge_request: 19693 +author: Oren Kanner +type: fixed diff --git a/changelogs/unreleased/27034-new-branch-length.yml b/changelogs/unreleased/27034-new-branch-length.yml new file mode 100644 index 0000000000000000000000000000000000000000..44f91de98121272e8e828c628de875e441831d60 --- /dev/null +++ b/changelogs/unreleased/27034-new-branch-length.yml @@ -0,0 +1,5 @@ +--- +title: Truncate recommended branch name to a sane length +merge_request: 18821 +author: +type: changed diff --git a/changelogs/unreleased/27433-fix-vulnerable-code-copied-from-devise.yml b/changelogs/unreleased/27433-fix-vulnerable-code-copied-from-devise.yml new file mode 100644 index 0000000000000000000000000000000000000000..0332d85c35213659f8ea869cc9cb1afa943e919d --- /dev/null +++ b/changelogs/unreleased/27433-fix-vulnerable-code-copied-from-devise.yml @@ -0,0 +1,5 @@ +--- +title: Update incrementing of failed logins to be thread-safe +merge_request: 19614 +author: +type: security diff --git a/changelogs/unreleased/27464-misleading-input-controller-for-dates.yml b/changelogs/unreleased/27464-misleading-input-controller-for-dates.yml new file mode 100644 index 0000000000000000000000000000000000000000..5c5c699122be0aa558b2e735f39416769d6c3f19 --- /dev/null +++ b/changelogs/unreleased/27464-misleading-input-controller-for-dates.yml @@ -0,0 +1,5 @@ +--- +title: Remove calendar icon from personal access tokens +merge_request: 20183 +author: +type: other diff --git a/changelogs/unreleased/28258-fix-project-clone-dropdrown-button-width.yml b/changelogs/unreleased/28258-fix-project-clone-dropdrown-button-width.yml new file mode 100644 index 0000000000000000000000000000000000000000..d72a64de6e3570029c5177511fc7bdb5c21c8934 --- /dev/null +++ b/changelogs/unreleased/28258-fix-project-clone-dropdrown-button-width.yml @@ -0,0 +1,5 @@ +--- +title: Fix project clone dropdown button width +merge_request: 19551 +author: George Tsiolis +type: fixed diff --git a/changelogs/unreleased/28302-move-add-license-button.yml b/changelogs/unreleased/28302-move-add-license-button.yml new file mode 100644 index 0000000000000000000000000000000000000000..d9a5c15990fab338a65e3839b2a2b6b2a7b29576 --- /dev/null +++ b/changelogs/unreleased/28302-move-add-license-button.yml @@ -0,0 +1,5 @@ +--- +title: Move add license button to project buttons +merge_request: 19370 +author: +type: changed diff --git a/changelogs/unreleased/28336-dropdown-icon-missing-on-compare-page.yml b/changelogs/unreleased/28336-dropdown-icon-missing-on-compare-page.yml new file mode 100644 index 0000000000000000000000000000000000000000..75f40d1ac8a2f150cac3de45bbfc699f8e239d2a --- /dev/null +++ b/changelogs/unreleased/28336-dropdown-icon-missing-on-compare-page.yml @@ -0,0 +1,5 @@ +--- +title: Adding dropdown arrow icon and updated text alignment +merge_request: +author: +type: other diff --git a/changelogs/unreleased/28338-different-dropdown-styles-on-settings-page.yml b/changelogs/unreleased/28338-different-dropdown-styles-on-settings-page.yml new file mode 100644 index 0000000000000000000000000000000000000000..c70ca59a847aabb5569f3fed712a96954047f71e --- /dev/null +++ b/changelogs/unreleased/28338-different-dropdown-styles-on-settings-page.yml @@ -0,0 +1,5 @@ +--- +title: Change selects from default browser style to custom style +merge_request: +author: +type: other diff --git a/changelogs/unreleased/28350-manifest-error-file-attach.yml b/changelogs/unreleased/28350-manifest-error-file-attach.yml new file mode 100644 index 0000000000000000000000000000000000000000..f1c11d5f1bd88c6d768352bc4d0f3e91c61adba7 --- /dev/null +++ b/changelogs/unreleased/28350-manifest-error-file-attach.yml @@ -0,0 +1,5 @@ +--- +title: Add max width on manifest file attachment input +merge_request: 19028 +author: +type: fixed diff --git a/changelogs/unreleased/28801-fix-canary-inconsistency.yml b/changelogs/unreleased/28801-fix-canary-inconsistency.yml new file mode 100644 index 0000000000000000000000000000000000000000..fae9dd241fe1c11e3c465546bf33da60d18231a4 --- /dev/null +++ b/changelogs/unreleased/28801-fix-canary-inconsistency.yml @@ -0,0 +1,5 @@ +--- +title: Fix canary badge and favicon inconsistency +merge_request: 19645 +author: +type: fixed diff --git a/changelogs/unreleased/28985-links-to-notes-in-collapsed-discussions-dont-work.yml b/changelogs/unreleased/28985-links-to-notes-in-collapsed-discussions-dont-work.yml new file mode 100644 index 0000000000000000000000000000000000000000..46d1e94829ba076166d4493dfade9042842c8f04 --- /dev/null +++ b/changelogs/unreleased/28985-links-to-notes-in-collapsed-discussions-dont-work.yml @@ -0,0 +1,5 @@ +--- +title: Fix expanding collapsed threads when reference link clicked +merge_request: 20148 +author: +type: fixed diff --git a/changelogs/unreleased/29121-rename-trace.yml b/changelogs/unreleased/29121-rename-trace.yml new file mode 100644 index 0000000000000000000000000000000000000000..14c724e83562accf0ceb6c985e310ae784204c49 --- /dev/null +++ b/changelogs/unreleased/29121-rename-trace.yml @@ -0,0 +1,5 @@ +--- +title: Replace wording trace with log +merge_request: +author: +type: changed diff --git a/changelogs/unreleased/29451-webide-currentsha.yml b/changelogs/unreleased/29451-webide-currentsha.yml new file mode 100644 index 0000000000000000000000000000000000000000..566da5974131d4b0c2fae29e43cabc1fa147beaf --- /dev/null +++ b/changelogs/unreleased/29451-webide-currentsha.yml @@ -0,0 +1,5 @@ +--- +title: 'Resolve: Web IDE Throws Error When Viewing Diff for Renamed Files' +merge_request: 19348 +author: +type: fixed diff --git a/changelogs/unreleased/29713-graphql-add-issue-relative-position-sort-2.yml b/changelogs/unreleased/29713-graphql-add-issue-relative-position-sort-2.yml new file mode 100644 index 0000000000000000000000000000000000000000..38a02a027de5e5f0a89d8acd6415b1c30993cbad --- /dev/null +++ b/changelogs/unreleased/29713-graphql-add-issue-relative-position-sort-2.yml @@ -0,0 +1,5 @@ +--- +title: 'Graphql query for issues can now be sorted by relative_position' +merge_request: 19713 +author: +type: added diff --git a/changelogs/unreleased/29986-remove-leaky-401-responses.yml b/changelogs/unreleased/29986-remove-leaky-401-responses.yml new file mode 100644 index 0000000000000000000000000000000000000000..3d60011b63f4c94a6819cf057cb803782cd608bf --- /dev/null +++ b/changelogs/unreleased/29986-remove-leaky-401-responses.yml @@ -0,0 +1,5 @@ +--- +title: Standardize error response when route is missing +merge_request: +author: +type: security diff --git a/changelogs/unreleased/30131-breadcrumb-back-to-integrations-not-correct.yml b/changelogs/unreleased/30131-breadcrumb-back-to-integrations-not-correct.yml new file mode 100644 index 0000000000000000000000000000000000000000..5b8a46b4a88044d970bde5ab8de300f182015e7d --- /dev/null +++ b/changelogs/unreleased/30131-breadcrumb-back-to-integrations-not-correct.yml @@ -0,0 +1,5 @@ +--- +title: Add missing breadcrumb in Project > Settings > Integrations +merge_request: 18990 +author: +type: fixed diff --git a/changelogs/unreleased/30161-expose-merge-request-status-in-api.yml b/changelogs/unreleased/30161-expose-merge-request-status-in-api.yml new file mode 100644 index 0000000000000000000000000000000000000000..aaac2d31b0c612e3b3ea9ed42068f9425e81d1ac --- /dev/null +++ b/changelogs/unreleased/30161-expose-merge-request-status-in-api.yml @@ -0,0 +1,5 @@ +--- +title: Expose mergeable state of a merge request +merge_request: 18888 +author: briankabiro +type: added diff --git a/changelogs/unreleased/30182-checkbox-with-no-text-causing-following-checkboxes-not-to-be-saved.yml b/changelogs/unreleased/30182-checkbox-with-no-text-causing-following-checkboxes-not-to-be-saved.yml new file mode 100644 index 0000000000000000000000000000000000000000..5ea21bbd983fc8976faba35ac4b8b58361f2add0 --- /dev/null +++ b/changelogs/unreleased/30182-checkbox-with-no-text-causing-following-checkboxes-not-to-be-saved.yml @@ -0,0 +1,5 @@ +--- +title: Fix checking task item when previous tasks contain only spaces +merge_request: 19724 +author: +type: fixed diff --git a/changelogs/unreleased/30229-gitlab-backgroundmigration-pruneorphanedgeoevents-did-you-mean-prun.yml b/changelogs/unreleased/30229-gitlab-backgroundmigration-pruneorphanedgeoevents-did-you-mean-prun.yml new file mode 100644 index 0000000000000000000000000000000000000000..daaf051ea0c239bfdaf2525f669ea524ee894025 --- /dev/null +++ b/changelogs/unreleased/30229-gitlab-backgroundmigration-pruneorphanedgeoevents-did-you-mean-prun.yml @@ -0,0 +1,5 @@ +--- +title: "[Geo] Fix: undefined Gitlab::BackgroundMigration::PruneOrphanedGeoEvents" +merge_request: 19638 +author: +type: fixed diff --git a/changelogs/unreleased/30533-asciidoc-image-link-lazy-loading.yml b/changelogs/unreleased/30533-asciidoc-image-link-lazy-loading.yml new file mode 100644 index 0000000000000000000000000000000000000000..47320c327196599c4889fbc91e3dc092fa70706d --- /dev/null +++ b/changelogs/unreleased/30533-asciidoc-image-link-lazy-loading.yml @@ -0,0 +1,5 @@ +--- +title: Enable image link and lazy loading in AsciiDoc documents +merge_request: 18164 +author: Guillaume Grossetie +type: fixed diff --git a/changelogs/unreleased/30616-improve-merge-request-description-placeholder.yml b/changelogs/unreleased/30616-improve-merge-request-description-placeholder.yml new file mode 100644 index 0000000000000000000000000000000000000000..942450313beebc328342881932e21e74c912b005 --- /dev/null +++ b/changelogs/unreleased/30616-improve-merge-request-description-placeholder.yml @@ -0,0 +1,5 @@ +--- +title: Improve merge request description placeholder +merge_request: 20032 +author: Jacopo Beschi @jacopo-beschi +type: changed diff --git a/changelogs/unreleased/30660-allow-to-enable-disable-auto-ssl-letsencrypt-support-via-api.yml b/changelogs/unreleased/30660-allow-to-enable-disable-auto-ssl-letsencrypt-support-via-api.yml new file mode 100644 index 0000000000000000000000000000000000000000..7ec23301eeb87c56880c42dd2fd11eac815277f8 --- /dev/null +++ b/changelogs/unreleased/30660-allow-to-enable-disable-auto-ssl-letsencrypt-support-via-api.yml @@ -0,0 +1,5 @@ +--- +title: Require explicit null parameters to remove pages domain certificate and allow to use Let's Encrypt certificates through API +merge_request: +author: +type: changed diff --git a/changelogs/unreleased/30695-remove-add-btn-in-approval-rule.yml b/changelogs/unreleased/30695-remove-add-btn-in-approval-rule.yml new file mode 100644 index 0000000000000000000000000000000000000000..5ef0c9ef67af1f5bdcd3e362d6dcf5dff12c8737 --- /dev/null +++ b/changelogs/unreleased/30695-remove-add-btn-in-approval-rule.yml @@ -0,0 +1,5 @@ +--- +title: Can directly add approvers to approval rule +merge_request: 18965 +author: +type: changed diff --git a/changelogs/unreleased/30810-blob-view-buttons.yml b/changelogs/unreleased/30810-blob-view-buttons.yml new file mode 100644 index 0000000000000000000000000000000000000000..e8599817192c35c6f261cfa335225be001571a22 --- /dev/null +++ b/changelogs/unreleased/30810-blob-view-buttons.yml @@ -0,0 +1,5 @@ +--- +title: Change blob edit view button styling +merge_request: 19566 +author: +type: other diff --git a/changelogs/unreleased/31134-add-usage-ping-data-for-project-services.yml b/changelogs/unreleased/31134-add-usage-ping-data-for-project-services.yml new file mode 100644 index 0000000000000000000000000000000000000000..52846a6a91593b7eb24cb5de94435a38e4785950 --- /dev/null +++ b/changelogs/unreleased/31134-add-usage-ping-data-for-project-services.yml @@ -0,0 +1,5 @@ +--- +title: Add usage ping data for project services +merge_request: 19687 +author: +type: added diff --git a/changelogs/unreleased/31184-refactor-disabled-sidebar-notification-to-vue.yml b/changelogs/unreleased/31184-refactor-disabled-sidebar-notification-to-vue.yml new file mode 100644 index 0000000000000000000000000000000000000000..8ef877ff2fcc04cc9a9f239d8f84c6b096a90751 --- /dev/null +++ b/changelogs/unreleased/31184-refactor-disabled-sidebar-notification-to-vue.yml @@ -0,0 +1,5 @@ +--- +title: Refactor disabled sidebar notifications to Vue +merge_request: 20007 +author: minghuan lei +type: other diff --git a/changelogs/unreleased/31222-follow-up-from-adjusts-snowplow-to-use-cookies-for-sessions.yml b/changelogs/unreleased/31222-follow-up-from-adjusts-snowplow-to-use-cookies-for-sessions.yml new file mode 100644 index 0000000000000000000000000000000000000000..6d62663a0bcdbc4f5e2894ce4bbc420a62c01b30 --- /dev/null +++ b/changelogs/unreleased/31222-follow-up-from-adjusts-snowplow-to-use-cookies-for-sessions.yml @@ -0,0 +1,5 @@ +--- +title: Rename snowplow_site_id to snowplow_app_id in application_settings table +merge_request: 19252 +author: +type: other diff --git a/changelogs/unreleased/31309-add-ability-for-users-to-organize-projects-on-the-operations-dashbo.yml b/changelogs/unreleased/31309-add-ability-for-users-to-organize-projects-on-the-operations-dashbo.yml new file mode 100644 index 0000000000000000000000000000000000000000..cd70b5f78640ddecd73e310fa27b9387a2341440 --- /dev/null +++ b/changelogs/unreleased/31309-add-ability-for-users-to-organize-projects-on-the-operations-dashbo.yml @@ -0,0 +1,5 @@ +--- +title: Add ability to reorder projects on operations dashboard +merge_request: 18855 +author: +type: added diff --git a/changelogs/unreleased/31364-error-when-attempting-to-view-gitlab-com-group-billing-page.yml b/changelogs/unreleased/31364-error-when-attempting-to-view-gitlab-com-group-billing-page.yml new file mode 100644 index 0000000000000000000000000000000000000000..22f69005dd5d679d3be97ccbdcd27f362328a727 --- /dev/null +++ b/changelogs/unreleased/31364-error-when-attempting-to-view-gitlab-com-group-billing-page.yml @@ -0,0 +1,5 @@ +--- +title: Fix error when viewing group billing page +merge_request: 18740 +author: +type: fixed diff --git a/changelogs/unreleased/31390-remove-pointer-cursor-from-memory-usage-chart.yml b/changelogs/unreleased/31390-remove-pointer-cursor-from-memory-usage-chart.yml new file mode 100644 index 0000000000000000000000000000000000000000..0bbbcb115231a7fc06fab0e13eb674c9d2817b4a --- /dev/null +++ b/changelogs/unreleased/31390-remove-pointer-cursor-from-memory-usage-chart.yml @@ -0,0 +1,5 @@ +--- +title: Remove pointer cursor from MemoryUsage chart on MR widget deployment +merge_request: 18599 +author: +type: fixed diff --git a/changelogs/unreleased/31411-add-loading-indicator-when-connecting-to-error-tracking-server.yml b/changelogs/unreleased/31411-add-loading-indicator-when-connecting-to-error-tracking-server.yml new file mode 100644 index 0000000000000000000000000000000000000000..7e62b9f90b4eef4506f85ed52d08a9855ea5db0a --- /dev/null +++ b/changelogs/unreleased/31411-add-loading-indicator-when-connecting-to-error-tracking-server.yml @@ -0,0 +1,5 @@ +--- +title: Add loading icon to error tracking settings page +merge_request: 19539 +author: +type: changed diff --git a/changelogs/unreleased/31658-add-rollback-dialog-environment.yml b/changelogs/unreleased/31658-add-rollback-dialog-environment.yml new file mode 100644 index 0000000000000000000000000000000000000000..d50feb434a2b057000b38ad6bfd51dfb0a9c1bd4 --- /dev/null +++ b/changelogs/unreleased/31658-add-rollback-dialog-environment.yml @@ -0,0 +1,5 @@ +--- +title: Fix environment name in rollback dialog +merge_request: 19209 +author: +type: fixed diff --git a/changelogs/unreleased/31843-contextual-documentation-to-help-users-download-npm-packages.yml b/changelogs/unreleased/31843-contextual-documentation-to-help-users-download-npm-packages.yml new file mode 100644 index 0000000000000000000000000000000000000000..d301f6fed5b807eb1454251b54659e9d2970401a --- /dev/null +++ b/changelogs/unreleased/31843-contextual-documentation-to-help-users-download-npm-packages.yml @@ -0,0 +1,5 @@ +--- +title: Added installation commands for npm and yarn packages to package detail page +merge_request: 18999 +author: +type: added diff --git a/changelogs/unreleased/31868-make-name-optional-parameter-of-release-entity.yml b/changelogs/unreleased/31868-make-name-optional-parameter-of-release-entity.yml new file mode 100644 index 0000000000000000000000000000000000000000..4ba2af01dfa3fc64a89b83781233d3ba4d4366c2 --- /dev/null +++ b/changelogs/unreleased/31868-make-name-optional-parameter-of-release-entity.yml @@ -0,0 +1,5 @@ +--- +title: Made `name` optional parameter of Release entity +merge_request: 19705 +author: +type: changed diff --git a/changelogs/unreleased/31912-epic-labels.yml b/changelogs/unreleased/31912-epic-labels.yml new file mode 100644 index 0000000000000000000000000000000000000000..ec4ca31f837606e4f63003e3810e5ed717b5f2ef --- /dev/null +++ b/changelogs/unreleased/31912-epic-labels.yml @@ -0,0 +1,5 @@ +--- +title: Manage and display labels from epic in the GraphQL API +merge_request: 19642 +author: +type: added diff --git a/changelogs/unreleased/31914-graphql-todos-mark-todo-as-done-pd.yml b/changelogs/unreleased/31914-graphql-todos-mark-todo-as-done-pd.yml new file mode 100644 index 0000000000000000000000000000000000000000..2a5a7a2ec5e6cabf5eb471d119249aee3be6a098 --- /dev/null +++ b/changelogs/unreleased/31914-graphql-todos-mark-todo-as-done-pd.yml @@ -0,0 +1,5 @@ +--- +title: Mark todo done by GraphQL API +merge_request: 18581 +author: +type: added diff --git a/changelogs/unreleased/31919-graphql-MR-assignee-mutation.yml b/changelogs/unreleased/31919-graphql-MR-assignee-mutation.yml new file mode 100644 index 0000000000000000000000000000000000000000..02c2e4421dc8e61047e7d25441adb535e47c3508 --- /dev/null +++ b/changelogs/unreleased/31919-graphql-MR-assignee-mutation.yml @@ -0,0 +1,5 @@ +--- +title: Add MergeRequestSetAssignees GraphQL mutation +merge_request: 19272 +author: +type: added diff --git a/changelogs/unreleased/31919-graphql-MR-label-mutation.yml b/changelogs/unreleased/31919-graphql-MR-label-mutation.yml new file mode 100644 index 0000000000000000000000000000000000000000..41a1a91713d6aad64b02daad2c6cdd1d0919426b --- /dev/null +++ b/changelogs/unreleased/31919-graphql-MR-label-mutation.yml @@ -0,0 +1,5 @@ +--- +title: 'GraphQL: Create MR mutations needed for the sidebar' +merge_request: 19913 +author: +type: added diff --git a/changelogs/unreleased/31919-graphql-MR-sidebar-mutations.yml b/changelogs/unreleased/31919-graphql-MR-sidebar-mutations.yml new file mode 100644 index 0000000000000000000000000000000000000000..e8d47abd16925f6fd5233bdf0b50f830bce0961b --- /dev/null +++ b/changelogs/unreleased/31919-graphql-MR-sidebar-mutations.yml @@ -0,0 +1,5 @@ +--- +title: 'GraphQL: Add Merge Request milestone mutation' +merge_request: 19257 +author: +type: added diff --git a/changelogs/unreleased/31964-make-snippet-list-easier-to-scan.yml b/changelogs/unreleased/31964-make-snippet-list-easier-to-scan.yml new file mode 100644 index 0000000000000000000000000000000000000000..d10165438d4be17f831411df25953ba64746ab42 --- /dev/null +++ b/changelogs/unreleased/31964-make-snippet-list-easier-to-scan.yml @@ -0,0 +1,5 @@ +--- +title: Make snippet list easier to scan +merge_request: 19490 +author: +type: other diff --git a/changelogs/unreleased/32052-add-pa-start-date-limit.yml b/changelogs/unreleased/32052-add-pa-start-date-limit.yml new file mode 100644 index 0000000000000000000000000000000000000000..fa2cf796edbb46350621f1b100c7681079fb35b5 --- /dev/null +++ b/changelogs/unreleased/32052-add-pa-start-date-limit.yml @@ -0,0 +1,5 @@ +--- +title: Add productivity analytics merge date filtering limit +merge_request: 32052 +author: +type: fixed diff --git a/changelogs/unreleased/32198-banner-alerting-of-project-move-is-showing-up-everywhere.yml b/changelogs/unreleased/32198-banner-alerting-of-project-move-is-showing-up-everywhere.yml new file mode 100644 index 0000000000000000000000000000000000000000..a6d211d59ca2d1443ffcb39e4a884d4ce804e006 --- /dev/null +++ b/changelogs/unreleased/32198-banner-alerting-of-project-move-is-showing-up-everywhere.yml @@ -0,0 +1,5 @@ +--- +title: Fix "project or group was moved" alerts showing up in the wrong pages +merge_request: 18985 +author: +type: fixed diff --git a/changelogs/unreleased/32358-add-modsec-feature-flag-to-usage-ping.yml b/changelogs/unreleased/32358-add-modsec-feature-flag-to-usage-ping.yml new file mode 100644 index 0000000000000000000000000000000000000000..9bfdf4eb513ffd313f619fb8041cfe7d0c697cbb --- /dev/null +++ b/changelogs/unreleased/32358-add-modsec-feature-flag-to-usage-ping.yml @@ -0,0 +1,5 @@ +--- +title: Add modsecurity feature flag to usage ping +merge_request: 20194 +author: +type: added diff --git a/changelogs/unreleased/32367-hashed-storage-migration-handle-failed-attachment-migrations-wth-ex.yml b/changelogs/unreleased/32367-hashed-storage-migration-handle-failed-attachment-migrations-wth-ex.yml new file mode 100644 index 0000000000000000000000000000000000000000..6690318d8acb69b08e4225664ec3affdcae4fcf3 --- /dev/null +++ b/changelogs/unreleased/32367-hashed-storage-migration-handle-failed-attachment-migrations-wth-ex.yml @@ -0,0 +1,6 @@ +--- +title: 'Hashed Storage Migration: Handle failed attachment migrations with existing + target path' +merge_request: 19061 +author: +type: fixed diff --git a/changelogs/unreleased/32398-geo-rake-gitlab-geo-check-on-the-primary-is-cluttered.yml b/changelogs/unreleased/32398-geo-rake-gitlab-geo-check-on-the-primary-is-cluttered.yml new file mode 100644 index 0000000000000000000000000000000000000000..e8a8828b92948063cad323b33baf7f4eb1f4f438 --- /dev/null +++ b/changelogs/unreleased/32398-geo-rake-gitlab-geo-check-on-the-primary-is-cluttered.yml @@ -0,0 +1,5 @@ +--- +title: "[Geo] Fix: rake gitlab:geo:check on the primary is cluttered" +merge_request: 19460 +author: +type: changed diff --git a/changelogs/unreleased/32419-growth-conversion-experiment-test-new-admin-upgrade-design-copy-for.yml b/changelogs/unreleased/32419-growth-conversion-experiment-test-new-admin-upgrade-design-copy-for.yml new file mode 100644 index 0000000000000000000000000000000000000000..12de1e6aadd31ee3d8513d030d0ba5cccfbd34f8 --- /dev/null +++ b/changelogs/unreleased/32419-growth-conversion-experiment-test-new-admin-upgrade-design-copy-for.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade design/copy for issue weights locked feature +merge_request: 17352 +author: +type: changed diff --git a/changelogs/unreleased/32458-update-group-creation-url-so-it-is-always-unique-and-does-not-gener.yml b/changelogs/unreleased/32458-update-group-creation-url-so-it-is-always-unique-and-does-not-gener.yml new file mode 100644 index 0000000000000000000000000000000000000000..53a7331b4d9931dcd0a7f790dc870ef7b1f2ed65 --- /dev/null +++ b/changelogs/unreleased/32458-update-group-creation-url-so-it-is-always-unique-and-does-not-gener.yml @@ -0,0 +1,5 @@ +--- +title: New group path uniqueness check +merge_request: 17394 +author: +type: added diff --git a/changelogs/unreleased/32464-backend-sentry-error-details.yml b/changelogs/unreleased/32464-backend-sentry-error-details.yml new file mode 100644 index 0000000000000000000000000000000000000000..aa7b44f9d083e38f45decbfa15fcebe5eee33129 --- /dev/null +++ b/changelogs/unreleased/32464-backend-sentry-error-details.yml @@ -0,0 +1,5 @@ +--- +title: API for stack trace & detail view of Sentry error in GitLab +merge_request: 19137 +author: +type: added diff --git a/changelogs/unreleased/32464-detail-view-of-sentry-error.yml b/changelogs/unreleased/32464-detail-view-of-sentry-error.yml new file mode 100644 index 0000000000000000000000000000000000000000..d23a2b08419f0d3bfcfc05e66fed3545abb66a52 --- /dev/null +++ b/changelogs/unreleased/32464-detail-view-of-sentry-error.yml @@ -0,0 +1,5 @@ +--- +title: Detail view of Sentry error in GitLab +merge_request: 18878 +author: +type: added diff --git a/changelogs/unreleased/32534-gitlab-rake-gitlab-cleanup-orphan_job_artifact_files-dry_run-false-is-not-removing-artifacts.yml b/changelogs/unreleased/32534-gitlab-rake-gitlab-cleanup-orphan_job_artifact_files-dry_run-false-is-not-removing-artifacts.yml new file mode 100644 index 0000000000000000000000000000000000000000..aa0e5fee47ae55f71da36ac789868c01f090b0c4 --- /dev/null +++ b/changelogs/unreleased/32534-gitlab-rake-gitlab-cleanup-orphan_job_artifact_files-dry_run-false-is-not-removing-artifacts.yml @@ -0,0 +1,5 @@ +--- +title: Correctly cleanup orphan job artifacts +merge_request: 17679 +author: Adam Mulvany +type: fixed diff --git a/changelogs/unreleased/32562-dynamic-db-pool-size-in-puma.yml b/changelogs/unreleased/32562-dynamic-db-pool-size-in-puma.yml new file mode 100644 index 0000000000000000000000000000000000000000..e90de9a4d6aeda2be36c336d07bfaa8c6d484ed5 --- /dev/null +++ b/changelogs/unreleased/32562-dynamic-db-pool-size-in-puma.yml @@ -0,0 +1,5 @@ +--- +title: 'Puma only: database connection pool now always >= number of worker threads' +merge_request: 19286 +author: +type: performance diff --git a/changelogs/unreleased/32685-remove-epics-tree-feature-flag.yml b/changelogs/unreleased/32685-remove-epics-tree-feature-flag.yml new file mode 100644 index 0000000000000000000000000000000000000000..5c30150e66a19fa2f1a33db93c7c998569c0f37d --- /dev/null +++ b/changelogs/unreleased/32685-remove-epics-tree-feature-flag.yml @@ -0,0 +1,5 @@ +--- +title: Show Tree UI containing child Epics and Issues within an Epic +merge_request: 19812 +author: +type: added diff --git a/changelogs/unreleased/32935-preventing-accidental-project-deletion-db-changes.yml b/changelogs/unreleased/32935-preventing-accidental-project-deletion-db-changes.yml new file mode 100644 index 0000000000000000000000000000000000000000..15b9f0d55cbab05dfd3cff60da606060fa0347c9 --- /dev/null +++ b/changelogs/unreleased/32935-preventing-accidental-project-deletion-db-changes.yml @@ -0,0 +1,5 @@ +--- +title: Add migrations and changes for soft-delete for projects +merge_request: 18791 +author: +type: added diff --git a/changelogs/unreleased/32935-preventing-accidental-project-deletion-index.yml b/changelogs/unreleased/32935-preventing-accidental-project-deletion-index.yml new file mode 100644 index 0000000000000000000000000000000000000000..7f85da35f83e115646be7dcacc51beb3c5841a52 --- /dev/null +++ b/changelogs/unreleased/32935-preventing-accidental-project-deletion-index.yml @@ -0,0 +1,5 @@ +--- +title: Add index on marked_for_deletion_at in projects table +merge_request: 19788 +author: +type: other diff --git a/changelogs/unreleased/32951-secure-modal-mobile-issue.yml b/changelogs/unreleased/32951-secure-modal-mobile-issue.yml new file mode 100644 index 0000000000000000000000000000000000000000..d3c4399cb35579b2d074e217ba2ee2b2f95fb4cc --- /dev/null +++ b/changelogs/unreleased/32951-secure-modal-mobile-issue.yml @@ -0,0 +1,5 @@ +--- +title: Fixes mobile styling issues on security modals +merge_request: 19391 +author: +type: fixed diff --git a/changelogs/unreleased/32962-update-gcp-credit-url.yml b/changelogs/unreleased/32962-update-gcp-credit-url.yml new file mode 100644 index 0000000000000000000000000000000000000000..87e3e7ff36433ab6059b5bed8981092b76e11788 --- /dev/null +++ b/changelogs/unreleased/32962-update-gcp-credit-url.yml @@ -0,0 +1,5 @@ +--- +title: Update GCP credit URLs +merge_request: 19683 +author: +type: fixed diff --git a/changelogs/unreleased/33042-persist-zoom-meetings-added-to-issues-in-the-database-2.yml b/changelogs/unreleased/33042-persist-zoom-meetings-added-to-issues-in-the-database-2.yml new file mode 100644 index 0000000000000000000000000000000000000000..dd7df055c7121ca38e8c558fe7478a4b9cc41995 --- /dev/null +++ b/changelogs/unreleased/33042-persist-zoom-meetings-added-to-issues-in-the-database-2.yml @@ -0,0 +1,5 @@ +--- +title: Store Zoom URLs in a table rather than in the issue description +merge_request: 18620 +author: +type: changed diff --git a/changelogs/unreleased/33054-share_groups_with_groups.yml b/changelogs/unreleased/33054-share_groups_with_groups.yml new file mode 100644 index 0000000000000000000000000000000000000000..0a1f9e5cb3b1886d8174f4697abafc07d2819e15 --- /dev/null +++ b/changelogs/unreleased/33054-share_groups_with_groups.yml @@ -0,0 +1,5 @@ +--- +title: Share groups with groups +merge_request: 17117 +author: +type: added diff --git a/changelogs/unreleased/33101-add-deployments-api-updated-after-param.yml b/changelogs/unreleased/33101-add-deployments-api-updated-after-param.yml new file mode 100644 index 0000000000000000000000000000000000000000..f3bc1dd97513145f27b744baeb288bde4701917c --- /dev/null +++ b/changelogs/unreleased/33101-add-deployments-api-updated-after-param.yml @@ -0,0 +1,5 @@ +--- +title: Allow order_by updated_at in Deployments API +merge_request: 19658 +author: +type: added diff --git a/changelogs/unreleased/33121-refactor-user-counts.yml b/changelogs/unreleased/33121-refactor-user-counts.yml new file mode 100644 index 0000000000000000000000000000000000000000..6e3ee5f18f3ab1ef2a0ef71b350232e2c9926dcf --- /dev/null +++ b/changelogs/unreleased/33121-refactor-user-counts.yml @@ -0,0 +1,5 @@ +--- +title: Refactor maximum user counts in license +merge_request: 19071 +author: briankabiro +type: changed diff --git a/changelogs/unreleased/33182-fix-productivity-analytics-multiple-labels-bug.yml b/changelogs/unreleased/33182-fix-productivity-analytics-multiple-labels-bug.yml new file mode 100644 index 0000000000000000000000000000000000000000..4c306c539d4abdf4d60e90b4dff515d0e32fbbf8 --- /dev/null +++ b/changelogs/unreleased/33182-fix-productivity-analytics-multiple-labels-bug.yml @@ -0,0 +1,5 @@ +--- +title: Fix productivity analytics listing with multiple labels +merge_request: 33182 +author: +type: fixed diff --git a/changelogs/unreleased/33268-updated-operations-metrics-charts-do-not-load-properly.yml b/changelogs/unreleased/33268-updated-operations-metrics-charts-do-not-load-properly.yml new file mode 100644 index 0000000000000000000000000000000000000000..e1a24404372b4bbb377f63b77f6431e475a7cebb --- /dev/null +++ b/changelogs/unreleased/33268-updated-operations-metrics-charts-do-not-load-properly.yml @@ -0,0 +1,5 @@ +--- +title: Fix empty chart in collapsed sections +merge_request: 18699 +author: +type: fixed diff --git a/changelogs/unreleased/33306-missing-field-discussions.yml b/changelogs/unreleased/33306-missing-field-discussions.yml new file mode 100644 index 0000000000000000000000000000000000000000..6506b6ad1c6e4e067db90ca6870d388607afb936 --- /dev/null +++ b/changelogs/unreleased/33306-missing-field-discussions.yml @@ -0,0 +1,5 @@ +--- +title: Prevents console warning on design upload +merge_request: 19297 +author: +type: fixed diff --git a/changelogs/unreleased/33460-webide-line-endings.yml b/changelogs/unreleased/33460-webide-line-endings.yml new file mode 100644 index 0000000000000000000000000000000000000000..62fe15c051b12faf669d2d192037f33db8b9e717 --- /dev/null +++ b/changelogs/unreleased/33460-webide-line-endings.yml @@ -0,0 +1,5 @@ +--- +title: 'Resolve: Web IDE does not create POSIX Compliant Files' +merge_request: 19339 +author: +type: fixed diff --git a/changelogs/unreleased/33533-go-to-root-if-no-path-on-branch.yml b/changelogs/unreleased/33533-go-to-root-if-no-path-on-branch.yml new file mode 100644 index 0000000000000000000000000000000000000000..c85faa9e7a2769f24e78dd3ddc4f6f0e3618f08e --- /dev/null +++ b/changelogs/unreleased/33533-go-to-root-if-no-path-on-branch.yml @@ -0,0 +1,6 @@ +--- +title: When a user views a file's blame or blob and switches to a branch where the + current file does not exist, they will now be redirected to the root of the repository. +merge_request: 18169 +author: Jesse Hall @jessehall3 +type: changed diff --git a/changelogs/unreleased/33557-make-pages-settings-e-g-enablement-access-control-more-visible.yml b/changelogs/unreleased/33557-make-pages-settings-e-g-enablement-access-control-more-visible.yml new file mode 100644 index 0000000000000000000000000000000000000000..d04771f47a82c0477c0b3cd69e92494de1467c7b --- /dev/null +++ b/changelogs/unreleased/33557-make-pages-settings-e-g-enablement-access-control-more-visible.yml @@ -0,0 +1,5 @@ +--- +title: Add warnings about pages access control settings +merge_request: 19067 +author: +type: added diff --git a/changelogs/unreleased/33672-add-enable-checkbox-for-grafana-authentication-settings.yml b/changelogs/unreleased/33672-add-enable-checkbox-for-grafana-authentication-settings.yml new file mode 100644 index 0000000000000000000000000000000000000000..bb45c5ec4d2a98ff142d4f46da87d9db9befeb27 --- /dev/null +++ b/changelogs/unreleased/33672-add-enable-checkbox-for-grafana-authentication-settings.yml @@ -0,0 +1,5 @@ +--- +title: Migrate enabled flag on grafana_integrations table +merge_request: 19234 +author: +type: changed diff --git a/changelogs/unreleased/33805-add_serverless_framework_template.yml b/changelogs/unreleased/33805-add_serverless_framework_template.yml new file mode 100644 index 0000000000000000000000000000000000000000..3f6ceddc84e7e20e03593c7caaa63f078fed5057 --- /dev/null +++ b/changelogs/unreleased/33805-add_serverless_framework_template.yml @@ -0,0 +1,5 @@ +--- +title: Add template for Serverless Framework/JS +merge_request: 33805 +author: +type: added diff --git a/changelogs/unreleased/33896-security-dashboard-projects.yml b/changelogs/unreleased/33896-security-dashboard-projects.yml new file mode 100644 index 0000000000000000000000000000000000000000..9f93e1b34979f63804ae73facddc2d0a09b9b700 --- /dev/null +++ b/changelogs/unreleased/33896-security-dashboard-projects.yml @@ -0,0 +1,5 @@ +--- +title: Create a users_security_dashboard_projects table to store the projects a user has added to their personal security dashboard +merge_request: 18708 +author: +type: added diff --git a/changelogs/unreleased/33897-make-arrow-buttons-work-within-search-box.yml b/changelogs/unreleased/33897-make-arrow-buttons-work-within-search-box.yml new file mode 100644 index 0000000000000000000000000000000000000000..8132343322b35e71bddce9c15ea0341ac3168967 --- /dev/null +++ b/changelogs/unreleased/33897-make-arrow-buttons-work-within-search-box.yml @@ -0,0 +1,5 @@ +--- +title: Fix keyboard shortcuts in header search autocomplete +merge_request: 18685 +author: +type: fixed diff --git a/changelogs/unreleased/33902-fix-private-group-todo-mentions.yml b/changelogs/unreleased/33902-fix-private-group-todo-mentions.yml new file mode 100644 index 0000000000000000000000000000000000000000..52405fe7553b6e21deae899ed6d0ab91b2cfe571 --- /dev/null +++ b/changelogs/unreleased/33902-fix-private-group-todo-mentions.yml @@ -0,0 +1,5 @@ +--- +title: Do not generate To-Dos additional when editing group mentions +merge_request: 19037 +author: +type: fixed diff --git a/changelogs/unreleased/34078-extend-audit-events-api-for-gitlab-com.yml b/changelogs/unreleased/34078-extend-audit-events-api-for-gitlab-com.yml new file mode 100644 index 0000000000000000000000000000000000000000..8b4d640c3f8edbde2f95776ab9fd99d9dd9a4545 --- /dev/null +++ b/changelogs/unreleased/34078-extend-audit-events-api-for-gitlab-com.yml @@ -0,0 +1,5 @@ +--- +title: Add Group Audit Events API +merge_request: 19868 +author: +type: added diff --git a/changelogs/unreleased/34132-graphql-epic-subscribtions.yml b/changelogs/unreleased/34132-graphql-epic-subscribtions.yml new file mode 100644 index 0000000000000000000000000000000000000000..a35da2e5212d9b634f637a01d0d2eca76c9b9413 --- /dev/null +++ b/changelogs/unreleased/34132-graphql-epic-subscribtions.yml @@ -0,0 +1,5 @@ +--- +title: Graphql mutation for (un)subscribing to an epic +merge_request: 19083 +author: +type: added diff --git a/changelogs/unreleased/34149-hide-delete-selected-in-designs-when-viewing-an-old-version.yml b/changelogs/unreleased/34149-hide-delete-selected-in-designs-when-viewing-an-old-version.yml new file mode 100644 index 0000000000000000000000000000000000000000..73e24b856fc32b528d50268dba932b53005398ab --- /dev/null +++ b/changelogs/unreleased/34149-hide-delete-selected-in-designs-when-viewing-an-old-version.yml @@ -0,0 +1,5 @@ +--- +title: Resolve Hide Delete selected in designs when viewing an old version +merge_request: 19889 +author: +type: fixed diff --git a/changelogs/unreleased/34230-fix-popover-image.yml b/changelogs/unreleased/34230-fix-popover-image.yml new file mode 100644 index 0000000000000000000000000000000000000000..c9cf230ac6cbe11d217a7fc9d123b059ea836dbb --- /dev/null +++ b/changelogs/unreleased/34230-fix-popover-image.yml @@ -0,0 +1,5 @@ +--- +title: Fix cluster feature highlight popover image +merge_request: 19372 +author: +type: fixed diff --git a/changelogs/unreleased/34258-embedding-sentry-stacktrace.yml b/changelogs/unreleased/34258-embedding-sentry-stacktrace.yml new file mode 100644 index 0000000000000000000000000000000000000000..8cbd16a0e401171f67ff8e87d660c868619bc550 --- /dev/null +++ b/changelogs/unreleased/34258-embedding-sentry-stacktrace.yml @@ -0,0 +1,5 @@ +--- +title: Sentry error stacktrace +merge_request: 19492 +author: +type: added diff --git a/changelogs/unreleased/34299-enable-color-chip-asciidoc.yml b/changelogs/unreleased/34299-enable-color-chip-asciidoc.yml new file mode 100644 index 0000000000000000000000000000000000000000..546e6bc6b635c00fb10fa058291f769f2db6a0e0 --- /dev/null +++ b/changelogs/unreleased/34299-enable-color-chip-asciidoc.yml @@ -0,0 +1,5 @@ +--- +title: Enable the color chip in AsciiDoc documents +merge_request: 18723 +author: +type: added diff --git a/changelogs/unreleased/34320-error-when-uploading-a-few-designs-in-a-row.yml b/changelogs/unreleased/34320-error-when-uploading-a-few-designs-in-a-row.yml new file mode 100644 index 0000000000000000000000000000000000000000..b727bc7f85ea8de9d6747510a736f7e1ce84199f --- /dev/null +++ b/changelogs/unreleased/34320-error-when-uploading-a-few-designs-in-a-row.yml @@ -0,0 +1,5 @@ +--- +title: Resolve Error when uploading a few designs in a row +merge_request: 18811 +author: +type: fixed diff --git a/changelogs/unreleased/34335-move-subscribed-field-to-issue-type.yml b/changelogs/unreleased/34335-move-subscribed-field-to-issue-type.yml new file mode 100644 index 0000000000000000000000000000000000000000..d411874e62a02bfa92d80e587b34c936d0b1ffa2 --- /dev/null +++ b/changelogs/unreleased/34335-move-subscribed-field-to-issue-type.yml @@ -0,0 +1,5 @@ +--- +title: Expose subscribed field in issue lists queried with GraphQL +merge_request: 19458 +author: briankabiro +type: changed diff --git a/changelogs/unreleased/34372-serverless-function-description-does-not-show-up-for-newly-created-.yml b/changelogs/unreleased/34372-serverless-function-description-does-not-show-up-for-newly-created-.yml new file mode 100644 index 0000000000000000000000000000000000000000..0a7bfd5fb4f1ede914f0d62d9744f6405549da80 --- /dev/null +++ b/changelogs/unreleased/34372-serverless-function-description-does-not-show-up-for-newly-created-.yml @@ -0,0 +1,5 @@ +--- +title: Fix serverless function descriptions not showing on Knative 0.7 +merge_request: 18973 +author: +type: fixed diff --git a/changelogs/unreleased/34416-subscribed-notification-header.yml b/changelogs/unreleased/34416-subscribed-notification-header.yml new file mode 100644 index 0000000000000000000000000000000000000000..dd1fca4255b4dd85d824c593dd98ad6974391ce2 --- /dev/null +++ b/changelogs/unreleased/34416-subscribed-notification-header.yml @@ -0,0 +1,5 @@ +--- +title: Set X-GitLab-NotificationReason header if notification reason is explicit subscription +merge_request: 18812 +author: +type: added diff --git a/changelogs/unreleased/34423-user-popover-immediately-closed-when-hovering-over-certain-areas.yml b/changelogs/unreleased/34423-user-popover-immediately-closed-when-hovering-over-certain-areas.yml new file mode 100644 index 0000000000000000000000000000000000000000..56a36830b546d05c95e1de085cb69148ab31bbe7 --- /dev/null +++ b/changelogs/unreleased/34423-user-popover-immediately-closed-when-hovering-over-certain-areas.yml @@ -0,0 +1,5 @@ +--- +title: Fix user popover not being displayed when the user has a status message +merge_request: 19519 +author: +type: fixed diff --git a/changelogs/unreleased/34426-use-new-list-task-icon-in-text-editor.yml b/changelogs/unreleased/34426-use-new-list-task-icon-in-text-editor.yml new file mode 100644 index 0000000000000000000000000000000000000000..2b66bd02e937a919c11cf311e0656d8c4eedcdfd --- /dev/null +++ b/changelogs/unreleased/34426-use-new-list-task-icon-in-text-editor.yml @@ -0,0 +1,6 @@ +--- +title: Replace task-done icon with list-task icon to better align with other toolbar + list icons +merge_request: +author: +type: other diff --git a/changelogs/unreleased/34431-add-report-type-vulnerabilities.yml b/changelogs/unreleased/34431-add-report-type-vulnerabilities.yml new file mode 100644 index 0000000000000000000000000000000000000000..36d21162d20496793aa62f21e81be8ea951ce742 --- /dev/null +++ b/changelogs/unreleased/34431-add-report-type-vulnerabilities.yml @@ -0,0 +1,5 @@ +--- +title: Added report_type attribute to Vulnerabilities +merge_request: 19179 +author: +type: changed diff --git a/changelogs/unreleased/34443-fix-template-bug.yml b/changelogs/unreleased/34443-fix-template-bug.yml new file mode 100644 index 0000000000000000000000000000000000000000..57881debcad112c35ecd7803c144b3c3b507964c --- /dev/null +++ b/changelogs/unreleased/34443-fix-template-bug.yml @@ -0,0 +1,5 @@ +--- +title: Fix template selector filename bug +merge_request: 19376 +author: +type: fixed diff --git a/changelogs/unreleased/34519-move-planels-in-dashboard-save-to-the-vuex-store.yml b/changelogs/unreleased/34519-move-planels-in-dashboard-save-to-the-vuex-store.yml new file mode 100644 index 0000000000000000000000000000000000000000..669a0c8bd9323928a6fef5ade584353fa3f26a53 --- /dev/null +++ b/changelogs/unreleased/34519-move-planels-in-dashboard-save-to-the-vuex-store.yml @@ -0,0 +1,5 @@ +--- +title: Save dashboard changes by the user into the vuex store +merge_request: 18862 +author: +type: changed diff --git a/changelogs/unreleased/34564-vulnerability-issue-links.yml b/changelogs/unreleased/34564-vulnerability-issue-links.yml new file mode 100644 index 0000000000000000000000000000000000000000..0f6f5610159e64cff1436709925730fcf6c87e7c --- /dev/null +++ b/changelogs/unreleased/34564-vulnerability-issue-links.yml @@ -0,0 +1,5 @@ +--- +title: Update the DB schema to allow linking between Vulnerabilities and Issues +merge_request: 19852 +author: +type: added diff --git a/changelogs/unreleased/34577-add-dep-scanner-var-maven.yml b/changelogs/unreleased/34577-add-dep-scanner-var-maven.yml new file mode 100644 index 0000000000000000000000000000000000000000..8b28cead295f06721712762f0b9a7e609b0302a1 --- /dev/null +++ b/changelogs/unreleased/34577-add-dep-scanner-var-maven.yml @@ -0,0 +1,5 @@ +--- +title: Add maven cli opts flag to maven security analyzer (part of dependency scanning) +merge_request: 19174 +author: +type: changed diff --git a/changelogs/unreleased/34606-Remove-IIFEs-from-merge_request-js.yml b/changelogs/unreleased/34606-Remove-IIFEs-from-merge_request-js.yml new file mode 100644 index 0000000000000000000000000000000000000000..9cb44979f3e24e4f5c0098b1c1c41e6caf0ce10c --- /dev/null +++ b/changelogs/unreleased/34606-Remove-IIFEs-from-merge_request-js.yml @@ -0,0 +1,5 @@ +--- +title: Remove IIFEs from merge_request.js +merge_request: 19294 +author: minghuan lei +type: other diff --git a/changelogs/unreleased/34607-Remove-IIFEs-from-branch_graph-js.yml b/changelogs/unreleased/34607-Remove-IIFEs-from-branch_graph-js.yml new file mode 100644 index 0000000000000000000000000000000000000000..6c902549c4c637e3f39f13156c3f2413daec9054 --- /dev/null +++ b/changelogs/unreleased/34607-Remove-IIFEs-from-branch_graph-js.yml @@ -0,0 +1,5 @@ +--- +title: Remove IIFEs from branch_graph.js +merge_request: 20008 +author: minghuan lei +type: other diff --git a/changelogs/unreleased/34610-Remove-IIFEs-from-new_branch_form-js.yml b/changelogs/unreleased/34610-Remove-IIFEs-from-new_branch_form-js.yml new file mode 100644 index 0000000000000000000000000000000000000000..7875772222f4c4cf8933837689e0b65f55657432 --- /dev/null +++ b/changelogs/unreleased/34610-Remove-IIFEs-from-new_branch_form-js.yml @@ -0,0 +1,5 @@ +--- +title: Remove IIFEs from new_branch_form.js +merge_request: 20009 +author: minghuan lei +type: other diff --git a/changelogs/unreleased/34624-remove-IIFEs-from-project_select-js.yml b/changelogs/unreleased/34624-remove-IIFEs-from-project_select-js.yml new file mode 100644 index 0000000000000000000000000000000000000000..a5acaf7c5dcb710c078b53ede2023e49ab02e28d --- /dev/null +++ b/changelogs/unreleased/34624-remove-IIFEs-from-project_select-js.yml @@ -0,0 +1,5 @@ +--- +title: Remove IIFEs from project_select.js +merge_request: 19288 +author: minghuan lei +type: other diff --git a/changelogs/unreleased/34717-update-expired-trial-copy.yml b/changelogs/unreleased/34717-update-expired-trial-copy.yml new file mode 100644 index 0000000000000000000000000000000000000000..d1f77d3e4cb5f3054fa6b815628860355b3fd8c0 --- /dev/null +++ b/changelogs/unreleased/34717-update-expired-trial-copy.yml @@ -0,0 +1,5 @@ +--- +title: Update expired trial status copy +merge_request: 18962 +author: +type: changed diff --git a/changelogs/unreleased/34755-refactor-getdateinpast-to-return-date-object.yml b/changelogs/unreleased/34755-refactor-getdateinpast-to-return-date-object.yml new file mode 100644 index 0000000000000000000000000000000000000000..0a374daa7d254c23b521d01eceff08121560983d --- /dev/null +++ b/changelogs/unreleased/34755-refactor-getdateinpast-to-return-date-object.yml @@ -0,0 +1,5 @@ +--- +title: Change return type of getDateInPast to Date +merge_request: 19081 +author: +type: changed diff --git a/changelogs/unreleased/34757-bugfix-graphql-missing-todo-types.yml b/changelogs/unreleased/34757-bugfix-graphql-missing-todo-types.yml new file mode 100644 index 0000000000000000000000000000000000000000..94ed1963f53cfbd4e99c4feeab7d07ac254e7966 --- /dev/null +++ b/changelogs/unreleased/34757-bugfix-graphql-missing-todo-types.yml @@ -0,0 +1,5 @@ +--- +title: Fix errors in GraphQL Todos API due to missing TargetTypeEnum values +merge_request: 19052 +author: +type: fixed diff --git a/changelogs/unreleased/34770-error-500-for-api-v4-projects-id-services-jira-nomethoderror-undefi.yml b/changelogs/unreleased/34770-error-500-for-api-v4-projects-id-services-jira-nomethoderror-undefi.yml new file mode 100644 index 0000000000000000000000000000000000000000..68de843402d1ef8fddacc5126e4055b71abb1506 --- /dev/null +++ b/changelogs/unreleased/34770-error-500-for-api-v4-projects-id-services-jira-nomethoderror-undefi.yml @@ -0,0 +1,5 @@ +--- +title: Fix project service API 500 error +merge_request: 19367 +author: +type: fixed diff --git a/changelogs/unreleased/34779-editing-metric-dashboard-using-yml-file.yml b/changelogs/unreleased/34779-editing-metric-dashboard-using-yml-file.yml new file mode 100644 index 0000000000000000000000000000000000000000..f4a4d67175ecd6b54001c682530e89dde1d2e0f7 --- /dev/null +++ b/changelogs/unreleased/34779-editing-metric-dashboard-using-yml-file.yml @@ -0,0 +1,5 @@ +--- +title: Add edit button to metrics dashboard +merge_request: 19279 +author: +type: added diff --git a/changelogs/unreleased/34827-delete-container-registry-tag-undefined-method-success-for-nil.yml b/changelogs/unreleased/34827-delete-container-registry-tag-undefined-method-success-for-nil.yml new file mode 100644 index 0000000000000000000000000000000000000000..97f5fae95bbbdf231299a2df77ca4705a57632eb --- /dev/null +++ b/changelogs/unreleased/34827-delete-container-registry-tag-undefined-method-success-for-nil.yml @@ -0,0 +1,5 @@ +--- +title: Fix crash when docker fails deleting tags +merge_request: 19208 +author: +type: fixed diff --git a/changelogs/unreleased/34850-fix-graphql-todo-ids.yml b/changelogs/unreleased/34850-fix-graphql-todo-ids.yml new file mode 100644 index 0000000000000000000000000000000000000000..ba3d63a2ee5b9be6e9c2639aef000b16bf5ca6a7 --- /dev/null +++ b/changelogs/unreleased/34850-fix-graphql-todo-ids.yml @@ -0,0 +1,5 @@ +--- +title: Fix Todo IDs in GraphQL API +merge_request: 19068 +author: +type: fixed diff --git a/changelogs/unreleased/34855-dependency-list-is-not-up-to-date-frontend.yml b/changelogs/unreleased/34855-dependency-list-is-not-up-to-date-frontend.yml new file mode 100644 index 0000000000000000000000000000000000000000..aa1858a1b98ec08250e461a7b79cfcc11c576452 --- /dev/null +++ b/changelogs/unreleased/34855-dependency-list-is-not-up-to-date-frontend.yml @@ -0,0 +1,5 @@ +--- +title: Add pipeline information to dependency list header +merge_request: 19352 +author: +type: added diff --git a/changelogs/unreleased/34944-fix-kubernetes-help-text-link.yml b/changelogs/unreleased/34944-fix-kubernetes-help-text-link.yml new file mode 100644 index 0000000000000000000000000000000000000000..5fd87c43bbc727c34cc58503d2e49745b1a3d6de --- /dev/null +++ b/changelogs/unreleased/34944-fix-kubernetes-help-text-link.yml @@ -0,0 +1,5 @@ +--- +title: Fix Kubernetes help text link +merge_request: 19121 +author: +type: fixed diff --git a/changelogs/unreleased/35217-add_fields_to_all_dashboards.yml b/changelogs/unreleased/35217-add_fields_to_all_dashboards.yml new file mode 100644 index 0000000000000000000000000000000000000000..36ee8977ab10fbc0a1229b1de9ade3de7b8b78e2 --- /dev/null +++ b/changelogs/unreleased/35217-add_fields_to_all_dashboards.yml @@ -0,0 +1,5 @@ +--- +title: Add can_edit and project_blob_path to metrics_dashboard endpoint +merge_request: 19663 +author: +type: added diff --git a/changelogs/unreleased/35440-Hide-start-trial-buttons-for-expired-namespaces.yml b/changelogs/unreleased/35440-Hide-start-trial-buttons-for-expired-namespaces.yml new file mode 100644 index 0000000000000000000000000000000000000000..d2d60e8c4987374ed304a170e540d74e1952e303 --- /dev/null +++ b/changelogs/unreleased/35440-Hide-start-trial-buttons-for-expired-namespaces.yml @@ -0,0 +1,5 @@ +--- +title: Hide repeated trial offers on self-hosted instances +merge_request: 19511 +author: +type: changed diff --git a/changelogs/unreleased/35440-for-com-namespaces-with-expired-trials-remove-start-trial-ctas.yml b/changelogs/unreleased/35440-for-com-namespaces-with-expired-trials-remove-start-trial-ctas.yml new file mode 100644 index 0000000000000000000000000000000000000000..3b4ec67334f2e29a70c3cdcaabdda2936b739961 --- /dev/null +++ b/changelogs/unreleased/35440-for-com-namespaces-with-expired-trials-remove-start-trial-ctas.yml @@ -0,0 +1,5 @@ +--- +title: Hide trial banner for namespaces with expired trials +merge_request: 19510 +author: +type: changed diff --git a/changelogs/unreleased/35528-add-ui-event-tracking-for-container-registry.yml b/changelogs/unreleased/35528-add-ui-event-tracking-for-container-registry.yml new file mode 100644 index 0000000000000000000000000000000000000000..9b1ae378fefa5e7616eb3a28f0e84d5a6751a4a9 --- /dev/null +++ b/changelogs/unreleased/35528-add-ui-event-tracking-for-container-registry.yml @@ -0,0 +1,5 @@ +--- +title: Add event tracking to container registry +merge_request: 19772 +author: +type: changed diff --git a/changelogs/unreleased/35534-broken-scroll-to-bottom.yml b/changelogs/unreleased/35534-broken-scroll-to-bottom.yml new file mode 100644 index 0000000000000000000000000000000000000000..a7b6d06a8f80617c78dd584e22741d28f7066988 --- /dev/null +++ b/changelogs/unreleased/35534-broken-scroll-to-bottom.yml @@ -0,0 +1,5 @@ +--- +title: Fix scroll to bottom with new job log +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/35537-button-regression-fix.yml b/changelogs/unreleased/35537-button-regression-fix.yml new file mode 100644 index 0000000000000000000000000000000000000000..05c2e7a5a627c1117d35c85fd80882a614d0a277 --- /dev/null +++ b/changelogs/unreleased/35537-button-regression-fix.yml @@ -0,0 +1,5 @@ +--- +title: Revert btn-xs styling in projects scss +merge_request: 19640 +author: +type: fixed diff --git a/changelogs/unreleased/35547-add-documentation-for-sign-in-application-setting.yml b/changelogs/unreleased/35547-add-documentation-for-sign-in-application-setting.yml new file mode 100644 index 0000000000000000000000000000000000000000..d13fa1d8fc5bf3516e5fc1ef0795e35608808f1a --- /dev/null +++ b/changelogs/unreleased/35547-add-documentation-for-sign-in-application-setting.yml @@ -0,0 +1,5 @@ +--- +title: Add documentation for sign-in application setting +merge_request: 19561 +author: Horatiu Eugen Vlad +type: added diff --git a/changelogs/unreleased/35618-add-clipboard-button-to-package-registry-information.yml b/changelogs/unreleased/35618-add-clipboard-button-to-package-registry-information.yml new file mode 100644 index 0000000000000000000000000000000000000000..faf22561fb0963120a3dd40b236c43f15c753609 --- /dev/null +++ b/changelogs/unreleased/35618-add-clipboard-button-to-package-registry-information.yml @@ -0,0 +1,5 @@ +--- +title: Adds a copy button next to package metadata on the details page +merge_request: 19881 +author: +type: added diff --git a/changelogs/unreleased/35637-add-start-a-trial-option-in-top-right-drop-down.yml b/changelogs/unreleased/35637-add-start-a-trial-option-in-top-right-drop-down.yml new file mode 100644 index 0000000000000000000000000000000000000000..f07de3cc104d1db441276151cc40ca7bc4a53609 --- /dev/null +++ b/changelogs/unreleased/35637-add-start-a-trial-option-in-top-right-drop-down.yml @@ -0,0 +1,5 @@ +--- +title: Add start a trial option in the top-right user dropdown +merge_request: 19632 +author: +type: added diff --git a/changelogs/unreleased/35709-update-squash-commit-sha-only-on-successful-merge.yml b/changelogs/unreleased/35709-update-squash-commit-sha-only-on-successful-merge.yml new file mode 100644 index 0000000000000000000000000000000000000000..8ab549c4b06a403e508e8a8d8ccc358dd9b7fa2b --- /dev/null +++ b/changelogs/unreleased/35709-update-squash-commit-sha-only-on-successful-merge.yml @@ -0,0 +1,5 @@ +--- +title: Update squash_commit_sha only on successful merge +merge_request: 19688 +author: +type: fixed diff --git a/changelogs/unreleased/35731-fix-snippets-with-emoji-import.yml b/changelogs/unreleased/35731-fix-snippets-with-emoji-import.yml new file mode 100644 index 0000000000000000000000000000000000000000..bf977958ca3b75794651728e2f79c551253b63af --- /dev/null +++ b/changelogs/unreleased/35731-fix-snippets-with-emoji-import.yml @@ -0,0 +1,5 @@ +--- +title: Fix import of snippets having `award_emoji` (Project Export/Import) +merge_request: 19690 +author: +type: fixed diff --git a/changelogs/unreleased/35749-improve-performance-of-lfs_object-queries.yml b/changelogs/unreleased/35749-improve-performance-of-lfs_object-queries.yml new file mode 100644 index 0000000000000000000000000000000000000000..ea2582805c4a414ab0fd09cc0c9c996b566e6d79 --- /dev/null +++ b/changelogs/unreleased/35749-improve-performance-of-lfs_object-queries.yml @@ -0,0 +1,5 @@ +--- +title: Improve performance of linking LFS objects during import +merge_request: 19709 +author: +type: performance diff --git a/changelogs/unreleased/35751-new-trial-flow-for-logged-in-user-should-not-take-them-to-the-custo.yml b/changelogs/unreleased/35751-new-trial-flow-for-logged-in-user-should-not-take-them-to-the-custo.yml new file mode 100644 index 0000000000000000000000000000000000000000..4e3a1479f1185b349f4c2473ee6c822ca6f9347f --- /dev/null +++ b/changelogs/unreleased/35751-new-trial-flow-for-logged-in-user-should-not-take-them-to-the-custo.yml @@ -0,0 +1,5 @@ +--- +title: Use new trial registration URL in billing +merge_request: 19978 +author: +type: fixed diff --git a/changelogs/unreleased/35908-embedded-videos-not-scaled-correctly.yml b/changelogs/unreleased/35908-embedded-videos-not-scaled-correctly.yml new file mode 100644 index 0000000000000000000000000000000000000000..a5234533dc23218a88292e809e85f6280db1816f --- /dev/null +++ b/changelogs/unreleased/35908-embedded-videos-not-scaled-correctly.yml @@ -0,0 +1,5 @@ +--- +title: Fixed the scale of embedded videos to fit the page +merge_request: 20056 +author: +type: fixed diff --git a/changelogs/unreleased/36113-visual-design-of-edit-and-web-ide-button-in-blob-view.yml b/changelogs/unreleased/36113-visual-design-of-edit-and-web-ide-button-in-blob-view.yml new file mode 100644 index 0000000000000000000000000000000000000000..30583144a9e09ce134f0d1777f06156dba3a22e1 --- /dev/null +++ b/changelogs/unreleased/36113-visual-design-of-edit-and-web-ide-button-in-blob-view.yml @@ -0,0 +1,5 @@ +--- +title: Visual design for edit buttons in blob view +merge_request: 19932 +author: +type: other diff --git a/changelogs/unreleased/36141-update-start-a-trial-option-in-top-right-drop-down-to-include-gold.yml b/changelogs/unreleased/36141-update-start-a-trial-option-in-top-right-drop-down-to-include-gold.yml new file mode 100644 index 0000000000000000000000000000000000000000..6952e630e2d6ec46eee5913a3b5a78ef8c171874 --- /dev/null +++ b/changelogs/unreleased/36141-update-start-a-trial-option-in-top-right-drop-down-to-include-gold.yml @@ -0,0 +1,5 @@ +--- +title: Update start a trial option in top right drop down to include Gold +merge_request: 19971 +author: +type: changed diff --git a/changelogs/unreleased/36142-update-saas-trial-header-to-include-the-tier-gold.yml b/changelogs/unreleased/36142-update-saas-trial-header-to-include-the-tier-gold.yml new file mode 100644 index 0000000000000000000000000000000000000000..d68b5b1f4b9c0d57a156e9583fce23a1a31b72bb --- /dev/null +++ b/changelogs/unreleased/36142-update-saas-trial-header-to-include-the-tier-gold.yml @@ -0,0 +1,5 @@ +--- +title: Update SaaS trial header to include the tier Gold +merge_request: 19970 +author: +type: changed diff --git a/changelogs/unreleased/36213-update-codequality-to-12-5.yml b/changelogs/unreleased/36213-update-codequality-to-12-5.yml new file mode 100644 index 0000000000000000000000000000000000000000..5b4429af81ed49dd5918eca026179166078a3e09 --- /dev/null +++ b/changelogs/unreleased/36213-update-codequality-to-12-5.yml @@ -0,0 +1,5 @@ +--- +title: Update registry.gitlab.com/gitlab-org/security-products/codequality to 12-5-stable +merge_request: 20046 +author: Takuya Noguchi +type: other diff --git a/changelogs/unreleased/36262-monitor-cluster-health-charts-does-not-load.yml b/changelogs/unreleased/36262-monitor-cluster-health-charts-does-not-load.yml new file mode 100644 index 0000000000000000000000000000000000000000..95817e6010e02b97a6a414d3c9a4c5b25b7f7325 --- /dev/null +++ b/changelogs/unreleased/36262-monitor-cluster-health-charts-does-not-load.yml @@ -0,0 +1,5 @@ +--- +title: Fix broken monitor cluster health dashboard +merge_request: 20120 +author: +type: fixed diff --git a/changelogs/unreleased/36453-flashcontainer-causing-page-header-to-be-off-center.yml b/changelogs/unreleased/36453-flashcontainer-causing-page-header-to-be-off-center.yml new file mode 100644 index 0000000000000000000000000000000000000000..12f44a5f661e6a5a0dbfe810233807b7f67fe7c7 --- /dev/null +++ b/changelogs/unreleased/36453-flashcontainer-causing-page-header-to-be-off-center.yml @@ -0,0 +1,5 @@ +--- +title: Move margin-top from flash container to flash +merge_request: 20211 +author: +type: other diff --git a/changelogs/unreleased/36460-metrics-dashboard-fails-to-load-script-doesn-t-stop.yml b/changelogs/unreleased/36460-metrics-dashboard-fails-to-load-script-doesn-t-stop.yml new file mode 100644 index 0000000000000000000000000000000000000000..313df8ba4fb06098ccdf871e32fb7914f8236452 --- /dev/null +++ b/changelogs/unreleased/36460-metrics-dashboard-fails-to-load-script-doesn-t-stop.yml @@ -0,0 +1,5 @@ +--- +title: Remove update hook from date filter to prevent js from getting stuck +merge_request: 20215 +author: +type: fixed diff --git a/changelogs/unreleased/48-add-company-question-to-profile-information.yml b/changelogs/unreleased/48-add-company-question-to-profile-information.yml new file mode 100644 index 0000000000000000000000000000000000000000..255f2289cdc924122509f352a8895ccac183920a --- /dev/null +++ b/changelogs/unreleased/48-add-company-question-to-profile-information.yml @@ -0,0 +1,5 @@ +--- +title: Ask if the user is setting up GitLab for a company during signup +merge_request: 17999 +author: +type: changed diff --git a/changelogs/unreleased/5366-display-anomaly-deviation-boundaries-on-dashboard-ce.yml b/changelogs/unreleased/5366-display-anomaly-deviation-boundaries-on-dashboard-ce.yml new file mode 100644 index 0000000000000000000000000000000000000000..08b85156259622f63a25c98944a054f47deabc20 --- /dev/null +++ b/changelogs/unreleased/5366-display-anomaly-deviation-boundaries-on-dashboard-ce.yml @@ -0,0 +1,5 @@ +--- +title: Added new chart component to display an anomaly boundary +merge_request: 16530 +author: +type: added diff --git a/changelogs/unreleased/63778-graphql-add-issue-due-date-sortring.yml b/changelogs/unreleased/63778-graphql-add-issue-due-date-sortring.yml new file mode 100644 index 0000000000000000000000000000000000000000..805c0f3c6b2e371fb9fd7bff47342f5fdc5af1a5 --- /dev/null +++ b/changelogs/unreleased/63778-graphql-add-issue-due-date-sortring.yml @@ -0,0 +1,5 @@ +--- +title: 'Issues queried in GraphQL now sortable by due date' +merge_request: 18094 +author: +type: added diff --git a/changelogs/unreleased/7104-show-timeframe-dates-in-epics-list.yml b/changelogs/unreleased/7104-show-timeframe-dates-in-epics-list.yml new file mode 100644 index 0000000000000000000000000000000000000000..529c3858fad69524ce624b89b7472e6066a17669 --- /dev/null +++ b/changelogs/unreleased/7104-show-timeframe-dates-in-epics-list.yml @@ -0,0 +1,5 @@ +--- +title: Show start and end dates in Epics list page +merge_request: 19006 +author: +type: added diff --git a/changelogs/unreleased/7816-commits-diff-total-api.yml b/changelogs/unreleased/7816-commits-diff-total-api.yml new file mode 100644 index 0000000000000000000000000000000000000000..95b8afda6822a14d8f649f298a59475b1a9ed143 --- /dev/null +++ b/changelogs/unreleased/7816-commits-diff-total-api.yml @@ -0,0 +1,5 @@ +--- +title: Show correct total number of commit diff's changes +merge_request: 19424 +author: +type: fixed diff --git a/changelogs/unreleased/8199-epic-quick-actions-preview.yml b/changelogs/unreleased/8199-epic-quick-actions-preview.yml new file mode 100644 index 0000000000000000000000000000000000000000..640c2b47c6fe21da36e506478fa3bb45d12bed77 --- /dev/null +++ b/changelogs/unreleased/8199-epic-quick-actions-preview.yml @@ -0,0 +1,5 @@ +--- +title: Fix previewing quick actions for epics +merge_request: 19042 +author: +type: fixed diff --git a/changelogs/unreleased/8558-bump-ado-image-for-modsec-secruleengine.yml b/changelogs/unreleased/8558-bump-ado-image-for-modsec-secruleengine.yml new file mode 100644 index 0000000000000000000000000000000000000000..615ae1452d05d7b62675a8caa66c737a445274fe --- /dev/null +++ b/changelogs/unreleased/8558-bump-ado-image-for-modsec-secruleengine.yml @@ -0,0 +1,5 @@ +--- +title: Bump Auto-Deploy image to v0.3.0 +merge_request: 18809 +author: +type: added diff --git a/changelogs/unreleased/8558-use-custom-modsecurity-ingress-config.yml b/changelogs/unreleased/8558-use-custom-modsecurity-ingress-config.yml new file mode 100644 index 0000000000000000000000000000000000000000..180715e89d6998dbb906fff80c4b24f376e6386a --- /dev/null +++ b/changelogs/unreleased/8558-use-custom-modsecurity-ingress-config.yml @@ -0,0 +1,5 @@ +--- +title: Add modsecurity template for ingress-controller +merge_request: 18485 +author: +type: changed diff --git a/changelogs/unreleased/CauhxMilloy-gitlab-addStartEndMarkersForTagsSearch.yml b/changelogs/unreleased/CauhxMilloy-gitlab-addStartEndMarkersForTagsSearch.yml new file mode 100644 index 0000000000000000000000000000000000000000..e06f7868a349ea840f70625607b62230e57f507d --- /dev/null +++ b/changelogs/unreleased/CauhxMilloy-gitlab-addStartEndMarkersForTagsSearch.yml @@ -0,0 +1,5 @@ +--- +title: Adding support for searching tags using '^' and '$' +merge_request: 19435 +author: Cauhx Milloy +type: added diff --git a/changelogs/unreleased/Remove-IIFEs-from-image_file-js.yml b/changelogs/unreleased/Remove-IIFEs-from-image_file-js.yml new file mode 100644 index 0000000000000000000000000000000000000000..4a5f8a892a78afeb80b595461575c1bb382144b8 --- /dev/null +++ b/changelogs/unreleased/Remove-IIFEs-from-image_file-js.yml @@ -0,0 +1,5 @@ +--- +title: Removed IIFEs from image_file.js +merge_request: 19548 +author: nuwe1 +type: other diff --git a/changelogs/unreleased/Remove-IIFEs-from-network-js.yml b/changelogs/unreleased/Remove-IIFEs-from-network-js.yml new file mode 100644 index 0000000000000000000000000000000000000000..2db5ab239d2070ce34ac1e252b0346f82f18040b --- /dev/null +++ b/changelogs/unreleased/Remove-IIFEs-from-network-js.yml @@ -0,0 +1,5 @@ +--- +title: Removed IIFEs from network.js file +merge_request: 19254 +author: nuwe1 +type: other diff --git a/changelogs/unreleased/Update-boards-components-board_form-vue.yml b/changelogs/unreleased/Update-boards-components-board_form-vue.yml new file mode 100644 index 0000000000000000000000000000000000000000..e754aaf3b94c3d01dece72f6add7275bd36e8e20 --- /dev/null +++ b/changelogs/unreleased/Update-boards-components-board_form-vue.yml @@ -0,0 +1,5 @@ +--- +title: Remove all reference to BoardService in board_form.vue +merge_request: 20158 +author: nuwe1 +type: other diff --git a/changelogs/unreleased/Update-boards-components-models-index-vue.yml b/changelogs/unreleased/Update-boards-components-models-index-vue.yml new file mode 100644 index 0000000000000000000000000000000000000000..e328c6ca0da9849bc56e1b74891d95fef8e81936 --- /dev/null +++ b/changelogs/unreleased/Update-boards-components-models-index-vue.yml @@ -0,0 +1,5 @@ +--- +title: Remove all references to BoardsService in index.vue +merge_request: 20152 +author: nuwe1 +type: other diff --git a/changelogs/unreleased/Update-boards_selector-vue-to-use-boardsStore.yml b/changelogs/unreleased/Update-boards_selector-vue-to-use-boardsStore.yml new file mode 100644 index 0000000000000000000000000000000000000000..95234bd906944f8ed765321d91f7424acda722f3 --- /dev/null +++ b/changelogs/unreleased/Update-boards_selector-vue-to-use-boardsStore.yml @@ -0,0 +1,5 @@ +--- +title: remove all references of BoardService in boards_selector.vue +merge_request: 20147 +author: nuwe1 +type: other diff --git a/changelogs/unreleased/ab-projects-api-indexes-authenticated.yml b/changelogs/unreleased/ab-projects-api-indexes-authenticated.yml new file mode 100644 index 0000000000000000000000000000000000000000..7bfb2b8d166029447fda48f197a48e63318252fb --- /dev/null +++ b/changelogs/unreleased/ab-projects-api-indexes-authenticated.yml @@ -0,0 +1,5 @@ +--- +title: Add index for authenticated requests to projects API default endpoint +merge_request: 19993 +author: +type: performance diff --git a/changelogs/unreleased/ab-projects-api-indexes.yml b/changelogs/unreleased/ab-projects-api-indexes.yml new file mode 100644 index 0000000000000000000000000000000000000000..90b67c08fef330e4fb08139973bb7c809c39645e --- /dev/null +++ b/changelogs/unreleased/ab-projects-api-indexes.yml @@ -0,0 +1,5 @@ +--- +title: Add index for unauthenticated requests to projects API default endpoint +merge_request: 19989 +author: +type: performance diff --git a/changelogs/unreleased/ab-projects-id-filter.yml b/changelogs/unreleased/ab-projects-id-filter.yml new file mode 100644 index 0000000000000000000000000000000000000000..6bc21ac445202dea3aa0f9fc78148238666e94b1 --- /dev/null +++ b/changelogs/unreleased/ab-projects-id-filter.yml @@ -0,0 +1,5 @@ +--- +title: Add id_before, id_after filter param to projects API +merge_request: 19949 +author: +type: added diff --git a/changelogs/unreleased/add-dead-jobs-to-api-sidekiq-metrics.yml b/changelogs/unreleased/add-dead-jobs-to-api-sidekiq-metrics.yml new file mode 100644 index 0000000000000000000000000000000000000000..ac06e83bc739c87f045a174ad4925a29366f4b9a --- /dev/null +++ b/changelogs/unreleased/add-dead-jobs-to-api-sidekiq-metrics.yml @@ -0,0 +1,5 @@ +--- +title: Add dead jobs to Sidekiq metrics API +merge_request: 19350 +author: Marco Peterseil +type: added diff --git a/changelogs/unreleased/add-default-plan.yml b/changelogs/unreleased/add-default-plan.yml new file mode 100644 index 0000000000000000000000000000000000000000..0bcab9b2919a800a346f296f29f006320c7fe49d --- /dev/null +++ b/changelogs/unreleased/add-default-plan.yml @@ -0,0 +1,5 @@ +--- +title: Create explicit Default and Free plans +merge_request: 19033 +author: +type: other diff --git a/changelogs/unreleased/add-inheritable-mixin.yml b/changelogs/unreleased/add-inheritable-mixin.yml new file mode 100644 index 0000000000000000000000000000000000000000..e0fd2326cc59da68744c80cbb1189907e7d5de5a --- /dev/null +++ b/changelogs/unreleased/add-inheritable-mixin.yml @@ -0,0 +1,5 @@ +--- +title: Make `Job`, `Bridge` and `Default` inheritable +merge_request: 18867 +author: +type: added diff --git a/changelogs/unreleased/add-missing-bottom-padding-in-settings.yml b/changelogs/unreleased/add-missing-bottom-padding-in-settings.yml new file mode 100644 index 0000000000000000000000000000000000000000..b87f83ce33a545865da3d758457adebd535a441e --- /dev/null +++ b/changelogs/unreleased/add-missing-bottom-padding-in-settings.yml @@ -0,0 +1,5 @@ +--- +title: Add missing bottom padding in CI/CD settings +merge_request: 19284 +author: George Tsiolis +type: fixed diff --git a/changelogs/unreleased/add-pendo-snippet.yml b/changelogs/unreleased/add-pendo-snippet.yml new file mode 100644 index 0000000000000000000000000000000000000000..c78db1f5d834ea05798cff899231c05a38e5890b --- /dev/null +++ b/changelogs/unreleased/add-pendo-snippet.yml @@ -0,0 +1,5 @@ +--- +title: Adds Application Settings and ui settings in the integration admin area for Pendo +merge_request: 15086 +author: +type: added diff --git a/changelogs/unreleased/add-rails-parser-for-new-cs-report-format.yml b/changelogs/unreleased/add-rails-parser-for-new-cs-report-format.yml new file mode 100644 index 0000000000000000000000000000000000000000..ae76ad3a4e679c7f06530bf452db371388a6ce77 --- /dev/null +++ b/changelogs/unreleased/add-rails-parser-for-new-cs-report-format.yml @@ -0,0 +1,5 @@ +--- +title: Handle new Container Scanning report format +merge_request: 19123 +author: +type: changed diff --git a/changelogs/unreleased/add-slack-slash-command-issue-comment.yml b/changelogs/unreleased/add-slack-slash-command-issue-comment.yml new file mode 100644 index 0000000000000000000000000000000000000000..ccd828303035c11f493bff31bd7ece1b6226a9ab --- /dev/null +++ b/changelogs/unreleased/add-slack-slash-command-issue-comment.yml @@ -0,0 +1,5 @@ +--- +title: Add a Slack slash command to add a comment to an issue +merge_request: 18946 +author: +type: added diff --git a/changelogs/unreleased/add-snowplow-iglu-registry-application-setting.yml b/changelogs/unreleased/add-snowplow-iglu-registry-application-setting.yml new file mode 100644 index 0000000000000000000000000000000000000000..b1e7447eaad226d230355911346d7eb33456b3fc --- /dev/null +++ b/changelogs/unreleased/add-snowplow-iglu-registry-application-setting.yml @@ -0,0 +1,5 @@ +--- +title: Add ApplicationSetting for snowplow_iglu_registry_url +merge_request: 18449 +author: +type: added diff --git a/changelogs/unreleased/ak-add-elastic-cluster-app.yml b/changelogs/unreleased/ak-add-elastic-cluster-app.yml new file mode 100644 index 0000000000000000000000000000000000000000..b8fd0880553cfbe6d3704da9de7bd5f2fe772730 --- /dev/null +++ b/changelogs/unreleased/ak-add-elastic-cluster-app.yml @@ -0,0 +1,4 @@ +title: Create table for elastic stack. +merge_request: 18015 +author: +type: added diff --git a/changelogs/unreleased/ak-fix-undefined-value.yml b/changelogs/unreleased/ak-fix-undefined-value.yml new file mode 100644 index 0000000000000000000000000000000000000000..d85f5ddc23f12e8f563aa2960b82a13c6ab3c557 --- /dev/null +++ b/changelogs/unreleased/ak-fix-undefined-value.yml @@ -0,0 +1,5 @@ +--- +title: Fix uninitialized constant SystemDashboardService +merge_request: 19453 +author: +type: fixed diff --git a/changelogs/unreleased/allow-adding-requests-to-performance-bar-manually.yml b/changelogs/unreleased/allow-adding-requests-to-performance-bar-manually.yml new file mode 100644 index 0000000000000000000000000000000000000000..0ec1167d9eb9256827f85209777e8d19ba188642 --- /dev/null +++ b/changelogs/unreleased/allow-adding-requests-to-performance-bar-manually.yml @@ -0,0 +1,5 @@ +--- +title: Allow adding requests to performance bar manually +merge_request: 18464 +author: +type: other diff --git a/changelogs/unreleased/allow-container-scanning-to-run-offline.yml b/changelogs/unreleased/allow-container-scanning-to-run-offline.yml new file mode 100644 index 0000000000000000000000000000000000000000..5cce0565d28f7383c65f4de8c9dd949df9b23a59 --- /dev/null +++ b/changelogs/unreleased/allow-container-scanning-to-run-offline.yml @@ -0,0 +1,5 @@ +--- +title: Allow container scanning to run offline by specifying the Clair DB image to use. +merge_request: 19161 +author: +type: changed diff --git a/changelogs/unreleased/an-mark-jobs-as-latency-sensitive.yml b/changelogs/unreleased/an-mark-jobs-as-latency-sensitive.yml new file mode 100644 index 0000000000000000000000000000000000000000..0ed019b8500455bf95014b4466bf89a48b02a81d --- /dev/null +++ b/changelogs/unreleased/an-mark-jobs-as-latency-sensitive.yml @@ -0,0 +1,5 @@ +--- +title: Attribute Sidekiq workers according to their workloads +merge_request: 18066 +author: +type: other diff --git a/changelogs/unreleased/an-sidekiq-records-failure-durations.yml b/changelogs/unreleased/an-sidekiq-records-failure-durations.yml new file mode 100644 index 0000000000000000000000000000000000000000..bbb0b230c6b60065e4888fed9447847953ac69d3 --- /dev/null +++ b/changelogs/unreleased/an-sidekiq-records-failure-durations.yml @@ -0,0 +1,5 @@ +--- +title: Record latencies for Sidekiq failures +merge_request: 18909 +author: +type: performance diff --git a/changelogs/unreleased/andr3-fix-designmanagement-upload-limit.yml b/changelogs/unreleased/andr3-fix-designmanagement-upload-limit.yml new file mode 100644 index 0000000000000000000000000000000000000000..71f9a9ba319d58f2b2dee96a61ce25e9d0b2e75b --- /dev/null +++ b/changelogs/unreleased/andr3-fix-designmanagement-upload-limit.yml @@ -0,0 +1,5 @@ +--- +title: Apply correctly the limit of 10 designs per upload +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/auto-deploy-image-0-7-0.yml b/changelogs/unreleased/auto-deploy-image-0-7-0.yml new file mode 100644 index 0000000000000000000000000000000000000000..ddb5b3d808de0666697c08a1fa96d6e4491aa72b --- /dev/null +++ b/changelogs/unreleased/auto-deploy-image-0-7-0.yml @@ -0,0 +1,5 @@ +--- +title: Bump Auto DevOps deploy image to v0.7.0 +merge_request: 20250 +author: +type: other diff --git a/changelogs/unreleased/backstage-remove-export-designs-feature-flag.yml b/changelogs/unreleased/backstage-remove-export-designs-feature-flag.yml new file mode 100644 index 0000000000000000000000000000000000000000..8e44173ba6041b3a7480eb9021f38d0bb9bcc8b5 --- /dev/null +++ b/changelogs/unreleased/backstage-remove-export-designs-feature-flag.yml @@ -0,0 +1,5 @@ +--- +title: Removes `export_designs` feature flag +merge_request: 18507 +author: nate geslin +type: other diff --git a/changelogs/unreleased/blackst0ne-make-sidekiq-testing-fake-default.yml b/changelogs/unreleased/blackst0ne-make-sidekiq-testing-fake-default.yml new file mode 100644 index 0000000000000000000000000000000000000000..658ead4c4fb64a5744b4e1c61bc8acaf9e22dbfd --- /dev/null +++ b/changelogs/unreleased/blackst0ne-make-sidekiq-testing-fake-default.yml @@ -0,0 +1,5 @@ +--- +title: Make 'Sidekiq::Testing.fake!' mode as default +merge_request: 31662 +author: "@blackst0ne" +type: other diff --git a/changelogs/unreleased/bug-fix-27164-Image-cannot-be-collapsed-on-merge-request-changes-tab.yml b/changelogs/unreleased/bug-fix-27164-Image-cannot-be-collapsed-on-merge-request-changes-tab.yml new file mode 100644 index 0000000000000000000000000000000000000000..229566311310ee09818fe2c6ea2a60c3dea22087 --- /dev/null +++ b/changelogs/unreleased/bug-fix-27164-Image-cannot-be-collapsed-on-merge-request-changes-tab.yml @@ -0,0 +1,5 @@ +--- +title: 'fixed #27164 Image cannot be collapsed on merge request changes tab' +merge_request: 18917 +author: Jannik Lehmann +type: fixed diff --git a/changelogs/unreleased/bvl-robust-gpg-homedir-cleanup.yml b/changelogs/unreleased/bvl-robust-gpg-homedir-cleanup.yml new file mode 100644 index 0000000000000000000000000000000000000000..766e9f169683477aff953a751bf71b0a8ba04b25 --- /dev/null +++ b/changelogs/unreleased/bvl-robust-gpg-homedir-cleanup.yml @@ -0,0 +1,5 @@ +--- +title: Improve handling of gpg-agent processes +merge_request: 19311 +author: +type: changed diff --git a/changelogs/unreleased/change-default-factor-on-merge-train.yml b/changelogs/unreleased/change-default-factor-on-merge-train.yml new file mode 100644 index 0000000000000000000000000000000000000000..7228366e44c820ac68099291a42dcdd1ab96bbf4 --- /dev/null +++ b/changelogs/unreleased/change-default-factor-on-merge-train.yml @@ -0,0 +1,5 @@ +--- +title: Change the default concurrency factor of merge train to 20 +merge_request: 20201 +author: +type: changed diff --git a/changelogs/unreleased/chore-slugify-duplication-removal.yml b/changelogs/unreleased/chore-slugify-duplication-removal.yml new file mode 100644 index 0000000000000000000000000000000000000000..a076e7f5429ef4fd6cb0c7a458454ab382009b9b --- /dev/null +++ b/changelogs/unreleased/chore-slugify-duplication-removal.yml @@ -0,0 +1,5 @@ +--- +title: Remove duplication from slugifyWithUnderscore function +merge_request: 20016 +author: Arun Kumar Mohan +type: other diff --git a/changelogs/unreleased/cluster_management_projects_updating.yml b/changelogs/unreleased/cluster_management_projects_updating.yml new file mode 100644 index 0000000000000000000000000000000000000000..8f1876fc5a1b58ba7b426e8f454eaf5a65662e97 --- /dev/null +++ b/changelogs/unreleased/cluster_management_projects_updating.yml @@ -0,0 +1,5 @@ +--- +title: Adds ability to set management project for cluster via API +merge_request: 18429 +author: +type: added diff --git a/changelogs/unreleased/consider-location-fingerprint-in-mr-widget.yml b/changelogs/unreleased/consider-location-fingerprint-in-mr-widget.yml new file mode 100644 index 0000000000000000000000000000000000000000..5d5edf25a3c3fdd50cb4080b497e8076e0a03ab9 --- /dev/null +++ b/changelogs/unreleased/consider-location-fingerprint-in-mr-widget.yml @@ -0,0 +1,5 @@ +--- +title: Use fingerprint when comparing security reports in MR widget +merge_request: 19654 +author: +type: fixed diff --git a/changelogs/unreleased/defect-diff-file-size.yml b/changelogs/unreleased/defect-diff-file-size.yml new file mode 100644 index 0000000000000000000000000000000000000000..9bc24cadf6b66e28558ab185995d20e32b1d74a2 --- /dev/null +++ b/changelogs/unreleased/defect-diff-file-size.yml @@ -0,0 +1,5 @@ +--- +title: Re-add missing file sizes in 2-Up diff file viewer +merge_request: 19710 +author: +type: fixed diff --git a/changelogs/unreleased/deployment-commit-tracking.yml b/changelogs/unreleased/deployment-commit-tracking.yml new file mode 100644 index 0000000000000000000000000000000000000000..357bf6af60f051c81a5c48f4e8dfdead59291078 --- /dev/null +++ b/changelogs/unreleased/deployment-commit-tracking.yml @@ -0,0 +1,5 @@ +--- +title: Add deployment_merge_requests table +merge_request: 18755 +author: +type: other diff --git a/changelogs/unreleased/do-not-abort-merge-trains-on-ff.yml b/changelogs/unreleased/do-not-abort-merge-trains-on-ff.yml new file mode 100644 index 0000000000000000000000000000000000000000..cdaf004c5537d3e4a8b7f3ed03168d29103a589a --- /dev/null +++ b/changelogs/unreleased/do-not-abort-merge-trains-on-ff.yml @@ -0,0 +1,5 @@ +--- +title: Abort only MWPS when FF only merge is impossible +merge_request: 18591 +author: +type: fixed diff --git a/changelogs/unreleased/drop-id-of-ci-trace-sectinos.yml b/changelogs/unreleased/drop-id-of-ci-trace-sectinos.yml new file mode 100644 index 0000000000000000000000000000000000000000..3071269df2ca0d99030fbafd55f6e8c59b884821 --- /dev/null +++ b/changelogs/unreleased/drop-id-of-ci-trace-sectinos.yml @@ -0,0 +1,5 @@ +--- +title: Drop `id` column from `ci_build_trace_sections` table +merge_request: 18741 +author: +type: changed diff --git a/changelogs/unreleased/dz-abuse-reports-filter.yml b/changelogs/unreleased/dz-abuse-reports-filter.yml new file mode 100644 index 0000000000000000000000000000000000000000..99211d84b58aa02890aaa81ddaa47082ec87f038 --- /dev/null +++ b/changelogs/unreleased/dz-abuse-reports-filter.yml @@ -0,0 +1,5 @@ +--- +title: Add user filtering to abuse reports page +merge_request: 19365 +author: +type: changed diff --git a/changelogs/unreleased/dz-add-indices-to-abuse-reports.yml b/changelogs/unreleased/dz-add-indices-to-abuse-reports.yml new file mode 100644 index 0000000000000000000000000000000000000000..8ea5dfce12dccc02578c93f5c3270d15124ef5b3 --- /dev/null +++ b/changelogs/unreleased/dz-add-indices-to-abuse-reports.yml @@ -0,0 +1,5 @@ +--- +title: Improve performance of admin/abuse_reports page +merge_request: 19630 +author: +type: performance diff --git a/changelogs/unreleased/dz-fix-clusters-api-doc-2.yml b/changelogs/unreleased/dz-fix-clusters-api-doc-2.yml new file mode 100644 index 0000000000000000000000000000000000000000..ca8842190cf0e4386aead5c9293c95ce9551813c --- /dev/null +++ b/changelogs/unreleased/dz-fix-clusters-api-doc-2.yml @@ -0,0 +1,5 @@ +--- +title: Fix api docs for deleting project cluster +merge_request: 19558 +author: +type: other diff --git a/changelogs/unreleased/dz-improve-admin-features.yml b/changelogs/unreleased/dz-improve-admin-features.yml new file mode 100644 index 0000000000000000000000000000000000000000..a15b593d04e6dc6d5892970594c5f38be71ad17c --- /dev/null +++ b/changelogs/unreleased/dz-improve-admin-features.yml @@ -0,0 +1,5 @@ +--- +title: Improve admin dashboard features +merge_request: 18666 +author: +type: changed diff --git a/changelogs/unreleased/dz-move-project-routes.yml b/changelogs/unreleased/dz-move-project-routes.yml new file mode 100644 index 0000000000000000000000000000000000000000..713f6d90f3207ffdf51fb2dfb68318d970122aee --- /dev/null +++ b/changelogs/unreleased/dz-move-project-routes.yml @@ -0,0 +1,5 @@ +--- +title: Move some project routes under - scope +merge_request: 19954 +author: +type: deprecated diff --git a/changelogs/unreleased/enable-environment-dashboard-on-prod.yml b/changelogs/unreleased/enable-environment-dashboard-on-prod.yml new file mode 100644 index 0000000000000000000000000000000000000000..47da49ca8aa3d78b3a2922ad454cd94299a73860 --- /dev/null +++ b/changelogs/unreleased/enable-environment-dashboard-on-prod.yml @@ -0,0 +1,5 @@ +--- +title: Enable environments dashboard by default +merge_request: 19838 +author: +type: added diff --git a/changelogs/unreleased/enable-group-events.yml b/changelogs/unreleased/enable-group-events.yml new file mode 100644 index 0000000000000000000000000000000000000000..8a7af8ab17000bf6e710af28bca5f3646ff0f540 --- /dev/null +++ b/changelogs/unreleased/enable-group-events.yml @@ -0,0 +1,5 @@ +--- +title: Show epic events on group activity page. +merge_request: 18869 +author: +type: added diff --git a/changelogs/unreleased/environments-dashboard-ux-tweaks.yml b/changelogs/unreleased/environments-dashboard-ux-tweaks.yml new file mode 100644 index 0000000000000000000000000000000000000000..bc45951538bdd95505c19d9c27090e90b32daa86 --- /dev/null +++ b/changelogs/unreleased/environments-dashboard-ux-tweaks.yml @@ -0,0 +1,5 @@ +--- +title: Minor UX improvements to Environments Dashboard page +merge_request: 18280 +author: +type: changed diff --git a/changelogs/unreleased/expose-artifacts-to-merge-request-widget.yml b/changelogs/unreleased/expose-artifacts-to-merge-request-widget.yml new file mode 100644 index 0000000000000000000000000000000000000000..24113325feba5c31b125276f95395971f264e9c4 --- /dev/null +++ b/changelogs/unreleased/expose-artifacts-to-merge-request-widget.yml @@ -0,0 +1,5 @@ +--- +title: Expose arbitrary job artifacts in Merge Request widget +merge_request: 18385 +author: +type: added diff --git a/changelogs/unreleased/fe-cluster-management-project.yml b/changelogs/unreleased/fe-cluster-management-project.yml new file mode 100644 index 0000000000000000000000000000000000000000..43b4ddc87243101fcc45b990c0727bfa6bb16554 --- /dev/null +++ b/changelogs/unreleased/fe-cluster-management-project.yml @@ -0,0 +1,5 @@ +--- +title: Add ability to select a Cluster management project +merge_request: 18928 +author: +type: added diff --git a/changelogs/unreleased/feat-unify-html-email-layouts.yml b/changelogs/unreleased/feat-unify-html-email-layouts.yml new file mode 100644 index 0000000000000000000000000000000000000000..ff3a54e9c1794f3475424a3ea6696a843ccba9f0 --- /dev/null +++ b/changelogs/unreleased/feat-unify-html-email-layouts.yml @@ -0,0 +1,5 @@ +--- +title: Unify html email layout for member html emails +merge_request: 17699 +author: Diego Louzán +type: added diff --git a/changelogs/unreleased/feature-cluster-cleanup-state-machine.yml b/changelogs/unreleased/feature-cluster-cleanup-state-machine.yml new file mode 100644 index 0000000000000000000000000000000000000000..0de86de090d8faf774c27585b4b565fc3e3f4146 --- /dev/null +++ b/changelogs/unreleased/feature-cluster-cleanup-state-machine.yml @@ -0,0 +1,5 @@ +--- +title: Add cleanup status to clusters +merge_request: 18144 +author: +type: added diff --git a/changelogs/unreleased/feature-reduce-cluster-ip-size.yml b/changelogs/unreleased/feature-reduce-cluster-ip-size.yml new file mode 100644 index 0000000000000000000000000000000000000000..62bdd3b8592ea5a3c5f95dfb06359dd5e8a4d480 --- /dev/null +++ b/changelogs/unreleased/feature-reduce-cluster-ip-size.yml @@ -0,0 +1,5 @@ +--- +title: Reduce the allocated IP for Cluster and Services +merge_request: 18341 +author: +type: changed diff --git a/changelogs/unreleased/fix-admin-mode-ui-buttons-missing-on-small-screens.yml b/changelogs/unreleased/fix-admin-mode-ui-buttons-missing-on-small-screens.yml new file mode 100644 index 0000000000000000000000000000000000000000..4014b5e7ab2f8d824b2e0e0218d7d1722532bf80 --- /dev/null +++ b/changelogs/unreleased/fix-admin-mode-ui-buttons-missing-on-small-screens.yml @@ -0,0 +1,5 @@ +--- +title: Fix missing admin mode UI buttons on bigger screen sizes +merge_request: 18585 +author: Diego Louzán +type: fixed diff --git a/changelogs/unreleased/fix-dropzone-no-element-exception.yml b/changelogs/unreleased/fix-dropzone-no-element-exception.yml new file mode 100644 index 0000000000000000000000000000000000000000..80dfb44c297e38c23cc81b56c38d6bf79b35df17 --- /dev/null +++ b/changelogs/unreleased/fix-dropzone-no-element-exception.yml @@ -0,0 +1,5 @@ +--- +title: Prevent Dropzone.js initialisation error by checking target element existence +merge_request: 20256 +author: Fabio Huser +type: fixed diff --git a/changelogs/unreleased/fix-job-log-style-reset.yml b/changelogs/unreleased/fix-job-log-style-reset.yml new file mode 100644 index 0000000000000000000000000000000000000000..eea41af23f6ff7e51d1b67104b6d2dfd562afc3c --- /dev/null +++ b/changelogs/unreleased/fix-job-log-style-reset.yml @@ -0,0 +1,5 @@ +--- +title: Fix style reset in job log when empty ANSI sequence is encoutered +merge_request: 20367 +author: +type: fixed diff --git a/changelogs/unreleased/fix-merge-train-is-not-refreshed-when-aborted.yml b/changelogs/unreleased/fix-merge-train-is-not-refreshed-when-aborted.yml new file mode 100644 index 0000000000000000000000000000000000000000..ab548fe6c97e58d66a1f4c771d1520d32e77cbeb --- /dev/null +++ b/changelogs/unreleased/fix-merge-train-is-not-refreshed-when-aborted.yml @@ -0,0 +1,5 @@ +--- +title: Fix merge train is not refreshed when the system aborts/drops a merge request +merge_request: 19763 +author: +type: fixed diff --git a/changelogs/unreleased/fix-stuck-ci-jobs-worker.yml b/changelogs/unreleased/fix-stuck-ci-jobs-worker.yml new file mode 100644 index 0000000000000000000000000000000000000000..e5e160f574312eda784e031642183631ff193cbc --- /dev/null +++ b/changelogs/unreleased/fix-stuck-ci-jobs-worker.yml @@ -0,0 +1,5 @@ +--- +title: Properly handle exceptions in StuckCiJobsWorker +merge_request: 19465 +author: +type: fixed diff --git a/changelogs/unreleased/fix_group_container_repositories_n_1.yml b/changelogs/unreleased/fix_group_container_repositories_n_1.yml new file mode 100644 index 0000000000000000000000000000000000000000..1ec2148b2749e4c3c092761f492474ed0ed383cf --- /dev/null +++ b/changelogs/unreleased/fix_group_container_repositories_n_1.yml @@ -0,0 +1,5 @@ +--- +title: Fix N+1 for group container repositories view +merge_request: 18979 +author: +type: performance diff --git a/changelogs/unreleased/fj-24837-codesanbox-usage-ping.yml b/changelogs/unreleased/fj-24837-codesanbox-usage-ping.yml new file mode 100644 index 0000000000000000000000000000000000000000..480fdbd51f4965faa62b4e3a145bd0b5b3158905 --- /dev/null +++ b/changelogs/unreleased/fj-24837-codesanbox-usage-ping.yml @@ -0,0 +1,5 @@ +--- +title: Add Codesandbox metrics to usage ping +merge_request: 19075 +author: +type: other diff --git a/changelogs/unreleased/fj-32057-web-ide-preview-markdown-image-bug.yml b/changelogs/unreleased/fj-32057-web-ide-preview-markdown-image-bug.yml new file mode 100644 index 0000000000000000000000000000000000000000..237f6bb6840105ec6e19d5a638db7d91cc4926cb --- /dev/null +++ b/changelogs/unreleased/fj-32057-web-ide-preview-markdown-image-bug.yml @@ -0,0 +1,5 @@ +--- +title: Fix broken images when previewing markdown files in Web IDE +merge_request: 18899 +author: +type: fixed diff --git a/changelogs/unreleased/gdk-672-bump-puma-killer-limits-for-dev.yml b/changelogs/unreleased/gdk-672-bump-puma-killer-limits-for-dev.yml new file mode 100644 index 0000000000000000000000000000000000000000..67b93cf95df7fd6a9c5967d877b4ace2cd63d725 --- /dev/null +++ b/changelogs/unreleased/gdk-672-bump-puma-killer-limits-for-dev.yml @@ -0,0 +1,5 @@ +--- +title: Increase PumaWorkerKiller memory limit in development environment +merge_request: 20039 +author: +type: performance diff --git a/changelogs/unreleased/georgekoltsov-fix-group-export-descendants.yml b/changelogs/unreleased/georgekoltsov-fix-group-export-descendants.yml new file mode 100644 index 0000000000000000000000000000000000000000..b826e4bbac4fe183659d2ddfffcfbe410d8d489f --- /dev/null +++ b/changelogs/unreleased/georgekoltsov-fix-group-export-descendants.yml @@ -0,0 +1,5 @@ +--- +title: Fix sub group export to export direct children +merge_request: 20172 +author: +type: fixed diff --git a/changelogs/unreleased/georgekoltsov-group-structure-export-api-endp.yml b/changelogs/unreleased/georgekoltsov-group-structure-export-api-endp.yml new file mode 100644 index 0000000000000000000000000000000000000000..ed14e958b7fe8b344785fb62bf7232b82b54552a --- /dev/null +++ b/changelogs/unreleased/georgekoltsov-group-structure-export-api-endp.yml @@ -0,0 +1,5 @@ +--- +title: Add API endpoint to trigger Group Structure Export +merge_request: 19779 +author: +type: added diff --git a/changelogs/unreleased/gitaly-version-v1.71.0.yml b/changelogs/unreleased/gitaly-version-v1.71.0.yml new file mode 100644 index 0000000000000000000000000000000000000000..306153ff4d7ee19c7fab8f9f8151c27f9e0fa91f --- /dev/null +++ b/changelogs/unreleased/gitaly-version-v1.71.0.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade to Gitaly v1.71.0 +merge_request: 19611 +author: +type: changed diff --git a/changelogs/unreleased/gitlab_ci_path.yml b/changelogs/unreleased/gitlab_ci_path.yml new file mode 100644 index 0000000000000000000000000000000000000000..900d1cccbabd84e3d0503441b9016a00643b2ffa --- /dev/null +++ b/changelogs/unreleased/gitlab_ci_path.yml @@ -0,0 +1,5 @@ +--- +title: Allow to define a default CI configuration path for new projects +merge_request: 18073 +author: Mathieu Parent +type: added diff --git a/changelogs/unreleased/growth-91-when-users-update-their-profile-do-not-let-them-complete-the-up.yml b/changelogs/unreleased/growth-91-when-users-update-their-profile-do-not-let-them-complete-the-up.yml new file mode 100644 index 0000000000000000000000000000000000000000..739d865b516a52c440f121c6676b27e5d5d54111 --- /dev/null +++ b/changelogs/unreleased/growth-91-when-users-update-their-profile-do-not-let-them-complete-the-up.yml @@ -0,0 +1,5 @@ +--- +title: Make role required when editing profile +merge_request: 19636 +author: +type: changed diff --git a/changelogs/unreleased/harishsr-emoticon-commit-links.yml b/changelogs/unreleased/harishsr-emoticon-commit-links.yml new file mode 100644 index 0000000000000000000000000000000000000000..0ad9dd1e101e48a2241a23885c9d0489166406c0 --- /dev/null +++ b/changelogs/unreleased/harishsr-emoticon-commit-links.yml @@ -0,0 +1,5 @@ +--- +title: Allow emojis to be linkable +merge_request: 18014 +author: +type: fixed diff --git a/changelogs/unreleased/helm-v2-16-1.yml b/changelogs/unreleased/helm-v2-16-1.yml new file mode 100644 index 0000000000000000000000000000000000000000..15abf254915902ba4b5cd3499eabbadf55bdc87f --- /dev/null +++ b/changelogs/unreleased/helm-v2-16-1.yml @@ -0,0 +1,5 @@ +--- +title: Helm v2.16.1 +merge_request: 19981 +author: +type: fixed diff --git a/changelogs/unreleased/hide-projects-when-admin-mode-is-disabled.yml b/changelogs/unreleased/hide-projects-when-admin-mode-is-disabled.yml new file mode 100644 index 0000000000000000000000000000000000000000..4957b104e2e0d51492ef522b481964989b9aa3a3 --- /dev/null +++ b/changelogs/unreleased/hide-projects-when-admin-mode-is-disabled.yml @@ -0,0 +1,5 @@ +--- +title: Hide projects without access to admin user when admin mode is disabled +merge_request: 18530 +author: Diego Louzán +type: changed diff --git a/changelogs/unreleased/id-avoid-preloading-merge-request-commits.yml b/changelogs/unreleased/id-avoid-preloading-merge-request-commits.yml new file mode 100644 index 0000000000000000000000000000000000000000..e937b8f2e6e884442e47b074756aececb5dca8fe --- /dev/null +++ b/changelogs/unreleased/id-avoid-preloading-merge-request-commits.yml @@ -0,0 +1,5 @@ +--- +title: Execute limited request for diff commits instead of preloading +merge_request: 19485 +author: +type: performance diff --git a/changelogs/unreleased/id-conditional-check-mergeability.yml b/changelogs/unreleased/id-conditional-check-mergeability.yml new file mode 100644 index 0000000000000000000000000000000000000000..1b52c86df59fee441a651ee2a6420ced4eb23733 --- /dev/null +++ b/changelogs/unreleased/id-conditional-check-mergeability.yml @@ -0,0 +1,5 @@ +--- +title: Run check_mergeability only if merge status requires it +merge_request: 19364 +author: +type: performance diff --git a/changelogs/unreleased/id-fix-any-approver-rule-for-projects.yml b/changelogs/unreleased/id-fix-any-approver-rule-for-projects.yml new file mode 100644 index 0000000000000000000000000000000000000000..e435d4357e4a176e9c3349a7740fbd757202d10c --- /dev/null +++ b/changelogs/unreleased/id-fix-any-approver-rule-for-projects.yml @@ -0,0 +1,5 @@ +--- +title: Fix any approver project rule records +merge_request: 18265 +author: +type: changed diff --git a/changelogs/unreleased/id-nil-short-commit-sha.yml b/changelogs/unreleased/id-nil-short-commit-sha.yml new file mode 100644 index 0000000000000000000000000000000000000000..3d925e1061630cadc1b0d4c3b7b8f442e0280670 --- /dev/null +++ b/changelogs/unreleased/id-nil-short-commit-sha.yml @@ -0,0 +1,5 @@ +--- +title: Serialize short sha as nil if head commit is blank +merge_request: 19014 +author: +type: fixed diff --git a/changelogs/unreleased/id-optimize-mergeable-discussions-state.yml b/changelogs/unreleased/id-optimize-mergeable-discussions-state.yml new file mode 100644 index 0000000000000000000000000000000000000000..db7f6712d5c83cd32e7019e7d978df39b3cb82ab --- /dev/null +++ b/changelogs/unreleased/id-optimize-mergeable-discussions-state.yml @@ -0,0 +1,5 @@ +--- +title: Optimize MergeRequest#mergeable_discussions_state? method +merge_request: 19988 +author: +type: performance diff --git a/changelogs/unreleased/increase-certmanager-install-timeout.yml b/changelogs/unreleased/increase-certmanager-install-timeout.yml new file mode 100644 index 0000000000000000000000000000000000000000..0f2987b04768d52ff45060d1f5fba9e3d4d61c76 --- /dev/null +++ b/changelogs/unreleased/increase-certmanager-install-timeout.yml @@ -0,0 +1,6 @@ +--- +title: Increase the timeout for GitLab-managed cert-manager installation to 90 seconds + (was 30 seconds) +merge_request: 19447 +author: +type: fixed diff --git a/changelogs/unreleased/infinite-scroll.yml b/changelogs/unreleased/infinite-scroll.yml new file mode 100644 index 0000000000000000000000000000000000000000..825eaacad4d8b63d959d5708dc54b71c0456b0ac --- /dev/null +++ b/changelogs/unreleased/infinite-scroll.yml @@ -0,0 +1,5 @@ +--- +title: Add Infinite scroll to Add Projects modal in the operations dashboard +merge_request: 17842 +author: +type: fixed diff --git a/changelogs/unreleased/introduce-feature-flag-api-enable-disable.yml b/changelogs/unreleased/introduce-feature-flag-api-enable-disable.yml new file mode 100644 index 0000000000000000000000000000000000000000..1c60b87d7b283c245f93d4fcb408a4932549b3cc --- /dev/null +++ b/changelogs/unreleased/introduce-feature-flag-api-enable-disable.yml @@ -0,0 +1,5 @@ +--- +title: Support Enable/Disable operations in Feature Flag API +merge_request: 18368 +author: +type: added diff --git a/changelogs/unreleased/issue-63160.yml b/changelogs/unreleased/issue-63160.yml new file mode 100644 index 0000000000000000000000000000000000000000..b76ea0805bf7fe253899f842553e8e063262fcdc --- /dev/null +++ b/changelogs/unreleased/issue-63160.yml @@ -0,0 +1,5 @@ +--- +title: Use initial commit SHA instead of branch id to request IDE files and contents +merge_request: 19348 +author: David Palubin +type: fixed diff --git a/changelogs/unreleased/jc-add-internal-socket-dir-to-setup.yml b/changelogs/unreleased/jc-add-internal-socket-dir-to-setup.yml new file mode 100644 index 0000000000000000000000000000000000000000..119e836b1eebdeb9445147b86dcea8d71533dd17 --- /dev/null +++ b/changelogs/unreleased/jc-add-internal-socket-dir-to-setup.yml @@ -0,0 +1,5 @@ +--- +title: Add internal_socket_dir to gitaly config in setup helper +merge_request: 19170 +author: +type: other diff --git a/changelogs/unreleased/jc-dont-render-commit-links.yml b/changelogs/unreleased/jc-dont-render-commit-links.yml new file mode 100644 index 0000000000000000000000000000000000000000..6146e354702d2d4b497dfdc573d65200d8e1029a --- /dev/null +++ b/changelogs/unreleased/jc-dont-render-commit-links.yml @@ -0,0 +1,5 @@ +--- +title: Do not render links in commit message on blame page +merge_request: 19128 +author: +type: performance diff --git a/changelogs/unreleased/jc-dont-try-to-movedirs-unless-legacy.yml b/changelogs/unreleased/jc-dont-try-to-movedirs-unless-legacy.yml new file mode 100644 index 0000000000000000000000000000000000000000..b1fd8a29a4f199c8498ec2cd41f21efeaceb9f02 --- /dev/null +++ b/changelogs/unreleased/jc-dont-try-to-movedirs-unless-legacy.yml @@ -0,0 +1,5 @@ +--- +title: Only move repos for legacy project storage +merge_request: 19410 +author: +type: fixed diff --git a/changelogs/unreleased/jej-group-saml-test-button-shows-response.yml b/changelogs/unreleased/jej-group-saml-test-button-shows-response.yml new file mode 100644 index 0000000000000000000000000000000000000000..03dcfcfb0c8cdae1a0f1c56b795840dc906c2ca9 --- /dev/null +++ b/changelogs/unreleased/jej-group-saml-test-button-shows-response.yml @@ -0,0 +1,5 @@ +--- +title: Users can verify SAML configuration and view SamlResponse XML +merge_request: 18362 +author: +type: added diff --git a/changelogs/unreleased/jej-prevent-ldap-sign-in.yml b/changelogs/unreleased/jej-prevent-ldap-sign-in.yml new file mode 100644 index 0000000000000000000000000000000000000000..cc4625d8b5acd29b7c34aa8a3d38b9c62a6d855b --- /dev/null +++ b/changelogs/unreleased/jej-prevent-ldap-sign-in.yml @@ -0,0 +1,5 @@ +--- +title: Add prevent_ldap_sign_in option so LDAP can be used exclusively for sync +merge_request: 18749 +author: +type: added diff --git a/changelogs/unreleased/jh_flash_messages_styling_22992.yml b/changelogs/unreleased/jh_flash_messages_styling_22992.yml new file mode 100644 index 0000000000000000000000000000000000000000..efbc2e3cd1a3ac72979d54dc0ff0ccf1a748c5cf --- /dev/null +++ b/changelogs/unreleased/jh_flash_messages_styling_22992.yml @@ -0,0 +1,5 @@ +--- +title: Update flash messages color sitewide +merge_request: 18369 +author: +type: changed diff --git a/changelogs/unreleased/jivanvl-add-support-heatmap-charts.yml b/changelogs/unreleased/jivanvl-add-support-heatmap-charts.yml new file mode 100644 index 0000000000000000000000000000000000000000..e9a13868559d5e52cd1b257b3e0f184684fac524 --- /dev/null +++ b/changelogs/unreleased/jivanvl-add-support-heatmap-charts.yml @@ -0,0 +1,5 @@ +--- +title: Add heatmap chart support +merge_request: 32424 +author: +type: added diff --git a/changelogs/unreleased/jj-ramirez-master-patch-71805.yml b/changelogs/unreleased/jj-ramirez-master-patch-71805.yml new file mode 100644 index 0000000000000000000000000000000000000000..b3ca8261601abcf509ef638bb46c208cdd6b5bc4 --- /dev/null +++ b/changelogs/unreleased/jj-ramirez-master-patch-71805.yml @@ -0,0 +1,5 @@ +--- +title: Update Runners Settings Text + Link to Docs +merge_request: 18534 +author: +type: changed diff --git a/changelogs/unreleased/jlenny-master-patch-21737.yml b/changelogs/unreleased/jlenny-master-patch-21737.yml new file mode 100644 index 0000000000000000000000000000000000000000..f1c5b3a8645f3efa79bc3596c8491f02bc2ed60a --- /dev/null +++ b/changelogs/unreleased/jlenny-master-patch-21737.yml @@ -0,0 +1,5 @@ +--- +title: Improve clarity of text for merge train position +merge_request: 19031 +author: +type: changed diff --git a/changelogs/unreleased/job-log-support-mac-line-break.yml b/changelogs/unreleased/job-log-support-mac-line-break.yml new file mode 100644 index 0000000000000000000000000000000000000000..12be5be687dbd5be6c95be127e15b19ed942833e --- /dev/null +++ b/changelogs/unreleased/job-log-support-mac-line-break.yml @@ -0,0 +1,5 @@ +--- +title: Let ANSI \r code replace the current job log line +merge_request: 18933 +author: +type: fixed diff --git a/changelogs/unreleased/jramsay-admin-mirror-helptext.yml b/changelogs/unreleased/jramsay-admin-mirror-helptext.yml new file mode 100644 index 0000000000000000000000000000000000000000..56e1f6ca45b5eab53d16592b5bb00f788e3e7210 --- /dev/null +++ b/changelogs/unreleased/jramsay-admin-mirror-helptext.yml @@ -0,0 +1,5 @@ +--- +title: Improve instance mirroring help text +merge_request: 19047 +author: +type: other diff --git a/changelogs/unreleased/jramsay-configure-default-branch-deletion.yml b/changelogs/unreleased/jramsay-configure-default-branch-deletion.yml new file mode 100644 index 0000000000000000000000000000000000000000..811a78c9dc015588d42d96bd57a207b34d185688 --- /dev/null +++ b/changelogs/unreleased/jramsay-configure-default-branch-deletion.yml @@ -0,0 +1,5 @@ +--- +title: Add project option for deleting source branch +merge_request: 18408 +author: Zsolt Kovari +type: added diff --git a/changelogs/unreleased/lm-grafana-auth-checkbox.yml b/changelogs/unreleased/lm-grafana-auth-checkbox.yml new file mode 100644 index 0000000000000000000000000000000000000000..39642f656eccd9d21135da80199fe178b9b56779 --- /dev/null +++ b/changelogs/unreleased/lm-grafana-auth-checkbox.yml @@ -0,0 +1,5 @@ +--- +title: Add grafana integration active status checkbox +merge_request: 19255 +author: +type: added diff --git a/changelogs/unreleased/lm-search-list-of-sentry-errors.yml b/changelogs/unreleased/lm-search-list-of-sentry-errors.yml new file mode 100644 index 0000000000000000000000000000000000000000..619c2b0c276506d274d22daaa58bb8a350aae5d4 --- /dev/null +++ b/changelogs/unreleased/lm-search-list-of-sentry-errors.yml @@ -0,0 +1,5 @@ +--- +title: Search list of Sentry errors by title in Gitlab +merge_request: 18772 +author: +type: added diff --git a/changelogs/unreleased/maintenance-move-branch-selector-to-top.yml b/changelogs/unreleased/maintenance-move-branch-selector-to-top.yml new file mode 100644 index 0000000000000000000000000000000000000000..78455c491168884dc5c2a39b375f1c081d3d618b --- /dev/null +++ b/changelogs/unreleased/maintenance-move-branch-selector-to-top.yml @@ -0,0 +1,6 @@ +--- +title: Reduce new MR page redundancy by moving the source/target branch selector to + the top +merge_request: 17559 +author: +type: changed diff --git a/changelogs/unreleased/make-register-job-service-to-be-resillient.yml b/changelogs/unreleased/make-register-job-service-to-be-resillient.yml new file mode 100644 index 0000000000000000000000000000000000000000..7587447c25244fcd87041a72eb4104ac69bd29d1 --- /dev/null +++ b/changelogs/unreleased/make-register-job-service-to-be-resillient.yml @@ -0,0 +1,5 @@ +--- +title: Make `jobs/request` to be resillient +merge_request: 19150 +author: +type: fixed diff --git a/changelogs/unreleased/make_cluster_management_project_ff_default_enabled.yml b/changelogs/unreleased/make_cluster_management_project_ff_default_enabled.yml new file mode 100644 index 0000000000000000000000000000000000000000..faabcca665e29ecc228c73be8c5de1d1e1763087 --- /dev/null +++ b/changelogs/unreleased/make_cluster_management_project_ff_default_enabled.yml @@ -0,0 +1,5 @@ +--- +title: Specify management project for a Kubernetes cluster +merge_request: 20216 +author: +type: added diff --git a/changelogs/unreleased/mc-bug-omips-not-blocking-on-skipped-pipelines.yml b/changelogs/unreleased/mc-bug-omips-not-blocking-on-skipped-pipelines.yml new file mode 100644 index 0000000000000000000000000000000000000000..560b4de81a02562d46be87ba235715491e4471c4 --- /dev/null +++ b/changelogs/unreleased/mc-bug-omips-not-blocking-on-skipped-pipelines.yml @@ -0,0 +1,5 @@ +--- +title: Block MR with OMIPS on skipped pipelines. +merge_request: 18838 +author: +type: fixed diff --git a/changelogs/unreleased/mc-feature-flatten-ci-scripts.yml b/changelogs/unreleased/mc-feature-flatten-ci-scripts.yml new file mode 100644 index 0000000000000000000000000000000000000000..3dfcc5e871cf11499d3eecc1ccf090734d6df322 --- /dev/null +++ b/changelogs/unreleased/mc-feature-flatten-ci-scripts.yml @@ -0,0 +1,5 @@ +--- +title: Add support for YAML anchors in CI scripts. +merge_request: 18849 +author: +type: changed diff --git a/changelogs/unreleased/mermaid-update.yml b/changelogs/unreleased/mermaid-update.yml new file mode 100644 index 0000000000000000000000000000000000000000..33b0c1a551c2f9596bb4cb3ff7bc4e8791d60a0c --- /dev/null +++ b/changelogs/unreleased/mermaid-update.yml @@ -0,0 +1,5 @@ +--- +title: Update to Mermaid v8.4.2 to support more graph types +merge_request: 19444 +author: +type: changed diff --git a/changelogs/unreleased/most-affected-projects.yml b/changelogs/unreleased/most-affected-projects.yml new file mode 100644 index 0000000000000000000000000000000000000000..1835f62e5338849cd040f51deb4e4e0f297d188d --- /dev/null +++ b/changelogs/unreleased/most-affected-projects.yml @@ -0,0 +1,5 @@ +--- +title: Add endpoint for a group's vulnerable projects +merge_request: 15317 +author: +type: added diff --git a/changelogs/unreleased/nfriend-add-release-links-to-milestone-detail-page.yml b/changelogs/unreleased/nfriend-add-release-links-to-milestone-detail-page.yml new file mode 100644 index 0000000000000000000000000000000000000000..e2d57b3a65fcd64dc0f86077aa61e5c07fb89de9 --- /dev/null +++ b/changelogs/unreleased/nfriend-add-release-links-to-milestone-detail-page.yml @@ -0,0 +1,5 @@ +--- +title: Add links to associated release(s) to the milestone detail page +merge_request: 17278 +author: +type: added diff --git a/changelogs/unreleased/nfriend-add-release-to-milestone-list-page.yml b/changelogs/unreleased/nfriend-add-release-to-milestone-list-page.yml new file mode 100644 index 0000000000000000000000000000000000000000..46bffa3476d54761e0b6626b7ac6f8ca54d0c799 --- /dev/null +++ b/changelogs/unreleased/nfriend-add-release-to-milestone-list-page.yml @@ -0,0 +1,5 @@ +--- +title: Add links to associated releases on the Milestones page +merge_request: 16558 +author: +type: added diff --git a/changelogs/unreleased/nfriend-add-releases-filter-for-issues.yml b/changelogs/unreleased/nfriend-add-releases-filter-for-issues.yml new file mode 100644 index 0000000000000000000000000000000000000000..cc709eacb83c23b75916430861bedfb34c722ab3 --- /dev/null +++ b/changelogs/unreleased/nfriend-add-releases-filter-for-issues.yml @@ -0,0 +1,5 @@ +--- +title: Add "release" filter to issue search page +merge_request: 18761 +author: +type: added diff --git a/changelogs/unreleased/nfriend-add-releases-filter-for-merge-requests.yml b/changelogs/unreleased/nfriend-add-releases-filter-for-merge-requests.yml new file mode 100644 index 0000000000000000000000000000000000000000..87e987a73b41e81ef1505e47058f81b715d5b94a --- /dev/null +++ b/changelogs/unreleased/nfriend-add-releases-filter-for-merge-requests.yml @@ -0,0 +1,5 @@ +--- +title: Add "release" filter to merge request search page +merge_request: 19315 +author: +type: added diff --git a/changelogs/unreleased/nfriend-edit-release-ux-cleanup.yml b/changelogs/unreleased/nfriend-edit-release-ux-cleanup.yml new file mode 100644 index 0000000000000000000000000000000000000000..f593db40382fc8a9dcfc4d008bdf251be375b92c --- /dev/null +++ b/changelogs/unreleased/nfriend-edit-release-ux-cleanup.yml @@ -0,0 +1,5 @@ +--- +title: Update help text of "Tag name" field on Edit Release page +merge_request: 19321 +author: +type: changed diff --git a/changelogs/unreleased/nfriend-fix-edit_url-property.yml b/changelogs/unreleased/nfriend-fix-edit_url-property.yml new file mode 100644 index 0000000000000000000000000000000000000000..fa5d24c9ad6038db737aa0776b445b9cc5e0b138 --- /dev/null +++ b/changelogs/unreleased/nfriend-fix-edit_url-property.yml @@ -0,0 +1,5 @@ +--- +title: Allow release block edit button to be visible +merge_request: 19226 +author: +type: fixed diff --git a/changelogs/unreleased/nfriend-move-release-data-into-footer.yml b/changelogs/unreleased/nfriend-move-release-data-into-footer.yml new file mode 100644 index 0000000000000000000000000000000000000000..64772924d260ca94d958da4e708fd13f3a032312 --- /dev/null +++ b/changelogs/unreleased/nfriend-move-release-data-into-footer.yml @@ -0,0 +1,5 @@ +--- +title: Move release meta-data into footer on Releases page +merge_request: 19451 +author: +type: changed diff --git a/changelogs/unreleased/nicolasdular-confirm-email-before-ci-usage.yml b/changelogs/unreleased/nicolasdular-confirm-email-before-ci-usage.yml new file mode 100644 index 0000000000000000000000000000000000000000..f24ffb6c368e1db8426cc2c179760722593494ed --- /dev/null +++ b/changelogs/unreleased/nicolasdular-confirm-email-before-ci-usage.yml @@ -0,0 +1,5 @@ +--- +title: Only allow confirmed users to run pipelines +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/osw-remove-n-plus-ones-from-branches-api-call.yml b/changelogs/unreleased/osw-remove-n-plus-ones-from-branches-api-call.yml new file mode 100644 index 0000000000000000000000000000000000000000..5e491c6847e81876e60f4202a0f648c6e61cb4f8 --- /dev/null +++ b/changelogs/unreleased/osw-remove-n-plus-ones-from-branches-api-call.yml @@ -0,0 +1,5 @@ +--- +title: Remove N+1 DB calls from branches API +merge_request: 19661 +author: +type: performance diff --git a/changelogs/unreleased/patch-35.yml b/changelogs/unreleased/patch-35.yml new file mode 100644 index 0000000000000000000000000000000000000000..22dce87919a2de1b47cf37e6c3e00c23df93e9e9 --- /dev/null +++ b/changelogs/unreleased/patch-35.yml @@ -0,0 +1,5 @@ +--- +title: Fix search button height on 404 page +merge_request: 19080 +author: +type: fixed diff --git a/changelogs/unreleased/ph-fixAdminGeoSidebarFlyOut.yml b/changelogs/unreleased/ph-fixAdminGeoSidebarFlyOut.yml new file mode 100644 index 0000000000000000000000000000000000000000..daafd2539fd4eba156e2007a42c0a022c56c7de3 --- /dev/null +++ b/changelogs/unreleased/ph-fixAdminGeoSidebarFlyOut.yml @@ -0,0 +1,5 @@ +--- +title: Fixed admin geo collapsed sidebar fly out not showing +merge_request: 19012 +author: +type: fixed diff --git a/changelogs/unreleased/ph-fixProtectedBranchesFlash.yml b/changelogs/unreleased/ph-fixProtectedBranchesFlash.yml new file mode 100644 index 0000000000000000000000000000000000000000..0307570ec24f0aee3de17a6c307cb63e01411c05 --- /dev/null +++ b/changelogs/unreleased/ph-fixProtectedBranchesFlash.yml @@ -0,0 +1,5 @@ +--- +title: Fixed protected branches flash styling +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/pokstad1-gitaly-1-70-0.yml b/changelogs/unreleased/pokstad1-gitaly-1-70-0.yml new file mode 100644 index 0000000000000000000000000000000000000000..3ced037b74a080766aa1a6bb46d75ecb70e1fe66 --- /dev/null +++ b/changelogs/unreleased/pokstad1-gitaly-1-70-0.yml @@ -0,0 +1,5 @@ +--- +title: Bump Gitaly to 1.70.0 and remove cache invalidation feature flag +merge_request: 18766 +author: +type: other diff --git a/changelogs/unreleased/record-sidekiq-queuing-latency.yml b/changelogs/unreleased/record-sidekiq-queuing-latency.yml new file mode 100644 index 0000000000000000000000000000000000000000..72bacd90e33a60d44dedfe7452a4bc8475e750af --- /dev/null +++ b/changelogs/unreleased/record-sidekiq-queuing-latency.yml @@ -0,0 +1,5 @@ +--- +title: Adds a Sidekiq queue duration metric +merge_request: 19005 +author: +type: other diff --git a/changelogs/unreleased/remove-dind-for-ds.yml b/changelogs/unreleased/remove-dind-for-ds.yml new file mode 100644 index 0000000000000000000000000000000000000000..b60c983d91e869e546742dc7fbd79a232fa1ea39 --- /dev/null +++ b/changelogs/unreleased/remove-dind-for-ds.yml @@ -0,0 +1,5 @@ +--- +title: Dependency Scanning template that doesn't rely on Docker-in-Docker +merge_request: +author: +type: other diff --git a/changelogs/unreleased/remove-domain-details.yml b/changelogs/unreleased/remove-domain-details.yml new file mode 100644 index 0000000000000000000000000000000000000000..a9eedd580f66f24a939f3af91be8aa831f99d8e8 --- /dev/null +++ b/changelogs/unreleased/remove-domain-details.yml @@ -0,0 +1,5 @@ +--- +title: Merge Details Page and Edit Page for Page Domains +merge_request: 16687 +author: +type: added diff --git a/changelogs/unreleased/remove-empty-github-service-templates.yml b/changelogs/unreleased/remove-empty-github-service-templates.yml new file mode 100644 index 0000000000000000000000000000000000000000..6f5bb3ddcf1d95e0e92e738b5366fdac37aadb01 --- /dev/null +++ b/changelogs/unreleased/remove-empty-github-service-templates.yml @@ -0,0 +1,5 @@ +--- +title: Remove empty Github service templates from database +merge_request: 18868 +author: +type: fixed diff --git a/changelogs/unreleased/remove_local_qualifier_from_geo_sync_indicators.yml b/changelogs/unreleased/remove_local_qualifier_from_geo_sync_indicators.yml new file mode 100644 index 0000000000000000000000000000000000000000..1ec7ace1740bb30f9fb472fdd7486081ad8a9b65 --- /dev/null +++ b/changelogs/unreleased/remove_local_qualifier_from_geo_sync_indicators.yml @@ -0,0 +1,5 @@ +--- +title: Remove local qualifier from geo sync indicators +merge_request: 20034 +author: Lee Tickett +type: fixed diff --git a/changelogs/unreleased/remove_unused_image_screenshot.yml b/changelogs/unreleased/remove_unused_image_screenshot.yml new file mode 100644 index 0000000000000000000000000000000000000000..c24704bb6fe72965f05011de142bf8f9aa7d1809 --- /dev/null +++ b/changelogs/unreleased/remove_unused_image_screenshot.yml @@ -0,0 +1,5 @@ +--- +title: Remove unused image/screenshot +merge_request: 20030 +author: Lee Tickett +type: fixed diff --git a/changelogs/unreleased/remove_var_from_bootstrap_jquery_spec_js.yml b/changelogs/unreleased/remove_var_from_bootstrap_jquery_spec_js.yml new file mode 100644 index 0000000000000000000000000000000000000000..940404031c99e842fa66f55a35aa7920693523c9 --- /dev/null +++ b/changelogs/unreleased/remove_var_from_bootstrap_jquery_spec_js.yml @@ -0,0 +1,5 @@ +--- +title: Remove var from bootstrap_jquery_spec.js +merge_request: 20089 +author: Lee Tickett +type: other diff --git a/changelogs/unreleased/remove_var_from_issue_js.yml b/changelogs/unreleased/remove_var_from_issue_js.yml new file mode 100644 index 0000000000000000000000000000000000000000..ee5cf59fb567fa86935d04797a27c2cb3c818332 --- /dev/null +++ b/changelogs/unreleased/remove_var_from_issue_js.yml @@ -0,0 +1,5 @@ +--- +title: Remove var from issue.js +merge_request: 20098 +author: Lee Tickett +type: other diff --git a/changelogs/unreleased/remove_var_from_labels_select_js.yml b/changelogs/unreleased/remove_var_from_labels_select_js.yml new file mode 100644 index 0000000000000000000000000000000000000000..7186fe4b2f8f393848c3d0d481993b6e800a5ee9 --- /dev/null +++ b/changelogs/unreleased/remove_var_from_labels_select_js.yml @@ -0,0 +1,5 @@ +--- +title: Remove var from labels_select.js +merge_request: 20153 +author: Lee Tickett +type: other diff --git a/changelogs/unreleased/remove_var_from_line_highlighter_js.yml b/changelogs/unreleased/remove_var_from_line_highlighter_js.yml new file mode 100644 index 0000000000000000000000000000000000000000..1de400f807b358a27042692ca519fbca5a95cc3f --- /dev/null +++ b/changelogs/unreleased/remove_var_from_line_highlighter_js.yml @@ -0,0 +1,5 @@ +--- +title: Remove var from line_highlighter.js +merge_request: 20108 +author: Lee Tickett +type: other diff --git a/changelogs/unreleased/remove_var_from_merge_request_tabs_spec_js.yml b/changelogs/unreleased/remove_var_from_merge_request_tabs_spec_js.yml new file mode 100644 index 0000000000000000000000000000000000000000..826a9da0d042ea2a658002f17cb76769cbdc3dc4 --- /dev/null +++ b/changelogs/unreleased/remove_var_from_merge_request_tabs_spec_js.yml @@ -0,0 +1,5 @@ +--- +title: Remove var from merge_request_tabs_spec.js +merge_request: 20087 +author: Lee Tickett +type: other diff --git a/changelogs/unreleased/remove_var_from_new_branch_Form_js.yml b/changelogs/unreleased/remove_var_from_new_branch_Form_js.yml new file mode 100644 index 0000000000000000000000000000000000000000..2f4bf8e44b54d0ffbb3dcc8658be826bb0d0561d --- /dev/null +++ b/changelogs/unreleased/remove_var_from_new_branch_Form_js.yml @@ -0,0 +1,5 @@ +--- +title: Remove var from new_branch_form.js +merge_request: 20099 +author: Lee Tickett +type: other diff --git a/changelogs/unreleased/remove_var_from_new_commit_form_js.yml b/changelogs/unreleased/remove_var_from_new_commit_form_js.yml new file mode 100644 index 0000000000000000000000000000000000000000..0abac1eb7c9b531619c4d5ad57388580b9dd1589 --- /dev/null +++ b/changelogs/unreleased/remove_var_from_new_commit_form_js.yml @@ -0,0 +1,5 @@ +--- +title: Remove var from new_commit_form.js +merge_request: 20095 +author: Lee Tickett +type: other diff --git a/changelogs/unreleased/remove_var_from_preview_markdown_js.yml b/changelogs/unreleased/remove_var_from_preview_markdown_js.yml new file mode 100644 index 0000000000000000000000000000000000000000..ea85fb8f2750ca95999295f79b350c5d962eeaaa --- /dev/null +++ b/changelogs/unreleased/remove_var_from_preview_markdown_js.yml @@ -0,0 +1,5 @@ +--- +title: Remove var from preview_markdown.js +merge_request: 20115 +author: Lee Tickett +type: other diff --git a/changelogs/unreleased/remove_var_from_project_select_js.yml b/changelogs/unreleased/remove_var_from_project_select_js.yml new file mode 100644 index 0000000000000000000000000000000000000000..736f8adc5dac71fb60e72c57d480a4be104a2b2d --- /dev/null +++ b/changelogs/unreleased/remove_var_from_project_select_js.yml @@ -0,0 +1,5 @@ +--- +title: Remove var from project_select.js +merge_request: 20091 +author: Lee Tickett +type: other diff --git a/changelogs/unreleased/remove_var_from_syntax_highlight_spec_js.yml b/changelogs/unreleased/remove_var_from_syntax_highlight_spec_js.yml new file mode 100644 index 0000000000000000000000000000000000000000..e68c9c9f1e6d13632f05446a118a5067b2d6b539 --- /dev/null +++ b/changelogs/unreleased/remove_var_from_syntax_highlight_spec_js.yml @@ -0,0 +1,5 @@ +--- +title: Remove var from syntax_highlight_spec.js +merge_request: 20086 +author: Lee Tickett +type: other diff --git a/changelogs/unreleased/remove_var_from_tree_js.yml b/changelogs/unreleased/remove_var_from_tree_js.yml new file mode 100644 index 0000000000000000000000000000000000000000..9738a44c4c8686150c3a9ee3a3f6085e019c5b85 --- /dev/null +++ b/changelogs/unreleased/remove_var_from_tree_js.yml @@ -0,0 +1,5 @@ +--- +title: Remove var from tree.js +merge_request: 20103 +author: Lee Tickett +type: other diff --git a/changelogs/unreleased/render-html-tags-in-job-log.yml b/changelogs/unreleased/render-html-tags-in-job-log.yml new file mode 100644 index 0000000000000000000000000000000000000000..cbdcf4e66106c885d4065872461727ac266bdcb6 --- /dev/null +++ b/changelogs/unreleased/render-html-tags-in-job-log.yml @@ -0,0 +1,5 @@ +--- +title: Do not escape HTML tags in Ansi2json as they are escaped in the frontend +merge_request: 19610 +author: +type: fixed diff --git a/changelogs/unreleased/retrigger-license-compliance.yml b/changelogs/unreleased/retrigger-license-compliance.yml new file mode 100644 index 0000000000000000000000000000000000000000..de02b0417ed2869bfdd226a35b239df7cc91d240 --- /dev/null +++ b/changelogs/unreleased/retrigger-license-compliance.yml @@ -0,0 +1,5 @@ +--- +title: Triggers the correct endpoint on licence approval +merge_request: 19078 +author: +type: fixed diff --git a/changelogs/unreleased/rs-change-service-failure-reasons.yml b/changelogs/unreleased/rs-change-service-failure-reasons.yml new file mode 100644 index 0000000000000000000000000000000000000000..28a04d2868055d3787ed45bade0d678908caa5ab --- /dev/null +++ b/changelogs/unreleased/rs-change-service-failure-reasons.yml @@ -0,0 +1,5 @@ +--- +title: 'Add an `error_code` attribute to the API response when a cherry-pick or revert fails.' +merge_request: 19518 +author: +type: added diff --git a/changelogs/unreleased/security-2914-labels-visible-despite-no-access-to-issues-repositories.yml b/changelogs/unreleased/security-2914-labels-visible-despite-no-access-to-issues-repositories.yml new file mode 100644 index 0000000000000000000000000000000000000000..59af202a3bdc8f6ca07dba7dbc3bb0b600c80e83 --- /dev/null +++ b/changelogs/unreleased/security-2914-labels-visible-despite-no-access-to-issues-repositories.yml @@ -0,0 +1,5 @@ +--- +title: Do not display project labels that are not visible for user accessing group labels +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-2920-fix-notes-with-label-cross-reference.yml b/changelogs/unreleased/security-2920-fix-notes-with-label-cross-reference.yml new file mode 100644 index 0000000000000000000000000000000000000000..b29014117290fa9eb33582741f6fa0d8ca7037ec --- /dev/null +++ b/changelogs/unreleased/security-2920-fix-notes-with-label-cross-reference.yml @@ -0,0 +1,5 @@ +--- +title: Show cross-referenced label and milestones in issues' activities only to authorized users +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-64519-nested-graphql-query-can-cause-denial-of-service.yml b/changelogs/unreleased/security-64519-nested-graphql-query-can-cause-denial-of-service.yml new file mode 100644 index 0000000000000000000000000000000000000000..5ce37b0d032754108bf41f3927553cd31b5b0982 --- /dev/null +++ b/changelogs/unreleased/security-64519-nested-graphql-query-can-cause-denial-of-service.yml @@ -0,0 +1,5 @@ +--- +title: Analyze incoming GraphQL queries and check for recursion +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-65756-ex-admin-attacker-can-comment-in-internal.yml b/changelogs/unreleased/security-65756-ex-admin-attacker-can-comment-in-internal.yml new file mode 100644 index 0000000000000000000000000000000000000000..3d9f480ba1101b2151ac28354db565d1fbc6f731 --- /dev/null +++ b/changelogs/unreleased/security-65756-ex-admin-attacker-can-comment-in-internal.yml @@ -0,0 +1,5 @@ +--- +title: Disallow unprivileged users from commenting on private repository commits +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-bvl-validate-force-remove-branch-on-mrs.yml b/changelogs/unreleased/security-bvl-validate-force-remove-branch-on-mrs.yml new file mode 100644 index 0000000000000000000000000000000000000000..50dc9c32c5d0c5a17377c87020a349641c0264c7 --- /dev/null +++ b/changelogs/unreleased/security-bvl-validate-force-remove-branch-on-mrs.yml @@ -0,0 +1,6 @@ +--- +title: Don't allow maintainers of a target project to delete the source branch of + a merge request from a fork +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-developer-transfer-project.yml b/changelogs/unreleased/security-developer-transfer-project.yml new file mode 100644 index 0000000000000000000000000000000000000000..fe533fc099a2a38a5b37aaeecd10f60bcc93f265 --- /dev/null +++ b/changelogs/unreleased/security-developer-transfer-project.yml @@ -0,0 +1,5 @@ +--- +title: Require Maintainer permission on group where project is transferred to +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-hide-private-members-in-project-member-autocomplete.yml b/changelogs/unreleased/security-hide-private-members-in-project-member-autocomplete.yml new file mode 100644 index 0000000000000000000000000000000000000000..5992e93bda2343cdef46238246f0cb2da39c4e79 --- /dev/null +++ b/changelogs/unreleased/security-hide-private-members-in-project-member-autocomplete.yml @@ -0,0 +1,3 @@ +--- +title: "Don't leak private members in project member autocomplete suggestions" +type: security diff --git a/changelogs/unreleased/security-id-fix-disclosure-of-private-repo-names.yml b/changelogs/unreleased/security-id-fix-disclosure-of-private-repo-names.yml new file mode 100644 index 0000000000000000000000000000000000000000..dfd7a2d11f9da408efab6a7ca753f6c36e1d7685 --- /dev/null +++ b/changelogs/unreleased/security-id-fix-disclosure-of-private-repo-names.yml @@ -0,0 +1,5 @@ +--- +title: Return 404 on LFS request if project doesn't exist +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-mask-sentry-token-ce.yml b/changelogs/unreleased/security-mask-sentry-token-ce.yml new file mode 100644 index 0000000000000000000000000000000000000000..e9fe780a48868c9c4a5f4466fc82e370699f9cdc --- /dev/null +++ b/changelogs/unreleased/security-mask-sentry-token-ce.yml @@ -0,0 +1,4 @@ +--- +title: Mask sentry auth token in Error Tracking dashboard +author: +type: security diff --git a/changelogs/unreleased/security-remove-deploy-access-levels-on-project-group-link-deletion.yml b/changelogs/unreleased/security-remove-deploy-access-levels-on-project-group-link-deletion.yml new file mode 100644 index 0000000000000000000000000000000000000000..b570d182a5367ff5d6bd6e1579100ad316826ad4 --- /dev/null +++ b/changelogs/unreleased/security-remove-deploy-access-levels-on-project-group-link-deletion.yml @@ -0,0 +1,5 @@ +--- +title: Remove deploy access level when project/group link is deleted +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-stored-xss-using-find-file.yml b/changelogs/unreleased/security-stored-xss-using-find-file.yml new file mode 100644 index 0000000000000000000000000000000000000000..41cd2f9494ffc16ada0ee8b797d7b020369ec655 --- /dev/null +++ b/changelogs/unreleased/security-stored-xss-using-find-file.yml @@ -0,0 +1,5 @@ +--- +title: Sanitize search text to prevent XSS +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-wiki-rdoc-content.yml b/changelogs/unreleased/security-wiki-rdoc-content.yml new file mode 100644 index 0000000000000000000000000000000000000000..f40f1abcd94184abedffceb15dde970484e3bf6d --- /dev/null +++ b/changelogs/unreleased/security-wiki-rdoc-content.yml @@ -0,0 +1,5 @@ +--- +title: Sanitize all wiki markup formats with GitLab sanitization pipelines +merge_request: +author: +type: security diff --git a/changelogs/unreleased/sh-add-api-exception-to-logs.yml b/changelogs/unreleased/sh-add-api-exception-to-logs.yml new file mode 100644 index 0000000000000000000000000000000000000000..273f342d8a15ee8148e612032d592a9003937f28 --- /dev/null +++ b/changelogs/unreleased/sh-add-api-exception-to-logs.yml @@ -0,0 +1,5 @@ +--- +title: Include exception and backtrace in API logs +merge_request: 19671 +author: +type: other diff --git a/changelogs/unreleased/sh-add-exception-backtrace-production-log.yml b/changelogs/unreleased/sh-add-exception-backtrace-production-log.yml new file mode 100644 index 0000000000000000000000000000000000000000..05e178926112234b32d16f0ebbab4972149734df --- /dev/null +++ b/changelogs/unreleased/sh-add-exception-backtrace-production-log.yml @@ -0,0 +1,5 @@ +--- +title: Add backtrace to production_json.log +merge_request: 20122 +author: +type: changed diff --git a/changelogs/unreleased/sh-bitbucket-importer-handle-superseded.yml b/changelogs/unreleased/sh-bitbucket-importer-handle-superseded.yml new file mode 100644 index 0000000000000000000000000000000000000000..35f169cae432a13f2e45ea088bb583509f1df241 --- /dev/null +++ b/changelogs/unreleased/sh-bitbucket-importer-handle-superseded.yml @@ -0,0 +1,5 @@ +--- +title: Make Bitbucket Cloud superseded pull requests as closed +merge_request: 19193 +author: +type: fixed diff --git a/changelogs/unreleased/sh-ensure-short-ttl-sessions.yml b/changelogs/unreleased/sh-ensure-short-ttl-sessions.yml new file mode 100644 index 0000000000000000000000000000000000000000..86c8fb74e216b7c7253b5c6666d9b0b8a8b09c65 --- /dev/null +++ b/changelogs/unreleased/sh-ensure-short-ttl-sessions.yml @@ -0,0 +1,5 @@ +--- +title: Set shorter TTL for all unauthenticated requests +merge_request: 19064 +author: +type: fixed diff --git a/changelogs/unreleased/sh-fix-bitbucket-importer-pr-state.yml b/changelogs/unreleased/sh-fix-bitbucket-importer-pr-state.yml new file mode 100644 index 0000000000000000000000000000000000000000..f5b90a296c13ba8006236baa0dea13e088142a6f --- /dev/null +++ b/changelogs/unreleased/sh-fix-bitbucket-importer-pr-state.yml @@ -0,0 +1,5 @@ +--- +title: Fix Bitbucket Cloud importer pull request state +merge_request: 19734 +author: +type: fixed diff --git a/changelogs/unreleased/sh-fix-protected-paths.yml b/changelogs/unreleased/sh-fix-protected-paths.yml new file mode 100644 index 0000000000000000000000000000000000000000..1cc0e704506e9a2c22f7977443d6e8a87a640e78 --- /dev/null +++ b/changelogs/unreleased/sh-fix-protected-paths.yml @@ -0,0 +1,5 @@ +--- +title: Only enable protected paths for POST requests +merge_request: 19184 +author: +type: fixed diff --git a/changelogs/unreleased/sh-gitaly-duration-measurement.yml b/changelogs/unreleased/sh-gitaly-duration-measurement.yml new file mode 100644 index 0000000000000000000000000000000000000000..60d00e7d9ab7c14681caa988074976e2acf37e35 --- /dev/null +++ b/changelogs/unreleased/sh-gitaly-duration-measurement.yml @@ -0,0 +1,5 @@ +--- +title: Fix Gitaly call duration measurements +merge_request: 18785 +author: +type: fixed diff --git a/changelogs/unreleased/sh-guard-repository-mirrors-read-only.yml b/changelogs/unreleased/sh-guard-repository-mirrors-read-only.yml new file mode 100644 index 0000000000000000000000000000000000000000..5dc916e1082902a4e00ae9e3a7577ac8a9d3a1c6 --- /dev/null +++ b/changelogs/unreleased/sh-guard-repository-mirrors-read-only.yml @@ -0,0 +1,5 @@ +--- +title: Disable pull mirror if repository is in read-only state +merge_request: 19182 +author: +type: fixed diff --git a/changelogs/unreleased/sh-set-admin-default-visibilities.yml b/changelogs/unreleased/sh-set-admin-default-visibilities.yml new file mode 100644 index 0000000000000000000000000000000000000000..46e0b5c0ca1f15d18e9acd2109328f5e56184d74 --- /dev/null +++ b/changelogs/unreleased/sh-set-admin-default-visibilities.yml @@ -0,0 +1,5 @@ +--- +title: Enforce default, global project and snippet visibilities +merge_request: 19188 +author: +type: fixed diff --git a/changelogs/unreleased/sh-set-httponly-experimentation-subject-id.yml b/changelogs/unreleased/sh-set-httponly-experimentation-subject-id.yml new file mode 100644 index 0000000000000000000000000000000000000000..00cd50d43fd5f10aebd9af93d2948c8346621e02 --- /dev/null +++ b/changelogs/unreleased/sh-set-httponly-experimentation-subject-id.yml @@ -0,0 +1,5 @@ +--- +title: Enable the HttpOnly flag for experimentation_subject_id cookie +merge_request: 19189 +author: +type: security diff --git a/changelogs/unreleased/sh-support-project-template-id-in-api.yml b/changelogs/unreleased/sh-support-project-template-id-in-api.yml new file mode 100644 index 0000000000000000000000000000000000000000..5087c6c711af6e3c501de10f4772383641a1f867 --- /dev/null +++ b/changelogs/unreleased/sh-support-project-template-id-in-api.yml @@ -0,0 +1,5 @@ +--- +title: Support template_project_id parameter in project creation API +merge_request: 20258 +author: +type: added diff --git a/changelogs/unreleased/sh-time-limit-merge-rebase-lock.yml b/changelogs/unreleased/sh-time-limit-merge-rebase-lock.yml new file mode 100644 index 0000000000000000000000000000000000000000..c96db4d2ea9fb9af137f03cbb9b6d33108c04926 --- /dev/null +++ b/changelogs/unreleased/sh-time-limit-merge-rebase-lock.yml @@ -0,0 +1,5 @@ +--- +title: Time limit the database lock when rebasing a merge request +merge_request: 18481 +author: +type: fixed diff --git a/changelogs/unreleased/sh-update-aws-sdk.yml b/changelogs/unreleased/sh-update-aws-sdk.yml new file mode 100644 index 0000000000000000000000000000000000000000..608cda98d86019da485adc5e2e4d4235456b5ee2 --- /dev/null +++ b/changelogs/unreleased/sh-update-aws-sdk.yml @@ -0,0 +1,5 @@ +--- +title: Update AWS SDK to 2.11.374 +merge_request: 18601 +author: +type: other diff --git a/changelogs/unreleased/sh-update-openid-connect.yml b/changelogs/unreleased/sh-update-openid-connect.yml new file mode 100644 index 0000000000000000000000000000000000000000..34341b6a385a77a7e4945bacb4f97a99c648d248 --- /dev/null +++ b/changelogs/unreleased/sh-update-openid-connect.yml @@ -0,0 +1,5 @@ +--- +title: Update omniauth_openid_connect to v0.3.3 +merge_request: 19525 +author: +type: fixed diff --git a/changelogs/unreleased/sh-upgrade-grpc.yml b/changelogs/unreleased/sh-upgrade-grpc.yml new file mode 100644 index 0000000000000000000000000000000000000000..d0c3034eb9343d7eada1b114f3ad3a3e5c81b4ea --- /dev/null +++ b/changelogs/unreleased/sh-upgrade-grpc.yml @@ -0,0 +1,5 @@ +--- +title: Update gRPC to v1.24.0 +merge_request: 18837 +author: +type: other diff --git a/changelogs/unreleased/sh-use-rails-redis-store.yml b/changelogs/unreleased/sh-use-rails-redis-store.yml new file mode 100644 index 0000000000000000000000000000000000000000..cf20c23b415833543927b5967c702722ffac6f99 --- /dev/null +++ b/changelogs/unreleased/sh-use-rails-redis-store.yml @@ -0,0 +1,5 @@ +--- +title: Use Rails 5.2 Redis caching store +merge_request: 19202 +author: +type: other diff --git a/changelogs/unreleased/sh-use-template-project-id.yml b/changelogs/unreleased/sh-use-template-project-id.yml new file mode 100644 index 0000000000000000000000000000000000000000..7784007f5368f607f31589e03b06a12d6115efb0 --- /dev/null +++ b/changelogs/unreleased/sh-use-template-project-id.yml @@ -0,0 +1,5 @@ +--- +title: Fix incorrect selection of custom templates +merge_request: 17205 +author: +type: fixed diff --git a/changelogs/unreleased/show-prometheus-is-updating.yml b/changelogs/unreleased/show-prometheus-is-updating.yml new file mode 100644 index 0000000000000000000000000000000000000000..a62c0eb26ac2afde1bcd03f26a56ac4d91d6f29c --- /dev/null +++ b/changelogs/unreleased/show-prometheus-is-updating.yml @@ -0,0 +1,5 @@ +--- +title: Expose prometheus status to monitor dashboard +merge_request: 18289 +author: +type: fixed diff --git a/changelogs/unreleased/sort-vulnerabilities-for-pipeline-dashboard.yml b/changelogs/unreleased/sort-vulnerabilities-for-pipeline-dashboard.yml new file mode 100644 index 0000000000000000000000000000000000000000..ffbfc652b81a921834298b956af02eb8af4dc220 --- /dev/null +++ b/changelogs/unreleased/sort-vulnerabilities-for-pipeline-dashboard.yml @@ -0,0 +1,5 @@ +--- +title: Pipeline vulnerability dashboard sort vulnerabilities by severity then confidence +merge_request: 18863 +author: +type: fixed diff --git a/changelogs/unreleased/stein_ma-gitlab-patch-32.yml b/changelogs/unreleased/stein_ma-gitlab-patch-32.yml new file mode 100644 index 0000000000000000000000000000000000000000..3e747408e15083eb64c57d9d6c9ea3f6c6ba702c --- /dev/null +++ b/changelogs/unreleased/stein_ma-gitlab-patch-32.yml @@ -0,0 +1,5 @@ +--- +title: Fixed a typo in the "Keyboard Shortcuts" pop-up +merge_request: 19217 +author: Manuel Stein +type: fixed diff --git a/changelogs/unreleased/tr-remove-grafana-ff.yml b/changelogs/unreleased/tr-remove-grafana-ff.yml new file mode 100644 index 0000000000000000000000000000000000000000..2b62f57872a9f8f5eedf667132f42c5ca9a2c6fa --- /dev/null +++ b/changelogs/unreleased/tr-remove-grafana-ff.yml @@ -0,0 +1,5 @@ +--- +title: Allow Grafana charts to be embedded in Gitlab Flavored Markdown +merge_request: 18486 +author: +type: added diff --git a/changelogs/unreleased/tracking-experimental-signup-flow.yml b/changelogs/unreleased/tracking-experimental-signup-flow.yml new file mode 100644 index 0000000000000000000000000000000000000000..e0effd396cb2fe2ebf9b6498c5844cab13f38104 --- /dev/null +++ b/changelogs/unreleased/tracking-experimental-signup-flow.yml @@ -0,0 +1,5 @@ +--- +title: Track the starting and stopping of the current signup flow and the experimental signup flow +merge_request: 17521 +author: +type: other diff --git a/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-10-0.yml b/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-10-0.yml new file mode 100644 index 0000000000000000000000000000000000000000..610eafe7be664d09750eeccaf4e30da6d70d2b4b --- /dev/null +++ b/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-10-0.yml @@ -0,0 +1,5 @@ +--- +title: Update GitLab Runner Helm Chart to 0.10.0 +merge_request: 18879 +author: +type: other diff --git a/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-10-1.yml b/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-10-1.yml new file mode 100644 index 0000000000000000000000000000000000000000..99e868f59f012b5842e61741175ff18967e3b637 --- /dev/null +++ b/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-10-1.yml @@ -0,0 +1,5 @@ +--- +title: Update GitLab Runner Helm Chart to 0.10.1 +merge_request: 19232 +author: +type: other diff --git a/changelogs/unreleased/update-pages-1-12.yml b/changelogs/unreleased/update-pages-1-12.yml new file mode 100644 index 0000000000000000000000000000000000000000..36d61acaf15723895ffe61f10142fbb294140e0c --- /dev/null +++ b/changelogs/unreleased/update-pages-1-12.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade pages to 1.12.0 +merge_request: 20217 +author: +type: added diff --git a/changelogs/unreleased/visual-review-api.yml b/changelogs/unreleased/visual-review-api.yml new file mode 100644 index 0000000000000000000000000000000000000000..667ec553fb22924fc308e4e9449ad51ad0dfb84f --- /dev/null +++ b/changelogs/unreleased/visual-review-api.yml @@ -0,0 +1,5 @@ +--- +title: New API endpoint for creating anonymous merge request discussions from Visual Review Tools +merge_request: 18710 +author: +type: added diff --git a/changelogs/unreleased/zj-pg-connection-ff-gitaly.yml b/changelogs/unreleased/zj-pg-connection-ff-gitaly.yml new file mode 100644 index 0000000000000000000000000000000000000000..39b335b76b99228747f0e9b16c1ad14d840a6741 --- /dev/null +++ b/changelogs/unreleased/zj-pg-connection-ff-gitaly.yml @@ -0,0 +1,5 @@ +--- +title: Remove required dependecy of Postgresql for Gitaly +merge_request: 18659 +author: +type: other diff --git a/config/application.rb b/config/application.rb index 5d7c52c5d8118802e5fc947219e3057d6ad0b0be..cad5c8bbe7630520a4b402d876c8133c540ca601 100644 --- a/config/application.rb +++ b/config/application.rb @@ -157,6 +157,8 @@ module Gitlab config.assets.paths << "#{config.root}/vendor/assets/fonts" config.assets.precompile << "print.css" + config.assets.precompile << "mailer.css" + config.assets.precompile << "mailer_client_specific.css" config.assets.precompile << "notify.css" config.assets.precompile << "mailers/*.css" config.assets.precompile << "page_bundles/ide.css" @@ -247,15 +249,18 @@ module Gitlab end # Use caching across all environments + # Full list of options: + # https://api.rubyonrails.org/classes/ActiveSupport/Cache/RedisCacheStore.html#method-c-new caching_config_hash = Gitlab::Redis::Cache.params + 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? # threaded context - caching_config_hash[:pool_size] = Sidekiq.options[:concurrency] + 5 + if Sidekiq.server? || defined?(::Puma) # threaded context + caching_config_hash[:pool_size] = Gitlab::Redis::Cache.pool_size caching_config_hash[:pool_timeout] = 1 end - config.cache_store = :redis_store, caching_config_hash + config.cache_store = :redis_cache_store, caching_config_hash config.active_job.queue_adapter = :sidekiq diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml index 5346bf45473163b0b2a0869a93926e6919bcbdec..84db15d65355b8cce07b3242416ae6de4de91f13 100644 --- a/config/dependency_decisions.yml +++ b/config/dependency_decisions.yml @@ -620,3 +620,9 @@ :why: https://github.com/hexorx/countries/blob/master/LICENSE :versions: [] :when: 2019-09-11 13:08:28.431132000 Z +- - :whitelist + - "(MIT OR CC0-1.0)" + - :who: + :why: + :versions: [] + :when: 2019-11-08 10:03:31.787226000 Z diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index f6814262b7ae8a5554e1ce65c5fad0fe1b14d78b..a5486e450d4bf86b83e2a1e233e7ca42cdc912ef 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -8,7 +8,7 @@ # If a setting requires an application restart say so in that screen. # # If you change this file in a Merge Request, please also create # # a MR on https://gitlab.com/gitlab-org/omnibus-gitlab/merge_requests. # -# For more details see https://gitlab.com/gitlab-org/omnibus-gitlab/blob/0928cfb09f43993fd9454b0b14dbd1924b1407bc/doc/settings/gitlab.yml.md # +# For more details see https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/doc/settings/gitlab.yml.md # ######################################################################## # # @@ -467,6 +467,13 @@ production: &base # enabled: true # primary_api_url: http://localhost:5000/ # internal address to the primary registry, will be used by GitLab to directly communicate with primary registry API + ## Feature Flag https://docs.gitlab.com/ee/user/project/operations/feature_flags.html + feature_flags: + unleash: + # enabled: false + # url: https://gitlab.com/api/v4/feature_flags/unleash/<project_id> + # app_name: gitlab.com # Environment name of your GitLab instance + # instance_id: INSTANCE_ID # # 2. GitLab CI settings @@ -494,6 +501,7 @@ production: &base # bundle exec rake gitlab:ldap:check RAILS_ENV=production ldap: enabled: false + prevent_ldap_sign_in: false # This setting controls the number of seconds between LDAP permission checks # for each user. After this time has expired for a given user, their next @@ -1024,12 +1032,6 @@ production: &base # enabled: true # address: localhost # port: 8083 - # # blackout_seconds: - # # defines an interval to block healthcheck, - # # but continue accepting application requests - # # this allows Load Balancer to notice service - # # being shutdown and not interrupt any of the clients - # blackout_seconds: 10 ## Prometheus settings # Do not modify these settings here. They should be modified in /etc/gitlab/gitlab.rb @@ -1041,6 +1043,14 @@ production: &base # enable: true # listen_address: 'localhost:9090' + shutdown: + # # blackout_seconds: + # # defines an interval to block healthcheck, + # # but continue accepting application requests + # # this allows Load Balancer to notice service + # # being shutdown and not interrupt any of the clients + # blackout_seconds: 10 + # # 5. Extra customization # ========================== diff --git a/config/initializers/0_inflections.rb b/config/initializers/0_inflections.rb index c0afa207ac326ee2d21b1997531aa0cab9682578..7690eafdc6b3c2d2ab840376359683d287eb3901 100644 --- a/config/initializers/0_inflections.rb +++ b/config/initializers/0_inflections.rb @@ -12,18 +12,19 @@ ActiveSupport::Inflector.inflections do |inflect| inflect.uncountable %w( award_emoji - project_statistics - system_note_metadata + container_repository_registry + design_registry event_log - project_auto_devops - project_registry file_registry + group_view job_artifact_registry - container_repository_registry - design_registry - vulnerability_feedback + lfs_object_registry + project_auto_devops + project_registry + project_statistics + system_note_metadata vulnerabilities_feedback - group_view + vulnerability_feedback ) inflect.acronym 'EE' end diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 7ee4a4e3610990b8a7e6138c45988762a7fd153b..df4f49524bc8a31f4bd2b54281a8facfcf53e71c 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -5,6 +5,7 @@ require_relative '../smime_signature_settings' # Default settings Settings['ldap'] ||= Settingslogic.new({}) Settings.ldap['enabled'] = false if Settings.ldap['enabled'].nil? +Settings.ldap['prevent_ldap_sign_in'] = false if Settings.ldap['prevent_ldap_sign_in'].blank? Gitlab.ee do Settings.ldap['sync_time'] = 3600 if Settings.ldap['sync_time'].nil? @@ -307,6 +308,13 @@ Gitlab.ee do Settings.geo.registry_replication['enabled'] ||= false end +# +# Unleash +# +Settings['feature_flags'] ||= Settingslogic.new({}) +Settings.feature_flags['unleash'] ||= Settingslogic.new({}) +Settings.feature_flags.unleash['enabled'] = false if Settings.feature_flags.unleash['enabled'].nil? + # # External merge request diffs # @@ -668,7 +676,12 @@ Settings.monitoring['web_exporter'] ||= Settingslogic.new({}) Settings.monitoring.web_exporter['enabled'] ||= false Settings.monitoring.web_exporter['address'] ||= 'localhost' Settings.monitoring.web_exporter['port'] ||= 8083 -Settings.monitoring.web_exporter['blackout_seconds'] ||= 10 + +# +# Shutdown settings +# +Settings['shutdown'] ||= Settingslogic.new({}) +Settings.shutdown['blackout_seconds'] ||= 10 # # Testing settings diff --git a/config/initializers/7_prometheus_metrics.rb b/config/initializers/7_prometheus_metrics.rb index 974eff1a5285e9cd00b3c80ec844d2af4b832809..d40049970c16ea80cd9949b8fb6ac177937fbcaf 100644 --- a/config/initializers/7_prometheus_metrics.rb +++ b/config/initializers/7_prometheus_metrics.rb @@ -70,8 +70,15 @@ if defined?(::Unicorn) || defined?(::Puma) Gitlab::Metrics::Exporter::WebExporter.instance.start end - Gitlab::Cluster::LifecycleEvents.on_before_phased_restart do - # We need to ensure that before we re-exec server + # DEPRECATED: TO BE REMOVED + # This is needed to implement blackout period of `web_exporter` + # https://gitlab.com/gitlab-org/gitlab/issues/35343#note_238479057 + Gitlab::Cluster::LifecycleEvents.on_before_blackout_period do + Gitlab::Metrics::Exporter::WebExporter.instance.mark_as_not_running! + end + + Gitlab::Cluster::LifecycleEvents.on_before_graceful_shutdown do + # We need to ensure that before we re-exec or shutdown server # we do stop the exporter Gitlab::Metrics::Exporter::WebExporter.instance.stop end diff --git a/config/initializers/database_config.rb b/config/initializers/database_config.rb new file mode 100644 index 0000000000000000000000000000000000000000..d8c2821066bb8edd3f4254e758f24b3899dc3214 --- /dev/null +++ b/config/initializers/database_config.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# when running on puma, scale connection pool size with the number +# of threads per worker process +if defined?(::Puma) + db_config = Gitlab::Database.config || + Rails.application.config.database_configuration[Rails.env] + puma_options = Puma.cli_config.options + + # 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'] = desired_pool_size + + # recreate the connection pool from the new config + ActiveRecord::Base.establish_connection(db_config) +end diff --git a/config/initializers/health_check.rb b/config/initializers/health_check.rb index 9f466dc39de34bd0ff4b912d9ed8230de320cc61..1496f20afc100e4adb2a637d9f7375669d30cdbc 100644 --- a/config/initializers/health_check.rb +++ b/config/initializers/health_check.rb @@ -8,3 +8,15 @@ HealthCheck.setup do |config| end end end + +Gitlab::Cluster::LifecycleEvents.on_before_fork do + Gitlab::HealthChecks::MasterCheck.register_master +end + +Gitlab::Cluster::LifecycleEvents.on_before_blackout_period do + Gitlab::HealthChecks::MasterCheck.finish_master +end + +Gitlab::Cluster::LifecycleEvents.on_worker_start do + Gitlab::HealthChecks::MasterCheck.register_worker +end diff --git a/config/initializers/lograge.rb b/config/initializers/lograge.rb index d5d4c589884491a7c651eaa69e1d547bd9ed7b18..769ef2af0e75f5ddcd32a108f5369583349b7c93 100644 --- a/config/initializers/lograge.rb +++ b/config/initializers/lograge.rb @@ -10,6 +10,11 @@ unless Sidekiq.server? # unmaintained gem that monkey patches `Time` config.lograge.formatter = Lograge::Formatters::Json.new config.lograge.logger = ActiveSupport::Logger.new(filename) + config.lograge.before_format = lambda do |data, payload| + data.delete(:error) + data + end + # Add request parameters to log output config.lograge.custom_options = lambda do |event| params = event.payload[:params] @@ -36,6 +41,20 @@ unless Sidekiq.server? payload[:cpu_s] = cpu_s end + # https://github.com/roidrage/lograge#logging-errors--exceptions + exception = event.payload[:exception_object] + + if exception + payload[:exception] = { + class: exception.class.name, + message: exception.message + } + + if exception.backtrace + payload[:exception][:backtrace] = Gitlab::Profiler.clean_backtrace(exception.backtrace) + end + end + payload end end diff --git a/config/initializers/rack_attack_git_basic_auth.rb b/config/initializers/rack_attack_git_basic_auth.rb index 6a721826170ac1283c56ca73f89a1d635aab8be7..71e5e2969ce83809f7bb63b78d8868fccc2f8645 100644 --- a/config/initializers/rack_attack_git_basic_auth.rb +++ b/config/initializers/rack_attack_git_basic_auth.rb @@ -1,14 +1,14 @@ -rack_attack_enabled = Gitlab.config.rack_attack.git_basic_auth['enabled'] +# Tell the Rack::Attack Rack middleware to maintain an IP blacklist. +# We update the blacklist in Gitlab::Auth::IpRateLimiter. +Rack::Attack.blocklist('Git HTTP Basic Auth') do |req| + rate_limiter = Gitlab::Auth::IpRateLimiter.new(req.ip) -unless Rails.env.test? || !rack_attack_enabled - # Tell the Rack::Attack Rack middleware to maintain an IP blacklist. We will - # update the blacklist from Grack::Auth#authenticate_user. - Rack::Attack.blacklist('Git HTTP Basic Auth') do |req| - Rack::Attack::Allow2Ban.filter(req.ip, Gitlab.config.rack_attack.git_basic_auth) do - # This block only gets run if the IP was not already banned. - # Return false, meaning that we do not see anything wrong with the - # request at this time - false - end + next false if !rate_limiter.enabled? || rate_limiter.trusted_ip? + + Rack::Attack::Allow2Ban.filter(req.ip, Gitlab.config.rack_attack.git_basic_auth) do + # This block only gets run if the IP was not already banned. + # Return false, meaning that we do not see anything wrong with the + # request at this time + false end end diff --git a/config/initializers/rack_attack_logging.rb b/config/initializers/rack_attack_logging.rb index be7c2175cb2dc7cac20c8dc1280a638fd9c03ab6..a95cb09755b1fe5787c1a9059f11a044c98e056a 100644 --- a/config/initializers/rack_attack_logging.rb +++ b/config/initializers/rack_attack_logging.rb @@ -2,8 +2,10 @@ # # Adds logging for all Rack Attack blocks and throttling events. -ActiveSupport::Notifications.subscribe('rack.attack') do |name, start, finish, request_id, req| - if [:throttle, :blacklist].include? req.env['rack.attack.match_type'] +ActiveSupport::Notifications.subscribe(/rack_attack/) do |name, start, finish, request_id, payload| + req = payload[:request] + + if [:throttle, :blocklist].include? req.env['rack.attack.match_type'] rack_attack_info = { message: 'Rack_Attack', env: req.env['rack.attack.match_type'], diff --git a/config/initializers/rack_attack_new.rb b/config/initializers/rack_attack_new.rb index b0f7febe4273f376ad57ace4b85f08b84cc6c2ba..92a8bf79432bf8c371828463e63deca242efc65d 100644 --- a/config/initializers/rack_attack_new.rb +++ b/config/initializers/rack_attack_new.rb @@ -39,45 +39,65 @@ module Gitlab::Throttle end class Rack::Attack + # Order conditions by how expensive they are: + # 1. The most expensive is the `req.unauthenticated?` and + # `req.authenticated_user_id` as it performs an expensive + # DB/Redis query to validate the request + # 2. Slightly less expensive is the need to query DB/Redis + # to unmarshal settings (`Gitlab::Throttle.settings`) + # + # We deliberately skip `/-/health|liveness|readiness` + # from Rack Attack as they need to always be accessible + # by Load Balancer and additional measure is implemented + # (token and whitelisting) to prevent abuse. throttle('throttle_unauthenticated', Gitlab::Throttle.unauthenticated_options) do |req| - Gitlab::Throttle.settings.throttle_unauthenticated_enabled && - req.unauthenticated? && - !req.should_be_skipped? && + if !req.should_be_skipped? && + Gitlab::Throttle.settings.throttle_unauthenticated_enabled && + req.unauthenticated? req.ip + end end throttle('throttle_authenticated_api', Gitlab::Throttle.authenticated_api_options) do |req| - Gitlab::Throttle.settings.throttle_authenticated_api_enabled && - req.api_request? && + if req.api_request? && + Gitlab::Throttle.settings.throttle_authenticated_api_enabled req.authenticated_user_id([:api]) + end end throttle('throttle_authenticated_web', Gitlab::Throttle.authenticated_web_options) do |req| - Gitlab::Throttle.settings.throttle_authenticated_web_enabled && - req.web_request? && + if req.web_request? && + Gitlab::Throttle.settings.throttle_authenticated_web_enabled req.authenticated_user_id([:api, :rss, :ics]) + end end throttle('throttle_unauthenticated_protected_paths', Gitlab::Throttle.protected_paths_options) do |req| - Gitlab::Throttle.protected_paths_enabled? && - req.unauthenticated? && - !req.should_be_skipped? && - req.protected_path? && + if req.post? && + !req.should_be_skipped? && + req.protected_path? && + Gitlab::Throttle.protected_paths_enabled? && + req.unauthenticated? req.ip + end end throttle('throttle_authenticated_protected_paths_api', Gitlab::Throttle.protected_paths_options) do |req| - Gitlab::Throttle.protected_paths_enabled? && - req.api_request? && - req.protected_path? && + if req.post? && + req.api_request? && + req.protected_path? && + Gitlab::Throttle.protected_paths_enabled? req.authenticated_user_id([:api]) + end end throttle('throttle_authenticated_protected_paths_web', Gitlab::Throttle.protected_paths_options) do |req| - Gitlab::Throttle.protected_paths_enabled? && - req.web_request? && - req.protected_path? && + if req.post? && + req.web_request? && + req.protected_path? && + Gitlab::Throttle.protected_paths_enabled? req.authenticated_user_id([:api, :rss, :ics]) + end end class Request @@ -97,12 +117,16 @@ class Rack::Attack path =~ %r{^/api/v\d+/internal/} end + def health_check_request? + path =~ %r{^/-/(health|liveness|readiness)} + end + def should_be_skipped? - api_internal_request? + api_internal_request? || health_check_request? end def web_request? - !api_request? + !api_request? && !health_check_request? end def protected_path? diff --git a/config/initializers/validate_puma.rb b/config/initializers/validate_puma.rb new file mode 100644 index 0000000000000000000000000000000000000000..64bd6e7bbc110f9ed6a41b62979da29463d6ef7c --- /dev/null +++ b/config/initializers/validate_puma.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +if defined?(::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 eff015459e3f98b24bbe22608399dc43069fa10d..950529f0355904069d5e51c5ff2e2c1f7b90232f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -19,6 +19,7 @@ en: project/grafana_integration: token: "Grafana HTTP API Token" grafana_url: "Grafana API URL" + grafana_enabled: "Grafana integration enabled" views: pagination: previous: "Prev" diff --git a/config/puma.example.development.rb b/config/puma.example.development.rb index f23ccc23c9a4b2e39c020e029a25abf29987eae7..6f686437f887686b122e96a39a6a0d5809c73231 100644 --- a/config/puma.example.development.rb +++ b/config/puma.example.development.rb @@ -14,9 +14,13 @@ rackup 'config.ru' pidfile '/home/git/gitlab/tmp/pids/puma.pid' state_path '/home/git/gitlab/tmp/pids/puma.state' -stdout_redirect '/home/git/gitlab/log/puma.stdout.log', - '/home/git/gitlab/log/puma.stderr.log', - true +## Uncomment the lines if you would like to write puma stdout & stderr streams +## to a different location than rails logs. +## When using GitLab Development Kit, by default, these logs will be consumed +## by runit and can be accessed using `gdk tail rails-web` +# stdout_redirect '/home/git/gitlab/log/puma.stdout.log', +# '/home/git/gitlab/log/puma.stderr.log', +# true # Configure "min" to be the minimum number of threads to use to answer # requests and "max" the maximum. diff --git a/config/routes.rb b/config/routes.rb index 5bfae777f1768d3adf9f102205eb049515501915..9fb4d94f0681e58554be5b18df041c35639a7e3b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -57,7 +57,7 @@ Rails.application.routes.draw do # Sign up get 'users/sign_up/welcome' => 'registrations#welcome' - patch 'users/sign_up/update_role' => 'registrations#update_role' + patch 'users/sign_up/update_registration' => 'registrations#update_registration' # Search get 'search' => 'search#show' @@ -142,6 +142,13 @@ Rails.application.routes.draw do collection do post :create_user post :create_gcp + post :create_aws + post :authorize_aws_role + delete :revoke_aws_role + + scope :aws do + get 'api/:resource', to: 'clusters#aws_proxy', as: :aws_proxy + end end member do diff --git a/config/routes/group.rb b/config/routes/group.rb index 093cde64c851d064c6a90968cbaa07c533b22d71..437c80b8c9278cabf91291b393285facd7d39adb 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -62,6 +62,8 @@ constraints(::Constraints::GroupUrlConstrainer.new) do delete :leave, on: :collection end + resources :group_links, only: [:index, :create, :update, :destroy], constraints: { id: /\d+/ } + resources :uploads, only: [:create] do collection do get ":secret/:filename", action: :show, as: :show, constraints: { filename: %r{[^/]+} } diff --git a/config/routes/project.rb b/config/routes/project.rb index 7d51cfd6dee26fabd56a620593d13bbfae77913c..3f913683b0085cef7775f049d7b4a2826a82bce0 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -179,7 +179,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end end - resources :releases, only: [:index] + resources :releases, only: [:index, :edit], param: :tag, constraints: { tag: %r{[^/]+} } resources :starrers, only: [:index] resources :forks, only: [:index, :new, :create] resources :group_links, only: [:index, :create, :update, :destroy], constraints: { id: /\d+/ } @@ -187,9 +187,35 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do resource :import, only: [:new, :create, :show] resource :avatar, only: [:show, :destroy] - get 'grafana/proxy/:datasource_id/*proxy_path', - to: 'grafana_api#proxy', - as: :grafana_api + scope :grafana, as: :grafana_api do + get 'proxy/:datasource_id/*proxy_path', to: 'grafana_api#proxy' + get :metrics_dashboard, to: 'grafana_api#metrics_dashboard' + end + + resource :mattermost, only: [:new, :create] + resource :variables, only: [:show, :update] + resources :triggers, only: [:index, :create, :edit, :update, :destroy] + + resource :mirror, only: [:show, :update] do + member do + get :ssh_host_keys, constraints: { format: :json } + post :update_now + end + end + + resource :cycle_analytics, only: [:show] + + namespace :cycle_analytics do + scope :events, controller: 'events' do + get :issue + get :plan + get :code + get :test + get :review + get :staging + get :production + end + end end # End of the /-/ scope. @@ -222,6 +248,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do resources :domains, except: :index, controller: 'pages_domains', constraints: { id: %r{[^/]+} } do member do post :verify + delete :clean_certificate end end end @@ -233,8 +260,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end end - resource :mattermost, only: [:new, :create] - namespace :prometheus do resources :metrics, constraints: { id: %r{[^\/]+} }, only: [:index, :new, :create, :edit, :update, :destroy] do get :active_common, on: :collection @@ -274,6 +299,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do get :discussions, format: :json post :rebase get :test_reports + get :exposed_artifacts scope constraints: { format: nil }, action: :show do get :commits, defaults: { tab: 'commits' } @@ -361,17 +387,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do put '/service_desk' => 'service_desk#update', as: :service_desk_refresh end - resource :variables, only: [:show, :update] - - resources :triggers, only: [:index, :create, :edit, :update, :destroy] - - resource :mirror, only: [:show, :update] do - member do - get :ssh_host_keys, constraints: { format: :json } - post :update_now - end - end - Gitlab.ee do resources :push_rules, constraints: { id: /\d+/ }, only: [:update] end @@ -430,6 +445,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do Gitlab.ee do get :logs + get '/pods/(:pod_name)/containers/(:container_name)/logs', to: 'environments#k8s_pod_logs', as: :k8s_pod_logs end end @@ -437,6 +453,10 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do get :metrics, action: :metrics_redirect get :folder, path: 'folders/*id', constraints: { format: /(html|json)/ } get :search + + Gitlab.ee do + get :logs, action: :logs_redirect + end end resources :deployments, only: [:index] do @@ -455,20 +475,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end end - resource :cycle_analytics, only: [:show] - - namespace :cycle_analytics do - scope :events, controller: 'events' do - get :issue - get :plan - get :code - get :test - get :review - get :staging - get :production - end - end - namespace :serverless do scope :functions do get '/:environment_id/:id', to: 'functions#show' @@ -609,10 +615,20 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do 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', + as: 'stack_trace' post :list_projects end end + scope :usage_ping, controller: :usage_ping do + post :web_ide_clientside_preview + end + # Since both wiki and repository routing contains wildcard characters # its preferable to keep it below all other project routes draw :wiki @@ -648,7 +664,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do # Legacy routes. # Introduced in 12.0. - # Should be removed after 12.1 + # Should be removed with https://gitlab.com/gitlab-org/gitlab/issues/28848. scope(path: '*namespace_id', as: :namespace, namespace_id: Gitlab::PathRegex.full_namespace_route_regex) do @@ -660,7 +676,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do :network, :graphs, :autocomplete_sources, :project_members, :deploy_keys, :deploy_tokens, :labels, :milestones, :services, :boards, :releases, - :forks, :group_links, :import, :avatar) + :forks, :group_links, :import, :avatar, :mirror, + :cycle_analytics, :mattermost, :variables, :triggers) end end end diff --git a/config/routes/user.rb b/config/routes/user.rb index d4616c8080deebffc1aaf904a01c8e8260aac4e2..31af321d2b2047f48e60250862834385e2c65102 100644 --- a/config/routes/user.rb +++ b/config/routes/user.rb @@ -13,7 +13,7 @@ def override_omniauth(provider, controller, path_prefix = '/users/auth') end # Use custom controller for LDAP omniauth callback -if Gitlab::Auth::LDAP::Config.enabled? +if Gitlab::Auth::LDAP::Config.sign_in_enabled? devise_scope :user do Gitlab::Auth::LDAP::Config.available_servers.each do |server| override_omniauth(server['provider_name'], 'ldap/omniauth_callbacks') @@ -55,6 +55,7 @@ scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) d get :starred, as: :starred_projects get :snippets get :exists + get :suggests get :activity get '/', to: redirect('%{username}'), as: nil end diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index b97e8ad67c91926c847f20a46a0b06b44247b32d..b4be61d8a3d7137c85144b93483508ada7279951 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -98,8 +98,10 @@ - [update_namespace_statistics, 1] - [chaos, 2] - [create_evidence, 2] + - [group_export, 1] # EE-specific queues + - [analytics, 1] - [ldap_group_sync, 2] - [create_github_webhook, 2] - [geo, 1] @@ -120,3 +122,4 @@ - [update_external_pull_requests, 3] - [refresh_license_compliance_checks, 2] - [design_management_new_version, 1] + - [epics, 2] diff --git a/config/webpack.config.js b/config/webpack.config.js index 25fb6cc5f5ab42c8977efd32c11c42aa067442d2..9c7a3f42c97f8ed940639bf95c32787b5c168977 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -73,7 +73,7 @@ function generateEntries() { const manualEntries = { default: defaultEntries, - raven: './raven/index.js', + sentry: './sentry/index.js', }; return Object.assign(manualEntries, autoEntries); @@ -299,6 +299,11 @@ module.exports = { from: path.join(ROOT_PATH, 'node_modules/pdfjs-dist/cmaps/'), to: path.join(ROOT_PATH, 'public/assets/webpack/cmaps/'), }, + { + from: path.join(ROOT_PATH, 'node_modules/@sourcegraph/code-host-integration/'), + to: path.join(ROOT_PATH, 'public/assets/webpack/sourcegraph/'), + ignore: ['package.json'], + }, { from: path.join( ROOT_PATH, diff --git a/danger/commit_messages/Dangerfile b/danger/commit_messages/Dangerfile index 064b8c94805317eec069e636f18cfb57564937a4..60bc90139abcb0576ff01f0f5d4d98f9fec031af 100644 --- a/danger/commit_messages/Dangerfile +++ b/danger/commit_messages/Dangerfile @@ -86,6 +86,12 @@ def unicode_emoji_regex ))x end +def count_filtered_commits(commits) + commits.count do |commit| + !commit.message.start_with?('fixup!', 'squash!') + end +end + 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. @@ -234,7 +240,7 @@ def lint_commit(commit) # rubocop:disable Metrics/AbcSize fail_commit( commit, 'Use full URLs instead of short references ' \ - '(`gitlab-org/gitlab-ce#123` or `!123`), as short references are ' \ + '(`gitlab-org/gitlab#123` or `!123`), as short references are ' \ 'displayed as plain text outside of GitLab' ) @@ -285,7 +291,7 @@ def lint_commits(commits) end end -if git.commits.length > 10 && !ce_upstream? +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.' diff --git a/db/fixtures/development/02_users.rb b/db/fixtures/development/02_users.rb new file mode 100644 index 0000000000000000000000000000000000000000..6e0b37d72583dc86357f07c1d542ea6be0e10dd7 --- /dev/null +++ b/db/fixtures/development/02_users.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class Gitlab::Seeder::Users + include ActionView::Helpers::NumberHelper + + RANDOM_USERS_COUNT = 20 + MASS_USERS_COUNT = ENV['CI'] ? 10 : 1_000_000 + MASS_INSERT_USERNAME_START = 'mass_insert_user_' + + attr_reader :opts + + def initialize(opts = {}) + @opts = opts + end + + def seed! + Sidekiq::Testing.inline! do + create_mass_users! + create_random_users! + end + end + + private + + def create_mass_users! + encrypted_password = Devise::Encryptor.digest(User, '12345678') + + Gitlab::Seeder.with_mass_insert(MASS_USERS_COUNT, User) do + ActiveRecord::Base.connection.execute <<~SQL + INSERT INTO users (username, name, email, confirmed_at, projects_limit, encrypted_password) + SELECT + '#{MASS_INSERT_USERNAME_START}' || seq, + 'Seed user ' || seq, + 'seed_user' || seq || '@example.com', + to_timestamp(seq), + #{MASS_USERS_COUNT}, + '#{encrypted_password}' + FROM generate_series(1, #{MASS_USERS_COUNT}) AS seq + SQL + end + + relation = User.where(admin: false) + Gitlab::Seeder.with_mass_insert(relation.count, Namespace) do + ActiveRecord::Base.connection.execute <<~SQL + INSERT INTO namespaces (name, path, owner_id) + SELECT + username, + username, + id + FROM users WHERE NOT admin + SQL + end + end + + def create_random_users! + RANDOM_USERS_COUNT.times do |i| + begin + User.create!( + username: FFaker::Internet.user_name, + name: FFaker::Name.name, + email: FFaker::Internet.email, + confirmed_at: DateTime.now, + password: '12345678' + ) + + print '.' + rescue ActiveRecord::RecordInvalid + print 'F' + end + end + end +end + +Gitlab::Seeder.quiet do + users = Gitlab::Seeder::Users.new + users.seed! +end diff --git a/db/fixtures/development/03_project.rb b/db/fixtures/development/03_project.rb index 46018cf68aa26cb6e79004588732d7a970335e99..87ef65276ebb4b5df0ae61dfded623a7a3655c42 100644 --- a/db/fixtures/development/03_project.rb +++ b/db/fixtures/development/03_project.rb @@ -1,137 +1,210 @@ require './spec/support/sidekiq' -# rubocop:disable Rails/Output - -Sidekiq::Testing.inline! do - Gitlab::Seeder.quiet do - Gitlab::Seeder.without_gitaly_timeout do - project_urls = %w[ - https://gitlab.com/gitlab-org/gitlab-test.git - https://gitlab.com/gitlab-org/gitlab-shell.git - https://gitlab.com/gnuwget/wget2.git - https://gitlab.com/Commit451/LabCoat.git - https://github.com/jashkenas/underscore.git - https://github.com/flightjs/flight.git - https://github.com/twitter/typeahead.js.git - https://github.com/h5bp/html5-boilerplate.git - https://github.com/google/material-design-lite.git - https://github.com/jlevy/the-art-of-command-line.git - https://github.com/FreeCodeCamp/freecodecamp.git - https://github.com/google/deepdream.git - https://github.com/jtleek/datasharing.git - https://github.com/WebAssembly/design.git - https://github.com/airbnb/javascript.git - https://github.com/tessalt/echo-chamber-js.git - https://github.com/atom/atom.git - https://github.com/mattermost/mattermost-server.git - https://github.com/purifycss/purifycss.git - https://github.com/facebook/nuclide.git - https://github.com/wbkd/awesome-d3.git - https://github.com/kilimchoi/engineering-blogs.git - https://github.com/gilbarbara/logos.git - https://github.com/reduxjs/redux.git - https://github.com/awslabs/s2n.git - https://github.com/arkency/reactjs_koans.git - https://github.com/twbs/bootstrap.git - https://github.com/chjj/ttystudio.git - https://github.com/MostlyAdequate/mostly-adequate-guide.git - https://github.com/octocat/Spoon-Knife.git - https://github.com/opencontainers/runc.git - https://github.com/googlesamples/android-topeka.git - ] - - large_project_urls = %w[ - https://github.com/torvalds/linux.git - https://gitlab.gnome.org/GNOME/gimp.git - https://gitlab.gnome.org/GNOME/gnome-mud.git - https://gitlab.com/fdroid/fdroidclient.git - https://gitlab.com/inkscape/inkscape.git - https://github.com/gnachman/iTerm2.git - ] - - def create_project(url, force_latest_storage: false) - group_path, project_path = url.split('/')[-2..-1] - - group = Group.find_by(path: group_path) - - unless group - group = Group.new( - name: group_path.titleize, - path: group_path - ) - group.description = FFaker::Lorem.sentence - group.save! - - group.add_owner(User.first) - end +class Gitlab::Seeder::Projects + include ActionView::Helpers::NumberHelper + + PROJECT_URLS = %w[ + https://gitlab.com/gitlab-org/gitlab-test.git + https://gitlab.com/gitlab-org/gitlab-shell.git + https://gitlab.com/gnuwget/wget2.git + https://gitlab.com/Commit451/LabCoat.git + https://github.com/jashkenas/underscore.git + https://github.com/flightjs/flight.git + https://github.com/twitter/typeahead.js.git + https://github.com/h5bp/html5-boilerplate.git + https://github.com/google/material-design-lite.git + https://github.com/jlevy/the-art-of-command-line.git + https://github.com/FreeCodeCamp/freecodecamp.git + https://github.com/google/deepdream.git + https://github.com/jtleek/datasharing.git + https://github.com/WebAssembly/design.git + https://github.com/airbnb/javascript.git + https://github.com/tessalt/echo-chamber-js.git + https://github.com/atom/atom.git + https://github.com/mattermost/mattermost-server.git + https://github.com/purifycss/purifycss.git + https://github.com/facebook/nuclide.git + https://github.com/wbkd/awesome-d3.git + https://github.com/kilimchoi/engineering-blogs.git + https://github.com/gilbarbara/logos.git + https://github.com/reduxjs/redux.git + https://github.com/awslabs/s2n.git + https://github.com/arkency/reactjs_koans.git + https://github.com/twbs/bootstrap.git + https://github.com/chjj/ttystudio.git + https://github.com/MostlyAdequate/mostly-adequate-guide.git + https://github.com/octocat/Spoon-Knife.git + https://github.com/opencontainers/runc.git + https://github.com/googlesamples/android-topeka.git + ] + LARGE_PROJECT_URLS = %w[ + https://github.com/torvalds/linux.git + https://gitlab.gnome.org/GNOME/gimp.git + https://gitlab.gnome.org/GNOME/gnome-mud.git + https://gitlab.com/fdroid/fdroidclient.git + https://gitlab.com/inkscape/inkscape.git + https://github.com/gnachman/iTerm2.git + ] + # Consider altering MASS_USERS_COUNT for less + # users with projects. + MASS_PROJECTS_COUNT_PER_USER = { + private: 3, # 3m projects + + internal: 1, # 1m projects + + public: 1 # 1m projects = 5m total + } + MASS_INSERT_NAME_START = 'mass_insert_project_' + + def seed! + Sidekiq::Testing.inline! do + create_real_projects! + create_large_projects! + create_mass_projects! + end + end - project_path.gsub!(".git", "") + private - params = { - import_url: url, - namespace_id: group.id, - name: project_path.titleize, - description: FFaker::Lorem.sentence, - visibility_level: Gitlab::VisibilityLevel.values.sample, - skip_disk_validation: true - } + def create_real_projects! + # You can specify how many projects you need during seed execution + size = ENV['SIZE'].present? ? ENV['SIZE'].to_i : 8 - if force_latest_storage - params[:storage_version] = Project::LATEST_STORAGE_VERSION - end + PROJECT_URLS.first(size).each_with_index do |url, i| + create_real_project!(url, force_latest_storage: i.even?) + end + end - project = nil + def create_large_projects! + return unless ENV['LARGE_PROJECTS'].present? - Sidekiq::Worker.skipping_transaction_check do - project = Projects::CreateService.new(User.first, params).execute + LARGE_PROJECT_URLS.each(&method(:create_real_project!)) - # Seed-Fu runs this entire fixture in a transaction, so the `after_commit` - # hook won't run until after the fixture is loaded. That is too late - # since the Sidekiq::Testing block has already exited. Force clearing - # the `after_commit` queue to ensure the job is run now. - project.send(:_run_after_commit_queue) - project.import_state.send(:_run_after_commit_queue) - end + if ENV['FORK'].present? + puts "\nGenerating forks" - if project.valid? && project.valid_repo? + project_name = ENV['FORK'] == 'true' ? 'torvalds/linux' : ENV['FORK'] + + project = Project.find_by_full_path(project_name) + + User.offset(1).first(5).each do |user| + new_project = ::Projects::ForkService.new(project, user).execute + + if new_project.valid? && (new_project.valid_repo? || new_project.import_state.scheduled?) print '.' else - puts project.errors.full_messages + new_project.errors.full_messages.each do |error| + puts "#{new_project.full_path}: #{error}" + end print 'F' end end + end + end - # You can specify how many projects you need during seed execution - size = ENV['SIZE'].present? ? ENV['SIZE'].to_i : 8 + def create_real_project!(url, force_latest_storage: false) + group_path, project_path = url.split('/')[-2..-1] - project_urls.first(size).each_with_index do |url, i| - create_project(url, force_latest_storage: i.even?) - end + group = Group.find_by(path: group_path) - if ENV['LARGE_PROJECTS'].present? - large_project_urls.each(&method(:create_project)) + unless group + group = Group.new( + name: group_path.titleize, + path: group_path + ) + group.description = FFaker::Lorem.sentence + group.save! - if ENV['FORK'].present? - puts "\nGenerating forks" + group.add_owner(User.first) + end - project_name = ENV['FORK'] == 'true' ? 'torvalds/linux' : ENV['FORK'] + project_path.gsub!(".git", "") - project = Project.find_by_full_path(project_name) + params = { + import_url: url, + namespace_id: group.id, + name: project_path.titleize, + description: FFaker::Lorem.sentence, + visibility_level: Gitlab::VisibilityLevel.values.sample, + skip_disk_validation: true + } - User.offset(1).first(5).each do |user| - new_project = Projects::ForkService.new(project, user).execute + if force_latest_storage + params[:storage_version] = Project::LATEST_STORAGE_VERSION + end - if new_project.valid? && (new_project.valid_repo? || new_project.import_state.scheduled?) - print '.' - else - new_project.errors.full_messages.each do |error| - puts "#{new_project.full_path}: #{error}" - end - print 'F' - end - end - end - end + project = nil + + Sidekiq::Worker.skipping_transaction_check do + project = ::Projects::CreateService.new(User.first, params).execute + + # Seed-Fu runs this entire fixture in a transaction, so the `after_commit` + # hook won't run until after the fixture is loaded. That is too late + # since the Sidekiq::Testing block has already exited. Force clearing + # the `after_commit` queue to ensure the job is run now. + project.send(:_run_after_commit_queue) + project.import_state.send(:_run_after_commit_queue) + end + + if project.valid? && project.valid_repo? + print '.' + else + puts project.errors.full_messages + print 'F' end end + + def create_mass_projects! + projects_per_user_count = MASS_PROJECTS_COUNT_PER_USER.values.sum + visibility_per_user = ['private'] * MASS_PROJECTS_COUNT_PER_USER.fetch(:private) + + ['internal'] * MASS_PROJECTS_COUNT_PER_USER.fetch(:internal) + + ['public'] * MASS_PROJECTS_COUNT_PER_USER.fetch(:public) + visibility_level_per_user = visibility_per_user.map { |visibility| Gitlab::VisibilityLevel.level_value(visibility) } + + visibility_per_user = visibility_per_user.join(',') + visibility_level_per_user = visibility_level_per_user.join(',') + + Gitlab::Seeder.with_mass_insert(User.count * projects_per_user_count, "Projects and relations") do + ActiveRecord::Base.connection.execute <<~SQL + INSERT INTO projects (name, path, creator_id, namespace_id, visibility_level, created_at, updated_at) + SELECT + 'Seed project ' || seq || ' ' || ('{#{visibility_per_user}}'::text[])[seq] AS project_name, + 'mass_insert_project_' || ('{#{visibility_per_user}}'::text[])[seq] || '_' || seq AS project_path, + u.id AS user_id, + n.id AS namespace_id, + ('{#{visibility_level_per_user}}'::int[])[seq] AS visibility_level, + NOW() AS created_at, + NOW() AS updated_at + FROM users u + CROSS JOIN generate_series(1, #{projects_per_user_count}) AS seq + JOIN namespaces n ON n.owner_id=u.id + SQL + + ActiveRecord::Base.connection.execute <<~SQL + INSERT INTO project_features (project_id, merge_requests_access_level, issues_access_level, wiki_access_level, + pages_access_level) + SELECT + id, + #{ProjectFeature::ENABLED} AS merge_requests_access_level, + #{ProjectFeature::ENABLED} AS issues_access_level, + #{ProjectFeature::ENABLED} AS wiki_access_level, + #{ProjectFeature::ENABLED} AS pages_access_level + FROM projects ON CONFLICT (project_id) DO NOTHING; + SQL + + ActiveRecord::Base.connection.execute <<~SQL + INSERT INTO routes (source_id, source_type, name, path) + SELECT + p.id, + 'Project', + u.name || ' / ' || p.name, + u.username || '/' || p.path + FROM projects p JOIN users u ON u.id=p.creator_id + ON CONFLICT (source_type, source_id) DO NOTHING; + SQL + end + end +end + +Gitlab::Seeder.quiet do + projects = Gitlab::Seeder::Projects.new + projects.seed! end diff --git a/db/fixtures/development/04_labels.rb b/db/fixtures/development/04_labels.rb index b9ae4098d763338349c2bc62ea5e3ff41cfe6339..21d552c89f5cb2eccd1cd5a1e1c32acfedd1185f 100644 --- a/db/fixtures/development/04_labels.rb +++ b/db/fixtures/development/04_labels.rb @@ -43,7 +43,7 @@ Gitlab::Seeder.quiet do end puts "\nGenerating project labels" - Project.all.find_each do |project| + Project.not_mass_generated.find_each do |project| Gitlab::Seeder::ProjectLabels.new(project).seed! end end diff --git a/db/fixtures/development/05_users.rb b/db/fixtures/development/05_users.rb deleted file mode 100644 index 101ff3a12099b471ea890888a557e8709260f59e..0000000000000000000000000000000000000000 --- a/db/fixtures/development/05_users.rb +++ /dev/null @@ -1,34 +0,0 @@ -require './spec/support/sidekiq' - -Gitlab::Seeder.quiet do - 20.times do |i| - begin - User.create!( - username: FFaker::Internet.user_name, - name: FFaker::Name.name, - email: FFaker::Internet.email, - confirmed_at: DateTime.now, - password: '12345678' - ) - - print '.' - rescue ActiveRecord::RecordInvalid - print 'F' - end - end - - 5.times do |i| - begin - User.create!( - username: "user#{i}", - name: "User #{i}", - email: "user#{i}@example.com", - confirmed_at: DateTime.now, - password: '12345678' - ) - print '.' - rescue ActiveRecord::RecordInvalid - print 'F' - end - end -end diff --git a/db/fixtures/development/06_teams.rb b/db/fixtures/development/06_teams.rb index b218f4e71fd9048cdcce231fa0e66a1b06b6f612..79ea96bf30e84e75117cf101ab8b01f589700b9b 100644 --- a/db/fixtures/development/06_teams.rb +++ b/db/fixtures/development/06_teams.rb @@ -3,7 +3,7 @@ require './spec/support/sidekiq' Sidekiq::Testing.inline! do Gitlab::Seeder.quiet do Group.all.each do |group| - User.all.sample(4).each do |user| + User.not_mass_generated.sample(4).each do |user| if group.add_user(user, Gitlab::Access.values.sample).persisted? print '.' else @@ -12,8 +12,8 @@ Sidekiq::Testing.inline! do end end - Project.all.each do |project| - User.all.sample(4).each do |user| + Project.not_mass_generated.each do |project| + User.not_mass_generated.sample(4).each do |user| if project.add_role(user, Gitlab::Access.sym_options.keys.sample) print '.' else diff --git a/db/fixtures/development/07_milestones.rb b/db/fixtures/development/07_milestones.rb index 271bfbc97e016fdc9c06b6560e55a8e1de9c6195..1194bb3fe6f223b42a3d3e0d0bb64da86e31cd04 100644 --- a/db/fixtures/development/07_milestones.rb +++ b/db/fixtures/development/07_milestones.rb @@ -1,7 +1,7 @@ require './spec/support/sidekiq' Gitlab::Seeder.quiet do - Project.all.each do |project| + Project.not_mass_generated.each do |project| 5.times do |i| milestone_params = { title: "v#{i}.0", diff --git a/db/fixtures/development/10_merge_requests.rb b/db/fixtures/development/10_merge_requests.rb index 4af545614f76c32feed67d1c66ee6d864fcc7b5d..29f2fabbd5f79e977391fd07cae2a23fa656c600 100644 --- a/db/fixtures/development/10_merge_requests.rb +++ b/db/fixtures/development/10_merge_requests.rb @@ -4,7 +4,13 @@ Gitlab::Seeder.quiet do # Limit the number of merge requests per project to avoid long seeds MAX_NUM_MERGE_REQUESTS = 10 - Project.non_archived.with_merge_requests_enabled.reject(&:empty_repo?).each do |project| + projects = Project + .non_archived + .with_merge_requests_enabled + .not_mass_generated + .reject(&:empty_repo?) + + projects.each do |project| branches = project.repository.branch_names.sample(MAX_NUM_MERGE_REQUESTS * 2) branches.each do |branch_name| diff --git a/db/fixtures/development/11_keys.rb b/db/fixtures/development/11_keys.rb index c405ecfdaf3014703b75dcbc5b0fe98a44479b33..13eadc35e07160d00c570f2d54e6ed0656e74920 100644 --- a/db/fixtures/development/11_keys.rb +++ b/db/fixtures/development/11_keys.rb @@ -9,7 +9,7 @@ Sidekiq::Testing.disable! do # that it falls under `Sidekiq::Testing.disable!`. Key.skip_callback(:commit, :after, :add_to_shell) - User.first(10).each do |user| + User.not_mass_generated.first(10).each do |user| key = "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt#{user.id + 100}6k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=" key = user.keys.create( diff --git a/db/fixtures/development/12_snippets.rb b/db/fixtures/development/12_snippets.rb index a9f4069a0f8a8ee1fa2d6f6f2b4795b65eb75b62..0ee9058a20bbc67f428100ca50411e6525f1e7bd 100644 --- a/db/fixtures/development/12_snippets.rb +++ b/db/fixtures/development/12_snippets.rb @@ -25,7 +25,7 @@ end eos 50.times do |i| - user = User.all.sample + user = User.not_mass_generated.sample PersonalSnippet.seed(:id, [{ id: i, diff --git a/db/fixtures/development/14_pipelines.rb b/db/fixtures/development/14_pipelines.rb index 5c8b681fa92a0395414f86923fe4c533ca756040..468caac23f9fb184316fdb72ddf23622c5636bd2 100644 --- a/db/fixtures/development/14_pipelines.rb +++ b/db/fixtures/development/14_pipelines.rb @@ -214,7 +214,7 @@ class Gitlab::Seeder::Pipelines end Gitlab::Seeder.quiet do - Project.all.sample(5).each do |project| + Project.not_mass_generated.sample(5).each do |project| project_builds = Gitlab::Seeder::Pipelines.new(project) project_builds.seed! end diff --git a/db/fixtures/development/16_protected_branches.rb b/db/fixtures/development/16_protected_branches.rb index 39d466fb43fe4de5580206eec525bc509e16dd8d..2b492ac1f61a764045e9ea0c631c5a3e1125b0ed 100644 --- a/db/fixtures/development/16_protected_branches.rb +++ b/db/fixtures/development/16_protected_branches.rb @@ -3,7 +3,7 @@ require './spec/support/sidekiq' Gitlab::Seeder.quiet do admin_user = User.find(1) - Project.all.each do |project| + Project.not_mass_generated.each do |project| params = { name: 'master' } diff --git a/db/fixtures/development/17_cycle_analytics.rb b/db/fixtures/development/17_cycle_analytics.rb index b7ddeef95b86cae0565d9f9c9d3c195faa9ffe88..606a4cb1dde20418e772223f98095860de4ff856 100644 --- a/db/fixtures/development/17_cycle_analytics.rb +++ b/db/fixtures/development/17_cycle_analytics.rb @@ -217,7 +217,7 @@ Gitlab::Seeder.quiet do flag = 'SEED_CYCLE_ANALYTICS' if ENV[flag] - Project.find_each do |project| + Project.not_mass_generated.find_each do |project| # This seed naively assumes that every project has a repository, and every # repository has a `master` branch, which may be the case for a pristine # GDK seed, but is almost never true for a GDK that's actually had diff --git a/db/fixtures/development/19_environments.rb b/db/fixtures/development/19_environments.rb index 3e227928a298a9896a772f6fa5d4a5eee444826b..083638042161ebeeaf3ded55e1a1cc54755a538a 100644 --- a/db/fixtures/development/19_environments.rb +++ b/db/fixtures/development/19_environments.rb @@ -67,7 +67,7 @@ class Gitlab::Seeder::Environments end Gitlab::Seeder.quiet do - Project.all.sample(5).each do |project| + Project.not_mass_generated.sample(5).each do |project| project_environments = Gitlab::Seeder::Environments.new(project) project_environments.seed! end diff --git a/db/fixtures/development/23_spam_logs.rb b/db/fixtures/development/23_spam_logs.rb index 81cc13e6b2d42d3001e7edc4e1c06ae18de062e9..4a839f5bc235a7968947ca9421732d668cd808e8 100644 --- a/db/fixtures/development/23_spam_logs.rb +++ b/db/fixtures/development/23_spam_logs.rb @@ -22,7 +22,7 @@ module Db end def self.random_user - User.find(User.pluck(:id).sample) + User.find(User.not_mass_generated.pluck(:id).sample) end end end diff --git a/db/fixtures/development/24_forks.rb b/db/fixtures/development/24_forks.rb index 971c6f0d0c83f2a8d24f3f8c2ef1eafda8da15e1..fa16b2a1d934343ab541d14e66b53b8000d1a908 100644 --- a/db/fixtures/development/24_forks.rb +++ b/db/fixtures/development/24_forks.rb @@ -2,8 +2,8 @@ require './spec/support/sidekiq' Sidekiq::Testing.inline! do Gitlab::Seeder.quiet do - User.all.sample(10).each do |user| - source_project = Project.public_only.sample + User.not_mass_generated.sample(10).each do |user| + source_project = Project.not_mass_generated.public_only.sample ## # 03_project.rb might not have created a public project because diff --git a/db/migrate/20180215181245_users_name_lower_index.rb b/db/migrate/20180215181245_users_name_lower_index.rb index 3b80601a7271b6d6586540ddf27e27719cd433dd..fa1a115a78ae9f6564855e7d1252f4ed19185f41 100644 --- a/db/migrate/20180215181245_users_name_lower_index.rb +++ b/db/migrate/20180215181245_users_name_lower_index.rb @@ -20,10 +20,6 @@ class UsersNameLowerIndex < ActiveRecord::Migration[4.2] def down return unless Gitlab::Database.postgresql? - if supports_drop_index_concurrently? - execute "DROP INDEX CONCURRENTLY IF EXISTS #{INDEX_NAME}" - else - execute "DROP INDEX IF EXISTS #{INDEX_NAME}" - end + execute "DROP INDEX CONCURRENTLY IF EXISTS #{INDEX_NAME}" end end diff --git a/db/migrate/20180504195842_project_name_lower_index.rb b/db/migrate/20180504195842_project_name_lower_index.rb index 3fe90c3fbb1e5f16f4b6b4b2b6973ed56741b799..fa74330d5d93e4be070201a125c43ae0264108e9 100644 --- a/db/migrate/20180504195842_project_name_lower_index.rb +++ b/db/migrate/20180504195842_project_name_lower_index.rb @@ -22,11 +22,7 @@ class ProjectNameLowerIndex < ActiveRecord::Migration[4.2] return unless Gitlab::Database.postgresql? disable_statement_timeout do - if supports_drop_index_concurrently? - execute "DROP INDEX CONCURRENTLY IF EXISTS #{INDEX_NAME}" - else - execute "DROP INDEX IF EXISTS #{INDEX_NAME}" - end + execute "DROP INDEX CONCURRENTLY IF EXISTS #{INDEX_NAME}" end end end diff --git a/db/migrate/20180902070406_create_group_group_links.rb b/db/migrate/20180902070406_create_group_group_links.rb new file mode 100644 index 0000000000000000000000000000000000000000..95fed0ebf962e50577c56ecd4efd50a7ebc18568 --- /dev/null +++ b/db/migrate/20180902070406_create_group_group_links.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class CreateGroupGroupLinks < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + create_table :group_group_links do |t| + t.timestamps_with_timezone null: false + + t.references :shared_group, null: false, + index: false, + foreign_key: { on_delete: :cascade, + to_table: :namespaces } + t.references :shared_with_group, null: false, + foreign_key: { on_delete: :cascade, + to_table: :namespaces } + t.date :expires_at + t.index [:shared_group_id, :shared_with_group_id], + { unique: true, + name: 'index_group_group_links_on_shared_group_and_shared_with_group' } + t.integer :group_access, { limit: 2, + default: 30, # Gitlab::Access::DEVELOPER + null: false } + end + end + + def down + drop_table :group_group_links + end +end diff --git a/db/migrate/20190703171157_add_sourcing_epic_dates.rb b/db/migrate/20190703171157_add_sourcing_epic_dates.rb new file mode 100644 index 0000000000000000000000000000000000000000..202e2098d5b54b9a01b8ed1fc6d7b1691d064023 --- /dev/null +++ b/db/migrate/20190703171157_add_sourcing_epic_dates.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddSourcingEpicDates < ActiveRecord::Migration[5.1] + DOWNTIME = false + + def change + add_column :epics, :start_date_sourcing_epic_id, :integer + add_column :epics, :due_date_sourcing_epic_id, :integer + end +end diff --git a/db/migrate/20190703171555_add_sourcing_epic_dates_fks.rb b/db/migrate/20190703171555_add_sourcing_epic_dates_fks.rb new file mode 100644 index 0000000000000000000000000000000000000000..4995a3cd03f332f651fb22c83a831f5463ff48c6 --- /dev/null +++ b/db/migrate/20190703171555_add_sourcing_epic_dates_fks.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class AddSourcingEpicDatesFks < ActiveRecord::Migration[5.1] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :epics, :start_date_sourcing_epic_id, where: 'start_date_sourcing_epic_id is not null' + add_concurrent_index :epics, :due_date_sourcing_epic_id, where: 'due_date_sourcing_epic_id is not null' + + add_concurrent_foreign_key :epics, :epics, column: :start_date_sourcing_epic_id, on_delete: :nullify + add_concurrent_foreign_key :epics, :epics, column: :due_date_sourcing_epic_id, on_delete: :nullify + end + + def down + remove_foreign_key_if_exists :epics, column: :start_date_sourcing_epic_id + remove_foreign_key_if_exists :epics, column: :due_date_sourcing_epic_id + + remove_concurrent_index :epics, :start_date_sourcing_epic_id + remove_concurrent_index :epics, :due_date_sourcing_epic_id + end +end diff --git a/db/migrate/20190805140353_remove_rendundant_index_from_releases.rb b/db/migrate/20190805140353_remove_rendundant_index_from_releases.rb index fc4bc1a423bf566f5291b2f3f58f8ad14cd648d1..477f8a850f8cdf5d0331c225120b96669fde4563 100644 --- a/db/migrate/20190805140353_remove_rendundant_index_from_releases.rb +++ b/db/migrate/20190805140353_remove_rendundant_index_from_releases.rb @@ -12,10 +12,13 @@ class RemoveRendundantIndexFromReleases < ActiveRecord::Migration[5.2] disable_ddl_transaction! def up - remove_concurrent_index :releases, :project_id + remove_concurrent_index_by_name :releases, 'index_releases_on_project_id' + + # This is an extra index that is not present in db/schema.rb but known to exist on some installs + remove_concurrent_index_by_name :releases, 'releases_project_id_idx' if index_exists_by_name?(:releases, 'releases_project_id_idx') end def down - add_concurrent_index :releases, :project_id + add_concurrent_index :releases, :project_id, name: 'index_releases_on_project_id' end end diff --git a/db/migrate/20190827222124_add_sourcegraph_configuration_to_application_settings.rb b/db/migrate/20190827222124_add_sourcegraph_configuration_to_application_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..e624642c2fcda6c04ed79f7988c22cc11ec50139 --- /dev/null +++ b/db/migrate/20190827222124_add_sourcegraph_configuration_to_application_settings.rb @@ -0,0 +1,21 @@ +# 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 AddSourcegraphConfigurationToApplicationSettings < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def up + add_column(:application_settings, :sourcegraph_enabled, :boolean, default: false, null: false) + add_column(:application_settings, :sourcegraph_url, :string, null: true, limit: 255) + end + + def down + remove_column(:application_settings, :sourcegraph_enabled) + remove_column(:application_settings, :sourcegraph_url) + end +end diff --git a/db/migrate/20190910211526_create_packages_conan_file_metadata.rb b/db/migrate/20190910211526_create_packages_conan_file_metadata.rb new file mode 100644 index 0000000000000000000000000000000000000000..0f8dacb72de1acc265f297a2b6346316cb203f9e --- /dev/null +++ b/db/migrate/20190910211526_create_packages_conan_file_metadata.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CreatePackagesConanFileMetadata < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :packages_conan_file_metadata do |t| + t.references :package_file, index: { unique: true }, null: false, foreign_key: { to_table: :packages_package_files, on_delete: :cascade }, type: :bigint + t.timestamps_with_timezone + t.string "recipe_revision", null: false, default: "0", limit: 255 + t.string "package_revision", limit: 255 + t.string "conan_package_reference", limit: 255 + t.integer "conan_file_type", limit: 2, null: false + end + end +end diff --git a/db/migrate/20190918104731_add_cleanup_status_to_cluster.rb b/db/migrate/20190918104731_add_cleanup_status_to_cluster.rb new file mode 100644 index 0000000000000000000000000000000000000000..0ba9d8e6c8956dacea5ea8d14ec84630bede1ba6 --- /dev/null +++ b/db/migrate/20190918104731_add_cleanup_status_to_cluster.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class AddCleanupStatusToCluster < 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(:clusters, :cleanup_status, + :smallint, + default: 1, + allow_null: false) + end + + def down + remove_column(:clusters, :cleanup_status) + end +end diff --git a/db/migrate/20190918121135_add_cleanup_status_reason_to_cluster.rb b/db/migrate/20190918121135_add_cleanup_status_reason_to_cluster.rb new file mode 100644 index 0000000000000000000000000000000000000000..4e71905e3a348d8529b65dea90ec60e0406ffaab --- /dev/null +++ b/db/migrate/20190918121135_add_cleanup_status_reason_to_cluster.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddCleanupStatusReasonToCluster < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + add_column :clusters, :cleanup_status_reason, :text + end +end diff --git a/db/migrate/20190930153535_create_zoom_meetings.rb b/db/migrate/20190930153535_create_zoom_meetings.rb new file mode 100644 index 0000000000000000000000000000000000000000..6b92c53da79eceaec1558a608181cdd2405bc5b6 --- /dev/null +++ b/db/migrate/20190930153535_create_zoom_meetings.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class CreateZoomMeetings < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + ZOOM_MEETING_STATUS_ADDED = 1 + + def change + create_table :zoom_meetings do |t| + t.references :project, foreign_key: { on_delete: :cascade }, + null: false + t.references :issue, foreign_key: { on_delete: :cascade }, + null: false + t.timestamps_with_timezone null: false + t.integer :issue_status, limit: 2, default: 1, null: false + t.string :url, limit: 255 + + t.index [:issue_id, :issue_status], unique: true, + where: "issue_status = #{ZOOM_MEETING_STATUS_ADDED}" + end + end +end diff --git a/db/migrate/20191002123516_create_clusters_applications_elastic_stack.rb b/db/migrate/20191002123516_create_clusters_applications_elastic_stack.rb new file mode 100644 index 0000000000000000000000000000000000000000..8910dc0d9fbfa4a107a05a580fa6e1057880c20d --- /dev/null +++ b/db/migrate/20191002123516_create_clusters_applications_elastic_stack.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 CreateClustersApplicationsElasticStack < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :clusters_applications_elastic_stacks do |t| + t.timestamps_with_timezone null: false + t.references :cluster, null: false, index: false, foreign_key: { on_delete: :cascade } + t.integer :status, null: false + t.string :version, null: false, limit: 255 + t.string :kibana_hostname, limit: 255 + t.text :status_reason + t.index :cluster_id, unique: true + end + end +end diff --git a/db/migrate/20191003015155_add_self_managed_prometheus_alerts.rb b/db/migrate/20191003015155_add_self_managed_prometheus_alerts.rb index 94d16e921dfa1f5148a4e41fbd93845d8b99d8b7..71d101534224dc2a0f92e762d33647c28c14f0bd 100644 --- a/db/migrate/20191003015155_add_self_managed_prometheus_alerts.rb +++ b/db/migrate/20191003015155_add_self_managed_prometheus_alerts.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class AddSelfManagedPrometheusAlerts < ActiveRecord::Migration[5.2] - # Set this constant to true if this migration requires downtime. DOWNTIME = false def change diff --git a/db/migrate/20191003161031_add_mark_for_deletion_to_projects.rb b/db/migrate/20191003161031_add_mark_for_deletion_to_projects.rb new file mode 100644 index 0000000000000000000000000000000000000000..86d581a43838a5649735390520109e6020887121 --- /dev/null +++ b/db/migrate/20191003161031_add_mark_for_deletion_to_projects.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddMarkForDeletionToProjects < ActiveRecord::Migration[5.2] + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + add_column :projects, :marked_for_deletion_at, :date + add_column :projects, :marked_for_deletion_by_user_id, :integer + end +end diff --git a/db/migrate/20191003161032_add_mark_for_deletion_indexes_to_projects.rb b/db/migrate/20191003161032_add_mark_for_deletion_indexes_to_projects.rb new file mode 100644 index 0000000000000000000000000000000000000000..d6ef6509fff450c89f83b6988a09d4a79d348926 --- /dev/null +++ b/db/migrate/20191003161032_add_mark_for_deletion_indexes_to_projects.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddMarkForDeletionIndexesToProjects < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_foreign_key :projects, :users, column: :marked_for_deletion_by_user_id, on_delete: :nullify + add_concurrent_index :projects, :marked_for_deletion_by_user_id, where: 'marked_for_deletion_by_user_id IS NOT NULL' + end + + def down + remove_foreign_key_if_exists :projects, column: :marked_for_deletion_by_user_id + remove_concurrent_index :projects, :marked_for_deletion_by_user_id + end +end diff --git a/db/migrate/20191003195218_add_pendo_enabled_to_application_settings.rb b/db/migrate/20191003195218_add_pendo_enabled_to_application_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..c5f5a8cd70c8410d52ae6d5baa84be7ee56e8bb7 --- /dev/null +++ b/db/migrate/20191003195218_add_pendo_enabled_to_application_settings.rb @@ -0,0 +1,15 @@ +class AddPendoEnabledToApplicationSettings < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default :application_settings, :pendo_enabled, :boolean, default: false, allow_null: false + end + + def down + remove_column :application_settings, :pendo_enabled + end +end diff --git a/db/migrate/20191003195620_add_pendo_url_to_application_settings.rb b/db/migrate/20191003195620_add_pendo_url_to_application_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..cc0895f8bee6b503a82a1b090610cd9794d62c8b --- /dev/null +++ b/db/migrate/20191003195620_add_pendo_url_to_application_settings.rb @@ -0,0 +1,9 @@ +class AddPendoUrlToApplicationSettings < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :application_settings, :pendo_url, :string, limit: 255 + end +end diff --git a/db/migrate/20191004080818_add_productivity_analytics_start_date.rb b/db/migrate/20191004080818_add_productivity_analytics_start_date.rb new file mode 100644 index 0000000000000000000000000000000000000000..287b0755bc190440a6237c6a6b4099c09318b962 --- /dev/null +++ b/db/migrate/20191004080818_add_productivity_analytics_start_date.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddProductivityAnalyticsStartDate < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :application_settings, :productivity_analytics_start_date, :datetime_with_timezone + end +end diff --git a/db/migrate/20191004081520_fill_productivity_analytics_start_date.rb b/db/migrate/20191004081520_fill_productivity_analytics_start_date.rb new file mode 100644 index 0000000000000000000000000000000000000000..9432cd6870883cb8737c8542483cc49a1cf85e7c --- /dev/null +++ b/db/migrate/20191004081520_fill_productivity_analytics_start_date.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# Expected migration duration: 1 minute +class FillProductivityAnalyticsStartDate < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :merge_request_metrics, :merged_at, + where: "merged_at > '2019-09-01' AND commits_count IS NOT NULL", + name: 'fill_productivity_analytics_start_date_tmp_index' + + execute( + <<SQL + UPDATE application_settings + SET productivity_analytics_start_date = COALESCE((SELECT MIN(merged_at) FROM merge_request_metrics + WHERE merged_at > '2019-09-01' AND commits_count IS NOT NULL), NOW()) +SQL + ) + + remove_concurrent_index :merge_request_metrics, :merged_at, + name: 'fill_productivity_analytics_start_date_tmp_index' + end + + def down + execute('UPDATE application_settings SET productivity_analytics_start_date = NULL') + end +end diff --git a/db/migrate/20191009100244_add_geo_design_repository_counters.rb b/db/migrate/20191009100244_add_geo_design_repository_counters.rb new file mode 100644 index 0000000000000000000000000000000000000000..26387453f8868ce7d3db663a0912d0f83bdd5aa0 --- /dev/null +++ b/db/migrate/20191009100244_add_geo_design_repository_counters.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddGeoDesignRepositoryCounters < ActiveRecord::Migration[5.1] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + change_table :geo_node_statuses do |t| + t.column :design_repositories_count, :integer + t.column :design_repositories_synced_count, :integer + t.column :design_repositories_failed_count, :integer + t.column :design_repositories_registry_count, :integer + end + end +end diff --git a/db/migrate/20191009110124_add_has_exposed_artifacts_to_ci_builds_metadata.rb b/db/migrate/20191009110124_add_has_exposed_artifacts_to_ci_builds_metadata.rb new file mode 100644 index 0000000000000000000000000000000000000000..86c3c540e5e37e2fb2fb7a7bd3bdfec928ae2cfd --- /dev/null +++ b/db/migrate/20191009110124_add_has_exposed_artifacts_to_ci_builds_metadata.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddHasExposedArtifactsToCiBuildsMetadata < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def up + add_column :ci_builds_metadata, :has_exposed_artifacts, :boolean + end + + def down + remove_column :ci_builds_metadata, :has_exposed_artifacts + end +end diff --git a/db/migrate/20191009110757_add_index_to_ci_builds_metadata_has_exposed_artifacts.rb b/db/migrate/20191009110757_add_index_to_ci_builds_metadata_has_exposed_artifacts.rb new file mode 100644 index 0000000000000000000000000000000000000000..6b8c452a62a2040ca059d8bddfb923735c00ef2b --- /dev/null +++ b/db/migrate/20191009110757_add_index_to_ci_builds_metadata_has_exposed_artifacts.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddIndexToCiBuildsMetadataHasExposedArtifacts < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :ci_builds_metadata, [:build_id], where: "has_exposed_artifacts IS TRUE", name: 'index_ci_builds_metadata_on_build_id_and_has_exposed_artifacts' + end + + def down + remove_concurrent_index_by_name :ci_builds_metadata, 'index_ci_builds_metadata_on_build_id_and_has_exposed_artifacts' + end +end diff --git a/db/migrate/20191010174846_add_snowplow_iglu_registry_url_to_application_settings.rb b/db/migrate/20191010174846_add_snowplow_iglu_registry_url_to_application_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..a40ce8dbee5e0db5b82a5d7e58dbf602b6552a88 --- /dev/null +++ b/db/migrate/20191010174846_add_snowplow_iglu_registry_url_to_application_settings.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddSnowplowIgluRegistryUrlToApplicationSettings < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + add_column :application_settings, :snowplow_iglu_registry_url, :string, limit: 255 + end +end diff --git a/db/migrate/20191011084019_add_project_deletion_adjourned_period_to_application_settings.rb b/db/migrate/20191011084019_add_project_deletion_adjourned_period_to_application_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..79546e332530ba006ccacdb3adb26556a583286c --- /dev/null +++ b/db/migrate/20191011084019_add_project_deletion_adjourned_period_to_application_settings.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddProjectDeletionAdjournedPeriodToApplicationSettings < ActiveRecord::Migration[5.2] + DOWNTIME = false + + DEFAULT_NUMBER_OF_DAYS_BEFORE_REMOVAL = 7 + + def change + add_column :application_settings, :deletion_adjourned_period, :integer, default: DEFAULT_NUMBER_OF_DAYS_BEFORE_REMOVAL, null: false + end +end diff --git a/db/migrate/20191013100213_add_squash_commit_sha_to_merge_requests.rb b/db/migrate/20191013100213_add_squash_commit_sha_to_merge_requests.rb new file mode 100644 index 0000000000000000000000000000000000000000..0a58f0a89aaca4893f8ac22b2da3864ccceb5479 --- /dev/null +++ b/db/migrate/20191013100213_add_squash_commit_sha_to_merge_requests.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddSquashCommitShaToMergeRequests < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + add_column :merge_requests, :squash_commit_sha, :binary + end +end diff --git a/db/migrate/20191014025629_rename_design_management_version_user_to_author.rb b/db/migrate/20191014025629_rename_design_management_version_user_to_author.rb new file mode 100644 index 0000000000000000000000000000000000000000..2359cc2e826872aeba8d9ffd382260ff0ad10af8 --- /dev/null +++ b/db/migrate/20191014025629_rename_design_management_version_user_to_author.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class RenameDesignManagementVersionUserToAuthor < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + rename_column_concurrently :design_management_versions, :user_id, :author_id + end + + def down + undo_rename_column_concurrently :design_management_versions, :user_id, :author_id + end +end diff --git a/db/migrate/20191014030730_add_author_index_to_design_management_versions.rb b/db/migrate/20191014030730_add_author_index_to_design_management_versions.rb new file mode 100644 index 0000000000000000000000000000000000000000..30e076f1fe6f9738ed72bc46093e81802dcbbb71 --- /dev/null +++ b/db/migrate/20191014030730_add_author_index_to_design_management_versions.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddAuthorIndexToDesignManagementVersions < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :design_management_versions, :author_id, where: 'author_id IS NOT NULL' + end + + def down + remove_concurrent_index :design_management_versions, :author_id + end +end diff --git a/db/migrate/20191016133352_create_ci_subscriptions_projects.rb b/db/migrate/20191016133352_create_ci_subscriptions_projects.rb new file mode 100644 index 0000000000000000000000000000000000000000..00ab2c19193b1d169e4a4fb417ef735c815b4d31 --- /dev/null +++ b/db/migrate/20191016133352_create_ci_subscriptions_projects.rb @@ -0,0 +1,21 @@ +# 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 CreateCiSubscriptionsProjects < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + create_table :ci_subscriptions_projects do |t| + t.references :downstream_project, null: false, index: false, foreign_key: { to_table: :projects, on_delete: :cascade } + t.references :upstream_project, null: false, foreign_key: { to_table: :projects, on_delete: :cascade } + end + + add_index :ci_subscriptions_projects, [:downstream_project_id, :upstream_project_id], + unique: true, name: 'index_ci_subscriptions_projects_unique_subscription' + end +end diff --git a/db/migrate/20191017001326_create_users_security_dashboard_projects.rb b/db/migrate/20191017001326_create_users_security_dashboard_projects.rb new file mode 100644 index 0000000000000000000000000000000000000000..398401dbee6729fa6a023b68fc131f46c8db3453 --- /dev/null +++ b/db/migrate/20191017001326_create_users_security_dashboard_projects.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateUsersSecurityDashboardProjects < ActiveRecord::Migration[5.2] + DOWNTIME = false + INDEX_NAME = 'users_security_dashboard_projects_unique_index' + + def change + create_table :users_security_dashboard_projects, id: false do |t| + t.references :user, null: false, foreign_key: { on_delete: :cascade } + t.references :project, null: false, index: false, foreign_key: { on_delete: :cascade } + end + + add_index :users_security_dashboard_projects, [:project_id, :user_id], name: INDEX_NAME, unique: true + end +end diff --git a/db/migrate/20191017094449_add_remove_source_branch_after_merge_to_projects.rb b/db/migrate/20191017094449_add_remove_source_branch_after_merge_to_projects.rb new file mode 100644 index 0000000000000000000000000000000000000000..021bf7d987080a99b0b4098fcc1ef65fc126ec23 --- /dev/null +++ b/db/migrate/20191017094449_add_remove_source_branch_after_merge_to_projects.rb @@ -0,0 +1,17 @@ +# 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 AddRemoveSourceBranchAfterMergeToProjects < ActiveRecord::Migration[5.1] + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def up + add_column :projects, :remove_source_branch_after_merge, :boolean + end + + def down + remove_column :projects, :remove_source_branch_after_merge + end +end diff --git a/db/migrate/20191017134513_add_deployment_merge_requests.rb b/db/migrate/20191017134513_add_deployment_merge_requests.rb new file mode 100644 index 0000000000000000000000000000000000000000..dbe09463d22774003deeba116a2daee833f7c3df --- /dev/null +++ b/db/migrate/20191017134513_add_deployment_merge_requests.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class AddDeploymentMergeRequests < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :deployment_merge_requests, id: false do |t| + t.references( + :deployment, + foreign_key: { on_delete: :cascade }, + type: :integer, + index: false, + null: false + ) + + t.references( + :merge_request, + foreign_key: { on_delete: :cascade }, + type: :integer, + index: true, + null: false + ) + + t.index( + [:deployment_id, :merge_request_id], + unique: true, + name: 'idx_deployment_merge_requests_unique_index' + ) + end + end +end diff --git a/db/migrate/20191017191341_create_clusters_applications_crossplane.rb b/db/migrate/20191017191341_create_clusters_applications_crossplane.rb new file mode 100644 index 0000000000000000000000000000000000000000..8dc25c561164100a74ecd57b2f9ac0f9c071a636 --- /dev/null +++ b/db/migrate/20191017191341_create_clusters_applications_crossplane.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreateClustersApplicationsCrossplane < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :clusters_applications_crossplane do |t| + t.timestamps_with_timezone null: false + t.references :cluster, null: false, index: false, foreign_key: { on_delete: :cascade } + t.integer :status, null: false + t.string :version, null: false, limit: 255 + t.string :stack, null: false, limit: 255 + t.text :status_reason + t.index :cluster_id, unique: true + end + end +end diff --git a/db/migrate/20191023132005_add_merge_requests_index_on_target_project_and_branch.rb b/db/migrate/20191023132005_add_merge_requests_index_on_target_project_and_branch.rb new file mode 100644 index 0000000000000000000000000000000000000000..a3de3f34c44e84a1cdd1751cae8bbeed7c09fc12 --- /dev/null +++ b/db/migrate/20191023132005_add_merge_requests_index_on_target_project_and_branch.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddMergeRequestsIndexOnTargetProjectAndBranch < 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_concurrent_index :merge_requests, [:target_project_id, :target_branch], + where: "state_id = 1 AND merge_when_pipeline_succeeds = true" + end + + def down + remove_concurrent_index :merge_requests, [:target_project_id, :target_branch] + end +end diff --git a/db/migrate/20191023152913_add_default_and_free_plans.rb b/db/migrate/20191023152913_add_default_and_free_plans.rb new file mode 100644 index 0000000000000000000000000000000000000000..4f5f800038620a7835bb2cd0f9e8e8e0c5172a47 --- /dev/null +++ b/db/migrate/20191023152913_add_default_and_free_plans.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class AddDefaultAndFreePlans < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + class Plan < ApplicationRecord + end + + def up + plan_names.each do |plan_name| + Plan.create_with(title: plan_name.titleize).find_or_create_by(name: plan_name) + end + end + + def down + Plan.where(name: plan_names).delete_all + end + + private + + def plan_names + [ + ('free' if Gitlab.com?), + 'default' + ].compact + end +end diff --git a/db/migrate/20191024134020_add_index_to_zoom_meetings.rb b/db/migrate/20191024134020_add_index_to_zoom_meetings.rb new file mode 100644 index 0000000000000000000000000000000000000000..ef3657b6a5edbe833ae5ede286e4bcfdaf81c507 --- /dev/null +++ b/db/migrate/20191024134020_add_index_to_zoom_meetings.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddIndexToZoomMeetings < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :zoom_meetings, :issue_status + end + + def down + remove_concurrent_index :zoom_meetings, :issue_status if index_exists?(:zoom_meetings, :issue_status) + end +end diff --git a/db/migrate/20191026124116_set_application_settings_default_project_and_snippet_visibility.rb b/db/migrate/20191026124116_set_application_settings_default_project_and_snippet_visibility.rb new file mode 100644 index 0000000000000000000000000000000000000000..9d19279510a967daa8610690532a38b4bedf8ebf --- /dev/null +++ b/db/migrate/20191026124116_set_application_settings_default_project_and_snippet_visibility.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class SetApplicationSettingsDefaultProjectAndSnippetVisibility < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + change_column_null :application_settings, :default_project_visibility, false, 0 + change_column_default :application_settings, :default_project_visibility, from: nil, to: 0 + + change_column_null :application_settings, :default_snippet_visibility, false, 0 + change_column_default :application_settings, :default_snippet_visibility, from: nil, to: 0 + end +end diff --git a/db/migrate/20191028162543_add_setup_for_company_to_user_preferences.rb b/db/migrate/20191028162543_add_setup_for_company_to_user_preferences.rb new file mode 100644 index 0000000000000000000000000000000000000000..18a8a2306e264ba252bdc968d94f839bc3a6673a --- /dev/null +++ b/db/migrate/20191028162543_add_setup_for_company_to_user_preferences.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddSetupForCompanyToUserPreferences < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + add_column :user_preferences, :setup_for_company, :boolean + end +end diff --git a/db/migrate/20191028184740_rename_snowplow_site_id_to_snowplow_app_id.rb b/db/migrate/20191028184740_rename_snowplow_site_id_to_snowplow_app_id.rb new file mode 100644 index 0000000000000000000000000000000000000000..4e3b2da670efc504bb6c143666dea6883f145e77 --- /dev/null +++ b/db/migrate/20191028184740_rename_snowplow_site_id_to_snowplow_app_id.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class RenameSnowplowSiteIdToSnowplowAppId < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + rename_column_concurrently :application_settings, :snowplow_site_id, :snowplow_app_id + end + + def down + undo_rename_column_concurrently :application_settings, :snowplow_site_id, :snowplow_app_id + end +end diff --git a/db/migrate/20191029125305_create_packages_conan_metadata.rb b/db/migrate/20191029125305_create_packages_conan_metadata.rb new file mode 100644 index 0000000000000000000000000000000000000000..c6abc509e41faaf51f66abb3e76d8998c4eb76ff --- /dev/null +++ b/db/migrate/20191029125305_create_packages_conan_metadata.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreatePackagesConanMetadata < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :packages_conan_metadata do |t| + t.references :package, index: { unique: true }, null: false, foreign_key: { to_table: :packages_packages, on_delete: :cascade }, type: :bigint + t.timestamps_with_timezone + t.string "package_username", null: false, limit: 255 + t.string "package_channel", null: false, limit: 255 + end + end +end diff --git a/db/migrate/20191029191901_add_enabled_to_grafana_integrations.rb b/db/migrate/20191029191901_add_enabled_to_grafana_integrations.rb new file mode 100644 index 0000000000000000000000000000000000000000..8db11724874fce841e60e4b1a26c84881627a1c8 --- /dev/null +++ b/db/migrate/20191029191901_add_enabled_to_grafana_integrations.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class AddEnabledToGrafanaIntegrations < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default( + :grafana_integrations, + :enabled, + :boolean, + allow_null: false, + default: false + ) + end + + def down + remove_column(:grafana_integrations, :enabled) + end +end diff --git a/db/migrate/20191030135044_create_plan_limits.rb b/db/migrate/20191030135044_create_plan_limits.rb new file mode 100644 index 0000000000000000000000000000000000000000..291d9824f6d7fd6cbe2db61622855385f6b23b3e --- /dev/null +++ b/db/migrate/20191030135044_create_plan_limits.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreatePlanLimits < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + create_table :plan_limits, id: false do |t| + t.references :plan, foreign_key: { on_delete: :cascade }, null: false, index: { unique: true } + t.integer :ci_active_pipelines, null: false, default: 0 + t.integer :ci_pipeline_size, null: false, default: 0 + t.integer :ci_active_jobs, null: false, default: 0 + end + end +end diff --git a/db/migrate/20191030152934_move_limits_from_plans.rb b/db/migrate/20191030152934_move_limits_from_plans.rb new file mode 100644 index 0000000000000000000000000000000000000000..020a028f648019e22c0352ffb2406109785781fb --- /dev/null +++ b/db/migrate/20191030152934_move_limits_from_plans.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class MoveLimitsFromPlans < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def up + execute <<~SQL + INSERT INTO plan_limits (plan_id, ci_active_pipelines, ci_pipeline_size, ci_active_jobs) + SELECT id, COALESCE(active_pipelines_limit, 0), COALESCE(pipeline_size_limit, 0), COALESCE(active_jobs_limit, 0) + FROM plans + SQL + end + + def down + execute 'DELETE FROM plan_limits' + end +end diff --git a/db/migrate/20191101092917_replace_index_on_metrics_merged_at.rb b/db/migrate/20191101092917_replace_index_on_metrics_merged_at.rb new file mode 100644 index 0000000000000000000000000000000000000000..b2baaee2b7669df9d4529465112281c575fce5b7 --- /dev/null +++ b/db/migrate/20191101092917_replace_index_on_metrics_merged_at.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class ReplaceIndexOnMetricsMergedAt < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :merge_request_metrics, :merged_at + remove_concurrent_index :merge_request_metrics, [:merged_at, :id] + end + + def down + add_concurrent_index :merge_request_metrics, [:merged_at, :id] + remove_concurrent_index :merge_request_metrics, :merged_at + end +end diff --git a/db/migrate/20191103202505_add_eks_credentials_to_application_settings.rb b/db/migrate/20191103202505_add_eks_credentials_to_application_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..3a167b4c67f1e1f43377867c2f1349cb8cd55103 --- /dev/null +++ b/db/migrate/20191103202505_add_eks_credentials_to_application_settings.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AddEksCredentialsToApplicationSettings < ActiveRecord::Migration[5.2] + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + add_column :application_settings, :eks_integration_enabled, :boolean, null: false, default: false + add_column :application_settings, :eks_account_id, :string, limit: 128 + add_column :application_settings, :eks_access_key_id, :string, limit: 128 + add_column :application_settings, :encrypted_eks_secret_access_key_iv, :string, limit: 255 + add_column :application_settings, :encrypted_eks_secret_access_key, :text + end +end diff --git a/db/migrate/20191104205020_add_license_details_to_application_settings.rb b/db/migrate/20191104205020_add_license_details_to_application_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..f951ae6492d2569672cb8bebb1fd32776215ad26 --- /dev/null +++ b/db/migrate/20191104205020_add_license_details_to_application_settings.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddLicenseDetailsToApplicationSettings < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :application_settings, :license_trial_ends_on, :date, null: true + end +end diff --git a/db/migrate/20191105094558_add_report_type_to_vulnerabilities.rb b/db/migrate/20191105094558_add_report_type_to_vulnerabilities.rb new file mode 100644 index 0000000000000000000000000000000000000000..8fb657bf9e7a12b1647eacf156e524198f6c3c4f --- /dev/null +++ b/db/migrate/20191105094558_add_report_type_to_vulnerabilities.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddReportTypeToVulnerabilities < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + add_column :vulnerabilities, :report_type, :integer, limit: 2 + end +end diff --git a/db/migrate/20191105193652_add_index_on_deployments_updated_at.rb b/db/migrate/20191105193652_add_index_on_deployments_updated_at.rb new file mode 100644 index 0000000000000000000000000000000000000000..10371c26dcc561165cc1c23750d92ce70a57950c --- /dev/null +++ b/db/migrate/20191105193652_add_index_on_deployments_updated_at.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class AddIndexOnDeploymentsUpdatedAt < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + INDEX_COLUMNS = [:project_id, :updated_at] + + disable_ddl_transaction! + + def up + add_concurrent_index(:deployments, INDEX_COLUMNS) + end + + def down + remove_concurrent_index(:deployments, INDEX_COLUMNS) + end +end diff --git a/db/migrate/20191107173446_add_sourcegraph_admin_and_user_preferences.rb b/db/migrate/20191107173446_add_sourcegraph_admin_and_user_preferences.rb new file mode 100644 index 0000000000000000000000000000000000000000..731ed82c9994f038f9eda53fd94666e44a0eff8a --- /dev/null +++ b/db/migrate/20191107173446_add_sourcegraph_admin_and_user_preferences.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddSourcegraphAdminAndUserPreferences < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + add_column(:application_settings, :sourcegraph_public_only, :boolean, default: true, null: false) + add_column(:user_preferences, :sourcegraph_enabled, :boolean) + end + + def down + remove_column(:application_settings, :sourcegraph_public_only) + remove_column(:user_preferences, :sourcegraph_enabled) + end +end diff --git a/db/migrate/20191107220314_add_index_to_projects_on_marked_for_deletion.rb b/db/migrate/20191107220314_add_index_to_projects_on_marked_for_deletion.rb new file mode 100644 index 0000000000000000000000000000000000000000..06849cf9bfd65d779396f529354aaf0ddfe3fda2 --- /dev/null +++ b/db/migrate/20191107220314_add_index_to_projects_on_marked_for_deletion.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddIndexToProjectsOnMarkedForDeletion < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :projects, :marked_for_deletion_at, where: 'marked_for_deletion_at IS NOT NULL' + end + + def down + remove_concurrent_index :projects, :marked_for_deletion_at + end +end diff --git a/db/migrate/20191111115229_add_group_id_to_import_export_uploads.rb b/db/migrate/20191111115229_add_group_id_to_import_export_uploads.rb new file mode 100644 index 0000000000000000000000000000000000000000..74ef0f27b3ebba33e4fb5bdd9cec9a3ac92e7325 --- /dev/null +++ b/db/migrate/20191111115229_add_group_id_to_import_export_uploads.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddGroupIdToImportExportUploads < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + add_column :import_export_uploads, :group_id, :bigint + end +end diff --git a/db/migrate/20191111115431_add_group_fk_to_import_export_uploads.rb b/db/migrate/20191111115431_add_group_fk_to_import_export_uploads.rb new file mode 100644 index 0000000000000000000000000000000000000000..403de3f33edc1042b83b90354615e480b6e0f6dd --- /dev/null +++ b/db/migrate/20191111115431_add_group_fk_to_import_export_uploads.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddGroupFkToImportExportUploads < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_foreign_key :import_export_uploads, :namespaces, column: :group_id, on_delete: :cascade + add_concurrent_index :import_export_uploads, :group_id, unique: true, where: 'group_id IS NOT NULL' + end + + def down + remove_foreign_key_without_error(:import_export_uploads, column: :group_id) + remove_concurrent_index(:import_export_uploads, :group_id) + end +end diff --git a/db/migrate/20191111121500_default_ci_config_path.rb b/db/migrate/20191111121500_default_ci_config_path.rb new file mode 100644 index 0000000000000000000000000000000000000000..f391f5ffe991ac772e6a3ad26ddce2efb64055de --- /dev/null +++ b/db/migrate/20191111121500_default_ci_config_path.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class DefaultCiConfigPath < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def up + add_column :application_settings, :default_ci_config_path, :string, limit: 255 + end + + def down + remove_column :application_settings, :default_ci_config_path + end +end diff --git a/db/migrate/20191112115247_add_cached_markdown_version_to_vulnerabilities.rb b/db/migrate/20191112115247_add_cached_markdown_version_to_vulnerabilities.rb new file mode 100644 index 0000000000000000000000000000000000000000..b0c513737e8323181a744239fb3adfc86ff20c06 --- /dev/null +++ b/db/migrate/20191112115247_add_cached_markdown_version_to_vulnerabilities.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddCachedMarkdownVersionToVulnerabilities < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + add_column :vulnerabilities, :cached_markdown_version, :integer + end +end diff --git a/db/migrate/20191112214305_add_indexes_for_projects_api_default_params.rb b/db/migrate/20191112214305_add_indexes_for_projects_api_default_params.rb new file mode 100644 index 0000000000000000000000000000000000000000..3893c0422c70f38a32facad8eff0313c9f332362 --- /dev/null +++ b/db/migrate/20191112214305_add_indexes_for_projects_api_default_params.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddIndexesForProjectsApiDefaultParams < 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) + remove_concurrent_index_by_name :projects, 'index_projects_on_visibility_level' + end + + def down + add_concurrent_index :projects, :visibility_level + remove_concurrent_index :projects, %i(visibility_level created_at id) + end +end diff --git a/db/migrate/20191112221821_add_indexes_for_projects_api_default_params_authenticated.rb b/db/migrate/20191112221821_add_indexes_for_projects_api_default_params_authenticated.rb new file mode 100644 index 0000000000000000000000000000000000000000..6ebc6a72854ac192ed6b74d1bcf36602f5709869 --- /dev/null +++ b/db/migrate/20191112221821_add_indexes_for_projects_api_default_params_authenticated.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddIndexesForProjectsApiDefaultParamsAuthenticated < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :projects, %i(created_at id) + remove_concurrent_index_by_name :projects, 'index_projects_on_created_at' + end + + def down + add_concurrent_index :projects, :created_at + remove_concurrent_index_by_name :projects, 'index_projects_on_created_at_and_id' + end +end diff --git a/db/migrate/20191112232338_ensure_no_empty_milestone_titles.rb b/db/migrate/20191112232338_ensure_no_empty_milestone_titles.rb new file mode 100644 index 0000000000000000000000000000000000000000..76cb511424e74a22d5a69ae249dfec3ddd69b292 --- /dev/null +++ b/db/migrate/20191112232338_ensure_no_empty_milestone_titles.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class EnsureNoEmptyMilestoneTitles < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + loop do + rows_updated = exec_update <<~SQL + UPDATE milestones SET title = '%BLANK' WHERE id IN (SELECT id FROM milestones WHERE title = '' LIMIT 500) + SQL + break if rows_updated < 500 + end + end + + def down; end +end diff --git a/db/migrate/20191114173508_add_resolved_attributes_to_vulnerabilities.rb b/db/migrate/20191114173508_add_resolved_attributes_to_vulnerabilities.rb new file mode 100644 index 0000000000000000000000000000000000000000..ec45a729ebb29fbc989b0818bce72028f9e0b047 --- /dev/null +++ b/db/migrate/20191114173508_add_resolved_attributes_to_vulnerabilities.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddResolvedAttributesToVulnerabilities < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def up + add_column :vulnerabilities, :resolved_by_id, :bigint + add_column :vulnerabilities, :resolved_at, :datetime_with_timezone + end + + def down + remove_column :vulnerabilities, :resolved_at + remove_column :vulnerabilities, :resolved_by_id + end +end diff --git a/db/migrate/20191114173602_add_foreign_key_on_resolved_by_id_to_vulnerabilities.rb b/db/migrate/20191114173602_add_foreign_key_on_resolved_by_id_to_vulnerabilities.rb new file mode 100644 index 0000000000000000000000000000000000000000..e0a125ca756230b349842c06ac7db5efff116ee7 --- /dev/null +++ b/db/migrate/20191114173602_add_foreign_key_on_resolved_by_id_to_vulnerabilities.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddForeignKeyOnResolvedByIdToVulnerabilities < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :vulnerabilities, :resolved_by_id + add_concurrent_foreign_key :vulnerabilities, :users, column: :resolved_by_id, on_delete: :nullify + end + + def down + remove_foreign_key :vulnerabilities, column: :resolved_by_id + remove_concurrent_index :vulnerabilities, :resolved_by_id + end +end diff --git a/db/migrate/20191115091425_create_vulnerability_issue_links.rb b/db/migrate/20191115091425_create_vulnerability_issue_links.rb new file mode 100644 index 0000000000000000000000000000000000000000..8398b6357c4a00884f1882a5f7adb26bb970928e --- /dev/null +++ b/db/migrate/20191115091425_create_vulnerability_issue_links.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class CreateVulnerabilityIssueLinks < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + create_table :vulnerability_issue_links do |t| + # index: false because idx_vulnerability_issue_links_on_vulnerability_id_and_issue_id refers the same column + t.references :vulnerability, null: false, index: false, foreign_key: { on_delete: :cascade } + # index: true is implied + t.references :issue, null: false, foreign_key: { on_delete: :cascade } + t.integer 'link_type', limit: 2, null: false, default: 1 # 'related' + t.index %i[vulnerability_id issue_id], + name: 'idx_vulnerability_issue_links_on_vulnerability_id_and_issue_id', + unique: true # only one link (and of only one type) is allowed + t.index %i[vulnerability_id link_type], + name: 'idx_vulnerability_issue_links_on_vulnerability_id_and_link_type', + where: 'link_type = 2', + unique: true # only one 'created' link per vulnerability is allowed + t.timestamps_with_timezone + end + end +end diff --git a/db/post_migrate/20190809072552_set_self_monitoring_project_alerting_token.rb b/db/post_migrate/20190809072552_set_self_monitoring_project_alerting_token.rb index 0c4faebc54827e97b3e924651b17fe1c59a43dd1..d10887fb5d5500787aaba994a75461718bc70449 100644 --- a/db/post_migrate/20190809072552_set_self_monitoring_project_alerting_token.rb +++ b/db/post_migrate/20190809072552_set_self_monitoring_project_alerting_token.rb @@ -3,71 +3,17 @@ class SetSelfMonitoringProjectAlertingToken < ActiveRecord::Migration[5.2] DOWNTIME = false - module Migratable - module Alerting - class ProjectAlertingSetting < ApplicationRecord - self.table_name = 'project_alerting_settings' - - belongs_to :project - - validates :token, presence: true - - attr_encrypted :token, - mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, - algorithm: 'aes-256-gcm' - - before_validation :ensure_token - - private - - def ensure_token - self.token ||= generate_token - end - - def generate_token - SecureRandom.hex - end - end - end - - class Project < ApplicationRecord - has_one :alerting_setting, inverse_of: :project, class_name: 'Alerting::ProjectAlertingSetting' - end - - class ApplicationSetting < ApplicationRecord - self.table_name = 'application_settings' - - belongs_to :instance_administration_project, class_name: 'Project' - - def self.current_without_cache - last - end - end - end - - def setup_alertmanager_token(project) - return unless License.feature_available?(:prometheus_alerts) - - project.create_alerting_setting! - end - def up - Gitlab.ee do - project = Migratable::ApplicationSetting.current_without_cache&.instance_administration_project + # no-op + # Converted to no-op in https://gitlab.com/gitlab-org/gitlab/merge_requests/17049. - if project - setup_alertmanager_token(project) - end - end + # This migration has been made a no-op because the pre-requisite migration + # which creates the self-monitoring project has already been removed in + # https://gitlab.com/gitlab-org/gitlab/merge_requests/16864. As + # such, this migration would do nothing. end def down - Gitlab.ee do - Migratable::ApplicationSetting.current_without_cache - &.instance_administration_project - &.alerting_setting - &.destroy! - end + # no-op end end diff --git a/db/post_migrate/20190918104222_schedule_productivity_analytics_backfill.rb b/db/post_migrate/20190918104222_schedule_productivity_analytics_backfill.rb index 23d3bbbc395489247002afe6683561a532ff50cf..cd759735f004b2a71f33079f9c1b94b56dacb7e8 100644 --- a/db/post_migrate/20190918104222_schedule_productivity_analytics_backfill.rb +++ b/db/post_migrate/20190918104222_schedule_productivity_analytics_backfill.rb @@ -6,7 +6,7 @@ class ScheduleProductivityAnalyticsBackfill < ActiveRecord::Migration[5.2] DOWNTIME = false def up - # no-op since the scheduling times out on GitLab.com + # no-op since the migration was removed end def down diff --git a/db/post_migrate/20190926180443_schedule_epic_issues_after_epics_move.rb b/db/post_migrate/20190926180443_schedule_epic_issues_after_epics_move.rb new file mode 100644 index 0000000000000000000000000000000000000000..86fe0f26681da90c69b81f410fac7934dd530749 --- /dev/null +++ b/db/post_migrate/20190926180443_schedule_epic_issues_after_epics_move.rb @@ -0,0 +1,35 @@ +# 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 ScheduleEpicIssuesAfterEpicsMove < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + INTERVAL = 5.minutes.to_i + BATCH_SIZE = 100 + MIGRATION = 'MoveEpicIssuesAfterEpics' + + disable_ddl_transaction! + + class Epic < ActiveRecord::Base + self.table_name = 'epics' + + include ::EachBatch + end + + def up + return unless ::Gitlab.ee? + + Epic.each_batch(of: BATCH_SIZE) do |batch, index| + range = batch.pluck('MIN(id)', 'MAX(id)').first + delay = index * INTERVAL + BackgroundMigrationWorker.perform_in(delay, MIGRATION, *range) + end + end + + def down + # no need + end +end diff --git a/db/post_migrate/20191008143850_fix_any_approver_rule_for_projects.rb b/db/post_migrate/20191008143850_fix_any_approver_rule_for_projects.rb new file mode 100644 index 0000000000000000000000000000000000000000..c1f4b7e42ab28debf3ad6f8dd21f9bdc8941fa3c --- /dev/null +++ b/db/post_migrate/20191008143850_fix_any_approver_rule_for_projects.rb @@ -0,0 +1,51 @@ +# 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 FixAnyApproverRuleForProjects < ActiveRecord::Migration[5.2] + DOWNTIME = false + BATCH_SIZE = 1000 + + disable_ddl_transaction! + + class ApprovalProjectRule < ActiveRecord::Base + NON_EXISTENT_RULE_TYPE = 4 + ANY_APPROVER_RULE_TYPE = 3 + + include EachBatch + + self.table_name = 'approval_project_rules' + + scope :any_approver, -> { where(rule_type: ANY_APPROVER_RULE_TYPE) } + scope :non_existent_rule_type, -> { where(rule_type: NON_EXISTENT_RULE_TYPE) } + end + + def up + return unless Gitlab.ee? + + # Remove approval project rule with rule type 4 if the project has a rule with rule_type 3 + # + # Currently, there is no projects on gitlab.com which have both rules with 3 and 4 rule type + # There's a code-level validation for a rule, which doesn't allow to create rules with the same names + # + # But in order to avoid failing the update query due to uniqueness constraint + # Let's run the delete query to be sure + project_ids = FixAnyApproverRuleForProjects::ApprovalProjectRule.any_approver.select(:project_id) + FixAnyApproverRuleForProjects::ApprovalProjectRule + .non_existent_rule_type + .where(project_id: project_ids) + .delete_all + + # Set approval project rule types to 3 + # Currently there are 18_445 records to be updated + FixAnyApproverRuleForProjects::ApprovalProjectRule.non_existent_rule_type.each_batch(of: BATCH_SIZE) do |rules| + rules.update_all(rule_type: FixAnyApproverRuleForProjects::ApprovalProjectRule::ANY_APPROVER_RULE_TYPE) + end + end + + def down + # The migration doesn't leave the database in an inconsistent state + # And can be run multiple times + end +end diff --git a/db/post_migrate/20191014030134_cleanup_design_management_version_user_to_author_rename.rb b/db/post_migrate/20191014030134_cleanup_design_management_version_user_to_author_rename.rb new file mode 100644 index 0000000000000000000000000000000000000000..e7132cbeeb714cab937f9ed4e0ed5eb7ae03a8cb --- /dev/null +++ b/db/post_migrate/20191014030134_cleanup_design_management_version_user_to_author_rename.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CleanupDesignManagementVersionUserToAuthorRename < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + cleanup_concurrent_column_rename :design_management_versions, :user_id, :author_id + end + + def down + undo_cleanup_concurrent_column_rename :design_management_versions, :user_id, :author_id + end +end diff --git a/db/post_migrate/20191017045817_schedule_fix_gitlab_com_pages_access_level.rb b/db/post_migrate/20191017045817_schedule_fix_gitlab_com_pages_access_level.rb new file mode 100644 index 0000000000000000000000000000000000000000..fc44568ea1797c4d02e299b870648b422ff341f1 --- /dev/null +++ b/db/post_migrate/20191017045817_schedule_fix_gitlab_com_pages_access_level.rb @@ -0,0 +1,17 @@ +# 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. + +# Code of this migration was removed after execution on gitlab.com +# https://gitlab.com/gitlab-org/gitlab/issues/34018 +# Empty migration is left here to avoid any problems with rolling back +class ScheduleFixGitlabComPagesAccessLevel < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def up + end + + def down + end +end diff --git a/db/post_migrate/20191017180026_drop_ci_build_trace_sections_id.rb b/db/post_migrate/20191017180026_drop_ci_build_trace_sections_id.rb new file mode 100644 index 0000000000000000000000000000000000000000..0405e23b465faec511c26a344f1aa2e963bf02f1 --- /dev/null +++ b/db/post_migrate/20191017180026_drop_ci_build_trace_sections_id.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class DropCiBuildTraceSectionsId < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def up + ## + # This column has already been ignored since 12.4 + # See https://gitlab.com/gitlab-org/gitlab/issues/32569 + remove_column :ci_build_trace_sections, :id + end + + def down + ## + # We don't backfill serial ids as it's not used in application code + # and quite expensive process. + add_column :ci_build_trace_sections, :id, :bigint + end +end diff --git a/db/post_migrate/20191021101942_remove_empty_github_service_templates.rb b/db/post_migrate/20191021101942_remove_empty_github_service_templates.rb new file mode 100644 index 0000000000000000000000000000000000000000..64abe93b3e819113b13c5376a46243870f1d4908 --- /dev/null +++ b/db/post_migrate/20191021101942_remove_empty_github_service_templates.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +## It's expected to delete one record on GitLab.com +# +class RemoveEmptyGithubServiceTemplates < ActiveRecord::Migration[5.2] + DOWNTIME = false + + class Service < ActiveRecord::Base + self.table_name = 'services' + self.inheritance_column = :_type_disabled + + serialize :properties, JSON + end + + def up + relationship.where(properties: {}).delete_all + end + + def down + relationship.find_or_create_by!(properties: {}) + end + + private + + def relationship + RemoveEmptyGithubServiceTemplates::Service.where(template: true, type: 'GithubService') + end +end diff --git a/db/post_migrate/20191022113635_nullify_feature_flag_plaintext_tokens.rb b/db/post_migrate/20191022113635_nullify_feature_flag_plaintext_tokens.rb new file mode 100644 index 0000000000000000000000000000000000000000..9ade1454844493c6ee462af430aca1907fa24181 --- /dev/null +++ b/db/post_migrate/20191022113635_nullify_feature_flag_plaintext_tokens.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class NullifyFeatureFlagPlaintextTokens < ActiveRecord::Migration[5.2] + DOWNTIME = false + + class FeatureFlagsClient < ActiveRecord::Base + include EachBatch + + self.table_name = 'operations_feature_flags_clients' + + scope :with_encrypted_token, -> { where.not(token_encrypted: nil) } + scope :with_plaintext_token, -> { where.not(token: nil) } + scope :without_plaintext_token, -> { where(token: nil) } + end + + disable_ddl_transaction! + + def up + return unless Gitlab.ee? + + # 7357 records to be updated on GitLab.com + FeatureFlagsClient.with_encrypted_token.with_plaintext_token.each_batch do |relation| + relation.update_all(token: nil) + end + end + + def down + return unless Gitlab.ee? + + # There is no way to restore only the tokens that were NULLifyed in the `up` + # but we can do is to restore _all_ of them in case it is needed. + say_with_time('Decrypting tokens from operations_feature_flags_clients') do + FeatureFlagsClient.with_encrypted_token.without_plaintext_token.find_each do |feature_flags_client| + token = Gitlab::CryptoHelper.aes256_gcm_decrypt(feature_flags_client.token_encrypted) + feature_flags_client.update_column(:token, token) + end + end + end +end diff --git a/db/post_migrate/20191029095537_cleanup_application_settings_snowplow_site_id_rename.rb b/db/post_migrate/20191029095537_cleanup_application_settings_snowplow_site_id_rename.rb new file mode 100644 index 0000000000000000000000000000000000000000..83b4a2af2b625e2dab70562ff7b89fd60e8f1c20 --- /dev/null +++ b/db/post_migrate/20191029095537_cleanup_application_settings_snowplow_site_id_rename.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CleanupApplicationSettingsSnowplowSiteIdRename < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + cleanup_concurrent_column_rename :application_settings, :snowplow_site_id, :snowplow_app_id + end + + def down + undo_cleanup_concurrent_column_rename :application_settings, :snowplow_site_id, :snowplow_app_id + end +end diff --git a/db/post_migrate/20191030193050_remove_pendo_from_application_settings.rb b/db/post_migrate/20191030193050_remove_pendo_from_application_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..33bbe6f8ea7bc4c39b61603100e9989e10aa3176 --- /dev/null +++ b/db/post_migrate/20191030193050_remove_pendo_from_application_settings.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class RemovePendoFromApplicationSettings < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + + DOWNTIME = false + + def up + remove_column :application_settings, :pendo_enabled + remove_column :application_settings, :pendo_url + end + + def down + add_column_with_default :application_settings, :pendo_enabled, :boolean, default: false, allow_null: false + add_column :application_settings, :pendo_url, :string, limit: 255 + end +end diff --git a/db/post_migrate/20191031112603_remove_limits_from_plans.rb b/db/post_migrate/20191031112603_remove_limits_from_plans.rb new file mode 100644 index 0000000000000000000000000000000000000000..30fb6a9d193f3de7a0177fb9be5e477c7241ccc9 --- /dev/null +++ b/db/post_migrate/20191031112603_remove_limits_from_plans.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class RemoveLimitsFromPlans < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def up + remove_column :plans, :active_pipelines_limit + remove_column :plans, :pipeline_size_limit + remove_column :plans, :active_jobs_limit + end + + def down + add_column :plans, :active_pipelines_limit, :integer + add_column :plans, :pipeline_size_limit, :integer + add_column :plans, :active_jobs_limit, :integer + end +end diff --git a/db/post_migrate/20191105094625_set_report_type_for_vulnerabilities.rb b/db/post_migrate/20191105094625_set_report_type_for_vulnerabilities.rb new file mode 100644 index 0000000000000000000000000000000000000000..6b7a158584d21407750cda0cdfd41da7711afd9f --- /dev/null +++ b/db/post_migrate/20191105094625_set_report_type_for_vulnerabilities.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class SetReportTypeForVulnerabilities < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def up + # set report_type based on associated vulnerability_occurrences + execute <<~SQL + UPDATE vulnerabilities + SET report_type = vulnerability_occurrences.report_type + FROM vulnerability_occurrences + WHERE vulnerabilities.id = vulnerability_occurrences.vulnerability_id + SQL + + # set default report_type for orphan vulnerabilities (there should be none but...) + execute 'UPDATE vulnerabilities SET report_type = 0 WHERE report_type IS NULL' + + change_column_null :vulnerabilities, :report_type, false + end + + def down + change_column_null :vulnerabilities, :report_type, true + + execute 'UPDATE vulnerabilities SET report_type = NULL' + end +end diff --git a/db/post_migrate/20191105140942_add_indices_to_abuse_reports.rb b/db/post_migrate/20191105140942_add_indices_to_abuse_reports.rb new file mode 100644 index 0000000000000000000000000000000000000000..2b2d04e8ccc1f4e6f944a1fd9452034d375874f6 --- /dev/null +++ b/db/post_migrate/20191105140942_add_indices_to_abuse_reports.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddIndicesToAbuseReports < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :abuse_reports, :user_id + end + + def down + remove_concurrent_index :abuse_reports, :user_id + end +end diff --git a/db/post_migrate/20191112115317_change_vulnerabilities_title_html_to_nullable.rb b/db/post_migrate/20191112115317_change_vulnerabilities_title_html_to_nullable.rb new file mode 100644 index 0000000000000000000000000000000000000000..6e0f3247410185135d15088854543e92fbd95e4b --- /dev/null +++ b/db/post_migrate/20191112115317_change_vulnerabilities_title_html_to_nullable.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ChangeVulnerabilitiesTitleHtmlToNullable < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + change_column_null :vulnerabilities, :title_html, true + end +end diff --git a/db/post_migrate/20191114173624_set_resolved_state_on_vulnerabilities.rb b/db/post_migrate/20191114173624_set_resolved_state_on_vulnerabilities.rb new file mode 100644 index 0000000000000000000000000000000000000000..b28aecdc0a3d6ad6493c8b119714c2fca6c5e34f --- /dev/null +++ b/db/post_migrate/20191114173624_set_resolved_state_on_vulnerabilities.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class SetResolvedStateOnVulnerabilities < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def up + execute <<~SQL + -- selecting IDs for all non-orphan Findings that either have no feedback or it's a non-dismissal feedback + WITH resolved_vulnerability_ids AS ( + SELECT DISTINCT vulnerability_id AS id + FROM vulnerability_occurrences + LEFT JOIN vulnerability_feedback ON vulnerability_feedback.project_fingerprint = ENCODE(vulnerability_occurrences.project_fingerprint::bytea, 'HEX') + WHERE vulnerability_id IS NOT NULL + AND (vulnerability_feedback.id IS NULL OR vulnerability_feedback.feedback_type <> 0) + ) + UPDATE vulnerabilities + SET state = 3, resolved_by_id = closed_by_id, resolved_at = NOW() + FROM resolved_vulnerability_ids + WHERE vulnerabilities.id IN (resolved_vulnerability_ids.id) + AND state = 2 -- only 'closed' Vulnerabilities become 'resolved' + SQL + end + + def down + execute <<~SQL + UPDATE vulnerabilities + SET state = 2, resolved_by_id = NULL, resolved_at = NULL -- state = 'closed' + WHERE state = 3 -- 'resolved' + SQL + end +end diff --git a/db/schema.rb b/db/schema.rb index 95a4efb7e2ab8fbc30be4e73ae34848559be2ce5..e34137229917be9d89b296af64bfacc836611eff 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_10_26_041447) do +ActiveRecord::Schema.define(version: 2019_11_15_091425) do # These are extensions that must be enabled in order to support this database enable_extension "pg_trgm" @@ -24,6 +24,7 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.datetime "updated_at" t.text "message_html" t.integer "cached_markdown_version" + t.index ["user_id"], name: "index_abuse_reports_on_user_id" end create_table "alerts_service_data", force: :cascade do |t| @@ -158,8 +159,8 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.text "restricted_visibility_levels" t.boolean "version_check_enabled", default: true t.integer "max_attachment_size", default: 10, null: false - t.integer "default_project_visibility" - t.integer "default_snippet_visibility" + t.integer "default_project_visibility", default: 0, null: false + t.integer "default_snippet_visibility", default: 0, null: false t.text "domain_whitelist" t.boolean "user_oauth_applications", default: true t.string "after_sign_out_path" @@ -287,7 +288,6 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.boolean "hide_third_party_offers", default: false, null: false t.boolean "snowplow_enabled", default: false, null: false t.string "snowplow_collector_hostname" - t.string "snowplow_site_id" t.string "snowplow_cookie_domain" t.boolean "instance_statistics_visibility_private", default: false, null: false t.boolean "web_ide_clientside_preview_enabled", default: false, null: false @@ -338,9 +338,23 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.boolean "throttle_incident_management_notification_enabled", default: false, null: false t.integer "throttle_incident_management_notification_period_in_seconds", default: 3600 t.integer "throttle_incident_management_notification_per_period", default: 3600 + t.string "snowplow_iglu_registry_url", limit: 255 t.integer "push_event_hooks_limit", default: 3, null: false t.integer "push_event_activities_limit", default: 3, null: false t.string "custom_http_clone_url_root", limit: 511 + t.integer "deletion_adjourned_period", default: 7, null: false + t.date "license_trial_ends_on" + t.boolean "eks_integration_enabled", default: false, null: false + t.string "eks_account_id", limit: 128 + t.string "eks_access_key_id", limit: 128 + t.string "encrypted_eks_secret_access_key_iv", limit: 255 + t.text "encrypted_eks_secret_access_key" + t.string "snowplow_app_id" + t.datetime_with_timezone "productivity_analytics_start_date" + t.string "default_ci_config_path", limit: 255 + t.boolean "sourcegraph_enabled", default: false, null: false + t.string "sourcegraph_url", limit: 255 + t.boolean "sourcegraph_public_only", default: true, null: false 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" @@ -597,7 +611,7 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.index ["project_id", "name"], name: "index_ci_build_trace_section_names_on_project_id_and_name", unique: true end - create_table "ci_build_trace_sections", id: :serial, force: :cascade do |t| + create_table "ci_build_trace_sections", id: false, force: :cascade do |t| t.integer "project_id", null: false t.datetime "date_start", null: false t.datetime "date_end", null: false @@ -691,7 +705,9 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.boolean "interruptible" t.jsonb "config_options" t.jsonb "config_variables" + t.boolean "has_exposed_artifacts" t.index ["build_id"], name: "index_ci_builds_metadata_on_build_id", unique: true + t.index ["build_id"], name: "index_ci_builds_metadata_on_build_id_and_has_exposed_artifacts", where: "(has_exposed_artifacts IS TRUE)" t.index ["build_id"], name: "index_ci_builds_metadata_on_build_id_and_interruptible", where: "(interruptible = true)" t.index ["project_id"], name: "index_ci_builds_metadata_on_project_id" end @@ -911,6 +927,13 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.index ["project_id"], name: "index_ci_stages_on_project_id" end + create_table "ci_subscriptions_projects", force: :cascade do |t| + t.bigint "downstream_project_id", null: false + t.bigint "upstream_project_id", null: false + t.index ["downstream_project_id", "upstream_project_id"], name: "index_ci_subscriptions_projects_unique_subscription", unique: true + t.index ["upstream_project_id"], name: "index_ci_subscriptions_projects_on_upstream_project_id" + end + create_table "ci_trigger_requests", id: :serial, force: :cascade do |t| t.integer "trigger_id", null: false t.text "variables" @@ -1037,6 +1060,8 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.boolean "managed", default: true, null: false t.boolean "namespace_per_environment", default: true, null: false t.integer "management_project_id" + t.integer "cleanup_status", limit: 2, default: 1, null: false + t.text "cleanup_status_reason" t.index ["enabled"], name: "index_clusters_on_enabled" t.index ["management_project_id"], name: "index_clusters_on_management_project_id", where: "(management_project_id IS NOT NULL)" t.index ["user_id"], name: "index_clusters_on_user_id" @@ -1053,6 +1078,28 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.index ["cluster_id"], name: "index_clusters_applications_cert_managers_on_cluster_id", unique: true end + create_table "clusters_applications_crossplane", id: :serial, force: :cascade do |t| + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.bigint "cluster_id", null: false + t.integer "status", null: false + t.string "version", limit: 255, null: false + t.string "stack", limit: 255, null: false + t.text "status_reason" + t.index ["cluster_id"], name: "index_clusters_applications_crossplane_on_cluster_id", unique: true + end + + create_table "clusters_applications_elastic_stacks", force: :cascade do |t| + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.bigint "cluster_id", null: false + t.integer "status", null: false + t.string "version", limit: 255, null: false + t.string "kibana_hostname", limit: 255 + t.text "status_reason" + t.index ["cluster_id"], name: "index_clusters_applications_elastic_stacks_on_cluster_id", unique: true + end + create_table "clusters_applications_helm", id: :serial, force: :cascade do |t| t.integer "cluster_id", null: false t.datetime "created_at", null: false @@ -1238,6 +1285,13 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.index ["token_encrypted"], name: "index_deploy_tokens_on_token_encrypted", unique: true end + create_table "deployment_merge_requests", id: false, force: :cascade do |t| + t.integer "deployment_id", null: false + t.integer "merge_request_id", null: false + t.index ["deployment_id", "merge_request_id"], name: "idx_deployment_merge_requests_unique_index", unique: true + t.index ["merge_request_id"], name: "index_deployment_merge_requests_on_merge_request_id" + end + create_table "deployments", id: :serial, force: :cascade do |t| t.integer "iid", null: false t.integer "project_id", null: false @@ -1264,6 +1318,7 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.index ["project_id", "iid"], name: "index_deployments_on_project_id_and_iid", unique: true t.index ["project_id", "status", "created_at"], name: "index_deployments_on_project_id_and_status_and_created_at" t.index ["project_id", "status"], name: "index_deployments_on_project_id_and_status" + t.index ["project_id", "updated_at"], name: "index_deployments_on_project_id_and_updated_at" end create_table "description_versions", force: :cascade do |t| @@ -1299,11 +1354,11 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do create_table "design_management_versions", force: :cascade do |t| t.binary "sha", null: false t.bigint "issue_id" - t.integer "user_id" t.datetime_with_timezone "created_at" + t.integer "author_id" + t.index ["author_id"], name: "index_design_management_versions_on_author_id", where: "(author_id IS NOT NULL)" t.index ["issue_id"], name: "index_design_management_versions_on_issue_id" t.index ["sha", "issue_id"], name: "index_design_management_versions_on_sha_and_issue_id", unique: true - t.index ["user_id"], name: "index_design_management_versions_on_user_id", where: "(user_id IS NOT NULL)" end create_table "draft_notes", force: :cascade do |t| @@ -1408,15 +1463,19 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.integer "parent_id" t.integer "relative_position" t.integer "state_id", limit: 2, default: 1, null: false + t.integer "start_date_sourcing_epic_id" + t.integer "due_date_sourcing_epic_id" t.index ["assignee_id"], name: "index_epics_on_assignee_id" 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 ["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)" end create_table "events", id: :serial, force: :cascade do |t| @@ -1631,6 +1690,10 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.integer "container_repositories_synced_count" t.integer "container_repositories_failed_count" t.integer "container_repositories_registry_count" + t.integer "design_repositories_count" + t.integer "design_repositories_synced_count" + t.integer "design_repositories_failed_count" + t.integer "design_repositories_registry_count" t.index ["geo_node_id"], name: "index_geo_node_statuses_on_geo_node_id", unique: true end @@ -1782,6 +1845,7 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.string "encrypted_token", limit: 255, null: false t.string "encrypted_token_iv", limit: 255, null: false t.string "grafana_url", limit: 1024, null: false + t.boolean "enabled", default: false, null: false t.index ["project_id"], name: "index_grafana_integrations_on_project_id" end @@ -1795,6 +1859,17 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.index ["key", "value"], name: "index_group_custom_attributes_on_key_and_value" end + create_table "group_group_links", force: :cascade do |t| + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.bigint "shared_group_id", null: false + t.bigint "shared_with_group_id", null: false + t.date "expires_at" + t.integer "group_access", limit: 2, default: 30, null: false + t.index ["shared_group_id", "shared_with_group_id"], name: "index_group_group_links_on_shared_group_and_shared_with_group", unique: true + t.index ["shared_with_group_id"], name: "index_group_group_links_on_shared_with_group_id" + end + create_table "historical_data", id: :serial, force: :cascade do |t| t.date "date", null: false t.integer "active_user_count" @@ -1820,6 +1895,8 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.integer "project_id" t.text "import_file" t.text "export_file" + t.bigint "group_id" + t.index ["group_id"], name: "index_import_export_uploads_on_group_id", unique: true, where: "(group_id IS NOT NULL)" t.index ["project_id"], name: "index_import_export_uploads_on_project_id" t.index ["updated_at"], name: "index_import_export_uploads_on_updated_at" end @@ -2251,7 +2328,7 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.index ["latest_closed_by_id"], name: "index_merge_request_metrics_on_latest_closed_by_id" t.index ["merge_request_id", "merged_at"], name: "index_merge_request_metrics_on_merge_request_id_and_merged_at", where: "(merged_at IS NOT NULL)" t.index ["merge_request_id"], name: "index_merge_request_metrics" - t.index ["merged_at", "id"], name: "index_merge_request_metrics_on_merged_at_and_id" + t.index ["merged_at"], name: "index_merge_request_metrics_on_merged_at" t.index ["merged_by_id"], name: "index_merge_request_metrics_on_merged_by_id" t.index ["pipeline_id"], name: "index_merge_request_metrics_on_pipeline_id" end @@ -2295,6 +2372,7 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.boolean "allow_maintainer_to_push" t.integer "state_id", limit: 2, default: 1, null: false t.string "rebase_jid" + t.binary "squash_commit_sha" t.index ["assignee_id"], name: "index_merge_requests_on_assignee_id" t.index ["author_id"], name: "index_merge_requests_on_author_id" t.index ["created_at"], name: "index_merge_requests_on_created_at" @@ -2317,6 +2395,7 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.index ["target_project_id", "iid"], name: "index_merge_requests_on_target_project_id_and_iid", unique: true t.index ["target_project_id", "iid"], name: "index_merge_requests_on_target_project_id_and_iid_opened", where: "((state)::text = 'opened'::text)" t.index ["target_project_id", "merge_commit_sha", "id"], name: "index_merge_requests_on_tp_id_and_merge_commit_sha_and_id" + t.index ["target_project_id", "target_branch"], name: "index_merge_requests_on_target_project_id_and_target_branch", where: "((state_id = 1) AND (merge_when_pipeline_succeeds = true))" t.index ["title"], name: "index_merge_requests_on_title" t.index ["title"], name: "index_merge_requests_on_title_trigram", opclass: :gin_trgm_ops, using: :gin t.index ["updated_by_id"], name: "index_merge_requests_on_updated_by_id", where: "(updated_by_id IS NOT NULL)" @@ -2613,6 +2692,26 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.index ["project_id", "token_encrypted"], name: "index_feature_flags_clients_on_project_id_and_token_encrypted", unique: true end + create_table "packages_conan_file_metadata", force: :cascade do |t| + t.bigint "package_file_id", null: false + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.string "recipe_revision", limit: 255, default: "0", null: false + t.string "package_revision", limit: 255 + t.string "conan_package_reference", limit: 255 + t.integer "conan_file_type", limit: 2, null: false + t.index ["package_file_id"], name: "index_packages_conan_file_metadata_on_package_file_id", unique: true + end + + create_table "packages_conan_metadata", force: :cascade do |t| + t.bigint "package_id", null: false + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.string "package_username", limit: 255, null: false + t.string "package_channel", limit: 255, null: false + t.index ["package_id"], name: "index_packages_conan_metadata_on_package_id", unique: true + end + create_table "packages_maven_metadata", force: :cascade do |t| t.bigint "package_id", null: false t.datetime_with_timezone "created_at", null: false @@ -2724,14 +2823,19 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.index ["user_id"], name: "index_personal_access_tokens_on_user_id" end + create_table "plan_limits", force: :cascade do |t| + t.bigint "plan_id", null: false + t.integer "ci_active_pipelines", default: 0, null: false + t.integer "ci_pipeline_size", default: 0, null: false + t.integer "ci_active_jobs", default: 0, null: false + t.index ["plan_id"], name: "index_plan_limits_on_plan_id", unique: true + end + create_table "plans", id: :serial, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "name" t.string "title" - t.integer "active_pipelines_limit" - t.integer "pipeline_size_limit" - t.integer "active_jobs_limit", default: 0 t.index ["name"], name: "index_plans_on_name" end @@ -3035,9 +3139,12 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.integer "max_pages_size" t.integer "max_artifacts_size" t.string "pull_mirror_branch_prefix", limit: 50 + t.boolean "remove_source_branch_after_merge" + t.date "marked_for_deletion_at" + t.integer "marked_for_deletion_by_user_id" t.index "lower((name)::text)", name: "index_projects_on_lower_name" t.index ["archived", "pending_delete", "merge_requests_require_code_owner_approval"], name: "projects_requiring_code_owner_approval", where: "((pending_delete = false) AND (archived = false) AND (merge_requests_require_code_owner_approval = true))" - t.index ["created_at"], name: "index_projects_on_created_at" + 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" @@ -3047,6 +3154,8 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do 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" + t.index ["marked_for_deletion_at"], name: "index_projects_on_marked_for_deletion_at", where: "(marked_for_deletion_at IS NOT NULL)" + 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"], name: "index_projects_on_name_trigram", opclass: :gin_trgm_ops, using: :gin @@ -3060,7 +3169,7 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) 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"], name: "index_projects_on_visibility_level" + t.index ["visibility_level", "created_at", "id"], name: "index_projects_on_visibility_level_and_created_at_and_id" end create_table "prometheus_alert_events", force: :cascade do |t| @@ -3677,6 +3786,8 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.boolean "time_format_in_24h" t.string "projects_sort", limit: 64 t.boolean "show_whitespace_in_diffs", default: true, null: false + t.boolean "sourcegraph_enabled" + t.boolean "setup_for_company" t.index ["user_id"], name: "index_user_preferences_on_user_id", unique: true end @@ -3816,6 +3927,13 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.index ["user_id", "project_id"], name: "index_users_ops_dashboard_projects_on_user_id_and_project_id", unique: true end + create_table "users_security_dashboard_projects", id: false, force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "project_id", null: false + t.index ["project_id", "user_id"], name: "users_security_dashboard_projects_unique_index", unique: true + t.index ["user_id"], name: "index_users_security_dashboard_projects_on_user_id" + end + create_table "users_star_projects", id: :serial, force: :cascade do |t| t.integer "project_id", null: false t.integer "user_id", null: false @@ -3838,7 +3956,7 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.datetime_with_timezone "created_at", null: false t.datetime_with_timezone "updated_at", null: false t.string "title", limit: 255, null: false - t.text "title_html", null: false + t.text "title_html" t.text "description" t.text "description_html" t.bigint "start_date_sourcing_milestone_id" @@ -3850,6 +3968,10 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.boolean "severity_overridden", default: false t.integer "confidence", limit: 2, null: false t.boolean "confidence_overridden", default: false + t.bigint "resolved_by_id" + t.datetime_with_timezone "resolved_at" + t.integer "report_type", limit: 2, null: false + t.integer "cached_markdown_version" t.index ["author_id"], name: "index_vulnerabilities_on_author_id" t.index ["closed_by_id"], name: "index_vulnerabilities_on_closed_by_id" t.index ["due_date_sourcing_milestone_id"], name: "index_vulnerabilities_on_due_date_sourcing_milestone_id" @@ -3857,6 +3979,7 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.index ["last_edited_by_id"], name: "index_vulnerabilities_on_last_edited_by_id" t.index ["milestone_id"], name: "index_vulnerabilities_on_milestone_id" t.index ["project_id"], name: "index_vulnerabilities_on_project_id" + t.index ["resolved_by_id"], name: "index_vulnerabilities_on_resolved_by_id" t.index ["start_date_sourcing_milestone_id"], name: "index_vulnerabilities_on_start_date_sourcing_milestone_id" t.index ["updated_by_id"], name: "index_vulnerabilities_on_updated_by_id" end @@ -3895,6 +4018,17 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.index ["project_id", "fingerprint"], name: "index_vulnerability_identifiers_on_project_id_and_fingerprint", unique: true end + create_table "vulnerability_issue_links", force: :cascade do |t| + t.bigint "vulnerability_id", null: false + t.bigint "issue_id", null: false + t.integer "link_type", limit: 2, default: 1, null: false + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.index ["issue_id"], name: "index_vulnerability_issue_links_on_issue_id" + t.index ["vulnerability_id", "issue_id"], name: "idx_vulnerability_issue_links_on_vulnerability_id_and_issue_id", unique: true + t.index ["vulnerability_id", "link_type"], name: "idx_vulnerability_issue_links_on_vulnerability_id_and_link_type", unique: true, where: "(link_type = 2)" + end + create_table "vulnerability_occurrence_identifiers", force: :cascade do |t| t.datetime_with_timezone "created_at", null: false t.datetime_with_timezone "updated_at", null: false @@ -3990,6 +4124,19 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do t.index ["type"], name: "index_web_hooks_on_type" end + create_table "zoom_meetings", force: :cascade do |t| + t.bigint "project_id", null: false + t.bigint "issue_id", null: false + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.integer "issue_status", limit: 2, default: 1, null: false + t.string "url", limit: 255 + t.index ["issue_id", "issue_status"], name: "index_zoom_meetings_on_issue_id_and_issue_status", unique: true, where: "(issue_status = 1)" + t.index ["issue_id"], name: "index_zoom_meetings_on_issue_id" + t.index ["issue_status"], name: "index_zoom_meetings_on_issue_status" + t.index ["project_id"], name: "index_zoom_meetings_on_project_id" + end + add_foreign_key "alerts_service_data", "services", on_delete: :cascade add_foreign_key "allowed_email_domains", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "analytics_cycle_analytics_group_stages", "labels", column: "end_event_label_id", on_delete: :cascade @@ -4080,6 +4227,8 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do add_foreign_key "ci_sources_pipelines", "projects", name: "fk_1e53c97c0a", on_delete: :cascade add_foreign_key "ci_stages", "ci_pipelines", column: "pipeline_id", name: "fk_fb57e6cc56", on_delete: :cascade add_foreign_key "ci_stages", "projects", name: "fk_2360681d1d", on_delete: :cascade + add_foreign_key "ci_subscriptions_projects", "projects", column: "downstream_project_id", on_delete: :cascade + add_foreign_key "ci_subscriptions_projects", "projects", column: "upstream_project_id", on_delete: :cascade add_foreign_key "ci_trigger_requests", "ci_triggers", column: "trigger_id", name: "fk_b8ec8b7245", on_delete: :cascade add_foreign_key "ci_triggers", "projects", name: "fk_e3e63f966e", on_delete: :cascade add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade @@ -4095,6 +4244,8 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do add_foreign_key "clusters", "projects", column: "management_project_id", name: "fk_f05c5e5a42", on_delete: :nullify add_foreign_key "clusters", "users", on_delete: :nullify add_foreign_key "clusters_applications_cert_managers", "clusters", on_delete: :cascade + add_foreign_key "clusters_applications_crossplane", "clusters", on_delete: :cascade + add_foreign_key "clusters_applications_elastic_stacks", "clusters", on_delete: :cascade add_foreign_key "clusters_applications_helm", "clusters", on_delete: :cascade add_foreign_key "clusters_applications_ingress", "clusters", on_delete: :cascade add_foreign_key "clusters_applications_jupyter", "clusters", on_delete: :cascade @@ -4111,6 +4262,8 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do add_foreign_key "dependency_proxy_blobs", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "dependency_proxy_group_settings", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "deploy_keys_projects", "projects", name: "fk_58a901ca7e", on_delete: :cascade + add_foreign_key "deployment_merge_requests", "deployments", on_delete: :cascade + add_foreign_key "deployment_merge_requests", "merge_requests", on_delete: :cascade add_foreign_key "deployments", "clusters", name: "fk_289bba3222", on_delete: :nullify add_foreign_key "deployments", "projects", name: "fk_b9a3851b82", on_delete: :cascade add_foreign_key "description_versions", "epics", on_delete: :cascade @@ -4121,7 +4274,7 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do add_foreign_key "design_management_designs_versions", "design_management_designs", column: "design_id", name: "fk_03c671965c", on_delete: :cascade add_foreign_key "design_management_designs_versions", "design_management_versions", column: "version_id", name: "fk_f4d25ba00c", on_delete: :cascade add_foreign_key "design_management_versions", "issues", on_delete: :cascade - add_foreign_key "design_management_versions", "users", name: "fk_ee16b939e5", on_delete: :nullify + add_foreign_key "design_management_versions", "users", column: "author_id", name: "fk_c1440b4896", on_delete: :nullify add_foreign_key "draft_notes", "merge_requests", on_delete: :cascade add_foreign_key "draft_notes", "users", column: "author_id", on_delete: :cascade add_foreign_key "elasticsearch_indexed_namespaces", "namespaces", on_delete: :cascade @@ -4130,7 +4283,9 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do add_foreign_key "epic_issues", "epics", on_delete: :cascade add_foreign_key "epic_issues", "issues", on_delete: :cascade add_foreign_key "epic_metrics", "epics", on_delete: :cascade + 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", "namespaces", column: "group_id", name: "fk_f081aa4489", on_delete: :cascade add_foreign_key "epics", "users", column: "assignee_id", name: "fk_dccd3f98fc", on_delete: :nullify @@ -4178,7 +4333,10 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do add_foreign_key "gpg_signatures", "projects", on_delete: :cascade add_foreign_key "grafana_integrations", "projects", on_delete: :cascade add_foreign_key "group_custom_attributes", "namespaces", column: "group_id", on_delete: :cascade + add_foreign_key "group_group_links", "namespaces", column: "shared_group_id", on_delete: :cascade + add_foreign_key "group_group_links", "namespaces", column: "shared_with_group_id", on_delete: :cascade 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 "index_statuses", "projects", name: "fk_74b2492545", on_delete: :cascade add_foreign_key "insights", "namespaces", on_delete: :cascade @@ -4264,6 +4422,8 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do add_foreign_key "operations_feature_flag_scopes", "operations_feature_flags", column: "feature_flag_id", on_delete: :cascade add_foreign_key "operations_feature_flags", "projects", on_delete: :cascade add_foreign_key "operations_feature_flags_clients", "projects", on_delete: :cascade + add_foreign_key "packages_conan_file_metadata", "packages_package_files", column: "package_file_id", on_delete: :cascade + add_foreign_key "packages_conan_metadata", "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_metadata", "packages_packages", column: "package_id", on_delete: :cascade @@ -4274,6 +4434,7 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do 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 "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 add_foreign_key "project_alerting_settings", "projects", on_delete: :cascade @@ -4301,6 +4462,7 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do add_foreign_key "project_statistics", "projects", on_delete: :cascade add_foreign_key "project_tracing_settings", "projects", on_delete: :cascade add_foreign_key "projects", "pool_repositories", name: "fk_6e5c14658a", on_delete: :nullify + add_foreign_key "projects", "users", column: "marked_for_deletion_by_user_id", name: "fk_25d8780d11", on_delete: :nullify add_foreign_key "prometheus_alert_events", "projects", on_delete: :cascade add_foreign_key "prometheus_alert_events", "prometheus_alerts", on_delete: :cascade add_foreign_key "prometheus_alerts", "environments", on_delete: :cascade @@ -4376,6 +4538,8 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do add_foreign_key "users", "namespaces", column: "managing_group_id", name: "fk_a4b8fefe3e", on_delete: :nullify add_foreign_key "users_ops_dashboard_projects", "projects", on_delete: :cascade add_foreign_key "users_ops_dashboard_projects", "users", on_delete: :cascade + add_foreign_key "users_security_dashboard_projects", "projects", on_delete: :cascade + add_foreign_key "users_security_dashboard_projects", "users", on_delete: :cascade add_foreign_key "users_star_projects", "projects", name: "fk_22cd27ddfc", on_delete: :cascade add_foreign_key "vulnerabilities", "epics", name: "fk_1d37cddf91", on_delete: :nullify add_foreign_key "vulnerabilities", "milestones", column: "due_date_sourcing_milestone_id", name: "fk_7c5bb22a22", on_delete: :nullify @@ -4385,6 +4549,7 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do add_foreign_key "vulnerabilities", "users", column: "author_id", name: "fk_b1de915a15", on_delete: :nullify add_foreign_key "vulnerabilities", "users", column: "closed_by_id", name: "fk_cf5c60acbf", on_delete: :nullify add_foreign_key "vulnerabilities", "users", column: "last_edited_by_id", name: "fk_1302949740", on_delete: :nullify + add_foreign_key "vulnerabilities", "users", column: "resolved_by_id", name: "fk_76bc5f5455", on_delete: :nullify add_foreign_key "vulnerabilities", "users", column: "updated_by_id", name: "fk_7ac31eacb9", on_delete: :nullify add_foreign_key "vulnerability_feedback", "ci_pipelines", column: "pipeline_id", on_delete: :nullify add_foreign_key "vulnerability_feedback", "issues", on_delete: :nullify @@ -4393,6 +4558,8 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do add_foreign_key "vulnerability_feedback", "users", column: "author_id", on_delete: :cascade add_foreign_key "vulnerability_feedback", "users", column: "comment_author_id", name: "fk_94f7c8a81e", on_delete: :nullify add_foreign_key "vulnerability_identifiers", "projects", on_delete: :cascade + add_foreign_key "vulnerability_issue_links", "issues", on_delete: :cascade + add_foreign_key "vulnerability_issue_links", "vulnerabilities", on_delete: :cascade add_foreign_key "vulnerability_occurrence_identifiers", "vulnerability_identifiers", column: "identifier_id", on_delete: :cascade add_foreign_key "vulnerability_occurrence_identifiers", "vulnerability_occurrences", column: "occurrence_id", on_delete: :cascade add_foreign_key "vulnerability_occurrence_pipelines", "ci_pipelines", column: "pipeline_id", on_delete: :cascade @@ -4404,4 +4571,6 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do add_foreign_key "vulnerability_scanners", "projects", on_delete: :cascade add_foreign_key "web_hook_logs", "web_hooks", on_delete: :cascade add_foreign_key "web_hooks", "projects", name: "fk_0c8ca6d9d1", on_delete: :cascade + add_foreign_key "zoom_meetings", "issues", on_delete: :cascade + add_foreign_key "zoom_meetings", "projects", on_delete: :cascade end diff --git a/doc/README.md b/doc/README.md index 61265f940042e0cb2e02f2bc9dd9824fb14b04e6..af573a3eb34e2c99bac1d49c6a0f2fe0fbc38a6a 100644 --- a/doc/README.md +++ b/doc/README.md @@ -119,8 +119,8 @@ The following documentation relates to the DevOps **Plan** stage: | [Related Issues](user/project/issues/related_issues.md) **(STARTER)** | Create a relationship between issues. | | [Roadmap](user/group/roadmap/index.md) **(ULTIMATE)** | Visualize epic timelines. | | [Service Desk](user/project/service_desk.md) **(PREMIUM)** | A simple way to allow people to create issues in your GitLab instance without needing their own user account. | -| [Time Tracking](workflow/time_tracking.md) | Track time spent on issues and merge requests. | -| [Todos](workflow/todos.md) | Keep track of work requiring attention with a chronological list displayed on a simple dashboard. | +| [Time Tracking](user/project/time_tracking.md) | Track time spent on issues and merge requests. | +| [Todos](user/todos.md) | Keep track of work requiring attention with a chronological list displayed on a simple dashboard. | <div align="right"> <a type="button" class="btn btn-default" href="#overview"> @@ -177,7 +177,7 @@ The following documentation relates to the DevOps **Create** stage: | [Protected branches](user/project/protected_branches.md) | Use protected branches. | | [Push rules](push_rules/push_rules.md) **(STARTER)** | Additional control over pushes to your projects. | | [Repositories](user/project/repository/index.md) | Manage source code repositories in GitLab's user interface. | -| [Repository mirroring](workflow/repository_mirroring.md) **(STARTER)** | Push to or pull from repositories outside of GitLab | +| [Repository mirroring](user/project/repository/repository_mirroring.md) **(STARTER)** | Push to or pull from repositories outside of GitLab | | [Start a merge request](user/project/repository/web_editor.md#tips) | Start merge request when committing via GitLab's user interface. | <div align="right"> @@ -188,13 +188,13 @@ The following documentation relates to the DevOps **Create** stage: #### Merge Requests -| Create Topics - Merge Requests | Description | -|:------------------------------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------| -| [Checking out merge requests locally](user/project/merge_requests/index.md#checkout-merge-requests-locally) | Tips for working with merge requests locally. | -| [Cherry-picking](user/project/merge_requests/cherry_pick_changes.md) | Use GitLab for cherry-picking changes. | -| [Merge request thread resolution](user/discussions/index.md#moving-a-single-thread-to-a-new-issue) | Resolve threads, move threads in a merge request to an issue, and only allow merge requests to be merged if all threads are resolved. | -| [Merge requests](user/project/merge_requests/index.md) | Merge request management. | -| [Work In Progress "WIP" merge requests](user/project/merge_requests/work_in_progress_merge_requests.md) | Prevent merges of work-in-progress merge requests. | +| Create Topics - Merge Requests | Description | +|:--------------------------------------------------------------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------| +| [Checking out merge requests locally](user/project/merge_requests/reviewing_and_managing_merge_requests.md#checkout-merge-requests-locally) | Tips for working with merge requests locally. | +| [Cherry-picking](user/project/merge_requests/cherry_pick_changes.md) | Use GitLab for cherry-picking changes. | +| [Merge request thread resolution](user/discussions/index.md#moving-a-single-thread-to-a-new-issue) | Resolve threads, move threads in a merge request to an issue, and only allow merge requests to be merged if all threads are resolved. | +| [Merge requests](user/project/merge_requests/index.md) | Merge request management. | +| [Work In Progress "WIP" merge requests](user/project/merge_requests/work_in_progress_merge_requests.md) | Prevent merges of work-in-progress merge requests. | <div align="right"> <a type="button" class="btn btn-default" href="#overview"> @@ -305,7 +305,7 @@ The following documentation relates to the DevOps **Configure** stage: | Configure Topics | Description | |:-----------------------------------------------------------------------------------------------------------------------------------|:--------------------------------------------------------------------------| | [Auto DevOps](topics/autodevops/index.md) | Automatically employ a complete DevOps lifecycle. | -| [Create Kubernetes clusters on GKE](user/project/clusters/index.md#add-new-gke-cluster) | Use Google Kubernetes Engine and GitLab. | +| [Create Kubernetes clusters](user/project/clusters/add_remove_clusters.md#add-new-cluster) | Use Kubernetes and GitLab. | | [Executable Runbooks](user/project/clusters/runbooks/index.md) | Documented procedures that explain how to carry out particular processes. | | [GitLab ChatOps](ci/chatops/README.md) | Interact with CI/CD jobs through chat services. | | [Installing Applications](user/project/clusters/index.md#installing-applications) | Deploy Helm, Ingress, and Prometheus on Kubernetes. | @@ -336,7 +336,8 @@ The following documentation relates to the DevOps **Monitor** stage: | [GitLab Prometheus](administration/monitoring/prometheus/index.md) **(CORE ONLY)** | Configure the bundled Prometheus to collect various metrics from your GitLab instance. | | [Health check](user/admin_area/monitoring/health_check.md) | GitLab provides liveness and readiness probes to indicate service health and reachability to required services. | | [Prometheus project integration](user/project/integrations/prometheus.md) | Configure the Prometheus integration per project and monitor your CI/CD environments. | -| [Prometheus metrics](user/project/integrations/prometheus_library/index.md) | Let Prometheus collect metrics from various services, like Kubernetes, NGINX, NGINX ingress controller, HAProxy, and Amazon Cloud Watch. | +| [Prometheus metrics](user/project/integrations/prometheus_library/index.md) | Let Prometheus collect metrics from various services, like Kubernetes, NGINX, NGINX Ingress controller, HAProxy, and Amazon Cloud Watch. | +| [Incident management](user/incident_management/index.md) | Use GitLab to help you better respond to incidents that may occur in your systems. | <div align="right"> <a type="button" class="btn btn-default" href="#overview"> @@ -375,7 +376,7 @@ We have the following documentation to rapidly uplift your GitLab knowledge: | Topic | Description | |:-----------------------------------------------------------------------------------------------------------------------|:---------------------------------------------------------------| | [GitLab basics guides](gitlab-basics/README.md) | Start working on the command line and with GitLab. | -| [GitLab Workflow](workflow/README.md) and [overview](https://about.gitlab.com/2016/10/25/gitlab-workflow-an-overview/) | Enhance your workflow with the best of GitLab Workflow. | +| [GitLab workflow overview](https://about.gitlab.com/2016/10/25/gitlab-workflow-an-overview/) | Enhance your workflow with the best of GitLab Workflow. | | [Get started with GitLab CI/CD](ci/quick_start/README.md) | Quickly implement GitLab CI/CD. | | [Auto DevOps](topics/autodevops/index.md) | Learn more about GitLab's Auto DevOps. | | [GitLab Markdown](user/markdown.md) | GitLab's advanced formatting system (GitLab Flavored Markdown) | @@ -393,7 +394,7 @@ Learn more about GitLab account management: | Topic | Description | |:-----------------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------| | [User account](user/profile/index.md) | Manage your account. | -| [Authentication](topics/authentication/index.md) | Account security with two-factor authentication, set up your ssh keys, and deploy keys for secure access to your projects. | +| [Authentication](topics/authentication/index.md) | Account security with two-factor authentication, set up your SSH keys, and deploy keys for secure access to your projects. | | [Profile settings](user/profile/index.md#profile-settings) | Manage your profile settings, two factor authentication, and more. | | [User permissions](user/permissions.md) | Learn what each role in a project can do. | @@ -411,7 +412,7 @@ Learn more about using Git, and using Git with GitLab: |:----------------------------------------------------------------------------|:---------------------------------------------------------------------------| | [Git](topics/git/index.md) | Getting started with Git, branching strategies, Git LFS, and advanced use. | | [Git cheatsheet](https://about.gitlab.com/images/press/git-cheat-sheet.pdf) | Download a PDF describing the most used Git operations. | -| [GitLab Flow](workflow/gitlab_flow.md) | Explore the best of Git with the GitLab Flow strategy. | +| [GitLab Flow](topics/gitlab_flow.md) | Explore the best of Git with the GitLab Flow strategy. | <div align="right"> <a type="button" class="btn btn-default" href="#overview"> @@ -426,7 +427,7 @@ If you are coming to GitLab from another platform, you'll find the following inf | Topic | Description | |:---------------------------------------------------------------|:---------------------------------------------------------------------------------------| | [Importing to GitLab](user/project/import/index.md) | Import your projects from GitHub, Bitbucket, GitLab.com, FogBugz, and SVN into GitLab. | -| [Migrating from SVN](workflow/importing/migrating_from_svn.md) | Convert a SVN repository to Git and GitLab. | +| [Migrating from SVN](user/project/import/svn.md) | Convert a SVN repository to Git and GitLab. | <div align="right"> <a type="button" class="btn btn-default" href="#overview"> diff --git a/doc/administration/audit_events.md b/doc/administration/audit_events.md index 61ea673071e397508593ef457ae2683c6fdf028e..ccb4ccbd5256778a709391257aada06d95ef3c35 100644 --- a/doc/administration/audit_events.md +++ b/doc/administration/audit_events.md @@ -59,6 +59,8 @@ From there, you can see the following actions: - 2FA enforcement/grace period changed - Roles allowed to create project changed +Group events can also be accessed via the [Group Audit Events API](../api/audit_events.md#group-audit-events-starter) + ### Project events **(STARTER)** NOTE: **Note:** @@ -107,6 +109,8 @@ the filter drop-down. You can further filter by specific group, project or user  +Instance events can also be accessed via the [Instance Audit Events API](../api/audit_events.md#instance-audit-events-premium-only) + ### Missing events Some events are not being tracked in Audit Events. Please see the following diff --git a/doc/administration/auth/ldap.md b/doc/administration/auth/ldap.md index e02ce1c0a212e291b785b676d48ea04cf68d1115..d449a5a72afb86ff6d9fea06daac8ea831109366 100644 --- a/doc/administration/auth/ldap.md +++ b/doc/administration/auth/ldap.md @@ -118,6 +118,7 @@ LDAP users must have an email address set, regardless of whether it is used to l ```ruby gitlab_rails['ldap_enabled'] = true +gitlab_rails['prevent_ldap_sign_in'] = false gitlab_rails['ldap_servers'] = YAML.load <<-EOS # remember to close this block with 'EOS' below ## ## 'main' is the GitLab 'provider ID' of this LDAP server @@ -357,6 +358,7 @@ production: # snip... ldap: enabled: false + prevent_ldap_sign_in: false servers: ## ## 'main' is the GitLab 'provider ID' of this LDAP server @@ -493,6 +495,38 @@ the configuration option `lowercase_usernames`. By default, this configuration o 1. [Restart GitLab](../restart_gitlab.md#installations-from-source) for the changes to take effect. +## Disable LDAP web sign in + +It can be be useful to prevent using LDAP credentials through the web UI when +an alternative such as SAML is preferred. This allows LDAP to be used for group +sync, while also allowing your SAML identity provider to handle additional +checks like custom 2FA. + +When LDAP web sign in is disabled, users will not see a **LDAP** tab on the sign in page. +This does not disable [using LDAP credentials for Git access](#git-password-authentication). + +**Omnibus configuration** + +1. Edit `/etc/gitlab/gitlab.rb`: + + ```ruby + gitlab_rails['prevent_ldap_sign_in'] = true + ``` + +1. [Reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect. + +**Source configuration** + +1. Edit `config/gitlab.yaml`: + + ```yaml + production: + ldap: + prevent_ldap_sign_in: true + ``` + +1. [Restart GitLab](../restart_gitlab.md#installations-from-source) for the changes to take effect. + ## Encryption ### TLS Server Authentication diff --git a/doc/administration/geo/disaster_recovery/background_verification.md b/doc/administration/geo/disaster_recovery/background_verification.md index 34c668b5fb5ba6d2dbbb7ea627c321c53b66a89b..5caf1d53a2cce871e558bca90627ec6ff48b5b13 100644 --- a/doc/administration/geo/disaster_recovery/background_verification.md +++ b/doc/administration/geo/disaster_recovery/background_verification.md @@ -11,9 +11,8 @@ calculated checksum. If the checksum of the data on the **primary** node matches data on the **secondary** node, the data transferred successfully. Following a planned failover, any corrupted data may be **lost**, depending on the extent of the corruption. -If verification fails on the **primary** node, this indicates that Geo is -successfully replicating a corrupted object; restore it from backup or remove it -it from the **primary** node to resolve the issue. +If verification fails on the **primary** node, this indicates Geo is replicating a corrupted object. +You can restore it from backup or remove it from the **primary** node to resolve the issue. If verification succeeds on the **primary** node but fails on the **secondary** node, this indicates that the object was corrupted during the replication process. diff --git a/doc/administration/geo/replication/configuration.md b/doc/administration/geo/replication/configuration.md index f09d9f20dab5e26def15a3dedec72b43dd72bdb7..44baab40153c0ce2f2ab4d6773b0c0f8743b26f5 100644 --- a/doc/administration/geo/replication/configuration.md +++ b/doc/administration/geo/replication/configuration.md @@ -187,14 +187,18 @@ keys must be manually replicated to the **secondary** node. 1. Visit the **primary** node's **Admin Area > Geo** (`/admin/geo/nodes`) in your browser. 1. Click the **New node** button. -1. Add the **secondary** node. Use the **exact** name you inputed for `gitlab_rails['geo_node_name']` as the Name and the full URL as the URL. **Do NOT** check the - **This is a primary node** checkbox. -  +1. Fill in **Name** with the `gitlab_rails['geo_node_name']` in + `/etc/gitlab/gitlab.rb`. These values must always match *exactly*, character + for character. +1. Fill in **URL** with the `external_url` in `/etc/gitlab/gitlab.rb`. These + values must always match, but it doesn't matter if one ends with a `/` and + the other doesn't. +1. **Do NOT** check the **This is a primary node** checkbox. 1. Optionally, choose which groups or storage shards should be replicated by the **secondary** node. Leave blank to replicate all. Read more in [selective synchronization](#selective-synchronization). -1. Click the **Add node** button. +1. Click the **Add node** button to add the **secondary** node. 1. SSH into your GitLab **secondary** server and restart the services: ```sh diff --git a/doc/administration/geo/replication/database.md b/doc/administration/geo/replication/database.md index fa1b0f0e1d702f466ecb17311ee1ac267c1381e8..f7da4e14e9d798d6f1dbae68519c7f692eab9721 100644 --- a/doc/administration/geo/replication/database.md +++ b/doc/administration/geo/replication/database.md @@ -425,6 +425,9 @@ data before running `pg_basebackup`. --host=<primary_node_ip> ``` + NOTE: **Note:** + Replication slot names must only contain lowercase letters, numbers, and the underscore character. + When prompted, enter the _plaintext_ password you set up for the `gitlab_replicator` user in the first step. @@ -454,7 +457,7 @@ The replication process is now complete. ## PgBouncer support (optional) -[PgBouncer](http://pgbouncer.github.io/) may be used with GitLab Geo to pool +[PgBouncer](https://www.pgbouncer.org/) may be used with GitLab Geo to pool PostgreSQL connections. We recommend using PgBouncer if you use GitLab in a high-availability configuration with a cluster of nodes supporting a Geo **primary** node and another cluster of nodes supporting a Geo **secondary** node. For more diff --git a/doc/administration/geo/replication/object_storage.md b/doc/administration/geo/replication/object_storage.md index a9087abcbd9507b129bec4f23b8dc9be52794267..3251a673e4eb5e057da256f2997f4ddc24de5d35 100644 --- a/doc/administration/geo/replication/object_storage.md +++ b/doc/administration/geo/replication/object_storage.md @@ -30,7 +30,7 @@ To enable GitLab replication, you must: checkbox. For LFS, follow the documentation to -[set up LFS object storage](../../../workflow/lfs/lfs_administration.md#storing-lfs-objects-in-remote-object-storage). +[set up LFS object storage](../../lfs/lfs_administration.md#storing-lfs-objects-in-remote-object-storage). For CI job artifacts, there is similar documentation to configure [jobs artifact object storage](../../job_artifacts.md#using-object-storage) diff --git a/doc/administration/geo/replication/troubleshooting.md b/doc/administration/geo/replication/troubleshooting.md index 4d64941411ad01064f4077cb785e366f50cc2ff6..d2fe02abbab4d76e165cf99deaae22127779675e 100644 --- a/doc/administration/geo/replication/troubleshooting.md +++ b/doc/administration/geo/replication/troubleshooting.md @@ -115,11 +115,19 @@ Any **secondary** nodes should point only to read-only instances. #### Can Geo detect the current node correctly? -Geo uses the defined node from the **Admin Area > Geo** screen, and tries to match -it with the value defined in the `/etc/gitlab/gitlab.rb` configuration file. -The relevant line looks like: `external_url "http://gitlab.example.com"`. +Geo finds the current machine's name in `/etc/gitlab/gitlab.rb` by first looking +for `gitlab_rails['geo_node_name']`. If it is not defined, then it defaults to +the external URL defined in e.g. `external_url "http://gitlab.example.com"`. To +get a machine's name, run: -To check if the node on the current machine is correctly detected type: +```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: ```sh sudo gitlab-rails runner "puts Gitlab::Geo.current_node.inspect" @@ -134,6 +142,106 @@ and expect something like: By running the command above, `primary` should be `true` when executed in the **primary** node, and `false` on any **secondary** node. +## Fixing errors found when running the Geo check rake task + +When running this rake task, you may see errors if the nodes are not properly configured: + +```sh +sudo gitlab-rake gitlab:geo:check +``` + +1. Rails did not provide a password when connecting to the database + + ```text + Checking Geo ... + + GitLab Geo is available ... Exception: fe_sendauth: no password supplied + GitLab Geo is enabled ... Exception: fe_sendauth: no password supplied + ... + Checking Geo ... Finished + ``` + + - Ensure that you have the `gitlab_rails['db_password']` set to the plain text-password used when creating the hash for `postgresql['sql_user_password']`. + +1. Rails is unable to connect to the database + + ```text + Checking Geo ... + + GitLab Geo is available ... Exception: FATAL: no pg_hba.conf entry for host "1.1.1.1", user "gitlab", database "gitlabhq_production", SSL on + FATAL: no pg_hba.conf entry for host "1.1.1.1", user "gitlab", database "gitlabhq_production", SSL off + GitLab Geo is enabled ... Exception: FATAL: no pg_hba.conf entry for host "1.1.1.1", user "gitlab", database "gitlabhq_production", SSL on + FATAL: no pg_hba.conf entry for host "1.1.1.1", user "gitlab", database "gitlabhq_production", SSL off + ... + Checking Geo ... Finished + ``` + + - Ensure that you have the IP address of the rails node included in `postgresql['md5_auth_cidr_addresses']`. + - Ensure that you have included the subnet mask on the IP address: `postgresql['md5_auth_cidr_addresses'] = ['1.1.1.1/32']`. + +1. Rails has supplied the incorrect password + + ```text + Checking Geo ... + GitLab Geo is available ... Exception: FATAL: password authentication failed for user "gitlab" + FATAL: password authentication failed for user "gitlab" + GitLab Geo is enabled ... Exception: FATAL: password authentication failed for user "gitlab" + FATAL: password authentication failed for user "gitlab" + ... + Checking Geo ... Finished + ``` + + - Verify the correct password is set for `gitlab_rails['db_password']` that was used when creating the hash in `postgresql['sql_user_password']` by running `gitlab-ctl pg-password-md5 gitlab` and entering the password. + +1. Check returns not a secondary node + + ```text + Checking Geo ... + + GitLab Geo is available ... yes + GitLab Geo is enabled ... yes + GitLab Geo secondary database is correctly configured ... not a secondary node + Database replication enabled? ... not a secondary node + ... + Checking Geo ... Finished + ``` + + - 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. + +1. Check returns Exception: PG::UndefinedTable: ERROR: relation "geo_nodes" does not exist + + ```text + Checking Geo ... + + GitLab Geo is available ... no + Try fixing it: + Upload a new license that includes the GitLab Geo feature + For more information see: + https://about.gitlab.com/features/gitlab-geo/ + GitLab Geo is enabled ... Exception: PG::UndefinedTable: ERROR: relation "geo_nodes" does not exist + LINE 8: WHERE a.attrelid = '"geo_nodes"'::regclass + ^ + : SELECT a.attname, format_type(a.atttypid, a.atttypmod), + pg_get_expr(d.adbin, d.adrelid), a.attnotnull, a.atttypid, a.atttypmod, + c.collname, col_description(a.attrelid, a.attnum) AS comment + FROM pg_attribute a + LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum + LEFT JOIN pg_type t ON a.atttypid = t.oid + LEFT JOIN pg_collation c ON a.attcollation = c.oid AND a.attcollation <> t.typcollation + WHERE a.attrelid = '"geo_nodes"'::regclass + AND a.attnum > 0 AND NOT a.attisdropped + ORDER BY a.attnum + ... + Checking Geo ... Finished + ``` + + When performing a Postgres major version (9 > 10) update this is expected. Follow: + + - [initiate-the-replication-process](https://docs.gitlab.com/ee/administration/geo/replication/database.html#step-3-initiate-the-replication-process) + - [Geo database has an outdated FDW remote schema](https://docs.gitlab.com/ee/administration/geo/replication/troubleshooting.html#geo-database-has-an-outdated-fdw-remote-schema-error) + ## Fixing replication errors The following sections outline troubleshooting steps for fixing replication @@ -258,7 +366,7 @@ to start again from scratch, there are a few steps that can help you: gitlab-ctl tail sidekiq ``` -1. Rename repository storage folders and create new ones +1. Rename repository storage folders and create new ones. If you are not concerned about possible orphaned directories and files, then you can simply skip this step. ```sh mv /var/opt/gitlab/git-data/repositories /var/opt/gitlab/git-data/repositories.old @@ -305,7 +413,9 @@ to start again from scratch, there are a few steps that can help you: 1. Reset the Tracking Database ```sh - gitlab-rake geo:db:reset + gitlab-rake geo:db:drop + gitlab-ctl reconfigure + gitlab-rake geo:db:setup ``` 1. Restart previously stopped services @@ -511,6 +621,20 @@ to [cleanup orphan artifact files](../../../raketasks/cleanup.md#remove-orphan-a On a Geo **secondary** node, this command will also clean up all Geo registry record related to the orphan files on disk. +## Fixing sign in errors + +### Message: The redirect URI included is not valid + +If you are able to log in to the **primary** node, but you receive this error +when attempting to log into a **secondary**, you should check that the Geo +node's URL matches its external URL. + +1. On the primary, visit **Admin Area > Geo**. +1. Find the affected **secondary** and click **Edit**. +1. Ensure the **URL** field matches the value found in `/etc/gitlab/gitlab.rb` + in `external_url "https://gitlab.example.com"` on the frontend server(s) of + the **secondary** node. + ## Fixing common errors This section documents common errors reported in the Admin UI and how to fix them. @@ -531,13 +655,6 @@ Geo cannot reuse an existing tracking database. It is safest to use a fresh secondary, or reset the whole secondary by following [Resetting Geo secondary node replication](#resetting-geo-secondary-node-replication). -If you are not concerned about possible orphaned directories and files, then you -can simply reset the existing tracking database with: - -```sh -sudo gitlab-rake geo:db:reset -``` - ### Geo node has a database that is writable which is an indication it is not configured for replication with the primary node This error refers to a problem with the database replica on a **secondary** node, diff --git a/doc/administration/geo/replication/using_a_geo_server.md b/doc/administration/geo/replication/using_a_geo_server.md index 55c7e78da9299299ba8236922298e1b1a2db0a41..37982f2756cc4bcf463e8eca425f627ccb2cac4a 100644 --- a/doc/administration/geo/replication/using_a_geo_server.md +++ b/doc/administration/geo/replication/using_a_geo_server.md @@ -10,8 +10,12 @@ Example of the output you will see when pushing to a **secondary** node: ```bash $ git push -> GitLab: You're pushing to a Geo secondary. -> GitLab: We'll help you by proxying this request to the primary: ssh://git@primary.geo/user/repo.git +remote: +remote: You're pushing to a Geo secondary. We'll help you by proxying this +remote: request to the primary: +remote: +remote: ssh://git@primary.geo/user/repo.git +remote: Everything up-to-date ``` diff --git a/doc/administration/geo/replication/version_specific_updates.md b/doc/administration/geo/replication/version_specific_updates.md index 6d550a49df47c94d124ae80dab4ccddf3f5210a8..5288cc6e186478169aae8b6a0b97284c38efd92d 100644 --- a/doc/administration/geo/replication/version_specific_updates.md +++ b/doc/administration/geo/replication/version_specific_updates.md @@ -4,6 +4,24 @@ Check this document if it includes instructions for the version you are updating These steps go together with the [general steps](updating_the_geo_nodes.md#general-update-steps) for updating Geo nodes. +## Updating to GitLab 12.2 + +GitLab 12.2 includes the following minor PostgreSQL updates: + +- To version `9.6.14` if you run PostgreSQL 9.6. +- To version `10.9` if you run PostgreSQL 10. + +This update will occur even if major PostgreSQL updates are disabled. + +Before [refreshing Foreign Data Wrapper during a Geo HA upgrade](https://docs.gitlab.com/omnibus/update/README.html#run-post-deployment-migrations-and-checks), +restart the Geo tracking database: + +```sh +sudo gitlab-ctl restart geo-postgresql +``` + +The restart avoids a version mismatch when PostgreSQL tries to load the FDW extension. + ## Updating to GitLab 12.1 By default, GitLab 12.1 will attempt to automatically update the diff --git a/doc/administration/git_annex.md b/doc/administration/git_annex.md new file mode 100644 index 0000000000000000000000000000000000000000..52d848efa2756ad83437cc66779276116836f012 --- /dev/null +++ b/doc/administration/git_annex.md @@ -0,0 +1,242 @@ +--- +disqus_identifier: 'https://docs.gitlab.com/ee/workflow/git_annex.html' +--- + +# Git annex + +> **Warning:** GitLab has [completely +removed][deprecate-annex-issue] in GitLab 9.0 (2017/03/22). +Read through the [migration guide from git-annex to Git LFS][guide]. + +The biggest limitation of Git, compared to some older centralized version +control systems, has been the maximum size of the repositories. + +The general recommendation is to not have Git repositories larger than 1GB to +preserve performance. Although GitLab has no limit (some repositories in GitLab +are over 50GB!), we subscribe to the advice to keep repositories as small as +you can. + +Not being able to version control large binaries is a big problem for many +larger organizations. +Videos, photos, audio, compiled binaries and many other types of files are too +large. As a workaround, people keep artwork-in-progress in a Dropbox folder and +only check in the final result. This results in using outdated files, not +having a complete history and increases the risk of losing work. + +This problem is solved in GitLab Enterprise Edition by integrating the +[git-annex] application. + +`git-annex` allows managing large binaries with Git without checking the +contents into Git. +You check-in only a symlink that contains the SHA-1 of the large binary. If you +need the large binary, you can sync it from the GitLab server over `rsync`, a +very fast file copying tool. + +## GitLab git-annex Configuration + +`git-annex` is disabled by default in GitLab. Below you will find the +configuration options required to enable it. + +### Requirements + +`git-annex` needs to be installed both on the server and the client side. + +For Debian-like systems (e.g., Debian, Ubuntu) this can be achieved by running: + +``` +sudo apt-get update && sudo apt-get install git-annex +``` + +For RedHat-like systems (e.g., CentOS, RHEL) this can be achieved by running: + +``` +sudo yum install epel-release && sudo yum install git-annex +``` + +### Configuration for Omnibus packages + +For Omnibus GitLab packages, only one configuration setting is needed. +The Omnibus package will internally set the correct options in all locations. + +1. In `/etc/gitlab/gitlab.rb` add the following line: + + ```ruby + gitlab_shell['git_annex_enabled'] = true + ``` + +1. Save the file and [reconfigure GitLab][] for the changes to take effect. + +### Configuration for installations from source + +There are 2 settings to enable git-annex on your GitLab server. + +One is located in `config/gitlab.yml` of the GitLab repository and the other +one is located in `config.yml` of GitLab Shell. + +1. In `config/gitlab.yml` add or edit the following lines: + + ```yaml + gitlab_shell: + git_annex_enabled: true + ``` + +1. In `config.yml` of GitLab Shell add or edit the following lines: + + ```yaml + git_annex_enabled: true + ``` + +1. Save the files and [restart GitLab][] for the changes to take effect. + +## Using GitLab git-annex + +> **Note:** +> Your Git remotes must be using the SSH protocol, not HTTP(S). + +Here is an example workflow of uploading a very large file and then checking it +into your Git repository: + +```bash +git clone git@example.com:group/project.git + +git annex init 'My Laptop' # initialize the annex project and give an optional description +cp ~/tmp/debian.iso ./ # copy a large file into the current directory +git annex add debian.iso # add the large file to git annex +git commit -am "Add Debian iso" # commit the file metadata +git annex sync --content # sync the Git repo and large file to the GitLab server +``` + +The output should look like this: + +``` +commit +On branch master +Your branch is ahead of 'origin/master' by 1 commit. + (use "git push" to publish your local commits) +nothing to commit, working tree clean +ok +pull origin +remote: Counting objects: 5, done. +remote: Compressing objects: 100% (4/4), done. +remote: Total 5 (delta 2), reused 0 (delta 0) +Unpacking objects: 100% (5/5), done. +From example.com:group/project + 497842b..5162f80 git-annex -> origin/git-annex +ok +(merging origin/git-annex into git-annex...) +(recording state in git...) +copy debian.iso (checking origin...) (to origin...) +SHA256E-s26214400--8092b3d482fb1b7a5cf28c43bc1425c8f2d380e86869c0686c49aa7b0f086ab2.iso + 26,214,400 100% 638.88kB/s 0:00:40 (xfr#1, to-chk=0/1) +ok +pull origin +ok +(recording state in git...) +push origin +Counting objects: 15, done. +Delta compression using up to 4 threads. +Compressing objects: 100% (13/13), done. +Writing objects: 100% (15/15), 1.64 KiB | 0 bytes/s, done. +Total 15 (delta 1), reused 0 (delta 0) +To example.com:group/project.git + * [new branch] git-annex -> synced/git-annex + * [new branch] master -> synced/master +ok +``` + +Your files can be found in the `master` branch, but you'll notice that there +are more branches created by the `annex sync` command. + +Git Annex will also create a new directory at `.git/annex/` and will record the +tracked files in the `.git/config` file. The files you assign to be tracked +with `git-annex` will not affect the existing `.git/config` records. The files +are turned into symbolic links that point to data in `.git/annex/objects/`. + +The `debian.iso` file in the example will contain the symbolic link: + +``` +.git/annex/objects/ZW/1k/SHA256E-s82701--6384039733b5035b559efd5a2e25a493ab6e09aabfd5162cc03f6f0ec238429d.png/SHA256E-s82701--6384039733b5035b559efd5a2e25a493ab6e09aabfd5162cc03f6f0ec238429d.iso +``` + +Use `git annex info` to retrieve the information about the local copy of your +repository. + +--- + +Downloading a single large file is also very simple: + +```bash +git clone git@gitlab.example.com:group/project.git + +git annex sync # sync Git branches but not the large file +git annex get debian.iso # download the large file +``` + +To download all files: + +```bash +git clone git@gitlab.example.com:group/project.git + +git annex sync --content # sync Git branches and download all the large files +``` + +By using `git-annex` without GitLab, anyone that can access the server can also +access the files of all projects, but GitLab Annex ensures that you can only +access files of projects you have access to (developer, maintainer, or owner role). + +## How it works + +Internally GitLab uses [GitLab Shell] to handle SSH access and this was a great +integration point for `git-annex`. +There is a setting in GitLab Shell so you can disable GitLab Annex support +if you want to. + +## Troubleshooting tips + +Differences in version of `git-annex` on the GitLab server and on local machines +can cause `git-annex` to raise unpredicted warnings and errors. + +Consult the [Annex upgrade page][annex-upgrade] for more information about +the differences between versions. You can find out which version is installed +on your server by navigating to <https://pkgs.org/download/git-annex> and +searching for your distribution. + +Although there is no general guide for `git-annex` errors, there are a few tips +on how to go around the warnings. + +### `git-annex-shell: Not a git-annex or gcrypt repository` + +This warning can appear on the initial `git annex sync --content` and is caused +by differences in `git-annex-shell`. You can read more about it +[in this git-annex issue][issue]. + +One important thing to note is that despite the warning, the `sync` succeeds +and the files are pushed to the GitLab repository. + +If you get hit by this, you can run the following command inside the repository +that the warning was raised: + +``` +git config remote.origin.annex-ignore false +``` + +Consecutive runs of `git annex sync --content` **should not** produce this +warning and the output should look like this: + +``` +commit ok +pull origin +ok +pull origin +ok +push origin +``` + +[annex-upgrade]: https://git-annex.branchable.com/upgrades/ +[deprecate-annex-issue]: https://gitlab.com/gitlab-org/gitlab/issues/1648 +[git-annex]: https://git-annex.branchable.com/ "git-annex website" +[gitlab shell]: https://gitlab.com/gitlab-org/gitlab-shell "GitLab Shell repository" +[guide]: lfs/migrate_from_git_annex_to_git_lfs.html +[issue]: https://git-annex.branchable.com/forum/Error_from_git-annex-shell_on_creation_of_gcrypt_special_remote/ "git-annex issue" +[reconfigure GitLab]: restart_gitlab.md#omnibus-gitlab-reconfigure +[restart GitLab]: restart_gitlab.md#installations-from-source diff --git a/doc/administration/gitaly/index.md b/doc/administration/gitaly/index.md index d5749427f6e37f9526eaa71a6368dee109520db6..822836500708ce4c29ec4a06c567c238b8c6d10f 100644 --- a/doc/administration/gitaly/index.md +++ b/doc/administration/gitaly/index.md @@ -89,6 +89,10 @@ your GitLab installation has three repository storages: `default`, `storage1` and `storage2`. You can use as little as just one server with one repository storage if desired. +Note: **Note:** The token referred to throughout the Gitaly documentation is +just an arbitrary password selected by the administrator. It is unrelated to +tokens created for the GitLab API or other similar web API tokens. + ### 1. Installation First install Gitaly on each Gitaly server using either @@ -142,11 +146,6 @@ the Gitaly server. The easiest way to accomplish this is to copy `/etc/gitlab/gi from an existing GitLab server to the Gitaly server. Without this shared secret, Git operations in GitLab will result in an API error. -NOTE: **Note:** -In most or all cases, the storage paths below end in `/repositories` which is -not the case with `path` in `git_data_dirs` of Omnibus GitLab installations. -Check the directory layout on your Gitaly server to be sure. - **For Omnibus GitLab** 1. Edit `/etc/gitlab/gitlab.rb`: @@ -193,24 +192,26 @@ Check the directory layout on your Gitaly server to be sure. On `gitaly1.internal`: ``` - gitaly['storage'] = [ - { 'name' => 'default' }, - { 'name' => 'storage1' }, - ] + git_data_dirs({ + 'default' => { + 'path' => '/var/opt/gitlab/git-data' + }, + 'storage1' => { + 'path' => '/mnt/gitlab/git-data' + }, + }) ``` On `gitaly2.internal`: ``` - gitaly['storage'] = [ - { 'name' => 'storage2' }, - ] + git_data_dirs({ + 'storage2' => { + 'path' => '/srv/gitlab/git-data' + }, + }) ``` - NOTE: **Note:** - In some cases, you'll have to set `path` for `gitaly['storage']` in the - format `'path' => '/mnt/gitlab/<storage name>/repositories'`. - 1. Save the file and [reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure). **For installations from source** @@ -222,6 +223,11 @@ Check the directory layout on your Gitaly server to be sure. [auth] token = 'abc123secret' + + [logging] + format = 'json' + level = 'info' + dir = '/var/log/gitaly' ``` 1. Append the following to `/home/git/gitaly/config.toml` for each respective server: @@ -231,9 +237,11 @@ Check the directory layout on your Gitaly server to be sure. ```toml [[storage]] name = 'default' + path = '/var/opt/gitlab/git-data/repositories' [[storage]] name = 'storage1' + path = '/mnt/gitlab/git-data/repositories' ``` On `gitaly2.internal`: @@ -241,12 +249,9 @@ Check the directory layout on your Gitaly server to be sure. ```toml [[storage]] name = 'storage2' + path = '/srv/gitlab/git-data/repositories' ``` - NOTE: **Note:** - In some cases, you'll have to set `path` for each `[[storage]]` in the - format `path = '/mnt/gitlab/<storage name>/repositories'`. - 1. Save the file and [restart GitLab](../restart_gitlab.md#installations-from-source). ### 4. Converting clients to use the Gitaly server @@ -327,6 +332,8 @@ When you tail the Gitaly logs on your Gitaly server you should see requests coming in. One sure way to trigger a Gitaly request is to clone a repository from your GitLab server over HTTP. +DANGER: **Danger:** If you have [custom server-side Git hooks](../custom_hooks.md#custom-server-side-git-hooks) configured, either per repository or globally, you must move these to the Gitaly node. If you have multiple Gitaly nodes, copy your custom hook(s) to all nodes. + ### Disabling the Gitaly service in a cluster environment If you are running Gitaly [as a remote @@ -404,11 +411,11 @@ To configure Gitaly with TLS: ``` 1. Save the file and [reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) on client node(s). -1. Create the `/etc/gitlab/ssl` directory and copy your key and certificate there: +1. On the Gitaly server, create the `/etc/gitlab/ssl` directory and copy your key and certificate there: ```sh sudo mkdir -p /etc/gitlab/ssl - sudo chmod 700 /etc/gitlab/ssl + sudo chmod 755 /etc/gitlab/ssl sudo cp key.pem cert.pem /etc/gitlab/ssl/ ``` @@ -550,8 +557,11 @@ a few things that you need to do: 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](../../workflow/lfs/lfs_administration.md#storing-lfs-objects-in-remote-object-storage). +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). NOTE: **Note:** One current feature of GitLab that still requires a shared directory (NFS) is diff --git a/doc/administration/gitaly/praefect.md b/doc/administration/gitaly/praefect.md index 9038675a28fc617e6ba3f154d876a0531e71fac3..83c9aa3f013cb44dbf5612ced5088ec75c2027d6 100644 --- a/doc/administration/gitaly/praefect.md +++ b/doc/administration/gitaly/praefect.md @@ -26,34 +26,42 @@ For this document, the following network topology is assumed: graph TB GitLab --> Gitaly; GitLab --> Praefect; - Praefect --> Preafect-Git-1; - Praefect --> Preafect-Git-2; - Praefect --> Preafect-Git-3; + Praefect --> Praefect-Gitaly-1; + Praefect --> Praefect-Gitaly-2; + Praefect --> Praefect-Gitaly-3; ``` Where `GitLab` is the collection of clients that can request Git operations. -`Gitaly` is a Gitaly server before using Praefect. The Praefect node has two +`Gitaly` is a Gitaly server before using Praefect. The Praefect node has three storage nodes attached. Praefect itself doesn't store data, but connects to -three Gitaly nodes, `Praefect-Git-1`, `Praefect-Git-2`, and `Praefect-Git-3`. -There should be no knowledge other than with Praefect about the existence of -the `Praefect-Git-X` nodes. +three Gitaly nodes, `Praefect-Gitaly-1`, `Praefect-Gitaly-2`, and `Praefect-Gitaly-3`. -### Setup +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. -In this setup guide, the Gitaly node will be added first, then Praefect, and -lastly we update the GitLab configuration. +Praefect will handle all Gitaly RPC requests to its child nodes. However, the child nodes +will still need to communicate with the GitLab server via its internal API for authentication +purposes. -#### Gitaly +### Setup -In their own machine, configure the Gitaly server as described in the -[gitaly documentation](index.md#3-gitaly-server-configuration). +In this setup guide we will start by configuring Praefect, then its child +Gitaly nodes, and lastly the GitLab server configuration. #### Praefect -Next, Praefect has to be enabled on its own node. Disable all other services, -and add each Gitaly node that will be connected to Praefect. In the example below, -the Gitaly nodes are named `praefect-git-X`. Note that one node is designated as -primary, by setting the primary to `true`: +On the Praefect node we disable all other services, including Gitaly. We list each +Gitaly node that will be connected to Praefect under `praefect['storage_nodes']`. + +In the example below, the Gitaly nodes are named `praefect-gitaly-N`. Note that one +node is designated as primary by setting the primary to `true`. + +`praefect['auth_token']` is the token used to authenticate with the GitLab server, +just like `gitaly['auth_token']` is used for a standard Gitaly server. + +The `token` field under each storage listed in `praefect['storage_nodes']` is used +to authenticate each child Gitaly node with Praefect. ```ruby # /etc/gitlab/gitlab.rb @@ -67,38 +75,111 @@ unicorn['enable'] = false sidekiq['enable'] = false gitlab_workhorse['enable'] = false gitaly['enable'] = false +``` + +##### Set up Praefect and its Gitaly nodes + +In the example below, the Gitaly nodes are named `praefect-git-X`. Note that one node is designated as +primary, by setting the primary to `true`: + +```ruby +# /etc/gitlab/gitlab.rb + +# Prevent database connections during 'gitlab-ctl reconfigure' +gitlab_rails['rake_cache_clear'] = false +gitlab_rails['auto_migrate'] = false + +praefect['enable'] = true + +# Make Praefect accept connections on all network interfaces. You must use +# firewalls to restrict access to this address/port. +praefect['listen_addr'] = '0.0.0.0:2305' # virtual_storage_name must match the same storage name given to praefect in git_data_dirs praefect['virtual_storage_name'] = 'praefect' -praefect['auth_token'] = 'super_secret_abc' -praefect['enable'] = true -praefect['storage_nodes'] = [ - { - 'storage' => 'praefect-git-1', - 'address' => 'tcp://praefect-git-1.internal', - 'token' => 'token1', + +# Authentication token to ensure only authorized servers can communicate with +# Praefect server +praefect['auth_token'] = 'praefect-token' +praefect['storage_nodes'] = { + 'praefect-gitaly-1' => { + 'address' => 'tcp://praefect-git-1.internal:8075', + 'token' => 'praefect-gitaly-token', 'primary' => true }, - { - 'storage' => 'praefect-git-2', - 'address' => 'tcp://praefect-git-2.internal', - 'token' => 'token2' + 'praefect-gitaly-2' => { + 'address' => 'tcp://praefect-git-2.internal:8075', + 'token' => 'praefect-gitaly-token' }, - { - 'storage' => 'praefect-git-3', - 'address' => 'tcp://praefect-git-3.internal', - 'token' => 'token3' + 'praefect-gitaly-3' => { + 'address' => 'tcp://praefect-git-3.internal:8075', + 'token' => 'praefect-gitaly-token' } -] +} ``` Save the file and [reconfigure Praefect](../restart_gitlab.md#omnibus-gitlab-reconfigure). +#### Gitaly + +Next we will configure each Gitaly server assigned to Praefect. Configuration for these +is the same as a normal standalone Gitaly server, except that we use storage names and +auth tokens from Praefect instead of GitLab. + +Below is an example configuration for `praefect-gitaly-1`, the only difference for the +other Gitaly nodes is the storage name under `git_data_dirs`. + +Note that `gitaly['auth_token']` matches the `token` value listed under `praefect['storage_nodes']` +on the Praefect node. + +```ruby +# /etc/gitlab/gitlab.rb + +# Avoid running unnecessary services on the Gitaly server +postgresql['enable'] = false +redis['enable'] = false +nginx['enable'] = false +prometheus['enable'] = false +unicorn['enable'] = false +sidekiq['enable'] = false +gitlab_workhorse['enable'] = false + +# Prevent database connections during 'gitlab-ctl reconfigure' +gitlab_rails['rake_cache_clear'] = false +gitlab_rails['auto_migrate'] = false + +# Configure the gitlab-shell API callback URL. Without this, `git push` will +# fail. This can be your 'front door' GitLab URL or an internal load +# balancer. +# Don't forget to copy `/etc/gitlab/gitlab-secrets.json` from web server to Gitaly server. +gitlab_rails['internal_api_url'] = 'https://gitlab.example.com' + +# Authentication token to ensure only authorized servers can communicate with +# Gitaly server +gitaly['auth_token'] = 'praefect-gitaly-token' + +# Make Gitaly accept connections on all network interfaces. You must use +# firewalls to restrict access to this address/port. +# Comment out following line if you only want to support TLS connections +gitaly['listen_addr'] = "0.0.0.0:8075" + +git_data_dirs({ + "praefect-gitaly-1" => { + "path" => "/var/opt/gitlab/git-data" + } +}) +``` + +Note that just as with a standard Gitaly server, `/etc/gitlab/gitlab-secrets.json` must +be copied from the GitLab server to the Gitaly node for authentication purposes. + +For more information on Gitaly server configuration, see our [gitaly documentation](index.md#3-gitaly-server-configuration). + #### GitLab When Praefect is running, it should be exposed as a storage to GitLab. This is done through setting the `git_data_dirs`. Assuming the default storage -configuration is used, there would be two storages available to GitLab: +is present, there should be two storages available to GitLab: ```ruby git_data_dirs({ @@ -109,6 +190,28 @@ git_data_dirs({ "gitaly_address" => "tcp://praefect.internal:2305" } }) + +gitlab_rails['gitaly_token'] = 'praefect-token' ``` +Note that the storage name used is the same as the `praefect['virtual_storage_name']` set +on the Praefect node. + +Also, the `gitlab_rails['gitaly_token']` matches the value of `praefect['auth_token']` +on Praefect. + Restart GitLab using `gitlab-ctl restart` on the GitLab node. + +### Testing Praefect + +To test Praefect, first set it as the default storage node for new projects +using **Admin Area > Settings > Repository > Repository storage**. Next, +create a new project and check the "Initialize repository with a README" box. + +If you receive a 503 error, check `/var/log/gitlab/gitlab-rails/production.log`. +A `GRPC::Unavailable (14:failed to connect to all addresses)` error indicates +that GitLab was unable to connect to Praefect. + +If the project is created but the README is not, then ensure that the +`/etc/gitlab/gitlab-secrets.json` file from the GitLab server has been copied +to the Gitaly servers. diff --git a/doc/administration/high_availability/README.md b/doc/administration/high_availability/README.md index 199944a160c7d3d42b5dd6ace4604702f1d1d673..7f0b4056acc80a9bee40a85e6b1fa3eb45a72324 100644 --- a/doc/administration/high_availability/README.md +++ b/doc/administration/high_availability/README.md @@ -38,14 +38,17 @@ The following components need to be considered for a scaled or highly-available environment. In many cases, components can be combined on the same nodes to reduce complexity. -- Unicorn/Workhorse - Web-requests (UI, API, Git over HTTP) +- GitLab application nodes (Unicorn / Puma, Workhorse) - Web-requests (UI, API, Git over HTTP) - Sidekiq - Asynchronous/Background jobs - PostgreSQL - Database - Consul - Database service discovery and health checks/failover - PgBouncer - Database pool manager - Redis - Key/Value store (User sessions, cache, queue for Sidekiq) - Sentinel - Redis health check/failover manager -- Gitaly - Provides high-level RPC access to Git repositories +- Gitaly - Provides high-level storage and RPC access to Git repositories +- S3 Object Storage service[^3] and / or NFS storage servers[^4] for entities such as Uploads, Artifacts, LFS Objects, etc... +- Load Balancer[^2] - Main entry point and handles load balancing for the GitLab application nodes. +- Monitor - Prometheus and Grafana monitoring with auto discovery. ## Scalable Architecture Examples @@ -67,8 +70,10 @@ larger one. - 1 PostgreSQL node - 1 Redis node -- 1 NFS/Gitaly storage server -- 2 or more GitLab application nodes (Unicorn, Workhorse, Sidekiq) +- 1 Gitaly node +- 1 or more Object Storage services[^3] and / or NFS storage server[^4] +- 2 or more GitLab application nodes (Unicorn / Puma, Workhorse, Sidekiq) +- 1 or more Load Balancer nodes[^2] - 1 Monitoring node (Prometheus, Grafana) #### Installation Instructions @@ -77,10 +82,12 @@ Complete the following installation steps in order. A link at the end of each section will bring you back to the Scalable Architecture Examples section so you can continue with the next step. -1. [PostgreSQL](database.md#postgresql-in-a-scaled-environment) +1. [PostgreSQL](database.md#postgresql-in-a-scaled-environment) with [PGBouncer](https://docs.gitlab.com/ee/administration/high_availability/pgbouncer.html) 1. [Redis](redis.md#redis-in-a-scaled-environment) -1. [Gitaly](gitaly.md) (recommended) or [NFS](nfs.md) +1. [Gitaly](gitaly.md) (recommended) and / or [NFS](nfs.md)[^4] 1. [GitLab application nodes](gitlab.md) + - With [Object Storage service enabled](../gitaly/index.md#eliminating-nfs-altogether)[^3] +1. [Load Balancer(s)](load_balancer.md)[^2] 1. [Monitoring node (Prometheus and Grafana)](monitoring_node.md) ### Full Scaling @@ -91,11 +98,13 @@ is split into separate Sidekiq and Unicorn/Workhorse nodes. One indication that this architecture is required is if Sidekiq queues begin to periodically increase in size, indicating that there is contention or there are not enough resources. -- 1 PostgreSQL node -- 1 Redis node -- 2 or more NFS/Gitaly storage servers +- 1 or more PostgreSQL nodes +- 1 or more Redis nodes +- 1 or more Gitaly storage servers +- 1 or more Object Storage services[^3] and / or NFS storage server[^4] - 2 or more Sidekiq nodes -- 2 or more GitLab application nodes (Unicorn, Workhorse) +- 2 or more GitLab application nodes (Unicorn / Puma, Workhorse, Sidekiq) +- 1 or more Load Balancer nodes[^2] - 1 Monitoring node (Prometheus, Grafana) ## High Availability Architecture Examples @@ -114,10 +123,10 @@ This may lead to the other nodes believing a failure has occurred and initiating automated failover. Isolating Redis and Consul from the services they monitor reduces the chances of a false positive that a failure has occurred. -The examples below do not really address high availability of NFS. Some enterprises -have access to NFS appliances that manage availability. This is the best case -scenario. In the future, GitLab may offer a more user-friendly solution to -[GitLab HA Storage](https://gitlab.com/gitlab-org/omnibus-gitlab/issues/2472). +The examples below do not address high availability of NFS for objects. We recommend a +S3 Object Storage service[^3] is used where possible over NFS but it's still required in +certain cases[^4]. Where NFS is to be used some enterprises have access to NFS appliances +that manage availability and this would be best case scenario. There are many options in between each of these examples. Work with GitLab Support to understand the best starting point for your workload and adapt from there. @@ -138,8 +147,10 @@ the contention. - 3 PostgreSQL nodes - 2 Redis nodes - 3 Consul/Sentinel nodes -- 2 or more GitLab application nodes (Unicorn, Workhorse, Sidekiq, PgBouncer) -- 1 NFS/Gitaly server +- 2 or more GitLab application nodes (Unicorn / Puma, Workhorse, Sidekiq) +- 1 Gitaly storage servers +- 1 Object Storage service[^3] and / or NFS storage server[^4] +- 1 or more Load Balancer nodes[^2] - 1 Monitoring node (Prometheus, Grafana)  @@ -156,8 +167,10 @@ contention due to certain workloads. - 2 Redis nodes - 3 Consul/Sentinel nodes - 2 or more Sidekiq nodes -- 2 or more GitLab application nodes (Unicorn, Workhorse) -- 1 or more NFS/Gitaly servers +- 2 or more GitLab application nodes (Unicorn / Puma, Workhorse, Sidekiq) +- 1 Gitaly storage servers +- 1 Object Storage service[^3] and / or NFS storage server[^4] +- 1 or more Load Balancer nodes[^2] - 1 Monitoring node (Prometheus, Grafana)  @@ -169,6 +182,7 @@ the basis of the GitLab.com architecture. While this scales well it also comes with the added complexity of many more nodes to configure, manage, and monitor. - 3 PostgreSQL nodes +- 1 or more PgBouncer nodes (with associated internal load balancers) - 4 or more Redis nodes (2 separate clusters for persistent and cache data) - 3 Consul nodes - 3 Sentinel nodes @@ -177,120 +191,116 @@ with the added complexity of many more nodes to configure, manage, and monitor. - 2 or more Git nodes (Git over SSH/Git over HTTP) - 2 or more API nodes (All requests to `/api`) - 2 or more Web nodes (All other web requests) -- 2 or more NFS/Gitaly servers +- 2 or more Gitaly storage servers +- 1 or more Object Storage services[^3] and / or NFS storage servers[^4] +- 1 or more Load Balancer nodes[^2] - 1 Monitoring node (Prometheus, Grafana)  -The following pages outline the steps necessary to configure each component -separately: +## Reference Architecture Examples -1. [Configure the database](database.md) -1. [Configure Redis](redis.md) - 1. [Configure Redis for GitLab source installations](redis_source.md) -1. [Configure NFS](nfs.md) - 1. [NFS Client and Host setup](nfs_host_client_setup.md) -1. [Configure the GitLab application servers](gitlab.md) -1. [Configure the load balancers](load_balancer.md) -1. [Monitoring node (Prometheus and Grafana)](monitoring_node.md) +The Support and Quality teams build, performance test, and validate Reference +Architectures that support set large numbers of users. The specifications below are a +representation of this work so far and may be adjusted in the future based on +additional testing and iteration. -## Reference Architecture Examples +The architectures have been tested with specific coded workloads. The throughputs +used for testing are calculated based on sample customer data. We test each endpoint +type with the following number of requests per second (RPS) per 1000 users: -These reference architecture examples rely on the general rule that approximately 2 requests per second (RPS) of load is generated for every 100 users. +- API: 20 RPS +- Web: 2 RPS +- Git: 2 RPS -The specifications here were performance tested against a specific coded -workload. Your exact needs may be more, depending on your workload. Your +Note that your exact needs may be more, depending on your workload. Your workload is influenced by factors such as - but not limited to - how active your users are, how much automation you use, mirroring, and repo/change size. ### 10,000 User Configuration - **Supported Users (approximate):** 10,000 -- **RPS:** 200 requests per second +- **Test RPS Rates:** API: 200 RPS, Web: 20 RPS, Git: 20 RPS - **Known Issues:** While validating the reference architecture, slow API endpoints were discovered. For details, see the related issues list in [this issue](https://gitlab.com/gitlab-org/gitlab-foss/issues/64335). -The Support and Quality teams built, performance tested, and validated an -environment that supports about 10,000 users. The specifications below are a -representation of the work so far. The specifications may be adjusted in the -future based on additional testing and iteration. - -| Service | Configuration | GCP type | -| ------------------------------|-------------------------|----------------| -| 3 GitLab Rails <br> - Puma workers on each node set to 90% of available CPUs with 16 threads | 32 vCPU, 28.8GB Memory | n1-highcpu-32 | -| 3 PostgreSQL | 4 vCPU, 15GB Memory | n1-standard-4 | -| 1 PgBouncer | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | -| X Gitaly[^1] <br> - Gitaly Ruby workers on each node set to 90% of available CPUs with 16 threads | 16 vCPU, 60GB Memory | n1-standard-16 | -| 3 Redis Cache + Sentinel <br> - Cache maxmemory set to 90% of available memory | 4 vCPU, 15GB Memory | n1-standard-4 | -| 3 Redis Persistent + Sentinel | 4 vCPU, 15GB Memory | n1-standard-4 | -| 4 Sidekiq | 4 vCPU, 15GB Memory | n1-standard-4 | -| 3 Consul | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | -| 1 NFS Server | 16 vCPU, 14.4GB Memory | n1-highcpu-16 | -| 1 Monitoring node | 4 CPU, 3.6GB Memory | n1-highcpu-4 | -| 1 Load Balancing node[^2] . | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | +| Service | Nodes | Configuration | GCP type | +| ----------------------------|-------|-----------------------|---------------| +| GitLab Rails <br> - Puma workers on each node set to 90% of available CPUs with 16 threads | 3 | 32 vCPU, 28.8GB Memory | n1-highcpu-32 | +| PostgreSQL | 3 | 4 vCPU, 15GB Memory | n1-standard-4 | +| PgBouncer | 3 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | +| Gitaly <br> - Gitaly Ruby workers on each node set to 20% of available CPUs | X[^1] . | 16 vCPU, 60GB Memory | n1-standard-16 | +| Redis Cache + Sentinel <br> - Cache maxmemory set to 90% of available memory | 3 | 4 vCPU, 15GB Memory | n1-standard-4 | +| Redis Persistent + Sentinel | 3 | 4 vCPU, 15GB Memory | n1-standard-4 | +| Sidekiq | 4 | 4 vCPU, 15GB Memory | n1-standard-4 | +| Consul | 3 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | +| NFS Server[^4] . | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | +| S3 Object Storage[^3] . | - | - | - | +| Monitoring node | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | +| External load balancing node[^2] . | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | +| Internal load balancing node[^2] . | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | + +NOTE: **Note:** Memory values are given directly by GCP machine sizes. On different cloud +vendors a best effort like for like can be used. ### 25,000 User Configuration - **Supported Users (approximate):** 25,000 -- **RPS:** 500 requests per second +- **Test RPS Rates:** API: 500 RPS, Web: 50 RPS, Git: 50 RPS - **Known Issues:** The slow API endpoints that were discovered during testing the 10,000 user architecture also affect the 25,000 user architecture. For details, see the related issues list in [this issue](https://gitlab.com/gitlab-org/gitlab-foss/issues/64335). -The GitLab Support and Quality teams built, performance tested, and validated an -environment that supports around 25,000 users. The specifications below are a -representation of the work so far. The specifications may be adjusted in the -future based on additional testing and iteration. - -NOTE: **Note:** The specifications here were performance tested against a -specific coded workload. Your exact needs may be more, depending on your -workload. Your workload is influenced by factors such as - but not limited to - -how active your users are, how much automation you use, mirroring, and -repo/change size. - -| Service | Configuration | GCP type | -| ------------------------------|-------------------------|----------------| -| 7 GitLab Rails <br> - Puma workers on each node set to 90% of available CPUs with 16 threads | 32 vCPU, 28.8GB Memory | n1-highcpu-32 | -| 3 PostgreSQL | 8 vCPU, 30GB Memory | n1-standard-8 | -| 1 PgBouncer | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | -| X Gitaly[^1] <br> - Gitaly Ruby workers on each node set to 90% of available CPUs with 16 threads | 32 vCPU, 120GB Memory | n1-standard-32 | -| 3 Redis Cache + Sentinel <br> - Cache maxmemory set to 90% of available memory | 4 vCPU, 15GB Memory | n1-standard-4 | -| 3 Redis Persistent + Sentinel | 4 vCPU, 15GB Memory | n1-standard-4 | -| 4 Sidekiq | 4 vCPU, 15GB Memory | n1-standard-4 | -| 3 Consul | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | -| 1 NFS Server | 16 vCPU, 14.4GB Memory | n1-highcpu-16 | -| 1 Monitoring node | 4 CPU, 3.6GB Memory | n1-highcpu-4 | -| 1 Load Balancing node[^2] . | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | +| Service | Nodes | Configuration | GCP type | +| ----------------------------|-------|-----------------------|---------------| +| GitLab Rails <br> - Puma workers on each node set to 90% of available CPUs with 16 threads | 7 | 32 vCPU, 28.8GB Memory | n1-highcpu-32 | +| PostgreSQL | 3 | 8 vCPU, 30GB Memory | n1-standard-8 | +| PgBouncer | 3 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | +| Gitaly <br> - Gitaly Ruby workers on each node set to 20% of available CPUs | X[^1] . | 32 vCPU, 120GB Memory | n1-standard-32 | +| Redis Cache + Sentinel <br> - Cache maxmemory set to 90% of available memory | 3 | 4 vCPU, 15GB Memory | n1-standard-4 | +| Redis Persistent + Sentinel | 3 | 4 vCPU, 15GB Memory | n1-standard-4 | +| Sidekiq | 4 | 4 vCPU, 15GB Memory | n1-standard-4 | +| Consul | 3 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | +| NFS Server[^4] . | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | +| S3 Object Storage[^3] . | - | - | - | +| Monitoring node | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | +| External load balancing node[^2] . | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | +| Internal load balancing node[^2] . | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | + +NOTE: **Note:** Memory values are given directly by GCP machine sizes. On different cloud +vendors a best effort like for like can be used. ### 50,000 User Configuration - **Supported Users (approximate):** 50,000 -- **RPS:** 1,000 requests per second +- **Test RPS Rates:** API: 1000 RPS, Web: 100 RPS, Git: 100 RPS - **Status:** Work-in-progress - **Related Issue:** See the [related issue](https://gitlab.com/gitlab-org/quality/performance/issues/66) for more information. -The Support and Quality teams are in the process of building and performance -testing an environment that will support around 50,000 users. The specifications -below are a very rough work-in-progress representation of the work so far. The -Quality team will be certifying this environment in late 2019. The -specifications may be adjusted prior to certification based on performance -testing. - -| Service | Configuration | GCP type | -| ------------------------------|-------------------------|----------------| -| 15 GitLab Rails <br> - Puma workers on each node set to 90% of available CPUs with 16 threads | 32 vCPU, 28.8GB Memory | n1-highcpu-32 | -| 3 PostgreSQL | 8 vCPU, 30GB Memory | n1-standard-8 | -| 1 PgBouncer | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | -| X Gitaly[^1] <br> - Gitaly Ruby workers on each node set to 90% of available CPUs with 16 threads | 64 vCPU, 240GB Memory | n1-standard-64 | -| 3 Redis Cache + Sentinel <br> - Cache maxmemory set to 90% of available memory | 4 vCPU, 15GB Memory | n1-standard-4 | -| 3 Redis Persistent + Sentinel | 4 vCPU, 15GB Memory | n1-standard-4 | -| 4 Sidekiq | 4 vCPU, 15GB Memory | n1-standard-4 | -| 3 Consul | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | -| 1 NFS Server | 16 vCPU, 14.4GB Memory | n1-highcpu-16 | -| 1 Monitoring node | 4 CPU, 3.6GB Memory | n1-highcpu-4 | -| 1 Load Balancing node[^2] . | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | +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. 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 | 15 | 32 vCPU, 28.8GB Memory | n1-highcpu-32 | +| PostgreSQL | 3 | 8 vCPU, 30GB Memory | n1-standard-8 | +| PgBouncer | 3 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | +| Gitaly <br> - Gitaly Ruby workers on each node set to 20% of available CPUs | X[^1] . | 64 vCPU, 240GB Memory | n1-standard-64 | +| Redis Cache + Sentinel <br> - Cache maxmemory set to 90% of available memory | 3 | 4 vCPU, 15GB Memory | n1-standard-4 | +| Redis Persistent + Sentinel | 3 | 4 vCPU, 15GB Memory | n1-standard-4 | +| Sidekiq | 4 | 4 vCPU, 15GB Memory | n1-standard-4 | +| Consul | 3 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | +| NFS Server[^4] . | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | +| S3 Object Storage[^3] . | - | - | - | +| Monitoring node | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | +| External load balancing node[^2] . | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | +| Internal load balancing node[^2] . | 1 | 8 vCPU, 7.2GB Memory | n1-highcpu-8 | + +NOTE: **Note:** Memory values are given directly by GCP machine sizes. On different cloud +vendors a best effort like for like can be used. [^1]: Gitaly node requirements are dependent on customer data. We recommend 2 nodes as an absolute minimum for performance at the 10,000 and 25,000 user @@ -298,5 +308,19 @@ testing. additional nodes should be considered in conjunction with a review of project counts and sizes. -[^2]: HAProxy is the only tested and recommended load balancer. Additional - options may be supported in the future. +[^2]: Our architectures have been tested and validated with [HAProxy](https://www.haproxy.org/) + as the load balancer. However other reputable load balancers with similar feature sets + should also work here but be aware these aren't validated. + +[^3]: For data objects such as LFS, Uploads, Artifacts, etc... We recommend a S3 Object Storage + where possible over NFS due to better performance and availability. Several types of objects + are supported for S3 storage - [Job artifacts](../job_artifacts.md#using-object-storage), + [LFS](../lfs/lfs_administration.md#storing-lfs-objects-in-remote-object-storage), + [Uploads](../uploads.md#using-object-storage-core-only), + [Merge Request Diffs](../merge_request_diffs.md#using-object-storage), + [Packages](../packages/index.md#using-object-storage) (Optional Feature), + [Dependency Proxy](../packages/dependency_proxy.md#using-object-storage) (Optional Feature). + +[^4]: NFS storage server is still required for [GitLab Pages](https://gitlab.com/gitlab-org/gitlab-pages/issues/196) + and optionally for CI Job Incremental Logging + ([can be switched to use Redis instead](https://docs.gitlab.com/ee/administration/job_logs.html#new-incremental-logging-architecture)). diff --git a/doc/administration/high_availability/consul.md b/doc/administration/high_availability/consul.md index b01419200cc2863658b7e7ce959fe5d47cb82bad..392b9b76c3189edd27fa02952e199db01709d2e6 100644 --- a/doc/administration/high_availability/consul.md +++ b/doc/administration/high_availability/consul.md @@ -102,6 +102,23 @@ To be safe, we recommend you only restart one server agent at a time to ensure t 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. +## Upgrades for bundled Consul + +Nodes running GitLab-bundled Consul should be: + +- Members of a healthy cluster prior to upgrading the GitLab Omnibus package. +- Upgraded one node at a time. + +NOTE: **NOTE:** +Running `curl http://127.0.0.1:8500/v1/health/state/critical` from any Consul node will identify existing health issues in the cluster. The command will return an empty array if the cluster is healthy. + +Consul clusters communicate using the raft protocol. If the current leader goes offline, there needs to be a leader election. A leader node must exist to facilitate synchronization across the cluster. If too many nodes go offline at the same time, the cluster will lose quorum and not elect a leader due to [broken consensus](https://www.consul.io/docs/internals/consensus.html). + +Consult the [troubleshooting section](#troubleshooting) if the cluster is not able to recover after the upgrade. The [outage recovery](#outage-recovery) may be of particular interest. + +NOTE: **NOTE:** +GitLab only uses Consul to store transient data that is easily regenerated. If the bundled Consul was not used by any process other than GitLab itself, then [rebuilding the cluster from scratch](#recreate-from-scratch) is fine. + ## Troubleshooting ### Consul server agents unable to communicate diff --git a/doc/administration/high_availability/database.md b/doc/administration/high_availability/database.md index a50cc0cbd03e7d3a7ac32b009c531322142dd98c..02684f575d49c4c48cbef64b8c014f2658e3c84a 100644 --- a/doc/administration/high_availability/database.md +++ b/doc/administration/high_availability/database.md @@ -135,7 +135,8 @@ The recommended configuration for a PostgreSQL HA requires: - `repmgrd` - A service to monitor, and handle failover in case of a failure - `Consul` agent - Used for service discovery, to alert other nodes when failover occurs - A minimum of three `Consul` server nodes -- A minimum of one `pgbouncer` service node +- A minimum of one `pgbouncer` service node, but it's recommended to have one per database node + - An internal load balancer (TCP) is required when there is more than one `pgbouncer` service node You also need to take into consideration the underlying network topology, making sure you have redundant connectivity between all Database and GitLab instances, @@ -155,13 +156,13 @@ Database nodes run two services with PostgreSQL: On failure, the old master node is automatically evicted from the cluster, and should be rejoined manually once recovered. - Consul. Monitors the status of each node in the database cluster and tracks its health in a service definition on the Consul cluster. -Alongside PgBouncer, there is a Consul agent that watches the status of the PostgreSQL service. If that status changes, Consul runs a script which updates the configuration and reloads PgBouncer +Alongside each PgBouncer, there is a Consul agent that watches the status of the PostgreSQL service. If that status changes, Consul runs a script which updates the configuration and reloads PgBouncer ##### Connection flow Each service in the package comes with a set of [default ports](https://docs.gitlab.com/omnibus/package-information/defaults.html#ports). You may need to make specific firewall rules for the connections listed below: -- Application servers connect to [PgBouncer default port](https://docs.gitlab.com/omnibus/package-information/defaults.html#pgbouncer) +- Application servers connect to either PgBouncer directly via its [default port](https://docs.gitlab.com/omnibus/package-information/defaults.html#pgbouncer) or via a configured Internal Load Balancer (TCP) that serves multiple PgBouncers. - PgBouncer connects to the primary database servers [PostgreSQL default port](https://docs.gitlab.com/omnibus/package-information/defaults.html#postgresql) - Repmgr connects to the database servers [PostgreSQL default port](https://docs.gitlab.com/omnibus/package-information/defaults.html#postgresql) - Postgres secondaries connect to the primary database servers [PostgreSQL default port](https://docs.gitlab.com/omnibus/package-information/defaults.html#postgresql) @@ -499,7 +500,7 @@ attributes set, but the following need to be set. # Disable PostgreSQL on the application node postgresql['enable'] = false - gitlab_rails['db_host'] = 'PGBOUNCER_NODE' + gitlab_rails['db_host'] = 'PGBOUNCER_NODE' or 'INTERNAL_LOAD_BALANCER' gitlab_rails['db_port'] = 6432 gitlab_rails['db_password'] = 'POSTGRESQL_USER_PASSWORD' gitlab_rails['auto_migrate'] = false @@ -533,7 +534,8 @@ Here we'll show you some fully expanded example configurations. ##### Example recommended setup -This example uses 3 Consul servers, 3 PostgreSQL servers, and 1 application node. +This example uses 3 Consul servers, 3 PgBouncer servers (with associated internal load balancer), +3 PostgreSQL servers, and 1 application node. We start with all servers on the same 10.6.0.0/16 private network range, they can connect to each freely other on those addresses. @@ -543,14 +545,16 @@ Here is a list and description of each machine and the assigned IP: - `10.6.0.11`: Consul 1 - `10.6.0.12`: Consul 2 - `10.6.0.13`: Consul 3 -- `10.6.0.21`: PostgreSQL master -- `10.6.0.22`: PostgreSQL secondary -- `10.6.0.23`: PostgreSQL secondary -- `10.6.0.31`: GitLab application - -All passwords are set to `toomanysecrets`, please do not use this password or derived hashes. +- `10.6.0.20`: Internal Load Balancer +- `10.6.0.21`: PgBouncer 1 +- `10.6.0.22`: PgBouncer 2 +- `10.6.0.23`: PgBouncer 3 +- `10.6.0.31`: PostgreSQL master +- `10.6.0.32`: PostgreSQL secondary +- `10.6.0.33`: PostgreSQL secondary +- `10.6.0.41`: GitLab application -The external_url for GitLab is `http://gitlab.example.com` +All passwords are set to `toomanysecrets`, please do not use this password or derived hashes and the external_url for GitLab is `http://gitlab.example.com`. Please note that after the initial configuration, if a failover occurs, the PostgresSQL master will change to one of the available secondaries until it is failed back. @@ -566,10 +570,45 @@ consul['configuration'] = { server: true, retry_join: %w(10.6.0.11 10.6.0.12 10.6.0.13) } +consul['monitoring_service_discovery'] = true +``` + +[Reconfigure Omnibus GitLab][reconfigure GitLab] for the changes to take effect. + +##### Example recommended setup for PgBouncer servers + +On each server edit `/etc/gitlab/gitlab.rb`: + +```ruby +# Disable all components except Pgbouncer and Consul agent +roles ['pgbouncer_role'] + +# Configure PgBouncer +pgbouncer['admin_users'] = %w(pgbouncer gitlab-consul) + +pgbouncer['users'] = { + 'gitlab-consul': { + password: '5e0e3263571e3704ad655076301d6ebe' + }, + 'pgbouncer': { + password: '771a8625958a529132abe6f1a4acb19c' + } +} + +consul['watchers'] = %w(postgresql) +consul['enable'] = true +consul['configuration'] = { + retry_join: %w(10.6.0.11 10.6.0.12 10.6.0.13) +} +consul['monitoring_service_discovery'] = true ``` [Reconfigure Omnibus GitLab][reconfigure GitLab] for the changes to take effect. +##### Internal load balancer setup + +An internal load balancer (TCP) is then required to be setup to serve each PgBouncer node (in this example on the IP of `10.6.0.20`). An example of how to do this can be found in the [PgBouncer Configure Internal Load Balancer](pgbouncer.md#configure-the-internal-load-balancer) section. + ##### Example recommended setup for PostgreSQL servers ###### Primary node @@ -589,9 +628,6 @@ postgresql['shared_preload_libraries'] = 'repmgr_funcs' # Disable automatic database migrations gitlab_rails['auto_migrate'] = false -# Configure the Consul agent -consul['services'] = %w(postgresql) - postgresql['pgbouncer_user_password'] = '771a8625958a529132abe6f1a4acb19c' postgresql['sql_user_password'] = '450409b85a0223a214b5fb1484f34d0f' postgresql['max_wal_senders'] = 4 @@ -599,9 +635,13 @@ postgresql['max_wal_senders'] = 4 postgresql['trust_auth_cidr_addresses'] = %w(10.6.0.0/16) repmgr['trust_auth_cidr_addresses'] = %w(10.6.0.0/16) +# Configure the Consul agent +consul['services'] = %w(postgresql) +consul['enable'] = true consul['configuration'] = { retry_join: %w(10.6.0.11 10.6.0.12 10.6.0.13) } +consul['monitoring_service_discovery'] = true ``` [Reconfigure Omnibus GitLab][reconfigure GitLab] for the changes to take effect. @@ -626,18 +666,15 @@ On the server edit `/etc/gitlab/gitlab.rb`: ```ruby external_url 'http://gitlab.example.com' -gitlab_rails['db_host'] = '127.0.0.1' +gitlab_rails['db_host'] = '10.6.0.20' # Internal Load Balancer for PgBouncer nodes gitlab_rails['db_port'] = 6432 gitlab_rails['db_password'] = 'toomanysecrets' gitlab_rails['auto_migrate'] = false postgresql['enable'] = false -pgbouncer['enable'] = true +pgbouncer['enable'] = false consul['enable'] = true -# Configure PgBouncer -pgbouncer['admin_users'] = %w(pgbouncer gitlab-consul) - # Configure Consul agent consul['watchers'] = %w(postgresql) @@ -661,7 +698,7 @@ consul['configuration'] = { After deploying the configuration follow these steps: -1. On `10.6.0.21`, our primary database +1. On `10.6.0.31`, our primary database Enable the `pg_trgm` extension @@ -673,7 +710,7 @@ After deploying the configuration follow these steps: CREATE EXTENSION pg_trgm; ``` -1. On `10.6.0.22`, our first standby database +1. On `10.6.0.32`, our first standby database Make this node a standby of the primary @@ -681,7 +718,7 @@ After deploying the configuration follow these steps: gitlab-ctl repmgr standby setup 10.6.0.21 ``` -1. On `10.6.0.23`, our second standby database +1. On `10.6.0.33`, our second standby database Make this node a standby of the primary @@ -689,7 +726,7 @@ After deploying the configuration follow these steps: gitlab-ctl repmgr standby setup 10.6.0.21 ``` -1. On `10.6.0.31`, our application server +1. On `10.6.0.41`, our application server Set `gitlab-consul` user's PgBouncer password to `toomanysecrets` @@ -705,7 +742,7 @@ After deploying the configuration follow these steps: #### Example minimal setup -This example uses 3 PostgreSQL servers, and 1 application node. +This example uses 3 PostgreSQL servers, and 1 application node (with PgBouncer setup alongside). It differs from the [recommended setup](#example-recommended-setup) by moving the Consul servers into the same servers we use for PostgreSQL. The trade-off is between reducing server counts, against the increased operational complexity of needing to deal with PostgreSQL [failover](#failover-procedure) and [restore](#restore-procedure) procedures in addition to [Consul outage recovery](consul.md#outage-recovery) on the same set of machines. diff --git a/doc/administration/high_availability/pgbouncer.md b/doc/administration/high_availability/pgbouncer.md index e7479ad1ecbe41db2f8d7f2b7900ac34426df119..09b33c3554a21bfd70fc2936652a4df21600359a 100644 --- a/doc/administration/high_availability/pgbouncer.md +++ b/doc/administration/high_availability/pgbouncer.md @@ -4,13 +4,9 @@ type: reference # Working with the bundle PgBouncer service -As part of its High Availability stack, GitLab Premium includes a bundled version of [PgBouncer](https://pgbouncer.github.io/) that can be managed through `/etc/gitlab/gitlab.rb`. +As part of its High Availability stack, GitLab Premium includes a bundled version of [PgBouncer](https://pgbouncer.github.io/) that can be managed through `/etc/gitlab/gitlab.rb`. PgBouncer is used to seamlessly migrate database connections between servers in a failover scenario. Additionally, it can be used in a non-HA setup to pool connections, speeding up response time while reducing resource usage. -In a High Availability setup, PgBouncer is used to seamlessly migrate database connections between servers in a failover scenario. - -Additionally, it can be used in a non-HA setup to pool connections, speeding up response time while reducing resource usage. - -It is recommended to run PgBouncer alongside the `gitlab-rails` service, or on its own dedicated node in a cluster. +In a HA setup, it's recommended to run a PgBouncer node separately for each database node with an internal load balancer (TCP) serving each accordingly. ## Operations @@ -18,7 +14,7 @@ It is recommended to run PgBouncer alongside the `gitlab-rails` service, or on i 1. Make sure you collect [`CONSUL_SERVER_NODES`](database.md#consul-information), [`CONSUL_PASSWORD_HASH`](database.md#consul-information), and [`PGBOUNCER_PASSWORD_HASH`](database.md#pgbouncer-information) before executing the next step. -1. Edit `/etc/gitlab/gitlab.rb` replacing values noted in the `# START user configuration` section: +1. One each node, edit the `/etc/gitlab/gitlab.rb` config file and replace values noted in the `# START user configuration` section as below: ```ruby # Disable all components except PgBouncer and Consul agent @@ -67,7 +63,7 @@ It is recommended to run PgBouncer alongside the `gitlab-rails` service, or on i #### PgBouncer Checkpoint -1. Ensure the node is talking to the current master: +1. Ensure each node is talking to the current master: ```sh gitlab-ctl pgb-console # You will be prompted for PGBOUNCER_PASSWORD @@ -100,6 +96,41 @@ It is recommended to run PgBouncer alongside the `gitlab-rails` service, or on i (2 rows) ``` +#### Configure the internal load balancer + +If you're running more than one PgBouncer node as recommended, then at this time you'll need to set up a TCP internal load balancer to serve each correctly. This can be done with any reputable TCP load balancer. + +As an example here's how you could do it with [HAProxy](https://www.haproxy.org/): + +``` +global + log /dev/log local0 + log localhost local1 notice + log stdout format raw local0 + +defaults + log global + default-server inter 10s fall 3 rise 2 + balance leastconn + +frontend internal-pgbouncer-tcp-in + bind *:6432 + mode tcp + option tcplog + + default_backend pgbouncer + +backend pgbouncer + mode tcp + option tcp-check + + server pgbouncer1 <ip>:6432 check + server pgbouncer2 <ip>:6432 check + server pgbouncer3 <ip>:6432 check +``` + +Refer to your preferred Load Balancer's documentation for further guidance. + ### Running PgBouncer as part of a non-HA GitLab installation 1. Generate PGBOUNCER_USER_PASSWORD_HASH with the command `gitlab-ctl pg-password-md5 pgbouncer` @@ -177,7 +208,7 @@ If you enable Monitoring, it must be enabled on **all** PgBouncer servers. #### Administrative console -As part of Omnibus GitLab, we provide a command `gitlab-ctl pgb-console` to automatically connect to the PgBouncer administrative console. Please see the [PgBouncer documentation](https://pgbouncer.github.io/usage.html#admin-console) for detailed instructions on how to interact with the console. +As part of Omnibus GitLab, we provide a command `gitlab-ctl pgb-console` to automatically connect to the PgBouncer administrative console. Please see the [PgBouncer documentation](https://www.pgbouncer.org/usage.html#admin-console) for detailed instructions on how to interact with the console. To start a session, run diff --git a/doc/administration/high_availability/redis.md b/doc/administration/high_availability/redis.md index ba4599e5bcd0e9e0fa1aae2959e42a89d0cb5a1d..72968cfed562b10fe47c31feb69736c800c9ab06 100644 --- a/doc/administration/high_availability/redis.md +++ b/doc/administration/high_availability/redis.md @@ -491,7 +491,7 @@ multiple machines with the Sentinel daemon. 1. **You can omit this step if the Sentinels will be hosted in the same node as the other Redis instances.** - [Download/install](https://about.gitlab.com/downloads-ee) the + [Download/install](https://about.gitlab.com/install/) the Omnibus GitLab Enterprise Edition package using **steps 1 and 2** from the GitLab downloads page. - Make sure you select the correct Omnibus package, with the same version diff --git a/doc/administration/incoming_email.md b/doc/administration/incoming_email.md index 88cf702cf0eb8c187335ca865f99fa55ae281079..a0360f1d252e9b1ec2efc84fa61ba71dedb4475c 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/index.md#create-new-merge-requests-by-email): +- [New merge request by email](../user/project/merge_requests/creating_merge_requests.md#create-new-merge-requests-by-email): 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/index.md#create-new-merge-requests-by-email)" +"[Create new merge request by email](../user/project/merge_requests/creating_merge_requests.md#create-new-merge-requests-by-email)" 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 f90b9b2c7d51e51584b9c18680f5eb11e6684248..bf21347fb9926b323d2c46e347f8f9724d9f90c7 100644 --- a/doc/administration/index.md +++ b/doc/administration/index.md @@ -43,7 +43,7 @@ Learn how to install, configure, update, and maintain your GitLab instance. ### Configuring GitLab -- [Adjust your instance's timezone](../workflow/timezone.md): Customize the default time zone of GitLab. +- [Adjust your instance's timezone](timezone.md): Customize the default time zone of GitLab. - [System hooks](../system_hooks/system_hooks.md): Notifications when users, projects and keys are changed. - [Security](../security/README.md): Learn what you can do to further secure your GitLab instance. - [Usage statistics, version check, and usage ping](../user/admin_area/settings/usage_statistics.md): Enable or disable information about your instance to be sent to GitLab, Inc. @@ -51,7 +51,7 @@ Learn how to install, configure, update, and maintain your GitLab instance. - [Polling](polling.md): Configure how often the GitLab UI polls for updates. - [GitLab Pages configuration](pages/index.md): Enable and configure GitLab Pages. - [GitLab Pages configuration for GitLab source installations](pages/source.md): Enable and configure GitLab Pages on [source installations](../install/installation.md#installation-from-source). -- [Uploads configuration](uploads.md): Configure GitLab uploads storage. +- [Uploads administration](uploads.md): Configure GitLab uploads storage. - [Environment variables](environment_variables.md): Supported environment variables that can be used to override their defaults values in order to configure GitLab. - [Plugins](plugins.md): With custom plugins, GitLab administrators can introduce custom integrations without modifying GitLab's source code. - [Enforcing Terms of Service](../user/admin_area/settings/terms.md) @@ -68,11 +68,10 @@ Learn how to install, configure, update, and maintain your GitLab instance. #### Customizing GitLab's appearance -- [Header logo](../customization/branded_page_and_email_header.md): Change the logo on all pages and email headers. -- [Favicon](../customization/favicon.md): Change the default favicon to your own logo. -- [Branded login page](../customization/branded_login_page.md): Customize the login page with your own logo, title, and description. -- [Welcome message](../customization/welcome_message.md): Add a custom welcome message to the sign-in page. -- ["New Project" page](../customization/new_project_page.md): Customize the text to be displayed on the page that opens whenever your users create a new project. +- [Header logo](../user/admin_area/appearance.md#navigation-bar): Change the logo on all pages and email headers. +- [Favicon](../user/admin_area/appearance.md#favicon): Change the default favicon to your own logo. +- [Branded login page](../user/admin_area/appearance.md#sign-in--sign-up-pages): Customize the login page with your own logo, title, and description. +- ["New Project" page](../user/admin_area/appearance.md#new-project-pages): Customize the text to be displayed on the page that opens whenever your users create a new project. - [Additional custom email text](../user/admin_area/settings/email.md#custom-additional-text-premium-only): Add additional custom text to emails sent from GitLab. **(PREMIUM ONLY)** ### Maintaining GitLab @@ -105,7 +104,7 @@ Learn how to install, configure, update, and maintain your GitLab instance. ## User settings and permissions - [Creating users](../user/profile/account/create_accounts.md): Create users manually or through authentication integrations. -- [Libravatar](../customization/libravatar.md): Use Libravatar instead of Gravatar for user avatars. +- [Libravatar](libravatar.md): Use Libravatar instead of Gravatar for user avatars. - [Sign-up restrictions](../user/admin_area/settings/sign_up_restrictions.md): block email addresses of specific domains, or whitelist only specific domains. - [Access restrictions](../user/admin_area/settings/visibility_and_access_controls.md#enabled-git-access-protocols): Define which Git access protocols can be used to talk to GitLab (SSH, HTTP, HTTPS). - [Authentication and Authorization](auth/README.md): Configure external authentication with LDAP, SAML, CAS and additional providers. @@ -120,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/index.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#create-new-merge-requests-by-email), 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. @@ -162,9 +161,10 @@ Learn how to install, configure, update, and maintain your GitLab instance. ## Git configuration options - [Custom Git hooks](custom_hooks.md): Custom Git hooks (on the filesystem) for when webhooks aren't enough. -- [Git LFS configuration](../workflow/lfs/lfs_administration.md): Learn how to configure LFS for GitLab. +- [Git LFS configuration](lfs/lfs_administration.md): Learn how to configure LFS for GitLab. - [Housekeeping](housekeeping.md): Keep your Git repositories tidy and fast. - [Configuring Git Protocol v2](git_protocol.md): Git protocol version 2 support. +- [Manage large files with `git-annex` (Deprecated)](git_annex.md) ## Monitoring GitLab diff --git a/doc/administration/integration/plantuml.md b/doc/administration/integration/plantuml.md index e595c640aac219db8ed6103e9d48b13725c1f4e1..23803b8254301d28a19a0db369945c4df6b61d2b 100644 --- a/doc/administration/integration/plantuml.md +++ b/doc/administration/integration/plantuml.md @@ -96,7 +96,7 @@ To enable the redirection, add the following line in `/etc/gitlab/gitlab.rb`: nginx['custom_gitlab_server_config'] = "location /-/plantuml/ { \n proxy_cache off; \n proxy_pass http://plantuml:8080/; \n}\n" # Built from source -nginx['custom_gitlab_server_config'] = "location /-/plantuml/ { \n proxy_cache off; \n proxy_pass http://127.0.0.1:8080/plantuml/; \n}\n" +nginx['custom_gitlab_server_config'] = "location /-/plantuml { \n rewrite ^/-/(plantuml.*) /$1 break;\n proxy_cache off; \n proxy_pass http://localhost:8080/plantuml; \n}\n" ``` To activate the changes, run the following command: diff --git a/doc/administration/job_logs.md b/doc/administration/job_logs.md index d6d56515ac64b55b1e9c39d6ecc766e3f872987f..6042786d1017e4bcb8f4638a44217abf5f6adf3d 100644 --- a/doc/administration/job_logs.md +++ b/doc/administration/job_logs.md @@ -1,8 +1,8 @@ # Job logs -> [Renamed from Job Traces to Job logs](https://gitlab.com/gitlab-org/gitlab/issues/29121) in 12.4. +> [Renamed from job traces to job logs](https://gitlab.com/gitlab-org/gitlab/issues/29121) in GitLab 12.5. -Job logs (traces) are sent by GitLab Runner while it's processing a job. You can see +Job logs are sent by GitLab Runner while it's processing a job. You can see logs in job pages, pipelines, email notifications, etc. ## Data flow @@ -33,9 +33,8 @@ To change the location where the job logs will be stored, follow the steps below gitlab_ci['builds_directory'] = '/mnt/to/gitlab-ci/builds' ``` -1. Save the file and [reconfigure GitLab][] for the changes to take effect. - ---- +1. Save the file and [reconfigure GitLab](restart_gitlab.md#omnibus-gitlab-reconfigure) for the + changes to take effect. **In installations from source:** @@ -48,10 +47,8 @@ To change the location where the job logs will be stored, follow the steps below builds_path: path/to/builds/ ``` -1. Save the file and [restart GitLab][] for the changes to take effect. - -[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" +1. Save the file and [restart GitLab](restart_gitlab.md#installations-from-source) for the changes + to take effect. ## Uploading logs to object storage @@ -69,8 +66,8 @@ job output in the UI will be empty. ## New incremental logging architecture -> [Introduced][ce-18169] in GitLab 10.4. -> [Announced as General availability][ce-46097] in GitLab 11.0. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/18169) in GitLab 10.4. +> - [Announced as generally available](https://gitlab.com/gitlab-org/gitlab-foss/issues/46097) in GitLab 11.0. NOTE: **Note:** This feature is off by default. See below for how to [enable or disable](#enabling-incremental-logging) it. @@ -83,7 +80,7 @@ The data flow is the same as described in the [data flow section](#data-flow) with one change: _the stored path of the first two phases is different_. This incremental 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. +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). The data are stored in the following Redis namespace: `Gitlab::Redis::SharedState`. @@ -163,7 +160,3 @@ instance. If the number of jobs is 1000, 128MB (128KB * 1000) is consumed. Also, it could pressure the database replication lag. `INSERT`s are generated to indicate that we have log chunk. `UPDATE`s with 128KB of data is issued once we receive multiple chunks. - -[ce-18169]: https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/18169 -[ce-21193]: https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/21193 -[ce-46097]: https://gitlab.com/gitlab-org/gitlab-foss/issues/46097 diff --git a/doc/workflow/lfs/images/git-annex-branches.png b/doc/administration/lfs/img/git-annex-branches.png similarity index 100% rename from doc/workflow/lfs/images/git-annex-branches.png rename to doc/administration/lfs/img/git-annex-branches.png diff --git a/doc/workflow/lfs/img/lfs-icon.png b/doc/administration/lfs/img/lfs-icon.png similarity index 100% rename from doc/workflow/lfs/img/lfs-icon.png rename to doc/administration/lfs/img/lfs-icon.png diff --git a/doc/administration/lfs/lfs_administration.md b/doc/administration/lfs/lfs_administration.md new file mode 100644 index 0000000000000000000000000000000000000000..f3b8029f487847c8a9168debb7c34b6a543fb32a --- /dev/null +++ b/doc/administration/lfs/lfs_administration.md @@ -0,0 +1,273 @@ +--- +disqus_identifier: 'https://docs.gitlab.com/ee/workflow/lfs/lfs_administration.html' +--- + +# GitLab Git LFS Administration + +Documentation on how to use Git LFS are under [Managing large binary files with Git LFS doc](manage_large_binaries_with_git_lfs.md). + +## Requirements + +- Git LFS is supported in GitLab starting with version 8.2. +- Support for object storage, such as AWS S3, was introduced in 10.0. +- Users need to install [Git LFS client](https://git-lfs.github.com) version 1.0.1 and up. + +## Configuration + +Git LFS objects can be large in size. By default, they are stored on the server +GitLab is installed on. + +There are various configuration options to help GitLab server administrators: + +- Enabling/disabling Git LFS support +- Changing the location of LFS object storage +- Setting up object storage supported by [Fog](http://fog.io/about/provider_documentation.html) + +### Configuration for Omnibus installations + +In `/etc/gitlab/gitlab.rb`: + +```ruby +# Change to true to enable lfs - enabled by default if not defined +gitlab_rails['lfs_enabled'] = false + +# Optionally, change the storage path location. Defaults to +# `#{gitlab_rails['shared_path']}/lfs-objects`. Which evaluates to +# `/var/opt/gitlab/gitlab-rails/shared/lfs-objects` by default. +gitlab_rails['lfs_storage_path'] = "/mnt/storage/lfs-objects" +``` + +### Configuration for installations from source + +In `config/gitlab.yml`: + +```yaml +# Change to true to enable lfs + lfs: + enabled: false + storage_path: /mnt/storage/lfs-objects +``` + +## Storing LFS objects in remote object storage + +> [Introduced][ee-2760] in [GitLab Premium][eep] 10.0. Brought to GitLab Core in 10.7. + +It is possible to store LFS objects in remote object storage which allows you +to offload local hard disk R/W operations, and free up disk space significantly. +GitLab is tightly integrated with `Fog`, so you can refer to its [documentation](http://fog.io/about/provider_documentation.html) +to check which storage services can be integrated with GitLab. +You can also use external object storage in a private local network. For example, +[MinIO](https://min.io/) is a standalone object storage service, is easy to set up, and works well with GitLab instances. + +GitLab provides two different options for the uploading mechanism: "Direct upload" and "Background upload". + +**Option 1. Direct upload** + +1. User pushes an lfs file to the GitLab instance +1. GitLab-workhorse uploads the file directly to the external object storage +1. GitLab-workhorse notifies GitLab-rails that the upload process is complete + +**Option 2. Background upload** + +1. User pushes an lfs file to the GitLab instance +1. GitLab-rails stores the file in the local file storage +1. GitLab-rails then uploads the file to the external object storage asynchronously + +The following general settings are supported. + +| Setting | Description | Default | +|---------|-------------|---------| +| `enabled` | Enable/disable object storage | `false` | +| `remote_directory` | The bucket name where LFS objects will be stored| | +| `direct_upload` | Set to true to enable direct upload of LFS without the need of local shared storage. Option may be removed once we decide to support only single storage for all files. | `false` | +| `background_upload` | Set to false to disable automatic upload. Option may be removed once upload is direct to S3 | `true` | +| `proxy_download` | Set to true to enable proxying all files served. Option allows to reduce egress traffic as this allows clients to download directly from remote storage instead of proxying all data | `false` | +| `connection` | Various connection options described below | | + +The `connection` settings match those provided by [Fog](https://github.com/fog). + +Here is a configuration example with S3. + +| Setting | Description | example | +|---------|-------------|---------| +| `provider` | The provider name | AWS | +| `aws_access_key_id` | AWS credentials, or compatible | `ABC123DEF456` | +| `aws_secret_access_key` | AWS credentials, or compatible | `ABC123DEF456ABC123DEF456ABC123DEF456` | +| `aws_signature_version` | AWS signature version to use. 2 or 4 are valid options. Digital Ocean Spaces and other providers may need 2. | 4 | +| `enable_signature_v4_streaming` | Set to true to enable HTTP chunked transfers with [AWS v4 signatures](https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html). Oracle Cloud S3 needs this to be false | true | +| `region` | AWS region | us-east-1 | +| `host` | S3 compatible host for when not using AWS, e.g. `localhost` or `storage.example.com` | s3.amazonaws.com | +| `endpoint` | Can be used when configuring an S3 compatible service such as [MinIO](https://min.io), by entering a URL such as `http://127.0.0.1:9000` | (optional) | +| `path_style` | Set to true to use `host/bucket_name/object` style paths instead of `bucket_name.host/object`. Leave as false for AWS S3 | false | +| `use_iam_profile` | Set to true to use IAM profile instead of access keys | false + +Here is a configuration example with GCS. + +| Setting | Description | example | +|---------|-------------|---------| +| `provider` | The provider name | `Google` | +| `google_project` | GCP project name | `gcp-project-12345` | +| `google_client_email` | The email address of the service account | `foo@gcp-project-12345.iam.gserviceaccount.com` | +| `google_json_key_location` | The json key path | `/path/to/gcp-project-12345-abcde.json` | + +NOTE: **Note:** +The service account must have permission to access the bucket. +[See more](https://cloud.google.com/storage/docs/authentication) + +Here is a configuration example with Rackspace Cloud Files. + +| Setting | Description | example | +|---------|-------------|---------| +| `provider` | The provider name | `Rackspace` | +| `rackspace_username` | The username of the Rackspace account with access to the container | `joe.smith` | +| `rackspace_api_key` | The API key of the Rackspace account with access to the container | `ABC123DEF456ABC123DEF456ABC123DE` | +| `rackspace_region` | The Rackspace storage region to use, a three letter code from the [list of service access endpoints](https://developer.rackspace.com/docs/cloud-files/v1/general-api-info/service-access/) | `iad` | +| `rackspace_temp_url_key` | The private key you have set in the Rackspace API for temporary URLs. Read more [here](https://developer.rackspace.com/docs/cloud-files/v1/use-cases/public-access-to-your-cloud-files-account/#tempurl) | `ABC123DEF456ABC123DEF456ABC123DE` | + +NOTE: **Note:** +Regardless of whether the container has public access enabled or disabled, Fog will +use the TempURL method to grant access to LFS objects. If you see errors in logs referencing +instantiating storage with a temp-url-key, ensure that you have set they key properly +on the Rackspace API and in `gitlab.rb`. You can verify the value of the key Rackspace +has set by sending a GET request with token header to the service access endpoint URL +and comparing the output of the returned headers. + +### Manual uploading to an object storage + +There are two ways to manually do the same thing as automatic uploading (described above). + +**Option 1: rake task** + +```sh +rake gitlab:lfs:migrate +``` + +**Option 2: rails console** + +```sh +$ sudo gitlab-rails console # Login to rails console + +> # Upload LFS files manually +> LfsObject.where(file_store: [nil, 1]).find_each do |lfs_object| +> lfs_object.file.migrate!(ObjectStorage::Store::REMOTE) if lfs_object.file.file.exists? +> end +``` + +### S3 for Omnibus installations + +On Omnibus installations, the settings are prefixed by `lfs_object_store_`: + +1. Edit `/etc/gitlab/gitlab.rb` and add the following lines by replacing with + the values you want: + + ```ruby + gitlab_rails['lfs_object_store_enabled'] = true + gitlab_rails['lfs_object_store_remote_directory'] = "lfs-objects" + gitlab_rails['lfs_object_store_connection'] = { + 'provider' => 'AWS', + 'region' => 'eu-central-1', + 'aws_access_key_id' => '1ABCD2EFGHI34JKLM567N', + 'aws_secret_access_key' => 'abcdefhijklmnopQRSTUVwxyz0123456789ABCDE', + # The below options configure an S3 compatible host instead of AWS + 'host' => 'localhost', + 'endpoint' => 'http://127.0.0.1:9000', + 'path_style' => true + } + ``` + +1. Save the file and [reconfigure GitLab]s for the changes to take effect. +1. Migrate any existing local LFS objects to the object storage: + + ```bash + gitlab-rake gitlab:lfs:migrate + ``` + + This will migrate existing LFS objects to object storage. New LFS objects + will be forwarded to object storage unless + `gitlab_rails['lfs_object_store_background_upload']` is set to false. + +### S3 for installations from source + +For source installations the settings are nested under `lfs:` and then +`object_store:`: + +1. Edit `/home/git/gitlab/config/gitlab.yml` and add or amend the following + lines: + + ```yaml + lfs: + enabled: true + object_store: + enabled: false + remote_directory: lfs-objects # Bucket name + connection: + provider: AWS + aws_access_key_id: 1ABCD2EFGHI34JKLM567N + aws_secret_access_key: abcdefhijklmnopQRSTUVwxyz0123456789ABCDE + region: eu-central-1 + # Use the following options to configure an AWS compatible host such as Minio + host: 'localhost' + endpoint: 'http://127.0.0.1:9000' + path_style: true + ``` + +1. Save the file and [restart GitLab][] for the changes to take effect. +1. Migrate any existing local LFS objects to the object storage: + + ```bash + sudo -u git -H bundle exec rake gitlab:lfs:migrate RAILS_ENV=production + ``` + + This will migrate existing LFS objects to object storage. New LFS objects + will be forwarded to object storage unless `background_upload` is set to + false. + +### Migrating back to local storage + +In order to migrate back to local storage: + +1. Set both `direct_upload` and `background_upload` to false under the LFS object storage settings. Don't forget to restart GitLab. +1. Run `rake gitlab:lfs:migrate_to_local` on your console. +1. Disable `object_storage` for LFS objects in `gitlab.rb`. Remember to restart GitLab afterwards. + +## Storage statistics + +You can see the total storage used for LFS objects on groups and projects +in the administration area, as well as through the [groups](../../api/groups.md) +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. +LFS files up to 6Gb can be uploaded without any extra steps, otherwise you need to use the following workaround. + +```shell +$ sudo gitlab-rails console # Login to rails console + +> # Set up timeouts. 20 minutes is enough to upload 30GB LFS files. +> # These settings are only in effect for the same session, i.e. they are not effective for sidekiq workers. +> ::Google::Apis::ClientOptions.default.open_timeout_sec = 1200 +> ::Google::Apis::ClientOptions.default.read_timeout_sec = 1200 +> ::Google::Apis::ClientOptions.default.send_timeout_sec = 1200 + +> # Upload LFS files manually. This process does not use sidekiq at all. +> LfsObject.where(file_store: [nil, 1]).find_each do |lfs_object| +> lfs_object.file.migrate!(ObjectStorage::Store::REMOTE) if lfs_object.file.file.exists? +> end +``` + +See more information in [!19581](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/19581) + +## Known limitations + +- Support for removing unreferenced LFS objects was added in 8.14 onwards. +- LFS authentications via SSH was added with GitLab 8.12. +- Only compatible with the Git LFS client versions 1.1.0 and up, or 1.0.2. +- The storage statistics currently count each LFS object multiple times for + every project linking to it. + +[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" +[eep]: https://about.gitlab.com/pricing/ "GitLab Premium" +[ee-2760]: https://gitlab.com/gitlab-org/gitlab/merge_requests/2760 diff --git a/doc/administration/lfs/manage_large_binaries_with_git_lfs.md b/doc/administration/lfs/manage_large_binaries_with_git_lfs.md new file mode 100644 index 0000000000000000000000000000000000000000..1fd3077ecb93da43753ba6b35325058b68deb474 --- /dev/null +++ b/doc/administration/lfs/manage_large_binaries_with_git_lfs.md @@ -0,0 +1,266 @@ +--- +disqus_identifier: 'https://docs.gitlab.com/ee/workflow/lfs/manage_large_binaries_with_git_lfs.html' +--- + +# Git LFS + +Managing large files such as audio, video and graphics files has always been one +of the shortcomings of Git. The general recommendation is to not have Git repositories +larger than 1GB to preserve performance. + + + +An LFS icon is shown on files tracked by Git LFS to denote if a file is stored +as a blob or as an LFS pointer. + +## How it works + +Git LFS client talks with the GitLab server over HTTPS. It uses HTTP Basic Authentication +to authorize client requests. Once the request is authorized, Git LFS client receives +instructions from where to fetch or where to push the large file. + +## GitLab server configuration + +Documentation for GitLab instance administrators is under [LFS administration doc](lfs_administration.md). + +## Requirements + +- Git LFS is supported in GitLab starting with version 8.2 +- Git LFS must be enabled under project settings +- [Git LFS client](https://git-lfs.github.com) version 1.0.1 and up + +## Known limitations + +- Git LFS v1 original API is not supported since it was deprecated early in LFS + development +- When SSH is set as a remote, Git LFS objects still go through HTTPS +- Any Git LFS request will ask for HTTPS credentials to be provided so a good Git + credentials store is recommended +- Git LFS always assumes HTTPS so if you have GitLab server on HTTP you will have + to add the URL to Git config manually (see [troubleshooting](#troubleshooting)) + +NOTE: **Note:** +With 8.12 GitLab added LFS support to SSH. The Git LFS communication +still goes over HTTP, but now the SSH client passes the correct credentials +to the Git LFS client, so no action is required by the user. + +## Using Git LFS + +Lets take a look at the workflow when you need to check large files into your Git +repository with Git LFS. For example, if you want to upload a very large file and +check it into your Git repository: + +```bash +git clone git@gitlab.example.com:group/project.git +git lfs install # initialize the Git LFS project +git lfs track "*.iso" # select the file extensions that you want to treat as large files +``` + +Once a certain file extension is marked for tracking as a LFS object you can use +Git as usual without having to redo the command to track a file with the same extension: + +```bash +cp ~/tmp/debian.iso ./ # copy a large file into the current directory +git add . # add the large file to the project +git commit -am "Added Debian iso" # commit the file meta data +git push origin master # sync the git repo and large file to the GitLab server +``` + +**Make sure** that `.gitattributes` is tracked by Git. Otherwise Git +LFS will not be working properly for people cloning the project: + +```bash +git add .gitattributes +``` + +Cloning the repository works the same as before. Git automatically detects the +LFS-tracked files and clones them via HTTP. If you performed the `git clone` +command with a SSH URL, you have to enter your GitLab credentials for HTTP +authentication. + +```bash +git clone git@gitlab.example.com:group/project.git +``` + +If you already cloned the repository and you want to get the latest LFS object +that are on the remote repository, eg. for a branch from origin: + +```bash +git lfs fetch origin master +``` + +### Migrate an existing repo to Git LFS + +Read the documentation on how to [migrate an existing Git repo with Git LFS](../../topics/git/migrate_to_git_lfs/index.md). + +## File Locking + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/35856) in GitLab 10.5. + +The first thing to do before using File Locking is to tell Git LFS which +kind of files are lockable. The following command will store PNG files +in LFS and flag them as lockable: + +```bash +git lfs track "*.png" --lockable +``` + +After executing the above command a file named `.gitattributes` will be +created or updated with the following content: + +```bash +*.png filter=lfs diff=lfs merge=lfs -text lockable +``` + +You can also register a file type as lockable without using LFS +(In order to be able to lock/unlock a file you need a remote server that implements the LFS File Locking API), +in order to do that you can edit the `.gitattributes` file manually: + +```bash +*.pdf lockable +``` + +After a file type has been registered as lockable, Git LFS will make +them readonly on the file system automatically. This means you will +need to lock the file before editing it. + +### Managing Locked Files + +Once you're ready to edit your file you need to lock it first: + +```bash +git lfs lock images/banner.png +Locked images/banner.png +``` + +This will register the file as locked in your name on the server: + +```bash +git lfs locks +images/banner.png joe ID:123 +``` + +Once you have pushed your changes, you can unlock the file so others can +also edit it: + +```bash +git lfs unlock images/banner.png +``` + +You can also unlock by id: + +```bash +git lfs unlock --id=123 +``` + +If for some reason you need to unlock a file that was not locked by you, +you can use the `--force` flag as long as you have a `maintainer` access on +the project: + +```bash +git lfs unlock --id=123 --force +``` + +## Troubleshooting + +### error: Repository or object not found + +There are a couple of reasons why this error can occur: + +- You don't have permissions to access certain LFS object + +Check if you have permissions to push to the project or fetch from the project. + +- Project is not allowed to access the LFS object + +LFS object you are trying to push to the project or fetch from the project is not +available to the project anymore. Probably the object was removed from the server. + +- Local Git repository is using deprecated LFS API + +### Invalid status for `<url>` : 501 + +Git LFS will log the failures into a log file. +To view this log file, while in project directory: + +```bash +git lfs logs last +``` + +If the status `error 501` is shown, it is because: + +- Git LFS is not enabled in project settings. Check your project settings and + enable Git LFS. + +- Git LFS support is not enabled on the GitLab server. Check with your GitLab + administrator why Git LFS is not enabled on the server. See + [LFS administration documentation](lfs_administration.md) for instructions + on how to enable LFS support. + +- Git LFS client version is not supported by GitLab server. Check your Git LFS + version with `git lfs version`. Check the Git config of the project for traces + of deprecated API with `git lfs -l`. If `batch = false` is set in the config, + remove the line and try to update your Git LFS client. Only version 1.0.1 and + newer are supported. + +### getsockopt: connection refused + +If you push a LFS object to a project and you receive an error similar to: +`Post <URL>/info/lfs/objects/batch: dial tcp IP: getsockopt: connection refused`, +the LFS client is trying to reach GitLab through HTTPS. However, your GitLab +instance is being served on HTTP. + +This behaviour is caused by Git LFS using HTTPS connections by default when a +`lfsurl` is not set in the Git config. + +To prevent this from happening, set the lfs url in project Git config: + +```bash +git config --add lfs.url "http://gitlab.example.com/group/project.git/info/lfs" +``` + +### Credentials are always required when pushing an object + +NOTE: **Note:** +With 8.12 GitLab added LFS support to SSH. The Git LFS communication +still goes over HTTP, but now the SSH client passes the correct credentials +to the Git LFS client, so no action is required by the user. + +Given that Git LFS uses HTTP Basic Authentication to authenticate the user pushing +the LFS object on every push for every object, user HTTPS credentials are required. + +By default, Git has support for remembering the credentials for each repository +you use. This is described in [Git credentials man pages](https://git-scm.com/docs/gitcredentials). + +For example, you can tell Git to remember the password for a period of time in +which you expect to push the objects: + +```bash +git config --global credential.helper 'cache --timeout=3600' +``` + +This will remember the credentials for an hour after which Git operations will +require re-authentication. + +If you are using OS X you can use `osxkeychain` to store and encrypt your credentials. +For Windows, you can use `wincred` or Microsoft's [Git Credential Manager for Windows](https://github.com/Microsoft/Git-Credential-Manager-for-Windows/releases). + +More details about various methods of storing the user credentials can be found +on [Git Credential Storage documentation](https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage). + +### LFS objects are missing on push + +GitLab checks files to detect LFS pointers on push. If LFS pointers are detected, GitLab tries to verify that those files already exist in LFS on GitLab. + +Verify that LFS in installed locally and consider a manual push with `git lfs push --all`. + +If you are storing LFS files outside of GitLab you can disable LFS on the project by setting `lfs_enabled: false` with the [projects API](../../api/projects.md#edit-project). + +### Hosting LFS objects externally + +It is possible to host LFS objects externally by setting a custom LFS url with `git config -f .lfsconfig lfs.url https://example.com/<project>.git/info/lfs`. + +You might choose to do this if you are using an appliance like a Sonatype Nexus to store LFS data. If you choose to use an external LFS store, +GitLab will not be able to verify LFS objects which means that pushes will fail if you have GitLab LFS support enabled. + +To stop push failure, LFS support can be disabled in the [Project settings](../../user/project/settings/index.md). This means you will lose GitLab LFS value-adds (Verifying LFS objects, UI integration for LFS). diff --git a/doc/administration/lfs/migrate_from_git_annex_to_git_lfs.md b/doc/administration/lfs/migrate_from_git_annex_to_git_lfs.md new file mode 100644 index 0000000000000000000000000000000000000000..cf798472d62eaffb5cfef31d62c7e670eff90764 --- /dev/null +++ b/doc/administration/lfs/migrate_from_git_annex_to_git_lfs.md @@ -0,0 +1,254 @@ +# Migration guide from Git Annex to Git LFS + +>**Note:** +Git Annex support [has been removed][issue-remove-annex] in GitLab Enterprise +Edition 9.0 (2017/03/22). + +Both [Git Annex][] and [Git LFS][] are tools to manage large files in Git. + +## History + +Git Annex [was introduced in GitLab Enterprise Edition 7.8][post-3], at a time +where Git LFS didn't yet exist. A few months later, GitLab brought support for +Git LFS in [GitLab 8.2][post-2] and is available for both Community and +Enterprise editions. + +## Differences between Git Annex and Git LFS + +Some items below are general differences between the two protocols and some are +ones that GitLab developed. + +- Git Annex works only through SSH, whereas Git LFS works both with SSH and HTTPS + (SSH support was added in GitLab 8.12). +- Annex files are stored in a sub-directory of the normal repositories, whereas + LFS files are stored outside of the repositories in a place you can define. +- Git Annex requires a more complex setup, but has much more options than Git + LFS. You can compare the commands each one offers by running `man git-annex` + and `man git-lfs`. +- Annex files cannot be browsed directly in GitLab's interface, whereas LFS + files can. + +## Migration steps + +>**Note:** +Since Git Annex files are stored in a sub-directory of the normal repositories +(`.git/annex/objects`) and LFS files are stored outside of the repositories, +they are not compatible as they are using a different scheme. Therefore, the +migration has to be done manually per repository. + +There are basically two steps you need to take in order to migrate from Git +Annex to Git LFS. + +### TL; DR + +If you know what you are doing and want to skip the reading, this is what you +need to do (we assume you have [git-annex enabled](../git_annex.md#using-gitlab-git-annex) in your +repository and that you have made backups in case something goes wrong). +Fire up a terminal, navigate to your Git repository and: + +1. Disable `git-annex`: + + ```bash + git annex sync --content + git annex direct + git annex uninit + git annex indirect + ``` + +1. Enable `git-lfs`: + + ``` + git lfs install + git lfs track <files> + git add . + git commit -m "commit message" + git push + ``` + +### Disabling Git Annex in your repo + +Before changing anything, make sure you have a backup of your repository first. +There are a couple of ways to do that, but you can simply clone it to another +local path and maybe push it to GitLab if you want a remote backup as well. +Here you'll find a guide on +[how to back up a **git-annex** repository to an external hard drive][bkp-ext-drive]. + +Since Annex files are stored as objects with symlinks and cannot be directly +modified, we need to first remove those symlinks. + +NOTE: **Note:** +Make sure the you read about the [`direct` mode][annex-direct] as it contains +useful information that may fit in your use case. Note that `annex direct` is +deprecated in Git Annex version 6, so you may need to upgrade your repository +if the server also has Git Annex 6 installed. Read more in the +[Git Annex troubleshooting tips](../git_annex.md#troubleshooting-tips) section. + +1. Backup your repository + + ```bash + cd repository + git annex sync --content + cd .. + git clone repository repository-backup + cd repository-backup + git annex get + cd .. + ``` + +1. Use `annex direct`: + + ```bash + cd repository + git annex direct + ``` + + The output should be similar to this: + + ```bash + commit + On branch master + Your branch is up-to-date with 'origin/master'. + nothing to commit, working tree clean + ok + direct debian.iso ok + direct ok + ``` + +1. Disable Git Annex with [`annex uninit`][uninit]: + + ```bash + git annex uninit + ``` + + The output should be similar to this: + + ```bash + unannex debian.iso ok + Deleted branch git-annex (was 2534d2c). + ``` + + This will `unannex` every file in the repository, leaving the original files. + +1. Switch back to `indirect` mode: + + ```bash + git annex indirect + ``` + + The output should be similar to this: + + ```bash + (merging origin/git-annex into git-annex...) + (recording state in git...) + commit (recording state in git...) + + ok + (recording state in git...) + [master fac3194] commit before switching to indirect mode + 1 file changed, 1 deletion(-) + delete mode 120000 alpine-virt-3.4.4-x86_64.iso + ok + indirect ok + ok + ``` + +--- + +At this point, you have two options. Either add, commit and push the files +directly back to GitLab or switch to Git LFS. We will tackle the LFS switch in +the next section. + +### Enabling Git LFS in your repo + +Git LFS is enabled by default on all GitLab products (GitLab CE, GitLab EE, +GitLab.com), therefore, you don't need to do anything server-side. + +1. First, make sure you have `git-lfs` installed locally: + + ```bash + git lfs help + ``` + + If the terminal doesn't prompt you with a full response on `git-lfs` commands, + [install the Git LFS client][install-lfs] first. + +1. Inside the repo, run the following command to initiate LFS: + + ```bash + git lfs install + ``` + +1. Enable `git-lfs` for the group of files you want to track. You + can track specific files, all files containing the same extension, or an + entire directory: + + ```bash + git lfs track images/01.png # per file + git lfs track **/*.png # per extension + git lfs track images/ # per directory + ``` + + Once you do that, run `git status` and you'll see `.gitattributes` added + to your repo. It collects all file patterns that you chose to track via + `git-lfs`. + +1. Add the files, commit and push them to GitLab: + + ```bash + git add . + git commit -m "commit message" + git push + ``` + + If your remote is set up with HTTP, you will be asked to enter your login + credentials. If you have [2FA enabled](../../user/profile/account/two_factor_authentication.md), make sure to use a + [personal access token](../../user/profile/account/two_factor_authentication.md#personal-access-tokens) + instead of your password. + +## Removing the Git Annex branches + +After the migration finishes successfully, you can remove all `git-annex` +related branches from your repository. + +On GitLab, navigate to your project's **Repository âž” Branches** and delete all +branches created by Git Annex: `git-annex`, and all under `synced/`. + + + +You can also do this on the command line with: + +```bash +git branch -d synced/master +git branch -d synced/git-annex +git push origin :synced/master +git push origin :synced/git-annex +git push origin :git-annex +git remote prune origin +``` + +If there are still some Annex objects inside your repository (`.git/annex/`) +or references inside `.git/config`, run `annex uninit` again: + +```bash +git annex uninit +``` + +## Further Reading + +- (Blog Post) [Getting Started with Git FLS][post-1] +- (Blog Post) [Announcing LFS Support in GitLab][post-2] +- (Blog Post) [GitLab Annex Solves the Problem of Versioning Large Binaries with Git][post-3] +- (GitLab Docs) [Git Annex](../git_annex.md) +- (GitLab Docs) [Git LFS](manage_large_binaries_with_git_lfs.md) + +[annex-direct]: https://git-annex.branchable.com/direct_mode/ +[bkp-ext-drive]: https://www.thomas-krenn.com/en/wiki/Git-annex_Repository_on_an_External_Hard_Drive +[Git Annex]: http://git-annex.branchable.com/ +[Git LFS]: https://git-lfs.github.com/ +[install-lfs]: https://git-lfs.github.com/ +[issue-remove-annex]: https://gitlab.com/gitlab-org/gitlab/issues/1648 +[lfs-track]: https://about.gitlab.com/blog/2017/01/30/getting-started-with-git-lfs-tutorial/#tracking-files-with-lfs +[post-1]: https://about.gitlab.com/blog/2017/01/30/getting-started-with-git-lfs-tutorial/ +[post-2]: https://about.gitlab.com/blog/2015/11/23/announcing-git-lfs-support-in-gitlab/ +[post-3]: https://about.gitlab.com/blog/2015/02/17/gitlab-annex-solves-the-problem-of-versioning-large-binaries-with-git/ +[uninit]: https://git-annex.branchable.com/git-annex-uninit/ diff --git a/doc/administration/logs.md b/doc/administration/logs.md index dae0dae839552b8a0077483959aa5026010089ba..aa10cdd220cfe601a0a8f3e18ace049b5e37296e 100644 --- a/doc/administration/logs.md +++ b/doc/administration/logs.md @@ -42,6 +42,48 @@ User clone/fetch activity using http transport appears in this log as `action: g In addition, the log contains the IP address from which the request originated (`remote_ip`) as well as the user's ID (`user_id`), and username (`username`). +NOTE: **Note:** Starting with GitLab 12.5, if an error occurs, an +`exception` field is included with `class`, `message`, and +`backtrace`. Previous versions included an `error` field instead of +`exception.class` and `exception.message`. For example: + +```json +{ + "method": "GET", + "path": "/admin", + "format": "html", + "controller": "Admin::DashboardController", + "action": "index", + "status": 500, + "duration": 2584.11, + "view": 0, + "db": 9.21, + "time": "2019-11-14T13:12:46.156Z", + "params": [], + "remote_ip": "127.0.0.1", + "user_id": 1, + "username": "root", + "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:70.0) Gecko/20100101 Firefox/70.0", + "queue_duration": 274.35, + "correlation_id": "KjDVUhNvvV3", + "cpu_s": 2.837645135999999, + "exception": { + "class": "NameError", + "message": "undefined local variable or method `adsf' for #<Admin::DashboardController:0x00007ff3c9648588>", + "backtrace": [ + "app/controllers/admin/dashboard_controller.rb:11:in `index'", + "ee/app/controllers/ee/admin/dashboard_controller.rb:14:in `index'", + "ee/lib/gitlab/ip_address_state.rb:10:in `with'", + "ee/app/controllers/ee/application_controller.rb:43:in `set_current_ip_address'", + "lib/gitlab/session.rb:11:in `with_session'", + "app/controllers/application_controller.rb:450:in `set_session_storage'", + "app/controllers/application_controller.rb:444:in `set_locale'", + "ee/lib/gitlab/jira/middleware.rb:19:in `call'" + ] + } +} +``` + ## `production.log` This file lives in `/var/log/gitlab/gitlab-rails/production.log` for diff --git a/doc/administration/monitoring/gitlab_instance_administration_project/index.md b/doc/administration/monitoring/gitlab_instance_administration_project/index.md index bb76ad59e3bb42d0f72eab385270d520a6f91408..b07bbafaf7dc763dc75bb5edad8834744d5b52d4 100644 --- a/doc/administration/monitoring/gitlab_instance_administration_project/index.md +++ b/doc/administration/monitoring/gitlab_instance_administration_project/index.md @@ -1,6 +1,7 @@ # GitLab instance administration project -> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/56883) in GitLab 12.2. +NOTE: **Note:** +This feature is not yet available and is [planned for 12.6](https://gitlab.com/gitlab-org/gitlab/issues/32351). 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/img/performance_bar.png b/doc/administration/monitoring/performance/img/performance_bar.png index d1187fd879a05741826586ae9ebf84bc9e45d102..acad60f863e7a1c5f711842c435017fbd6b25d06 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/performance_bar.md b/doc/administration/monitoring/performance/performance_bar.md index 53c08e32cb2c8ccfbf85b0e4fe13682939e3f941..a52b6227e145b52cabe341df889ca71ad48d72a7 100644 --- a/doc/administration/monitoring/performance/performance_bar.md +++ b/doc/administration/monitoring/performance/performance_bar.md @@ -8,14 +8,17 @@ activated, it looks as follows: It allows you to see (from left to right): - the current host serving the page -- time taken and number of DB queries, click through for details of these queries +- time taken and number of DB queries; click through for details of these queries  -- time taken and number of [Gitaly] calls, click through for details of these calls +- time taken and number of [Gitaly] calls; click through for details of these calls  -- time taken and number of [Rugged] calls, click through for details of these calls +- time taken and number of [Rugged] calls; click through for details of these calls  -- time taken and number of Redis calls, click through for details of these calls +- time taken and number of Redis calls; click through for details of these calls  +- a link to add a request's details to the performance bar; the request can be + added by its full URL (authenticated as the current user), or by the value of + its `X-Request-Id` header On the far right is a request selector that allows you to view the same metrics (excluding the page timing and line profiler) for any requests made while the @@ -51,7 +54,7 @@ Make sure _Enable the Performance Bar_ is checked and hit **Save** to save the changes. Once the Performance Bar is enabled, you will need to press the [<kbd>p</kbd> + -<kbd>b</kbd> keyboard shortcut](../../../workflow/shortcuts.md) to actually +<kbd>b</kbd> keyboard shortcut](../../../user/shortcuts.md) to actually display it. You can toggle the Bar using the same shortcut. diff --git a/doc/administration/monitoring/prometheus/index.md b/doc/administration/monitoring/prometheus/index.md index c35d6f505be49961fc510889d0be12d394458f8c..c0b563bd76ea6fef79a9d18e9e3c896c202870e3 100644 --- a/doc/administration/monitoring/prometheus/index.md +++ b/doc/administration/monitoring/prometheus/index.md @@ -78,6 +78,31 @@ To change the address/port that Prometheus listens on: 1. Save the file and [reconfigure GitLab][reconfigure] for the changes to take effect +### Adding custom scrape configs + +You can configure additional scrape targets for the GitLab Omnibus-bundled +Prometheus by editing `prometheus['scrape_configs']` in `/etc/gitlab/gitlab.rb` +using the [Prometheus scrape target configuration](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#%3Cscrape_config%3E) +syntax. + +Here is an example configuration to scrape `http://1.1.1.1:8060/probe?param_a=test¶m_b=additional_test`: + +```ruby +prometheus['scrape_configs'] = [ + { + 'job_name': 'custom-scrape', + 'metrics_path': '/probe', + 'params' => { + 'param_a' => ['test'], + 'param_b' => ['additional_test'] + }, + 'static_configs' => [ + 'targets' => ['1.1.1.1:8060'], + ], + }, +] +``` + ### Using an external Prometheus server NOTE: **Note:** diff --git a/doc/administration/operations/index.md b/doc/administration/operations/index.md index df208b3f427ae7f9cdbc23aac3675481d1b7770a..1d80e23eecf8ad293791b6758049e59a0c0a7e8b 100644 --- a/doc/administration/operations/index.md +++ b/doc/administration/operations/index.md @@ -21,3 +21,6 @@ Keep your GitLab instance up and running smoothly. performance can have a big impact on GitLab performance, especially for actions that read or write Git repositories. This information will help benchmark filesystem performance against known good and bad real-world systems. +- [ChatOps Scripts](https://gitlab.com/gitlab-com/chatops): The GitLab.com Infrastructure team uses this repository to house + common ChatOps scripts they use to troubleshoot and maintain the production instance of GitLab.com. + These scripts are likely useful to administrators of GitLab instances of all sizes. diff --git a/doc/administration/operations/sidekiq_memory_killer.md b/doc/administration/operations/sidekiq_memory_killer.md index 79e9fb778b6b83d8a20f2a3034186edb97dfc5a6..6438dbb9dabfc101153b1883197b3c365ffaf02d 100644 --- a/doc/administration/operations/sidekiq_memory_killer.md +++ b/doc/administration/operations/sidekiq_memory_killer.md @@ -34,7 +34,7 @@ The MemoryKiller is controlled using environment variables. In _daemon_ mode, the MemoryKiller checks the Sidekiq process RSS every 3 seconds (defined by `SIDEKIQ_MEMORY_KILLER_CHECK_INTERVAL`). -- `SIDEKIQ_MEMORY_KILLER_MAX_RSS`: if this variable is set, and its value is greater +- `SIDEKIQ_MEMORY_KILLER_MAX_RSS` (KB): if this variable is set, and its value is greater than 0, the MemoryKiller is enabled. Otherwise the MemoryKiller is disabled. `SIDEKIQ_MEMORY_KILLER_MAX_RSS` defines the Sidekiq process allowed RSS. @@ -52,7 +52,7 @@ The MemoryKiller is controlled using environment variables. [in the Omnibus GitLab repository](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/files/gitlab-cookbooks/gitlab/attributes/default.rb). -- `SIDEKIQ_MEMORY_KILLER_HARD_LIMIT_RSS`: is used by _daemon_ mode. If the Sidekiq +- `SIDEKIQ_MEMORY_KILLER_HARD_LIMIT_RSS` (KB): is used by _daemon_ mode. If the Sidekiq process RSS (expressed in kilobytes) exceeds `SIDEKIQ_MEMORY_KILLER_HARD_LIMIT_RSS`, an immediate graceful restart of Sidekiq is triggered. diff --git a/doc/administration/packages/container_registry.md b/doc/administration/packages/container_registry.md index bf86a549fda9737c16ab619f2b0e94dfec2e1e92..a62e3ab603d7333010e36ff96d31ab2db16d6e0e 100644 --- a/doc/administration/packages/container_registry.md +++ b/doc/administration/packages/container_registry.md @@ -18,7 +18,9 @@ You can read more about the Docker Registry at **Omnibus GitLab installations** -All you have to do is configure the domain name under which the Container +If you are using the Omnibus GitLab built in [Let's Encrypt integration](https://docs.gitlab.com/omnibus/settings/ssl.html#lets-encrypt-integration), as of GitLab 12.5, the Container Registry will be automatically enabled on port 5050 of the default domain. + +If you would like to use a separate domain, all you have to do is configure the domain name under which the Container Registry will listen to. Read [#container-registry-domain-configuration](#container-registry-domain-configuration) and pick one of the two options that fits your case. @@ -353,7 +355,7 @@ configuration. NOTE: **Note:** Enabling a storage driver other than `filesystem` would mean that your Docker client needs to be able to access the storage backend directly. -In that case, you must use an address that resolves and is accessible outside GitLab server. +In that case, you must use an address that resolves and is accessible outside GitLab server. The Docker client will continue to authenticate via GitLab but data transfer will be direct to and from the storage backend. The different supported drivers are: @@ -877,6 +879,6 @@ The above image shows: - The HEAD request to the AWS bucket reported a 403 Unauthorized. What does this mean? This strongly suggests that the S3 user does not have the right -[permissions to perform a HEAD request](https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectHEAD.html). +[permissions to perform a HEAD request](https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html). The solution: check the [IAM permissions again](https://docs.docker.com/registry/storage-drivers/s3/). Once the right permissions were set, the error will go away. diff --git a/doc/administration/pages/index.md b/doc/administration/pages/index.md index cacfb73451cef9a78d8314f2961fed2fe4ce6813..f51c375860bf39c3728e5c54280218961ac4b1da 100644 --- a/doc/administration/pages/index.md +++ b/doc/administration/pages/index.md @@ -120,7 +120,7 @@ The Pages daemon doesn't listen to the outside world. 1. Set the external URL for GitLab Pages in `/etc/gitlab/gitlab.rb`: - ```shell + ```ruby pages_external_url 'http://example.io' ``` @@ -145,7 +145,7 @@ outside world. 1. Place the certificate and key inside `/etc/gitlab/ssl` 1. In `/etc/gitlab/gitlab.rb` specify the following configuration: - ```shell + ```ruby pages_external_url 'https://example.io' pages_nginx['redirect_http_to_https'] = true @@ -167,7 +167,7 @@ behavior: 1. Edit `/etc/gitlab/gitlab.rb`. 1. Set the `inplace_chroot` to `true` for GitLab Pages: - ```shell + ```ruby gitlab_pages['inplace_chroot'] = true ``` @@ -202,7 +202,7 @@ world. Custom domains are supported, but no TLS. 1. Edit `/etc/gitlab/gitlab.rb`: - ```shell + ```ruby pages_external_url "http://example.io" nginx['listen_addresses'] = ['192.0.2.1'] pages_nginx['enable'] = false @@ -233,7 +233,7 @@ world. Custom domains and TLS are supported. 1. Edit `/etc/gitlab/gitlab.rb`: - ```shell + ```ruby pages_external_url "https://example.io" nginx['listen_addresses'] = ['192.0.2.1'] pages_nginx['enable'] = false @@ -271,7 +271,7 @@ sites served under a custom domain. To enable it, you'll need to: -1. Choose an email on which you will recieve notifications about expiring domains. +1. Choose an email on which you will receive notifications about expiring domains. 1. Navigate to your instance's **Admin Area > Settings > Preferences** and expand **Pages** settings. 1. Enter the email for receiving notifications and accept Let's Encrypt's Terms of Service as shown below. 1. Click **Save changes**. @@ -332,7 +332,7 @@ Follow the steps below to configure verbose logging of GitLab Pages daemon. If you wish to make it log events with level `DEBUG` you must configure this in `/etc/gitlab/gitlab.rb`: - ```shell + ```ruby gitlab_pages['log_verbose'] = true ``` @@ -347,7 +347,7 @@ are stored. If you wish to store them in another location you must set it up in `/etc/gitlab/gitlab.rb`: - ```shell + ```ruby gitlab_rails['pages_path'] = "/mnt/storage/pages" ``` @@ -363,14 +363,14 @@ Omnibus GitLab 11.1. If you wish to disable it you must configure this in `/etc/gitlab/gitlab.rb`: - ```shell + ```ruby gitlab_pages['listen_proxy'] = nil ``` If you wish to make it listen on a different port you must configure this also in `/etc/gitlab/gitlab.rb`: - ```shell + ```ruby gitlab_pages['listen_proxy'] = "localhost:10080" ``` @@ -382,21 +382,26 @@ 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 default is 100MB. -## Running GitLab Pages in a separate server +## Running GitLab Pages on a separate server -You may want to run GitLab Pages daemon on a separate server in order to decrease the load on your main application server. -Follow the steps below to configure GitLab Pages in 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. -1. Suppose you have the main GitLab application server named `app1`. Prepare - new Linux server (let's call it `app2`), create NFS share there and configure access to - this share from `app1`. Let's use the default GitLab Pages folder `/var/opt/gitlab/gitlab-rails/shared/pages` - as the shared folder on `app2` and mount it to `/mnt/pages` on `app1`. +To configure GitLab Pages on a separate server: -1. On `app2` install GitLab omnibus and modify `/etc/gitlab/gitlab.rb` this way: +1. Set up a new server. This will become the **Pages server**. - ```shell +1. Create an NFS share on the new server and configure this share to + allow access from your main **GitLab server**. For this example, we use the + default GitLab Pages folder `/var/opt/gitlab/gitlab-rails/shared/pages` + as the shared folder on the new server and we will mount it to `/mnt/pages` + on the **GitLab server**. + +1. On the **Pages server**, install Omnibus GitLab and modify `/etc/gitlab/gitlab.rb` + to include: + + ```ruby external_url 'http://<ip-address-of-the-server>' - pages_external_url "http://<your-pages-domain>" + pages_external_url "http://<your-pages-server-URL>" postgresql['enable'] = false redis['enable'] = false prometheus['enable'] = false @@ -409,20 +414,82 @@ Follow the steps below to configure GitLab Pages in a separate server. gitlab_rails['auto_migrate'] = false ``` -1. Run `sudo gitlab-ctl reconfigure`. -1. On `app1` apply the following changes to `/etc/gitlab/gitlab.rb`: +1. [Reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect. - ```shell +1. On the **GitLab server**, make the following changes to `/etc/gitlab/gitlab.rb`: + + ```ruby gitlab_pages['enable'] = false - pages_external_url "http://<your-pages-domain>" + pages_external_url "http://<your-pages-server-URL>" gitlab_rails['pages_path'] = "/mnt/pages" ``` -1. Run `sudo gitlab-ctl reconfigure`. +1. [Reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect. + +It is possible to run GitLab Pages on multiple servers if you wish to distribute +the load. You can do this through standard load balancing practices such as +configuring your DNS server to return multiple IPs for your Pages server, +configuring a load balancer to work at the IP level, and so on. If you wish to +set up GitLab Pages on multiple servers, perform the above procedure for each +Pages server. + +### Access control when running GitLab Pages on a separate server + +If you are [running GitLab Pages on a separate server](#running-gitlab-pages-on-a-separate-server), +then you must use the following procedure to configure [access control](#access-control): + +1. On the **GitLab server**, add the following to `/etc/gitlab/gitlab.rb`: + + ```ruby + gitlab_pages['enable'] = true + gitlab_pages['access_control'] = true + ``` + +1. [Reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the + changes to take effect. The `gitlab-secrets.json` file is now updated with the + new configuration. + + DANGER: **Danger:** + The `gitlab-secrets.json` file contains secrets that control database encryption. + Do not edit or replace this file on the **GitLab server** or you might + experience permanent data loss. Make a backup copy of this file before proceeding, + as explained in the following steps. + +1. Create a backup of the secrets file on the **GitLab server**: + + ```shell + cp /etc/gitlab/gitlab-secrets.json /etc/gitlab/gitlab-secrets.json.bak + ``` + +1. Create a backup of the secrets file on the **Pages server**: + + ```shell + cp /etc/gitlab/gitlab-secrets.json /etc/gitlab/gitlab-secrets.json.bak + ``` + +1. Disable Pages on the **GitLab server** by setting the following in + `/etc/gitlab/gitlab.rb`: + + ```ruby + gitlab_pages['enable'] = false + ``` + +1. [Reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect. + +1. Copy the `/etc/gitlab/gitlab-secrets.json` file from the **GitLab server** + to the **Pages server**. + +1. On your **Pages server**, add the following to `/etc/gitlab/gitlab.rb`: + + ```ruby + gitlab_pages['gitlab_server'] = "https://<your-gitlab-server-URL>" + ``` + +1. [Reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect. ## Backup -Pages are part of the [regular backup][backup] so there is nothing to configure. +GitLab Pages are part of the [regular backup][backup], so there is no separate backup to configure. ## Security diff --git a/doc/administration/repository_storage_paths.md b/doc/administration/repository_storage_paths.md index 7d3e36e9796253213bb6c7c6e613772ccbe81f0f..86998280b9319e16f7f22ada838cf7b3257553cd 100644 --- a/doc/administration/repository_storage_paths.md +++ b/doc/administration/repository_storage_paths.md @@ -2,8 +2,8 @@ > [Introduced][ce-4578] in GitLab 8.10. -GitLab allows you to define multiple repository storage paths to distribute the -storage load between several mount points. +GitLab allows you to define multiple repository storage paths (sometimes called +storage shards) to distribute the storage load between several mount points. > **Notes:** > diff --git a/doc/administration/repository_storage_types.md b/doc/administration/repository_storage_types.md index 227d6928bafd36903e5dc0047fa58711588da3b9..9c7b5bc6b87ac964ab2a99430e56d1db3dace812 100644 --- a/doc/administration/repository_storage_types.md +++ b/doc/administration/repository_storage_types.md @@ -5,8 +5,8 @@ Two different storage layouts can be used to store the repositories on disk and their characteristics. -GitLab can be configured to use one or multiple repository shard locations -that can be: +GitLab can be configured to use one or multiple repository storage paths/shard +locations that can be: - Mounted to the local disk - Exposed as an NFS shared volume @@ -34,8 +34,8 @@ easy for Administrators to find where the repository is stored. On the other hand this has some drawbacks: Storage location will concentrate huge amount of top-level namespaces. The -impact can be reduced by the introduction of [multiple storage -paths][storage-paths]. +impact can be reduced by the introduction of +[multiple storage paths](repository_storage_paths.md). Because backups are a snapshot of the same URL mapping, if you try to recover a very old backup, you need to verify whether any project has taken the place of @@ -183,7 +183,8 @@ CI Artifacts are S3 compatible since **9.4** (GitLab Premium), and available in ##### LFS Objects -LFS Objects implements a similar storage pattern using 2 chars, 2 level folders, following Git own implementation: +[LFS Objects in GitLab](lfs/manage_large_binaries_with_git_lfs.md) implement a similar +storage pattern using 2 chars, 2 level folders, following Git's own implementation: ```ruby "shared/lfs-objects/#{oid[0..1}/#{oid[2..3]}/#{oid[4..-1]}" @@ -192,10 +193,9 @@ LFS Objects implements a similar storage pattern using 2 chars, 2 level folders, "shared/lfs-objects/89/09/029eb962194cfb326259411b22ae3f4a814b5be4f80651735aeef9f3229c" ``` -They are also S3 compatible since **10.0** (GitLab Premium), and available in GitLab Core since **10.7**. +LFS objects are also [S3 compatible](lfs/lfs_administration.md#storing-lfs-objects-in-remote-object-storage). [ce-2821]: https://gitlab.com/gitlab-com/infrastructure/issues/2821 [ce-28283]: https://gitlab.com/gitlab-org/gitlab-foss/issues/28283 [rake/migrate-to-hashed]: raketasks/storage.md#migrate-existing-projects-to-hashed-storage -[storage-paths]: repository_storage_types.md [gitaly]: gitaly/index.md diff --git a/doc/administration/timezone.md b/doc/administration/timezone.md new file mode 100644 index 0000000000000000000000000000000000000000..3594ba1918123747e48a1072c8f834eb61bdbde1 --- /dev/null +++ b/doc/administration/timezone.md @@ -0,0 +1,37 @@ +# Changing your time zone + +The global time zone configuration parameter can be changed in `config/gitlab.yml`: + +```text +# time_zone: 'UTC' +``` + +Uncomment and customize if you want to change the default time zone of the GitLab application. + +## Viewing available timezones + +To see all available time zones, run `bundle exec rake time:zones:all`. + +For Omnibus installations, run `gitlab-rake time:zones:all`. + +NOTE: **Note:** +Currently, this rake task does not list timezones in TZInfo format required by GitLab Omnibus during a reconfigure: [#58672](https://gitlab.com/gitlab-org/gitlab-foss/issues/58672). + +## Changing time zone in Omnibus installations + +GitLab defaults its time zone to UTC. It has a global timezone configuration parameter in `/etc/gitlab/gitlab.rb`. + +To obtain a list of timezones, log in to your GitLab application server and run a command that generates a list of timezones in TZInfo format for the server. For example, install `timedatectl` and run `timedatectl list-timezones`. + +To update, add the timezone that best applies to your location. For example: + +```ruby +gitlab_rails['time_zone'] = 'America/New_York' +``` + +After adding the configuration parameter, reconfigure and restart your GitLab instance: + +```sh +gitlab-ctl reconfigure +gitlab-ctl restart +``` diff --git a/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md b/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md index 34a5acbe7b79143ffe8e6be842b71fcfcc06229a..dd220d0871d7ff02b1f34ded77f5a0888f775b5c 100644 --- a/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md +++ b/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md @@ -442,7 +442,7 @@ personal_access_token = User.find(123).personal_access_tokens.create( scopes: [:api] ) -personal_access_token.token +puts personal_access_token.token ``` You might also want to manually set the token string: @@ -715,7 +715,7 @@ For more information, see the [confidential issue](../../user/project/issues/con ```ruby Ci::Pipeline.where(project_id: p.id).where(status: 'pending').count -Ci::Pipeline.where(project_id: p.id).where(status: 'pending').each {|p| p.cancel} +Ci::Pipeline.where(project_id: p.id).where(status: 'pending').each {|p| p.cancel if p.stuck?} Ci::Pipeline.where(project_id: p.id).where(status: 'pending').count ``` diff --git a/doc/administration/uploads.md b/doc/administration/uploads.md index 04cebe568f3590b67653b5601e53392251bd66df..bccfe7c542f2bb8f0d4a2eb12f065e63cb771d2b 100644 --- a/doc/administration/uploads.md +++ b/doc/administration/uploads.md @@ -63,8 +63,8 @@ For source installations the following settings are nested under `uploads:` and |---------|-------------|---------| | `enabled` | Enable/disable object storage | `false` | | `remote_directory` | The bucket name where Uploads will be stored| | -| `direct_upload` | Set to true to enable direct upload of Uploads without the need of local shared storage. Option may be removed once we decide to support only single storage for all files. | `false` | -| `background_upload` | Set to false to disable automatic upload. Option may be removed once upload is direct to S3 | `true` | +| `direct_upload` | Set to true to remove Unicorn from the Upload path. Workhorse handles the actual Artifact Upload to Object Storage while Unicorn does minimal processing to keep track of the upload. There is no need for local shared storage. The option may be removed if support for a single storage type for all files is introduced. Read more on [what the direct_upload setting means](https://docs.gitlab.com/ee/development/uploads.html#what-does-the-direct_upload-setting-mean). | `false` | +| `background_upload` | Set to false to disable automatic upload. Option may be removed once upload is direct to S3 (if `direct_upload` is set to `true` it will override `background_upload`) | `true` | | `proxy_download` | Set to true to enable proxying all files served. Option allows to reduce egress traffic as this allows clients to download directly from remote storage instead of proxying all data | `false` | | `connection` | Various connection options described below | | diff --git a/doc/api/api_resources.md b/doc/api/api_resources.md index 232a9825691fcef49ba0cbc2e6ecef450b61d5e5..c2713f54c47a12deb33c3e70feeddd704c6d0f26 100644 --- a/doc/api/api_resources.md +++ b/doc/api/api_resources.md @@ -24,7 +24,7 @@ The following API resources are available in the project context: | [Commits](commits.md) | `/projects/:id/repository/commits`, `/projects/:id/statuses` | | [Container Registry](container_registry.md) | `/projects/:id/registry/repositories` | | [Custom attributes](custom_attributes.md) | `/projects/:id/custom_attributes` (also available for groups and users) | -| [Dependencies](dependencies.md) **(ULTIMATE)** | `/projects/:id/dependencies` +| [Dependencies](dependencies.md) **(ULTIMATE)** | `/projects/:id/dependencies` | | [Deploy keys](deploy_keys.md) | `/projects/:id/deploy_keys` (also available standalone) | | [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) | @@ -67,7 +67,9 @@ The following API resources are available in the project context: | [Search](search.md) | `/projects/:id/search` (also available for groups and standalone) | | [Services](services.md) | `/projects/:id/services` | | [Tags](tags.md) | `/projects/:id/repository/tags` | -| [Vulnerabilities](vulnerabilities.md) **(ULTIMATE)** | `/projects/:id/vulnerabilities` +| [Visual Review discussions](visual_review_discussions.md) **(STARTER**) | `/projects/:id/merge_requests/:merge_request_id/visual_review_discussions` | +| [Vulnerabilities](vulnerabilities.md) **(ULTIMATE)** | `/projects/:id/vulnerabilities` | +| [Vulnerability Findings](vulnerability_findings.md) **(ULTIMATE)** | `/projects/:id/vulnerability_findings` | | [Wikis](wikis.md) | `/projects/:id/wikis` | ## Group resources diff --git a/doc/api/audit_events.md b/doc/api/audit_events.md index aca221cf990b45e22941447084a54ad7129ad59f..e451b975d4247b3a26e48834ebe53d7c72e21489 100644 --- a/doc/api/audit_events.md +++ b/doc/api/audit_events.md @@ -1,10 +1,12 @@ -# Audit Events API **(PREMIUM ONLY)** +# Audit Events API + +## Instance Audit Events **(PREMIUM ONLY)** The Audit Events API allows you to retrieve [instance audit events](../administration/audit_events.md#instance-events-premium-only). To retrieve audit events using the API, you must [authenticate yourself](README.html#authentication) as an Administrator. -## Retrieve all instance audit events +### Retrieve all instance audit events ``` GET /audit_events @@ -15,7 +17,7 @@ GET /audit_events | `created_after` | string | no | Return audit events created on or after the given time. Format: ISO 8601 YYYY-MM-DDTHH:MM:SSZ | | `created_before` | string | no | Return audit events created on or before the given time. Format: ISO 8601 YYYY-MM-DDTHH:MM:SSZ | | `entity_type` | string | no | Return audit events for the given entity type. Valid values are: `User`, `Group`, or `Project`. | -| `entity_id` | boolean | no | Return audit events for the given entity ID. Requires `entity_type` attribute to be present. | +| `entity_id` | integer | no | Return audit events for the given entity ID. Requires `entity_type` attribute to be present. | By default, `GET` requests return 20 results at a time because the API results are paginated. @@ -83,7 +85,7 @@ Example response: ] ``` -## Retrieve single instance audit event +### Retrieve single instance audit event ``` GET /audit_events/:id @@ -113,3 +115,109 @@ Example response: "created_at": "2019-08-30T07:00:41.885Z" } ``` + +## Group Audit Events **(STARTER)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/34078) in GitLab 12.5. + +The Group Audit Events API allows you to retrieve [group audit events](../administration/audit_events.html#group-events-starter). + +To retrieve group audit events using the API, you must [authenticate yourself](README.html#authentication) as an Administrator or an owner of the group. + +### Retrieve all group audit events + +``` +GET /groups/:id/audit_events +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) | +| `created_after` | string | no | Return group audit events created on or after the given time. Format: ISO 8601 YYYY-MM-DDTHH:MM:SSZ | +| `created_before` | string | no | Return group audit events created on or before the given time. Format: ISO 8601 YYYY-MM-DDTHH:MM:SSZ | + +By default, `GET` requests return 20 results at a time because the API results +are paginated. + +Read more on [pagination](README.md#pagination). + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" https://primary.example.com/api/v4/groups/60/audit_events +``` + +Example response: + +```json +[ + { + "id": 2, + "author_id": 1, + "entity_id": 60, + "entity_type": "Group", + "details": { + "custom_message": "Group marked for deletion", + "author_name": "Administrator", + "target_id": "flightjs", + "target_type": "Group", + "target_details": "flightjs", + "ip_address": "127.0.0.1", + "entity_path": "flightjs" + }, + "created_at": "2019-08-28T19:36:44.162Z" + }, + { + "id": 1, + "author_id": 1, + "entity_id": 60, + "entity_type": "Group", + "details": { + "add": "group", + "author_name": "Administrator", + "target_id": "flightjs", + "target_type": "Group", + "target_details": "flightjs", + "ip_address": "127.0.0.1", + "entity_path": "flightjs" + }, + "created_at": "2019-08-27T18:36:44.162Z" + } +] +``` + +### Retrieve a specific group audit event + +Only available to group owners and administrators. + +``` +GET /groups/:id/audit_events/:audit_event_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) | +| `audit_event_id` | integer | yes | ID of the audit event | + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" https://primary.example.com/api/v4/groups/60/audit_events/2 +``` + +Example response: + +```json +{ + "id": 2, + "author_id": 1, + "entity_id": 60, + "entity_type": "Group", + "details": { + "custom_message": "Group marked for deletion", + "author_name": "Administrator", + "target_id": "flightjs", + "target_type": "Group", + "target_details": "flightjs", + "ip_address": "127.0.0.1", + "entity_path": "flightjs" + }, + "created_at": "2019-08-28T19:36:44.162Z" +} +``` diff --git a/doc/api/branches.md b/doc/api/branches.md index 31c8add300d853fe2838b07ef662d7c6819919ff..bba8876163eb783ad456da16c6a67c5a431e6907 100644 --- a/doc/api/branches.md +++ b/doc/api/branches.md @@ -21,7 +21,7 @@ Parameters: | Attribute | Type | Required | Description | |:----------|:---------------|:---------|:------------| | `id` | integer/string | yes | ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user.| -| `search` | string | no | Return list of branches containing the search string. You can use `^term` and `term$` to find branches that begin and end with `term` respectively.| +| `search` | string | no | Return list of branches containing the search string. You can use `^term` and `term$` to find branches that begin and end with `term` respectively. | Example request: diff --git a/doc/api/commits.md b/doc/api/commits.md index 3927a4bbc6207b8066cf6fe7ee27fba13c99cf51..f4bb09843c87c4007224cbf97d1a5f7327c5408c 100644 --- a/doc/api/commits.md +++ b/doc/api/commits.md @@ -317,6 +317,21 @@ Example response: } ``` +In the event of a failed cherry-pick, the response will provide context about +why: + +```json +{ + "message": "Sorry, we cannot cherry-pick this commit automatically. This commit may already have been cherry-picked, or a more recent commit may have updated some of its content.", + "error_code": "empty" +} +``` + +In this case, the cherry-pick failed because the changeset was empty and likely +indicates that the commit already exists in the target branch. The other +possible error code is `conflict`, which indicates that there was a merge +conflict. + ## Revert a commit > [Introduced][ce-22919] in GitLab 11.5. @@ -358,6 +373,19 @@ Example response: } ``` +In the event of a failed revert, the response will provide context about why: + +```json +{ + "message": "Sorry, we cannot revert this commit automatically. This commit may already have been reverted, or a more recent commit may have updated some of its content.", + "error_code": "conflict" +} +``` + +In this case, the revert failed because the attempted revert generated a merge +conflict. The other possible error code is `empty`, which indicates that the +changeset was empty, likely due to the change having already been reverted. + ## Get the diff of a commit Get the diff of a commit in a project. @@ -670,6 +698,7 @@ Example response: "merge_status":"can_be_merged", "sha":"af5b13261899fb2c0db30abdd0af8b07cb44fdc5", "merge_commit_sha":null, + "squash_commit_sha":null, "user_notes_count":0, "discussion_locked":null, "should_remove_source_branch":null, diff --git a/doc/api/deployments.md b/doc/api/deployments.md index 27254c42e3a211777804df017b05ff534fa123d3..6fc6599a47d4b0da9e80372ebac81874d76e79d9 100644 --- a/doc/api/deployments.md +++ b/doc/api/deployments.md @@ -11,7 +11,7 @@ GET /projects/:id/deployments | Attribute | Type | Required | Description | |-----------|---------|----------|---------------------| | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | -| `order_by`| string | no | Return deployments ordered by `id` or `iid` or `created_at` or `ref` fields. Default is `id` | +| `order_by`| string | no | Return deployments ordered by `id` or `iid` or `created_at` or `updated_at` or `ref` fields. Default is `id` | | `sort` | string | no | Return deployments sorted in `asc` or `desc` order. Default is `asc` | ```bash @@ -62,6 +62,15 @@ Example of response "twitter": "", "website_url": "", "organization": "" + }, + "pipeline": { + "created_at": "2016-08-11T02:12:10.222Z", + "id": 36, + "ref": "master", + "sha": "99d03678b90d914dbb1b109132516d71a4a03ea8", + "status": "success", + "updated_at": "2016-08-11T02:12:10.222Z", + "web_url": "http://gitlab.dev/root/project/pipelines/12" } }, "environment": { @@ -122,6 +131,15 @@ Example of response "twitter": "", "website_url": "", "organization": "" + }, + "pipeline": { + "created_at": "2016-08-11T07:43:52.143Z", + "id": 37, + "ref": "master", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "status": "success", + "updated_at": "2016-08-11T07:43:52.143Z", + "web_url": "http://gitlab.dev/root/project/pipelines/13" } }, "environment": { @@ -219,6 +237,15 @@ Example of response "created_at": "2016-08-11T13:28:26.000+02:00", "message": "Merge branch 'rename-readme' into 'master'\r\n\r\nRename README\r\n\r\n\r\n\r\nSee merge request !2" }, + "pipeline": { + "created_at": "2016-08-11T07:43:52.143Z", + "id": 42, + "ref": "master", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "status": "success", + "updated_at": "2016-08-11T07:43:52.143Z", + "web_url": "http://gitlab.dev/root/project/pipelines/5" + } "runner": null } } diff --git a/doc/api/epic_links.md b/doc/api/epic_links.md index 665c902355f5bb541d9e12f5ee7e4012f73c136b..e81dc88da81851a93b60b2f50813e9eff797c2b3 100644 --- a/doc/api/epic_links.md +++ b/doc/api/epic_links.md @@ -50,12 +50,14 @@ Example response: "start_date": null, "start_date_is_fixed": false, "start_date_fixed": null, - "start_date_from_milestones": null, - "end_date": "2018-07-31", + "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", + "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", "labels": [] @@ -102,12 +104,14 @@ Example response: "start_date": null, "start_date_is_fixed": false, "start_date_fixed": null, - "start_date_from_milestones": null, - "end_date": "2018-07-31", + "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", + "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", "labels": [] @@ -189,12 +193,14 @@ Example response: "start_date": null, "start_date_is_fixed": false, "start_date_fixed": null, - "start_date_from_milestones": null, - "end_date": "2018-07-31", + "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", + "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", "labels": [] @@ -241,12 +247,14 @@ Example response: "start_date": null, "start_date_is_fixed": false, "start_date_fixed": null, - "start_date_from_milestones": null, - "end_date": "2018-07-31", + "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", + "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", "labels": [] diff --git a/doc/api/epics.md b/doc/api/epics.md index c7a050f1465fde4dd1cc131fe44cf4368be95491..531c75fd8c5abd4e21416715230f560e16b6976e 100644 --- a/doc/api/epics.md +++ b/doc/api/epics.md @@ -14,9 +14,13 @@ The [epic issues API](epic_issues.md) allows you to interact with issues associa > [Introduced][ee-6448] in GitLab 11.3. -Since start date and due date can be dynamically sourced from related issue milestones, when user has edit permission, additional fields will be shown. These include two boolean fields `start_date_is_fixed` and `due_date_is_fixed`, and four date fields `start_date_fixed`, `start_date_from_milestones`, `due_date_fixed` and `due_date_from_milestones`. +Since start date and due date can be dynamically sourced from related issue milestones, when user has edit permission, +additional fields will be shown. These include two boolean fields `start_date_is_fixed` and `due_date_is_fixed`, +and four date fields `start_date_fixed`, `start_date_from_inherited_source`, `due_date_fixed` and `due_date_from_inherited_source`. -`end_date` has been deprecated in favor of `due_date`. +- `end_date` has been deprecated in favor of `due_date`. +- `start_date_from_milestones` has been deprecated in favor of `start_date_from_inherited_source` +- `due_date_from_milestones` has been deprecated in favor of `due_date_from_inherited_source` ## Epics pagination @@ -80,12 +84,14 @@ Example response: "start_date": null, "start_date_is_fixed": false, "start_date_fixed": null, - "start_date_from_milestones": null, - "end_date": "2018-07-31", + "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", + "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", @@ -136,12 +142,14 @@ Example response: "start_date": null, "start_date_is_fixed": false, "start_date_fixed": null, - "start_date_from_milestones": null, - "end_date": "2018-07-31", + "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", + "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", @@ -204,12 +212,14 @@ Example response: "start_date": null, "start_date_is_fixed": false, "start_date_fixed": null, - "start_date_from_milestones": null, - "end_date": "2018-07-31", + "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", + "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", @@ -272,12 +282,14 @@ Example response: "start_date": null, "start_date_is_fixed": false, "start_date_fixed": null, - "start_date_from_milestones": null, - "end_date": "2018-07-31", + "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", + "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", diff --git a/doc/api/feature_flag_specs.md b/doc/api/feature_flag_specs.md new file mode 100644 index 0000000000000000000000000000000000000000..6a2cd047f85e017328c6c70a3dea0485cc096962 --- /dev/null +++ b/doc/api/feature_flag_specs.md @@ -0,0 +1,291 @@ +# Feature Flag Specs API **(PREMIUM)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/9566) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.5. + +The API for creating, updating, reading and deleting [Feature Flag Specs](../user/project/operations/feature_flags.md#define-environment-specs). +Automation engineers benefit from this API by being able to modify Feature Flag Specs without accessing user interface. +To manage the [Feature Flag](../user/project/operations/feature_flags.md) resources via public API, please refer to the [Feature Flags API](feature_flags.md) document. + +Users with Developer or higher [permissions](../user/permissions.md) can access Feature Flag Specs API. + +## List all effective feature flag specs under the specified environment + +Get all effective feature flag specs under the specified [environment](../ci/environments.md). + +For instance, there are two specs, `staging` and `production`, for a feature flag. +When you pass `production` as a parameter to this endpoint, the system returns +the `production` feature flag spec only. + +``` +GET /projects/:id/feature_flag_scopes +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `environment` | string | yes | The [environment](../ci/environments.md) name | + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/1/feature_flag_scopes?environment=production +``` + +Example response: + +```json +[ + { + "id": 88, + "active": true, + "environment_scope": "production", + "strategies": [ + { + "name": "userWithId", + "parameters": { + "userIds": "1,2,3" + } + } + ], + "created_at": "2019-11-04T08:36:41.327Z", + "updated_at": "2019-11-04T08:36:41.327Z", + "name": "awesome_feature" + }, + { + "id": 82, + "active": true, + "environment_scope": "*", + "strategies": [ + { + "name": "default", + "parameters": {} + } + ], + "created_at": "2019-11-04T08:13:51.425Z", + "updated_at": "2019-11-04T08:39:45.751Z", + "name": "merge_train" + }, + { + "id": 81, + "active": false, + "environment_scope": "production", + "strategies": [ + { + "name": "default", + "parameters": {} + } + ], + "created_at": "2019-11-04T08:13:10.527Z", + "updated_at": "2019-11-04T08:13:10.527Z", + "name": "new_live_trace" + } +] +``` + +## List all specs of a feature flag + +Get all specs of a feature flag. + +``` +GET /projects/:id/feature_flags/:name/scopes +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `name` | string | yes | The name of the feature flag. | + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/1/feature_flags/new_live_trace/scopes +``` + +Example response: + +```json +[ + { + "id": 79, + "active": false, + "environment_scope": "*", + "strategies": [ + { + "name": "default", + "parameters": {} + } + ], + "created_at": "2019-11-04T08:13:10.516Z", + "updated_at": "2019-11-04T08:13:10.516Z" + }, + { + "id": 80, + "active": true, + "environment_scope": "staging", + "strategies": [ + { + "name": "default", + "parameters": {} + } + ], + "created_at": "2019-11-04T08:13:10.525Z", + "updated_at": "2019-11-04T08:13:10.525Z" + }, + { + "id": 81, + "active": false, + "environment_scope": "production", + "strategies": [ + { + "name": "default", + "parameters": {} + } + ], + "created_at": "2019-11-04T08:13:10.527Z", + "updated_at": "2019-11-04T08:13:10.527Z" + } +] +``` + +## New feature flag spec + +Creates a new feature flag spec. + +``` +POST /projects/:id/feature_flags/:name/scopes +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `name` | string | yes | The name of the feature flag. | +| `environment_scope` | string | yes | The [environment spec](../ci/environments.md#scoping-environments-with-specs) of the feature flag. | +| `active` | boolean | yes | Whether the spec is active. | +| `strategies` | json | yes | The [strategies](../user/project/operations/feature_flags.md#feature-flag-strategies) of the feature flag spec. | + +```bash +curl https://gitlab.example.com/api/v4/projects/1/feature_flags/new_live_trace/scopes \ + --header "PRIVATE-TOKEN: <your_access_token>" \ + --header "Content-type: application/json" \ + --data @- << EOF +{ + "environment_scope": "*", + "active": false, + "strategies": [{ "name": "default", "parameters": {} }] +} +EOF +``` + +Example response: + +```json +{ + "id": 81, + "active": false, + "environment_scope": "*", + "strategies": [ + { + "name": "default", + "parameters": {} + } + ], + "created_at": "2019-11-04T08:13:10.527Z", + "updated_at": "2019-11-04T08:13:10.527Z" +} +``` + +## Single feature flag spec + +Gets a single feature flag spec. + +``` +GET /projects/:id/feature_flags/:name/scopes/:environment_scope +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `name` | string | yes | The name of the feature flag. | +| `environment_scope` | string | yes | The URL-encoded [environment spec](../ci/environments.md#scoping-environments-with-specs) of the feature flag. | + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/:id/feature_flags/new_live_trace/scopes/production +``` + +Example response: + +```json +{ + "id": 81, + "active": false, + "environment_scope": "production", + "strategies": [ + { + "name": "default", + "parameters": {} + } + ], + "created_at": "2019-11-04T08:13:10.527Z", + "updated_at": "2019-11-04T08:13:10.527Z" +} +``` + +## Edit feature flag spec + +Updates an existing feature flag spec. + +``` +PUT /projects/:id/feature_flags/:name/scopes/:environment_scope +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `name` | string | yes | The name of the feature flag. | +| `environment_scope` | string | yes | The URL-encoded [environment spec](../ci/environments.md#scoping-environments-with-specs) of the feature flag. | +| `active` | boolean | yes | Whether the spec is active. | +| `strategies` | json | yes | The [strategies](../user/project/operations/feature_flags.md#feature-flag-strategies) of the feature flag spec. | + +```bash +curl https://gitlab.example.com/api/v4/projects/1/feature_flags/new_live_trace/scopes/production \ + --header "PRIVATE-TOKEN: <your_access_token>" \ + --header "Content-type: application/json" \ + --data @- << EOF +{ + "active": true, + "strategies": [{ "name": "userWithId", "parameters": { "userIds": "1,2,3" } }] +} +EOF +``` + +Example response: + +```json +{ + "id": 81, + "active": true, + "environment_scope": "production", + "strategies": [ + { + "name": "userWithId", + "parameters": { "userIds": "1,2,3" } + } + ], + "created_at": "2019-11-04T08:13:10.527Z", + "updated_at": "2019-11-04T08:13:10.527Z" +} +``` + +## Delete feature flag spec + +Deletes a feature flag spec. + +``` +DELETE /projects/:id/feature_flags/:name/scopes/:environment_scope +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `name` | string | yes | The name of the feature flag. | +| `environment_scope` | string | yes | The URL-encoded [environment spec](../ci/environments.md#scoping-environments-with-specs) of the feature flag. | + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" --request DELETE https://gitlab.example.com/api/v4/projects/1/feature_flags/new_live_trace/scopes/production +``` diff --git a/doc/api/feature_flags.md b/doc/api/feature_flags.md new file mode 100644 index 0000000000000000000000000000000000000000..def452d36fb7ccd893c0dfbd6211562962df331d --- /dev/null +++ b/doc/api/feature_flags.md @@ -0,0 +1,308 @@ +# Feature Flags API **(PREMIUM)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/9566) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.5. + +API for accessing resources of [GitLab Feature Flags](../user/project/operations/feature_flags.md). + +Users with Developer or higher [permissions](../user/permissions.md) can access Feature Flag API. + +## Feature Flags pagination + +By default, `GET` requests return 20 results at a time because the API results +are [paginated](README.md#pagination). + +## List feature flags for a project + +Gets all feature flags of the requested project. + +``` +GET /projects/:id/feature_flags +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `scope` | string | no | The condition of feature flags, one of: `enabled`, `disabled`. | + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/1/feature_flags +``` + +Example response: + +```json +[ + { + "name":"merge_train", + "description":"This feature is about merge train", + "created_at":"2019-11-04T08:13:51.423Z", + "updated_at":"2019-11-04T08:13:51.423Z", + "scopes":[ + { + "id":82, + "active":false, + "environment_scope":"*", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:13:51.425Z", + "updated_at":"2019-11-04T08:13:51.425Z" + }, + { + "id":83, + "active":true, + "environment_scope":"review/*", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:13:51.427Z", + "updated_at":"2019-11-04T08:13:51.427Z" + }, + { + "id":84, + "active":false, + "environment_scope":"production", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:13:51.428Z", + "updated_at":"2019-11-04T08:13:51.428Z" + } + ] + }, + { + "name":"new_live_trace", + "description":"This is a new live trace feature", + "created_at":"2019-11-04T08:13:10.507Z", + "updated_at":"2019-11-04T08:13:10.507Z", + "scopes":[ + { + "id":79, + "active":false, + "environment_scope":"*", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:13:10.516Z", + "updated_at":"2019-11-04T08:13:10.516Z" + }, + { + "id":80, + "active":true, + "environment_scope":"staging", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:13:10.525Z", + "updated_at":"2019-11-04T08:13:10.525Z" + }, + { + "id":81, + "active":false, + "environment_scope":"production", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:13:10.527Z", + "updated_at":"2019-11-04T08:13:10.527Z" + } + ] + } +] +``` + +## New feature flag + +Creates a new feature flag. + +``` +POST /projects/:id/feature_flags +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `name` | string | yes | The name of the feature flag. | +| `description` | string | no | The description of the feature flag. | +| `scopes` | JSON | no | The [feature flag specs](../user/project/operations/feature_flags.md#define-environment-specs) of the feature flag. | +| `scopes:environment_scope` | string | no | The [environment spec](../ci/environments.md#scoping-environments-with-specs). | +| `scopes:active` | boolean | no | Whether the spec is active. | +| `scopes:strategies` | JSON | no | The [strategies](../user/project/operations/feature_flags.md#feature-flag-strategies) of the feature flag spec. | + +```bash +curl https://gitlab.example.com/api/v4/projects/1/feature_flags \ + --header "PRIVATE-TOKEN: <your_access_token>" \ + --header "Content-type: application/json" \ + --data @- << EOF +{ + "name": "awesome_feature", + "scopes": [{ "environment_scope": "*", "active": false, "strategies": [{ "name": "default", "parameters": {} }] }, + { "environment_scope": "production", "active": true, "strategies": [{ "name": "userWithId", "parameters": { "userIds": "1,2,3" } }] }] +} +EOF +``` + +Example response: + +```json +{ + "name":"awesome_feature", + "description":null, + "created_at":"2019-11-04T08:32:27.288Z", + "updated_at":"2019-11-04T08:32:27.288Z", + "scopes":[ + { + "id":85, + "active":false, + "environment_scope":"*", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:32:29.324Z", + "updated_at":"2019-11-04T08:32:29.324Z" + }, + { + "id":86, + "active":true, + "environment_scope":"production", + "strategies":[ + { + "name":"userWithId", + "parameters":{ + "userIds":"1,2,3" + } + } + ], + "created_at":"2019-11-04T08:32:29.328Z", + "updated_at":"2019-11-04T08:32:29.328Z" + } + ] +} +``` + +## Single feature flag + +Gets a single feature flag. + +``` +GET /projects/:id/feature_flags/:name +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `name` | string | yes | The name of the feature flag. | + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/1/feature_flags/new_live_trace +``` + +Example response: + +```json +{ + "name":"new_live_trace", + "description":"This is a new live trace feature", + "created_at":"2019-11-04T08:13:10.507Z", + "updated_at":"2019-11-04T08:13:10.507Z", + "scopes":[ + { + "id":79, + "active":false, + "environment_scope":"*", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:13:10.516Z", + "updated_at":"2019-11-04T08:13:10.516Z" + }, + { + "id":80, + "active":true, + "environment_scope":"staging", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:13:10.525Z", + "updated_at":"2019-11-04T08:13:10.525Z" + }, + { + "id":81, + "active":false, + "environment_scope":"production", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:13:10.527Z", + "updated_at":"2019-11-04T08:13:10.527Z" + } + ] +} +``` + +## Delete feature flag + +Deletes a feature flag. + +``` +DELETE /projects/:id/feature_flags/:name +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `name` | string | yes | The name of the feature flag. | + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" --request DELETE https://gitlab.example.com/api/v4/projects/1/feature_flags/awesome_feature +``` diff --git a/doc/api/geo_nodes.md b/doc/api/geo_nodes.md index 5eba7f038eddb4d794af6d373a70e7421f255b81..e44d69f14192820e3f5faebe1f7f47160ca1a418 100644 --- a/doc/api/geo_nodes.md +++ b/doc/api/geo_nodes.md @@ -237,6 +237,10 @@ Example response: "container_repositories_synced_count": nil, "container_repositories_failed_count": nil, "container_repositories_synced_in_percentage": "0.00%", + "design_repositories_count": 3, + "design_repositories_synced_count": nil, + "design_repositories_failed_count": nil, + "design_repositories_synced_in_percentage": "0.00%", "projects_count": 41, "repositories_failed_count": nil, "repositories_synced_count": nil, @@ -304,6 +308,10 @@ Example response: "container_repositories_synced_count": nil, "container_repositories_failed_count": nil, "container_repositories_synced_in_percentage": "0.00%", + "design_repositories_count": 3, + "design_repositories_synced_count": nil, + "design_repositories_failed_count": nil, + "design_repositories_synced_in_percentage": "0.00%", "projects_count": 41, "repositories_failed_count": 1, "repositories_synced_count": 40, @@ -387,6 +395,10 @@ Example response: "container_repositories_synced_count": nil, "container_repositories_failed_count": nil, "container_repositories_synced_in_percentage": "0.00%", + "design_repositories_count": 3, + "design_repositories_synced_count": nil, + "design_repositories_failed_count": nil, + "design_repositories_synced_in_percentage": "0.00%", "projects_count": 41, "repositories_failed_count": 1, "repositories_synced_count": 40, diff --git a/doc/api/graphql/index.md b/doc/api/graphql/index.md index 9eb254b46774f525caab4798874219af8e341400..510b36eba8f13ceeb62a11cbb3decd55907f2955 100644 --- a/doc/api/graphql/index.md +++ b/doc/api/graphql/index.md @@ -53,6 +53,11 @@ GitLab's GraphQL reference [is available](reference/index.md). It is automatically generated from GitLab's GraphQL schema and embedded in a Markdown file. +Machine-readable versions are also available: + +- [JSON format](reference/gitlab_schema.json) +- [IDL format](reference/gitlab_schema.graphql) + ## GraphiQL The API can be explored by using the GraphiQL IDE, it is available on your diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql new file mode 100644 index 0000000000000000000000000000000000000000..a357c93b020c145313a0dbf730dcfad7428cea56 --- /dev/null +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -0,0 +1,5422 @@ +""" +Autogenerated input type of AddAwardEmoji +""" +input AddAwardEmojiInput { + """ + The global id of the awardable resource + """ + awardableId: ID! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The emoji name + """ + name: String! +} + +""" +Autogenerated return type of AddAwardEmoji +""" +type AddAwardEmojiPayload { + """ + The award emoji after mutation + """ + awardEmoji: AwardEmoji + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! +} + +type AwardEmoji { + """ + The emoji description + """ + description: String! + + """ + The emoji as an icon + """ + emoji: String! + + """ + The emoji name + """ + name: String! + + """ + The emoji in unicode + """ + unicode: String! + + """ + The unicode version for this emoji + """ + unicodeVersion: String! + + """ + The user who awarded the emoji + """ + user: User! +} + +type Blob implements Entry { + flatPath: String! + id: ID! + lfsOid: String + name: String! + path: String! + + """ + Last commit sha for entry + """ + sha: String! + type: EntryType! + webUrl: String +} + +""" +The connection type for Blob. +""" +type BlobConnection { + """ + A list of edges. + """ + edges: [BlobEdge] + + """ + A list of nodes. + """ + nodes: [Blob] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type BlobEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Blob +} + +type Commit { + """ + Author of the commit + """ + author: User + + """ + Commit authors name + """ + authorName: String + + """ + Timestamp of when the commit was authored + """ + authoredDate: Time + + """ + Description of the commit message + """ + description: String + + """ + ID (global ID) of the commit + """ + id: ID! + + """ + Latest pipeline of the commit + """ + latestPipeline( + """ + Filter pipelines by the ref they are run for + """ + ref: String + + """ + Filter pipelines by the sha of the commit they are run for + """ + sha: String + + """ + Filter pipelines by their status + """ + status: PipelineStatusEnum + ): Pipeline @deprecated(reason: "use pipelines") + + """ + Raw commit message + """ + message: String + + """ + Pipelines of the commit ordered latest first + """ + pipelines( + """ + 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 + + """ + Filter pipelines by the ref they are run for + """ + ref: String + + """ + Filter pipelines by the sha of the commit they are run for + """ + sha: String + + """ + Filter pipelines by their status + """ + status: PipelineStatusEnum + ): PipelineConnection + + """ + SHA1 ID of the commit + """ + sha: String! + + """ + Rendered HTML of the commit signature + """ + signatureHtml: String + + """ + Title of the commit message + """ + title: String + + """ + Web URL of the commit + """ + webUrl: String! +} + +""" +Autogenerated input type of CreateDiffNote +""" +input CreateDiffNoteInput { + """ + The content note itself + """ + body: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The global id of the resource to add a note to + """ + noteableId: ID! + + """ + The position of this note on a diff + """ + position: DiffPositionInput! +} + +""" +Autogenerated return type of CreateDiffNote +""" +type CreateDiffNotePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + The note after mutation + """ + note: Note +} + +""" +Autogenerated input type of CreateEpic +""" +input CreateEpicInput { + """ + The IDs of labels to be added to the epic. + """ + addLabelIds: [ID!] + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The description of the epic + """ + description: String + + """ + The end date of the epic + """ + dueDateFixed: String + + """ + Indicates end date should be sourced from due_date_fixed field not the issue milestones + """ + dueDateIsFixed: Boolean + + """ + The group the epic to mutate is in + """ + groupPath: ID! + + """ + The IDs of labels to be removed from the epic. + """ + removeLabelIds: [ID!] + + """ + The start date of the epic + """ + startDateFixed: String + + """ + Indicates start date should be sourced from start_date_fixed field not the issue milestones + """ + startDateIsFixed: Boolean + + """ + The title of the epic + """ + title: String +} + +""" +Autogenerated return type of CreateEpic +""" +type CreateEpicPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The created epic + """ + epic: Epic + + """ + Reasons why the mutation failed. + """ + errors: [String!]! +} + +""" +Autogenerated input type of CreateImageDiffNote +""" +input CreateImageDiffNoteInput { + """ + The content note itself + """ + body: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The global id of the resource to add a note to + """ + noteableId: ID! + + """ + The position of this note on a diff + """ + position: DiffImagePositionInput! +} + +""" +Autogenerated return type of CreateImageDiffNote +""" +type CreateImageDiffNotePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + The note after mutation + """ + note: Note +} + +""" +Autogenerated input type of CreateNote +""" +input CreateNoteInput { + """ + The content note itself + """ + body: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The global id of the discussion this note is in reply to + """ + discussionId: ID + + """ + The global id of the resource to add a note to + """ + noteableId: ID! +} + +""" +Autogenerated return type of CreateNote +""" +type CreateNotePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + The note after mutation + """ + note: Note +} + +type Design implements Noteable { + diffRefs: DiffRefs! + + """ + All discussions on this noteable + """ + discussions( + """ + 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 + ): DiscussionConnection! + + """ + The change that happened to the design at this version + """ + event: DesignVersionEvent! + filename: String! + fullPath: String! + id: ID! + image: String! + issue: Issue! + + """ + All notes on this noteable + """ + notes( + """ + 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 + ): NoteConnection! + + """ + The total count of user-created notes for this design + """ + notesCount: Int! + project: Project! + + """ + All versions related to this design ordered newest first + """ + versions( + """ + 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 + ): DesignVersionConnection! +} + +type DesignCollection { + """ + All designs for this collection + """ + designs( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Filters designs to only those that existed at the version. If argument is + omitted or nil then all designs will reflect the latest version + """ + atVersion: ID + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Filters designs by their filename + """ + filenames: [String!] + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Filters designs by their ID + """ + ids: [ID!] + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): DesignConnection! + issue: Issue! + project: Project! + + """ + All versions related to all designs ordered newest first + """ + versions( + """ + 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 + ): DesignVersionConnection! +} + +""" +The connection type for Design. +""" +type DesignConnection { + """ + A list of edges. + """ + edges: [DesignEdge] + + """ + A list of nodes. + """ + nodes: [Design] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type DesignEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Design +} + +""" +Autogenerated input type of DesignManagementDelete +""" +input DesignManagementDeleteInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The filenames of the designs to delete + """ + filenames: [String!]! + + """ + The iid of the issue to modify designs for + """ + iid: ID! + + """ + The project where the issue is to upload designs for + """ + projectPath: ID! +} + +""" +Autogenerated return type of DesignManagementDelete +""" +type DesignManagementDeletePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + The new version in which the designs are deleted + """ + version: DesignVersion +} + +""" +Autogenerated input type of DesignManagementUpload +""" +input DesignManagementUploadInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The files to upload + """ + files: [Upload!]! + + """ + The iid of the issue to modify designs for + """ + iid: ID! + + """ + The project where the issue is to upload designs for + """ + projectPath: ID! +} + +""" +Autogenerated return type of DesignManagementUpload +""" +type DesignManagementUploadPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The designs that were uploaded by the mutation + """ + designs: [Design!]! + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + Any designs that were skipped from the upload due to there being no change to their content since their last version + """ + skippedDesigns: [Design!]! +} + +type DesignVersion { + """ + All designs that were changed in this version + """ + designs( + """ + 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 + ): DesignConnection! + id: ID! + sha: ID! +} + +""" +The connection type for DesignVersion. +""" +type DesignVersionConnection { + """ + A list of edges. + """ + edges: [DesignVersionEdge] + + """ + A list of nodes. + """ + nodes: [DesignVersion] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type DesignVersionEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: DesignVersion +} + +""" +Mutation event of a Design within a Version +""" +enum DesignVersionEvent { + """ + A creation event + """ + CREATION + + """ + A deletion event + """ + DELETION + + """ + A modification event + """ + MODIFICATION + + """ + No change + """ + NONE +} + +""" +Autogenerated input type of DestroyNote +""" +input DestroyNoteInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The global id of the note to destroy + """ + id: ID! +} + +""" +Autogenerated return type of DestroyNote +""" +type DestroyNotePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + The note after mutation + """ + note: Note +} + +type DetailedStatus { + detailsPath: String! + favicon: String! + group: String! + hasDetails: Boolean! + icon: String! + label: String! + text: String! + tooltip: String! +} + +input DiffImagePositionInput { + """ + The merge base of the branch the comment was made on + """ + baseSha: String + + """ + The sha of the head at the time the comment was made + """ + headSha: String! + + """ + The total height of the image + """ + height: Int! + + """ + The paths of the file that was changed. Both of the properties of this input + are optional, but at least one of them is required + """ + paths: DiffPathsInput! + + """ + The sha of the branch being compared against + """ + startSha: String! + + """ + The total width of the image + """ + width: Int! + + """ + The X postion on which the comment was made + """ + x: Int! + + """ + The Y position on which the comment was made + """ + y: Int! +} + +input DiffPathsInput { + """ + The path of the file on the head sha + """ + newPath: String + + """ + The path of the file on the start sha + """ + oldPath: String +} + +type DiffPosition { + diffRefs: DiffRefs! + + """ + The path of the file that was changed + """ + filePath: String! + + """ + The total height of the image + """ + height: Int + + """ + The line on head sha that was changed + """ + newLine: Int + + """ + The path of the file on the head sha. + """ + newPath: String + + """ + The line on start sha that was changed + """ + oldLine: Int + + """ + The path of the file on the start sha. + """ + oldPath: String + positionType: DiffPositionType! + + """ + The total width of the image + """ + width: Int + + """ + The X postion on which the comment was made + """ + x: Int + + """ + The Y position on which the comment was made + """ + y: Int +} + +input DiffPositionInput { + """ + The merge base of the branch the comment was made on + """ + baseSha: String + + """ + The sha of the head at the time the comment was made + """ + headSha: String! + + """ + The line on head sha that was changed + """ + newLine: Int! + + """ + The line on start sha that was changed + """ + oldLine: Int + + """ + The paths of the file that was changed. Both of the properties of this input + are optional, but at least one of them is required + """ + paths: DiffPathsInput! + + """ + The sha of the branch being compared against + """ + startSha: String! +} + +""" +Type of file the position refers to +""" +enum DiffPositionType { + image + text +} + +type DiffRefs { + """ + The merge base of the branch the comment was made on + """ + baseSha: String! + + """ + The sha of the head at the time the comment was made + """ + headSha: String! + + """ + The sha of the branch being compared against + """ + startSha: String! +} + +type Discussion { + createdAt: Time! + id: ID! + + """ + All notes in the discussion + """ + notes( + """ + 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 + ): NoteConnection! + + """ + The ID used to reply to this discussion + """ + replyId: ID! +} + +""" +The connection type for Discussion. +""" +type DiscussionConnection { + """ + A list of edges. + """ + edges: [DiscussionEdge] + + """ + A list of nodes. + """ + nodes: [Discussion] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type DiscussionEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Discussion +} + +interface Entry { + flatPath: String! + id: ID! + name: String! + path: String! + + """ + Last commit sha for entry + """ + sha: String! + type: EntryType! +} + +""" +Type of a tree entry +""" +enum EntryType { + blob + commit + tree +} + +type Epic implements Noteable { + author: User! + children( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Filter epics by author + """ + authorUsername: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + List epics within a time frame where epics.end_date is between start_date + and end_date parameters (start_date parameter must be present) + """ + endDate: Time + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + The IID of the epic, e.g., "1" + """ + iid: ID + + """ + The list of IIDs of epics, e.g., [1, 2] + """ + iids: [ID!] + + """ + Filter epics by labels + """ + labelName: [String!] + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Filter epics by title and description + """ + search: String + + """ + List epics by sort order + """ + sort: EpicSort + + """ + List epics within a time frame where epics.start_date is between start_date + and end_date parameters (end_date parameter must be present) + """ + startDate: Time + + """ + Filter epics by state + """ + state: EpicState + ): EpicConnection + closedAt: Time + createdAt: Time + + """ + Number of open and closed descendant epics and issues + """ + descendantCounts: EpicDescendantCount + description: String + + """ + All discussions on this noteable + """ + discussions( + """ + 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 + ): DiscussionConnection! + dueDate: Time + dueDateFixed: Time + dueDateFromMilestones: Time + dueDateIsFixed: Boolean + group: Group! + hasChildren: Boolean! + hasIssues: Boolean! + id: ID! + iid: ID! + + """ + A list of issues associated with the epic + """ + issues( + """ + 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 + ): EpicIssueConnection + + """ + Labels assigned to the epic + """ + labels( + """ + 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 + ): LabelConnection + + """ + All notes on this noteable + """ + notes( + """ + 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 + ): NoteConnection! + parent: Epic + + """ + List of participants for the epic + """ + participants( + """ + 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 + ): UserConnection + reference(full: Boolean = false): String! + relationPath: String + + """ + The relative position of the epic in the Epic tree + """ + relativePosition: Int + startDate: Time + startDateFixed: Time + startDateFromMilestones: Time + startDateIsFixed: Boolean + state: EpicState! + + """ + Boolean flag for whether the currently logged in user is subscribed to this epic + """ + subscribed: Boolean! + title: String + updatedAt: Time + + """ + Permissions for the current user on the resource + """ + userPermissions: EpicPermissions! + webPath: String! + webUrl: String! +} + +""" +The connection type for Epic. +""" +type EpicConnection { + """ + A list of edges. + """ + edges: [EpicEdge] + + """ + A list of nodes. + """ + nodes: [Epic] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +type EpicDescendantCount { + """ + Number of closed sub-epics + """ + closedEpics: Int + + """ + Number of closed epic issues + """ + closedIssues: Int + + """ + Number of opened sub-epics + """ + openedEpics: Int + + """ + Number of opened epic issues + """ + openedIssues: Int +} + +""" +An edge in a connection. +""" +type EpicEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Epic +} + +type EpicIssue implements Noteable { + """ + Assignees of the issue + """ + assignees( + """ + 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 + ): UserConnection + + """ + User that created the issue + """ + author: User! + + """ + Timestamp of when the issue was closed + """ + closedAt: Time + + """ + Indicates the issue is confidential + """ + confidential: Boolean! + + """ + Timestamp of when the issue was created + """ + createdAt: Time! + + """ + Description of the issue + """ + description: String + + """ + The GitLab Flavored Markdown rendering of `description` + """ + descriptionHtml: String + designCollection: DesignCollection + designs: DesignCollection @deprecated(reason: "use design_collection") + + """ + Indicates discussion is locked on the issue + """ + discussionLocked: Boolean! + + """ + All discussions on this noteable + """ + discussions( + """ + 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 + ): DiscussionConnection! + + """ + Number of downvotes the issue has received + """ + downvotes: Int! + + """ + Due date of the issue + """ + dueDate: Time + + """ + The epic to which issue belongs + """ + epic: Epic + epicIssueId: ID! + + """ + The global id of the epic-issue relation + """ + id: ID + + """ + Internal ID of the issue + """ + iid: ID! + + """ + Labels of the issue + """ + labels( + """ + 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 + ): LabelConnection + + """ + Milestone of the issue + """ + milestone: Milestone + + """ + All notes on this noteable + """ + notes( + """ + 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 + ): NoteConnection! + + """ + List of participants in the issue + """ + participants( + """ + 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 + ): UserConnection + + """ + Internal reference of the issue. Returned in shortened format by default + """ + reference( + """ + Boolean option specifying whether the reference should be returned in full + """ + full: Boolean = false + ): String! + relationPath: String + + """ + Relative position of the issue (used for positioning in epic tree and issue boards) + """ + relativePosition: Int + + """ + State of the issue + """ + state: IssueState! + + """ + Boolean flag for whether the currently logged in user is subscribed to this issue + """ + subscribed: Boolean! + + """ + Task completion status of the issue + """ + taskCompletionStatus: TaskCompletionStatus! + + """ + Time estimate of the issue + """ + timeEstimate: Int! + + """ + Title of the issue + """ + title: String! + + """ + The GitLab Flavored Markdown rendering of `title` + """ + titleHtml: String + + """ + Total time reported as spent on the issue + """ + totalTimeSpent: Int! + + """ + Timestamp of when the issue was last updated + """ + updatedAt: Time! + + """ + Number of upvotes the issue has received + """ + upvotes: Int! + + """ + Number of user notes of the issue + """ + userNotesCount: Int! + + """ + Permissions for the current user on the resource + """ + userPermissions: IssuePermissions! + + """ + Web path of the issue + """ + webPath: String! + + """ + Web URL of the issue + """ + webUrl: String! + weight: Int +} + +""" +The connection type for EpicIssue. +""" +type EpicIssueConnection { + """ + A list of edges. + """ + edges: [EpicIssueEdge] + + """ + A list of nodes. + """ + nodes: [EpicIssue] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type EpicIssueEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: EpicIssue +} + +""" +Check permissions for the current user on an epic +""" +type EpicPermissions { + """ + Whether or not a user can perform `admin_epic` on this resource + """ + adminEpic: Boolean! + + """ + Whether or not a user can perform `award_emoji` on this resource + """ + awardEmoji: Boolean! + + """ + Whether or not a user can perform `create_epic` on this resource + """ + createEpic: Boolean! + + """ + Whether or not a user can perform `create_note` on this resource + """ + createNote: Boolean! + + """ + Whether or not a user can perform `destroy_epic` on this resource + """ + destroyEpic: Boolean! + + """ + Whether or not a user can perform `read_epic` on this resource + """ + readEpic: Boolean! + + """ + Whether or not a user can perform `read_epic_iid` on this resource + """ + readEpicIid: Boolean! + + """ + Whether or not a user can perform `update_epic` on this resource + """ + updateEpic: Boolean! +} + +""" +Autogenerated input type of EpicSetSubscription +""" +input EpicSetSubscriptionInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The group the epic to (un)subscribe is in + """ + groupPath: ID! + + """ + The iid of the epic to (un)subscribe + """ + iid: ID! + + """ + The desired state of the subscription + """ + subscribedState: Boolean! +} + +""" +Autogenerated return type of EpicSetSubscription +""" +type EpicSetSubscriptionPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The epic after mutation + """ + epic: Epic + + """ + Reasons why the mutation failed. + """ + errors: [String!]! +} + +""" +Roadmap sort values +""" +enum EpicSort { + """ + End date at ascending order + """ + end_date_asc + + """ + End date at descending order + """ + end_date_desc + + """ + Start date at ascending order + """ + start_date_asc + + """ + Start date at descending order + """ + start_date_desc +} + +""" +State of a GitLab epic +""" +enum EpicState { + all + closed + opened +} + +""" +State event of a GitLab Epic +""" +enum EpicStateEvent { + """ + Close the Epic + """ + CLOSE + + """ + Reopen the Epic + """ + REOPEN +} + +input EpicTreeNodeFieldsInputType { + """ + The id of the epic_issue or issue that the actual epic or issue is switched with + """ + adjacentReferenceId: ID! + + """ + The id of the epic_issue or epic that is being moved + """ + id: ID! + + """ + The type of the switch, after or before allowed + """ + relativePosition: MoveType! +} + +""" +Autogenerated input type of EpicTreeReorder +""" +input EpicTreeReorderInput { + """ + The id of the base epic of the tree + """ + baseEpicId: ID! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Parameters for updating the tree positions + """ + moved: EpicTreeNodeFieldsInputType! +} + +""" +Autogenerated return type of EpicTreeReorder +""" +type EpicTreeReorderPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! +} + +type Group { + """ + Avatar URL of the group + """ + avatarUrl: String + + """ + Description of the namespace + """ + description: String + + """ + The GitLab Flavored Markdown rendering of `description` + """ + descriptionHtml: String + epic( + """ + Filter epics by author + """ + authorUsername: String + + """ + List epics within a time frame where epics.end_date is between start_date + and end_date parameters (start_date parameter must be present) + """ + endDate: Time + + """ + The IID of the epic, e.g., "1" + """ + iid: ID + + """ + The list of IIDs of epics, e.g., [1, 2] + """ + iids: [ID!] + + """ + Filter epics by labels + """ + labelName: [String!] + + """ + Filter epics by title and description + """ + search: String + + """ + List epics by sort order + """ + sort: EpicSort + + """ + List epics within a time frame where epics.start_date is between start_date + and end_date parameters (end_date parameter must be present) + """ + startDate: Time + + """ + Filter epics by state + """ + state: EpicState + ): Epic + epics( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Filter epics by author + """ + authorUsername: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + List epics within a time frame where epics.end_date is between start_date + and end_date parameters (start_date parameter must be present) + """ + endDate: Time + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + The IID of the epic, e.g., "1" + """ + iid: ID + + """ + The list of IIDs of epics, e.g., [1, 2] + """ + iids: [ID!] + + """ + Filter epics by labels + """ + labelName: [String!] + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Filter epics by title and description + """ + search: String + + """ + List epics by sort order + """ + sort: EpicSort + + """ + List epics within a time frame where epics.start_date is between start_date + and end_date parameters (end_date parameter must be present) + """ + startDate: Time + + """ + Filter epics by state + """ + state: EpicState + ): EpicConnection + epicsEnabled: Boolean + + """ + Full name of the namespace + """ + fullName: String! + + """ + Full path of the namespace + """ + fullPath: ID! + + """ + ID of the namespace + """ + id: ID! + + """ + Indicates if Large File Storage (LFS) is enabled for namespace + """ + lfsEnabled: Boolean + + """ + Name of the namespace + """ + name: String! + + """ + Parent group + """ + parent: Group + + """ + Path of the namespace + """ + path: String! + + """ + Projects within this namespace + """ + projects( + """ + 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 + + """ + Include also subgroup projects + """ + includeSubgroups: Boolean = false + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): ProjectConnection! + + """ + Indicates if users can request access to namespace + """ + requestAccessEnabled: Boolean + + """ + Aggregated storage statistics of the namespace. Only available for root namespaces + """ + rootStorageStatistics: RootStorageStatistics + + """ + Permissions for the current user on the resource + """ + userPermissions: GroupPermissions! + + """ + Visibility of the namespace + """ + visibility: String + + """ + Web URL of the group + """ + webUrl: String! +} + +type GroupPermissions { + """ + Whether or not a user can perform `read_group` on this resource + """ + readGroup: Boolean! +} + +""" +State of a GitLab issue or merge request +""" +enum IssuableState { + closed + locked + opened +} + +type Issue implements Noteable { + """ + Assignees of the issue + """ + assignees( + """ + 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 + ): UserConnection + + """ + User that created the issue + """ + author: User! + + """ + Timestamp of when the issue was closed + """ + closedAt: Time + + """ + Indicates the issue is confidential + """ + confidential: Boolean! + + """ + Timestamp of when the issue was created + """ + createdAt: Time! + + """ + Description of the issue + """ + description: String + + """ + The GitLab Flavored Markdown rendering of `description` + """ + descriptionHtml: String + designCollection: DesignCollection + designs: DesignCollection @deprecated(reason: "use design_collection") + + """ + Indicates discussion is locked on the issue + """ + discussionLocked: Boolean! + + """ + All discussions on this noteable + """ + discussions( + """ + 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 + ): DiscussionConnection! + + """ + Number of downvotes the issue has received + """ + downvotes: Int! + + """ + Due date of the issue + """ + dueDate: Time + + """ + The epic to which issue belongs + """ + epic: Epic + + """ + Internal ID of the issue + """ + iid: ID! + + """ + Labels of the issue + """ + labels( + """ + 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 + ): LabelConnection + + """ + Milestone of the issue + """ + milestone: Milestone + + """ + All notes on this noteable + """ + notes( + """ + 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 + ): NoteConnection! + + """ + List of participants in the issue + """ + participants( + """ + 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 + ): UserConnection + + """ + Internal reference of the issue. Returned in shortened format by default + """ + reference( + """ + Boolean option specifying whether the reference should be returned in full + """ + full: Boolean = false + ): String! + + """ + Relative position of the issue (used for positioning in epic tree and issue boards) + """ + relativePosition: Int + + """ + State of the issue + """ + state: IssueState! + + """ + Boolean flag for whether the currently logged in user is subscribed to this issue + """ + subscribed: Boolean! + + """ + Task completion status of the issue + """ + taskCompletionStatus: TaskCompletionStatus! + + """ + Time estimate of the issue + """ + timeEstimate: Int! + + """ + Title of the issue + """ + title: String! + + """ + The GitLab Flavored Markdown rendering of `title` + """ + titleHtml: String + + """ + Total time reported as spent on the issue + """ + totalTimeSpent: Int! + + """ + Timestamp of when the issue was last updated + """ + updatedAt: Time! + + """ + Number of upvotes the issue has received + """ + upvotes: Int! + + """ + Number of user notes of the issue + """ + userNotesCount: Int! + + """ + Permissions for the current user on the resource + """ + userPermissions: IssuePermissions! + + """ + Web path of the issue + """ + webPath: String! + + """ + Web URL of the issue + """ + webUrl: String! + weight: Int +} + +""" +The connection type for Issue. +""" +type IssueConnection { + """ + A list of edges. + """ + edges: [IssueEdge] + + """ + A list of nodes. + """ + nodes: [Issue] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type IssueEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Issue +} + +""" +Check permissions for the current user on a issue +""" +type IssuePermissions { + """ + Whether or not a user can perform `admin_issue` on this resource + """ + adminIssue: Boolean! + + """ + Whether or not a user can perform `create_design` on this resource + """ + createDesign: Boolean! + + """ + Whether or not a user can perform `create_note` on this resource + """ + createNote: Boolean! + + """ + Whether or not a user can perform `destroy_design` on this resource + """ + destroyDesign: Boolean! + + """ + Whether or not a user can perform `read_design` on this resource + """ + readDesign: Boolean! + + """ + Whether or not a user can perform `read_issue` on this resource + """ + readIssue: Boolean! + + """ + Whether or not a user can perform `reopen_issue` on this resource + """ + reopenIssue: Boolean! + + """ + Whether or not a user can perform `update_issue` on this resource + """ + updateIssue: Boolean! +} + +""" +Values for sorting issues +""" +enum IssueSort { + """ + Due date by ascending order + """ + DUE_DATE_ASC + + """ + Due date by descending order + """ + DUE_DATE_DESC + + """ + Relative position by ascending order + """ + RELATIVE_POSITION_ASC + + """ + Created at ascending order + """ + created_asc + + """ + Created at descending order + """ + created_desc + + """ + Updated at ascending order + """ + updated_asc + + """ + Updated at descending order + """ + updated_desc +} + +""" +State of a GitLab issue +""" +enum IssueState { + closed + locked + opened +} + +type Label { + """ + Background color of the label + """ + color: String! + + """ + Description of the label (markdown rendered as HTML for caching) + """ + description: String + + """ + The GitLab Flavored Markdown rendering of `description` + """ + descriptionHtml: String + + """ + Label ID + """ + id: ID! + + """ + Text color of the label + """ + textColor: String! + + """ + Content of the label + """ + title: String! +} + +""" +The connection type for Label. +""" +type LabelConnection { + """ + A list of edges. + """ + edges: [LabelEdge] + + """ + A list of nodes. + """ + nodes: [Label] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type LabelEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Label +} + +type MergeRequest implements Noteable { + """ + Indicates if members of the target project can push to the fork + """ + allowCollaboration: Boolean + + """ + Assignees of the merge request + """ + assignees( + """ + 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 + ): UserConnection + + """ + Timestamp of when the merge request was created + """ + createdAt: Time! + + """ + Default merge commit message of the merge request + """ + defaultMergeCommitMessage: String + + """ + Description of the merge request (markdown rendered as HTML for caching) + """ + description: String + + """ + The GitLab Flavored Markdown rendering of `description` + """ + descriptionHtml: String + + """ + Diff head SHA of the merge request + """ + diffHeadSha: String + + """ + References of the base SHA, the head SHA, and the start SHA for this merge request + """ + diffRefs: DiffRefs + + """ + Indicates if comments on the merge request are locked to members only + """ + discussionLocked: Boolean! + + """ + All discussions on this noteable + """ + discussions( + """ + 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 + ): DiscussionConnection! + + """ + Number of downvotes for the merge request + """ + downvotes: Int! + + """ + Indicates if the project settings will lead to source branch deletion after merge + """ + forceRemoveSourceBranch: Boolean + + """ + The pipeline running on the branch HEAD of the merge request + """ + headPipeline: Pipeline + + """ + ID of the merge request + """ + id: ID! + + """ + Internal ID of the merge request + """ + iid: String! + + """ + Commit SHA of the merge request if merge is in progress + """ + inProgressMergeCommitSha: String + + """ + Labels of the merge request + """ + labels( + """ + 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 + ): LabelConnection + + """ + Deprecated - renamed to defaultMergeCommitMessage + """ + mergeCommitMessage: String @deprecated(reason: "Renamed to defaultMergeCommitMessage") + + """ + SHA of the merge request commit (set once merged) + """ + mergeCommitSha: String + + """ + Error message due to a merge error + """ + mergeError: String + + """ + Indicates if a merge is currently occurring + """ + mergeOngoing: Boolean! + + """ + Status of the merge request + """ + mergeStatus: String + + """ + Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS) + """ + mergeWhenPipelineSucceeds: Boolean + + """ + Indicates if all discussions in the merge request have been resolved, allowing the merge request to be merged + """ + mergeableDiscussionsState: Boolean + + """ + The milestone of the merge request + """ + milestone: Milestone + + """ + All notes on this noteable + """ + notes( + """ + 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 + ): NoteConnection! + + """ + Participants in the merge request + """ + participants( + """ + 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 + ): UserConnection + + """ + Pipelines for the merge request + """ + pipelines( + """ + 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 + + """ + Filter pipelines by the ref they are run for + """ + ref: String + + """ + Filter pipelines by the sha of the commit they are run for + """ + sha: String + + """ + Filter pipelines by their status + """ + status: PipelineStatusEnum + ): PipelineConnection! + + """ + Alias for target_project + """ + project: Project! + + """ + ID of the merge request project + """ + projectId: Int! + + """ + Rebase commit SHA of the merge request + """ + rebaseCommitSha: String + + """ + Indicates if there is a rebase currently in progress for the merge request + """ + rebaseInProgress: Boolean! + + """ + Internal reference of the merge request. Returned in shortened format by default + """ + reference( + """ + Boolean option specifying whether the reference should be returned in full + """ + full: Boolean = false + ): String! + + """ + Indicates if the merge request will be rebased + """ + shouldBeRebased: Boolean! + + """ + Indicates if the source branch of the merge request will be deleted after merge + """ + shouldRemoveSourceBranch: Boolean + + """ + Source branch of the merge request + """ + sourceBranch: String! + + """ + Indicates if the source branch of the merge request exists + """ + sourceBranchExists: Boolean! + + """ + Source project of the merge request + """ + sourceProject: Project + + """ + ID of the merge request source project + """ + sourceProjectId: Int + + """ + State of the merge request + """ + state: MergeRequestState! + + """ + Indicates if the currently logged in user is subscribed to this merge request + """ + subscribed: Boolean! + + """ + Target branch of the merge request + """ + targetBranch: String! + + """ + Target project of the merge request + """ + targetProject: Project! + + """ + ID of the merge request target project + """ + targetProjectId: Int! + + """ + Completion status of tasks + """ + taskCompletionStatus: TaskCompletionStatus! + + """ + Time estimate of the merge request + """ + timeEstimate: Int! + + """ + Title of the merge request + """ + title: String! + + """ + The GitLab Flavored Markdown rendering of `title` + """ + titleHtml: String + + """ + Total time reported as spent on the merge request + """ + totalTimeSpent: Int! + + """ + Timestamp of when the merge request was last updated + """ + updatedAt: Time! + + """ + Number of upvotes for the merge request + """ + upvotes: Int! + + """ + User notes count of the merge request + """ + userNotesCount: Int + + """ + Permissions for the current user on the resource + """ + userPermissions: MergeRequestPermissions! + + """ + Web URL of the merge request + """ + webUrl: String + + """ + Indicates if the merge request is a work in progress (WIP) + """ + workInProgress: Boolean! +} + +""" +The connection type for MergeRequest. +""" +type MergeRequestConnection { + """ + A list of edges. + """ + edges: [MergeRequestEdge] + + """ + A list of nodes. + """ + nodes: [MergeRequest] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type MergeRequestEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: MergeRequest +} + +""" +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 + """ + adminMergeRequest: Boolean! + + """ + Whether or not a 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 + """ + createNote: Boolean! + + """ + Whether or not a 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 + """ + readMergeRequest: Boolean! + + """ + Whether or not a 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 + """ + revertOnCurrentMergeRequest: Boolean! + + """ + Whether or not a user can perform `update_merge_request` on this resource + """ + updateMergeRequest: Boolean! +} + +""" +Autogenerated input type of MergeRequestSetAssignees +""" +input MergeRequestSetAssigneesInput { + """ + The usernames to assign to the merge request. Replaces existing assignees by default. + """ + assigneeUsernames: [String!]! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The iid of the merge request to mutate + """ + iid: String! + + """ + The operation to perform. Defaults to REPLACE. + """ + operationMode: MutationOperationMode + + """ + The project the merge request to mutate is in + """ + projectPath: ID! +} + +""" +Autogenerated return type of MergeRequestSetAssignees +""" +type MergeRequestSetAssigneesPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + The merge request after mutation + """ + mergeRequest: MergeRequest +} + +""" +Autogenerated input type of MergeRequestSetLabels +""" +input MergeRequestSetLabelsInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The iid of the merge request to mutate + """ + iid: String! + + """ + The Label IDs to set. Replaces existing labels by default. + """ + labelIds: [ID!]! + + """ + Changes the operation mode. Defaults to REPLACE. + """ + operationMode: MutationOperationMode + + """ + The project the merge request to mutate is in + """ + projectPath: ID! +} + +""" +Autogenerated return type of MergeRequestSetLabels +""" +type MergeRequestSetLabelsPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + The merge request after mutation + """ + mergeRequest: MergeRequest +} + +""" +Autogenerated input type of MergeRequestSetLocked +""" +input MergeRequestSetLockedInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The iid of the merge request to mutate + """ + iid: String! + + """ + Whether or not to lock the merge request. + """ + locked: Boolean! + + """ + The project the merge request to mutate is in + """ + projectPath: ID! +} + +""" +Autogenerated return type of MergeRequestSetLocked +""" +type MergeRequestSetLockedPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + The merge request after mutation + """ + mergeRequest: MergeRequest +} + +""" +Autogenerated input type of MergeRequestSetMilestone +""" +input MergeRequestSetMilestoneInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The iid of the merge request to mutate + """ + iid: String! + + """ + The milestone to assign to the merge request. + """ + milestoneId: ID + + """ + The project the merge request to mutate is in + """ + projectPath: ID! +} + +""" +Autogenerated return type of MergeRequestSetMilestone +""" +type MergeRequestSetMilestonePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + The merge request after mutation + """ + mergeRequest: MergeRequest +} + +""" +Autogenerated input type of MergeRequestSetSubscription +""" +input MergeRequestSetSubscriptionInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The iid of the merge request to mutate + """ + iid: String! + + """ + The project the merge request to mutate is in + """ + projectPath: ID! + + """ + The desired state of the subscription + """ + subscribedState: Boolean! +} + +""" +Autogenerated return type of MergeRequestSetSubscription +""" +type MergeRequestSetSubscriptionPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + The merge request after mutation + """ + mergeRequest: MergeRequest +} + +""" +Autogenerated input type of MergeRequestSetWip +""" +input MergeRequestSetWipInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The iid of the merge request to mutate + """ + iid: String! + + """ + The project the merge request to mutate is in + """ + projectPath: ID! + + """ + Whether or not to set the merge request as a WIP. + """ + wip: Boolean! +} + +""" +Autogenerated return type of MergeRequestSetWip +""" +type MergeRequestSetWipPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + The merge request after mutation + """ + mergeRequest: MergeRequest +} + +""" +State of a GitLab merge request +""" +enum MergeRequestState { + closed + locked + merged + opened +} + +type Metadata { + """ + Revision + """ + revision: String! + + """ + Version + """ + version: String! +} + +type Milestone { + """ + Timestamp of milestone creation + """ + createdAt: Time! + + """ + Description of the milestone + """ + description: String + + """ + Timestamp of the milestone due date + """ + dueDate: Time + + """ + ID of the milestone + """ + id: ID! + + """ + Timestamp of the milestone start date + """ + startDate: Time + + """ + State of the milestone + """ + state: String! + + """ + Title of the milestone + """ + title: String! + + """ + Timestamp of last milestone update + """ + updatedAt: Time! +} + +""" +The position the adjacent object should be moved. +""" +enum MoveType { + """ + The adjacent object will be moved after the object that is being moved. + """ + after + + """ + The adjacent object will be moved before the object that is being moved. + """ + before +} + +type Mutation { + addAwardEmoji(input: AddAwardEmojiInput!): AddAwardEmojiPayload + createDiffNote(input: CreateDiffNoteInput!): CreateDiffNotePayload + createEpic(input: CreateEpicInput!): CreateEpicPayload + createImageDiffNote(input: CreateImageDiffNoteInput!): CreateImageDiffNotePayload + createNote(input: CreateNoteInput!): CreateNotePayload + designManagementDelete(input: DesignManagementDeleteInput!): DesignManagementDeletePayload + designManagementUpload(input: DesignManagementUploadInput!): DesignManagementUploadPayload + destroyNote(input: DestroyNoteInput!): DestroyNotePayload + epicSetSubscription(input: EpicSetSubscriptionInput!): EpicSetSubscriptionPayload + epicTreeReorder(input: EpicTreeReorderInput!): EpicTreeReorderPayload + mergeRequestSetAssignees(input: MergeRequestSetAssigneesInput!): MergeRequestSetAssigneesPayload + mergeRequestSetLabels(input: MergeRequestSetLabelsInput!): MergeRequestSetLabelsPayload + mergeRequestSetLocked(input: MergeRequestSetLockedInput!): MergeRequestSetLockedPayload + mergeRequestSetMilestone(input: MergeRequestSetMilestoneInput!): MergeRequestSetMilestonePayload + mergeRequestSetSubscription(input: MergeRequestSetSubscriptionInput!): MergeRequestSetSubscriptionPayload + mergeRequestSetWip(input: MergeRequestSetWipInput!): MergeRequestSetWipPayload + removeAwardEmoji(input: RemoveAwardEmojiInput!): RemoveAwardEmojiPayload + todoMarkDone(input: TodoMarkDoneInput!): TodoMarkDonePayload + toggleAwardEmoji(input: ToggleAwardEmojiInput!): ToggleAwardEmojiPayload + updateEpic(input: UpdateEpicInput!): UpdateEpicPayload + updateNote(input: UpdateNoteInput!): UpdateNotePayload +} + +""" +Different toggles for changing mutator behavior. +""" +enum MutationOperationMode { + """ + Performs an append operation + """ + APPEND + + """ + Performs a removal operation + """ + REMOVE + + """ + Performs a replace operation + """ + REPLACE +} + +type Namespace { + """ + Description of the namespace + """ + description: String + + """ + The GitLab Flavored Markdown rendering of `description` + """ + descriptionHtml: String + + """ + Full name of the namespace + """ + fullName: String! + + """ + Full path of the namespace + """ + fullPath: ID! + + """ + ID of the namespace + """ + id: ID! + + """ + Indicates if Large File Storage (LFS) is enabled for namespace + """ + lfsEnabled: Boolean + + """ + Name of the namespace + """ + name: String! + + """ + Path of the namespace + """ + path: String! + + """ + Projects within this namespace + """ + projects( + """ + 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 + + """ + Include also subgroup projects + """ + includeSubgroups: Boolean = false + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): ProjectConnection! + + """ + Indicates if users can request access to namespace + """ + requestAccessEnabled: Boolean + + """ + Aggregated storage statistics of the namespace. Only available for root namespaces + """ + rootStorageStatistics: RootStorageStatistics + + """ + Visibility of the namespace + """ + visibility: String +} + +type Note { + """ + The user who wrote this note + """ + author: User! + + """ + The content note itself + """ + body: String! + + """ + The GitLab Flavored Markdown rendering of `note` + """ + bodyHtml: String + createdAt: Time! + + """ + The discussion this note is a part of + """ + discussion: Discussion + id: ID! + + """ + The position of this note on a diff + """ + position: DiffPosition + + """ + The project this note is associated to + """ + project: Project + resolvable: Boolean! + + """ + The time the discussion was resolved + """ + resolvedAt: Time + + """ + The user that resolved the discussion + """ + resolvedBy: User + + """ + Whether or not this note was created by the system or by a user + """ + system: Boolean! + updatedAt: Time! + + """ + Permissions for the current user on the resource + """ + userPermissions: NotePermissions! +} + +""" +The connection type for Note. +""" +type NoteConnection { + """ + A list of edges. + """ + edges: [NoteEdge] + + """ + A list of nodes. + """ + nodes: [Note] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type NoteEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Note +} + +type NotePermissions { + """ + Whether or not a user can perform `admin_note` on this resource + """ + adminNote: Boolean! + + """ + Whether or not a user can perform `award_emoji` on this resource + """ + awardEmoji: Boolean! + + """ + Whether or not a user can perform `create_note` on this resource + """ + createNote: Boolean! + + """ + Whether or not a user can perform `read_note` on this resource + """ + readNote: Boolean! + + """ + Whether or not a user can perform `resolve_note` on this resource + """ + resolveNote: Boolean! +} + +interface Noteable { + """ + All discussions on this noteable + """ + discussions( + """ + 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 + ): DiscussionConnection! + + """ + All notes on this noteable + """ + notes( + """ + 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 + ): NoteConnection! +} + +""" +Information about pagination in a connection. +""" +type PageInfo { + """ + When paginating forwards, the cursor to continue. + """ + endCursor: String + + """ + When paginating forwards, are there more items? + """ + hasNextPage: Boolean! + + """ + When paginating backwards, are there more items? + """ + hasPreviousPage: Boolean! + + """ + When paginating backwards, the cursor to continue. + """ + startCursor: String +} + +type Pipeline { + beforeSha: String + committedAt: Time + + """ + Coverage percentage + """ + coverage: Float + createdAt: Time! + detailedStatus: DetailedStatus! + + """ + Duration of the pipeline in seconds + """ + duration: Int + finishedAt: Time + id: ID! + iid: String! + sha: String! + startedAt: Time + status: PipelineStatusEnum! + updatedAt: Time! + + """ + Permissions for the current user on the resource + """ + userPermissions: PipelinePermissions! +} + +""" +The connection type for Pipeline. +""" +type PipelineConnection { + """ + A list of edges. + """ + edges: [PipelineEdge] + + """ + A list of nodes. + """ + nodes: [Pipeline] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type PipelineEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Pipeline +} + +type PipelinePermissions { + """ + Whether or not a user can perform `admin_pipeline` on this resource + """ + adminPipeline: Boolean! + + """ + Whether or not a user can perform `destroy_pipeline` on this resource + """ + destroyPipeline: Boolean! + + """ + Whether or not a user can perform `update_pipeline` on this resource + """ + updatePipeline: Boolean! +} + +enum PipelineStatusEnum { + CANCELED + CREATED + FAILED + MANUAL + PENDING + PREPARING + RUNNING + SCHEDULED + SKIPPED + SUCCESS +} + +type Project { + """ + Archived status of the project + """ + archived: Boolean + + """ + URL to avatar image file of the project + """ + avatarUrl: String + + """ + Indicates if the project stores Docker container images in a container registry + """ + containerRegistryEnabled: Boolean + + """ + Timestamp of the project creation + """ + createdAt: Time + + """ + Short description of the project + """ + description: String + + """ + The GitLab Flavored Markdown rendering of `description` + """ + descriptionHtml: String + + """ + Number of times the project has been forked + """ + forksCount: Int! + + """ + Full path of the project + """ + fullPath: ID! + + """ + Group of the project + """ + group: Group + + """ + URL to connect to the project via HTTPS + """ + httpUrlToRepo: String + + """ + ID of the project + """ + id: ID! + + """ + Status of project import background job of the project + """ + importStatus: String + + """ + A single issue of the project + """ + issue( + """ + Issues closed after this date + """ + closedAfter: Time + + """ + Issues closed before this date + """ + closedBefore: Time + + """ + Issues created after this date + """ + createdAfter: Time + + """ + Issues created before this date + """ + createdBefore: Time + + """ + The IID of the issue, e.g., "1" + """ + iid: String + + """ + The list of IIDs of issues, e.g., [1, 2] + """ + iids: [String!] + + """ + Labels applied to the Issue + """ + labelName: [String] + search: String + + """ + Sort issues by this criteria + """ + sort: IssueSort = created_desc + + """ + Current state of Issue + """ + state: IssuableState + + """ + Issues updated after this date + """ + updatedAfter: Time + + """ + Issues updated before this date + """ + updatedBefore: Time + ): Issue + + """ + Issues of the project + """ + issues( + """ + 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 + + """ + Issues closed after this date + """ + closedAfter: Time + + """ + Issues closed before this date + """ + closedBefore: Time + + """ + Issues created after this date + """ + createdAfter: Time + + """ + Issues created before this date + """ + createdBefore: Time + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + The IID of the issue, e.g., "1" + """ + iid: String + + """ + The list of IIDs of issues, e.g., [1, 2] + """ + iids: [String!] + + """ + Labels applied to the Issue + """ + labelName: [String] + + """ + Returns the last _n_ elements from the list. + """ + last: Int + search: String + + """ + Sort issues by this criteria + """ + sort: IssueSort = created_desc + + """ + Current state of Issue + """ + state: IssuableState + + """ + Issues updated after this date + """ + updatedAfter: Time + + """ + Issues updated before this date + """ + updatedBefore: Time + ): IssueConnection + + """ + (deprecated) Does this project have issues enabled?. Use `issues_access_level` instead + """ + issuesEnabled: Boolean + + """ + (deprecated) Enable jobs for this project. Use `builds_access_level` instead + """ + jobsEnabled: Boolean + + """ + Timestamp of the project last activity + """ + lastActivityAt: Time + + """ + Indicates if the project has Large File Storage (LFS) enabled + """ + lfsEnabled: Boolean + + """ + A single merge request of the project + """ + mergeRequest( + """ + The IID of the merge request, e.g., "1" + """ + iid: String + + """ + The list of IIDs of issues, e.g., [1, 2] + """ + iids: [String!] + ): MergeRequest + + """ + Merge requests of the project + """ + mergeRequests( + """ + 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 + + """ + The IID of the merge request, e.g., "1" + """ + iid: String + + """ + The list of IIDs of issues, e.g., [1, 2] + """ + iids: [String!] + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): MergeRequestConnection + + """ + (deprecated) Does this project have merge_requests enabled?. Use `merge_requests_access_level` instead + """ + mergeRequestsEnabled: Boolean + + """ + Indicates if no merge commits should be created and all merges should instead + be fast-forwarded, which means that merging is only allowed if the branch + could be fast-forwarded. + """ + mergeRequestsFfOnlyEnabled: Boolean + + """ + Name of the project (without namespace) + """ + name: String! + + """ + Full name of the project with its namespace + """ + nameWithNamespace: String! + + """ + Namespace of the project + """ + namespace: Namespace + + """ + Indicates if merge requests of the project can only be merged when all the discussions are resolved + """ + onlyAllowMergeIfAllDiscussionsAreResolved: Boolean + + """ + Indicates if merge requests of the project can only be merged with successful jobs + """ + onlyAllowMergeIfPipelineSucceeds: Boolean + + """ + Number of open issues for the project + """ + openIssuesCount: Int + + """ + Path of the project + """ + path: String! + + """ + Build pipelines of the project + """ + pipelines( + """ + 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 + + """ + Filter pipelines by the ref they are run for + """ + ref: String + + """ + Filter pipelines by the sha of the commit they are run for + """ + sha: String + + """ + Filter pipelines by their status + """ + status: PipelineStatusEnum + ): PipelineConnection + + """ + 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 + """ + printingMergeRequestLinkEnabled: Boolean + + """ + Indicates if there is public access to pipelines and job details of the project, including output logs and artifacts + """ + publicJobs: Boolean + + """ + Indicates if `Delete source branch` option should be enabled by default for all new merge requests of the project + """ + removeSourceBranchAfterMerge: Boolean + + """ + Git repository of the project + """ + repository: Repository + + """ + Indicates if users can request member access to the project + """ + requestAccessEnabled: Boolean + + """ + Indicates if shared runners are enabled on the project + """ + sharedRunnersEnabled: Boolean + + """ + (deprecated) Does this project have snippets enabled?. Use `snippets_access_level` instead + """ + snippetsEnabled: Boolean + + """ + URL to connect to the project via SSH + """ + sshUrlToRepo: String + + """ + Number of times the project has been starred + """ + starCount: Int! + + """ + Statistics of the project + """ + statistics: ProjectStatistics + + """ + List of project tags + """ + tagList: String + + """ + Permissions for the current user on the resource + """ + userPermissions: ProjectPermissions! + + """ + Visibility of the project + """ + visibility: String + + """ + Web URL of the project + """ + webUrl: String + + """ + (deprecated) Does this project have wiki enabled?. Use `wiki_access_level` instead + """ + wikiEnabled: Boolean +} + +""" +The connection type for Project. +""" +type ProjectConnection { + """ + A list of edges. + """ + edges: [ProjectEdge] + + """ + A list of nodes. + """ + nodes: [Project] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type ProjectEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Project +} + +type ProjectPermissions { + """ + Whether or not a user can perform `admin_operations` on this resource + """ + adminOperations: Boolean! + + """ + Whether or not a user can perform `admin_project` on this resource + """ + adminProject: Boolean! + + """ + Whether or not a user can perform `admin_remote_mirror` on this resource + """ + adminRemoteMirror: Boolean! + + """ + Whether or not a user can perform `admin_wiki` on this resource + """ + adminWiki: Boolean! + + """ + Whether or not a user can perform `archive_project` on this resource + """ + archiveProject: Boolean! + + """ + Whether or not a user can perform `change_namespace` on this resource + """ + changeNamespace: Boolean! + + """ + Whether or not a user can perform `change_visibility_level` on this resource + """ + changeVisibilityLevel: Boolean! + + """ + Whether or not a user can perform `create_deployment` on this resource + """ + createDeployment: Boolean! + + """ + Whether or not a user can perform `create_design` on this resource + """ + createDesign: Boolean! + + """ + Whether or not a user can perform `create_issue` on this resource + """ + createIssue: Boolean! + + """ + Whether or not a user can perform `create_label` on this resource + """ + createLabel: Boolean! + + """ + Whether or not a 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 + """ + createMergeRequestIn: Boolean! + + """ + Whether or not a user can perform `create_pages` on this resource + """ + createPages: Boolean! + + """ + Whether or not a user can perform `create_pipeline` on this resource + """ + createPipeline: Boolean! + + """ + Whether or not a user can perform `create_pipeline_schedule` on this resource + """ + createPipelineSchedule: Boolean! + + """ + Whether or not a user can perform `create_project_snippet` on this resource + """ + createProjectSnippet: Boolean! + + """ + Whether or not a user can perform `create_wiki` on this resource + """ + createWiki: Boolean! + + """ + Whether or not a user can perform `destroy_design` on this resource + """ + destroyDesign: Boolean! + + """ + Whether or not a user can perform `destroy_pages` on this resource + """ + destroyPages: Boolean! + + """ + Whether or not a user can perform `destroy_wiki` on this resource + """ + destroyWiki: Boolean! + + """ + Whether or not a user can perform `download_code` on this resource + """ + downloadCode: Boolean! + + """ + Whether or not a user can perform `download_wiki_code` on this resource + """ + downloadWikiCode: Boolean! + + """ + Whether or not a user can perform `fork_project` on this resource + """ + forkProject: Boolean! + + """ + Whether or not a 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 + """ + pushToDeleteProtectedBranch: Boolean! + + """ + Whether or not a user can perform `read_commit_status` on this resource + """ + readCommitStatus: Boolean! + + """ + Whether or not a user can perform `read_cycle_analytics` on this resource + """ + readCycleAnalytics: Boolean! + + """ + Whether or not a user can perform `read_design` on this resource + """ + readDesign: Boolean! + + """ + Whether or not a user can perform `read_pages_content` on this resource + """ + readPagesContent: Boolean! + + """ + Whether or not a user can perform `read_project` on this resource + """ + readProject: Boolean! + + """ + Whether or not a user can perform `read_project_member` on this resource + """ + readProjectMember: Boolean! + + """ + Whether or not a user can perform `read_wiki` on this resource + """ + readWiki: Boolean! + + """ + Whether or not a user can perform `remove_fork_project` on this resource + """ + removeForkProject: Boolean! + + """ + Whether or not a user can perform `remove_pages` on this resource + """ + removePages: Boolean! + + """ + Whether or not a user can perform `remove_project` on this resource + """ + removeProject: Boolean! + + """ + Whether or not a user can perform `rename_project` on this resource + """ + renameProject: Boolean! + + """ + Whether or not a user can perform `request_access` on this resource + """ + requestAccess: Boolean! + + """ + Whether or not a user can perform `update_pages` on this resource + """ + updatePages: Boolean! + + """ + Whether or not a user can perform `update_wiki` on this resource + """ + updateWiki: Boolean! + + """ + Whether or not a user can perform `upload_file` on this resource + """ + uploadFile: Boolean! +} + +type ProjectStatistics { + """ + Build artifacts size of the project + """ + buildArtifactsSize: Int! + + """ + Commit count of the project + """ + commitCount: Int! + + """ + Large File Storage (LFS) object size of the project + """ + lfsObjectsSize: Int! + + """ + Packages size of the project + """ + packagesSize: Int! + + """ + Repository size of the project + """ + repositorySize: Int! + + """ + Storage size of the project + """ + storageSize: Int! + + """ + Wiki size of the project + """ + wikiSize: Int +} + +type Query { + """ + Get information about current user + """ + currentUser: User + + """ + Testing endpoint to validate the API with + """ + echo(text: String!): String! + + """ + Find a group + """ + group( + """ + The full path of the project, group or namespace, e.g., "gitlab-org/gitlab-foss" + """ + fullPath: ID! + ): Group + + """ + Metadata about GitLab + """ + metadata: Metadata + + """ + Find a namespace + """ + namespace( + """ + The full path of the project, group or namespace, e.g., "gitlab-org/gitlab-foss" + """ + fullPath: ID! + ): Namespace + + """ + Find a project + """ + project( + """ + The full path of the project, group or namespace, e.g., "gitlab-org/gitlab-foss" + """ + fullPath: ID! + ): Project +} + +""" +Autogenerated input type of RemoveAwardEmoji +""" +input RemoveAwardEmojiInput { + """ + The global id of the awardable resource + """ + awardableId: ID! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The emoji name + """ + name: String! +} + +""" +Autogenerated return type of RemoveAwardEmoji +""" +type RemoveAwardEmojiPayload { + """ + The award emoji after mutation + """ + awardEmoji: AwardEmoji + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! +} + +type Repository { + """ + Indicates repository has no visible content + """ + empty: Boolean! + + """ + Indicates a corresponding Git repository exists on disk + """ + exists: Boolean! + + """ + Default branch of the repository + """ + rootRef: String + + """ + Tree of the repository + """ + tree( + """ + The path to get the tree for. Default value is the root of the repository + """ + path: String = "" + + """ + Used to get a recursive tree. Default is false + """ + recursive: Boolean = false + + """ + The commit ref to get the tree for. Default value is HEAD + """ + ref: String = "head" + ): Tree +} + +type RootStorageStatistics { + """ + The CI artifacts size in bytes + """ + buildArtifactsSize: Int! + + """ + The LFS objects size in bytes + """ + lfsObjectsSize: Int! + + """ + The packages size in bytes + """ + packagesSize: Int! + + """ + The git repository size in bytes + """ + repositorySize: Int! + + """ + The total storage in bytes + """ + storageSize: Int! + + """ + The wiki size in bytes + """ + wikiSize: Int! +} + +type Submodule implements Entry { + flatPath: String! + id: ID! + name: String! + path: String! + + """ + Last commit sha for entry + """ + sha: String! + treeUrl: String + type: EntryType! + webUrl: String +} + +""" +The connection type for Submodule. +""" +type SubmoduleConnection { + """ + A list of edges. + """ + edges: [SubmoduleEdge] + + """ + A list of nodes. + """ + nodes: [Submodule] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type SubmoduleEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Submodule +} + +""" +Completion status of tasks +""" +type TaskCompletionStatus { + """ + Number of completed tasks + """ + completedCount: Int! + + """ + Number of total tasks + """ + count: Int! +} + +""" +Time represented in ISO 8601 +""" +scalar Time + +""" +Representing a todo entry +""" +type Todo { + """ + Action of the todo + """ + action: TodoActionEnum! + + """ + The owner of this todo + """ + author: User! + + """ + Body of the todo + """ + body: String! + + """ + Timestamp this todo was created + """ + createdAt: Time! + + """ + Group this todo is associated with + """ + group: Group + + """ + Id of the todo + """ + id: ID! + + """ + The project this todo is associated with + """ + project: Project + + """ + State of the todo + """ + state: TodoStateEnum! + + """ + Target type of the todo + """ + targetType: TodoTargetEnum! +} + +enum TodoActionEnum { + approval_required + assigned + build_failed + directly_addressed + marked + mentioned + unmergeable +} + +""" +The connection type for Todo. +""" +type TodoConnection { + """ + A list of edges. + """ + edges: [TodoEdge] + + """ + A list of nodes. + """ + nodes: [Todo] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type TodoEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Todo +} + +""" +Autogenerated input type of TodoMarkDone +""" +input TodoMarkDoneInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The global id of the todo to mark as done + """ + id: ID! +} + +""" +Autogenerated return type of TodoMarkDone +""" +type TodoMarkDonePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + The requested todo + """ + todo: Todo! +} + +enum TodoStateEnum { + done + pending +} + +enum TodoTargetEnum { + """ + A Commit + """ + COMMIT + + """ + A Design + """ + DESIGN + + """ + An Epic + """ + EPIC + + """ + An Issue + """ + ISSUE + + """ + A MergeRequest + """ + MERGEREQUEST +} + +""" +Autogenerated input type of ToggleAwardEmoji +""" +input ToggleAwardEmojiInput { + """ + The global id of the awardable resource + """ + awardableId: ID! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The emoji name + """ + name: String! +} + +""" +Autogenerated return type of ToggleAwardEmoji +""" +type ToggleAwardEmojiPayload { + """ + The award emoji after mutation + """ + awardEmoji: AwardEmoji + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + True when the emoji was awarded, false when it was removed + """ + toggledOn: Boolean! +} + +type Tree { + blobs( + """ + 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 + ): BlobConnection! + + """ + Last commit for the tree + """ + lastCommit: Commit + submodules( + """ + 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 + ): SubmoduleConnection! + trees( + """ + 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 + ): TreeEntryConnection! +} + +""" +Represents a directory +""" +type TreeEntry implements Entry { + flatPath: String! + id: ID! + name: String! + path: String! + + """ + Last commit sha for entry + """ + sha: String! + type: EntryType! + webUrl: String +} + +""" +The connection type for TreeEntry. +""" +type TreeEntryConnection { + """ + A list of edges. + """ + edges: [TreeEntryEdge] + + """ + A list of nodes. + """ + nodes: [TreeEntry] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type TreeEntryEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: TreeEntry +} + +""" +Autogenerated input type of UpdateEpic +""" +input UpdateEpicInput { + """ + The IDs of labels to be added to the epic. + """ + addLabelIds: [ID!] + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The description of the epic + """ + description: String + + """ + The end date of the epic + """ + dueDateFixed: String + + """ + Indicates end date should be sourced from due_date_fixed field not the issue milestones + """ + dueDateIsFixed: Boolean + + """ + The group the epic to mutate is in + """ + groupPath: ID! + + """ + The iid of the epic to mutate + """ + iid: String! + + """ + The IDs of labels to be removed from the epic. + """ + removeLabelIds: [ID!] + + """ + The start date of the epic + """ + startDateFixed: String + + """ + Indicates start date should be sourced from start_date_fixed field not the issue milestones + """ + startDateIsFixed: Boolean + + """ + State event for the epic + """ + stateEvent: EpicStateEvent + + """ + The title of the epic + """ + title: String +} + +""" +Autogenerated return type of UpdateEpic +""" +type UpdateEpicPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The epic after mutation + """ + epic: Epic + + """ + Reasons why the mutation failed. + """ + errors: [String!]! +} + +""" +Autogenerated input type of UpdateNote +""" +input UpdateNoteInput { + """ + The content note itself + """ + body: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The global id of the note to update + """ + id: ID! +} + +""" +Autogenerated return type of UpdateNote +""" +type UpdateNotePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + The note after mutation + """ + note: Note +} + +scalar Upload + +type User { + """ + URL of the user's avatar + """ + avatarUrl: String! + + """ + Human-readable name of the user + """ + name: String! + + """ + Todos of the user + """ + todos( + """ + The action to be filtered + """ + action: [TodoActionEnum!] + + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + The ID of an author + """ + authorId: [ID!] + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + The ID of a group + """ + groupId: [ID!] + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + The ID of a project + """ + projectId: [ID!] + + """ + The state of the todo + """ + state: [TodoStateEnum!] + + """ + The type of the todo + """ + type: [TodoTargetEnum!] + ): TodoConnection! + + """ + Username of the user. Unique within this instance of GitLab + """ + username: String! + + """ + Web URL of the user + """ + webUrl: String! +} + +""" +The connection type for User. +""" +type UserConnection { + """ + A list of edges. + """ + edges: [UserEdge] + + """ + A list of nodes. + """ + nodes: [User] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type UserEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: User +} \ No newline at end of file diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json new file mode 100644 index 0000000000000000000000000000000000000000..fea67f28d69522caa6d8f580484396d15e79a5c8 --- /dev/null +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -0,0 +1,18749 @@ +{ + "data": { + "__schema": { + "queryType": { + "name": "Query" + }, + "mutationType": { + "name": "Mutation" + }, + "subscriptionType": null, + "types": [ + { + "kind": "SCALAR", + "name": "Boolean", + "description": "Represents `true` or `false` values.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "String", + "description": "Represents textual data as UTF-8 character sequences. This type is most often used by GraphQL to represent free-form human-readable text.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Query", + "description": null, + "fields": [ + { + "name": "currentUser", + "description": "Get information about current user", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "User", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "echo", + "description": "Testing endpoint to validate the API with", + "args": [ + { + "name": "text", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "group", + "description": "Find a group", + "args": [ + { + "name": "fullPath", + "description": "The full path of the project, group or namespace, e.g., \"gitlab-org/gitlab-foss\"", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Group", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Metadata about GitLab", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Metadata", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "namespace", + "description": "Find a namespace", + "args": [ + { + "name": "fullPath", + "description": "The full path of the project, group or namespace, e.g., \"gitlab-org/gitlab-foss\"", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Namespace", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "project", + "description": "Find a project", + "args": [ + { + "name": "fullPath", + "description": "The full path of the project, group or namespace, e.g., \"gitlab-org/gitlab-foss\"", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Project", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Project", + "description": null, + "fields": [ + { + "name": "archived", + "description": "Archived status of the project", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "avatarUrl", + "description": "URL to avatar image file of the project", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "containerRegistryEnabled", + "description": "Indicates if the project stores Docker container images in a container registry", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": "Timestamp of the project creation", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": "Short description of the project", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "descriptionHtml", + "description": "The GitLab Flavored Markdown rendering of `description`", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "forksCount", + "description": "Number of times the project has been forked", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fullPath", + "description": "Full path of the project", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "group", + "description": "Group of the project", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Group", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "httpUrlToRepo", + "description": "URL to connect to the project via HTTPS", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "ID of the project", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "importStatus", + "description": "Status of project import background job of the project", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issue", + "description": "A single issue of the project", + "args": [ + { + "name": "iid", + "description": "The IID of the issue, e.g., \"1\"", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "iids", + "description": "The list of IIDs of issues, e.g., [1, 2]", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "state", + "description": "Current state of Issue", + "type": { + "kind": "ENUM", + "name": "IssuableState", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "labelName", + "description": "Labels applied to the Issue", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "createdBefore", + "description": "Issues created before this date", + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "createdAfter", + "description": "Issues created after this date", + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "updatedBefore", + "description": "Issues updated before this date", + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "updatedAfter", + "description": "Issues updated after this date", + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "closedBefore", + "description": "Issues closed before this date", + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "closedAfter", + "description": "Issues closed after this date", + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "search", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "sort", + "description": "Sort issues by this criteria", + "type": { + "kind": "ENUM", + "name": "IssueSort", + "ofType": null + }, + "defaultValue": "created_desc" + } + ], + "type": { + "kind": "OBJECT", + "name": "Issue", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issues", + "description": "Issues of the project", + "args": [ + { + "name": "iid", + "description": "The IID of the issue, e.g., \"1\"", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "iids", + "description": "The list of IIDs of issues, e.g., [1, 2]", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "state", + "description": "Current state of Issue", + "type": { + "kind": "ENUM", + "name": "IssuableState", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "labelName", + "description": "Labels applied to the Issue", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "createdBefore", + "description": "Issues created before this date", + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "createdAfter", + "description": "Issues created after this date", + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "updatedBefore", + "description": "Issues updated before this date", + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "updatedAfter", + "description": "Issues updated after this date", + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "closedBefore", + "description": "Issues closed before this date", + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "closedAfter", + "description": "Issues closed after this date", + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "search", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "sort", + "description": "Sort issues by this criteria", + "type": { + "kind": "ENUM", + "name": "IssueSort", + "ofType": null + }, + "defaultValue": "created_desc" + }, + { + "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": "IssueConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issuesEnabled", + "description": "(deprecated) Does this project have issues enabled?. Use `issues_access_level` instead", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "jobsEnabled", + "description": "(deprecated) Enable jobs for this project. Use `builds_access_level` instead", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lastActivityAt", + "description": "Timestamp of the project last activity", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lfsEnabled", + "description": "Indicates if the project has Large File Storage (LFS) enabled", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequest", + "description": "A single merge request of the project", + "args": [ + { + "name": "iid", + "description": "The IID of the merge request, e.g., \"1\"", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "iids", + "description": "The list of IIDs of issues, e.g., [1, 2]", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequest", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequests", + "description": "Merge requests of the project", + "args": [ + { + "name": "iid", + "description": "The IID of the merge request, e.g., \"1\"", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "iids", + "description": "The list of IIDs of issues, e.g., [1, 2]", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "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": "MergeRequestConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequestsEnabled", + "description": "(deprecated) Does this project have merge_requests enabled?. Use `merge_requests_access_level` instead", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequestsFfOnlyEnabled", + "description": "Indicates if no merge commits should be created and all merges should instead be fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "Name of the project (without namespace)", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nameWithNamespace", + "description": "Full name of the project with its namespace", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "namespace", + "description": "Namespace of the project", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Namespace", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "onlyAllowMergeIfAllDiscussionsAreResolved", + "description": "Indicates if merge requests of the project can only be merged when all the discussions are resolved", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "onlyAllowMergeIfPipelineSucceeds", + "description": "Indicates if merge requests of the project can only be merged with successful jobs", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "openIssuesCount", + "description": "Number of open issues for the project", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "path", + "description": "Path of the project", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pipelines", + "description": "Build pipelines of the project", + "args": [ + { + "name": "status", + "description": "Filter pipelines by their status", + "type": { + "kind": "ENUM", + "name": "PipelineStatusEnum", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "ref", + "description": "Filter pipelines by the ref they are run for", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "sha", + "description": "Filter pipelines by the sha of the commit they are run for", + "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": "PipelineConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "printingMergeRequestLinkEnabled", + "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", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "publicJobs", + "description": "Indicates if there is public access to pipelines and job details of the project, including output logs and artifacts", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "removeSourceBranchAfterMerge", + "description": "Indicates if `Delete source branch` option should be enabled by default for all new merge requests of the project", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "repository", + "description": "Git repository of the project", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Repository", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "requestAccessEnabled", + "description": "Indicates if users can request member access to the project", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sharedRunnersEnabled", + "description": "Indicates if shared runners are enabled on the project", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "snippetsEnabled", + "description": "(deprecated) Does this project have snippets enabled?. Use `snippets_access_level` instead", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sshUrlToRepo", + "description": "URL to connect to the project via SSH", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "starCount", + "description": "Number of times the project has been starred", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "statistics", + "description": "Statistics of the project", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "ProjectStatistics", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tagList", + "description": "List of project tags", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userPermissions", + "description": "Permissions for the current user on the resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ProjectPermissions", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "visibility", + "description": "Visibility of the project", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webUrl", + "description": "Web URL of the project", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wikiEnabled", + "description": "(deprecated) Does this project have wiki enabled?. Use `wiki_access_level` instead", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ProjectPermissions", + "description": null, + "fields": [ + { + "name": "adminOperations", + "description": "Whether or not a user can perform `admin_operations` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "adminProject", + "description": "Whether or not a user can perform `admin_project` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "adminRemoteMirror", + "description": "Whether or not a user can perform `admin_remote_mirror` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "adminWiki", + "description": "Whether or not a user can perform `admin_wiki` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "archiveProject", + "description": "Whether or not a user can perform `archive_project` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "changeNamespace", + "description": "Whether or not a user can perform `change_namespace` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "changeVisibilityLevel", + "description": "Whether or not a user can perform `change_visibility_level` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createDeployment", + "description": "Whether or not a user can perform `create_deployment` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createDesign", + "description": "Whether or not a user can perform `create_design` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createIssue", + "description": "Whether or not a user can perform `create_issue` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createLabel", + "description": "Whether or not a user can perform `create_label` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createMergeRequestFrom", + "description": "Whether or not a user can perform `create_merge_request_from` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createMergeRequestIn", + "description": "Whether or not a user can perform `create_merge_request_in` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createPages", + "description": "Whether or not a user can perform `create_pages` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createPipeline", + "description": "Whether or not a user can perform `create_pipeline` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createPipelineSchedule", + "description": "Whether or not a user can perform `create_pipeline_schedule` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createProjectSnippet", + "description": "Whether or not a user can perform `create_project_snippet` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createWiki", + "description": "Whether or not a user can perform `create_wiki` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyDesign", + "description": "Whether or not a user can perform `destroy_design` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyPages", + "description": "Whether or not a user can perform `destroy_pages` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyWiki", + "description": "Whether or not a user can perform `destroy_wiki` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "downloadCode", + "description": "Whether or not a user can perform `download_code` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "downloadWikiCode", + "description": "Whether or not a user can perform `download_wiki_code` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "forkProject", + "description": "Whether or not a user can perform `fork_project` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pushCode", + "description": "Whether or not a user can perform `push_code` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pushToDeleteProtectedBranch", + "description": "Whether or not a user can perform `push_to_delete_protected_branch` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "readCommitStatus", + "description": "Whether or not a user can perform `read_commit_status` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "readCycleAnalytics", + "description": "Whether or not a user can perform `read_cycle_analytics` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "readDesign", + "description": "Whether or not a user can perform `read_design` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "readPagesContent", + "description": "Whether or not a user can perform `read_pages_content` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "readProject", + "description": "Whether or not a user can perform `read_project` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "readProjectMember", + "description": "Whether or not a user can perform `read_project_member` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "readWiki", + "description": "Whether or not a user can perform `read_wiki` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "removeForkProject", + "description": "Whether or not a user can perform `remove_fork_project` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "removePages", + "description": "Whether or not a user can perform `remove_pages` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "removeProject", + "description": "Whether or not a user can perform `remove_project` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "renameProject", + "description": "Whether or not a user can perform `rename_project` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "requestAccess", + "description": "Whether or not a user can perform `request_access` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatePages", + "description": "Whether or not a user can perform `update_pages` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateWiki", + "description": "Whether or not a user can perform `update_wiki` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "uploadFile", + "description": "Whether or not a user can perform `upload_file` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "ID", + "description": "Represents a unique identifier that is Base64 obfuscated. It is often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `\"VXNlci0xMA==\"`) or integer (such as `4`) input value will be accepted as an ID.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Int", + "description": "Represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Time", + "description": "Time represented in ISO 8601", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Namespace", + "description": null, + "fields": [ + { + "name": "description", + "description": "Description of the namespace", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "descriptionHtml", + "description": "The GitLab Flavored Markdown rendering of `description`", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fullName", + "description": "Full name of the namespace", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fullPath", + "description": "Full path of the namespace", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "ID of the namespace", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lfsEnabled", + "description": "Indicates if Large File Storage (LFS) is enabled for namespace", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "Name of the namespace", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "path", + "description": "Path of the namespace", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "projects", + "description": "Projects within this namespace", + "args": [ + { + "name": "includeSubgroups", + "description": "Include also subgroup projects", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false" + }, + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ProjectConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "requestAccessEnabled", + "description": "Indicates if users can request access to namespace", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rootStorageStatistics", + "description": "Aggregated storage statistics of the namespace. Only available for root namespaces", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "RootStorageStatistics", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "visibility", + "description": "Visibility of the namespace", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "RootStorageStatistics", + "description": null, + "fields": [ + { + "name": "buildArtifactsSize", + "description": "The CI artifacts size in bytes", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lfsObjectsSize", + "description": "The LFS objects size in bytes", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "packagesSize", + "description": "The packages size in bytes", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "repositorySize", + "description": "The git repository size in bytes", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "storageSize", + "description": "The total storage in bytes", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wikiSize", + "description": "The wiki size in bytes", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ProjectConnection", + "description": "The connection type for Project.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ProjectEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Project", + "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": "PageInfo", + "description": "Information about pagination in a connection.", + "fields": [ + { + "name": "endCursor", + "description": "When paginating forwards, the cursor to continue.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasNextPage", + "description": "When paginating forwards, are there more items?", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasPreviousPage", + "description": "When paginating backwards, are there more items?", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startCursor", + "description": "When paginating backwards, the cursor to continue.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ProjectEdge", + "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": "Project", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Group", + "description": null, + "fields": [ + { + "name": "avatarUrl", + "description": "Avatar URL of the group", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": "Description of the namespace", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "descriptionHtml", + "description": "The GitLab Flavored Markdown rendering of `description`", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "epic", + "description": null, + "args": [ + { + "name": "iid", + "description": "The IID of the epic, e.g., \"1\"", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "iids", + "description": "The list of IIDs of epics, e.g., [1, 2]", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "state", + "description": "Filter epics by state", + "type": { + "kind": "ENUM", + "name": "EpicState", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "search", + "description": "Filter epics by title and description", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "sort", + "description": "List epics by sort order", + "type": { + "kind": "ENUM", + "name": "EpicSort", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "authorUsername", + "description": "Filter epics by author", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "labelName", + "description": "Filter epics by labels", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "startDate", + "description": "List epics within a time frame where epics.start_date is between start_date and end_date parameters (end_date parameter must be present)", + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "endDate", + "description": "List epics within a time frame where epics.end_date is between start_date and end_date parameters (start_date parameter must be present)", + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Epic", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "epics", + "description": null, + "args": [ + { + "name": "iid", + "description": "The IID of the epic, e.g., \"1\"", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "iids", + "description": "The list of IIDs of epics, e.g., [1, 2]", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "state", + "description": "Filter epics by state", + "type": { + "kind": "ENUM", + "name": "EpicState", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "search", + "description": "Filter epics by title and description", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "sort", + "description": "List epics by sort order", + "type": { + "kind": "ENUM", + "name": "EpicSort", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "authorUsername", + "description": "Filter epics by author", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "labelName", + "description": "Filter epics by labels", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "startDate", + "description": "List epics within a time frame where epics.start_date is between start_date and end_date parameters (end_date parameter must be present)", + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "endDate", + "description": "List epics within a time frame where epics.end_date is between start_date and end_date parameters (start_date parameter must be present)", + "type": { + "kind": "SCALAR", + "name": "Time", + "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": "EpicConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "epicsEnabled", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fullName", + "description": "Full name of the namespace", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fullPath", + "description": "Full path of the namespace", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "ID of the namespace", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lfsEnabled", + "description": "Indicates if Large File Storage (LFS) is enabled for namespace", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "Name of the namespace", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "parent", + "description": "Parent group", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Group", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "path", + "description": "Path of the namespace", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "projects", + "description": "Projects within this namespace", + "args": [ + { + "name": "includeSubgroups", + "description": "Include also subgroup projects", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false" + }, + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ProjectConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "requestAccessEnabled", + "description": "Indicates if users can request access to namespace", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rootStorageStatistics", + "description": "Aggregated storage statistics of the namespace. Only available for root namespaces", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "RootStorageStatistics", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userPermissions", + "description": "Permissions for the current user on the resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "GroupPermissions", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "visibility", + "description": "Visibility of the namespace", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webUrl", + "description": "Web URL of the group", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "GroupPermissions", + "description": null, + "fields": [ + { + "name": "readGroup", + "description": "Whether or not a user can perform `read_group` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Epic", + "description": null, + "fields": [ + { + "name": "author", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "User", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "children", + "description": null, + "args": [ + { + "name": "iid", + "description": "The IID of the epic, e.g., \"1\"", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "iids", + "description": "The list of IIDs of epics, e.g., [1, 2]", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "state", + "description": "Filter epics by state", + "type": { + "kind": "ENUM", + "name": "EpicState", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "search", + "description": "Filter epics by title and description", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "sort", + "description": "List epics by sort order", + "type": { + "kind": "ENUM", + "name": "EpicSort", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "authorUsername", + "description": "Filter epics by author", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "labelName", + "description": "Filter epics by labels", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "startDate", + "description": "List epics within a time frame where epics.start_date is between start_date and end_date parameters (end_date parameter must be present)", + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "endDate", + "description": "List epics within a time frame where epics.end_date is between start_date and end_date parameters (start_date parameter must be present)", + "type": { + "kind": "SCALAR", + "name": "Time", + "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": "EpicConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "closedAt", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "descendantCounts", + "description": "Number of open and closed descendant epics and issues", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "EpicDescendantCount", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "discussions", + "description": "All discussions on this noteable", + "args": [ + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DiscussionConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dueDate", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dueDateFixed", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dueDateFromMilestones", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dueDateIsFixed", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "group", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Group", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasChildren", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasIssues", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "iid", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issues", + "description": "A list of issues associated with the epic", + "args": [ + { + "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": "EpicIssueConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "labels", + "description": "Labels assigned to the epic", + "args": [ + { + "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": "LabelConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "notes", + "description": "All notes on this noteable", + "args": [ + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "NoteConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "parent", + "description": null, + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Epic", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "participants", + "description": "List of participants for the epic", + "args": [ + { + "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": "UserConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "reference", + "description": null, + "args": [ + { + "name": "full", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false" + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "relationPath", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "relativePosition", + "description": "The relative position of the epic in the Epic tree", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startDate", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startDateFixed", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startDateFromMilestones", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startDateIsFixed", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "EpicState", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscribed", + "description": "Boolean flag for whether the currently logged in user is subscribed to this epic", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "title", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userPermissions", + "description": "Permissions for the current user on the resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "EpicPermissions", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webPath", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webUrl", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Noteable", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INTERFACE", + "name": "Noteable", + "description": null, + "fields": [ + { + "name": "discussions", + "description": "All discussions on this noteable", + "args": [ + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DiscussionConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "notes", + "description": "All notes on this noteable", + "args": [ + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "NoteConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "Design", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "Epic", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "EpicIssue", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "Issue", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "MergeRequest", + "ofType": null + } + ] + }, + { + "kind": "OBJECT", + "name": "NoteConnection", + "description": "The connection type for Note.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "NoteEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Note", + "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": "NoteEdge", + "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": "Note", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Note", + "description": null, + "fields": [ + { + "name": "author", + "description": "The user who wrote this note", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "User", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "body", + "description": "The content note itself", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "bodyHtml", + "description": "The GitLab Flavored Markdown rendering of `note`", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "discussion", + "description": "The discussion this note is a part of", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Discussion", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "position", + "description": "The position of this note on a diff", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "DiffPosition", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "project", + "description": "The project this note is associated to", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Project", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "resolvable", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "resolvedAt", + "description": "The time the discussion was resolved", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "resolvedBy", + "description": "The user that resolved the discussion", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "User", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "system", + "description": "Whether or not this note was created by the system or by a user", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userPermissions", + "description": "Permissions for the current user on the resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "NotePermissions", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "NotePermissions", + "description": null, + "fields": [ + { + "name": "adminNote", + "description": "Whether or not a user can perform `admin_note` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "awardEmoji", + "description": "Whether or not a user can perform `award_emoji` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createNote", + "description": "Whether or not a user can perform `create_note` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "readNote", + "description": "Whether or not a user can perform `read_note` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "resolveNote", + "description": "Whether or not a user can perform `resolve_note` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "User", + "description": null, + "fields": [ + { + "name": "avatarUrl", + "description": "URL of the user's avatar", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "Human-readable name of the user", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "todos", + "description": "Todos of the user", + "args": [ + { + "name": "action", + "description": "The action to be filtered", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "TodoActionEnum", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "authorId", + "description": "The ID of an author", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "projectId", + "description": "The ID of a project", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "groupId", + "description": "The ID of a group", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "state", + "description": "The state of the todo", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "TodoStateEnum", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "type", + "description": "The type of the todo", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "TodoTargetEnum", + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TodoConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "username", + "description": "Username of the user. Unique within this instance of GitLab", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webUrl", + "description": "Web URL of the user", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "TodoConnection", + "description": "The connection type for Todo.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TodoEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Todo", + "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": "TodoEdge", + "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": "Todo", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Todo", + "description": "Representing a todo entry", + "fields": [ + { + "name": "action", + "description": "Action of the todo", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "TodoActionEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "author", + "description": "The owner of this todo", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "User", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "body", + "description": "Body of the todo", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": "Timestamp this todo was created", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "group", + "description": "Group this todo is associated with", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Group", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "Id of the todo", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "project", + "description": "The project this todo is associated with", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Project", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": "State of the todo", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "TodoStateEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "targetType", + "description": "Target type of the todo", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "TodoTargetEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "TodoActionEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "assigned", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mentioned", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "build_failed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "marked", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "approval_required", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "unmergeable", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "directly_addressed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "TodoTargetEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "COMMIT", + "description": "A Commit", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ISSUE", + "description": "An Issue", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MERGEREQUEST", + "description": "A MergeRequest", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DESIGN", + "description": "A Design", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "EPIC", + "description": "An Epic", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "TodoStateEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "pending", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "done", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Discussion", + "description": null, + "fields": [ + { + "name": "createdAt", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "notes", + "description": "All notes in the discussion", + "args": [ + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "NoteConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "replyId", + "description": "The ID used to reply to this discussion", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DiffPosition", + "description": null, + "fields": [ + { + "name": "diffRefs", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DiffRefs", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "filePath", + "description": "The path of the file that was changed", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "height", + "description": "The total height of the image", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "newLine", + "description": "The line on head sha that was changed", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "newPath", + "description": "The path of the file on the head sha.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "oldLine", + "description": "The line on start sha that was changed", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "oldPath", + "description": "The path of the file on the start sha.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "positionType", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "DiffPositionType", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "width", + "description": "The total width of the image", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "x", + "description": "The X postion on which the comment was made", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "y", + "description": "The Y position on which the comment was made", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DiffRefs", + "description": null, + "fields": [ + { + "name": "baseSha", + "description": "The merge base of the branch the comment was made on", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "headSha", + "description": "The sha of the head at the time the comment was made", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startSha", + "description": "The sha of the branch being compared against", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "DiffPositionType", + "description": "Type of file the position refers to", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "text", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "image", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DiscussionConnection", + "description": "The connection type for Discussion.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DiscussionEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Discussion", + "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": "DiscussionEdge", + "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": "Discussion", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "EpicPermissions", + "description": "Check permissions for the current user on an epic", + "fields": [ + { + "name": "adminEpic", + "description": "Whether or not a user can perform `admin_epic` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "awardEmoji", + "description": "Whether or not a user can perform `award_emoji` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createEpic", + "description": "Whether or not a user can perform `create_epic` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createNote", + "description": "Whether or not a user can perform `create_note` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyEpic", + "description": "Whether or not a user can perform `destroy_epic` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "readEpic", + "description": "Whether or not a user can perform `read_epic` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "readEpicIid", + "description": "Whether or not a user can perform `read_epic_iid` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateEpic", + "description": "Whether or not a user can perform `update_epic` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "EpicState", + "description": "State of a GitLab epic", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "all", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "opened", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "closed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "EpicConnection", + "description": "The connection type for Epic.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "EpicEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Epic", + "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": "EpicEdge", + "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": "Epic", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "EpicSort", + "description": "Roadmap sort values", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "start_date_desc", + "description": "Start date at descending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "start_date_asc", + "description": "Start date at ascending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "end_date_desc", + "description": "End date at descending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "end_date_asc", + "description": "End date at ascending order", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "LabelConnection", + "description": "The connection type for Label.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "LabelEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Label", + "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": "LabelEdge", + "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": "Label", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Label", + "description": null, + "fields": [ + { + "name": "color", + "description": "Background color of the label", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": "Description of the label (markdown rendered as HTML for caching)", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "descriptionHtml", + "description": "The GitLab Flavored Markdown rendering of `description`", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "Label ID", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "textColor", + "description": "Text color of the label", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "title", + "description": "Content of the label", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "UserConnection", + "description": "The connection type for User.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "UserEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "User", + "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": "UserEdge", + "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": "User", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "EpicIssueConnection", + "description": "The connection type for EpicIssue.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "EpicIssueEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "EpicIssue", + "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": "EpicIssueEdge", + "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": "EpicIssue", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "EpicIssue", + "description": null, + "fields": [ + { + "name": "assignees", + "description": "Assignees of the issue", + "args": [ + { + "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": "UserConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "author", + "description": "User that created the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "User", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "closedAt", + "description": "Timestamp of when the issue was closed", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "confidential", + "description": "Indicates the issue is confidential", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": "Timestamp of when the issue was created", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": "Description of the issue", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "descriptionHtml", + "description": "The GitLab Flavored Markdown rendering of `description`", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "designCollection", + "description": null, + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "DesignCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "designs", + "description": null, + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "DesignCollection", + "ofType": null + }, + "isDeprecated": true, + "deprecationReason": "use design_collection" + }, + { + "name": "discussionLocked", + "description": "Indicates discussion is locked on the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "discussions", + "description": "All discussions on this noteable", + "args": [ + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DiscussionConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "downvotes", + "description": "Number of downvotes the issue has received", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dueDate", + "description": "Due date of the issue", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "epic", + "description": "The epic to which issue belongs", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Epic", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "epicIssueId", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "The global id of the epic-issue relation", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "iid", + "description": "Internal ID of the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "labels", + "description": "Labels of the issue", + "args": [ + { + "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": "LabelConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "milestone", + "description": "Milestone of the issue", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Milestone", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "notes", + "description": "All notes on this noteable", + "args": [ + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "NoteConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "participants", + "description": "List of participants in the issue", + "args": [ + { + "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": "UserConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "reference", + "description": "Internal reference of the issue. Returned in shortened format by default", + "args": [ + { + "name": "full", + "description": "Boolean option specifying whether the reference should be returned in full", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false" + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "relationPath", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "relativePosition", + "description": "Relative position of the issue (used for positioning in epic tree and issue boards)", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": "State of the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "IssueState", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscribed", + "description": "Boolean flag for whether the currently logged in user is subscribed to this issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taskCompletionStatus", + "description": "Task completion status of the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TaskCompletionStatus", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timeEstimate", + "description": "Time estimate of the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "title", + "description": "Title of the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "titleHtml", + "description": "The GitLab Flavored Markdown rendering of `title`", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalTimeSpent", + "description": "Total time reported as spent on the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": "Timestamp of when the issue was last updated", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "upvotes", + "description": "Number of upvotes the issue has received", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userNotesCount", + "description": "Number of user notes of the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userPermissions", + "description": "Permissions for the current user on the resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "IssuePermissions", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webPath", + "description": "Web path of the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webUrl", + "description": "Web URL of the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "weight", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Noteable", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "IssuePermissions", + "description": "Check permissions for the current user on a issue", + "fields": [ + { + "name": "adminIssue", + "description": "Whether or not a user can perform `admin_issue` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createDesign", + "description": "Whether or not a user can perform `create_design` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createNote", + "description": "Whether or not a user can perform `create_note` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyDesign", + "description": "Whether or not a user can perform `destroy_design` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "readDesign", + "description": "Whether or not a user can perform `read_design` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "readIssue", + "description": "Whether or not a user can perform `read_issue` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "reopenIssue", + "description": "Whether or not a user can perform `reopen_issue` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateIssue", + "description": "Whether or not a user can perform `update_issue` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "IssueState", + "description": "State of a GitLab issue", + "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": "OBJECT", + "name": "Milestone", + "description": null, + "fields": [ + { + "name": "createdAt", + "description": "Timestamp of milestone creation", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": "Description of the milestone", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dueDate", + "description": "Timestamp of the milestone due date", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "ID of the milestone", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startDate", + "description": "Timestamp of the milestone start date", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": "State of the milestone", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "title", + "description": "Title of the milestone", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": "Timestamp of last milestone update", + "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": "TaskCompletionStatus", + "description": "Completion status of tasks", + "fields": [ + { + "name": "completedCount", + "description": "Number of completed tasks", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "count", + "description": "Number of total tasks", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DesignCollection", + "description": null, + "fields": [ + { + "name": "designs", + "description": "All designs for this collection", + "args": [ + { + "name": "ids", + "description": "Filters designs by their ID", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "filenames", + "description": "Filters designs by their filename", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "atVersion", + "description": "Filters designs to only those that existed at the version. If argument is omitted or nil then all designs will reflect the latest version", + "type": { + "kind": "SCALAR", + "name": "ID", + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DesignConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issue", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Issue", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "project", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Project", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "versions", + "description": "All versions related to all designs ordered newest first", + "args": [ + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DesignVersionConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Issue", + "description": null, + "fields": [ + { + "name": "assignees", + "description": "Assignees of the issue", + "args": [ + { + "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": "UserConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "author", + "description": "User that created the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "User", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "closedAt", + "description": "Timestamp of when the issue was closed", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "confidential", + "description": "Indicates the issue is confidential", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": "Timestamp of when the issue was created", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": "Description of the issue", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "descriptionHtml", + "description": "The GitLab Flavored Markdown rendering of `description`", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "designCollection", + "description": null, + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "DesignCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "designs", + "description": null, + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "DesignCollection", + "ofType": null + }, + "isDeprecated": true, + "deprecationReason": "use design_collection" + }, + { + "name": "discussionLocked", + "description": "Indicates discussion is locked on the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "discussions", + "description": "All discussions on this noteable", + "args": [ + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DiscussionConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "downvotes", + "description": "Number of downvotes the issue has received", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dueDate", + "description": "Due date of the issue", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "epic", + "description": "The epic to which issue belongs", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Epic", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "iid", + "description": "Internal ID of the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "labels", + "description": "Labels of the issue", + "args": [ + { + "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": "LabelConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "milestone", + "description": "Milestone of the issue", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Milestone", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "notes", + "description": "All notes on this noteable", + "args": [ + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "NoteConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "participants", + "description": "List of participants in the issue", + "args": [ + { + "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": "UserConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "reference", + "description": "Internal reference of the issue. Returned in shortened format by default", + "args": [ + { + "name": "full", + "description": "Boolean option specifying whether the reference should be returned in full", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false" + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "relativePosition", + "description": "Relative position of the issue (used for positioning in epic tree and issue boards)", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": "State of the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "IssueState", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscribed", + "description": "Boolean flag for whether the currently logged in user is subscribed to this issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taskCompletionStatus", + "description": "Task completion status of the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TaskCompletionStatus", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timeEstimate", + "description": "Time estimate of the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "title", + "description": "Title of the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "titleHtml", + "description": "The GitLab Flavored Markdown rendering of `title`", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalTimeSpent", + "description": "Total time reported as spent on the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": "Timestamp of when the issue was last updated", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "upvotes", + "description": "Number of upvotes the issue has received", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userNotesCount", + "description": "Number of user notes of the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userPermissions", + "description": "Permissions for the current user on the resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "IssuePermissions", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webPath", + "description": "Web path of the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webUrl", + "description": "Web URL of the issue", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "weight", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Noteable", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DesignConnection", + "description": "The connection type for Design.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DesignEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Design", + "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": "DesignEdge", + "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": "Design", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Design", + "description": null, + "fields": [ + { + "name": "diffRefs", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DiffRefs", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "discussions", + "description": "All discussions on this noteable", + "args": [ + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DiscussionConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "event", + "description": "The change that happened to the design at this version", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "DesignVersionEvent", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "filename", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fullPath", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "image", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issue", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Issue", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "notes", + "description": "All notes on this noteable", + "args": [ + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "NoteConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "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": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Project", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "versions", + "description": "All versions related to this design ordered newest first", + "args": [ + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DesignVersionConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Noteable", + "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 + }, + { + "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 + }, + { + "kind": "OBJECT", + "name": "DesignVersion", + "description": null, + "fields": [ + { + "name": "designs", + "description": "All designs that were changed in this version", + "args": [ + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DesignConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sha", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "EpicDescendantCount", + "description": null, + "fields": [ + { + "name": "closedEpics", + "description": "Number of closed sub-epics", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "closedIssues", + "description": "Number of closed epic issues", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "openedEpics", + "description": "Number of opened sub-epics", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "openedIssues", + "description": "Number of opened epic issues", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ProjectStatistics", + "description": null, + "fields": [ + { + "name": "buildArtifactsSize", + "description": "Build artifacts size of the project", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "commitCount", + "description": "Commit count of the project", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lfsObjectsSize", + "description": "Large File Storage (LFS) object size of the project", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "packagesSize", + "description": "Packages size of the project", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "repositorySize", + "description": "Repository size of the project", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "storageSize", + "description": "Storage size of the project", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wikiSize", + "description": "Wiki size of the project", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Repository", + "description": null, + "fields": [ + { + "name": "empty", + "description": "Indicates repository has no visible content", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "exists", + "description": "Indicates a corresponding Git repository exists on disk", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rootRef", + "description": "Default branch of the repository", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tree", + "description": "Tree of the repository", + "args": [ + { + "name": "path", + "description": "The path to get the tree for. Default value is the root of the repository", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": "\"\"" + }, + { + "name": "ref", + "description": "The commit ref to get the tree for. Default value is HEAD", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": "\"head\"" + }, + { + "name": "recursive", + "description": "Used to get a recursive tree. Default is false", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false" + } + ], + "type": { + "kind": "OBJECT", + "name": "Tree", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Tree", + "description": null, + "fields": [ + { + "name": "blobs", + "description": null, + "args": [ + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "BlobConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lastCommit", + "description": "Last commit for the tree", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Commit", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "submodules", + "description": null, + "args": [ + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "SubmoduleConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "trees", + "description": null, + "args": [ + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TreeEntryConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Commit", + "description": null, + "fields": [ + { + "name": "author", + "description": "Author of the commit", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "User", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "authorName", + "description": "Commit authors name", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "authoredDate", + "description": "Timestamp of when the commit was authored", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": "Description of the commit message", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "ID (global ID) of the commit", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "latestPipeline", + "description": "Latest pipeline of the commit", + "args": [ + { + "name": "status", + "description": "Filter pipelines by their status", + "type": { + "kind": "ENUM", + "name": "PipelineStatusEnum", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "ref", + "description": "Filter pipelines by the ref they are run for", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "sha", + "description": "Filter pipelines by the sha of the commit they are run for", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Pipeline", + "ofType": null + }, + "isDeprecated": true, + "deprecationReason": "use pipelines" + }, + { + "name": "message", + "description": "Raw commit message", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pipelines", + "description": "Pipelines of the commit ordered latest first", + "args": [ + { + "name": "status", + "description": "Filter pipelines by their status", + "type": { + "kind": "ENUM", + "name": "PipelineStatusEnum", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "ref", + "description": "Filter pipelines by the ref they are run for", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "sha", + "description": "Filter pipelines by the sha of the commit they are run for", + "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": "PipelineConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sha", + "description": "SHA1 ID of the commit", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "signatureHtml", + "description": "Rendered HTML of the commit signature", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "title", + "description": "Title of the commit message", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webUrl", + "description": "Web URL of the commit", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PipelineConnection", + "description": "The connection type for Pipeline.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PipelineEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Pipeline", + "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": "PipelineEdge", + "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": "Pipeline", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Pipeline", + "description": null, + "fields": [ + { + "name": "beforeSha", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "committedAt", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coverage", + "description": "Coverage percentage", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "detailedStatus", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DetailedStatus", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "duration", + "description": "Duration of the pipeline in seconds", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "finishedAt", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "iid", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sha", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startedAt", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "PipelineStatusEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userPermissions", + "description": "Permissions for the current user on the resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PipelinePermissions", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PipelinePermissions", + "description": null, + "fields": [ + { + "name": "adminPipeline", + "description": "Whether or not a user can perform `admin_pipeline` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyPipeline", + "description": "Whether or not a user can perform `destroy_pipeline` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatePipeline", + "description": "Whether or not a user can perform `update_pipeline` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "PipelineStatusEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "CREATED", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PREPARING", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PENDING", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "RUNNING", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FAILED", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SUCCESS", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CANCELED", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SKIPPED", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MANUAL", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SCHEDULED", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DetailedStatus", + "description": null, + "fields": [ + { + "name": "detailsPath", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "favicon", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "group", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasDetails", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "icon", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "label", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "text", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tooltip", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Float", + "description": "Represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "TreeEntryConnection", + "description": "The connection type for TreeEntry.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TreeEntryEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TreeEntry", + "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": "TreeEntryEdge", + "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": "TreeEntry", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "TreeEntry", + "description": "Represents a directory", + "fields": [ + { + "name": "flatPath", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "path", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sha", + "description": "Last commit sha for entry", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "EntryType", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webUrl", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Entry", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INTERFACE", + "name": "Entry", + "description": null, + "fields": [ + { + "name": "flatPath", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "path", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sha", + "description": "Last commit sha for entry", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "EntryType", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "Blob", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "Submodule", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "TreeEntry", + "ofType": null + } + ] + }, + { + "kind": "ENUM", + "name": "EntryType", + "description": "Type of a tree entry", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "tree", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "blob", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "commit", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SubmoduleConnection", + "description": "The connection type for Submodule.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "SubmoduleEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Submodule", + "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": "SubmoduleEdge", + "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": "Submodule", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Submodule", + "description": null, + "fields": [ + { + "name": "flatPath", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "path", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sha", + "description": "Last commit sha for entry", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "treeUrl", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "EntryType", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webUrl", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Entry", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "BlobConnection", + "description": "The connection type for Blob.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "BlobEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Blob", + "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": "BlobEdge", + "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": "Blob", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Blob", + "description": null, + "fields": [ + { + "name": "flatPath", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lfsOid", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "path", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sha", + "description": "Last commit sha for entry", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "EntryType", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webUrl", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Entry", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "MergeRequestConnection", + "description": "The connection type for MergeRequest.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "MergeRequestEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "MergeRequest", + "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": "MergeRequestEdge", + "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": "MergeRequest", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "MergeRequest", + "description": null, + "fields": [ + { + "name": "allowCollaboration", + "description": "Indicates if members of the target project can push to the fork", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "assignees", + "description": "Assignees of the merge request", + "args": [ + { + "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": "UserConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": "Timestamp of when the merge request was created", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "defaultMergeCommitMessage", + "description": "Default merge commit message of the merge request", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": "Description of the merge request (markdown rendered as HTML for caching)", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "descriptionHtml", + "description": "The GitLab Flavored Markdown rendering of `description`", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "diffHeadSha", + "description": "Diff head SHA of the merge request", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "diffRefs", + "description": "References of the base SHA, the head SHA, and the start SHA for this merge request", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "DiffRefs", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "discussionLocked", + "description": "Indicates if comments on the merge request are locked to members only", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "discussions", + "description": "All discussions on this noteable", + "args": [ + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DiscussionConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "downvotes", + "description": "Number of downvotes for the merge request", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "forceRemoveSourceBranch", + "description": "Indicates if the project settings will lead to source branch deletion after merge", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "headPipeline", + "description": "The pipeline running on the branch HEAD of the merge request", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Pipeline", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "ID of the merge request", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "iid", + "description": "Internal ID of the merge request", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "inProgressMergeCommitSha", + "description": "Commit SHA of the merge request if merge is in progress", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "labels", + "description": "Labels of the merge request", + "args": [ + { + "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": "LabelConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeCommitMessage", + "description": "Deprecated - renamed to defaultMergeCommitMessage", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": true, + "deprecationReason": "Renamed to defaultMergeCommitMessage" + }, + { + "name": "mergeCommitSha", + "description": "SHA of the merge request commit (set once merged)", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeError", + "description": "Error message due to a merge error", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeOngoing", + "description": "Indicates if a merge is currently occurring", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeStatus", + "description": "Status of the merge request", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeWhenPipelineSucceeds", + "description": "Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS)", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeableDiscussionsState", + "description": "Indicates if all discussions in the merge request have been resolved, allowing the merge request to be merged", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "milestone", + "description": "The milestone of the merge request", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Milestone", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "notes", + "description": "All notes on this noteable", + "args": [ + { + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "NoteConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "participants", + "description": "Participants in the merge request", + "args": [ + { + "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": "UserConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pipelines", + "description": "Pipelines for the merge request", + "args": [ + { + "name": "status", + "description": "Filter pipelines by their status", + "type": { + "kind": "ENUM", + "name": "PipelineStatusEnum", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "ref", + "description": "Filter pipelines by the ref they are run for", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "sha", + "description": "Filter pipelines by the sha of the commit they are run for", + "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": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PipelineConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "project", + "description": "Alias for target_project", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Project", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "projectId", + "description": "ID of the merge request project", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rebaseCommitSha", + "description": "Rebase commit SHA of the merge request", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rebaseInProgress", + "description": "Indicates if there is a rebase currently in progress for the merge request", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "reference", + "description": "Internal reference of the merge request. Returned in shortened format by default", + "args": [ + { + "name": "full", + "description": "Boolean option specifying whether the reference should be returned in full", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false" + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "shouldBeRebased", + "description": "Indicates if the merge request will be rebased", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "shouldRemoveSourceBranch", + "description": "Indicates if the source branch of the merge request will be deleted after merge", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sourceBranch", + "description": "Source branch of the merge request", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sourceBranchExists", + "description": "Indicates if the source branch of the merge request exists", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sourceProject", + "description": "Source project of the merge request", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Project", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sourceProjectId", + "description": "ID of the merge request source project", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": "State of the merge request", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "MergeRequestState", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscribed", + "description": "Indicates if the currently logged in user is subscribed to this merge request", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "targetBranch", + "description": "Target branch of the merge request", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "targetProject", + "description": "Target project of the merge request", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Project", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "targetProjectId", + "description": "ID of the merge request target project", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taskCompletionStatus", + "description": "Completion status of tasks", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TaskCompletionStatus", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timeEstimate", + "description": "Time estimate of the merge request", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "title", + "description": "Title of the merge request", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "titleHtml", + "description": "The GitLab Flavored Markdown rendering of `title`", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalTimeSpent", + "description": "Total time reported as spent on the merge request", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": "Timestamp of when the merge request was last updated", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "upvotes", + "description": "Number of upvotes for the merge request", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userNotesCount", + "description": "User notes count of the merge request", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userPermissions", + "description": "Permissions for the current user on the resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "MergeRequestPermissions", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webUrl", + "description": "Web URL of the merge request", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "workInProgress", + "description": "Indicates if the merge request is a work in progress (WIP)", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Noteable", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "MergeRequestPermissions", + "description": "Check permissions for the current user on a merge request", + "fields": [ + { + "name": "adminMergeRequest", + "description": "Whether or not a user can perform `admin_merge_request` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cherryPickOnCurrentMergeRequest", + "description": "Whether or not a user can perform `cherry_pick_on_current_merge_request` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createNote", + "description": "Whether or not a user can perform `create_note` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pushToSourceBranch", + "description": "Whether or not a user can perform `push_to_source_branch` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "readMergeRequest", + "description": "Whether or not a user can perform `read_merge_request` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "removeSourceBranch", + "description": "Whether or not a user can perform `remove_source_branch` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "revertOnCurrentMergeRequest", + "description": "Whether or not a user can perform `revert_on_current_merge_request` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateMergeRequest", + "description": "Whether or not a user can perform `update_merge_request` on this resource", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "MergeRequestState", + "description": "State of a GitLab 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 + }, + { + "name": "merged", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "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 + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Metadata", + "description": null, + "fields": [ + { + "name": "revision", + "description": "Revision", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "version", + "description": "Version", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Mutation", + "description": null, + "fields": [ + { + "name": "addAwardEmoji", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "AddAwardEmojiInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "AddAwardEmojiPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createDiffNote", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateDiffNoteInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CreateDiffNotePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createEpic", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateEpicInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CreateEpicPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createImageDiffNote", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateImageDiffNoteInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CreateImageDiffNotePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createNote", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateNoteInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CreateNotePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "designManagementDelete", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DesignManagementDeleteInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DesignManagementDeletePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "designManagementUpload", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DesignManagementUploadInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DesignManagementUploadPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyNote", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyNoteInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyNotePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "epicSetSubscription", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "EpicSetSubscriptionInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "EpicSetSubscriptionPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "epicTreeReorder", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "EpicTreeReorderInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "EpicTreeReorderPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequestSetAssignees", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetAssigneesInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequestSetAssigneesPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequestSetLabels", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetLabelsInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequestSetLabelsPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequestSetLocked", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetLockedInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequestSetLockedPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequestSetMilestone", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetMilestoneInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequestSetMilestonePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequestSetSubscription", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetSubscriptionInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequestSetSubscriptionPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequestSetWip", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetWipInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequestSetWipPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "removeAwardEmoji", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RemoveAwardEmojiInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "RemoveAwardEmojiPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "todoMarkDone", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "TodoMarkDoneInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "TodoMarkDonePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toggleAwardEmoji", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ToggleAwardEmojiInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ToggleAwardEmojiPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateEpic", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateEpicInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "UpdateEpicPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateNote", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateNoteInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "UpdateNotePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AddAwardEmojiPayload", + "description": "Autogenerated return type of AddAwardEmoji", + "fields": [ + { + "name": "awardEmoji", + "description": "The award emoji after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "AwardEmoji", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AwardEmoji", + "description": null, + "fields": [ + { + "name": "description", + "description": "The emoji description", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "emoji", + "description": "The emoji as an icon", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "The emoji name", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "unicode", + "description": "The emoji in unicode", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "unicodeVersion", + "description": "The unicode version for this emoji", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "user", + "description": "The user who awarded the emoji", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "User", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "AddAwardEmojiInput", + "description": "Autogenerated input type of AddAwardEmoji", + "fields": null, + "inputFields": [ + { + "name": "awardableId", + "description": "The global id of the awardable resource", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "name", + "description": "The emoji name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "RemoveAwardEmojiPayload", + "description": "Autogenerated return type of RemoveAwardEmoji", + "fields": [ + { + "name": "awardEmoji", + "description": "The award emoji after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "AwardEmoji", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "RemoveAwardEmojiInput", + "description": "Autogenerated input type of RemoveAwardEmoji", + "fields": null, + "inputFields": [ + { + "name": "awardableId", + "description": "The global id of the awardable resource", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "name", + "description": "The emoji name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ToggleAwardEmojiPayload", + "description": "Autogenerated return type of ToggleAwardEmoji", + "fields": [ + { + "name": "awardEmoji", + "description": "The award emoji after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "AwardEmoji", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toggledOn", + "description": "True when the emoji was awarded, false when it was removed", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "ToggleAwardEmojiInput", + "description": "Autogenerated input type of ToggleAwardEmoji", + "fields": null, + "inputFields": [ + { + "name": "awardableId", + "description": "The global id of the awardable resource", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "name", + "description": "The emoji name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "MergeRequestSetLabelsPayload", + "description": "Autogenerated return type of MergeRequestSetLabels", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequest", + "description": "The merge request after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequest", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetLabelsInput", + "description": "Autogenerated input type of MergeRequestSetLabels", + "fields": null, + "inputFields": [ + { + "name": "projectPath", + "description": "The project the merge request to mutate is in", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "iid", + "description": "The iid of the merge request to mutate", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "labelIds", + "description": "The Label IDs to set. Replaces existing labels by default.\n", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + } + }, + "defaultValue": null + }, + { + "name": "operationMode", + "description": "Changes the operation mode. Defaults to REPLACE.\n", + "type": { + "kind": "ENUM", + "name": "MutationOperationMode", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "MutationOperationMode", + "description": "Different toggles for changing mutator behavior.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "REPLACE", + "description": "Performs a replace operation", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "APPEND", + "description": "Performs an append operation", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "REMOVE", + "description": "Performs a removal operation", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "MergeRequestSetLockedPayload", + "description": "Autogenerated return type of MergeRequestSetLocked", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequest", + "description": "The merge request after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequest", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetLockedInput", + "description": "Autogenerated input type of MergeRequestSetLocked", + "fields": null, + "inputFields": [ + { + "name": "projectPath", + "description": "The project the merge request to mutate is in", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "iid", + "description": "The iid of the merge request to mutate", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "locked", + "description": "Whether or not to lock the merge request.\n", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "MergeRequestSetMilestonePayload", + "description": "Autogenerated return type of MergeRequestSetMilestone", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequest", + "description": "The merge request after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequest", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetMilestoneInput", + "description": "Autogenerated input type of MergeRequestSetMilestone", + "fields": null, + "inputFields": [ + { + "name": "projectPath", + "description": "The project the merge request to mutate is in", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "iid", + "description": "The iid of the merge request to mutate", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "milestoneId", + "description": "The milestone to assign to the merge request.\n", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "MergeRequestSetSubscriptionPayload", + "description": "Autogenerated return type of MergeRequestSetSubscription", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequest", + "description": "The merge request after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequest", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetSubscriptionInput", + "description": "Autogenerated input type of MergeRequestSetSubscription", + "fields": null, + "inputFields": [ + { + "name": "projectPath", + "description": "The project the merge request to mutate is in", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "iid", + "description": "The iid of the merge request to mutate", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "subscribedState", + "description": "The desired state of the subscription", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "MergeRequestSetWipPayload", + "description": "Autogenerated return type of MergeRequestSetWip", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequest", + "description": "The merge request after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequest", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetWipInput", + "description": "Autogenerated input type of MergeRequestSetWip", + "fields": null, + "inputFields": [ + { + "name": "projectPath", + "description": "The project the merge request to mutate is in", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "iid", + "description": "The iid of the merge request to mutate", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "wip", + "description": "Whether or not to set the merge request as a WIP.\n", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "MergeRequestSetAssigneesPayload", + "description": "Autogenerated return type of MergeRequestSetAssignees", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequest", + "description": "The merge request after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequest", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetAssigneesInput", + "description": "Autogenerated input type of MergeRequestSetAssignees", + "fields": null, + "inputFields": [ + { + "name": "projectPath", + "description": "The project the merge request to mutate is in", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "iid", + "description": "The iid of the merge request to mutate", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "assigneeUsernames", + "description": "The usernames to assign to the merge request. Replaces existing assignees by default.\n", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "defaultValue": null + }, + { + "name": "operationMode", + "description": "The operation to perform. Defaults to REPLACE.\n", + "type": { + "kind": "ENUM", + "name": "MutationOperationMode", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CreateNotePayload", + "description": "Autogenerated return type of CreateNote", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "note", + "description": "The note after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Note", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateNoteInput", + "description": "Autogenerated input type of CreateNote", + "fields": null, + "inputFields": [ + { + "name": "noteableId", + "description": "The global id of the resource to add a note to", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "body", + "description": "The content note itself", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "discussionId", + "description": "The global id of the discussion this note is in reply to", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CreateDiffNotePayload", + "description": "Autogenerated return type of CreateDiffNote", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "note", + "description": "The note after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Note", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateDiffNoteInput", + "description": "Autogenerated input type of CreateDiffNote", + "fields": null, + "inputFields": [ + { + "name": "noteableId", + "description": "The global id of the resource to add a note to", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "body", + "description": "The content note itself", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "position", + "description": "The position of this note on a diff", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DiffPositionInput", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DiffPositionInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "headSha", + "description": "The sha of the head at the time the comment was made", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "baseSha", + "description": "The merge base of the branch the comment was made on", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "startSha", + "description": "The sha of the branch being compared against", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "paths", + "description": "The paths of the file that was changed. Both of the properties of this input are optional, but at least one of them is required", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DiffPathsInput", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "oldLine", + "description": "The line on start sha that was changed", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "newLine", + "description": "The line on head sha that was changed", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DiffPathsInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "oldPath", + "description": "The path of the file on the start sha", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "newPath", + "description": "The path of the file on the head sha", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CreateImageDiffNotePayload", + "description": "Autogenerated return type of CreateImageDiffNote", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "note", + "description": "The note after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Note", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateImageDiffNoteInput", + "description": "Autogenerated input type of CreateImageDiffNote", + "fields": null, + "inputFields": [ + { + "name": "noteableId", + "description": "The global id of the resource to add a note to", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "body", + "description": "The content note itself", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "position", + "description": "The position of this note on a diff", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DiffImagePositionInput", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DiffImagePositionInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "headSha", + "description": "The sha of the head at the time the comment was made", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "baseSha", + "description": "The merge base of the branch the comment was made on", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "startSha", + "description": "The sha of the branch being compared against", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "paths", + "description": "The paths of the file that was changed. Both of the properties of this input are optional, but at least one of them is required", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DiffPathsInput", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "x", + "description": "The X postion on which the comment was made", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "y", + "description": "The Y position on which the comment was made", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "width", + "description": "The total width of the image", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "height", + "description": "The total height of the image", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "UpdateNotePayload", + "description": "Autogenerated return type of UpdateNote", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "note", + "description": "The note after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Note", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateNoteInput", + "description": "Autogenerated input type of UpdateNote", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": "The global id of the note to update", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "body", + "description": "The content note itself", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyNotePayload", + "description": "Autogenerated return type of DestroyNote", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "note", + "description": "The note after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Note", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyNoteInput", + "description": "Autogenerated input type of DestroyNote", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": "The global id of the note to destroy", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "TodoMarkDonePayload", + "description": "Autogenerated return type of TodoMarkDone", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "todo", + "description": "The requested todo", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Todo", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "TodoMarkDoneInput", + "description": "Autogenerated input type of TodoMarkDone", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": "The global id of the todo to mark as done", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DesignManagementUploadPayload", + "description": "Autogenerated return type of DesignManagementUpload", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "designs", + "description": "The designs that were uploaded by the mutation", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Design", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "skippedDesigns", + "description": "Any designs that were skipped from the upload due to there being no change to their content since their last version", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Design", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DesignManagementUploadInput", + "description": "Autogenerated input type of DesignManagementUpload", + "fields": null, + "inputFields": [ + { + "name": "projectPath", + "description": "The project where the issue is to upload designs for", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "iid", + "description": "The iid of the issue to modify designs for", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "files", + "description": "The files to upload", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Upload", + "ofType": null + } + } + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Upload", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DesignManagementDeletePayload", + "description": "Autogenerated return type of DesignManagementDelete", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "version", + "description": "The new version in which the designs are deleted", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "DesignVersion", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DesignManagementDeleteInput", + "description": "Autogenerated input type of DesignManagementDelete", + "fields": null, + "inputFields": [ + { + "name": "projectPath", + "description": "The project where the issue is to upload designs for", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "iid", + "description": "The iid of the issue to modify designs for", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "filenames", + "description": "The filenames of the designs to delete", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "EpicTreeReorderPayload", + "description": "Autogenerated return type of EpicTreeReorder", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "EpicTreeReorderInput", + "description": "Autogenerated input type of EpicTreeReorder", + "fields": null, + "inputFields": [ + { + "name": "baseEpicId", + "description": "The id of the base epic of the tree", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "moved", + "description": "Parameters for updating the tree positions", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "EpicTreeNodeFieldsInputType", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "EpicTreeNodeFieldsInputType", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "id", + "description": "The id of the epic_issue or epic that is being moved", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "adjacentReferenceId", + "description": "The id of the epic_issue or issue that the actual epic or issue is switched with", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "relativePosition", + "description": "The type of the switch, after or before allowed", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "MoveType", + "ofType": null + } + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "MoveType", + "description": "The position the adjacent object should be moved.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "before", + "description": "The adjacent object will be moved before the object that is being moved.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "after", + "description": "The adjacent object will be moved after the object that is being moved.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "UpdateEpicPayload", + "description": "Autogenerated return type of UpdateEpic", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "epic", + "description": "The epic after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Epic", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateEpicInput", + "description": "Autogenerated input type of UpdateEpic", + "fields": null, + "inputFields": [ + { + "name": "groupPath", + "description": "The group the epic to mutate is in", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "title", + "description": "The title of the epic", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "description", + "description": "The description of the epic", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "startDateFixed", + "description": "The start date of the epic", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "dueDateFixed", + "description": "The end date of the epic", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "startDateIsFixed", + "description": "Indicates start date should be sourced from start_date_fixed field not the issue milestones", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "dueDateIsFixed", + "description": "Indicates end date should be sourced from due_date_fixed field not the issue milestones", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "addLabelIds", + "description": "The IDs of labels to be added to the epic.", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "removeLabelIds", + "description": "The IDs of labels to be removed from the epic.", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "iid", + "description": "The iid of the epic to mutate", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "stateEvent", + "description": "State event for the epic", + "type": { + "kind": "ENUM", + "name": "EpicStateEvent", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "EpicStateEvent", + "description": "State event of a GitLab Epic", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "REOPEN", + "description": "Reopen the Epic", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CLOSE", + "description": "Close the Epic", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CreateEpicPayload", + "description": "Autogenerated return type of CreateEpic", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "epic", + "description": "The created epic", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Epic", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateEpicInput", + "description": "Autogenerated input type of CreateEpic", + "fields": null, + "inputFields": [ + { + "name": "groupPath", + "description": "The group the epic to mutate is in", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "title", + "description": "The title of the epic", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "description", + "description": "The description of the epic", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "startDateFixed", + "description": "The start date of the epic", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "dueDateFixed", + "description": "The end date of the epic", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "startDateIsFixed", + "description": "Indicates start date should be sourced from start_date_fixed field not the issue milestones", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "dueDateIsFixed", + "description": "Indicates end date should be sourced from due_date_fixed field not the issue milestones", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "addLabelIds", + "description": "The IDs of labels to be added to the epic.", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "removeLabelIds", + "description": "The IDs of labels to be removed from the epic.", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "EpicSetSubscriptionPayload", + "description": "Autogenerated return type of EpicSetSubscription", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "epic", + "description": "The epic after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Epic", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "EpicSetSubscriptionInput", + "description": "Autogenerated input type of EpicSetSubscription", + "fields": null, + "inputFields": [ + { + "name": "groupPath", + "description": "The group the epic to (un)subscribe is in", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "iid", + "description": "The iid of the epic to (un)subscribe", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "subscribedState", + "description": "The desired state of the subscription", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Schema", + "description": "A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations.", + "fields": [ + { + "name": "directives", + "description": "A list of all directives supported by this server.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Directive", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mutationType", + "description": "If this server supports mutation, the type that mutation operations will be rooted at.", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "queryType", + "description": "The type that query operations will be rooted at.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionType", + "description": "If this server support subscription, the type that subscription operations will be rooted at.", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "types", + "description": "A list of all types supported by this server.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Type", + "description": "The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum.\n\nDepending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name and description, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types.", + "fields": [ + { + "name": "description", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "enumValues", + "description": null, + "args": [ + { + "name": "includeDeprecated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false" + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__EnumValue", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fields", + "description": null, + "args": [ + { + "name": "includeDeprecated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false" + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Field", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "inputFields", + "description": null, + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "interfaces", + "description": null, + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "kind", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "__TypeKind", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ofType", + "description": null, + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "possibleTypes", + "description": null, + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Field", + "description": "Object and Interface types are described by a list of Fields, each of which has a name, potentially a list of arguments, and a return type.", + "fields": [ + { + "name": "args", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deprecationReason", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isDeprecated", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Directive", + "description": "A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document.\n\nIn some cases, you need to provide options to alter GraphQL's execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.", + "fields": [ + { + "name": "args", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "locations", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "__DirectiveLocation", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "onField", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": true, + "deprecationReason": "Use `locations`." + }, + { + "name": "onFragment", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": true, + "deprecationReason": "Use `locations`." + }, + { + "name": "onOperation", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": true, + "deprecationReason": "Use `locations`." + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__EnumValue", + "description": "One possible value for a given Enum. Enum values are unique values, not a placeholder for a string or numeric value. However an Enum value is returned in a JSON response as a string.", + "fields": [ + { + "name": "deprecationReason", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isDeprecated", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__InputValue", + "description": "Arguments provided to Fields or Directives and the input fields of an InputObject are represented as Input Values which describe their type and optionally a default value.", + "fields": [ + { + "name": "defaultValue", + "description": "A GraphQL-formatted string representing the default value for this input value.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "__TypeKind", + "description": "An enum describing what kind of type a given `__Type` is.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "SCALAR", + "description": "Indicates this type is a scalar.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "OBJECT", + "description": "Indicates this type is an object. `fields` and `interfaces` are valid fields.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INTERFACE", + "description": "Indicates this type is an interface. `fields` and `possibleTypes` are valid fields.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UNION", + "description": "Indicates this type is a union. `possibleTypes` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM", + "description": "Indicates this type is an enum. `enumValues` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_OBJECT", + "description": "Indicates this type is an input object. `inputFields` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LIST", + "description": "Indicates this type is a list. `ofType` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NON_NULL", + "description": "Indicates this type is a non-null. `ofType` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "__DirectiveLocation", + "description": "A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "QUERY", + "description": "Location adjacent to a query operation.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MUTATION", + "description": "Location adjacent to a mutation operation.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SUBSCRIPTION", + "description": "Location adjacent to a subscription operation.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FIELD", + "description": "Location adjacent to a field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FRAGMENT_DEFINITION", + "description": "Location adjacent to a fragment definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FRAGMENT_SPREAD", + "description": "Location adjacent to a fragment spread.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INLINE_FRAGMENT", + "description": "Location adjacent to an inline fragment.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SCHEMA", + "description": "Location adjacent to a schema definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SCALAR", + "description": "Location adjacent to a scalar definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "OBJECT", + "description": "Location adjacent to an object type definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FIELD_DEFINITION", + "description": "Location adjacent to a field definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ARGUMENT_DEFINITION", + "description": "Location adjacent to an argument definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INTERFACE", + "description": "Location adjacent to an interface definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UNION", + "description": "Location adjacent to a union definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM", + "description": "Location adjacent to an enum definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM_VALUE", + "description": "Location adjacent to an enum value definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_OBJECT", + "description": "Location adjacent to an input object type definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_FIELD_DEFINITION", + "description": "Location adjacent to an input object field definition.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + } + ], + "directives": [ + { + "name": "include", + "description": "Directs the executor to include this field or fragment only when the `if` argument is true.", + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "name": "if", + "description": "Included when true.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null + } + ] + }, + { + "name": "skip", + "description": "Directs the executor to skip this field or fragment when the `if` argument is true.", + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "name": "if", + "description": "Skipped when true.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null + } + ] + }, + { + "name": "deprecated", + "description": "Marks an element of a GraphQL schema as no longer supported.", + "locations": [ + "FIELD_DEFINITION", + "ENUM_VALUE" + ], + "args": [ + { + "name": "reason", + "description": "Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted in [Markdown](https://daringfireball.net/projects/markdown/).", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": "\"No longer supported\"" + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 839289cf6773d09c2c9621476fda08b8bb984f08..151e43f4cff1327c4be99a5f2058378c2553f5ac 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -36,6 +36,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | Name | Type | Description | | --- | ---- | ---------- | | `id` | ID! | | +| `sha` | String! | Last commit sha for entry | | `name` | String! | | | `type` | EntryType! | | | `path` | String! | | @@ -47,16 +48,17 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | Name | Type | Description | | --- | ---- | ---------- | -| `id` | ID! | | -| `sha` | String! | | -| `title` | String | | -| `description` | String | | -| `message` | String | | -| `authoredDate` | Time | | -| `webUrl` | String! | | -| `signatureHtml` | String | Rendered html for the commit signature | -| `author` | User | | -| `latestPipeline` | Pipeline | Latest pipeline for this commit | +| `id` | ID! | ID (global ID) of the commit | +| `sha` | String! | SHA1 ID of the commit | +| `title` | String | Title of the commit message | +| `description` | String | Description of the commit message | +| `message` | String | Raw commit message | +| `authoredDate` | Time | Timestamp of when the commit was authored | +| `webUrl` | String! | Web URL of the commit | +| `signatureHtml` | String | Rendered HTML of the commit signature | +| `authorName` | String | Commit authors name | +| `author` | User | Author of the commit | +| `latestPipeline` | Pipeline | Latest pipeline of the commit | ### CreateDiffNotePayload @@ -66,6 +68,14 @@ 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 + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `epic` | Epic | The created epic | + ### CreateImageDiffNotePayload | Name | Type | Description | @@ -212,36 +222,47 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `relationPath` | String | | | `reference` | String! | | | `subscribed` | Boolean! | Boolean flag for whether the currently logged in user is subscribed to this epic | +| `descendantCounts` | EpicDescendantCount | Number of open and closed descendant epics and issues | + +### EpicDescendantCount + +| Name | Type | Description | +| --- | ---- | ---------- | +| `openedEpics` | Int | Number of opened sub-epics | +| `closedEpics` | Int | Number of closed sub-epics | +| `openedIssues` | Int | Number of opened epic issues | +| `closedIssues` | Int | Number of closed epic issues | ### EpicIssue | Name | Type | Description | | --- | ---- | ---------- | | `userPermissions` | IssuePermissions! | Permissions for the current user on the resource | -| `iid` | ID! | | -| `title` | String! | | +| `iid` | ID! | Internal ID of the issue | +| `title` | String! | Title of the issue | | `titleHtml` | String | The GitLab Flavored Markdown rendering of `title` | -| `description` | String | | +| `description` | String | Description of the issue | | `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` | -| `state` | IssueState! | | -| `reference` | String! | | -| `author` | User! | | -| `milestone` | Milestone | | -| `dueDate` | Time | | -| `confidential` | Boolean! | | -| `discussionLocked` | Boolean! | | -| `upvotes` | Int! | | -| `downvotes` | Int! | | -| `userNotesCount` | Int! | | -| `webPath` | String! | | -| `webUrl` | String! | | -| `relativePosition` | Int | | -| `timeEstimate` | Int! | The time estimate on the issue | +| `state` | IssueState! | State of the issue | +| `reference` | String! | Internal reference of the issue. Returned in shortened format by default | +| `author` | User! | User that created the issue | +| `milestone` | Milestone | Milestone of the issue | +| `dueDate` | Time | Due date of the issue | +| `confidential` | Boolean! | Indicates the issue is confidential | +| `discussionLocked` | Boolean! | Indicates discussion is locked on the issue | +| `upvotes` | Int! | Number of upvotes the issue has received | +| `downvotes` | Int! | Number of downvotes the issue has received | +| `userNotesCount` | Int! | Number of user notes of the issue | +| `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 | +| `timeEstimate` | Int! | Time estimate of the issue | | `totalTimeSpent` | Int! | Total time reported as spent on the issue | -| `closedAt` | Time | | -| `createdAt` | Time! | | -| `updatedAt` | Time! | | -| `taskCompletionStatus` | TaskCompletionStatus! | | +| `closedAt` | Time | Timestamp of when the issue was closed | +| `createdAt` | Time! | Timestamp of when the issue was created | +| `updatedAt` | Time! | Timestamp of when the issue was last updated | +| `taskCompletionStatus` | TaskCompletionStatus! | Task completion status of the issue | | `epic` | Epic | The epic to which issue belongs | | `weight` | Int | | | `designs` | DesignCollection | | @@ -263,67 +284,40 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `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 | -### EpicTreeReorderPayload +### EpicSetSubscriptionPayload | Name | Type | Description | | --- | ---- | ---------- | | `clientMutationId` | String | A unique identifier for the client performing the mutation. | | `errors` | String! => Array | Reasons why the mutation failed. | +| `epic` | Epic | The epic after mutation | -### ExtendedIssue +### EpicTreeReorderPayload | Name | Type | Description | | --- | ---- | ---------- | -| `userPermissions` | IssuePermissions! | Permissions for the current user on the resource | -| `iid` | ID! | | -| `title` | String! | | -| `titleHtml` | String | The GitLab Flavored Markdown rendering of `title` | -| `description` | String | | -| `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` | -| `state` | IssueState! | | -| `reference` | String! | | -| `author` | User! | | -| `milestone` | Milestone | | -| `dueDate` | Time | | -| `confidential` | Boolean! | | -| `discussionLocked` | Boolean! | | -| `upvotes` | Int! | | -| `downvotes` | Int! | | -| `userNotesCount` | Int! | | -| `webPath` | String! | | -| `webUrl` | String! | | -| `relativePosition` | Int | | -| `timeEstimate` | Int! | The time estimate on the issue | -| `totalTimeSpent` | Int! | Total time reported as spent on the issue | -| `closedAt` | Time | | -| `createdAt` | Time! | | -| `updatedAt` | Time! | | -| `taskCompletionStatus` | TaskCompletionStatus! | | -| `epic` | Epic | The epic to which issue belongs | -| `weight` | Int | | -| `designs` | DesignCollection | | -| `designCollection` | DesignCollection | | -| `subscribed` | Boolean! | Boolean flag for whether the currently logged in user is subscribed to this issue | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | ### Group | Name | Type | Description | | --- | ---- | ---------- | -| `id` | ID! | | -| `name` | String! | | -| `path` | String! | | -| `fullName` | String! | | -| `fullPath` | ID! | | -| `description` | String | | +| `id` | ID! | ID of the namespace | +| `name` | String! | Name of the namespace | +| `path` | String! | Path of the namespace | +| `fullName` | String! | Full name of the namespace | +| `fullPath` | ID! | Full path of the namespace | +| `description` | String | Description of the namespace | | `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` | -| `visibility` | String | | -| `lfsEnabled` | Boolean | | -| `requestAccessEnabled` | Boolean | | -| `rootStorageStatistics` | RootStorageStatistics | The aggregated storage statistics. Only available for root namespaces | +| `visibility` | String | Visibility of the namespace | +| `lfsEnabled` | Boolean | Indicates if Large File Storage (LFS) is enabled for namespace | +| `requestAccessEnabled` | Boolean | Indicates if users can request access to namespace | +| `rootStorageStatistics` | RootStorageStatistics | Aggregated storage statistics of the namespace. Only available for root namespaces | | `userPermissions` | GroupPermissions! | Permissions for the current user on the resource | -| `webUrl` | String! | | -| `avatarUrl` | String | | -| `parent` | Group | | +| `webUrl` | String! | Web URL of the group | +| `avatarUrl` | String | Avatar URL of the group | +| `parent` | Group | Parent group | | `epicsEnabled` | Boolean | | | `epic` | Epic | | @@ -338,30 +332,31 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | Name | Type | Description | | --- | ---- | ---------- | | `userPermissions` | IssuePermissions! | Permissions for the current user on the resource | -| `iid` | ID! | | -| `title` | String! | | +| `iid` | ID! | Internal ID of the issue | +| `title` | String! | Title of the issue | | `titleHtml` | String | The GitLab Flavored Markdown rendering of `title` | -| `description` | String | | +| `description` | String | Description of the issue | | `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` | -| `state` | IssueState! | | -| `reference` | String! | | -| `author` | User! | | -| `milestone` | Milestone | | -| `dueDate` | Time | | -| `confidential` | Boolean! | | -| `discussionLocked` | Boolean! | | -| `upvotes` | Int! | | -| `downvotes` | Int! | | -| `userNotesCount` | Int! | | -| `webPath` | String! | | -| `webUrl` | String! | | -| `relativePosition` | Int | | -| `timeEstimate` | Int! | The time estimate on the issue | +| `state` | IssueState! | State of the issue | +| `reference` | String! | Internal reference of the issue. Returned in shortened format by default | +| `author` | User! | User that created the issue | +| `milestone` | Milestone | Milestone of the issue | +| `dueDate` | Time | Due date of the issue | +| `confidential` | Boolean! | Indicates the issue is confidential | +| `discussionLocked` | Boolean! | Indicates discussion is locked on the issue | +| `upvotes` | Int! | Number of upvotes the issue has received | +| `downvotes` | Int! | Number of downvotes the issue has received | +| `userNotesCount` | Int! | Number of user notes of the issue | +| `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 | +| `timeEstimate` | Int! | Time estimate of the issue | | `totalTimeSpent` | Int! | Total time reported as spent on the issue | -| `closedAt` | Time | | -| `createdAt` | Time! | | -| `updatedAt` | Time! | | -| `taskCompletionStatus` | TaskCompletionStatus! | | +| `closedAt` | Time | Timestamp of when the issue was closed | +| `createdAt` | Time! | Timestamp of when the issue was created | +| `updatedAt` | Time! | Timestamp of when the issue was last updated | +| `taskCompletionStatus` | TaskCompletionStatus! | Task completion status of the issue | | `epic` | Epic | The epic to which issue belongs | | `weight` | Int | | | `designs` | DesignCollection | | @@ -384,65 +379,66 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | Name | Type | Description | | --- | ---- | ---------- | -| `description` | String | | +| `id` | ID! | Label ID | +| `description` | String | Description of the label (markdown rendered as HTML for caching) | | `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` | -| `title` | String! | | -| `color` | String! | | -| `textColor` | String! | | +| `title` | String! | Content of the label | +| `color` | String! | Background color of the label | +| `textColor` | String! | Text color of the label | ### MergeRequest | Name | Type | Description | | --- | ---- | ---------- | | `userPermissions` | MergeRequestPermissions! | Permissions for the current user on the resource | -| `id` | ID! | | -| `iid` | String! | | -| `title` | String! | | +| `id` | ID! | ID of the merge request | +| `iid` | String! | Internal ID of the merge request | +| `title` | String! | Title of the merge request | | `titleHtml` | String | The GitLab Flavored Markdown rendering of `title` | -| `description` | String | | +| `description` | String | Description of the merge request (markdown rendered as HTML for caching) | | `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` | -| `state` | MergeRequestState! | | -| `createdAt` | Time! | | -| `updatedAt` | Time! | | -| `sourceProject` | Project | | -| `targetProject` | Project! | | -| `diffRefs` | DiffRefs | | -| `project` | Project! | | -| `projectId` | Int! | | -| `sourceProjectId` | Int | | -| `targetProjectId` | Int! | | -| `sourceBranch` | String! | | -| `targetBranch` | String! | | -| `workInProgress` | Boolean! | | -| `mergeWhenPipelineSucceeds` | Boolean | | -| `diffHeadSha` | String | | -| `mergeCommitSha` | String | | -| `userNotesCount` | Int | | -| `shouldRemoveSourceBranch` | Boolean | | -| `forceRemoveSourceBranch` | Boolean | | -| `mergeStatus` | String | | -| `inProgressMergeCommitSha` | String | | -| `mergeError` | String | | -| `allowCollaboration` | Boolean | | -| `shouldBeRebased` | Boolean! | | -| `rebaseCommitSha` | String | | -| `rebaseInProgress` | Boolean! | | -| `mergeCommitMessage` | String | | -| `defaultMergeCommitMessage` | String | | -| `mergeOngoing` | Boolean! | | -| `sourceBranchExists` | Boolean! | | -| `mergeableDiscussionsState` | Boolean | | -| `webUrl` | String | | -| `upvotes` | Int! | | -| `downvotes` | Int! | | -| `headPipeline` | Pipeline | | -| `milestone` | Milestone | The milestone this merge request is linked to | -| `subscribed` | Boolean! | Boolean flag for whether the currently logged in user is subscribed to this MR | -| `discussionLocked` | Boolean! | Boolean flag determining if comments on the merge request are locked to members only | -| `timeEstimate` | Int! | The time estimate for the merge request | +| `state` | MergeRequestState! | State of the merge request | +| `createdAt` | Time! | Timestamp of when the merge request was created | +| `updatedAt` | Time! | Timestamp of when the merge request was last updated | +| `sourceProject` | Project | Source project of the merge request | +| `targetProject` | Project! | Target project of the merge request | +| `diffRefs` | DiffRefs | References of the base SHA, the head SHA, and the start SHA for this merge request | +| `project` | Project! | Alias for target_project | +| `projectId` | Int! | ID of the merge request project | +| `sourceProjectId` | Int | ID of the merge request source project | +| `targetProjectId` | Int! | ID of the merge request target project | +| `sourceBranch` | String! | Source branch of the merge request | +| `targetBranch` | String! | Target branch of the merge request | +| `workInProgress` | Boolean! | Indicates if the merge request is a work in progress (WIP) | +| `mergeWhenPipelineSucceeds` | Boolean | Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS) | +| `diffHeadSha` | String | Diff head SHA of the merge request | +| `mergeCommitSha` | String | SHA of the merge request commit (set once merged) | +| `userNotesCount` | Int | User notes count of the merge request | +| `shouldRemoveSourceBranch` | Boolean | Indicates if the source branch of the merge request will be deleted after merge | +| `forceRemoveSourceBranch` | Boolean | Indicates if the project settings will lead to source branch deletion after merge | +| `mergeStatus` | String | Status of the merge request | +| `inProgressMergeCommitSha` | String | Commit SHA of the merge request if merge is in progress | +| `mergeError` | String | Error message due to a merge error | +| `allowCollaboration` | Boolean | Indicates if members of the target project can push to the fork | +| `shouldBeRebased` | Boolean! | Indicates if the merge request will be rebased | +| `rebaseCommitSha` | String | Rebase commit SHA of the merge request | +| `rebaseInProgress` | Boolean! | Indicates if there is a rebase currently in progress for the merge request | +| `mergeCommitMessage` | String | Deprecated - renamed to defaultMergeCommitMessage | +| `defaultMergeCommitMessage` | String | Default merge commit message of the merge request | +| `mergeOngoing` | Boolean! | Indicates if a merge is currently occurring | +| `sourceBranchExists` | Boolean! | Indicates if the source branch of the merge request exists | +| `mergeableDiscussionsState` | Boolean | Indicates if all discussions in the merge request have been resolved, allowing the merge request to be merged | +| `webUrl` | String | Web URL of the merge request | +| `upvotes` | Int! | Number of upvotes for the merge request | +| `downvotes` | Int! | Number of downvotes for the merge request | +| `headPipeline` | Pipeline | The pipeline running on the branch HEAD of the merge request | +| `milestone` | Milestone | The milestone of the merge request | +| `subscribed` | Boolean! | Indicates if the currently logged in user is subscribed to this merge request | +| `discussionLocked` | Boolean! | Indicates if comments on the merge request are locked to members only | +| `timeEstimate` | Int! | Time estimate of the merge request | | `totalTimeSpent` | Int! | Total time reported as spent on the merge request | -| `reference` | String! | Internal merge request reference. Returned in shortened format by default | -| `taskCompletionStatus` | TaskCompletionStatus! | | +| `reference` | String! | Internal reference of the merge request. Returned in shortened format by default | +| `taskCompletionStatus` | TaskCompletionStatus! | Completion status of tasks | ### MergeRequestPermissions @@ -457,6 +453,46 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `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 | +### MergeRequestSetAssigneesPayload + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `mergeRequest` | MergeRequest | The merge request after mutation | + +### MergeRequestSetLabelsPayload + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `mergeRequest` | MergeRequest | The merge request after mutation | + +### MergeRequestSetLockedPayload + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `mergeRequest` | MergeRequest | The merge request after mutation | + +### MergeRequestSetMilestonePayload + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `mergeRequest` | MergeRequest | The merge request after mutation | + +### MergeRequestSetSubscriptionPayload + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `mergeRequest` | MergeRequest | The merge request after mutation | + ### MergeRequestSetWipPayload | Name | Type | Description | @@ -469,36 +505,37 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | Name | Type | Description | | --- | ---- | ---------- | -| `version` | String! | | -| `revision` | String! | | +| `version` | String! | Version | +| `revision` | String! | Revision | ### Milestone | Name | Type | Description | | --- | ---- | ---------- | -| `description` | String | | -| `title` | String! | | -| `state` | String! | | -| `dueDate` | Time | | -| `startDate` | Time | | -| `createdAt` | Time! | | -| `updatedAt` | Time! | | +| `id` | ID! | ID of the milestone | +| `description` | String | Description of the milestone | +| `title` | String! | Title of the milestone | +| `state` | String! | State of the milestone | +| `dueDate` | Time | Timestamp of the milestone due date | +| `startDate` | Time | Timestamp of the milestone start date | +| `createdAt` | Time! | Timestamp of milestone creation | +| `updatedAt` | Time! | Timestamp of last milestone update | ### Namespace | Name | Type | Description | | --- | ---- | ---------- | -| `id` | ID! | | -| `name` | String! | | -| `path` | String! | | -| `fullName` | String! | | -| `fullPath` | ID! | | -| `description` | String | | +| `id` | ID! | ID of the namespace | +| `name` | String! | Name of the namespace | +| `path` | String! | Path of the namespace | +| `fullName` | String! | Full name of the namespace | +| `fullPath` | ID! | Full path of the namespace | +| `description` | String | Description of the namespace | | `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` | -| `visibility` | String | | -| `lfsEnabled` | Boolean | | -| `requestAccessEnabled` | Boolean | | -| `rootStorageStatistics` | RootStorageStatistics | The aggregated storage statistics. Only available for root namespaces | +| `visibility` | String | Visibility of the namespace | +| `lfsEnabled` | Boolean | Indicates if Large File Storage (LFS) is enabled for namespace | +| `requestAccessEnabled` | Boolean | Indicates if users can request access to namespace | +| `rootStorageStatistics` | RootStorageStatistics | Aggregated storage statistics of the namespace. Only available for root namespaces | ### Note @@ -570,46 +607,47 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | Name | Type | Description | | --- | ---- | ---------- | | `userPermissions` | ProjectPermissions! | Permissions for the current user on the resource | -| `id` | ID! | | -| `fullPath` | ID! | | -| `path` | String! | | -| `nameWithNamespace` | String! | | -| `name` | String! | | -| `description` | String | | +| `id` | ID! | ID of the project | +| `fullPath` | ID! | Full path of the project | +| `path` | String! | Path of the project | +| `nameWithNamespace` | String! | Full name of the project with its namespace | +| `name` | String! | Name of the project (without namespace) | +| `description` | String | Short description of the project | | `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` | -| `tagList` | String | | -| `sshUrlToRepo` | String | | -| `httpUrlToRepo` | String | | -| `webUrl` | String | | -| `starCount` | Int! | | -| `forksCount` | Int! | | -| `createdAt` | Time | | -| `lastActivityAt` | Time | | -| `archived` | Boolean | | -| `visibility` | String | | -| `containerRegistryEnabled` | Boolean | | -| `sharedRunnersEnabled` | Boolean | | -| `lfsEnabled` | Boolean | | -| `mergeRequestsFfOnlyEnabled` | Boolean | | -| `avatarUrl` | String | | -| `issuesEnabled` | Boolean | | -| `mergeRequestsEnabled` | Boolean | | -| `wikiEnabled` | Boolean | | -| `snippetsEnabled` | Boolean | | -| `jobsEnabled` | Boolean | | -| `publicJobs` | Boolean | | -| `openIssuesCount` | Int | | -| `importStatus` | String | | -| `onlyAllowMergeIfPipelineSucceeds` | Boolean | | -| `requestAccessEnabled` | Boolean | | -| `onlyAllowMergeIfAllDiscussionsAreResolved` | Boolean | | -| `printingMergeRequestLinkEnabled` | Boolean | | -| `namespace` | Namespace | | -| `group` | Group | | -| `statistics` | ProjectStatistics | | -| `repository` | Repository | | -| `mergeRequest` | MergeRequest | | -| `issue` | ExtendedIssue | | +| `tagList` | String | List of project tags | +| `sshUrlToRepo` | String | URL to connect to the project via SSH | +| `httpUrlToRepo` | String | URL to connect to the project via HTTPS | +| `webUrl` | String | Web URL of the project | +| `starCount` | Int! | Number of times the project has been starred | +| `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 | +| `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 | +| `lfsEnabled` | Boolean | Indicates if the project has Large File Storage (LFS) enabled | +| `mergeRequestsFfOnlyEnabled` | Boolean | Indicates if no merge commits should be created and all merges should instead be fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded. | +| `avatarUrl` | String | URL to avatar image file of the project | +| `issuesEnabled` | Boolean | (deprecated) Does this project have issues enabled?. Use `issues_access_level` instead | +| `mergeRequestsEnabled` | Boolean | (deprecated) Does this project have merge_requests enabled?. Use `merge_requests_access_level` instead | +| `wikiEnabled` | Boolean | (deprecated) Does this project have wiki enabled?. Use `wiki_access_level` instead | +| `snippetsEnabled` | Boolean | (deprecated) Does this project have snippets enabled?. Use `snippets_access_level` instead | +| `jobsEnabled` | Boolean | (deprecated) Enable jobs for this project. Use `builds_access_level` instead | +| `publicJobs` | Boolean | Indicates if there is public access to pipelines and job details of the project, including output logs and artifacts | +| `openIssuesCount` | Int | Number of open issues for the project | +| `importStatus` | String | Status of project import background job of the project | +| `onlyAllowMergeIfPipelineSucceeds` | Boolean | Indicates if merge requests of the project can only be merged with successful jobs | +| `requestAccessEnabled` | Boolean | Indicates if users can request member access to the project | +| `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 | +| `namespace` | Namespace | Namespace of the project | +| `group` | Group | Group of the project | +| `statistics` | ProjectStatistics | Statistics of the project | +| `repository` | Repository | Git repository of the project | +| `mergeRequest` | MergeRequest | A single merge request of the project | +| `issue` | Issue | A single issue of the project | ### ProjectPermissions @@ -661,13 +699,13 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | Name | Type | Description | | --- | ---- | ---------- | -| `commitCount` | Int! | | -| `storageSize` | Int! | | -| `repositorySize` | Int! | | -| `lfsObjectsSize` | Int! | | -| `buildArtifactsSize` | Int! | | -| `packagesSize` | Int! | | -| `wikiSize` | Int | | +| `commitCount` | Int! | Commit count of the project | +| `storageSize` | Int! | Storage size of the project | +| `repositorySize` | Int! | Repository size of the project | +| `lfsObjectsSize` | Int! | Large File Storage (LFS) object size of the project | +| `buildArtifactsSize` | Int! | Build artifacts size of the project | +| `packagesSize` | Int! | Packages size of the project | +| `wikiSize` | Int | Wiki size of the project | ### RemoveAwardEmojiPayload @@ -681,10 +719,10 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | Name | Type | Description | | --- | ---- | ---------- | -| `rootRef` | String | | -| `empty` | Boolean! | | -| `exists` | Boolean! | | -| `tree` | Tree | | +| `rootRef` | String | Default branch of the repository | +| `empty` | Boolean! | Indicates repository has no visible content | +| `exists` | Boolean! | Indicates a corresponding Git repository exists on disk | +| `tree` | Tree | Tree of the repository | ### RootStorageStatistics @@ -702,6 +740,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | Name | Type | Description | | --- | ---- | ---------- | | `id` | ID! | | +| `sha` | String! | Last commit sha for entry | | `name` | String! | | | `type` | EntryType! | | | `path` | String! | | @@ -713,8 +752,8 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | Name | Type | Description | | --- | ---- | ---------- | -| `count` | Int! | | -| `completedCount` | Int! | | +| `count` | Int! | Number of total tasks | +| `completedCount` | Int! | Number of completed tasks | ### Todo @@ -730,6 +769,14 @@ 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 + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `todo` | Todo! | The requested todo | + ### ToggleAwardEmojiPayload | Name | Type | Description | @@ -750,6 +797,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | Name | Type | Description | | --- | ---- | ---------- | | `id` | ID! | | +| `sha` | String! | Last commit sha for entry | | `name` | String! | | | `type` | EntryType! | | | `path` | String! | | @@ -776,7 +824,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | Name | Type | Description | | --- | ---- | ---------- | -| `name` | String! | | -| `username` | String! | | -| `avatarUrl` | String! | | -| `webUrl` | String! | | +| `name` | String! | Human-readable name of the user | +| `username` | String! | Username of the user. Unique within this instance of GitLab | +| `avatarUrl` | String! | URL of the user's avatar | +| `webUrl` | String! | Web URL of the user | diff --git a/doc/api/group_clusters.md b/doc/api/group_clusters.md index e878bb5fa4d31d7ebf7798b828ce01691e1029c2..143f57628112359a9f3a06a2c71f7afee5614108 100644 --- a/doc/api/group_clusters.md +++ b/doc/api/group_clusters.md @@ -53,6 +53,16 @@ Example response: "api_url":"https://104.197.68.152", "authorization_type":"rbac", "ca_cert":"-----BEGIN CERTIFICATE-----\r\nhFiK1L61owwDQYJKoZIhvcNAQELBQAw\r\nLzEtMCsGA1UEAxMkZDA1YzQ1YjctNzdiMS00NDY0LThjNmEtMTQ0ZDJkZjM4ZDBj\r\nMB4XDTE4MTIyNzIwMDM1MVoXDTIzMTIyNjIxMDM1MVowLzEtMCsGA1UEAxMkZDA1\r\nYzQ1YjctNzdiMS00NDY0LThjNmEtMTQ0ZDJkZjM.......-----END CERTIFICATE-----" + }, + "management_project": + { + "id":2, + "description":null, + "name":"project2", + "name_with_namespace":"John Doe8 / project2", + "path":"project2", + "path_with_namespace":"namespace2/project2", + "created_at":"2019-10-11T02:55:54.138Z" } }, { @@ -111,6 +121,16 @@ Example response: "authorization_type":"rbac", "ca_cert":"-----BEGIN CERTIFICATE-----\r\nhFiK1L61owwDQYJKoZIhvcNAQELBQAw\r\nLzEtMCsGA1UEAxMkZDA1YzQ1YjctNzdiMS00NDY0LThjNmEtMTQ0ZDJkZjM4ZDBj\r\nMB4XDTE4MTIyNzIwMDM1MVoXDTIzMTIyNjIxMDM1MVowLzEtMCsGA1UEAxMkZDA1\r\nYzQ1YjctNzdiMS00NDY0LThjNmEtMTQ0ZDJkZjM.......-----END CERTIFICATE-----" }, + "management_project": + { + "id":2, + "description":null, + "name":"project2", + "name_with_namespace":"John Doe8 / project2", + "path":"project2", + "path_with_namespace":"namespace2/project2", + "created_at":"2019-10-11T02:55:54.138Z" + }, "group": { "id":26, @@ -135,6 +155,7 @@ Parameters: | `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) | | `name` | String | yes | The name of the cluster | | `domain` | String | no | The [base domain](../user/group/clusters/index.md#base-domain) of the cluster | +| `management_project_id` | integer | no | The ID of the [management project](../user/clusters/management_project.md) for the cluster | | `enabled` | Boolean | no | Determines if cluster is active or not, defaults to true | | `managed` | Boolean | no | Determines if GitLab will manage namespaces and service accounts for this cluster, defaults to true | | `platform_kubernetes_attributes[api_url]` | String | yes | The URL to access the Kubernetes API | @@ -178,6 +199,7 @@ Example response: "authorization_type":"rbac", "ca_cert":"-----BEGIN CERTIFICATE-----\r\nhFiK1L61owwDQYJKoZIhvcNAQELBQAw\r\nLzEtMCsGA1UEAxMkZDA1YzQ1YjctNzdiMS00NDY0LThjNmEtMTQ0ZDJkZjM4ZDBj\r\nMB4XDTE4MTIyNzIwMDM1MVoXDTIzMTIyNjIxMDM1MVowLzEtMCsGA1UEAxMkZDA1\r\nYzQ1YjctNzdiMS00NDY0LThjNmEtMTQ0ZDJkZjM.......-----END CERTIFICATE-----" }, + "management_project":null, "group": { "id":26, @@ -210,7 +232,7 @@ Parameters: NOTE: **Note:** `name`, `api_url`, `ca_cert` and `token` can only be updated if the cluster was added -through the ["Add existing Kubernetes cluster"](../user/project/clusters/index.md#add-existing-kubernetes-cluster) option or +through the ["Add existing Kubernetes cluster"](../user/project/clusters/add_remove_clusters.md#add-existing-cluster) option or through the ["Add existing cluster to group"](#add-existing-cluster-to-group) endpoint. Example request: @@ -248,6 +270,16 @@ Example response: "authorization_type":"rbac", "ca_cert":null }, + "management_project": + { + "id":2, + "description":null, + "name":"project2", + "name_with_namespace":"John Doe8 / project2", + "path":"project2", + "path_with_namespace":"namespace2/project2", + "created_at":"2019-10-11T02:55:54.138Z" + }, "group": { "id":26, diff --git a/doc/api/groups.md b/doc/api/groups.md index 312bd04e24cffb9dbd3cabd6618c40e9e08dfb52..94f46b11a0f4129d82678c300040299b8486eaf4 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -611,6 +611,10 @@ GET /groups?search=foobar ] ``` +## Group Audit Events **(STARTER)** + +Group audit events can be accessed via the [Group Audit Events API](audit_events.md#group-audit-events-starter) + ## Sync group with LDAP **(CORE ONLY)** Syncs the group with its linked LDAP group. Only available to group owners and administrators. diff --git a/doc/api/issues.md b/doc/api/issues.md index 0ddbb18ce9218ec4eab56fa433e34a566e3a5ca9..54b2737074192152489b79780a9bc9954e6a5834 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -577,14 +577,22 @@ the `weight` parameter: ``` Users on GitLab [Ultimate](https://about.gitlab.com/pricing/) will additionally see -the `epic_iid` property: +the `epic` property: -```json +```javascript { "project_id" : 4, "description" : "Omnis vero earum sunt corporis dolor et placeat.", - "epic_iid" : 42, - ... + "epic": { + "epic_iid" : 5, //deprecated, use `iid` of the `epic` attribute + "epic": { + "id" : 42, + "iid" : 5, + "title": "My epic epic", + "url" : "/groups/h5bp/-/epics/5", + "group_id": 8 + }, + // ... } ``` @@ -592,6 +600,9 @@ the `epic_iid` property: **Note**: The `closed_by` attribute was [introduced in GitLab 10.6][ce-17042]. This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists. +**Note**: The `epic_iid` attribute is deprecated and [will be removed in 13.0](https://gitlab.com/gitlab-org/gitlab/issues/35157). +Please use `iid` of the `epic` attribute instead. + ## New issue Creates a new project issue. @@ -1416,6 +1427,7 @@ Example response: "merge_status": "cannot_be_merged", "sha": "3b7b528e9353295c1c125dad281ac5b5deae5f12", "merge_commit_sha": null, + "squash_commit_sha": null, "discussion_locked": null, "should_remove_source_branch": null, "force_remove_source_branch": false, @@ -1546,6 +1558,7 @@ Example response: "merge_status": "unchecked", "sha": "5a62481d563af92b8e32d735f2fa63b94e806835", "merge_commit_sha": null, + "squash_commit_sha": null, "user_notes_count": 1, "should_remove_source_branch": null, "force_remove_source_branch": false, diff --git a/doc/api/license.md b/doc/api/license.md index 12f1d03d576294e812b12e801087b686a9040a96..c56a5fee95a46eb65d6020a69274827a39026ec1 100644 --- a/doc/api/license.md +++ b/doc/api/license.md @@ -17,6 +17,7 @@ GET /license "starts_at": "2018-01-27", "expires_at": "2022-01-27", "historical_max": 300, + "maximum_user_count": 300, "expired": false, "overage": 200, "user_limit": 100, @@ -46,6 +47,7 @@ GET /licenses "starts_at": "2018-01-27", "expires_at": "2022-01-27", "historical_max": 300, + "maximum_user_count": 300, "expired": false, "overage": 200, "user_limit": 100, @@ -64,6 +66,7 @@ GET /licenses "starts_at": "2018-01-27", "expires_at": "2022-01-27", "historical_max": 300, + "maximum_user_count": 300, "expired": false, "overage": 200, "user_limit": 100, @@ -112,6 +115,7 @@ Example response: "starts_at": "2018-01-27", "expires_at": "2022-01-27", "historical_max": 300, + "maximum_user_count": 300, "expired": false, "overage": 200, "user_limit": 100, @@ -155,6 +159,7 @@ Example response: "starts_at": "2018-01-27", "expires_at": "2022-01-27", "historical_max": 300, + "maximum_user_count": 300, "expired": false, "overage": 200, "user_limit": 100, diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index 4bc46c3030d1c07171dcd59c71855db91df59d53..7074d0249ef31a2dfb73435a77daa8f41cb4264b 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -126,6 +126,7 @@ Parameters: "merge_status": "can_be_merged", "sha": "8888888888888888888888888888888888888888", "merge_commit_sha": null, + "squash_commit_sha": null, "user_notes_count": 1, "discussion_locked": null, "should_remove_source_branch": true, @@ -287,6 +288,7 @@ Parameters: "merge_status": "can_be_merged", "sha": "8888888888888888888888888888888888888888", "merge_commit_sha": null, + "squash_commit_sha": null, "user_notes_count": 1, "discussion_locked": null, "should_remove_source_branch": true, @@ -304,7 +306,9 @@ Parameters: "task_completion_status":{ "count":0, "completed_count":0 - } + }, + "has_conflicts": false, + "blocking_discussions_resolved": true } ] ``` @@ -438,6 +442,7 @@ Parameters: "merge_status": "can_be_merged", "sha": "8888888888888888888888888888888888888888", "merge_commit_sha": null, + "squash_commit_sha": null, "user_notes_count": 1, "discussion_locked": null, "should_remove_source_branch": true, @@ -453,7 +458,9 @@ Parameters: "task_completion_status":{ "count":0, "completed_count":0 - } + }, + "has_conflicts": false, + "blocking_discussions_resolved": true } ] ``` @@ -559,6 +566,7 @@ Parameters: "merge_error": null, "sha": "8888888888888888888888888888888888888888", "merge_commit_sha": null, + "squash_commit_sha": null, "user_notes_count": 1, "discussion_locked": null, "should_remove_source_branch": true, @@ -606,7 +614,9 @@ Parameters: "task_completion_status":{ "count":0, "completed_count":0 - } + }, + "has_conflicts": false, + "blocking_discussions_resolved": true } ``` @@ -763,6 +773,7 @@ Parameters: "subscribed" : true, "sha": "8888888888888888888888888888888888888888", "merge_commit_sha": null, + "squash_commit_sha": null, "user_notes_count": 1, "changes_count": "1", "should_remove_source_branch": true, @@ -970,6 +981,7 @@ order for it to take effect: "merge_error": null, "sha": "8888888888888888888888888888888888888888", "merge_commit_sha": null, + "squash_commit_sha": null, "user_notes_count": 1, "discussion_locked": null, "should_remove_source_branch": true, @@ -1123,6 +1135,7 @@ Must include at least one non-required attribute from above. "merge_error": null, "sha": "8888888888888888888888888888888888888888", "merge_commit_sha": null, + "squash_commit_sha": null, "user_notes_count": 1, "discussion_locked": null, "should_remove_source_branch": true, @@ -1292,6 +1305,7 @@ Parameters: "merge_error": null, "sha": "8888888888888888888888888888888888888888", "merge_commit_sha": null, + "squash_commit_sha": null, "user_notes_count": 1, "discussion_locked": null, "should_remove_source_branch": true, @@ -1464,6 +1478,7 @@ Parameters: "merge_error": null, "sha": "8888888888888888888888888888888888888888", "merge_commit_sha": null, + "squash_commit_sha": null, "user_notes_count": 1, "discussion_locked": null, "should_remove_source_branch": true, @@ -1749,6 +1764,7 @@ Example response: "merge_status": "can_be_merged", "sha": "8888888888888888888888888888888888888888", "merge_commit_sha": null, + "squash_commit_sha": null, "user_notes_count": 1, "discussion_locked": null, "should_remove_source_branch": true, @@ -1894,6 +1910,7 @@ Example response: "merge_status": "can_be_merged", "sha": "8888888888888888888888888888888888888888", "merge_commit_sha": null, + "squash_commit_sha": null, "user_notes_count": 1, "discussion_locked": null, "should_remove_source_branch": true, @@ -2055,6 +2072,7 @@ Example response: "subscribed": true, "sha": "8888888888888888888888888888888888888888", "merge_commit_sha": null, + "squash_commit_sha": null, "user_notes_count": 7, "changes_count": "1", "should_remove_source_branch": true, diff --git a/doc/api/packages.md b/doc/api/packages.md index 13d773e4f9900034e37c411705d2b89a40265558..bab3f91bc40a941c8c67b8eff4126fb642a15d51 100644 --- a/doc/api/packages.md +++ b/doc/api/packages.md @@ -2,7 +2,9 @@ This is the API docs of [GitLab Packages](../administration/packages/index.md). -## List project packages +## List packages + +### Within a project > [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/9259) in GitLab 11.8. @@ -42,6 +44,47 @@ Example response: By default, the `GET` request will return 20 results, since the API is [paginated](README.md#pagination). +### Within a group + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/18871) in GitLab 12.5. + +Get a list of project packages at the group level. +When accessed without authentication, only packages of public projects are returned. + +``` +GET /groups/:id/packages +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | ID or [URL-encoded path of the group](README.md#namespaced-path-encoding). | +| `exclude_subgroups` | boolean | false | If the param is included as true, packages from projects from subgroups are not listed. Default is `false`. | + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/:id/packages?exclude_subgroups=true +``` + +Example response: + +```json +[ + { + "id": 1, + "name": "com/mycompany/my-app", + "version": "1.0-SNAPSHOT", + "package_type": "maven" + }, + { + "id": 2, + "name": "@foo/bar", + "version": "1.0.3", + "package_type": "npm" + } +] +``` + +By default, the `GET` request will return 20 results, since the API is [paginated](README.md#pagination). + ## Get a project package > [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/9667) in GitLab 11.9. diff --git a/doc/api/pages_domains.md b/doc/api/pages_domains.md index 9678203eb4081b23e255889e50beef0efc3a5659..9d482781cdec24001dcb026ef771e81c4cd1fede 100644 --- a/doc/api/pages_domains.md +++ b/doc/api/pages_domains.md @@ -22,6 +22,7 @@ curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/ap "domain": "ssl.domain.example", "url": "https://ssl.domain.example", "project_id": 1337, + "auto_ssl_enabled": false, "certificate": { "expired": false, "expiration": "2020-04-12T14:32:00.000Z" @@ -55,6 +56,7 @@ curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/ap { "domain": "ssl.domain.example", "url": "https://ssl.domain.example", + "auto_ssl_enabled": false, "certificate": { "subject": "/O=Example, Inc./OU=Example Origin CA/CN=Example Origin Certificate", "expired": false, @@ -76,7 +78,7 @@ GET /projects/:id/pages/domains/:domain | Attribute | Type | Required | Description | | --------- | -------------- | -------- | ---------------------------------------- | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | -| `domain` | string | yes | The domain | +| `domain` | string | yes | The custom domain indicated by the user | ```bash curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/5/pages/domains/www.domain.example @@ -97,6 +99,7 @@ curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/ap { "domain": "ssl.domain.example", "url": "https://ssl.domain.example", + "auto_ssl_enabled": false, "certificate": { "subject": "/O=Example, Inc./OU=Example Origin CA/CN=Example Origin Certificate", "expired": false, @@ -114,12 +117,13 @@ Creates a new pages domain. The user must have permissions to create new pages d POST /projects/:id/pages/domains ``` -| Attribute | Type | Required | Description | -| ------------- | -------------- | -------- | ---------------------------------------- | -| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | -| `domain` | string | yes | The domain | -| `certificate` | file/string | no | The certificate in PEM format with intermediates following in most specific to least specific order.| -| `key` | file/string | no | The certificate key in PEM format. | +| Attribute | Type | Required | Description | +| -------------------| -------------- | -------- | ---------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `domain` | string | yes | The custom domain indicated by the user | +| `auto_ssl_enabled` | boolean | no | Enables [automatic generation](../user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md) of SSL certificates issued by Let's Encrypt for custom domains. | +| `certificate` | file/string | no | The certificate in PEM format with intermediates following in most specific to least specific order.| +| `key` | file/string | no | The certificate key in PEM format. | ```bash curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --form "domain=ssl.domain.example" --form "certificate=@/path/to/cert.pem" --form "key=@/path/to/key.pem" https://gitlab.example.com/api/v4/projects/5/pages/domains @@ -129,10 +133,15 @@ curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --form "domain curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --form "domain=ssl.domain.example" --form "certificate=$CERT_PEM" --form "key=$KEY_PEM" https://gitlab.example.com/api/v4/projects/5/pages/domains ``` +```bash +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --form "domain=ssl.domain.example" --form "auto_ssl_enabled=true" https://gitlab.example.com/api/v4/projects/5/pages/domains +``` + ```json { "domain": "ssl.domain.example", "url": "https://ssl.domain.example", + "auto_ssl_enabled": true, "certificate": { "subject": "/O=Example, Inc./OU=Example Origin CA/CN=Example Origin Certificate", "expired": false, @@ -150,12 +159,15 @@ Updates an existing project pages domain. The user must have permissions to chan PUT /projects/:id/pages/domains/:domain ``` -| Attribute | Type | Required | Description | -| ------------- | -------------- | -------- | ---------------------------------------- | -| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | -| `domain` | string | yes | The domain | -| `certificate` | file/string | no | The certificate in PEM format with intermediates following in most specific to least specific order.| -| `key` | file/string | no | The certificate key in PEM format. | +| Attribute | Type | Required | Description | +| ------------------ | -------------- | -------- | ---------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `domain` | string | yes | The custom domain indicated by the user | +| `auto_ssl_enabled` | boolean | no | Enables [automatic generation](../user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md) of SSL certificates issued by Let's Encrypt for custom domains. | +| `certificate` | file/string | no | The certificate in PEM format with intermediates following in most specific to least specific order.| +| `key` | file/string | no | The certificate key in PEM format. | + +### Adding certificate ```bash curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" --form "certificate=@/path/to/cert.pem" --form "key=@/path/to/key.pem" https://gitlab.example.com/api/v4/projects/5/pages/domains/ssl.domain.example @@ -169,6 +181,7 @@ curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" --form "certifi { "domain": "ssl.domain.example", "url": "https://ssl.domain.example", + "auto_ssl_enabled": false, "certificate": { "subject": "/O=Example, Inc./OU=Example Origin CA/CN=Example Origin Certificate", "expired": false, @@ -178,6 +191,36 @@ curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" --form "certifi } ``` +### Enabling Let's Encrypt integration for Pages custom domains + +```bash +curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" --form "auto_ssl_enabled=true" https://gitlab.example.com/api/v4/projects/5/pages/domains/ssl.domain.example +``` + +```json +{ + "domain": "ssl.domain.example", + "url": "https://ssl.domain.example", + "auto_ssl_enabled": true +} +``` + +### Removing certificate + +To remove the SSL certificate attached to the Pages domain, run: + +```bash +curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" --form "certificate=" --form "key=" https://gitlab.example.com/api/v4/projects/5/pages/domains/ssl.domain.example +``` + +```json +{ + "domain": "ssl.domain.example", + "url": "https://ssl.domain.example", + "auto_ssl_enabled": false +} +``` + ## Delete pages domain Deletes an existing project pages domain. @@ -189,7 +232,7 @@ DELETE /projects/:id/pages/domains/:domain | Attribute | Type | Required | Description | | --------- | -------------- | -------- | ---------------------------------------- | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | -| `domain` | string | yes | The domain | +| `domain` | string | yes | The custom domain indicated by the user | ```bash curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/5/pages/domains/ssl.domain.example diff --git a/doc/api/project_clusters.md b/doc/api/project_clusters.md index 633ef20deb404b3917f3eacb2bc23edd3d88301d..1aa225d30abd8e57e8a90ff18c39cbbb83cc201b 100644 --- a/doc/api/project_clusters.md +++ b/doc/api/project_clusters.md @@ -1,7 +1,6 @@ # Project clusters API -> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/23922) -in GitLab 11.7. +> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/23922) in GitLab 11.7. NOTE: **Note:** User will need at least maintainer access to use these endpoints. @@ -54,6 +53,16 @@ Example response: "namespace":"cluster-1-namespace", "authorization_type":"rbac", "ca_cert":"-----BEGIN CERTIFICATE-----\r\nhFiK1L61owwDQYJKoZIhvcNAQELBQAw\r\nLzEtMCsGA1UEAxMkZDA1YzQ1YjctNzdiMS00NDY0LThjNmEtMTQ0ZDJkZjM4ZDBj\r\nMB4XDTE4MTIyNzIwMDM1MVoXDTIzMTIyNjIxMDM1MVowLzEtMCsGA1UEAxMkZDA1\r\nYzQ1YjctNzdiMS00NDY0LThjNmEtMTQ0ZDJkZjM.......-----END CERTIFICATE-----" + }, + "management_project": + { + "id":2, + "description":null, + "name":"project2", + "name_with_namespace":"John Doe8 / project2", + "path":"project2", + "path_with_namespace":"namespace2/project2", + "created_at":"2019-10-11T02:55:54.138Z" } }, { @@ -113,6 +122,16 @@ Example response: "authorization_type":"rbac", "ca_cert":"-----BEGIN CERTIFICATE-----\r\nhFiK1L61owwDQYJKoZIhvcNAQELBQAw\r\nLzEtMCsGA1UEAxMkZDA1YzQ1YjctNzdiMS00NDY0LThjNmEtMTQ0ZDJkZjM4ZDBj\r\nMB4XDTE4MTIyNzIwMDM1MVoXDTIzMTIyNjIxMDM1MVowLzEtMCsGA1UEAxMkZDA1\r\nYzQ1YjctNzdiMS00NDY0LThjNmEtMTQ0ZDJkZjM.......-----END CERTIFICATE-----" }, + "management_project": + { + "id":2, + "description":null, + "name":"project2", + "name_with_namespace":"John Doe8 / project2", + "path":"project2", + "path_with_namespace":"namespace2/project2", + "created_at":"2019-10-11T02:55:54.138Z" + }, "project": { "id":26, @@ -205,6 +224,7 @@ Example response: "authorization_type":"rbac", "ca_cert":"-----BEGIN CERTIFICATE-----\r\nhFiK1L61owwDQYJKoZIhvcNAQELBQAw\r\nLzEtMCsGA1UEAxMkZDA1YzQ1YjctNzdiMS00NDY0LThjNmEtMTQ0ZDJkZjM4ZDBj\r\nMB4XDTE4MTIyNzIwMDM1MVoXDTIzMTIyNjIxMDM1MVowLzEtMCsGA1UEAxMkZDA1\r\nYzQ1YjctNzdiMS00NDY0LThjNmEtMTQ0ZDJkZjM.......-----END CERTIFICATE-----" }, + "management_project":null, "project": { "id":26, @@ -253,6 +273,7 @@ Parameters: | `cluster_id` | integer | yes | The ID of the cluster | | `name` | String | no | The name of the cluster | | `domain` | String | no | The [base domain](../user/project/clusters/index.md#base-domain) of the cluster | +| `management_project_id` | integer | no | The ID of the [management project](../user/clusters/management_project.md) for the cluster | | `platform_kubernetes_attributes[api_url]` | String | no | The URL to access the Kubernetes API | | `platform_kubernetes_attributes[token]` | String | no | The token to authenticate against Kubernetes | | `platform_kubernetes_attributes[ca_cert]` | String | no | TLS certificate (needed if API is using a self-signed TLS certificate | @@ -261,7 +282,7 @@ Parameters: NOTE: **Note:** `name`, `api_url`, `ca_cert` and `token` can only be updated if the cluster was added -through the ["Add existing Kubernetes cluster"](../user/project/clusters/index.md#add-existing-kubernetes-cluster) option or +through the ["Add existing Kubernetes cluster"](../user/project/clusters/add_remove_clusters.md#add-existing-cluster) option or through the ["Add existing cluster to project"](#add-existing-cluster-to-project) endpoint. Example request: @@ -300,6 +321,16 @@ Example response: "authorization_type":"rbac", "ca_cert":null }, + "management_project": + { + "id":2, + "description":null, + "name":"project2", + "name_with_namespace":"John Doe8 / project2", + "path":"project2", + "path_with_namespace":"namespace2/project2", + "created_at":"2019-10-11T02:55:54.138Z" + }, "project": { "id":26, @@ -351,5 +382,5 @@ Parameters: Example request: ```bash -curl --header 'Private-Token: <your_access_token>' https://gitlab.example.com/api/v4/projects/26/clusters/23' +curl --request DELETE --header 'Private-Token: <your_access_token>' https://gitlab.example.com/api/v4/projects/26/clusters/23 ``` diff --git a/doc/api/projects.md b/doc/api/projects.md index c352b972b17cbcdf2c7ff143b699bac0631af156..222ab729810b1d906e7a33a8e536edca639004ad 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -58,6 +58,8 @@ GET /projects | `wiki_checksum_failed` | boolean | no | **(PREMIUM)** Limit projects where the wiki checksum calculation has failed ([Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/6137) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.2) | | `repository_checksum_failed` | boolean | no | **(PREMIUM)** Limit projects where the repository checksum calculation has failed ([Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/6137) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.2) | | `min_access_level` | integer | no | Limit by current user minimal [access level](members.md) | +| `id_after` | integer | no | Limit results to projects with IDs greater than the specified ID | +| `id_before` | integer | no | Limit results to projects with IDs less than the specified ID | When `simple=true` or the user is unauthenticated this returns something like: @@ -148,6 +150,7 @@ When the user is authenticated and `simple` is not set this returns something li "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, + "remove_source_branch_after_merge": false, "request_access_enabled": false, "merge_method": "merge", "statistics": { @@ -232,6 +235,7 @@ When the user is authenticated and `simple` is not set this returns something li "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, + "remove_source_branch_after_merge": false, "request_access_enabled": false, "merge_method": "merge", "statistics": { @@ -302,6 +306,8 @@ GET /users/:user_id/projects | `with_merge_requests_enabled` | boolean | no | Limit by enabled merge requests feature | | `with_programming_language` | string | no | Limit by projects which use the given programming language | | `min_access_level` | integer | no | Limit by current user minimal [access level](members.md) | +| `id_after` | integer | no | Limit results to projects with IDs greater than the specified ID | +| `id_before` | integer | no | Limit results to projects with IDs less than the specified ID | ```json [ @@ -357,6 +363,7 @@ GET /users/:user_id/projects "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, + "remove_source_branch_after_merge": false, "request_access_enabled": false, "merge_method": "merge", "statistics": { @@ -441,6 +448,7 @@ GET /users/:user_id/projects "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, + "remove_source_branch_after_merge": false, "request_access_enabled": false, "merge_method": "merge", "statistics": { @@ -550,6 +558,7 @@ Example response: "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, + "remove_source_branch_after_merge": false, "request_access_enabled": false, "merge_method": "merge", "statistics": { @@ -631,6 +640,7 @@ Example response: "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, + "remove_source_branch_after_merge": false, "request_access_enabled": false, "merge_method": "merge", "statistics": { @@ -757,6 +767,7 @@ GET /projects/:id "repository_storage": "default", "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, + "remove_source_branch_after_merge": false, "printing_merge_requests_link_enabled": true, "request_access_enabled": false, "merge_method": "merge", @@ -917,6 +928,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 | +| `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 | | `tag_list` | array | no | The list of tags for a project; put array of tags, that should be finally assigned to a project | @@ -936,6 +948,7 @@ POST /projects | `mirror_trigger_builds` | boolean | no | **(STARTER)** Pull mirroring triggers builds | | `initialize_with_readme` | boolean | no | `false` by default | | `template_name` | string | no | When used without `use_custom_template`, name of a [built-in project template](../gitlab-basics/create-project.md#built-in-templates). When used with `use_custom_template`, name of a custom project template | +| `template_project_id` | integer | no | **(PREMIUM)** When used with `use_custom_template`, project ID of a custom project template. This is preferable to using `template_name` since `template_name` may be ambiguous. | | `use_custom_template` | boolean | no | **(PREMIUM)** Use either custom [instance](../user/admin_area/custom_project_templates.md) or [group](../user/group/custom_project_templates.md) (with `group_with_project_templates_id`) project template | | `group_with_project_templates_id` | integer | no | **(PREMIUM)** For group-level custom templates, specifies ID of group from which all the custom project templates are sourced. Leave empty for instance-level templates. Requires `use_custom_template` to be true | @@ -978,6 +991,7 @@ 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 | +| `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 | | `tag_list` | array | no | The list of tags for a project; put array of tags, that should be finally assigned to a project | @@ -1039,6 +1053,7 @@ 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 | +| `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 | | `tag_list` | array | no | The list of tags for a project; put array of tags, that should be finally assigned to a project | @@ -1165,6 +1180,7 @@ Example responses: "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, + "remove_source_branch_after_merge": false, "request_access_enabled": false, "merge_method": "merge", "_links": { @@ -1252,6 +1268,7 @@ Example response: "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, + "remove_source_branch_after_merge": false, "request_access_enabled": false, "merge_method": "merge", "_links": { @@ -1338,6 +1355,7 @@ Example response: "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, + "remove_source_branch_after_merge": false, "request_access_enabled": false, "merge_method": "merge", "_links": { @@ -1511,6 +1529,7 @@ Example response: "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, + "remove_source_branch_after_merge": false, "request_access_enabled": false, "merge_method": "merge", "_links": { @@ -1616,6 +1635,7 @@ Example response: "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, + "remove_source_branch_after_merge": false, "request_access_enabled": false, "merge_method": "merge", "_links": { diff --git a/doc/api/releases/index.md b/doc/api/releases/index.md index ee2df3e4c5d89faec4fce022c065ef4a97dac520..7f41e237401fe0236823334e310dbca8f0565f8c 100644 --- a/doc/api/releases/index.md +++ b/doc/api/releases/index.md @@ -303,7 +303,7 @@ POST /projects/:id/releases | Attribute | Type | Required | Description | | -------------------| --------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------- | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](../README.md#namespaced-path-encoding). | -| `name` | string | yes | The release name. | +| `name` | string | no | The release name. | | `tag_name` | string | yes | The tag where the release will be created from. | | `description` | string | yes | The description of the release. You can use [markdown](../../user/markdown.md). | | `ref` | string | yes, if `tag_name` doesn't exist | If `tag_name` doesn't exist, the release will be created from `ref`. It can be a commit SHA, another tag name, or a branch name. | diff --git a/doc/api/scim.md b/doc/api/scim.md index 8cbd6103e88f369911ad7c1a1b1ba3efe74c710f..cf9d8ebbec287fec7d588a414d43af31b736c7b5 100644 --- a/doc/api/scim.md +++ b/doc/api/scim.md @@ -5,8 +5,7 @@ The SCIM API implements the [the RFC7644 protocol](https://tools.ietf.org/html/rfc7644). NOTE: **Note:** -[Group SSO](../user/group/saml_sso/index.md) and the feature -flag `:group_scim` must be enabled for the group. For more information, see [SCIM setup documentation](../user/group/saml_sso/scim_setup.md#requirements). +[Group SSO](../user/group/saml_sso/index.md) must be enabled for the group. For more information, see [SCIM setup documentation](../user/group/saml_sso/scim_setup.md#requirements). ## Get a list of SAML users @@ -22,7 +21,7 @@ Parameters: | Attribute | Type | Required | Description | |:----------|:--------|:---------|:----------------------------------------------------------------------------------------------------------------------------------------| -| `filter` | string | yes | A [filter](#available-filters) expression. | +| `filter` | string | no | A [filter](#available-filters) expression. | | `group_path` | string | yes | Full path to the group. | | `startIndex` | integer | no | The 1-based index indicating where to start returning results from. A value of less than one will be interpreted as 1. | | `count` | integer | no | Desired maximum number of query results. | diff --git a/doc/api/search.md b/doc/api/search.md index ca08f5ca0d755f517c2407f335c217db099a5d24..8e20722052e0fc53a972486c700d449ee0cc7f9b 100644 --- a/doc/api/search.md +++ b/doc/api/search.md @@ -181,6 +181,7 @@ Example response: "merge_status": "can_be_merged", "sha": "78765a2d5e0a43585945c58e61ba2f822e4d090b", "merge_commit_sha": null, + "squash_commit_sha": null, "user_notes_count": 0, "discussion_locked": null, "should_remove_source_branch": null, @@ -299,6 +300,7 @@ Example response: { "basename": "home", "data": "hello\n\nand bye\n\nend", + "path": "home.md", "filename": "home.md", "id": null, "ref": "master", @@ -308,6 +310,8 @@ Example response: ] ``` +**Note:** `filename` is deprecated in favor of `path`. Both return the full path of the file inside the repository, but in the future `filename` will be only the file name and not the full path (see [this issue][gitlab-34521]). + ### Scope: commits **(STARTER)** This scope is available only if [Elasticsearch](../integration/elasticsearch.md) is enabled. @@ -367,6 +371,7 @@ Example response: { "basename": "README", "data": "```\n\n## Installation\n\nQuick start using the [pre-built", + "path": "README.md", "filename": "README.md", "id": null, "ref": "master", @@ -376,6 +381,8 @@ Example response: ] ``` +**Note:** `filename` is deprecated in favor of `path`. Both return the full path of the file inside the repository, but in the future `filename` will be only the file name and not the full path (see [this issue][gitlab-34521]). + ### Scope: users ```bash @@ -577,6 +584,7 @@ Example response: "merge_status": "can_be_merged", "sha": "78765a2d5e0a43585945c58e61ba2f822e4d090b", "merge_commit_sha": null, + "squash_commit_sha": null, "user_notes_count": 0, "discussion_locked": null, "should_remove_source_branch": null, @@ -633,6 +641,7 @@ Example response: { "basename": "home", "data": "hello\n\nand bye\n\nend", + "path": "home.md", "filename": "home.md", "id": null, "ref": "master", @@ -642,6 +651,8 @@ Example response: ] ``` +**Note:** `filename` is deprecated in favor of `path`. Both return the full path of the file inside the repository, but in the future `filename` will be only the file name and not the full path (see [this issue][gitlab-34521]). + ### Scope: commits **(STARTER)** This scope is available only if [Elasticsearch](../integration/elasticsearch.md) is enabled. @@ -701,6 +712,7 @@ Example response: { "basename": "README", "data": "```\n\n## Installation\n\nQuick start using the [pre-built", + "path": "README.md", "filename": "README.md", "id": null, "ref": "master", @@ -710,6 +722,8 @@ Example response: ] ``` +**Note:** `filename` is deprecated in favor of `path`. Both return the full path of the file inside the repository, but in the future `filename` will be only the file name and not the full path (see [this issue][gitlab-34521]). + ### Scope: users ```bash @@ -878,6 +892,7 @@ Example response: "merge_status": "can_be_merged", "sha": "78765a2d5e0a43585945c58e61ba2f822e4d090b", "merge_commit_sha": null, + "squash_commit_sha": null, "user_notes_count": 0, "discussion_locked": null, "should_remove_source_branch": null, @@ -981,6 +996,7 @@ Example response: { "basename": "home", "data": "hello\n\nand bye\n\nend", + "path": "home.md", "filename": "home.md", "id": null, "ref": "master", @@ -990,6 +1006,8 @@ Example response: ] ``` +**Note:** `filename` is deprecated in favor of `path`. Both return the full path of the file inside the repository, but in the future `filename` will be only the file name and not the full path (see [this issue][gitlab-34521]). + ### Scope: commits ```bash @@ -1051,6 +1069,7 @@ Example response: { "basename": "README", "data": "```\n\n## Installation\n\nQuick start using the [pre-built", + "path": "README.md", "filename": "README.md", "id": null, "ref": "master", @@ -1060,6 +1079,8 @@ Example response: ] ``` +**Note:** `filename` is deprecated in favor of `path`. Both return the full path of the file inside the repository, but in the future `filename` will be only the file name and not the full path (see [this issue][gitlab-34521]). + ### Scope: users ```bash @@ -1082,3 +1103,4 @@ Example response: ``` [ce-41763]: https://gitlab.com/gitlab-org/gitlab-foss/issues/41763 +[gitlab-34521]: https://gitlab.com/gitlab-org/gitlab/issues/34521 diff --git a/doc/api/services.md b/doc/api/services.md index 4abc02dec3c04204a3234650e8da6e5cc8f1eb11..609c7e62e3623487d96de7b1acd23d25b673da3d 100644 --- a/doc/api/services.md +++ b/doc/api/services.md @@ -605,7 +605,7 @@ Set Jira service for a project. > Starting with GitLab 8.14, `api_url`, `issues_url`, `new_issue_url` and > `project_url` are replaced by `url`. If you are using an -> older version, [follow this documentation][old-jira-api]. +> older version, [follow this documentation](https://gitlab.com/gitlab-org/gitlab/blob/8-13-stable-ee/doc/api/services.md#jira). ``` PUT /projects/:id/services/jira @@ -1224,9 +1224,6 @@ Get Jenkins CI (Deprecated) service settings for a project. GET /projects/:id/services/jenkins-deprecated ``` -[jira-doc]: ../user/project/integrations/jira.md -[old-jira-api]: https://gitlab.com/gitlab-org/gitlab/blob/8-13-stable/doc/api/services.md#jira - ## MockCI Mock an external CI. See [`gitlab-org/gitlab-mock-ci-service`](https://gitlab.com/gitlab-org/gitlab-mock-ci-service) for an example of a companion mock service. diff --git a/doc/api/settings.md b/doc/api/settings.md index 2d9e435bbb624e9df595413c60f944447f4e4f5a..51d5e5f35d76d77ae022bd7d83225c3488216ebb 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -40,6 +40,7 @@ Example response: "domain_blacklist_enabled" : false, "domain_blacklist" : [], "created_at" : "2016-01-04T15:44:55.176Z", + "default_ci_config_path" : null, "default_project_visibility" : "private", "default_group_visibility" : "private", "gravatar_enabled" : true, @@ -113,6 +114,7 @@ Example response: "restricted_visibility_levels": [], "max_attachment_size": 10, "session_expire_delay": 10080, + "default_ci_config_path" : null, "default_project_visibility": "internal", "default_snippet_visibility": "private", "default_group_visibility": "private", @@ -198,6 +200,7 @@ are listed in the descriptions of the relevant settings. | `container_registry_token_expire_delay` | integer | no | Container Registry token duration in minutes. | | `default_artifacts_expire_in` | string | no | Set the default expiration time for each job's artifacts. | | `default_branch_protection` | integer | no | Determine if developers can push to master. Can take: `0` _(not protected, both developers and maintainers can push new commits, force push, or delete the branch)_, `1` _(partially protected, developers and maintainers can push new commits, but cannot force push or delete the branch)_ or `2` _(fully protected, developers cannot push new commits, but maintainers can; no-one can force push or delete the branch)_ as a parameter. Default is `2`. | +| `default_ci_config_path` | string | no | Default CI configuration path for new projects (`.gitlab-ci.yml` if not set). | | `default_group_visibility` | string | no | What visibility level new groups receive. Can take `private`, `internal` and `public` as a parameter. Default is `private`. | | `default_project_creation` | integer | no | Default project creation protection. Can take: `0` _(No one)_, `1` _(Maintainers)_ or `2` _(Developers + Maintainers)_| | `default_projects_limit` | integer | no | Project limit per user. Default is `100000`. | @@ -212,6 +215,10 @@ are listed in the descriptions of the relevant settings. | `dsa_key_restriction` | integer | no | The minimum allowed bit length of an uploaded DSA key. Default is `0` (no restriction). `-1` disables DSA keys. | | `ecdsa_key_restriction` | integer | no | The minimum allowed curve size (in bits) of an uploaded ECDSA key. Default is `0` (no restriction). `-1` disables ECDSA keys. | | `ed25519_key_restriction` | integer | no | The minimum allowed curve size (in bits) of an uploaded ED25519 key. Default is `0` (no restriction). `-1` disables ED25519 keys. | +| `eks_integration_enabled` | boolean | no | Enable integration with Amazon EKS | +| `eks_account_id` | string | no | Amazon account ID | +| `eks_access_key_id` | string | no | AWS IAM access key ID | +| `eks_secret_access_key` | string | no | AWS IAM secret access key | | `elasticsearch_aws_access_key` | string | no | **(PREMIUM)** AWS IAM access key | | `elasticsearch_aws` | boolean | no | **(PREMIUM)** Enable the use of AWS hosted Elasticsearch | | `elasticsearch_aws_region` | string | no | **(PREMIUM)** The AWS region the Elasticsearch domain is configured | @@ -257,7 +264,6 @@ are listed in the descriptions of the relevant settings. | `housekeeping_incremental_repack_period` | integer | required by: `housekeeping_enabled` | Number of Git pushes after which an incremental `git repack` is run. | | `html_emails_enabled` | boolean | no | Enable HTML emails. | | `import_sources` | array of strings | no | Sources to allow project import from, possible values: `github`, `bitbucket`, `bitbucket_server`, `gitlab`, `google_code`, `fogbugz`, `git`, `gitlab_project`, `gitea`, `manifest`, and `phabricator`. | - | `instance_statistics_visibility_private` | boolean | no | When set to `true` Instance statistics will only be available to admins. | | `local_markdown_version` | integer | no | Increase this value when any cached markdown should be invalidated. | | `max_artifacts_size` | integer | no | Maximum artifacts size in MB | @@ -271,7 +277,7 @@ are listed in the descriptions of the relevant settings. | `metrics_port` | integer | required by: `metrics_enabled` | The UDP port to use for connecting to InfluxDB. | | `metrics_sample_interval` | integer | required by: `metrics_enabled` | The sampling interval in seconds. | | `metrics_timeout` | integer | required by: `metrics_enabled` | The amount of seconds after which InfluxDB will time out. | -| `mirror_available` | boolean | no | Allow mirrors to be set up for projects. If disabled, only admins will be able to set up mirrors in projects. | +| `mirror_available` | boolean | no | Allow repository mirroring to configured by project Maintainers. If disabled, only Admins will be able to configure repository mirroring. | | `mirror_capacity_threshold` | integer | no | **(PREMIUM)** Minimum capacity to be available before scheduling more mirrors preemptively | | `mirror_max_capacity` | integer | no | **(PREMIUM)** Maximum number of mirrors that can be synchronizing at the same time. | | `mirror_max_delay` | integer | no | **(PREMIUM)** Maximum time (in minutes) between updates that a mirror can have when scheduled to synchronize. | @@ -316,7 +322,11 @@ are listed in the descriptions of the relevant settings. | `snowplow_collector_hostname` | string | required by: `snowplow_enabled` | The Snowplow collector hostname. (e.g. `snowplow.trx.gitlab.net`) | | `snowplow_cookie_domain` | string | no | The Snowplow cookie domain. (e.g. `.gitlab.com`) | | `snowplow_enabled` | boolean | no | Enable snowplow tracking. | -| `snowplow_site_id` | string | no | The Snowplow site name / application id. (e.g. `gitlab`) | +| `snowplow_app_id` | string | no | The Snowplow site name / application id. (e.g. `gitlab`) | +| `snowplow_iglu_registry_url` | string | no | The Snowplow base Iglu Schema Registry URL to use for custom context and self describing events'| +| `sourcegraph_enabled` | boolean | no | Enables Sourcegraph integration. Default is `false`. **If enabled, requires** `sourcegraph_url`. | +| `sourcegraph_url` | string | required by: `sourcegraph_enabled` | The Sourcegraph instance URL for integration. | +| `sourcegraph_public_only` | boolean | no | Blocks Sourcegraph from being loaded on private and internal projects. Defaul is `true`. | | `terminal_max_session_time` | integer | no | Maximum time for web terminal websocket connection (in seconds). Set to `0` for unlimited time. | | `terms` | text | required by: `enforce_terms` | (**Required by:** `enforce_terms`) Markdown content for the ToS. | | `throttle_authenticated_api_enabled` | boolean | no | (**If enabled, requires:** `throttle_authenticated_api_period_in_seconds` and `throttle_authenticated_api_requests_per_period`) Enable authenticated API request rate limit. Helps reduce request volume (e.g. from crawlers or abusive bots). | diff --git a/doc/api/sidekiq_metrics.md b/doc/api/sidekiq_metrics.md index 5f2202fa51dc603aea7b1bd712feabdb24436fe2..95449d1ff7747b3c17b2f51079f8ca182924b875 100644 --- a/doc/api/sidekiq_metrics.md +++ b/doc/api/sidekiq_metrics.md @@ -92,7 +92,8 @@ Example response: "jobs": { "processed": 2, "failed": 0, - "enqueued": 0 + "enqueued": 0, + "dead": 0 } } ``` @@ -145,7 +146,8 @@ Example response: "jobs": { "processed": 2, "failed": 0, - "enqueued": 0 + "enqueued": 0, + "dead": 0 } } ``` diff --git a/doc/api/tags.md b/doc/api/tags.md index 56143969e3c6e16a9d087e3ba9732b38610120a1..13c4b83dda8b617e47f909f3e11487c7b1b1671c 100644 --- a/doc/api/tags.md +++ b/doc/api/tags.md @@ -17,7 +17,7 @@ Parameters: | `id` | integer/string| yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user| | `order_by` | string | no | Return tags ordered by `name` or `updated` fields. Default is `updated` | | `sort` | string | no | Return tags sorted in `asc` or `desc` order. Default is `desc` | -| `search` | string | no | Return list of tags matching the search criteria | +| `search` | string | no | Return list of tags matching the search criteria. You can use `^term` and `term$` to find tags that begin and end with `term` respectively. | > Support for `search` was [introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/54401) in GitLab 11.8. diff --git a/doc/api/users.md b/doc/api/users.md index f95ad7b62ba269b19b477c86309e6c866ce94e9a..c82a5e23c8e34175bee1ba08e253fdb9ee4a12a9 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -1124,7 +1124,7 @@ Parameters: ## Block user -Blocks the specified user. Available only for admin. +Blocks the specified user. Available only for admin. ``` POST /users/:id/block @@ -1139,7 +1139,7 @@ Will return `201 OK` on success, `404 User Not Found` is user cannot be found or ## Unblock user -Unblocks the specified user. Available only for admin. +Unblocks the specified user. Available only for admin. ``` POST /users/:id/unblock diff --git a/doc/api/visual_review_discussions.md b/doc/api/visual_review_discussions.md new file mode 100644 index 0000000000000000000000000000000000000000..385c1bf201ddcbef4ea88a14c9bc8ad63fd5a00d --- /dev/null +++ b/doc/api/visual_review_discussions.md @@ -0,0 +1,40 @@ +# Visual Review discussions API **(STARTER)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/18710) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.5. + +Visual Review discussions are notes on Merge Requests sent as +feedback from [Visual Reviews](../ci/review_apps/index.md#visual-reviews-starter). + +## Create new merge request thread + +Creates a new thread to a single project merge request. This is similar to creating +a note but other comments (replies) can be added to it later. + +``` +POST /projects/:id/merge_requests/:merge_request_iid/visual_review_discussions +``` + +Parameters: + +| Attribute | Type | Required | Description | +| ------------------------- | -------------- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `merge_request_iid` | integer | yes | The IID of a merge request | +| `body` | string | yes | The content of the thread | +| `position` | hash | no | Position when creating a diff note | +| `position[base_sha]` | string | yes | Base commit SHA in the source branch | +| `position[start_sha]` | string | yes | SHA referencing commit in target branch | +| `position[head_sha]` | string | yes | SHA referencing HEAD of this merge request | +| `position[position_type]` | string | yes | Type of the position reference. Either `text` or `image`. | +| `position[new_path]` | string | no | File path after change | +| `position[new_line]` | integer | no | Line number after change (Only stored for `text` diff notes) | +| `position[old_path]` | string | no | File path before change | +| `position[old_line]` | integer | no | Line number before change (Only stored for `text` diff notes) | +| `position[width]` | integer | no | Width of the image (Only stored for `image` diff notes) | +| `position[height]` | integer | no | Height of the image (Only stored for `image` diff notes) | +| `position[x]` | integer | no | X coordinate (Only stored for `image` diff notes) | +| `position[y]` | integer | no | Y coordinate (Only stored for `image` diff notes) | + +```bash +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/5/merge_requests/11/visual_review_discussions?body=comment +``` diff --git a/doc/api/vulnerabilities.md b/doc/api/vulnerabilities.md index eaa4c13de55783560a138f9e8a5e49bb39439d4d..21b3a6f4c96532a73275b831094a09f141eaf77c 100644 --- a/doc/api/vulnerabilities.md +++ b/doc/api/vulnerabilities.md @@ -1,115 +1,3 @@ # Vulnerabilities API **(ULTIMATE)** -Every API call to vulnerabilities must be authenticated. - -If a user is not a member of a project and the project is private, a `GET` -request on that project will result in a `404` status code. - -CAUTION: **Caution:** -This API is in an alpha stage and considered unstable. -The response payload may be subject to change or breakage -across GitLab releases. - -## Vulnerabilities pagination - -By default, `GET` requests return 20 results at a time because the API results -are paginated. - -Read more on [pagination](README.md#pagination). - -## List project vulnerabilities - -List all of a project's vulnerabilities. - -``` -GET /projects/:id/vulnerabilities -GET /projects/:id/vulnerabilities?report_type=sast -GET /projects/:id/vulnerabilities?report_type=container_scanning -GET /projects/:id/vulnerabilities?report_type=sast,dast -GET /projects/:id/vulnerabilities?scope=all -GET /projects/:id/vulnerabilities?scope=dismissed -GET /projects/:id/vulnerabilities?severity=high -GET /projects/:id/vulnerabilities?confidence=unknown,experimental -GET /projects/:id/vulnerabilities?pipeline_id=42 -``` - -| Attribute | Type | Required | Description | -| ------------- | -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user. | -| `report_type` | string array | no | Returns vulnerabilities belonging to specified report type. Valid values: `sast`, `dast`, `dependency_scanning`, or `container_scanning`. | -| `scope` | string | no | Returns vulnerabilities for the given scope: `all` or `dismissed`. Defaults to `dismissed` | -| `severity` | string array | no | Returns vulnerabilities belonging to specified severity level: `undefined`, `info`, `unknown`, `low`, `medium`, `high`, or `critical`. Defaults to all' | -| `confidence` | string array | no | Returns vulnerabilities belonging to specified confidence level: `undefined`, `ignore`, `unknown`, `experimental`, `low`, `medium`, `high`, or `confirmed`. Defaults to all | -| `pipeline_id` | integer/string | no | Returns vulnerabilities belonging to specified pipeline. | - -```bash -curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/4/vulnerabilities -``` - -Example response: - -```json -[ - { - "id": null, - "report_type": "dependency_scanning", - "name": "Authentication bypass via incorrect DOM traversal and canonicalization in saml2-js", - "severity": "unknown", - "confidence": "undefined", - "scanner": { - "external_id": "gemnasium", - "name": "Gemnasium" - }, - "identifiers": [ - { - "external_type": "gemnasium", - "external_id": "9952e574-7b5b-46fa-a270-aeb694198a98", - "name": "Gemnasium-9952e574-7b5b-46fa-a270-aeb694198a98", - "url": "https://deps.sec.gitlab.com/packages/npm/saml2-js/versions/1.5.0/advisories" - }, - { - "external_type": "cve", - "external_id": "CVE-2017-11429", - "name": "CVE-2017-11429", - "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-11429" - } - ], - "project_fingerprint": "fa6f5b6c5d240b834ac5e901dc69f9484cef89ec", - "create_vulnerability_feedback_issue_path": "/tests/yarn-remediation-test/vulnerability_feedback", - "create_vulnerability_feedback_merge_request_path": "/tests/yarn-remediation-test/vulnerability_feedback", - "create_vulnerability_feedback_dismissal_path": "/tests/yarn-remediation-test/vulnerability_feedback", - "project": { - "id": 31, - "name": "yarn-remediation-test", - "full_path": "/tests/yarn-remediation-test", - "full_name": "tests / yarn-remediation-test" - }, - "dismissal_feedback": null, - "issue_feedback": null, - "merge_request_feedback": null, - "description": "Some XML DOM traversal and canonicalization APIs may be inconsistent in handling of comments within XML nodes. Incorrect use of these APIs by some SAML libraries results in incorrect parsing of the inner text of XML nodes such that any inner text after the comment is lost prior to cryptographically signing the SAML message. Text after the comment therefore has no impact on the signature on the SAML message.\r\n\r\nA remote attacker can modify SAML content for a SAML service provider without invalidating the cryptographic signature, which may allow attackers to bypass primary authentication for the affected SAML service provider.", - "links": [ - { - "url": "https://github.com/Clever/saml2/commit/3546cb61fd541f219abda364c5b919633609ef3d#diff-af730f9f738de1c9ad87596df3f6de84R279" - }, - { - "url": "https://www.kb.cert.org/vuls/id/475445" - }, - { - "url": "https://github.com/Clever/saml2/issues/127" - } - ], - "location": { - "file": "yarn.lock", - "dependency": { - "package": { - "name": "saml2-js" - }, - "version": "1.5.0" - } - }, - "solution": "Upgrade to fixed version.\r\n", - "blob_path": "/tests/yarn-remediation-test/blob/cc6c4a0778460455ae5d16ca7025ca9ca1ca75ac/yarn.lock" - } -] -``` +This document was moved to [another location](vulnerability_findings.md). diff --git a/doc/api/vulnerability_findings.md b/doc/api/vulnerability_findings.md new file mode 100644 index 0000000000000000000000000000000000000000..3d3f12aeef53531490571078ca5ed28ffaff473b --- /dev/null +++ b/doc/api/vulnerability_findings.md @@ -0,0 +1,128 @@ +# Vulnerability Findings API **(ULTIMATE)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/19029) in GitLab Ultimate 12.5. + +NOTE: **Note:** +This API resource is renamed from Vulnerabilities to Vulnerability Findings because the Vulnerabilities are reserved +for serving the upcoming [Standalone Vulnerability objects](https://gitlab.com/gitlab-org/gitlab/issues/13561). +To fix any broken integrations with the former Vulnerabilities API, change the `vulnerabilities` URL part to be +`vulnerability_findings`. + +Every API call to vulnerability findings must be [authenticated](README.md#authentication). + +Vulnerability findings are project-bound entities. If a user is not +a member of a project and the project is private, a request on +that project will result in a `404` status code. + +If a user is able to access the project but does not have permission to +[use the Project Security Dashboard](../user/permissions.md#project-members-permissions), +any request for vulnerability findings of this project will result in a `403` status code. + +CAUTION: **Caution:** +This API is in an alpha stage and considered unstable. +The response payload may be subject to change or breakage +across GitLab releases. + +## Vulnerability findings pagination + +By default, `GET` requests return 20 results at a time because the API results +are paginated. + +Read more on [pagination](README.md#pagination). + +## List project vulnerability findings + +List all of a project's vulnerability findings. + +``` +GET /projects/:id/vulnerability_findings +GET /projects/:id/vulnerability_findings?report_type=sast +GET /projects/:id/vulnerability_findings?report_type=container_scanning +GET /projects/:id/vulnerability_findings?report_type=sast,dast +GET /projects/:id/vulnerability_findings?scope=all +GET /projects/:id/vulnerability_findings?scope=dismissed +GET /projects/:id/vulnerability_findings?severity=high +GET /projects/:id/vulnerability_findings?confidence=unknown,experimental +GET /projects/:id/vulnerability_findings?pipeline_id=42 +``` + +| Attribute | Type | Required | Description | +| ------------- | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) which the authenticated user is a member of. | +| `report_type` | string array | no | Returns vulnerability findings belonging to specified report type. Valid values: `sast`, `dast`, `dependency_scanning`, or `container_scanning`. Defaults to all. | +| `scope` | string | no | Returns vulnerability findings for the given scope: `all` or `dismissed`. Defaults to `dismissed`. | +| `severity` | string array | no | Returns vulnerability findings belonging to specified severity level: `undefined`, `info`, `unknown`, `low`, `medium`, `high`, or `critical`. Defaults to all. | +| `confidence` | string array | no | Returns vulnerability findings belonging to specified confidence level: `undefined`, `ignore`, `unknown`, `experimental`, `low`, `medium`, `high`, or `confirmed`. Defaults to all. | +| `pipeline_id` | integer/string | no | Returns vulnerability findings belonging to specified pipeline. | + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/4/vulnerability_findings +``` + +Example response: + +```json +[ + { + "id": null, + "report_type": "dependency_scanning", + "name": "Authentication bypass via incorrect DOM traversal and canonicalization in saml2-js", + "severity": "unknown", + "confidence": "undefined", + "scanner": { + "external_id": "gemnasium", + "name": "Gemnasium" + }, + "identifiers": [ + { + "external_type": "gemnasium", + "external_id": "9952e574-7b5b-46fa-a270-aeb694198a98", + "name": "Gemnasium-9952e574-7b5b-46fa-a270-aeb694198a98", + "url": "https://deps.sec.gitlab.com/packages/npm/saml2-js/versions/1.5.0/advisories" + }, + { + "external_type": "cve", + "external_id": "CVE-2017-11429", + "name": "CVE-2017-11429", + "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-11429" + } + ], + "project_fingerprint": "fa6f5b6c5d240b834ac5e901dc69f9484cef89ec", + "create_vulnerability_feedback_issue_path": "/tests/yarn-remediation-test/vulnerability_feedback", + "create_vulnerability_feedback_merge_request_path": "/tests/yarn-remediation-test/vulnerability_feedback", + "create_vulnerability_feedback_dismissal_path": "/tests/yarn-remediation-test/vulnerability_feedback", + "project": { + "id": 31, + "name": "yarn-remediation-test", + "full_path": "/tests/yarn-remediation-test", + "full_name": "tests / yarn-remediation-test" + }, + "dismissal_feedback": null, + "issue_feedback": null, + "merge_request_feedback": null, + "description": "Some XML DOM traversal and canonicalization APIs may be inconsistent in handling of comments within XML nodes. Incorrect use of these APIs by some SAML libraries results in incorrect parsing of the inner text of XML nodes such that any inner text after the comment is lost prior to cryptographically signing the SAML message. Text after the comment therefore has no impact on the signature on the SAML message.\r\n\r\nA remote attacker can modify SAML content for a SAML service provider without invalidating the cryptographic signature, which may allow attackers to bypass primary authentication for the affected SAML service provider.", + "links": [ + { + "url": "https://github.com/Clever/saml2/commit/3546cb61fd541f219abda364c5b919633609ef3d#diff-af730f9f738de1c9ad87596df3f6de84R279" + }, + { + "url": "https://www.kb.cert.org/vuls/id/475445" + }, + { + "url": "https://github.com/Clever/saml2/issues/127" + } + ], + "location": { + "file": "yarn.lock", + "dependency": { + "package": { + "name": "saml2-js" + }, + "version": "1.5.0" + } + }, + "solution": "Upgrade to fixed version.\r\n", + "blob_path": "/tests/yarn-remediation-test/blob/cc6c4a0778460455ae5d16ca7025ca9ca1ca75ac/yarn.lock" + } +] +``` diff --git a/doc/ci/README.md b/doc/ci/README.md index 5286764d178f1e9ddbc338f172a190d09e0e8aa3..d1cf7e63c6319d2f0e4abcdde2c4fdc9be349d54 100644 --- a/doc/ci/README.md +++ b/doc/ci/README.md @@ -132,7 +132,7 @@ Its feature set is listed on the table below according to DevOps stages. | [Container Scanning](../user/application_security/container_scanning/index.md) **(ULTIMATE)** | Check your Docker containers for known vulnerabilities.| | [Dependency Scanning](../user/application_security/dependency_scanning/index.md) **(ULTIMATE)** | Analyze your dependencies for known vulnerabilities. | | [License Compliance](../user/application_security/license_compliance/index.md) **(ULTIMATE)** | Search your project dependencies for their licenses. | -| [Security Test reports](../user/project/merge_requests/index.md#security-reports-ultimate) **(ULTIMATE)** | Check for app vulnerabilities. | +| [Security Test reports](../user/application_security/index.md) **(ULTIMATE)** | Check for app vulnerabilities. | ## Examples diff --git a/doc/ci/chatops/README.md b/doc/ci/chatops/README.md index 234e7f4ed800f68a5364b2029cbfdc05a2477d2b..d9236b47a9ae0d4cb5b072d06ae6cb0deaff0588 100644 --- a/doc/ci/chatops/README.md +++ b/doc/ci/chatops/README.md @@ -58,6 +58,11 @@ ls: - echo -e "section_start:$( date +%s ):chat_reply\r\033[0K\n$( ls -la )\nsection_end:$( date +%s ):chat_reply\r\033[0K" ``` +## 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. + ## GitLab ChatOps icon Say Hi to our ChatOps bot. diff --git a/doc/ci/ci_cd_for_external_repos/bitbucket_integration.md b/doc/ci/ci_cd_for_external_repos/bitbucket_integration.md index 54b2193911636febc132202e222ab53e143deffb..dd474b09a9cd71df1943343f680cf1cbe7030b0a 100644 --- a/doc/ci/ci_cd_for_external_repos/bitbucket_integration.md +++ b/doc/ci/ci_cd_for_external_repos/bitbucket_integration.md @@ -151,7 +151,7 @@ To use GitLab CI/CD with a Bitbucket Cloud repository: GitLab is now configured to mirror changes from Bitbucket, run CI/CD pipelines configured in `.gitlab-ci.yml` and push the status to Bitbucket. -[pull-mirroring]: ../../workflow/repository_mirroring.md#pulling-from-a-remote-repository-starter +[pull-mirroring]: ../../user/project/repository/repository_mirroring.md#pulling-from-a-remote-repository-starter <!-- ## Troubleshooting diff --git a/doc/ci/ci_cd_for_external_repos/github_integration.md b/doc/ci/ci_cd_for_external_repos/github_integration.md index 08660b014b008f2520a7627ee3ef6db2e89cfe1a..3df47d4cd4f6d0a464bcc632a09d0915a1e6d1ad 100644 --- a/doc/ci/ci_cd_for_external_repos/github_integration.md +++ b/doc/ci/ci_cd_for_external_repos/github_integration.md @@ -26,7 +26,7 @@ To perform a one-off authorization with GitHub to grant GitLab access your repositories: 1. Open <https://github.com/settings/tokens/new> to create a **Personal Access - Token**. This token with be used to access your repository and push commit + Token**. This token will be used to access your repository and push commit statuses to GitHub. The `repo` and `admin:repo_hook` should be enable to allow GitLab access to @@ -46,7 +46,7 @@ repositories: GitLab will: 1. Import the project. -1. Enable [Pull Mirroring](../../workflow/repository_mirroring.md#pulling-from-a-remote-repository-starter) +1. Enable [Pull Mirroring](../../user/project/repository/repository_mirroring.md#pulling-from-a-remote-repository-starter) 1. Enable [GitHub project integration](../../user/project/integrations/github.md) 1. Create a web hook on GitHub to notify GitLab of new commits. diff --git a/doc/ci/ci_cd_for_external_repos/index.md b/doc/ci/ci_cd_for_external_repos/index.md index 35e2117c285918d6231f28f422eff6d90733c60e..b5878e70c53493b4df35cbdd7c2d55955fddc81a 100644 --- a/doc/ci/ci_cd_for_external_repos/index.md +++ b/doc/ci/ci_cd_for_external_repos/index.md @@ -20,7 +20,7 @@ Instead of moving your entire project to GitLab, you can connect your external repository to get the benefits of GitLab CI/CD. Connecting an external repository will set up [repository mirroring][mirroring] -and create a lightweight project where issues, merge requests, wiki, and +and create a lightweight project with issues, merge requests, wiki, and snippets disabled. These features [can be re-enabled later][settings]. @@ -101,5 +101,5 @@ requests and not on branches you can add `except: [branches]` to the job specs. [ee-4642]: https://gitlab.com/gitlab-org/gitlab/merge_requests/4642 [eep]: https://about.gitlab.com/pricing/ -[mirroring]: ../../workflow/repository_mirroring.md +[mirroring]: ../../user/project/repository/repository_mirroring.md [settings]: ../../user/project/settings/index.md#sharing-and-permissions diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index f4bb7cd7d9fd5b7a8fcbb6ef575e8878ab79c5b0..c892320327b738f0f2262a0cd4d2779dbfbf6786 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -174,7 +174,7 @@ support this. The above command will register a new Runner to use the special `docker:19.03.1` image, which is provided by Docker. **Notice that it's using the `privileged` mode to start the build and service - containers.** If you want to use [docker-in-docker] mode, you always + containers.** If you want to use [docker-in-docker](https://www.docker.com/blog/docker-can-now-run-within-docker/) mode, you always have to use `privileged = true` in your Docker containers. This will also mount `/certs/client` for the service and build @@ -723,19 +723,22 @@ or [Kubernetes](https://docs.gitlab.com/runner/executors/kubernetes.html) execut make sure that [`pull_policy`](https://docs.gitlab.com/runner/executors/docker.html#how-pull-policies-work) is set to `always`. -[docker-in-docker]: https://blog.docker.com/2013/09/docker-can-now-run-within-docker/ [docker-cap]: https://docs.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities [2fa]: ../../user/profile/account/two_factor_authentication.md [pat]: ../../user/profile/personal_access_tokens.md -<!-- ## Troubleshooting +## 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. +### docker: Cannot connect to the Docker daemon at tcp://docker:2375. Is the docker daemon running? -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 is a common error when you are using +[Docker in Docker](#use-docker-in-docker-workflow-with-docker-executor) +v19.03 or higher. + +This occurs because Docker starts on TLS automatically, so you need to do some set up. +If: + +- This is the first time setting it up, carefully read + [using Docker in Docker workflow](#use-docker-in-docker-workflow-with-docker-executor). +- You are upgrading from v18.09 or earlier, read our + [upgrade guide](https://about.gitlab.com/blog/2019/07/31/docker-in-docker-with-docker-19-dot-03/). diff --git a/doc/ci/environments.md b/doc/ci/environments.md index cef95c8e22a0fcb1075390b727930eee96d308ec..6d62072260809afe0255aa821b76ea9a49b4702d 100644 --- a/doc/ci/environments.md +++ b/doc/ci/environments.md @@ -292,10 +292,10 @@ For the value of: the web server to serve these requests is based on your setup. We have used `$CI_ENVIRONMENT_SLUG` here because it is guaranteed to be unique. If - you're using a workflow like [GitLab Flow](../workflow/gitlab_flow.md), collisions + you're using a workflow like [GitLab Flow](../topics/gitlab_flow.md), collisions are unlikely and you may prefer environment names to be more closely based on the - branch name. In that case, you could use `$CI_COMMIT_REF_SLUG` in `environment:url` in - the example above: `https://$CI_COMMIT_REF_SLUG.example.com`, which would give a URL + branch name. In that case, you could use `$CI_COMMIT_REF_NAME` in `environment:url` in + the example above: `https://$CI_COMMIT_REF_NAME.example.com`, which would give a URL of `https://100-do-the-thing.example.com`. NOTE: **Note:** diff --git a/doc/ci/examples/deployment/README.md b/doc/ci/examples/deployment/README.md index afe02e0a7d856c5a0d847c434599404a55cc99d1..7af797f1851c3fc19dcb89dc6b4471d6c5b2081c 100644 --- a/doc/ci/examples/deployment/README.md +++ b/doc/ci/examples/deployment/README.md @@ -35,8 +35,8 @@ apt-get install ruby-dev The Dpl provides support for vast number of services, including: Heroku, Cloud Foundry, AWS/S3, and more. To use it simply define provider and any additional parameters required by the provider. -For example if you want to use it to deploy your application to heroku, you need to specify `heroku` as provider, specify `api-key` and `app`. -There's more and all possible parameters can be found here: <https://github.com/travis-ci/dpl#heroku>. +For example if you want to use it to deploy your application to Heroku, you need to specify `heroku` as provider, specify `api-key` and `app`. +All possible parameters can be found here: <https://github.com/travis-ci/dpl#heroku-api>. ```yaml staging: 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 e1c59f3b025b802e064914bac9d88aed48521890..ffcc81953950d014520ce7e8e77d2f2f1423bff1 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 @@ -38,7 +38,7 @@ that's tested and deployed on every push to the `master` branch of the [codebase This will also provide boilerplate code for starting a browser-based game with the following components: -- Written in [Typescript](https://www.typescriptlang.org/) and [PhaserJs](https://phaser.io) +- Written in [TypeScript](https://www.typescriptlang.org/) and [PhaserJs](https://phaser.io) - Building, running, and testing with [Gulp](https://gulpjs.com) - Unit tests with [Chai](https://www.chaijs.com) and [Mocha](https://mochajs.org/) - CI/CD with GitLab @@ -508,7 +508,7 @@ deploy: ## Conclusion Within the [demo repository](https://gitlab.com/blitzgren/gitlab-game-demo) you can also find a handful of boilerplate code to get -[Typescript](https://www.typescriptlang.org/), [Mocha](https://mochajs.org/), [Gulp](https://gulpjs.com/) and [Phaser](https://phaser.io) all playing +[TypeScript](https://www.typescriptlang.org/), [Mocha](https://mochajs.org/), [Gulp](https://gulpjs.com/) and [Phaser](https://phaser.io) all playing together nicely with GitLab CI/CD, which is the result of lessons learned while making [Dark Nova](https://www.darknova.io). Using a combination of free and open source software, we have a full CI/CD pipeline, a game foundation, and unit tests, all running and deployed at every push to master - with shockingly little code. diff --git a/doc/ci/examples/laravel_with_gitlab_and_envoy/index.md b/doc/ci/examples/laravel_with_gitlab_and_envoy/index.md index a7ed4ca3514d46bffe35817f5547d0f4dcf93307..5acdd273548f3298a9f24f5ace0dbf78fc76d433 100644 --- a/doc/ci/examples/laravel_with_gitlab_and_envoy/index.md +++ b/doc/ci/examples/laravel_with_gitlab_and_envoy/index.md @@ -379,7 +379,7 @@ These are persistent data and will be shared to every new release. Now, we would need to deploy our app by running `envoy run deploy`, but it won't be necessary since GitLab can handle that for us with CI's [environments](../../environments.md), which will be described [later](#setting-up-gitlab-cicd) in this tutorial. Now it's time to commit [Envoy.blade.php](https://gitlab.com/mehranrasulian/laravel-sample/blob/master/Envoy.blade.php) and push it to the `master` branch. -To keep things simple, we commit directly to `master`, without using [feature-branches](../../../workflow/gitlab_flow.md#github-flow-as-a-simpler-alternative) since collaboration is beyond the scope of this tutorial. +To keep things simple, we commit directly to `master`, without using [feature-branches](../../../topics/gitlab_flow.md#github-flow-as-a-simpler-alternative) since collaboration is beyond the scope of this tutorial. In a real world project, teams may use [Issue Tracker](../../../user/project/issues/index.md) and [Merge Requests](../../../user/project/merge_requests/index.md) to move their code across branches: ```bash diff --git a/doc/ci/img/pipelines_junit_test_report_ui_v12_5.png b/doc/ci/img/pipelines_junit_test_report_ui_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..5b1e3254f8ba387ede586eed0ab82cf18625deef Binary files /dev/null and b/doc/ci/img/pipelines_junit_test_report_ui_v12_5.png differ diff --git a/doc/ci/interactive_web_terminal/index.md b/doc/ci/interactive_web_terminal/index.md index 361e526ed96b2a602b154aec85bfc98ed433be60..f7e8a0e412c2562d38cc3654f8b0c09d65506956 100644 --- a/doc/ci/interactive_web_terminal/index.md +++ b/doc/ci/interactive_web_terminal/index.md @@ -59,7 +59,7 @@ the terminal and type commands like a normal shell. If you have the terminal open and the job has finished with its tasks, the terminal will block the job from finishing for the duration configured in -[`[session_server].terminal_max_retention_time`](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-session_server-section) until you +[`[session_server].session_timeout`](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-session_server-section) until you close the terminal window.  diff --git a/doc/ci/introduction/index.md b/doc/ci/introduction/index.md index a644a89eee43b4d98a9a4fc254c2f0ff5a95d6b5..a07252f48035cdb1838102e0114612d870f76664 100644 --- a/doc/ci/introduction/index.md +++ b/doc/ci/introduction/index.md @@ -5,7 +5,7 @@ type: concepts # Introduction to CI/CD with GitLab -In this document we'll present an overview of the concepts of Continuous Integration, +In this document, we'll present an overview of the concepts of Continuous Integration, Continuous Delivery, and Continuous Deployment, as well as an introduction to GitLab CI/CD. @@ -31,7 +31,7 @@ to be applied according to what best suits your strategy. ### Continuous Integration -Consider an application which has its code stored in a Git +Consider an application that has its code stored in a Git repository in GitLab. Developers push code changes every day, multiple times a day. For every push to the repository, you can create a set of scripts to build and test your application @@ -94,7 +94,7 @@ To add scripts to that file, you'll need to organize them in a sequence that suits your application and are in accordance with the tests you wish to perform. To visualize the process, imagine that all the scripts you add to the configuration file are the -same as the commands you run on a terminal in your computer. +same as the commands you run on a terminal on your computer. Once you've added your `.gitlab-ci.yml` configuration file to your repository, GitLab will detect it and run your scripts with the @@ -121,7 +121,7 @@ Both of them compose a **pipeline** triggered at every push to any branch of the repository. GitLab CI/CD not only executes the jobs you've -set, but also shows you what's happening during execution, as you +set but also shows you what's happening during execution, as you would see in your terminal:  @@ -164,7 +164,7 @@ Once you're happy with your implementation: GitLab CI/CD is capable of doing a lot more, but this workflow exemplifies GitLab's ability to track the entire process, -without the need of any external tool to deliver your software. +without the need for an external tool to deliver your software. And, most usefully, you can visualize all the steps through the GitLab UI. @@ -172,7 +172,7 @@ the GitLab UI. If we take a deeper look into the basic workflow, we can see the features available in GitLab at each stage of the DevOps -lifecycle, as shown on the illustration below. +lifecycle, as shown in the illustration below.  @@ -207,7 +207,7 @@ With GitLab CI/CD you can also: - Deploy your app to different [environments](../environments.md). - Install your own [GitLab Runner](https://docs.gitlab.com/runner/). - [Schedule pipelines](../../user/project/pipelines/schedules.md). -- Check for app vulnerabilities with [Security Test reports](../../user/project/merge_requests/index.md#security-reports-ultimate). **(ULTIMATE)** +- Check for app vulnerabilities with [Security Test reports](../../user/application_security/index.md). **(ULTIMATE)** To see all CI/CD features, navigate back to the [CI/CD index](../README.md). diff --git a/doc/ci/junit_test_reports.md b/doc/ci/junit_test_reports.md index bc30e0073939482f6f73cd78f4980865ac7cb2b1..c03d1798ae185977cfa0f0f4089f525611ee8e9a 100644 --- a/doc/ci/junit_test_reports.md +++ b/doc/ci/junit_test_reports.md @@ -178,3 +178,27 @@ Currently, the following tools might not work because their XML formats are unsu |Case|Tool|Issue| |---|---|---| |`<testcase>` does not have `classname` attribute|ESlint, sass-lint|<https://gitlab.com/gitlab-org/gitlab-foss/issues/50964>| + +## Viewing JUnit test reports on GitLab + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/24792) in GitLab 12.5. + +If JUnit XML files are generated and uploaded as part of a pipeline, these reports +can be viewed inside the pipelines details page. The **Tests** tab on this page will +display a list of test suites and cases reported from the XML file. + + + +You can view all the known test suites and click on each of these to see further +details, including the cases that makeup the suite. Cases are ordered by status, +with failed showing at the top, skipped next and successful cases last. + +### Enabling the feature + +This feature comes with the `:junit_pipeline_view` feature flag disabled by default. +To enable this feature, ask a GitLab administrator with Rails console access to run the +following command: + +```ruby +Feature.enable(:junit_pipeline_view) +``` diff --git a/doc/ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/index.md b/doc/ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/index.md index f2a7902c9ca3620ed5ee5dac1188a84c896465e4..b8976ffae7f55100c269f0f440631716cae219c8 100644 --- a/doc/ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/index.md +++ b/doc/ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/index.md @@ -32,8 +32,8 @@ Merge trains have the following requirements and limitations: - This feature requires that [pipelines for merged results](../index.md#pipelines-for-merged-results-premium) are **configured properly**. -- Each merge train can run a maximum of **four** pipelines in parallel. - If more than four merge requests are added to the merge train, the merge requests +- Each merge train can run a maximum of **twenty** pipelines in parallel. + If more than twenty merge requests are added to the merge train, the merge requests will be queued until a slot in the merge train is free. There is no limit to the number of merge requests that can be queued. - This feature does not support [squash and merge](../../../../user/project/merge_requests/squash_and_merge.md). diff --git a/doc/ci/multi_project_pipelines.md b/doc/ci/multi_project_pipelines.md index 093d334e9377bd5a14d7c5add4f751c21da2e98c..f13d05716f1a712f8b77ec9cb3be3e4bc69f69be 100644 --- a/doc/ci/multi_project_pipelines.md +++ b/doc/ci/multi_project_pipelines.md @@ -87,10 +87,9 @@ not be found, or a user does not have access rights to create pipeline there, the `staging` job is going to be marked as _failed_. CAUTION: **Caution:** -`staging` will succeed as soon as a downstream pipeline gets created. -GitLab does not support status attribution yet, however adding first-class -`trigger` configuration syntax is ground work for implementing -[status attribution](https://gitlab.com/gitlab-org/gitlab-foss/issues/39640). +In the example, `staging` will be marked as succeeded as soon as a downstream pipeline +gets created. If you want to display the downstream pipeline's status instead, see +[Mirroring status from triggered pipeline](#mirroring-status-from-triggered-pipeline). NOTE: **Note:** Bridge jobs do not support every configuration entry that a user can use diff --git a/doc/ci/pipelines.md b/doc/ci/pipelines.md index e5f2701c6ae1c0f4ceb2d87377c827957eb8c2e1..590a02b306cd3af156c480a13247e347e2909cab 100644 --- a/doc/ci/pipelines.md +++ b/doc/ci/pipelines.md @@ -28,7 +28,7 @@ If all the jobs in a stage: - Fail, the next stage is not (usually) executed and the pipeline ends early. NOTE: **Note:** -If you have a [mirrored repository that GitLab pulls from](../workflow/repository_mirroring.md#pulling-from-a-remote-repository-starter), +If you have a [mirrored repository that GitLab pulls from](../user/project/repository/repository_mirroring.md#pulling-from-a-remote-repository-starter), you may need to enable pipeline triggering in your project's **Settings > Repository > Pull from a remote repository > Trigger pipelines for mirror updates**. @@ -269,6 +269,38 @@ To execute a pipeline manually: The pipeline will execute the jobs as configured. +#### Using a query string + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/24146) in GitLab 12.5. + +Variables on the **Run Pipeline** page can be pre-populated by passing variable keys and values +in a query string appended to the `pipelines/new` URL. The format is: + +```plaintext +.../pipelines/new?ref=<branch>&var[<variable_key>]=<value>&file_var[<file_key>]=<value> +``` + +The following parameters are supported: + +- `ref`: specify the branch to populate the **Run for** field with. +- `var`: specify a `Variable` variable. +- `file_var`: specify a `File` variable. + +For each `var` or `file_var`, a key and value are required. + +For example, the query string +`.../pipelines/new?ref=my_branch&var[foo]=bar&file_var[file_foo]=file_bar` will pre-populate the +**Run Pipeline** page as follows: + +- **Run for** field: `my_branch`. +- **Variables** section: + - Variable: + - Key: `foo` + - Value: `bar` + - File: + - Key: `file_foo` + - Value: `file_bar` + ### Accessing pipelines You can find the current and historical pipeline runs under your project's diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md index 10a898be900d5737a69f0584c04b11be43ea1b59..68e977c1c98b786b81e20485d17226175d0cd843 100644 --- a/doc/ci/quick_start/README.md +++ b/doc/ci/quick_start/README.md @@ -50,7 +50,7 @@ project's **Pipelines** page. This guide assumes that you have: -- A working GitLab instance of version 8.0+r or are using +- A working GitLab instance of version 8.0+ or are using [GitLab.com](https://gitlab.com). - A project in GitLab that you would like to use CI for. - Maintainer or owner access to the project @@ -143,7 +143,7 @@ Now if you go to the **Pipelines** page you will see that the pipeline is pending. NOTE: **Note:** -If you have a [mirrored repository where GitLab pulls from](../../workflow/repository_mirroring.md#pulling-from-a-remote-repository-starter), +If you have a [mirrored repository where GitLab pulls from](../../user/project/repository/repository_mirroring.md#pulling-from-a-remote-repository-starter), you may need to enable pipeline triggering in your project's **Settings > Repository > Pull from a remote repository > Trigger pipelines for mirror updates**. diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 5d86d382aa8269b42ffa7f08f39fa00dd8b0970c..cff797549ba3382c15d6be1954c52a933ba444f2 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -54,25 +54,37 @@ or directly in the `.gitlab-ci.yml` file and reuse them as you wish. That can be very powerful as it can be used for scripting without the need to specify the value itself. -#### Variable types +#### Types of variables > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/46806) in GitLab 11.11. There are two types of variables supported by GitLab: -- "Variable": the Runner will create an environment variable named same as the variable key and set its value to the variable value. -- "File": the Runner will write the variable value to a temporary file and set the path to this file as the value of an environment variable named same as the variable key. +- [Variable type](#variable-type): The Runner will create an environment variable named the same as the + variable key and set its value to the variable value. +- [File type](#file-type): The Runner will write the variable value to a temporary file and set the + path to this file as the value of an environment variable, named the same as the variable key. -Many tools (like [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html) and [kubectl](https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#the-kubeconfig-environment-variable)) provide the ability to customise configuration using files by either providing the file path as a command line argument or an environment variable. Prior to the introduction of variable types, the common pattern was to use the value of a CI variable, save it in a file, and then use the newly created file in your script: +##### Variable type + +Many tools (like [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html) +and [kubectl](https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#the-kubeconfig-environment-variable)) +provide the ability to customise configuration using files by either providing the +file path as a command line argument or an environment variable. In the past, the +common pattern was to read the value of a CI variable, save it in a file, and then +use the newly created file in your script: ```bash -# Save the content of variable in a file +# Read certificate stored in $KUBE_CA_PEM variable and save it in a new file echo "$KUBE_CA_PEM" > "$(pwd)/kube.ca.pem" - # Use the newly created file +# Pass the newly created file to kubectl kubectl config set-cluster e2e --server="$KUBE_URL" --certificate-authority="$(pwd)/kube.ca.pem" ``` -This can be simplified by creating a variable of type "File" and using it directly. For example, let's say we have the following variables. +##### File type + +The example above can now be simplified by creating a "File" type variable, and using +it directly. For example, let's say we have the following variables:  @@ -345,7 +357,12 @@ Group-level variables can be added by: 1. Inputing variable types, keys, and values in the **Variables** section. Any variables of [subgroups](../../user/group/subgroups/index.md) will be inherited recursively. -Once you set them, they will be available for all subsequent pipelines. +Once you set them, they will be available for all subsequent pipelines. Any group-level user defined variables can be viewed in projects by: + +1. Navigating to the project's **Settings > CI/CD** page. +1. Expanding the **Variables** section. + + ## Priority of environment variables diff --git a/doc/ci/variables/deprecated_variables.md b/doc/ci/variables/deprecated_variables.md index cdca5bf27fc7c893e9993dd74b8bd25709cf7129..543da481938a0188acb6394425e9fc94b8130025 100644 --- a/doc/ci/variables/deprecated_variables.md +++ b/doc/ci/variables/deprecated_variables.md @@ -20,15 +20,15 @@ future GitLab releases.** | 8.x name | 9.0+ name | | --------------------- |------------------------ | +| `CI_BUILD_BEFORE_SHA` | `CI_COMMIT_BEFORE_SHA` | | `CI_BUILD_ID` | `CI_JOB_ID` | +| `CI_BUILD_MANUAL` | `CI_JOB_MANUAL` | +| `CI_BUILD_NAME` | `CI_JOB_NAME` | | `CI_BUILD_REF` | `CI_COMMIT_SHA` | -| `CI_BUILD_TAG` | `CI_COMMIT_TAG` | -| `CI_BUILD_BEFORE_SHA` | `CI_COMMIT_BEFORE_SHA` | | `CI_BUILD_REF_NAME` | `CI_COMMIT_REF_NAME` | | `CI_BUILD_REF_SLUG` | `CI_COMMIT_REF_SLUG` | -| `CI_BUILD_NAME` | `CI_JOB_NAME` | -| `CI_BUILD_STAGE` | `CI_JOB_STAGE` | | `CI_BUILD_REPO` | `CI_REPOSITORY_URL` | -| `CI_BUILD_TRIGGERED` | `CI_PIPELINE_TRIGGERED` | -| `CI_BUILD_MANUAL` | `CI_JOB_MANUAL` | +| `CI_BUILD_STAGE` | `CI_JOB_STAGE` | +| `CI_BUILD_TAG` | `CI_COMMIT_TAG` | | `CI_BUILD_TOKEN` | `CI_JOB_TOKEN` | +| `CI_BUILD_TRIGGERED` | `CI_PIPELINE_TRIGGERED` | diff --git a/doc/ci/variables/img/inherited_group_variables_v12_5.png b/doc/ci/variables/img/inherited_group_variables_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..f9043df051c72c62c39d9058b352d566f16e6faf Binary files /dev/null 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 20e70d212b0f95c1e863af394fa3d0ad9055aaa1..b93ff62cc21cf75df3f34eb8d7b3ad0140db69f6 100644 --- a/doc/ci/variables/predefined_variables.md +++ b/doc/ci/variables/predefined_variables.md @@ -21,111 +21,111 @@ future GitLab releases.** ## Variables reference -| Variable | GitLab | Runner | Description | -|-------------------------------------------|--------|--------|-------------| -| `ARTIFACT_DOWNLOAD_ATTEMPTS` | 8.15 | 1.9 | Number of attempts to download artifacts running a job | -| `CHAT_INPUT` | 10.6 | all | Additional arguments passed in the [ChatOps](../chatops/README.md) command | -| `CHAT_CHANNEL` | 10.6 | all | Source chat channel which triggered the [ChatOps](../chatops/README.md) command | -| `CI` | all | 0.4 | Mark that job is executed in CI environment | -| `CI_BUILDS_DIR` | all | 11.10 | Top-level directory where builds are executed. | -| `CI_CONCURRENT_ID` | all | 11.10 | Unique ID of build execution within a single executor. | -| `CI_CONCURRENT_PROJECT_ID` | all | 11.10 | Unique ID of build execution within a single executor and project. | -| `CI_COMMIT_BEFORE_SHA` | 11.2 | all | The previous latest commit present on a branch before a merge request. Only populated when there is a merge request associated with the pipeline. | -| `CI_COMMIT_DESCRIPTION` | 10.8 | all | The description of the commit: the message without first line, if the title is shorter than 100 characters; full message in other case. | -| `CI_COMMIT_MESSAGE` | 10.8 | all | The full commit message. | -| `CI_COMMIT_REF_NAME` | 9.0 | all | The branch or tag name for which project is built | -| `CI_COMMIT_REF_SLUG` | 9.0 | all | `$CI_COMMIT_REF_NAME` lowercased, shortened to 63 bytes, and with everything except `0-9` and `a-z` replaced with `-`. No leading / trailing `-`. Use in URLs, host names and domain names. | -| `CI_COMMIT_SHA` | 9.0 | all | The commit revision for which project is built | -| `CI_COMMIT_SHORT_SHA` | 11.7 | all | The first eight characters of `CI_COMMIT_SHA` | -| `CI_COMMIT_TAG` | 9.0 | 0.5 | The commit tag name. Present only when building tags. | -| `CI_COMMIT_TITLE` | 10.8 | all | The title of the commit - the full first line of the message | -| `CI_CONFIG_PATH` | 9.4 | 0.5 | The path to CI config file. Defaults to `.gitlab-ci.yml` | -| `CI_DEBUG_TRACE` | all | 1.7 | Whether [debug logging (tracing)](README.md#debug-logging) is enabled | -| `CI_DEPLOY_PASSWORD` | 10.8 | all | Authentication password of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related.| -| `CI_DEPLOY_USER` | 10.8 | all | Authentication username of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related.| -| `CI_DISPOSABLE_ENVIRONMENT` | all | 10.1 | Marks that the job is executed in a disposable environment (something that is created only for this job and disposed of/destroyed after the execution - all executors except `shell` and `ssh`). If the environment is disposable, it is set to true, otherwise it is not defined at all. | -| `CI_ENVIRONMENT_NAME` | 8.15 | all | The name of the environment for this job. Only present if [`environment:name`](../yaml/README.md#environmentname) is set. | -| `CI_ENVIRONMENT_SLUG` | 8.15 | all | A simplified version of the environment name, suitable for inclusion in DNS, URLs, Kubernetes labels, etc. Only present if [`environment:name`](../yaml/README.md#environmentname) is set. | -| `CI_ENVIRONMENT_URL` | 9.3 | all | The URL of the environment for this job. Only present if [`environment:url`](../yaml/README.md#environmenturl) is set. | -| `CI_DEFAULT_BRANCH` | 12.4 | all | The name of the default branch for the project. | -| `CI_JOB_ID` | 9.0 | all | The unique id of the current job that GitLab CI uses internally | -| `CI_JOB_MANUAL` | 8.12 | all | The flag to indicate that job was manually started | -| `CI_JOB_NAME` | 9.0 | 0.5 | The name of the job as defined in `.gitlab-ci.yml` | -| `CI_JOB_STAGE` | 9.0 | 0.5 | The name of the stage as defined in `.gitlab-ci.yml` | -| `CI_JOB_TOKEN` | 9.0 | 1.2 | Token used for authenticating with the [GitLab Container Registry][registry] and downloading [dependent repositories][dependent-repositories] | -| `CI_JOB_URL` | 11.1 | 0.5 | Job details URL | -| `CI_MERGE_REQUEST_ID` | 11.6 | all | The ID 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_IID` | 11.6 | all | The IID 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_PROJECT_ID` | 11.6 | all | The ID of the project 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_PROJECT_PATH` | 11.6 | all | The path of the project of the merge request if [the pipelines are for merge requests](../merge_request_pipelines/index.md) (e.g. `namespace/awesome-project`). Available only if `only: [merge_requests]` is used and the merge request is created. | -| `CI_MERGE_REQUEST_PROJECT_URL` | 11.6 | all | The URL of the project of the merge request if [the pipelines are for merge requests](../merge_request_pipelines/index.md) (e.g. `http://192.168.10.15:3000/namespace/awesome-project`). Available only if `only: [merge_requests]` is used and the merge request is created. | -| `CI_MERGE_REQUEST_REF_PATH` | 11.6 | all | The ref path of the merge request if [the pipelines are for merge requests](../merge_request_pipelines/index.md). (e.g. `refs/merge-requests/1/head`). Available only if `only: [merge_requests]` is used and the merge request is created. | -| `CI_MERGE_REQUEST_SOURCE_BRANCH_NAME` | 11.6 | all | The source 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_SOURCE_BRANCH_SHA` | 11.9 | all | The HEAD SHA of the source 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_SOURCE_PROJECT_ID` | 11.6 | all | The ID of the source project 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_SOURCE_PROJECT_PATH` | 11.6 | all | The path of the source project 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_SOURCE_PROJECT_URL` | 11.6 | all | The URL of the source project 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_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_ASSIGNEES` | 11.9 | all | Comma-separated list of username(s) of assignee(s) for 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_MILESTONE` | 11.9 | all | The milestone 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_LABELS` | 11.9 | all | Comma-separated label names 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_EXTERNAL_PULL_REQUEST_IID` | 12.3 | all | Pull Request ID from GitHub if the [pipelines are for external pull requests](../ci_cd_for_external_repos/index.md#pipelines-for-external-pull-requests). Available only if `only: [external_pull_requests]` is used and the pull request is open. | -| `CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_SHA` | 12.3 | all | The HEAD SHA of the source branch of the pull request if [the pipelines are for external pull requests](../ci_cd_for_external_repos/index.md#pipelines-for-external-pull-requests). Available only if `only: [external_pull_requests]` is used and the pull request is open. | -| `CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_SHA` | 12.3 | all | The HEAD SHA of the target branch of the pull request if [the pipelines are for external pull requests](../ci_cd_for_external_repos/index.md#pipelines-for-external-pull-requests). Available only if `only: [external_pull_requests]` is used and the pull request is open. | -| `CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_NAME` | 12.3 | all | The source branch name of the pull request if [the pipelines are for external pull requests](../ci_cd_for_external_repos/index.md#pipelines-for-external-pull-requests). Available only if `only: [external_pull_requests]` is used and the pull request is open. | -| `CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_NAME` | 12.3 | all | The target branch name of the pull request if [the pipelines are for external pull requests](../ci_cd_for_external_repos/index.md#pipelines-for-external-pull-requests). Available only if `only: [external_pull_requests]` is used and the pull request is open. | -| `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_API_V4_URL` | 11.7 | all | The GitLab API v4 root URL | -| `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`, and `pipeline`. 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. | -| `CI_PROJECT_ID` | all | all | The unique id of the current project that GitLab CI uses internally | -| `CI_PROJECT_NAME` | 8.10 | 0.5 | The name of the directory for the project that is currently being built. For example, if the project URL is `gitlab.example.com/group-name/project-1`, the `CI_PROJECT_NAME` would be `project-1`. | -| `CI_PROJECT_TITLE` | 12.4 | all | The human-readable project name as displayed in the GitLab web interface. | -| `CI_PROJECT_NAMESPACE` | 8.10 | 0.5 | The project namespace (username or groupname) that is currently being built | -| `CI_PROJECT_PATH` | 8.10 | 0.5 | The namespace with project name | -| `CI_PROJECT_PATH_SLUG` | 9.3 | all | `$CI_PROJECT_PATH` lowercased and with everything except `0-9` and `a-z` replaced with `-`. Use in URLs and domain names. | -| `CI_PROJECT_URL` | 8.10 | 0.5 | The HTTP(S) address to access project | -| `CI_PROJECT_VISIBILITY` | 10.3 | all | The project visibility (internal, private, public) | -| `CI_PROJECT_REPOSITORY_LANGUAGES` | 12.3 | all | Comma-separated, lowercased list of the languages used in the repository (e.g. `ruby,javascript,html,css`) | -| `CI_COMMIT_REF_PROTECTED` | 11.11 | all | If the job is running on a protected branch | -| `CI_REGISTRY` | 8.10 | 0.5 | If the Container Registry is enabled it returns the address of GitLab's Container Registry | -| `CI_REGISTRY_IMAGE` | 8.10 | 0.5 | If the Container Registry is enabled for the project it returns the address of the registry tied to the specific project | -| `CI_REGISTRY_PASSWORD` | 9.0 | all | The password to use to push containers to the GitLab Container Registry | -| `CI_REGISTRY_USER` | 9.0 | all | The username to use to push containers to the GitLab Container Registry | -| `CI_REPOSITORY_URL` | 9.0 | all | The URL to clone the Git repository | -| `CI_RUNNER_DESCRIPTION` | 8.10 | 0.5 | The description of the runner as saved in GitLab | -| `CI_RUNNER_EXECUTABLE_ARCH` | all | 10.6 | The OS/architecture of the GitLab Runner executable (note that this is not necessarily the same as the environment of the executor) | -| `CI_RUNNER_ID` | 8.10 | 0.5 | The unique id of runner being used | -| `CI_RUNNER_REVISION` | all | 10.6 | GitLab Runner revision that is executing the current job | -| `CI_RUNNER_TAGS` | 8.10 | 0.5 | The defined runner tags | -| `CI_RUNNER_VERSION` | all | 10.6 | GitLab Runner version that is executing the current job | -| `CI_RUNNER_SHORT_TOKEN` | all | 12.3 | First eight characters of GitLab Runner's token used to authenticate new job requests. Used as Runner's unique ID | -| `CI_SERVER` | all | all | Mark that job is executed in CI environment | -| `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 | -| `CI_SERVER_VERSION` | all | all | GitLab version that is used to schedule jobs | -| `CI_SERVER_VERSION_MAJOR` | 11.4 | all | GitLab version major component | -| `CI_SERVER_VERSION_MINOR` | 11.4 | all | GitLab version minor component | -| `CI_SERVER_VERSION_PATCH` | 11.4 | all | GitLab version patch component | -| `CI_SHARED_ENVIRONMENT` | all | 10.1 | Marks that the job is executed in a shared environment (something that is persisted across CI invocations like `shell` or `ssh` executor). If the environment is shared, it is set to true, otherwise it is not defined at all. | -| `GET_SOURCES_ATTEMPTS` | 8.15 | 1.9 | Number of attempts to fetch sources running a job | -| `GITLAB_CI` | all | all | Mark that job is executed in GitLab CI environment | -| `GITLAB_USER_EMAIL` | 8.12 | all | The email of the user who started the job | -| `GITLAB_USER_ID` | 8.12 | all | The id of the user who started the job | -| `GITLAB_USER_LOGIN` | 10.0 | all | The login username of the user who started the job | -| `GITLAB_USER_NAME` | 10.0 | all | The real name of the user who started the job | -| `RESTORE_CACHE_ATTEMPTS` | 8.15 | 1.9 | Number of attempts to restore the cache running a job | -| `GITLAB_FEATURES` | 10.6 | all | The comma separated list of licensed features available for your instance and plan | +| Variable | GitLab | Runner | Description | +|-----------------------------------------------|--------|--------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ARTIFACT_DOWNLOAD_ATTEMPTS` | 8.15 | 1.9 | Number of attempts to download artifacts running a job | +| `CHAT_CHANNEL` | 10.6 | all | Source chat channel which triggered the [ChatOps](../chatops/README.md) command | +| `CHAT_INPUT` | 10.6 | all | Additional arguments passed in the [ChatOps](../chatops/README.md) command | +| `CI` | all | 0.4 | Mark that job is executed in CI environment | +| `CI_API_V4_URL` | 11.7 | all | The GitLab API v4 root URL | +| `CI_BUILDS_DIR` | all | 11.10 | Top-level directory where builds are executed. | +| `CI_COMMIT_BEFORE_SHA` | 11.2 | all | The previous latest commit present on a branch before a merge request. Only populated when there is a merge request associated with the pipeline. | +| `CI_COMMIT_DESCRIPTION` | 10.8 | all | The description of the commit: the message without first line, if the title is shorter than 100 characters; full message in other case. | +| `CI_COMMIT_MESSAGE` | 10.8 | all | The full commit message. | +| `CI_COMMIT_REF_NAME` | 9.0 | all | The branch or tag name for which project is built | +| `CI_COMMIT_REF_PROTECTED` | 11.11 | all | If the job is running on a protected branch | +| `CI_COMMIT_REF_SLUG` | 9.0 | all | `$CI_COMMIT_REF_NAME` lowercased, shortened to 63 bytes, and with everything except `0-9` and `a-z` replaced with `-`. No leading / trailing `-`. Use in URLs, host names and domain names. | +| `CI_COMMIT_SHA` | 9.0 | all | The commit revision for which project is built | +| `CI_COMMIT_SHORT_SHA` | 11.7 | all | The first eight characters of `CI_COMMIT_SHA` | +| `CI_COMMIT_TAG` | 9.0 | 0.5 | The commit tag name. Present only when building tags. | +| `CI_COMMIT_TITLE` | 10.8 | all | The title of the commit - the full first line of the message | +| `CI_CONCURRENT_ID` | all | 11.10 | Unique ID of build execution within a single executor. | +| `CI_CONCURRENT_PROJECT_ID` | all | 11.10 | Unique ID of build execution within a single executor and project. | +| `CI_CONFIG_PATH` | 9.4 | 0.5 | The path to CI config file. Defaults to `.gitlab-ci.yml` | +| `CI_DEBUG_TRACE` | all | 1.7 | Whether [debug logging (tracing)](README.md#debug-logging) is enabled | +| `CI_DEFAULT_BRANCH` | 12.4 | all | The name of the default branch for the project. | +| `CI_DEPLOY_PASSWORD` | 10.8 | all | Authentication password of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related. | +| `CI_DEPLOY_USER` | 10.8 | all | Authentication username of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related. | +| `CI_DISPOSABLE_ENVIRONMENT` | all | 10.1 | Marks that the job is executed in a disposable environment (something that is created only for this job and disposed of/destroyed after the execution - all executors except `shell` and `ssh`). If the environment is disposable, it is set to true, otherwise it is not defined at all. | +| `CI_ENVIRONMENT_NAME` | 8.15 | all | The name of the environment for this job. Only present if [`environment:name`](../yaml/README.md#environmentname) is set. | +| `CI_ENVIRONMENT_SLUG` | 8.15 | all | A simplified version of the environment name, suitable for inclusion in DNS, URLs, Kubernetes labels, etc. Only present if [`environment:name`](../yaml/README.md#environmentname) is set. | +| `CI_ENVIRONMENT_URL` | 9.3 | all | The URL of the environment for this job. Only present if [`environment:url`](../yaml/README.md#environmenturl) is set. | +| `CI_EXTERNAL_PULL_REQUEST_IID` | 12.3 | all | Pull Request ID from GitHub if the [pipelines are for external pull requests](../ci_cd_for_external_repos/index.md#pipelines-for-external-pull-requests). Available only if `only: [external_pull_requests]` is used and the pull request is open. | +| `CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_NAME` | 12.3 | all | The source branch name of the pull request if [the pipelines are for external pull requests](../ci_cd_for_external_repos/index.md#pipelines-for-external-pull-requests). Available only if `only: [external_pull_requests]` is used and the pull request is open. | +| `CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_SHA` | 12.3 | all | The HEAD SHA of the source branch of the pull request if [the pipelines are for external pull requests](../ci_cd_for_external_repos/index.md#pipelines-for-external-pull-requests). Available only if `only: [external_pull_requests]` is used and the pull request is open. | +| `CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_NAME` | 12.3 | all | The target branch name of the pull request if [the pipelines are for external pull requests](../ci_cd_for_external_repos/index.md#pipelines-for-external-pull-requests). Available only if `only: [external_pull_requests]` is used and the pull request is open. | +| `CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_SHA` | 12.3 | all | The HEAD SHA of the target branch of the pull request if [the pipelines are for external pull requests](../ci_cd_for_external_repos/index.md#pipelines-for-external-pull-requests). Available only if `only: [external_pull_requests]` is used and the pull request is open. | +| `CI_JOB_ID` | 9.0 | all | The unique id of the current job that GitLab CI uses internally | +| `CI_JOB_MANUAL` | 8.12 | all | The flag to indicate that job was manually started | +| `CI_JOB_NAME` | 9.0 | 0.5 | The name of the job as defined in `.gitlab-ci.yml` | +| `CI_JOB_STAGE` | 9.0 | 0.5 | The name of the stage as defined in `.gitlab-ci.yml` | +| `CI_JOB_TOKEN` | 9.0 | 1.2 | Token used for authenticating with the [GitLab Container Registry][registry] and downloading [dependent repositories][dependent-repositories] | +| `CI_JOB_URL` | 11.1 | 0.5 | Job details URL | +| `CI_MERGE_REQUEST_ASSIGNEES` | 11.9 | all | Comma-separated list of username(s) of assignee(s) for 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_ID` | 11.6 | all | The ID 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_IID` | 11.6 | all | The IID 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_LABELS` | 11.9 | all | Comma-separated label names 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_MILESTONE` | 11.9 | all | The milestone 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_PROJECT_ID` | 11.6 | all | The ID of the project 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_PROJECT_PATH` | 11.6 | all | The path of the project of the merge request if [the pipelines are for merge requests](../merge_request_pipelines/index.md) (e.g. `namespace/awesome-project`). Available only if `only: [merge_requests]` is used and the merge request is created. | +| `CI_MERGE_REQUEST_PROJECT_URL` | 11.6 | all | The URL of the project of the merge request if [the pipelines are for merge requests](../merge_request_pipelines/index.md) (e.g. `http://192.168.10.15:3000/namespace/awesome-project`). Available only if `only: [merge_requests]` is used and the merge request is created. | +| `CI_MERGE_REQUEST_REF_PATH` | 11.6 | all | The ref path of the merge request if [the pipelines are for merge requests](../merge_request_pipelines/index.md). (e.g. `refs/merge-requests/1/head`). Available only if `only: [merge_requests]` is used and the merge request is created. | +| `CI_MERGE_REQUEST_SOURCE_BRANCH_NAME` | 11.6 | all | The source 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_SOURCE_BRANCH_SHA` | 11.9 | all | The HEAD SHA of the source 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_SOURCE_PROJECT_ID` | 11.6 | all | The ID of the source project 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_SOURCE_PROJECT_PATH` | 11.6 | all | The path of the source project 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_SOURCE_PROJECT_URL` | 11.6 | all | The URL of the source project 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_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_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`, and `pipeline`. 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. | +| `CI_PROJECT_ID` | all | all | The unique id of the current project that GitLab CI uses internally | +| `CI_PROJECT_NAME` | 8.10 | 0.5 | The name of the directory for the project that is currently being built. For example, if the project URL is `gitlab.example.com/group-name/project-1`, the `CI_PROJECT_NAME` would be `project-1`. | +| `CI_PROJECT_NAMESPACE` | 8.10 | 0.5 | The project namespace (username or groupname) that is currently being built | +| `CI_PROJECT_PATH` | 8.10 | 0.5 | The namespace with project name | +| `CI_PROJECT_PATH_SLUG` | 9.3 | all | `$CI_PROJECT_PATH` lowercased and with everything except `0-9` and `a-z` replaced with `-`. Use in URLs and domain names. | +| `CI_PROJECT_REPOSITORY_LANGUAGES` | 12.3 | all | Comma-separated, lowercased list of the languages used in the repository (e.g. `ruby,javascript,html,css`) | +| `CI_PROJECT_TITLE` | 12.4 | all | The human-readable project name as displayed in the GitLab web interface. | +| `CI_PROJECT_URL` | 8.10 | 0.5 | The HTTP(S) address to access project | +| `CI_PROJECT_VISIBILITY` | 10.3 | all | The project visibility (internal, private, public) | +| `CI_REGISTRY` | 8.10 | 0.5 | If the Container Registry is enabled it returns the address of GitLab's Container Registry. This variable will include a `:port` value if one has been specified in the registry configuration. | +| `CI_REGISTRY_IMAGE` | 8.10 | 0.5 | If the Container Registry is enabled for the project it returns the address of the registry tied to the specific project | +| `CI_REGISTRY_PASSWORD` | 9.0 | all | The password to use to push containers to the GitLab Container Registry | +| `CI_REGISTRY_USER` | 9.0 | all | The username to use to push containers to the GitLab Container Registry | +| `CI_REPOSITORY_URL` | 9.0 | all | The URL to clone the Git repository | +| `CI_RUNNER_DESCRIPTION` | 8.10 | 0.5 | The description of the runner as saved in GitLab | +| `CI_RUNNER_EXECUTABLE_ARCH` | all | 10.6 | The OS/architecture of the GitLab Runner executable (note that this is not necessarily the same as the environment of the executor) | +| `CI_RUNNER_ID` | 8.10 | 0.5 | The unique id of runner being used | +| `CI_RUNNER_REVISION` | all | 10.6 | GitLab Runner revision that is executing the current job | +| `CI_RUNNER_SHORT_TOKEN` | all | 12.3 | First eight characters of GitLab Runner's token used to authenticate new job requests. Used as Runner's unique ID | +| `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_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 | +| `CI_SERVER_VERSION` | all | all | GitLab version that is used to schedule jobs | +| `CI_SERVER_VERSION_MAJOR` | 11.4 | all | GitLab version major component | +| `CI_SERVER_VERSION_MINOR` | 11.4 | all | GitLab version minor component | +| `CI_SERVER_VERSION_PATCH` | 11.4 | all | GitLab version patch component | +| `CI_SHARED_ENVIRONMENT` | all | 10.1 | Marks that the job is executed in a shared environment (something that is persisted across CI invocations like `shell` or `ssh` executor). If the environment is shared, it is set to true, otherwise it is not defined at all. | +| `GET_SOURCES_ATTEMPTS` | 8.15 | 1.9 | Number of attempts to fetch sources running a job | +| `GITLAB_CI` | all | all | Mark that job is executed in GitLab CI environment | +| `GITLAB_FEATURES` | 10.6 | all | The comma separated list of licensed features available for your instance and plan | +| `GITLAB_USER_EMAIL` | 8.12 | all | The email of the user who started the job | +| `GITLAB_USER_ID` | 8.12 | all | The id of the user who started the job | +| `GITLAB_USER_LOGIN` | 10.0 | all | The login username of the user who started the job | +| `GITLAB_USER_NAME` | 10.0 | all | The real name of the user who started the job | +| `RESTORE_CACHE_ATTEMPTS` | 8.15 | 1.9 | Number of attempts to restore the cache running a job | [gitlab-deploy-token]: ../../user/project/deploy_tokens/index.md#gitlab-deploy-token [registry]: ../../user/packages/container_registry/index.md diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 4569e9ff9b6483bedca642eb79521255397fafac..62644e7887231cdf9b790e017e4f25371456c1ce 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -23,7 +23,7 @@ We have complete examples of configuring pipelines: - To see a large `.gitlab-ci.yml` file used in an enterprise, see the [`.gitlab-ci.yml` file for `gitlab`](https://gitlab.com/gitlab-org/gitlab/blob/master/.gitlab-ci.yml). NOTE: **Note:** -If you have a [mirrored repository where GitLab pulls from](../../workflow/repository_mirroring.md#pulling-from-a-remote-repository-starter), +If you have a [mirrored repository where GitLab pulls from](../../user/project/repository/repository_mirroring.md#pulling-from-a-remote-repository-starter), you may need to enable pipeline triggering in your project's **Settings > Repository > Pull from a remote repository > Trigger pipelines for mirror updates**. @@ -106,7 +106,7 @@ The following table lists available parameters for jobs: | [`when`](#when) | When to run job. Also available: `when:manual` and `when:delayed`. | | [`environment`](#environment) | Name of an environment to which the job deploys. Also available: `environment:name`, `environment:url`, `environment:on_stop`, and `environment:action`. | | [`cache`](#cache) | List of files that should be cached between subsequent runs. Also available: `cache:paths`, `cache:key`, `cache:untracked`, and `cache:policy`. | -| [`artifacts`](#artifacts) | List of files and directories to attach to a job on success. Also available: `artifacts:paths`, `artifacts:name`, `artifacts:untracked`, `artifacts:when`, `artifacts:expire_in`, `artifacts:reports`, and `artifacts:reports:junit`.<br><br>In GitLab [Enterprise Edition](https://about.gitlab.com/pricing/), these are available: `artifacts:reports:codequality`, `artifacts:reports:sast`, `artifacts:reports:dependency_scanning`, `artifacts:reports:container_scanning`, `artifacts:reports:dast`, `artifacts:reports:license_management`, `artifacts:reports:performance` and `artifacts:reports:metrics`. | +| [`artifacts`](#artifacts) | List of files and directories to attach to a job on success. Also available: `artifacts:paths`, `artifacts:expose_as`, `artifacts:name`, `artifacts:untracked`, `artifacts:when`, `artifacts:expire_in`, `artifacts:reports`, and `artifacts:reports:junit`.<br><br>In GitLab [Enterprise Edition](https://about.gitlab.com/pricing/), these are available: `artifacts:reports:codequality`, `artifacts:reports:sast`, `artifacts:reports:dependency_scanning`, `artifacts:reports:container_scanning`, `artifacts:reports:dast`, `artifacts:reports:license_management`, `artifacts:reports:performance` and `artifacts:reports:metrics`. | | [`dependencies`](#dependencies) | Restrict which artifacts are passed to a specific job by providing a list of jobs to fetch artifacts from. | | [`coverage`](#coverage) | Code coverage settings for a given job. | | [`retry`](#retry) | When and how many times a job can be auto-retried in case of a failure. | @@ -135,6 +135,7 @@ The following job parameters can be defined inside a `default:` block: - [`before_script`](#before_script-and-after_script) - [`after_script`](#before_script-and-after_script) - [`cache`](#cache) +- [`interruptible`](#interruptible) In the following example, the `ruby:2.5` image is set as the default for all jobs except the `rspec 2.6` job, which uses the `ruby:2.6` image: @@ -181,6 +182,25 @@ that the YAML parser knows to interpret the whole thing as a string rather than a "key: value" pair. Be careful when using special characters: `:`, `{`, `}`, `[`, `]`, `,`, `&`, `*`, `#`, `?`, `|`, `-`, `<`, `>`, `=`, `!`, `%`, `@`, `` ` ``. +#### YAML anchors for `script` + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/23005) in GitLab 12.5. + +You can use [YAML anchors](#anchors) with scripts, which makes it possible to +include a predefined list of commands in multiple jobs. + +Example: + +```yaml +.something: &something +- echo 'something' + +job_name: + script: + - *something + - echo 'this is the script' +``` + ### `image` Used to specify [a Docker image](../docker/using_docker_images.md#what-is-an-image) to use for the job. @@ -240,34 +260,26 @@ For more information, see see [Available settings for `services`](../docker/usin > Introduced in GitLab 8.7 and requires GitLab Runner v1.2. -`before_script` is used to define the command that should be run before all -jobs, including deploy jobs, but after the restoration of [artifacts](#artifacts). +`before_script` is used to define a command that should be run before each +job, including deploy jobs, but after the restoration of any [artifacts](#artifacts). This must be an an array. -`after_script` is used to define the command that will be run after all -jobs, including failed ones. This must be an an array. - -Scripts specified in `before_script` are: +Scripts specified in `before_script` are concatenated with any scripts specified +in the main [`script`](#script), and executed together in a single shell. -- Concatenated with scripts specified in the main `script`. Job-level - `before_script` definition override global-level `before_script` definition - when concatenated with `script` definition. -- Executed together with main `script` script as one script in a single shell - context. +`after_script` is used to define the command that will be run after each +job, including failed ones. This must be an an array. -Scripts specified in `after_script`: +Scripts specified in `after_script` are executed in a new shell, separate from any +`before_script` or `script` scripts. As a result, they: - Have a current working directory set back to the default. -- Are executed in a shell context separated from `before_script` and `script` - scripts. -- Because of separated context, cannot see changes done by scripts defined - in `before_script` or `script` scripts, either: - - In shell. For example, command aliases and variables exported in `script` - scripts. - - Outside of the working tree (depending on the Runner executor). For example, - software installed by a `before_script` or `script` scripts. - -It's possible to overwrite the globally defined `before_script` and `after_script` +- Have no access to changes done by scripts defined in `before_script` or `script`, including: + - Command aliases and variables exported in `script` scripts. + - Changes outside of the working tree (depending on the Runner executor), like + software installed by a `before_script` or `script` script. + +It's possible to overwrite a globally defined `before_script` or `after_script` if you set it per-job: ```yaml @@ -284,6 +296,33 @@ job: - execute this after my script ``` +#### YAML anchors for `before_script` and `after_script` + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/23005) in GitLab 12.5. + +You can use [YAML anchors](#anchors) with `before_script` and `after_script`, +which makes it possible to include a predefined list of commands in multiple +jobs. + +Example: + +```yaml +.something_before: &something_before +- echo 'something before' + +.something_after: &something_after +- echo 'something after' + + +job_name: + before_script: + - *something_before + script: + - echo 'this is the script' + after_script: + - *something_after +``` + ### `stages` `stages` is used to define stages that can be used by jobs and is defined @@ -329,6 +368,37 @@ The following stages are available to every pipeline: User-defined stages are executed after `.pre` and before `.post`. +The order of `.pre` and `.post` cannot be changed, even if defined out of order in `.gitlab-ci.yml`. +For example, the following are equivalent configuration: + +- Configured in order: + + ```yml + stages: + - .pre + - a + - b + - .post + ``` + +- Configured out of order: + + ```yml + stages: + - a + - .pre + - b + - .post + ``` + +- Not explicitly configured: + + ```yml + stages: + - a + - b + ``` + ### `stage` `stage` is defined per-job and relies on [`stages`](#stages) which is defined @@ -379,6 +449,9 @@ Jobs will run on your own Runners in parallel only if: ### `only`/`except` (basic) +NOTE: **Note:** +These parameters will soon be [deprecated](https://gitlab.com/gitlab-org/gitlab/issues/27449) in favor of [`rules`](#rules) as it offers a more powerful syntax. + `only` and `except` are two parameters that set a job policy to limit when jobs are created: @@ -926,12 +999,11 @@ docker build: when: delayed start_in: '3 hours' - when: on_success # Otherwise include the job and set to run normally - ``` Additional job configuration may be added to rules in the future. If something useful isn't available, please -[open an issue](https://www.gitlab.com/gitlab-org/gitlab/issues). +[open an issue](https://gitlab.com/gitlab-org/gitlab/issues). ### `tags` @@ -1463,6 +1535,50 @@ cache: - binaries/ ``` +##### `cache:key:files` + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/18986) in GitLab v12.5. + +If `cache:key:files` is added, one or two files must be defined with it. The cache `key` +will be a SHA computed from the most recent commits (one or two) that changed the +given files. If neither file was changed in any commits, the key will be `default`. + +```yaml +cache: + key: + files: + - Gemfile.lock + - package.json + paths: + - vendor/ruby + - node_modules +``` + +##### `cache:key:prefix` + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/18986) in GitLab v12.5. + +The `prefix` parameter adds extra functionality to `key:files` by allowing the key to +be composed of the given `prefix` combined with the SHA computed for `cache:key:files`. +For example, adding a `prefix` of `rspec`, will +cause keys to look like: `rspec-feef9576d21ee9b6a32e30c5c79d0a0ceb68d1e5`. If neither +file was changed in any commits, the prefix is added to `default`, so the key in the +example would be `rspec-default`. + +`prefix` follows the same restrictions as `key`, so it can use any of the +[predefined variables](../variables/README.md). Similarly, the `/` character or the +equivalent URI-encoded `%2F`, or a value made only of `.` or `%2E`, is not allowed. + +```yaml +cache: + key: + files: + - Gemfile.lock + prefix: ${CI_JOB_NAME} + paths: + - vendor/ruby +``` + #### `cache:untracked` Set `untracked: true` to cache all files that are untracked in your Git @@ -1596,6 +1712,47 @@ release-job: - tags ``` +#### `artifacts:expose_as` + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/15018) in GitLab 12.5. + +The `expose_as` keyword can be used to expose [job artifacts](../../user/project/pipelines/job_artifacts.md) +in the [merge request](../../user/project/merge_requests/index.md) UI. + +For example, to match a single file: + +```yml +test: + script: [ 'echo 1' ] + artifacts: + expose_as: 'artifact 1' + paths: ['path/to/file.txt'] +``` + +With this configuration, GitLab will add a link **artifact 1** to the relevant merge request +that points to `file1.txt`. + +An example that will match an entire directory: + +```yml +test: + script: [ 'echo 1' ] + artifacts: + expose_as: 'artifact 1' + paths: ['path/to/directory/'] +``` + +Note the following: + +- A maximum of 10 job artifacts per merge request can be exposed. +- Glob patterns are unsupported. +- If a directory is specified, the link will be to the job [artifacts browser](../../user/project/pipelines/job_artifacts.md#browsing-artifacts) if there is more than + one file in the directory. +- For exposed single file artifacts with `.html`, `.htm`, `.txt`, `.json`, `.xml`, + and `.log` extensions, if [GitLab Pages](../../administration/pages/index.md) is: + - Enabled, GitLab will automatically render the artifact. + - Not enabled, you will see the file in the artifacts browser. + #### `artifacts:name` > Introduced in GitLab 8.6 and GitLab Runner v1.1.0. @@ -1910,8 +2067,6 @@ Defining an empty array will skip downloading any artifacts for that job. The status of the previous job is not considered when using `dependencies`, so if it failed or it is a manual job that was not run, no error occurs. ---- - In the following example, we define two jobs with artifacts, `build:osx` and `build:linux`. When the `test:osx` is executed, the artifacts from `build:osx` will be downloaded and extracted in the context of the build. The same happens @@ -2249,6 +2404,24 @@ staging: branch: stable ``` +It is possible to mirror the status from a triggered pipeline: + +``` +trigger_job: + trigger: + project: my/project + strategy: depend +``` + +It is possible to mirror the status from an upstream pipeline: + +``` +upstream_bridge: + stage: test + needs: + pipeline: other/project +``` + ### `interruptible` > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/23464) in GitLab 12.3. diff --git a/doc/development/README.md b/doc/development/README.md index 16b073045cccdd4999a34a16fb4c456bfde86001..66df6f46e863d14fbe9f9e6be34ef87ca4a28c80 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -99,7 +99,7 @@ description: 'Learn how to contribute to GitLab.' - [Post deployment migrations](post_deployment_migrations.md) - [Background migrations](background_migrations.md) - [Swapping tables](swapping_tables.md) -- [Deleting exiting migrations](deleting_migrations.md) +- [Deleting migrations](deleting_migrations.md) ### Best practices @@ -118,6 +118,7 @@ description: 'Learn how to contribute to GitLab.' - [Query Count Limits](query_count_limits.md) - [Database helper modules](database_helpers.md) - [Code comments](code_comments.md) +- [Creating enums](creating_enums.md) ### Case studies diff --git a/doc/development/api_graphql_styleguide.md b/doc/development/api_graphql_styleguide.md index cdd0e9b2a7b0e58b80c44b2e3cafbd423b21c507..b0d94511c6e16cde4cf6c0008ff7cec2c0c3eb3a 100644 --- a/doc/development/api_graphql_styleguide.md +++ b/doc/development/api_graphql_styleguide.md @@ -43,14 +43,14 @@ a new presenter specifically for GraphQL. The presenter is initialized using the object resolved by a field, and the context. -### Exposing Global ids +### Exposing Global IDs -When exposing an `id` field on a type, we will by default try to -expose a global id by calling `to_global_id` on the resource being +When exposing an `ID` field on a type, we will by default try to +expose a global ID by calling `to_global_id` on the resource being rendered. To override this behaviour, you can implement an `id` method on the -type for which you are exposing an id. Please make sure that when +type for which you are exposing an ID. Please make sure that when exposing a `GraphQL::ID_TYPE` using a custom method that it is globally unique. @@ -146,6 +146,10 @@ query($project_path: ID!) { } ``` +To ensure that we get consistent ordering, we will append an ordering on the primary +key, in descending order. This is usually `id`, so basically we will add `order(id: :desc)` +to the end of the relation. A primary key _must_ be available on the underlying table. + ### Exposing permissions for a type To expose permissions the current user has on a resource, you can call @@ -236,6 +240,47 @@ end ``` +## Descriptions + +All fields and arguments +[must have descriptions](https://gitlab.com/gitlab-org/gitlab/merge_requests/16438). + +A description of a field or argument is given using the `description:` +keyword. For example: + +```ruby +field :id, GraphQL::ID_TYPE, description: 'ID of the resource' +``` + +Descriptions of fields and arguments are viewable to users through: + +- The [GraphiQL explorer](../api/graphql/#graphiql). +- The [static GraphQL API reference](../api/graphql/#reference). + +### Description styleguide + +To ensure consistency, the following should be followed whenever adding or updating +descriptions: + +- Mention the name of the resource in the description. Example: + `'Labels of the issue'` (issue being the resource). +- Use `"{x} of the {y}"` where possible. Example: `'Title of the issue'`. + Do not start descriptions with `The`. +- Descriptions of `GraphQL::BOOLEAN_TYPE` fields should answer the question: "What does + this field do?". Example: `'Indicates project has a Git repository'`. +- Always include the word `"timestamp"` when describing an argument or + field of type `Types::TimeType`. This lets the reader know that the + format of the property will be `Time`, rather than just `Date`. +- No `.` at end of strings. + +Example: + +```ruby +field :id, GraphQL::ID_TYPE, description: 'ID of the Issue' +field :confidential, GraphQL::BOOLEAN_TYPE, description: 'Indicates the issue is confidential' +field :closed_at, Types::TimeType, description: 'Timestamp of when the issue was closed' +``` + ## Authorization Authorizations can be applied to both types and fields using the same @@ -350,7 +395,10 @@ To find objects to display in a field, we can add resolvers to `app/graphql/resolvers`. Arguments can be defined within the resolver, those arguments will be -made available to the fields using the resolver. +made available to the fields using the resolver. When exposing a model +that had an internal ID (`iid`), prefer using that in combination with +the namespace path as arguments in a resolver over a database +ID. Othewise use a [globally unique ID](#exposing-global-ids). We already have a `FullPathLoader` that can be included in other resolvers to quickly find Projects and Namespaces which will have a @@ -365,6 +413,10 @@ actions. In the same way a GET-request should not modify data, we cannot modify data in a regular GraphQL-query. We can however in a mutation. +To find objects for a mutation, arguments need to be specified. As with +[resolvers](#resolvers), prefer using internal ID or, if needed, a +global ID rather than the database ID. + ### Fields In the most common situations, a mutation would return 2 fields: @@ -496,6 +548,32 @@ found, we should raise a `Gitlab::Graphql::Errors::ResourceNotAvailable` error. Which will be correctly rendered to the clients. +## Gitlab's custom scalars + +### `Types::TimeType` + +[`Types::TimeType`](https://gitlab.com/gitlab-org/gitlab/blob/master/app%2Fgraphql%2Ftypes%2Ftime_type.rb) +must be used as the type for all fields and arguments that deal with Ruby +`Time` and `DateTime` objects. + +The type is +[a custom scalar](https://github.com/rmosolgo/graphql-ruby/blob/master/guides/type_definitions/scalars.md#custom-scalars) +that: + +- Converts Ruby's `Time` and `DateTime` objects into standardized + ISO-8601 formatted strings, when used as the type for our GraphQL fields. +- Converts ISO-8601 formatted time strings into Ruby `Time` objects, + when used as the type for our GraphQL arguments. + +This allows our GraphQL API to have a standardized way that it presents time +and handles time inputs. + +Example: + +```ruby +field :created_at, Types::TimeType, null: false, description: 'Timestamp of when the issue was created' +``` + ## Testing _full stack_ tests for a graphql query or mutation live in @@ -540,7 +618,7 @@ it 'returns a successful response' do end ``` -## Documentation +## Documentation and Schema -For information on generating GraphQL documentation, see -[Rake tasks related to GraphQL](rake_tasks.md#update-graphql-documentation). +For information on generating GraphQL documentation and schema files, see +[Rake tasks related to GraphQL](rake_tasks.md#update-graphql-documentation-and-schema-definitions). diff --git a/doc/development/architecture.md b/doc/development/architecture.md index ccedb96d27d08824366e0a1fe7933a03f1a46096..b579f812d99406d6d9f94b55fdc4f81fdff100ea 100644 --- a/doc/development/architecture.md +++ b/doc/development/architecture.md @@ -59,10 +59,10 @@ graph TB Unicorn --> Gitaly Sidekiq --> Redis Sidekiq --> PgBouncer + Sidekiq --> Gitaly GitLabWorkhorse[GitLab Workhorse] --> Unicorn GitLabWorkhorse --> Redis GitLabWorkhorse --> Gitaly - Gitaly --> Redis NGINX --> GitLabWorkhorse NGINX -- TCP 8090 --> GitLabPages[GitLab Pages] NGINX --> Grafana[Grafana] diff --git a/doc/development/changelog.md b/doc/development/changelog.md index 8ded3f393eed1b78c9c1581db730d936a9788f34..af2c540cca550f571892bf40837e4f11e3b60d4b 100644 --- a/doc/development/changelog.md +++ b/doc/development/changelog.md @@ -41,7 +41,10 @@ the `author` field. GitLab team members **should not**. a changelog entry regardless of these guidelines if the contributor wants one. Example: "Fixed a typo on the search results page." - Any docs-only changes **should not** have a changelog entry. -- Any change behind a feature flag **should not** have a changelog entry. The entry should be added [in the merge request removing the feature flags](feature_flags/development.md). +- Any change behind a feature flag **should not** have a changelog entry. The + entry should be added [in the merge request removing the feature flags](feature_flags/development.md). + If the change includes a database migration, there should be a changelog entry + for the migration change. - A fix for a regression introduced and then fixed in the same release (i.e., fixing a bug introduced during a monthly release candidate) **should not** have a changelog entry. diff --git a/doc/development/chatops_on_gitlabcom.md b/doc/development/chatops_on_gitlabcom.md index 8a313a120f1a2489c17a71769b4bf42760468e82..456dd1d4b4bcc2c5069f6096bf50abb10246b6fc 100644 --- a/doc/development/chatops_on_gitlabcom.md +++ b/doc/development/chatops_on_gitlabcom.md @@ -14,7 +14,7 @@ tasks such as: To request access to Chatops on GitLab.com: 1. Log into <https://ops.gitlab.net/users/sign_in> **using the same username** as for GitLab.com (you may have to rename it). -1. Ask [an owner/maintainer in the `chatops` project](https://gitlab.com/gitlab-com/chatops/-/project_members?search=&sort=access_level_desc) to add you by running `/chatops run member add <username> gitlab-com/chatops --ops`. +1. Ask [a project member in the `chatops` project](https://ops.gitlab.net/gitlab-com/chatops/-/project_members) to add you by running `/chatops run member add <username> gitlab-com/chatops --ops`. ## See also diff --git a/doc/development/code_review.md b/doc/development/code_review.md index 421b70bd2db054aedafbe81bbf68b41c941680df..77c57bb332d94ab5696e9d33683e353e745ed665 100644 --- a/doc/development/code_review.md +++ b/doc/development/code_review.md @@ -170,8 +170,8 @@ Maintainers should check before merging if the merge request is approved by the required approvers. Maintainers must check before merging if the merge request is introducing new -vulnerabilities, by inspecting the list in the Merge Request [Security -Widget](../user/project/merge_requests/index.md#security-reports-ultimate). +vulnerabilities, by inspecting the list in the Merge Request +[Security Widget](../user/application_security/index.md). When in doubt, a [Security Engineer](https://about.gitlab.com/company/team/) can be involved. The list of detected vulnerabilities must be either empty or containing: @@ -368,7 +368,7 @@ Enterprise Edition instance. This has some implications: - [Background migrations](background_migrations.md) run in Sidekiq, and should only be done for migrations that would take an extreme amount of time at GitLab.com scale. -1. **Sidekiq workers** [cannot change in a backwards-incompatible way](sidekiq_style_guide.md#removing-or-renaming-queues): +1. **Sidekiq workers** [cannot change in a backwards-incompatible way](sidekiq_style_guide.md#sidekiq-compatibility-across-updates): 1. Sidekiq queues are not drained before a deploy happens, so there will be workers in the queue from the previous version of GitLab. 1. If you need to change a method signature, try to do so across two releases, diff --git a/doc/development/contributing/index.md b/doc/development/contributing/index.md index 92dd040a2bdeb59b690a246432126cbcc8e8c3e9..481a18aac3d4eefba01cfbc7676e8720475454b0 100644 --- a/doc/development/contributing/index.md +++ b/doc/development/contributing/index.md @@ -118,6 +118,10 @@ This [documentation](merge_request_workflow.md) outlines the current merge reque This [documentation](style_guides.md) outlines the current style guidelines. +## Getting an Enterprise Edition License + +If you need a license for contributing to an EE-feature, please [follow these instructions](https://about.gitlab.com/handbook/marketing/community-relations/code-contributor-program/#for-contributors-to-the-gitlab-enterprise-edition-ee). + --- [Return to Development documentation](../README.md) diff --git a/doc/development/contributing/issue_workflow.md b/doc/development/contributing/issue_workflow.md index 349bb371835cf443b332006ecaa2ea50aacff1be..f32400d44a22f1ce90873157a60818205defceed 100644 --- a/doc/development/contributing/issue_workflow.md +++ b/doc/development/contributing/issue_workflow.md @@ -268,7 +268,7 @@ Each issue scheduled for the current milestone should be labeled ~Deliverable or ~"Stretch". Any open issue for a previous milestone should be labeled ~"Next Patch Release", or otherwise rescheduled to a different milestone. -#### Priority labels +### Priority labels Priority labels help us define the time a ~bug fix should be completed. Priority determines how quickly the defect turnaround time must be. If there are multiple defects, the priority decides which defect has to be fixed immediately versus later. diff --git a/doc/development/contributing/merge_request_workflow.md b/doc/development/contributing/merge_request_workflow.md index 86f17f4ecdb90013be48d0245a4a5845faa50c42..510e90524ed42ba350ba10fdd7d3aa6f2d2344de 100644 --- a/doc/development/contributing/merge_request_workflow.md +++ b/doc/development/contributing/merge_request_workflow.md @@ -36,7 +36,7 @@ include a regression test are merged quickly, while new features without proper tests might be slower to receive feedback. The workflow to make a merge request is as follows: -1. [Fork](../../workflow/forking_workflow.md#creating-a-fork) the project into +1. [Fork](../../user/project/repository/forking_workflow.md) the project into your personal namespace (or group) on GitLab.com. 1. Create a feature branch in your fork (don't work off `master`). 1. Write [tests](../rake_tasks.md#run-tests) and code. @@ -69,7 +69,7 @@ request is as follows: the issue(s) once the merge request is merged. 1. If you're allowed to (Core team members, for example), set a relevant milestone and [labels](issue_workflow.md). -1. If the MR changes the UI, it should include *Before* and *After* screenshots. +1. If the MR changes the UI, you'll need approval from a Product Designer (UX), based on the appropriate [product category](https://about.gitlab.com/handbook/product/categories/). UI changes should use available components from the GitLab Design System, [Pajamas](https://design.gitlab.com/). The MR must include *Before* and *After* screenshots. 1. If the MR changes CSS classes, please include the list of affected pages, which can be found by running `grep css-class ./app -R`. 1. Be prepared to answer questions and incorporate feedback into your MR with new @@ -222,7 +222,7 @@ requirements. on the CI server. 1. Regressions and bugs are covered with tests that reduce the risk of the issue happening again. -1. Performance/scalability implications have been considered, addressed, and tested. +1. [Performance guidelines](../merge_request_performance_guidelines.md) have been followed. 1. [Documented](../documentation/index.md) in the `/doc` directory. 1. [Changelog entry added](../changelog.md), if necessary. 1. Reviewed by relevant (UX/FE/BE/tech writing) reviewers and all concerns are addressed. diff --git a/doc/development/creating_enums.md b/doc/development/creating_enums.md new file mode 100644 index 0000000000000000000000000000000000000000..64385a2ea79432dbdafa72d78f13afb147b738d3 --- /dev/null +++ b/doc/development/creating_enums.md @@ -0,0 +1,15 @@ +# Creating enums + +When creating a new enum, it should use the database type `SMALLINT`. +The `SMALLINT` type size is 2 bytes, which is sufficient for an enum. +This would help to save space in the database. + +To use this type, add `limit: 2` to the migration that creates the column. + +Example: + +```rb +def change + add_column :ci_job_artifacts, :file_format, :integer, limit: 2 +end +``` diff --git a/doc/development/database_debugging.md b/doc/development/database_debugging.md index 9947f9c16c005c3b363700bfd10163cee82031c9..65a3e518585d638c2c5a55e1e5bd7ec285acf5a9 100644 --- a/doc/development/database_debugging.md +++ b/doc/development/database_debugging.md @@ -19,7 +19,7 @@ If you just want to delete everything and start over with an empty DB (~1 minute - `bundle exec rake db:reset RAILS_ENV=development` -If you just want to delete everything and start over with dummy data (~40 minutes). This also does `db:reset` and runs DB-specific migrations: +If you just want to delete everything and start over with dummy data (~4 minutes). This also does `db:reset` and runs DB-specific migrations: - `bundle exec rake dev:setup RAILS_ENV=development` diff --git a/doc/development/database_review.md b/doc/development/database_review.md index 39236ab1910b03ff9cdf7ff58269fffdf7d7ee23..f3c190024174d0a169ec6a84aed83de2ee91ef1a 100644 --- a/doc/development/database_review.md +++ b/doc/development/database_review.md @@ -47,6 +47,13 @@ A database **reviewer**'s role is to: reassign MR to the database **maintainer** suggested by Reviewer Roulette. +#### When there are no database maintainers available + +Currently we have a [critical shortage of database maintainers](https://gitlab.com/gitlab-org/gitlab/issues/29717). Until we are able to increase the number of database maintainers to support the volume of reviews, we have implemented this temporary solution. If the database **reviewer** cannot find an available database **maintainer** then: + +1. Assign the MR for a second review by a **database trainee maintainer** for further review. +1. Once satisfied with the review process, and if the database **maintainer** is still not available, skip the database maintainer approval step and assign the merge request to a backend maintainer for final review and approval. + A database **maintainer**'s role is to: - Perform the final database review on the MR. diff --git a/doc/development/documentation/index.md b/doc/development/documentation/index.md index fb0aa5130f8226274788be71182fae76ce4442e6..7d575e9b0b13de81d51fda96d86e0616c980325f 100644 --- a/doc/development/documentation/index.md +++ b/doc/development/documentation/index.md @@ -16,7 +16,7 @@ In addition to this page, the following resources can help you craft and contrib ## Source files and rendered web locations -Documentation for GitLab, GitLab Runner, Omnibus GitLab and Charts are published to <https://docs.gitlab.com>. Documentation for GitLab is also published within the application at `/help` on the domain of the GitLab instance. +Documentation for GitLab, GitLab Runner, Omnibus GitLab and Charts is published to <https://docs.gitlab.com>. Documentation for GitLab is also published within the application at `/help` on the domain of the GitLab instance. At `/help`, only help for your current edition and version is included. Help for other versions is available at <https://docs.gitlab.com/archives/>. The source of the documentation exists within the codebase of each GitLab application in the following repository locations: @@ -67,8 +67,6 @@ This document was moved to [another location](path/to/new_doc.md). where `path/to/new_doc.md` is the relative path to the root directory `doc/`. ---- - For example, if you move `doc/workflow/lfs/lfs_administration.md` to `doc/administration/lfs.md`, then the steps would be: diff --git a/doc/development/documentation/site_architecture/index.md b/doc/development/documentation/site_architecture/index.md index f5a12e9c2162ebd8835d19e3d41a9c503a18b4bc..bf873995e54531f8c1d431fcbdd96c675bcb60ca 100644 --- a/doc/development/documentation/site_architecture/index.md +++ b/doc/development/documentation/site_architecture/index.md @@ -4,14 +4,16 @@ description: "Learn how GitLab's documentation website is architectured." # Documentation site architecture -Learn how we build and architecture [`gitlab-docs`](https://gitlab.com/gitlab-org/gitlab-docs) -and deploy it to <https://docs.gitlab.com>. +The [`gitlab-docs`](https://gitlab.com/gitlab-org/gitlab-docs) project hosts +the repository which is used to generate the GitLab documentation website and +is deployed to <https://docs.gitlab.com>. It uses the [Nanoc](http://nanoc.ws) +static site generator. -## Repository +## Architecture While the source of the documentation content is stored in GitLab's respective product -repositories, the source that is used to build the documentation site _from that content_ -is located at <https://gitlab.com/gitlab-org/gitlab-docs>. +repositories, the source that is used to build the documentation +site _from that content_ is located at <https://gitlab.com/gitlab-org/gitlab-docs>. The following diagram illustrates the relationship between the repositories from where content is sourced, the `gitlab-docs` project, and the published output. @@ -43,8 +45,23 @@ from where content is sourced, the `gitlab-docs` project, and the published outp G --> L ``` -See the [README there](https://gitlab.com/gitlab-org/gitlab-docs/blob/master/README.md) -for detailed information. +You will not find any GitLab docs content in the `gitlab-docs` repository. +All documentation files are hosted in the respective repository of each +product, and all together are pulled to generate the docs website: + +- [GitLab](https://gitlab.com/gitlab-org/gitlab/tree/master/doc) +- [Omnibus GitLab](https://gitlab.com/gitlab-org/omnibus-gitlab/tree/master/doc) +- [GitLab Runner](https://gitlab.com/gitlab-org/gitlab-runner/tree/master/docs) +- [GitLab Chart](https://gitlab.com/charts/gitlab/tree/master/doc) + +NOTE: **Note:** +In September 2019, we [moved towards a single codebase](https://gitlab.com/gitlab-org/gitlab-ee/issues/2952), +as such the docs for CE and EE are now identical. For historical reasons and +in order not to break any existing links throughout the internet, we still +maintain the CE docs (`https://docs.gitlab.com/ce/`), although it is hidden +from the website, and is now a symlink to the EE docs. When +[Pages supports redirects](https://gitlab.com/gitlab-org/gitlab-pages/issues/24), +we will be able to remove this completely. ## Assets @@ -73,28 +90,112 @@ Read through [the global navigation documentation](global_nav.md) to understand: - How the global navigation is built. - How to add new navigation items. -## Deployment +<!-- +## Helpers -The docs site is deployed to production with GitLab Pages, and previewed in -merge requests with Review Apps. +TBA +--> -The deployment aspects will be soon transferred from the [original document](https://gitlab.com/gitlab-org/gitlab-docs/blob/master/README.md) -to this page. +## Using YAML data files -<!-- -## Repositories +The easiest way to achieve something similar to +[Jekyll's data files](https://jekyllrb.com/docs/datafiles/) in Nanoc is by +using the [`@items`](https://nanoc.ws/doc/reference/variables/#items-and-layouts) +variable. -TBA +The data file must be placed inside the `content/` directory and then it can +be referenced in an ERB template. -## Search engine +Suppose we have the `content/_data/versions.yaml` file with the content: -TBA +```yaml +versions: +- 10.6 +- 10.5 +- 10.4 +``` -## Versions +We can then loop over the `versions` array with something like: -TBA +```erb +<% @items['/_data/versions.yaml'][:versions].each do | version | %> -## Helpers +<h3><%= version %></h3> -TBA ---> +<% end &> +``` + +Note that the data file must have the `yaml` extension (not `yml`) and that +we reference the array with a symbol (`:versions`). + +## Bumping versions of CSS and Javascript + +Whenever the custom CSS and Javascript files under `content/assets/` change, +make sure to bump their version in the frontmatter. This method guarantees that +your changes will take effect by clearing the cache of previous files. + +Always use Nanoc's way of including those files, do not hardcode them in the +layouts. For example use: + +```erb +<script async type="application/javascript" src="<%= @items['/assets/javascripts/badges.*'].path %>"></script> + +<link rel="stylesheet" href="<%= @items['/assets/stylesheets/toc.*'].path %>"> +``` + +The links pointing to the files should be similar to: + +```erb +<%= @items['/path/to/assets/file.*'].path %> +``` + +Nanoc will then build and render those links correctly according with what's +defined in [`Rules`](https://gitlab.com/gitlab-org/gitlab-docs/blob/master/Rules). + +## Linking to source files + +A helper called [`edit_on_gitlab`](https://gitlab.com/gitlab-org/gitlab-docs/blob/master/lib/helpers/edit_on_gitlab.rb) can be used +to link to a page's source file. We can link to both the simple editor and the +web IDE. Here's how you can use it in a Nanoc layout: + +- Default editor: `<a href="<%= edit_on_gitlab(@item, editor: :simple) %>">Simple editor</a>` +- Web IDE: `<a href="<%= edit_on_gitlab(@item, editor: :webide) %>">Web IDE</a>` + +If you don't specify `editor:`, the simple one is used by default. + +## Algolia search engine + +The docs site uses [Algolia docsearch](https://community.algolia.com/docsearch/) +for its search function. This is how it works: + +1. GitLab is a member of the [docsearch program](https://community.algolia.com/docsearch/#join-docsearch-program), + which is the free tier of [Algolia](https://www.algolia.com/). +1. Algolia hosts a [doscsearch config](https://github.com/algolia/docsearch-configs/blob/master/configs/gitlab.json) + for the GitLab docs site, and we've worked together to refine it. +1. That [config](https://community.algolia.com/docsearch/config-file.html) is + parsed by their [crawler](https://community.algolia.com/docsearch/crawler-overview.html) + every 24h and [stores](https://community.algolia.com/docsearch/inside-the-engine.html) + the [docsearch index](https://community.algolia.com/docsearch/how-do-we-build-an-index.html) + on [Algolia's servers](https://community.algolia.com/docsearch/faq.html#where-is-my-data-hosted%3F). +1. On the docs side, we use a [docsearch layout](https://gitlab.com/gitlab-org/gitlab-docs/blob/master/layouts/docsearch.html) which + is present on pretty much every page except <https://docs.gitlab.com/search/>, + which uses its [own layout](https://gitlab.com/gitlab-org/gitlab-docs/blob/master/layouts/instantsearch.html). In those layouts, + there's a javascript snippet which initiates docsearch by using an API key + and an index name (`gitlab`) that are needed for Algolia to show the results. + +NOTE: **For GitLab employees:** +The credentials to access the Algolia dashboard are stored in 1Password. If you +want to receive weekly reports of the search usage, search the Google doc with +title "Email, Slack, and GitLab Groups and Aliases", search for `docsearch`, +and add a comment with your email to be added to the alias that gets the weekly +reports. + +## Monthly release process (versions) + +The docs website supports versions and each month we add the latest one to the list. +For more information, read about the [monthly release process](release_process.md). + +## Review Apps for documentation merge requests + +If you are contributing to GitLab docs read how to [create a Review App with each +merge request](../index.md#previewing-the-changes-live). diff --git a/doc/development/documentation/site_architecture/release_process.md b/doc/development/documentation/site_architecture/release_process.md new file mode 100644 index 0000000000000000000000000000000000000000..6f723531f4c0115a91074eaa58614ae5e1eb2fef --- /dev/null +++ b/doc/development/documentation/site_architecture/release_process.md @@ -0,0 +1,241 @@ +# GitLab Docs monthly release process + +The [`dockerfiles` directory](https://gitlab.com/gitlab-org/gitlab-docs/blob/master/dockerfiles/) +contains all needed Dockerfiles to build and deploy the versioned website. It +is heavily inspired by Docker's +[Dockerfile](https://github.com/docker/docker.github.io/blob/06ed03db13895bfe867761b6fc2ad40acf6026dd/Dockerfile). + +The following Dockerfiles are used. + +| Dockerfile | Docker image | Description | +| ---------- | ------------ | ----------- | +| [`Dockerfile.bootstrap`](https://gitlab.com/gitlab-org/gitlab-docs/blob/master/dockerfiles/Dockerfile.bootstrap) | `gitlab-docs:bootstrap` | Contains all the dependencies that are needed to build the website. If the gems are updated and `Gemfile{,.lock}` changes, the image must be rebuilt. | +| [`Dockerfile.builder.onbuild`](https://gitlab.com/gitlab-org/gitlab-docs/blob/master/dockerfiles/Dockerfile.builder.onbuild) | `gitlab-docs:builder-onbuild` | Base image to build the docs website. It uses `ONBUILD` to perform all steps and depends on `gitlab-docs:bootstrap`. | +| [`Dockerfile.nginx.onbuild`](https://gitlab.com/gitlab-org/gitlab-docs/blob/master/dockerfiles/Dockerfile.nginx.onbuild) | `gitlab-docs:nginx-onbuild` | Base image to use for building documentation archives. It uses `ONBUILD` to perform all required steps to copy the archive, and relies upon its parent `Dockerfile.builder.onbuild` that is invoked when building single documentation achives (see the `Dockerfile` of each branch. | +| [`Dockerfile.archives`](https://gitlab.com/gitlab-org/gitlab-docs/blob/master/dockerfiles/Dockerfile.archives) | `gitlab-docs:archives` | Contains all the versions of the website in one archive. It copies all generated HTML files from every version in one location. | + +## How to build the images + +Although build images are built automatically via GitLab CI/CD, you can build +and tag all tooling images locally: + +1. Make sure you have [Docker installed](https://docs.docker.com/install/). +1. Make sure you're on the `dockerfiles/` directory of the `gitlab-docs` repo. +1. Build the images: + + ```sh + docker build -t registry.gitlab.com/gitlab-org/gitlab-docs:bootstrap -f Dockerfile.bootstrap ../ + docker build -t registry.gitlab.com/gitlab-org/gitlab-docs:builder-onbuild -f Dockerfile.builder.onbuild ../ + docker build -t registry.gitlab.com/gitlab-org/gitlab-docs:nginx-onbuild -f Dockerfile.nginx.onbuild ../ + ``` + +For each image, there's a manual job under the `images` stage in +[`.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitlab-docs/blob/master/.gitlab-ci.yml) which can be invoked at will. + +## Monthly release process + +When a new GitLab version is released on the 22nd, we need to create the respective +single Docker image, and update some files so that the dropdown works correctly. + +### 1. Add the chart version + +Since the charts use a different version number than all the other GitLab +products, we need to add a +[version mapping](https://docs.gitlab.com/charts/installation/version_mappings.html): + +1. Check that there is a [stable branch created](https://gitlab.com/gitlab-org/charts/gitlab/-/branches) + for the new chart version. If you're unsure or can't find it, drop a line in + the `#g_delivery` channel. +1. Make sure you're on the root path of the `gitlab-docs` repo. +1. Open `content/_data/chart_versions.yaml` and add the new stable branch version using the + version mapping. Note that only the `major.minor` version is needed. +1. Create a new merge request and merge it. + +TIP: **Tip:** +It can be handy to create the future mappings since they are pretty much known. +In that case, when a new GitLab version is released, you don't have to repeat +this first step. + +### 2. Create an image for a single version + +The single docs version must be created before the release merge request, but +this needs to happen when the stable branches for all products have been created. + +1. Make sure you're on the root path of the `gitlab-docs` repo. +1. Run the raketask to create the single version: + + ```sh + ./bin/rake "release:single[12.0]" + ``` + + A new `Dockerfile.12.0` should have been created and committed to a new branch. + +1. Push the newly created branch, but **don't create a merge request**. + Once you push, the `image:docker-singe` job will create a new Docker image + tagged with the branch name you created in the first step. In the end, the + image will be uploaded in the [Container Registry](https://gitlab.com/gitlab-org/gitlab-docs/container_registry) + and it will be listed under the + [`registry` environment folder](https://gitlab.com/gitlab-org/gitlab-docs/environments/folders/registry). + +Optionally, you can test locally by building the image and running it: + +```sh +docker build -t docs:12.0 -f Dockerfile.12.0 . +docker run -it --rm -p 4000:4000 docs:12.0 +``` + +Visit `http://localhost:4000/12.0/` to see if everything works correctly. + +### 3. Create the release merge request + +Now it's time to create the monthly release merge request that adds the new +version and rotates the old one: + +1. Make sure you're on the root path of the `gitlab-docs` repo. +1. Create a branch `release-X-Y`: + + ```sh + git checkout -b release-12-0 + ``` + +1. **Rotate the online and offline versions:** + + At any given time, there are 4 browsable online versions: one pulled from + the upstream master branches (docs for GitLab.com) and the three latest + stable versions. + + Edit `content/_data/versions.yaml` and rotate the versions to reflect the + new changes: + + - `online`: The 3 latest stable versions. + - `offline`: All the previous versions offered as an offline archive. + +1. **Add the new offline version in the 404 page redirect script:** + + Since we're deprecating the oldest version each month, we need to redirect + those URLs in order not to create [404 entries](https://gitlab.com/gitlab-org/gitlab-docs/issues/221). + There's a temporary hack for now: + + 1. Edit `content/404.html`, making sure all offline versions under + `content/_data/versions.yaml` are in the Javascript snippet at the end of + the document. + +1. **Update the `:latest` and `:archives` Docker images:** + + The following two Dockerfiles need to be updated: + + 1. `dockerfiles/Dockerfile.archives` - Add the latest version at the top of + the list. + 1. `Dockerfile.master` - Rotate the versions (oldest gets removed and latest + is added at the top of the list). + +1. In the end, there should be four files in total that have changed. + Commit and push to create the merge request using the "Release" template: + + ```sh + git add content/ Dockerfile.master dockerfiles/Dockerfile.archives + git commit -m "Release 12.0" + git push origin release-12-0 + ``` + +### 4. Update the dropdown for all online versions + +The versions dropdown is in a way "hardcoded". When the site is built, it looks +at the contents of `content/_data/versions.yaml` and based on that, the dropdown +is populated. So, older branches will have different content, which means the +dropdown will be one or more releases behind. Remember that the new changes of +the dropdown are included in the unmerged `release-X-Y` branch. + +The content of `content/_data/versions.yaml` needs to change for all online +versions: + +1. Before creating the merge request, [disable the scheduled pipeline](https://gitlab.com/gitlab-org/gitlab-docs/pipeline_schedules/228/edit) + by unchecking the "Active" option. Since all steps must run in sequence, we need + to do this to avoid race conditions in the event some previous versions are + updated before the release merge request is merged. +1. Run the raketask that will create all the respective merge requests needed to + update the dropdowns and will be set to automatically be merged when their + pipelines succeed. The `release-X-Y` branch needs to be present locally, + otherwise the raketask will fail: + + ```sh + ./bin/rake release:dropdowns + ``` + +Once all are merged, proceed to the following and final step. + +TIP: **Tip:** +In case a pipeline fails, see [troubleshooting](#troubleshooting). + +### 5. Merge the release merge request + +The dropdown merge requests should have now been merged into their respective +version (stable branch), which will trigger another pipeline. At this point, +you need to only babysit the pipelines and make sure they don't fail: + +1. Check the [pipelines page](https://gitlab.com/gitlab-org/gitlab-docs/pipelines) + and make sure all stable branches have green pipelines. +1. After all the pipelines of the online versions succeed, merge the release merge request. +1. Finally, re-activate the [scheduled pipeline](https://gitlab.com/gitlab-org/gitlab-docs/pipeline_schedules/228/edit), + save it, and hit the play button to get it started. + +Once the scheduled pipeline succeeds, the docs site will be deployed with all +new versions online. + +## Update an old Docker image with new upstream docs content + +If there are any changes to any of the stable branches of the products that are +not included in the single Docker image, just +[rerun the pipeline](https://gitlab.com/gitlab-org/gitlab-docs/pipelines/new) +for the version in question. + +## Porting new website changes to old versions + +CAUTION: **Warning:** +Porting changes to older branches can have unintended effects as we're constantly +changing the backend of the website. Use only when you know what you're doing +and make sure to test locally. + +The website will keep changing and being improved. In order to consolidate +those changes to the stable branches, we'd need to pick certain changes +from time to time. + +If this is not possible or there are many changes, merge master into them: + +```sh +git branch 12.0 +git fetch origin master +git merge origin/master +``` + +## Troubleshooting + +Releasing a new version is a long process that involves many moving parts. + +### `test_internal_links_and_anchors` failing on dropdown merge requests + +When [updating the dropdown for the stable versions](#4-update-the-dropdown-for-all-online-versions), +there may be cases where some links might fail. The process of how the +dropdown MRs are created have a caveat, and that is that the tests run by +pulling the master branches of all products, instead of the respective stable +ones. + +In a real world scenario, the [Update 12.2 dropdown to match that of 12.4](https://gitlab.com/gitlab-org/gitlab-docs/merge_requests/604) +merge request failed because of the [`test_internal_links_and_anchors` test](https://gitlab.com/gitlab-org/gitlab-docs/-/jobs/328042431). + +This happened because there has been a rename of a product (`gitlab-monitor` to `gitlab-exporter`) +and the old name was still referenced in the 12.2 docs. If the respective stable +branches for 12.2 were used, this wouldn't have failed, but as we can see from +the [`compile_dev` job](https://gitlab.com/gitlab-org/gitlab-docs/-/jobs/328042427), +the `master` branches were pulled. + +To fix this, you need to [re-run the pipeline](https://gitlab.com/gitlab-org/gitlab-docs/pipelines/new) +for the `update-12-2-for-release-12-4` branch, by including the following environment variables: + +- `BRANCH_CE` set to `12-2-stable` +- `BRANCH_EE` set to `12-2-stable-ee` +- `BRANCH_OMNIBUS` set to `12-2-stable` +- `BRANCH_RUNNER` set to `12-2-stable` +- `BRANCH_CHARTS` set to `2-2-stable` + +This should make the MR pass. diff --git a/doc/development/documentation/styleguide.md b/doc/development/documentation/styleguide.md index b6ec7a858fafe6deef605bc1d9021794f7b38b12..e6d666473c367b6a6c3c65b616cafbbab832cb09 100644 --- a/doc/development/documentation/styleguide.md +++ b/doc/development/documentation/styleguide.md @@ -604,9 +604,6 @@ Inside the document: - Always use a proper description for what the image is about. That way, when a browser fails to show the image, this text will be used as an alternative description. -- If there are consecutive images with little text between them, always add - three dashes (`---`) between the image and the text to create a horizontal - line for better clarity. - If a heading is placed right after an image, always add three dashes (`---`) between the image and the heading. @@ -1182,12 +1179,12 @@ Rendered example: - Prefer to use examples using the personal access token and don't pass data of username and password. -| Methods | Description | -|:-------------------------------------------|:------------------------------------------------------| -| `-H "PRIVATE-TOKEN: <your_access_token>"` | Use this method as is, whenever authentication needed | -| `-X POST` | Use this method when creating new objects | -| `-X PUT` | Use this method when updating existing objects | -| `-X DELETE` | Use this method when removing existing objects | +| Methods | Description | +|:------------------------------------------- |:------------------------------------------------------| +| `--header "PRIVATE-TOKEN: <your_access_token>"` | Use this method as is, whenever authentication needed | +| `--request POST` | Use this method when creating new objects | +| `--request PUT` | Use this method when updating existing objects | +| `--request DELETE` | Use this method when removing existing objects | ### cURL Examples @@ -1209,9 +1206,9 @@ Create a new project under the authenticated user's namespace: curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects?name=foo" ``` -#### Post data using cURL's --data +#### Post data using cURL's `--data` -Instead of using `-X POST` and appending the parameters to the URI, you can use +Instead of using `--request POST` and appending the parameters to the URI, you can use cURL's `--data` option. The example below will create a new project `foo` under the authenticated user's namespace. diff --git a/doc/development/documentation/workflow.md b/doc/development/documentation/workflow.md index 24399391b1a236fc4f550d2ae888431239e79861..c373b976453a2a1a0c54b30c0e7d8b13d0039309 100644 --- a/doc/development/documentation/workflow.md +++ b/doc/development/documentation/workflow.md @@ -208,7 +208,7 @@ code reviewer have ensured: Documentation [is required](../contributing/merge_request_workflow.html#definition-of-done) for a milestone when: -- A new or enhanced feature is shipped that impacts the user of administrator experience. +- A new or enhanced feature is shipped that impacts the user or administrator experience. - There are changes to the UI or API. - A process, workflow, or previously documented feature is changed. - A feature is deprecated or removed. diff --git a/doc/development/ee_features.md b/doc/development/ee_features.md index cc9df4794929369dd8fe6b3e09f31ee438500c64..d716325f3322fb8838571864afcf91e4d8e0635d 100644 --- a/doc/development/ee_features.md +++ b/doc/development/ee_features.md @@ -245,47 +245,29 @@ end #### Use self-descriptive wrapper methods -When it's not possible/logical to modify the implementation of a -method. Wrap it in a self-descriptive method and use that method. +When it's not possible/logical to modify the implementation of a method, then +wrap it in a self-descriptive method and use that method. -For example, in CE only an `admin` is allowed to access all private -projects/groups, but in EE also an `auditor` has full private -access. It would be incorrect to override the implementation of -`User#admin?`, so instead add a method `full_private_access?` to -`app/models/users.rb`. The implementation in CE will be: +For example, in GitLab-FOSS, the only user created by the system is `User.ghost` +but in EE there are several types of bot-users that aren't really users. It would +be incorrect to override the implementation of `User#ghost?`, so instead we add +a method `#internal?` to `app/models/user.rb`. The implementation will be: ```ruby -def full_private_access? - admin? +def internal? + ghost? end ``` In EE, the implementation `ee/app/models/ee/users.rb` would be: ```ruby -override :full_private_access? -def full_private_access? - super || auditor? +override :internal? +def internal? + super || bot? end ``` -In `lib/gitlab/visibility_level.rb` this method is used to return the -allowed visibility levels: - -```ruby -def levels_for_user(user = nil) - if user.full_private_access? - [PRIVATE, INTERNAL, PUBLIC] - elsif # ... -end -``` - -See [CE MR][ce-mr-full-private] and [EE MR][ee-mr-full-private] for -full implementation details. - -[ce-mr-full-private]: https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/12373 -[ee-mr-full-private]: https://gitlab.com/gitlab-org/gitlab/merge_requests/2199 - ### Code in `config/routes` When we add `draw :admin` in `config/routes.rb`, the application will try to diff --git a/doc/development/event_tracking/index.md b/doc/development/event_tracking/index.md index efc61d13cb075108f4e88164d324c9f1b3f47900..ac19053320d6686fe060f641b530cb6783c087a4 100644 --- a/doc/development/event_tracking/index.md +++ b/doc/development/event_tracking/index.md @@ -68,7 +68,3 @@ Once enabled, tracking events can be inspected locally by either: - Looking at the network panel of the browser's development tools - Using the [Snowplow Chrome Extension](https://chrome.google.com/webstore/detail/snowplow-inspector/maplkdomeamdlngconidoefjpogkmljm). - -## Additional libraries - -Session tracking is handled by [Pendo](https://www.pendo.io/), which is a purely client library and is a relatively minor development concern but is worth including in this documentation. diff --git a/doc/development/fe_guide/development_process.md b/doc/development/fe_guide/development_process.md index 3724bf607576e196f638d0e4f7696ec4a0c521c0..5b02098f020381c071b18dd1c5de69e0c9808bde 100644 --- a/doc/development/fe_guide/development_process.md +++ b/doc/development/fe_guide/development_process.md @@ -73,7 +73,7 @@ With the purpose of being [respectful of others' time](https://about.gitlab.com/ - Before assigning to a maintainer, assign to a reviewer. - If you assigned a merge request, or pinged someone directly, keep in mind that we work in different timezones and asynchronously, so be patient. Unless the merge request is urgent (like fixing a broken master), please don't DM or reassign the merge request before waiting for a 24-hour window. - If you have a question regarding your merge request/issue, make it on the merge request/issue. When we DM each other, we no longer have a SSOT and [no one else is able to contribute](https://about.gitlab.com/handbook/values/#public-by-default). -- When you have a big WIP merge request with many changes, you're adivsed to get the review started before adding/removing significant code. Make sure it is assigned well before the release cut-off, as the reviewer(s)/maintainer(s) would always prioritize reviewing finished MRs before WIP ones. +- When you have a big WIP merge request with many changes, you're advised to get the review started before adding/removing significant code. Make sure it is assigned well before the release cut-off, as the reviewer(s)/maintainer(s) would always prioritize reviewing finished MRs before WIP ones. - Make sure to remove the WIP title before the last round of review. ### Share your work early diff --git a/doc/development/fe_guide/graphql.md b/doc/development/fe_guide/graphql.md index fe4f6d7bec8c1c743dc7ed4a24713fc10cb21a6d..894a613ec2de3f959705e4e6ef2bfc85c847c6cf 100644 --- a/doc/development/fe_guide/graphql.md +++ b/doc/development/fe_guide/graphql.md @@ -19,6 +19,14 @@ To save duplicated clients getting created in different apps, we have a [default client][default-client] that should be used. This setups the Apollo client with the correct URL and also sets the CSRF headers. +Default client accepts two parameters: `resolvers` and `config`. + +- `resolvers` parameter is created to accept an object of resolvers for [local state management](#local-state-with-apollo) queries and mutations +- `config` parameter takes an object of configuration settings: + - `cacheConfig` field accepts an optional object of settings to [customize Apollo cache](https://github.com/apollographql/apollo-client/tree/master/packages/apollo-cache-inmemory#configuration) + - `baseUrl` allows us to pass a URL for GraphQL endpoint different from our main endpoint (i.e.`${gon.relative_url_root}/api/graphql`) + - `assumeImmutableResults` (set to `false` by default) - this setting, when set to `true`, will assume that every single operation on updating Apollo Cache is immutable. It also sets `freezeResults` to `true`, so any attempt on mutating Apollo Cache will throw a console warning in development environment. Please ensure you're following the immutability pattern on cache update operations before setting this option to `true`. + ## GraphQL Queries To save query compilation at runtime, webpack can directly import `.graphql` diff --git a/doc/development/fe_guide/style_guide_js.md b/doc/development/fe_guide/style_guide_js.md index 306b19c6e5dd11a4eaaecf82c4a979c0996ee79a..43cd8180b6e75884775bdb5a53eb5d0dd233f868 100644 --- a/doc/development/fe_guide/style_guide_js.md +++ b/doc/development/fe_guide/style_guide_js.md @@ -581,6 +581,18 @@ Please check this [rules][eslint-plugin-vue-rules] for more documentation. <component /> ``` +#### Component usage within templates + +1. Prefer a component's kebab-cased name over other styles when using it in a template + + ```javascript + // bad + <MyComponent /> + + // good + <my-component /> + ``` + #### Ordering 1. Tag order in `.vue` file diff --git a/doc/development/fe_guide/style_guide_scss.md b/doc/development/fe_guide/style_guide_scss.md index 07c87920dab5446d68d51dc2213b064b6bbd70e2..54d41b42c77d450611dfbe20a29f0f33aab899cb 100644 --- a/doc/development/fe_guide/style_guide_scss.md +++ b/doc/development/fe_guide/style_guide_scss.md @@ -27,7 +27,7 @@ New utility classes should be added to [`utilities.scss`](https://gitlab.com/git | Font size | `.text-{size}` | `.text-2` | - `{variant}` is one of 'primary', 'secondary', 'success', 'warning', 'error' -- `{shade}` is on of the shades listed on [colors](https://design.gitlab.com/product-foundations/colors/) +- `{shade}` is one of the shades listed on [colors](https://design.gitlab.com/product-foundations/colors/) - `{size}` is a number from 1-6 from our [Type scale](https://design.gitlab.com/product-foundations/typography) #### When should I create component classes? diff --git a/doc/development/feature_flags/controls.md b/doc/development/feature_flags/controls.md index f22c3bb1e3794deec1b5a909c86c8798b5537a5d..cbbbe1cd5ea02fb87b348961822abc7826f5554a 100644 --- a/doc/development/feature_flags/controls.md +++ b/doc/development/feature_flags/controls.md @@ -29,6 +29,14 @@ Monitor stage, Health group. For all production environment Chatops commands, use the `#production` channel. +Regardless of the channel in which the Chatops command is ran, any feature flag change that affects GitLab.com will automatically be logged in an issue. + +The issue is created in the [gl-infra/feature-flag-log](https://gitlab.com/gitlab-com/gl-infra/feature-flag-log/issues?scope=all&utf8=%E2%9C%93&state=closed) project, and it will at minimum log the Slack handle of person enabling a feature flag, the time, and the name of the flag being changed. + +The issue is then also posted to GitLab Inc. internal [Grafana dashboard](https://dashboards.gitlab.net/) as an annotation marker to make the change even more visible. + +Changes to the issue format can be submitted in the [Chatops project](https://gitlab.com/gitlab-com/chatops). + ## Rolling out changes When the changes are deployed to the environments it is time to start diff --git a/doc/development/feature_flags/development.md b/doc/development/feature_flags/development.md index 929c9b1c71c0f3d3448626ab75ae6ad9a7ed79a3..c410c7eae41a3e10b9a238b09b2543c9acdca6da 100644 --- a/doc/development/feature_flags/development.md +++ b/doc/development/feature_flags/development.md @@ -25,7 +25,8 @@ end Features that are developed and are intended to be merged behind a feature flag should not include a changelog entry. The entry should be added in the merge -request removing the feature flags. +request removing the feature flag. If the feature contains any DB migration it +should include a changelog entry for DB changes. In the rare case that you need the feature flag to be on automatically, use `default_enabled: true` when checking: @@ -51,20 +52,16 @@ isn't gated by a License or Plan. [namespace-fa]: https://gitlab.com/gitlab-org/gitlab/blob/4cc1c62918aa4c31750cb21dfb1a6c3492d71080/ee/app/models/ee/namespace.rb#L71-85 [license-fa]: https://gitlab.com/gitlab-org/gitlab/blob/4cc1c62918aa4c31750cb21dfb1a6c3492d71080/ee/app/models/license.rb#L293-300 -An important side-effect of the implicit feature flags mentioned above is that +**An important side-effect of the implicit feature flags mentioned above is that unless the feature is explicitly disabled or limited to a percentage of users, -the feature flag check will default to `true`. +the feature flag check will default to `true`.** As an example, if you were to ship the backend half of a feature behind a flag, you'd want to explicitly disable that flag until the frontend half is also ready -to be shipped. [You can do this via Chatops](controls.md): - -``` -/chatops run feature set some_feature 0 -``` - -Note that you can do this at any time, even before the merge request using the -flag has been merged! +to be shipped. To make sure this feature is disabled for both GitLab.com and +self-managed instances you'd need to explicitly call `Feature.enabled?` method +before the `feature_available` method. This ensures the feature_flag is defaulting +to `false`. ## Feature groups diff --git a/doc/development/geo.md b/doc/development/geo.md index 446d85fceed9f872d921d6cfc6f4c8b7fee11a25..5010e44e826501d319f9676424d47d87ca49a008 100644 --- a/doc/development/geo.md +++ b/doc/development/geo.md @@ -101,15 +101,16 @@ it's successful, we replace the main repo with the newly cloned one. ### Uploads replication File uploads are also being replicated to the **secondary** node. To -track the state of syncing, the `Geo::FileRegistry` model is used. +track the state of syncing, the `Geo::UploadRegistry` model is used. -#### File Registry +#### Upload Registry Similar to the [Project Registry](#project-registry), there is a -`Geo::FileRegistry` model that tracks the synced uploads. +`Geo::UploadRegistry` model that tracks the synced uploads. -CI Job Artifacts are synced in a similar way as uploads or LFS -objects, but they are tracked by `Geo::JobArtifactRegistry` model. +CI Job Artifacts and LFS objects are synced in a similar way as uploads, +but they are tracked by `Geo::JobArtifactRegistry`, and `Geo::LfsObjectRegistry` +models respectively. #### File Download Dispatch worker @@ -490,6 +491,24 @@ When some write actions are not allowed because the node is a The database itself will already be read-only in a replicated setup, so we don't need to take any extra step for that. +## Steps needed to replicate a new data type + +As GitLab evolves, we constantly need to add new resources to the Geo replication system. +The implementation depends on resource specifics, but there are several things +that need to be taken care of: + +- Event generation on the primary site. Whenever a new resource is changed/updated, we need to + create a task for the Log Cursor. +- Event handling. The Log Cursor needs to have a handler for every event type generated by the primary site. +- Dispatch worker (cron job). Make sure the backfill condition works well. +- Sync worker. +- Registry with all possible states. +- Verification. +- Cleaner. When sync settings are changed for the secondary site, some resources need to be cleaned up. +- Geo Node Status. We need to provide API endpoints as well as some presentation in the GitLab Admin Area. +- Health Check. If we can perform some pre-cheÑks and make node unhealthy if something is wrong, we should do that. + The `rake gitlab:geo:check` command has to be updated too. + ## History of communication channel The communication channel has changed since first iteration, you can diff --git a/doc/development/git_object_deduplication.md b/doc/development/git_object_deduplication.md index e8af6346524f994fd92f6b19c761ad540adffc70..6d9eb90d48259996caceae3f875af6c3903c840b 100644 --- a/doc/development/git_object_deduplication.md +++ b/doc/development/git_object_deduplication.md @@ -1,6 +1,6 @@ # How Git object deduplication works in GitLab -When a GitLab user [forks a project](../workflow/forking_workflow.md), +When a GitLab user [forks a project](../user/project/repository/forking_workflow.md), GitLab creates a new Project with an associated Git repository that is a copy of the original project at the time of the fork. If a large project gets forked often, this can lead to a quick increase in Git repository diff --git a/doc/development/gitaly.md b/doc/development/gitaly.md index 64f283f69d967e725679349cf85ed6adca32877c..7d3c2b8fdf8c1fd6ebe56fdb043cc80daac14693 100644 --- a/doc/development/gitaly.md +++ b/doc/development/gitaly.md @@ -166,12 +166,11 @@ end Normally, GitLab CE/EE tests use a local clone of Gitaly in `tmp/tests/gitaly` pinned at the version specified in -`GITALY_SERVER_VERSION`. The `GITALY_SERVER_VERSION` file supports -`=my-branch` syntax to use a custom branch in <https://gitlab.com/gitlab-org/gitaly>. If +`GITALY_SERVER_VERSION`. The `GITALY_SERVER_VERSION` file supports also +branches and SHA to use a custom commit in <https://gitlab.com/gitlab-org/gitaly>. If you want to run tests locally against a modified version of Gitaly you can replace `tmp/tests/gitaly` with a symlink. This is much faster -because the `=my-branch` syntax forces a Gitaly re-install each time -you run `rspec`. +because if will avoid a Gitaly re-install each time you run `rspec`. ```shell rm -rf tmp/tests/gitaly diff --git a/doc/development/gotchas.md b/doc/development/gotchas.md index c7eed880554dce86f7a3710ea1df8eb21fbf4a01..da27ae9110bfe1bdf49662e030cf83053aac0653 100644 --- a/doc/development/gotchas.md +++ b/doc/development/gotchas.md @@ -168,3 +168,73 @@ in an initializer._ ### Further reading - Stack Overflow: [Why you should not write inline JavaScript](https://softwareengineering.stackexchange.com/questions/86589/why-should-i-avoid-inline-scripting) + +## Auto loading + +Rails auto-loading on `development` differs from the load policy in the `production` environment. +In development mode, `config.eager_load` is set to `false`, which means classes +are loaded as needed. With the classic Rails autoloader, it is known that this can lead to +[Rails resolving the wrong class](https://guides.rubyonrails.org/v5.2/autoloading_and_reloading_constants.html#when-constants-aren-t-missed-relative-references) +if the class name is ambiguous. This can be fixed by specifying the complete namespace to the class. + +### Error prone example + +```ruby +# app/controllers/application_controller.rb +class ApplicationController < ActionController::Base + ... +end + +# app/controllers/projects/application_controller.rb +class Projects::ApplicationController < ApplicationController + ... + private + + def project + ... + end +end + +# app/controllers/projects/submodule/some_controller.rb +module Projects + module Submodule + class SomeController < ApplicationController + def index + @some_id = project.id + end + end + end +end +``` + +In this case, if for any reason the top level `ApplicationController` +is loaded but `Projects::ApplicationController` is not, `ApplicationController` +would be resolved to `::ApplicationController` and then the `project` method will +be undefined and we will get an error. + +#### Solution + +```ruby +# app/controllers/projects/submodule/some_controller.rb +module Projects + module Submodule + class SomeController < Projects::ApplicationController + def index + @some_id = project.id + end + end + end +end +``` + +By specifying `Projects::`, we tell Rails exactly what class we are referring +to and we would avoid the issue. + +NOTE: **Note:** +This problem will disappear as soon as we upgrade to Rails 6 and use the Zeitwerk autoloader. + +### Further reading + +- Rails Guides: [Autoloading and Reloading Constants (Classic Mode)](https://guides.rubyonrails.org/autoloading_and_reloading_constants_classic_mode.html) +- Ruby Constant lookup: [Everything you ever wanted to know about constant lookup in Ruby](http://cirw.in/blog/constant-lookup) +- Rails 6 and Zeitwerk autoloader: [Understanding Zeitwerk in Rails 6](https://medium.com/cedarcode/understanding-zeitwerk-in-rails-6-f168a9f09a1f) diff --git a/doc/development/i18n/externalization.md b/doc/development/i18n/externalization.md index 2f067ede70f8229b32904ada49978e39488b203f..c8960ac0f61282986e65eccd858c68f82355a356 100644 --- a/doc/development/i18n/externalization.md +++ b/doc/development/i18n/externalization.md @@ -326,9 +326,8 @@ file in. Once the changes are on master, they will be picked up by [Crowdin](https://translate.gitlab.com) and be presented for translation. -We don't need to check in any changes to the -`locale/[language]/gitlab.po` files. Those will be updated in a [when -translations from Crowdin are merged](merging_translations.md). +We don't need to check in any changes to the `locale/[language]/gitlab.po` files. +They are updated automatically when [translations from Crowdin are merged](merging_translations.md). If there are merge conflicts in the `gitlab.pot` file, you can delete the file and regenerate it using the same command. diff --git a/doc/development/i18n/merging_translations.md b/doc/development/i18n/merging_translations.md index f5953cc5f6cf4485e34ce05261059107af3e14ec..c773b187cc170dbaf4899f7dac3068130177b8c2 100644 --- a/doc/development/i18n/merging_translations.md +++ b/doc/development/i18n/merging_translations.md @@ -1,13 +1,12 @@ # Merging translations from Crowdin -Crowdin automatically syncs the `gitlab.pot` file presenting newly -added translations to the community of translators. +Crowdin automatically syncs the `gitlab.pot` file with the Crowdin service, presenting +newly added externalized strings to the community of translators. -At the same time, it creates a merge request to merge all newly added -& approved translations. Find the [merge request created by -`gitlab-crowdin-bot`](https://gitlab.com/gitlab-org/gitlab/merge_requests?scope=all&utf8=%E2%9C%93&state=opened&author_username=gitlab-crowdin-bot) -to see new and merged merge requests. They are created in EE and need -to be ported to CE manually. +[GitLab Crowdin Bot](https://gitlab.com/gitlab-crowdin-bot) also creates merge requests +to take newly approved translation submissions and merge them into the `locale/<language>/gitlab.po` +files. Check the [merge requests created by `gitlab-crowdin-bot`](https://gitlab.com/gitlab-org/gitlab/merge_requests?scope=all&utf8=%E2%9C%93&state=opened&author_username=gitlab-crowdin-bot) +to see new and merged merge requests. ## Validation @@ -21,7 +20,7 @@ doesn't do. Create a new pipeline at `https://gitlab.com/gitlab-org/gitlab/pipel If there are validation errors, the easiest solution is to disapprove the offending string in Crowdin, leaving a comment with what is required to fix the offense. There is an -[issue](https://gitlab.com/gitlab-org/gitlab-foss/issues/49208) +[issue](https://gitlab.com/gitlab-org/gitlab/issues/23256) suggesting to automate this process. Disapproving will exclude the invalid translation, the merge request will be updated within a few minutes. @@ -32,19 +31,16 @@ clicking `Pause sync` on the [Crowdin integration settings page](https://translate.gitlab.com/project/gitlab-ee/settings#integration). When all failures are resolved, the translations need to be double -checked once more as discussed in [confidential issue](../../user/project/issues/confidential_issues.md) `https://gitlab.com/gitlab-org/gitlab-foss/issues/37850`. +checked once more as discussed in [confidential issue](../../user/project/issues/confidential_issues.md) `https://gitlab.com/gitlab-org/gitlab/issues/19485`. ## Merging translations When all translations are found good and pipelines pass the -translations can be merged into the master branch. After that is done, -create a new merge request cherry-picking the translations from EE to -CE. When merging the translations, make sure to check the `Remove -source branch` checkbox, so Crowdin recreates the `master-i18n` from -master after the new translation was merged. - -We are discussing automating this entire process -[here](https://gitlab.com/gitlab-org/gitlab-foss/issues/39309). +translations can be merged into the master branch. When merging the translations, +make sure to check the **Remove source branch** checkbox, so Crowdin recreates the +`master-i18n` from master after the new translation was merged. + +We are discussing [automating this entire process](https://gitlab.com/gitlab-org/gitlab/issues/19896). ## Recreate the merge request diff --git a/doc/development/i18n/translation.md b/doc/development/i18n/translation.md index c1a6fd8983cc831b1c49d7b802b944ceaa672393..50a417e9996a48136b65aaba46dd4b6479caf497 100644 --- a/doc/development/i18n/translation.md +++ b/doc/development/i18n/translation.md @@ -83,7 +83,7 @@ Therefore "create a new user" would translate into "Benutzer(in) anlegen". ### Updating the glossary To propose additions to the glossary please -[open an issue](https://gitlab.com/gitlab-org/gitlab-foss/issues). +[open an issue](https://gitlab.com/gitlab-org/gitlab/issues?scope=all&utf8=✓&state=all&label_name[]=Category%3AInternationalization). ## French Translation Guidelines diff --git a/doc/development/kubernetes.md b/doc/development/kubernetes.md index 82aa02ac75d0eec64f263bfe9c68b223c1ddfdba..a0dd97f2a1ca4ea4c1813ca0ff6ad7c86c73188a 100644 --- a/doc/development/kubernetes.md +++ b/doc/development/kubernetes.md @@ -74,6 +74,50 @@ We have some Webmock stubs in [`KubernetesHelpers`](https://gitlab.com/gitlab-org/gitlab/blob/master/spec/support/helpers/kubernetes_helpers.rb) which can help with mocking out calls to Kubernetes API in your tests. +### Amazon EKS integration + +This section outlines the process for allowing a GitLab instance to create EKS clusters. + +The following prerequisites are required: + +A `Customer` AWS account. This is the account in which the +EKS cluster will be created. The following resources must be present: + +- A provisioning role that has permissions to create the cluster + and associated resources. It must list the `GitLab` AWS account + as a trusted entity. +- A VPC, management role, security group, and subnets for use by the cluster. + +A `GitLab` AWS account. This is the account which performs +the provisioning actions. The following resources must be present: + +- A service account with permissions to assume the provisioning + role in the `Customer` account above. +- Credentials for this service account configured in GitLab via + the `kubernetes` section of `gitlab.yml`. + +The process for creating a cluster is as follows: + +1. Using the `:provision_role_external_id`, GitLab assumes the role provided + by `:provision_role_arn` and stores a set of temporary credentials on the + provider record. By default these credentials are valid for one hour. +1. A CloudFormation stack is created, based on the + [`AWS CloudFormation EKS template`](https://gitlab.com/gitlab-org/gitlab/blob/master/vendor/aws/cloudformation/eks_cluster.yaml). + This triggers creation of all resources required for an EKS cluster. +1. GitLab polls the status of the stack until all resources are ready, + which takes somewhere between 10 and 15 minutes in most cases. +1. When the stack is ready, GitLab stores the cluster details and generates + another set of temporary credentials, this time to allow connecting to + the cluster via Kubeclient. These credentials are valid for one minute. +1. GitLab configures the worker nodes so that they are able to authenticate + to the cluster, and creates a service account for itself for future operations. +1. Credentials that are no longer required are removed. This deletes the following + attributes: + + - `access_key_id` + - `secret_access_key` + - `session_token` + ## Security ### SSRF @@ -123,3 +167,10 @@ they are written: ```bash kubectl logs <pod_name> --follow -n gitlab-managed-apps ``` + +## GitLab Managed Apps + +GitLab provides [GitLab Managed Apps](../user/clusters/applications.html), a one-click install for various applications which can be added directly to your configured cluster. + +**<i class="fa fa-youtube-play youtube" aria-hidden="true"></i> +For an overview of how to add a new GitLab-mananged app, see [How to add GitLab-managed-apps to Kubernetes integration](https://youtu.be/mKm-jkranEk).** diff --git a/doc/development/lfs.md b/doc/development/lfs.md index cb4c2d8967b8ce89a3d00445f11d92e7e7d4189b..9139bbaca0e17b52c96c4ffb66dbf2f7160ae244 100644 --- a/doc/development/lfs.md +++ b/doc/development/lfs.md @@ -5,7 +5,7 @@ In April 2019, Francisco Javier López hosted a [Deep Dive] on GitLab's [Git LFS] implementation to share his domain specific knowledge with anyone who may work in this part of the code base in the future. You can find the [recording on YouTube], and the slides on [Google Slides] and in [PDF]. Everything covered in this deep dive was accurate as of GitLab 11.10, and while specific details may have changed since then, it should still serve as a good introduction. [Deep Dive]: https://gitlab.com/gitlab-org/create-stage/issues/1 -[Git LFS]: ../workflow/lfs/manage_large_binaries_with_git_lfs.html +[Git LFS]: ../administration/lfs/manage_large_binaries_with_git_lfs.md [recording on YouTube]: https://www.youtube.com/watch?v=Yyxwcksr0Qc [Google Slides]: https://docs.google.com/presentation/d/1E-aw6-z0rYd0346YhIWE7E9A65zISL9iIMAOq2zaw9E/edit [PDF]: https://gitlab.com/gitlab-org/create-stage/uploads/07a89257a140db067bdfb484aecd35e1/Git_LFS_Deep_Dive__Create_.pdf diff --git a/doc/development/merge_request_performance_guidelines.md b/doc/development/merge_request_performance_guidelines.md index 4456e5e6d18567bfbd411a79db7af39651fdb490..2e80e813a4bdc59f400b78b2e4a64f33128de2a0 100644 --- a/doc/development/merge_request_performance_guidelines.md +++ b/doc/development/merge_request_performance_guidelines.md @@ -1,7 +1,9 @@ # Merge Request Performance Guidelines +Each new introduced merge request **should be performant by default**. + To ensure a merge request does not negatively impact performance of GitLab -_every_ merge request **must** adhere to the guidelines outlined in this +_every_ merge request **should** adhere to the guidelines outlined in this document. There are no exceptions to this rule unless specifically discussed with and agreed upon by backend maintainers and performance specialists. @@ -12,6 +14,19 @@ the following guides: - [Performance Guidelines](performance.md) - [What requires downtime?](what_requires_downtime.md) +## Definition + +The term `SHOULD` per the [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt) means: + +> This word, or the adjective "RECOMMENDED", mean that there +> may exist valid reasons in particular circumstances to ignore a +> particular item, but the full implications must be understood and +> carefully weighed before choosing a different course. + +Ideally, each of these tradeoffs should be documented +in the separate issues, labelled accordingly and linked +to original issue and epic. + ## Impact Analysis **Summary:** think about the impact your merge request may have on performance @@ -44,6 +59,64 @@ should ask one of the merge request reviewers to review your changes. You can find a list of these reviewers at <https://about.gitlab.com/company/team/>. A reviewer in turn can request a performance specialist to review the changes. +## Think outside of the box + +Everyone has their own perception how the new feature is going to be used. +Always consider how users might be using the feature instead. Usually, +users test our features in a very unconventional way, +like by brute forcing or abusing edge conditions that we have. + +## Data set + +The data set that will be processed by the merge request should be known +and documented. The feature should clearly document what the expected +data set is for this feature to process, and what problems it might cause. + +If you would think about the following example that puts +a strong emphasis of data set being processed. +The problem is simple: you want to filter a list of files from +some git repository. Your feature requests a list of all files +from the repository and perform search for the set of files. +As an author you should in context of that problem consider +the following: + +1. What repositories are going to be supported? +1. How long it will take for big repositories like Linux kernel? +1. Is there something that we can do differently to not process such a + big data set? +1. Should we build some fail-safe mechanism to contain + computational complexity? Usually it is better to degrade + the service for a single user instead of all users. + +## Query plans and database structure + +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. +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. +See how the query will behave if the result will be a few +and a few thousand. + +This is needed as we have users using GitLab for very big projects and +in a very unconventional way. Even, if it seems that it is unlikely +that such big data set will be used, it is still plausible that one +of our customers will have the problem with the feature. + +Understanding ahead of time how it is going to behave at scale even if we accept it, +is the desired outcome. We should always have a plan or understanding what it takes +to optimise feature to the magnitude of higher usage patterns. + +Every database structure should be optimised and sometimes even over-described +to be prepared to be easily extended. The hardest part after some point is +data migration. Migrating millions of rows will always be troublesome and +can have negative impact on application. + +To better understand how to get help with the query plan reviews +read this section on [how to prepare the merge request for a database review](https://docs.gitlab.com/ee/development/database_review.html#how-to-prepare-the-merge-request-for-a-database-review). + ## Query Counts **Summary:** a merge request **should not** increase the number of executed SQL @@ -172,3 +245,107 @@ Caching data per transaction can be done using `Gitlab::SafeRequestStore` to avoid having to remember to check `RequestStore.active?`). Caching data in Redis can be done using [Rails' caching system](https://guides.rubyonrails.org/caching_with_rails.html). + +## Pagination + +Each feature that renders a list of items as a table needs to include pagination. + +The main styles of pagination are: + +1. Offset-based pagination: user goes to a specific page, like 1. User sees the next page number, + and the total number of pages. This style is well supported by all components of GitLab. +1. Offset-based pagination, but without the count: user goes to a specific page, like 1. + User sees only the next page number, but does not see the total amount of pages. +1. Next page using keyset-based pagination: user can only go to next page, as we do not know how many pages + are available. +1. Infinite scrolling pagination: user scrolls the page and next items are loaded asynchronously. This is ideal, + as it has exact same benefits as the previous one. + +The ultimately scalable solution for pagination is to use Keyset-based pagination. +However, we don't have support for that at GitLab at that moment. You +can follow the progress looking at [API: Keyset Pagination +](https://gitlab.com/groups/gitlab-org/-/epics/2039). + +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. + The database has to sort and iterate all previous items, and this operation usually + can result in substantial load put on database. + +## Badge counters + +Counters should always be truncated. It means that we do not want to present +the exact number over some threshold. The reason for that is for the cases where we want +to calculate exact number of items, we effectively need to filter each of them for +the purpose of knowing the exact number of items matching. + +From ~UX perspective it is often acceptable to see that you have over 1000+ pipelines, +instead of that you have 40000+ pipelines, but at a tradeoff of loading page for 2s longer. + +An example of this pattern is the list of pipelines and jobs. We truncate numbers to `1000+`, +but we show an accurate number of running pipelines, which is the most interesting information. + +There's a helper method that can be used for that purpose - `NumbersHelper.limited_counter_with_delimiter` - +that accepts an upper limit of counting rows. + +In some cases it is desired that badge counters are loaded asynchronously. +This can speed up the initial page load and give a better user experience overall. + +## Application/misuse limits + +Every new feature should have safe usage quotas introduced. +The quota should be optimised to a level that we consider the feature to +be performant and usable for the user, but **not limiting**. + +**We want the features to be fully usable for the users.** +**However, we want to ensure that the feature will continue to perform well if used at its limit** +**and it will not cause availability issues.** + +Consider that it is always better to start with some kind of limitation, +instead of later introducing a breaking change that would result in some +workflows breaking. + +The intent is to provide a safe usage pattern for the feature, +as our implementation decisions are optimised for the given data set. +Our feature limits should reflect the optimisations that we introduced. + +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 + 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. + +Examples: + +1. Pipeline Schedules: It is very unlikely that user will want to create + 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 + assigned the the project. + +1. GitLab CI includes: We started with the limit of maximum of 50 nested includes. + We understood that performance of the feature was acceptable at that level. + We received a request from the community that the limit is too small. + We had a time to understand the customer requirement, and implement an additional + fail-safe mechanism (time-based one) to increase the limit 100, and if needed increase it + further without negative impact on availability of the feature and GitLab. + +## Usage of feature flags + +Each feature that has performance critical elements or has a known performance deficiency +needs to come with feature flag to disable it. + +The feature flag makes our team more happy, because they can monitor the system and +quickly react without our users noticing the problem. + +Performance deficiencies should be addressed right away after we merge initial +changes. + +Read more about when and how feature flags should be used in +[Feature flags in GitLab development](https://docs.gitlab.com/ee/development/feature_flags/process.html#feature-flags-in-gitlab-development). diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md index 20d705136b2754c27eeec90975bccddda475091a..32c4313a1ed67c9a08902f5f9f6fa6ff90e77f45 100644 --- a/doc/development/migration_style_guide.md +++ b/doc/development/migration_style_guide.md @@ -211,7 +211,7 @@ class MyMigration < ActiveRecord::Migration[4.2] end def down - remove_index :table, :column if index_exists?(:table, :column) + remove_concurrent_index :table, :column end end ``` diff --git a/doc/development/packages.md b/doc/development/packages.md index 2474392db62580c73ab533208571fd6750a55296..6d4a9ea9f413b3eb237d0e64b09b8667d6f9bc61 100644 --- a/doc/development/packages.md +++ b/doc/development/packages.md @@ -4,12 +4,13 @@ This document will guide you through adding another [package management system]( See already supported package types in [Packages documentation](../administration/packages/index.md) -Since GitLab packages' UI is pretty generic, it is possible to add new +Since GitLab packages' UI is pretty generic, it is possible to add basic new package system support by solely backend changes. This guide is superficial and does not cover the way the code should be written. However, you can find a good example by looking at existing merge requests with Maven and NPM support: - [NPM registry support](https://gitlab.com/gitlab-org/gitlab/merge_requests/8673). +- [Conan repository](https://gitlab.com/gitlab-org/gitlab/issues/8248). - [Maven repository](https://gitlab.com/gitlab-org/gitlab/merge_requests/6607). - [Instance level endpoint for Maven repository](https://gitlab.com/gitlab-org/gitlab/merge_requests/8757) @@ -34,7 +35,7 @@ endpoints like: - GET package file content. - PUT upload package. -Since the packages belong to a project, it's expected to have project-level endpoint +Since the packages belong to a project, it's expected to have project-level endpoint (remote) for uploading and downloading them. For example: ``` @@ -44,9 +45,48 @@ PUT https://gitlab.com/api/v4/projects/<your_project_id>/packages/npm/ Group-level and instance-level endpoints are good to have but are optional. -NOTE: **Note:** -To avoid name conflict for instance-level endpoints we use -[the package naming convention](../user/packages/npm_registry/index.md#package-naming-convention) +## Naming conventions + +To avoid name conflict for instance-level endpoints you will need to define a package naming convention +that gives a way to identify the project that the package belongs to. This generally involves using the project +id or full project path in the package name. See +[Conan's naming convention](../user/packages/conan_repository/index.md#package-recipe-naming-convention) as an example. + +For group and project-level endpoints, naming can be less constrained, and it will be up to the group and project +members to be certain that there is no conflict between two package names, however the system should prevent +a user from reusing an existing name within a given scope. + +Otherwise, naming should follow the package manager's naming conventions and include a validation in the `package.md` +model for that package type. + +## File uploads + +File uploads should be handled by GitLab workhorse using object accelerated uploads. What this means is that +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). + +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 +(instance, group, project). [This merge request](https://gitlab.com/gitlab-org/gitlab-workhorse/merge_requests/412/diffs) +demonstrates adding an instance-level endpoint for Conan to workhorse. You can also see the Maven project level endpoint +implemented in the same file. + +Once the route has been added, you will need to add an additional `/authorize` version of the upload endpoint to your API file. +[Here is an example](https://gitlab.com/gitlab-org/gitlab/blob/398fef1ca26ae2b2c3dc89750f6b20455a1e5507/ee/lib/api/maven_packages.rb#L164) +of the additional endpoint added for Maven. The `/authorize` endpoint verifies and authorizes the request from workhorse, +then the normal upload endpoint is implemented below, consuming the metadata that workhorse provides in order to +create the package record. Workhorse provides a variety of file metadata such as type, size, and different checksum formats. + +For testing purposes, you may want to [enable object storage](https://gitlab.com/gitlab-org/gitlab-development-kit/blob/master/doc/howto/object_storage.md) +in your local development environment. + +## Services and finders + +Logic for performing tasks such as creating package or package file records or finding packages should not live +within the API file, but should live in services and finders. Existing services and finders should be used or +extended when possible to keep the common package logic grouped as much as possible. ## Configuration @@ -56,7 +96,7 @@ to add anything there. Packages can be configured to use object storage, therefore your code must support it. -## Database +## Database and handling metadata The current database model allows you to store a name and a version for each package. Every time you upload a new package, you can either create a new record of `Package` @@ -65,4 +105,58 @@ information like the file `name`, `side`, `sha1`, etc. If there is specific data necessary to be stored for only one package system support, consider creating a separate metadata model. See `packages_maven_metadata` table -and `Packages::MavenMetadatum` model as example for package specific data. +and `Packages::MavenMetadatum` model as an example for package specific data, and `packages_conan_file_metadata` table +and `Packages::ConanFileMetadatum` model as an example for package file specific data. + +If there is package specific behavior for a given package manager, add those methods to the metadata models and +delegate from the package model. + +Note that the existing package UI only displays information within the `packages_packages` and `packages_package_files` +tables. If the data stored in the metadata tables need to be displayed, a ~frontend change will be required. + +## Authorization + +There are project and group level permissions for `read_package`, `create_package`, and `destroy_package`. Each +endpoint should +[authorize the requesting user](https://gitlab.com/gitlab-org/gitlab/blob/398fef1ca26ae2b2c3dc89750f6b20455a1e5507/ee/lib/api/conan_packages.rb#L84) +against the project or group before continuing. + +## Keep iterations small + +When implementing a new package manager, it is easy to end up creating one large merge request containing all of the +necessary endpoints and services necessary to support basic usage. If this is the case, consider putting the +API endpoints behind a [feature flag](feature_flags/development.md) and +submitting each endpoint or behavior (download, upload, etc) in different merge requests to shorten the review +process. + +### Potential MRs for any given package system + +#### MVC MRs + +These changes represent all that is needed to deliver a minimally usable package management system. + +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. Endpoints required for upload/publish +1. Endpoints required for install/download +1. Endpoints required for remove/delete + +#### Possible post-MVC MRs + +These updates are not essential to be able to publish and consume packages, but may be desired as the system is +released for general use. + +1. Endpoints required for search +1. Front end updates to display additional package information and metadata +1. Limits on file sizes +1. Tracking for metrics + +## Exceptions + +This documentation is just guidelines on how to implement a package manager to match the existing structure and logic +already present within GitLab. While the structure is intended to be extendable and flexible enough to allow for +any given package manager, if there is good reason to stray due to the constraints or needs of a given package +manager, then it should be raised and discussed within the implementation issue or merge request to work towards +the most efficient outcome. diff --git a/doc/development/pipelines.md b/doc/development/pipelines.md index 5954de03db4ba8d1bf14a3f965841a257512fa4a..764bd68000d7cca267381dca44eef433aa457b17 100644 --- a/doc/development/pipelines.md +++ b/doc/development/pipelines.md @@ -15,6 +15,8 @@ as much as possible. The current stages are: +- `sync`: This stage is used to synchronize changes from gitlab-org/gitlab to + gitlab-org/gitlab-foss. - `prepare`: This stage includes jobs that prepare artifacts that are needed by jobs in subsequent stages. - `quick-test`: This stage includes test jobs that should run first and fail the @@ -27,7 +29,6 @@ The current stages are: - `review`: This stage includes jobs that deploy the GitLab and Docs Review Apps. - `qa`: This stage includes jobs that perform QA tasks against the Review App that is deployed in the previous stage. -- `notification`: This stage includes jobs that sends notifications about pipeline status. - `post-test`: This stage includes jobs that build reports or gather data from the previous stages' jobs (e.g. coverage, Knapsack metadata etc.). - `pages`: This stage includes a job that deploys the various reports as @@ -38,7 +39,8 @@ The current stages are: ## Default image The default image is currently -`gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.3-golang-1.11-git-2.22-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.3-golang-1.11-git-2.22-chrome-73.0-node-12.x-yarn-1.16-postgresql-9.6-graphicsmagick-1.3.33`. + It includes Ruby 2.6.3, Go 1.11, Git 2.22, Chrome 73, Node 12, Yarn 1.16, PostgreSQL 9.6, and Graphics Magick 1.3.33. @@ -48,24 +50,13 @@ project, which is push-mirrored to <https://dev.gitlab.org/gitlab/gitlab-build-i for redundancy. The current version of the build images can be found in the -["Used by GitLab CE/EE section"](https://gitlab.com/gitlab-org/gitlab-build-images/blob/master/.gitlab-ci.yml). +["Used by GitLab section"](https://gitlab.com/gitlab-org/gitlab-build-images/blob/master/.gitlab-ci.yml). ## Default variables In addition to the [predefined variables](../ci/variables/predefined_variables.md), -each pipeline includes the following [variables](../ci/variables/README.md): - -- `RAILS_ENV: "test"` -- `NODE_ENV: "test"` -- `SIMPLECOV: "true"` -- `GIT_DEPTH: "50"` -- `GIT_SUBMODULE_STRATEGY: "none"` -- `GET_SOURCES_ATTEMPTS: "3"` -- `KNAPSACK_RSPEC_SUITE_REPORT_PATH: knapsack/${CI_PROJECT_NAME}/rspec_report-master.json` -- `FLAKY_RSPEC_SUITE_REPORT_PATH: rspec_flaky/report-suite.json` -- `BUILD_ASSETS_IMAGE: "false"` -- `ES_JAVA_OPTS: "-Xms256m -Xmx256m"` -- `ELASTIC_URL: "http://elastic:changeme@docker.elastic.co-elasticsearch-elasticsearch:9200"` +each pipeline includes default variables defined in +<https://gitlab.com/gitlab-org/gitlab/blob/master/.gitlab-ci.yml>. ## Common job definitions @@ -85,22 +76,35 @@ These common definitions are: Ruby/Rails and frontend tasks. - `.default-only`: Restricts the cases where a job is created. This currently includes `master`, `/^[\d-]+-stable(-ee)?$/` (stable branches), - `/^\d+-\d+-auto-deploy-\d+$/` (security branches), `merge_requests`, `tags`. + `/^\d+-\d+-auto-deploy-\d+$/` (auto-deploy branches), `/^security\//` (security branches), `merge_requests`, `tags`. Note that jobs won't be created for branches with this default configuration. -- `.only-review`: Only creates a job for the `gitlab-org` namespace and if - Kubernetes integration is available. Also, prevents a job from being created - for `master` and auto-deploy branches. -- `.only-review-schedules`: Same as `.only-review` but also restrict a job to - only run for [schedules](../user/project/pipelines/schedules.md). -- `.only-canonical-schedules`: Only creates a job for scheduled pipelines in - the `gitlab-org/gitlab` and `gitlab-org/gitlab-foss` projects +- `.only:variables-canonical-dot-com`: Only creates a job if the project is + located under <https://gitlab.com/gitlab-org>. +- `.only:variables_refs-canonical-dot-com-schedules`: Same as + `.only:variables-canonical-dot-com` but add the condition that pipeline is scheduled. +- `.except:refs-deploy`: Don't create a job if the `ref` is an auto-deploy branch. +- `.except:refs-master-tags-stable-deploy`: Don't create a job if the `ref` is one of: + - `master` + - a tag + - a stable branch + - an auto-deploy branch +- `.only:kubernetes`: Only creates a job if a Kubernetes integration is enabled + on the project. +- `.only-review`: This extends from: + - `.only:variables-canonical-dot-com` + - `.only:kubernetes` + - `.except:refs-master-tags-stable-deploy` +- `.only-review-schedules`: This extends from: + - `.only:variables_refs-canonical-dot-com-schedules` + - `.only:kubernetes` + - `.except:refs-deploy` - `.use-pg9`: Allows a job to use the `postgres:9.6` and `redis:alpine` services. - `.use-pg10`: Allows a job to use the `postgres:10.9` and `redis:alpine` services. - `.use-pg9-ee`: Same as `.use-pg9` but also use the `docker.elastic.co/elasticsearch/elasticsearch:5.6.12` services. - `.use-pg10-ee`: Same as `.use-pg10` but also use the `docker.elastic.co/elasticsearch/elasticsearch:5.6.12` services. -- `.only-ee`: Only creates a job for the `gitlab` project. +- `.only-ee`: Only creates a job for the `gitlab` or `gitlab-ee` project. - `.only-ee-as-if-foss`: Same as `.only-ee` but simulate the FOSS project by setting the `FOSS_ONLY='1'` environment variable. @@ -111,11 +115,13 @@ the cases where it should be created [based on the changes](../ci/yaml/README.md#onlychangesexceptchanges) from a commit or MR by extending from the following CI definitions: -- `.only-code-changes`: Allows a job to only be created upon code-related changes. -- `.only-qa-changes`: Allows a job to only be created upon QA-related changes. -- `.only-docs-changes`: Allows a job to only be created upon docs-related changes. -- `.only-code-qa-changes`: Allows a job to only be created upon code-related or QA-related changes. -- `.only-graphql-changes`: Allows a job to only be created upon graphql-related changes. +- `.only:changes-code`: Allows a job to only be created upon code-related changes. +- `.only:changes-qa`: Allows a job to only be created upon QA-related changes. +- `.only:changes-docs`: Allows a job to only be created upon docs-related changes. +- `.only:changes-graphql`: Allows a job to only be created upon GraphQL-related changes. +- `.only:changes-code-backstage`: Allows a job to only be created upon code-related or backstage-related (e.g. Danger, RuboCop, specs) changes. +- `.only:changes-code-qa`: Allows a job to only be created upon code-related or QA-related changes. +- `.only:changes-code-backstage-qa`: Allows a job to only be created upon code-related, backstage-related (e.g. Danger, RuboCop, specs) or QA-related changes. **See <https://gitlab.com/gitlab-org/gitlab/blob/master/.gitlab/ci/global.gitlab-ci.yml> for the list of exact patterns.** @@ -203,11 +209,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 833b0acb13ec033484d4530753d4c2162511d4b8..8e5ef6e57c02da8f4cf1c2a0eeced6db75644574 100644 --- a/doc/development/policies.md +++ b/doc/development/policies.md @@ -157,3 +157,18 @@ end ``` will include all rules from `ProjectPolicy`. The delegated conditions will be evaluated with the correct delegated subject, and will be sorted along with the regular rules in the policy. Note that only the relevant rules for a particular ability will actually be considered. + +## Specifying Policy Class + +You can also override the Policy used for a given subject: + +```ruby +class Foo + + def self.declarative_policy_class + 'SomeOtherPolicy' + end +end +``` + +This will use & check permissions on the `SomeOtherPolicy` class rather than the usual calculated `FooPolicy` class. diff --git a/doc/development/profiling.md b/doc/development/profiling.md index 04897e770f8ecfd4f6fb0ef4a5a7d95d6daf6bce..18683fa10f818af6b2b866a16ad9ac6306055fc9 100644 --- a/doc/development/profiling.md +++ b/doc/development/profiling.md @@ -42,6 +42,10 @@ Passing a `logger:` keyword argument to `Gitlab::Profiler.profile` will send ActiveRecord and ActionController log output to that logger. Further options are documented with the method source. +```ruby +Gitlab::Profiler.profile('/gitlab-org/gitlab-test', user: User.first, logger: Logger.new(STDOUT)) +``` + There is also a RubyProf printer available: `Gitlab::Profiler::TotalTimeFlatPrinter`. This acts like `RubyProf::FlatPrinter`, but its `min_percent` option works on the method's diff --git a/doc/development/rake_tasks.md b/doc/development/rake_tasks.md index a6d3c008686bfeefdabb0b5330300bbdb23b31d4..369806d462b041b8e4b2993bc07baa15c3730573 100644 --- a/doc/development/rake_tasks.md +++ b/doc/development/rake_tasks.md @@ -12,6 +12,14 @@ The `setup` task is an alias for `gitlab:setup`. This tasks calls `db:reset` to create the database, and calls `db:seed_fu` to seed the database. Note: `db:setup` calls `db:seed` but this does nothing. +### Env variables + +**MASS_INSERT**: Create millions of users (2m), projects (5m) and its +relations. It's highly recommended to run the seed with it to catch slow queries +while developing. Expect the process to take up to 20 extra minutes. + +**LARGE_PROJECTS**: Create large projects (through import) from a predefined set of urls. + ### Seeding issues for all or a given project You can seed issues for all or a given project with the `gitlab:seed:issues` @@ -221,7 +229,7 @@ bundle exec rake db:obsolete_ignored_columns Feel free to remove their definitions from their `ignored_columns` definitions. -## Update GraphQL Documentation +## Update GraphQL Documentation and Schema definitions To generate GraphQL documentation based on the GitLab schema, run: @@ -243,3 +251,13 @@ The actual renderer is at `Gitlab::Graphql::Docs::Renderer`. `@parsed_schema` is an instance variable that the `graphql-docs` gem expects to have available. `Gitlab::Graphql::Docs::Helper` defines the `object` method we currently use. This is also where you should implement any new methods for new types you'd like to display. + +### Update machine-readable schema files + +To generate GraphQL schema files based on the GitLab schema, run: + +```shell +bundle exec rake gitlab:graphql:schema:dump +``` + +This uses graphql-ruby's built-in rake tasks to generate files in both [IDL](https://www.prisma.io/blog/graphql-sdl-schema-definition-language-6755bcb9ce51) and JSON formats. diff --git a/doc/development/repository_mirroring.md b/doc/development/repository_mirroring.md index 8521d6fcd307635229814449bea3416eb81b717a..0a0c91821cfb4c455f81a81c6cce4ae588b1d089 100644 --- a/doc/development/repository_mirroring.md +++ b/doc/development/repository_mirroring.md @@ -5,6 +5,6 @@ In December 2018, Tiago Botelho hosted a [Deep Dive] on GitLab's [Pull Repository Mirroring functionality] to share his domain specific knowledge with anyone who may work in this part of the code base in the future. You can find the [recording on YouTube], and the slides in [PDF]. Everything covered in this deep dive was accurate as of GitLab 11.6, and while specific details may have changed since then, it should still serve as a good introduction. [Deep Dive]: https://gitlab.com/gitlab-org/create-stage/issues/1 -[Pull Repository Mirroring functionality]: ../workflow/repository_mirroring.md#pulling-from-a-remote-repository-starter +[Pull Repository Mirroring functionality]: ../user/project/repository/repository_mirroring.md#pulling-from-a-remote-repository-starter [recording on YouTube]: https://www.youtube.com/watch?v=sSZq0fpdY-Y [PDF]: https://gitlab.com/gitlab-org/create-stage/uploads/8693404888a941fd851f8a8ecdec9675/Gitlab_Create_-_Pull_Mirroring_Deep_Dive.pdf diff --git a/doc/development/sidekiq_style_guide.md b/doc/development/sidekiq_style_guide.md index d52a3e652e3ea5aaafce05d487cd2879866b44a7..e433691c1ed7f53324bf89db94616436a40ccae8 100644 --- a/doc/development/sidekiq_style_guide.md +++ b/doc/development/sidekiq_style_guide.md @@ -61,6 +61,168 @@ the extra jobs will take resources away from jobs from workers that were already there, if the resources available to the Sidekiq process handling the namespace are not adjusted appropriately. +## Latency Sensitive Jobs + +If a large number of background jobs get scheduled at once, queueing of jobs may +occur while jobs wait for a worker node to be become available. This is normal +and gives the system resilience by allowing it to gracefully handle spikes in +traffic. Some jobs, however, are more sensitive to latency than others. Examples +of these jobs include: + +1. A job which updates a merge request following a push to a branch. +1. A job which invalidates a cache of known branches for a project after a push + to the branch. +1. A job which recalculates the groups and projects a user can see after a + change in permissions. +1. A job which updates the status of a CI pipeline after a state change to a job + in the pipeline. + +When these jobs are delayed, the user may perceive the delay as a bug: for +example, they may push a branch and then attempt to create a merge request for +that branch, but be told in the UI that the branch does not exist. We deem these +jobs to be `latency_sensitive`. + +Extra effort is made to ensure that these jobs are started within a very short +period of time after being scheduled. However, in order to ensure throughput, +these jobs also have very strict execution duration requirements: + +1. The median job execution time should be less than 1 second. +1. 99% of jobs should complete within 10 seconds. + +If a worker cannot meet these expectations, then it cannot be treated as a +`latency_sensitive` worker: consider redesigning the worker, or splitting the +work between two different workers, one with `latency_sensitive` code that +executes quickly, and the other with non-`latency_sensitive`, which has no +execution latency requirements (but also has lower scheduling targets). + +This can be summed up in the following table: + +| **Latency Sensitivity** | **Queue Scheduling Target** | **Execution Latency Requirement** | +|-------------------------|-----------------------------|-------------------------------------| +| Not `latency_sensitive` | 1 minute | Maximum run time of 1 hour | +| `latency_sensitive` | 100 milliseconds | p50 of 1 second, p99 of 10 seconds | + +To mark a worker as being `latency_sensitive`, use the +`latency_sensitive_worker!` attribute, as shown in this example: + +```ruby +class LatencySensitiveWorker + include ApplicationWorker + + latency_sensitive_worker! + + # ... +end +``` + +## Jobs with External Dependencies + +Most background jobs in the GitLab application communicate with other GitLab +services, eg Postgres, Redis, Gitaly and Object Storage. These are considered +to be "internal" dependencies for a job. + +However, some jobs will be dependent on external services in order to complete +successfully. Some examples include: + +1. Jobs which call web-hooks configured by a user. +1. Jobs which deploy an application to a k8s cluster configured by a user. + +These jobs have "external dependencies". This is important for the operation of +the background processing cluster in several ways: + +1. Most external dependencies (such as web-hooks) do not provide SLOs, and + therefore we cannot guarantee the execution latencies on these jobs. Since we + cannot guarantee execution latency, we cannot ensure throughput and + therefore, in high-traffic environments, we need to ensure that jobs with + external dependencies are separated from `latency_sensitive` jobs, to ensure + throughput on those queues. +1. Errors in jobs with external dependencies have higher alerting thresholds as + there is a likelihood that the cause of the error is external. + +```ruby +class ExternalDependencyWorker + include ApplicationWorker + + # Declares that this worker depends on + # third-party, external services in order + # to complete successfully + worker_has_external_dependencies! + + # ... +end +``` + +NOTE: **Note:** Note that a job cannot be both latency sensitive and have +external dependencies. + +## CPU-bound and Memory-bound Workers + +Workers that are constrained by CPU or memory resource limitations should be +annotated with the `worker_resource_boundary` method. + +Most workers tend to spend most of their time blocked, wait on network responses +from other services such as Redis, Postgres and Gitaly. Since Sidekiq is a +multithreaded environment, these jobs can be scheduled with high concurrency. + +Some workers, however, spend large amounts of time _on-cpu_ running logic in +Ruby. Ruby MRI does not support true multithreading - it relies on the +[GIL](https://thoughtbot.com/blog/untangling-ruby-threads#the-global-interpreter-lock) +to greatly simplify application development by only allowing one section of Ruby +code in a process to run at a time, no matter how many cores the machine +hosting the process has. For IO bound workers, this is not a problem, since most +of the threads are blocked in underlying libraries (which are outside of the +GIL). + +If many threads are attempting to run Ruby code simultaneously, this will lead +to contention on the GIL which will have the affect of slowing down all +processes. + +In high-traffic environments, knowing that a worker is CPU-bound allows us to +run it on a different fleet with lower concurrency. This ensures optimal +performance. + +Likewise, if a worker uses large amounts of memory, we can run these on a +bespoke low concurrency, high memory fleet. + +Note that Memory-bound workers create heavy GC workloads, with pauses of +10-50ms. This will have an impact on the latency requirements for the +worker. For this reason, `memory` bound, `latency_sensitive` jobs are not +permitted and will fail CI. In general, `memory` bound workers are +discouraged, and alternative approaches to processing the work should be +considered. + +## Declaring a Job as CPU-bound + +This example shows how to declare a job as being CPU-bound. + +```ruby +class CPUIntensiveWorker + include ApplicationWorker + + # Declares that this worker will perform a lot of + # calculations on-CPU. + worker_resource_boundary :cpu + + # ... +end +``` + +## Determining whether a worker is CPU-bound + +We use the following approach to determine whether a worker is CPU-bound: + +- In the sidekiq structured JSON logs, aggregate the worker `duration` and + `cpu_s` fields. +- `duration` refers to the total job execution duration, in seconds +- `cpu_s` is derived from the + [`Process::CLOCK_THREAD_CPUTIME_ID`](https://www.rubydoc.info/stdlib/core/Process:clock_gettime) + counter, and is a measure of time spent by the job on-CPU. +- Divide `cpu_s` by `duration` to get the percentage time spend on-CPU. +- If this ratio exceeds 33%, the worker is considered CPU-bound and should be + annotated as such. +- Note that these values should not be used over small sample sizes, but + rather over fairly large aggregates. + ## Feature Categorization Each Sidekiq worker, or one of its ancestor classes, must declare a @@ -74,7 +236,7 @@ The declaration uses the `feature_category` class method, as shown below. class SomeScheduledTaskWorker include ApplicationWorker - # Declares that this feature is part of the + # Declares that this worker is part of the # `continuous_integration` feature category feature_category :continuous_integration @@ -88,11 +250,11 @@ source](https://gitlab.com/gitlab-com/www-gitlab-com/blob/master/data/stages.yml ### Updating `config/feature_categories.yml` -Occassionally new features will be added to GitLab stages. When this occurs, you +Occasionally new features will be added to GitLab stages. When this occurs, you can automatically update `config/feature_categories.yml` by running `scripts/update-feature-categories`. This script will fetch and parse [`stages.yml`](https://gitlab.com/gitlab-com/www-gitlab-com/blob/master/data/stages.yml) -and generare a new version of the file, which needs to be checked into source control. +and generate a new version of the file, which needs to be checked into source control. ### Excluding Sidekiq workers from feature categorization @@ -116,9 +278,63 @@ end Each Sidekiq worker must be tested using RSpec, just like any other class. These tests should be placed in `spec/workers`. -## Removing or renaming queues +## Sidekiq Compatibility across Updates + +Keep in mind that the arguments for a Sidekiq job are stored in a queue while it +is scheduled for execution. During a online update, this could lead to several +possible situations: + +1. An older version of the application publishes a job, which is executed by an + upgraded Sidekiq node. +1. A job is queued before an upgrade, but executed after an upgrade. +1. A job is queued by a node running the newer version of the application, but + executed on a node running an older version of the application. + +### Changing the arguments for a worker + +Jobs need to be backwards- and forwards-compatible between consecutive versions +of the application. + +This can be done by following this process: + +1. **Do not remove arguments from the `perform` function.**. Instead, use the + following approach + 1. Provide a default value (usually `nil`) and use a comment to mark the + argument as deprecated + 1. Stop using the argument in `perform_async`. + 1. Ignore the value in the worker class, but do not remove it until the next + major release. + +### Removing workers + +Try to avoid removing workers and their queues in minor and patch +releases. -Try to avoid renaming or removing workers and their queues in minor and patch releases. During online update instance can have pending jobs and removing the queue can lead to those jobs being stuck forever. If you can't write migration for those -Sidekiq jobs, please consider doing rename or remove queue in major release only. +Sidekiq jobs, please consider removing the worker in a major release only. + +### Renaming queues + +For the same reasons that removing workers is dangerous, care should be taken +when renaming queues. + +When renaming queues, use the `sidekiq_queue_migrate` helper migration method, +as show in this example: + +```ruby +class MigrateTheRenamedSidekiqQueue < ActiveRecord::Migration[5.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + sidekiq_queue_migrate 'old_queue_name', to: 'new_queue_name' + end + + def down + sidekiq_queue_migrate 'new_queue_name', to: 'old_queue_name' + end +end + +``` diff --git a/doc/development/testing_guide/best_practices.md b/doc/development/testing_guide/best_practices.md index 32e079f915ca804d2721d7a021d978abcc9e27cb..fe3989474e6a6bee864ac598c9d5d21e7d80eaf7 100644 --- a/doc/development/testing_guide/best_practices.md +++ b/doc/development/testing_guide/best_practices.md @@ -44,6 +44,14 @@ bundle exec rspec bundle exec rspec spec/[path]/[to]/[spec].rb ``` +Use [guard](https://github.com/guard/guard) to continuously monitor for changes and only run matching tests: + +```sh +bundle exec guard +``` + +When using spring and guard together, use `SPRING=1 bundle exec guard` instead to make use of spring. + ### General guidelines - Use a single, top-level `describe ClassName` block. @@ -61,6 +69,7 @@ bundle exec rspec spec/[path]/[to]/[spec].rb - 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 `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. ### System / Feature tests @@ -356,10 +365,22 @@ However, if a spec makes direct Redis calls, it should mark itself with the `:clean_gitlab_redis_cache`, `:clean_gitlab_redis_shared_state` or `:clean_gitlab_redis_queues` traits as appropriate. -Sidekiq jobs are typically not run in specs, but this behaviour can be altered -in each spec through the use of `perform_enqueued_jobs` blocks. Any spec that -causes Sidekiq jobs to be pushed to Redis should use the `:sidekiq` trait, to -ensure that they are removed once the spec completes. +#### Background jobs / Sidekiq + +By default, Sidekiq jobs are enqueued into a jobs array and aren't processed. +If a test enqueues Sidekiq jobs and need them to be processed, the +`:sidekiq_inline` trait can be used. + +The `:sidekiq_might_not_need_inline` trait was added when [Sidekiq inline mode was +changed to fake mode](https://gitlab.com/gitlab-org/gitlab/merge_requests/15479) +to all the tests that needed Sidekiq to actually process jobs. Tests with +this trait should be either fixed to not rely on Sidekiq processing jobs, or their +`:sidekiq_might_not_need_inline` trait should be updated to `:sidekiq_inline` if +the processing of background jobs is needed/expected. + +NOTE: **Note:** +The usage of `perform_enqueued_jobs` is currently useless since our +workers aren't inheriting from `ApplicationJob` / `ActiveJob::Base`. #### Filesystem 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 042879b47aacf240f3654bfbba5452ac70d0c5b2..e2a0d267ba1a7d159afe9409e59e875b2391feda 100644 --- a/doc/development/testing_guide/end_to_end/best_practices.md +++ b/doc/development/testing_guide/end_to_end/best_practices.md @@ -65,3 +65,31 @@ This library [saves the screenshots in the RSpec's `after` hook](https://github. Given this fact, we should limit the use of `before(:all)` to only those operations where a screenshot is not necessary in case of failure and QA logs would be enough for debugging. + +## Ensure tests do not leave the browser logged in + +All QA tests expect to be able to log in at the start of the test. + +That's not possible if a test leaves the browser logged in when it finishes. Normally this isn't a problem because [Capybara resets the session after each test](https://github.com/teamcapybara/capybara/blob/9ebc5033282d40c73b0286e60217515fd1bb0b5d/lib/capybara/rspec.rb#L18). But Capybara does that in an `after` block, so when a test logs in in an `after(:context)` block, the browser returns to a logged in state *after* Capybara had logged it out. And so the next test will fail. + +For an example see: <https://gitlab.com/gitlab-org/gitlab/issues/34736> + +Ideally, any actions peformed in an `after(:context)` (or [`before(:context)`](#limit-the-use-of-beforeall-hook)) block would be performed via the API. But if it's necessary to do so via the UI (e.g., if API functionality doesn't exist), make sure to log out at the end of the block. + +```ruby +after(:all) do + login unless Page::Main::Menu.perform(&:signed_in?) + + # Do something while logged in + + Page::Main::Menu.perform(&:sign_out) +end +``` + +## Tag tests that require Administrator access + +We don't run tests that require Administrator access against our Production environments. + +When you add a new test that requires Administrator access, apply the RSpec metadata `:requires_admin` so that the test will not be included in the test suites executed against Production and other environments on which we don't want to run those tests. + +Note: When running tests locally or configuring a pipeline, the environment variable `QA_CAN_TEST_ADMIN_FEATURES` can be set to `false` to skip tests that have the `:requires_admin` tag. diff --git a/doc/development/testing_guide/end_to_end/feature_flags.md b/doc/development/testing_guide/end_to_end/feature_flags.md new file mode 100644 index 0000000000000000000000000000000000000000..bf1e70be9cbf6fc88b6e99d6b8f621ac7760d54d --- /dev/null +++ b/doc/development/testing_guide/end_to_end/feature_flags.md @@ -0,0 +1,27 @@ +# Testing with feature flags + +To run a specific test with a feature flag enabled you can use the `QA::Runtime::Feature` class to enabled and disable feature flags ([via the API](../../../api/features.md)). + +Note that administrator authorization is required to change feature flags. `QA::Runtime::Feature` will automatically authenticate as an administrator as long as you provide an appropriate access token via `GITLAB_QA_ADMIN_ACCESS_TOKEN` (recommended), or provide `GITLAB_ADMIN_USERNAME` and `GITLAB_ADMIN_PASSWORD`. + +```ruby +context "with feature flag enabled" do + before do + Runtime::Feature.enable('feature_flag_name') + end + + it "feature flag test" do + # Execute a test with a feature flag enabled + end + + after do + Runtime::Feature.disable('feature_flag_name') + end +end +``` + +## Running a scenario with a feature flag enabled + +It's also possible to run an entire scenario with a feature flag enabled, without having to edit existing tests or write new ones. + +Please see the [QA readme](https://gitlab.com/gitlab-org/gitlab/tree/master/qa#running-tests-with-a-feature-flag-enabled) for details. diff --git a/doc/development/testing_guide/end_to_end/flows.md b/doc/development/testing_guide/end_to_end/flows.md new file mode 100644 index 0000000000000000000000000000000000000000..fb1d82914aad63006fd3c19c363087fe0a8a23e4 --- /dev/null +++ b/doc/development/testing_guide/end_to_end/flows.md @@ -0,0 +1,56 @@ +# Flows in GitLab QA + +Flows are frequently used sequences of actions. They are a higher level +of abstraction than page objects. Flows can include multiple page objects, +or any other relevant code. + +For example, the sign in flow encapsulates two steps that are included +in every browser UI test. + +```ruby +# QA::Flow::Login + +def sign_in(as: nil) + Runtime::Browser.visit(:gitlab, Page::Main::Login) + Page::Main::Login.perform { |login| login.sign_in_using_credentials(user: as) } +end + +# When used in a test + +it 'performs a test after signing in as the default user' do + Flow::Login.sign_in + + # Perform the test +end +``` + +`QA::Flow::Login` provides an even more useful flow, allowing a test to easily switch users. + +```ruby +# QA::Flow::Login + +def while_signed_in(as: nil) + Page::Main::Menu.perform(&:sign_out_if_signed_in) + + sign_in(as: as) + + yield + + Page::Main::Menu.perform(&:sign_out) +end + +# When used in a test + +it 'performs a test as one user and verifies as another' do + user1 = Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_1, Runtime::Env.gitlab_qa_password_1) + user2 = Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_2, Runtime::Env.gitlab_qa_password_2) + + Flow::Login.while_signed_in(as: user1) do + # Perform some setup as user1 + end + + Flow::Login.sign_in(as: user2) + + # Perform the rest of the test as user2 +end +``` diff --git a/doc/development/testing_guide/end_to_end/index.md b/doc/development/testing_guide/end_to_end/index.md index a9fb4be284e3b360808571f67972563750c77111..19885f5756f55ee2122de020b8d36434dfe1acc7 100644 --- a/doc/development/testing_guide/end_to_end/index.md +++ b/doc/development/testing_guide/end_to_end/index.md @@ -130,6 +130,8 @@ Continued reading: - [Quick Start Guide](quick_start_guide.md) - [Style Guide](style_guide.md) - [Best Practices](best_practices.md) +- [Testing with feature flags](feature_flags.md) +- [Flows](flows.md) ## Where can I ask for help? 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 28111c18378adda29a6c6410e2343b2d48448a25..554995fa2e22b37dd80f073cf6a5c2dc5503ed3e 100644 --- a/doc/development/testing_guide/end_to_end/page_objects.md +++ b/doc/development/testing_guide/end_to_end/page_objects.md @@ -167,6 +167,65 @@ There are two supported methods of defining elements within a view. Any existing `.qa-selector` class should be considered deprecated and we should prefer the `data-qa-selector` method of definition. +### Dynamic element selection + +> 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? +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. + +We got around this by adding the `data-qa-*` extensible selection mechanism. + +#### Examples + +**Example 1** + +Given the following Rails view (using GitLab Issues as an example): + +```haml +%ul.issues-list + - @issues.each do |issue| + %li.issue{data: { qa_selector: 'issue', qa_issue_title: issue.title } }= link_to issue +``` + +We can select on that specific issue by matching on the Rails model. + +```ruby +class Page::Project::Issues::Index < Page::Base + def has_issue?(issue) + has_element? :issue, issue_title: issue + end +end +``` + +In our test, we can validate that this particular issue exists. + +```ruby +describe 'Issue' do + it 'has an issue titled "hello"' do + Page::Project::Issues::Index.perform do |index| + expect(index).to have_issue('hello') + end + end +end +``` + +**Example 2** + +*By an index...* + +```haml +%ol + - @some_model.each_with_index do |model, idx| + %li.model{ data: { qa_selector: 'model', qa_index: idx } } +``` + +```ruby +expect(the_page).to have_element(:model, index: 1) #=> select on the first model that appears in the list +``` + ### Exceptions In some cases it might not be possible or worthwhile to add a selector. diff --git a/doc/development/testing_guide/flaky_tests.md b/doc/development/testing_guide/flaky_tests.md index 0823c2e02b81aac93717fb1c511bddbfad4ba28a..3a96f8204fc8668bce2eb40b808726aee117b207 100644 --- a/doc/development/testing_guide/flaky_tests.md +++ b/doc/development/testing_guide/flaky_tests.md @@ -83,6 +83,7 @@ This was originally implemented in: <https://gitlab.com/gitlab-org/gitlab-foss/m - In JS tests, shifting elements can cause Capybara to misclick when the element moves at the exact time Capybara sends the click - [Dropdowns rendering upward or downward due to window size and scroll position](https://gitlab.com/gitlab-org/gitlab/merge_requests/17660) - [Lazy loaded images can cause Capybara to misclick](https://gitlab.com/gitlab-org/gitlab/merge_requests/18713) +- [Triggering JS events before the event handlers are set up](https://gitlab.com/gitlab-org/gitlab/merge_requests/18742) #### Capybara viewport size related issues diff --git a/doc/development/testing_guide/frontend_testing.md b/doc/development/testing_guide/frontend_testing.md index 314995ca9b3d99cfb1cdfe72e2bf670c5b775bb3..236f175cee5b366ad4becd9ffa74c219ca3d8eb7 100644 --- a/doc/development/testing_guide/frontend_testing.md +++ b/doc/development/testing_guide/frontend_testing.md @@ -119,6 +119,50 @@ Global mocks introduce magic and can affect how modules are imported in your tes When in doubt, construct mocks in your test file using [`jest.mock()`](https://jestjs.io/docs/en/jest-object#jestmockmodulename-factory-options), [`jest.spyOn()`](https://jestjs.io/docs/en/jest-object#jestspyonobject-methodname), etc. +### Data-driven tests + +Similar to [RSpec's parameterized tests](best_practices.md#table-based--parameterized-tests), +Jest supports data-driven tests for: + +- Individual tests using [`test.each`](https://jestjs.io/docs/en/api#testeachtable-name-fn-timeout) (aliased to `it.each`). +- Groups of tests using [`describe.each`](https://jestjs.io/docs/en/api#describeeachtable-name-fn-timeout). + +These can be useful for reducing repetition within tests. Each option can take an array of +data values or a tagged template literal. + +For example: + +```javascript +// function to test +const icon = status => status ? 'pipeline-passed' : 'pipeline-failed' +const message = status => status ? 'pipeline-passed' : 'pipeline-failed' + +// test with array block +it.each([ + [false, 'pipeline-failed'], + [true, 'pipeline-passed'] +])('icon with %s will return %s', + (status, icon) => { + expect(renderPipeline(status)).toEqual(icon) + } +); + +// test suite with tagged template literal block +describe.each` + status | icon | message + ${false} | ${'pipeline-failed'} | ${'Pipeline failed - boo-urns'} + ${true} | ${'pipeline-passed'} | ${'Pipeline succeeded - win!'} +`('pipeline component', ({ status, icon, message }) => { + it(`returns icon ${icon} with status ${status}`, () => { + expect(icon(status)).toEqual(message) + }) + + it(`returns message ${message} with status ${status}`, () => { + expect(message(status)).toEqual(message) + }) +}); +``` + ## Karma test suite GitLab uses the [Karma][karma] test runner with [Jasmine] as its test @@ -457,6 +501,39 @@ it('waits for an event', () => { }); ``` +#### Ensuring that tests are isolated + +Tests are normally architected in a pattern which requires a recurring setup and breakdown of the component under test. This is done by making use of the `beforeEach` and `afterEach` hooks. + +Example + +```javascript + let wrapper; + + beforeEach(() => { + wrapper = mount(Component); + }); + + afterEach(() => { + wrapper.destroy(); + }); +``` + +When looking at this initially you'd suspect that the component is setup before each test and then broken down afterwards, providing isolation between tests. + +This is however not entirely true as the `destroy` method does not remove everything which has been mutated on the `wrapper` object. For functional components, destroy only removes the rendered DOM elements from the document. + +In order to ensure that a clean wrapper object and DOM are being used in each test, the breakdown of the component should rather be performed as follows: + +```javascript + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); +``` + +See also the [Vue Test Utils documention on `destroy`](https://vue-test-utils.vuejs.org/api/wrapper/#destroy). + #### Migrating flaky Karma tests to Jest Some of our Karma tests are flaky because they access the properties of a shared scope. diff --git a/doc/development/testing_guide/review_apps.md b/doc/development/testing_guide/review_apps.md index 3dd403f148e7b2bfa40ba547eed44a74e00e868a..ecfcbc731e10b087944801d8baa1f7acf788937c 100644 --- a/doc/development/testing_guide/review_apps.md +++ b/doc/development/testing_guide/review_apps.md @@ -189,10 +189,10 @@ that the `review-apps-ce/ee` cluster is unhealthy. Leading indicators may be hea The following items may help diagnose this: -- [Instance group CPU Utilization in GCP](https://console.cloud.google.com/compute/instanceGroups/details/us-central1-b/gke-review-apps-ee-preemp-n1-standard-8affc0f5-grp?project=gitlab-review-apps&tab=monitoring&graph=GCE_CPU&duration=P30D) - helpful to identify if nodes are problematic or the entire cluster is trending towards unhealthy -- [Instance Group size in GCP](https://console.cloud.google.com/compute/instanceGroups/details/us-central1-b/gke-review-apps-ee-preemp-n1-standard-8affc0f5-grp?project=gitlab-review-apps&tab=monitoring&graph=GCE_SIZE&duration=P30D) - aids in identifying load spikes on the cluster. Kubernetes will add nodes up to 220 based on total resource requests. -- `kubectl top nodes --sort-by=cpu` - can identify if node spikes are common or load on specific nodes which may get rebalanced by the Kubernetes scheduler. -- `kubectl top pods --sort-by=cpu` - +- [Review Apps Health dashboard](https://app.google.stackdriver.com/dashboards/6798952013815386466?project=gitlab-review-apps&timeDomain=1d) + - Aids in identifying load spikes on the cluster, and if nodes are problematic or the entire cluster is trending towards unhealthy. +- `kubectl top nodes | sort --key 3 --numeric` - can identify if node spikes are common or load on specific nodes which may get rebalanced by the Kubernetes scheduler. +- `kubectl top pods | sort --key 2 --numeric` - - [K9s] - K9s is a powerful command line dashboard which allows you to filter by labels. This can help identify trends with apps exceeding the [review-app resource requests](https://gitlab.com/gitlab-org/gitlab/blob/master/scripts/review_apps/base-config.yaml). Kubernetes will schedule pods to nodes based on resource requests and allow for CPU usage up to the limits. - In K9s you can sort or add filters by typing the `/` character - `-lrelease=<review-app-slug>` - filters down to all pods for a release. This aids in determining what is having issues in a single deployment diff --git a/doc/development/understanding_explain_plans.md b/doc/development/understanding_explain_plans.md index 7c926c83a36e2a41c2e15ce62a55e03ef5ee072c..53b50b6332c8494ace5b4498ed8718ca28f0f4ff 100644 --- a/doc/development/understanding_explain_plans.md +++ b/doc/development/understanding_explain_plans.md @@ -705,6 +705,43 @@ For more information about the available options, run: /chatops run explain --help ``` +### `#database-lab` + +Another tool GitLab employees can use is a chatbot powered by [Joe](https://gitlab.com/postgres-ai/joe), available in the [`#database-lab`](https://gitlab.slack.com/archives/CLJMDRD8C) channel on Slack. +Unlike chatops, it gives you a way to execute DDL statements (like creating indexes and tables) and get query plan not only for `SELECT` but also `UPDATE` and `DELETE`. + +For example, in order to test new index you can do the following: + +Create the index: + +``` +exec CREATE INDEX index_projects_marked_for_deletion ON projects (marked_for_deletion_at) WHERE marked_for_deletion_at IS NOT NULL +``` + +Analyze the table to update its statistics: + +``` +exec ANALYZE projects +``` + +Get the query plan: + +``` +explain SELECT * FROM projects WHERE marked_for_deletion_at < CURRENT_DATE +``` + +Once done you can rollback your changes: + +``` +reset +``` + +For more information about the available options, run: + +``` +help +``` + ## Further reading A more extensive guide on understanding query plans can be found in diff --git a/doc/development/utilities.md b/doc/development/utilities.md index 38e416d68e4d8f8afd73a97928eb151b4e34c908..25869a0d2b52fc2f54ce909bb5d814b2cc53cb90 100644 --- a/doc/development/utilities.md +++ b/doc/development/utilities.md @@ -1,6 +1,6 @@ # GitLab utilities -We developed a number of utilities to ease development. +We have developed a number of utilities to help ease development: ## `MergeHash` @@ -51,15 +51,15 @@ Refer to: <https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/utils/mer Refer to <https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/utils/override.rb>: -- This utility could help us check if a particular method would override - another method or not. It has the same idea of Java's `@Override` annotation - or Scala's `override` keyword. However we only do this check when +- This utility can help you check if one method would override + another or not. It is the same concept as Java's `@Override` annotation + or Scala's `override` keyword. However, you should only do this check when `ENV['STATIC_VERIFICATION']` is set to avoid production runtime overhead. - This is useful to check: + This is useful for checking: - - If we have typos in overriding methods. - - If we renamed the overridden methods, making original overriding methods - overrides nothing. + - If you have typos in overriding methods. + - If you renamed the overridden methods, which make the original override methods + irrelevant. Here's a simple example: @@ -100,11 +100,11 @@ Refer to <https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/utils/stro - Memoize the value even if it is `nil` or `false`. - We often do `@value ||= compute`, however this doesn't work well if - `compute` might eventually give `nil` and we don't want to compute again. - Instead we could use `defined?` to check if the value is set or not. - However it's tedious to write such pattern, and `StrongMemoize` would - help us use such pattern. + We often do `@value ||= compute`. However, this doesn't work well if + `compute` might eventually give `nil` and you don't want to compute again. + Instead you could use `defined?` to check if the value is set or not. + It's tedious to write such pattern, and `StrongMemoize` would + help you use such pattern. Instead of writing patterns like this: @@ -118,7 +118,7 @@ Refer to <https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/utils/stro end ``` - We could write it like: + You could write it like: ``` ruby class Find @@ -151,7 +151,7 @@ and the cache key would be based on the class name, method name, optionally customized instance level values, optionally customized method level values, and optional method arguments. -A simple example that only uses the instance level customised values: +A simple example that only uses the instance level customised values is: ``` ruby class UserAccess @@ -169,8 +169,8 @@ end This way, the result of `can_push_to_branch?` would be cached in `RequestStore.store` based on the cache key. If `RequestStore` is not -currently active, then it would be stored in a hash saved in an -instance variable, so the cache logic would be the same. +currently active, then it would be stored in a hash, and saved in an +instance variable so the cache logic would be the same. We can also set different strategies for different methods: @@ -184,3 +184,83 @@ class Commit request_cache(:author) { author_email } end ``` + +## `ReactiveCaching` + +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 will stop being refreshed, and then be removed. + +Example of use: + +```ruby +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. + +```ruby +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`, therefore, it will keep the stored value up to date. +Calculations are never run concurrently. + +Calling `#result` while a value is cached will call the block given to +`#with_reactive_cache`, yielding the cached value. It will also extend the +lifetime by the `reactive_cache_lifetime` value. + +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. diff --git a/doc/gitlab-basics/README.md b/doc/gitlab-basics/README.md index fc3d36910f2dc35315900cf7b15338e6ebec1e09..258a85d04743e3b718c001d61f60c7a107273870 100644 --- a/doc/gitlab-basics/README.md +++ b/doc/gitlab-basics/README.md @@ -23,6 +23,7 @@ The following are guides to basic GitLab functionality: - [Create a group](../user/group/index.md#create-a-new-group), to combine and administer projects together. - [Create a branch](create-branch.md), to make changes to files stored in a project's repository. +- [Feature branch workflow](feature_branch_workflow.md). - [Fork a project](fork-project.md), to duplicate projects so they can be worked on in parallel. - [Add a file](add-file.md), to add new files to a project's repository. - [Create an issue](../user/project/issues/managing_issues.md#create-a-new-issue), @@ -30,7 +31,7 @@ The following are guides to basic GitLab functionality: - [Create a merge request](add-merge-request.md), to request changes made in a branch be merged into a project's repository. - See how these features come together in the [GitLab Flow introduction video](https://youtu.be/InKNIvky2KE) - and [GitLab Flow page](../workflow/gitlab_flow.md). + and [GitLab Flow page](../topics/gitlab_flow.md). ## Working with Git from the command line diff --git a/doc/gitlab-basics/feature_branch_workflow.md b/doc/gitlab-basics/feature_branch_workflow.md new file mode 100644 index 0000000000000000000000000000000000000000..2b641126d0d981256f7f7f3dfdf8234f650f190b --- /dev/null +++ b/doc/gitlab-basics/feature_branch_workflow.md @@ -0,0 +1,35 @@ +--- +disqus_identifier: 'https://docs.gitlab.com/ee/workflow/workflow.html' +--- + +# Feature branch workflow + +1. Clone project: + + ```bash + git clone git@example.com:project-name.git + ``` + +1. Create branch with your feature: + + ```bash + git checkout -b $feature_name + ``` + +1. Write code. Commit changes: + + ```bash + git commit -am "My feature is ready" + ``` + +1. Push your branch to GitLab: + + ```bash + git push origin $feature_name + ``` + +1. Review your code on commits page. + +1. Create a merge request. + +1. Your team lead will review the code & merge it to the main branch. diff --git a/doc/gitlab-basics/fork-project.md b/doc/gitlab-basics/fork-project.md index 5c19985121df132a39e63d55585412073568ff91..e92491a0821ae12ae1d2406c312790d04dc8bfa0 100644 --- a/doc/gitlab-basics/fork-project.md +++ b/doc/gitlab-basics/fork-project.md @@ -8,4 +8,4 @@ A fork is a copy of an original repository that you put in another namespace where you can experiment and apply changes that you can later decide whether or not to share, without affecting the original project. -It takes just a few steps to [fork a project in GitLab](../workflow/forking_workflow.md#creating-a-fork). +It takes just a few steps to [fork a project in GitLab](../user/project/repository/forking_workflow.md#creating-a-fork). diff --git a/doc/gitlab-basics/start-using-git.md b/doc/gitlab-basics/start-using-git.md index 05329993c64c8827c48528eaf6a84123cc266fee..1f43b151d5d94a3c11f583cb2e7214db9bbafd3c 100644 --- a/doc/gitlab-basics/start-using-git.md +++ b/doc/gitlab-basics/start-using-git.md @@ -287,7 +287,7 @@ git reset HEAD~1 This leaves the changed files and folders unstaged in your local repository. CAUTION: **Warning:** -A Git commit should not usually be reverse, particularly if you already pushed it +A Git commit should not usually be reversed, particularly if you already pushed it to the remote repository. Although you can undo a commit, the best option is to avoid the situation altogether by working carefully. diff --git a/doc/install/README.md b/doc/install/README.md index b906deadca9291cc99bf5f43433462190e3092c4..441826687aac5b43c1483d52f65cc182b09c7f46 100644 --- a/doc/install/README.md +++ b/doc/install/README.md @@ -7,7 +7,7 @@ type: index # Installation **(CORE ONLY)** GitLab can be installed in most GNU/Linux distributions and in a number -of cloud providers. To get the best experience from GitLab you need to balance +of cloud providers. To get the best experience from GitLab, you need to balance performance, reliability, ease of administration (backups, upgrades and troubleshooting), and cost of hosting. diff --git a/doc/install/aws/index.md b/doc/install/aws/index.md index 2dea763688e7e12a0a8d7bba3096c778dc213765..c1dde05196caa88b83d64dc3c7d14b9e81976ee3 100644 --- a/doc/install/aws/index.md +++ b/doc/install/aws/index.md @@ -539,7 +539,7 @@ which would otherwise take much space. In particular, you can store in S3: -- [The Git LFS objects](../../workflow/lfs/lfs_administration.md#s3-for-omnibus-installations) ((Omnibus GitLab installations)) +- [The Git LFS objects](../../administration/lfs/lfs_administration.md#s3-for-omnibus-installations) ((Omnibus GitLab installations)) - [The Container Registry images](../../administration/packages/container_registry.md#container-registry-storage-driver) (Omnibus GitLab installations) - [The GitLab CI/CD job artifacts](../../administration/job_artifacts.md#using-object-storage) (Omnibus GitLab installations) diff --git a/doc/install/installation.md b/doc/install/installation.md index dd4b5544659f57a4d91ed6a1f3b5768dc4471df7..98094ca1185140966357f494ff1eb8e8373a3729 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -13,7 +13,7 @@ If you want to install on RHEL/CentOS, we recommend using the [Omnibus packages](https://about.gitlab.com/install/). This guide is long because it covers many cases and includes all commands you -need, this is [one of the few installation scripts that actually works out of the box](https://twitter.com/robinvdvleuten/status/424163226532986880). +need, this is [one of the few installation scripts that actually work out of the box](https://twitter.com/robinvdvleuten/status/424163226532986880). The following steps have been known to work. **Use caution when you deviate** from this guide. Make sure you don't violate any assumptions GitLab makes about its environment. For example, many people run into permission problems because @@ -35,7 +35,7 @@ After this termination runit will detect Sidekiq is not running and will start i Since installations from source don't use runit for process supervision, Sidekiq can't be terminated and its memory usage will grow over time. -## Select version to install +## Select a version to install Make sure you view [this installation guide](https://gitlab.com/gitlab-org/gitlab/blob/master/doc/install/installation.md) from the branch (version) of GitLab you would like to install (e.g., `11-7-stable`). You can select the branch in the version dropdown in the top left corner of GitLab (below the menu bar). @@ -56,7 +56,7 @@ of this page: | |-- repositories ``` -- `/home/git/.ssh` - Contains OpenSSH settings. Specifically the `authorized_keys` +- `/home/git/.ssh` - Contains OpenSSH settings. Specifically, the `authorized_keys` file managed by GitLab Shell. - `/home/git/gitlab` - GitLab core software. - `/home/git/gitlab-shell` - Core add-on component of GitLab. Maintains SSH @@ -183,7 +183,7 @@ sudo make prefix=/usr/local install # When editing config/gitlab.yml (Step 5), change the git -> bin_path to /usr/local/bin/git ``` -For the [Custom Favicon](../customization/favicon.md) to work, GraphicsMagick +For the [Custom Favicon](../user/admin_area/appearance.md#favicon) to work, GraphicsMagick needs to be installed. ```sh @@ -315,7 +315,7 @@ use of extensions and concurrent index removal, you need at least PostgreSQL 9.2 sudo -u postgres psql -d template1 -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;" ``` -1. Create the GitLab production database and grant all privileges on database: +1. Create the GitLab production database and grant all privileges on the database: ```sh sudo -u postgres psql -d template1 -c "CREATE DATABASE gitlabhq_production OWNER git;" @@ -397,7 +397,7 @@ sudo usermod -aG redis git ## 8. GitLab ```sh -# We'll install GitLab into home directory of the user "git" +# We'll install GitLab into the home directory of the user "git" cd /home/git ``` @@ -424,7 +424,7 @@ cd /home/git/gitlab # Copy the example GitLab config sudo -u git -H cp config/gitlab.yml.example config/gitlab.yml -# Update GitLab config file, follow the directions at top of file +# Update GitLab config file, follow the directions at top of the file sudo -u git -H editor config/gitlab.yml # Copy the example secrets file @@ -465,7 +465,7 @@ nproc # Enable cluster mode if you expect to have a high load instance # Set the number of workers to at least the number of cores -# Ex. change amount of workers to 3 for 2GB RAM server +# Ex. change the amount of workers to 3 for 2GB RAM server sudo -u git -H editor config/unicorn.rb # Copy the example Rack attack config @@ -588,7 +588,7 @@ You can specify a different Git repository by providing it as an extra parameter sudo -u git -H bundle exec rake "gitlab:workhorse:install[/home/git/gitlab-workhorse,https://example.com/gitlab-workhorse.git]" RAILS_ENV=production ``` -### Install GitLab-Elasticsearch-indexer` +### Install GitLab-Elasticsearch-indexer GitLab-Elasticsearch-Indexer uses [GNU Make](https://www.gnu.org/software/make/). The following command-line will install GitLab-Elasticsearch-Indexer in `/home/git/gitlab-elasticsearch-indexer` @@ -670,7 +670,7 @@ sudo -u git -H bundle exec rake gitlab:setup RAILS_ENV=production # or you can skip the question by adding force=yes sudo -u git -H bundle exec rake gitlab:setup RAILS_ENV=production force=yes -# When done you see 'Administrator account created:' +# When done, you see 'Administrator account created:' ``` NOTE: **Note:** @@ -684,7 +684,7 @@ sudo -u git -H bundle exec rake gitlab:setup RAILS_ENV=production GITLAB_ROOT_PA The `secrets.yml` file stores encryption keys for sessions and secure variables. Backup `secrets.yml` someplace safe, but don't store it in the same place as your database backups. -Otherwise your secrets are exposed if one of your backups is compromised. +Otherwise, your secrets are exposed if one of your backups is compromised. ### Install Init Script @@ -835,7 +835,7 @@ initial administrator account. Enter your desired password and you'll be redirected back to the login screen. The default account's username is **root**. Provide the password you created -earlier and login. After login you can change the username if you wish. +earlier and login. After login, you can change the username if you wish. **Enjoy!** @@ -905,7 +905,7 @@ for the changes to take effect. ### Custom Redis Connection -If you'd like to connect to a Redis server on a non-standard port or on a different host, you can configure its connection string via the `config/resque.yml` file. +If you'd like to connect to a Redis server on a non-standard port or a different host, you can configure its connection string via the `config/resque.yml` file. ``` # example @@ -921,7 +921,7 @@ production: url: unix:/path/to/redis/socket ``` -Also you can use environment variables in the `config/resque.yml` file: +Also, you can use environment variables in the `config/resque.yml` file: ``` # example diff --git a/doc/install/openshift_and_gitlab/index.md b/doc/install/openshift_and_gitlab/index.md index 010e56fb0974c90f34c88c1feebd5b3a7f1d346f..181d4414a9bfdd1de9787beee497bf432e58de2d 100644 --- a/doc/install/openshift_and_gitlab/index.md +++ b/doc/install/openshift_and_gitlab/index.md @@ -23,8 +23,6 @@ tools that will help us achieve our goal. For a video demonstration on installing GitLab on OpenShift, check the article [In 13 minutes from Kubernetes to a complete application development tool](https://about.gitlab.com/blog/2016/11/14/idea-to-production/). ---- - ## Prerequisites CAUTION: **Caution:** This information is no longer up to date, as the current versions diff --git a/doc/install/requirements.md b/doc/install/requirements.md index 8b53ee7c3e150efc406a25391af0997d11b4d404..ecd6516bd2e3ebbfc2fbbc014cbaa7c4e1982d2c 100644 --- a/doc/install/requirements.md +++ b/doc/install/requirements.md @@ -35,7 +35,7 @@ Please see the [installation from source guide](installation.md) and the [instal ### Microsoft Windows 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-foss/issues/46567). +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 diff --git a/doc/integration/README.md b/doc/integration/README.md index 3a08303bf209384a5801750e7b55b6b30b286d88..3f33aa94cb902c76bb0841213d1f01ef1d73c580 100644 --- a/doc/integration/README.md +++ b/doc/integration/README.md @@ -2,45 +2,71 @@ comments: false --- -# GitLab Integration - -GitLab integrates with multiple third-party services to allow external issue -trackers and external authentication. - -See the documentation below for details on how to configure these services. - -- [Akismet](akismet.md) Configure Akismet to stop spam -- [Auth0 OmniAuth](auth0.md) Enable the Auth0 OmniAuth provider -- [Bitbucket](bitbucket.md) Import projects from Bitbucket.org and login to your GitLab instance with your Bitbucket.org account -- [CAS](cas.md) Configure GitLab to sign in using CAS -- [External issue tracker](external-issue-tracker.md) Redmine, Jira, etc. -- [Gmail actions buttons](gmail_action_buttons_for_gitlab.md) Adds GitLab actions to messages -- [Jenkins](jenkins.md) Integrate with the Jenkins CI -- [Jira](../user/project/integrations/jira.md) Integrate with the Jira issue tracker -- [Kerberos](kerberos.md) Integrate with Kerberos -- [LDAP](ldap.md) Set up sign in via LDAP -- [OAuth2 provider](oauth_provider.md) OAuth2 application creation -- [OmniAuth](omniauth.md) Sign in via Twitter, GitHub, GitLab.com, Google, Bitbucket, Facebook, Shibboleth, SAML, Crowd, Azure and Authentiq ID -- [OpenID Connect](openid_connect_provider.md) Use GitLab as an identity provider -- [PlantUML](../administration/integration/plantuml.md) Configure PlantUML to use diagrams in AsciiDoc documents. -- [reCAPTCHA](recaptcha.md) Configure GitLab to use Google reCAPTCHA for new users -- [SAML](saml.md) Configure GitLab as a SAML 2.0 Service Provider -- [Trello](trello_power_up.md) Integrate Trello with GitLab - -> GitLab Enterprise Edition contains [advanced Jenkins support](jenkins.md). +# GitLab integrations + +GitLab can be integrated with external services for enhanced functionality. + +## Issue trackers + +You can use an [external issue tracker](external-issue-tracker.md) at the same time as the GitLab issue tracker, or use only the external issue tracker. + +GitLab can be integrated with the following external issue trackers: + +- Jira +- Redmine +- Bugzilla +- YouTrack + +## Authentication sources + +GitLab can be configured to authenticate access requests with the following authentication sources: + +- Enable the [Auth0 OmniAuth](auth0.md) provider. +- Enable sign in with [Bitbucket](bitbucket.md) accounts. +- Configure GitLab to sign in using [CAS](cas.md). +- Integrate with [Kerberos](kerberos.md). +- Enable sign in via [LDAP](ldap.md). +- Enable [OAuth2 provider](oauth_provider.md) application creation. +- Use [OmniAuth](omniauth.md) to enable sign in via Twitter, GitHub, GitLab.com, Google, +Bitbucket, Facebook, Shibboleth, SAML, Crowd, Azure or Authentiq ID. +- Use GitLab as an [OpenID Connect](openid_connect_provider.md) identity provider. +- Configure GitLab as a [SAML](saml.md) 2.0 Service Provider. + +## Security enhancements + +GitLab can be integrated with the following external services to enhance security: + +- [Akismet](akismet.md) helps reduce spam. +- Google [reCAPTCHA](recaptcha.md) helps verify new users. + +GitLab also provides features to improve the security of your own application. For more details see [GitLab Secure](../user/application_security/index.md). + +## Continuous integration + +GitLab can be integrated with the following external service for continuous integration: + +- [Jenkins](jenkins.md) CI. **(STARTER)** + +## Feature enhancements + +GitLab can be integrated with the following enhancements: + +- Add GitLab actions to [Gmail actions buttons](gmail_action_buttons_for_gitlab.md). +- Configure [PlantUML](../administration/integration/plantuml.md) to use diagrams in AsciiDoc documents. +- Attach merge requests to [Trello](trello_power_up.md) cards. +- Enable integrated code intelligence powered by [Sourcegraph](sourcegraph.md). ## Project services -Integration with services such as Campfire, Flowdock, HipChat, -Pivotal Tracker, and Slack are available in the form of a [Project Service][]. +Integration with services such as Campfire, Flowdock, HipChat, Pivotal Tracker, and Slack are available as [Project Services](../user/project/integrations/project_services.md). + +## Troubleshooting -[Project Service]: ../user/project/integrations/project_services.md +### SSL certificate errors -## SSL certificate errors +When trying to integrate GitLab with services that are using self-signed certificates, it is very likely that SSL certificate errors will occur in different parts of the application, most likely Sidekiq. -When trying to integrate GitLab with services that are using self-signed certificates, -it is very likely that SSL certificate errors will occur on different parts of the -application, most likely Sidekiq. There are 2 approaches you can take to solve this: +There are two approaches you can take to solve this: 1. Add the root certificate to the trusted chain of the OS. 1. If using Omnibus, you can add the certificate to GitLab's trusted certificates. @@ -61,12 +87,12 @@ in to GitLab Omnibus. It is enough to concatenate the certificate to the main trusted certificate however it may be overwritten during upgrades: -```bash +```shell cat jira.pem >> /opt/gitlab/embedded/ssl/certs/cacert.pem ``` After that restart GitLab with: -```bash +```shell sudo gitlab-ctl restart ``` diff --git a/doc/integration/bitbucket.md b/doc/integration/bitbucket.md index 63ffa69e6066e8f2f25e609d20517b4e9acc13c6..7cead234709438cd4fd6d645b9029afc9a7f880a 100644 --- a/doc/integration/bitbucket.md +++ b/doc/integration/bitbucket.md @@ -24,7 +24,7 @@ Bitbucket.org. GitLab 8.15 significantly simplified the way to integrate Bitbucket.org with GitLab. You are encouraged to upgrade your GitLab instance if you haven't done so already. If you're using GitLab 8.14 or below, [use the previous integration -docs][bb-old]. +docs](https://gitlab.com/gitlab-org/gitlab/blob/8-14-stable-ee/doc/integration/bitbucket.md). To enable the Bitbucket OmniAuth provider you must register your application with Bitbucket.org. Bitbucket will generate an application ID and secret key for @@ -135,9 +135,6 @@ GitLab and [start importing your projects][bb-import]. If you want to import projects from Bitbucket, but don't want to enable signing in, you can [disable Sign-Ins in the admin panel](omniauth.md#enable-or-disable-sign-in-with-an-omniauth-provider-without-disabling-import-sources). -[init-oauth]: omniauth.md#initial-omniauth-configuration -[bb-import]: ../workflow/importing/import_projects_from_bitbucket.md -[bb-old]: https://gitlab.com/gitlab-org/gitlab/blob/8-14-stable/doc/integration/bitbucket.md -[bitbucket-docs]: https://confluence.atlassian.com/bitbucket/use-the-ssh-protocol-with-bitbucket-cloud-221449711.html#UsetheSSHprotocolwithBitbucketCloud-KnownhostorBitbucket%27spublickeyfingerprints +[bb-import]: ../user/project/import/bitbucket.md [reconfigure GitLab]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure [restart]: ../administration/restart_gitlab.md#installations-from-source diff --git a/doc/integration/elasticsearch.md b/doc/integration/elasticsearch.md index da53987ce1b42dd1829597733b08e6f05aa51ce4..5c77bd5bcd97eeacfeccd3a2428d6909a8f46297 100644 --- a/doc/integration/elasticsearch.md +++ b/doc/integration/elasticsearch.md @@ -583,3 +583,12 @@ 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. + +### Reverting to basic search + +Sometimes there may be issues with your Elasticsearch index data and as such +GitLab will allow you to revert to "basic search" when there are no search +results and assuming that basic search is supported in that scope. This "basic +search" will behave as though you don't have Elasticsearch enabled at all for +your instance and search using other data sources (ie. Postgres data and Git +data). diff --git a/doc/integration/img/sourcegraph_admin_v12_5.png b/doc/integration/img/sourcegraph_admin_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..23e38f566193f206c2d547521bdeb7f10295a7e5 Binary files /dev/null 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 new file mode 100644 index 0000000000000000000000000000000000000000..c70448c0a8a479f7be409f2f9886be5bad04115e Binary files /dev/null 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 new file mode 100644 index 0000000000000000000000000000000000000000..878d61436464e87c2584933d440be6070ebef698 Binary files /dev/null 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 new file mode 100644 index 0000000000000000000000000000000000000000..2c0e138e296c17c248f163bb6c45e8cbedd8e3ee Binary files /dev/null and b/doc/integration/img/sourcegraph_user_preferences_v12_5.png differ diff --git a/doc/integration/saml.md b/doc/integration/saml.md index b72be55aca31e30b3b347a07ea2c1cd7d4db6038..099cab0f5b8eda06c9a0dbfbb64612c40696cac1 100644 --- a/doc/integration/saml.md +++ b/doc/integration/saml.md @@ -572,7 +572,7 @@ installations from source. Restart Unicorn using the `sudo gitlab-ctl restart un command on Omnibus installations and `sudo service gitlab restart` on installations from source. -You may also find the [SSO Tracer](https://addons.mozilla.org/en-US/firefox/addon/sso-tracer/) +You may also find the [SAML Tracer](https://addons.mozilla.org/en-US/firefox/addon/saml-tracer/) (Firefox) and [SAML Chrome Panel](https://chrome.google.com/webstore/detail/saml-chrome-panel/paijfdbeoenhembfhkhllainmocckace) (Chrome) browser extensions useful in your debugging. diff --git a/doc/integration/slash_commands.md b/doc/integration/slash_commands.md index b8842ef3a438ab1a98df6d8dba96f5db101e2820..bc2f190920cb39c7c587118dc41d2c71691d293b 100644 --- a/doc/integration/slash_commands.md +++ b/doc/integration/slash_commands.md @@ -18,6 +18,7 @@ Taking the trigger term as `project-name`, the commands are: | `/project-name issue close <id>` | Closes the issue with id `<id>` | | `/project-name issue search <query>` | Shows up to 5 issues matching `<query>` | | `/project-name issue move <id> to <project>` | Moves issue ID `<id>` to `<project>` | +| `/project-name issue comment <id> <shift+return> <comment>` | Adds a new comment to an issue with id `<id>` and comment body `<comment>` | | `/project-name deploy <from> to <to>` | Deploy from the `<from>` environment to the `<to>` environment | | `/project-name run <job name> <arguments>` | Execute [ChatOps](../ci/chatops/README.md) job `<job name>` on `master` | diff --git a/doc/integration/sourcegraph.md b/doc/integration/sourcegraph.md new file mode 100644 index 0000000000000000000000000000000000000000..5e7cbdfbac3e42ccc150de55d968366d1fca5130 --- /dev/null +++ b/doc/integration/sourcegraph.md @@ -0,0 +1,128 @@ +--- +type: reference, how-to +--- + +# Sourcegraph integration + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/16556) in GitLab 12.5. Please note that this integration is in BETA and [behind a feature flag](#enable-the-sourcegraph-feature-flag). + +[Sourcegraph](https://sourcegraph.com) provides code intelligence features, natively integrated into the GitLab UI. + +For GitLab.com users, see [Sourcegraph for GitLab.com](#sourcegraph-for-gitlabcom). + + + +<i class="fa fa-youtube-play youtube" aria-hidden="true"></i> +For an overview, watch the video [Sourcegraph's new GitLab native integration](https://www.youtube.com/watch?v=LjVxkt4_sEA). + +NOTE: **Note:** +This feature requires user opt-in. After Sourcegraph has been enabled for your GitLab instance, +you can choose to enable Sourcegraph [through your user preferences](#enable-sourcegraph-in-user-preferences). + +## Set up for self-managed GitLab instances **(CORE ONLY)** + +Before you can enable Sourcegraph code intelligence in GitLab you will need to: + +- Enable the `sourcegraph` feature flag for your GitLab instance. +- Configure a Sourcegraph instance with your GitLab instance as an external service. + +### Enable the Sourcegraph feature flag + +NOTE: **Note:** +If you are running a self-managed instance, the Sourcegraph integration will not be available +unless the feature flag `sourcegraph` is enabled. This can be done from the Rails console +by instance administrators. + +Use these commands to start the Rails console: + +```sh +# Omnibus GitLab +gitlab-rails console + +# Installation from source +cd /home/git/gitlab +sudo -u git -H bin/rails console RAILS_ENV=production +``` + +Then run the following command to enable the feature flag: + +``` +Feature.enable(:sourcegraph) +``` + +You can also enable the feature flag only for specific projects with: + +``` +Feature.enable(:sourcegraph, Project.find_by_full_path('my_group/my_project')) +``` + +### Set up a self-managed Sourcegraph instance + +If you are new to Sourcegraph, head over to the [Sourcegraph installation documentation](https://docs.sourcegraph.com/admin) and get your instance up and running. + +### Connect your Sourcegraph instance to your GitLab instance + +1. Navigate to the site admin area in Sourcegraph. +1. [Configure your GitLab external service](https://docs.sourcegraph.com/admin/external_service/gitlab). +You can skip this step if you already have your GitLab repositories searchable in Sourcegraph. +1. Validate that you can search your repositories from GitLab in your Sourcegraph instance by running a test query. +1. Add your GitLab instance URL to the [`corsOrigin` setting](https://docs.sourcegraph.com/admin/config/site_config#corsOrigin) in your site configuration. + +### Configure your GitLab instance with Sourcegraph + +1. In GitLab, go to **Admin Area > Settings > Integrations**. +1. Expand the **Sourcegraph** configuration section. +1. Check **Enable Sourcegraph**. +1. Set the Sourcegraph URL to your Sourcegraph instance, e.g., `https://sourcegraph.example.com`. + + + +## Enable Sourcegraph in user preferences + +If a GitLab administrator has enabled Sourcegraph, you can enable this feature in your user preferences. + +1. In GitLab, click your avatar in the top-right corner, then click **Settings**. On the left-hand nav, click **Preferences**. +1. Under **Integrations**, find the **Sourcegraph** section. +1. Check **Enable Sourcegraph**. + + + +## Using Sourcegraph code intelligence + +Once enabled, participating projects will have a code intelligence popover available in +the following code views: + +- Merge request diffs +- Commit view +- File view + +When visiting one of these views, you can now hover over a code reference to see a popover with: + +- 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. + + + +## Sourcegraph for GitLab.com + +Sourcegraph powered code intelligence will be incrementally rolled out on GitLab.com. +It will eventually become available for all public projects, but for now, it is only +available for some specific [`gitlab-org` projects](https://gitlab.com/gitlab-org/). +This means that you can see it working and use it to dig into the code of these projects, +but you cannot use it on your own project on GitLab.com yet. + +If you would like to use it in your own projects as of GitLab 12.5, you can do so by +setting up a self-managed GitLab instance. + +Follow the epic [&2201](https://gitlab.com/groups/gitlab-org/-/epics/2201) for +updates. + +## Sourcegraph and Privacy + +From Sourcegraph's [extension documentation](https://docs.sourcegraph.com/integration/browser_extension#privacy) which is the +engine behind the native GitLab integration: + +> Sourcegraph integrations never send any logs, pings, usage statistics, or telemetry to Sourcegraph.com. +> They will only connect to Sourcegraph.com as required to provide code intelligence or other functionality on public code. +> As a result, no private code, private repository names, usernames, or any other specific data is sent to Sourcegraph.com. diff --git a/doc/integration/ultra_auth.md b/doc/integration/ultra_auth.md index fb950ba989a5afe26f1b4bde779445311b3137a0..83b2d7fe0969e8f0061c027e9d3c50ad186a9774 100644 --- a/doc/integration/ultra_auth.md +++ b/doc/integration/ultra_auth.md @@ -8,7 +8,7 @@ To enable UltraAuth OmniAuth provider, you must use UltraAuth's credentials for To get the credentials (a pair of Client ID and Client Secret), you must register an application on UltraAuth. 1. Sign in to [UltraAuth](https://ultraauth.com). -1. Navigate to [Create an App](https://ultraauth.com/select-strategy) and click on "Ruby on Rails". +1. Navigate to **Create an App** and click on **Ruby on Rails**. 1. Scroll down the page that is displayed to locate the **Client ID** and **Client Secret**. Keep this page open as you continue configuration. diff --git a/doc/intro/README.md b/doc/intro/README.md index 33b2337228063dd3982cc21e40e9b005d5d618fb..58cb11423d52747383b740193d5cf10c2272f09a 100644 --- a/doc/intro/README.md +++ b/doc/intro/README.md @@ -18,13 +18,13 @@ Create issues, labels, milestones, cast your vote, and review issues. - [Create an issue](../user/project/issues/managing_issues.md#create-a-new-issue) - [Assign labels to issues](../user/project/labels.md) - [Use milestones as an overview of your project's tracker](../user/project/milestones/index.md) -- [Use voting to express your like/dislike to issues and merge requests](../workflow/award_emoji.md) +- [Use voting to express your like/dislike to issues and merge requests](../user/award_emojis.md) ## Collaborate Create merge requests and review code. -- [Fork a project and contribute to it](../workflow/forking_workflow.md) +- [Fork a project and contribute to it](../user/project/repository/forking_workflow.md) - [Create a new merge request](../gitlab-basics/add-merge-request.md) - [Automatically close issues from merge requests](../user/project/issues/managing_issues.md#closing-issues-automatically) - [Automatically merge when pipeline succeeds](../user/project/merge_requests/merge_when_pipeline_succeeds.md) diff --git a/doc/policy/maintenance.md b/doc/policy/maintenance.md index d118c2f40cb84f3eb16d7f0472b94ff1a3ef812f..ef94236d71134dffe8d4881393890a7ac4277c28 100644 --- a/doc/policy/maintenance.md +++ b/doc/policy/maintenance.md @@ -30,21 +30,68 @@ The following table describes the version types and their release cadence: ## Patch releases -Patch releases usually only include bug fixes and are only done for the current -stable release. That said, in some cases, we may backport it to previous stable +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. + +These two policies are in place because: + +1. GitLab has Community and Enterprise distributions, doubling the amount of work +necessary to test/release the software. +1. Backporting to more than one release creates a high development, quality assurance, +and support cost. +1. Supporting parallel version discourages incremental upgrades which over time accumulate in +complexity and create upgrade challenges for all users. GitLab has a dedicated team ensuring that +incremental upgrades (and installations) are as simple as possible. +1. The number of changes created in the GitLab application is high, which contributes to backporting complexity to older releases. In number of cases, backporting has to go through the same +review process a new change goes through. +1. Ensuring that tests pass on older release is a considerable challenge in some cases, and as such is very time consuming. + +Including new features in patch releases is not possible as that would break [Semantic Versioning]. +Breaking [Semantic Versioning] has the following consequences for users that +have to adhere to various internal requirements (e.g. org. compliance, verifying new features and similar): + +1. Inability to quickly upgrade to leverage bug fixes included in patch versions. +1. Inability to quickly upgrade to leverage security fixes included in patch versions. +1. Requirements consisting of extensive testing for not only stable GitLab release, but every patch version. + +In cases where a strategic user has a requirement to test a feature before it is +officially released, we can offer to create a Release Candidate (RC) version that will +include the specific feature. This should be needed only in extreme cases, and can be requested for consideration by raising an issue in [release/tasks] issue tracker. +It is important to note that the Release Candidate will also contain other +features and changes as it is not possible to easily isolate a specific feature (similar reasons as noted above). The Release Candidate will be no different than any code that is deployed to GitLab.com or is publicly accessible. + +### Backporting to older releases + +Backporting to more than one stable release is reserved for [security releases](#security-releases). +In some cases however, we may need to backport *a bug fix* to more than one stable release, depending on the severity of the bug. -For instance, if we release `10.1.1` with a fix for a severe bug introduced in -`10.0.0`, we could backport the fix to a new `10.0.x` patch release. +Decision on whether backporting a change will be performed is done at the discretion of the [current release managers][release-managers], similar to what is described in the [managing bugs] process, based on *all* of the following: + +1. Estimated [severity][severity-labels] of the bug: Highest possible impact to users based on the current definition of severity. + +1. Estimated [priority][priority-labels] of the bug: Immediate impact on all impacted users based on the above estimated severity. + +1. Potentially incurring data loss and/or security breach. + +1. Potentially affecting one or more strategic accounts due to a proven inability by the user to upgrade to the current stable version. + +If *all* of the above are satisfied, the backport releases can be created for +the current stable stable release, and two previous monthly releases. +For instance, if we release `11.2.1` with a fix for a severe bug introduced in +`11.0.0`, we could backport the fix to a new `11.0.x`, and `11.1.x` patch release. + +To request backporting to more than one stable release for consideration, raise an issue in [release/tasks] issue tracker. ### 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 support one stable release at any given time, but for -medium-level security issues, we may backport security fixes to the previous two -monthly releases. +Our current policy is to backport security fixes to 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/) @@ -91,3 +138,9 @@ Please see the table below for some examples: 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/). + +[release-managers]: https://about.gitlab.com/community/release-managers/ +[priority-definition]: ../development/contributing/issue_workflow.md#priority-labels +[severity-labels]: ../development/contributing/issue_workflow.html#severity-labels +[managing bugs]: https://gitlab.com/gitlab-org/gitlab/blob/master/PROCESS.md#managing-bugs +[release/tasks]: https://gitlab.com/gitlab-org/release/tasks/issues diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md index fe9617c75ad70bbe328577888400f4e60065b111..006e998b1ab7f53b4ed48d29bb87aed46e5f851b 100644 --- a/doc/raketasks/backup_restore.md +++ b/doc/raketasks/backup_restore.md @@ -877,7 +877,7 @@ including (but not restricted to): - [Custom Pages domains](../user/project/pages/custom_domains_ssl_tls_certification/index.md) - [Project error tracking](../user/project/operations/error_tracking.md) - [Runner authentication](../ci/runners/README.md) -- [Project mirroring](../workflow/repository_mirroring.md) +- [Project mirroring](../user/project/repository/repository_mirroring.md) - [Web hooks](../user/project/integrations/webhooks.md) In cases like CI/CD variables and Runner authentication, you might diff --git a/doc/raketasks/cleanup.md b/doc/raketasks/cleanup.md index 67bf7cbd82882d5a350689ada48fa5dfeeb5c68d..937f15554b4209480ebb3daccf7c0e530d7a7347 100644 --- a/doc/raketasks/cleanup.md +++ b/doc/raketasks/cleanup.md @@ -88,7 +88,7 @@ gitlab-rake gitlab:cleanup:orphan_job_artifact_files DRY_RUN=false You can also limit the number of files to delete with `LIMIT`: ```shell -gitlab-rake gitlab:cleanup:orphan_job_artifact_files LIMIT=100` +gitlab-rake gitlab:cleanup:orphan_job_artifact_files LIMIT=100 ``` This will only delete up to 100 files from disk. You can use this to diff --git a/doc/security/webhooks.md b/doc/security/webhooks.md index b9af1ac108fc00e6b645ce0f9df83d5dd772a40d..cb9ad2b694c91f60b9d216bc83af4ea48f0ba63e 100644 --- a/doc/security/webhooks.md +++ b/doc/security/webhooks.md @@ -9,19 +9,24 @@ local network, these may be vulnerable to exploitation via Webhooks. With [Webhooks](../user/project/integrations/webhooks.md), you and your project maintainers and owners can set up URLs to be triggered when specific changes -occur in your projects. Normally, these requests are sent to external web services -specifically set up for this purpose, that process the request and its attached -data in some appropriate way. +occur in your projects. Normally, these requests are sent to external web +services specifically set up for this purpose, that process the request and its +attached data in some appropriate way. Things get hairy, however, when a Webhook is set up with a URL that doesn't point to an external, but to an internal service, that may do something completely unintended when the webhook is triggered and the POST request is sent. -Because Webhook requests are made by the GitLab server itself, these have -complete access to everything running on the server (`http://localhost:123`) or -within the server's local network (`http://192.168.1.12:345`), even if these -services are otherwise protected and inaccessible from the outside world. +Webhook requests are made by the GitLab server itself and use a single +(optional) secret token per hook for authorization (instead of a user or +repo-specific token). As a result, these may have broader access than +intended to everything running on the server hosting the webhook (which +may include the GitLab server or API itself, e.g., `http://localhost:123`). +Depending on the called webhook, this may also result in network access +to other servers within that webhook server's local network (e.g., +`http://192.168.1.12:345`), even if these services are otherwise protected +and inaccessible from the outside world. If a web service does not require authentication, Webhooks can be used to trigger destructive commands by getting the GitLab server to make POST requests diff --git a/doc/ssh/README.md b/doc/ssh/README.md index 07b426b7f2859e34b21e5dc04c77b21ec89fe511..01d86331a0a8d5a18b870b26136c752cd0bf53f1 100644 --- a/doc/ssh/README.md +++ b/doc/ssh/README.md @@ -51,7 +51,7 @@ GitLab supports RSA, DSA, ECDSA, and ED25519 keys. Their difference lies on the signing algorithm, and some of them have advantages over the others. For more information, you can read this [nice article on ArchWiki](https://wiki.archlinux.org/index.php/SSH_keys#Choosing_the_authentication_key_type). -We'll focus on ED25519 and RSA and here. +We'll focus on ED25519 and RSA here. NOTE: **Note:** As an admin, you can [restrict which keys should be permitted and their minimum length](../security/ssh_keys_restrictions.md). diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md index a1373639a8727e972e1b498491118411fdc05515..93549ac4de5804536b1b214936e5fe51d4fb3f1f 100644 --- a/doc/topics/autodevops/index.md +++ b/doc/topics/autodevops/index.md @@ -95,58 +95,85 @@ Auto DevOps. To make full use of Auto DevOps, you will need: +- **Kubernetes** (for Auto Review Apps, Auto Deploy, and Auto Monitoring) + + To enable deployments, you will need: + + 1. A [Kubernetes 1.12+ cluster](../../user/project/clusters/index.md) for the project. The easiest + way is to add a [new cluster using the GitLab UI](../../user/project/clusters/add_remove_clusters.md#add-new-cluster). + 1. NGINX Ingress. You can deploy it to your Kubernetes cluster by installing + the [GitLab-managed app for Ingress](../../user/clusters/applications.md#ingress), + once you have configured GitLab's Kubernetes integration in the previous step. + + Alternatively, you can use the + [`nginx-ingress`](https://github.com/helm/charts/tree/master/stable/nginx-ingress) + Helm chart to install Ingress manually. + + NOTE: **Note:** + If you are using your own Ingress instead of the one provided by GitLab's managed + apps, ensure you are running at least version 0.9.0 of NGINX Ingress and + [enable Prometheus metrics](https://github.com/helm/charts/tree/master/stable/nginx-ingress#prometheus-metrics) + in order for the response metrics to appear. You will also have to + [annotate](https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/) + the NGINX Ingress deployment to be scraped by Prometheus using + `prometheus.io/scrape: "true"` and `prometheus.io/port: "10254"`. + +- **Base domain** (for Auto Review Apps, Auto Deploy, and Auto Monitoring) + + You will need a domain configured with wildcard DNS which is going to be used + by all of your Auto DevOps applications. If you're using the + [GitLab-managed app for Ingress](../../user/clusters/applications.md#ingress), + the URL endpoint will be automatically configured for you. + + You will then need to [specify the Auto DevOps base domain](#auto-devops-base-domain). + - **GitLab Runner** (for all stages) Your Runner needs to be configured to be able to run Docker. Generally this means using either the [Docker](https://docs.gitlab.com/runner/executors/docker.html) or [Kubernetes](https://docs.gitlab.com/runner/executors/kubernetes.html) executors, with [privileged mode enabled](https://docs.gitlab.com/runner/executors/docker.html#use-docker-in-docker-with-privileged-mode). - The Runners do not need to be installed in the Kubernetes cluster, but the Kubernetes executor is easy to use and is automatically autoscaling. Docker-based Runners can be configured to autoscale as well, using [Docker Machine](https://docs.gitlab.com/runner/install/autoscaling.html). + If you have configured GitLab's Kubernetes integration in the first step, you + can deploy it to your cluster by installing the + [GitLab-managed app for GitLab Runner](../../user/clusters/applications.md#gitlab-runner). + Runners should be registered as [shared Runners](../../ci/runners/README.md#registering-a-shared-runner) for the entire GitLab instance, or [specific Runners](../../ci/runners/README.md#registering-a-specific-runner) - that are assigned to specific projects. -- **Base domain** (for Auto Review Apps and Auto Deploy) - - You will need a domain configured with wildcard DNS which is going to be used - by all of your Auto DevOps applications. - - Read the [specifics](#auto-devops-base-domain). -- **Kubernetes** (for Auto Review Apps, Auto Deploy, and Auto Monitoring) - - To enable deployments, you will need: + that are assigned to specific projects (the default if you have installed the + GitLab Runner managed application). - - Kubernetes 1.5+. - - A [Kubernetes cluster][kubernetes-clusters] for the project. - - A load balancer. You can use NGINX Ingress by deploying it to your - Kubernetes cluster by either: - - Using the [`nginx-ingress`](https://github.com/helm/charts/tree/master/stable/nginx-ingress) Helm chart. - - Installing the Ingress [GitLab Managed App](../../user/clusters/applications.md#ingress). - **Prometheus** (for Auto Monitoring) - To enable Auto Monitoring, you - will need Prometheus installed somewhere (inside or outside your cluster) and - configured to scrape your Kubernetes cluster. To get response metrics - (in addition to system metrics), you need to - [configure Prometheus to monitor NGINX](../../user/project/integrations/prometheus_library/nginx_ingress.md#configuring-nginx-ingress-monitoring). + To enable Auto Monitoring, you will need Prometheus installed somewhere + (inside or outside your cluster) and configured to scrape your Kubernetes cluster. + If you have configured GitLab's Kubernetes integration, you can deploy it to + your cluster by installing the + [GitLab-managed app for Prometheus](../../user/clusters/applications.md#prometheus). The [Prometheus service](../../user/project/integrations/prometheus.md) - integration needs to be enabled for the project, or enabled as a + integration needs to be enabled for the project (or enabled as a [default service template](../../user/project/integrations/services_templates.md) - for the entire GitLab installation. + for the entire GitLab installation). + + To get response metrics (in addition to system metrics), you need to + [configure Prometheus to monitor NGINX](../../user/project/integrations/prometheus_library/nginx_ingress.md#configuring-nginx-ingress-monitoring). If you do not have Kubernetes or Prometheus installed, then Auto Review Apps, Auto Deploy, and Auto Monitoring will be silently skipped. +One all requirements are met, you can go ahead and [enable Auto DevOps](#enablingdisabling-auto-devops). + ## Auto DevOps base domain -The Auto DevOps base domain is required if you want to make use of [Auto -Review Apps](#auto-review-apps) and [Auto Deploy](#auto-deploy). It can be defined -in any of the following places: +The Auto DevOps base domain is required if you want to make use of +[Auto Review Apps](#auto-review-apps), [Auto Deploy](#auto-deploy), and +[Auto Monitoring](#auto-monitoring). It can be defined in any of the following +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 @@ -156,9 +183,15 @@ in any of the following places: The base domain variable `KUBE_INGRESS_BASE_DOMAIN` follows the same order of precedence as other environment [variables](../../ci/variables/README.md#priority-of-environment-variables). -NOTE: **Note** -`AUTO_DEVOPS_DOMAIN` environment variable is deprecated and -[is scheduled to be removed](https://gitlab.com/gitlab-org/gitlab-foss/issues/56959). +TIP: **Tip:** +If you're using the [GitLab managed app for Ingress](../../user/clusters/applications.md#ingress), +the URL endpoint should be automatically configured for you. All you have to do +is use its value for the `KUBE_INGRESS_BASE_DOMAIN` variable. + +NOTE: **Note:** +`AUTO_DEVOPS_DOMAIN` was [deprecated in GitLab 11.8](https://gitlab.com/gitlab-org/gitlab-foss/issues/52363) +and replaced with `KUBE_INGRESS_BASE_DOMAIN`. It was removed in +[GitLab 12.0](https://gitlab.com/gitlab-org/gitlab-foss/issues/56959). A wildcard DNS A record matching the base domain(s) is required, for example, given a base domain of `example.com`, you'd need a DNS entry like: @@ -179,77 +212,28 @@ Auto DevOps base domain to `1.2.3.4.nip.io`. Once set up, all requests will hit the load balancer, which in turn will route them to the Kubernetes pods that run your application(s). -NOTE: **Note:** -From GitLab 11.8, `KUBE_INGRESS_BASE_DOMAIN` replaces `AUTO_DEVOPS_DOMAIN`. -Support for `AUTO_DEVOPS_DOMAIN` was [removed in GitLab -12.0](https://gitlab.com/gitlab-org/gitlab-foss/issues/56959). - -## Using multiple Kubernetes clusters **(PREMIUM)** - -When using Auto DevOps, you may want to deploy different environments to -different Kubernetes clusters. This is possible due to the 1:1 connection that -[exists between them](../../user/project/clusters/index.md#multiple-kubernetes-clusters-premium). - -In the [Auto DevOps template] (used behind the scenes by Auto DevOps), there -are currently 3 defined environment names that you need to know: - -- `review/` (every environment starting with `review/`) -- `staging` -- `production` - -Those environments are tied to jobs that use [Auto Deploy](#auto-deploy), so -except for the environment scope, they would also need to have a different -domain they would be deployed to. This is why you need to define a separate -`KUBE_INGRESS_BASE_DOMAIN` variable for all the above -[based on the environment](../../ci/variables/README.md#limiting-environment-scopes-of-environment-variables). - -The following table is an example of how the three different clusters would -be configured. - -| Cluster name | Cluster environment scope | `KUBE_INGRESS_BASE_DOMAIN` variable value | Variable environment scope | Notes | -|--------------|---------------------------|-------------------------------------------|----------------------------|---| -| review | `review/*` | `review.example.com` | `review/*` | The review cluster which will run all [Review Apps](../../ci/review_apps/index.md). `*` is a wildcard, which means it will be used by every environment name starting with `review/`. | -| staging | `staging` | `staging.example.com` | `staging` | (Optional) The staging cluster which will run the deployments of the staging environments. You need to [enable it first](#deploy-policy-for-staging-and-production-environments). | -| production | `production` | `example.com` | `production` | The production cluster which will run the deployments of the production environment. You can use [incremental rollouts](#incremental-rollout-to-production-premium). | - -To add a different cluster for each environment: - -1. Navigate to your project's **Operations > Kubernetes** and create the Kubernetes clusters - with their respective environment scope as described from the table above. - -  - -1. After the clusters are created, navigate to each one and install Helm Tiller - and Ingress. Wait for the Ingress IP address to be assigned. -1. Make sure you have [configured your DNS](#auto-devops-base-domain) with the - specified Auto DevOps domains. -1. Navigate to each cluster's page, through **Operations > Kubernetes**, - and add the domain based on its Ingress IP address. - -Now that all is configured, you can test your setup by creating a merge request -and verifying that your app is deployed as a review app in the Kubernetes -cluster with the `review/*` environment scope. Similarly, you can check the -other environments. - ## Enabling/Disabling Auto DevOps -When first using Auto Devops, review the [requirements](#requirements) to ensure all necessary components to make +When first using Auto DevOps, review the [requirements](#requirements) to ensure all necessary components to make full use of Auto DevOps are available. If this is your fist time, we recommend you follow the [quick start guide](quick_start_guide.md). GitLab.com users can enable/disable Auto DevOps at the project-level only. Self-managed users can enable/disable Auto DevOps at the project-level, group-level or instance-level. -### At the instance level (Administrators only) +### At the project level -Even when disabled at the instance level, group owners and project maintainers can still enable -Auto DevOps at the group and project level, respectively. +If enabling, check that your project doesn't have a `.gitlab-ci.yml`, or if one exists, remove it. -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. Go to your project's **Settings > CI/CD > Auto DevOps**. +1. Toggle the **Default to Auto DevOps pipeline** checkbox (checked to enable, unchecked to disable) +1. When enabling, it's optional but recommended to add in the [base domain](#auto-devops-base-domain) + that will be used by Auto DevOps to [deploy your application](#auto-deploy) + and choose the [deployment strategy](#deployment-strategy). 1. Click **Save changes** for the changes to take effect. +When the feature has been enabled, an Auto DevOps pipeline is triggered on the default branch. + ### At the group level > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/52447) in GitLab 11.10. @@ -266,19 +250,16 @@ When enabling or disabling Auto DevOps at group-level, group configuration will the subgroups and projects inside that group, unless Auto DevOps is specifically enabled or disabled on the subgroup or project. -### At the project level +### At the instance level (Administrators only) -If enabling, check that your project doesn't have a `.gitlab-ci.yml`, or if one exists, remove it. +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 your project's **Settings > CI/CD > Auto DevOps**. -1. Toggle the **Default to Auto DevOps pipeline** checkbox (checked to enable, unchecked to disable) -1. When enabling, it's optional but recommended to add in the [base domain](#auto-devops-base-domain) - that will be used by Auto DevOps to [deploy your application](#auto-deploy) - and choose the [deployment strategy](#deployment-strategy). +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. -When the feature has been enabled, an Auto DevOps pipeline is triggered on the default branch. - ### Enable for a percentage of projects There is also a feature flag to enable Auto DevOps by default to your chosen percentage of projects. @@ -310,6 +291,53 @@ The available options are: - `master` branch is directly deployed to staging. - Manual actions are provided for incremental rollout to production. +## Using multiple Kubernetes clusters **(PREMIUM)** + +When using Auto DevOps, you may want to deploy different environments to +different Kubernetes clusters. This is possible due to the 1:1 connection that +[exists between them](../../user/project/clusters/index.md#multiple-kubernetes-clusters-premium). + +In the [Auto DevOps template] (used behind the scenes by Auto DevOps), there +are currently 3 defined environment names that you need to know: + +- `review/` (every environment starting with `review/`) +- `staging` +- `production` + +Those environments are tied to jobs that use [Auto Deploy](#auto-deploy), so +except for the environment scope, they would also need to have a different +domain they would be deployed to. This is why you need to define a separate +`KUBE_INGRESS_BASE_DOMAIN` variable for all the above +[based on the environment](../../ci/variables/README.md#limiting-environment-scopes-of-environment-variables). + +The following table is an example of how the three different clusters would +be configured. + +| Cluster name | Cluster environment scope | `KUBE_INGRESS_BASE_DOMAIN` variable value | Variable environment scope | Notes | +|--------------|---------------------------|-------------------------------------------|----------------------------|---| +| review | `review/*` | `review.example.com` | `review/*` | The review cluster which will run all [Review Apps](../../ci/review_apps/index.md). `*` is a wildcard, which means it will be used by every environment name starting with `review/`. | +| staging | `staging` | `staging.example.com` | `staging` | (Optional) The staging cluster which will run the deployments of the staging environments. You need to [enable it first](#deploy-policy-for-staging-and-production-environments). | +| production | `production` | `example.com` | `production` | The production cluster which will run the deployments of the production environment. You can use [incremental rollouts](#incremental-rollout-to-production-premium). | + +To add a different cluster for each environment: + +1. Navigate to your project's **Operations > Kubernetes** and create the Kubernetes clusters + with their respective environment scope as described from the table above. + +  + +1. After the clusters are created, navigate to each one and install Helm Tiller + and Ingress. Wait for the Ingress IP address to be assigned. +1. Make sure you have [configured your DNS](#auto-devops-base-domain) with the + specified Auto DevOps domains. +1. Navigate to each cluster's page, through **Operations > Kubernetes**, + and add the domain based on its Ingress IP address. + +Now that all is configured, you can test your setup by creating a merge request +and verifying that your app is deployed as a review app in the Kubernetes +cluster with the `review/*` environment scope. Similarly, you can check the +other environments. + ## Stages of Auto DevOps The following sections describe the stages of Auto DevOps. Read them carefully @@ -670,9 +698,28 @@ workers: terminationGracePeriodSeconds: 60 ``` -### Auto Monitoring +#### Running commands in the container + +Applications built with [Auto Build](#auto-build) using Herokuish, the default +unless you have [a custom Dockerfile](#auto-build-using-a-dockerfile), may require +commands to be wrapped as follows: + +```shell +/bin/herokuish procfile exec $COMMAND +``` + +This might be neccessary, for example, when: + +- Attaching using `kubectl exec`. +- Using GitLab's [Web Terminal](../../ci/environments.md#web-terminals). + +For example, to start a Rails console from the application root directory, run: + +```sh +/bin/herokuish procfile exec bin/rails c +``` -See the [requirements](#requirements) for Auto Monitoring to enable this stage. +### Auto Monitoring Once your application is deployed, Auto Monitoring makes it possible to monitor your application's server and response metrics right out of the box. Auto @@ -687,18 +734,15 @@ The metrics include: - **Response Metrics:** latency, throughput, error rate - **System Metrics:** CPU utilization, memory utilization -In order to make use of monitoring you need to: +To make use of Auto Monitoring: -1. [Deploy Prometheus](../../user/project/integrations/prometheus.md) into your Kubernetes cluster -1. If you would like response metrics, ensure you are running at least version - 0.9.0 of NGINX Ingress and - [enable Prometheus metrics](https://github.com/kubernetes/ingress-nginx/blob/master/docs/examples/customization/custom-vts-metrics-prometheus/nginx-vts-metrics-conf.yaml). -1. Finally, [annotate](https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/) - the NGINX Ingress deployment to be scraped by Prometheus using - `prometheus.io/scrape: "true"` and `prometheus.io/port: "10254"`. - -To view the metrics, open the -[Monitoring dashboard for a deployed environment](../../ci/environments.md#monitoring-environments). +1. [Install and configure the requirements](#requirements). +1. [Enable Auto DevOps](#enablingdisabling-auto-devops) if you haven't done already. +1. Finally, go to your project's **CI/CD > Pipelines** and run a pipeline. +1. Once the pipeline finishes successfully, open the + [monitoring dashboard for a deployed environment](../../ci/environments.md#monitoring-environments) + to view the metrics of your deployed application. To view the metrics of the + whole Kubernetes cluster, navigate to **Operations > Metrics**.  @@ -725,6 +769,8 @@ or a `.buildpacks` file in your project: CAUTION: **Caution:** Using multiple buildpacks isn't yet supported by Auto DevOps. +CAUTION: **Caution:** When using the `.buildpacks` file, Auto Test will not work. The buildpack [heroku-buildpack-multi](https://github.com/heroku/heroku-buildpack-multi/) (which is used under the hood to parse the `.buildpacks` file) doesn't provide the necessary commands `bin/test-compile` and `bin/test`. Make sure to provide the project variable `BUILDPACK_URL` instead. + ### Custom `Dockerfile` If your project has a `Dockerfile` in the root of the project repo, Auto DevOps @@ -924,6 +970,7 @@ applications. | `AUTO_DEVOPS_CHART_REPOSITORY_NAME` | From GitLab 11.11, used to set the name of the Helm repository. Defaults to `gitlab`. | | `AUTO_DEVOPS_CHART_REPOSITORY_USERNAME` | From GitLab 11.11, used to set a username to connect to the Helm repository. Defaults to no credentials. Also set `AUTO_DEVOPS_CHART_REPOSITORY_PASSWORD`. | | `AUTO_DEVOPS_CHART_REPOSITORY_PASSWORD` | From GitLab 11.11, used to set a password to connect to the Helm repository. Defaults to no credentials. Also set `AUTO_DEVOPS_CHART_REPOSITORY_USERNAME`. | +| `AUTO_DEVOPS_MODSECURITY_SEC_RULE_ENGINE` | From GitLab 12.5, used in combination with [Modsecurity feature flag](../../user/clusters/applications.md#web-application-firewall-modsecurity) to toggle [Modsecurity's `SecRuleEngine`](https://github.com/SpiderLabs/ModSecurity/wiki/Reference-Manual-(v2.x)#SecRuleEngine) behavior. Defaults to `DetectionOnly`. | | `BUILDPACK_URL` | Buildpack's full URL. Can point to either Git repositories or a tarball URL. For Git repositories, it is possible to point to a specific `ref`. For example `https://github.com/heroku/heroku-buildpack-ruby.git#v142`. | | `CANARY_ENABLED` | From GitLab 11.0, used to define a [deploy policy for canary environments](#deploy-policy-for-canary-environments-premium). | | `CANARY_PRODUCTION_REPLICAS` | Number of canary replicas to deploy for [Canary Deployments](../../user/project/canary_deployments.md) in the production environment. Takes precedence over `CANARY_REPLICAS`. Defaults to 1. | @@ -968,7 +1015,6 @@ The following table lists variables related to security tools. | **Variable** | **Description** | | `SAST_CONFIDENCE_LEVEL` | Minimum confidence level of security issues you want to be reported; `1` for Low, `2` for Medium, `3` for High. Defaults to `3`. | -| `DS_DISABLE_REMOTE_CHECKS` | Whether remote Dependency Scanning checks are disabled. Defaults to `"false"`. Set to `"true"` to disable checks that send data to GitLab central servers. [Read more about remote checks](../../user/application_security/dependency_scanning/index.md#remote-checks). | #### Disable jobs @@ -1301,7 +1347,6 @@ curl --data "value=true" --header "PRIVATE-TOKEN: personal_access_token" https:/ ``` [ce-37115]: https://gitlab.com/gitlab-org/gitlab-foss/issues/37115 -[kubernetes-clusters]: ../../user/project/clusters/index.md [docker-in-docker]: ../../docker/using_docker_build.md#use-docker-in-docker-executor [review-app]: ../../ci/review_apps/index.md [container-registry]: ../../user/packages/container_registry/index.md diff --git a/doc/topics/autodevops/quick_start_guide.md b/doc/topics/autodevops/quick_start_guide.md index d9bdd73221f68dab0006f317f3e555a5cec32b4b..ce3a3dd5ca6d9764b09c4f54b72fc5b9a5ae878d 100644 --- a/doc/topics/autodevops/quick_start_guide.md +++ b/doc/topics/autodevops/quick_start_guide.md @@ -25,7 +25,7 @@ Google account (for example, one that you use to access Gmail, Drive, etc.) or c TIP: **Tip:** Every new Google Cloud Platform (GCP) account receives [$300 in credit](https://console.cloud.google.com/freetrial), 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=a0n60000006Vpz4AAC) and apply for credit. +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. ## Creating a new project from a template @@ -212,7 +212,7 @@ under **Settings > CI/CD > Environment variables**. ### Working with branches -Following the [GitLab flow](../../workflow/gitlab_flow.md#working-with-feature-branches), +Following the [GitLab flow](../gitlab_flow.md#working-with-feature-branches), let's create a feature branch that will add some content to the application. Under your repository, navigate to the following file: `app/views/welcome/index.html.erb`. @@ -280,6 +280,6 @@ and customized to fit your workflow. Here are some helpful resources for further 1. [Multiple Kubernetes clusters](index.md#using-multiple-kubernetes-clusters-premium) **(PREMIUM)** 1. [Incremental rollout to production](index.md#incremental-rollout-to-production-premium) **(PREMIUM)** 1. [Disable jobs you don't need with environment variables](index.md#environment-variables) -1. [Use a static IP for your cluster](../../user/project/clusters/index.md#using-a-static-ip) +1. [Use a static IP for your cluster](../../user/clusters/applications.md#using-a-static-ip) 1. [Use your own buildpacks to build your application](index.md#custom-buildpacks) 1. [Prometheus monitoring](../../user/project/integrations/prometheus.md) diff --git a/doc/topics/git/index.md b/doc/topics/git/index.md index d6e1f83b876b27a5e4fc48283e25f2fc5aceab41..4325980a60c46009ddfa704a2387307bf7352660 100644 --- a/doc/topics/git/index.md +++ b/doc/topics/git/index.md @@ -32,7 +32,7 @@ The following resources will help you get started with Git: - Commits: - [Revert a commit](../../user/project/merge_requests/revert_changes.md#reverting-a-commit) - [Cherry-picking a commit](../../user/project/merge_requests/cherry_pick_changes.md#cherry-picking-a-commit) - - [Squashing commits](../../workflow/gitlab_flow.md#squashing-commits-with-rebase) + - [Squashing commits](../gitlab_flow.md#squashing-commits-with-rebase) ### Concepts @@ -85,7 +85,7 @@ The following relate to Git Large File Storage: - [Getting Started with Git LFS](https://about.gitlab.com/blog/2017/01/30/getting-started-with-git-lfs-tutorial/) - [Migrate an existing Git repo with Git LFS](migrate_to_git_lfs/index.md) -- [GitLab Git LFS user documentation](../../workflow/lfs/manage_large_binaries_with_git_lfs.md) -- [GitLab Git LFS admin documentation](../../workflow/lfs/lfs_administration.md) -- [git-annex to Git-LFS migration guide](../../workflow/lfs/migrate_from_git_annex_to_git_lfs.md) +- [GitLab Git LFS user documentation](../../administration/lfs/manage_large_binaries_with_git_lfs.md) +- [GitLab Git LFS admin documentation](../../administration/lfs/lfs_administration.md) +- [git-annex to Git-LFS migration guide](../../administration/lfs/migrate_from_git_annex_to_git_lfs.md) - [Towards a production quality open source Git LFS server](https://about.gitlab.com/blog/2015/08/13/towards-a-production-quality-open-source-git-lfs-server/) diff --git a/doc/topics/git/migrate_to_git_lfs/index.md b/doc/topics/git/migrate_to_git_lfs/index.md index 0c30b45c55288a1a9ba5b4901b80113c28a04126..eec1c3c10c1455cf268e195633b2ac0f837859cd 100644 --- a/doc/topics/git/migrate_to_git_lfs/index.md +++ b/doc/topics/git/migrate_to_git_lfs/index.md @@ -163,9 +163,9 @@ but commented out to help encourage others to add to it in the future. --> ## References - [Getting Started with Git LFS](https://about.gitlab.com/blog/2017/01/30/getting-started-with-git-lfs-tutorial/) -- [Migrate from Git Annex to Git LFS](../../../workflow/lfs/migrate_from_git_annex_to_git_lfs.md) -- [GitLab's Git LFS user documentation](../../../workflow/lfs/manage_large_binaries_with_git_lfs.md) -- [GitLab's Git LFS administrator documentation](../../../workflow/lfs/lfs_administration.md) +- [Migrate from Git Annex to Git LFS](../../../administration/lfs/migrate_from_git_annex_to_git_lfs.md) +- [GitLab's Git LFS user documentation](../../../administration/lfs/manage_large_binaries_with_git_lfs.md) +- [GitLab's Git LFS administrator documentation](../../../administration/lfs/lfs_administration.md) - Alternative method to [migrate an existing repo to Git LFS](https://github.com/git-lfs/git-lfs/wiki/Tutorial#migrating-existing-repository-data-to-lfs) <!-- diff --git a/doc/topics/git/partial_clone.md b/doc/topics/git/partial_clone.md index ce1b551ddb6229187f0dfefdc0edd4b47339c1c7..e6f84ee825150c04095d4ce1b3f2e3b683b8f8b9 100644 --- a/doc/topics/git/partial_clone.md +++ b/doc/topics/git/partial_clone.md @@ -39,16 +39,20 @@ Follow [Git for enormous repositories](https://gitlab.com/groups/gitlab-org/-/ep ## Enabling partial clone -GitLab 12.1 uses Git 2.21.0 which has an arbitrary file access security -vulnerability when `uploadpack.allowFilter` is enabled, and should not be -enabled in production environments. +> [Introduced](https://gitlab.com/gitlab-org/gitaly/issues/1553) in GitLab 12.4. -A feature flag is planned to enable `uploadpack.allowFilter` and -`uploadpack.allowAnySHA1InWant` once the version of Git used by GitLab has been -updated to Git 2.22.0. +To enable partial clone, use the [feature flags API](../../api/features.md). +For example: -Follow [this issue](https://gitlab.com/gitlab-org/gitaly/issues/1553) for -updated. +```sh +curl --data "value=true" --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/features/gitaly_upload_pack_filter +``` + +Alternatively, flip the switch and enable the feature flag: + +```ruby +Feature.enable(:gitaly_upload_pack_filter) +``` ## Excluding objects by size diff --git a/doc/topics/gitlab_flow.md b/doc/topics/gitlab_flow.md new file mode 100644 index 0000000000000000000000000000000000000000..0fab4de8454debe7f19a7180ba351c8e793093ea --- /dev/null +++ b/doc/topics/gitlab_flow.md @@ -0,0 +1,330 @@ +--- +disqus_identifier: 'https://docs.gitlab.com/ee/workflow/gitlab_flow.html' +--- + +# Introduction to GitLab Flow + + + +Git allows a wide variety of branching strategies and workflows. +Because of this, many organizations end up with workflows that are too complicated, not clearly defined, or not integrated with issue tracking systems. +Therefore, we propose GitLab flow as a clearly defined set of best practices. +It combines [feature-driven development](https://en.wikipedia.org/wiki/Feature-driven_development) and [feature branches](https://martinfowler.com/bliki/FeatureBranch.html) with issue tracking. + +Organizations coming to Git from other version control systems frequently find it hard to develop a productive workflow. +This article describes GitLab flow, which integrates the Git workflow with an issue tracking system. +It offers a simple, transparent, and effective way to work with Git. + + + +When converting to Git, you have to get used to the fact that it takes three steps to share a commit with colleagues. +Most version control systems have only one step: committing from the working copy to a shared server. +In Git, you add files from the working copy to the staging area. After that, you commit them to your local repo. +The third step is pushing to a shared remote repository. +After getting used to these three steps, the next challenge is the branching model. + + + +Since many organizations new to Git have no conventions for how to work with it, their repositories can quickly become messy. +The biggest problem is that many long-running branches emerge that all contain part of the changes. +People have a hard time figuring out which branch has the latest code, or which branch to deploy to production. +Frequently, the reaction to this problem is to adopt a standardized pattern such as [Git flow](https://nvie.com/posts/a-successful-git-branching-model/) and [GitHub flow](http://scottchacon.com/2011/08/31/github-flow.html). +We think there is still room for improvement. In this document, we describe a set of practices we call GitLab flow. + +For a video introduction of how this works in GitLab, see [GitLab Flow](https://youtu.be/InKNIvky2KE). + +## Git flow and its problems + + + +Git flow was one of the first proposals to use Git branches, and it has received a lot of attention. +It suggests a `master` branch and a separate `develop` branch, as well as supporting branches for features, releases, and hotfixes. +The development happens on the `develop` branch, moves to a release branch, and is finally merged into the `master` branch. + +Git flow is a well-defined standard, but its complexity introduces two problems. +The first problem is that developers must use the `develop` branch and not `master`. `master` is reserved for code that is released to production. +It is a convention to call your default branch `master` and to mostly branch from and merge to this. +Since most tools automatically use the `master` branch as the default, it is annoying to have to switch to another branch. + +The second problem of Git flow is the complexity introduced by the hotfix and release branches. +These branches can be a good idea for some organizations but are overkill for the vast majority of them. +Nowadays, most organizations practice continuous delivery, which means that your default branch can be deployed. +Continuous delivery removes the need for hotfix and release branches, including all the ceremony they introduce. +An example of this ceremony is the merging back of release branches. +Though specialized tools do exist to solve this, they require documentation and add complexity. +Frequently, developers make mistakes such as merging changes only into `master` and not into the `develop` branch. +The reason for these errors is that Git flow is too complicated for most use cases. +For example, many projects do releases but don't need to do hotfixes. + +## GitHub flow as a simpler alternative + + + +In reaction to Git flow, GitHub created a simpler alternative. +[GitHub flow](https://guides.github.com/introduction/flow/index.html) has only feature branches and a `master` branch. +This flow is clean and straightforward, and many organizations have adopted it with great success. +Atlassian recommends [a similar strategy](https://www.atlassian.com/blog/git/simple-git-workflow-is-simple), although they rebase feature branches. +Merging everything into the `master` branch and frequently deploying means you minimize the amount of unreleased code, which is in line with lean and continuous delivery best practices. +However, this flow still leaves a lot of questions unanswered regarding deployments, environments, releases, and integrations with issues. +With GitLab flow, we offer additional guidance for these questions. + +## Production branch with GitLab flow + + + +GitHub flow assumes you can deploy to production every time you merge a feature branch. +While this is possible in some cases, such as SaaS applications, there are many cases where this is not possible. +One case is where you don't control the timing of a release, for example, an iOS application that is released when it passes App Store validation. +Another case is when you have deployment windows — for example, workdays from 10 AM to 4 PM when the operations team is at full capacity — but you also merge code at other times. +In these cases, you can make a production branch that reflects the deployed code. +You can deploy a new version by merging `master` into the production branch. +If you need to know what code is in production, you can just checkout the production branch to see. +The approximate time of deployment is easily visible as the merge commit in the version control system. +This time is pretty accurate if you automatically deploy your production branch. +If you need a more exact time, you can have your deployment script create a tag on each deployment. +This flow prevents the overhead of releasing, tagging, and merging that happens with Git flow. + +## Environment branches with GitLab flow + + + +It might be a good idea to have an environment that is automatically updated to the `master` branch. +Only, in this case, the name of this environment might differ from the branch name. +Suppose you have a staging environment, a pre-production environment, and a production environment. +In this case, deploy the `master` branch to staging. +To deploy to pre-production, create a merge request from the `master` branch to the pre-production branch. +Go live by merging the pre-production branch into the production branch. +This workflow, where commits only flow downstream, ensures that everything is tested in all environments. +If you need to cherry-pick a commit with a hotfix, it is common to develop it on a feature branch and merge it into `master` with a merge request. +In this case, do not delete the feature branch yet. +If `master` passes automatic testing, you then merge the feature branch into the other branches. +If this is not possible because more manual testing is required, you can send merge requests from the feature branch to the downstream branches. + +## Release branches with GitLab flow + + + +You only need to work with release branches if you need to release software to the outside world. +In this case, each branch contains a minor version, for example, 2-3-stable, 2-4-stable, etc. +Create stable branches using `master` as a starting point, and branch as late as possible. +By doing this, you minimize the length of time during which you have to apply bug fixes to multiple branches. +After announcing a release branch, only add serious bug fixes to the branch. +If possible, first merge these bug fixes into `master`, and then cherry-pick them into the release branch. +If you start by merging into the release branch, you might forget to cherry-pick them into `master`, and then you'd encounter the same bug in subsequent releases. +Merging into `master` and then cherry-picking into release is called an "upstream first" policy, which is also practiced by [Google](https://www.chromium.org/chromium-os/chromiumos-design-docs/upstream-first) and [Red Hat](https://www.redhat.com/en/blog/a-community-for-using-openstack-with-red-hat-rdo). +Every time you include a bug fix in a release branch, increase the patch version (to comply with [Semantic Versioning](https://semver.org/)) by setting a new tag. +Some projects also have a stable branch that points to the same commit as the latest released branch. +In this flow, it is not common to have a production branch (or Git flow `master` branch). + +## Merge/pull requests with GitLab flow + + + +Merge or pull requests are created in a Git management application. They ask an assigned person to merge two branches. +Tools such as GitHub and Bitbucket choose the name "pull request" since the first manual action is to pull the feature branch. +Tools such as GitLab and others choose the name "merge request" since the final action is to merge the feature branch. +In this article, we'll refer to them as merge requests. + +If you work on a feature branch for more than a few hours, it is good to share the intermediate result with the rest of the team. +To do this, create a merge request without assigning it to anyone. +Instead, mention people in the description or a comment, for example, "/cc @mark @susan." +This indicates that the merge request is not ready to be merged yet, but feedback is welcome. +Your team members can comment on the merge request in general or on specific lines with line comments. +The merge request serves as a code review tool, and no separate code review tools should be needed. +If the review reveals shortcomings, anyone can commit and push a fix. +Usually, the person to do this is the creator of the merge request. +The diff in the merge request automatically updates when new commits are pushed to the branch. + +When you are ready for your feature branch to be merged, assign the merge request to the person who knows most about the codebase you are changing. +Also, mention any other people from whom you would like feedback. +After the assigned person feels comfortable with the result, they can merge the branch. +If the assigned person does not feel comfortable, they can request more changes or close the merge request without merging. + +In GitLab, it is common to protect the long-lived branches, e.g., the `master` branch, so that [most developers can't modify them](../user/permissions.md). +So, if you want to merge into a protected branch, assign your merge request to someone with maintainer permissions. + +After you merge a feature branch, you should remove it from the source control software. +In GitLab, you can do this when merging. +Removing finished branches ensures that the list of branches shows only work in progress. +It also ensures that if someone reopens the issue, they can use the same branch name without causing problems. + +NOTE: **Note:** +When you reopen an issue you need to create a new merge request. + + + +## Issue tracking with GitLab flow + + + +GitLab flow is a way to make the relation between the code and the issue tracker more transparent. + +Any significant change to the code should start with an issue that describes the goal. +Having a reason for every code change helps to inform the rest of the team and to keep the scope of a feature branch small. +In GitLab, each change to the codebase starts with an issue in the issue tracking system. +If there is no issue yet, create the issue, as long as the change will take a significant amount of work, i.e., more than 1 hour. +In many organizations, raising an issue is part of the development process because they are used in sprint planning. +The issue title should describe the desired state of the system. +For example, the issue title "As an administrator, I want to remove users without receiving an error" is better than "Admin can't remove users." + +When you are ready to code, create a branch for the issue from the `master` branch. +This branch is the place for any work related to this change. + +NOTE: **Note:** +The name of a branch might be dictated by organizational standards. + +When you are done or want to discuss the code, open a merge request. +A merge request is an online place to discuss the change and review the code. + +If you open the merge request but do not assign it to anyone, it is a "Work In Progress" merge request. +These are used to discuss the proposed implementation but are not ready for inclusion in the `master` branch yet. +Start the title of the merge request with `[WIP]` or `WIP:` to prevent it from being merged before it's ready. + +When you think the code is ready, assign the merge request to a reviewer. +The reviewer can merge the changes when they think the code is ready for inclusion in the `master` branch. +When they press the merge button, GitLab merges the code and creates a merge commit that makes this event easily visible later on. +Merge requests always create a merge commit, even when the branch could be merged without one. +This merge strategy is called "no fast-forward" in Git. +After the merge, delete the feature branch since it is no longer needed. +In GitLab, this deletion is an option when merging. + +Suppose that a branch is merged but a problem occurs and the issue is reopened. +In this case, it is no problem to reuse the same branch name since the first branch was deleted when it was merged. +At any time, there is at most one branch for every issue. +It is possible that one feature branch solves more than one issue. + +## Linking and closing issues from merge requests + + + +Link to issues by mentioning them in commit messages or the description of a merge request, for example, "Fixes #16" or "Duck typing is preferred. See #12." +GitLab then creates links to the mentioned issues and creates comments in the issues linking back to the merge request. + +To automatically close linked issues, mention them with the words "fixes" or "closes," for example, "fixes #14" or "closes #67." GitLab closes these issues when the code is merged into the default branch. + +If you have an issue that spans across multiple repositories, create an issue for each repository and link all issues to a parent issue. + +## Squashing commits with rebase + + + +With Git, you can use an interactive rebase (`rebase -i`) to squash multiple commits into one or reorder them. +This functionality is useful if you want to replace a couple of small commits with a single commit, or if you want to make the order more logical. + +However, you should never rebase commits you have pushed to a remote server. +Rebasing creates new commits for all your changes, which can cause confusion because the same change would have multiple identifiers. +It also causes merge errors for anyone working on the same branch because their history would not match with yours. +Also, if someone has already reviewed your code, rebasing makes it hard to tell what changed since the last review. + +You should also never rebase commits authored by other people. +Not only does this rewrite history, but it also loses authorship information. +Rebasing prevents the other authors from being attributed and sharing part of the [`git blame`](https://git-scm.com/docs/git-blame). + +If a merge involves many commits, it may seem more difficult to undo. +You might think to solve this by squashing all the changes into one commit before merging, but as discussed earlier, it is a bad idea to rebase commits that you have already pushed. +Fortunately, there is an easy way to undo a merge with all its commits. +The way to do this is by reverting the merge commit. +Preserving this ability to revert a merge is a good reason to always use the "no fast-forward" (`--no-ff`) strategy when you merge manually. + +NOTE: **Note:** +If you revert a merge commit and then change your mind, revert the revert commit to redo the merge. +Git does not allow you to merge the code again otherwise. + +## Reducing merge commits in feature branches + + + +Having lots of merge commits can make your repository history messy. +Therefore, you should try to avoid merge commits in feature branches. +Often, people avoid merge commits by just using rebase to reorder their commits after the commits on the `master` branch. +Using rebase prevents a merge commit when merging `master` into your feature branch, and it creates a neat linear history. +However, as discussed in [the section about rebasing](#squashing-commits-with-rebase), you should never rebase commits you have pushed to a remote server. +This restriction makes it impossible to rebase work in progress that you already shared with your team, which is something we recommend. + +Rebasing also creates more work, since every time you rebase, you have to resolve similar conflicts. +Sometimes you can reuse recorded resolutions (`rerere`), but merging is better since you only have to resolve conflicts once. +Atlassian has a more thorough explanation of the tradeoffs between merging and rebasing [on their blog](https://www.atlassian.com/blog/git/git-team-workflows-merge-or-rebase). + +A good way to prevent creating many merge commits is to not frequently merge `master` into the feature branch. +There are three reasons to merge in `master`: utilizing new code, resolving merge conflicts, and updating long-running branches. + +If you need to utilize some code that was introduced in `master` after you created the feature branch, you can often solve this by just cherry-picking a commit. + +If your feature branch has a merge conflict, creating a merge commit is a standard way of solving this. + +NOTE: **Note:** +Sometimes you can use .gitattributes to reduce merge conflicts. +For example, you can set your changelog file to use the [union merge driver](https://git-scm.com/docs/gitattributes#gitattributes-union) so that multiple new entries don't conflict with each other. + +The last reason for creating merge commits is to keep long-running feature branches up-to-date with the latest state of the project. +The solution here is to keep your feature branches short-lived. +Most feature branches should take less than one day of work. +If your feature branches often take more than a day of work, try to split your features into smaller units of work. + +If you need to keep a feature branch open for more than a day, there are a few strategies to keep it up-to-date. +One option is to use continuous integration (CI) to merge in `master` at the start of the day. +Another option is to only merge in from well-defined points in time, for example, a tagged release. +You could also use [feature toggles](https://martinfowler.com/bliki/FeatureToggle.html) to hide incomplete features so you can still merge back into `master` every day. + +> **Note:** Don't confuse automatic branch testing with continuous integration. +> Martin Fowler makes this distinction in [his article about feature branches](https://martinfowler.com/bliki/FeatureBranch.html): +> +> "I've heard people say they are doing CI because they are running builds, perhaps using a CI server, on every branch with every commit. +> That's continuous building, and a Good Thing, but there's no *integration*, so it's not CI." + +In conclusion, you should try to prevent merge commits, but not eliminate them. +Your codebase should be clean, but your history should represent what actually happened. +Developing software happens in small, messy steps, and it is OK to have your history reflect this. +You can use tools to view the network graphs of commits and understand the messy history that created your code. +If you rebase code, the history is incorrect, and there is no way for tools to remedy this because they can't deal with changing commit identifiers. + +## Commit often and push frequently + +Another way to make your development work easier is to commit often. +Every time you have a working set of tests and code, you should make a commit. +Splitting up work into individual commits provides context for developers looking at your code later. +Smaller commits make it clear how a feature was developed, and they make it easy to roll back to a specific good point in time or to revert one code change without reverting several unrelated changes. + +Committing often also makes it easy to share your work, which is important so that everyone is aware of what you are working on. +You should push your feature branch frequently, even when it is not yet ready for review. +By sharing your work in a feature branch or [a merge request](#mergepull-requests-with-gitlab-flow), you prevent your team members from duplicating work. +Sharing your work before it's complete also allows for discussion and feedback about the changes, which can help improve the code before it gets to review. + +## How to write a good commit message + + + +A commit message should reflect your intention, not just the contents of the commit. +It is easy to see the changes in a commit, so the commit message should explain why you made those changes. +An example of a good commit message is: "Combine templates to reduce duplicate code in the user views." +The words "change," "improve," "fix," and "refactor" don't add much information to a commit message. +For example, "Improve XML generation" could be better written as "Properly escape special characters in XML generation." +For more information about formatting commit messages, please see this excellent [blog post by Tim Pope](https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). + +## Testing before merging + + + +In old workflows, the continuous integration (CI) server commonly ran tests on the `master` branch only. +Developers had to ensure their code did not break the `master` branch. +When using GitLab flow, developers create their branches from this `master` branch, so it is essential that it never breaks. +Therefore, each merge request must be tested before it is accepted. +CI software like Travis CI and GitLab CI show the build results right in the merge request itself to make this easy. + +There is one drawback to testing merge requests: the CI server only tests the feature branch itself, not the merged result. +Ideally, the server could also test the `master` branch after each change. +However, retesting on every commit to `master` is computationally expensive and means you are more frequently waiting for test results. +Since feature branches should be short-lived, testing just the branch is an acceptable risk. +If new commits in `master` cause merge conflicts with the feature branch, merge `master` back into the branch to make the CI server re-run the tests. +As said before, if you often have feature branches that last for more than a few days, you should make your issues smaller. + +## Working with feature branches + + + +When creating a feature branch, always branch from an up-to-date `master`. +If you know before you start that your work depends on another branch, you can also branch from there. +If you need to merge in another branch after starting, explain the reason in the merge commit. +If you have not pushed your commits to a shared location yet, you can also incorporate changes by rebasing on `master` or another feature branch. +Do not merge from upstream again if your code can work and merge cleanly without doing so. +Merging only when needed prevents creating merge commits in your feature branch that later end up littering the `master` history. diff --git a/doc/workflow/img/gitlab_flow.png b/doc/topics/img/gitlab_flow.png similarity index 100% rename from doc/workflow/img/gitlab_flow.png rename to doc/topics/img/gitlab_flow.png diff --git a/doc/workflow/img/ci_mr.png b/doc/topics/img/gitlab_flow_ci_mr.png similarity index 100% rename from doc/workflow/img/ci_mr.png rename to doc/topics/img/gitlab_flow_ci_mr.png diff --git a/doc/workflow/img/close_issue_mr.png b/doc/topics/img/gitlab_flow_close_issue_mr.png similarity index 100% rename from doc/workflow/img/close_issue_mr.png rename to doc/topics/img/gitlab_flow_close_issue_mr.png diff --git a/doc/workflow/img/environment_branches.png b/doc/topics/img/gitlab_flow_environment_branches.png similarity index 100% rename from doc/workflow/img/environment_branches.png rename to doc/topics/img/gitlab_flow_environment_branches.png diff --git a/doc/workflow/img/four_stages.png b/doc/topics/img/gitlab_flow_four_stages.png similarity index 100% rename from doc/workflow/img/four_stages.png rename to doc/topics/img/gitlab_flow_four_stages.png diff --git a/doc/workflow/img/git_pull.png b/doc/topics/img/gitlab_flow_git_pull.png similarity index 100% rename from doc/workflow/img/git_pull.png rename to doc/topics/img/gitlab_flow_git_pull.png diff --git a/doc/workflow/img/gitdashflow.png b/doc/topics/img/gitlab_flow_gitdashflow.png similarity index 100% rename from doc/workflow/img/gitdashflow.png rename to doc/topics/img/gitlab_flow_gitdashflow.png diff --git a/doc/workflow/img/github_flow.png b/doc/topics/img/gitlab_flow_github_flow.png similarity index 100% rename from doc/workflow/img/github_flow.png rename to doc/topics/img/gitlab_flow_github_flow.png diff --git a/doc/workflow/img/good_commit.png b/doc/topics/img/gitlab_flow_good_commit.png similarity index 100% rename from doc/workflow/img/good_commit.png rename to doc/topics/img/gitlab_flow_good_commit.png diff --git a/doc/workflow/img/merge_commits.png b/doc/topics/img/gitlab_flow_merge_commits.png similarity index 100% rename from doc/workflow/img/merge_commits.png rename to doc/topics/img/gitlab_flow_merge_commits.png diff --git a/doc/workflow/img/merge_request.png b/doc/topics/img/gitlab_flow_merge_request.png similarity index 100% rename from doc/workflow/img/merge_request.png rename to doc/topics/img/gitlab_flow_merge_request.png diff --git a/doc/workflow/img/messy_flow.png b/doc/topics/img/gitlab_flow_messy_flow.png similarity index 100% rename from doc/workflow/img/messy_flow.png rename to doc/topics/img/gitlab_flow_messy_flow.png diff --git a/doc/workflow/img/mr_inline_comments.png b/doc/topics/img/gitlab_flow_mr_inline_comments.png similarity index 100% rename from doc/workflow/img/mr_inline_comments.png rename to doc/topics/img/gitlab_flow_mr_inline_comments.png diff --git a/doc/workflow/img/production_branch.png b/doc/topics/img/gitlab_flow_production_branch.png similarity index 100% rename from doc/workflow/img/production_branch.png rename to doc/topics/img/gitlab_flow_production_branch.png diff --git a/doc/workflow/img/rebase.png b/doc/topics/img/gitlab_flow_rebase.png similarity index 100% rename from doc/workflow/img/rebase.png rename to doc/topics/img/gitlab_flow_rebase.png diff --git a/doc/workflow/img/release_branches.png b/doc/topics/img/gitlab_flow_release_branches.png similarity index 100% rename from doc/workflow/img/release_branches.png rename to doc/topics/img/gitlab_flow_release_branches.png diff --git a/doc/workflow/img/remove_checkbox.png b/doc/topics/img/gitlab_flow_remove_checkbox.png similarity index 100% rename from doc/workflow/img/remove_checkbox.png rename to doc/topics/img/gitlab_flow_remove_checkbox.png diff --git a/doc/topics/index.md b/doc/topics/index.md index b51f24b02e405b60e72024fd12c4705bc84d9ea5..71048ec5aa4d34f436a6cdc8838942b5b466787c 100644 --- a/doc/topics/index.md +++ b/doc/topics/index.md @@ -11,6 +11,7 @@ tutorials, technical overviews, blog posts) and videos. - [Authentication](authentication/index.md) - [Continuous Integration (GitLab CI)](../ci/README.md) - [Git](git/index.md) +- [GitLab Flow](gitlab_flow.md) - [GitLab Installation](../install/README.md) - [GitLab Pages](../user/project/pages/index.md) diff --git a/doc/university/README.md b/doc/university/README.md index 8f5a5038bb953c8b7a163def81e00a829410834e..9725cb14fc5f6c9e7965ae841ba31fd5c2bc4b37 100644 --- a/doc/university/README.md +++ b/doc/university/README.md @@ -129,7 +129,7 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres 1. [GitLab Flow vs Forking in GitLab - Video](https://www.youtube.com/watch?v=UGotqAUACZA) 1. [GitLab Flow Overview](https://about.gitlab.com/blog/2014/09/29/gitlab-flow/) 1. [Always Start with an Issue](https://about.gitlab.com/blog/2016/03/03/start-with-an-issue/) -1. [GitLab Flow Documentation](../workflow/gitlab_flow.md) +1. [GitLab Flow Documentation](../topics/gitlab_flow.md) ### 2.5. GitLab Comparisons diff --git a/doc/university/support/README.md b/doc/university/support/README.md index 1c77fbeb8d6c783dcf227edd7c8e728dc78fe311..ebdd453ff3ce1f7a07cd7559624a445ef55c8380 100644 --- a/doc/university/support/README.md +++ b/doc/university/support/README.md @@ -170,7 +170,7 @@ Some tickets need specific knowledge or a deep understanding of a particular com Move on to understanding some of GitLab's more advanced features. You can make use of GitLab.com to understand the features from an end-user perspective and then use your own instance to understand setup and configuration of the feature from an Administrative perspective -- Set up and try [Git LFS](../../workflow/lfs/manage_large_binaries_with_git_lfs.md) +- Set up and try [Git LFS](../../administration/lfs/manage_large_binaries_with_git_lfs.md) - Get to know the [GitLab API](../../api/README.md), its capabilities and shortcomings - Learn how to [migrate from SVN to Git](../../user/project/import/svn.md) - Set up [GitLab CI](../../ci/quick_start/README.md) diff --git a/doc/university/training/gitlab_flow.md b/doc/university/training/gitlab_flow.md index 66e645a0af83d8666f0cbe7fe81547fb94305074..b80eb031aeea17335d49978a5bd2e57fe0497cd4 100644 --- a/doc/university/training/gitlab_flow.md +++ b/doc/university/training/gitlab_flow.md @@ -38,7 +38,7 @@ type: reference ## More details -For more information, read through the [GitLab Flow](../../workflow/gitlab_flow.md) +For more information, read through the [GitLab Flow](../../topics/gitlab_flow.md) documentation. <!-- ## Troubleshooting diff --git a/doc/update/README.md b/doc/update/README.md index 965f29bc8aabb6887cc9a9b1bd04a16f584ef994..6834deb1a85a7723a0872c016656a775fcfe4351 100644 --- a/doc/update/README.md +++ b/doc/update/README.md @@ -69,7 +69,13 @@ 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. +`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 +``` 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 diff --git a/doc/user/admin_area/activating_deactivating_users.md b/doc/user/admin_area/activating_deactivating_users.md new file mode 100644 index 0000000000000000000000000000000000000000..78a07f4a04e328c0f8b7ac67e934a2da2b172bea --- /dev/null +++ b/doc/user/admin_area/activating_deactivating_users.md @@ -0,0 +1,66 @@ +--- +type: howto +--- + +# Activating and deactivating users + +GitLab administrators can deactivate and activate users. + +## Deactivating a user + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/63921) in GitLab 12.4. + +In order to temporarily prevent access by a GitLab user that has no recent activity, administrators +can choose to deactivate the user. + +Deactivating a user is functionally identical to [blocking a user](blocking_unblocking_users.md), +with the following differences: + +- It does not prohibit the user from logging back in via the UI. +- Once a deactivated user logs back into the GitLab UI, their account is set to active. + +A deactivated user: + +- Cannot access Git repositories or the API. +- Will not receive any notifications from GitLab. +- Will not be able to use [slash commands](../../integration/slash_commands.md). + +Personal projects, and group and user history of the deactivated user will be left intact. + +A user can be deactivated from the Admin Area. To do this: + +1. Navigate to **Admin Area > Overview > Users**. +1. Select a user. +1. Under the **Account** tab, click **Deactivate user**. + +Please note that for the deactivation option to be visible to an admin, the user: + +- Must be currently active. +- Should not have any activity in the last 180 days. + +Users can also be deactivated using the [GitLab API](../../api/users.html#deactivate-user). + +NOTE: **Note:** +A deactivated user does not consume a [seat](../../subscriptions/index.md#managing-subscriptions). + +## Activating a user + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/63921) in GitLab 12.4. + +A deactivated user can be activated from the Admin Area. + +To do this: + +1. Navigate to **Admin Area > Overview > Users**. +1. Click on the **Deactivated** tab. +1. Select a user. +1. Under the **Account** tab, click **Activate user**. + +Users can also be activated using the [GitLab API](../../api/users.html#activate-user). + +NOTE: **Note:** +Activating a user will change the user's state to active and it consumes a +[seat](../../subscriptions/index.md#managing-subscriptions). + +TIP: **Tip:** +A deactivated user can also activate their account by themselves by simply logging back via the UI. diff --git a/doc/user/admin_area/blocking_unblocking_users.md b/doc/user/admin_area/blocking_unblocking_users.md new file mode 100644 index 0000000000000000000000000000000000000000..8868170169ef38d36f048ed83743ef751f8febcd --- /dev/null +++ b/doc/user/admin_area/blocking_unblocking_users.md @@ -0,0 +1,48 @@ +--- +type: howto +--- + +# Blocking and unblocking users + +GitLab administrators block and unblock users. + +## Blocking a user + +In order to completely prevent access of a user to the GitLab instance, administrators can choose to +block the user. + +Users can be blocked [via an abuse report](abuse_reports.md#blocking-users), +or directly from the Admin Area. To do this: + +1. Navigate to **Admin Area > Overview > Users**. +1. Select a user. +1. Under the **Account** tab, click **Block user**. + +A blocked user: + +- Will not be able to login. +- Cannot access Git repositories or the API. +- Will not receive any notifications from GitLab. +- Will not be able to use [slash commands](../../integration/slash_commands.md). + +Personal projects, and group and user history of the blocked user will be left intact. + +Users can also be blocked using the [GitLab API](../../api/users.html#block-user). + +NOTE: **Note:** +A blocked user does not consume a [seat](../../subscriptions/index.md#managing-subscriptions). + +## Unblocking a user + +A blocked user can be unblocked from the Admin Area. To do this: + +1. Navigate to **Admin Area > Overview > Users**. +1. Click on the **Blocked** tab. +1. Select a user. +1. Under the **Account** tab, click **Unblock user**. + +Users can also be unblocked using the [GitLab API](../../api/users.html#unblock-user). + +NOTE: **Note:** +Unblocking a user will change the user's state to active and it consumes a +[seat](../../subscriptions/index.md#managing-subscriptions). diff --git a/doc/user/admin_area/diff_limits.md b/doc/user/admin_area/diff_limits.md index 5117b5f476f17eb1dcfeb0f518f6bc212676d4a9..4e24c25de8ff0b129487949d1e6b346c213b4bd1 100644 --- a/doc/user/admin_area/diff_limits.md +++ b/doc/user/admin_area/diff_limits.md @@ -6,7 +6,7 @@ type: reference You can set a maximum size for display of diff files (patches). -For details about diff files, [View changes between files](../project/merge_requests/index.md#view-changes-between-file-versions). +For details about diff files, [View changes between files](../project/merge_requests/reviewing_and_managing_merge_requests.md#view-changes-between-file-versions). ## Maximum diff patch size diff --git a/doc/user/admin_area/index.md b/doc/user/admin_area/index.md index c75a8bcac797b9db93adea6de0dfeee3492eda64..35cb2b42c56400612ecaa502742ff4ca552983b2 100644 --- a/doc/user/admin_area/index.md +++ b/doc/user/admin_area/index.md @@ -112,8 +112,8 @@ To list users matching a specific criteria, click on one of the following tabs o - **2FA Enabled** - **2FA Disabled** - **External** -- **Blocked** -- **Deactivated** +- **[Blocked](blocking_unblocking_users.md)** +- **[Deactivated](activating_deactivating_users.md)** - **Without projects** For each user, their username, email address, are listed, also the date their account was diff --git a/doc/user/admin_area/monitoring/health_check.md b/doc/user/admin_area/monitoring/health_check.md index 6439607de330eea17e4ec28f16cab89a85ea1d49..103d7ecc573a05e687ac647069bdc9e481c494e1 100644 --- a/doc/user/admin_area/monitoring/health_check.md +++ b/doc/user/admin_area/monitoring/health_check.md @@ -13,7 +13,7 @@ type: concepts, howto GitLab provides liveness and readiness probes to indicate service health and reachability to required services. These probes report on the status of the database connection, Redis connection, and access to the filesystem. These -endpoints [can be provided to schedulers like Kubernetes][kubernetes] to hold +endpoints [can be provided to schedulers like Kubernetes](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/) to hold traffic until the system is ready or restart the container as needed. ## IP whitelist @@ -39,7 +39,11 @@ GET http://localhost/-/liveness ## Health -Checks whether the application server is running. It does not verify the database or other services are running. +Checks whether the application server is running. +It does not verify the database or other services +are running. This endpoint circumvents Rails Controllers +and is implemented as additional middleware `BasicHealthCheck` +very early into the request processing lifecycle. ```text GET /-/health @@ -59,10 +63,17 @@ GitLab OK ## Readiness -The readiness probe checks whether the GitLab instance is ready to use. It checks the dependent services (Database, Redis, Gitaly etc.) and gives a status for each. +The readiness probe checks whether the GitLab instance is ready +to accept traffic via Rails Controllers. The check by default +does validate only instance-checks. + +If the `all=1` parameter is specified, the check will also validate +the dependent services (Database, Redis, Gitaly etc.) +and gives a status for each. ```text GET /-/readiness +GET /-/readiness?all=1 ``` Example request: @@ -75,37 +86,30 @@ Example response: ```json { - "db_check":{ + "master_check":[{ "status":"failed", - "message": "unexpected Db check result: 0" - }, - "redis_check":{ - "status":"ok" - }, - "cache_check":{ - "status":"ok" - }, - "queues_check":{ - "status":"ok" - }, - "shared_state_check":{ - "status":"ok" - }, - "gitaly_check":{ - "status":"ok", - "labels":{ - "shard":"default" - } - } - } + "message": "unexpected Master check result: false" + }], + ... +} ``` +On failure, the endpoint will return a `503` HTTP status code. + +This check does hit the database and Redis if authenticated via `token`. + +This check is being exempt from Rack Attack. + ## Liveness DANGER: **Warning:** -In Gitlab [12.4](https://about.gitlab.com/upcoming-releases/) the response body of the Liveness check will change to match the example below. +In Gitlab [12.4](https://about.gitlab.com/upcoming-releases/) +the response body of the Liveness check was changed +to match the example below. -The liveness probe checks whether the application server is alive. Unlike the [`health`](#health) check, this check hits the database. +Checks whether the application server is running. +This probe is used to know if Rails Controllers +are not deadlocked due to a multi-threading. ```text GET /-/liveness @@ -127,7 +131,9 @@ On success, the endpoint will return a `200` HTTP status code, and a response li } ``` -On failure, the endpoint will return a `500` HTTP status code. +On failure, the endpoint will return a `503` HTTP status code. + +This check is being exempt from Rack Attack. ## Access token (Deprecated) @@ -163,4 +169,3 @@ but commented out to help encourage others to add to it in the future. --> [pingdom]: https://www.pingdom.com [nagios-health]: https://nagios-plugins.org/doc/man/check_http.html [newrelic-health]: https://docs.newrelic.com/docs/alerts/alert-policies/downtime-alerts/availability-monitoring -[kubernetes]: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-probes/ 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 a1beee404eb409d12c9009bf04b3ce36e18fc632..e443127a8a043563587fe03319fedf455432aa71 100644 --- a/doc/user/admin_area/settings/account_and_limit_settings.md +++ b/doc/user/admin_area/settings/account_and_limit_settings.md @@ -32,7 +32,7 @@ For instance, consider the following workflow: 1. Your team develops apps which require large files to be stored in the application repository. -1. Although you have enabled [Git LFS](../../../workflow/lfs/manage_large_binaries_with_git_lfs.md#git-lfs) +1. Although you have enabled [Git LFS](../../../administration/lfs/manage_large_binaries_with_git_lfs.md#git-lfs) to your project, your storage has grown significantly. 1. Before you exceed available storage, you set up a limit of 10 GB per repository. diff --git a/doc/user/admin_area/settings/continuous_integration.md b/doc/user/admin_area/settings/continuous_integration.md index c60b33231056c6251ddc769839514e64ae4c04e5..f775dd8bbb4cebdced80392aa23617a0364cd9d2 100644 --- a/doc/user/admin_area/settings/continuous_integration.md +++ b/doc/user/admin_area/settings/continuous_integration.md @@ -134,6 +134,19 @@ Once that time passes, the jobs will be archived and no longer able to be retried. Make it empty to never expire jobs. It has to be no less than 1 day, for example: <code>15 days</code>, <code>1 month</code>, <code>2 years</code>. +## Default CI configuration path + +> [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): + +1. Go to **Admin area > Settings > Continuous Integration and Deployment**. +1. Input the new path in the **Default CI configuration path** field. +1. Hit **Save changes** for the changes to take effect. + +It is also possible to specify a [custom CI configuration path for a specific project](../../project/pipelines/settings.md#custom-ci-configuration-path). + <!-- ## Troubleshooting Include any troubleshooting steps that you can foresee. If you know beforehand what issues diff --git a/doc/user/admin_area/settings/email.md b/doc/user/admin_area/settings/email.md index 6026f9dc735542aa38fbf811a5bf672213d0114b..4611d5f5c778185d29c56e12d58f4d58e4169cbb 100644 --- a/doc/user/admin_area/settings/email.md +++ b/doc/user/admin_area/settings/email.md @@ -8,7 +8,7 @@ You can customize some of the content in emails sent from your GitLab instance. ## Custom logo -The logo in the header of some emails can be customized, see the [logo customization section](../../../customization/branded_page_and_email_header.md). +The logo in the header of some emails can be customized, see the [logo customization section](../appearance.md#navigation-bar). ## Custom additional text **(PREMIUM ONLY)** diff --git a/doc/user/admin_area/settings/img/two_factor_grace_period.png b/doc/user/admin_area/settings/img/two_factor_grace_period.png new file mode 100644 index 0000000000000000000000000000000000000000..e7fb52969aa1a3abcd445c4a9b43451105582027 Binary files /dev/null and b/doc/user/admin_area/settings/img/two_factor_grace_period.png differ diff --git a/doc/user/admin_area/settings/index.md b/doc/user/admin_area/settings/index.md index 4ca91ae533982d477c89f7123944347bdb6d0468..42f496bfbfaf859f720a65095632586a1e09384a 100644 --- a/doc/user/admin_area/settings/index.md +++ b/doc/user/admin_area/settings/index.md @@ -14,6 +14,7 @@ include: - [Continuous Integration and Deployment](continuous_integration.md) - [Email](email.md) - [Sign up restrictions](sign_up_restrictions.md) +- [Sign in restrictions](sign_in_restrictions.md) - [Terms](terms.md) - [Third party offers](third_party_offers.md) - [Usage statistics](usage_statistics.md) diff --git a/doc/user/admin_area/settings/sign_in_restrictions.md b/doc/user/admin_area/settings/sign_in_restrictions.md new file mode 100644 index 0000000000000000000000000000000000000000..0975766400fb3a6ead2be916322aa8d0ff76b846 --- /dev/null +++ b/doc/user/admin_area/settings/sign_in_restrictions.md @@ -0,0 +1,56 @@ +--- +type: reference +--- + +# Sign-in restrictions **(CORE ONLY)** + +You can use sign-in restrictions to limit the authentication with password +for web interface and Git over HTTP(S), two-factor authentication enforcing, as well as +as configuring the home page URL and after sign-out path. + +## Password authentication enabled + +You can restrict the password authentication for web interface and Git over HTTP(S): + +- **Web interface**: When this feature is disabled, an [external authentication provider](../../../administration/auth/README.md) must be used. +- **Git over HTTP(S)**: When this feature is disabled, a [Personal Access Token](../../profile/personal_access_tokens.md) must be used to authenticate. + +## Two-factor authentication + +When this feature enabled, all users will have to use the [two-factor authentication](../../profile/account/two_factor_authentication.md). + +Once the two-factor authentication is configured as mandatory, the users will be allowed +to skip forced configuration of two-factor authentication for the configurable grace +period in hours. + + + +## Sign-in information + +All users that are not logged-in will be redirected to the page represented by the configured +"Home page URL" if value is not empty. + +All users will be redirect to the page represented by the configured "After sign out path" +after sign out if value is not empty. + +If a "Sign in text" in Markdown format is provided, then every user will be presented with +this message after logging-in. + +## Settings + +To access this feature: + +1. Navigate to the **Settings > General** in the Admin area. +1. Expand the **Sign-in restrictions** section. + +<!-- ## 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. --> diff --git a/doc/user/admin_area/settings/usage_statistics.md b/doc/user/admin_area/settings/usage_statistics.md index 98126f72a78d6f1fe90fa242fcbaf1ea1ce46928..81edd9eac34b8ea9a61350be41a9b1df5298791c 100644 --- a/doc/user/admin_area/settings/usage_statistics.md +++ b/doc/user/admin_area/settings/usage_statistics.md @@ -31,7 +31,7 @@ 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 > Usage statistics**. +disable the version check at **Admin area > Settings > Metrics and profiling > Usage statistics**. ## Usage ping **(CORE ONLY)** @@ -85,7 +85,7 @@ 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 > Usage statistics**. +Statistics visibility section under **Admin area > Settings > Metrics and profiling > Usage statistics**. <!-- ## 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 f718e31e8bdac3eda7b4056a9d5b4922e572b143..73406fd503755861f9c4ca184d2445ee5a0576e7 100644 --- a/doc/user/admin_area/settings/visibility_and_access_controls.md +++ b/doc/user/admin_area/settings/visibility_and_access_controls.md @@ -177,7 +177,7 @@ For more details, see [SSH key restrictions](../../../security/ssh_keys_restrict > [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/3586) in GitLab 10.3. -This option is enabled by default. By disabling it, both [pull and push mirroring](../../../workflow/repository_mirroring.md) will no longer +This option is enabled by default. By disabling it, both [pull and push mirroring](../../project/repository/repository_mirroring.md) will no longer work in every repository and can only be re-enabled by an admin on a per-project basis.  diff --git a/doc/user/analytics/cycle_analytics.md b/doc/user/analytics/cycle_analytics.md index e17202645d398386c15ec890cd3875e6fdd146fe..c75f101b0e11127ae7b173be09dc2d853e7976ee 100644 --- a/doc/user/analytics/cycle_analytics.md +++ b/doc/user/analytics/cycle_analytics.md @@ -3,9 +3,10 @@ > - Introduced prior to GitLab 12.3 at the project level. > - [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/12077) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.3 at the group level. -Cycle Analytics measures the time spent to go from an [idea to production] - also known -as cycle time - for each of your projects. Cycle Analytics displays the median time for an idea to -reach production, along with the time typically spent in each DevOps stage along the way. +Cycle Analytics measures the time spent to go from an +[idea to production](https://about.gitlab.com/blog/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/#from-idea-to-production-with-gitlab) +(also known as cycle time) for each of your projects. Cycle Analytics displays the median time +spent in each stage defined in the process. NOTE: **Note:** Use the `cycle_analytics` feature flag to enable at the group level. @@ -14,8 +15,8 @@ Cycle Analytics is useful in order to quickly determine the velocity of a given project. It points to bottlenecks in the development process, enabling management to uncover, triage, and identify the root cause of slowdowns in the software development life cycle. -Cycle Analytics is tightly coupled with the [GitLab flow] and calculates a separate median for each -stage. +Cycle Analytics is tightly coupled with the [GitLab flow](../../topics/gitlab_flow.md) and +calculates a separate median for each stage. ## Overview @@ -46,6 +47,16 @@ There are seven stages that are tracked as part of the Cycle Analytics calculati - **Production** (Total) - Total lifecycle time; i.e. the velocity of the project or team +## Date ranges + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/13216) in GitLab 12.4. + +GitLab provides the ability to filter analytics based on a date range. To filter results: + +1. Select a group. +1. Optionally select a project. +1. Select a date range using the available date pickers. + ## How the data is measured Cycle Analytics records cycle time and data based on the project issues with the @@ -53,7 +64,8 @@ exception of the staging and production stages, where only data deployed to production are measured. Specifically, if your CI is not set up and you have not defined a `production` -or `production/*` [environment], then you will not have any data for those stages. +or `production/*` [environment](../../ci/yaml/README.md#environment), then you will not have any +data for those stages. Each stage of Cycle Analytics is further described in the table below. @@ -64,11 +76,9 @@ Each stage of Cycle Analytics is further described in the table below. | Code | Measures the median time between pushing a first commit (previous stage) and creating a merge request (MR) related to that commit. The key to keep the process tracked is to include the [issue closing pattern](../project/issues/managing_issues.md#closing-issues-automatically) to the description of the merge request (for example, `Closes #xxx`, where `xxx` is the number of the issue related to this merge request). If the issue closing pattern is not present in the merge request description, the MR is not considered to the measurement time of the stage. | | Test | Measures the median time to run the entire pipeline for that project. It's related to the time GitLab CI takes to run every job for the commits pushed to that merge request defined in the previous stage. It is basically the start->finish time for all pipelines. | | 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. | +| 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. | ---- - How this works, behind the scenes: 1. Issues and merge requests are grouped together in pairs, such that for each @@ -81,12 +91,12 @@ How this works, behind the scenes: we need for the stages, like issue creation date, merge request merge time, etc. -To sum up, anything that doesn't follow [GitLab flow] will not be tracked and the +To sum up, anything that doesn't follow [GitLab flow](../../workflow/gitlab_flow.md) will not be tracked and the Cycle Analytics dashboard will not present any data for: -- merge requests that do not close an issue. -- issues not labeled with a label present in the Issue Board or for issues not assigned a milestone. -- staging and production stages, if the project has no `production` or `production/*` +- Merge requests that do not close an issue. +- Issues not labeled with a label present in the Issue Board or for issues not assigned a milestone. +- Staging and production stages, if the project has no `production` or `production/*` environment. ## Example workflow @@ -107,7 +117,7 @@ environments is configured. 1. Push branch and create a merge request that contains the [issue closing pattern](../project/issues/managing_issues.md#closing-issues-automatically) in its description at 14:00 (stop of **Code** stage / start of **Test** and **Review** stages). -1. The CI starts running your scripts defined in [`.gitlab-ci.yml`][yml] and +1. The CI starts running your scripts defined in [`.gitlab-ci.yml`](../../ci/yaml/README.md) and takes 5min (stop of **Test** stage). 1. Review merge request, ensure that everything is OK and merge the merge request at 19:00. (stop of **Review** stage / start of **Staging** stage). @@ -151,7 +161,7 @@ The current permissions on the Project Cycle Analytics dashboard are: - Internal projects - any authenticated user can access. - Private projects - any member Guest and above can access. -You can [read more about permissions][permissions] in general. +You can [read more about permissions](../../ci/yaml/README.md) in general. NOTE: **Note:** As of GitLab 12.3, the project-level page is deprecated. You should access @@ -169,14 +179,6 @@ For Cycle Analytics functionality introduced in GitLab 12.3 and later: Learn more about Cycle Analytics in the following resources: -- [Cycle Analytics feature page](https://about.gitlab.com/product/cycle-analytics/) -- [Cycle Analytics feature preview](https://about.gitlab.com/blog/2016/09/16/feature-preview-introducing-cycle-analytics/) -- [Cycle Analytics feature highlight](https://about.gitlab.com/blog/2016/09/21/cycle-analytics-feature-highlight/) - -[ce-5986]: https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/5986 -[ce-20975]: https://gitlab.com/gitlab-org/gitlab-foss/issues/20975 -[environment]: ../../ci/yaml/README.md#environment -[GitLab flow]: ../../workflow/gitlab_flow.md -[idea to production]: https://about.gitlab.com/blog/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/#from-idea-to-production-with-gitlab -[permissions]: ../permissions.md -[yml]: ../../ci/yaml/README.md +- [Cycle Analytics feature page](https://about.gitlab.com/product/cycle-analytics/). +- [Cycle Analytics feature preview](https://about.gitlab.com/blog/2016/09/16/feature-preview-introducing-cycle-analytics/). +- [Cycle Analytics feature highlight](https://about.gitlab.com/blog/2016/09/21/cycle-analytics-feature-highlight/). diff --git a/doc/user/analytics/productivity_analytics.md b/doc/user/analytics/productivity_analytics.md index aecbac15c985893f92285fd500f7547339746013..40295e47e89455162181ccc06f963bee2d550223 100644 --- a/doc/user/analytics/productivity_analytics.md +++ b/doc/user/analytics/productivity_analytics.md @@ -42,10 +42,19 @@ The following metrics and visualizations are available on a project or group lev - Number of lines of code per commit. - Number of files touched. - Scatterplot showing all MRs merged on a certain date, together with the days it took to complete the action and a 30 day rolling median. - - Users can zoom in and out on specific days of interest. - Table showing the list of merge requests with their respective time duration metrics. - Users can sort by any of the above metrics. +## Date ranges + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/13188) in GitLab 12.4. + +GitLab has the ability to filter analytics based on a date range. To filter results: + +1. Select a group. +1. Optionally select a project. +1. Select a date range using the available date pickers. + ## Permissions The **Productivity Analytics** dashboard can be accessed only: diff --git a/doc/user/application_security/container_scanning/index.md b/doc/user/application_security/container_scanning/index.md index 14dae56f087390b693eb1b158167c88f0660db50..931755c63053e9657cb99cc7504811db6ba9069b 100644 --- a/doc/user/application_security/container_scanning/index.md +++ b/doc/user/application_security/container_scanning/index.md @@ -40,10 +40,9 @@ to perform audits for your Docker-based apps. To enable Container Scanning in your pipeline, you need: - A GitLab Runner with the - [`docker`](https://docs.gitlab.com/runner/executors/docker.html#use-docker-in-docker-with-privileged-mode) or - [`kubernetes`](https://docs.gitlab.com/runner/install/kubernetes.html#running-privileged-containers-for-the-runners) - executor running in privileged mode. If you're using the shared Runners on GitLab.com, - this is enabled by default. + [`docker`](https://docs.gitlab.com/runner/executors/docker.html) or + [`kubernetes`](https://docs.gitlab.com/runner/install/kubernetes.html) + executor. - Docker `18.09.03` or higher installed on the machine where the Runners are running. If you're using the shared Runners on GitLab.com, this is already the case. @@ -150,17 +149,18 @@ container_scanning: Container Scanning can be [configured](#overriding-the-container-scanning-template) using environment variables. -| Environment Variable | Description | Default | -| ------ | ------ | ------ | -| `KLAR_TRACE` | Set to true to enable more verbose output from klar. | `"false"` | -| `DOCKER_USER` | Username for accessing a Docker registry requiring authentication. | `$CI_REGISTRY_USER` | -| `DOCKER_PASSWORD` | Password for accessing a Docker registry requiring authentication. | `$CI_REGISTRY_PASSWORD` | -| `CLAIR_OUTPUT` | Severity level threshold. Vulnerabilities with severity level higher than or equal to this threshold will be outputted. Supported levels are `Unknown`, `Negligible`, `Low`, `Medium`, `High`, `Critical` and `Defcon1`. | `Unknown` | -| `REGISTRY_INSECURE` | Allow [Klar](https://github.com/optiopay/klar) to access insecure registries (HTTP only). Should only be set to `true` when testing the image locally. | `"false"` | -| `CLAIR_VULNERABILITIES_DB_URL` | This variable is explicitly set in the [services section](https://gitlab.com/gitlab-org/gitlab/blob/30522ca8b901223ac8c32b633d8d67f340b159c1/lib/gitlab/ci/templates/Security/Container-Scanning.gitlab-ci.yml#L17-19) of the `Container-Scanning.gitlab-ci.yml` file and defaults to `clair-vulnerabilities-db`. This value represents the address that the [postgres server hosting the vulnerabilities definitions](https://hub.docker.com/r/arminc/clair-db) is running on and **shouldn't be changed** unless you're running the image locally as described in the [Running the scanning tool](https://gitlab.com/gitlab-org/security-products/analyzers/klar/#running-the-scanning-tool) section of the [klar readme](https://gitlab.com/gitlab-org/security-products/analyzers/klar). | `clair-vulnerabilities-db` | -| `CI_APPLICATION_REPOSITORY` | Docker repository URL for the image to be scanned. | `$CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG` | -| `CI_APPLICATION_TAG` | Docker respository tag for the image to be scanned. | `$CI_COMMIT_SHA` | -| `CLAIR_DB_IMAGE_TAG` | The Docker image tag for the [postgres server hosting the vulnerabilities definitions](https://hub.docker.com/r/arminc/clair-db). It can be useful to override this value with a specific version, for example, to provide a consistent set of vulnerabilities for integration testing purposes. | `latest` | +| Environment Variable | Description | Default | +| ------ | ------ | ------ | +| `KLAR_TRACE` | Set to true to enable more verbose output from klar. | `"false"` | +| `DOCKER_USER` | Username for accessing a Docker registry requiring authentication. | `$CI_REGISTRY_USER` | +| `DOCKER_PASSWORD` | Password for accessing a Docker registry requiring authentication. | `$CI_REGISTRY_PASSWORD` | +| `CLAIR_OUTPUT` | Severity level threshold. Vulnerabilities with severity level higher than or equal to this threshold will be outputted. Supported levels are `Unknown`, `Negligible`, `Low`, `Medium`, `High`, `Critical` and `Defcon1`. | `Unknown` | +| `REGISTRY_INSECURE` | Allow [Klar](https://github.com/optiopay/klar) to access insecure registries (HTTP only). Should only be set to `true` when testing the image locally. | `"false"` | +| `CLAIR_VULNERABILITIES_DB_URL` | This variable is explicitly set in the [services section](https://gitlab.com/gitlab-org/gitlab/blob/30522ca8b901223ac8c32b633d8d67f340b159c1/lib/gitlab/ci/templates/Security/Container-Scanning.gitlab-ci.yml#L17-19) of the `Container-Scanning.gitlab-ci.yml` file and defaults to `clair-vulnerabilities-db`. This value represents the address that the [postgres server hosting the vulnerabilities definitions](https://hub.docker.com/r/arminc/clair-db) is running on and **shouldn't be changed** unless you're running the image locally as described in the [Running the scanning tool](https://gitlab.com/gitlab-org/security-products/analyzers/klar/#running-the-scanning-tool) section of the [GitLab klar analyzer readme](https://gitlab.com/gitlab-org/security-products/analyzers/klar). | `clair-vulnerabilities-db` | +| `CI_APPLICATION_REPOSITORY` | Docker repository URL for the image to be scanned. | `$CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG` | +| `CI_APPLICATION_TAG` | Docker respository tag for the image to be scanned. | `$CI_COMMIT_SHA` | +| `CLAIR_DB_IMAGE` | The Docker image name and tag for the [postgres server hosting the vulnerabilities definitions](https://hub.docker.com/r/arminc/clair-db). It can be useful to override this value with a specific version, for example, to provide a consistent set of vulnerabilities for integration testing purposes, or to refer to a locally hosted vulnerabilities database for an on-premise air-gapped installation. | `arminc/clair-db:latest` | +| `CLAIR_DB_IMAGE_TAG` | (**DEPRECATED - use `CLAIR_DB_IMAGE` instead**) The Docker image tag for the [postgres server hosting the vulnerabilities definitions](https://hub.docker.com/r/arminc/clair-db). It can be useful to override this value with a specific version, for example, to provide a consistent set of vulnerabilities for integration testing purposes. | `latest` | ## Security Dashboard @@ -178,6 +178,47 @@ Once a vulnerability is found, you can interact with it. Read more on how to For more information about the vulnerabilities database update, check the [maintenance table](../index.md#maintenance-and-update-of-the-vulnerabilities-database). +## Running Container Scanning in an offline air-gapped installation + +Container Scanning can be executed on an offline air-gapped GitLab Ultimate installation using the following process: + +1. Host the following Docker images on a [local Docker container registry](../../packages/container_registry/index.md): + - [arminc/clair-db vulnerabilities database](https://hub.docker.com/r/arminc/clair-db) + - [GitLab klar analyzer](https://gitlab.com/gitlab-org/security-products/analyzers/klar) +1. [Override the container scanning template](#overriding-the-container-scanning-template) in your `.gitlab-ci.yml` file to refer to the Docker images hosted on your local Docker container registry: + + ```yaml + include: + - template: Container-Scanning.gitlab-ci.yml + + container_scanning: + image: $CI_REGISTRY/namespace/gitlab-klar-analyzer + variables: + CLAIR_DB_IMAGE: $CI_REGISTRY/namespace/clair-vulnerabilities-db + ``` + +It may be worthwhile to set up a [scheduled pipeline](../../project/pipelines/schedules.md) to automatically build a new version of the vulnerabilities database on a preset schedule. You can use the following `.gitlab-yml.ci` as a template: + +```yaml +image: docker:stable + +services: + - docker:stable-dind + +stages: + - build + +build_latest_vulnerabilities: + stage: build + script: + - docker pull arminc/clair-db:latest + - docker tag arminc/clair-db:latest $CI_REGISTRY/namespace/clair-vulnerabilities-db + - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY + - docker push $CI_REGISTRY/namespace/clair-vulnerabilities-db +``` + +The above template will work for a GitLab Docker registry running on a local installation, however, if you're using a non-GitLab Docker registry, you'll need to change the `$CI_REGISTRY` value and the `docker login` credentials to match the details of your local registry. + ## Troubleshooting ### docker: Error response from daemon: failed to copy xattrs diff --git a/doc/user/application_security/dast/index.md b/doc/user/application_security/dast/index.md index 951c4b9dd732b6230fb2de5270af67d2e08a0ba3..d285b5ff585931d3885236de30faeb549e766c82 100644 --- a/doc/user/application_security/dast/index.md +++ b/doc/user/application_security/dast/index.md @@ -339,3 +339,33 @@ 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. --> + +## Troubleshooting + +### Running out of memory + +By default, ZAProxy, which DAST relies on, is allocated memory that sums to 25% +of the total memory on the host. +Since it keeps most of its information in memory during a scan, +it is possible for DAST to run out of memory while scanning large applications. +This results in the following error: + +``` +[zap.out] java.lang.OutOfMemoryError: Java heap space +``` + +Fortunately, it is straightforward to increase the amount of memory available +for DAST by overwriting the `script` key in the DAST template: + +```yaml +include: + template: DAST.gitlab-ci.yml + +dast: + script: + - export DAST_WEBSITE=${DAST_WEBSITE:-$(cat environment_url.txt)} + - /analyze -t $DAST_WEBSITE -z"-Xmx3072m" +``` + +Here, DAST is being allocated 3072 MB. +Change the number after `-Xmx` to the required memory amount. diff --git a/doc/user/application_security/dependency_list/img/dependency_list_v12_4.png b/doc/user/application_security/dependency_list/img/dependency_list_v12_4.png new file mode 100644 index 0000000000000000000000000000000000000000..4687987b763b557a371a8c9ed22810a9f2f4f388 Binary files /dev/null and b/doc/user/application_security/dependency_list/img/dependency_list_v12_4.png differ diff --git a/doc/user/application_security/dependency_list/index.md b/doc/user/application_security/dependency_list/index.md index 8366e943ccc9ef9ee0b9dd45bf69f1d6ab1b1069..2828d4871534a8fba48de4087f2a9d84ec58583e 100644 --- a/doc/user/application_security/dependency_list/index.md +++ b/doc/user/application_security/dependency_list/index.md @@ -17,7 +17,7 @@ sidebar. ## Viewing dependencies - + Dependencies are displayed with the following information: diff --git a/doc/user/application_security/dependency_scanning/index.md b/doc/user/application_security/dependency_scanning/index.md index 9f87d79025eed59af36bf1b29dffd3c811cfc668..0e46052b0bd5138ac080414e49c111a7144323e0 100644 --- a/doc/user/application_security/dependency_scanning/index.md +++ b/doc/user/application_security/dependency_scanning/index.md @@ -37,7 +37,7 @@ The results are sorted by the severity of the vulnerability: ## Requirements -To run a Dependency Scanning job, you need GitLab Runner with the +To run a Dependency Scanning job, by default, you need GitLab Runner with the [`docker`](https://docs.gitlab.com/runner/executors/docker.html#use-docker-in-docker-with-privileged-mode) or [`kubernetes`](https://docs.gitlab.com/runner/install/kubernetes.html#running-privileged-containers-for-the-runners) executor running in privileged mode. If you're using the shared Runners on GitLab.com, @@ -47,6 +47,8 @@ CAUTION: **Caution:** If you use your own Runners, make sure that the Docker version you have installed is **not** `19.03.00`. See [troubleshooting information](#error-response-from-daemon-error-processing-tar-file-docker-tar-relocation-error) for details. +Privileged mode is not necessary if you've [disabled Docker in Docker for Dependency Scanning](#disabling-docker-in-docker-for-dependency-scanning) + ## Supported languages and package managers The following languages and dependency managers are supported. @@ -59,27 +61,10 @@ The following languages and dependency managers are supported. | Go ([Golang](https://golang.org/)) | not currently ([issue](https://gitlab.com/gitlab-org/gitlab/issues/7132 "Dependency Scanning for Go")) | not available | | PHP ([Composer](https://getcomposer.org/)) | yes | [gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) | | Python ([pip](https://pip.pypa.io/en/stable/)) | yes | [gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) | -| Python ([Pipfile](https://docs.pipenv.org/en/latest/basics/)) | not currently ([issue](https://gitlab.com/gitlab-org/gitlab/issues/11756 "Pipfile.lock support for Dependency Scanning"))| not available | +| Python ([Pipfile](https://pipenv.kennethreitz.org/en/latest/basics/)) | not currently ([issue](https://gitlab.com/gitlab-org/gitlab/issues/11756 "Pipfile.lock support for Dependency Scanning"))| not available | | 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) | -## Remote checks - -While some tools pull a local database to check vulnerabilities, some others -like Gemnasium require sending data to GitLab central servers to analyze them: - -1. Gemnasium scans the dependencies of your project locally and sends a list of - packages to GitLab central servers. -1. The servers return the list of known vulnerabilities for all versions of - these packages. -1. The client picks up the relevant vulnerabilities by comparing with the versions - of the packages that are used by the project. - -The Gemnasium client does **NOT** send the exact package versions your project relies on. - -You can disable the remote checks by [using](#customizing-the-dependency-scanning-settings) -the `DS_DISABLE_REMOTE_CHECKS` environment variable and setting it to `"true"`. - ## Configuration For GitLab 11.9 and later, to enable Dependency Scanning, you must @@ -116,7 +101,7 @@ include: template: Dependency-Scanning.gitlab-ci.yml variables: - DS_DISABLE_REMOTE_CHECKS: "true" + DS_PYTHON_VERSION: 2 ``` Because template is [evaluated before](../../../ci/yaml/README.md#include) the pipeline @@ -150,7 +135,7 @@ 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_DEPENDENCY_PATH` | Path to load Python pip dependencies from. ([Introduced](https://gitlab.com/gitlab-org/gitlab/issues/12412) in GitLab 12.2) | | | `DS_DEFAULT_ANALYZERS` | Override the names of the official default images. Read more about [customizing analyzers](analyzers.md). | | -| `DS_DISABLE_REMOTE_CHECKS` | Do not send any data to GitLab. Used in the [Gemnasium analyzer](#remote-checks). | | +| `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). | | | `DS_EXCLUDED_PATHS` | Exclude vulnerabilities from output based on the paths. A comma-separated list of patterns. Patterns can be globs, file or folder paths. Parent directories will also match patterns. | `DS_EXCLUDED_PATHS=doc,spec` | | `DS_DOCKER_CLIENT_NEGOTIATION_TIMEOUT` | Time limit for Docker client negotiation. Timeouts are parsed using Go's [`ParseDuration`](https://golang.org/pkg/time/#ParseDuration). Valid time units are `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`. For example, `300ms`, `1.5h`, or `2h45m`. | | @@ -158,6 +143,50 @@ using environment variables. | `DS_RUN_ANALYZER_TIMEOUT` | Time limit when running an analyzer. Timeouts are parsed using Go's [`ParseDuration`](https://golang.org/pkg/time/#ParseDuration). Valid time units are `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`. For example, `300ms`, `1.5h`, or `2h45m`. | | | `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. | | +| `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)). | | + +### Using private Maven repos + +If you have a private Maven repository which requires login credentials, +you can use the `MAVEN_CLI_OPTS` environment variable to pass variables +specified in your settings (e.g., username, password, etc.). + +For example, if you have a settings file in your project source (e.g., `mysettings.xml`) +that looks like the following, you can specify the variables +[by adding an entry under your project's settings](../../../ci/variables/README.md#via-the-ui), +so that you don't have to expose your private data in `.gitlab-ci.yml` (e.g., adding +`MAVEN_CLI_OPTS` with value `--settings mysettings.xml -Dprivate.username=foo -Dprivate.password=bar`). + +```xml +<!-- mysettings.xml --> +<settings> + ... + <servers> + <server> + <id>private_server</id> + <username>${private.username}</username> + <password>${private.password}</password> + </server> + </servers> +</settings> +``` + +### Disabling Docker in Docker for Dependency Scanning + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/12487) in GitLab Ultimate 12.5. + +You can avoid the need for Docker in Docker by running the individual analyzers. +This does not require running the executor in privileged mode. For example: + +```yaml +include: + template: Dependency-Scanning.gitlab-ci.yml + +variables: + DS_DISABLE_DIND: "true" +``` + +This will create individual `<analyzer-name>-dependency_scanning` jobs for each analyzer that runs in your CI/CD pipeline. ## Interacting with the vulnerabilities diff --git a/doc/user/application_security/index.md b/doc/user/application_security/index.md index e9f5898950e4ab63b6a4d67fbc7dc87b1178af0f..dbbcb606ac76e3dfe3b44b4690463adc8e19b0c2 100644 --- a/doc/user/application_security/index.md +++ b/doc/user/application_security/index.md @@ -203,14 +203,34 @@ An approval will be optional when a license report: - Contains no software license violations. - Contains only new licenses that are `approved` or unknown. -<!-- ## Troubleshooting +## 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. +### Getting error message `sast job: stage parameter should be [some stage name here]` -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. --> +When including a security job template like [`SAST`](sast/index.md#configuration), +the following error can be raised, depending on your GitLab CI/CD configuration: + +``` +Found errors in your .gitlab-ci.yml: + +* sast job: stage parameter should be unit-tests +``` + +This error appears when the stage (nammed `test`) of the included job isn't declared +in `.gitlab-ci.yml`. +To fix this issue, you can either: + +- Add a `test` stage in your `.gitlab-ci.yml`. +- Change the default stage of the included security jobs. For example, with `SAST`: + + ```yaml + include: + template: SAST.gitlab-ci.yml + + sast: + stage: unit-tests + ``` + +[Learn more on overriding the SAST template](sast/index.md#overriding-the-sast-template). +All the security scanning tools define their stage, so this error can occur with +all of them. diff --git a/doc/user/application_security/license_compliance/index.md b/doc/user/application_security/license_compliance/index.md index 75a3b33e32edb79f72a16d623ee967dec23bc187..3cf8301adcaeed1136d0ebd8bf4a460a6816df4c 100644 --- a/doc/user/application_security/license_compliance/index.md +++ b/doc/user/application_security/license_compliance/index.md @@ -94,8 +94,20 @@ always take the latest License Compliance artifact available. Behind the scenes, [GitLab License Compliance Docker image](https://gitlab.com/gitlab-org/security-products/license-management) is used to detect the languages/frameworks and in turn analyzes the licenses. -The License Compliance settings can be changed through environment variables by using the -[`variables`](../../../ci/yaml/README.md#variables) parameter in `.gitlab-ci.yml`. These variables are documented in the [License Compliance documentation](https://gitlab.com/gitlab-org/security-products/license-management#settings). +The License Compliance settings can be changed through [environment variables](#available-variables) by using the +[`variables`](../../../ci/yaml/README.md#variables) parameter in `.gitlab-ci.yml`. + +### Available variables + +License Compliance can be configured using environment variables. + +| Environment variable | Required | Description | +|-----------------------|----------|-------------| +| `MAVEN_CLI_OPTS` | no | Additional arguments for the mvn executable. If not supplied, defaults to `-DskipTests`. | +| `LICENSE_FINDER_CLI_OPTS` | no | Additional arguments for the `license_finder` executable. For example, if your project has both Golang and Ruby code stored in different directories and you want to only scan the Ruby code, you can update your `.gitlab-ci-yml` template to specify which project directories to scan, like `LICENSE_FINDER_CLI_OPTS: '--debug --aggregate-paths=. ruby'`. | +| `LM_JAVA_VERSION` | no | Version of Java. If set to `11`, Maven and Gradle use Java 11 instead of Java 8. | +| `LM_PYTHON_VERSION` | no | Version of Python. If set to `3`, dependencies are installed using Python 3 instead of Python 2.7. | +| `SETUP_CMD` | no | Custom setup for the dependency installation. (experimental) | ### Installing custom dependencies diff --git a/doc/user/application_security/sast/analyzers.md b/doc/user/application_security/sast/analyzers.md index 76a566f751413d9e71e3d564dc2bce104d08eff3..6eb2ca71e7182fb08ebc6a4d2d4ea9320ae8b173 100644 --- a/doc/user/application_security/sast/analyzers.md +++ b/doc/user/application_security/sast/analyzers.md @@ -25,7 +25,7 @@ SAST supports the following official analyzers: - [`security-code-scan`](https://gitlab.com/gitlab-org/security-products/analyzers/security-code-scan) (Security Code Scan (.NET)) - [`sobelow`](https://gitlab.com/gitlab-org/security-products/analyzers/sobelow) (Sobelow (Elixir Phoenix)) - [`spotbugs`](https://gitlab.com/gitlab-org/security-products/analyzers/spotbugs) (SpotBugs with the Find Sec Bugs plugin (Ant, Gradle and wrapper, Grails, Maven and wrapper, SBT)) -- [`tslint`](https://gitlab.com/gitlab-org/security-products/analyzers/tslint) (TSLint (Typescript)) +- [`tslint`](https://gitlab.com/gitlab-org/security-products/analyzers/tslint) (TSLint (TypeScript)) The analyzers are published as Docker images that SAST will use to launch dedicated containers for each analysis. @@ -111,6 +111,9 @@ This configuration doesn't benefit from the integrated detection step. SAST has to fetch and spawn each Docker image to establish whether the custom analyzer can scan the source code. +CAUTION: **Caution:** +Custom analyzers are not spawned automatically when [Docker In Docker](index.md#disabling-docker-in-docker-for-sast) is disabled. + ## Analyzers Data | Property \ Tool | Apex | Bandit | Brakeman | ESLint security | Find Sec Bugs | Flawfinder | Go AST Scanner | NodeJsScan | Php CS Security Audit | Security code Scan (.NET) | TSLint Security | Sobelow | diff --git a/doc/user/application_security/sast/index.md b/doc/user/application_security/sast/index.md index cb54d9f3853d6131819f69902151f26fe9fd812e..615eb072ea7493dfe0dd8d3c5191d45d6a7facde 100644 --- a/doc/user/application_security/sast/index.md +++ b/doc/user/application_security/sast/index.md @@ -78,7 +78,7 @@ The following table shows which languages, package managers and frameworks are s | Python ([pip](https://pip.pypa.io/en/stable/)) | [bandit](https://github.com/PyCQA/bandit) | 10.3 | | Ruby on Rails | [brakeman](https://brakemanscanner.org) | 10.3 | | Scala ([Ant](https://ant.apache.org/), [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/) and [SBT](https://www.scala-sbt.org/)) | [SpotBugs](https://spotbugs.github.io/) with the [find-sec-bugs](https://find-sec-bugs.github.io/) plugin | 11.0 (SBT) & 11.9 (Ant, Gradle, Maven) | -| Typescript | [TSLint config security](https://github.com/webschik/tslint-config-security/) | 11.9 | +| TypeScript | [TSLint config security](https://github.com/webschik/tslint-config-security/) | 11.9 | NOTE: **Note:** The Java analyzers can also be used for variants like the @@ -146,7 +146,15 @@ sast: CI_DEBUG_TRACE: "true" ``` -### Using a variable to pass username and password to a private Maven repository +### Using environment variables to pass credentials for private repositories + +Some analyzers require downloading the project's dependencies in order to +perform the analysis. In turn, such dependencies may live in private Git +repositories and thus require credentials like username and password to download them. +Depending on the analyzer, such credentials can be provided to +it via [custom environment variables](#custom-environment-variables). + +#### Using a variable to pass username and password to a private Maven repository If you have a private Apache Maven repository that requires login credentials, you can use the `MAVEN_CLI_OPTS` [environment variable](#available-variables) @@ -184,14 +192,14 @@ SAST can be [configured](#customizing-the-sast-settings) using environment varia The following are Docker image-related variables. -| Environment variable | Description | -|-------------------------------|--------------------------------------------------------------------------------| -| `SAST_ANALYZER_IMAGES` | Comma separated list of custom images. Default images are still enabled. Read more about [customizing analyzers](analyzers.md). | -| `SAST_ANALYZER_IMAGE_PREFIX` | Override the name of the Docker registry providing the default images (proxy). Read more about [customizing analyzers](analyzers.md). | -| `SAST_ANALYZER_IMAGE_TAG` | Override the Docker tag of the default images. Read more about [customizing analyzers](analyzers.md). | -| `SAST_DEFAULT_ANALYZERS` | Override the names of default images. Read more about [customizing analyzers](analyzers.md). | -| `SAST_DISABLE_DIND` | Disable Docker in Docker and run analyzers [individually](#disabling-docker-in-docker-for-sast). | -| `SAST_PULL_ANALYZER_IMAGES` | Pull the images from the Docker registry (set to 0 to disable). Read more about [customizing analyzers](analyzers.md). | +| Environment variable | Description | +|------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `SAST_ANALYZER_IMAGES` | Comma separated list of custom images. Default images are still enabled. Read more about [customizing analyzers](analyzers.md). Not available when [Docker in Docker is disabled](#disabling-docker-in-docker-for-sast). | +| `SAST_ANALYZER_IMAGE_PREFIX` | Override the name of the Docker registry providing the default images (proxy). Read more about [customizing analyzers](analyzers.md). | +| `SAST_ANALYZER_IMAGE_TAG` | Override the Docker tag of the default images. Read more about [customizing analyzers](analyzers.md). | +| `SAST_DEFAULT_ANALYZERS` | Override the names of default images. Read more about [customizing analyzers](analyzers.md). | +| `SAST_DISABLE_DIND` | Disable Docker in Docker and run analyzers [individually](#disabling-docker-in-docker-for-sast). | +| `SAST_PULL_ANALYZER_IMAGES` | Pull the images from the Docker registry (set to 0 to disable). Read more about [customizing analyzers](analyzers.md). Not available when [Docker in Docker is disabled](#disabling-docker-in-docker-for-sast). | #### Vulnerability filters @@ -216,6 +224,9 @@ The following variables configure timeouts. | `SAST_PULL_ANALYZER_IMAGE_TIMEOUT` | 5m | Time limit when pulling the image of an analyzer. Timeouts are parsed using Go's [`ParseDuration`](https://golang.org/pkg/time/#ParseDuration). Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". For example, "300ms", "1.5h" or "2h45m". | | `SAST_RUN_ANALYZER_TIMEOUT` | 20m | Time limit when running an analyzer. Timeouts are parsed using Go's [`ParseDuration`](https://golang.org/pkg/time/#ParseDuration). Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". For example, "300ms", "1.5h" or "2h45m".| +NOTE: **Note:** +Timeout variables are not applicable for setups with [disabled Docker In Docker](index.md#disabling-docker-in-docker-for-sast). + #### Analyzer settings Some analyzers can be customized with environment variables. @@ -234,6 +245,19 @@ Some analyzers can be customized with environment variables. | `SBT_PATH` | spotbugs | Path to the `sbt` executable. | | `FAIL_NEVER` | spotbugs | Set to `1` to ignore compilation failure. | +#### Custom environment variables + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/18193) in GitLab Ultimate 12.5. + +In addition to the aforementioned SAST configuration variables, +all [custom environment variables](../../../ci/variables/README.md#creating-a-custom-environment-variable) are propagated +to the underlying SAST analyzer images if +[the SAST vendored template](#configuration) is used. + +CAUTION: **Caution:** +Variables having names starting with these prefixes will **not** be propagated to the SAST Docker container and/or +analyzer containers: `DOCKER_`, `CI`, `GITLAB_`, `FF_`, `HOME`, `PWD`, `OLDPWD`, `PATH`, `SHLVL`, `HOSTNAME`. + ## Reports JSON format CAUTION: **Caution:** diff --git a/doc/user/application_security/security_dashboard/img/group_security_dashboard_v12_3.png b/doc/user/application_security/security_dashboard/img/group_security_dashboard_v12_3.png deleted file mode 100644 index 1fe76a9e08f59b10bf148caa8e6fbb69bec22afd..0000000000000000000000000000000000000000 Binary files a/doc/user/application_security/security_dashboard/img/group_security_dashboard_v12_3.png and /dev/null differ diff --git a/doc/user/application_security/security_dashboard/img/group_security_dashboard_v12_4.png b/doc/user/application_security/security_dashboard/img/group_security_dashboard_v12_4.png new file mode 100644 index 0000000000000000000000000000000000000000..682dcbec63f7bf6870e059a616efa34deac8dc8a Binary files /dev/null and b/doc/user/application_security/security_dashboard/img/group_security_dashboard_v12_4.png differ diff --git a/doc/user/application_security/security_dashboard/index.md b/doc/user/application_security/security_dashboard/index.md index 0e26206f0707e17e209a8b738fd4908f1618ee29..688d231d568b15c6e5a2bf9e0550a1ff30231b84 100644 --- a/doc/user/application_security/security_dashboard/index.md +++ b/doc/user/application_security/security_dashboard/index.md @@ -71,12 +71,12 @@ Once you're on the dashboard, at the top you should see a series of filters for: - Report type - Project -To the right of the filters, you should see a **Hide dismissed** toggle button. +To the right of the filters, you should see a **Hide dismissed** toggle button ([available in GitLab Ultimate 12.5](https://gitlab.com/gitlab-org/gitlab/issues/9102)). NOTE: **Note:** The dashboard only shows projects with [security reports](#supported-reports) enabled in a group. - + Selecting one or more filters will filter the results in this page. Disabling the **Hide dismissed** toggle button will let you also see vulnerabilities that have been dismissed. diff --git a/doc/user/clusters/applications.md b/doc/user/clusters/applications.md index dc6f859e88119cdb8bef67301d903be7c4e63566..c3e2e6bca5b01af54d17bb0491be0bc09bd8db3e 100644 --- a/doc/user/clusters/applications.md +++ b/doc/user/clusters/applications.md @@ -7,7 +7,7 @@ These applications are needed for [Review Apps](../../ci/review_apps/index.md) and [deployments](../../ci/environments.md) when using [Auto DevOps](../../topics/autodevops/index.md). You can install them after you -[create a cluster](../project/clusters/index.md#adding-and-removing-clusters). +[create a cluster](../project/clusters/add_remove_clusters.md). ## Installing applications @@ -40,6 +40,7 @@ The following applications can be installed: - [GitLab Runner](#gitlab-runner) - [JupyterHub](#jupyterhub) - [Knative](#knative) +- [Crossplane](#crossplane) With the exception of Knative, the applications will be installed in a dedicated namespace called `gitlab-managed-apps`. @@ -82,19 +83,21 @@ 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. -NOTE: **Note:** -The -[jetstack/cert-manager](https://github.com/jetstack/cert-manager) -chart is used to install this application with a -[`values.yaml`](https://gitlab.com/gitlab-org/gitlab/blob/master/vendor/cert_manager/values.yaml) -file. Prior to GitLab 12.3, -the [stable/cert-manager](https://github.com/helm/charts/tree/master/stable/cert-manager) -chart was used. +The chart used to install this application depends on the version of GitLab used. In: -NOTE: **Note:** -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, uninstall cert-manager (consider [backing up any additional configuration](https://docs.cert-manager.io/en/latest/tasks/backup-restore-crds.html)), then install cert-manager again. +- GitLab 12.3 and newer, the [jetstack/cert-manager](https://github.com/jetstack/cert-manager) + chart is used with a [`values.yaml`](https://gitlab.com/gitlab-org/gitlab/blob/master/vendor/cert_manager/values.yaml) + file. +- 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). + +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. ### GitLab Runner @@ -105,10 +108,20 @@ To resolve this, uninstall cert-manager (consider [backing up any additional con project that is used to run your jobs and send the results back to GitLab. It is used in conjunction with [GitLab CI/CD](../../ci/README.md), the open-source continuous integration -service included with GitLab that coordinates the jobs. When installing -the GitLab Runner via the applications, it will run in **privileged -mode** by default. Make sure you read the [security -implications](../project/clusters/index.md#security-implications) before doing so. +service included with GitLab that coordinates the jobs. + +If the project is on GitLab.com, shared Runners are available +(the first 2000 minutes are free, you can +[buy more later](../../subscriptions/index.md#extra-shared-runners-pipeline-minutes)) +and you do not have to deploy one if they are enough for your needs. If a +project-specific Runner is desired, or there are no shared Runners, it is easy +to deploy one. + +Note that the deployed Runner will be set as **privileged**, which means it will essentially +have root access to the underlying machine. This is required to build Docker images, +so it is the default. Make sure you read the +[security implications](../project/clusters/index.md#security-implications) +before deploying one. NOTE: **Note:** The [`runner/gitlab-runner`](https://gitlab.com/gitlab-org/charts/gitlab-runner) @@ -126,12 +139,113 @@ balancing, SSL termination, and name-based virtual hosting. It acts as a web proxy for your applications and is useful if you want to use [Auto DevOps](../../topics/autodevops/index.md) or deploy your own web apps. +NOTE: **Note:** +With the following procedure, a load balancer must be installed in your cluster +to obtain the endpoint. You can use either +Ingress, or Knative's own load balancer ([Istio](https://istio.io)) if using Knative. + +In order to publish your web application, you first need to find the endpoint which will be either an IP +address or a hostname associated with your load balancer. + +To install it, click on the **Install** button for Ingress. GitLab will attempt +to determine the external endpoint and it should be available within a few minutes. + +#### Determining the external endpoint automatically + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/17052) in GitLab 10.6. + +After you install Ingress, the external endpoint should be available within a few minutes. + +TIP: **Tip:** +This endpoint can be used for the +[Auto DevOps base domain](../../topics/autodevops/index.md#auto-devops-base-domain) +using the `KUBE_INGRESS_BASE_DOMAIN` environment variable. + +If the endpoint doesn't appear and your cluster runs on Google Kubernetes Engine: + +1. Check your [Kubernetes cluster on Google Kubernetes Engine](https://console.cloud.google.com/kubernetes) to ensure there are no errors on its nodes. +1. Ensure you have enough [Quotas](https://console.cloud.google.com/iam-admin/quotas) on Google Kubernetes Engine. For more information, see [Resource Quotas](https://cloud.google.com/compute/quotas). +1. Check [Google Cloud's Status](https://status.cloud.google.com/) to ensure they are not having any disruptions. + +Once installed, you may see a `?` for "Ingress IP Address" depending on the +cloud provider. For EKS specifically, this is because the ELB is created +with a DNS name, not an IP address. If GitLab is still unable to +determine the endpoint of your Ingress or Knative application, you can +[determine it manually](#determining-the-external-endpoint-manually). + NOTE: **Note:** The [`stable/nginx-ingress`](https://github.com/helm/charts/tree/master/stable/nginx-ingress) chart is used to install this application with a [`values.yaml`](https://gitlab.com/gitlab-org/gitlab/blob/master/vendor/ingress/values.yaml) file. +#### Determining the external endpoint manually + +If the cluster is on GKE, click the **Google Kubernetes Engine** link in the +**Advanced settings**, or go directly to the +[Google Kubernetes Engine dashboard](https://console.cloud.google.com/kubernetes/) +and select the proper project and cluster. Then click **Connect** and execute +the `gcloud` command in a local terminal or using the **Cloud Shell**. + +If the cluster is not on GKE, follow the specific instructions for your +Kubernetes provider to configure `kubectl` with the right credentials. +The output of the following examples will show the external endpoint of your +cluster. This information can then be used to set up DNS entries and forwarding +rules that allow external access to your deployed applications. + +If you installed Ingress via the **Applications**, run the following command: + +```bash +kubectl get service --namespace=gitlab-managed-apps ingress-nginx-ingress-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip}' +``` + +Some Kubernetes clusters return a hostname instead, like [Amazon EKS](https://aws.amazon.com/eks/). For these platforms, run: + +```bash +kubectl get service --namespace=gitlab-managed-apps ingress-nginx-ingress-controller -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' +``` + +For Istio/Knative, the command will be different: + +```bash +kubectl get svc --namespace=istio-system knative-ingressgateway -o jsonpath='{.status.loadBalancer.ingress[0].ip} ' +``` + +Otherwise, you can list the IP addresses of all load balancers: + +```bash +kubectl get svc --all-namespaces -o jsonpath='{range.items[?(@.status.loadBalancer.ingress)]}{.status.loadBalancer.ingress[*].ip} ' +``` + +NOTE: **Note:** +If EKS is used, an [Elastic Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/) +will also be created, which will incur additional AWS costs. + +NOTE: **Note:** +You may see a trailing `%` on some Kubernetes versions, **do not include it**. + +The Ingress is now available at this address and will route incoming requests to +the proper service based on the DNS name in the request. To support this, a +wildcard DNS CNAME record should be created for the desired domain name. For example, +`*.myekscluster.com` would point to the Ingress hostname obtained earlier. + +#### Using a static IP + +By default, an ephemeral external IP address is associated to the cluster's load +balancer. If you associate the ephemeral IP with your DNS and the IP changes, +your apps will not be able to be reached, and you'd have to change the DNS +record again. In order to avoid that, you should change it into a static +reserved IP. + +Read how to [promote an ephemeral external IP address in GKE](https://cloud.google.com/compute/docs/ip-addresses/reserve-static-external-ip-address#promote_ephemeral_ip). + +#### Pointing your DNS at the external endpoint + +Once you've set up the external endpoint, you should associate it with a [wildcard DNS +record](https://en.wikipedia.org/wiki/Wildcard_DNS_record) such as `*.example.com.` +in order to be able to reach your apps. If your external endpoint is an IP address, +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)). @@ -150,7 +264,7 @@ 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 -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 ``` There is a small performance overhead by enabling `modsecurity`. However, if this is @@ -242,7 +356,7 @@ server to use the external IP address for that domain. For any application created and installed, they will be accessible as `<program_name>.<kubernetes_namespace>.<domain_name>`. This will require your Kubernetes cluster to have [RBAC -enabled](../project/clusters/index.md#rbac-cluster-resources). +enabled](../project/clusters/add_remove_clusters.md#rbac-cluster-resources). NOTE: **Note:** The [`knative/knative`](https://storage.googleapis.com/triggermesh-charts) @@ -257,12 +371,52 @@ chart is used to install this application. open-source monitoring and alerting system useful to supervise your deployed applications. +GitLab is able to monitor applications automatically, using the +[Prometheus integration](../project/integrations/prometheus.md). Kubernetes container CPU and +memory metrics are automatically collected, and response metrics are retrieved +from NGINX Ingress as well. + +To enable monitoring, simply install Prometheus into the cluster with the +**Install** button. + NOTE: **Note:** The [`stable/prometheus`](https://github.com/helm/charts/tree/master/stable/prometheus) chart is used to install this application with a [`values.yaml`](https://gitlab.com/gitlab-org/gitlab/blob/master/vendor/prometheus/values.yaml) file. +### Crossplane + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/34702) in GitLab 12.5 for project-level clusters. + +[Crossplane](https://crossplane.io/docs) is a multi-cloud control plane useful for +managing applications and infrastructure across multiple clouds. It extends the +Kubernetes API using: + +- Custom resources. +- Controllers that watch those custom resources. + +Crossplane allows provisioning and lifecycle management of infrastructure components +across cloud providers in a uniform manner by abstracting cloud provider-specific +configurations. + +The Crossplane GitLab-managed application: + +- Installs Crossplane with a provider of choice on a Kubernetes cluster attached to the + project repository. +- Can then be used to provision infrastructure or managed applications such as + PostgreSQL (for example, CloudSQL from GCP or RDS from AWS) and other services + required by the application via the Auto DevOps pipeline. + +For information on configuring Crossplane installed on the cluster, see +[Crossplane configuration](crossplane.md). + +NOTE: **Note:** +[`alpha/crossplane`](https://charts.crossplane.io/alpha/) chart v0.4.1 is used to +install Crossplane using the +[`values.yaml`](https://github.com/crossplaneio/crossplane/blob/master/cluster/charts/crossplane/values.yaml.tmpl) +file. + ## Upgrading applications > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/24789) in GitLab 11.8. @@ -296,13 +450,14 @@ 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. | | JupyterHub | 12.1+ | All data not committed to GitLab will be deleted and cannot be restored. | | 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. | To uninstall an application: diff --git a/doc/user/clusters/crossplane.md b/doc/user/clusters/crossplane.md new file mode 100644 index 0000000000000000000000000000000000000000..37210b22f6fe6318e240fffa6b929e4bbb6031f8 --- /dev/null +++ b/doc/user/clusters/crossplane.md @@ -0,0 +1,292 @@ +# Crossplane configuration + +Once Crossplane [is installed](applications.md#crossplane), it must be configured for +use. + +The process of configuring Crossplane includes: + +1. Configuring RBAC permissions. +1. Configuring Crossplane with a cloud provider. +1. Configure managed service access. +1. Setting up Resource classes. +1. Using Auto DevOps configuration options. +1. Connect to the PostgreSQL instance. + +To allow Crossplane to provision cloud services such as PostgreSQL, the cloud provider +stack must be configured with a user account. For example: + +- A service account for GCP. +- An IAM user for AWS. + +Important notes: + +- This guide uses GCP as an example. However, the process for AWS and Azure will be +similar. +- Crossplane requires the Kubernetes cluster to be VPC native with Alias IPs enabled so +that the IP address of the pods are routable within the GCP network. + +First, we need to declare some environment variables with configuration that will be used throughout this guide: + +```sh +export PROJECT_ID=crossplane-playground # the GCP project where all resources reside. +export NETWORK_NAME=default # the GCP network where your GKE is provisioned. +export REGION=us-central1 # the GCP region where the GKE cluster is provisioned. +``` + +## Configure RBAC permissions + +- For a non-GitLab managed cluster(s), ensure that the service account for the token provided can manage resources in the `database.crossplane.io` API group. +Manually grant GitLab's service account the ability to manage resources in the +`database.crossplane.io` API group. The Aggregated ClusterRole allows us to do that. +​ +NOTE: **Note:** +For a non-GitLab managed cluster, ensure that the service account for the token provided can manage resources in the `database.crossplane.io` API group. +​1. Save the following YAML as `crossplane-database-role.yaml`: + +```sh +cat > crossplane-database-role.yaml <<EOF +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: crossplane-database-role + labels: + rbac.authorization.k8s.io/aggregate-to-edit: "true" +rules: +- apiGroups: + - database.crossplane.io + resources: + - postgresqlinstances + verbs: + - get + - list + - create + - update + - delete + - patch + - watch +EOF +``` + +Once the file is created, apply it with the following command in order to create the necessary role: + +```sh +kubectl apply -f crossplane-database-role.yaml +``` + +## Configure Crossplane with a cloud provider + +See [Configure Your Cloud Provider Account](https://crossplane.io/docs/v0.4/cloud-providers.html) +to configure the installed cloud provider stack with a user account. + +Note that the Secret and the Provider resource referencing the Secret needs to be +applied to the `gitlab-managed-apps` namespace in the guide. Make sure you change that +while following the process. + +[Configure Providers](https://crossplane.io/docs/v0.4/cloud-providers.html) + +## Configure Managed Service Access + +We need to configure connectivity between the PostgreSQL database and the GKE cluster. +This can done by either: + +- Using Crossplane as demonstrated below. +- Directly in the GCP console by +[configuring private services access](https://cloud.google.com/vpc/docs/configure-private-services-access). +Create a GlobalAddress and Connection resources: + +```sh +cat > network.yaml <<EOF +--- +# gitlab-ad-globaladdress defines the IP range that will be allocated for cloud services connecting to the instances in the given Network. + +apiVersion: compute.gcp.crossplane.io/v1alpha3 +kind: GlobalAddress +metadata: + name: gitlab-ad-globaladdress +spec: + providerRef: + name: gcp-provider + reclaimPolicy: Delete + name: gitlab-ad-globaladdress + purpose: VPC_PEERING + addressType: INTERNAL + prefixLength: 16 + network: projects/$PROJECT_ID/global/networks/$NETWORK_NAME +--- +# gitlab-ad-connection is what allows cloud services to use the allocated GlobalAddress for communication. Behind +# the scenes, it creates a VPC peering to the network that those service instances actually live. + +apiVersion: servicenetworking.gcp.crossplane.io/v1alpha3 +kind: Connection +metadata: + name: gitlab-ad-connection +spec: + providerRef: + name: gcp-provider + reclaimPolicy: Delete + parent: services/servicenetworking.googleapis.com + network: projects/$PROJECT_ID/global/networks/$NETWORK_NAME + reservedPeeringRangeRefs: + - name: gitlab-ad-globaladdress +EOF +``` + +Apply the settings specified in the file with the following command: + +```sh +kubectl apply -f network.yaml +``` + +You can verify creation of the network resources with the following commands. +Verify that the status of both of these resources is ready and is synced. + +```sh +kubectl describe connection.servicenetworking.gcp.crossplane.io gitlab-ad-connection +kubectl describe globaladdress.compute.gcp.crossplane.io gitlab-ad-globaladdress +``` + +## Setting up Resource classes + +Resource classes are a way of defining a configuration for the required managed service. We will define the Postgres Resource class + +- Define a gcp-postgres-standard.yaml resourceclass which contains + +1. A default CloudSQLInstanceClass. +1. A CloudSQLInstanceClass with labels. + +```sh +cat > gcp-postgres-standard.yaml <<EOF +apiVersion: database.gcp.crossplane.io/v1beta1 +kind: CloudSQLInstanceClass +metadata: + name: cloudsqlinstancepostgresql-standard + labels: + gitlab-ad-demo: "true" +specTemplate: + writeConnectionSecretsToNamespace: gitlab-managed-apps + forProvider: + databaseVersion: POSTGRES_9_6 + region: $REGION + settings: + tier: db-custom-1-3840 + dataDiskType: PD_SSD + dataDiskSizeGb: 10 + ipConfiguration: + privateNetwork: projects/$PROJECT_ID/global/networks/$NETWORK_NAME + # this should match the name of the provider created in the above step + providerRef: + name: gcp-provider + reclaimPolicy: Delete +--- +apiVersion: database.gcp.crossplane.io/v1beta1 +kind: CloudSQLInstanceClass +metadata: + name: cloudsqlinstancepostgresql-standard-default + annotations: + resourceclass.crossplane.io/is-default-class: "true" +specTemplate: + writeConnectionSecretsToNamespace: gitlab-managed-apps + forProvider: + databaseVersion: POSTGRES_9_6 + region: $REGION + settings: + tier: db-custom-1-3840 + dataDiskType: PD_SSD + dataDiskSizeGb: 10 + ipConfiguration: + privateNetwork: projects/$PROJECT_ID/global/networks/$NETWORK_NAME + # this should match the name of the provider created in the above step + providerRef: + name: gcp-provider + reclaimPolicy: Delete +EOF +``` + +Apply the resource class configuration with the following command: + +```sh +kubectl apply -f gcp-postgres-standard.yaml +``` + +Verify creation of the Resource class with the following command: + +```sh +kubectl get cloudsqlinstanceclasses +``` + +The Resource Classes allow you to define classes of service for a managed service. We could create another `CloudSQLInstanceClass` which requests for a larger or a faster disk. It could also request for a specific version of the database. + +## Auto DevOps Configuration Options + +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. + +- `postgres.managed` set to true which will select a default resource class. + The resource class needs to be marked with the annotation + `resourceclass.crossplane.io/is-default-class: "true"`. The CloudSQLInstanceClass + `cloudsqlinstancepostgresql-standard-default` will be used to satisfy the claim. + +- `postgres.managed` set to `true` with `postgres.managedClassSelector` + providing the resource class to choose based on labels. In this case, the + value of `postgres.managedClassSelector.matchLabels.gitlab-ad-demo="true"` + will select the CloudSQLInstance class `cloudsqlinstancepostgresql-standard` + to satisfy the claim request. + +The Auto DevOps pipeline should provision a PostgresqlInstance when it runs succesfully. + +Verify creation of the PostgresQL Instance. + +```sh +kubectl get postgresqlinstance +``` + +Sample Output: The `STATUS` field of the PostgresqlInstance transitions to `BOUND` when it is successfully provisioned. + +``` +NAME STATUS CLASS-KIND CLASS-NAME RESOURCE-KIND RESOURCE-NAME AGE +staging-test8 Bound CloudSQLInstanceClass cloudsqlinstancepostgresql-standard CloudSQLInstance xp-ad-demo-24-staging-staging-test8-jj55c 9m +``` + +The endpoint of the PostgreSQL instance, and the user credentials, are present in a secret called `app-postgres` within the same project namespace. + +Verify the secret with the database information is created with the following command: + +```sh +kubectl describe secret app-postgres +``` + +Sample Output: + +``` +Name: app-postgres +Namespace: xp-ad-demo-24-staging +Labels: <none> +Annotations: crossplane.io/propagate-from-name: 108e460e-06c7-11ea-b907-42010a8000bd + crossplane.io/propagate-from-namespace: gitlab-managed-apps + crossplane.io/propagate-from-uid: 10c79605-06c7-11ea-b907-42010a8000bd + +Type: Opaque + +Data +==== +privateIP: 8 bytes +publicIP: 13 bytes +serverCACertificateCert: 1272 bytes +serverCACertificateCertSerialNumber: 1 bytes +serverCACertificateCreateTime: 24 bytes +serverCACertificateExpirationTime: 24 bytes +username: 8 bytes +endpoint: 8 bytes +password: 27 bytes +serverCACertificateCommonName: 98 bytes +serverCACertificateInstance: 41 bytes +serverCACertificateSha1Fingerprint: 40 bytes +``` + +## Connect to the PostgresQL instance + +Follow this [GCP guide](https://cloud.google.com/sql/docs/postgres/connect-kubernetes-engine) if you +would like to connect to the newly provisioned Postgres database instance on CloudSQL. 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 new file mode 100644 index 0000000000000000000000000000000000000000..63e2d1cd4e876aa1bac2587560a8fab8edf09cd8 Binary files /dev/null and b/doc/user/clusters/img/advanced-settings-cluster-management-project-v12_5.png differ diff --git a/doc/user/clusters/management_project.md b/doc/user/clusters/management_project.md index 37308ad71758c1ca3fd58dfabef4e1c290f78f84..83b6f6fe300304953bfb04df7cdf5c4ca19d04ac 100644 --- a/doc/user/clusters/management_project.md +++ b/doc/user/clusters/management_project.md @@ -4,7 +4,7 @@ CAUTION: **Warning:** This is an _alpha_ feature, and it is subject to change at any time without prior notice. -> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/17866) in GitLab 12.4 +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/32810) in GitLab 12.5 A project can be designated as the management project for a cluster. A management project can be used to run deployment jobs with @@ -20,14 +20,37 @@ This can be useful for: ## Permissions Only the management project will receive `cluster-admin` privileges. All -other projects will continue to receive [namespace scoped `edit` level privileges](../project/clusters/index.md#rbac-cluster-resources). +other projects will continue to receive [namespace scoped `edit` level privileges](../project/clusters/add_remove_clusters.md#rbac-cluster-resources). + +Management projects are restricted to the following: + +- For project-level clusters, the management project must in the same + namespace (or descendants) as the cluster's project. +- For group-level clusters, the management project must in the same + group (or descendants) as as the cluster's group. +- For instance-level clusters, there are no such restrictions. ## Usage +To use a cluster management project for a cluster: + +1. Select the project. +1. Configure your pipelines. +1. Set an environment scope. + ### Selecting a cluster management project -This will be implemented as part of [this -issue](https://gitlab.com/gitlab-org/gitlab/issues/32810). +To select a cluster management project to use: + +1. Navigate to the appropriate configuration page. For a: + - [Project-level cluster](../project/clusters/index.md), navigate to your project's + **Operations > Kubernetes** page. + - [Group-level cluster](../group/clusters/index.md), navigate to your group's **Kubernetes** + page. +1. Select the project using **Cluster management project field** in the **Advanced settings** + section. + + ### Configuring your pipeline @@ -60,7 +83,7 @@ to a management project: | Staging | `staging` | | Production | `production` | -The the following environments set in +The following environments set in [`.gitlab-ci.yml`](../../ci/yaml/README.md) will deploy to the Development, Staging, and Production cluster respectively. @@ -86,16 +109,3 @@ configure production cluster: environment: name: production ``` - -## Disabling this feature - -This feature is enabled by default. To disable this feature, disable the -feature flag `:cluster_management_project`. - -To check if the feature flag is enabled on your GitLab instance, -please ask an administrator to execute the following in a Rails console: - -```ruby -Feature.enabled?(:cluster_management_project) # Check if it's enabled or not. -Feature.disable(:cluster_management_project) # Disable the feature flag. -``` diff --git a/doc/user/group/clusters/index.md b/doc/user/group/clusters/index.md index 4742e7189b75f50bfcb68ceefd049b74b8070ff4..1fe456902a2d248b31a5bd1be6fd99fd7946fbcc 100644 --- a/doc/user/group/clusters/index.md +++ b/doc/user/group/clusters/index.md @@ -58,13 +58,18 @@ differentiate the new cluster from the rest. 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 -[Access controls](../../project/clusters/index.md#access-controls) section for details on which resources will +[Access controls](../../project/clusters/add_remove_clusters.md#access-controls) section for details on which resources will be created. -If you choose to manage your own cluster, project-specific resources will not be created -automatically. If you are using [Auto DevOps](../../../topics/autodevops/index.md), you will -need to explicitly provide the `KUBE_NAMESPACE` [deployment variable](../../project/clusters/index.md#deployment-variables) -that will be used by your deployment jobs. +For clusters not managed by GitLab, project-specific resources will not be created +automatically. If you are using [Auto DevOps](../../../topics/autodevops/index.md) +for deployments with a cluster not managed by GitLab, you must ensure: + +- The project's deployment service account has permissions to deploy to + [`KUBE_NAMESPACE`](../../project/clusters/index.md#deployment-variables). +- `KUBECONFIG` correctly reflects any changes to `KUBE_NAMESPACE` + (this is [not automatic](https://gitlab.com/gitlab-org/gitlab/issues/31519)). Editing + `KUBE_NAMESPACE` directly is discouraged. NOTE: **Note:** If you [install applications](#installing-applications) on your cluster, GitLab will create @@ -147,7 +152,7 @@ are deployed to the Kubernetes cluster, see the documentation for For important information about securely configuring GitLab Runners, see [Security of -Runners](../../project/clusters/index.md#security-of-gitlab-runners) +Runners](../../project/clusters/add_remove_clusters.md#security-of-gitlab-runners) documentation for project-level clusters. <!-- ## Troubleshooting diff --git a/doc/user/group/epics/img/epic_view_roadmap_v12.3.png b/doc/user/group/epics/img/epic_view_roadmap_v12.3.png old mode 100755 new mode 100644 diff --git a/doc/user/group/epics/img/epic_view_v12.3.png b/doc/user/group/epics/img/epic_view_v12.3.png old mode 100755 new mode 100644 diff --git a/doc/user/group/epics/img/epics_list_view_v12.3.png b/doc/user/group/epics/img/epics_list_view_v12.3.png deleted file mode 100755 index c6817a503e7a914a923ba8e19ea022112eade955..0000000000000000000000000000000000000000 Binary files a/doc/user/group/epics/img/epics_list_view_v12.3.png and /dev/null differ 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 new file mode 100644 index 0000000000000000000000000000000000000000..2520ee67abc02a1916b15221bbaaad784a8236ce Binary files /dev/null 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 f9690d4edfe50c396e4bbfcb7cae773f8bf78491..0753df70bc2743db3b8550fb01e2cb3ee2b17569 100644 --- a/doc/user/group/epics/index.md +++ b/doc/user/group/epics/index.md @@ -10,7 +10,7 @@ Epics let you manage your portfolio of projects more efficiently and with less effort by tracking groups of issues that share a theme, across projects and milestones. - + ## Use cases @@ -92,24 +92,44 @@ To remove a child epic from a parent epic: ## Start date and due date -To set a **Start date** and **Due date** for an epic, you can choose either of the following: +To set a **Start date** and **Due date** for an epic, select one of the following: - **Fixed**: Enter a fixed value. -- **From milestones:** Inherit a dynamic value from the issues added to the epic. +- **From milestones**: Inherit a dynamic value from the issues added to the epic. +- **Inherited**: Inherit a dynamic value from the issues added to the epic. ([Introduced](https://gitlab.com/gitlab-org/gitlab/issues/7332) in GitLab 12.5 to replace **From milestones**). -If you select **From milestones** for the start date, GitLab will automatically set the -date to be earliest start date across all milestones that are currently assigned -to the issues that are added to the epic. Similarly, if you select "From milestones" -for the due date, GitLab will set it to be the latest due date across all -milestones that are currently assigned to those issues. +### Milestones -These are dynamic dates which are recalculated immediately if any of the following occur: +If you select **From milestones** for the start date, GitLab will automatically set the date to be earliest +start date across all milestones that are currently assigned to the issues that are added to the epic. +Similarly, if you select **From milestones** for the due date, GitLab will set it to be the latest due date across +all milestones that are currently assigned to those issues. + +These are dynamic dates which are recalculated if any of the following occur: - Milestones are re-assigned to the issues. - Milestone dates change. - Issues are added or removed from the epic. -## Roadmap +### Inherited + +If you select **Inherited** for the start date, GitLab will scan all child epics and issues assigned to the epic, +and will set the start date to match the earliest found start date or milestone. Similarly, if you select +**Inherited** for the due date, GitLab will set the due date to match the latest due date or milestone +found among its child epics and issues. + +These are dynamic dates and recalculated if any of the following occur: + +- A child epic's dates change. +- Milestones are reassigned to an issue. +- A milestone's dates change. +- Issues are added to, or removed from, the epic. + +Because the epic's dates can inherit dates from its children, the start date and due date propagate from the bottom to the top. +If the start date of a child epic on the lowest level changes, that becomes the earliest possible start date for its parent epic, +then the parent epic's start date will reflect the change and this will propagate upwards to the top epic. + +## Roadmap in epics > [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/7327) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.10. @@ -121,6 +141,8 @@ have a [start or due date](#start-date-and-due-date), a ## Reordering issues and child epics +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/9367) in GitLab 12.5. + New issues and child epics are added to the top of their respective lists in the **Epics and Issues** tab. You can reorder the list of issues and the list of child epics. Issues and child epics cannot be intermingled. To reorder issues assigned to an epic: @@ -267,7 +289,7 @@ Once you wrote your comment, you can either: ## Notifications -- [Receive notifications](../../../workflow/notifications.md) for epic events. +- [Receive notifications](../../profile/notifications.md) for epic events. <!-- ## Troubleshooting diff --git a/doc/user/group/index.md b/doc/user/group/index.md index c4be08c842b9ba88a5086c3df64fc4f206799f07..5f45a462f947ee8b6f4e1326b34a9aa85cd3b49e 100644 --- a/doc/user/group/index.md +++ b/doc/user/group/index.md @@ -75,7 +75,7 @@ By doing so: ## Issues and merge requests within a group Issues and merge requests are part of projects. For a given group, you can view all of the -[issues](../project/issues/index.md#issues-list) and [merge requests](../project/merge_requests/index.md#merge-requests-per-group) across all projects in that group, +[issues](../project/issues/index.md#issues-list) and [merge requests](../project/merge_requests/reviewing_and_managing_merge_requests.md#view-merge-requests-for-all-projects-in-a-group) across all projects in that group, together in a single list view. ### Bulk editing issues and merge requests @@ -123,7 +123,7 @@ For more details on creating groups, watch the video [GitLab Namespaces (users, ## Add users to a group A benefit of putting multiple projects in one group is that you can -give a user to access to all projects in the group with one action. +give a user access to all projects in the group with one action. Add members to a group by navigating to the group's dashboard and clicking **Members**. @@ -135,14 +135,14 @@ Consider a group with two projects: - On the **Group Members** page, you can now add a new user to the group. - Now, because this user is a **Developer** member of the group, they automatically - gets **Developer** access to **all projects** within that group. + get **Developer** access to **all projects** within that group. To increase the access level of an existing user for a specific project, add them again as a new member to the project with the desired permission level. ## Request access to a group -As a group owner, you can enable or disable the ability for non members to request access to +As a group owner, you can enable or disable the ability for non-members to request access to your group. Go to the group settings, and click **Allow users to request access**. As a user, you can request to be a member of a group, if that setting is enabled. Go to the group for which you'd like to be a member, and click the **Request Access** button on the right @@ -353,10 +353,10 @@ content. Restriction currently applies to: - UI. -- API access. +- [From GitLab 12.3](https://gitlab.com/gitlab-org/gitlab/issues/12874), API access. - [From GitLab 12.4](https://gitlab.com/gitlab-org/gitlab/issues/32113), Git actions via SSH. -To avoid accidental lock-out, admins and group owners are are able to access +To avoid accidental lock-out, admins and group owners are able to access the group regardless of the IP restriction. #### Allowed domain restriction **(PREMIUM)** @@ -385,7 +385,7 @@ Some domains cannot be restricted. These are the most popular public email domai To enable this feature: 1. Navigate to the group's **Settings > General** page. -1. Expand the **Permissions, LFS, 2FA** section, and enter domain name into **Restrict membership by email** field. +1. Expand the **Permissions, LFS, 2FA** section, and enter the domain name into **Restrict membership by email** field. 1. Click **Save changes**. This will enable the domain-checking for all new users added to the group from this moment on. @@ -421,8 +421,9 @@ Define project templates at a group level by setting a group as the template sou #### Disabling email notifications -You can disable all email notifications related to the group, which also includes -it's subgroups and projects. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/23585) in GitLab 12.2. + +You can disable all email notifications related to the group, which includes its subgroups and projects. To enable this feature: @@ -444,7 +445,7 @@ To enable this feature: > [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/13294) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.0. -A group owner can check the aggregated storage usage for all the project in a group, sub-groups included, in the **Storage** tab of the **Usage Quotas** page available to the group page settings list. +A group owner can check the aggregated storage usage for all the projects in a group, sub-groups included, in the **Storage** tab of the **Usage Quotas** page available to the group page settings list.  diff --git a/doc/user/group/roadmap/index.md b/doc/user/group/roadmap/index.md index bcd79bd04bf9a995a6ba7b62a11d90f62b2fcf9e..a72cd990706652be9253ade71820029b14ffa842 100644 --- a/doc/user/group/roadmap/index.md +++ b/doc/user/group/roadmap/index.md @@ -26,7 +26,7 @@ Epics in the view can be sorted by: Each option contains a button that toggles the sort order between **ascending** and **descending**. The sort option and order will be persisted when browsing Epics, including the [epics list view](../epics/index.md). -Roadmaps can also be [visualized inside an epic](../epics/index.md#roadmap). +Roadmaps can also be [visualized inside an epic](../epics/index.md#roadmap-in-epics). ## Timeline duration diff --git a/doc/user/group/saml_sso/index.md b/doc/user/group/saml_sso/index.md index ecf2934b877ea69b569a0b420142a15ce05acc55..6fd564147968c06b0323e7e24bfbad1a016703c1 100644 --- a/doc/user/group/saml_sso/index.md +++ b/doc/user/group/saml_sso/index.md @@ -26,6 +26,23 @@ SAML SSO for GitLab.com groups does not sync users between providers without usi  +### NameID + +GitLab.com uses the SAML NameID to identify users. The NameID element: + +- Is a required field in the SAML response. +- Must be unique to each user. +- Must be a persistent value that will never change, such as a randomly generated unique user ID. +- Is case sensitive. The NameID must match exactly on subsequent login attempts, so should not rely on user input that could change between upper and lower case. +- Should not be an email address or username. We strongly recommend against these as it is hard to guarantee they will never change, for example when a person's name changes. Email addresses are also case-insensitive, which can result in users being unable to sign in. + +CAUTION: **Warning:** +Once users have signed into GitLab using the SSO SAML setup, changing the `NameID` will break the configuration and potentially lock users out of the GitLab group. + +#### NameID Format + +We recommend setting the NameID format to `Persistent` unless using a field (such as email) that requires a different format. + ### SSO enforcement SSO enforcement was: @@ -58,25 +75,16 @@ Since use of the group managed account requires the use of SSO, users of group m - The user will be unable to access the group (their credentials will no longer work on the identity provider when prompted to SSO). - Contributions in the group (e.g. issues, merge requests) will remain intact. -### NameID +#### Assertions -GitLab.com uses the SAML NameID to identify users. The NameID element: +When using Group Manged Accounts, the following user details need to be passed to GitLab as SAML Assertions in order for us to be able to create a user: -- Is a required field in the SAML response. -- Must be unique to each user. -- Must be a persistent value that will never change, such as a randomly generated unique user ID. -- Is case sensitive. The NameID must match exactly on subsequent login attempts, so should not rely on user input that could change between upper and lower case. - -We strongly recommend against using Email as the NameID as it is hard to guarantee it will never change, for example when a person's name changes. Similarly usernames should be avoided if possible. - -### Assertions - -| Field | Supported keys | -|-------|----------------| +| Field | Supported keys | +|-----------------|----------------| | Email (required)| `email`, `mail` | -| Full Name | `name` | -| First Name | `first_name`, `firstname`, `firstName` | -| Last Name | `last_name`, `lastname`, `lastName` | +| Full Name | `name` | +| First Name | `first_name`, `firstname`, `firstName` | +| Last Name | `last_name`, `lastname`, `lastName` | ## Metadata configuration @@ -108,17 +116,28 @@ NOTE: **Note:** GitLab is unable to provide support for IdPs that are not listed | Azure | [Configuring single sign-on to applications](https://docs.microsoft.com/en-us/azure/active-directory/manage-apps/configure-single-sign-on-non-gallery-applications) | | Auth0 | [Auth0 as Identity Provider](https://auth0.com/docs/protocols/saml/saml-idp-generic) | | G Suite | [Set up your own custom SAML application](https://support.google.com/a/answer/6087519?hl=en) | -| JumpCloud | [Single Sign On (SSO) with GitLab](https://support.jumpcloud.com/customer/en/portal/articles/2810701-single-sign-on-sso-with-gitlab) | +| JumpCloud | [Single Sign On (SSO) with GitLab](https://support.jumpcloud.com/support/s/article/single-sign-on-sso-with-gitlab-2019-08-21-10-36-47) | | Okta | [Setting up a SAML application in Okta](https://developer.okta.com/docs/guides/saml-application-setup/overview/) | | OneLogin | [Use the OneLogin SAML Test Connector](https://onelogin.service-now.com/support?id=kb_article&sys_id=93f95543db109700d5505eea4b96198f) | | Ping Identity | [Add and configure a new SAML application](https://support.pingidentity.com/s/document-item?bundleId=pingone&topicId=xsh1564020480660-1.html) | When [configuring your identify provider](#configuring-your-identity-provider), please consider the notes below for specific providers to help avoid common issues and as a guide for terminology used. +### Okta setup notes + +| GitLab Setting | Okta Field | +|--------------|----------------| +| Identifier | Audience URI | +| Assertion consumer service URL | Single sign on URL | + +Under Okta's **Single sign on URL** field, check the option **Use this for Recipient URL and Destination URL**. + +Set attribute statements according to the [assertions table](#assertions). + ### OneLogin setup notes -NOTE: **Note:** -The GitLab app listed in the directory is for self-managed GitLab instances. Please use a generic SAML Test Connector. +The GitLab app listed in the OneLogin app catalog is for self-managed GitLab instances. +For GitLab.com, use a generic SAML Test Connector such as the SAML Test Connector (Advanced). | GitLab Setting | OneLogin Field | |--------------|----------------| diff --git a/doc/user/group/saml_sso/scim_setup.md b/doc/user/group/saml_sso/scim_setup.md index 60b779b3f7089d35137f01a3444a7787f7c75fa1..392b27bb42f01833b53b972ae1c1580574910c2e 100644 --- a/doc/user/group/saml_sso/scim_setup.md +++ b/doc/user/group/saml_sso/scim_setup.md @@ -25,25 +25,6 @@ The following identity providers are supported: ## Requirements - [Group SSO](index.md) needs to be configured. -- The `scim_group` feature flag must be enabled: - - Run the following commands in a Rails console: - - ```sh - # Omnibus GitLab - gitlab-rails console - - # Installation from source - cd /home/git/gitlab - sudo -u git -H bin/rails console RAILS_ENV=production - ``` - - To enable SCIM for a group named `group_name`: - - ```ruby - group = Group.find_by_full_path('group_name') - Feature.enable(:group_scim, group) - ``` ## GitLab configuration @@ -85,8 +66,13 @@ You can then test the connection by clicking on **Test Connection**. If the conn 1. Click **Delete** next to the `mail` mapping. 1. Map `userPrincipalName` to `emails[type eq "work"].value` and change it's **Matching precedence** to `2`. 1. Map `mailNickname` to `userName`. -1. Create a new mapping by clicking **Add New Mapping** then set **Source attribute** to `objectId`, **Target attribute** to `id`, **Match objects using this attribute** to `Yes`, and **Matching precedence** to `1`. -1. Create a new mapping by clicking **Add New Mapping** then set **Source attribute** to `objectId`, and **Target attribute** to `externalId`. +1. Determine how GitLab will uniquely identify users. + + - Use `objectId` unless users already have SAML linked for your group. + - If you already have users with SAML linked then use the `Name ID` value from the [SAML configuration](#azure). Using a different value will likely cause duplicate users and prevent users from accessing the GitLab group. + +1. Create a new mapping by clicking **Add New Mapping** then set **Source attribute** to the unique identifier determined above, **Target attribute** to `id`, **Match objects using this attribute** to `Yes`, and **Matching precedence** to `1`. +1. Create a new mapping by clicking **Add New Mapping** then set **Source attribute** to the unique identifier determined above, and **Target attribute** to `externalId`. 1. Click the `userPrincipalName` mapping and change **Match objects using this attribute** to `No`. Save your changes and you should have the following configuration: @@ -118,6 +104,9 @@ You can then test the connection by clicking on **Test Connection**. If the conn Once enabled, the synchronization details and any errors will appear on the bottom of the **Provisioning** screen, together with a link to the audit logs. +CAUTION: **Warning:** +Once synchronized, changing the field mapped to `id` and `externalId` will likely cause provisioning errors, duplicate users, and prevent existing users from accessing the GitLab group. + ## Troubleshooting ### Testing Azure connection: invalid credentials diff --git a/doc/user/group/subgroups/index.md b/doc/user/group/subgroups/index.md index a3606fadb8918a8c7a0a6ac70687c3907d87d62f..52b7035389a4314786b935478f60d00a42252888 100644 --- a/doc/user/group/subgroups/index.md +++ b/doc/user/group/subgroups/index.md @@ -176,7 +176,7 @@ Here's a list of what you can't do with subgroups: - [GitLab Pages](../../project/pages/index.md) supports projects hosted under a subgroup, but not subgroup websites. That means that only the highest-level group supports - [group websites](../../project/pages/getting_started_part_one.md#gitlab-pages-domain-names), + [group websites](../../project/pages/getting_started_part_one.md#gitlab-pages-default-domain-names), although you can have project websites under a subgroup. - It is not possible to share a project with a group that's an ancestor of the group the project is in. That means you can only share as you walk down diff --git a/doc/workflow/img/todos_add_todo_sidebar.png b/doc/user/img/todos_add_todo_sidebar.png similarity index 100% rename from doc/workflow/img/todos_add_todo_sidebar.png rename to doc/user/img/todos_add_todo_sidebar.png diff --git a/doc/workflow/img/todos_icon.png b/doc/user/img/todos_icon.png similarity index 100% rename from doc/workflow/img/todos_icon.png rename to doc/user/img/todos_icon.png diff --git a/doc/workflow/img/todos_index.png b/doc/user/img/todos_index.png similarity index 100% rename from doc/workflow/img/todos_index.png rename to doc/user/img/todos_index.png diff --git a/doc/workflow/img/todos_mark_done_sidebar.png b/doc/user/img/todos_mark_done_sidebar.png similarity index 100% rename from doc/workflow/img/todos_mark_done_sidebar.png rename to doc/user/img/todos_mark_done_sidebar.png diff --git a/doc/workflow/img/todo_list_item.png b/doc/user/img/todos_todo_list_item.png similarity index 100% rename from doc/workflow/img/todo_list_item.png rename to doc/user/img/todos_todo_list_item.png diff --git a/doc/user/incident_management/img/incident_management_settings.png b/doc/user/incident_management/img/incident_management_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..25ad4fd08b7544cb0a6e63d7ca667d726bdb0136 Binary files /dev/null and b/doc/user/incident_management/img/incident_management_settings.png differ diff --git a/doc/user/incident_management/index.md b/doc/user/incident_management/index.md new file mode 100644 index 0000000000000000000000000000000000000000..5ac27d227a11c6624ccdefcd4c01ed88e267ff53 --- /dev/null +++ b/doc/user/incident_management/index.md @@ -0,0 +1,131 @@ +--- +description: "GitLab - Incident Management. GitLab offers solutions for handling incidents in your applications and services" +--- + +# Incident Management + +GitLab offers solutions for handling incidents in your applications and services, +from setting up an alert with Prometheus, to receiving a notification via a +monitoring tool like Slack, and automatically setting up Zoom calls with your +support team. + +## Configuring incidents **(ULTIMATE)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/4925) in GitLab Ultimate 11.11. + +The Incident Management features can be enabled and disabled via your project's +**Settings > Operations > Incidents**. + + + +### Automatically create issues from alerts + +GitLab issues can automatically be created as a result of an alert notification. +An issue created this way will contain the error information to help you further +debug it. + +### Issue templates + +You can create your own [issue templates](../project/description_templates.md#creating-issue-templates) +that can be [used within Incident Management](../project/integrations/prometheus.md#taking-action-on-incidents-ultimate). + +To select your issue template for use within Incident Management: + +1. Visit your project's **Settings > Operations > Incidents**. +1. Select the template from the **Issue Template** dropdown. + +## Alerting + +GitLab can react to the alerts that your applications and services may be +triggering by automatically creating issues, and alerting developers via email. + +### Prometheus alerts + +Prometheus alerts can be set up in both: + +- [GitLab-managed Prometheus](../project/integrations/prometheus.md#setting-up-alerts-for-prometheus-metrics-ultimate) and +- [Self-managed Prometheus](../project/integrations/prometheus.md#external-prometheus-instances) installations. + +### Alert endpoint + +GitLab can accept alerts from any source via a generic webhook receiver. When +you set up the generic alerts integration, a unique endpoint will +be created which can receive a payload in JSON format. + +[Read more on setting this up, including how to customize the payload](../project/integrations/generic_alerts.md). + +### Recovery alerts + +GitLab can [automatically close issues](../project/integrations/prometheus.md#taking-action-on-incidents-ultimate) +that have been automatically created when you receive notification that the +alert is resolved. + +## Embedded metrics + +Metrics can be embedded anywhere where GitLab Markdown is used, for example, +descriptions and comments on issues and merge requests. + +TIP: **Tip:** +Both GitLab-hosted and Grafana metrics can also be +[embedded in issue templates](../project/integrations/prometheus.md#embedding-metrics-in-issue-templates). + +### GitLab-hosted metrics + +Learn how to embed [GitLab hosted metric charts](../project/integrations/prometheus.md#embedding-metric-charts-within-gitlab-flavored-markdown). + +### Grafana metrics + +Learn how to embed [Grafana hosted metric charts](../project/integrations/prometheus.md#embedding-grafana-charts). + +## Slack integration + +Slack slash commands allow you to control GitLab and view content right inside +Slack, without having to leave it. + +Learn how to [set up Slack slash commands](../project/integrations/slack_slash_commands.md) +and how to [use them](../../integration/slash_commands.md). + +### Slash commands + +Please refer to a list of [available slash commands](../../integration/slash_commands.md) and associated descriptions. + +## Zoom in issues + +In order to communicate synchronously for incidents management, GitLab allows to +associate a Zoom meeting with an issue. Once you start a Zoom call for a fire-fight, +you need a way to associate the conference call with an issue, so that your team +members can join swiftly without requesting a link. + +Read more how to [add or remove a zoom meeting](../project/issues/associate_zoom_meeting.md). + +### Alerting + +You can let GitLab know of alerts that may be triggering in your applications and services. GitLab can react to these by automatically creating Issues, and alerting developers via Email. + +#### Prometheus Alerts + +Prometheus alerts can be setup in both GitLab-managed Prometheus installs and self-managed Prometheus installs. + +Documentation for each method can be found here: + +- [GitLab-managed Prometheus](../project/integrations/prometheus.md#setting-up-alerts-for-prometheus-metrics-ultimate) +- [Self-managed Prometheus](../project/integrations/prometheus.md#external-prometheus-instances) + +#### Alert Endpoint + +GitLab can accept alerts from any source via a generic webhook receiver. When you set up the generic alerts integration, a unique endpoint will +be created which can receive a payload in JSON format. + +More information on setting this up, including how to customize the payload [can be found here](../project/integrations/generic_alerts.md). + +#### Recovery Alerts + +Coming soon: GitLab can automatically close Issues that have been automatically created when we receive notification that the alert is resolved. + +### Configuring Incidents + +Incident Management features can be easily enabled & disabled via the Project settings page. Head to Project -> Settings -> Operations -> Incidents. + +#### Auto-creation + +GitLab Issues can automatically be created as a result of an Alert notification. An Issue created this way will contain error information to help you further debug the error. diff --git a/doc/user/index.md b/doc/user/index.md index ee5d4a0a07b48b12c1b2522323ac0406515f1567..ab953b6d8bfb0184e008738f814630faadbebe44 100644 --- a/doc/user/index.md +++ b/doc/user/index.md @@ -50,15 +50,15 @@ GitLab is a Git-based platform that integrates a great number of essential tools With GitLab Enterprise Edition, you can also: - Provide support with [Service Desk](project/service_desk.md). -- Improve collaboration with - [Merge Request Approvals](project/merge_requests/index.md#merge-request-approvals-starter), - [Multiple Assignees for Issues](project/issues/multiple_assignees_for_issues.md), - and [Multiple Issue Boards](project/issue_board.md#multiple-issue-boards). +- Improve collaboration with: + - [Merge Request Approvals](project/merge_requests/merge_request_approvals.md). **(STARTER)** + - [Multiple Assignees for Issues](project/issues/multiple_assignees_for_issues.md). **(STARTER)** + - [Multiple Issue Boards](project/issue_board.md#multiple-issue-boards). - Create formal relationships between issues with [Related Issues](project/issues/related_issues.md). - Use [Burndown Charts](project/milestones/burndown_charts.md) to track progress during a sprint or while working on a new version of their software. - Leverage [Elasticsearch](../integration/elasticsearch.md) with [Advanced Global Search](search/advanced_global_search.md) and [Advanced Syntax Search](search/advanced_search_syntax.md) for faster, more advanced code search across your entire GitLab instance. - [Authenticate users with Kerberos](../integration/kerberos.md). -- [Mirror a repository](../workflow/repository_mirroring.md) from elsewhere on your local server. +- [Mirror a repository](project/repository/repository_mirroring.md) from elsewhere on your local server. - [Export issues as CSV](project/issues/csv_export.md). - View your entire CI/CD pipeline involving more than one project with [Multiple-Project Pipelines](../ci/multi_project_pipeline_graphs.md). - [Lock files](project/file_lock.md) to prevent conflicts. @@ -130,12 +130,12 @@ gather feedback through [resolvable threads](discussions/index.md#resolvable-com Read through the [GFM documentation](markdown.md) to learn how to apply the best of GitLab Flavored Markdown in your threads, comments, -issues and merge requests descriptions, and everywhere else GMF is +issues and merge requests descriptions, and everywhere else GFM is supported. ## Todos -Never forget to reply to your collaborators. [GitLab Todos](../workflow/todos.md) +Never forget to reply to your collaborators. [GitLab Todos](todos.md) are a tool for working faster and more effectively with your team, by listing all user or group mentions, as well as issues and merge requests you're assigned to. @@ -150,6 +150,11 @@ requests you're assigned to. you have quick access to. You can also gather feedback on them through [Discussions](#Discussions). +## Keyboard shortcuts + +There are many [keyboard shortcuts](shortcuts.md) in GitLab to help you navigate between +pages and accomplish tasks faster. + ## Integrations [Integrate GitLab](../integration/README.md) with your preferred tool, diff --git a/doc/user/markdown.md b/doc/user/markdown.md index 0b4bb43b4bfc2887b90818416025890b919865dc..3bd0dcafc1901777e85daec94e1daf840b2b87d2 100644 --- a/doc/user/markdown.md +++ b/doc/user/markdown.md @@ -405,6 +405,7 @@ GFM will recognize the following: | 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"` | +| scoped label by name | `~"priority::high"` | `namespace/project~"priority::high"` | `project~"priority::high"` | | project milestone by ID | `%123` | `namespace/project%123` | `project%123` | | one-word milestone by name | `%v1.23` | `namespace/project%v1.23` | `project%v1.23` | | multi-word milestone by name | `%"release candidate"` | `namespace/project%"release candidate"` | `project%"release candidate"` | @@ -417,9 +418,10 @@ GFM will recognize the following: > If this is not rendered correctly, [view it in GitLab itself](https://gitlab.com/gitlab-org/gitlab/blob/master/doc/user/markdown.md#task-lists). -You can add task lists anywhere markdown is supported, but you can only "click" to -toggle the boxes if they are in issues, merge requests, or comments. In other places -you must edit the markdown manually to change the status by adding or removing the `x`. +You can add task lists anywhere Markdown is supported, but you can only "click" +to toggle the boxes if they are in issues, merge requests, or comments. In other +places you must edit the Markdown manually to change the status by adding or +removing an `x` within the square brackets. To create a task list, add a specially-formatted Markdown list. You can use either unordered or ordered lists: diff --git a/doc/user/operations_dashboard/img/index_operations_dashboard_top_bar_icon.png b/doc/user/operations_dashboard/img/index_operations_dashboard_top_bar_icon.png deleted file mode 100644 index d4db5e88672aa0f185ba362876367da7ec09d685..0000000000000000000000000000000000000000 Binary files a/doc/user/operations_dashboard/img/index_operations_dashboard_top_bar_icon.png and /dev/null differ diff --git a/doc/user/operations_dashboard/index.md b/doc/user/operations_dashboard/index.md index 649a95a5f3a770c1b9fa34d07b8fed321f0aedeb..cdb80cca6f77de7ee9ae33011c196dd0b14c534b 100644 --- a/doc/user/operations_dashboard/index.md +++ b/doc/user/operations_dashboard/index.md @@ -5,10 +5,7 @@ The Operations Dashboard provides a summary of each project's operational health, including pipeline and alert status. -The dashboard can be accessed via the top bar, by clicking on the new -dashboard icon: - - +The dashboard can be accessed via the top bar, by clicking **More > Operations**. ## Adding a project to the dashboard @@ -28,6 +25,10 @@ last commit, pipeline status, and when it was last deployed.  +## Arranging projects on a dashboard + +You can drag project cards to change their order. The card order is currently only saved to your browser, so will not change the dashboard for other people. + ## Making it the default dashboard when you sign in The Operations Dashboard can also be made the default GitLab dashboard shown when diff --git a/doc/user/packages/conan_repository/index.md b/doc/user/packages/conan_repository/index.md index f81756f7979f719a89682dd4355c6f9137e54dff..953c7472f4d027b5412f14c1a5fb1ae997b2e83f 100644 --- a/doc/user/packages/conan_repository/index.md +++ b/doc/user/packages/conan_repository/index.md @@ -1,6 +1,6 @@ # GitLab Conan Repository **(PREMIUM)** -> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/8248) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.4. +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/8248) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.5. With the GitLab Conan Repository, every project can have its own space to store Conan packages. diff --git a/doc/user/packages/dependency_proxy/index.md b/doc/user/packages/dependency_proxy/index.md index d6358d72348dc27a2b3f300758fa281039733267..60f4dbc0abba03a2da3c8bb54a67384f2aeb0130 100644 --- a/doc/user/packages/dependency_proxy/index.md +++ b/doc/user/packages/dependency_proxy/index.md @@ -1,4 +1,4 @@ -# Dependency Proxy **(PREMIUM)** +# Dependency Proxy **(PREMIUM ONLY)** > [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/7934) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.11. diff --git a/doc/user/packages/npm_registry/index.md b/doc/user/packages/npm_registry/index.md index 5f5d86ab17e3e101918b39c91c1d517ec7dcfb58..d8b59ae63d0f6716c94cefe202cfe026f267c3c1 100644 --- a/doc/user/packages/npm_registry/index.md +++ b/doc/user/packages/npm_registry/index.md @@ -42,6 +42,9 @@ it is not possible due to a naming collision. For example: | `gitlab-org/gitlab` | `@gitlab-org/gitlab` | Yes | | `gitlab-org/gitlab` | `@foo/bar` | No | +CAUTION: **When updating the path of a user/group or transferring a (sub)group/project:** +If you update the root namespace of a project with NPM packages, your changes will be rejected. To be allowed to do that, make sure to remove any NPM package first. Don't forget to update your `.npmrc` files to follow the above naming convention and run `npm publish` if necessary. + Now, you can configure your project to authenticate with the GitLab NPM Registry. @@ -105,6 +108,21 @@ Then, you could run `npm publish` either locally or via GitLab CI/CD: - **GitLab CI/CD:** Set an `NPM_TOKEN` [variable](../../../ci/variables/README.md) under your project's **Settings > CI/CD > Variables**. + +### Authenticating with a CI job token + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/9104) in GitLab Premium 12.5. + +If you’re using NPM with GitLab CI/CD, a CI job token can be used instead of a personal access token. +The token will inherit the permissions of the user that generates the pipeline. + +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} +``` ## Uploading packages diff --git a/doc/user/permissions.md b/doc/user/permissions.md index 90874eca2eb96a9ce4e88fbb0f283957d233d7b2..70660e5e22f27ce9849b11883db9d2bacb56add7 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -51,11 +51,11 @@ The following table depicts the various user permission levels in a project. | View Security reports **(ULTIMATE)** | ✓ (*3*) | ✓ | ✓ | ✓ | ✓ | | View Dependency list **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | | View licenses in Dependency list **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | -| View [Design Management](project/issues/design_management.md) pages **(PREMIUM)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | +| View [Design Management](project/issues/design_management.md) pages **(PREMIUM)** | ✓ | ✓ | ✓ | ✓ | ✓ | | View project code | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | | Pull project code | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | | View GitLab Pages protected by [access control](project/pages/introduction.md#gitlab-pages-access-control-core) | ✓ | ✓ | ✓ | ✓ | ✓ | -| View wiki pages | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | +| View wiki pages | ✓ | ✓ | ✓ | ✓ | ✓ | | See a list of jobs | ✓ (*3*) | ✓ | ✓ | ✓ | ✓ | | See a job log | ✓ (*3*) | ✓ | ✓ | ✓ | ✓ | | Download and browse job artifacts | ✓ (*3*) | ✓ | ✓ | ✓ | ✓ | @@ -83,7 +83,7 @@ The following table depicts the various user permission levels in a project. | Push to non-protected branches | | | ✓ | ✓ | ✓ | | Force push to non-protected branches | | | ✓ | ✓ | ✓ | | Remove non-protected branches | | | ✓ | ✓ | ✓ | -| Create new merge request | | | ✓ | ✓ | ✓ | +| Create new merge request | | ✓ | ✓ | ✓ | ✓ | | Assign merge requests | | | ✓ | ✓ | ✓ | | Label merge requests | | | ✓ | ✓ | ✓ | | Lock merge request threads | | | ✓ | ✓ | ✓ | @@ -103,6 +103,7 @@ The following table depicts the various user permission levels in a project. | Apply code change suggestions | | | ✓ | ✓ | ✓ | | Create and edit wiki pages | | | ✓ | ✓ | ✓ | | Rewrite/remove Git tags | | | ✓ | ✓ | ✓ | +| Manage Feature Flags **(PREMIUM)** | | | ✓ | ✓ | ✓ | | Use environment terminals | | | | ✓ | ✓ | | Run Web IDE's Interactive Web Terminals **(ULTIMATE ONLY)** | | | | ✓ | ✓ | | Add new team members | | | | ✓ | ✓ | @@ -120,6 +121,7 @@ The following table depicts the various user permission levels in a project. | Manage GitLab Pages domains and certificates | | | | ✓ | ✓ | | Remove GitLab Pages | | | | ✓ | ✓ | | Manage clusters | | | | ✓ | ✓ | +| View Pods logs **(ULTIMATE)** | | | | ✓ | ✓ | | Manage license policy **(ULTIMATE)** | | | | ✓ | ✓ | | Edit comments (posted by any user) | | | | ✓ | ✓ | | Manage Error Tracking | | | | ✓ | ✓ | @@ -168,7 +170,7 @@ the [documentation on Cycle Analytics permissions](analytics/cycle_analytics.md# Developers and users with higher permission level can use all the functionality of the Issue Board, that is create/delete lists -and drag issues around. Read though the +and drag issues around. Read through the [documentation on Issue Boards permissions](project/issue_board.md#permissions) to learn more. diff --git a/doc/user/profile/account/delete_account.md b/doc/user/profile/account/delete_account.md index be761ca7558a786ef7a0f697ca7d7e316b1df596..beea063672e63d783a26aacd0cb1d2e05548fad2 100644 --- a/doc/user/profile/account/delete_account.md +++ b/doc/user/profile/account/delete_account.md @@ -32,63 +32,6 @@ As an administrator, you can delete a user account by: - **Delete user and contributions** to delete the user and their associated records. -### Blocking a user - -In addition to blocking a user -[via an abuse report](../../admin_area/abuse_reports.md#blocking-users), -a user can be blocked directly from the Admin area. To do this: - -1. Navigate to **Admin Area > Overview > Users**. -1. Selecting a user. -1. Under the **Account** tab, click **Block user**. - -### Deactivating a user - -> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/63921) in GitLab 12.4. - -A user can be deactivated from the Admin area. Deactivating a user is functionally identical to blocking a user, with the following differences: - -- It does not prohibit the user from logging back in via the UI. -- Once a deactivated user logs back into the GitLab UI, their account is set to active. - -A deactivated user: - -- Cannot access Git repositories or the API. -- Will not receive any notifications from GitLab. -- Will not be able to use [slash commands](../../../integration/slash_commands.md). - -Personal projects, group and user history of the deactivated user will be left intact. - -NOTE: **Note:** -A deactivated user does not consume a [seat](../../../subscriptions/index.md#managing-subscriptions). - -To do this: - -1. Navigate to **Admin Area > Overview > Users**. -1. Select a user. -1. Under the **Account** tab, click **Deactivate user**. - -Please note that for the deactivation option to be visible to an admin, the user: - -- Must be currently active. -- Should not have any activity in the last 180 days. - -### Activating a user - -> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/63921) in GitLab 12.4. - -A deactivated user can be activated from the Admin area. Activating a user sets their account to active state. - -To do this: - -1. Navigate to **Admin Area > Overview > Users**. -1. Click on the **Deactivated** tab. -1. Select a user. -1. Under the **Account** tab, click **Activate user**. - -TIP: **Tip:** -A deactivated user can also activate their account by themselves by simply logging back via the UI. - ## Associated Records > - Introduced for issues in diff --git a/doc/workflow/img/notification_global_settings.png b/doc/user/profile/img/notification_global_settings.png similarity index 100% rename from doc/workflow/img/notification_global_settings.png rename to doc/user/profile/img/notification_global_settings.png diff --git a/doc/workflow/img/notification_group_settings.png b/doc/user/profile/img/notification_group_settings.png similarity index 100% rename from doc/workflow/img/notification_group_settings.png rename to doc/user/profile/img/notification_group_settings.png diff --git a/doc/workflow/img/notification_project_settings.png b/doc/user/profile/img/notification_project_settings.png similarity index 100% rename from doc/workflow/img/notification_project_settings.png rename to doc/user/profile/img/notification_project_settings.png diff --git a/doc/user/profile/index.md b/doc/user/profile/index.md index 9c4001f0a79998a2126985d67508b81b29411d1d..06e4eac66236e340156387a89191c1261a8736de 100644 --- a/doc/user/profile/index.md +++ b/doc/user/profile/index.md @@ -53,7 +53,7 @@ From there, you can: [use GitLab as an OAuth provider](../../integration/oauth_provider.md#introduction-to-oauth) - Manage [personal access tokens](personal_access_tokens.md) to access your account via API and authorized applications - Add and delete emails linked to your account -- Choose which email to use for notifications, web-based commits, and display on your public profile +- Choose which email to use for [notifications](notifications.md), web-based commits, and display on your public profile - Manage [SSH keys](../../ssh/README.md) to access your account via SSH - Manage your [preferences](preferences.md#syntax-highlighting-theme) to customize your own GitLab experience diff --git a/doc/user/profile/notifications.md b/doc/user/profile/notifications.md new file mode 100644 index 0000000000000000000000000000000000000000..388576a48db67c24da59f6bf2bdca7b0b6dfe636 --- /dev/null +++ b/doc/user/profile/notifications.md @@ -0,0 +1,231 @@ +--- +disqus_identifier: 'https://docs.gitlab.com/ee/workflow/notifications.html' +--- + +# GitLab Notification Emails + +GitLab Notifications allow you to stay informed about what's happening in GitLab. With notifications enabled, you can receive updates about activity in issues, merge requests, and epics. Notifications are sent via email. + +## Receiving notifications + +You will receive notifications for one of the following reasons: + +- You participate in an issue, merge request, or epic. In this context, _participate_ means comment, or edit. +- You enable notifications in an issue, merge request, or epic. To enable notifications, click the **Notifications** toggle in the sidebar to _on_. + +While notifications are enabled, you will receive notification of actions occurring in that issue, merge request, or epic. + +NOTE: **Note:** +Notifications can be blocked by an admin, preventing them from being sent. + +## Tuning your notifications + +The quantity of notifications can be overwhelming. GitLab allows you to tune the notifications you receive. For example, you may want to be notified about all activity in a specific project, but for others, only be notified when you are mentioned by name. + +You can tune the notifications you receive by combining your notification settings: + +- [Global notification settings](#global-notification-settings) +- [Notification scope](#notification-scope) +- [Notification levels](#notification-levels) + +### Editing notification settings + +To edit your notification settings: + +1. Click on your profile picture and select **Settings**. +1. Click **Notifications** in the left sidebar. +1. Edit the desired notification settings. Edited settings are automatically saved and enabled. + +These notification settings apply only to you. They do not affect the notifications received by anyone else in the same project or group. + + + +## Global notification settings + +Your **Global notification settings** are the default settings unless you select different values for a project or a group. + +- Notification email + - This is the email address your notifications will be sent to. +- Global notification level + - This is the default [notification level](#notification-levels) which applies to all your notifications. +- Receive notifications about your own activity. + - Check this checkbox if you want to receive notification about your own activity. Default: Not checked. + +### Notification scope + +You can tune the scope of your notifications by selecting different notification levels for each project and group. + +Notification scope is applied in order of precedence (highest to lowest): + +- Project + - For each project, you can select a notification level. Your project setting overrides the group setting. +- Group + - For each group, you can select a notification level. Your group setting overrides your default setting. +- Global (default) + - Your global, or _default_, notification level applies if you have not selected a notification level for the project or group in which the activity occurred. + +#### Project notifications + +You can select a notification level for each project. This can be useful if you need to closely monitor activity in select projects. + + + +To select a notification level for a project, use either of these methods: + +1. Click on your profile picture and select **Settings**. +1. Click **Notifications** in the left sidebar. +1. Locate the project in the **Projects** section. +1. Select the desired [notification level](#notification-levels). + +Or: + +1. Navigate to the project's page. +1. Click the notification dropdown, marked with a bell icon. +1. Select the desired [notification level](#notification-levels). + +#### Group notifications + +You can select a notification level and email address for each group. + + + +##### Group notification level + +To select a notification level for a group, use either of these methods: + +1. Click on your profile picture and select **Settings**. +1. Click **Notifications** in the left sidebar. +1. Locate the project in the **Groups** section. +1. Select the desired [notification level](#notification-levels). + +--- + +1. Navigate to the group's page. +1. Click the notification dropdown, marked with a bell icon. +1. Select the desired [notification level](#notification-levels). + +##### Group notification email address + +> Introduced in GitLab 12.0 + +You can select an email address to receive notifications for each group you belong to. This could be useful, for example, if you work freelance, and want to keep email about clients' projects separate. + +1. Click on your profile picture and select **Settings**. +1. Click **Notifications** in the left sidebar. +1. Locate the project in the **Groups** section. +1. Select the desired email address. + +### Notification levels + +For each project and group you can select one of the following levels: + +| Level | Description | +|:------------|:------------| +| Global | Your global settings apply. | +| Watch | Receive notifications for any activity. | +| On mention | Receive notifications when `@mentioned` in comments. | +| Participate | Receive notifications for threads you have participated in. | +| Disabled | Turns off notifications. | +| Custom | Receive notifications for custom selected events. | + +## Notification events + +Users will be notified of the following events: + +| Event | Sent to | Settings level | +|------------------------------|---------------------|------------------------------| +| New SSH key added | User | Security email, always sent. | +| New email added | User | Security email, always sent. | +| Email changed | User | Security email, always sent. | +| Password changed | User | Security email, always sent. | +| New user created | User | Sent on user creation, except for OmniAuth (LDAP)| +| User added to project | User | Sent when user is added to project | +| Project access level changed | User | Sent when user project access level is changed | +| User added to group | User | Sent when user is added to group | +| Group access level changed | User | Sent when user group access level is changed | +| Project moved | Project members (1) | (1) not disabled | +| New release | Project members | Custom notification | + +## Issue / Epics / Merge request events + +In most of the below cases, the notification will be sent to: + +- Participants: + - the author and assignee of the issue/merge request + - authors of comments on the issue/merge request + - anyone mentioned by `@username` in the title or description of the issue, merge request or epic **(ULTIMATE)** + - anyone with notification level "Participating" or higher that is mentioned by `@username` in any of the comments on the issue, merge request, or epic **(ULTIMATE)** +- Watchers: users with notification level "Watch" +- Subscribers: anyone who manually subscribed to the issue, merge request, or epic **(ULTIMATE)** +- Custom: Users with notification level "custom" who turned on notifications for any of the events present in the table below + +| Event | Sent to | +|------------------------|---------| +| New issue | | +| Close issue | | +| Reassign issue | The above, plus the old assignee | +| Reopen issue | | +| Due issue | Participants and Custom notification level with this event selected | +| Change milestone issue | Subscribers, participants mentioned, and Custom notification level with this event selected | +| Remove milestone issue | Subscribers, participants mentioned, and Custom notification level with this event selected | +| New merge request | | +| Push to merge request | Participants and Custom notification level with this event selected | +| Reassign merge request | The above, plus the old assignee | +| Close merge request | | +| Reopen merge request | | +| Merge merge request | | +| Change milestone merge request | Subscribers, participants mentioned, and Custom notification level with this event selected | +| Remove milestone merge request | Subscribers, participants mentioned, and Custom notification level with this event selected | +| New comment | The above, plus anyone mentioned by `@username` in the comment, with notification level "Mention" or higher | +| Failed pipeline | The author of the pipeline | +| Successful pipeline | The author of the pipeline, if they have the custom notification setting for successful pipelines set | +| New epic **(ULTIMATE)** | | +| Close epic **(ULTIMATE)** | | +| Reopen epic **(ULTIMATE)** | | + +In addition, if the title or description of an Issue or Merge Request is +changed, notifications will be sent to any **new** mentions by `@username` as +if they had been mentioned in the original text. + +You won't receive notifications for Issues, Merge Requests or Milestones created +by yourself (except when an issue is due). You will only receive automatic +notifications when somebody else comments or adds changes to the ones that +you've created or mentions you. + +If an open merge request becomes unmergeable due to conflict, its author will be notified about the cause. +If a user has also set the merge request to automatically merge once pipeline succeeds, +then that user will also be notified. + +## Email Headers + +Notification emails include headers that provide extra content about the notification received: + +| Header | Description | +|-----------------------------|-------------------------------------------------------------------------| +| X-GitLab-Project | The name of the project the notification belongs to | +| X-GitLab-Project-Id | The ID of the project | +| X-GitLab-Project-Path | The path of the project | +| X-GitLab-(Resource)-ID | The ID of the resource the notification is for, where resource is `Issue`, `MergeRequest`, `Commit`, etc| +| X-GitLab-Discussion-ID | Only in comment emails, the ID of the thread the comment is from | +| X-GitLab-Pipeline-Id | Only in pipeline emails, the ID of the pipeline the notification is for | +| X-GitLab-Reply-Key | A unique token to support reply by email | +| X-GitLab-NotificationReason | The reason for being notified. "mentioned", "assigned", etc | +| List-Id | The path of the project in a RFC 2919 mailing list identifier useful for email organization, for example, with Gmail filters | + +### X-GitLab-NotificationReason + +This header holds the reason for the notification to have been sent out, +where reason can be `mentioned`, `assigned`, `own_activity`, etc. +Only one reason is sent out according to its priority: + +- `own_activity` +- `assigned` +- `mentioned` + +The reason in this header will also be shown in the footer of the notification email. For example an email with the +reason `assigned` will have this sentence in the footer: +`"You are receiving this email because you have been assigned an item on {configured GitLab hostname}"` + +NOTE: **Note:** +Only reasons listed above have been implemented so far. +Further implementation is [being discussed](https://gitlab.com/gitlab-org/gitlab/issues/20689). diff --git a/doc/user/profile/preferences.md b/doc/user/profile/preferences.md index 82a6d2b37033075f9022cf1d8fad0451e44e0b26..b299c74c8f4feed1d2ac853369e8155326d16581 100644 --- a/doc/user/profile/preferences.md +++ b/doc/user/profile/preferences.md @@ -82,7 +82,7 @@ You have 8 options here that you can use for your default dashboard view: - Your projects' activity - Starred projects' activity - Your groups -- Your [Todos](../../workflow/todos.md) +- Your [Todos](../todos.md) - Assigned Issues - Assigned Merge Requests - Operations Dashboard **(PREMIUM)** @@ -128,6 +128,19 @@ You can choose one of the following options as the first day of the week: If you select **System Default**, the system-wide default setting will be used. +## Integrations + +Configure your preferences with third-party services which provide enhancements to your GitLab experience. + +### Sourcegraph + +NOTE: **Note:** +This setting is only visible if Sourcegraph has been enabled by a GitLab administrator. + +Manage the availability of integrated code intelligence features powered by +Sourcegraph. View [the Sourcegraph feature documentation](../../integration/sourcegraph.md#enable-sourcegraph-in-user-preferences) +for more information. + <!-- ## Troubleshooting Include any troubleshooting steps that you can foresee. If you know beforehand what issues diff --git a/doc/user/project/clusters/add_remove_clusters.md b/doc/user/project/clusters/add_remove_clusters.md new file mode 100644 index 0000000000000000000000000000000000000000..150a451dfe5982440aedaa2613e0b5b35f821fc3 --- /dev/null +++ b/doc/user/project/clusters/add_remove_clusters.md @@ -0,0 +1,730 @@ +# Adding and removing Kubernetes clusters + +GitLab can integrate with the following Kubernetes providers: + +- Google Kubernetes Engine (GKE). +- Amazon Elastic Kubernetes Service (EKS). + +TIP: **Tip:** +Every new Google Cloud Platform (GCP) account receives [$300 in credit upon sign up](https://console.cloud.google.com/freetrial), +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. + +## Access controls + +When creating a cluster in GitLab, you will be asked if you would like to create either: + +- A [Role-based access control (RBAC)](https://kubernetes.io/docs/reference/access-authn-authz/rbac/) cluster. +- An [Attribute-based access control (ABAC)](https://kubernetes.io/docs/reference/access-authn-authz/abac/) cluster. + +NOTE: **Note:** +[RBAC](#rbac-cluster-resources) is recommended and the GitLab default. + +GitLab creates the necessary service accounts and privileges to install and run +[GitLab managed applications](index.md#installing-applications). When GitLab creates the cluster, +a `gitlab` service account with `cluster-admin` privileges is created in the `default` namespace +to manage the newly created cluster. + +NOTE: **Note:** +Restricted service account for deployment was [introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/51716) in GitLab 11.5. + +When you install Helm into your cluster, the `tiller` service account +is created with `cluster-admin` privileges in the `gitlab-managed-apps` +namespace. + +This service account will be: + +- Added to the installed Helm Tiller +- Used by Helm to install and run [GitLab managed applications](index.md#installing-applications). + +Helm will also create additional service accounts and other resources for each +installed application. Consult the documentation of the Helm charts for each application +for details. + +If you are [adding an existing Kubernetes cluster](add_remove_clusters.md#add-existing-cluster), +ensure the token of the account has administrator privileges for the cluster. + +The resources created by GitLab differ depending on the type of cluster. + +### Important notes + +Note the following about access controls: + +- Environment-specific resources are only created if your cluster is + [managed by GitLab](index.md#gitlab-managed-clusters). +- If your cluster was created before GitLab 12.2, it will use a single namespace for all project + environments. + +### RBAC cluster resources + +GitLab creates the following resources for RBAC clusters. + +| Name | Type | Details | Created when | +|:----------------------|:---------------------|:-----------------------------------------------------------------------------------------------------------|:-----------------------| +| `gitlab` | `ServiceAccount` | `default` namespace | Creating a new cluster | +| `gitlab-admin` | `ClusterRoleBinding` | [`cluster-admin`](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#user-facing-roles) roleRef | Creating a new cluster | +| `gitlab-token` | `Secret` | Token for `gitlab` ServiceAccount | Creating a new cluster | +| `tiller` | `ServiceAccount` | `gitlab-managed-apps` namespace | Installing Helm Tiller | +| `tiller-admin` | `ClusterRoleBinding` | `cluster-admin` roleRef | Installing Helm Tiller | +| Environment namespace | `Namespace` | Contains all environment-specific resources | Deploying to a cluster | +| Environment namespace | `ServiceAccount` | Uses namespace of environment | Deploying to a cluster | +| Environment namespace | `Secret` | Token for environment ServiceAccount | Deploying to a cluster | +| Environment namespace | `RoleBinding` | [`edit`](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#user-facing-roles) roleRef | Deploying to a cluster | + +### ABAC cluster resources + +GitLab creates the following resources for ABAC clusters. + +| Name | Type | Details | Created when | +|:----------------------|:---------------------|:-------------------------------------|:---------------------------| +| `gitlab` | `ServiceAccount` | `default` namespace | Creating a new cluster | +| `gitlab-token` | `Secret` | Token for `gitlab` ServiceAccount | Creating a new cluster | +| `tiller` | `ServiceAccount` | `gitlab-managed-apps` namespace | Installing Helm Tiller | +| `tiller-admin` | `ClusterRoleBinding` | `cluster-admin` roleRef | Installing Helm Tiller | +| Environment namespace | `Namespace` | Contains all environment-specific resources | Deploying to a cluster | +| Environment namespace | `ServiceAccount` | Uses namespace of environment | Deploying to a cluster | +| Environment namespace | `Secret` | Token for environment ServiceAccount | Deploying to a cluster | + +### Security of GitLab Runners + +GitLab Runners have the [privileged mode](https://docs.gitlab.com/runner/executors/docker.html#the-privileged-mode) +enabled by default, which allows them to execute special commands and running +Docker in Docker. This functionality is needed to run some of the +[Auto DevOps](../../../topics/autodevops/index.md) +jobs. This implies the containers are running in privileged mode and you should, +therefore, be aware of some important details. + +The privileged flag gives all capabilities to the running container, which in +turn can do almost everything that the host can do. Be aware of the +inherent security risk associated with performing `docker run` operations on +arbitrary images as they effectively have root access. + +If you don't want to use GitLab Runner in privileged mode, either: + +- Use shared Runners on GitLab.com. They don't have this security issue. +- Set up your own Runners using configuration described at + [Shared Runners](../../gitlab_com/index.md#shared-runners). This involves: + 1. Making sure that you don't have it installed via + [the applications](index.md#installing-applications). + 1. Installing a Runner + [using `docker+machine`](https://docs.gitlab.com/runner/executors/docker_machine.html). + +## Add new cluster + +### 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). + +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 + +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). + +Also note the following: + +- 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. + +#### 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. + +1. Click **Add Kubernetes cluster**. +1. Click **Create with Google Kubernetes Engine**. +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: + - **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. + - **Google Cloud Platform project** - Choose the project you created in your GCP + console that will host the Kubernetes cluster. Learn more about + [Google Cloud Platform projects](https://cloud.google.com/resource-manager/docs/creating-managing-projects). + - **Zone** - Choose the [region zone](https://cloud.google.com/compute/docs/regions-zones/) + under which the cluster will be created. + - **Number of nodes** - Enter the number of nodes you wish the cluster to have. + - **Machine type** - The [machine type](https://cloud.google.com/compute/docs/machine-types) + of the Virtual Machine instance that the cluster will be based on. + - **Enable Cloud Run on GKE (beta)** - Check this if you want to use Cloud Run on GKE for this cluster. + See the [Cloud Run on GKE section](#cloud-run-on-gke) for more information. + - **GitLab-managed cluster** - Leave this checked if you want GitLab to manage namespaces and service accounts for this cluster. + See the [Managed clusters section](index.md#gitlab-managed-clusters) for more information. +1. Finally, click the **Create Kubernetes cluster** button. + +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). + +#### Cloud Run on GKE + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/16566) in GitLab 12.4. + +You can choose to use Cloud Run on GKE in place of installing Knative and Istio +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: + +1. Navigate to your project's **Operations > Kubernetes** page. + + NOTE: **Note:** + You need Maintainer [permissions](../../permissions.md) and above to access the Kubernetes page. + +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. 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**. + 1. Under `Select type of trusted entity`, select **Another AWS account**. + 1. Enter the Account ID from GitLab into the `Account ID` field. + 1. Check **Require external ID**. + 1. Enter the External ID from GitLab into the `External ID` field. + 1. Click **Next: Permissions**. + 1. Click **Create Policy**, which will open a new window. + 1. Select the **JSON** tab, and paste in the following snippet in place of the existing content: + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "autoscaling:CreateAutoScalingGroup", + "autoscaling:DescribeAutoScalingGroups", + "autoscaling:DescribeScalingActivities", + "autoscaling:UpdateAutoScalingGroup", + "autoscaling:CreateLaunchConfiguration", + "autoscaling:DescribeLaunchConfigurations", + "cloudformation:CreateStack", + "cloudformation:DescribeStacks", + "ec2:AuthorizeSecurityGroupEgress", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:RevokeSecurityGroupEgress", + "ec2:RevokeSecurityGroupIngress", + "ec2:CreateSecurityGroup", + "ec2:createTags", + "ec2:DescribeImages", + "ec2:DescribeKeyPairs", + "ec2:DescribeRegions", + "ec2:DescribeSecurityGroups", + "ec2:DescribeSubnets", + "ec2:DescribeVpcs", + "eks:CreateCluster", + "eks:DescribeCluster", + "iam:AddRoleToInstanceProfile", + "iam:AttachRolePolicy", + "iam:CreateRole", + "iam:CreateInstanceProfile", + "iam:GetRole", + "iam:ListRoles", + "iam:PassRole", + "ssm:GetParameters" + ], + "Resource": "*" + } + ] + } + ``` + + NOTE: **Note:** + These permissions give GitLab the ability to create resources, but not delete them. + This means that if an error is encountered during the creation process, changes will + not be rolled back and you must remove resources manually. You can do this by deleting + the relevant [CloudFormation stack](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-console-delete-stack.html) + + 1. Click **Review policy**. + 1. Enter a suitable name for this policy, and click **Create Policy**. You can now close this window. + 1. Switch back to the "Create role" window, and select the policy you just created. + 1. Click **Next: Tags**, and optionally enter any tags you wish to associate with this role. + 1. Click **Next: Review**. + 1. Enter a role name and optional description into the fields provided. + 1. Click **Create role**, the new role name will appear at the top. Click on its name and copy the `Role ARN` from the newly created role. +1. In GitLab, enter the copied role ARN into the `Role ARN` field. +1. Click **Authenticate with AWS**. +1. Choose your cluster's settings: + - **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. + - **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) + that you can use to connect to your worker nodes if required. + - **VPC** - Select a [VPC](https://docs.aws.amazon.com/vpc/latest/userguide/what-is-amazon-vpc.html) + to use for your EKS Cluster resources. + - **Subnets** - Choose the [subnets](https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Subnets.html) + in your VPC where your worker nodes will run. + - **Security group** - Choose the [security group](https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html) + to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets. + - **Instance type** - The [instance type](https://aws.amazon.com/ec2/instance-types/) of your worker nodes. + - **Node count** - The number of worker nodes. + - **GitLab-managed cluster** - Leave this checked if you want GitLab to manage namespaces and service accounts for this cluster. + See the [Managed clusters section](index.md#gitlab-managed-clusters) for more information. +1. Finally, click the **Create Kubernetes cluster** button. + +After about 10 minutes, your cluster will be ready to go. You can now proceed +to install some [pre-defined applications](index.md#installing-applications). + +## Add existing cluster + +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). + +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. + + NOTE: **Note:** + You need Maintainer [permissions](../../permissions.md) and above to access the Kubernetes page. + +1. Click **Add Kubernetes cluster**. +1. Click **Add an existing Kubernetes cluster** 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. + - **API URL** (required) - + It's the URL that GitLab uses to access the Kubernetes API. Kubernetes + exposes several APIs, we want the "base" URL that is common to all of them, + e.g., `https://kubernetes.example.com` rather than `https://kubernetes.example.com/api/v1`. + + Get the API URL by running this command: + + ```sh + kubectl cluster-info | grep 'Kubernetes master' | awk '/http/ {print $NF}' + ``` + + - **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 + `default-token-xxxxx`. Copy that token name for use below. + - Get the certificate by running this command: + + ```sh + + kubectl get secret <secret name> -o jsonpath="{['data']['ca\.crt']}" | base64 --decode + + ``` + + NOTE: **Note:** + If the command returns the entire certificate chain, you need copy the *root ca* + certificate at the bottom of the chain. + + - **Token** - + GitLab authenticates against Kubernetes using service tokens, which are + scoped to a particular `namespace`. + **The token used should belong to a service account with + [`cluster-admin`](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#user-facing-roles) + privileges.** To create this service account: + + 1. Create a file called `gitlab-admin-service-account.yaml` with contents: + + ```yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: gitlab-admin + namespace: kube-system + --- + apiVersion: rbac.authorization.k8s.io/v1beta1 + kind: ClusterRoleBinding + metadata: + name: gitlab-admin + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin + subjects: + - kind: ServiceAccount + name: gitlab-admin + namespace: kube-system + ``` + + 1. Apply the service account and cluster role binding to your cluster: + + ```bash + kubectl apply -f gitlab-admin-service-account.yaml + ``` + + You will need the `container.clusterRoleBindings.create` permission + to create cluster-level roles. If you do not have this permission, + you can alternatively enable Basic Authentication and then run the + `kubectl apply` command as an admin: + + ```bash + kubectl apply -f gitlab-admin-service-account.yaml --username=admin --password=<password> + ``` + + NOTE: **Note:** + Basic Authentication can be turned on and the password credentials + can be obtained using the Google Cloud Console. + + Output: + + ```bash + serviceaccount "gitlab-admin" created + clusterrolebinding "gitlab-admin" created + ``` + + 1. Retrieve the token for the `gitlab-admin` service account: + + ```bash + kubectl -n kube-system describe secret $(kubectl -n kube-system get secret | grep gitlab-admin | awk '{print $1}') + ``` + + Copy the `<authentication_token>` value from the output: + + ```yaml + Name: gitlab-admin-token-b5zv4 + Namespace: kube-system + Labels: <none> + Annotations: kubernetes.io/service-account.name=gitlab-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> + ``` + + NOTE: **Note:** + For GKE clusters, you will need the + `container.clusterRoleBindings.create` permission to create a cluster + role binding. You can follow the [Google Cloud + documentation](https://cloud.google.com/iam/docs/granting-changing-revoking-access) + to grant access. + + - **GitLab-managed cluster** - Leave this checked if you want GitLab to manage namespaces and service accounts for this cluster. + See the [Managed clusters section](index.md#gitlab-managed-clusters) for more information. + + - **Project namespace** (optional) - You don't have to fill it in; by leaving + it blank, GitLab will create one for you. Also: + - Each project should have a unique namespace. + - The project namespace is not necessarily the namespace of the secret, if + you're using a secret with broader permissions, like the secret from `default`. + - You should **not** use `default` as the project namespace. + - If you or someone created a secret specifically for the project, usually + with limited permissions, the secret's namespace and project namespace may + be the same. + +1. Finally, click the **Create Kubernetes cluster** button. + +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 + 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. + + + +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. + +If you would like to utilize your own CI/CD scripts to deploy to the cluster, you can stop here. + +#### Disable Role-Based Access Control (RBAC) (optional) + +When connecting a cluster via GitLab integration, you may specify whether the +cluster is RBAC-enabled or not. This will affect how GitLab interacts with the +cluster for certain operations. If you **did not** check the "RBAC-enabled cluster" +checkbox at creation time, GitLab will assume RBAC is disabled for your cluster +when interacting with it. If so, you must disable RBAC on your cluster for the +integration to work properly. + + + +NOTE: **Note**: Disabling RBAC means that any application running in the cluster, +or user who can authenticate to the cluster, has full API access. This is a +[security concern](index.md#security-implications), and may not be desirable. + +To effectively disable RBAC, global permissions can be applied granting full access: + +```bash +kubectl create clusterrolebinding permissive-binding \ + --clusterrole=cluster-admin \ + --user=admin \ + --user=kubelet \ + --group=system:serviceaccounts +``` + +#### Create a default Storage Class + +Amazon EKS doesn't have a default Storage Class out of the box, which means +requests for persistent volumes will not be automatically fulfilled. As part +of Auto DevOps, the deployed Postgres instance requests persistent storage, +and without a default storage class it will fail to start. + +If a default Storage Class doesn't already exist and is desired, follow Amazon's +[guide on storage classes](https://docs.aws.amazon.com/eks/latest/userguide/storage-classes.html) +to create one. + +Alternatively, disable Postgres by setting the project variable +[`POSTGRES_ENABLED`](../../../topics/autodevops/#environment-variables) to `false`. + +#### Deploy the app to EKS + +With RBAC disabled and services deployed, +[Auto DevOps](../../../topics/autodevops/index.md) can now be leveraged +to build, test, and deploy the app. + +[Enable Auto DevOps](../../../topics/autodevops/index.md#at-the-project-level) +if not already enabled. If a wildcard DNS entry was created resolving to the +Load Balancer, enter it in the `domain` field under the Auto DevOps settings. +Otherwise, the deployed app will not be externally available outside of the cluster. + + + +A new pipeline will automatically be created, which will begin to build, test, +and deploy the app. + +After the pipeline has finished, your app will be running in EKS and available +to users. Click on **CI/CD > Environments**. + + + +You will see a list of the environments and their deploy status, as well as +options to browse to the app, view monitoring metrics, and even access a shell +on the running pod. + +## Enabling or disabling integration + +After you have successfully added your cluster information, you can enable the +Kubernetes cluster integration: + +1. Click the **Enabled/Disabled** switch +1. Hit **Save** for the changes to take effect + +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. + +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`. + +## Learn more + +To learn more on automatically deploying your applications, +read about [Auto DevOps](../../../topics/autodevops/index.md). diff --git a/doc/user/project/clusters/eks_and_gitlab/img/create_dns.png b/doc/user/project/clusters/eks_and_gitlab/img/create_dns.png deleted file mode 100644 index 61ed85e5cd969c5d2ad4202192de58ee9a93a039..0000000000000000000000000000000000000000 Binary files a/doc/user/project/clusters/eks_and_gitlab/img/create_dns.png and /dev/null differ diff --git a/doc/user/project/clusters/eks_and_gitlab/img/create_project.png b/doc/user/project/clusters/eks_and_gitlab/img/create_project.png deleted file mode 100644 index b02ab4b9064064afc09d29b53c511aa6b3c05087..0000000000000000000000000000000000000000 Binary files a/doc/user/project/clusters/eks_and_gitlab/img/create_project.png and /dev/null differ diff --git a/doc/user/project/clusters/eks_and_gitlab/img/deploy_apps.png b/doc/user/project/clusters/eks_and_gitlab/img/deploy_apps.png deleted file mode 100644 index 0d9fcc838d9b6d649aa97aa2bbcc0075590c1393..0000000000000000000000000000000000000000 Binary files a/doc/user/project/clusters/eks_and_gitlab/img/deploy_apps.png and /dev/null differ diff --git a/doc/user/project/clusters/eks_and_gitlab/index.md b/doc/user/project/clusters/eks_and_gitlab/index.md index 22576b84926cb16049e08c500edfde9a1588f958..fda8cd6340efee28e24258f7f8313ff5e967575f 100644 --- a/doc/user/project/clusters/eks_and_gitlab/index.md +++ b/doc/user/project/clusters/eks_and_gitlab/index.md @@ -1,278 +1,5 @@ -# Connecting and deploying to an Amazon EKS cluster +--- +redirect_to: '../add_remove_clusters.md#add-existing-eks-cluster' +--- -In this tutorial, we will show how to integrate an -[Amazon EKS](https://aws.amazon.com/eks/) cluster with GitLab and begin -deploying applications. - -## Introduction - -For an end-to-end walkthrough we will: - -1. Start with a new project based on the sample Ruby on Rails template. -1. Integrate an EKS cluster. -1. Utilize [Auto DevOps](../../../../topics/autodevops/) to build, test, and deploy our application. - -You will need: - -1. An account on GitLab, like [GitLab.com](https://gitlab.com). -1. An Amazon EKS cluster (with worker nodes properly configured). -1. `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). - -## Creating a new project - -On GitLab, create a new project by clicking on the `+` icon in the top navigation -bar and selecting **New project**. - -On the new project screen, click on the **Create from template** tab, and select -"Use template" for the Ruby on Rails sample project. - -Give the project a name, and then select **Create project**. - - - -## 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: - - 2.1. Create a file called `eks-admin-service-account.yaml` with contents: - - ```yaml - apiVersion: v1 - kind: ServiceAccount - metadata: - name: eks-admin - namespace: kube-system - ``` - - 2.2. Apply the service account to your cluster: - - ```bash - kubectl apply -f eks-admin-service-account.yaml - ``` - - Output: - - ```bash - serviceaccount "eks-admin" created - ``` - - 2.3. 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 - ``` - - 2.4. Apply the cluster role binding to your cluster: - - ```bash - kubectl apply -f eks-admin-cluster-role-binding.yaml - ``` - - Output: - - ```bash - clusterrolebinding "eks-admin" created - ``` - - 2.5. 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. - - - -Click on **Add Kubernetes cluster**, the cluster is now connected to GitLab. -At this point, [Kubernetes deployment variables](../#deployment-variables) will -automatically be available during CI/CD jobs, making it easy to interact with the cluster. - -If you would like to utilize your own CI/CD scripts to deploy to the cluster, you can stop here. - -## Disable Role-Based Access Control (RBAC) (optional) - -When connecting a cluster via GitLab integration, you may specify whether the -cluster is RBAC-enabled or not. This will affect how GitLab interacts with the -cluster for certain operations. If you **did not** check the "RBAC-enabled cluster" -checkbox at creation time, GitLab will assume RBAC is disabled for your cluster -when interacting with it. If so, you must disable RBAC on your cluster for the -integration to work properly. - - - -NOTE: **Note**: Disabling RBAC means that any application running in the cluster, -or user who can authenticate to the cluster, has full API access. This is a -[security concern](../index.md#security-implications), and may not be desirable. - -To effectively disable RBAC, global permissions can be applied granting full access: - -```bash -kubectl create clusterrolebinding permissive-binding \ - --clusterrole=cluster-admin \ - --user=admin \ - --user=kubelet \ - --group=system:serviceaccounts -``` - -## Deploy services to the cluster - -GitLab supports one-click deployment of helpful services to the cluster, many of -which support Auto DevOps. Back on the Kubernetes cluster screen in GitLab, a -list of applications is now available to deploy. - -First, install Helm Tiller, a package manager for Kubernetes. This enables -deployment of the other applications. - - - -### Deploying NGINX Ingress (optional) - -Next, if you would like the deployed app to be reachable on the internet, deploy -the Ingress. Note that this will also cause an -[Elastic Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/) -to be created, which will incur additional AWS costs. - -Once installed, you may see a `?` for "Ingress IP Address". This is because the -created ELB is available at a DNS name, not an IP address. To get the DNS name, -run: - -```sh -kubectl get service ingress-nginx-ingress-controller -n gitlab-managed-apps -o jsonpath="{.status.loadBalancer.ingress[0].hostname}" -``` - -Note that you may see a trailing `%` on some Kubernetes versions, **do not include it**. - -The Ingress is now available at this address and will route incoming requests to -the proper service based on the DNS name in the request. To support this, a -wildcard DNS CNAME record should be created for the desired domain name. For example, -`*.myekscluster.com` would point to the Ingress hostname obtained earlier. - - - -### Deploying the GitLab Runner (optional) - -If the project is on GitLab.com, free shared Runners are available and you do -not have to deploy one. If a project specific Runner is desired, or there are no -shared Runners, it is easy to deploy one. - -Simply click on the **Install** button for the GitLab Runner. It is important to -note that the Runner deployed is set as **privileged**, which means it essentially -has root access to the underlying machine. This is required to build docker images, -and so is on by default. - -### Deploying Prometheus (optional) - -GitLab is able to monitor applications automatically, utilizing -[Prometheus](../../integrations/prometheus.html). Kubernetes container CPU and -memory metrics are automatically collected, and response metrics are retrieved -from NGINX Ingress as well. - -To enable monitoring, simply install Prometheus into the cluster with the -**Install** button. - -## Create a default Storage Class - -Amazon EKS doesn't have a default Storage Class out of the box, which means -requests for persistent volumes will not be automatically fulfilled. As part -of Auto DevOps, the deployed Postgres instance requests persistent storage, -and without a default storage class it will fail to start. - -If a default Storage Class doesn't already exist and is desired, follow Amazon's -[guide on storage classes](https://docs.aws.amazon.com/eks/latest/userguide/storage-classes.html) -to create one. - -Alternatively, disable Postgres by setting the project variable -[`POSTGRES_ENABLED`](../../../../topics/autodevops/#environment-variables) to `false`. - -## Deploy the app to EKS - -With RBAC disabled and services deployed, -[Auto DevOps](../../../../topics/autodevops/index.md) can now be leveraged -to build, test, and deploy the app. - -[Enable Auto DevOps](../../../../topics/autodevops/index.md#at-the-project-level) -if not already enabled. If a wildcard DNS entry was created resolving to the -Load Balancer, enter it in the `domain` field under the Auto DevOps settings. -Otherwise, the deployed app will not be externally available outside of the cluster. - - - -A new pipeline will automatically be created, which will begin to build, test, -and deploy the app. - -After the pipeline has finished, your app will be running in EKS and available -to users. Click on **CI/CD > Environments**. - - - -You will see a list of the environments and their deploy status, as well as -options to browse to the app, view monitoring metrics, and even access a shell -on the running pod. - -## Learn more - -To learn more on automatically deploying your applications, -read about [Auto DevOps](../../../../topics/autodevops/index.md). +This document was moved to [another location](../add_remove_clusters.md#add-existing-eks-cluster). diff --git a/doc/user/project/clusters/eks_and_gitlab/img/add_cluster.png b/doc/user/project/clusters/img/add_cluster.png similarity index 100% rename from doc/user/project/clusters/eks_and_gitlab/img/add_cluster.png rename to doc/user/project/clusters/img/add_cluster.png diff --git a/doc/user/project/clusters/eks_and_gitlab/img/environment.png b/doc/user/project/clusters/img/environment.png similarity index 100% rename from doc/user/project/clusters/eks_and_gitlab/img/environment.png rename to doc/user/project/clusters/img/environment.png diff --git a/doc/user/project/clusters/img/kubernetes_pod_logs_v12_4.png b/doc/user/project/clusters/img/kubernetes_pod_logs_v12_4.png deleted file mode 100644 index 73c2ecd182a78c98a998743c0a849ba16eb23098..0000000000000000000000000000000000000000 Binary files a/doc/user/project/clusters/img/kubernetes_pod_logs_v12_4.png and /dev/null differ diff --git a/doc/user/project/clusters/img/kubernetes_pod_logs_v12_5.png b/doc/user/project/clusters/img/kubernetes_pod_logs_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..e54637e7218c2b7344c5f376fb24bd2809032f81 Binary files /dev/null and b/doc/user/project/clusters/img/kubernetes_pod_logs_v12_5.png differ diff --git a/doc/user/project/clusters/eks_and_gitlab/img/pipeline.png b/doc/user/project/clusters/img/pipeline.png similarity index 100% rename from doc/user/project/clusters/eks_and_gitlab/img/pipeline.png rename to doc/user/project/clusters/img/pipeline.png diff --git a/doc/user/project/clusters/eks_and_gitlab/img/rbac.png b/doc/user/project/clusters/img/rbac.png similarity index 100% rename from doc/user/project/clusters/eks_and_gitlab/img/rbac.png rename to doc/user/project/clusters/img/rbac.png diff --git a/doc/user/project/clusters/img/sidebar_menu_pod_logs_v12_5.png b/doc/user/project/clusters/img/sidebar_menu_pod_logs_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..f113b0353f2e232e51ca334fead71f9e80c5add9 Binary files /dev/null and b/doc/user/project/clusters/img/sidebar_menu_pod_logs_v12_5.png differ diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md index 9ecb785d6fe6faa19ebb9864437cae7b1c9bdede..c5c2c2c07e7fc9ce0e015a8054a2558834595509 100644 --- a/doc/user/project/clusters/index.md +++ b/doc/user/project/clusters/index.md @@ -28,12 +28,11 @@ Using the GitLab project Kubernetes integration, you can: - Use [Deploy Boards](#deploy-boards-premium). **(PREMIUM)** - Use [Canary Deployments](#canary-deployments-premium). **(PREMIUM)** - View [Pod logs](#pod-logs-ultimate). **(ULTIMATE)** - -You can also: - -- Connect and deploy to an [Amazon EKS cluster](eks_and_gitlab/index.html). - 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 @@ -98,236 +97,10 @@ pods are annotated with: `$CI_ENVIRONMENT_SLUG` and `$CI_PROJECT_PATH_SLUG` are the values of the CI variables. -## Adding and removing clusters - -There are two options when adding a new cluster to your project: - -- Associate your account with Google Kubernetes Engine (GKE) to - [create new clusters](#add-new-gke-cluster) from within GitLab. -- Provide credentials to an - [existing Kubernetes cluster](#add-existing-kubernetes-cluster). - -### Add new GKE cluster - -TIP: **Tip:** -Every new Google Cloud Platform (GCP) account receives [$300 in credit upon sign up](https://console.cloud.google.com/freetrial), -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=a0n60000006Vpz4AAC) and apply for credit. - -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. - -#### Requirements - -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). - -#### Creating the cluster - -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. - -1. Click **Add Kubernetes cluster**. -1. Click **Create with Google Kubernetes Engine**. -1. Connect your Google account if you haven't done already by clicking the - **Sign in with Google** button. -1. From there on, choose your cluster's settings: - - **Kubernetes cluster name** - The name you wish to give the cluster. - - **Environment scope** - The [associated environment](#setting-the-environment-scope-premium) to this cluster. - - **Google Cloud Platform project** - Choose the project you created in your GCP - console that will host the Kubernetes cluster. Learn more about - [Google Cloud Platform projects](https://cloud.google.com/resource-manager/docs/creating-managing-projects). - - **Zone** - Choose the [region zone](https://cloud.google.com/compute/docs/regions-zones/) - under which the cluster will be created. - - **Number of nodes** - Enter the number of nodes you wish the cluster to have. - - **Machine type** - The [machine type](https://cloud.google.com/compute/docs/machine-types) - of the Virtual Machine instance that the cluster will be based on. - - **Enable Cloud Run on GKE (beta)** - Check this if you want to use Cloud Run on GKE for this cluster. See the [Cloud Run on GKE section](#cloud-run-on-gke) for more information. - - **GitLab-managed cluster** - Leave this checked if you want GitLab to manage namespaces and service accounts for this cluster. See the [Managed clusters section](#gitlab-managed-clusters) for more information. -1. Finally, click the **Create Kubernetes cluster** button. - -After a couple of minutes, your cluster will be ready to go. You can now proceed -to install some [pre-defined applications](#installing-applications). - -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. - -NOTE: **Note:** -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. - -### Add existing Kubernetes 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. - -To add an existing 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. - -1. Click **Add Kubernetes cluster**. -1. Click **Add an existing Kubernetes cluster** and fill in the details: - - **Kubernetes cluster name** (required) - The name you wish to give the cluster. - - **Environment scope** (required) - The - [associated environment](#setting-the-environment-scope-premium) to this cluster. - - **API URL** (required) - - It's the URL that GitLab uses to access the Kubernetes API. Kubernetes - exposes several APIs, we want the "base" URL that is common to all of them, - e.g., `https://kubernetes.example.com` rather than `https://kubernetes.example.com/api/v1`. - - Get the API URL by running this command: - - ```sh - kubectl cluster-info | grep 'Kubernetes master' | awk '/http/ {print $NF}' - ``` - - - **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 - `default-token-xxxxx`. Copy that token name for use below. - - Get the certificate by running this command: - - ```sh - kubectl get secret <secret name> -o jsonpath="{['data']['ca\.crt']}" | base64 --decode - ``` - - - **Token** - - GitLab authenticates against Kubernetes using service tokens, which are - scoped to a particular `namespace`. - **The token used should belong to a service account with - [`cluster-admin`](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#user-facing-roles) - privileges.** To create this service account: - - 1. Create a file called `gitlab-admin-service-account.yaml` with contents: - - ```yaml - apiVersion: v1 - kind: ServiceAccount - metadata: - name: gitlab-admin - namespace: kube-system - --- - apiVersion: rbac.authorization.k8s.io/v1beta1 - kind: ClusterRoleBinding - metadata: - name: gitlab-admin - roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: cluster-admin - subjects: - - kind: ServiceAccount - name: gitlab-admin - namespace: kube-system - ``` - - 1. Apply the service account and cluster role binding to your cluster: - - ```bash - kubectl apply -f gitlab-admin-service-account.yaml - ``` - - Output: - - ```bash - serviceaccount "gitlab-admin" created - clusterrolebinding "gitlab-admin" created - ``` - - 1. Retrieve the token for the `gitlab-admin` service account: - - ```bash - kubectl -n kube-system describe secret $(kubectl -n kube-system get secret | grep gitlab-admin | awk '{print $1}') - ``` - - Copy the `<authentication_token>` value from the output: - - ```yaml - Name: gitlab-admin-token-b5zv4 - Namespace: kube-system - Labels: <none> - Annotations: kubernetes.io/service-account.name=gitlab-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> - ``` - - NOTE: **Note:** - For GKE clusters, you will need the - `container.clusterRoleBindings.create` permission to create a cluster - role binding. You can follow the [Google Cloud - documentation](https://cloud.google.com/iam/docs/granting-changing-revoking-access) - to grant access. - - - **GitLab-managed cluster** - Leave this checked if you want GitLab to manage namespaces and service accounts for this cluster. See the [Managed clusters section](#gitlab-managed-clusters) for more information. - - - **Project namespace** (optional) - You don't have to fill it in; by leaving - it blank, GitLab will create one for you. Also: - - Each project should have a unique namespace. - - The project namespace is not necessarily the namespace of the secret, if - you're using a secret with broader permissions, like the secret from `default`. - - You should **not** use `default` as the project namespace. - - If you or someone created a secret specifically for the project, usually - with limited permissions, the secret's namespace and project namespace may - be the same. - -1. Finally, click the **Create Kubernetes cluster** button. - -After a couple of minutes, your cluster will be ready to go. You can now proceed -to install some [pre-defined applications](#installing-applications). - -### Enabling or disabling integration - -After you have successfully added your cluster information, you can enable the -Kubernetes cluster integration: - -1. Click the **Enabled/Disabled** switch -1. Hit **Save** for the changes to take effect - -To disable the Kubernetes cluster integration, follow the same procedure. - -### Removing integration - -NOTE: **Note:** -You need Maintainer [permissions](../../permissions.md) and above to remove a Kubernetes cluster integration. - -NOTE: **Note:** -When you remove a cluster, you only remove its relation to GitLab, not the -cluster itself. To remove the cluster, you can do so by visiting the GKE -dashboard or using `kubectl`. - -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. - ## Cluster configuration -This section covers important considerations for configuring Kubernetes -clusters with GitLab. +After [adding a Kubernetes cluster](add_remove_clusters.md) to GitLab, read this section that covers +important considerations for configuring Kubernetes clusters with GitLab. ### Security implications @@ -340,15 +113,6 @@ functionalities needed to successfully build and deploy a containerized application. Bear in mind that the same credentials are used for all the applications running on the cluster. -### Cloud Run on GKE - -> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/16566) in GitLab 12.4. - -You can choose to use Cloud Run on GKE in place of installing Knative and Istio -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. - ### GitLab-managed clusters > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/22011) in GitLab 11.5. @@ -356,7 +120,7 @@ create time and cannot be [installed or uninstalled](../../clusters/applications 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 -[Access controls](#access-controls) section for details on which resources will +[Access controls](add_remove_clusters.md#access-controls) section for details on which resources will be created. If you choose to manage your own cluster, project-specific resources will not be created @@ -386,97 +150,6 @@ you can either: - Create an `A` record that points to the Ingress IP address with your domain provider. - Enter a wildcard DNS address using a service such as nip.io or xip.io. For example, `192.168.1.1.xip.io`. -### Access controls - -When creating a cluster in GitLab, you will be asked if you would like to create either: - -- An [Attribute-based access control (ABAC)](https://kubernetes.io/docs/reference/access-authn-authz/abac/) cluster. -- A [Role-based access control (RBAC)](https://kubernetes.io/docs/reference/access-authn-authz/rbac/) cluster. - -NOTE: **Note:** -[RBAC](#rbac-cluster-resources) is recommended and the GitLab default. - -GitLab creates the necessary service accounts and privileges to install and run -[GitLab managed applications](#installing-applications). When GitLab creates the cluster, -a `gitlab` service account with `cluster-admin` privileges is created in the `default` namespace -to manage the newly created cluster. - - NOTE: **Note:** - Restricted service account for deployment was [introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/51716) in GitLab 11.5. - -When you install Helm into your cluster, the `tiller` service account -is created with `cluster-admin` privileges in the `gitlab-managed-apps` -namespace. This service account will be added to the installed Helm Tiller and will -be used by Helm to install and run [GitLab managed applications](#installing-applications). -Helm will also create additional service accounts and other resources for each -installed application. Consult the documentation of the Helm charts for each application -for details. - -If you are [adding an existing Kubernetes cluster](#add-existing-kubernetes-cluster), -ensure the token of the account has administrator privileges for the cluster. - -The resources created by GitLab differ depending on the type of cluster. - -#### ABAC cluster resources - -GitLab creates the following resources for ABAC clusters. - -| Name | Type | Details | Created when | -|:----------------------|:---------------------|:-------------------------------------|:---------------------------| -| `gitlab` | `ServiceAccount` | `default` namespace | Creating a new GKE Cluster | -| `gitlab-token` | `Secret` | Token for `gitlab` ServiceAccount | Creating a new GKE Cluster | -| `tiller` | `ServiceAccount` | `gitlab-managed-apps` namespace | Installing Helm Tiller | -| `tiller-admin` | `ClusterRoleBinding` | `cluster-admin` roleRef | Installing Helm Tiller | -| Environment namespace | `Namespace` | Contains all environment-specific resources | Deploying to a cluster | -| Environment namespace | `ServiceAccount` | Uses namespace of environment | Deploying to a cluster | -| Environment namespace | `Secret` | Token for environment ServiceAccount | Deploying to a cluster | - -#### RBAC cluster resources - -GitLab creates the following resources for RBAC clusters. - -| Name | Type | Details | Created when | -|:----------------------|:---------------------|:-----------------------------------------------------------------------------------------------------------|:---------------------------| -| `gitlab` | `ServiceAccount` | `default` namespace | Creating a new GKE Cluster | -| `gitlab-admin` | `ClusterRoleBinding` | [`cluster-admin`](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#user-facing-roles) roleRef | Creating a new GKE Cluster | -| `gitlab-token` | `Secret` | Token for `gitlab` ServiceAccount | Creating a new GKE Cluster | -| `tiller` | `ServiceAccount` | `gitlab-managed-apps` namespace | Installing Helm Tiller | -| `tiller-admin` | `ClusterRoleBinding` | `cluster-admin` roleRef | Installing Helm Tiller | -| Environment namespace | `Namespace` | Contains all environment-specific resources | Deploying to a cluster | -| Environment namespace | `ServiceAccount` | Uses namespace of environment | Deploying to a cluster | -| Environment namespace | `Secret` | Token for environment ServiceAccount | Deploying to a cluster | -| Environment namespace | `RoleBinding` | [`edit`](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#user-facing-roles) roleRef | Deploying to a cluster | - -NOTE: **Note:** -Environment-specific resources are only created if your cluster is [managed by GitLab](#gitlab-managed-clusters). - -NOTE: **Note:** -If your cluster was created before GitLab 12.2, it will use a single namespace for all project environments. - -#### Security of GitLab Runners - -GitLab Runners have the [privileged mode](https://docs.gitlab.com/runner/executors/docker.html#the-privileged-mode) -enabled by default, which allows them to execute special commands and running -Docker in Docker. This functionality is needed to run some of the -[Auto DevOps](../../../topics/autodevops/index.md) -jobs. This implies the containers are running in privileged mode and you should, -therefore, be aware of some important details. - -The privileged flag gives all capabilities to the running container, which in -turn can do almost everything that the host can do. Be aware of the -inherent security risk associated with performing `docker run` operations on -arbitrary images as they effectively have root access. - -If you don't want to use GitLab Runner in privileged mode, either: - -- Use shared Runners on GitLab.com. They don't have this security issue. -- Set up your own Runners using configuration described at - [Shared Runners](../../gitlab_com/index.md#shared-runners). This involves: - 1. Making sure that you don't have it installed via - [the applications](#installing-applications). - 1. Installing a Runner - [using `docker+machine`](https://docs.gitlab.com/runner/executors/docker_machine.html). - ### Setting the environment scope **(PREMIUM)** When adding more than one Kubernetes cluster to your project, you need to differentiate @@ -545,93 +218,12 @@ differentiate the new cluster with the rest. ## Installing applications -GitLab can install and manage some applications in your project-level -cluster. For more information on installing, upgrading, uninstalling, -and troubleshooting applications for your project cluster, see +GitLab can install and manage some applications like Helm, GitLab Runner, Ingress, +Prometheus, etc., in your project-level cluster. For more information on +installing, upgrading, uninstalling, and troubleshooting applications for +your project cluster, see [GitLab Managed Apps](../../clusters/applications.md). -### Getting the external endpoint - -NOTE: **Note:** -With the following procedure, a load balancer must be installed in your cluster -to obtain the endpoint. You can use either -[Ingress](#installing-applications), or Knative's own load balancer -([Istio](https://istio.io)) if using [Knative](#installing-applications). - -In order to publish your web application, you first need to find the endpoint which will be either an IP -address or a hostname associated with your load balancer. - -#### Automatically determining the external endpoint - -> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/17052) in GitLab 10.6. - -After you install [Ingress or Knative](#installing-applications), GitLab attempts to determine the external endpoint -and it should be available within a few minutes. If the endpoint doesn't appear -and your cluster runs on Google Kubernetes Engine: - -1. Check your [Kubernetes cluster on Google Kubernetes Engine](https://console.cloud.google.com/kubernetes) to ensure there are no errors on its nodes. -1. Ensure you have enough [Quotas](https://console.cloud.google.com/iam-admin/quotas) on Google Kubernetes Engine. For more information, see [Resource Quotas](https://cloud.google.com/compute/quotas). -1. Check [Google Cloud's Status](https://status.cloud.google.com/) to ensure they are not having any disruptions. - -If GitLab is still unable to determine the endpoint of your Ingress or Knative application, you can -manually determine it by following the steps below. - -#### Manually determining the external endpoint - -If the cluster is on GKE, click the **Google Kubernetes Engine** link in the -**Advanced settings**, or go directly to the -[Google Kubernetes Engine dashboard](https://console.cloud.google.com/kubernetes/) -and select the proper project and cluster. Then click **Connect** and execute -the `gcloud` command in a local terminal or using the **Cloud Shell**. - -If the cluster is not on GKE, follow the specific instructions for your -Kubernetes provider to configure `kubectl` with the right credentials. -The output of the following examples will show the external endpoint of your -cluster. This information can then be used to set up DNS entries and forwarding -rules that allow external access to your deployed applications. - -If you installed the Ingress [via the **Applications**](#installing-applications), -run the following command: - -```bash -kubectl get service --namespace=gitlab-managed-apps ingress-nginx-ingress-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip}' -``` - -Some Kubernetes clusters return a hostname instead, like [Amazon EKS](https://aws.amazon.com/eks/). For these platforms, run: - -```bash -kubectl get service --namespace=gitlab-managed-apps ingress-nginx-ingress-controller -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' -``` - -For Istio/Knative, the command will be different: - -```bash -kubectl get svc --namespace=istio-system knative-ingressgateway -o jsonpath='{.status.loadBalancer.ingress[0].ip} ' -``` - -Otherwise, you can list the IP addresses of all load balancers: - -```bash -kubectl get svc --all-namespaces -o jsonpath='{range.items[?(@.status.loadBalancer.ingress)]}{.status.loadBalancer.ingress[*].ip} ' -``` - -#### Using a static IP - -By default, an ephemeral external IP address is associated to the cluster's load -balancer. If you associate the ephemeral IP with your DNS and the IP changes, -your apps will not be able to be reached, and you'd have to change the DNS -record again. In order to avoid that, you should change it into a static -reserved IP. - -Read how to [promote an ephemeral external IP address in GKE](https://cloud.google.com/compute/docs/ip-addresses/reserve-static-external-ip-address#promote_ephemeral_ip). - -#### Pointing your DNS at the external endpoint - -Once you've set up the external endpoint, you should associate it with a [wildcard DNS -record](https://en.wikipedia.org/wiki/Wildcard_DNS_record) such as `*.example.com.` -in order to be able to reach your apps. If your external endpoint is an IP address, -use an A record. If your external endpoint is a hostname, use a CNAME record. - ## Deploying to a Kubernetes cluster A Kubernetes cluster can be the destination for a deployment job. If @@ -654,8 +246,8 @@ GitLab CI/CD build environment. | Variable | Description | | -------- | ----------- | | `KUBE_URL` | Equal to the API URL. | -| `KUBE_TOKEN` | The Kubernetes token of the [environment service account](#access-controls). | -| `KUBE_NAMESPACE` | The Kubernetes namespace is auto-generated if not specified. The default value is `<project_name>-<project_id>-<environment>`. You can overwrite it to use different one if needed, otherwise the `KUBE_NAMESPACE` variable will receive the default value. | +| `KUBE_TOKEN` | The Kubernetes token of the [environment service account](add_remove_clusters.md#access-controls). | +| `KUBE_NAMESPACE` | The namespace associated with the project's deployment service account. In the format `<project_name>-<project_id>-<environment>`. For GitLab-managed clusters, a matching namespace is automatically created by GitLab in the cluster. | | `KUBE_CA_PEM_FILE` | Path to a file containing PEM data. Only present if a custom CA bundle was specified. | | `KUBE_CA_PEM` | (**deprecated**) Raw PEM data. Only if a custom CA bundle was specified. | | `KUBECONFIG` | Path to a file containing `kubeconfig` for this deployment. CA bundle would be embedded if specified. This config also embeds the same token defined in `KUBE_TOKEN` so you likely will only need this variable. This variable name is also automatically picked up by `kubectl` so you won't actually need to reference it explicitly if using `kubectl`. | @@ -668,6 +260,16 @@ service account of the cluster integration. NOTE: **Note:** If your cluster was created before GitLab 12.2, default `KUBE_NAMESPACE` will be set to `<project_name>-<project_id>`. +When deploying a custom namespace: + +- The custom namespace must exist in your cluster. +- The project's deployment service account must have permission to deploy to the namespace. +- `KUBECONFIG` must be updated to use the custom namespace instead of the GitLab-provided default (this is [not automatic](https://gitlab.com/gitlab-org/gitlab/issues/31519)). +- If deploying with Auto DevOps, you must *also* override `KUBE_NAMESPACE` with the custom namespace. + +CAUTION: **Caution:** +GitLab does not save custom namespaces in the database. So while deployments work with custom namespaces, GitLab's integration for already-deployed environments will not pick up the customized values. For example, [Deploy Boards](../deploy_boards.md) will not work as intended for those deployments. For more information, see the [related issue](https://gitlab.com/gitlab-org/gitlab/issues/27630). + ### Troubleshooting Before the deployment jobs starts, GitLab creates the following specifically for diff --git a/doc/user/project/clusters/kubernetes_pod_logs.md b/doc/user/project/clusters/kubernetes_pod_logs.md index 4036eaf0bfb302c946e0423f3e72abd4566cce66..797ddf784cce84cf6b18292a62a33f77c1e99172 100644 --- a/doc/user/project/clusters/kubernetes_pod_logs.md +++ b/doc/user/project/clusters/kubernetes_pod_logs.md @@ -11,7 +11,31 @@ Everything you need to build, test, deploy, and run your app at scale. ## Overview -[Kubernetes](https://kubernetes.io) pod logs can be viewed directly within GitLab. Logs can be displayed by clicking on a specific pod from [Deploy Boards](../deploy_boards.md): +[Kubernetes](https://kubernetes.io) pod logs can be viewed directly within GitLab. + + + +## Requirements + +[Deploying to a Kubernetes environment](../deploy_boards.md#enabling-deploy-boards) is required in order to be able to use Pod Logs. + +## Usage + +To access pod logs, you must have the right [permissions](../../permissions.md#project-members-permissions). + +You can access them in two ways. + +### From the project sidebar + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/22011) in GitLab 12.5. + +Go to **Operations > Pod logs** on the sidebar menu. + + + +### From Deploy Boards + +Logs can be displayed by clicking on a specific pod from [Deploy Boards](../deploy_boards.md): 1. Go to **Operations > Environments** and find the environment which contains the desired pod, like `production`. 1. On the **Environments** page, you should see the status of the environment's pods with [Deploy Boards](../deploy_boards.md). @@ -23,9 +47,3 @@ Everything you need to build, test, deploy, and run your app at scale. - [From GitLab 12.4](https://gitlab.com/gitlab-org/gitlab/issues/5769), environments. Support for pods with multiple containers is coming [in a future release](https://gitlab.com/gitlab-org/gitlab/issues/6502). - -  - -## Requirements - -[Enabling Deploy Boards](../deploy_boards.md#enabling-deploy-boards) is required in order to be able to use Pod Logs. diff --git a/doc/user/project/clusters/runbooks/index.md b/doc/user/project/clusters/runbooks/index.md index 7b17ec6823474d810609e66d4137c0aefcf59fc7..bffae4c50692836d11879dfc1ab868f8b3d57d42 100644 --- a/doc/user/project/clusters/runbooks/index.md +++ b/doc/user/project/clusters/runbooks/index.md @@ -35,7 +35,7 @@ for an overview of how this is accomplished in GitLab!** To create an executable runbook, you will need: 1. **Kubernetes** - A Kubernetes cluster is required to deploy the rest of the applications. - The simplest way to get started is to add a cluster using [GitLab's GKE integration](../index.md#add-new-gke-cluster). + The simplest way to get started is to add a cluster using one of [GitLab's integrations](../add_remove_clusters.md#add-new-cluster). 1. **Helm Tiller** - Helm is a package manager for Kubernetes and is required to install all the other applications. It is installed in its own pod inside the cluster which can run the Helm CLI in a safe environment. @@ -60,7 +60,7 @@ the components outlined above and the preloaded demo runbook. ### 1. Add a Kubernetes cluster -Follow the steps outlined in [Add new GKE cluster](../index.md#add-new-gke-cluster) +Follow the steps outlined in [Add new cluster](../add_remove_clusters.md#add-new-cluster) to add a Kubernetes cluster to your project. ### 2. Install Helm Tiller, Ingress, and JupyterHub diff --git a/doc/user/project/clusters/serverless/index.md b/doc/user/project/clusters/serverless/index.md index 9a9857bd5dacf8e85c809b2be6f0b8298140d3ed..ffd7b0c0f2aeb6b02ecb7212e1214983d1016557 100644 --- a/doc/user/project/clusters/serverless/index.md +++ b/doc/user/project/clusters/serverless/index.md @@ -13,14 +13,16 @@ GitLab supports several ways deploy Serverless applications in both Kubernetes E Currently we support: -- [Knative](#knative): Build Knative applications with Knative and gitlabktl on GKE -- [AWS Lambda](aws.md): Create serverless applications via the Serverless Framework and GitLab CI +- [Knative](#knative): Build Knative applications with Knative and gitlabktl on GKE. +- [AWS Lambda](aws.md): Create serverless applications via the Serverless Framework and GitLab CI. ## Knative Run serverless workloads on Kubernetes using [Knative](https://cloud.google.com/knative/). -Knative extends Kubernetes to provide a set of middleware components that are useful to build modern, source-centric, container-based applications. Knative brings some significant benefits out of the box through its main components: +Knative extends Kubernetes to provide a set of middleware components that are useful to build +modern, source-centric, container-based applications. Knative brings some significant benefits out +of the box through its main components: - [Serving](https://github.com/knative/serving): Request-driven compute that can scale to zero. - [Eventing](https://github.com/knative/eventing): Management and delivery of events. @@ -39,7 +41,7 @@ To run Knative on GitLab, you will need: - If you are planning on deploying a serverless application, 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](../index.md#add-new-gke-cluster). + The simplest way to get started is to add a cluster using [GitLab's GKE integration](../add_remove_clusters.md#gke-cluster). 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. @@ -62,20 +64,22 @@ To run Knative on GitLab, you will need: using our [runtimes](https://gitlab.com/gitlab-org/serverless/runtimes). 1. **Prometheus** (optional): Installing Prometheus allows you to monitor the scale and traffic of your serverless function/application. See [Installing Applications](../index.md#installing-applications) for more information. +1. **Logging** (optional): Configuring logging allows you to view and search request logs for your serverless function/application. + See [Configuring logging](#configuring-logging) for more information. ## Installing Knative via GitLab's Kubernetes integration NOTE: **Note:** The minimum recommended cluster size to run Knative is 3-nodes, 6 vCPUs, and 22.50 GB memory. **RBAC must be enabled.** -1. [Add a Kubernetes cluster](../index.md) and [install Helm](../index.md#installing-applications). +1. [Add a Kubernetes cluster](../add_remove_clusters.md) and [install Helm](../index.md#installing-applications). 1. Once Helm has been successfully installed, scroll down to the Knative app section. Enter the domain to be used with your application/functions (e.g. `example.com`) and click **Install**.  1. After the Knative installation has finished, you can wait for the IP address or hostname to be displayed in the - **Knative Endpoint** field or [retrieve the Istio Ingress Endpoint manually](../#manually-determining-the-external-endpoint). + **Knative Endpoint** field or [retrieve the Istio Ingress Endpoint manually](../../../clusters/applications.md#determining-the-external-endpoint-manually). NOTE: **Note:** Running `kubectl` commands on your cluster requires setting up access to the cluster first. @@ -108,7 +112,7 @@ You must do the following: 1. Follow the steps to [add an existing Kubernetes - cluster](../index.md#add-existing-kubernetes-cluster). + cluster](../add_remove_clusters.md#add-existing-cluster). 1. Ensure GitLab can manage Knative: - For a non-GitLab managed cluster, ensure that the service account for the token @@ -164,13 +168,61 @@ You must do the following: or [serverless applications](#deploying-serverless-applications) onto your cluster. -## Deploying functions +## Configuring logging -> Introduced in GitLab 11.6. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/33330) in GitLab 12.5. + +### Prerequisites + +- A GitLab-managed cluster. +- `kubectl` installed and working. + +Running `kubectl` commands on your cluster requires setting up access to the +cluster first. For clusters created on: + +- GKE, see [GKE Cluster Access](https://cloud.google.com/kubernetes-engine/docs/how-to/cluster-access-for-kubectl) +- Other platforms, see [Install and Set Up kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/). + +### Enable request log template + +Run the following command to enable request logs: + +```shell +kubectl edit cm -n knative-serving config-observability +``` + +Copy the `logging.request-log-template` from the `data._example` field to the data field one level up in the hierarchy. + +### Enable request logs + +Run the following commands to install Elasticsearch, Kibana, and Filebeat into a `kube-logging` namespace and configure all nodes to forward logs using Filebeat: -Using functions is useful for dealing with independent events without needing -to maintain a complex unified infrastructure. This allows you to focus on a -single task that can be executed/scaled automatically and independently. +```shell +kubectl apply -f https://gitlab.com/gitlab-org/serverless/configurations/knative/raw/v0.7.0/kube-logging-filebeat.yaml +kubectl label nodes --all beta.kubernetes.io/filebeat-ready="true" +``` + +### Viewing request logs + +To view request logs: + +1. Run `kubectl proxy`. +1. Navigate to Kibana UI. + +Or: + +1. Open the Kibana UI. +1. Click on **Discover**, then select `filebeat-*` from the dropdown on the left. +1. Enter `kubernetes.container.name:"queue-proxy" AND message:/httpRequest/` into the search box. + +## Supported runtimes + +Serverless functions for GitLab can be written in 6 supported languages: + +- NodeJS and Ruby, with GitLab-managed and OpenFaas runtimes. +- C#, Go, PHP, and Python with OpenFaaS runtimes only. + +### GitLab managed runtimes Currently the following [runtimes](https://gitlab.com/gitlab-org/serverless/runtimes) are offered: @@ -180,6 +232,31 @@ Currently the following [runtimes](https://gitlab.com/gitlab-org/serverless/runt `Dockerfile` presence is assumed when a runtime is not specified. +### OpenFaaS runtimes + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/29253) in GitLab 12.5. + +[OpenFaaS classic runtimes](https://github.com/openfaas/templates#templates-in-store) can be used with GitLab serverless. +Runtimes are specified using the pattern: `openfaas/classic/<template_name>`. The following +example shows how to define a function in `serverless.yml` using an OpenFaaS runtime: + +```yaml +hello: + source: ./hello + runtime: openfaas/classic/ruby + description: "Ruby function using OpenFaaS classic runtime" +``` + +`handler` is not needed for OpenFaaS functions. The location of the handler is defined +by the conventions of the runtime. + +See the [`ruby-openfaas-function`](https://gitlab.com/knative-examples/ruby-openfaas-function) +project for an example of a function using an OpenFaaS runtime. + +## Deploying functions + +> Introduced in GitLab 11.6. + You can find and import all the files referenced in this doc in the **[functions example project](https://gitlab.com/knative-examples/functions)**. @@ -311,10 +388,49 @@ The sample function can now be triggered from any HTTP client using a simple `PO  +### Running functions locally + +Running a function locally is a good way to quickly verify behavior during development. + +Running functions locally requires: + +- Go 1.12 or newer installed. +- Docker Engine installed and running. +- `gitlabktl` installed using the Go package manager: + + ```shell + GO111MODULE=on go get gitlab.com/gitlab-org/gitlabktl + ``` + +To run a function locally: + +1. Navigate to the root of your GitLab serverless project. +1. Build your function into a Docker image: + + ```shell + gitlabktl serverless build + ``` + +1. Run your function in Docker: + + ```shell + docker run -itp 8080:8080 <your_function_name> + ``` + +1. Invoke your function: + + ```shell + curl http://localhost:8080 + ``` + ## Deploying Serverless applications > Introduced in GitLab 11.5. +Serverless applications are the building block of serverless functions. They are useful in scenarios where an existing +runtime does not meet the needs of an application, such as one written in a language that has no runtime available. Note +though that serverless applications should be stateless! + NOTE: **Note:** You can reference and import the sample [Knative Ruby App](https://gitlab.com/knative-examples/knative-ruby-app) to get started. 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 old mode 100755 new mode 100644 diff --git a/doc/user/project/img/code_owners_approval_protected_branch_v12_4.png b/doc/user/project/img/code_owners_approval_protected_branch_v12_4.png old mode 100755 new mode 100644 diff --git a/doc/workflow/time_tracking/img/time_tracking_example_v12_2.png b/doc/user/project/img/time_tracking_example_v12_2.png similarity index 100% rename from doc/workflow/time_tracking/img/time_tracking_example_v12_2.png rename to doc/user/project/img/time_tracking_example_v12_2.png diff --git a/doc/workflow/time_tracking/img/time_tracking_sidebar_v8_16.png b/doc/user/project/img/time_tracking_sidebar_v8_16.png similarity index 100% rename from doc/workflow/time_tracking/img/time_tracking_sidebar_v8_16.png rename to doc/user/project/img/time_tracking_sidebar_v8_16.png diff --git a/doc/user/project/import/gitea.md b/doc/user/project/import/gitea.md index f883e4474e2708af4364175e19af19c603e4b7bb..94ab9d9195b44836dcd74a31adf8e42af5b4fa69 100644 --- a/doc/user/project/import/gitea.md +++ b/doc/user/project/import/gitea.md @@ -75,7 +75,5 @@ You also can:  ---- - You can also choose a different name for the project and a different namespace, if you have the privileges to do so. diff --git a/doc/user/project/import/github.md b/doc/user/project/import/github.md index 0aeca7f73ad6c08b41a016881a2c46b0484f35ec..9b98c52c4b8a844fcddff69d129352bab7a9ab81 100644 --- a/doc/user/project/import/github.md +++ b/doc/user/project/import/github.md @@ -125,7 +125,7 @@ your GitHub repositories are listed. ## Mirroring and pipeline status sharing -Depending your GitLab tier, [project mirroring](../../../workflow/repository_mirroring.md) can be set up to keep +Depending your GitLab tier, [project mirroring](../repository/repository_mirroring.md) can be set up to keep your imported project in sync with its GitHub copy. Additionally, you can configure GitLab to send pipeline status updates back GitHub with the diff --git a/doc/user/project/index.md b/doc/user/project/index.md index 7ae288996da4fa85945091180c1571b4e326ad0e..c173d3d3e11248e9970c5dc38a76d659215fc0df 100644 --- a/doc/user/project/index.md +++ b/doc/user/project/index.md @@ -26,6 +26,7 @@ When you create a project in GitLab, you'll have access to a large number of from messing with history or pushing code without review - [Protected tags](protected_tags.md): Control over who has permission to create tags, and prevent accidental update or deletion + - [Repository mirroring](repository/repository_mirroring.md) - [Signing commits](gpg_signed_commits/index.md): use GPG to sign your commits - [Deploy tokens](deploy_tokens/index.md): Manage project-based deploy tokens that allow permanent access to the repository and Container Registry. - [Web IDE](web_ide/index.md) @@ -44,7 +45,7 @@ When you create a project in GitLab, you'll have access to a large number of - [Review Apps](../../ci/review_apps/index.md): Live preview the results of the changes proposed in a merge request in a per-branch basis - [Labels](labels.md): Organize issues and merge requests by labels -- [Time Tracking](../../workflow/time_tracking.md): Track estimate time +- [Time Tracking](time_tracking.md): Track estimate time and time spent on the conclusion of an issue or merge request - [Milestones](milestones/index.md): Work towards a target date diff --git a/doc/user/project/integrations/generic_alerts.md b/doc/user/project/integrations/generic_alerts.md index ec43696fdeef4a04fcf128e88a6d8f0f2f47ede0..62310dd9177bb2f6473c6765c13aa772fe0c36ee 100644 --- a/doc/user/project/integrations/generic_alerts.md +++ b/doc/user/project/integrations/generic_alerts.md @@ -1,6 +1,6 @@ # Generic alerts integration **(ULTIMATE)** -> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/13203) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.3. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/13203) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.4. GitLab can accept alerts from any source via a generic webhook receiver. When you set up the generic alerts integration, a unique endpoint will @@ -16,7 +16,7 @@ authored by the GitLab Alert Bot. To set up the generic alerts integration: 1. Navigate to **Settings > Integrations** in a project. -1. Click on **Alert endpoint**. +1. Click on **Alerts endpoint**. 1. Toggle the **Active** alert setting. The `URL` and `Authorization Key` for the webhook configuration can be found there. ## Customizing the payload @@ -37,12 +37,12 @@ Example request: ```sh curl --request POST \ --data '{"title": "Incident title"}' \ - --header "Authorization: Bearer <autorization_key>" \ + --header "Authorization: Bearer <authorization_key>" \ --header "Content-Type: application/json" \ <url> ``` -The `<autorization_key>` and `<url>` values can be found when [setting up generic alerts](#setting-up-generic-alerts). +The `<authorization_key>` and `<url>` values can be found when [setting up generic alerts](#setting-up-generic-alerts). Example payload: diff --git a/doc/user/project/integrations/gitlab_slack_application.md b/doc/user/project/integrations/gitlab_slack_application.md index 50adb5993e5e29b30c44074a9907c493683c5e3a..c1e6f93de30aac87c71e21bcd9301aa5f34ea3fa 100644 --- a/doc/user/project/integrations/gitlab_slack_application.md +++ b/doc/user/project/integrations/gitlab_slack_application.md @@ -31,7 +31,7 @@ integration settings. Keep in mind that you need to have the appropriate permissions for your Slack team in order to be able to install a new application, read more in Slack's -docs on [Adding an app to your team][slack-docs]. +docs on [Adding an app to your team](https://slack.com/help/articles/202035138). To enable GitLab's service for your Slack team: @@ -60,6 +60,5 @@ project, you would do: /gitlab gitlab-org/gitlab issue show 1001 ``` -[slack-docs]: https://get.slack.help/hc/en-us/articles/202035138-Adding-apps-to-your-team [slash commands]: ../../../integration/slash_commands.md [slack-manual]: slack_slash_commands.md diff --git a/doc/user/project/integrations/img/embed_metrics_issue_template.png b/doc/user/project/integrations/img/embed_metrics_issue_template.png new file mode 100644 index 0000000000000000000000000000000000000000..3c6a243e5c1ca9de4eb4211489edeaa9d0528910 Binary files /dev/null and b/doc/user/project/integrations/img/embed_metrics_issue_template.png differ diff --git a/doc/user/project/integrations/img/grafana_panel_v12_5.png b/doc/user/project/integrations/img/grafana_panel_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..18c17b910cddec1c862350bb7a39ff7d59f61f8e Binary files /dev/null and b/doc/user/project/integrations/img/grafana_panel_v12_5.png differ diff --git a/doc/user/project/integrations/img/grafana_sharing_dialog_v12_5.png b/doc/user/project/integrations/img/grafana_sharing_dialog_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..fae62dd50dfdea9fff8545d84aaf2118c14d86ed Binary files /dev/null and b/doc/user/project/integrations/img/grafana_sharing_dialog_v12_5.png differ diff --git a/doc/user/project/integrations/img/heatmap_panel_type.png b/doc/user/project/integrations/img/heatmap_panel_type.png new file mode 100644 index 0000000000000000000000000000000000000000..a2b3911ec686ab0b7ff19b393e23b82d3fa51c7c Binary files /dev/null and b/doc/user/project/integrations/img/heatmap_panel_type.png differ diff --git a/doc/user/project/integrations/img/http_proxy_access_v12_5.png b/doc/user/project/integrations/img/http_proxy_access_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..0036a916a12d0838ba980f151fd1d7251de8c914 Binary files /dev/null and b/doc/user/project/integrations/img/http_proxy_access_v12_5.png differ diff --git a/doc/user/project/integrations/img/prometheus_dashboard_anomaly_panel_type.png b/doc/user/project/integrations/img/prometheus_dashboard_anomaly_panel_type.png new file mode 100644 index 0000000000000000000000000000000000000000..5cba6fa9038775466b635c7b3db82fa0b9534f56 Binary files /dev/null and b/doc/user/project/integrations/img/prometheus_dashboard_anomaly_panel_type.png differ diff --git a/doc/user/project/integrations/img/rendered_grafana_embed_v12_5.png b/doc/user/project/integrations/img/rendered_grafana_embed_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..6cabe4193bd9c73d87889583b37eea906a1e7842 Binary files /dev/null and b/doc/user/project/integrations/img/rendered_grafana_embed_v12_5.png differ diff --git a/doc/user/project/integrations/img/select_query_variables_v12_5.png b/doc/user/project/integrations/img/select_query_variables_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..23503577327bb4bb374b716682db12cfa480d65d Binary files /dev/null and b/doc/user/project/integrations/img/select_query_variables_v12_5.png differ diff --git a/doc/user/project/integrations/jira.md b/doc/user/project/integrations/jira.md index 6d2a0563ec1b3b01f6c285413894573851c5a5d2..874a1092b73546ab806229c6580200229a87c208 100644 --- a/doc/user/project/integrations/jira.md +++ b/doc/user/project/integrations/jira.md @@ -59,7 +59,7 @@ When connecting to **Jira Cloud**, which supports authentication via API token, > higher is required. > - GitLab 8.14 introduced a new way to integrate with Jira which greatly simplified > the configuration options you have to enter. If you are using an older version, -> [follow this documentation][jira-repo-old-docs]. +> [follow this documentation](https://gitlab.com/gitlab-org/gitlab/blob/8-13-stable-ee/doc/project_services/jira.md). > - In order to support Oracle's Access Manager, GitLab will send additional cookies > to enable Basic Auth. The cookie being added to each request is `OBBasicAuth` with > a value of `fromDialog`. @@ -205,4 +205,3 @@ authenticate with the Jira site. You will need to log in to your Jira instance and complete the CAPTCHA. [services-templates]: services_templates.md -[jira-repo-old-docs]: https://gitlab.com/gitlab-org/gitlab/blob/8-13-stable/doc/project_services/jira.md diff --git a/doc/user/project/integrations/project_services.md b/doc/user/project/integrations/project_services.md index e385ee536362bcdaada2d272ed9a58e323eb3a6c..315039f82b36414c1df6ab1d652cbc98d31efc4f 100644 --- a/doc/user/project/integrations/project_services.md +++ b/doc/user/project/integrations/project_services.md @@ -58,7 +58,7 @@ Click on the service links to see further configuration instructions and details ## Push hooks limit -> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/31009) in GitLab 12.4. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/17874) in GitLab 12.4. If a single push includes changes to more than three branches or tags, services supported by `push_hooks` and `tag_push_hooks` events won't be executed. diff --git a/doc/user/project/integrations/prometheus.md b/doc/user/project/integrations/prometheus.md index d7666d00e76cdc802880760b7f056423500d9a63..d3d4afefb59657c7f2e6381c60648785eb4ba95d 100644 --- a/doc/user/project/integrations/prometheus.md +++ b/doc/user/project/integrations/prometheus.md @@ -117,7 +117,7 @@ You can view the performance dashboard for an environment by [clicking on the mo Custom metrics can be monitored by adding them on the monitoring dashboard page. Once saved, they will be displayed on the environment performance dashboard provided that either: -- A [connected Kubernetes cluster](../clusters/index.md#adding-and-removing-clusters) with the environment scope of `*` is used and [Prometheus installed on the cluster](#enabling-prometheus-integration), or +- A [connected Kubernetes cluster](../clusters/add_remove_clusters.md) with the environment scope of `*` is used and [Prometheus installed on the cluster](#enabling-prometheus-integration) - Prometheus is [manually configured](#manual-configuration-of-prometheus).  @@ -139,7 +139,7 @@ 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 curly braces with a leading percent. For example: `%{ci_environment_slug}`. +To specify a variable in a query, enclose it in quotation marks with curly braces with a leading percent. For example: `"%{ci_environment_slug}"`. ### Defining custom dashboards per project @@ -211,11 +211,11 @@ The following tables outline the details of expected properties. | Property | Type | Required | Description | | ------ | ------ | ------ | ------- | -| `type` | enum | no, defaults to `area-chart` | Specifies the chart type to use, can be `area-chart` or `line-chart` | +| `type` | enum | no, defaults to `area-chart` | Specifies the chart type to use, can be: `area-chart`, `line-chart` or `anomaly-chart`. | | `title` | string | yes | Heading for the panel. | | `y_label` | string | no, but highly encouraged | Y-Axis label for the panel. | | `weight` | number | no, defaults to order in file | Order to appear within the grouping. Lower number means higher priority, which will be higher on the page. Numbers do not need to be consecutive. | -| `metrics` | array | yes | The metrics which should be displayed in the panel. | +| `metrics` | array | yes | The metrics which should be displayed in the panel. Any number of metrics can be displayed when `type` is `area-chart` or `line-chart`, whereas only 3 can be displayed when `type` is `anomaly-chart`. | **Metrics (`metrics`) properties:** @@ -231,20 +231,20 @@ The following tables outline the details of expected properties. The below panel types are supported in monitoring dashboards. -##### Area +##### Area or Line Chart -To add an area panel type to a dashboard, look at the following sample dashboard file: +To add an area chart panel type to a dashboard, look at the following sample dashboard file: ```yaml dashboard: 'Dashboard Title' panel_groups: - group: 'Group Title' panels: - - type: area-chart - title: "Chart Title" + - type: area-chart # or line-chart + title: 'Area Chart Title' y_label: "Y-Axis" metrics: - - id: 10 + - id: area_http_requests_total query_range: 'http_requests_total' label: "Metric of Ages" unit: "count" @@ -255,10 +255,52 @@ Note the following properties: | Property | Type | Required | Description | | ------ | ------ | ------ | ------ | | type | string | no | Type of panel to be rendered. Optional for area panel types | -| query_range | yes | required | For area panel types, you must use a [range query](https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries) | +| query_range | string | required | For area panel types, you must use a [range query](https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries) |  +##### Anomaly chart + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/16530) in GitLab 12.5. + +To add an anomaly chart panel type to a dashboard, add add a panel with *exactly* 3 metrics. + +The first metric represents the current state, and the second and third metrics represent the upper and lower limit respectively: + +```yaml +dashboard: 'Dashboard Title' +panel_groups: + - group: 'Group Title' + panels: + - type: anomaly-chart + title: "Chart Title" + y_label: "Y-Axis" + metrics: + - id: anomaly_requests_normal + query_range: 'http_requests_total' + label: "# of Requests" + unit: "count" + metrics: + - id: anomaly_requests_upper_limit + query_range: 10000 + label: "Max # of requests" + unit: "count" + metrics: + - id: anomaly_requests_lower_limit + query_range: 2000 + label: "Min # of requests" + unit: "count" +``` + +Note the following properties: + +| Property | Type | Required | Description | +| ------ | ------ | ------ | ------ | +| type | string | required | Must be `anomaly-chart` for anomaly panel types | +| query_range | yes | required | For anomaly panel types, you must use a [range query](https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries) in every metric. | + + + ##### Single Stat To add a single stat panel type to a dashboard, look at the following sample dashboard file: @@ -286,6 +328,42 @@ Note the following properties:  +##### Heatmaps + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/30581) in GitLab 12.5. + +To add a heatmap panel type to a dashboard, look at the following sample dashboard file: + +```yaml +dashboard: 'Dashboard Title' +panel_groups: + - group: 'Group Title' + panels: + - title: "Heatmap" + type: "heatmap" + metrics: + - id: 10 + query: 'sum(rate(nginx_upstream_responses_total{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[60m])) by (status_code)' + unit: req/sec + label: "Status code" +``` + +Note the following properties: + +| Property | Type | Required | Description | +| ------ | ------ | ------ | ------ | +| type | string | yes | Type of panel to be rendered. For heatmap panel types, set to `heatmap` | +| query_range | yes | yes | For area panel types, you must use a [range query](https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries) | + + + +### View and edit the source file of a custom dashboard + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/34779) in GitLab 12.5. + +When viewing a custom dashboard of a project, you can view the original +`.yml` file by clicking on **Edit dashboard** button. + ### Downloading data as CSV Data from Prometheus charts on the metrics dashboard can be downloaded as CSV. @@ -332,9 +410,12 @@ receivers: ... ``` +In order for GitLab to associate your alerts with an [environment](../../../ci/environments.md), you need to configure a `gitlab_environment_name` label on the alerts you set up in Prometheus. The value of this should match the name of your Environment in GitLab. + ### Taking action on incidents **(ULTIMATE)** -> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/4925) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.11. +>- [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/4925) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.11. +>- [From GitLab Ultimate 12.5](https://gitlab.com/gitlab-org/gitlab/issues/13401), when GitLab receives a recovery alert, it will automatically close the associated issue. Alerts can be used to trigger actions, like open an issue automatically (enabled by default since `12.1`). To configure the actions: @@ -355,6 +436,8 @@ Once enabled, an issue will be opened automatically when an alert is triggered w - Optional list of attached annotations extracted from `annotations/*` - Alert [GFM](../../markdown.md): GitLab Flavored Markdown from `annotations/gitlab_incident_markdown` +When GitLab receives a **Recovery Alert**, it will automatically close the associated issue. This action will be recorded as a system message on the issue indicated that it was closed automatically by the GitLab Alert bot. + To further customize the issue, you can add labels, mentions, or any other supported [quick action](../quick_actions.md) in the selected issue template, which will apply to all incidents. To limit quick actions or other information to only specific types of alerts, use the `annotations/gitlab_incident_markdown` field. Since [version 12.2](https://gitlab.com/gitlab-org/gitlab-foss/issues/63373), GitLab will tag each incident issue with the `incident` label automatically. If the label does not yet exist, it will be created automatically as well. @@ -389,6 +472,8 @@ Prometheus server. ## Embedding metric charts within GitLab Flavored Markdown +### Embedding GitLab-managed Kubernetes metrics + > [Introduced][ce-29691] in GitLab 12.2. It is possible to display metrics charts within [GitLab Flavored Markdown](../../markdown.md#gitlab-flavored-markdown-gfm). @@ -414,9 +499,19 @@ The following requirements must be met for the metric to unfurl:  -### Embedding live Grafana charts +### Embedding metrics in issue templates + +It is also possible to embed either the default dashboard metrics or individual metrics in issue templates. For charts to render side-by-side, links to the entire metrics dashboard or individual metrics should be separated by either a comma or a space. + + + +### Embedding Grafana charts -It is also possible to embed live [Grafana](https://docs.gitlab.com/omnibus/settings/grafana.html) charts within issues, as a [Direct Linked Rendered Image](https://grafana.com/docs/reference/sharing/#direct-link-rendered-image). +Grafana metrics can be embedded in [GitLab Flavored Markdown](../../markdown.md). + +#### Embedding charts via Grafana Rendered Images + +It is possible to embed live [Grafana](https://docs.gitlab.com/omnibus/settings/grafana.html) charts in issues, as a [direct linked rendered image](https://grafana.com/docs/reference/sharing/#direct-link-rendered-image). The sharing dialog within Grafana provides the link, as highlighted below. @@ -435,6 +530,41 @@ This will render like so: <img src="https://dashboards.gitlab.com/render/d-solo/RZmbBr7mk/gitlab-triage?orgId=1&refresh=30s&var-env=gprd&var-environment=gprd&var-prometheus=prometheus-01-inf-gprd&var-prometheus_app=prometheus-app-01-inf-gprd&var-backend=All&var-type=All&var-stage=main&panelId=1247&width=1000&height=300"/> +#### Embedding charts via integration with Grafana HTTP API + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/31376) in GitLab 12.5. + +Each project can support integration with one Grafana instance. This configuration allows a user to copy a link to a panel in Grafana, then paste it into a GitLab markdown field. The chart will be rendered in the GitLab chart format. + +Prerequisites for embedding from a Grafana instance: + +1. The datasource must be a Prometheus instance. +1. The datasource must be proxyable, so the HTTP Access setting should be set to `Server`. + + + +##### Setting up the Grafana integration + +1. [Generate an Admin-level API Token in Grafana.](https://grafana.com/docs/http_api/auth/#create-api-token) +1. In your GitLab project, navigate to **Settings > Operations > Grafana Authentication**. +1. To enable the integration, check the "Active" checkbox. +1. For "Grafana URL", enter the base URL of the Grafana instance. +1. For "API Token", enter the Admin API Token you just generated. +1. Click **Save Changes**. + +##### Generating a link to a chart + +1. In Grafana, navigate to the dashboard you wish to embed a panel from. +  +1. In the upper-left corner of the page, select a specific value for each variable required for the queries in the chart. +  +1. In Grafana, click on a panel's title, then click **Share** to open the panel's sharing dialog to the **Link** tab. +1. If your Prometheus queries use Grafana's custom template variables, ensure that "Template variables" and "Current time range" options are toggled to **On**. Of Grafana global template variables, only `$__interval`, `$__from`, and `$__to` are currently supported. +  +1. Click **Copy** to copy the URL to the clipboard. +1. In GitLab, paste the URL into a markdown field and save. The chart will take a few moments to render. +  + ## Troubleshooting If the "No data found" screen continues to appear, it could be due to: diff --git a/doc/user/project/integrations/prometheus_library/nginx_ingress.md b/doc/user/project/integrations/prometheus_library/nginx_ingress.md index d630956c109d0622094caae41051f2dbe621a2a3..93f6dbb030238d19eac5610237fa7a5bda4a6962 100644 --- a/doc/user/project/integrations/prometheus_library/nginx_ingress.md +++ b/doc/user/project/integrations/prometheus_library/nginx_ingress.md @@ -30,7 +30,7 @@ For other deployments, there is [some configuration](#manually-setting-up-nginx- ### About managed NGINX Ingress deployments -NGINX Ingress is deployed into the `gitlab-managed-apps` namespace, using the [official Helm chart](https://github.com/helm/charts/tree/master/stable/nginx-ingress). NGINX Ingress will be [externally reachable via the Load Balancer's Endpoint](../../clusters/index.md#getting-the-external-endpoint). +NGINX Ingress is deployed into the `gitlab-managed-apps` namespace, using the [official Helm chart](https://github.com/helm/charts/tree/master/stable/nginx-ingress). NGINX Ingress will be [externally reachable via the Load Balancer's Endpoint](../../../clusters/applications.md#ingress). NGINX is configured for Prometheus monitoring, by setting: diff --git a/doc/user/project/integrations/prometheus_library/nginx_ingress_vts.md b/doc/user/project/integrations/prometheus_library/nginx_ingress_vts.md index 83eac44666cdbfd0fbf4ac27ab22dee1d75d9b8c..a1dcb10519613af675cd559d07dfe9d6371e2eaf 100644 --- a/doc/user/project/integrations/prometheus_library/nginx_ingress_vts.md +++ b/doc/user/project/integrations/prometheus_library/nginx_ingress_vts.md @@ -30,7 +30,7 @@ For other deployments, there is [some configuration](#manually-setting-up-nginx- ### About managed NGINX Ingress deployments -NGINX Ingress is deployed into the `gitlab-managed-apps` namespace, using the [official Helm chart](https://github.com/helm/charts/tree/master/stable/nginx-ingress). NGINX Ingress will be [externally reachable via the Load Balancer's Endpoint](../../clusters/index.md#getting-the-external-endpoint). +NGINX Ingress is deployed into the `gitlab-managed-apps` namespace, using the [official Helm chart](https://github.com/helm/charts/tree/master/stable/nginx-ingress). NGINX Ingress will be [externally reachable via the Load Balancer's Endpoint](../../../clusters/applications.md#ingress). NGINX is configured for Prometheus monitoring, by setting: diff --git a/doc/user/project/issue_board.md b/doc/user/project/issue_board.md index 103d50a94e8e74d79781ca8cc6a1e4392424173e..403972941b2907a61c7a55e4bfe57e5c3f23bc9d 100644 --- a/doc/user/project/issue_board.md +++ b/doc/user/project/issue_board.md @@ -287,7 +287,7 @@ Different issue board features are available in different [GitLab tiers](https:/ | Tier | Number of Project Issue Boards | Number of Group Issue Boards | Configurable Issue Boards | Assignee Lists | |----------|--------------------------------|------------------------------|---------------------------|----------------| -| Core / Free | 1 | 1 | No | No | +| Core / Free | Multiple | 1 | No | No | | Starter / Bronze | Multiple | 1 | Yes | No | | Premium / Silver | Multiple | Multiple | Yes | Yes | | Ultimate / Gold | Multiple | Multiple | Yes | Yes | diff --git a/doc/user/project/issues/associate_zoom_meeting.md b/doc/user/project/issues/associate_zoom_meeting.md new file mode 100644 index 0000000000000000000000000000000000000000..24775204c9f01c583e327244bc9b1579180921af --- /dev/null +++ b/doc/user/project/issues/associate_zoom_meeting.md @@ -0,0 +1,42 @@ +# Associate a Zoom meeting with an issue + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/16609) in GitLab 12.4. + +In order to communicate synchronously for incidents management, +GitLab allows to associate a Zoom meeting with an issue. +Once you start a Zoom call for a fire-fight, you need a way to +associate the conference call with an issue, so that your team +members can join swiftly without requesting a link. + +## Adding a zoom meeting to an issue + +To associate a zoom meeting with an issue, you can use GitLab's +[quick actions](../quick_actions.md#quick-actions-for-issues-merge-requests-and-epics). + +In an issue, leave a comment using the `/zoom` quick action followed by a valid Zoom link: + +```sh +/zoom https://zoom.us/j/123456789 +``` + +If the Zoom meeting URL is valid and you have at least [Reporter permissions](../../permissions.md), +a system alert will notify you that the addition of the meeting URL was successful. +The issue's description will be automatically edited to include the Zoom link, and a button will +appear right under the issue's title. + + + +You are only allowed to attach a single Zoom meeting to an issue. If you attempt +to add a second Zoom meeting using the `/zoom` quick action, it won't work, you +need to [remove it](#removing-an-existing-zoom-meeting-from-an-issue) first. + +## Removing an existing Zoom meeting from an issue + +Similarly to adding a zoom meeting, you can remove it with a quick action: + +```sh +/remove_zoom +``` + +If you have at least [Reporter permissions](../../permissions.md), +a system alert will notify you that the meeting URL was successfully removed. diff --git a/doc/user/project/issues/csv_export.md b/doc/user/project/issues/csv_export.md index fb7fdde7b940765a7940084c9bea45d6716be795..b97bcd47f61cebf8c5aa9c5899ec898591ebfde7 100644 --- a/doc/user/project/issues/csv_export.md +++ b/doc/user/project/issues/csv_export.md @@ -67,8 +67,8 @@ Data will be encoded with a comma as the column delimiter, with `"` used to quot | Milestone | Title of the issue milestone | | Weight | Issue weight | | Labels | Title of any labels joined with a `,` | -| Time Estimate | [Time estimate](../../../workflow/time_tracking.md#estimates) in seconds | -| Time Spent | [Time spent](../../../workflow/time_tracking.md#time-spent) in seconds | +| Time Estimate | [Time estimate](../time_tracking.md#estimates) in seconds | +| Time Spent | [Time spent](../time_tracking.md#time-spent) in seconds | ## Limitations diff --git a/doc/user/project/issues/design_management.md b/doc/user/project/issues/design_management.md index 169da7049a641581484383a5b8ec2b9d5f3fffa5..594f73dbfbedfd085310e5ad895777fd51040bb7 100644 --- a/doc/user/project/issues/design_management.md +++ b/doc/user/project/issues/design_management.md @@ -22,24 +22,26 @@ For an overview, see the video [Design Management (GitLab 12.2)](https://www.you ## Requirements Design Management requires -[Large File Storage (LFS)](../../../workflow/lfs/manage_large_binaries_with_git_lfs.md) +[Large File Storage (LFS)](../../../administration/lfs/manage_large_binaries_with_git_lfs.md) to be enabled: - For GitLab.com, LFS is already enabled. - For self-managed instances, a GitLab administrator must have - [enabled LFS globally](../../../workflow/lfs/lfs_administration.md). + [enabled LFS globally](../../../administration/lfs/lfs_administration.md). - For both GitLab.com and self-managed instances: LFS must be enabled for the project itself. If enabled globally, LFS will be enabled by default to all projects. To enable LFS on the project level, navigate to your project's **Settings > General**, expand **Visibility, project features, permissions** and enable **Git Large File Storage**. +Design Management requires that projects are using +[hashed storage](../../../administration/repository_storage_types.html#hashed-storage) +(the default storage type since v10.0). + ## Limitations - Files uploaded must have a file extension of either `png`, `jpg`, `jpeg`, `gif`, `bmp`, `tiff` or `ico`. The [`svg` extension is not yet supported](https://gitlab.com/gitlab-org/gitlab/issues/12771). - Design uploads are limited to 10 files at a time. -- Design Management is - [not yet supported in the project export](https://gitlab.com/gitlab-org/gitlab/issues/11090). - Design Management data [isn't deleted when a project is destroyed](https://gitlab.com/gitlab-org/gitlab/issues/13429) yet. - Design Management data [won't be moved](https://gitlab.com/gitlab-org/gitlab/issues/13426) @@ -112,12 +114,16 @@ viewed by browsing previous versions. ## Adding annotations to designs -When a design image is displayed, you can add annotations to it by clicking on -the image. A badge is added to the image and a form is displayed to start a new -discussion. For example: +When a design is uploaded, you can add annotations by clicking on +the image on the exact location you'd like to add the note to. +A badge is added to the image identifying the annotation, from +which you can start a new discussion:  -When submitted, the form saves a badge linked to the discussion on the image. Different discussions have different badge numbers. For example: +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. diff --git a/doc/user/project/issues/due_dates.md b/doc/user/project/issues/due_dates.md index 240859651e28e9649fd1acda7038b2e0022c14e6..b19d5dc1650b97421f2a9e86112892dc83bfbd36 100644 --- a/doc/user/project/issues/due_dates.md +++ b/doc/user/project/issues/due_dates.md @@ -33,7 +33,7 @@ the icon and the date colored red. You can sort issues by those that are  -Due dates also appear in your [todos list](../../../workflow/todos.md). +Due dates also appear in your [todos list](../../todos.md).  diff --git a/doc/workflow/issue_weight/issue.png b/doc/user/project/issues/img/issue_weight.png similarity index 100% rename from doc/workflow/issue_weight/issue.png rename to doc/user/project/issues/img/issue_weight.png diff --git a/doc/user/project/issues/img/select_all_designs_v12_4.png b/doc/user/project/issues/img/select_all_designs_v12_4.png deleted file mode 100644 index b08b04c1214a4a8e5beaea07e3ff0624a5a3633b..0000000000000000000000000000000000000000 Binary files a/doc/user/project/issues/img/select_all_designs_v12_4.png and /dev/null differ diff --git a/doc/user/project/issues/img/zoom-quickaction-button.png b/doc/user/project/issues/img/zoom-quickaction-button.png index d6d691b22671ef4d4f91d45dd14abe5e91daa7dc..c95a56b43e8bf935e34e3d9dc5ff64043dc0e718 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/issue_data_and_actions.md b/doc/user/project/issues/issue_data_and_actions.md index 01f4eb5b91243e689c4395505f7e291fd3039250..92da4235afa4f4b718bab93b7ee8f0245c4287eb 100644 --- a/doc/user/project/issues/issue_data_and_actions.md +++ b/doc/user/project/issues/issue_data_and_actions.md @@ -41,7 +41,7 @@ after it is closed. #### 2. To Do -You can add issues to and remove issues from your [GitLab To-Do List](../../../workflow/todos.md). +You can add issues to and remove issues from your [GitLab To-Do List](../../todos.md). The button to do this has a different label depending on whether the issue is already on your To-Do List or not. If the issue is: @@ -83,9 +83,9 @@ Select a [milestone](../milestones/index.md) to attribute that issue to. #### 6. Time Tracking -Use [GitLab Quick Actions](../quick_actions.md) to [track estimates and time spent on issues](../../../workflow/time_tracking.md). -You can add an [estimate of the time it will take](../../../workflow/time_tracking.md#estimates) -to resolve the issue, and also add [the time spent](../../../workflow/time_tracking.md#time-spent) +Use [GitLab Quick Actions](../quick_actions.md) to [track estimates and time spent on issues](../time_tracking.md). +You can add an [estimate of the time it will take](../time_tracking.md#estimates) +to resolve the issue, and also add [the time spent](../time_tracking.md#time-spent) on the resolution of the issue. #### 7. Due date @@ -109,7 +109,7 @@ from which you can select **Create new label**. #### 9. Weight **(STARTER)** -[Assign a weight](../../../workflow/issue_weight.md) to an issue. +[Assign a weight](issue_weight.md) to an issue. Larger values are used to indicate more effort is required to complete the issue. Only positive values or zero are allowed. @@ -131,7 +131,7 @@ or were mentioned in the description or threads. #### 13. Notifications -Click on the icon to enable/disable [notifications](../../../workflow/notifications.md#issue--epics--merge-request-events) +Click on the icon to enable/disable [notifications](../../profile/notifications.md#issue--epics--merge-request-events) for the issue. This will automatically enable if you participate in the issue in any way. - **Enable**: If you are not a participant in the discussion on that issue, but @@ -162,7 +162,7 @@ allowing many formatting options. You can mention a user or a group present in your GitLab instance with `@username` or `@groupname` and they will be notified via todos and email, unless they have disabled all notifications in their profile settings. This is controlled in the -[notification settings](../../../workflow/notifications.md). +[notification settings](../../profile/notifications.md). Mentions for yourself (the current logged in user), will be highlighted in a different color, allowing you to easily see which comments involve you, helping you focus on @@ -257,4 +257,4 @@ You can attach and remove Zoom meetings to issues using the `/zoom` and `/remove Attaching a [Zoom](https://zoom.us) call an issue results in a **Join Zoom meeting** button at the top of the issue, just under the header. - +Read more how to [add or remove a zoom meeting](associate_zoom_meeting.md). diff --git a/doc/user/project/issues/issue_weight.md b/doc/user/project/issues/issue_weight.md new file mode 100644 index 0000000000000000000000000000000000000000..4b8d2318e9bdfe02febd64f773391b2791c14e8d --- /dev/null +++ b/doc/user/project/issues/issue_weight.md @@ -0,0 +1,25 @@ +--- +disqus_identifier: 'https://docs.gitlab.com/ee/workflow/issue_weight.html' +--- + +# Issue weight **(STARTER)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/76) in [GitLab Starter](https://about.gitlab.com/pricing/) 8.3. + +When you have a lot of issues, it can be hard to get an overview. +By adding a weight to each issue, you can get a better idea of how much time, +value or complexity a given issue has or will cost. + +You can set the weight of an issue during its creation, by simply changing the +value in the dropdown menu. You can set it to a non-negative integer +value from 0, 1, 2, and so on. (The database stores a 4-byte value, so the +upper bound is essentially limitless). +You can remove weight from an issue +as well. + +This value will appear on the right sidebar of an individual issue, as well as +in the issues page next to a distinctive balance scale icon. + +As an added bonus, you can see the total sum of all issues on the milestone page. + + diff --git a/doc/user/project/labels.md b/doc/user/project/labels.md index cfd6d4eaf4b31a26ab2c1134212c6b2c734c0cbd..d8356abdd1cbb985bff66bd1ad722241f9707c4c 100644 --- a/doc/user/project/labels.md +++ b/doc/user/project/labels.md @@ -210,8 +210,8 @@ The following can be filtered by labels: ## Subscribing to labels From the project label list page and the group label list page, you can subscribe -to [notifications](../../workflow/notifications.md) of a given label, to alert you -that the label has been assigned to an epic, issue, and merge request. +to [notifications](../profile/notifications.md) of a given label, to alert you +that the label has been assigned to an epic, issue, or merge request.  diff --git a/doc/user/project/merge_requests/code_quality.md b/doc/user/project/merge_requests/code_quality.md index 92681e741de8d056885107acff1a5317e7c4f3da..69bdfe10e3ffd6f5f55f5f2258bed0f22898b7fb 100644 --- a/doc/user/project/merge_requests/code_quality.md +++ b/doc/user/project/merge_requests/code_quality.md @@ -66,6 +66,18 @@ 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. +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: + +```yaml +include: + - template: Code-Quality.gitlab-ci.yml + +code_quality: + artifacts: + paths: [gl-code-quality-report.json] +``` + The included `code_quality` job is running in the `test` stage, so it needs to be included in your CI config, like so: ```yaml @@ -91,7 +103,7 @@ old job definitions are still maintained they have been deprecated and may be re in the next major release, GitLab 12.0. You are advised to update your current `.gitlab-ci.yml` configuration to reflect that change. -For GitLab 11.5 and earlier, the job should look like: +For GitLab 11.5 and later, the job should look like: ```yaml code_quality: diff --git a/doc/user/project/merge_requests/creating_merge_requests.md b/doc/user/project/merge_requests/creating_merge_requests.md new file mode 100644 index 0000000000000000000000000000000000000000..084ebf32a927e68788d3615f2ef0a3e80680eb3f --- /dev/null +++ b/doc/user/project/merge_requests/creating_merge_requests.md @@ -0,0 +1,156 @@ +--- +type: index, reference +--- + +# Creating merge requests + +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. + +## Creating new merge requests + +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. + +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. + +If you have recently pushed changes to GitLab, the **Create merge request** button will +also appear 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. + +## Workflow for new merge requests + +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. + +From here, you can also: + +- 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). + +Many of these can be set when pushing changes from the command line, with +[Git push options](../push_options.md). + +### 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 will set 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. + +## 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's 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). + +## Deleting the source branch + +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). + +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. + +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. + + + +## Create new merge requests by email + +_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._ + +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 +a **Email a new merge request to this project** button. The subject will be +used as the source branch name for the new merge request and the target branch +will be the default branch for the project. The message body (if not empty) +will be used as the merge request description. You need +["Reply by email"](../../../administration/reply_by_email.md) enabled to use +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. +You can add this address to your contact list for easy access. + + + +_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._ + +### Adding patches when creating a merge request via e-mail + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/22723) in GitLab 11.5. + +You can add commits to the merge request being created by adding +patches as attachments to the email. All attachments with a filename +ending in `.patch` will be considered patches and they will be processed +ordered by name. + +The combined size of the patches can be 2MB. + +If the source branch from the subject does not exist, it will be +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. diff --git a/doc/user/project/merge_requests/img/approvals_premium_project_edit_v12_3.png b/doc/user/project/merge_requests/img/approvals_premium_project_edit_v12_3.png deleted file mode 100644 index bbb131e86e9e8773cf1874386a6de37c728a069e..0000000000000000000000000000000000000000 Binary files a/doc/user/project/merge_requests/img/approvals_premium_project_edit_v12_3.png and /dev/null differ diff --git a/doc/user/project/merge_requests/img/approvals_premium_project_edit_v12_5.png b/doc/user/project/merge_requests/img/approvals_premium_project_edit_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..24c8c8f8c11eb4d78d166d62763bf876fc9a2eb6 Binary files /dev/null and b/doc/user/project/merge_requests/img/approvals_premium_project_edit_v12_5.png differ diff --git a/doc/user/project/merge_requests/img/mr_approvals_by_code_owners_v12_4.png b/doc/user/project/merge_requests/img/mr_approvals_by_code_owners_v12_4.png old mode 100755 new mode 100644 diff --git a/doc/user/project/merge_requests/index.md b/doc/user/project/merge_requests/index.md index 2ab7c3fb15bb27452ea86af56db1b5def9a5d2c8..1ca8c882ac70fb90c8a89d232d41bd0b95fa1b1d 100644 --- a/doc/user/project/merge_requests/index.md +++ b/doc/user/project/merge_requests/index.md @@ -1,5 +1,5 @@ --- -type: index, reference, concepts +type: index, reference --- # Merge requests @@ -9,45 +9,9 @@ to source code that exist as commits on a given Git branch.  -## Overview - -A Merge Request (**MR**) is the basis of GitLab as a code collaboration -and version control platform. -It is as simple as the name implies: a _request_ to _merge_ one branch into another. - -With GitLab merge requests, you can: - -- Compare the changes between two [branches](https://git-scm.com/book/en/v2/Git-Branching-Branches-in-a-Nutshell#_git_branching) -- [Review and discuss](../../discussions/index.md#threads) the proposed modifications inline -- Live preview the changes when [Review Apps](../../../ci/review_apps/index.md) is configured for your project -- Build, test, and deploy your code in a per-branch basis with built-in [GitLab CI/CD](../../../ci/README.md) -- Prevent the merge request from being merged before it's ready with [WIP MRs](#work-in-progress-merge-requests) -- View the deployment process through [Pipeline Graphs](../../../ci/pipelines.md#visualizing-pipelines) -- [Automatically close the issue(s)](../../project/issues/managing_issues.md#closing-issues-automatically) that originated the implementation proposed in the merge request -- Assign it to any registered user, and change the assignee how many times you need -- Assign a [milestone](../../project/milestones/index.md) and track the development of a broader implementation -- Organize your issues and merge requests consistently throughout the project with [labels](../../project/labels.md) -- Add a time estimation and the time spent with that merge request with [Time Tracking](../../../workflow/time_tracking.md#time-tracking) -- [Resolve merge conflicts from the UI](#resolve-conflicts) -- Enable [fast-forward merge requests](#fast-forward-merge-requests) -- Enable [semi-linear history merge requests](#semi-linear-history-merge-requests) as another security layer to guarantee the pipeline is passing in the target branch -- [Create new merge requests by email](#create-new-merge-requests-by-email) -- [Allow collaboration](allow_collaboration.md) so members of the target project can push directly to the fork -- [Squash and merge](squash_and_merge.md) for a cleaner commit history - -With **[GitLab Enterprise Edition][ee]**, you can also: - -- Prepare a full review and submit it once it's ready with [Merge Request Reviews](../../discussions/index.md#merge-request-reviews-premium) **(PREMIUM)** -- View the deployment process across projects with [Multi-Project Pipelines](../../../ci/multi_project_pipelines.md) **(PREMIUM)** -- Request [approvals](merge_request_approvals.md) from your managers **(STARTER)** -- Analyze the impact of your changes with [Code Quality reports](code_quality.md) **(STARTER)** -- Manage the licenses of your dependencies with [License Compliance](../../application_security/license_compliance/index.md) **(ULTIMATE)** -- Analyze your source code for vulnerabilities with [Static Application Security Testing](../../application_security/sast/index.md) **(ULTIMATE)** -- Analyze your running web applications for vulnerabilities with [Dynamic Application Security Testing](../../application_security/dast/index.md) **(ULTIMATE)** -- Analyze your dependencies for vulnerabilities with [Dependency Scanning](../../application_security/dependency_scanning/index.md) **(ULTIMATE)** -- Analyze your Docker images for vulnerabilities with [Container Scanning](../../application_security/container_scanning/index.md) **(ULTIMATE)** -- Determine the performance impact of changes with [Browser Performance Testing](#browser-performance-testing-premium) **(PREMIUM)** -- Specify merge order dependencies with [Merge Request Dependencies](#merge-request-dependencies-premium) **(PREMIUM)** +A Merge Request (**MR**) is the basis of GitLab as a code collaboration and version +control platform. It is as simple as the name implies: a _request_ to _merge_ one +branch into another. ## Use cases @@ -58,8 +22,11 @@ A. Consider you are a software developer working in a team: 1. You work on the implementation optimizing code with [Code Quality reports](code_quality.md) **(STARTER)** 1. You verify your changes with [JUnit test reports](../../../ci/junit_test_reports.md) in GitLab CI/CD 1. You avoid using dependencies whose license is not compatible with your project with [License Compliance reports](../../application_security/license_compliance/index.md) **(ULTIMATE)** -1. You request the [approval](#merge-request-approvals-starter) from your manager -1. Your manager pushes a commit with their final review, [approves the merge request](merge_request_approvals.md), and set it to [merge when pipeline succeeds](#merge-when-pipeline-succeeds) (Merge Request Approvals are available in GitLab Starter) +1. You request the [approval](merge_request_approvals.md) from your manager **(STARTER)** +1. Your manager: + 1. Pushes a commit with their final review + 1. [Approves the merge request](merge_request_approvals.md) **(STARTER)** + 1. Sets it to [merge when pipeline succeeds](merge_when_pipeline_succeeds.md) 1. Your changes get deployed to production with [manual actions](../../../ci/yaml/README.md#whenmanual) for GitLab CI/CD 1. Your implementations were successfully shipped to your customer @@ -71,547 +38,112 @@ B. Consider you're a web developer writing a webpage for your company's website: 1. You request your web designers for their implementation 1. You request the [approval](merge_request_approvals.md) from your manager **(STARTER)** 1. Once approved, your merge request is [squashed and merged](squash_and_merge.md), and [deployed to staging with GitLab Pages](https://about.gitlab.com/blog/2016/08/26/ci-deployment-and-environments/) -1. Your production team [cherry picks](#cherry-pick-changes) the merge commit into production - -## Merge requests per project - -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). - - - -## Merge requests per group - -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. - - - -## Deleting the source branch - -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. - -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. - -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. - - - -## Allow collaboration on merge requests across forks - -When a user opens a merge request from a fork, they are given the option to allow -upstream maintainers to collaborate with them on the source branch. This allows -the maintainers of the upstream project to make small fixes or rebase branches -before merging, reducing the back and forth of accepting community contributions. - -[Learn more about allowing upstream members to push to forks.](allow_collaboration.md) +1. Your production team [cherry picks](cherry_pick_changes.md) the merge commit into production + +## 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 | + +## 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. | + +## Testing and reports in merge requests + +GitLab has the ability to test the changes included in a merge request, and can display +or link to useful information directly in the merge request page: + +| Feature | Description | +|--------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [Browser Performance Testing](browser_performance_testing.md) **(PREMIUM)** | Quickly determine the performance impact of pending code changes. | +| [Code Quality](code_quality.md) **(STARTER)** | Analyze your source code quality using the [Code Climate](https://codeclimate.com/) analyzer and show the Code Climate report right in the merge request widget area. | +| [Display arbitrary job artifacts](../../../ci/yaml/README.md#artifactsexpose_as) | Configure CI pipelines with the `artifacts:expose_as` parameter to directly link to selected [artifacts](../pipelines/job_artifacts.md) in merge requests. | +| [GitLab CI/CD](../../../ci/README.md) | Build, test, and deploy your code in a per-branch basis with built-in CI/CD. | +| [JUnit test reports](../../../ci/junit_test_reports.md) | Configure your CI jobs to use JUnit test reports, and let GitLab display a report on the merge request so that it’s easier and faster to identify the failure without having to check the entire job log. | +| [Metrics Reports](../../../ci/metrics_reports.md) **(PREMIUM)** | Display the Metrics Report on the merge request so that it's fast and easy to identify changes to important metrics. | +| [Multi-Project pipelines](../../../ci/multi_project_pipelines.md) **(PREMIUM)** | When you set up GitLab CI/CD across multiple projects, you can visualize the entire pipeline, including all cross-project interdependencies. | +| [Pipelines for merge requests](../../../ci/merge_request_pipelines/index.md) | Customize a specific pipeline structure for merge requests in order to speed the cycle up by running only important jobs. | +| [Pipeline Graphs](../../../ci/pipelines.md#visualizing-pipelines) | View the status of pipelines within the merge request, including the deployment process. | + +### Security Reports **(ULTIMATE)** + +In addition to the reports listed above, GitLab can do many types of [Security reports](../../application_security/index.md), +generated by scanning and reporting any vulnerabilities found in your project: + +| Feature | Description | +|-----------------------------------------------------------------------------------------|------------------------------------------------------------------| +| [Container Scanning](../../application_security/container_scanning/index.md) | Analyze your Docker images for known vulnerabilities. | +| [Dynamic Application Security Testing (DAST)](../../application_security/dast/index.md) | Analyze your running web applications for known vulnerabilities. | +| [Dependency Scanning](../../application_security/dependency_scanning/index.md) | Analyze your dependencies for known vulnerabilities. | +| [License Compliance](../../application_security/license_compliance/index.md) | Manage the licenses of your dependencies. | +| [Static Application Security Testing (SAST)](../../application_security/sast/index.md) | Analyze your source code for known vulnerabilities. | ## Authorization for merge requests There are two main ways to have a merge request flow with GitLab: -1. Working with [protected branches][] in a single repository +1. Working with [protected branches](../protected_branches.md) in a single repository 1. Working with forks of an authoritative project [Learn more about the authorization for merge requests.](authorization_for_merge_requests.md) - -## Cherry-pick changes - -Cherry-pick any commit in the UI by simply clicking the **Cherry-pick** button -in a merged merge requests or a commit. - -[Learn more about cherry-picking changes.](cherry_pick_changes.md) - -## Semi-linear history merge requests - -A merge commit is created for every merge, but the branch is only merged if -a fast-forward merge is possible. This ensures that if the merge request build -succeeded, the target branch build will also succeed after merging. - -Navigate to a project's settings, select the **Merge commit with semi-linear -history** option under **Merge Requests: Merge method** and save your changes. - -## Fast-forward merge requests - -If you prefer a linear Git history and a way to accept merge requests without -creating merge commits, you can configure this on a per-project basis. - -[Read more about fast-forward merge requests.](fast_forward_merge.md) - -## Merge when pipeline succeeds - -When reviewing a merge request that looks ready to merge but still has one or -more CI jobs running, you can set it to be merged automatically when CI -pipeline succeeds. This way, you don't have to wait for the pipeline to finish -and remember to merge the request manually. - -[Learn more about merging when pipeline succeeds.](merge_when_pipeline_succeeds.md) - -## Resolve threads in merge requests reviews - -Keep track of the progress during a code review with resolving comments. -Resolving comments prevents you from forgetting to address feedback and lets -you hide threads that are no longer relevant. - -[Read more about resolving threads in merge requests reviews.](../../discussions/index.md) - -## View changes between file versions - -The **Changes** tab of a merge request shows the changes to files between branches or -commits. This view of changes to a file is also known as a **diff**. By default, the diff view -compares the file in the merge request branch and the file in the target branch. - -The diff view includes the following: - -- The file's name and path. -- The number of lines added and deleted. -- Buttons for the following options: - - Toggle comments for this file; useful for inline reviews. - - Edit the file in the merge request's branch. - - Show full file, in case you want to look at the changes in context with the rest of the file. - - View file at the current commit. - - Preview the changes with [Review Apps](../../../ci/review_apps/index.md). -- The changed lines, with the specific changes highlighted. - - - -## Commenting on any file line in merge requests - -> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/13950) in GitLab 11.5. - -GitLab provides a way of leaving comments in any part of the file being changed -in a Merge Request. To do so, click the **...** button in the gutter of the Merge Request diff UI to expand the diff lines and leave a comment, just as you would for a changed line. - - - -## Perform a Review **(PREMIUM)** - -Start a review in order to create multiple comments on a diff and publish them once you're ready. -Starting a review allows you to get all your thoughts in order and ensure you haven't missed anything -before submitting all your comments. - -[Learn more about Merge Request Reviews](../../discussions/index.md#merge-request-reviews-premium) - -## Squash and merge - -GitLab allows you to squash all changes present in a merge request into a single -commit when merging, to allow for a neater commit history. - -[Learn more about squash and merge.](squash_and_merge.md) - -## Suggest changes - -> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/18008) in GitLab 11.6. - -As a reviewer, you can add suggestions to change the content in -merge request threads, and users with appropriate [permission](../../permissions.md) -can easily apply them to the codebase directly from the UI. Read -through the documentation on [Suggest changes](../../discussions/index.md#suggest-changes) -to learn more. - -## 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's 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). - -## Resolve conflicts - -When a merge request has conflicts, GitLab may provide the option to resolve -those conflicts in the GitLab UI. - -[Learn more about resolving merge conflicts in the UI.](resolve_conflicts.md) - -## Create new merge requests by email - -_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._ - -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 -a **Email a new merge request to this project** button. The subject will be -used as the source branch name for the new merge request and the target branch -will be the default branch for the project. The message body (if not empty) -will be used as the merge request description. You need -["Reply by email"](../../../administration/reply_by_email.md) enabled to use -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. -You can add this address to your contact list for easy access. - - - -_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._ - -### Adding patches when creating a merge request via e-mail - -> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/22723) in GitLab 11.5. - -You can add commits to the merge request being created by adding -patches as attachments to the email. All attachments with a filename -ending in `.patch` will be considered patches and they will be processed -ordered by name. - -The combined size of the patches can be 2MB. - -If the source branch from the subject does not exist, it will be -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. - -## Use Git push options with merge requests - -Use [Git push options](../push_options.md) to create or update merge requests when -pushing changes to GitLab with Git, without needing to use the GitLab interface. - -## Find the merge request that introduced a change - -> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/2383) in GitLab 10.5. - -When viewing the commit details page, GitLab will link to the merge request (or -merge requests, if it's in more than one) containing that commit. - -This only applies to commits that are in the most recent version of a merge -request - if a commit was in a merge request, then rebased out of that merge -request, they will not be linked. - -[Read more about merge request versions](versions.md) - -## Revert changes - -GitLab implements Git's powerful feature to revert any commit with introducing -a **Revert** button in merge requests and commit details. - -[Learn more about reverting changes in the UI](revert_changes.md) - -## Merge requests versions - -Every time you push to a branch that is tied to a merge request, a new version -of merge request diff is created. When you visit a merge request that contains -more than one pushes, you can select and compare the versions of those merge -request diffs. - -[Read more about merge request versions](versions.md) - -## Work In Progress merge requests - -To prevent merge requests from accidentally being accepted before they're -completely ready, GitLab blocks the "Accept" button for merge requests that -have been marked as a **Work In Progress**. - -[Learn more about setting a merge request as "Work In Progress".](work_in_progress_merge_requests.md) - -## Merge Requests for Confidential Issues - -Create [merge requests to resolve confidential issues](../issues/confidential_issues.md#merge-requests-for-confidential-issues) -for preventing leakage or early release of sensitive data through regular merge requests. - -## Merge request approvals **(STARTER)** - -> Included in [GitLab Starter](https://about.gitlab.com/product/). - -If you want to make sure every merge request is approved by one or more people, -you can enforce this workflow by using merge request approvals. Merge request -approvals allow you to set the number of necessary approvals and predefine a -list of approvers that will need to approve every merge request in a project. - -[Read more about merge request approvals.](merge_request_approvals.md) - -## Code Quality **(STARTER)** - -> Introduced in [GitLab Starter](https://about.gitlab.com/product/) 9.3. - -If you are using [GitLab CI][ci], you can analyze your source code quality using -the [Code Climate][cc] analyzer [Docker image][cd]. Going a step further, GitLab -can show the Code Climate report right in the merge request widget area. - -[Read more about Code Quality reports.](code_quality.md) - -## Metrics Reports **(PREMIUM)** - -> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/9788) in [GitLab Premium](https://about.gitlab.com/product/) 11.10. -Requires GitLab Runner 11.10 and above. - -If you are using [GitLab CI][ci], you can configure your job to output custom -metrics and GitLab will display the Metrics Report on the merge request so -that it's fast and easy to identify changes to important metrics. - -[Read more about Metrics Report](../../../ci/metrics_reports.md). - -## Browser Performance Testing **(PREMIUM)** - -> Introduced in [GitLab Premium](https://about.gitlab.com/product/) 10.3. - -If your application offers a web interface and you are using [GitLab CI/CD][ci], you can quickly determine the performance impact of pending code changes. GitLab uses [Sitespeed.io][sitespeed], a free and open source tool for measuring the performance of web sites, to analyze the performance of specific pages. - -GitLab runs the [Sitespeed.io container][sitespeed-container] and displays the difference in overall performance scores between the source and target branches. - -[Read more about Browser Performance Testing.](browser_performance_testing.md) - -## Merge Request Dependencies **(PREMIUM)** - -> Introduced in [GitLab Premium](https://about.gitlab.com/product/) 12.2. - -A single logical change may be split across several merge requests, across -several projects. When this happens, the order in which MRs are merged is -important. - -GitLab allows you to specify that a merge request depends on other MRs. With -this relationship in place, the merge request cannot be merged until all of its -dependencies have also been merged, helping to maintain the consistency of a -single logical change. - -[Read more about merge request dependencies.](merge_request_dependencies.md) - -## Security reports **(ULTIMATE)** - -GitLab can scan and report any vulnerabilities found in your project. - -[Read more about security reports.](../../application_security/index.md) - -## JUnit test reports - -Configure your CI jobs to use JUnit test reports, and let GitLab display a report -on the merge request so that it’s easier and faster to identify the failure -without having to check the entire job log. - -[Read more about JUnit test reports](../../../ci/junit_test_reports.md). - -## Merge request diff file navigation - -When reviewing changes in the **Changes** tab the diff can be navigated using -the file tree or file list. As you scroll through large diffs with many -changes, you can quickly jump to any changed file using the file tree or file -list. - - - -### Incrementally expand merge request diffs - -By default, the diff shows only the parts of a file which are changed. -To view more unchanged lines above or below a change click on the -**Expand up** or **Expand down** icons. You can also click on **Show all lines** -to expand the entire file. - - - -## Ignore whitespace changes in Merge Request diff view - -If you click the **Hide whitespace changes** button, you can see the diff -without whitespace changes (if there are any). This is also working when on a -specific commit page. - - - ->**Tip:** -You can append `?w=1` while on the diffs page of a merge request to ignore any -whitespace changes. - -## 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). - -## Pipelines for merge requests - -When a developer updates a merge request, a pipeline should quickly report back -its result to the developer, but often pipelines take long time to complete -because general branch pipelines contain unnecessary jobs from the merge request standpoint. -You can customize a specific pipeline structure for merge requests in order to -speed the cycle up by running only important jobs. - -Learn more about [pipelines for merge requests](../../../ci/merge_request_pipelines/index.md). - -## Pipeline status in merge requests - -If you've set up [GitLab CI/CD](../../../ci/README.md) in your project, -you will be able to see: - -- Both pre and post-merge pipelines and the environment information if any. -- Which deployments are in progress. - -If there's an [environment](../../../ci/environments.md) and the application is -successfully deployed to it, the deployed environment and the link to the -Review App will be shown as well. - -### Post-merge pipeline status - -When a merge request is merged, you can see the post-merge pipeline status of -the branch the merge request was merged into. For example, when a merge request -is merged into the master branch and then triggers a deployment to the staging -environment. - -Deployments that are ongoing will be shown, as well as the deploying/deployed state -for environments. If it's the first time the branch is deployed, the link -will return a `404` error until done. During the deployment, the stop button will -be disabled. If the pipeline fails to deploy, the deployment info will be hidden. - - - -For more information, [read about pipelines](../../../ci/pipelines.md). - -## Bulk editing merge requests - -Find out about [bulk editing merge requests](../../project/bulk_editing.md). - -## Troubleshooting - -Sometimes things don't go as expected in a merge request, here are some -troubleshooting steps. - -### Merge request cannot retrieve the pipeline status - -This can occur if Sidekiq doesn't pick up the changes fast enough. - -#### Sidekiq - -Sidekiq didn't process the CI state change fast enough. Please wait a few -seconds and the status will update automatically. - -#### Bug - -Merge Request pipeline statuses can't be retrieved when the following occurs: - -1. A Merge Request is created -1. The Merge Request is closed -1. Changes are made in the project -1. The Merge Request is reopened - -To enable the pipeline status to be properly retrieved, close and reopen the -Merge Request again. - -## Tips - -Here are some tips that will help you be more efficient with merge requests in -the command line. - -> **Note:** -This section might move in its own document in the future. - -### Checkout merge requests locally - -A merge request contains all the history from a repository, plus the additional -commits added to the branch associated with the merge request. Here's a few -tricks to checkout a merge request locally. - -Please note that you can checkout a merge request locally even if the source -project is a fork (even a private fork) of the target project. - -#### Checkout locally by adding a Git alias - -Add the following alias to your `~/.gitconfig`: - -``` -[alias] - mr = !sh -c 'git fetch $1 merge-requests/$2/head:mr-$1-$2 && git checkout mr-$1-$2' - -``` - -Now you can check out a particular merge request from any repository and any -remote. For example, to check out the merge request with ID 5 as shown in GitLab -from the `origin` remote, do: - -``` -git mr origin 5 -``` - -This will fetch the merge request into a local `mr-origin-5` branch and check -it out. - -#### Checkout locally by modifying `.git/config` for a given repository - -Locate the section for your GitLab remote in the `.git/config` file. It looks -like this: - -``` -[remote "origin"] - url = https://gitlab.com/gitlab-org/gitlab-foss.git - fetch = +refs/heads/*:refs/remotes/origin/* -``` - -You can open the file with: - -``` -git config -e -``` - -Now add the following line to the above section: - -``` -fetch = +refs/merge-requests/*/head:refs/remotes/origin/merge-requests/* -``` - -In the end, it should look like this: - -``` -[remote "origin"] - url = https://gitlab.com/gitlab-org/gitlab-foss.git - fetch = +refs/heads/*:refs/remotes/origin/* - fetch = +refs/merge-requests/*/head:refs/remotes/origin/merge-requests/* -``` - -Now you can fetch all the merge requests: - -``` -git fetch origin - -... -From https://gitlab.com/gitlab-org/gitlab-foss.git - * [new ref] refs/merge-requests/1/head -> origin/merge-requests/1 - * [new ref] refs/merge-requests/2/head -> origin/merge-requests/2 -... -``` - -And to check out a particular merge request: - -``` -git checkout origin/merge-requests/1 -``` - -All the above can be done with the [`git-mr`](https://gitlab.com/glensc/git-mr) script. - -[protected branches]: ../protected_branches.md -[ci]: ../../../ci/README.md -[cc]: https://codeclimate.com/ -[cd]: https://hub.docker.com/r/codeclimate/codeclimate/ -[sitespeed]: https://www.sitespeed.io -[sitespeed-container]: https://hub.docker.com/r/sitespeedio/sitespeed.io/ -[ee]: https://about.gitlab.com/pricing/ "GitLab Enterprise Edition" diff --git a/doc/user/project/merge_requests/merge_request_approvals.md b/doc/user/project/merge_requests/merge_request_approvals.md index 2aa92ba2316568a5bb57395ff800c88086a8849f..76c348eb93e1fe5fd237b91a70141778c0e72942 100644 --- a/doc/user/project/merge_requests/merge_request_approvals.md +++ b/doc/user/project/merge_requests/merge_request_approvals.md @@ -75,9 +75,9 @@ request approval rules: 1. Click **Add approvers** to create a new approval rule. 1. Just like in [GitLab Starter](#editing-approvals), select the approval members and approvals required. 1. Give the approval rule a name that describes the set of approvers selected. -1. Click **Add approvers** to submit the new rule. +1. Click **Add approval rule** to submit the new rule. -  +  ## Multiple approval rules **(PREMIUM)** @@ -219,8 +219,6 @@ and the project level approvers are changed after a merge request is created, the merge request retains the previous approvers. However, the approvers can be changed by [editing the merge request](#overriding-the-merge-request-approvals-default-settings). ---- - The default approval settings can now be overridden when creating a [merge request](index.md) or by editing it after it's been created: diff --git a/doc/user/project/merge_requests/merge_when_pipeline_succeeds.md b/doc/user/project/merge_requests/merge_when_pipeline_succeeds.md index dab2184448ad48d7ff530cd25112ab22ecbfcba0..6630179ea472c0f64925b16cd85fc2a4c5bdd093 100644 --- a/doc/user/project/merge_requests/merge_when_pipeline_succeeds.md +++ b/doc/user/project/merge_requests/merge_when_pipeline_succeeds.md @@ -85,3 +85,8 @@ 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. --> + +## Use it from the command line + +You can use [Push Options](../push_options.md) to trigger this feature when +pushing. 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 new file mode 100644 index 0000000000000000000000000000000000000000..f693b0b1e7293fe80d871753bedf6a046600a5ea --- /dev/null +++ b/doc/user/project/merge_requests/reviewing_and_managing_merge_requests.md @@ -0,0 +1,251 @@ +--- +type: index, reference +--- + +# Reviewing and managing merge requests + +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](creating_merge_requests.md), +which is then reviewed, and accepted (or rejected). + +## View project merge requests + +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). + + + +## View merge requests for all projects in a group + +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. + + + +## Semi-linear history merge requests + +A merge commit is created for every merge, but the branch is only merged if +a fast-forward merge is possible. This ensures that if the merge request build +succeeded, the target branch build will also succeed after merging. + +Navigate to a project's settings, select the **Merge commit with semi-linear history** +option under **Merge Requests: Merge method** and save your changes. + +## View changes between file versions + +The **Changes** tab, below the main merge request details and next to the discussion tab, +shows the changes to files between branches or commits. This view of changes to a +file is also known as a **diff**. By default, the diff view compares the file in the +merge request branch and the file in the target branch. + +The diff view includes the following: + +- The file's name and path. +- The number of lines added and deleted. +- Buttons for the following options: + - Toggle comments for this file; useful for inline reviews. + - Edit the file in the merge request's branch. + - Show full file, in case you want to look at the changes in context with the rest of the file. + - View file at the current commit. + - Preview the changes with [Review Apps](../../../ci/review_apps/index.md). +- The changed lines, with the specific changes highlighted. + + + +### Merge request diff file navigation + +When reviewing changes in the **Changes** tab the diff can be navigated using +the file tree or file list. As you scroll through large diffs with many +changes, you can quickly jump to any changed file using the file tree or file +list. + + + +### Incrementally expand merge request diffs + +By default, the diff shows only the parts of a file which are changed. +To view more unchanged lines above or below a change click on the +**Expand up** or **Expand down** icons. You can also click on **Show all lines** +to expand the entire file. + + + +### Ignore whitespace changes in Merge Request diff view + +If you click the **Hide whitespace changes** button, you can see the diff +without whitespace changes (if there are any). This is also working when on a +specific commit page. + + + +>**Tip:** +You can append `?w=1` while on the diffs page of a merge request to ignore any +whitespace changes. + +## Commenting on any file line in merge requests + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/13950) in GitLab 11.5. + +GitLab provides a way of leaving comments in any part of the file being changed +in a Merge Request. To do so, click the **...** button in the gutter of the Merge Request diff UI to expand the diff lines and leave a comment, just as you would for a changed line. + + + +## 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 + +If you've set up [GitLab CI/CD](../../../ci/README.md) in your project, +you will be able to see: + +- Both pre and post-merge pipelines and the environment information if any. +- Which deployments are in progress. + +If there's an [environment](../../../ci/environments.md) and the application is +successfully deployed to it, the deployed environment and the link to the +Review App will be shown as well. + +### Post-merge pipeline status + +When a merge request is merged, you can see the post-merge pipeline status of +the branch the merge request was merged into. For example, when a merge request +is merged into the master branch and then triggers a deployment to the staging +environment. + +Deployments that are ongoing will be shown, as well as the deploying/deployed state +for environments. If it's the first time the branch is deployed, the link +will return a `404` error until done. During the deployment, the stop button will +be disabled. If the pipeline fails to deploy, the deployment info will be hidden. + + + +For more information, [read about pipelines](../../../ci/pipelines.md). + +## Troubleshooting + +Sometimes things don't go as expected in a merge request, here are some +troubleshooting steps. + +### Merge request cannot retrieve the pipeline status + +This can occur if Sidekiq doesn't pick up the changes fast enough. + +#### Sidekiq + +Sidekiq didn't process the CI state change fast enough. Please wait a few +seconds and the status will update automatically. + +#### Bug + +Merge Request pipeline statuses can't be retrieved when the following occurs: + +1. A Merge Request is created +1. The Merge Request is closed +1. Changes are made in the project +1. The Merge Request is reopened + +To enable the pipeline status to be properly retrieved, close and reopen the +Merge Request again. + +## Tips + +Here are some tips that will help you be more efficient with merge requests in +the command line. + +> **Note:** +This section might move in its own document in the future. + +### Checkout merge requests locally + +A merge request contains all the history from a repository, plus the additional +commits added to the branch associated with the merge request. Here's a few +tricks to checkout a merge request locally. + +Please note that you can checkout a merge request locally even if the source +project is a fork (even a private fork) of the target project. + +#### Checkout locally by adding a Git alias + +Add the following alias to your `~/.gitconfig`: + +``` +[alias] + mr = !sh -c 'git fetch $1 merge-requests/$2/head:mr-$1-$2 && git checkout mr-$1-$2' - +``` + +Now you can check out a particular merge request from any repository and any +remote. For example, to check out the merge request with ID 5 as shown in GitLab +from the `origin` remote, do: + +``` +git mr origin 5 +``` + +This will fetch the merge request into a local `mr-origin-5` branch and check +it out. + +#### Checkout locally by modifying `.git/config` for a given repository + +Locate the section for your GitLab remote in the `.git/config` file. It looks +like this: + +``` +[remote "origin"] + url = https://gitlab.com/gitlab-org/gitlab-foss.git + fetch = +refs/heads/*:refs/remotes/origin/* +``` + +You can open the file with: + +``` +git config -e +``` + +Now add the following line to the above section: + +``` +fetch = +refs/merge-requests/*/head:refs/remotes/origin/merge-requests/* +``` + +In the end, it should look like this: + +``` +[remote "origin"] + url = https://gitlab.com/gitlab-org/gitlab-foss.git + fetch = +refs/heads/*:refs/remotes/origin/* + fetch = +refs/merge-requests/*/head:refs/remotes/origin/merge-requests/* +``` + +Now you can fetch all the merge requests: + +``` +git fetch origin + +... +From https://gitlab.com/gitlab-org/gitlab-foss.git + * [new ref] refs/merge-requests/1/head -> origin/merge-requests/1 + * [new ref] refs/merge-requests/2/head -> origin/merge-requests/2 +... +``` + +And to check out a particular merge request: + +``` +git checkout origin/merge-requests/1 +``` + +All the above can be done with the [`git-mr`](https://gitlab.com/glensc/git-mr) script. diff --git a/doc/user/project/merge_requests/versions.md b/doc/user/project/merge_requests/versions.md index fbe216c3aedf39c587e2ddb1b04efc11444a2531..ffd0efb365a76fa4167cc5de5e33ae97291092be 100644 --- a/doc/user/project/merge_requests/versions.md +++ b/doc/user/project/merge_requests/versions.md @@ -35,6 +35,17 @@ changes appears as a system note.  +## Find the merge request that introduced a change + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/2383) in GitLab 10.5. + +When viewing the commit details page, GitLab will link to the merge request (or +merge requests, if it's in more than one) containing that commit. + +This only applies to commits that are in the most recent version of a merge +request - if a commit was in a merge request, then rebased out of that merge +request, they will not be linked. + <!-- ## Troubleshooting Include any troubleshooting steps that you can foresee. If you know beforehand what issues diff --git a/doc/user/project/milestones/index.md b/doc/user/project/milestones/index.md index 105854ccd3394076aa1e61201f55005b1c005273..21a4e3d8eadf1d6ceece65760fca5ca8d3090071 100644 --- a/doc/user/project/milestones/index.md +++ b/doc/user/project/milestones/index.md @@ -12,23 +12,21 @@ Milestones allow you to organize issues and merge requests into a cohesive group ## Milestones as Agile sprints -Milestones can be used as Agile sprints. -Set the milestone start date and due date to represent -the start and end of your Agile sprint. -Set the milestone title to the name of your Agile sprint, -such as `November 2018 sprint`. -Add an issue to your Agile sprint by associating -the milestone to the issue. +Milestones can be used as Agile sprints so that you can track all issues and merge requests related to a particular sprint. To do so: + +1. Set the milestone start date and due date to represent the start and end of your Agile sprint. +1. Set the milestone title to the name of your Agile sprint, such as `November 2018 sprint`. +1. Add an issue to your Agile sprint by associating the desired milestone from the issue's right-hand sidebar. ## Milestones as releases -Milestones can be used as releases. -Set the milestone due date to represent the release date of your release. -(And leave the milestone start date blank.) -Set the milestone title to the version of your release, -such as `Version 9.4`. -Add an issue to your release by associating -the milestone to the issue. +Similarily, 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`. +1. Add an issue to your release by associating the desired milestone from the issue's right-hand sidebar. + +Additionally, you can integrate milestones with GitLab's [Releases feature](../releases/index.md#releases-associated-with-milestones). ## Project milestones and group milestones @@ -103,30 +101,18 @@ When filtering by milestone, in addition to choosing a specific project mileston ## Milestone view -Not all features in the project milestone view are available in the group milestone view. This table summarizes the differences: - -| Feature | Project milestone view | Group milestone view | -|--------------------------------------|:----------------------:|:--------------------:| -| Title an description | ✓ | ✓ | -| Issues assigned to milestone | ✓ | | -| Merge requests assigned to milestone | ✓ | | -| Participants and labels used | ✓ | | -| Percentage complete | ✓ | ✓ | -| Start date and due date | ✓ | ✓ | -| Total issue time spent | ✓ | ✓ | -| Total issue weight | ✓ | | -| Burndown chart **[STARTER}** | ✓ | ✓ | - The milestone view shows the title and description. -### Project milestone features - -These features are only available for project milestones and not group milestones. +There are also tabs below these that show the following: -- Issues assigned to the milestone are displayed in three columns: Unstarted issues, ongoing issues, and completed issues. -- Merge requests assigned to the milestone are displayed in four columns: Work in progress merge requests, waiting for merge, rejected, and closed. -- Participants and labels that are used in issues and merge requests that have the milestone assigned are displayed. -- [Burndown chart](#project-burndown-charts-starter). +- Issues + Shows all issues assigned to the milestone. These are displayed in three columns: Unstarted issues, ongoing issues, and completed issues. +- Merge requests + Shows all merge requests assigned to the milestone. These are displayed in four columns: Work in progress merge requests, waiting for merge, rejected, and closed. +- Participants + Shows all assignees of issues assigned to the milestone. +- Labels + Shows all labels that are used in issues assigned to the milestone. ### Project Burndown Charts **(STARTER)** @@ -144,9 +130,8 @@ The milestone sidebar on the milestone view shows the following: - Percentage complete, which is calculated as number of closed issues divided by total number of issues. - The start date and due date. -- The total time spent on all issues that have the milestone assigned. - -For project milestones only, the milestone sidebar shows the total issue weight of all issues that have the milestone assigned. +- The total time spent on all issues assigned to the milestone. +- The total issue weight of all issues assigned to the milestone.  diff --git a/doc/user/project/operations/error_tracking.md b/doc/user/project/operations/error_tracking.md index 1b319c5641c9b0bf9057740ba5afe19306b113ab..04eda026bc361ebead14bc2966ad1655b99f8cd4 100644 --- a/doc/user/project/operations/error_tracking.md +++ b/doc/user/project/operations/error_tracking.md @@ -6,7 +6,7 @@ Error tracking allows developers to easily discover and view the errors that the ## Sentry error tracking -[Sentry](https://sentry.io/) is an open source error tracking system. GitLab allows administrators to connect Sentry to GitLab, to allow users to view a list of Sentry errors in GitLab itself. +[Sentry](https://sentry.io/) is an open source error tracking system. GitLab allows administrators to connect Sentry to GitLab, to allow users to view a list of Sentry errors in GitLab. ### Deploying Sentry @@ -20,6 +20,7 @@ You will need at least Maintainer [permissions](../../permissions.md) to enable GitLab provides an easy way to connect Sentry to your project: 1. Sign up to Sentry.io or [deploy your own](#deploying-sentry) Sentry instance. +1. [Create](https://docs.sentry.io/guides/integrate-frontend/create-new-project/) a new Sentry project. For each GitLab project that you want to integrate, we recommend that you create a new Sentry project. 1. [Find or generate](https://docs.sentry.io/api/auth/) a Sentry auth token for your Sentry project. Make sure to give the token at least the following scopes: `event:read` and `project:read`. 1. Navigate to your project’s **Settings > Operations**. @@ -31,11 +32,27 @@ GitLab provides an easy way to connect Sentry to your project: 1. Click **Save changes** for the changes to take effect. 1. You can now visit **Operations > Error Tracking** in your project's sidebar to [view a list](#error-tracking-list) of Sentry errors. +### Enabling Gitlab issues links + +You may also want to enable Sentry's GitLab integration by following the steps in the [Sentry documentation](https://docs.sentry.io/workflow/integrations/global-integrations/#gitlab) + ## Error Tracking List NOTE: **Note:** You will need at least Reporter [permissions](../../permissions.md) to view the Error Tracking list. The Error Tracking list may be found at **Operations > Error Tracking** in your project's sidebar. +Errors can be filtered by title.  + +## Error Details + +From error list, users can navigate to the error details page by clicking the title of any error. + +This page has: + +- A link to Sentry issue. +- A full stack trace along with other details. + + diff --git a/doc/user/project/operations/feature_flags.md b/doc/user/project/operations/feature_flags.md index 08df92959c3779001fd1c80adea9067943019853..c05f8fa8bc4f9d58928f870eb1449792cd5b98fc 100644 --- a/doc/user/project/operations/feature_flags.md +++ b/doc/user/project/operations/feature_flags.md @@ -81,7 +81,14 @@ NOTE: **NOTE** We'd highly recommend you to use the [Environment](../../../ci/environments.md) feature in order to quickly assess which flag is enabled per environment. -## Rollout strategy +## Feature Flag strategies + +GitLab Feature Flag adopts [Unleash](https://unleash.github.io) +as the feature flag engine. In unleash, there is a concept of rulesets for granular feature flag controls, +called [strategies](https://unleash.github.io/docs/activation_strategy). +Supported strategies for GitLab Feature Flags are described below. + +### Rollout strategy > [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/8240) in GitLab 12.2. @@ -91,13 +98,13 @@ The status of an environment spec ultimately determines whether or not a feature For instance, a feature will always be disabled for every user if the matching environment spec has a disabled status, regardless of the chosen rollout strategy. However, a feature will be enabled for 50% of logged-in users if the matching environment spec has an enabled status along with a **Percent rollout (logged in users)** strategy set to 50%. -### All users +#### All users Enables the feature for all users. It is implemented using the Unleash [`default`](https://unleash.github.io/docs/activation_strategy#default) activation strategy. -### Percent rollout (logged in users) +#### Percent rollout (logged in users) Enables the feature for a percentage of authenticated users. It is implemented using the Unleash @@ -112,7 +119,7 @@ CAUTION: **Caution:** If this strategy is selected, then the Unleash client **must** be given a user ID for the feature to be enabled. See the [Ruby example](#ruby-application-example) below. -## Target users +### Target users strategy > [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/8240) in GitLab 12.2. @@ -134,7 +141,7 @@ In order to use Feature Flags, you need to first [get the access credentials](#configuring-feature-flags) from GitLab and then prepare your application and hook it with a [client library](#client-libraries). -### Configuring Feature Flags +## Configuring Feature Flags To get the access credentials that your application will need to talk to GitLab: @@ -153,7 +160,7 @@ if **Instance ID** will be single token or multiple tokens, assigned to the **Environment**. Also, **Application name** could describe the version of application instead of the running environment. -### Client libraries +## Client libraries GitLab currently implements a single backend that is compatible with [Unleash](https://github.com/Unleash/unleash#client-implementations) clients. @@ -178,7 +185,7 @@ Community contributed clients: - [Unofficial .Net Core Unleash client](https://github.com/onybo/unleash-client-core) - [Unleash client for Python 3](https://github.com/aes/unleash-client-python) -### Golang application example +## Golang application example Here's an example of how to integrate the feature flags in a Golang application: @@ -219,7 +226,7 @@ func main() { } ``` -### Ruby application example +## Ruby application example Here's an example of how to integrate the feature flags in a Ruby application. @@ -249,3 +256,11 @@ else puts "hello, world!" end ``` + +## Feature Flags API + +You can create, update, read, and delete Feature Flags via API +to control them in an automated flow: + +- [Feature Flags API](../../../api/feature_flags.md) +- [Feature Flag Specs API](../../../api/feature_flag_specs.md) diff --git a/doc/user/project/operations/img/error_details_v12_5.png b/doc/user/project/operations/img/error_details_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..5e3e63006400931825bb1f25c02f130f56755a57 Binary files /dev/null and b/doc/user/project/operations/img/error_details_v12_5.png differ diff --git a/doc/user/project/operations/img/error_tracking_list.png b/doc/user/project/operations/img/error_tracking_list.png index 194c7b440a4fdd7cc406df437f3eeb0020bed744..79b464e021ef1df204d3e2fca0770f52abeb58ab 100644 Binary files a/doc/user/project/operations/img/error_tracking_list.png and b/doc/user/project/operations/img/error_tracking_list.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 326a2d302d29916bbf04f9f991d304c17707c7fa..2f16606c5a8bd021d153e2e78b5992b0850efbe9 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 @@ -169,6 +169,22 @@ from the GitLab project. in place: your domain will be periodically reverified, and may be disabled if the record is removed. +##### Troubleshooting Pages domain verification + +To manually verify that you have properly configured the domain verification +`TXT` DNS entry, you can run the following command in your terminal: + +``` +dig _gitlab-pages-verification-code.<YOUR-PAGES-DOMAIN> TXT +``` + +Expect the output: + +``` +;; ANSWER SECTION: +_gitlab-pages-verification-code.<YOUR-PAGES-DOMAIN>. 300 IN TXT "gitlab-pages-verification-code=<YOUR-VERIFICATION-CODE>" +``` + ### Adding more domain aliases You can add more than one alias (custom domains and subdomains) to the same project. diff --git a/doc/user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md b/doc/user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md index c9b504dc6ee156a7e3393633d3685250ff5525dd..1d64e843e4683a05f6ab72598d7ef0d0f6f00b8a 100644 --- a/doc/user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md +++ b/doc/user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md @@ -5,7 +5,8 @@ description: "Automatic Let's Encrypt SSL certificates for GitLab Pages." # GitLab Pages integration with Let's Encrypt -> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/28996) in GitLab 12.1. +> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/28996) in GitLab 12.1. For versions earlier than GitLab 12.1, see the [manual Let's Encrypt instructions](../lets_encrypt_for_gitlab_pages.md). +This feature is in **beta** and may still have bugs. See all the related issues linked from this [issue's description](https://gitlab.com/gitlab-org/gitlab-foss/issues/28996) for more information. The GitLab Pages integration with Let's Encrypt (LE) allows you to use LE certificates for your Pages website with custom domains @@ -16,19 +17,11 @@ GitLab does it for you, out-of-the-box. open source Certificate Authority. CAUTION: **Caution:** -This feature is in **beta** and might present bugs and UX issues -such as [#64870](https://gitlab.com/gitlab-org/gitlab-foss/issues/64870). -See all the related issues linked from this [issue's description](https://gitlab.com/gitlab-org/gitlab-foss/issues/28996) -for more information. - -CAUTION: **Caution:** -This feature covers only certificates for **custom domains**, -not the wildcard certificate required to run [Pages daemon](../../../../administration/pages/index.md) **(CORE ONLY)**. -Wildcard certificate generation is tracked in [this issue](https://gitlab.com/gitlab-org/omnibus-gitlab/issues/3342). +This feature covers only certificates for **custom domains**, not the wildcard certificate required to run [Pages daemon](../../../../administration/pages/index.md) **(CORE ONLY)**. Wildcard certificate generation is tracked in [this issue](https://gitlab.com/gitlab-org/omnibus-gitlab/issues/3342). ## Requirements -Before you can enable automatic provisioning of a SSL certificate for your domain, make sure you have: +Before you can enable automatic provisioning of an SSL certificate for your domain, make sure you have: - Created a [project](../getting_started_part_two.md) in GitLab containing your website's source code. @@ -36,7 +29,7 @@ Before you can enable automatic provisioning of a SSL certificate for your domai pointing it to your Pages website. - [Added your domain to your Pages project](index.md#1-add-a-custom-domain-to-pages) and verified your ownership. -- Have your website up and running, accessible through your custom domain. +- Verified your website is up and running, accessible through your custom domain. NOTE: **Note:** GitLab's Let's Encrypt integration is enabled and available on GitLab.com. @@ -45,7 +38,7 @@ For **self-managed** GitLab instances, make sure your administrator has ## Enabling Let's Encrypt integration for your custom domain -Once you've met the requirements, to enable Let's Encrypt integration: +Once you've met the requirements, enable Let's Encrypt integration: 1. Navigate to your project's **Settings > Pages**. 1. Find your domain and click **Details**. diff --git a/doc/user/project/pages/getting_started/fork_sample_project.md b/doc/user/project/pages/getting_started/fork_sample_project.md new file mode 100644 index 0000000000000000000000000000000000000000..ac1a29ca2a02ae89adfaed82606e7ec3e60fb261 --- /dev/null +++ b/doc/user/project/pages/getting_started/fork_sample_project.md @@ -0,0 +1,61 @@ +--- +type: reference, howto +--- + +# New Pages website from a forked sample + +To get started with GitLab Pages from a sample website, the easiest +way to do it is by using one of the [bundled templates](pages_bundled_template.md). +If you don't find one that suits your needs, you can opt by +forking (copying) a [sample project from the most popular Static Site Generators](https://gitlab.com/pages). + +<table class="borderless-table center fixed-table middle width-80"> + <tr> + <td style="width: 30%"><img src="../img/icons/fork.png" alt="Fork" class="image-noshadow half-width"></td> + <td style="width: 10%"> + <strong> + <i class="fa fa-angle-double-right" aria-hidden="true"></i> + </strong> + </td> + <td style="width: 30%"><img src="../img/icons/terminal.png" alt="Deploy" class="image-noshadow half-width"></td> + <td style="width: 10%"> + <strong> + <i class="fa fa-angle-double-right" aria-hidden="true"></i> + </strong> + </td> + <td style="width: 30%"><img src="../img/icons/click.png" alt="Visit" class="image-noshadow half-width"></td> + </tr> + <tr> + <td><em>Fork an example project</em></td> + <td></td> + <td><em>Deploy your website</em></td> + <td></td> + <td><em>Visit your website's URL</em></td> + </tr> +</table> + +**<i class="fa fa-youtube-play youtube" aria-hidden="true"></i> Watch a [video tutorial](https://www.youtube.com/watch?v=TWqh9MtT4Bg) with all the steps below.** + +1. [Fork](../../../../gitlab-basics/fork-project.md) a sample project from the [GitLab Pages examples](https://gitlab.com/pages) group. +1. From the left sidebar, navigate to your project's **CI/CD > Pipelines** + and click **Run pipeline** to trigger GitLab CI/CD to build and deploy your + site to the server. +1. Once the pipeline has finished successfully, find the link to visit your + website from your project's **Settings > Pages**. It can take aproximatelly + 30 minutes to be deployed. + +You can also take some **optional** further steps: + +- _Remove the fork relationship._ The fork relationship is necessary to contribute back to the project you originally forked from. If you don't have any intentions to do so, you can remove it. To do so, navigate to your project's **Settings**, expand **Advanced settings**, and scroll down to **Remove fork relationship**: + +  + +- _Make it a user or group website._ To turn a **project website** forked + from the Pages group into a **user/group** website, you'll need to: + - Rename it to `namespace.gitlab.io`: go to your project's + **Settings > General** and expand **Advanced**. Scroll down to + **Rename repository** and change the path to `namespace.gitlab.io`. + - Adjust your SSG's [base URL](../getting_started_part_one.md#urls-and-baseurls) from `"project-name"` to + `""`. This setting will be at a different place for each SSG, as each of them + have their own structure and file tree. Most likely, it will be in the SSG's + config file. 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 new file mode 100644 index 0000000000000000000000000000000000000000..62b5fa331174cf064b4041ad0274eb96f93164b2 --- /dev/null +++ b/doc/user/project/pages/getting_started/new_or_existing_website.md @@ -0,0 +1,45 @@ +--- +type: reference, howto +--- + +# Start a new Pages website from scratch or deploy an existing website + +If you already have a website and want to deploy it with GitLab Pages, +or, if you want to start a new site from scratch, you'll need to: + +- Create a new project in GitLab to hold your site content. +- Set up GitLab CI/CD to deploy your website to Pages. + +To do so, follow the steps below. + +1. From your **Project**'s **[Dashboard](https://gitlab.com/dashboard/projects)**, + click **New project**, and name it according to the + [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, + add the remote URL: + `git remote add origin git@gitlab.com:namespace/project-name.git`, + then add, commit, and push to GitLab. +1. From the your **Project**'s page, click **Set up CI/CD**: + +  + +1. Choose one of the templates from the dropbox menu. + Pick up the template corresponding to the SSG you're using (or plain HTML). + +  + + Note that, if you don't find a corresponding template, you can look into + [GitLab Pages group of sample projects](https://gitlab.com/pages), + you may find one among them that suits your needs, from which you + can copy `.gitlab-ci.yml`'s content and adjust for your case. + If you don't find it there either, [learn how to write a `.gitlab-ci.yml` + file for GitLab Pages](../getting_started_part_four.md). + +Once you have both site files and `.gitlab-ci.yml` in your project's +root, GitLab CI/CD will build your site and deploy it with Pages. +Once the first build passes, you access your site by +navigating to your **Project**'s **Settings** > **Pages**, +where you'll find its default URL. It can take approximately 30 min to be +deployed. diff --git a/doc/user/project/pages/getting_started/pages_bundled_template.md b/doc/user/project/pages/getting_started/pages_bundled_template.md new file mode 100644 index 0000000000000000000000000000000000000000..802abeb3cc2565bfe66e8aa92d0819c85344391b --- /dev/null +++ b/doc/user/project/pages/getting_started/pages_bundled_template.md @@ -0,0 +1,29 @@ +--- +type: reference, howto +--- + +# New Pages website from a bundled template + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/47857) +in GitLab 11.8. + +The simplest way to create a GitLab Pages site is to use one of the most +popular templates, which come already bundled with GitLab and are ready to go. + +1. From the top navigation, click the **+** button and select **New project**. +1. Select **Create from Template**. +1. Choose one of the templates starting with **Pages**: + +  + +1. From the left sidebar, navigate to your project's **CI/CD > Pipelines** + and click **Run pipeline** to trigger GitLab CI/CD to build and deploy your + site to the server. +1. After the pipeline has finished successfully, wait approximately 30 minutes + for your website to be visible. After waiting 30 minutes, find the link to + visit your website from your project's **Settings > Pages**. If the link + leads to a 404 page, wait a few minutes and try again. + +Your website is then visible on your domain and you can modify your files +as you wish. For every modification pushed to your repository, GitLab CI/CD +will run a new pipeline to immediately publish your changes to the server. diff --git a/doc/user/project/pages/getting_started_part_one.md b/doc/user/project/pages/getting_started_part_one.md index 0b1cae9ab4c35aca9d1282cafb622ec60629511a..b876e547ba544beb232bcf01e20612a09bb53cd5 100644 --- a/doc/user/project/pages/getting_started_part_one.md +++ b/doc/user/project/pages/getting_started_part_one.md @@ -3,25 +3,12 @@ last_updated: 2018-06-04 type: concepts, reference --- -# Static sites and GitLab Pages domains +# GitLab Pages domain names, URLs, and baseurls On this document, learn how to name your project for GitLab Pages according to your intended website's URL. -## Static sites - -GitLab Pages only supports static websites, meaning, -your output files must be HTML, CSS, and JavaScript only. - -To create your static site, you can either hardcode in HTML, -CSS, and JS, or use a [Static Site Generator (SSG)](https://www.staticgen.com/) -to simplify your code and build the static site for you, -which is highly recommendable and much faster than hardcoding. - -See the [further reading](#further-reading) section below for -references on static site concepts. - -## GitLab Pages domain names +## GitLab Pages default domain names >**Note:** If you use your own GitLab instance to deploy your @@ -93,11 +80,35 @@ To understand Pages domains clearly, read the examples below. - On your GitLab instance, replace `gitlab.io` above with your Pages server domain. Ask your sysadmin for this information. -_Read on about [Projects for GitLab Pages and URL structure](getting_started_part_two.md)._ +## URLs and baseurls + +Every Static Site Generator (SSG) default configuration expects +to find your website under a (sub)domain (`example.com`), not +in a subdirectory of that domain (`example.com/subdir`). Therefore, +whenever you publish a project website (`namespace.gitlab.io/project-name`), +you'll have to look for this configuration (base URL) on your SSG's +documentation and set it up to reflect this pattern. + +For example, for a Jekyll site, the `baseurl` is defined in the Jekyll +configuration file, `_config.yml`. If your website URL is +`https://john.gitlab.io/blog/`, you need to add this line to `_config.yml`: + +```yaml +baseurl: "/blog" +``` + +On the contrary, if you deploy your website after forking one of +our [default examples](https://gitlab.com/pages), the baseurl will +already be configured this way, as all examples there are project +websites. If you decide to make yours a user or group website, you'll +have to remove this configuration from your project. For the Jekyll +example we've just mentioned, you'd have to change Jekyll's `_config.yml` to: + +```yaml +baseurl: "" +``` -### Further reading +## Custom domains -- Read through this technical overview on [Static versus Dynamic Websites](https://about.gitlab.com/blog/2016/06/03/ssg-overview-gitlab-pages-part-1-dynamic-x-static/) -- Understand [how modern Static Site Generators work](https://about.gitlab.com/blog/2016/06/10/ssg-overview-gitlab-pages-part-2/) and what you can add to your static site -- You can use [any SSG with GitLab Pages](https://about.gitlab.com/blog/2016/06/17/ssg-overview-gitlab-pages-part-3-examples-ci/) -- Fork an [example project](https://gitlab.com/pages) to build your website based upon +GitLab Pages supports custom domains and subdomains, served under HTTP or HTTPS. +See [GitLab Pages custom domains and SSL/TLS Certificates](custom_domains_ssl_tls_certification/index.md) for more information. diff --git a/doc/user/project/pages/getting_started_part_two.md b/doc/user/project/pages/getting_started_part_two.md index ff75291708717f39d8e413e63cdf2a09a10c3af7..70e84f5d486066d5fb731044324bf8e083418d46 100644 --- a/doc/user/project/pages/getting_started_part_two.md +++ b/doc/user/project/pages/getting_started_part_two.md @@ -1,172 +1,5 @@ --- -last_updated: 2019-06-04 -type: reference, howto +redirect_to: 'index.md' --- -# Projects for GitLab Pages and URL structure - -## What you need to get started - -To get started with GitLab Pages, you need: - -1. A project, thus a repository to hold your website's codebase. -1. A configuration file (`.gitlab-ci.yml`) to deploy your site. -1. A specific `job` called `pages` in the configuration file - that will make GitLab aware that you are deploying a GitLab Pages website. -1. A `public` directory with the static content of the website. - -Optional Features: - -1. A custom domain or subdomain. -1. A DNS pointing your (sub)domain to your Pages site. - 1. **Optional**: an SSL/TLS certificate so your custom - domain is accessible under HTTPS. - -The optional settings, custom domain, DNS records, and SSL/TLS certificates, are described in [GitLab Pages custom domains and SSL/TLS Certificates](custom_domains_ssl_tls_certification/index.md)). - -## Project - -Your GitLab Pages project is a regular project created the -same way you do for the other ones. To get started with GitLab Pages, you have three ways: - -- [Use one of the popular project templates bundled with GitLab](#use-one-of-the-popular-pages-templates-bundled-with-gitlab). -- [Fork one of the templates from Page Examples](#fork-a-project-to-get-started-from). -- [Create a new project from scratch](#create-a-project-from-scratch). - -### Use one of the popular Pages templates bundled with GitLab - -> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/47857) -in GitLab 11.8. - -The simplest way to create a GitLab Pages site is to -[use one of the most popular templates](index.md#getting-started), -which come already bundled with GitLab and are ready to go. - -### Fork a project to get started from - -If you don't find an existing project template that suits you, -we've created this [group](https://gitlab.com/pages) of default projects -containing the most popular SSGs templates to get you started. - -<table class="borderless-table center fixed-table middle width-80"> - <tr> - <td style="width: 30%"><img src="img/icons/fork.png" alt="Fork" class="image-noshadow half-width"></td> - <td style="width: 10%"> - <strong> - <i class="fa fa-angle-double-right" aria-hidden="true"></i> - </strong> - </td> - <td style="width: 30%"><img src="img/icons/terminal.png" alt="Deploy" class="image-noshadow half-width"></td> - <td style="width: 10%"> - <strong> - <i class="fa fa-angle-double-right" aria-hidden="true"></i> - </strong> - </td> - <td style="width: 30%"><img src="img/icons/click.png" alt="Visit" class="image-noshadow half-width"></td> - </tr> - <tr> - <td><em>Fork an example project</em></td> - <td></td> - <td><em>Deploy your website</em></td> - <td></td> - <td><em>Visit your website's URL</em></td> - </tr> -</table> - -**<i class="fa fa-youtube-play youtube" aria-hidden="true"></i> Watch a [video tutorial](https://www.youtube.com/watch?v=TWqh9MtT4Bg) with all the steps below.** - -1. [Fork](../../../gitlab-basics/fork-project.md) a sample project from the [GitLab Pages examples](https://gitlab.com/pages) group. -1. From the left sidebar, navigate to your project's **CI/CD > Pipelines** - and click **Run pipeline** to trigger GitLab CI/CD to build and deploy your - site to the server. -1. Once the pipeline has finished successfully, find the link to visit your - website from your project's **Settings > Pages**. - -You can also take some **optional** further steps: - -- _Remove the fork relationship._ The fork relationship is necessary to contribute back to the project you originally forked from. If you don't have any intentions to do so, you can remove it. To do so, navigate to your project's **Settings**, expand **Advanced settings**, and scroll down to **Remove fork relationship**: - -  - -- _Make it a user or group website._ To turn a **project website** forked - from the Pages group into a **user/group** website, you'll need to: - - Rename it to `namespace.gitlab.io`: go to your project's - **Settings > General** and expand **Advanced**. Scroll down to - **Rename repository** and change the path to `namespace.gitlab.io`. - - Adjust your SSG's [base URL](#urls-and-baseurls) from `"project-name"` to - `""`. This setting will be at a different place for each SSG, as each of them - have their own structure and file tree. Most likely, it will be in the SSG's - config file. - -### Create a project from scratch - -1. From your **Project**'s **[Dashboard](https://gitlab.com/dashboard/projects)**, - click **New project**, and name it according to the - [Pages domain names](getting_started_part_one.md#gitlab-pages-domain-names). -1. Clone it to your local computer, add your website - files to your project, add, commit and push to GitLab. -1. From the your **Project**'s page, click **Set up CI/CD**: - -  - -1. Choose one of the templates from the dropbox menu. - Pick up the template corresponding to the SSG you're using (or plain HTML). - -  - -Once you have both site files and `.gitlab-ci.yml` in your project's -root, GitLab CI/CD will build your site and deploy it with Pages. -Once the first build passes, you see your site is live by -navigating to your **Project**'s **Settings** > **Pages**, -where you'll find its default URL. - -> **Notes:** -> -> - GitLab Pages [supports any SSG](https://about.gitlab.com/blog/2016/06/17/ssg-overview-gitlab-pages-part-3-examples-ci/), but, -> if you don't find yours among the templates, you'll need -> to configure your own `.gitlab-ci.yml`. To do that, please -> read through the article [Creating and Tweaking GitLab CI/CD for GitLab Pages](getting_started_part_four.md). New SSGs are very welcome among -> the [example projects](https://gitlab.com/pages). If you set -> up a new one, please -> [contribute](https://gitlab.com/pages/pages.gitlab.io/blob/master/CONTRIBUTING.md) -> to our examples. -> -> - The second step _"Clone it to your local computer"_, can be done -> differently, achieving the same results: instead of cloning the bare -> repository to you local computer and moving your site files into it, -> you can run `git init` in your local website directory, add the -> remote URL: `git remote add origin git@gitlab.com:namespace/project-name.git`, -> then add, commit, and push to GitLab. - -## URLs and Baseurls - -Every Static Site Generator (SSG) default configuration expects -to find your website under a (sub)domain (`example.com`), not -in a subdirectory of that domain (`example.com/subdir`). Therefore, -whenever you publish a project website (`namespace.gitlab.io/project-name`), -you'll have to look for this configuration (base URL) on your SSG's -documentation and set it up to reflect this pattern. - -For example, for a Jekyll site, the `baseurl` is defined in the Jekyll -configuration file, `_config.yml`. If your website URL is -`https://john.gitlab.io/blog/`, you need to add this line to `_config.yml`: - -```yaml -baseurl: "/blog" -``` - -On the contrary, if you deploy your website after forking one of -our [default examples](https://gitlab.com/pages), the baseurl will -already be configured this way, as all examples there are project -websites. If you decide to make yours a user or group website, you'll -have to remove this configuration from your project. For the Jekyll -example we've just mentioned, you'd have to change Jekyll's `_config.yml` to: - -```yaml -baseurl: "" -``` - -## Custom Domains - -GitLab Pages supports custom domains and subdomains, served under HTTP or HTTPS. -See [GitLab Pages custom domains and SSL/TLS Certificates](custom_domains_ssl_tls_certification/index.md) for more information. +This document was moved to [another location](index.md#getting-started). diff --git a/doc/user/project/pages/img/new_project_for_pages_v12_5.png b/doc/user/project/pages/img/new_project_for_pages_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..8d2dc0bf9f539669727882dc587961cd6b33526e Binary files /dev/null and b/doc/user/project/pages/img/new_project_for_pages_v12_5.png differ diff --git a/doc/user/project/pages/img/pages_workflow_v12_5.png b/doc/user/project/pages/img/pages_workflow_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..ca5190fca79aa896ae5e6a8ef48d0b8e08a84f5b Binary files /dev/null and b/doc/user/project/pages/img/pages_workflow_v12_5.png differ diff --git a/doc/user/project/pages/index.md b/doc/user/project/pages/index.md index 7d533c6f9d1dba0eaf3755b40c3367d34dc6634e..abd67c90dd6253e07cacc93e7cbc514a3e90c9e2 100644 --- a/doc/user/project/pages/index.md +++ b/doc/user/project/pages/index.md @@ -19,38 +19,7 @@ You can use it either for personal or business websites, such as portfolios, documentation, manifestos, and business presentations. You can also attribute any license to your content. -<table class="borderless-table center fixed-table"> - <tr> - <td style="width: 22%"><img src="img/icons/cogs.png" alt="SSGs" class="image-noshadow half-width"></td> - <td style="width: 4%"> - <strong> - <i class="fa fa-angle-double-right" aria-hidden="true"></i> - </strong> - </td> - <td style="width: 22%"><img src="img/icons/monitor.png" alt="Websites" class="image-noshadow half-width"></td> - <td style="width: 4%"> - <strong> - <i class="fa fa-angle-double-right" aria-hidden="true"></i> - </strong> - </td> - <td style="width: 22%"><img src="img/icons/free.png" alt="Pages is free" class="image-noshadow half-width"></td> - <td style="width: 4%"> - <strong> - <i class="fa fa-angle-double-right" aria-hidden="true"></i> - </strong> - </td> - <td style="width: 22%"><img src="img/icons/lock.png" alt="Secure your website" class="image-noshadow half-width"></td> - </tr> - <tr> - <td><em>Use any static website generator or plain HTML</em></td> - <td></td> - <td><em>Create websites for your projects, groups, or user account</em></td> - <td></td> - <td><em>Host on GitLab.com for free, or on your own GitLab instance</em></td> - <td></td> - <td><em>Connect your custom domain(s) and TLS certificates</em></td> - </tr> -</table> +<img src="img/pages_workflow_v12_5.png" alt="Pages websites workflow" class="image-noshadow"> Pages is available for free for all GitLab.com users as well as for self-managed instances (GitLab Core, Starter, Premium, and Ultimate). @@ -73,6 +42,7 @@ publish any website written directly in plain HTML, CSS, and JavaScript.</p> To use GitLab Pages, first you need to create a project in GitLab to upload your website's files to. These projects can be either public, internal, or private, at your own choice. + GitLab will always deploy your website from a very specific folder called `public` in your repository. Note that when you create a new project in GitLab, a [repository](../repository/index.md) becomes available automatically. @@ -80,67 +50,50 @@ becomes available automatically. To deploy your site, GitLab will use its built-in tool called [GitLab CI/CD](../../../ci/README.md), to build your site and publish it to the GitLab Pages server. The sequence of scripts that GitLab CI/CD runs to accomplish this task is created from a file named -`.gitlab-ci.yml`, which you can [create and modify](getting_started_part_four.md) at will. +`.gitlab-ci.yml`, which you can [create and modify](getting_started_part_four.md) at will. A specific `job` called `pages` in the configuration file will make GitLab aware that you are deploying a GitLab Pages website. -You can either use GitLab's [default domain for GitLab Pages websites](getting_started_part_one.md#gitlab-pages-domain-names), +You can either use GitLab's [default domain for GitLab Pages websites](getting_started_part_one.md#gitlab-pages-default-domain-names), `*.gitlab.io`, or your own domain (`example.com`). In that case, you'll need admin access to your domain's registrar (or control panel) to set it up with Pages. -Optionally, when adding your own domain, you can add an SSL/TLS certificate to secure your -site under the HTTPS protocol. - ### Getting started To get started with GitLab Pages, you can either: -- [Create a project from scratch](getting_started_part_two.md#create-a-project-from-scratch). -- [Copy an existing example project](getting_started_part_two.md#fork-a-project-to-get-started-from). -- Use a bundled project template ready to go: - -1. From the top navigation, click the **+** button and select **New project**. -1. Select **Create from Template**. -1. Choose one of the templates starting with **Pages**: - -  +- [Use a bundled website template ready to go](getting_started/pages_bundled_template.md). +- [Copy an existing sample](getting_started/fork_sample_project.md). +- [Create a website from scratch or deploy an existing one](getting_started/new_or_existing_website.md). -1. From the left sidebar, navigate to your project's **CI/CD > Pipelines** - and click **Run pipeline** to trigger GitLab CI/CD to build and deploy your - site to the server. -1. After the pipeline has finished successfully, wait approximately 30 minutes - for your website to be visible. After waiting 30 minutes, find the link to - visit your website from your project's **Settings > Pages**. If the link - leads to a 404 page, wait a few minutes and try again. +<img src="img/new_project_for_pages_v12_5.png" alt="New projects for GitLab Pages" class="image-noshadow"> -Your website is then visible on your domain and you can modify your files -as you wish. For every modification pushed to your repository, GitLab CI/CD -will run a new pipeline to immediately publish your changes to the server. +Optional features: -_Advanced options:_ +- Use a [custom domain or subdomain](custom_domains_ssl_tls_certification/index.md#set-up-pages-with-a-custom-domain). +- Add an [SSL/TLS certificate to secure your site under the HTTPS protocol](custom_domains_ssl_tls_certification/index.md#adding-an-ssltls-certificate-to-pages). -- [Use a custom domain](custom_domains_ssl_tls_certification/index.md#set-up-pages-with-a-custom-domain) -- Apply [SSL/TLS certification](custom_domains_ssl_tls_certification/index.md#adding-an-ssltls-certificate-to-pages) to your custom domain +Note that, if you're using GitLab Pages default domain (`.gitlab.io`), +your website will be automatically secure and available under +HTTPS. If you're using your own custom domain, you can +optionally secure it with SSL/TLS certificates. ## Availability If you're using GitLab.com, your website will be publicly available to the internet. + +To restrict access to your website, enable [GitLab Pages Access Control](pages_access_control.md). + If you're using self-managed instances (Core, Starter, Premium, or Ultimate), your websites will be published on your own server, according to the [Pages admin settings](../../../administration/pages/index.md) chosen by your sysadmin, who can opt for making them public or internal to your server. -Note that, if you're using GitLab Pages default domain (`.gitlab.io`), -your website will be automatically secure and available under -HTTPS. If you're using your own custom domain, you can -optionally secure it with SSL/TLS certificates. - ## Explore GitLab Pages To learn more about configuration options for GitLab Pages, read the following: | Document | Description | | --- | --- | -| [Static websites and Pages domains](getting_started_part_one.md) | Understand what is a static website, and how GitLab Pages default domains work. | -| [Projects and URL structure](getting_started_part_two.md) | Forking projects and creating new ones from scratch, understanding URLs structure and baseurls. | +| [GitLab Pages domain names, URLs, and baseurls](getting_started_part_one.md) | Understand how GitLab Pages default domains work. | | [GitLab CI/CD for GitLab Pages](getting_started_part_four.md) | Understand how to create your own `.gitlab-ci.yml` for your site. | | [Exploring GitLab Pages](introduction.md) | Requirements, technical aspects, specific GitLab CI's configuration options, Access Control, custom 404 pages, limitations, FAQ. | |---+---| diff --git a/doc/user/project/pages/introduction.md b/doc/user/project/pages/introduction.md index 86257e2aa03001934e9dc0b23db8d6bb022cb1f3..01e1909f6d6566f8cf472258c769fce90d71d7cd 100644 --- a/doc/user/project/pages/introduction.md +++ b/doc/user/project/pages/introduction.md @@ -69,40 +69,7 @@ don't have to create and edit HTML files manually. For example, Jekyll has the ## GitLab Pages Access Control **(CORE)** -> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/33422) in GitLab 11.5. - -You can enable Pages access control on your project, so that only -[members of your project](../../permissions.md#project-members-permissions) -(at least Guest) can access your website: - -1. Navigate to your project's **Settings > General > Permissions**. -1. Toggle the **Pages** button to enable the access control. - - NOTE: **Note:** - If you don't see the toggle button, that means that it's not enabled. - Ask your administrator to [enable it](../../../administration/pages/index.md#access-control). - -1. The Pages access control dropdown allows you to set who can view pages hosted - with GitLab Pages, depending on your project's visibility: - - - If your project is private: - - **Only project members**: Only project members will be able to browse the website. - - **Everyone**: Everyone, both logged into and logged out of GitLab, will be able to browse the website, no matter their project membership. - - If your project is internal: - - **Only project members**: Only project members will be able to browse the website. - - **Everyone with access**: Everyone logged into GitLab will be able to browse the website, no matter their project membership. - - **Everyone**: Everyone, both logged into and logged out of GitLab, will be able to browse the website, no matter their project membership. - - If your project is public: - - **Only project members**: Only project members will be able to browse the website. - - **Everyone with access**: Everyone, both logged into and logged out of GitLab, will be able to browse the website, no matter their project membership. - -1. Click **Save changes**. - ---- - -The next time someone tries to access your website and the access control is -enabled, they will be presented with a page to sign into GitLab and verify they -can access the website. +To restrict access to your website, enable [GitLab Pages Access Control](pages_access_control.md). ## Unpublishing your Pages diff --git a/doc/user/project/pages/lets_encrypt_for_gitlab_pages.md b/doc/user/project/pages/lets_encrypt_for_gitlab_pages.md index 1338c7e58f5b6cb4f19f4f5e34421bcf12433a21..c9bd3e35a5feda28194b3bf01ecf369a43893907 100644 --- a/doc/user/project/pages/lets_encrypt_for_gitlab_pages.md +++ b/doc/user/project/pages/lets_encrypt_for_gitlab_pages.md @@ -21,8 +21,8 @@ open source Certificate Authority. To follow along with this tutorial, we assume you already have: -- Created a [project](getting_started_part_two.md) in GitLab which - contains your website's source code. +- [Created a project](index.md#getting-started) in GitLab + containing your website's source code. - Acquired a domain (`example.com`) and added a [DNS entry](custom_domains_ssl_tls_certification/index.md#set-up-pages-with-a-custom-domain) pointing it to your Pages website. - [Added your domain to your Pages project](custom_domains_ssl_tls_certification/index.md#steps) diff --git a/doc/user/project/pages/pages_access_control.md b/doc/user/project/pages/pages_access_control.md new file mode 100644 index 0000000000000000000000000000000000000000..cd715c6e3b9f0d059907f1c0ac71f4374d1c9075 --- /dev/null +++ b/doc/user/project/pages/pages_access_control.md @@ -0,0 +1,48 @@ +--- +type: reference, howto +--- + +# GitLab Pages Access Control + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/33422) in GitLab 11.5. +> - Available on GitLab.com in GitLab 12.4. + +You can enable Pages access control on your project, so that only +[members of your project](../../permissions.md#project-members-permissions) +(at least Guest) can access your website: + +1. Navigate to your project's **Settings > General > Permissions**. +1. Toggle the **Pages** button to enable the access control. + + NOTE: **Note:** + If you don't see the toggle button, that means that it's not enabled. + Ask your administrator to [enable it](../../../administration/pages/index.md#access-control). + +1. The Pages access control dropdown allows you to set who can view pages hosted + with GitLab Pages, depending on your project's visibility: + + - If your project is private: + - **Only project members**: Only project members will be able to browse the website. + - **Everyone**: Everyone, both logged into and logged out of GitLab, will be able to browse the website, no matter their project membership. + - If your project is internal: + - **Only project members**: Only project members will be able to browse the website. + - **Everyone with access**: Everyone logged into GitLab will be able to browse the website, no matter their project membership. + - **Everyone**: Everyone, both logged into and logged out of GitLab, will be able to browse the website, no matter their project membership. + - If your project is public: + - **Only project members**: Only project members will be able to browse the website. + - **Everyone with access**: Everyone, both logged into and logged out of GitLab, will be able to browse the website, no matter their project membership. + +1. Click **Save changes**. + +The next time someone tries to access your website and the access control is +enabled, they will be presented with a page to sign into GitLab and verify they +can access the website. + +## Terminating a Pages session + +If you want to log out from your Pages website, +you can do so by revoking application access token for GitLab Pages: + +1. Navigate to your profile's **Settings > Applications**. +1. Find **Authorized applications** at the bottom of the page. +1. Find **GitLab Pages** and press the **Revoke** button. diff --git a/doc/user/project/protected_branches.md b/doc/user/project/protected_branches.md index b7c9faeb1dfe7e5263a1bb14110a06b099fbf43e..8ce575222b984dd2acdc2e9fc61b3dd698e715e3 100644 --- a/doc/user/project/protected_branches.md +++ b/doc/user/project/protected_branches.md @@ -55,7 +55,7 @@ the actions that different roles can perform with the protected branch. For example, you could set "Allowed to push" to "No one", and "Allowed to merge" to "Developers + Maintainers", to require _everyone_ to submit a merge request for changes going into the protected branch. This is compatible with workflows like -the [GitLab workflow](../../workflow/gitlab_flow.md). +the [GitLab workflow](../../topics/gitlab_flow.md). However, there are workflows where that is not needed, and only protecting from force pushes and branch removal is useful. For those workflows, you can allow @@ -118,10 +118,11 @@ all matching branches: When a protected branch or wildcard protected branches are set to [**No one** is **Allowed to push**](#using-the-allowed-to-merge-and-allowed-to-push-settings), -Developers (and users with higher [permission levels](../permissions.md)) are allowed -to create a new protected branch, but only via the UI or through the API (to avoid -creating protected branches accidentally from the command line or from a Git -client application). +Developers (and users with higher [permission levels](../permissions.md)) are +allowed to create a new protected branch as long as they are +[**Allowed to merge**](#using-the-allowed-to-merge-and-allowed-to-push-settings). +This can only be done via the UI or through the API (to avoid creating protected +branches accidentally from the command line or from a Git client application). To create a new branch through the user interface: diff --git a/doc/user/project/push_options.md b/doc/user/project/push_options.md index 51c46dbd1d4cd76625f3df157f06d1f66fb224a6..8952f845b96cb9dd86c751c4f19976342b2c8085 100644 --- a/doc/user/project/push_options.md +++ b/doc/user/project/push_options.md @@ -75,3 +75,33 @@ merge request, and target a branch named `my-target-branch`: ```shell git push -o merge_request.create -o merge_request.target=my-target-branch ``` + +Additionally if you want the merge request to merge as soon as the pipeline succeeds you can do: + +```shell +git push -o merge_request.create -o merge_request.target=my-target-branch -o merge_request.merge_when_pipeline_succeeds +``` + +## Useful Git aliases + +As shown above, Git push options can cause Git commands to grow very long. If +you use the same push options frequently, it's useful to create [Git +aliases](https://git-scm.com/book/en/v2/Git-Basics-Git-Aliases). Git aliases +are command line shortcuts for Git which can significantly simplify the use of +long Git commands. + +### Merge when pipeline succeeds alias + +To set up a Git alias for the [merge when pipeline succeeds Git push +option](#push-options-for-merge-requests): + +```shell +git config --global alias.mwps "push -o merge_request.create -o merge_request.target=master -o merge_request.merge_when_pipeline_succeeds" +``` + +Then to quickly push a local branch that will target master and merge when the +pipeline succeeds: + +```shell +git mwps origin <local-branch-name> +``` diff --git a/doc/user/project/releases/img/edit_release_page_v12_5.png b/doc/user/project/releases/img/edit_release_page_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..8b9c502a2ef95b1f252e013ba7683b8f104ca53f Binary files /dev/null and b/doc/user/project/releases/img/edit_release_page_v12_5.png differ diff --git a/doc/user/project/releases/img/milestone_list_with_releases_v12_5.png b/doc/user/project/releases/img/milestone_list_with_releases_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..2e3ec08ba87ce8ee978f7e1237000271f2f440b2 Binary files /dev/null and b/doc/user/project/releases/img/milestone_list_with_releases_v12_5.png differ diff --git a/doc/user/project/releases/img/milestone_with_releases_v12_5.png b/doc/user/project/releases/img/milestone_with_releases_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..8719a58ce4e136be6cd97506da5f058df3c1a5a3 Binary files /dev/null and b/doc/user/project/releases/img/milestone_with_releases_v12_5.png differ diff --git a/doc/workflow/releases/new_tag.png b/doc/user/project/releases/img/new_tag_12_5.png similarity index 100% rename from doc/workflow/releases/new_tag.png rename to doc/user/project/releases/img/new_tag_12_5.png diff --git a/doc/user/project/releases/img/release_edit_button_v12_5.png b/doc/user/project/releases/img/release_edit_button_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..f60b0ecb1be88d34d600ec5667a9d43d31a3da6c Binary files /dev/null and b/doc/user/project/releases/img/release_edit_button_v12_5.png differ diff --git a/doc/user/project/releases/img/release_with_milestone_v12_5.png b/doc/user/project/releases/img/release_with_milestone_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..2a7a2ee9754fb45b8f5c81ccf9ba34fe2498a7d4 Binary files /dev/null and b/doc/user/project/releases/img/release_with_milestone_v12_5.png differ diff --git a/doc/workflow/releases/tags.png b/doc/user/project/releases/img/tags_12_5.png similarity index 100% rename from doc/workflow/releases/tags.png rename to doc/user/project/releases/img/tags_12_5.png diff --git a/doc/user/project/releases/index.md b/doc/user/project/releases/index.md index ceb077ab8af75bb3ae1f959508273a2ea126c6f3..8372aefc94c67cd580cd01e8845480411ae5c53e 100644 --- a/doc/user/project/releases/index.md +++ b/doc/user/project/releases/index.md @@ -58,6 +58,31 @@ links from your GitLab instance. NOTE: **NOTE** You can manipulate links of each release entry with [Release Links API](../../../api/releases/links.md) +#### Releases associated with milestones + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/29020) in GitLab 12.5. + +Releases can optionally be associated with one or more +[project milestones](../milestones/index.md#project-milestones-and-group-milestones) +by including a `milestones` array in your requests to the +[Releases API](../../../api/releases/index.md#create-a-release). + +Releases display this association with the **Milestone** indicator near +the top of the Release block on the **Project overview > Releases** page. + + + +Below is an example of milestones with no Releases, one Release, and two +Releases, respectively. + + + +This relationship is also visible in the **Releases** section of the sidebar +when viewing a specific milestone. Below is an example of a milestone +associated with a large number of Releases. + + + ## Releases list Navigate to **Project > Releases** in order to see the list of releases for a given @@ -65,6 +90,27 @@ project.  +## Editing a release + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/26016) in GitLab 12.5. + +To edit the details of a release, navigate to **Project overview > Releases** and click +the edit button (pencil icon) in the top-right corner of the release you want to modify. + + + +This will bring you to the **Edit Release** page, from which you can +change some of the release's details. + + + +Currently, it is only possible to edit the release title and notes. +To change other release information, such as its tag, associated +milestones, or release date, use the +[Releases API](../../../api/releases/index.md#update-a-release). Editing this +information through the **Edit Release** page is planned for a future version +of GitLab. + ## Notification for Releases > [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/26001) in GitLab 12.4. @@ -77,6 +123,91 @@ following modal window will be then displayed, from which you can select **New r  +## Add release notes to Git tags + +You can add release notes to any Git tag using the notes feature. Release notes +behave like any other markdown form in GitLab so you can write text and +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. + +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 +about the release: + + + +You can also edit an existing tag to add release notes: + + + +## Release Evidence + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/26019) in GitLab 12.5. + +Each time a new release is created, specific related data is collected in +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 +release as a JSON object. In GitLab 12.5, a link to +the Evidence data is provided for [each Release](#releases-list). + +Here's what this object can look like: + +```json +{ + "release": { + "id": 5, + "tag": "v4.0", + "name": "New release", + "project_id": 45, + "project_name": "Project name", + "released_at": "2019-06-28 13:23:40 UTC", + "milestones": [ + { + "id": 11, + "title": "v4.0-rc1", + "state": "closed", + "due_date": "2019-05-12 12:00:00 UTC", + "created_at": "2019-04-17 15:45:12 UTC", + "issues": [ + { + "id": 82, + "title": "The top-right popup is broken", + "author_name": "John Doe", + "author_email": "john@doe.com", + "state": "closed", + "due_date": "2019-05-10 12:00:00 UTC" + }, + { + "id": 89, + "title": "The title of this page is misleading", + "author_name": "Jane Smith", + "author_email": "jane@smith.com", + "state": "closed", + "due_date": "nil" + } + ] + }, + { + "id": 12, + "title": "v4.0-rc2", + "state": "closed", + "due_date": "2019-05-30 18:30:00 UTC", + "created_at": "2019-04-17 15:45:12 UTC", + "issues": [] + } + ] + } +} +``` + <!-- ## 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 new file mode 100644 index 0000000000000000000000000000000000000000..576001d4305a19bae10239eafb70f9291ec90b2b --- /dev/null +++ b/doc/user/project/repository/file_finder.md @@ -0,0 +1,45 @@ +--- +disqus_identifier: 'https://docs.gitlab.com/ee/workflow/file_finder.html' +--- + +# File finder + +> [Introduced][gh-9889] in GitLab 8.4. + +The file finder feature allows you to search for a file in a repository using the +GitLab UI. + +You can find the **Find File** button when in the **Files** section of a +project. + + + +For those who prefer to keep their fingers on the keyboard, there is a +[shortcut button](../../shortcuts.md) as well, which you can invoke from _anywhere_ +in a project. + +Press `t` to launch the File search function when in **Issues**, +**Merge requests**, **Milestones**, even the project's settings. + +Start typing what you are searching for and watch the magic happen. With the +up/down arrows, you go up and down the results, with `Esc` you close the search +and go back to **Files**. + +## How it works + +The File finder feature is powered by the [Fuzzy filter](https://github.com/jeancroy/fuzz-aldrin-plus) library. + +It implements a fuzzy search with highlight, and tries to provide intuitive +results by recognizing patterns that people use while searching. + +For example, consider the [GitLab CE repository][ce] and that we want to open +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. + + + +[gh-9889]: https://github.com/gitlabhq/gitlabhq/pull/9889 "File finder pull request" +[ce]: https://gitlab.com/gitlab-org/gitlab-foss/tree/master "GitLab CE repository" diff --git a/doc/user/project/repository/forking_workflow.md b/doc/user/project/repository/forking_workflow.md new file mode 100644 index 0000000000000000000000000000000000000000..8756760fe4bb9cf85e792a9b72424a518e592015 --- /dev/null +++ b/doc/user/project/repository/forking_workflow.md @@ -0,0 +1,55 @@ +--- +disqus_identifier: 'https://docs.gitlab.com/ee/workflow/forking_workflow.html' +--- + +# Project forking workflow + +Forking a project to your own namespace is useful if you have no write +access to the project you want to contribute to. If you do have write +access or can request it, we recommend working together in the same +repository since it is simpler. See our [GitLab Flow](../../../topics/gitlab_flow.md) +document more information about using branches to work together. + +## Creating a fork + +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. Once you do that, you'll be presented with a screen where you can choose + the namespace to fork to. Only namespaces (groups and your own + namespace) where you have write access to, will be shown. Click on the + namespace to create your fork there. + +  + + **Note:** + If the namespace you chose to fork the project to has another project with + the same path name, you will be presented with a warning that the forking + could not be completed. Try to resolve the error before repeating the forking + process. + +  + +After the forking is done, you can start working on the newly created +repository. There, you will have full [Owner](../../permissions.md) +access, so you can set it up as you please. + +## Merging upstream + +Once you are ready to send your code back to the main project, you need +to create a merge request. Choose your forked project's main branch as +the source and the original project's main branch as the destination and +create the [merge request](../merge_requests/index.md). + + + +You can then assign the merge request to someone to have them review +your changes. Upon pressing the 'Submit Merge Request' button, your +changes will be added to the repository and branch you're merging into. + + + +[gitlab flow]: https://about.gitlab.com/blog/2014/09/29/gitlab-flow/ "GitLab Flow blog post" diff --git a/doc/workflow/img/file_finder_find_button.png b/doc/user/project/repository/img/file_finder_find_button.png similarity index 100% rename from doc/workflow/img/file_finder_find_button.png rename to doc/user/project/repository/img/file_finder_find_button.png diff --git a/doc/workflow/img/file_finder_find_file.png b/doc/user/project/repository/img/file_finder_find_file.png similarity index 100% rename from doc/workflow/img/file_finder_find_file.png rename to doc/user/project/repository/img/file_finder_find_file.png diff --git a/doc/workflow/forking/branch_select.png b/doc/user/project/repository/img/forking_workflow_branch_select.png similarity index 100% rename from doc/workflow/forking/branch_select.png rename to doc/user/project/repository/img/forking_workflow_branch_select.png diff --git a/doc/workflow/img/forking_workflow_choose_namespace.png b/doc/user/project/repository/img/forking_workflow_choose_namespace.png similarity index 100% rename from doc/workflow/img/forking_workflow_choose_namespace.png rename to doc/user/project/repository/img/forking_workflow_choose_namespace.png diff --git a/doc/workflow/img/forking_workflow_fork_button.png b/doc/user/project/repository/img/forking_workflow_fork_button.png similarity index 100% rename from doc/workflow/img/forking_workflow_fork_button.png rename to doc/user/project/repository/img/forking_workflow_fork_button.png diff --git a/doc/workflow/forking/merge_request.png b/doc/user/project/repository/img/forking_workflow_merge_request.png similarity index 100% rename from doc/workflow/forking/merge_request.png rename to doc/user/project/repository/img/forking_workflow_merge_request.png diff --git a/doc/workflow/img/forking_workflow_path_taken_error.png b/doc/user/project/repository/img/forking_workflow_path_taken_error.png similarity index 100% rename from doc/workflow/img/forking_workflow_path_taken_error.png rename to doc/user/project/repository/img/forking_workflow_path_taken_error.png diff --git a/doc/workflow/img/copy_ssh_public_key_button.png b/doc/user/project/repository/img/repository_mirroring_copy_ssh_public_key_button.png similarity index 100% rename from doc/workflow/img/copy_ssh_public_key_button.png rename to doc/user/project/repository/img/repository_mirroring_copy_ssh_public_key_button.png diff --git a/doc/workflow/img/repository_mirroring_force_update.png b/doc/user/project/repository/img/repository_mirroring_force_update.png similarity index 100% rename from doc/workflow/img/repository_mirroring_force_update.png rename to doc/user/project/repository/img/repository_mirroring_force_update.png diff --git a/doc/workflow/img/repository_mirroring_pull_settings_lower.png b/doc/user/project/repository/img/repository_mirroring_pull_settings_lower.png similarity index 100% rename from doc/workflow/img/repository_mirroring_pull_settings_lower.png rename to doc/user/project/repository/img/repository_mirroring_pull_settings_lower.png diff --git a/doc/workflow/img/repository_mirroring_pull_settings_upper.png b/doc/user/project/repository/img/repository_mirroring_pull_settings_upper.png similarity index 100% rename from doc/workflow/img/repository_mirroring_pull_settings_upper.png rename to doc/user/project/repository/img/repository_mirroring_pull_settings_upper.png diff --git a/doc/workflow/img/repository_mirroring_push_settings.png b/doc/user/project/repository/img/repository_mirroring_push_settings.png similarity index 100% rename from doc/workflow/img/repository_mirroring_push_settings.png rename to doc/user/project/repository/img/repository_mirroring_push_settings.png diff --git a/doc/user/project/repository/index.md b/doc/user/project/repository/index.md index c14783b72bde6c5a41dba11140a42dee671377d0..5a6e011220c5350fbd179901a4e63e606d5a78c7 100644 --- a/doc/user/project/repository/index.md +++ b/doc/user/project/repository/index.md @@ -11,7 +11,8 @@ A repository is part of a [project](../index.md), which has a lot of other featu ## Create a repository To create a new repository, all you need to do is -[create a new project](../../../gitlab-basics/create-project.md). +[create a new project](../../../gitlab-basics/create-project.md) or +[fork an existing project](forking_workflow.md). Once you create a new project, you can add new files via UI (read the section below) or via command line. @@ -55,7 +56,7 @@ To get started with the command line, please read through the ### Find files -Use GitLab's [file finder](../../../workflow/file_finder.md) to search for files in a repository. +Use GitLab's [file finder](file_finder.md) to search for files in a repository. ### Supported markup languages and extensions diff --git a/doc/user/project/repository/repository_mirroring.md b/doc/user/project/repository/repository_mirroring.md new file mode 100644 index 0000000000000000000000000000000000000000..a682983ab8328a0152ad6be9ed38539c443a8413 --- /dev/null +++ b/doc/user/project/repository/repository_mirroring.md @@ -0,0 +1,430 @@ +--- +disqus_identifier: 'https://docs.gitlab.com/ee/workflow/repository_mirroring.html' +--- + +# Repository mirroring + +Repository mirroring allows for mirroring of repositories to and from external sources. It can be +used to mirror branches, tags, and commits between repositories. + +A repository mirror at GitLab will be updated automatically. You can also manually trigger an update +at most once every 5 minutes. + +## Overview + +Repository mirroring is useful when you want to use a repository outside of GitLab. + +There are two kinds of repository mirroring supported by GitLab: + +- Push: for mirroring a GitLab repository to another location. +- Pull: for mirroring a repository from another location to GitLab. **(STARTER)** + +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 +immediate update, unless: + +- The mirror is already being updated. +- 5 minutes haven't elapsed since its last update. + +## Use cases + +The following are some possible use cases for repository mirroring: + +- You migrated to GitLab but still need to keep your project in another source. In that case, you + can simply set it up to mirror to GitLab (pull) and all the essential history of commits, tags, + and branches will be available in your GitLab instance. **(STARTER)** +- You have old projects in another source that you don't use actively anymore, but don't want to + remove for archiving purposes. In that case, you can create a push mirror so that your active + GitLab repository can push its changes to the old location. + +## Pushing to a remote repository **(CORE)** + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/249) in GitLab Enterprise Edition 8.7. +> - [Moved to GitLab Core](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/18715) in 10.8. + +For an existing project, you can set up push mirroring as follows: + +1. Navigate to your project's **Settings > Repository** and expand the **Mirroring repositories** section. +1. Enter a repository URL. +1. Select **Push** from the **Mirror direction** dropdown. +1. Select an authentication method from the **Authentication method** dropdown, if necessary. +1. Check the **Only mirror protected branches** box, if necessary. +1. Click the **Mirror repository** button to save the configuration. + + + +When push mirroring is enabled, only push commits directly to the mirrored repository to prevent the +mirror diverging. All changes will end up in the mirrored repository whenever: + +- Commits are pushed to GitLab. +- A [forced update](#forcing-an-update-core) is initiated. + +Changes pushed to files in the repository are automatically pushed to the remote mirror at least: + +- Within five minutes of being received. +- Within one minute if **Only mirror protected branches** is enabled. + +In the case of a diverged branch, you will see an error indicated at the **Mirroring repositories** +section. + +### Push only protected branches **(CORE)** + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/3350) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.3. +> - [Moved to GitLab Core](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/18715) in 10.8. + +You can choose to only push your protected branches from GitLab to your remote repository. + +To use this option, check the **Only mirror protected branches** box when creating a repository +mirror. + +## Setting up a push mirror from GitLab to GitHub **(CORE)** + +To set up a mirror from GitLab to GitHub, you need to follow these steps: + +1. Create a [GitHub personal access token](https://help.github.com/en/articles/creating-a-personal-access-token-for-the-command-line) with the `public_repo` box checked. +1. Fill in the **Git repository URL** field using this format: `https://<your_github_username>@github.com/<your_github_group>/<your_github_project>.git`. +1. Fill in **Password** field with your GitHub personal access token. +1. Click the **Mirror repository** button. + +The mirrored repository will be listed. For example, `https://*****:*****@github.com/<your_github_group>/<your_github_project>.git`. + +The repository will push soon. To force a push, click the appropriate button. + +## Setting up a push mirror to another GitLab instance with 2FA activated + +1. On the destination GitLab instance, create a [personal access token](../../profile/personal_access_tokens.md) with `API` scope. +1. On the source GitLab instance: + 1. Fill in the **Git repository URL** field using this format: `https://oauth2@<destination host>/<your_gitlab_group_or_name>/<your_gitlab_project>.git`. + 1. Fill in **Password** field with the GitLab personal access token created on the destination GitLab instance. + 1. Click the **Mirror repository** button. + +## Pulling from a remote repository **(STARTER)** + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/51) in GitLab Enterprise Edition 8.2. +> - [Added Git LFS support](https://gitlab.com/gitlab-org/gitlab/issues/10871) in [GitLab Starter](https://about.gitlab.com/pricing/) 11.11. + +NOTE: **Note:** This feature [is available for free](https://gitlab.com/gitlab-org/gitlab/issues/10361) to +GitLab.com users until March 22nd, 2020. + +You can set up a repository to automatically have its branches, tags, and commits updated from an +upstream repository. + +This is useful when a repository you're interested in is located on a different server, and you want +to be able to browse its content and its activity using the familiar GitLab interface. + +To configure mirror pulling for an existing project: + +1. Navigate to your project's **Settings > Repository** and expand the **Mirroring repositories** + section. +1. Enter a repository URL. +1. Select **Pull** from the **Mirror direction** dropdown. +1. Select an authentication method from the **Authentication method** dropdown, if necessary. +1. If necessary, check the following boxes: + - **Overwrite diverged branches**. + - **Trigger pipelines for mirror updates**. + - **Only mirror protected branches**. +1. Click the **Mirror repository** button to save the configuration. + + + +--- + + + +Because GitLab is now set to pull changes from the upstream repository, you should not push commits +directly to the repository on GitLab. Instead, any commits should be pushed to the upstream repository. +Changes pushed to the upstream repository will be pulled into the GitLab repository, either: + +- Automatically within a certain period of time. +- When a [forced update](#forcing-an-update-core) is initiated. + +CAUTION: **Caution:** +If you do manually update a branch in the GitLab repository, the branch will become diverged from +upstream and GitLab will no longer automatically update this branch to prevent any changes from being lost. + +### How it works + +Once the pull mirroring feature has been enabled for a repository, the repository is added to a queue. + +Once per minute, a Sidekiq cron job schedules repository mirrors to update, based on: + +- The capacity available. This is determined by Sidekiq settings. For GitLab.com, see [GitLab.com Sidekiq settings](../../gitlab_com/index.md#sidekiq). +- The number of repository mirrors already in the queue that are due to be updated. Being due depends on when the repository mirror was last updated and how many times it's been retried. + +Repository mirrors are updated as Sidekiq becomes available to process them. If the process of updating the repository mirror: + +- Succeeds, an update will be enqueued again with at least a 30 minute wait. +- Fails (for example, a branch diverged from upstream), it will be attempted again later. Mirrors can fail + up to 14 times before they will not be enqueued for update again. + +### SSH authentication + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/2551) for Pull mirroring in [GitLab Starter](https://about.gitlab.com/pricing/) 9.5. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/22982) for Push mirroring in [GitLab Core](https://about.gitlab.com/pricing/) 11.6 + +SSH authentication is mutual: + +- You have to prove to the server that you're allowed to access the repository. +- The server also has to prove to *you* that it's who it claims to be. + +You provide your credentials as a password or public key. The server that the +other repository resides on provides its credentials as a "host key", the +fingerprint of which needs to be verified manually. + +If you're mirroring over SSH (that is, using an `ssh://` URL), you can authenticate using: + +- Password-based authentication, just as over HTTPS. +- Public key authentication. This is often more secure than password authentication, + especially when the other repository supports [Deploy Keys](../../../ssh/README.md#deploy-keys). + +To get started: + +1. Navigate to your project's **Settings > Repository** and expand the **Mirroring repositories** section. +1. Enter an `ssh://` URL for mirroring. + +NOTE: **Note:** +SCP-style URLs (that is, `git@example.com:group/project.git`) are not supported at this time. + +Entering the URL adds two buttons to the page: + +- **Detect host keys**. +- **Input host keys manually**. + +If you click the: + +- **Detect host keys** button, GitLab will fetch the host keys from the server and display the fingerprints. +- **Input host keys manually** button, a field is displayed where you can paste in host keys. + +Assuming you used the former, you now need to verify that the fingerprints are +those you expect. GitLab.com and other code hosting sites publish their +fingerprints in the open for you to check: + +- [AWS CodeCommit](https://docs.aws.amazon.com/codecommit/latest/userguide/regions.html#regions-fingerprints) +- [Bitbucket](https://confluence.atlassian.com/bitbucket/ssh-keys-935365775.html) +- [GitHub](https://help.github.com/en/articles/githubs-ssh-key-fingerprints) +- [GitLab.com](../../gitlab_com/index.md#ssh-host-keys-fingerprints) +- [Launchpad](https://help.launchpad.net/SSHFingerprints) +- [Savannah](http://savannah.gnu.org/maintenance/SshAccess/) +- [SourceForge](https://sourceforge.net/p/forge/documentation/SSH%20Key%20Fingerprints/) + +Other providers will vary. If you're running self-managed GitLab, or otherwise +have access to the server for the other repository, you can securely gather the +key fingerprints: + +```sh +$ cat /etc/ssh/ssh_host*pub | ssh-keygen -E md5 -l -f - +256 MD5:f4:28:9f:23:99:15:21:1b:bf:ed:1f:8e:a0:76:b2:9d root@example.com (ECDSA) +256 MD5:e6:eb:45:8a:3c:59:35:5f:e9:5b:80:12:be:7e:22:73 root@example.com (ED25519) +2048 MD5:3f:72:be:3d:62:03:5c:62:83:e8:6e:14:34:3a:85:1d root@example.com (RSA) +``` + +NOTE: **Note:** +You may need to exclude `-E md5` for some older versions of SSH. + +When mirroring the repository, GitLab will now check that at least one of the +stored host keys matches before connecting. This can prevent malicious code from +being injected into your mirror, or your password being stolen. + +### SSH public key authentication + +To use SSH public key authentication, you'll also need to choose that option +from the **Authentication method** dropdown. When the mirror is created, +GitLab generates a 4096-bit RSA key that can be copied by clicking the **Copy SSH public key** button. + + + +You then need to add the public SSH key to the other repository's configuration: + +- If the other repository is hosted on GitLab, you should add the public SSH key + as a [Deploy Key](../../../ssh/README.md#deploy-keys). +- If the other repository is hosted elsewhere, you may need to add the key to + your user's `authorized_keys` file. Paste the entire public SSH key into the + file on its own line and save it. + +If you need to change the key at any time, you can remove and re-add the mirror +to generate a new key. You'll have to update the other repository with the new +key to keep the mirror running. + +### Overwrite diverged branches **(STARTER)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/4559) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.6. + +You can choose to always update your local branches with remote versions, even if they have +diverged from the remote. + +CAUTION: **Caution:** +For mirrored branches, enabling this option results in the loss of local changes. + +To use this option, check the **Overwrite diverged branches** box when creating a repository mirror. + +### Only mirror protected branches **(STARTER)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/3326) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.3. + +You can choose to pull mirror only the protected branches from your remote repository to GitLab. +Non-protected branches are not mirrored and can diverge. + +To use this option, check the **Only mirror protected branches** box when creating a repository mirror. + +### Hard failure **(STARTER)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/3117) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.2. + +Once the mirroring process is unsuccessfully retried 14 times in a row, it will get marked as hard +failed. This will become visible in either the: + +- Project's main dashboard. +- Pull mirror settings page. + +When a project is hard failed, it will no longer get picked up for mirroring. A user can resume the +project mirroring again by [Forcing an update](#forcing-an-update-core). + +### Trigger update using API **(STARTER)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/3453) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.3. + +Pull mirroring uses polling to detect new branches and commits added upstream, often minutes +afterwards. If you notify GitLab by [API](../../../api/projects.md#start-the-pull-mirroring-process-for-a-project-starter), +updates will be pulled immediately. + +For more information, see [Start the pull mirroring process for a Project](../../../api/projects.md#start-the-pull-mirroring-process-for-a-project-starter). + +## Forcing an update **(CORE)** + +While mirrors are scheduled to update automatically, you can always force an update by using the +update button which is available on the **Mirroring repositories** section of the **Repository Settings** page. + + + +## Bidirectional mirroring **(STARTER)** + +CAUTION: **Caution:** +Bidirectional mirroring may cause conflicts. + +If you configure a GitLab repository to both pull from, and push to, the same remote source, there +is no guarantee that either repository will update correctly. If you set up a repository for +bidirectional mirroring, you should prepare for the likely conflicts by deciding who will resolve +them and how they will be resolved. + +Rewriting any mirrored commit on either remote will cause conflicts and mirroring to fail. This can +be prevented by: + +- [Pulling only protected branches](#only-mirror-protected-branches-starter). +- [Pushing only protected branches](#push-only-protected-branches-core). + +You should [protect the branches](../protected_branches.md) you wish to mirror on both +remotes to prevent conflicts caused by rewriting history. + +Bidirectional mirroring also creates a race condition where commits made close together to the same +branch causes conflicts. The race condition can be mitigated by reducing the mirroring delay by using +a [Push event webhook](../integrations/webhooks.md#push-events) to trigger an immediate +pull to GitLab. Push mirroring from GitLab is rate limited to once per minute when only push mirroring +protected branches. + +### Preventing conflicts using a `pre-receive` hook + +CAUTION: **Warning:** +The solution proposed will negatively impact the performance of +Git push operations because they will be proxied to the upstream Git +repository. + +A server-side `pre-receive` hook can be used to prevent the race condition +described above by only accepting the push after first pushing the commit to +the upstream Git repository. In this configuration one Git repository acts as +the authoritative upstream, and the other as downstream. The `pre-receive` hook +will be installed on the downstream repository. + +Read about [configuring custom Git hooks](../../../administration/custom_hooks.md) on the GitLab server. + +A sample `pre-receive` hook is provided below. + +```bash +#!/usr/bin/env bash + +# --- Assume only one push mirror target +# Push mirroring remotes are named `remote_mirror_<id>`, this finds the first remote and uses that. +TARGET_REPO=$(git remote | grep -m 1 remote_mirror) + +proxy_push() +{ + # --- Arguments + OLDREV=$(git rev-parse $1) + NEWREV=$(git rev-parse $2) + REFNAME="$3" + + # --- Pattern of branches to proxy pushes + whitelisted=$(expr "$branch" : "\(master\)") + + case "$refname" in + refs/heads/*) + branch=$(expr "$refname" : "refs/heads/\(.*\)") + + if [ "$whitelisted" = "$branch" ]; then + error="$(git push --quiet $TARGET_REPO $NEWREV:$REFNAME 2>&1)" + fail=$? + + if [ "$fail" != "0" ]; then + echo >&2 "" + echo >&2 " Error: updates were rejected by upstream server" + echo >&2 " This is usually caused by another repository pushing changes" + echo >&2 " to the same ref. You may want to first integrate remote changes" + echo >&2 "" + return + fi + fi + ;; + esac +} + +# Allow dual mode: run from the command line just like the update hook, or +# if no arguments are given then run as a hook script +if [ -n "$1" -a -n "$2" -a -n "$3" ]; then + # Output to the terminal in command line mode - if someone wanted to + # resend an email; they could redirect the output to sendmail + # themselves + PAGER= proxy_push $2 $3 $1 +else + # Push is proxied upstream one ref at a time. Because of this it is possible + # for some refs to succeed, and others to fail. This will result in a failed + # push. + while read oldrev newrev refname + do + proxy_push $oldrev $newrev $refname + done +fi +``` + +### Mirroring with Perforce Helix via Git Fusion **(STARTER)** + +CAUTION: **Warning:** +Bidirectional mirroring should not be used as a permanent configuration. Refer to +[Migrating from Perforce Helix](../import/perforce.md) for alternative migration approaches. + +[Git Fusion](https://www.perforce.com/manuals/git-fusion/#Git-Fusion/section_avy_hyc_gl.html) provides a Git interface +to [Perforce Helix](https://www.perforce.com/products) which can be used by GitLab to bidirectionally +mirror projects with GitLab. This may be useful in some situations when migrating from Perforce Helix +to GitLab where overlapping Perforce Helix workspaces cannot be migrated simultaneously to GitLab. + +If using mirroring with Perforce Helix, you should only mirror protected branches. Perforce Helix +will reject any pushes that rewrite history. Only the fewest number of branches should be mirrored +due to the performance limitations of Git Fusion. + +When configuring mirroring with Perforce Helix via Git Fusion, the following Git Fusion +settings are recommended: + +- `change-pusher` should be disabled. Otherwise, every commit will be rewritten as being committed + by the mirroring account, rather than being mapped to existing Perforce Helix users or the `unknown_git` user. +- `unknown_git` user will be used as the commit author if the GitLab user does not exist in + Perforce Helix. + +Read about [Git Fusion settings on Perforce.com](https://www.perforce.com/manuals/git-fusion/Content/Git-Fusion/section_vss_bdw_w3.html#section_zdp_zz1_3l). + +## Troubleshooting + +Should an error occur during a push, GitLab will display an "Error" highlight for that repository. Details on the error can then be seen by hovering over the highlight text. + +### 13:Received RST_STREAM with error code 2 with GitHub + +If you receive an "13:Received RST_STREAM with error code 2" while mirroring to a GitHub repository, your GitHub settings might be set to block pushes that expose your email address used in commits. Either set your email address on GitHub to be public, or disable the [Block command line pushes that expose my email](https://github.com/settings/emails) setting. diff --git a/doc/user/project/settings/index.md b/doc/user/project/settings/index.md index 131999dbf6026fda10c669213251db52781fea71..2dc507901d0f07516d019530eb62f55d4e04170d 100644 --- a/doc/user/project/settings/index.md +++ b/doc/user/project/settings/index.md @@ -49,10 +49,11 @@ Add an [issue description template](../description_templates.md#description-temp Set up your project's merge request settings: - Set up the merge request method (merge commit, [fast-forward merge](../merge_requests/fast_forward_merge.html)). -- Merge request [description templates](../description_templates.md#description-templates). +- Add merge request [description templates](../description_templates.md#description-templates). - Enable [merge request approvals](../merge_requests/merge_request_approvals.md). **(STARTER)** -- Enable [merge only of pipeline succeeds](../merge_requests/merge_when_pipeline_succeeds.md). -- Enable [merge only when all discussions are resolved](../../discussions/index.md#only-allow-merge-requests-to-be-merged-if-all-threads-are-resolved). +- 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)  diff --git a/doc/user/project/time_tracking.md b/doc/user/project/time_tracking.md new file mode 100644 index 0000000000000000000000000000000000000000..9cdee0f2b5a03843ebadeb0a9911d2196321eae5 --- /dev/null +++ b/doc/user/project/time_tracking.md @@ -0,0 +1,92 @@ +--- +type: reference +disqus_identifier: 'https://docs.gitlab.com/ee/workflow/time_tracking.html' +--- + +# Time Tracking + +> Introduced in GitLab 8.14. + +Time Tracking allows you to track estimates and time spent on issues and merge +requests within GitLab. + +## Overview + +Time Tracking allows you to: + +- Record the time spent working on an issue or a merge request. +- Add an estimate of the amount of time needed to complete an issue or a merge + request. + +You don't have to indicate an estimate to enter the time spent, and vice versa. + +Data about time tracking is shown on the issue/merge request sidebar, as shown +below. + + + +## How to enter data + +Time Tracking uses two [quick actions](quick_actions.md) +that GitLab introduced with this new feature: `/spend` and `/estimate`. + +Quick actions can be used in the body of an issue or a merge request, but also +in a comment in both an issue or a merge request. + +Below is an example of how you can use those new quick actions inside a comment. + + + +Adding time entries (time spent or estimates) is limited to project members. + +### Estimates + +To enter an estimate, write `/estimate`, followed by the time. For example, if +you need to enter an estimate of 3 days, 5 hours and 10 minutes, you would write +`/estimate 3d 5h 10m`. Time units that we support are listed at the bottom of +this help page. + +Every time you enter a new time estimate, any previous time estimates will be +overridden by this new value. There should only be one valid estimate in an +issue or a merge request. + +To remove an estimation entirely, use `/remove_estimate`. + +### Time spent + +To enter a time spent, use `/spend 3d 5h 10m`. + +Every new time spent entry will be added to the current total time spent for the +issue or the merge request. + +You can remove time by entering a negative amount: `/spend -3d` will remove 3 +days from the total time spent. You can't go below 0 minutes of time spent, +so GitLab will automatically reset the time spent if you remove a larger amount +of time compared to the time that was entered already. + +To remove all the time spent at once, use `/remove_time_spent`. + +## Configuration + +The following time units are available: + +- Months (mo) +- Weeks (w) +- Days (d) +- Hours (h) +- Minutes (m) + +Default conversion rates are 1mo = 4w, 1w = 5d and 1d = 8h. + +### Limit displayed units to hours **(CORE ONLY)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/29469/) in GitLab 12.1. + +In GitLab self-managed instances, the display of time units can be limited to +hours through the option in **Admin Area > Settings > Preferences** under **Localization**. + +With this option enabled, `75h` is displayed instead of `1w 4d 3h`. + +## Other interesting links + +- [Time Tracking landing page in the GitLab handbook](https://about.gitlab.com/solutions/time-tracking/) diff --git a/doc/user/search/advanced_search_syntax.md b/doc/user/search/advanced_search_syntax.md index d65dd32fe111cdeae9a45c604ac5e953db0baa54..faa3a1181373dbae03d16e2d6fb67d0649377ea6 100644 --- a/doc/user/search/advanced_search_syntax.md +++ b/doc/user/search/advanced_search_syntax.md @@ -17,6 +17,8 @@ The Advanced Syntax Search is a subset of the [Advanced Global Search](advanced_global_search.md), which you can use if you want to have more specific search results. +Advanced Global Search only supports searching the [default branch](../project/repository/branches/index.md#default-branch). + ## Use cases Let's say for example that the product you develop relies on the code of another diff --git a/doc/user/search/img/issue_search_filter_v12_5.png b/doc/user/search/img/issue_search_filter_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..1e2dd3d98a3eb20a702e9e371bdf9ba34b5aee2c Binary files /dev/null and b/doc/user/search/img/issue_search_filter_v12_5.png differ diff --git a/doc/user/search/index.md b/doc/user/search/index.md index 8d7b4a429aa4ea4642329bce1d7e0a4732b6c5cf..bc31052b7586b243fe8765f8cf982245ad2d812d 100644 --- a/doc/user/search/index.md +++ b/doc/user/search/index.md @@ -32,13 +32,14 @@ on the search field on the top-right of your screen: 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, -label, weight, and 'my-reaction' (based on your emoji votes). When done, press **Enter** on your keyboard to filter the issues. +release, label, weight, confidentiality, and "my-reaction" (based on your emoji votes). +When done, press **Enter** on your keyboard to filter the issues. - + 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, -milestone, and label. +approver, milestone, release, label, "my-reaction", "work in progess" status, and target branch. ### Filtering by **None** / **Any** @@ -99,8 +100,8 @@ quickly access issues and merge requests created or assigned to you within that ## To-Do List -Your [To-Do List](../../workflow/todos.md#gitlab-to-do-list) can be searched by "to do" and "done". -You can [filter](../../workflow/todos.md#filtering-your-to-do-list) them per project, +Your [To-Do List](../todos.md#gitlab-to-do-list) can be searched by "to do" and "done". +You can [filter](../todos.md#filtering-your-to-do-list) them per project, author, type, and action. Also, you can sort them by [**Label priority**](../../user/project/labels.md#label-priority), **Last created** and **Oldest created**. diff --git a/doc/user/shortcuts.md b/doc/user/shortcuts.md new file mode 100644 index 0000000000000000000000000000000000000000..54e7938d8f7c07bf9a3cebcadb8f2e273fc08005 --- /dev/null +++ b/doc/user/shortcuts.md @@ -0,0 +1,135 @@ +--- +type: reference +disqus_identifier: 'https://docs.gitlab.com/ee/workflow/shortcuts.html' +--- + +# GitLab keyboard shortcuts + +GitLab has many useful keyboard shortcuts to make it easier to access different features. +You can see the quick reference sheet within GitLab itself with <kbd>Shift</kbd> + <kbd>?</kbd>. + +The [Global Shortcuts](#global-shortcuts) work from any area of GitLab, but you must +be in specific pages for the other shortcuts to be available, as explained in each +section below. + +## Global Shortcuts + +These shortcuts are available in most areas of GitLab + +| Keyboard Shortcut | Description | +| ------------------------------- | ----------- | +| <kbd>?</kbd> | Show/hide shortcut reference sheet. | +| <kbd>Shift</kbd> + <kbd>p</kbd> | Go to your Projects page. | +| <kbd>Shift</kbd> + <kbd>g</kbd> | Go to your Groups page. | +| <kbd>Shift</kbd> + <kbd>a</kbd> | Go to your Activity page. | +| <kbd>Shift</kbd> + <kbd>l</kbd> | Go to your Milestones page. | +| <kbd>Shift</kbd> + <kbd>s</kbd> | Go to your Snippets page. | +| <kbd>s</kbd> | Put cursor in the issues/merge requests search. | +| <kbd>Shift</kbd> + <kbd>i</kbd> | Go to your Issues page. | +| <kbd>Shift</kbd> + <kbd>m</kbd> | Go to your Merge requests page.| +| <kbd>Shift</kbd> + <kbd>t</kbd> | Go to your To-Do List page. | +| <kbd>p</kbd> + <kbd>b</kbd> | Show/hide the Performance Bar. | + +Additionally, the following shortcuts are available when editing text in text fields, +for example comments, replies, or issue and merge request descriptions: + +| Keyboard Shortcut | Description | +| ---------------------------------------------------------------------- | ----------- | +| <kbd>↑</kbd> | Edit your last comment. You must be in a blank text field below a thread, and you must already have at least one comment in the thread. | +| <kbd>⌘</kbd> (Mac) / <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>p</kbd> | Toggle Markdown preview, when editing text in a text field that has **Write** and **Preview** tabs at the top. | + +## Project + +These shortcuts are available from any page within a project. You must type them +relatively quickly to work, and they will take you to another page in the project. + +| Keyboard Shortcut | Description | +| --------------------------- | ----------- | +| <kbd>g</kbd> + <kbd>p</kbd> | Go to the project home page (**Project > Details**). | +| <kbd>g</kbd> + <kbd>v</kbd> | Go to the project activity feed (**Project > Activity**). | +| <kbd>g</kbd> + <kbd>r</kbd> | Go to the project releases list (**Project > Releases**). | +| <kbd>g</kbd> + <kbd>f</kbd> | Go to the [project files](#project-files) list (**Repository > Files**). | +| <kbd>t</kbd> | Go to the project file search page. (**Repository > Files**, click **Find Files**). | +| <kbd>g</kbd> + <kbd>c</kbd> | Go to the project commits list (**Repository > Commits**). | +| <kbd>g</kbd> + <kbd>n</kbd> | Go to the [repository graph](#repository-graph) page (**Repository > Graph**). | +| <kbd>g</kbd> + <kbd>d</kbd> | Go to repository charts (**Repository > Charts**). | +| <kbd>g</kbd> + <kbd>i</kbd> | Go to the project issues list (**Issues > List**). | +| <kbd>i</kbd> | Go to the New Issue page (**Issues**, click **New Issue** ). | +| <kbd>g</kbd> + <kbd>b</kbd> | Go to the project issue boards list (**Issues > Boards**). | +| <kbd>g</kbd> + <kbd>m</kbd> | Go to the project merge requests list (**Merge Requests**). | +| <kbd>g</kbd> + <kbd>j</kbd> | Go to the CI/CD jobs list (**CI/CD > Jobs**). | +| <kbd>g</kbd> + <kbd>l</kbd> | Go to the project metrics (**Operations > Metrics**). | +| <kbd>g</kbd> + <kbd>e</kbd> | Go to the project environments (**Operations > Environments**). | +| <kbd>g</kbd> + <kbd>k</kbd> | Go to the project Kubernetes cluster integration page (**Operations > Kubernetes**). Note that you must have at least [`maintainer` permissions](permissions.md) to access this page. | +| <kbd>g</kbd> + <kbd>s</kbd> | Go to the project snippets list (**Snippets**). | +| <kbd>g</kbd> + <kbd>w</kbd> | Go to the project wiki (**Wiki**), if enabled. | + +### Issues and Merge Requests + +These shortcuts are available when viewing issues and merge requests. + +| Keyboard Shortcut | Description | +| ---------------------------- | ----------- | +| <kbd>e</kbd> | Edit description. | +| <kbd>a</kbd> | Change assignee. | +| <kbd>m</kbd> | Change milestone. | +| <kbd>l</kbd> | Change label. | +| <kbd>r</kbd> | Start writing a comment. If any text is selected, it will be quoted in the comment. Can't be used to reply within a thread. | +| <kbd>n</kbd> | Move to next unresolved discussion (Merge requests only). | +| <kbd>p</kbd> | Move to previous unresolved discussion (Merge requests only). | +| <kbd>]</kbd> or <kbd>j</kbd> | Move to next file (Merge requests only). | +| <kbd>[</kbd> or <kbd>k</kbd> | Move to previous file (Merge requests only). | + +### Project Files + +These shortcuts are available when browsing the files in a project (navigate to +**Repository** > **Files**): + +| Keyboard Shortcut | Description | +| ----------------- | ----------- | +| <kbd>↑</kbd> | Move selection up. | +| <kbd>↓</kbd> | Move selection down. | +| <kbd>enter</kbd> | Open selection. | +| <kbd>esc</kbd> | Go back to file list screen (only while searching for files, **Repository > Files** then click on **Find File**). | +| <kbd>y</kbd> | Go to file permalink (only while viewing a file). | + +### Web IDE + +These shortcuts are available when editing a file with the [Web IDE](project/web_ide/index.md): + +| Keyboard Shortcut | Description | +| ------------------------------------------------------- | ----------- | +| <kbd>⌘</kbd> (Mac) / <kbd>Ctrl</kbd> + <kbd>p</kbd> | Search for, and then open another file for editing. | +| <kbd>⌘</kbd> (Mac) / <kbd>Ctrl</kbd> + <kbd>Enter</kbd> | Commit (when editing the commit message). | + +### Repository Graph + +These shortcuts are available when viewing the project [repository graph](project/repository/index.md#repository-graph) +page (navigate to **Repository > Graph**): + +| Keyboard Shortcut | Description | +| ------------------------------------------------------------------ | ----------- | +| <kbd>â†</kbd> or <kbd>h</kbd> | Scroll left. | +| <kbd>→</kbd> or <kbd>l</kbd> | Scroll right. | +| <kbd>↑</kbd> or <kbd>k</kbd> | Scroll up. | +| <kbd>↓</kbd> or <kbd>j</kbd> | Scroll down. | +| <kbd>Shift</kbd> + <kbd>↑</kbd> or <kbd>Shift</kbd> + <kbd>k</kbd> | Scroll to top. | +| <kbd>Shift</kbd> + <kbd>↓</kbd> or <kbd>Shift</kbd> + <kbd>j</kbd> | Scroll to bottom. | + +### Wiki pages + +This shortcut is available when viewing a [wiki page](project/wiki/index.md): + +| Keyboard Shortcut | Description | +| ----------------- | ----------- | +| <kbd>e</kbd> | Edit wiki page. | + +## Epics **(ULTIMATE)** + +These shortcuts are available when viewing [Epics](group/epics/index.md): + +| Keyboard Shortcut | Description | +| ----------------- | ----------- | +| <kbd>r</kbd> | Start writing a comment. If any text is selected, it will be quoted in the comment. Can't be used to reply within a thread. | +| <kbd>e</kbd> | Edit description. | +| <kbd>l</kbd> | Change label. | diff --git a/doc/user/todos.md b/doc/user/todos.md new file mode 100644 index 0000000000000000000000000000000000000000..d53baa688a40c68e74b4a8c122b1f345a3a03b06 --- /dev/null +++ b/doc/user/todos.md @@ -0,0 +1,142 @@ +--- +disqus_identifier: 'https://docs.gitlab.com/ee/workflow/todos.html' +--- + +# GitLab To-Do List + +> [Introduced][ce-2817] in GitLab 8.5. + +When you log into GitLab, you normally want to see where you should spend your +time, take some action, or know what you need to keep an eye on without +a huge pile of e-mail notifications. GitLab is where you do your work, +so being able to get started quickly is important. + +Your To-Do List offers a chronological list of items that are waiting for your input, all +in a simple dashboard. + + + +You can quickly access your To-Do List by clicking the checkmark icon next to the +search bar in the top navigation. If the count is: + +- Less than 100, the number in blue is the number of To-Do items. +- 100 or more, the number displays as 99+. The exact number displays + on the To-Do List. +you still have open. Otherwise, the number displays as 99+. The exact number +displays on the To-Do List. + + + +## What triggers a To Do + +A To Do displays on your To-Do List when: + +- An issue or merge request is assigned to you +- You are `@mentioned` in the description or comment of an: + - Issue + - Merge Request + - Epic **(ULTIMATE)** +- You are `@mentioned` in a comment on a commit +- A job in the CI pipeline running for your merge request failed, but this + job is not allowed to fail +- An open merge request becomes unmergeable due to conflict, and you are either: + - The author + - Have set it to automatically merge once the pipeline succeeds + +To-do triggers are not affected by [GitLab Notification Email settings](profile/notifications.md). + +NOTE: **Note:** +When a user no longer has access to a resource related to a To Do (like an issue, merge request, project, or group) the related To-Do items are deleted within the next hour for security reasons. The delete is delayed to prevent data loss, in case the user's access was revoked by mistake. + +### Directly addressing a To Do + +> [Introduced][ce-7926] in GitLab 9.0. + +If you are mentioned at the start of a line, the To Do you receive will be listed +as 'directly addressed'. For example, in this comment: + +```markdown +@alice What do you think? cc: @bob + +- @carol can you please have a look? + +>>> +@dan what do you think? +>>> + +@erin @frank thank you! +``` + +The people receiving directly addressed To-Do items are `@alice`, `@erin`, and +`@frank`. Directly addressed To-Do items only differ from mentions in their type +for filtering purposes; otherwise, they appear as normal. + +### Manually creating a To Do + +You can also add the following to your To-Do List by clicking the **Add a To Do** button on an: + +- Issue +- Merge Request +- Epic **(ULTIMATE)** + + + +## Marking a To Do as done + +Any action to the following will mark the corresponding To Do as done: + +- Issue +- Merge Request +- Epic **(ULTIMATE)** + +Actions that dismiss To-Do items include: + +- Changing the assignee +- Changing the milestone +- Adding/removing a label +- Commenting on the issue + +Your To-Do List is personal, and items are only marked as done if the action comes from +you. If you close the issue or merge request, your To Do is automatically +marked as done. + +To prevent other users from closing issues without you being notified, if someone else closes, merges, or takes action on the any of the following, your To Do will remain pending: + +- Issue +- Merge request +- Epic **(ULTIMATE)** + +There is just one To Do for each of these, so mentioning a user a hundred times in an issue will only trigger one To Do. + +If no action is needed, you can manually mark the To Do as done by clicking the +corresponding **Done** button, and it will disappear from your To-Do List. + + + +You can also mark a To Do as done by clicking the **Mark as done** button in the sidebar of the following: + +- Issue +- Merge Request +- Epic **(ULTIMATE)** + + + +You can mark all your To-Do items as done at once by clicking the **Mark all as +done** button. + +## Filtering your To-Do List + +There are four kinds of filters you can use on your To-Do List. + +| Filter | Description | +| ------- | ----------- | +| Project | Filter by project | +| Group | Filter by group | +| Author | Filter by the author that triggered the To Do | +| Type | Filter by issue, merge request, or epic **(ULTIMATE)** | +| Action | Filter by the action that triggered the To Do | + +You can also filter by more than one of these at the same time. The possible Actions are `Any Action`, `Assigned`, `Mentioned`, `Added`, `Pipelines`, and `Directly Addressed`, [as described above](#what-triggers-a-to-do). + +[ce-2817]: https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/2817 +[ce-7926]: https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/7926 diff --git a/doc/workflow/README.md b/doc/workflow/README.md index c6396672e595ce1872ccfb235142405f1cdc3f50..9836255932a670f15541f7bde50aee5daf790741 100644 --- a/doc/workflow/README.md +++ b/doc/workflow/README.md @@ -2,53 +2,10 @@ comments: false --- -# Workflow +# Workflow (Deprecated) -- [Automatic issue closing](../user/project/issues/managing_issues.md#closing-issues-automatically) -- [Change your time zone](timezone.md) -- [Cycle Analytics](../user/project/cycle_analytics.md) -- [Description templates](../user/project/description_templates.md) -- [Feature branch workflow](workflow.md) -- [GitLab Flow](gitlab_flow.md) -- [Groups](../user/group/index.md) -- Issues - The GitLab Issue Tracker is an advanced and complete tool for - tracking the evolution of a new idea or the process of solving a problem. - - [Exporting Issues](../user/project/issues/csv_export.md) **(STARTER)** Export issues as a CSV, emailed as an attachment. - - [Confidential issues](../user/project/issues/confidential_issues.md) - - [Due date for issues](../user/project/issues/due_dates.md) -- [Issue Board](../user/project/issue_board.md) -- [Keyboard shortcuts](shortcuts.md) -- [File finder](file_finder.md) -- [File lock](../user/project/file_lock.md) **(PREMIUM)** -- [Labels](../user/project/labels.md) -- [Issue weight](issue_weight.md) **(STARTER)** -- [Notification emails](notifications.md) -- [Projects](../user/project/index.md) -- [Project forking workflow](forking_workflow.md) -- [Project users](../user/project/members/index.md) -- [Protected branches](../user/project/protected_branches.md) -- [Protected tags](../user/project/protected_tags.md) -- [Quick Actions](../user/project/quick_actions.md) -- [Sharing projects with groups](../user/project/members/share_project_with_groups.md) -- [Time tracking](time_tracking.md) -- [Web Editor](../user/project/repository/web_editor.md) -- [Releases](releases.md) -- [Milestones](../user/project/milestones/index.md) -- [Merge Requests](../user/project/merge_requests/index.md) - - [Authorization for merge requests](../user/project/merge_requests/authorization_for_merge_requests.md) - - [Cherry-pick changes](../user/project/merge_requests/cherry_pick_changes.md) - - [Merge when pipeline succeeds](../user/project/merge_requests/merge_when_pipeline_succeeds.md) - - [Resolve threads in merge requests reviews](../user/discussions/index.md) - - [Resolve merge conflicts in the UI](../user/project/merge_requests/resolve_conflicts.md) - - [Revert changes in the UI](../user/project/merge_requests/revert_changes.md) - - [Merge requests versions](../user/project/merge_requests/versions.md) - - ["Work In Progress" merge requests](../user/project/merge_requests/work_in_progress_merge_requests.md) - - [Fast-forward merge requests](../user/project/merge_requests/fast_forward_merge.md) - - [Merge request approvals](../user/project/merge_requests/merge_request_approvals.md) **(STARTER)** -- [Repository mirroring](repository_mirroring.md) **(STARTER)** -- [Service Desk](../user/project/service_desk.md) **(PREMIUM)** -- [Manage large binaries with Git LFS](lfs/manage_large_binaries_with_git_lfs.md) -- [Importing from SVN, GitHub, Bitbucket, etc](importing/README.md) -- [Todos](todos.md) -- [Snippets](../user/snippets.md) -- [Subgroups](../user/group/subgroups/index.md) +This page was deprecated, with all content previously stored under the `/workflow` path moved +to other locations in the documentation site, organized by topic. You can use the search +box to find the content you are looking for, browse the main [GitLab Documentation page](../README.md), +or view the [issue that deprecated this page](https://gitlab.com/gitlab-org/gitlab/issues/32940) +for more details. diff --git a/doc/workflow/file_finder.md b/doc/workflow/file_finder.md index 8eb705b5363d06dc53903b3508fede1b3c015f84..f7098c88fd1c0e8aa2f69bc5bf237c6a64a94375 100644 --- a/doc/workflow/file_finder.md +++ b/doc/workflow/file_finder.md @@ -1,41 +1,5 @@ -# File finder +--- +redirect_to: '../user/project/repository/file_finder.md' +--- -> [Introduced][gh-9889] in GitLab 8.4. - -The file finder feature allows you to quickly shortcut your way when you are -searching for a file in a repository using the GitLab UI. - -You can find the **Find File** button when in the **Files** section of a -project. - - - -For those who prefer to keep their fingers on the keyboard, there is a -[shortcut button](shortcuts.md) as well, which you can invoke from _anywhere_ -in a project. - -Press `t` to launch the File search function when in **Issues**, -**Merge requests**, **Milestones**, even the project's settings. - -Start typing what you are searching for and watch the magic happen. With the -up/down arrows, you go up and down the results, with `Esc` you close the search -and go back to **Files**. - -## How it works - -The File finder feature is powered by the [Fuzzy filter](https://github.com/jeancroy/fuzz-aldrin-plus) library. - -It implements a fuzzy search with highlight, and tries to provide intuitive -results by recognizing patterns that people use while searching. - -For example, consider the [GitLab CE repository][ce] and that we want to open -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. - - - -[gh-9889]: https://github.com/gitlabhq/gitlabhq/pull/9889 "File finder pull request" -[ce]: https://gitlab.com/gitlab-org/gitlab-foss/tree/master "GitLab CE repository" +This document was moved to [another location](../user/project/repository/file_finder.md). diff --git a/doc/workflow/forking_workflow.md b/doc/workflow/forking_workflow.md index 48be38b2ecae44bb7463e20e0a1b9b727e5e9d86..fa617d859a540a4fb4bf91496f9e0ab6945a1650 100644 --- a/doc/workflow/forking_workflow.md +++ b/doc/workflow/forking_workflow.md @@ -1,51 +1,5 @@ -# Project forking workflow +--- +redirect_to: '../user/project/repository/forking_workflow.md' +--- -Forking a project to your own namespace is useful if you have no write -access to the project you want to contribute to. If you do have write -access or can request it, we recommend working together in the same -repository since it is simpler. See our [GitLab Flow](gitlab_flow.md) -document more information about using branches to work together. - -## Creating a fork - -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. Once you do that, you'll be presented with a screen where you can choose - the namespace to fork to. Only namespaces (groups and your own - namespace) where you have write access to, will be shown. Click on the - namespace to create your fork there. - -  - - **Note:** - If the namespace you chose to fork the project to has another project with - the same path name, you will be presented with a warning that the forking - could not be completed. Try to resolve the error before repeating the forking - process. - -  - -After the forking is done, you can start working on the newly created -repository. There, you will have full [Owner](../user/permissions.md) -access, so you can set it up as you please. - -## Merging upstream - -Once you are ready to send your code back to the main project, you need -to create a merge request. Choose your forked project's main branch as -the source and the original project's main branch as the destination and -create the [merge request](merge_requests.md). - - - -You can then assign the merge request to someone to have them review -your changes. Upon pressing the 'Submit Merge Request' button, your -changes will be added to the repository and branch you're merging into. - - - -[gitlab flow]: https://about.gitlab.com/blog/2014/09/29/gitlab-flow/ "GitLab Flow blog post" +This document was moved to [another location](../user/project/repository/forking_workflow.md). diff --git a/doc/workflow/git_annex.md b/doc/workflow/git_annex.md index 84d49569a957ef845fd0d50e3629469a1673997c..e54d52ea70d9ad50ccf8a5df48992e9e94924720 100644 --- a/doc/workflow/git_annex.md +++ b/doc/workflow/git_annex.md @@ -1,238 +1,5 @@ -# Git annex - -> **Warning:** GitLab has [completely -removed][deprecate-annex-issue] in GitLab 9.0 (2017/03/22). -Read through the [migration guide from git-annex to Git LFS][guide]. - -The biggest limitation of Git, compared to some older centralized version -control systems, has been the maximum size of the repositories. - -The general recommendation is to not have Git repositories larger than 1GB to -preserve performance. Although GitLab has no limit (some repositories in GitLab -are over 50GB!), we subscribe to the advice to keep repositories as small as -you can. - -Not being able to version control large binaries is a big problem for many -larger organizations. -Videos, photos, audio, compiled binaries and many other types of files are too -large. As a workaround, people keep artwork-in-progress in a Dropbox folder and -only check in the final result. This results in using outdated files, not -having a complete history and increases the risk of losing work. - -This problem is solved in GitLab Enterprise Edition by integrating the -[git-annex] application. - -`git-annex` allows managing large binaries with Git without checking the -contents into Git. -You check-in only a symlink that contains the SHA-1 of the large binary. If you -need the large binary, you can sync it from the GitLab server over `rsync`, a -very fast file copying tool. - -## GitLab git-annex Configuration - -`git-annex` is disabled by default in GitLab. Below you will find the -configuration options required to enable it. - -### Requirements - -`git-annex` needs to be installed both on the server and the client side. - -For Debian-like systems (e.g., Debian, Ubuntu) this can be achieved by running: - -``` -sudo apt-get update && sudo apt-get install git-annex -``` - -For RedHat-like systems (e.g., CentOS, RHEL) this can be achieved by running: - -``` -sudo yum install epel-release && sudo yum install git-annex -``` - -### Configuration for Omnibus packages - -For Omnibus GitLab packages, only one configuration setting is needed. -The Omnibus package will internally set the correct options in all locations. - -1. In `/etc/gitlab/gitlab.rb` add the following line: - - ```ruby - gitlab_shell['git_annex_enabled'] = true - ``` - -1. Save the file and [reconfigure GitLab][] for the changes to take effect. - -### Configuration for installations from source - -There are 2 settings to enable git-annex on your GitLab server. - -One is located in `config/gitlab.yml` of the GitLab repository and the other -one is located in `config.yml` of GitLab Shell. - -1. In `config/gitlab.yml` add or edit the following lines: - - ```yaml - gitlab_shell: - git_annex_enabled: true - ``` - -1. In `config.yml` of GitLab Shell add or edit the following lines: - - ```yaml - git_annex_enabled: true - ``` - -1. Save the files and [restart GitLab][] for the changes to take effect. - -## Using GitLab git-annex - -> **Note:** -> Your Git remotes must be using the SSH protocol, not HTTP(S). - -Here is an example workflow of uploading a very large file and then checking it -into your Git repository: - -```bash -git clone git@example.com:group/project.git - -git annex init 'My Laptop' # initialize the annex project and give an optional description -cp ~/tmp/debian.iso ./ # copy a large file into the current directory -git annex add debian.iso # add the large file to git annex -git commit -am "Add Debian iso" # commit the file metadata -git annex sync --content # sync the Git repo and large file to the GitLab server -``` - -The output should look like this: - -``` -commit -On branch master -Your branch is ahead of 'origin/master' by 1 commit. - (use "git push" to publish your local commits) -nothing to commit, working tree clean -ok -pull origin -remote: Counting objects: 5, done. -remote: Compressing objects: 100% (4/4), done. -remote: Total 5 (delta 2), reused 0 (delta 0) -Unpacking objects: 100% (5/5), done. -From example.com:group/project - 497842b..5162f80 git-annex -> origin/git-annex -ok -(merging origin/git-annex into git-annex...) -(recording state in git...) -copy debian.iso (checking origin...) (to origin...) -SHA256E-s26214400--8092b3d482fb1b7a5cf28c43bc1425c8f2d380e86869c0686c49aa7b0f086ab2.iso - 26,214,400 100% 638.88kB/s 0:00:40 (xfr#1, to-chk=0/1) -ok -pull origin -ok -(recording state in git...) -push origin -Counting objects: 15, done. -Delta compression using up to 4 threads. -Compressing objects: 100% (13/13), done. -Writing objects: 100% (15/15), 1.64 KiB | 0 bytes/s, done. -Total 15 (delta 1), reused 0 (delta 0) -To example.com:group/project.git - * [new branch] git-annex -> synced/git-annex - * [new branch] master -> synced/master -ok -``` - -Your files can be found in the `master` branch, but you'll notice that there -are more branches created by the `annex sync` command. - -Git Annex will also create a new directory at `.git/annex/` and will record the -tracked files in the `.git/config` file. The files you assign to be tracked -with `git-annex` will not affect the existing `.git/config` records. The files -are turned into symbolic links that point to data in `.git/annex/objects/`. - -The `debian.iso` file in the example will contain the symbolic link: - -``` -.git/annex/objects/ZW/1k/SHA256E-s82701--6384039733b5035b559efd5a2e25a493ab6e09aabfd5162cc03f6f0ec238429d.png/SHA256E-s82701--6384039733b5035b559efd5a2e25a493ab6e09aabfd5162cc03f6f0ec238429d.iso -``` - -Use `git annex info` to retrieve the information about the local copy of your -repository. - +--- +redirect_to: '../administration/git_annex.md' --- -Downloading a single large file is also very simple: - -```bash -git clone git@gitlab.example.com:group/project.git - -git annex sync # sync Git branches but not the large file -git annex get debian.iso # download the large file -``` - -To download all files: - -```bash -git clone git@gitlab.example.com:group/project.git - -git annex sync --content # sync Git branches and download all the large files -``` - -By using `git-annex` without GitLab, anyone that can access the server can also -access the files of all projects, but GitLab Annex ensures that you can only -access files of projects you have access to (developer, maintainer, or owner role). - -## How it works - -Internally GitLab uses [GitLab Shell] to handle SSH access and this was a great -integration point for `git-annex`. -There is a setting in GitLab Shell so you can disable GitLab Annex support -if you want to. - -## Troubleshooting tips - -Differences in version of `git-annex` on the GitLab server and on local machines -can cause `git-annex` to raise unpredicted warnings and errors. - -Consult the [Annex upgrade page][annex-upgrade] for more information about -the differences between versions. You can find out which version is installed -on your server by navigating to <https://pkgs.org/download/git-annex> and -searching for your distribution. - -Although there is no general guide for `git-annex` errors, there are a few tips -on how to go around the warnings. - -### `git-annex-shell: Not a git-annex or gcrypt repository` - -This warning can appear on the initial `git annex sync --content` and is caused -by differences in `git-annex-shell`. You can read more about it -[in this git-annex issue][issue]. - -One important thing to note is that despite the warning, the `sync` succeeds -and the files are pushed to the GitLab repository. - -If you get hit by this, you can run the following command inside the repository -that the warning was raised: - -``` -git config remote.origin.annex-ignore false -``` - -Consecutive runs of `git annex sync --content` **should not** produce this -warning and the output should look like this: - -``` -commit ok -pull origin -ok -pull origin -ok -push origin -``` - -[annex-upgrade]: https://git-annex.branchable.com/upgrades/ -[deprecate-annex-issue]: https://gitlab.com/gitlab-org/gitlab/issues/1648 -[git-annex]: https://git-annex.branchable.com/ "git-annex website" -[gitlab shell]: https://gitlab.com/gitlab-org/gitlab-shell "GitLab Shell repository" -[guide]: lfs/migrate_from_git_annex_to_git_lfs.html -[issue]: https://git-annex.branchable.com/forum/Error_from_git-annex-shell_on_creation_of_gcrypt_special_remote/ "git-annex issue" -[reconfigure GitLab]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure -[restart GitLab]: ../administration/restart_gitlab.md#installations-from-source +This document was moved to [another location](../administration/git_annex.md). diff --git a/doc/workflow/git_lfs.md b/doc/workflow/git_lfs.md index da217b0a5da08f1053d646d18562f9fa9fd7bcdb..0a8c33c264c63d8773d34976b04e648c7f4acfbe 100644 --- a/doc/workflow/git_lfs.md +++ b/doc/workflow/git_lfs.md @@ -1,5 +1,5 @@ --- -redirect_to: 'lfs/manage_large_binaries_with_git_lfs.md' +redirect_to: '../administration/lfs/manage_large_binaries_with_git_lfs.md' --- -This document was moved to [another location](lfs/manage_large_binaries_with_git_lfs.md). +This document was moved to [another location](../administration/lfs/manage_large_binaries_with_git_lfs.md). diff --git a/doc/workflow/gitlab_flow.md b/doc/workflow/gitlab_flow.md index e3568d6489dd95cbe2f6500f01e147f62d4b74af..e03281c0ffc1a07d4a72426b0c2b6fff7d0d03f3 100644 --- a/doc/workflow/gitlab_flow.md +++ b/doc/workflow/gitlab_flow.md @@ -1,326 +1,5 @@ -# Introduction to GitLab Flow +--- +redirect_to: '../topics/gitlab_flow.md' +--- - - -Git allows a wide variety of branching strategies and workflows. -Because of this, many organizations end up with workflows that are too complicated, not clearly defined, or not integrated with issue tracking systems. -Therefore, we propose GitLab flow as a clearly defined set of best practices. -It combines [feature-driven development](https://en.wikipedia.org/wiki/Feature-driven_development) and [feature branches](https://martinfowler.com/bliki/FeatureBranch.html) with issue tracking. - -Organizations coming to Git from other version control systems frequently find it hard to develop a productive workflow. -This article describes GitLab flow, which integrates the Git workflow with an issue tracking system. -It offers a simple, transparent, and effective way to work with Git. - - - -When converting to Git, you have to get used to the fact that it takes three steps to share a commit with colleagues. -Most version control systems have only one step: committing from the working copy to a shared server. -In Git, you add files from the working copy to the staging area. After that, you commit them to your local repo. -The third step is pushing to a shared remote repository. -After getting used to these three steps, the next challenge is the branching model. - - - -Since many organizations new to Git have no conventions for how to work with it, their repositories can quickly become messy. -The biggest problem is that many long-running branches emerge that all contain part of the changes. -People have a hard time figuring out which branch has the latest code, or which branch to deploy to production. -Frequently, the reaction to this problem is to adopt a standardized pattern such as [Git flow](https://nvie.com/posts/a-successful-git-branching-model/) and [GitHub flow](http://scottchacon.com/2011/08/31/github-flow.html). -We think there is still room for improvement. In this document, we describe a set of practices we call GitLab flow. - -For a video introduction of how this works in GitLab, see [GitLab Flow](https://youtu.be/InKNIvky2KE). - -## Git flow and its problems - - - -Git flow was one of the first proposals to use Git branches, and it has received a lot of attention. -It suggests a `master` branch and a separate `develop` branch, as well as supporting branches for features, releases, and hotfixes. -The development happens on the `develop` branch, moves to a release branch, and is finally merged into the `master` branch. - -Git flow is a well-defined standard, but its complexity introduces two problems. -The first problem is that developers must use the `develop` branch and not `master`. `master` is reserved for code that is released to production. -It is a convention to call your default branch `master` and to mostly branch from and merge to this. -Since most tools automatically use the `master` branch as the default, it is annoying to have to switch to another branch. - -The second problem of Git flow is the complexity introduced by the hotfix and release branches. -These branches can be a good idea for some organizations but are overkill for the vast majority of them. -Nowadays, most organizations practice continuous delivery, which means that your default branch can be deployed. -Continuous delivery removes the need for hotfix and release branches, including all the ceremony they introduce. -An example of this ceremony is the merging back of release branches. -Though specialized tools do exist to solve this, they require documentation and add complexity. -Frequently, developers make mistakes such as merging changes only into `master` and not into the `develop` branch. -The reason for these errors is that Git flow is too complicated for most use cases. -For example, many projects do releases but don't need to do hotfixes. - -## GitHub flow as a simpler alternative - - - -In reaction to Git flow, GitHub created a simpler alternative. -[GitHub flow](https://guides.github.com/introduction/flow/index.html) has only feature branches and a `master` branch. -This flow is clean and straightforward, and many organizations have adopted it with great success. -Atlassian recommends [a similar strategy](https://www.atlassian.com/blog/git/simple-git-workflow-is-simple), although they rebase feature branches. -Merging everything into the `master` branch and frequently deploying means you minimize the amount of unreleased code, which is in line with lean and continuous delivery best practices. -However, this flow still leaves a lot of questions unanswered regarding deployments, environments, releases, and integrations with issues. -With GitLab flow, we offer additional guidance for these questions. - -## Production branch with GitLab flow - - - -GitHub flow assumes you can deploy to production every time you merge a feature branch. -While this is possible in some cases, such as SaaS applications, there are many cases where this is not possible. -One case is where you don't control the timing of a release, for example, an iOS application that is released when it passes App Store validation. -Another case is when you have deployment windows — for example, workdays from 10 AM to 4 PM when the operations team is at full capacity — but you also merge code at other times. -In these cases, you can make a production branch that reflects the deployed code. -You can deploy a new version by merging `master` into the production branch. -If you need to know what code is in production, you can just checkout the production branch to see. -The approximate time of deployment is easily visible as the merge commit in the version control system. -This time is pretty accurate if you automatically deploy your production branch. -If you need a more exact time, you can have your deployment script create a tag on each deployment. -This flow prevents the overhead of releasing, tagging, and merging that happens with Git flow. - -## Environment branches with GitLab flow - - - -It might be a good idea to have an environment that is automatically updated to the `master` branch. -Only, in this case, the name of this environment might differ from the branch name. -Suppose you have a staging environment, a pre-production environment, and a production environment. -In this case, deploy the `master` branch to staging. -To deploy to pre-production, create a merge request from the `master` branch to the pre-production branch. -Go live by merging the pre-production branch into the production branch. -This workflow, where commits only flow downstream, ensures that everything is tested in all environments. -If you need to cherry-pick a commit with a hotfix, it is common to develop it on a feature branch and merge it into `master` with a merge request. -In this case, do not delete the feature branch yet. -If `master` passes automatic testing, you then merge the feature branch into the other branches. -If this is not possible because more manual testing is required, you can send merge requests from the feature branch to the downstream branches. - -## Release branches with GitLab flow - - - -You only need to work with release branches if you need to release software to the outside world. -In this case, each branch contains a minor version, for example, 2-3-stable, 2-4-stable, etc. -Create stable branches using `master` as a starting point, and branch as late as possible. -By doing this, you minimize the length of time during which you have to apply bug fixes to multiple branches. -After announcing a release branch, only add serious bug fixes to the branch. -If possible, first merge these bug fixes into `master`, and then cherry-pick them into the release branch. -If you start by merging into the release branch, you might forget to cherry-pick them into `master`, and then you'd encounter the same bug in subsequent releases. -Merging into `master` and then cherry-picking into release is called an "upstream first" policy, which is also practiced by [Google](https://www.chromium.org/chromium-os/chromiumos-design-docs/upstream-first) and [Red Hat](https://www.redhat.com/en/blog/a-community-for-using-openstack-with-red-hat-rdo). -Every time you include a bug fix in a release branch, increase the patch version (to comply with [Semantic Versioning](https://semver.org/)) by setting a new tag. -Some projects also have a stable branch that points to the same commit as the latest released branch. -In this flow, it is not common to have a production branch (or Git flow `master` branch). - -## Merge/pull requests with GitLab flow - - - -Merge or pull requests are created in a Git management application. They ask an assigned person to merge two branches. -Tools such as GitHub and Bitbucket choose the name "pull request" since the first manual action is to pull the feature branch. -Tools such as GitLab and others choose the name "merge request" since the final action is to merge the feature branch. -In this article, we'll refer to them as merge requests. - -If you work on a feature branch for more than a few hours, it is good to share the intermediate result with the rest of the team. -To do this, create a merge request without assigning it to anyone. -Instead, mention people in the description or a comment, for example, "/cc @mark @susan." -This indicates that the merge request is not ready to be merged yet, but feedback is welcome. -Your team members can comment on the merge request in general or on specific lines with line comments. -The merge request serves as a code review tool, and no separate code review tools should be needed. -If the review reveals shortcomings, anyone can commit and push a fix. -Usually, the person to do this is the creator of the merge request. -The diff in the merge request automatically updates when new commits are pushed to the branch. - -When you are ready for your feature branch to be merged, assign the merge request to the person who knows most about the codebase you are changing. -Also, mention any other people from whom you would like feedback. -After the assigned person feels comfortable with the result, they can merge the branch. -If the assigned person does not feel comfortable, they can request more changes or close the merge request without merging. - -In GitLab, it is common to protect the long-lived branches, e.g., the `master` branch, so that [most developers can't modify them](../permissions/permissions.md). -So, if you want to merge into a protected branch, assign your merge request to someone with maintainer permissions. - -After you merge a feature branch, you should remove it from the source control software. -In GitLab, you can do this when merging. -Removing finished branches ensures that the list of branches shows only work in progress. -It also ensures that if someone reopens the issue, they can use the same branch name without causing problems. - -NOTE: **Note:** -When you reopen an issue you need to create a new merge request. - - - -## Issue tracking with GitLab flow - - - -GitLab flow is a way to make the relation between the code and the issue tracker more transparent. - -Any significant change to the code should start with an issue that describes the goal. -Having a reason for every code change helps to inform the rest of the team and to keep the scope of a feature branch small. -In GitLab, each change to the codebase starts with an issue in the issue tracking system. -If there is no issue yet, create the issue, as long as the change will take a significant amount of work, i.e., more than 1 hour. -In many organizations, raising an issue is part of the development process because they are used in sprint planning. -The issue title should describe the desired state of the system. -For example, the issue title "As an administrator, I want to remove users without receiving an error" is better than "Admin can't remove users." - -When you are ready to code, create a branch for the issue from the `master` branch. -This branch is the place for any work related to this change. - -NOTE: **Note:** -The name of a branch might be dictated by organizational standards. - -When you are done or want to discuss the code, open a merge request. -A merge request is an online place to discuss the change and review the code. - -If you open the merge request but do not assign it to anyone, it is a "Work In Progress" merge request. -These are used to discuss the proposed implementation but are not ready for inclusion in the `master` branch yet. -Start the title of the merge request with `[WIP]` or `WIP:` to prevent it from being merged before it's ready. - -When you think the code is ready, assign the merge request to a reviewer. -The reviewer can merge the changes when they think the code is ready for inclusion in the `master` branch. -When they press the merge button, GitLab merges the code and creates a merge commit that makes this event easily visible later on. -Merge requests always create a merge commit, even when the branch could be merged without one. -This merge strategy is called "no fast-forward" in Git. -After the merge, delete the feature branch since it is no longer needed. -In GitLab, this deletion is an option when merging. - -Suppose that a branch is merged but a problem occurs and the issue is reopened. -In this case, it is no problem to reuse the same branch name since the first branch was deleted when it was merged. -At any time, there is at most one branch for every issue. -It is possible that one feature branch solves more than one issue. - -## Linking and closing issues from merge requests - - - -Link to issues by mentioning them in commit messages or the description of a merge request, for example, "Fixes #16" or "Duck typing is preferred. See #12." -GitLab then creates links to the mentioned issues and creates comments in the issues linking back to the merge request. - -To automatically close linked issues, mention them with the words "fixes" or "closes," for example, "fixes #14" or "closes #67." GitLab closes these issues when the code is merged into the default branch. - -If you have an issue that spans across multiple repositories, create an issue for each repository and link all issues to a parent issue. - -## Squashing commits with rebase - - - -With Git, you can use an interactive rebase (`rebase -i`) to squash multiple commits into one or reorder them. -This functionality is useful if you want to replace a couple of small commits with a single commit, or if you want to make the order more logical. - -However, you should never rebase commits you have pushed to a remote server. -Rebasing creates new commits for all your changes, which can cause confusion because the same change would have multiple identifiers. -It also causes merge errors for anyone working on the same branch because their history would not match with yours. -Also, if someone has already reviewed your code, rebasing makes it hard to tell what changed since the last review. - -You should also never rebase commits authored by other people. -Not only does this rewrite history, but it also loses authorship information. -Rebasing prevents the other authors from being attributed and sharing part of the [`git blame`](https://git-scm.com/docs/git-blame). - -If a merge involves many commits, it may seem more difficult to undo. -You might think to solve this by squashing all the changes into one commit before merging, but as discussed earlier, it is a bad idea to rebase commits that you have already pushed. -Fortunately, there is an easy way to undo a merge with all its commits. -The way to do this is by reverting the merge commit. -Preserving this ability to revert a merge is a good reason to always use the "no fast-forward" (`--no-ff`) strategy when you merge manually. - -NOTE: **Note:** -If you revert a merge commit and then change your mind, revert the revert commit to redo the merge. -Git does not allow you to merge the code again otherwise. - -## Reducing merge commits in feature branches - - - -Having lots of merge commits can make your repository history messy. -Therefore, you should try to avoid merge commits in feature branches. -Often, people avoid merge commits by just using rebase to reorder their commits after the commits on the `master` branch. -Using rebase prevents a merge commit when merging `master` into your feature branch, and it creates a neat linear history. -However, as discussed in [the section about rebasing](#squashing-commits-with-rebase), you should never rebase commits you have pushed to a remote server. -This restriction makes it impossible to rebase work in progress that you already shared with your team, which is something we recommend. - -Rebasing also creates more work, since every time you rebase, you have to resolve similar conflicts. -Sometimes you can reuse recorded resolutions (`rerere`), but merging is better since you only have to resolve conflicts once. -Atlassian has a more thorough explanation of the tradeoffs between merging and rebasing [on their blog](https://www.atlassian.com/blog/git/git-team-workflows-merge-or-rebase). - -A good way to prevent creating many merge commits is to not frequently merge `master` into the feature branch. -There are three reasons to merge in `master`: utilizing new code, resolving merge conflicts, and updating long-running branches. - -If you need to utilize some code that was introduced in `master` after you created the feature branch, you can often solve this by just cherry-picking a commit. - -If your feature branch has a merge conflict, creating a merge commit is a standard way of solving this. - -NOTE: **Note:** -Sometimes you can use .gitattributes to reduce merge conflicts. -For example, you can set your changelog file to use the [union merge driver](https://git-scm.com/docs/gitattributes#gitattributes-union) so that multiple new entries don't conflict with each other. - -The last reason for creating merge commits is to keep long-running feature branches up-to-date with the latest state of the project. -The solution here is to keep your feature branches short-lived. -Most feature branches should take less than one day of work. -If your feature branches often take more than a day of work, try to split your features into smaller units of work. - -If you need to keep a feature branch open for more than a day, there are a few strategies to keep it up-to-date. -One option is to use continuous integration (CI) to merge in `master` at the start of the day. -Another option is to only merge in from well-defined points in time, for example, a tagged release. -You could also use [feature toggles](https://martinfowler.com/bliki/FeatureToggle.html) to hide incomplete features so you can still merge back into `master` every day. - -> **Note:** Don't confuse automatic branch testing with continuous integration. -> Martin Fowler makes this distinction in [his article about feature branches](https://martinfowler.com/bliki/FeatureBranch.html): -> -> "I've heard people say they are doing CI because they are running builds, perhaps using a CI server, on every branch with every commit. -> That's continuous building, and a Good Thing, but there's no *integration*, so it's not CI." - -In conclusion, you should try to prevent merge commits, but not eliminate them. -Your codebase should be clean, but your history should represent what actually happened. -Developing software happens in small, messy steps, and it is OK to have your history reflect this. -You can use tools to view the network graphs of commits and understand the messy history that created your code. -If you rebase code, the history is incorrect, and there is no way for tools to remedy this because they can't deal with changing commit identifiers. - -## Commit often and push frequently - -Another way to make your development work easier is to commit often. -Every time you have a working set of tests and code, you should make a commit. -Splitting up work into individual commits provides context for developers looking at your code later. -Smaller commits make it clear how a feature was developed, and they make it easy to roll back to a specific good point in time or to revert one code change without reverting several unrelated changes. - -Committing often also makes it easy to share your work, which is important so that everyone is aware of what you are working on. -You should push your feature branch frequently, even when it is not yet ready for review. -By sharing your work in a feature branch or [a merge request](#mergepull-requests-with-gitlab-flow), you prevent your team members from duplicating work. -Sharing your work before it's complete also allows for discussion and feedback about the changes, which can help improve the code before it gets to review. - -## How to write a good commit message - - - -A commit message should reflect your intention, not just the contents of the commit. -It is easy to see the changes in a commit, so the commit message should explain why you made those changes. -An example of a good commit message is: "Combine templates to reduce duplicate code in the user views." -The words "change," "improve," "fix," and "refactor" don't add much information to a commit message. -For example, "Improve XML generation" could be better written as "Properly escape special characters in XML generation." -For more information about formatting commit messages, please see this excellent [blog post by Tim Pope](https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). - -## Testing before merging - - - -In old workflows, the continuous integration (CI) server commonly ran tests on the `master` branch only. -Developers had to ensure their code did not break the `master` branch. -When using GitLab flow, developers create their branches from this `master` branch, so it is essential that it never breaks. -Therefore, each merge request must be tested before it is accepted. -CI software like Travis CI and GitLab CI show the build results right in the merge request itself to make this easy. - -There is one drawback to testing merge requests: the CI server only tests the feature branch itself, not the merged result. -Ideally, the server could also test the `master` branch after each change. -However, retesting on every commit to `master` is computationally expensive and means you are more frequently waiting for test results. -Since feature branches should be short-lived, testing just the branch is an acceptable risk. -If new commits in `master` cause merge conflicts with the feature branch, merge `master` back into the branch to make the CI server re-run the tests. -As said before, if you often have feature branches that last for more than a few days, you should make your issues smaller. - -## Working with feature branches - - - -When creating a feature branch, always branch from an up-to-date `master`. -If you know before you start that your work depends on another branch, you can also branch from there. -If you need to merge in another branch after starting, explain the reason in the merge commit. -If you have not pushed your commits to a shared location yet, you can also incorporate changes by rebasing on `master` or another feature branch. -Do not merge from upstream again if your code can work and merge cleanly without doing so. -Merging only when needed prevents creating merge commits in your feature branch that later end up littering the `master` history. +This document was moved to [another location](../topics/gitlab_flow.md). diff --git a/doc/workflow/issue_weight.md b/doc/workflow/issue_weight.md index 79b8e5f516471a1f358bb74835bdab1ab741fba9..94eb38356e8946128fcc7763a53d69487b5a4b76 100644 --- a/doc/workflow/issue_weight.md +++ b/doc/workflow/issue_weight.md @@ -1,21 +1,5 @@ -# Issue weight **(STARTER)** +--- +redirect_to: '../user/project/issues/issue_weight.md' +--- -> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/76) in [GitLab Starter](https://about.gitlab.com/pricing/) 8.3. - -When you have a lot of issues, it can be hard to get an overview. -By adding a weight to each issue, you can get a better idea of how much time, -value or complexity a given issue has or will cost. - -You can set the weight of an issue during its creation, by simply changing the -value in the dropdown menu. You can set it to a non-negative integer -value from 0, 1, 2, and so on. (The database stores a 4-byte value, so the -upper bound is essentially limitless). -You can remove weight from an issue -as well. - -This value will appear on the right sidebar of an individual issue, as well as -in the issues page next to a distinctive balance scale icon. - -As an added bonus, you can see the total sum of all issues on the milestone page. - - +This document was moved to [another location](../user/project/issues/issue_weight.md). diff --git a/doc/workflow/lfs/lfs_administration.md b/doc/workflow/lfs/lfs_administration.md index 7ad87982501efb4f4baa46bfbd1869866103d7f3..58c48b4f6e6fe2541921e38bbd53abb6a5691e29 100644 --- a/doc/workflow/lfs/lfs_administration.md +++ b/doc/workflow/lfs/lfs_administration.md @@ -1,269 +1,5 @@ -# GitLab Git LFS Administration +--- +redirect_to: '../../administration/lfs/lfs_administration.md' +--- -Documentation on how to use Git LFS are under [Managing large binary files with Git LFS doc](manage_large_binaries_with_git_lfs.md). - -## Requirements - -- Git LFS is supported in GitLab starting with version 8.2. -- Support for object storage, such as AWS S3, was introduced in 10.0. -- Users need to install [Git LFS client](https://git-lfs.github.com) version 1.0.1 and up. - -## Configuration - -Git LFS objects can be large in size. By default, they are stored on the server -GitLab is installed on. - -There are various configuration options to help GitLab server administrators: - -- Enabling/disabling Git LFS support -- Changing the location of LFS object storage -- Setting up object storage supported by [Fog](http://fog.io/about/provider_documentation.html) - -### Configuration for Omnibus installations - -In `/etc/gitlab/gitlab.rb`: - -```ruby -# Change to true to enable lfs - enabled by default if not defined -gitlab_rails['lfs_enabled'] = false - -# Optionally, change the storage path location. Defaults to -# `#{gitlab_rails['shared_path']}/lfs-objects`. Which evaluates to -# `/var/opt/gitlab/gitlab-rails/shared/lfs-objects` by default. -gitlab_rails['lfs_storage_path'] = "/mnt/storage/lfs-objects" -``` - -### Configuration for installations from source - -In `config/gitlab.yml`: - -```yaml -# Change to true to enable lfs - lfs: - enabled: false - storage_path: /mnt/storage/lfs-objects -``` - -## Storing LFS objects in remote object storage - -> [Introduced][ee-2760] in [GitLab Premium][eep] 10.0. Brought to GitLab Core in 10.7. - -It is possible to store LFS objects in remote object storage which allows you -to offload local hard disk R/W operations, and free up disk space significantly. -GitLab is tightly integrated with `Fog`, so you can refer to its [documentation](http://fog.io/about/provider_documentation.html) -to check which storage services can be integrated with GitLab. -You can also use external object storage in a private local network. For example, -[MinIO](https://min.io/) is a standalone object storage service, is easy to set up, and works well with GitLab instances. - -GitLab provides two different options for the uploading mechanism: "Direct upload" and "Background upload". - -**Option 1. Direct upload** - -1. User pushes an lfs file to the GitLab instance -1. GitLab-workhorse uploads the file directly to the external object storage -1. GitLab-workhorse notifies GitLab-rails that the upload process is complete - -**Option 2. Background upload** - -1. User pushes an lfs file to the GitLab instance -1. GitLab-rails stores the file in the local file storage -1. GitLab-rails then uploads the file to the external object storage asynchronously - -The following general settings are supported. - -| Setting | Description | Default | -|---------|-------------|---------| -| `enabled` | Enable/disable object storage | `false` | -| `remote_directory` | The bucket name where LFS objects will be stored| | -| `direct_upload` | Set to true to enable direct upload of LFS without the need of local shared storage. Option may be removed once we decide to support only single storage for all files. | `false` | -| `background_upload` | Set to false to disable automatic upload. Option may be removed once upload is direct to S3 | `true` | -| `proxy_download` | Set to true to enable proxying all files served. Option allows to reduce egress traffic as this allows clients to download directly from remote storage instead of proxying all data | `false` | -| `connection` | Various connection options described below | | - -The `connection` settings match those provided by [Fog](https://github.com/fog). - -Here is a configuration example with S3. - -| Setting | Description | example | -|---------|-------------|---------| -| `provider` | The provider name | AWS | -| `aws_access_key_id` | AWS credentials, or compatible | `ABC123DEF456` | -| `aws_secret_access_key` | AWS credentials, or compatible | `ABC123DEF456ABC123DEF456ABC123DEF456` | -| `aws_signature_version` | AWS signature version to use. 2 or 4 are valid options. Digital Ocean Spaces and other providers may need 2. | 4 | -| `enable_signature_v4_streaming` | Set to true to enable HTTP chunked transfers with [AWS v4 signatures](https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html). Oracle Cloud S3 needs this to be false | true | -| `region` | AWS region | us-east-1 | -| `host` | S3 compatible host for when not using AWS, e.g. `localhost` or `storage.example.com` | s3.amazonaws.com | -| `endpoint` | Can be used when configuring an S3 compatible service such as [MinIO](https://min.io), by entering a URL such as `http://127.0.0.1:9000` | (optional) | -| `path_style` | Set to true to use `host/bucket_name/object` style paths instead of `bucket_name.host/object`. Leave as false for AWS S3 | false | -| `use_iam_profile` | Set to true to use IAM profile instead of access keys | false - -Here is a configuration example with GCS. - -| Setting | Description | example | -|---------|-------------|---------| -| `provider` | The provider name | `Google` | -| `google_project` | GCP project name | `gcp-project-12345` | -| `google_client_email` | The email address of the service account | `foo@gcp-project-12345.iam.gserviceaccount.com` | -| `google_json_key_location` | The json key path | `/path/to/gcp-project-12345-abcde.json` | - -NOTE: **Note:** -The service account must have permission to access the bucket. -[See more](https://cloud.google.com/storage/docs/authentication) - -Here is a configuration example with Rackspace Cloud Files. - -| Setting | Description | example | -|---------|-------------|---------| -| `provider` | The provider name | `Rackspace` | -| `rackspace_username` | The username of the Rackspace account with access to the container | `joe.smith` | -| `rackspace_api_key` | The API key of the Rackspace account with access to the container | `ABC123DEF456ABC123DEF456ABC123DE` | -| `rackspace_region` | The Rackspace storage region to use, a three letter code from the [list of service access endpoints](https://developer.rackspace.com/docs/cloud-files/v1/general-api-info/service-access/) | `iad` | -| `rackspace_temp_url_key` | The private key you have set in the Rackspace API for temporary URLs. Read more [here](https://developer.rackspace.com/docs/cloud-files/v1/use-cases/public-access-to-your-cloud-files-account/#tempurl) | `ABC123DEF456ABC123DEF456ABC123DE` | - -NOTE: **Note:** -Regardless of whether the container has public access enabled or disabled, Fog will -use the TempURL method to grant access to LFS objects. If you see errors in logs referencing -instantiating storage with a temp-url-key, ensure that you have set they key properly -on the Rackspace API and in `gitlab.rb`. You can verify the value of the key Rackspace -has set by sending a GET request with token header to the service access endpoint URL -and comparing the output of the returned headers. - -### Manual uploading to an object storage - -There are two ways to manually do the same thing as automatic uploading (described above). - -**Option 1: rake task** - -```sh -rake gitlab:lfs:migrate -``` - -**Option 2: rails console** - -```sh -$ sudo gitlab-rails console # Login to rails console - -> # Upload LFS files manually -> LfsObject.where(file_store: [nil, 1]).find_each do |lfs_object| -> lfs_object.file.migrate!(ObjectStorage::Store::REMOTE) if lfs_object.file.file.exists? -> end -``` - -### S3 for Omnibus installations - -On Omnibus installations, the settings are prefixed by `lfs_object_store_`: - -1. Edit `/etc/gitlab/gitlab.rb` and add the following lines by replacing with - the values you want: - - ```ruby - gitlab_rails['lfs_object_store_enabled'] = true - gitlab_rails['lfs_object_store_remote_directory'] = "lfs-objects" - gitlab_rails['lfs_object_store_connection'] = { - 'provider' => 'AWS', - 'region' => 'eu-central-1', - 'aws_access_key_id' => '1ABCD2EFGHI34JKLM567N', - 'aws_secret_access_key' => 'abcdefhijklmnopQRSTUVwxyz0123456789ABCDE', - # The below options configure an S3 compatible host instead of AWS - 'host' => 'localhost', - 'endpoint' => 'http://127.0.0.1:9000', - 'path_style' => true - } - ``` - -1. Save the file and [reconfigure GitLab]s for the changes to take effect. -1. Migrate any existing local LFS objects to the object storage: - - ```bash - gitlab-rake gitlab:lfs:migrate - ``` - - This will migrate existing LFS objects to object storage. New LFS objects - will be forwarded to object storage unless - `gitlab_rails['lfs_object_store_background_upload']` is set to false. - -### S3 for installations from source - -For source installations the settings are nested under `lfs:` and then -`object_store:`: - -1. Edit `/home/git/gitlab/config/gitlab.yml` and add or amend the following - lines: - - ```yaml - lfs: - enabled: true - object_store: - enabled: false - remote_directory: lfs-objects # Bucket name - connection: - provider: AWS - aws_access_key_id: 1ABCD2EFGHI34JKLM567N - aws_secret_access_key: abcdefhijklmnopQRSTUVwxyz0123456789ABCDE - region: eu-central-1 - # Use the following options to configure an AWS compatible host such as Minio - host: 'localhost' - endpoint: 'http://127.0.0.1:9000' - path_style: true - ``` - -1. Save the file and [restart GitLab][] for the changes to take effect. -1. Migrate any existing local LFS objects to the object storage: - - ```bash - sudo -u git -H bundle exec rake gitlab:lfs:migrate RAILS_ENV=production - ``` - - This will migrate existing LFS objects to object storage. New LFS objects - will be forwarded to object storage unless `background_upload` is set to - false. - -### Migrating back to local storage - -In order to migrate back to local storage: - -1. Set both `direct_upload` and `background_upload` to false under the LFS object storage settings. Don't forget to restart GitLab. -1. Run `rake gitlab:lfs:migrate_to_local` on your console. -1. Disable `object_storage` for LFS objects in `gitlab.rb`. Remember to restart GitLab afterwards. - -## Storage statistics - -You can see the total storage used for LFS objects on groups and projects -in the administration area, as well as through the [groups](../../api/groups.md) -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. -LFS files up to 6Gb can be uploaded without any extra steps, otherwise you need to use the following workaround. - -```shell -$ sudo gitlab-rails console # Login to rails console - -> # Set up timeouts. 20 minutes is enough to upload 30GB LFS files. -> # These settings are only in effect for the same session, i.e. they are not effective for sidekiq workers. -> ::Google::Apis::ClientOptions.default.open_timeout_sec = 1200 -> ::Google::Apis::ClientOptions.default.read_timeout_sec = 1200 -> ::Google::Apis::ClientOptions.default.send_timeout_sec = 1200 - -> # Upload LFS files manually. This process does not use sidekiq at all. -> LfsObject.where(file_store: [nil, 1]).find_each do |lfs_object| -> lfs_object.file.migrate!(ObjectStorage::Store::REMOTE) if lfs_object.file.file.exists? -> end -``` - -See more information in [!19581](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/19581) - -## Known limitations - -- Support for removing unreferenced LFS objects was added in 8.14 onwards. -- LFS authentications via SSH was added with GitLab 8.12. -- Only compatible with the Git LFS client versions 1.1.0 and up, or 1.0.2. -- The storage statistics currently count each LFS object multiple times for - every project linking to it. - -[reconfigure gitlab]: ../../administration/restart_gitlab.md#omnibus-gitlab-reconfigure "How to reconfigure Omnibus GitLab" -[restart gitlab]: ../../administration/restart_gitlab.md#installations-from-source "How to restart GitLab" -[eep]: https://about.gitlab.com/pricing/ "GitLab Premium" -[ee-2760]: https://gitlab.com/gitlab-org/gitlab/merge_requests/2760 +This document was moved to [another location](../../administration/lfs/lfs_administration.md). diff --git a/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md b/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md index f747a7b5196d727657763f9ea9b7d4282e315634..56e2f72284a167fadfa8cf9350dfdf925ff05329 100644 --- a/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md +++ b/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md @@ -1,262 +1,5 @@ -# Git LFS +--- +redirect_to: '../../administration/lfs/manage_large_binaries_with_git_lfs.md' +--- -Managing large files such as audio, video and graphics files has always been one -of the shortcomings of Git. The general recommendation is to not have Git repositories -larger than 1GB to preserve performance. - - - -An LFS icon is shown on files tracked by Git LFS to denote if a file is stored -as a blob or as an LFS pointer. - -## How it works - -Git LFS client talks with the GitLab server over HTTPS. It uses HTTP Basic Authentication -to authorize client requests. Once the request is authorized, Git LFS client receives -instructions from where to fetch or where to push the large file. - -## GitLab server configuration - -Documentation for GitLab instance administrators is under [LFS administration doc](lfs_administration.md). - -## Requirements - -- Git LFS is supported in GitLab starting with version 8.2 -- Git LFS must be enabled under project settings -- [Git LFS client](https://git-lfs.github.com) version 1.0.1 and up - -## Known limitations - -- Git LFS v1 original API is not supported since it was deprecated early in LFS - development -- When SSH is set as a remote, Git LFS objects still go through HTTPS -- Any Git LFS request will ask for HTTPS credentials to be provided so a good Git - credentials store is recommended -- Git LFS always assumes HTTPS so if you have GitLab server on HTTP you will have - to add the URL to Git config manually (see [troubleshooting](#troubleshooting)) - -NOTE: **Note:** -With 8.12 GitLab added LFS support to SSH. The Git LFS communication -still goes over HTTP, but now the SSH client passes the correct credentials -to the Git LFS client, so no action is required by the user. - -## Using Git LFS - -Lets take a look at the workflow when you need to check large files into your Git -repository with Git LFS. For example, if you want to upload a very large file and -check it into your Git repository: - -```bash -git clone git@gitlab.example.com:group/project.git -git lfs install # initialize the Git LFS project -git lfs track "*.iso" # select the file extensions that you want to treat as large files -``` - -Once a certain file extension is marked for tracking as a LFS object you can use -Git as usual without having to redo the command to track a file with the same extension: - -```bash -cp ~/tmp/debian.iso ./ # copy a large file into the current directory -git add . # add the large file to the project -git commit -am "Added Debian iso" # commit the file meta data -git push origin master # sync the git repo and large file to the GitLab server -``` - -**Make sure** that `.gitattributes` is tracked by Git. Otherwise Git -LFS will not be working properly for people cloning the project: - -```bash -git add .gitattributes -``` - -Cloning the repository works the same as before. Git automatically detects the -LFS-tracked files and clones them via HTTP. If you performed the `git clone` -command with a SSH URL, you have to enter your GitLab credentials for HTTP -authentication. - -```bash -git clone git@gitlab.example.com:group/project.git -``` - -If you already cloned the repository and you want to get the latest LFS object -that are on the remote repository, eg. for a branch from origin: - -```bash -git lfs fetch origin master -``` - -### Migrate an existing repo to Git LFS - -Read the documentation on how to [migrate an existing Git repo with Git LFS](../../topics/git/migrate_to_git_lfs/index.md). - -## File Locking - -> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/35856) in GitLab 10.5. - -The first thing to do before using File Locking is to tell Git LFS which -kind of files are lockable. The following command will store PNG files -in LFS and flag them as lockable: - -```bash -git lfs track "*.png" --lockable -``` - -After executing the above command a file named `.gitattributes` will be -created or updated with the following content: - -```bash -*.png filter=lfs diff=lfs merge=lfs -text lockable -``` - -You can also register a file type as lockable without using LFS -(In order to be able to lock/unlock a file you need a remote server that implements the LFS File Locking API), -in order to do that you can edit the `.gitattributes` file manually: - -```bash -*.pdf lockable -``` - -After a file type has been registered as lockable, Git LFS will make -them readonly on the file system automatically. This means you will -need to lock the file before editing it. - -### Managing Locked Files - -Once you're ready to edit your file you need to lock it first: - -```bash -git lfs lock images/banner.png -Locked images/banner.png -``` - -This will register the file as locked in your name on the server: - -```bash -git lfs locks -images/banner.png joe ID:123 -``` - -Once you have pushed your changes, you can unlock the file so others can -also edit it: - -```bash -git lfs unlock images/banner.png -``` - -You can also unlock by id: - -```bash -git lfs unlock --id=123 -``` - -If for some reason you need to unlock a file that was not locked by you, -you can use the `--force` flag as long as you have a `maintainer` access on -the project: - -```bash -git lfs unlock --id=123 --force -``` - -## Troubleshooting - -### error: Repository or object not found - -There are a couple of reasons why this error can occur: - -- You don't have permissions to access certain LFS object - -Check if you have permissions to push to the project or fetch from the project. - -- Project is not allowed to access the LFS object - -LFS object you are trying to push to the project or fetch from the project is not -available to the project anymore. Probably the object was removed from the server. - -- Local Git repository is using deprecated LFS API - -### Invalid status for `<url>` : 501 - -Git LFS will log the failures into a log file. -To view this log file, while in project directory: - -```bash -git lfs logs last -``` - -If the status `error 501` is shown, it is because: - -- Git LFS is not enabled in project settings. Check your project settings and - enable Git LFS. - -- Git LFS support is not enabled on the GitLab server. Check with your GitLab - administrator why Git LFS is not enabled on the server. See - [LFS administration documentation](lfs_administration.md) for instructions - on how to enable LFS support. - -- Git LFS client version is not supported by GitLab server. Check your Git LFS - version with `git lfs version`. Check the Git config of the project for traces - of deprecated API with `git lfs -l`. If `batch = false` is set in the config, - remove the line and try to update your Git LFS client. Only version 1.0.1 and - newer are supported. - -### getsockopt: connection refused - -If you push a LFS object to a project and you receive an error similar to: -`Post <URL>/info/lfs/objects/batch: dial tcp IP: getsockopt: connection refused`, -the LFS client is trying to reach GitLab through HTTPS. However, your GitLab -instance is being served on HTTP. - -This behaviour is caused by Git LFS using HTTPS connections by default when a -`lfsurl` is not set in the Git config. - -To prevent this from happening, set the lfs url in project Git config: - -```bash -git config --add lfs.url "http://gitlab.example.com/group/project.git/info/lfs" -``` - -### Credentials are always required when pushing an object - -NOTE: **Note:** -With 8.12 GitLab added LFS support to SSH. The Git LFS communication -still goes over HTTP, but now the SSH client passes the correct credentials -to the Git LFS client, so no action is required by the user. - -Given that Git LFS uses HTTP Basic Authentication to authenticate the user pushing -the LFS object on every push for every object, user HTTPS credentials are required. - -By default, Git has support for remembering the credentials for each repository -you use. This is described in [Git credentials man pages](https://git-scm.com/docs/gitcredentials). - -For example, you can tell Git to remember the password for a period of time in -which you expect to push the objects: - -```bash -git config --global credential.helper 'cache --timeout=3600' -``` - -This will remember the credentials for an hour after which Git operations will -require re-authentication. - -If you are using OS X you can use `osxkeychain` to store and encrypt your credentials. -For Windows, you can use `wincred` or Microsoft's [Git Credential Manager for Windows](https://github.com/Microsoft/Git-Credential-Manager-for-Windows/releases). - -More details about various methods of storing the user credentials can be found -on [Git Credential Storage documentation](https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage). - -### LFS objects are missing on push - -GitLab checks files to detect LFS pointers on push. If LFS pointers are detected, GitLab tries to verify that those files already exist in LFS on GitLab. - -Verify that LFS in installed locally and consider a manual push with `git lfs push --all`. - -If you are storing LFS files outside of GitLab you can disable LFS on the project by setting `lfs_enabled: false` with the [projects API](../../api/projects.md#edit-project). - -### Hosting LFS objects externally - -It is possible to host LFS objects externally by setting a custom LFS url with `git config -f .lfsconfig lfs.url https://example.com/<project>.git/info/lfs`. - -You might choose to do this if you are using an appliance like a Sonatype Nexus to store LFS data. If you choose to use an external LFS store, -GitLab will not be able to verify LFS objects which means that pushes will fail if you have GitLab LFS support enabled. - -To stop push failure, LFS support can be disabled in the [Project settings](../../user/project/settings/index.md). This means you will lose GitLab LFS value-adds (Verifying LFS objects, UI integration for LFS). +This document was moved to [another location](../../administration/lfs/manage_large_binaries_with_git_lfs.md). diff --git a/doc/workflow/lfs/migrate_from_git_annex_to_git_lfs.md b/doc/workflow/lfs/migrate_from_git_annex_to_git_lfs.md index 8f24929c9dc3f284344daac0ca7cb4525e91bbbd..997ef8938a6d06d30c9091d395778426b6e8e996 100644 --- a/doc/workflow/lfs/migrate_from_git_annex_to_git_lfs.md +++ b/doc/workflow/lfs/migrate_from_git_annex_to_git_lfs.md @@ -1,254 +1,5 @@ -# Migration guide from Git Annex to Git LFS - ->**Note:** -Git Annex support [has been removed][issue-remove-annex] in GitLab Enterprise -Edition 9.0 (2017/03/22). - -Both [Git Annex][] and [Git LFS][] are tools to manage large files in Git. - -## History - -Git Annex [was introduced in GitLab Enterprise Edition 7.8][post-3], at a time -where Git LFS didn't yet exist. A few months later, GitLab brought support for -Git LFS in [GitLab 8.2][post-2] and is available for both Community and -Enterprise editions. - -## Differences between Git Annex and Git LFS - -Some items below are general differences between the two protocols and some are -ones that GitLab developed. - -- Git Annex works only through SSH, whereas Git LFS works both with SSH and HTTPS - (SSH support was added in GitLab 8.12). -- Annex files are stored in a sub-directory of the normal repositories, whereas - LFS files are stored outside of the repositories in a place you can define. -- Git Annex requires a more complex setup, but has much more options than Git - LFS. You can compare the commands each one offers by running `man git-annex` - and `man git-lfs`. -- Annex files cannot be browsed directly in GitLab's interface, whereas LFS - files can. - -## Migration steps - ->**Note:** -Since Git Annex files are stored in a sub-directory of the normal repositories -(`.git/annex/objects`) and LFS files are stored outside of the repositories, -they are not compatible as they are using a different scheme. Therefore, the -migration has to be done manually per repository. - -There are basically two steps you need to take in order to migrate from Git -Annex to Git LFS. - -### TL; DR - -If you know what you are doing and want to skip the reading, this is what you -need to do (we assume you have [git-annex enabled](../git_annex.md#using-gitlab-git-annex) in your -repository and that you have made backups in case something goes wrong). -Fire up a terminal, navigate to your Git repository and: - -1. Disable `git-annex`: - - ```bash - git annex sync --content - git annex direct - git annex uninit - git annex indirect - ``` - -1. Enable `git-lfs`: - - ``` - git lfs install - git lfs track <files> - git add . - git commit -m "commit message" - git push - ``` - -### Disabling Git Annex in your repo - -Before changing anything, make sure you have a backup of your repository first. -There are a couple of ways to do that, but you can simply clone it to another -local path and maybe push it to GitLab if you want a remote backup as well. -Here you'll find a guide on -[how to back up a **git-annex** repository to an external hard drive][bkp-ext-drive]. - -Since Annex files are stored as objects with symlinks and cannot be directly -modified, we need to first remove those symlinks. - -NOTE: **Note:** -Make sure the you read about the [`direct` mode][annex-direct] as it contains -useful information that may fit in your use case. Note that `annex direct` is -deprecated in Git Annex version 6, so you may need to upgrade your repository -if the server also has Git Annex 6 installed. Read more in the -[Git Annex troubleshooting tips](../git_annex.md#troubleshooting-tips) section. - -1. Backup your repository - - ```bash - cd repository - git annex sync --content - cd .. - git clone repository repository-backup - cd repository-backup - git annex get - cd .. - ``` - -1. Use `annex direct`: - - ```bash - cd repository - git annex direct - ``` - - The output should be similar to this: - - ```bash - commit - On branch master - Your branch is up-to-date with 'origin/master'. - nothing to commit, working tree clean - ok - direct debian.iso ok - direct ok - ``` - -1. Disable Git Annex with [`annex uninit`][uninit]: - - ```bash - git annex uninit - ``` - - The output should be similar to this: - - ```bash - unannex debian.iso ok - Deleted branch git-annex (was 2534d2c). - ``` - - This will `unannex` every file in the repository, leaving the original files. - -1. Switch back to `indirect` mode: - - ```bash - git annex indirect - ``` - - The output should be similar to this: - - ```bash - (merging origin/git-annex into git-annex...) - (recording state in git...) - commit (recording state in git...) - - ok - (recording state in git...) - [master fac3194] commit before switching to indirect mode - 1 file changed, 1 deletion(-) - delete mode 120000 alpine-virt-3.4.4-x86_64.iso - ok - indirect ok - ok - ``` - +--- +redirect_to: '../../administration/lfs/migrate_from_git_annex_to_git_lfs.md' --- -At this point, you have two options. Either add, commit and push the files -directly back to GitLab or switch to Git LFS. We will tackle the LFS switch in -the next section. - -### Enabling Git LFS in your repo - -Git LFS is enabled by default on all GitLab products (GitLab CE, GitLab EE, -GitLab.com), therefore, you don't need to do anything server-side. - -1. First, make sure you have `git-lfs` installed locally: - - ```bash - git lfs help - ``` - - If the terminal doesn't prompt you with a full response on `git-lfs` commands, - [install the Git LFS client][install-lfs] first. - -1. Inside the repo, run the following command to initiate LFS: - - ```bash - git lfs install - ``` - -1. Enable `git-lfs` for the group of files you want to track. You - can track specific files, all files containing the same extension, or an - entire directory: - - ```bash - git lfs track images/01.png # per file - git lfs track **/*.png # per extension - git lfs track images/ # per directory - ``` - - Once you do that, run `git status` and you'll see `.gitattributes` added - to your repo. It collects all file patterns that you chose to track via - `git-lfs`. - -1. Add the files, commit and push them to GitLab: - - ```bash - git add . - git commit -m "commit message" - git push - ``` - - If your remote is set up with HTTP, you will be asked to enter your login - credentials. If you have [2FA enabled](../../user/profile/account/two_factor_authentication.md), make sure to use a - [personal access token](../../user/profile/account/two_factor_authentication.md#personal-access-tokens) - instead of your password. - -## Removing the Git Annex branches - -After the migration finishes successfully, you can remove all `git-annex` -related branches from your repository. - -On GitLab, navigate to your project's **Repository âž” Branches** and delete all -branches created by Git Annex: `git-annex`, and all under `synced/`. - - - -You can also do this on the command line with: - -```bash -git branch -d synced/master -git branch -d synced/git-annex -git push origin :synced/master -git push origin :synced/git-annex -git push origin :git-annex -git remote prune origin -``` - -If there are still some Annex objects inside your repository (`.git/annex/`) -or references inside `.git/config`, run `annex uninit` again: - -```bash -git annex uninit -``` - -## Further Reading - -- (Blog Post) [Getting Started with Git FLS][post-1] -- (Blog Post) [Announcing LFS Support in GitLab][post-2] -- (Blog Post) [GitLab Annex Solves the Problem of Versioning Large Binaries with Git][post-3] -- (GitLab Docs) [Git Annex](../git_annex.md) -- (GitLab Docs) [Git LFS](manage_large_binaries_with_git_lfs.md) - -[annex-direct]: https://git-annex.branchable.com/direct_mode/ -[bkp-ext-drive]: https://www.thomas-krenn.com/en/wiki/Git-annex_Repository_on_an_External_Hard_Drive -[Git Annex]: http://git-annex.branchable.com/ -[Git LFS]: https://git-lfs.github.com/ -[install-lfs]: https://git-lfs.github.com/ -[issue-remove-annex]: https://gitlab.com/gitlab-org/gitlab/issues/1648 -[lfs-track]: https://about.gitlab.com/blog/2017/01/30/getting-started-with-git-lfs-tutorial/#tracking-files-with-lfs -[post-1]: https://about.gitlab.com/blog/2017/01/30/getting-started-with-git-lfs-tutorial/ -[post-2]: https://about.gitlab.com/blog/2015/11/23/announcing-git-lfs-support-in-gitlab/ -[post-3]: https://about.gitlab.com/blog/2015/02/17/gitlab-annex-solves-the-problem-of-versioning-large-binaries-with-git/ -[uninit]: https://git-annex.branchable.com/git-annex-uninit/ +This document was moved to [another location](../../administration/lfs/migrate_from_git_annex_to_git_lfs.md). diff --git a/doc/workflow/notifications.md b/doc/workflow/notifications.md index d619c870c5ede37cc3d66bb098c4c8363c869080..23f96360484137ba68ce9fdd0261b0d746ce4ec8 100644 --- a/doc/workflow/notifications.md +++ b/doc/workflow/notifications.md @@ -1,172 +1,5 @@ -# GitLab Notification Emails +--- +redirect_to: '../user/profile/notifications.md' +--- -GitLab has a notification system in place to notify a user of events that are important for the workflow. - -## Notification settings - -You can find notification settings under the user profile. - - - -Notification settings are divided into three groups: - -- Global settings -- Group settings -- Project settings - -Each of these settings have levels of notification: - -- Global: For groups and projects, notifications as per global settings. -- Watch: Receive notifications for any activity. -- Participate: Receive notifications for threads you have participated in. -- On Mention: Receive notifications when `@mentioned` in comments. -- Disabled: Turns off notifications. -- Custom: Receive notifications for custom selected events. - -> Introduced in GitLab 12.0 - -You can also select an email address to receive notifications for each group you belong to. - -### Global Settings - -Global settings are at the bottom of the hierarchy. -Any setting set here will be overridden by a setting at the group or a project level. - -Group or Project settings can use `global` notification setting which will then use -anything that is set at Global Settings. - -### Group Settings - - - -Group settings are taking precedence over Global Settings but are on a level below Project or Subgroup settings: - -``` -Group < Subgroup < Project -``` - -This means that you can set a different level of notifications per group while still being able -to have a finer level setting per project or subgroup. -Organization like this is suitable for users that belong to different groups but don't have the -same need for being notified for every group they are member of. -These settings can be configured on group page under the name of the group. It will be the dropdown with the bell icon. They can also be configured on the user profile notifications dropdown. - -The group owner can disable email notifications for a group, which also includes -it's subgroups and projects. If this is the case, you will not receive any corresponding notifications, -and the notification button will be disabled with an explanatory tooltip. - -### Project Settings - - - -Project settings are at the top level and any setting placed at this level will take precedence of any -other setting. -This is suitable for users that have different needs for notifications per project basis. -These settings can be configured on project page under the name of the project. It will be the dropdown with the bell icon. They can also be configured on the user profile notifications dropdown. - -The project owner (or it's group owner) can disable email notifications for the project. -If this is the case, you will not receive any corresponding notifications, and the notification -button will be disabled with an explanatory tooltip. - -## Notification events - -Below is the table of events users can be notified of: - -| Event | Sent to | Settings level | -|------------------------------|---------------------|------------------------------| -| New SSH key added | User | Security email, always sent. | -| New email added | User | Security email, always sent. | -| Email changed | User | Security email, always sent. | -| Password changed | User | Security email, always sent. | -| New user created | User | Sent on user creation, except for OmniAuth (LDAP)| -| User added to project | User | Sent when user is added to project | -| Project access level changed | User | Sent when user project access level is changed | -| User added to group | User | Sent when user is added to group | -| Group access level changed | User | Sent when user group access level is changed | -| Project moved | Project members (1) | (1) not disabled | -| New release | Project members | Custom notification | - -### Issue / Epics / Merge request events - -In most of the below cases, the notification will be sent to: - -- Participants: - - the author and assignee of the issue/merge request - - authors of comments on the issue/merge request - - anyone mentioned by `@username` in the title or description of the issue, merge request or epic **(ULTIMATE)** - - anyone with notification level "Participating" or higher that is mentioned by `@username` - in any of the comments on the issue, merge request, or epic **(ULTIMATE)** -- Watchers: users with notification level "Watch" -- Subscribers: anyone who manually subscribed to the issue, merge request, or epic **(ULTIMATE)** -- Custom: Users with notification level "custom" who turned on notifications for any of the events present in the table below - -| Event | Sent to | -|------------------------|---------| -| New issue | | -| Close issue | | -| Reassign issue | The above, plus the old assignee | -| Reopen issue | | -| Due issue | Participants and Custom notification level with this event selected | -| Change milestone issue | Subscribers, participants mentioned, and Custom notification level with this event selected | -| Remove milestone issue | Subscribers, participants mentioned, and Custom notification level with this event selected | -| New merge request | | -| Push to merge request | Participants and Custom notification level with this event selected | -| Reassign merge request | The above, plus the old assignee | -| Close merge request | | -| Reopen merge request | | -| Merge merge request | | -| Change milestone merge request | Subscribers, participants mentioned, and Custom notification level with this event selected | -| Remove milestone merge request | Subscribers, participants mentioned, and Custom notification level with this event selected | -| New comment | The above, plus anyone mentioned by `@username` in the comment, with notification level "Mention" or higher | -| Failed pipeline | The author of the pipeline | -| Successful pipeline | The author of the pipeline, if they have the custom notification setting for successful pipelines set | -| New epic **(ULTIMATE)** | | -| Close epic **(ULTIMATE)** | | -| Reopen epic **(ULTIMATE)** | | - -In addition, if the title or description of an Issue or Merge Request is -changed, notifications will be sent to any **new** mentions by `@username` as -if they had been mentioned in the original text. - -You won't receive notifications for Issues, Merge Requests or Milestones created -by yourself (except when an issue is due). You will only receive automatic -notifications when somebody else comments or adds changes to the ones that -you've created or mentions you. - -If an open merge request becomes unmergeable due to conflict, its author will be notified about the cause. -If a user has also set the merge request to automatically merge once pipeline succeeds, -then that user will also be notified. - -### Email Headers - -Notification emails include headers that provide extra content about the notification received: - -| Header | Description | -|-----------------------------|-------------------------------------------------------------------------| -| X-GitLab-Project | The name of the project the notification belongs to | -| X-GitLab-Project-Id | The ID of the project | -| X-GitLab-Project-Path | The path of the project | -| X-GitLab-(Resource)-ID | The ID of the resource the notification is for, where resource is `Issue`, `MergeRequest`, `Commit`, etc| -| X-GitLab-Discussion-ID | Only in comment emails, the ID of the thread the comment is from | -| X-GitLab-Pipeline-Id | Only in pipeline emails, the ID of the pipeline the notification is for | -| X-GitLab-Reply-Key | A unique token to support reply by email | -| X-GitLab-NotificationReason | The reason for being notified. "mentioned", "assigned", etc | -| List-Id | The path of the project in a RFC 2919 mailing list identifier useful for email organization, for example, with Gmail filters | - -#### X-GitLab-NotificationReason - -This header holds the reason for the notification to have been sent out, -where reason can be `mentioned`, `assigned`, `own_activity`, etc. -Only one reason is sent out according to its priority: - -- `own_activity` -- `assigned` -- `mentioned` - -The reason in this header will also be shown in the footer of the notification email. For example an email with the -reason `assigned` will have this sentence in the footer: -`"You are receiving this email because you have been assigned an item on {configured GitLab hostname}"` - -NOTE: **Note:** -Only reasons listed above have been implemented so far. -Further implementation is [being discussed](https://gitlab.com/gitlab-org/gitlab-foss/issues/42062). +This document was moved to [another location](../user/profile/notifications.md). diff --git a/doc/workflow/releases.md b/doc/workflow/releases.md index 1fd63a556c6055fdd70e29ff657e8629a81efcd3..f3ba61f6a5c54e282b44408f4380597a25bd5671 100644 --- a/doc/workflow/releases.md +++ b/doc/workflow/releases.md @@ -1,22 +1,5 @@ -# Releases +--- +redirect_to: '../user/project/releases/index.md#add-release-notes-to-git-tags' +--- -NOTE: In GitLab 11.7, we introduced the full fledged [Releases](../user/project/releases/index.md) -feature. You can still create release notes on this page, but the new method is preferred. - -You can add release notes to any Git tag using the notes feature. Release notes -behave like any other markdown form in GitLab so you can write text and -drag-n-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 - -## New tag page with release notes text area - - - -## Tags page with button to add or edit release notes for existing Git tag - - +This document was moved to [another location](../user/project/releases/index.md#add-release-notes-to-git-tags). diff --git a/doc/workflow/repository_mirroring.md b/doc/workflow/repository_mirroring.md index 6d1a59137899229cefbd36fe0f9b0bcbd72a6594..dc77f4f47af08ab47c4bee8299a75623b02798be 100644 --- a/doc/workflow/repository_mirroring.md +++ b/doc/workflow/repository_mirroring.md @@ -1,426 +1,5 @@ -# Repository mirroring - -Repository mirroring allows for mirroring of repositories to and from external sources. It can be -used to mirror branches, tags, and commits between repositories. - -A repository mirror at GitLab will be updated automatically. You can also manually trigger an update -at most once every 5 minutes. - -## Overview - -Repository mirroring is useful when you want to use a repository outside of GitLab. - -There are two kinds of repository mirroring supported by GitLab: - -- Push: for mirroring a GitLab repository to another location. -- Pull: for mirroring a repository from another location to GitLab. **(STARTER)** - -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](../user/permissions.md) to the project can also force an -immediate update, unless: - -- The mirror is already being updated. -- 5 minutes haven't elapsed since its last update. - -## Use cases - -The following are some possible use cases for repository mirroring: - -- You migrated to GitLab but still need to keep your project in another source. In that case, you - can simply set it up to mirror to GitLab (pull) and all the essential history of commits, tags, - and branches will be available in your GitLab instance. **(STARTER)** -- You have old projects in another source that you don't use actively anymore, but don't want to - remove for archiving purposes. In that case, you can create a push mirror so that your active - GitLab repository can push its changes to the old location. - -## Pushing to a remote repository **(CORE)** - -> - [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/249) in GitLab Enterprise Edition 8.7. -> - [Moved to GitLab Core](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/18715) in 10.8. - -For an existing project, you can set up push mirroring as follows: - -1. Navigate to your project's **Settings > Repository** and expand the **Mirroring repositories** section. -1. Enter a repository URL. -1. Select **Push** from the **Mirror direction** dropdown. -1. Select an authentication method from the **Authentication method** dropdown, if necessary. -1. Check the **Only mirror protected branches** box, if necessary. -1. Click the **Mirror repository** button to save the configuration. - - - -When push mirroring is enabled, only push commits directly to the mirrored repository to prevent the -mirror diverging. All changes will end up in the mirrored repository whenever: - -- Commits are pushed to GitLab. -- A [forced update](#forcing-an-update-core) is initiated. - -Changes pushed to files in the repository are automatically pushed to the remote mirror at least: - -- Within five minutes of being received. -- Within one minute if **Only mirror protected branches** is enabled. - -In the case of a diverged branch, you will see an error indicated at the **Mirroring repositories** -section. - -### Push only protected branches **(CORE)** - -> - [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/3350) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.3. -> - [Moved to GitLab Core](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/18715) in 10.8. - -You can choose to only push your protected branches from GitLab to your remote repository. - -To use this option, check the **Only mirror protected branches** box when creating a repository -mirror. - -## Setting up a push mirror from GitLab to GitHub **(CORE)** - -To set up a mirror from GitLab to GitHub, you need to follow these steps: - -1. Create a [GitHub personal access token](https://help.github.com/en/articles/creating-a-personal-access-token-for-the-command-line) with the `public_repo` box checked. -1. Fill in the **Git repository URL** field using this format: `https://<your_github_username>@github.com/<your_github_group>/<your_github_project>.git`. -1. Fill in **Password** field with your GitHub personal access token. -1. Click the **Mirror repository** button. - -The mirrored repository will be listed. For example, `https://*****:*****@github.com/<your_github_group>/<your_github_project>.git`. - -The repository will push soon. To force a push, click the appropriate button. - -## Setting up a push mirror to another GitLab instance with 2FA activated - -1. On the destination GitLab instance, create a [personal access token](../user/profile/personal_access_tokens.md) with `API` scope. -1. On the source GitLab instance: - 1. Fill in the **Git repository URL** field using this format: `https://oauth2@<destination host>/<your_gitlab_group_or_name>/<your_gitlab_project>.git`. - 1. Fill in **Password** field with the GitLab personal access token created on the destination GitLab instance. - 1. Click the **Mirror repository** button. - -## Pulling from a remote repository **(STARTER)** - -> - [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/51) in GitLab Enterprise Edition 8.2. -> - [Added Git LFS support](https://gitlab.com/gitlab-org/gitlab/issues/10871) in [GitLab Starter](https://about.gitlab.com/pricing/) 11.11. - -NOTE: **Note:** This feature [is available for free](https://gitlab.com/gitlab-org/gitlab/issues/10361) to -GitLab.com users until March 22nd, 2020. - -You can set up a repository to automatically have its branches, tags, and commits updated from an -upstream repository. - -This is useful when a repository you're interested in is located on a different server, and you want -to be able to browse its content and its activity using the familiar GitLab interface. - -To configure mirror pulling for an existing project: - -1. Navigate to your project's **Settings > Repository** and expand the **Mirroring repositories** - section. -1. Enter a repository URL. -1. Select **Pull** from the **Mirror direction** dropdown. -1. Select an authentication method from the **Authentication method** dropdown, if necessary. -1. If necessary, check the following boxes: - - **Overwrite diverged branches**. - - **Trigger pipelines for mirror updates**. - - **Only mirror protected branches**. -1. Click the **Mirror repository** button to save the configuration. - - - +--- +redirect_to: '../user/project/repository/repository_mirroring.md' --- - - -Because GitLab is now set to pull changes from the upstream repository, you should not push commits -directly to the repository on GitLab. Instead, any commits should be pushed to the upstream repository. -Changes pushed to the upstream repository will be pulled into the GitLab repository, either: - -- Automatically within a certain period of time. -- When a [forced update](#forcing-an-update-core) is initiated. - -CAUTION: **Caution:** -If you do manually update a branch in the GitLab repository, the branch will become diverged from -upstream and GitLab will no longer automatically update this branch to prevent any changes from being lost. - -### How it works - -Once the pull mirroring feature has been enabled for a repository, the repository is added to a queue. - -Once per minute, a Sidekiq cron job schedules repository mirrors to update, based on: - -- The capacity available. This is determined by Sidekiq settings. For GitLab.com, see [GitLab.com Sidekiq settings](../user/gitlab_com/index.md#sidekiq). -- The number of repository mirrors already in the queue that are due to be updated. Being due depends on when the repository mirror was last updated and how many times it's been retried. - -Repository mirrors are updated as Sidekiq becomes available to process them. If the process of updating the repository mirror: - -- Succeeds, an update will be enqueued again with at least a 30 minute wait. -- Fails (for example, a branch diverged from upstream), it will be attempted again later. Mirrors can fail - up to 14 times before they will not be enqueued for update again. - -### SSH authentication - -> - [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/2551) for Pull mirroring in [GitLab Starter](https://about.gitlab.com/pricing/) 9.5. -> - [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/22982) for Push mirroring in [GitLab Core](https://about.gitlab.com/pricing/) 11.6 - -SSH authentication is mutual: - -- You have to prove to the server that you're allowed to access the repository. -- The server also has to prove to *you* that it's who it claims to be. - -You provide your credentials as a password or public key. The server that the -other repository resides on provides its credentials as a "host key", the -fingerprint of which needs to be verified manually. - -If you're mirroring over SSH (that is, using an `ssh://` URL), you can authenticate using: - -- Password-based authentication, just as over HTTPS. -- Public key authentication. This is often more secure than password authentication, - especially when the other repository supports [Deploy Keys](../ssh/README.md#deploy-keys). - -To get started: - -1. Navigate to your project's **Settings > Repository** and expand the **Mirroring repositories** section. -1. Enter an `ssh://` URL for mirroring. - -NOTE: **Note:** -SCP-style URLs (that is, `git@example.com:group/project.git`) are not supported at this time. - -Entering the URL adds two buttons to the page: - -- **Detect host keys**. -- **Input host keys manually**. - -If you click the: - -- **Detect host keys** button, GitLab will fetch the host keys from the server and display the fingerprints. -- **Input host keys manually** button, a field is displayed where you can paste in host keys. - -Assuming you used the former, you now need to verify that the fingerprints are -those you expect. GitLab.com and other code hosting sites publish their -fingerprints in the open for you to check: - -- [AWS CodeCommit](https://docs.aws.amazon.com/codecommit/latest/userguide/regions.html#regions-fingerprints) -- [Bitbucket](https://confluence.atlassian.com/bitbucket/ssh-keys-935365775.html) -- [GitHub](https://help.github.com/en/articles/githubs-ssh-key-fingerprints) -- [GitLab.com](../user/gitlab_com/index.md#ssh-host-keys-fingerprints) -- [Launchpad](https://help.launchpad.net/SSHFingerprints) -- [Savannah](http://savannah.gnu.org/maintenance/SshAccess/) -- [SourceForge](https://sourceforge.net/p/forge/documentation/SSH%20Key%20Fingerprints/) - -Other providers will vary. If you're running self-managed GitLab, or otherwise -have access to the server for the other repository, you can securely gather the -key fingerprints: - -```sh -$ cat /etc/ssh/ssh_host*pub | ssh-keygen -E md5 -l -f - -256 MD5:f4:28:9f:23:99:15:21:1b:bf:ed:1f:8e:a0:76:b2:9d root@example.com (ECDSA) -256 MD5:e6:eb:45:8a:3c:59:35:5f:e9:5b:80:12:be:7e:22:73 root@example.com (ED25519) -2048 MD5:3f:72:be:3d:62:03:5c:62:83:e8:6e:14:34:3a:85:1d root@example.com (RSA) -``` - -NOTE: **Note:** -You may need to exclude `-E md5` for some older versions of SSH. - -When mirroring the repository, GitLab will now check that at least one of the -stored host keys matches before connecting. This can prevent malicious code from -being injected into your mirror, or your password being stolen. - -### SSH public key authentication - -To use SSH public key authentication, you'll also need to choose that option -from the **Authentication method** dropdown. When the mirror is created, -GitLab generates a 4096-bit RSA key that can be copied by clicking the **Copy SSH public key** button. - - - -You then need to add the public SSH key to the other repository's configuration: - -- If the other repository is hosted on GitLab, you should add the public SSH key - as a [Deploy Key](../ssh/README.md#deploy-keys). -- If the other repository is hosted elsewhere, you may need to add the key to - your user's `authorized_keys` file. Paste the entire public SSH key into the - file on its own line and save it. - -If you need to change the key at any time, you can remove and re-add the mirror -to generate a new key. You'll have to update the other repository with the new -key to keep the mirror running. - -### Overwrite diverged branches **(STARTER)** - -> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/4559) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.6. - -You can choose to always update your local branches with remote versions, even if they have -diverged from the remote. - -CAUTION: **Caution:** -For mirrored branches, enabling this option results in the loss of local changes. - -To use this option, check the **Overwrite diverged branches** box when creating a repository mirror. - -### Only mirror protected branches **(STARTER)** - -> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/3326) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.3. - -You can choose to pull mirror only the protected branches from your remote repository to GitLab. -Non-protected branches are not mirrored and can diverge. - -To use this option, check the **Only mirror protected branches** box when creating a repository mirror. - -### Hard failure **(STARTER)** - -> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/3117) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.2. - -Once the mirroring process is unsuccessfully retried 14 times in a row, it will get marked as hard -failed. This will become visible in either the: - -- Project's main dashboard. -- Pull mirror settings page. - -When a project is hard failed, it will no longer get picked up for mirroring. A user can resume the -project mirroring again by [Forcing an update](#forcing-an-update-core). - -### Trigger update using API **(STARTER)** - -> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/3453) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.3. - -Pull mirroring uses polling to detect new branches and commits added upstream, often minutes -afterwards. If you notify GitLab by [API](../api/projects.md#start-the-pull-mirroring-process-for-a-project-starter), -updates will be pulled immediately. - -For more information, see [Start the pull mirroring process for a Project](../api/projects.md#start-the-pull-mirroring-process-for-a-project-starter). - -## Forcing an update **(CORE)** - -While mirrors are scheduled to update automatically, you can always force an update by using the -update button which is available on the **Mirroring repositories** section of the **Repository Settings** page. - - - -## Bidirectional mirroring **(STARTER)** - -CAUTION: **Caution:** -Bidirectional mirroring may cause conflicts. - -If you configure a GitLab repository to both pull from, and push to, the same remote source, there -is no guarantee that either repository will update correctly. If you set up a repository for -bidirectional mirroring, you should prepare for the likely conflicts by deciding who will resolve -them and how they will be resolved. - -Rewriting any mirrored commit on either remote will cause conflicts and mirroring to fail. This can -be prevented by: - -- [Pulling only protected branches](#only-mirror-protected-branches-starter). -- [Pushing only protected branches](#push-only-protected-branches-core). - -You should [protect the branches](../user/project/protected_branches.md) you wish to mirror on both -remotes to prevent conflicts caused by rewriting history. - -Bidirectional mirroring also creates a race condition where commits made close together to the same -branch causes conflicts. The race condition can be mitigated by reducing the mirroring delay by using -a [Push event webhook](../user/project/integrations/webhooks.md#push-events) to trigger an immediate -pull to GitLab. Push mirroring from GitLab is rate limited to once per minute when only push mirroring -protected branches. - -### Preventing conflicts using a `pre-receive` hook - -CAUTION: **Warning:** -The solution proposed will negatively impact the performance of -Git push operations because they will be proxied to the upstream Git -repository. - -A server-side `pre-receive` hook can be used to prevent the race condition -described above by only accepting the push after first pushing the commit to -the upstream Git repository. In this configuration one Git repository acts as -the authoritative upstream, and the other as downstream. The `pre-receive` hook -will be installed on the downstream repository. - -Read about [configuring custom Git hooks](../administration/custom_hooks.md) on the GitLab server. - -A sample `pre-receive` hook is provided below. - -```bash -#!/usr/bin/env bash - -# --- Assume only one push mirror target -# Push mirroring remotes are named `remote_mirror_<id>`, this finds the first remote and uses that. -TARGET_REPO=$(git remote | grep -m 1 remote_mirror) - -proxy_push() -{ - # --- Arguments - OLDREV=$(git rev-parse $1) - NEWREV=$(git rev-parse $2) - REFNAME="$3" - - # --- Pattern of branches to proxy pushes - whitelisted=$(expr "$branch" : "\(master\)") - - case "$refname" in - refs/heads/*) - branch=$(expr "$refname" : "refs/heads/\(.*\)") - - if [ "$whitelisted" = "$branch" ]; then - error="$(git push --quiet $TARGET_REPO $NEWREV:$REFNAME 2>&1)" - fail=$? - - if [ "$fail" != "0" ]; then - echo >&2 "" - echo >&2 " Error: updates were rejected by upstream server" - echo >&2 " This is usually caused by another repository pushing changes" - echo >&2 " to the same ref. You may want to first integrate remote changes" - echo >&2 "" - return - fi - fi - ;; - esac -} - -# Allow dual mode: run from the command line just like the update hook, or -# if no arguments are given then run as a hook script -if [ -n "$1" -a -n "$2" -a -n "$3" ]; then - # Output to the terminal in command line mode - if someone wanted to - # resend an email; they could redirect the output to sendmail - # themselves - PAGER= proxy_push $2 $3 $1 -else - # Push is proxied upstream one ref at a time. Because of this it is possible - # for some refs to succeed, and others to fail. This will result in a failed - # push. - while read oldrev newrev refname - do - proxy_push $oldrev $newrev $refname - done -fi -``` - -### Mirroring with Perforce Helix via Git Fusion **(STARTER)** - -CAUTION: **Warning:** -Bidirectional mirroring should not be used as a permanent configuration. Refer to -[Migrating from Perforce Helix](../user/project/import/perforce.md) for alternative migration approaches. - -[Git Fusion](https://www.perforce.com/manuals/git-fusion/#Git-Fusion/section_avy_hyc_gl.html) provides a Git interface -to [Perforce Helix](https://www.perforce.com/products) which can be used by GitLab to bidirectionally -mirror projects with GitLab. This may be useful in some situations when migrating from Perforce Helix -to GitLab where overlapping Perforce Helix workspaces cannot be migrated simultaneously to GitLab. - -If using mirroring with Perforce Helix, you should only mirror protected branches. Perforce Helix -will reject any pushes that rewrite history. Only the fewest number of branches should be mirrored -due to the performance limitations of Git Fusion. - -When configuring mirroring with Perforce Helix via Git Fusion, the following Git Fusion -settings are recommended: - -- `change-pusher` should be disabled. Otherwise, every commit will be rewritten as being committed - by the mirroring account, rather than being mapped to existing Perforce Helix users or the `unknown_git` user. -- `unknown_git` user will be used as the commit author if the GitLab user does not exist in - Perforce Helix. - -Read about [Git Fusion settings on Perforce.com](https://www.perforce.com/manuals/git-fusion/Content/Git-Fusion/section_vss_bdw_w3.html#section_zdp_zz1_3l). - -## Troubleshooting - -Should an error occur during a push, GitLab will display an "Error" highlight for that repository. Details on the error can then be seen by hovering over the highlight text. - -### 13:Received RST_STREAM with error code 2 with GitHub - -If you receive an "13:Received RST_STREAM with error code 2" while mirroring to a GitHub repository, your GitHub settings might be set to block pushes that expose your email address used in commits. Either set your email address on GitHub to be public, or disable the [Block command line pushes that expose my email](https://github.com/settings/emails) setting. +This document was moved to [another location](../user/project/repository/repository_mirroring.md). diff --git a/doc/workflow/shortcuts.md b/doc/workflow/shortcuts.md index 2ec733182f8b9b6a17caedbcf4bc943ccfe5c9e0..4b35c61ec5e52d3e31664bd04a1ecdcf30d4b3cb 100644 --- a/doc/workflow/shortcuts.md +++ b/doc/workflow/shortcuts.md @@ -1,134 +1,5 @@ --- -type: reference +redirect_to: '../user/shortcuts.md' --- -# GitLab keyboard shortcuts - -GitLab has many useful keyboard shortcuts to make it easier to access different features. -You can see the quick reference sheet within GitLab itself with <kbd>Shift</kbd> + <kbd>?</kbd>. - -The [Global Shortcuts](#global-shortcuts) work from any area of GitLab, but you must -be in specific pages for the other shortcuts to be available, as explained in each -section below. - -## Global Shortcuts - -These shortcuts are available in most areas of GitLab - -| Keyboard Shortcut | Description | -| ------------------------------- | ----------- | -| <kbd>?</kbd> | Show/hide shortcut reference sheet. | -| <kbd>Shift</kbd> + <kbd>p</kbd> | Go to your Projects page. | -| <kbd>Shift</kbd> + <kbd>g</kbd> | Go to your Groups page. | -| <kbd>Shift</kbd> + <kbd>a</kbd> | Go to your Activity page. | -| <kbd>Shift</kbd> + <kbd>l</kbd> | Go to your Milestones page. | -| <kbd>Shift</kbd> + <kbd>s</kbd> | Go to your Snippets page. | -| <kbd>s</kbd> | Put cursor in the issues/merge requests search. | -| <kbd>Shift</kbd> + <kbd>i</kbd> | Go to your Issues page. | -| <kbd>Shift</kbd> + <kbd>m</kbd> | Go to your Merge requests page.| -| <kbd>Shift</kbd> + <kbd>t</kbd> | Go to your To-Do List page. | -| <kbd>p</kbd> + <kbd>b</kbd> | Show/hide the Performance Bar. | - -Additionally, the following shortcuts are available when editing text in text fields, -for example comments, replies, or issue and merge request descriptions: - -| Keyboard Shortcut | Description | -| ---------------------------------------------------------------------- | ----------- | -| <kbd>↑</kbd> | Edit your last comment. You must be in a blank text field below a thread, and you must already have at least one comment in the thread. | -| <kbd>⌘</kbd> (Mac) / <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>p</kbd> | Toggle Markdown preview, when editing text in a text field that has **Write** and **Preview** tabs at the top. | - -## Project - -These shortcuts are available from any page within a project. You must type them -relatively quickly to work, and they will take you to another page in the project. - -| Keyboard Shortcut | Description | -| --------------------------- | ----------- | -| <kbd>g</kbd> + <kbd>p</kbd> | Go to the project home page (**Project > Details**). | -| <kbd>g</kbd> + <kbd>v</kbd> | Go to the project activity feed (**Project > Activity**). | -| <kbd>g</kbd> + <kbd>r</kbd> | Go to the project releases list (**Project > Releases**). | -| <kbd>g</kbd> + <kbd>f</kbd> | Go to the [project files](#project-files) list (**Repository > Files**). | -| <kbd>t</kbd> | Go to the project file search page. (**Repository > Files**, click **Find Files**). | -| <kbd>g</kbd> + <kbd>c</kbd> | Go to the project commits list (**Repository > Commits**). | -| <kbd>g</kbd> + <kbd>n</kbd> | Go to the [repository graph](#repository-graph) page (**Repository > Graph**). | -| <kbd>g</kbd> + <kbd>d</kbd> | Go to repository charts (**Repository > Charts**). | -| <kbd>g</kbd> + <kbd>i</kbd> | Go to the project issues list (**Issues > List**). | -| <kbd>i</kbd> | Go to the New Issue page (**Issues**, click **New Issue** ). | -| <kbd>g</kbd> + <kbd>b</kbd> | Go to the project issue boards list (**Issues > Boards**). | -| <kbd>g</kbd> + <kbd>m</kbd> | Go to the project merge requests list (**Merge Requests**). | -| <kbd>g</kbd> + <kbd>j</kbd> | Go to the CI/CD jobs list (**CI/CD > Jobs**). | -| <kbd>g</kbd> + <kbd>l</kbd> | Go to the project metrics (**Operations > Metrics**). | -| <kbd>g</kbd> + <kbd>e</kbd> | Go to the project environments (**Operations > Environments**). | -| <kbd>g</kbd> + <kbd>k</kbd> | Go to the project Kubernetes cluster integration page (**Operations > Kubernetes**). Note that you must have at least [`maintainer` permissions](../user/permissions.md) to access this page. | -| <kbd>g</kbd> + <kbd>s</kbd> | Go to the project snippets list (**Snippets**). | -| <kbd>g</kbd> + <kbd>w</kbd> | Go to the project wiki (**Wiki**), if enabled. | - -### Issues and Merge Requests - -These shortcuts are available when viewing issues and merge requests. - -| Keyboard Shortcut | Description | -| ---------------------------- | ----------- | -| <kbd>e</kbd> | Edit description. | -| <kbd>a</kbd> | Change assignee. | -| <kbd>m</kbd> | Change milestone. | -| <kbd>l</kbd> | Change label. | -| <kbd>r</kbd> | Start writing a comment. If any text is selected, it will be quoted in the comment. Can't be used to reply within a thread. | -| <kbd>n</kbd> | Move to next unresolved discussion (Merge requests only). | -| <kbd>p</kbd> | Move to previous unresolved discussion (Merge requests only). | -| <kbd>]</kbd> or <kbd>j</kbd> | Move to next file (Merge requests only). | -| <kbd>[</kbd> or <kbd>k</kbd> | Move to previous file (Merge requests only). | - -### Project Files - -These shortcuts are available when browsing the files in a project (navigate to -**Repository** > **Files**): - -| Keyboard Shortcut | Description | -| ----------------- | ----------- | -| <kbd>↑</kbd> | Move selection up. | -| <kbd>↓</kbd> | Move selection down. | -| <kbd>enter</kbd> | Open selection. | -| <kbd>esc</kbd> | Go back to file list screen (only while searching for files, **Repository > Files** then click on **Find File**). | -| <kbd>y</kbd> | Go to file permalink (only while viewing a file). | - -### Web IDE - -These shortcuts are available when editing a file with the [Web IDE](../user/project/web_ide/index.md): - -| Keyboard Shortcut | Description | -| ------------------------------------------------------- | ----------- | -| <kbd>⌘</kbd> (Mac) / <kbd>Ctrl</kbd> + <kbd>p</kbd> | Search for, and then open another file for editing. | -| <kbd>⌘</kbd> (Mac) / <kbd>Ctrl</kbd> + <kbd>Enter</kbd> | Commit (when editing the commit message). | - -### Repository Graph - -These shortcuts are available when viewing the project [repository graph](../user/project/repository/index.md#repository-graph) -page (navigate to **Repository > Graph**): - -| Keyboard Shortcut | Description | -| ------------------------------------------------------------------ | ----------- | -| <kbd>â†</kbd> or <kbd>h</kbd> | Scroll left. | -| <kbd>→</kbd> or <kbd>l</kbd> | Scroll right. | -| <kbd>↑</kbd> or <kbd>k</kbd> | Scroll up. | -| <kbd>↓</kbd> or <kbd>j</kbd> | Scroll down. | -| <kbd>Shift</kbd> + <kbd>↑</kbd> or <kbd>Shift</kbd> + <kbd>k</kbd> | Scroll to top. | -| <kbd>Shift</kbd> + <kbd>↓</kbd> or <kbd>Shift</kbd> + <kbd>j</kbd> | Scroll to bottom. | - -### Wiki pages - -This shortcut is available when viewing a [wiki page](../user/project/wiki/index.md): - -| Keyboard Shortcut | Description | -| ----------------- | ----------- | -| <kbd>e</kbd> | Edit wiki page. | - -## Epics **(ULTIMATE)** - -These shortcuts are available when viewing [Epics](../user/group/epics/index.md): - -| Keyboard Shortcut | Description | -| ----------------- | ----------- | -| <kbd>r</kbd> | Start writing a comment. If any text is selected, it will be quoted in the comment. Can't be used to reply within a thread. | -| <kbd>e</kbd> | Edit description. | -| <kbd>l</kbd> | Change label. | +This document was moved to [another location](../user/shortcuts.md). diff --git a/doc/workflow/time_tracking.md b/doc/workflow/time_tracking.md index 3d2e1de24daea47f2f7a563faacdd4ca67eb1bf0..e109410e22d8c1a862daeadbd3bed3d3ce7237e8 100644 --- a/doc/workflow/time_tracking.md +++ b/doc/workflow/time_tracking.md @@ -1,91 +1,5 @@ --- -type: reference +redirect_to: '../user/project/time_tracking.md' --- -# Time Tracking - -> Introduced in GitLab 8.14. - -Time Tracking allows you to track estimates and time spent on issues and merge -requests within GitLab. - -## Overview - -Time Tracking allows you to: - -- Record the time spent working on an issue or a merge request. -- Add an estimate of the amount of time needed to complete an issue or a merge - request. - -You don't have to indicate an estimate to enter the time spent, and vice versa. - -Data about time tracking is shown on the issue/merge request sidebar, as shown -below. - - - -## How to enter data - -Time Tracking uses two [quick actions](../user/project/quick_actions.md) -that GitLab introduced with this new feature: `/spend` and `/estimate`. - -Quick actions can be used in the body of an issue or a merge request, but also -in a comment in both an issue or a merge request. - -Below is an example of how you can use those new quick actions inside a comment. - - - -Adding time entries (time spent or estimates) is limited to project members. - -### Estimates - -To enter an estimate, write `/estimate`, followed by the time. For example, if -you need to enter an estimate of 3 days, 5 hours and 10 minutes, you would write -`/estimate 3d 5h 10m`. Time units that we support are listed at the bottom of -this help page. - -Every time you enter a new time estimate, any previous time estimates will be -overridden by this new value. There should only be one valid estimate in an -issue or a merge request. - -To remove an estimation entirely, use `/remove_estimate`. - -### Time spent - -To enter a time spent, use `/spend 3d 5h 10m`. - -Every new time spent entry will be added to the current total time spent for the -issue or the merge request. - -You can remove time by entering a negative amount: `/spend -3d` will remove 3 -days from the total time spent. You can't go below 0 minutes of time spent, -so GitLab will automatically reset the time spent if you remove a larger amount -of time compared to the time that was entered already. - -To remove all the time spent at once, use `/remove_time_spent`. - -## Configuration - -The following time units are available: - -- Months (mo) -- Weeks (w) -- Days (d) -- Hours (h) -- Minutes (m) - -Default conversion rates are 1mo = 4w, 1w = 5d and 1d = 8h. - -### Limit displayed units to hours **(CORE ONLY)** - -> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/29469/) in GitLab 12.1. - -In GitLab self-managed instances, the display of time units can be limited to -hours through the option in **Admin Area > Settings > Preferences** under **Localization**. - -With this option enabled, `75h` is displayed instead of `1w 4d 3h`. - -## Other interesting links - -- [Time Tracking landing page in the GitLab handbook](https://about.gitlab.com/solutions/time-tracking/) +This document was moved to [another location](../user/project/time_tracking.md). diff --git a/doc/workflow/timezone.md b/doc/workflow/timezone.md index 3594ba1918123747e48a1072c8f834eb61bdbde1..f1a2e1af66a28138a466fb0eda812845e55e7f22 100644 --- a/doc/workflow/timezone.md +++ b/doc/workflow/timezone.md @@ -1,37 +1,5 @@ -# Changing your time zone +--- +redirect_to: '../administration/timezone.md' +--- -The global time zone configuration parameter can be changed in `config/gitlab.yml`: - -```text -# time_zone: 'UTC' -``` - -Uncomment and customize if you want to change the default time zone of the GitLab application. - -## Viewing available timezones - -To see all available time zones, run `bundle exec rake time:zones:all`. - -For Omnibus installations, run `gitlab-rake time:zones:all`. - -NOTE: **Note:** -Currently, this rake task does not list timezones in TZInfo format required by GitLab Omnibus during a reconfigure: [#58672](https://gitlab.com/gitlab-org/gitlab-foss/issues/58672). - -## Changing time zone in Omnibus installations - -GitLab defaults its time zone to UTC. It has a global timezone configuration parameter in `/etc/gitlab/gitlab.rb`. - -To obtain a list of timezones, log in to your GitLab application server and run a command that generates a list of timezones in TZInfo format for the server. For example, install `timedatectl` and run `timedatectl list-timezones`. - -To update, add the timezone that best applies to your location. For example: - -```ruby -gitlab_rails['time_zone'] = 'America/New_York' -``` - -After adding the configuration parameter, reconfigure and restart your GitLab instance: - -```sh -gitlab-ctl reconfigure -gitlab-ctl restart -``` +This document was moved to [another location](../administration/timezone.md). diff --git a/doc/workflow/todos.md b/doc/workflow/todos.md index 5d576d8ff3550bd1af9cc990ac6fb19dbdb24bea..48c9a3faf1d26aa913c8e20f0a42384e49488d8b 100644 --- a/doc/workflow/todos.md +++ b/doc/workflow/todos.md @@ -1,138 +1,5 @@ -# GitLab To-Do List +--- +redirect_to: '../user/todos.md' +--- -> [Introduced][ce-2817] in GitLab 8.5. - -When you log into GitLab, you normally want to see where you should spend your -time, take some action, or know what you need to keep an eye on without -a huge pile of e-mail notifications. GitLab is where you do your work, -so being able to get started quickly is important. - -Your To-Do List offers a chronological list of items that are waiting for your input, all -in a simple dashboard. - - - -You can quickly access your To-Do List by clicking the checkmark icon next to the -search bar in the top navigation. If the count is: - -- Less than 100, the number in blue is the number of To-Do items. -- 100 or more, the number displays as 99+. The exact number displays - on the To-Do List. -you still have open. Otherwise, the number displays as 99+. The exact number -displays on the To-Do List. - - - -## What triggers a To Do - -A To Do displays on your To-Do List when: - -- An issue or merge request is assigned to you -- You are `@mentioned` in the description or comment of an: - - Issue - - Merge Request - - Epic **(ULTIMATE)** -- You are `@mentioned` in a comment on a commit -- A job in the CI pipeline running for your merge request failed, but this - job is not allowed to fail -- An open merge request becomes unmergeable due to conflict, and you are either: - - The author - - Have set it to automatically merge once the pipeline succeeds - -To-do triggers are not affected by [GitLab Notification Email settings](notifications.md). - -NOTE: **Note:** -When a user no longer has access to a resource related to a To Do (like an issue, merge request, project, or group) the related To-Do items are deleted within the next hour for security reasons. The delete is delayed to prevent data loss, in case the user's access was revoked by mistake. - -### Directly addressing a To Do - -> [Introduced][ce-7926] in GitLab 9.0. - -If you are mentioned at the start of a line, the To Do you receive will be listed -as 'directly addressed'. For example, in this comment: - -```markdown -@alice What do you think? cc: @bob - -- @carol can you please have a look? - ->>> -@dan what do you think? ->>> - -@erin @frank thank you! -``` - -The people receiving directly addressed To-Do items are `@alice`, `@erin`, and -`@frank`. Directly addressed To-Do items only differ from mentions in their type -for filtering purposes; otherwise, they appear as normal. - -### Manually creating a To Do - -You can also add the following to your To-Do List by clicking the **Add a To Do** button on an: - -- Issue -- Merge Request -- Epic **(ULTIMATE)** - - - -## Marking a To Do as done - -Any action to the following will mark the corresponding To Do as done: - -- Issue -- Merge Request -- Epic **(ULTIMATE)** - -Actions that dismiss To-Do items include: - -- Changing the assignee -- Changing the milestone -- Adding/removing a label -- Commenting on the issue - -Your To-Do List is personal, and items are only marked as done if the action comes from -you. If you close the issue or merge request, your To Do is automatically -marked as done. - -To prevent other users from closing issues without you being notified, if someone else closes, merges, or takes action on the any of the following, your To Do will remain pending: - -- Issue -- Merge request -- Epic **(ULTIMATE)** - -There is just one To Do for each of these, so mentioning a user a hundred times in an issue will only trigger one To Do. - -If no action is needed, you can manually mark the To Do as done by clicking the -corresponding **Done** button, and it will disappear from your To-Do List. - - - -You can also mark a To Do as done by clicking the **Mark as done** button in the sidebar of the following: - -- Issue -- Merge Request -- Epic **(ULTIMATE)** - - - -You can mark all your To-Do items as done at once by clicking the **Mark all as -done** button. - -## Filtering your To-Do List - -There are four kinds of filters you can use on your To-Do List. - -| Filter | Description | -| ------- | ----------- | -| Project | Filter by project | -| Group | Filter by group | -| Author | Filter by the author that triggered the To Do | -| Type | Filter by issue, merge request, or epic **(ULTIMATE)** | -| Action | Filter by the action that triggered the To Do | - -You can also filter by more than one of these at the same time. The possible Actions are `Any Action`, `Assigned`, `Mentioned`, `Added`, `Pipelines`, and `Directly Addressed`, [as described above](#what-triggers-a-to-do). - -[ce-2817]: https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/2817 -[ce-7926]: https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/7926 +This document was moved to [another location](../user/todos.md). diff --git a/doc/workflow/workflow.md b/doc/workflow/workflow.md index 7fac41c3b6fd76f87c0d1dbd5c449275373d65c2..c77d95cd326fae5e1ca98da1b3090e2063942062 100644 --- a/doc/workflow/workflow.md +++ b/doc/workflow/workflow.md @@ -1,31 +1,5 @@ -# Feature branch workflow +--- +redirect_to: '../gitlab-basics/feature_branch_workflow.md' +--- -1. Clone project: - - ```bash - git clone git@example.com:project-name.git - ``` - -1. Create branch with your feature: - - ```bash - git checkout -b $feature_name - ``` - -1. Write code. Commit changes: - - ```bash - git commit -am "My feature is ready" - ``` - -1. Push your branch to GitLab: - - ```bash - git push origin $feature_name - ``` - -1. Review your code on commits page. - -1. Create a merge request. - -1. Your team lead will review the code & merge it to the main branch. +This document was moved to [another location](../gitlab-basics/feature_branch_workflow.md). diff --git a/jest.config.js b/jest.config.js index c2a512e8afa4af6b4ef5d5679120ab0ffedcb0d7..3f9dc3fe2130e0da3f3b1ffe5b8d8f04c0d0763e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -28,32 +28,39 @@ if (isESLint) { testMatch = testMatch.map(path => path.replace('_spec.js', '')); } +const moduleNameMapper = { + '^~(/.*)$': '<rootDir>/app/assets/javascripts$1', + '^ee_component(/.*)$': + '<rootDir>/app/assets/javascripts/vue_shared/components/empty_component.js', + '^ee_else_ce(/.*)$': '<rootDir>/app/assets/javascripts$1', + '^helpers(/.*)$': '<rootDir>/spec/frontend/helpers$1', + '^vendor(/.*)$': '<rootDir>/vendor/assets/javascripts$1', + '\\.(jpg|jpeg|png|svg)$': '<rootDir>/spec/frontend/__mocks__/file_mock.js', + 'emojis(/.*).json': '<rootDir>/fixtures/emojis$1.json', + '^spec/test_constants$': '<rootDir>/spec/frontend/helpers/test_constants', +}; + +if (IS_EE) { + const rootDirEE = '<rootDir>/ee/app/assets/javascripts$1'; + Object.assign(moduleNameMapper, { + '^ee(/.*)$': rootDirEE, + '^ee_component(/.*)$': rootDirEE, + '^ee_else_ce(/.*)$': rootDirEE, + }); +} + // eslint-disable-next-line import/no-commonjs module.exports = { testMatch, moduleFileExtensions: ['js', 'json', 'vue'], - moduleNameMapper: { - '^~(/.*)$': '<rootDir>/app/assets/javascripts$1', - '^ee(/.*)$': '<rootDir>/ee/app/assets/javascripts$1', - '^ee_component(/.*)$': IS_EE - ? '<rootDir>/ee/app/assets/javascripts$1' - : '<rootDir>/app/assets/javascripts/vue_shared/components/empty_component.js', - '^ee_else_ce(/.*)$': IS_EE - ? '<rootDir>/ee/app/assets/javascripts$1' - : '<rootDir>/app/assets/javascripts$1', - '^helpers(/.*)$': '<rootDir>/spec/frontend/helpers$1', - '^vendor(/.*)$': '<rootDir>/vendor/assets/javascripts$1', - '\\.(jpg|jpeg|png|svg)$': '<rootDir>/spec/frontend/__mocks__/file_mock.js', - 'emojis(/.*).json': '<rootDir>/fixtures/emojis$1.json', - '^spec/test_constants$': '<rootDir>/spec/frontend/helpers/test_constants', - }, + moduleNameMapper, collectCoverageFrom: ['<rootDir>/app/assets/javascripts/**/*.{js,vue}'], coverageDirectory: '<rootDir>/coverage-frontend/', coverageReporters: ['json', 'lcov', 'text-summary', 'clover'], cacheDirectory: '<rootDir>/tmp/cache/jest', modulePathIgnorePatterns: ['<rootDir>/.yarn-cache/'], reporters, - setupFilesAfterEnv: ['<rootDir>/spec/frontend/test_setup.js'], + setupFilesAfterEnv: ['<rootDir>/spec/frontend/test_setup.js', 'jest-canvas-mock'], restoreMocks: true, transform: { '^.+\\.(gql|graphql)$': 'jest-transform-graphql', diff --git a/lib/api/api.rb b/lib/api/api.rb index d71f0c38ce68748471848c957b9265a1fb3865ac..a2bdb76b8345dd63685dfde94949ff531f015e07 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -21,6 +21,7 @@ module API Gitlab::GrapeLogging::Loggers::ClientEnvLogger.new, Gitlab::GrapeLogging::Loggers::RouteLogger.new, Gitlab::GrapeLogging::Loggers::UserLogger.new, + Gitlab::GrapeLogging::Loggers::ExceptionLogger.new, Gitlab::GrapeLogging::Loggers::QueueDurationLogger.new, Gitlab::GrapeLogging::Loggers::PerfLogger.new, Gitlab::GrapeLogging::Loggers::CorrelationIdLogger.new @@ -112,6 +113,7 @@ module API mount ::API::Files mount ::API::GroupBoards mount ::API::GroupClusters + mount ::API::GroupExport mount ::API::GroupLabels mount ::API::GroupMilestones mount ::API::Groups diff --git a/lib/api/branches.rb b/lib/api/branches.rb index f8f79ab6f5a08d9482b83748686af94cae1a4697..054242dca4c8429542691b11ced723c053e23d13 100644 --- a/lib/api/branches.rb +++ b/lib/api/branches.rb @@ -32,7 +32,7 @@ module API use :filter_params end get ':id/repository/branches' do - Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42329') + user_project.preload_protected_branches repository = user_project.repository diff --git a/lib/api/commits.rb b/lib/api/commits.rb index ffff40141de5e7b34ae92537564aeb0fe426e95d..63a7fdfa3ab2e0b876bf35aa14522011b2259de8 100644 --- a/lib/api/commits.rb +++ b/lib/api/commits.rb @@ -169,7 +169,7 @@ module API not_found! 'Commit' unless commit - raw_diffs = ::Kaminari.paginate_array(commit.raw_diffs.to_a) + raw_diffs = ::Kaminari.paginate_array(commit.diffs(expanded: true).diffs.to_a) present paginate(raw_diffs), with: Entities::Diff end @@ -223,7 +223,7 @@ module API present user_project.repository.commit(result[:result]), with: Entities::Commit else - render_api_error!(result[:message], 400) + error!(result.slice(:message, :error_code), 400, header) end end @@ -257,7 +257,7 @@ module API present user_project.repository.commit(result[:result]), with: Entities::Commit else - render_api_error!(result[:message], 400) + error!(result.slice(:message, :error_code), 400, header) end end diff --git a/lib/api/deployments.rb b/lib/api/deployments.rb index da8825470710c648263418ba9d04bc543ce75571..f97200f20b9e129f8895453ef119038a8d6d7038 100644 --- a/lib/api/deployments.rb +++ b/lib/api/deployments.rb @@ -17,7 +17,7 @@ module API end params do use :pagination - optional :order_by, type: String, values: %w[id iid created_at ref], default: 'id', desc: 'Return deployments ordered by `id` or `iid` or `created_at` or `ref`' + optional :order_by, type: String, values: %w[id iid created_at updated_at ref], default: 'id', desc: 'Return deployments ordered by `id` or `iid` or `created_at` or `updated_at` or `ref`' optional :sort, type: String, values: %w[asc desc], default: 'asc', desc: 'Sort by asc (ascending) or desc (descending)' end # rubocop: disable CodeReuse/ActiveRecord diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 91811efacd72f79026d6a4c97fc35f4e3713e172..9617f1a8acfd655c2ac9df0b6c21b4363cbcad75 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -307,6 +307,7 @@ module API expose :only_allow_merge_if_pipeline_succeeds expose :request_access_enabled expose :only_allow_merge_if_all_discussions_are_resolved + expose :remove_source_branch_after_merge expose :printing_merge_request_link_enabled expose :merge_method expose :statistics, using: 'API::Entities::ProjectStatistics', if: -> (project, options) { @@ -488,11 +489,11 @@ module API end expose :developers_can_push do |repo_branch, options| - options[:project].protected_branches.developers_can?(:push, repo_branch.name) + ::ProtectedBranch.developers_can?(:push, repo_branch.name, protected_refs: options[:project].protected_branches) end expose :developers_can_merge do |repo_branch, options| - options[:project].protected_branches.developers_can?(:merge, repo_branch.name) + ::ProtectedBranch.developers_can?(:merge, repo_branch.name, protected_refs: options[:project].protected_branches) end expose :can_push do |repo_branch, options| @@ -754,6 +755,7 @@ module API end expose :diff_head_sha, as: :sha expose :merge_commit_sha + expose :squash_commit_sha expose :discussion_locked expose :should_remove_source_branch?, as: :should_remove_source_branch expose :force_remove_source_branch?, as: :force_remove_source_branch @@ -776,6 +778,10 @@ module API expose :squash expose :task_completion_status + + expose :cannot_be_merged?, as: :has_conflicts + + expose :mergeable_discussions_state?, as: :blocking_discussions_resolved end class MergeRequest < MergeRequestBasic @@ -1248,6 +1254,7 @@ module API # let's not expose the secret key in a response attributes.delete(:asset_proxy_secret_key) + attributes.delete(:eks_secret_access_key) attributes end @@ -1290,7 +1297,11 @@ module API end class Release < Grape::Entity - expose :name + include ::API::Helpers::Presentable + + expose :name do |release, _| + can_download_code? ? release.name : "Release-#{release.id}" + end expose :tag, as: :tag_name, if: ->(_, _) { can_download_code? } expose :description expose :description_html do |entity| @@ -1302,8 +1313,8 @@ module API expose :commit, using: Entities::Commit, if: ->(_, _) { can_download_code? } expose :upcoming_release?, as: :upcoming_release expose :milestones, using: Entities::Milestone, if: -> (release, _) { release.milestones.present? } - expose :commit_path, if: ->(_, _) { can_download_code? } - expose :tag_path, if: ->(_, _) { can_download_code? } + expose :commit_path, expose_nil: false + expose :tag_path, expose_nil: false expose :assets do expose :assets_count, as: :count do |release, _| assets_to_exclude = can_download_code? ? [] : [:sources] @@ -1315,8 +1326,9 @@ module API end end expose :_links do - expose :merge_requests_url, if: -> (_) { release_mr_issue_urls_available? } - expose :issues_url, if: -> (_) { release_mr_issue_urls_available? } + expose :merge_requests_url, expose_nil: false + expose :issues_url, expose_nil: false + expose :edit_url, expose_nil: false end private @@ -1324,36 +1336,6 @@ module API def can_download_code? Ability.allowed?(options[:current_user], :download_code, object.project) end - - def commit_path - return unless object.commit - - Gitlab::Routing.url_helpers.project_commit_path(project, object.commit.id) - end - - def tag_path - Gitlab::Routing.url_helpers.project_tag_path(project, object.tag) - end - - def merge_requests_url - Gitlab::Routing.url_helpers.project_merge_requests_url(project, params_for_issues_and_mrs) - end - - def issues_url - Gitlab::Routing.url_helpers.project_issues_url(project, params_for_issues_and_mrs) - end - - def params_for_issues_and_mrs - { scope: 'all', state: 'opened', release_tag: object.tag } - end - - def release_mr_issue_urls_available? - ::Feature.enabled?(:release_mr_issue_urls, project) - end - - def project - @project ||= object.project - end end class Tag < Grape::Entity @@ -1699,6 +1681,7 @@ module API expose :verified?, as: :verified expose :verification_code, as: :verification_code expose :enabled_until + expose :auto_ssl_enabled expose :certificate, as: :certificate_expiration, @@ -1714,6 +1697,7 @@ module API expose :verified?, as: :verified expose :verification_code, as: :verification_code expose :enabled_until + expose :auto_ssl_enabled expose :certificate, if: ->(pages_domain, _) { pages_domain.certificate? }, @@ -1737,7 +1721,12 @@ module API class Blob < Grape::Entity expose :basename expose :data - expose :filename + expose :path + # TODO: :filename was renamed to :path but both still return the full path, + # in the future we can only return the filename here without the leading + # directory path. + # https://gitlab.com/gitlab-org/gitlab/issues/34521 + expose :filename, &:path expose :id expose :ref expose :startline @@ -1813,6 +1802,7 @@ module API expose :user, using: Entities::UserBasic expose :platform_kubernetes, using: Entities::Platform::Kubernetes expose :provider_gcp, using: Entities::Provider::Gcp + expose :management_project, using: Entities::ProjectIdentity end class ClusterProject < Cluster diff --git a/lib/api/group_clusters.rb b/lib/api/group_clusters.rb index a70ac63cc6ed9b913d4fcb9aea29f797b8c9cbec..abfe10b7fa1a924faeb1f55179dc4fa288d3fd11 100644 --- a/lib/api/group_clusters.rb +++ b/lib/api/group_clusters.rb @@ -84,6 +84,7 @@ module API requires :cluster_id, type: Integer, desc: 'The cluster ID' optional :name, type: String, desc: 'Cluster name' optional :domain, type: String, desc: 'Cluster base domain' + optional :management_project_id, type: Integer, desc: 'The ID of the management project' optional :platform_kubernetes_attributes, type: Hash, desc: %q(Platform Kubernetes data) do optional :api_url, type: String, desc: 'URL to access the Kubernetes API' optional :token, type: String, desc: 'Token to authenticate against Kubernetes' diff --git a/lib/api/group_container_repositories.rb b/lib/api/group_container_repositories.rb index fd24662cc9ad33821ad4d274602d409c66b728da..7f95b411b3673ad73b789d691e15d98d846bd459 100644 --- a/lib/api/group_container_repositories.rb +++ b/lib/api/group_container_repositories.rb @@ -23,9 +23,11 @@ module API end get ':id/registry/repositories' do repositories = ContainerRepositoriesFinder.new( - id: user_group.id, container_type: :group + user: current_user, subject: user_group ).execute + track_event('list_repositories') + present paginate(repositories), with: Entities::ContainerRegistry::Repository, tags: params[:tags] end end diff --git a/lib/api/group_export.rb b/lib/api/group_export.rb new file mode 100644 index 0000000000000000000000000000000000000000..8025a16e19164066de6768faba06d52904d024b2 --- /dev/null +++ b/lib/api/group_export.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module API + class GroupExport < Grape::API + before do + authorize! :admin_group, user_group + end + + params do + requires :id, type: String, desc: 'The ID of a group' + end + resource :groups, requirements: { id: %r{[^/]+} } do + desc 'Download export' do + detail 'This feature was introduced in GitLab 12.5.' + end + get ':id/export/download' do + if user_group.export_file_exists? + present_carrierwave_file!(user_group.export_file) + else + render_api_error!('404 Not found or has expired', 404) + end + end + + desc 'Start export' do + detail 'This feature was introduced in GitLab 12.5.' + end + post ':id/export' do + GroupExportWorker.perform_async(current_user.id, user_group.id, params) + + accepted! + end + end + end +end diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 19c29847ce3d72d64e667c19f4d9e66e8f3a6c57..49b86489a8b948fbc79c1b594894fefbea359218 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -9,6 +9,7 @@ module API GITLAB_SHARED_SECRET_HEADER = "Gitlab-Shared-Secret" SUDO_PARAM = :sudo API_USER_ENV = 'gitlab.api.user' + API_EXCEPTION_ENV = 'gitlab.api.exception' def declared_params(options = {}) options = { include_parent_namespaces: false }.merge(options) @@ -387,6 +388,9 @@ module API Gitlab::Sentry.track_acceptable_exception(exception, extra: params) end + # This is used with GrapeLogging::Loggers::ExceptionLogger + env[API_EXCEPTION_ENV] = exception + # lifted from https://github.com/rails/rails/blob/master/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb#L60 trace = exception.backtrace @@ -451,6 +455,17 @@ module API end end + def track_event(action = action_name, **args) + category = args.delete(:category) || self.options[:for].name + raise "invalid category" unless category + + ::Gitlab::Tracking.event(category, action.to_s, **args) + rescue => error + Rails.logger.warn( # rubocop:disable Gitlab/RailsLogger + "Tracking event failed for action: #{action}, category: #{category}, message: #{error.message}" + ) + end + protected def project_finder_params_ce @@ -464,6 +479,8 @@ module API finder_params[:user] = params.delete(:user) if params[:user] finder_params[:custom_attributes] = params[:custom_attributes] if params[:custom_attributes] finder_params[:min_access_level] = params[:min_access_level] if params[:min_access_level] + finder_params[:id_after] = params[:id_after] if params[:id_after] + finder_params[:id_before] = params[:id_before] if params[:id_before] finder_params end diff --git a/lib/api/helpers/internal_helpers.rb b/lib/api/helpers/internal_helpers.rb index 4c575381d30cf8db97de7234d8a1da1dd275572d..dfac777e4a1dbf49055213de410585edeaa023ed 100644 --- a/lib/api/helpers/internal_helpers.rb +++ b/lib/api/helpers/internal_helpers.rb @@ -140,7 +140,8 @@ module API { repository: repository.gitaly_repository, address: Gitlab::GitalyClient.address(project.repository_storage), - token: Gitlab::GitalyClient.token(project.repository_storage) + token: Gitlab::GitalyClient.token(project.repository_storage), + features: Feature::Gitaly.server_feature_flags } end end diff --git a/lib/api/helpers/pagination.rb b/lib/api/helpers/pagination.rb index 71bbc218f944a1a080eec7b8263ed749e4afc8c4..9c5b355e823487f431d59c00b8eeddf14812f2f4 100644 --- a/lib/api/helpers/pagination.rb +++ b/lib/api/helpers/pagination.rb @@ -4,254 +4,7 @@ module API module Helpers module Pagination def paginate(relation) - strategy = if params[:pagination] == 'keyset' && Feature.enabled?('api_keyset_pagination') - KeysetPaginationStrategy - else - DefaultPaginationStrategy - end - - strategy.new(self).paginate(relation) - end - - 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 - 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 - end - - class KeysetPaginationInfo - attr_reader :relation, :request_context - - def initialize(relation, request_context) - # This is because it's rather complex to support multiple values with possibly different sort directions - # (and we don't need this in the API) - if relation.order_values.size > 1 - raise "Pagination only supports ordering by a single column." \ - "The following columns were given: #{relation.order_values.map { |v| v.expr.name }}" - end - - @relation = relation - @request_context = request_context - end - - def fields - keys.zip(values).reject { |_, v| v.nil? }.to_h - end - - def column_for_order_by(relation) - relation.order_values.first&.expr&.name - end - - # Sort direction (`:asc` or `:desc`) - def sort - @sort ||= if order_by_primary_key? - # Default order is by id DESC - :desc - else - # API defaults to DESC order if param `sort` not present - request_context.params[:sort]&.to_sym || :desc - end - end - - # Do we only sort by primary key? - def order_by_primary_key? - keys.size == 1 && keys.first == primary_key - end - - def primary_key - relation.model.primary_key.to_sym - end - - def sort_ascending? - sort == :asc - end - - # Build hash of request parameters for a given record (relevant to pagination) - def params_for(record) - return {} unless record - - keys.each_with_object({}) do |key, h| - h["ks_prev_#{key}".to_sym] = record.attributes[key.to_s] - end - end - - private - - # All values present in request parameters that correspond to #keys. - def values - @values ||= keys.map do |key| - request_context.params["ks_prev_#{key}".to_sym] - end - end - - # All keys relevant to pagination. - # This always includes the primary key. Optionally, the `order_by` key is prepended. - def keys - @keys ||= [column_for_order_by(relation), primary_key].compact.uniq - end - end - - class KeysetPaginationStrategy < Base - attr_reader :request_context - delegate :params, :header, :request, to: :request_context - - def initialize(request_context) - @request_context = request_context - end - - # rubocop: disable CodeReuse/ActiveRecord - def paginate(relation) - pagination = KeysetPaginationInfo.new(relation, request_context) - - paged_relation = relation.limit(per_page) - - if conds = conditions(pagination) - paged_relation = paged_relation.where(*conds) - end - - # In all cases: sort by primary key (possibly in addition to another sort column) - paged_relation = paged_relation.order(pagination.primary_key => pagination.sort) - - add_default_pagination_headers - - if last_record = paged_relation.last - next_page_params = pagination.params_for(last_record) - add_navigation_links(next_page_params) - end - - paged_relation - end - # rubocop: enable CodeReuse/ActiveRecord - - private - - def conditions(pagination) - fields = pagination.fields - - return if fields.empty? - - placeholder = fields.map { '?' } - - comp = if pagination.sort_ascending? - '>' - else - '<' - end - - [ - # Row value comparison: - # (A, B) < (a, b) <=> (A < a) OR (A = a AND B < b) - # <=> A <= a AND ((A < a) OR (A = a AND B < b)) - "(#{fields.keys.join(',')}) #{comp} (#{placeholder.join(',')})", - *fields.values - ] - end - - def add_default_pagination_headers - header 'X-Per-Page', per_page.to_s - end - - def add_navigation_links(next_page_params) - header 'X-Next-Page', page_href(next_page_params) - header 'Link', link_for('next', next_page_params) - end - - def link_for(rel, next_page_params) - %(<#{page_href(next_page_params)}>; rel="#{rel}") - end - end - - class DefaultPaginationStrategy < Base - attr_reader :request_context - delegate :params, :header, :request, to: :request_context - - def initialize(request_context) - @request_context = request_context - end - - def paginate(relation) - paginate_with_limit_optimization(add_default_order(relation)).tap do |data| - add_pagination_headers(data) - end - end - - private - - def paginate_with_limit_optimization(relation) - pagination_data = relation.page(params[:page]).per(params[:per_page]) - return pagination_data unless pagination_data.is_a?(ActiveRecord::Relation) - return pagination_data unless Feature.enabled?(:api_kaminari_count_with_limit) - - limited_total_count = pagination_data.total_count_with_limit - if limited_total_count > Kaminari::ActiveRecordRelationMethods::MAX_COUNT_LIMIT - # The call to `total_count_with_limit` memoizes `@arel` because of a call to `references_eager_loaded_tables?` - # We need to call `reset` because `without_count` relies on `@arel` being unmemoized - pagination_data.reset.without_count - else - pagination_data - end - end - - def add_default_order(relation) - if relation.is_a?(ActiveRecord::Relation) && relation.order_values.empty? - relation = relation.order(:id) # rubocop: disable CodeReuse/ActiveRecord - end - - relation - end - - def add_pagination_headers(paginated_data) - header 'X-Per-Page', paginated_data.limit_value.to_s - header 'X-Page', paginated_data.current_page.to_s - header 'X-Next-Page', paginated_data.next_page.to_s - header 'X-Prev-Page', paginated_data.prev_page.to_s - header 'Link', pagination_links(paginated_data) - - return if data_without_counts?(paginated_data) - - header 'X-Total', paginated_data.total_count.to_s - header 'X-Total-Pages', total_pages(paginated_data).to_s - end - - def pagination_links(paginated_data) - [].tap do |links| - links << %(<#{page_href(page: paginated_data.prev_page)}>; rel="prev") if paginated_data.prev_page - links << %(<#{page_href(page: paginated_data.next_page)}>; rel="next") if paginated_data.next_page - links << %(<#{page_href(page: 1)}>; rel="first") - - links << %(<#{page_href(page: total_pages(paginated_data))}>; rel="last") unless data_without_counts?(paginated_data) - end.join(', ') - end - - def total_pages(paginated_data) - # Ensure there is in total at least 1 page - [paginated_data.total_pages, 1].max - end - - def data_without_counts?(paginated_data) - paginated_data.is_a?(Kaminari::PaginatableWithoutCount) - end + ::Gitlab::Pagination::OffsetPagination.new(self).paginate(relation) end end end diff --git a/lib/api/helpers/projects_helpers.rb b/lib/api/helpers/projects_helpers.rb index 946192042748114a4b086bb3c85ae30f38e4dacb..47b1f037eb8bdd3fd237a480bdb71ee1620a380f 100644 --- a/lib/api/helpers/projects_helpers.rb +++ b/lib/api/helpers/projects_helpers.rb @@ -30,6 +30,7 @@ module API optional :shared_runners_enabled, type: Boolean, desc: 'Flag indication if shared runners are enabled for that project' 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 :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.' @@ -94,6 +95,7 @@ module API :path, :printing_merge_request_link_enabled, :public_builds, + :remove_source_branch_after_merge, :repository_access_level, :request_access_enabled, :resolve_outdated_diff_discussions, @@ -109,7 +111,6 @@ module API :jobs_enabled, :merge_requests_enabled, :wiki_enabled, - :jobs_enabled, :snippets_enabled ] end diff --git a/lib/api/internal/base.rb b/lib/api/internal/base.rb index d9a22484c1ff3ff5135588396a87e2d8b4511eaf..c70f2f3e2c838707bc69b23548cf8f94ee44d53f 100644 --- a/lib/api/internal/base.rb +++ b/lib/api/internal/base.rb @@ -77,7 +77,7 @@ module API response_with_status(**payload) when ::Gitlab::GitAccessResult::CustomAction - response_with_status(code: 300, message: check_result.message, payload: check_result.payload) + response_with_status(code: 300, payload: check_result.payload, gl_console_messages: check_result.console_messages) else response_with_status(code: 500, success: false, message: UNKNOWN_CHECK_RESULT_ERROR) end diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index 1436238c5cf49b96570d1e782bd0e52e8533d789..6e10414def4a4633a0e409ba929055a64240783b 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -296,9 +296,12 @@ module API end get ':id/merge_requests/:merge_request_iid/commits' do merge_request = find_merge_request_with_access(params[:merge_request_iid]) - commits = ::Kaminari.paginate_array(merge_request.commits) - present paginate(commits), with: Entities::Commit + commits = + paginate(merge_request.merge_request_diff.merge_request_diff_commits) + .map { |commit| Commit.from_hash(commit.to_hash, merge_request.project) } + + present commits, with: Entities::Commit end desc 'Show the merge request changes' do @@ -404,7 +407,8 @@ module API merge_params = HashWithIndifferentAccess.new( commit_message: params[:merge_commit_message], squash_commit_message: params[:squash_commit_message], - should_remove_source_branch: params[:should_remove_source_branch] + should_remove_source_branch: params[:should_remove_source_branch], + sha: params[:sha] || merge_request.diff_head_sha ) if merge_when_pipeline_succeeds && merge_request.head_pipeline && merge_request.head_pipeline.active? @@ -455,6 +459,8 @@ module API status :accepted present rebase_in_progress: merge_request.rebase_in_progress? + rescue ::MergeRequest::RebaseLockTimeout => e + render_api_error!(e.message, 409) end desc 'List issues that will be closed on merge' do diff --git a/lib/api/pages_domains.rb b/lib/api/pages_domains.rb index ec2fe8270b7f41ec17a08a98a3bbd3a7bafa5c74..2d02a4e624c239265d612189021c3c7bcb88c4c6 100644 --- a/lib/api/pages_domains.rb +++ b/lib/api/pages_domains.rb @@ -92,8 +92,10 @@ module API requires :domain, type: String, desc: 'The domain' # rubocop:disable Scalability/FileUploads # TODO: remove rubocop disable - https://gitlab.com/gitlab-org/gitlab/issues/14960 - optional :certificate, allow_blank: false, types: [File, String], desc: 'The certificate', as: :user_provided_certificate - optional :key, allow_blank: false, types: [File, String], desc: 'The key', as: :user_provided_key + optional :certificate, types: [File, String], desc: 'The certificate', as: :user_provided_certificate + optional :key, types: [File, String], desc: 'The key', as: :user_provided_key + optional :auto_ssl_enabled, allow_blank: false, type: Boolean, default: false, + desc: "Enables automatic generation of SSL certificates issued by Let's Encrypt for custom domains." # rubocop:enable Scalability/FileUploads all_or_none_of :user_provided_certificate, :user_provided_key end @@ -116,14 +118,16 @@ module API requires :domain, type: String, desc: 'The domain' # rubocop:disable Scalability/FileUploads # TODO: remove rubocop disable - https://gitlab.com/gitlab-org/gitlab/issues/14960 - optional :certificate, allow_blank: false, types: [File, String], desc: 'The certificate', as: :user_provided_certificate - optional :key, allow_blank: false, types: [File, String], desc: 'The key', as: :user_provided_key + optional :certificate, types: [File, String], desc: 'The certificate', as: :user_provided_certificate + optional :key, types: [File, String], desc: 'The key', as: :user_provided_key + optional :auto_ssl_enabled, allow_blank: true, type: Boolean, + desc: "Enables automatic generation of SSL certificates issued by Let's Encrypt for custom domains." # rubocop:enable Scalability/FileUploads end put ":id/pages/domains/:domain", requirements: PAGES_DOMAINS_ENDPOINT_REQUIREMENTS do authorize! :update_pages, user_project - pages_domain_params = declared(params, include_parent_namespaces: false) + pages_domain_params = declared(params, include_parent_namespaces: false, include_missing: false) # Remove empty private key if certificate is not empty. if pages_domain_params[:user_provided_certificate] && !pages_domain_params[:user_provided_key] diff --git a/lib/api/project_clusters.rb b/lib/api/project_clusters.rb index 45c800d7d1eef473be54a303b36f4540bddc0aed..8e35914f48ae30599e6bffa1b3df790d16ba4f8f 100644 --- a/lib/api/project_clusters.rb +++ b/lib/api/project_clusters.rb @@ -88,6 +88,7 @@ module API requires :cluster_id, type: Integer, desc: 'The cluster ID' optional :name, type: String, desc: 'Cluster name' optional :domain, type: String, desc: 'Cluster base domain' + optional :management_project_id, type: Integer, desc: 'The ID of the management project' optional :platform_kubernetes_attributes, type: Hash, desc: %q(Platform Kubernetes data) do optional :api_url, type: String, desc: 'URL to access the Kubernetes API' optional :token, type: String, desc: 'Token to authenticate against Kubernetes' diff --git a/lib/api/project_container_repositories.rb b/lib/api/project_container_repositories.rb index 2a05974509a6c256072662e210b62b5cd1811a78..2b33069e324a29f6600cb849e9cc1a0408bcf3a2 100644 --- a/lib/api/project_container_repositories.rb +++ b/lib/api/project_container_repositories.rb @@ -24,9 +24,11 @@ module API end get ':id/registry/repositories' do repositories = ContainerRepositoriesFinder.new( - id: user_project.id, container_type: :project + user: current_user, subject: user_project ).execute + track_event( 'list_repositories') + present paginate(repositories), with: Entities::ContainerRegistry::Repository, tags: params[:tags] end @@ -40,6 +42,7 @@ module API authorize_admin_container_image! DeleteContainerRepositoryWorker.perform_async(current_user.id, repository.id) + track_event('delete_repository') status :accepted end @@ -56,6 +59,8 @@ module API authorize_read_container_image! tags = Kaminari.paginate_array(repository.tags) + track_event('list_tags') + present paginate(tags), with: Entities::ContainerRegistry::Tag end @@ -77,6 +82,8 @@ module API CleanupContainerRepositoryWorker.perform_async(current_user.id, repository.id, declared_params.except(:repository_id)) + track_event('delete_tag_bulk') + status :accepted end @@ -111,6 +118,8 @@ module API .execute(repository) if result[:status] == :success + track_event('delete_tag') + status :ok else status :bad_request diff --git a/lib/api/projects.rb b/lib/api/projects.rb index d2dacafe7f9dccb37129de6052bd6d8fc4809bdf..669def2b63cf4a000507f54b1f89128c5e151f87 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -61,6 +61,8 @@ module API optional :with_merge_requests_enabled, type: Boolean, default: false, desc: 'Limit by enabled merge requests feature' optional :with_programming_language, type: String, desc: 'Limit to repositories which use the given programming language' optional :min_access_level, type: Integer, values: Gitlab::Access.all_values, desc: 'Limit by minimum access level of authenticated user' + optional :id_after, type: Integer, desc: 'Limit results to projects with IDs greater than the specified ID' + optional :id_before, type: Integer, desc: 'Limit results to projects with IDs less than the specified ID' use :optional_filter_params_ee end @@ -69,7 +71,8 @@ module API optional :namespace_id, type: Integer, desc: 'Namespace ID for the new project. Default to the user namespace.' optional :import_url, type: String, desc: 'URL from which the project is imported' optional :template_name, type: String, desc: "Name of template from which to create project" - mutually_exclusive :import_url, :template_name + optional :template_project_id, type: Integer, desc: "Project ID of template from which to create project" + mutually_exclusive :import_url, :template_name, :template_project_id end def load_projects diff --git a/lib/api/releases.rb b/lib/api/releases.rb index 4238529142c60aeb985f18875edcbde897b70690..3f600ef4a043f033e72f1e5cbaf279f716e6dde5 100644 --- a/lib/api/releases.rb +++ b/lib/api/releases.rb @@ -45,7 +45,7 @@ module API end params do requires :tag_name, type: String, desc: 'The name of the tag', as: :tag - requires :name, type: String, desc: 'The name of the release' + optional :name, type: String, desc: 'The name of the release' requires :description, type: String, desc: 'The release notes' optional :ref, type: String, desc: 'The commit sha or branch name' optional :assets, type: Hash do diff --git a/lib/api/settings.rb b/lib/api/settings.rb index c90ba0c9b5db61aed9ae2891d88974d053956b19..5362b3060c1e547795ea88fbb9b4df869d6a6d63 100644 --- a/lib/api/settings.rb +++ b/lib/api/settings.rb @@ -42,6 +42,7 @@ module API optional :asset_proxy_whitelist, type: Array[String], coerce_with: Validations::Types::CommaSeparatedToArray.coerce, desc: 'Assets that match these domain(s) will NOT be proxied. Wildcards allowed. Your GitLab installation URL is automatically whitelisted.' optional :container_registry_token_expire_delay, type: Integer, desc: 'Authorization token duration (minutes)' optional :default_artifacts_expire_in, type: String, desc: "Set the default expiration time for each job's artifacts" + optional :default_ci_config_path, type: String, desc: 'The instance default CI configuration path for new projects' optional :default_project_creation, type: Integer, values: ::Gitlab::Access.project_creation_values, desc: 'Determine if developers can create projects in the group' optional :default_branch_protection, type: Integer, values: ::Gitlab::Access.protection_values, desc: 'Determine if developers can push to master' optional :default_group_visibility, type: String, values: Gitlab::VisibilityLevel.string_values, desc: 'The default group visibility' @@ -52,6 +53,12 @@ module API optional :domain_blacklist_enabled, type: Boolean, desc: 'Enable domain blacklist for sign ups' optional :domain_blacklist, type: Array[String], coerce_with: Validations::Types::CommaSeparatedToArray.coerce, desc: 'Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com' optional :domain_whitelist, type: Array[String], coerce_with: Validations::Types::CommaSeparatedToArray.coerce, desc: 'ONLY users with e-mail addresses that match these domain(s) will be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com' + optional :eks_integration_enabled, type: Boolean, desc: 'Enable integration with Amazon EKS' + given eks_integration_enabled: -> (val) { val } do + requires :eks_account_id, type: String, desc: 'Amazon account ID for EKS integration' + requires :eks_access_key_id, type: String, desc: 'Access key ID for the EKS integration IAM user' + requires :eks_secret_access_key, type: String, desc: 'Secret access key for the EKS integration IAM user' + end optional :email_author_in_body, type: Boolean, desc: 'Some email servers do not support overriding the email sender name. Enable this option to include the name of the author of the issue, merge request or comment in the email body instead.' optional :enabled_git_access_protocol, type: String, values: %w[ssh http nil], desc: 'Allow only the selected protocols to be used for Git access.' optional :gitaly_timeout_default, type: Integer, desc: 'Default Gitaly timeout, in seconds. Set to 0 to disable timeouts.' @@ -129,16 +136,22 @@ module API optional :sign_in_text, type: String, desc: 'The sign in text of the GitLab application' optional :signin_enabled, type: Boolean, desc: 'Flag indicating if password authentication is enabled for the web interface' # support legacy names, can be removed in v5 optional :signup_enabled, type: Boolean, desc: 'Flag indicating if sign up is enabled' + optional :sourcegraph_enabled, type: Boolean, desc: 'Enable Sourcegraph' + optional :sourcegraph_public_only, type: Boolean, desc: 'Only allow public projects to communicate with Sourcegraph' + given sourcegraph_enabled: ->(val) { val } do + requires :sourcegraph_url, type: String, desc: 'The configured Sourcegraph instance URL' + end optional :terminal_max_session_time, type: Integer, desc: 'Maximum time for web terminal websocket connection (in seconds). Set to 0 for unlimited time.' optional :usage_ping_enabled, type: Boolean, desc: 'Every week GitLab will report license usage back to GitLab, Inc.' optional :instance_statistics_visibility_private, type: Boolean, desc: 'When set to `true` Instance statistics will only be available to admins' optional :local_markdown_version, type: Integer, desc: 'Local markdown version, increase this value when any cached markdown should be invalidated' optional :allow_local_requests_from_hooks_and_services, type: Boolean, desc: 'Deprecated: Use :allow_local_requests_from_web_hooks_and_services instead. Allow requests to the local network from hooks and services.' # support legacy names, can be removed in v5 optional :snowplow_enabled, type: Grape::API::Boolean, desc: 'Enable Snowplow tracking' + optional :snowplow_iglu_registry_url, type: String, desc: 'The Snowplow base Iglu Schema Registry URL to use for custom context and self describing events' given snowplow_enabled: ->(val) { val } do requires :snowplow_collector_hostname, type: String, desc: 'The Snowplow collector hostname' optional :snowplow_cookie_domain, type: String, desc: 'The Snowplow cookie domain' - optional :snowplow_site_id, type: String, desc: 'The Snowplow site name / application ic' + optional :snowplow_app_id, type: String, desc: 'The Snowplow site name / application id' end ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type| diff --git a/lib/api/sidekiq_metrics.rb b/lib/api/sidekiq_metrics.rb index daa9598a2046d69ce3e8d10331b18254fce5921a..693c20cb73aa36922162c7e4198cc65527c95b57 100644 --- a/lib/api/sidekiq_metrics.rb +++ b/lib/api/sidekiq_metrics.rb @@ -36,7 +36,8 @@ module API { processed: stats.processed, failed: stats.failed, - enqueued: stats.enqueued + enqueued: stats.enqueued, + dead: stats.dead_size } end end diff --git a/lib/banzai/filter/inline_grafana_metrics_filter.rb b/lib/banzai/filter/inline_grafana_metrics_filter.rb new file mode 100644 index 0000000000000000000000000000000000000000..321580b532f17b5a1a30ec26adb1396b75ac22c2 --- /dev/null +++ b/lib/banzai/filter/inline_grafana_metrics_filter.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Banzai + module Filter + # HTML filter that inserts a placeholder element for each + # reference to a grafana dashboard. + class InlineGrafanaMetricsFilter < Banzai::Filter::InlineEmbedsFilter + # Placeholder element for the frontend to use as an + # injection point for charts. + def create_element(params) + begin_loading_dashboard(params[:url]) + + doc.document.create_element( + 'div', + class: 'js-render-metrics', + 'data-dashboard-url': metrics_dashboard_url(params) + ) + end + + def embed_params(node) + query_params = Gitlab::Metrics::Dashboard::Url.parse_query(node['href']) + return unless [:panelId, :from, :to].all? do |param| + query_params.include?(param) + end + + { url: node['href'], start: query_params[:from], end: query_params[:to] } + end + + # Selects any links with an href contains the configured + # grafana domain for the project + def xpath_search + return unless grafana_url.present? + + %(descendant-or-self::a[starts-with(@href, '#{grafana_url}')]) + end + + private + + def project + context[:project] + end + + def grafana_url + project&.grafana_integration&.grafana_url + end + + def metrics_dashboard_url(params) + Gitlab::Routing.url_helpers.project_grafana_api_metrics_dashboard_url( + project, + embedded: true, + grafana_url: params[:url], + start: format_time(params[:start]), + end: format_time(params[:end]) + ) + end + + # Formats a timestamp from Grafana for compatibility with + # parsing in JS via `new Date(timestamp)` + # + # @param time [String] Represents miliseconds since epoch + def format_time(time) + Time.at(time.to_i / 1000).utc.strftime('%FT%TZ') + end + + # Fetches a dashboard and caches the result for the + # FE to fetch quickly while rendering charts + def begin_loading_dashboard(url) + ::Gitlab::Metrics::Dashboard::Finder.find( + project, + embedded: true, + grafana_url: url + ) + end + end + end +end diff --git a/lib/banzai/filter/inline_metrics_redactor_filter.rb b/lib/banzai/filter/inline_metrics_redactor_filter.rb index 4d8a50288987bcbe6b056d1b246a26f5d8c50844..e84ba83e03e2a91f4a5d1f7ee1c8b2724ed1a9fa 100644 --- a/lib/banzai/filter/inline_metrics_redactor_filter.rb +++ b/lib/banzai/filter/inline_metrics_redactor_filter.rb @@ -8,14 +8,17 @@ module Banzai include Gitlab::Utils::StrongMemoize METRICS_CSS_CLASS = '.js-render-metrics' + URL = Gitlab::Metrics::Dashboard::Url + + Embed = Struct.new(:project_path, :permission) # Finds all embeds based on the css class the FE # uses to identify the embedded content, removing # only unnecessary nodes. def call nodes.each do |node| - path = paths_by_node[node] - user_has_access = user_access_by_path[path] + embed = embeds_by_node[node] + user_has_access = user_access_by_embed[embed] node.remove unless user_has_access end @@ -30,40 +33,69 @@ module Banzai end # Returns all nodes which the FE will identify as - # a metrics dashboard placeholder element + # a metrics embed placeholder element # # @return [Nokogiri::XML::NodeSet] def nodes @nodes ||= doc.css(METRICS_CSS_CLASS) end - # Maps a node to the full path of a project. + # Maps a node to key properties of an embed. # Memoized so we only need to run the regex to get # the project full path from the url once per node. # - # @return [Hash<Nokogiri::XML::Node, String>] - def paths_by_node - strong_memoize(:paths_by_node) do - nodes.each_with_object({}) do |node, paths| - paths[node] = path_for_node(node) + # @return [Hash<Nokogiri::XML::Node, Embed>] + def embeds_by_node + strong_memoize(:embeds_by_node) do + nodes.each_with_object({}) do |node, embeds| + embed = Embed.new + url = node.attribute('data-dashboard-url').to_s + + set_path_and_permission(embed, url, URL.regex, :read_environment) + set_path_and_permission(embed, url, URL.grafana_regex, :read_project) unless embed.permission + + embeds[node] = embed if embed.permission end end end - # Gets a project's full_path from the dashboard url - # in the placeholder node. The FE will use the attr - # `data-dashboard-url`, so we want to check against that - # attribute directly in case a user has manually - # created a metrics element (rather than supporting - # an alternate attr in InlineMetricsFilter). + # Attempts to determine the path and permission attributes + # of a url based on expected dashboard url formats and + # sets the attributes on an Embed object # - # @return [String] - def path_for_node(node) - url = node.attribute('data-dashboard-url').to_s - - Gitlab::Metrics::Dashboard::Url.regex.match(url) do |m| + # @param embed [Embed] + # @param url [String] + # @param regex [RegExp] + # @param permission [Symbol] + def set_path_and_permission(embed, url, regex, permission) + return unless path = regex.match(url) do |m| "#{$~[:namespace]}/#{$~[:project]}" end + + embed.project_path = path + embed.permission = permission + end + + # Returns a mapping representing whether the current user + # has permission to view the embed for the project. + # Determined in a batch + # + # @return [Hash<Embed, Boolean>] + def user_access_by_embed + strong_memoize(:user_access_by_embed) do + unique_embeds.each_with_object({}) do |embed, access| + project = projects_by_path[embed.project_path] + + access[embed] = Ability.allowed?(user, embed.permission, project) + end + end + end + + # Returns a unique list of embeds + # + # @return [Array<Embed>] + def unique_embeds + embeds_by_node.values.uniq end # Maps a project's full path to a Project object. @@ -74,22 +106,17 @@ module Banzai def projects_by_path strong_memoize(:projects_by_path) do Project.eager_load(:route, namespace: [:route]) - .where_full_path_in(paths_by_node.values.uniq) + .where_full_path_in(unique_project_paths) .index_by(&:full_path) end end - # Returns a mapping representing whether the current user - # has permission to view the metrics for the project. - # Determined in a batch + # Returns a list of the full_paths of every project which + # has an embed in the doc # - # @return [Hash<Project, Boolean>] - def user_access_by_path - strong_memoize(:user_access_by_path) do - projects_by_path.each_with_object({}) do |(path, project), access| - access[path] = Ability.allowed?(user, :read_environment, project) - end - end + # @return [Array<String>] + def unique_project_paths + embeds_by_node.values.map(&:project_path).uniq end end end diff --git a/lib/banzai/filter/video_link_filter.rb b/lib/banzai/filter/video_link_filter.rb index ed82fbc1f94692dddaba8df7e87cc9c0a3510f43..98987ee2019439839275b6107ad54707604ede1c 100644 --- a/lib/banzai/filter/video_link_filter.rb +++ b/lib/banzai/filter/video_link_filter.rb @@ -15,7 +15,7 @@ module Banzai end def extra_element_attrs - { width: "100%" } + { width: "400" } end end end diff --git a/lib/banzai/pipeline/ascii_doc_pipeline.rb b/lib/banzai/pipeline/ascii_doc_pipeline.rb index 82b99d3de4aba38e449b0ea49df5bb400aa1e6ac..90edc7010f45b7a6217766f7ce677337c8bfc471 100644 --- a/lib/banzai/pipeline/ascii_doc_pipeline.rb +++ b/lib/banzai/pipeline/ascii_doc_pipeline.rb @@ -10,6 +10,9 @@ module Banzai Filter::SyntaxHighlightFilter, Filter::ExternalLinkFilter, Filter::PlantumlFilter, + Filter::ColorFilter, + Filter::ImageLazyLoadFilter, + Filter::ImageLinkFilter, Filter::AsciiDocPostProcessingFilter ] end diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb index 08e27257fdf6c4d98bb3d1443e507d3554e0d7d5..f6c12cdb53be6ec1721504bc834f4320ddc7228f 100644 --- a/lib/banzai/pipeline/gfm_pipeline.rb +++ b/lib/banzai/pipeline/gfm_pipeline.rb @@ -30,6 +30,7 @@ module Banzai Filter::ImageLazyLoadFilter, Filter::ImageLinkFilter, Filter::InlineMetricsFilter, + Filter::InlineGrafanaMetricsFilter, Filter::TableOfContentsFilter, Filter::AutolinkFilter, Filter::ExternalLinkFilter, diff --git a/lib/bitbucket/representation/pull_request.rb b/lib/bitbucket/representation/pull_request.rb index a498c9bc2136e8c60cbcd03252580f3581cf1238..8d9de2dbc7df4070e738ee4de8b6ee06f0e1d529 100644 --- a/lib/bitbucket/representation/pull_request.rb +++ b/lib/bitbucket/representation/pull_request.rb @@ -16,9 +16,10 @@ module Bitbucket end def state - if raw['state'] == 'MERGED' + case raw['state'] + when 'MERGED' 'merged' - elsif raw['state'] == 'DECLINED' + when 'DECLINED', 'SUPERSEDED' 'closed' else 'opened' diff --git a/lib/container_registry/client.rb b/lib/container_registry/client.rb index 92861c567a8ecf9c3113d45e2d1c27d309ae1ea9..bc0347f6ea1d9d765dd5f0891f10b9dd85e0cd0d 100644 --- a/lib/container_registry/client.rb +++ b/lib/container_registry/client.rb @@ -51,7 +51,7 @@ module ContainerRegistry def upload_blob(name, content, digest) upload = faraday.post("/v2/#{name}/blobs/uploads/") - return unless upload.success? + return upload unless upload.success? location = URI(upload.headers['location']) diff --git a/lib/declarative_policy.rb b/lib/declarative_policy.rb index d99a209dc878adbb1547c4a66a4cd0b6dd215b96..9e9df88373a97a380f8f1c427d4a383935e91c4a 100644 --- a/lib/declarative_policy.rb +++ b/lib/declarative_policy.rb @@ -74,7 +74,14 @@ module DeclarativePolicy next unless klass.name begin - policy_class = "#{klass.name}Policy".constantize + klass_name = + if subject_class.respond_to?(:declarative_policy_class) + subject_class.declarative_policy_class + else + "#{klass.name}Policy" + end + + policy_class = klass_name.constantize # NOTE: the < operator here tests whether policy_class # inherits from Base. We can't use #is_a? because that diff --git a/lib/feature/gitaly.rb b/lib/feature/gitaly.rb index 81f8ba5c8c3dd0efa7d83182c691009670648ba8..0ac2d017e1afca4f6f95ab2370cbc49770ebd715 100644 --- a/lib/feature/gitaly.rb +++ b/lib/feature/gitaly.rb @@ -7,7 +7,6 @@ class Feature # Server feature flags should use '_' to separate words. SERVER_FEATURE_FLAGS = %w[ - cache_invalidator inforef_uploadpack_cache get_all_lfs_pointers_go ].freeze @@ -20,7 +19,7 @@ class Feature default_on = DEFAULT_ON_FLAGS.include?(feature_flag) Feature.enabled?("gitaly_#{feature_flag}", default_enabled: default_on) - rescue ActiveRecord::NoDatabaseError + rescue ActiveRecord::NoDatabaseError, PG::ConnectionBad false end diff --git a/lib/generators/rails/post_deployment_migration/post_deployment_migration_generator.rb b/lib/generators/rails/post_deployment_migration/post_deployment_migration_generator.rb index 15cdd25e7116e4e311dedba2d54efcbabe406b4e..568104cb30bdb344a098500dfdbe57057d603d39 100644 --- a/lib/generators/rails/post_deployment_migration/post_deployment_migration_generator.rb +++ b/lib/generators/rails/post_deployment_migration/post_deployment_migration_generator.rb @@ -5,7 +5,7 @@ require 'rails/generators' module Rails class PostDeploymentMigrationGenerator < Rails::Generators::NamedBase def create_migration_file - timestamp = Time.now.strftime('%Y%m%d%H%M%S') + timestamp = Time.now.utc.strftime('%Y%m%d%H%M%S') template "migration.rb", "db/post_migrate/#{timestamp}_#{file_name}.rb" end diff --git a/lib/gitlab.rb b/lib/gitlab.rb index ad8e693ccbc071e4cfc0725829d5ff44f90c9535..0e6db54eb46d51e6f751e807c4a1d4bd160942b3 100644 --- a/lib/gitlab.rb +++ b/lib/gitlab.rb @@ -47,6 +47,18 @@ module Gitlab Gitlab.config.gitlab.url == COM_URL || gl_subdomain? end + def self.canary? + Gitlab::Utils.to_boolean(ENV['CANARY']) + end + + def self.com_and_canary? + com? && canary? + end + + def self.com_but_not_canary? + com? && !canary? + end + def self.org? Gitlab.config.gitlab.url == 'https://dev.gitlab.org' end diff --git a/lib/gitlab/analytics/cycle_analytics/base_query_builder.rb b/lib/gitlab/analytics/cycle_analytics/base_query_builder.rb index 33cbe1a62ef9c13fe8fa656767839ec91fd99029..9ea20a4d6a4406a4de6bffa9edd54db042d2b416 100644 --- a/lib/gitlab/analytics/cycle_analytics/base_query_builder.rb +++ b/lib/gitlab/analytics/cycle_analytics/base_query_builder.rb @@ -68,3 +68,5 @@ module Gitlab end end end + +Gitlab::Analytics::CycleAnalytics::BaseQueryBuilder.prepend_if_ee('EE::Gitlab::Analytics::CycleAnalytics::BaseQueryBuilder') diff --git a/lib/gitlab/analytics/cycle_analytics/data_collector.rb b/lib/gitlab/analytics/cycle_analytics/data_collector.rb index 0c0f737f2c925b7bb171528de0a7512a836688e5..05b166729123cacbfb0642c6fa2f885533bd9eee 100644 --- a/lib/gitlab/analytics/cycle_analytics/data_collector.rb +++ b/lib/gitlab/analytics/cycle_analytics/data_collector.rb @@ -12,6 +12,8 @@ module Gitlab class DataCollector include Gitlab::Utils::StrongMemoize + delegate :serialized_records, to: :records_fetcher + def initialize(stage:, params: {}) @stage = stage @params = params diff --git a/lib/gitlab/analytics/cycle_analytics/records_fetcher.rb b/lib/gitlab/analytics/cycle_analytics/records_fetcher.rb index 90d03142b2a63c500ca96c3b535dafe62f909371..2662aa38d6b4448ca081175edd20786033bc445c 100644 --- a/lib/gitlab/analytics/cycle_analytics/records_fetcher.rb +++ b/lib/gitlab/analytics/cycle_analytics/records_fetcher.rb @@ -130,3 +130,5 @@ module Gitlab end end end + +Gitlab::Analytics::CycleAnalytics::RecordsFetcher.prepend_if_ee('EE::Gitlab::Analytics::CycleAnalytics::RecordsFetcher') diff --git a/lib/gitlab/analytics/cycle_analytics/stage_events.rb b/lib/gitlab/analytics/cycle_analytics/stage_events.rb index 58572446de611a044f4e78f856476ccb7ba26fdc..f6e220441420964c9560b3dcfbb5b410d1e180a8 100644 --- a/lib/gitlab/analytics/cycle_analytics/stage_events.rb +++ b/lib/gitlab/analytics/cycle_analytics/stage_events.rb @@ -47,27 +47,29 @@ module Gitlab ] }.freeze - def [](identifier) + def self.[](identifier) events.find { |e| e.identifier.to_s.eql?(identifier.to_s) } || raise(KeyError) end # hash for defining ActiveRecord enum: identifier => number - def to_enum - ENUM_MAPPING.each_with_object({}) { |(k, v), hash| hash[k.identifier] = v } + def self.to_enum + enum_mapping.each_with_object({}) { |(k, v), hash| hash[k.identifier] = v } end - # will be overridden in EE with custom events - def pairing_rules + def self.pairing_rules PAIRING_RULES end - # will be overridden in EE with custom events - def events + def self.events EVENTS end - module_function :[], :to_enum, :pairing_rules, :events + def self.enum_mapping + ENUM_MAPPING + end end end end end + +Gitlab::Analytics::CycleAnalytics::StageEvents.prepend_if_ee('::EE::Gitlab::Analytics::CycleAnalytics::StageEvents') diff --git a/lib/gitlab/analytics/cycle_analytics/stage_events/code_stage_start.rb b/lib/gitlab/analytics/cycle_analytics/stage_events/code_stage_start.rb index 6af1b90bccc2d2918150f67b63236da597763ff6..9f0ca80ba5013cc92a4bee59a922b2f1e752d33d 100644 --- a/lib/gitlab/analytics/cycle_analytics/stage_events/code_stage_start.rb +++ b/lib/gitlab/analytics/cycle_analytics/stage_events/code_stage_start.rb @@ -4,7 +4,7 @@ module Gitlab module Analytics module CycleAnalytics module StageEvents - class CodeStageStart < SimpleStageEvent + class CodeStageStart < StageEvent def self.name s_("CycleAnalyticsEvent|Issue first mentioned in a commit") end diff --git a/lib/gitlab/analytics/cycle_analytics/stage_events/issue_created.rb b/lib/gitlab/analytics/cycle_analytics/stage_events/issue_created.rb index 8c9a80740a9bfe60ff176cc9ae1be0c8c0b5f916..a159580b7bd5e264c7d34c077f46977011d543c6 100644 --- a/lib/gitlab/analytics/cycle_analytics/stage_events/issue_created.rb +++ b/lib/gitlab/analytics/cycle_analytics/stage_events/issue_created.rb @@ -4,7 +4,7 @@ module Gitlab module Analytics module CycleAnalytics module StageEvents - class IssueCreated < SimpleStageEvent + class IssueCreated < StageEvent def self.name s_("CycleAnalyticsEvent|Issue created") end diff --git a/lib/gitlab/analytics/cycle_analytics/stage_events/issue_first_mentioned_in_commit.rb b/lib/gitlab/analytics/cycle_analytics/stage_events/issue_first_mentioned_in_commit.rb index fe7f2d85f8bf6ab01e8ae0be24aefcbe655c3b58..a3b7fa16daf25a0cc473006d1127bfdba7e917ee 100644 --- a/lib/gitlab/analytics/cycle_analytics/stage_events/issue_first_mentioned_in_commit.rb +++ b/lib/gitlab/analytics/cycle_analytics/stage_events/issue_first_mentioned_in_commit.rb @@ -4,7 +4,7 @@ module Gitlab module Analytics module CycleAnalytics module StageEvents - class IssueFirstMentionedInCommit < SimpleStageEvent + class IssueFirstMentionedInCommit < MetricsBasedStageEvent def self.name s_("CycleAnalyticsEvent|Issue first mentioned in a commit") end @@ -20,12 +20,6 @@ module Gitlab def timestamp_projection issue_metrics_table[:first_mentioned_in_commit_at] end - - # rubocop: disable CodeReuse/ActiveRecord - def apply_query_customization(query) - query.joins(:metrics) - end - # rubocop: enable CodeReuse/ActiveRecord end end end diff --git a/lib/gitlab/analytics/cycle_analytics/stage_events/issue_stage_end.rb b/lib/gitlab/analytics/cycle_analytics/stage_events/issue_stage_end.rb index 77e4092b9abe51c0154e6d0945b1498688efbe54..0ea98e82eccc20507dc2ab96d92d2e35c12dd1ed 100644 --- a/lib/gitlab/analytics/cycle_analytics/stage_events/issue_stage_end.rb +++ b/lib/gitlab/analytics/cycle_analytics/stage_events/issue_stage_end.rb @@ -4,7 +4,7 @@ module Gitlab module Analytics module CycleAnalytics module StageEvents - class IssueStageEnd < SimpleStageEvent + class IssueStageEnd < MetricsBasedStageEvent def self.name PlanStageStart.name end @@ -26,7 +26,7 @@ module Gitlab # rubocop: disable CodeReuse/ActiveRecord def apply_query_customization(query) - query.joins(:metrics).where(issue_metrics_table[:first_added_to_board_at].not_eq(nil).or(issue_metrics_table[:first_associated_with_milestone_at].not_eq(nil))) + super.where(issue_metrics_table[:first_added_to_board_at].not_eq(nil).or(issue_metrics_table[:first_associated_with_milestone_at].not_eq(nil))) end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_created.rb b/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_created.rb index 7059c425b8f214e3a244a0749f22ba3354d6ec0a..013e068e479f6b0896766173649943189d437eb3 100644 --- a/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_created.rb +++ b/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_created.rb @@ -4,7 +4,7 @@ module Gitlab module Analytics module CycleAnalytics module StageEvents - class MergeRequestCreated < SimpleStageEvent + class MergeRequestCreated < StageEvent def self.name s_("CycleAnalyticsEvent|Merge request created") end diff --git a/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_first_deployed_to_production.rb b/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_first_deployed_to_production.rb index 3d7482eaaf0ba6a1cc3403611b7260b94357a575..654d0befbc3461c7e20108418e9a50403f926be8 100644 --- a/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_first_deployed_to_production.rb +++ b/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_first_deployed_to_production.rb @@ -4,7 +4,7 @@ module Gitlab module Analytics module CycleAnalytics module StageEvents - class MergeRequestFirstDeployedToProduction < SimpleStageEvent + class MergeRequestFirstDeployedToProduction < MetricsBasedStageEvent def self.name s_("CycleAnalyticsEvent|Merge request first deployed to production") end @@ -23,7 +23,7 @@ module Gitlab # rubocop: disable CodeReuse/ActiveRecord def apply_query_customization(query) - query.joins(:metrics).where(timestamp_projection.gteq(mr_table[:created_at])) + super.where(timestamp_projection.gteq(mr_table[:created_at])) end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_finished.rb b/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_finished.rb index 36bb4d6fc8de1c54e8b908befb8b88f55c3252fb..a0b1c12756fe4dfd6a272a92923f6818c8a5255b 100644 --- a/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_finished.rb +++ b/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_finished.rb @@ -4,7 +4,7 @@ module Gitlab module Analytics module CycleAnalytics module StageEvents - class MergeRequestLastBuildFinished < SimpleStageEvent + class MergeRequestLastBuildFinished < MetricsBasedStageEvent def self.name s_("CycleAnalyticsEvent|Merge request last build finish time") end @@ -20,12 +20,6 @@ module Gitlab def timestamp_projection mr_metrics_table[:latest_build_finished_at] end - - # rubocop: disable CodeReuse/ActiveRecord - def apply_query_customization(query) - query.joins(:metrics) - end - # rubocop: enable CodeReuse/ActiveRecord end end end diff --git a/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_started.rb b/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_started.rb index 468d9899cc7a199b7b532f04f1dcdbb35cd26cae..da3b5cdfaa490dc9999f2725000d96e9fa852ac9 100644 --- a/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_started.rb +++ b/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_started.rb @@ -4,7 +4,7 @@ module Gitlab module Analytics module CycleAnalytics module StageEvents - class MergeRequestLastBuildStarted < SimpleStageEvent + class MergeRequestLastBuildStarted < MetricsBasedStageEvent def self.name s_("CycleAnalyticsEvent|Merge request last build start time") end @@ -20,12 +20,6 @@ module Gitlab def timestamp_projection mr_metrics_table[:latest_build_started_at] end - - # rubocop: disable CodeReuse/ActiveRecord - def apply_query_customization(query) - query.joins(:metrics) - end - # rubocop: enable CodeReuse/ActiveRecord end end end diff --git a/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_merged.rb b/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_merged.rb index 82ecaf1cd6be6dd5bd628c2a88099d2b4d295c66..e67a6f7eea6732c8d52e909c18519d8a985f13b7 100644 --- a/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_merged.rb +++ b/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_merged.rb @@ -4,7 +4,7 @@ module Gitlab module Analytics module CycleAnalytics module StageEvents - class MergeRequestMerged < SimpleStageEvent + class MergeRequestMerged < MetricsBasedStageEvent def self.name s_("CycleAnalyticsEvent|Merge request merged") end @@ -20,12 +20,6 @@ module Gitlab def timestamp_projection mr_metrics_table[:merged_at] end - - # rubocop: disable CodeReuse/ActiveRecord - def apply_query_customization(query) - query.joins(:metrics) - end - # rubocop: enable CodeReuse/ActiveRecord end end end diff --git a/lib/gitlab/analytics/cycle_analytics/stage_events/metrics_based_stage_event.rb b/lib/gitlab/analytics/cycle_analytics/stage_events/metrics_based_stage_event.rb new file mode 100644 index 0000000000000000000000000000000000000000..4ca8745abe492bc366178ee65a2f531efec1d7a1 --- /dev/null +++ b/lib/gitlab/analytics/cycle_analytics/stage_events/metrics_based_stage_event.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Gitlab + module Analytics + module CycleAnalytics + module StageEvents + class MetricsBasedStageEvent < StageEvent + # rubocop: disable CodeReuse/ActiveRecord + def apply_query_customization(query) + query.joins(:metrics) + end + # rubocop: enable CodeReuse/ActiveRecord + end + end + end + end +end diff --git a/lib/gitlab/analytics/cycle_analytics/stage_events/plan_stage_start.rb b/lib/gitlab/analytics/cycle_analytics/stage_events/plan_stage_start.rb index 7ece7d62faad8aac7b0ae267c5e75ab8a9d7dc2a..37168a1fb0fb3f97745557d79dfb8d9ad61d5401 100644 --- a/lib/gitlab/analytics/cycle_analytics/stage_events/plan_stage_start.rb +++ b/lib/gitlab/analytics/cycle_analytics/stage_events/plan_stage_start.rb @@ -4,7 +4,7 @@ module Gitlab module Analytics module CycleAnalytics module StageEvents - class PlanStageStart < SimpleStageEvent + class PlanStageStart < MetricsBasedStageEvent def self.name s_("CycleAnalyticsEvent|Issue first associated with a milestone or issue first added to a board") end @@ -26,8 +26,7 @@ module Gitlab # rubocop: disable CodeReuse/ActiveRecord def apply_query_customization(query) - query - .joins(:metrics) + super .where(issue_metrics_table[:first_added_to_board_at].not_eq(nil).or(issue_metrics_table[:first_associated_with_milestone_at].not_eq(nil))) .where(issue_metrics_table[:first_mentioned_in_commit_at].not_eq(nil)) end diff --git a/lib/gitlab/analytics/cycle_analytics/stage_events/production_stage_end.rb b/lib/gitlab/analytics/cycle_analytics/stage_events/production_stage_end.rb index 607371a32e8f2e895eb3e46a93478a727e4e34b2..b249f6874e75f6e55d39a99b800829a19117fe14 100644 --- a/lib/gitlab/analytics/cycle_analytics/stage_events/production_stage_end.rb +++ b/lib/gitlab/analytics/cycle_analytics/stage_events/production_stage_end.rb @@ -4,7 +4,7 @@ module Gitlab module Analytics module CycleAnalytics module StageEvents - class ProductionStageEnd < SimpleStageEvent + class ProductionStageEnd < StageEvent def self.name PlanStageStart.name end diff --git a/lib/gitlab/analytics/cycle_analytics/stage_events/simple_stage_event.rb b/lib/gitlab/analytics/cycle_analytics/stage_events/simple_stage_event.rb deleted file mode 100644 index 253c489d82283c805f4992806986cab6f2959412..0000000000000000000000000000000000000000 --- a/lib/gitlab/analytics/cycle_analytics/stage_events/simple_stage_event.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Analytics - module CycleAnalytics - module StageEvents - # Represents a simple event that usually refers to one database column and does not require additional user input - class SimpleStageEvent < StageEvent - end - end - end - end -end diff --git a/lib/gitlab/analytics/cycle_analytics/stage_events/stage_event.rb b/lib/gitlab/analytics/cycle_analytics/stage_events/stage_event.rb index aa392140eb5d46cd9c6002554a0d28f21b3acb93..667d6def4148034fa6c3f7c1281b04290a1fc69d 100644 --- a/lib/gitlab/analytics/cycle_analytics/stage_events/stage_event.rb +++ b/lib/gitlab/analytics/cycle_analytics/stage_events/stage_event.rb @@ -35,6 +35,10 @@ module Gitlab query end + def label_based? + false + end + private attr_reader :params diff --git a/lib/gitlab/auth/ip_rate_limiter.rb b/lib/gitlab/auth/ip_rate_limiter.rb index 74d359bcd283978c4a9f18d02f52d548919df252..acb46abb6f31ff463cad25c76e44f88906670220 100644 --- a/lib/gitlab/auth/ip_rate_limiter.rb +++ b/lib/gitlab/auth/ip_rate_limiter.rb @@ -21,11 +21,12 @@ module Gitlab end def register_fail! + return false if trusted_ip? + # Allow2Ban.filter will return false if this IP has not failed too often yet @banned = Rack::Attack::Allow2Ban.filter(ip, config) do - # If we return false here, the failure for this IP is ignored by Allow2Ban - # If we return true here, the count for the IP is incremented. - ip_can_be_banned? + # We return true to increment the count for this IP + true end end @@ -33,20 +34,16 @@ module Gitlab @banned end + def trusted_ip? + trusted_ips.any? { |netmask| netmask.include?(ip) } + end + private def config Gitlab.config.rack_attack.git_basic_auth end - def ip_can_be_banned? - !trusted_ip? - end - - def trusted_ip? - trusted_ips.any? { |netmask| netmask.include?(ip) } - end - def trusted_ips strong_memoize(:trusted_ips) do config.ip_whitelist.map do |proxy| diff --git a/lib/gitlab/auth/ldap/config.rb b/lib/gitlab/auth/ldap/config.rb index eb1d0925c55f1b7404906d246dae1677bf77b4ae..4bc0ceedae7ddcf69f23a4e01b67ab035868e73d 100644 --- a/lib/gitlab/auth/ldap/config.rb +++ b/lib/gitlab/auth/ldap/config.rb @@ -21,6 +21,14 @@ module Gitlab Gitlab.config.ldap.enabled end + def self.sign_in_enabled? + enabled? && !prevent_ldap_sign_in? + end + + def self.prevent_ldap_sign_in? + Gitlab.config.ldap.prevent_ldap_sign_in + end + def self.servers Gitlab.config.ldap['servers']&.values || [] end diff --git a/lib/gitlab/background_migration/legacy_upload_mover.rb b/lib/gitlab/background_migration/legacy_upload_mover.rb index c9e47f210be61e754cf0df373e653aace2a4e955..1879a6c54275ae67ad7c9326ff75c24a2aa7a95e 100644 --- a/lib/gitlab/background_migration/legacy_upload_mover.rb +++ b/lib/gitlab/background_migration/legacy_upload_mover.rb @@ -18,6 +18,7 @@ module Gitlab def execute return unless upload + return unless upload.model_type == 'Note' if !project # if we don't have models associated with the upload we can not move it diff --git a/lib/gitlab/background_migration/legacy_uploads_migrator.rb b/lib/gitlab/background_migration/legacy_uploads_migrator.rb index a9d38a27e0c18fa48d9dbcf4d54bd0d66d7b7439..f7cadb9b00d34cf72692a925d9ed938118d7d38e 100644 --- a/lib/gitlab/background_migration/legacy_uploads_migrator.rb +++ b/lib/gitlab/background_migration/legacy_uploads_migrator.rb @@ -14,7 +14,7 @@ module Gitlab include Database::MigrationHelpers def perform(start_id, end_id) - Upload.where(id: start_id..end_id, uploader: 'AttachmentUploader').find_each do |upload| + Upload.where(id: start_id..end_id, uploader: 'AttachmentUploader', model_type: 'Note').find_each do |upload| LegacyUploadMover.new(upload).execute end end diff --git a/lib/gitlab/background_migration/populate_untracked_uploads_dependencies.rb b/lib/gitlab/background_migration/populate_untracked_uploads_dependencies.rb index f5fb33f16602093d0cab2140dbcc75d1b216b85b..23e8be4a9ab62b892bd0f4a67b9e4eae947e5df6 100644 --- a/lib/gitlab/background_migration/populate_untracked_uploads_dependencies.rb +++ b/lib/gitlab/background_migration/populate_untracked_uploads_dependencies.rb @@ -176,7 +176,7 @@ module Gitlab self.table_name = 'projects' def self.find_by_full_path(path) - order_sql = "(CASE WHEN routes.path = #{connection.quote(path)} THEN 0 ELSE 1 END)" + order_sql = Arel.sql("(CASE WHEN routes.path = #{connection.quote(path)} THEN 0 ELSE 1 END)") where_full_path_in(path).reorder(order_sql).take end diff --git a/lib/gitlab/ci/ansi2json/converter.rb b/lib/gitlab/ci/ansi2json/converter.rb index 8d25b66af9c72cf6e152cc75240082345cfc5396..cbda3808b86ec4ec5bac5a972242cf201a9ed1f1 100644 --- a/lib/gitlab/ci/ansi2json/converter.rb +++ b/lib/gitlab/ci/ansi2json/converter.rb @@ -22,11 +22,11 @@ module Gitlab start_offset = @state.offset - @state.set_current_line!(style: Style.new(@state.inherited_style)) + @state.new_line!( + style: Style.new(@state.inherited_style)) stream.each_line do |line| - s = StringScanner.new(line) - convert_line(s) + consume_line(line) end # This must be assigned before flushing the current line @@ -52,26 +52,41 @@ module Gitlab private - def convert_line(scanner) - until scanner.eos? - - if scanner.scan(Gitlab::Regex.build_trace_section_regex) - handle_section(scanner) - elsif scanner.scan(/\e([@-_])(.*?)([@-~])/) - handle_sequence(scanner) - elsif scanner.scan(/\e(([@-_])(.*?)?)?$/) - break - elsif scanner.scan(/</) - @state.current_line << '<' - elsif scanner.scan(/\r?\n/) - # we advance the offset of the next current line - # so it does not start from \n - flush_current_line(advance_offset: scanner.matched_size) - else - @state.current_line << scanner.scan(/./m) - end - - @state.offset += scanner.matched_size + def consume_line(line) + scanner = StringScanner.new(line) + + consume_token(scanner) until scanner.eos? + end + + def consume_token(scanner) + if scan_token(scanner, Gitlab::Regex.build_trace_section_regex, consume: false) + handle_section(scanner) + elsif scan_token(scanner, /\e([@-_])(.*?)([@-~])/) + handle_sequence(scanner) + elsif scan_token(scanner, /\e(([@-_])(.*?)?)?$/) + # stop scanning + scanner.terminate + elsif scan_token(scanner, /\r?\n/) + flush_current_line + elsif scan_token(scanner, /\r/) + # drop last line + @state.current_line.clear! + elsif scan_token(scanner, /.[^\e\r\ns]*/m) + # this is a join from all previous tokens and first letters + # it always matches at least one character `.` + # it matches everything that is not start of: + # `\e`, `<`, `\r`, `\n`, `s` (for section_start) + @state.current_line << scanner[0] + else + raise 'invalid parser state' + end + end + + def scan_token(scanner, match, consume: true) + scanner.scan(match).tap do |result| + # we need to move offset as soon + # as we match the token + @state.offset += scanner.matched_size if consume && result end end @@ -96,32 +111,50 @@ module Gitlab section_name = sanitize_section_name(section) if action == "start" - handle_section_start(section_name, timestamp) + handle_section_start(scanner, section_name, timestamp) elsif action == "end" - handle_section_end(section_name, timestamp) + handle_section_end(scanner, section_name, timestamp) + else + raise 'unsupported action' end end - def handle_section_start(section, timestamp) - flush_current_line unless @state.current_line.empty? + def handle_section_start(scanner, section, timestamp) + # We make a new line for new section + flush_current_line + @state.open_section(section, timestamp) + + # we need to consume match after handling + # the open of section, as we want the section + # marker to be refresh on incremental update + @state.offset += scanner.matched_size end - def handle_section_end(section, timestamp) + def handle_section_end(scanner, section, timestamp) return unless @state.section_open?(section) - flush_current_line unless @state.current_line.empty? + # We flush the content to make the end + # of section to be a new line + flush_current_line + @state.close_section(section, timestamp) - # ensure that section end is detached from the last - # line in the section + # we need to consume match before handling + # as we want the section close marker + # not to be refreshed on incremental update + @state.offset += scanner.matched_size + + # this flushes an empty line with `section_duration` flush_current_line end - def flush_current_line(advance_offset: 0) - @lines << @state.current_line.to_h + def flush_current_line + unless @state.current_line.empty? + @lines << @state.current_line.to_h + end - @state.set_current_line!(advance_offset: advance_offset) + @state.new_line! end def sanitize_section_name(section) diff --git a/lib/gitlab/ci/ansi2json/line.rb b/lib/gitlab/ci/ansi2json/line.rb index 173fb1df88ed98da4ad39570e4bb53f337020b7e..21aa1f84353cfcd9fe0a59287c463e592bf052fa 100644 --- a/lib/gitlab/ci/ansi2json/line.rb +++ b/lib/gitlab/ci/ansi2json/line.rb @@ -47,12 +47,17 @@ module Gitlab @current_segment.text << data end + def clear! + @segments.clear + @current_segment = Segment.new(style: style) + end + def style @current_segment.style end def empty? - @segments.empty? && @current_segment.empty? + @segments.empty? && @current_segment.empty? && @section_duration.nil? end def update_style(ansi_commands) diff --git a/lib/gitlab/ci/ansi2json/state.rb b/lib/gitlab/ci/ansi2json/state.rb index db7a9035b8b4ca25e329f6b995b9a5cbe2caa919..7e1a8102a352a039d53d6b6f22926cfaaf787722 100644 --- a/lib/gitlab/ci/ansi2json/state.rb +++ b/lib/gitlab/ci/ansi2json/state.rb @@ -46,9 +46,9 @@ module Gitlab @open_sections.key?(section) end - def set_current_line!(style: nil, advance_offset: 0) + def new_line!(style: nil) new_line = Line.new( - offset: @offset + advance_offset, + offset: @offset, style: style || @current_line.style, sections: @open_sections.keys ) diff --git a/lib/gitlab/ci/ansi2json/style.rb b/lib/gitlab/ci/ansi2json/style.rb index 2739ffdfa5d9a9bf1414c3792eaafa2a981ebed4..77f61178b37393df0582934b08934801749cfae7 100644 --- a/lib/gitlab/ci/ansi2json/style.rb +++ b/lib/gitlab/ci/ansi2json/style.rb @@ -15,14 +15,10 @@ module Gitlab end def update(ansi_commands) - command = ansi_commands.shift - return unless command - - if changes = Gitlab::Ci::Ansi2json::Parser.new(command, ansi_commands).changes - apply_changes(changes) - end + # treat e\[m as \e[0m + ansi_commands = ['0'] if ansi_commands.empty? - update(ansi_commands) + evaluate_stack_command(ansi_commands) end def set? @@ -50,6 +46,17 @@ module Gitlab private + def evaluate_stack_command(ansi_commands) + command = ansi_commands.shift + return unless command + + if changes = Gitlab::Ci::Ansi2json::Parser.new(command, ansi_commands).changes + apply_changes(changes) + end + + evaluate_stack_command(ansi_commands) + end + def apply_changes(changes) case when changes[:reset] diff --git a/lib/gitlab/ci/build/context/base.rb b/lib/gitlab/ci/build/context/base.rb new file mode 100644 index 0000000000000000000000000000000000000000..02b97ea76e93e5247f4d519e1e3791a174429d67 --- /dev/null +++ b/lib/gitlab/ci/build/context/base.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Build + module Context + class Base + attr_reader :pipeline + + def initialize(pipeline) + @pipeline = pipeline + end + + def variables + raise NotImplementedError + end + + protected + + def pipeline_attributes + { + pipeline: pipeline, + project: pipeline.project, + user: pipeline.user, + ref: pipeline.ref, + tag: pipeline.tag, + trigger_request: pipeline.legacy_trigger, + protected: pipeline.protected_ref? + } + end + end + end + end + end +end diff --git a/lib/gitlab/ci/build/context/build.rb b/lib/gitlab/ci/build/context/build.rb new file mode 100644 index 0000000000000000000000000000000000000000..dfd86d3ad7230feb686377f9fde5e540a35820c9 --- /dev/null +++ b/lib/gitlab/ci/build/context/build.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Build + module Context + class Build < Base + include Gitlab::Utils::StrongMemoize + + attr_reader :attributes + + def initialize(pipeline, attributes = {}) + super(pipeline) + + @attributes = attributes + end + + def variables + strong_memoize(:variables) do + # This is a temporary piece of technical debt to allow us access + # to the CI variables to evaluate rules before we persist a Build + # with the result. We should refactor away the extra Build.new, + # but be able to get CI Variables directly from the Seed::Build. + stub_build.scoped_variables_hash + end + end + + private + + def stub_build + ::Ci::Build.new(build_attributes) + end + + def build_attributes + attributes.merge(pipeline_attributes) + end + end + end + end + end +end diff --git a/lib/gitlab/ci/build/context/global.rb b/lib/gitlab/ci/build/context/global.rb new file mode 100644 index 0000000000000000000000000000000000000000..fdd3ac358d57b24154e56053238abafe99123d9d --- /dev/null +++ b/lib/gitlab/ci/build/context/global.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Build + module Context + class Global < Base + include Gitlab::Utils::StrongMemoize + + def initialize(pipeline, yaml_variables:) + super(pipeline) + + @yaml_variables = yaml_variables.to_a + end + + def variables + strong_memoize(:variables) do + # This is a temporary piece of technical debt to allow us access + # to the CI variables to evaluate workflow:rules + # with the result. We should refactor away the extra Build.new, + # but be able to get CI Variables directly from the Seed::Build. + stub_build.scoped_variables_hash + .reject { |key, _value| key =~ /\ACI_(JOB|BUILD)/ } + end + end + + private + + def stub_build + ::Ci::Build.new(build_attributes) + end + + def build_attributes + pipeline_attributes.merge( + yaml_variables: @yaml_variables) + end + end + end + end + end +end diff --git a/lib/gitlab/ci/build/policy/changes.rb b/lib/gitlab/ci/build/policy/changes.rb index 9c705a1cd3eddbf99aa6d972c5fb8f2be5ef3e2d..9ae4198bbf7958748bb3441175ee67183d1b35ae 100644 --- a/lib/gitlab/ci/build/policy/changes.rb +++ b/lib/gitlab/ci/build/policy/changes.rb @@ -9,7 +9,7 @@ module Gitlab @globs = Array(globs) end - def satisfied_by?(pipeline, seed) + def satisfied_by?(pipeline, context) return true if pipeline.modified_paths.nil? pipeline.modified_paths.any? do |path| diff --git a/lib/gitlab/ci/build/policy/kubernetes.rb b/lib/gitlab/ci/build/policy/kubernetes.rb index 4c7dc947cd057dfeefa7bfdfe7664c14bc2296b3..4e8693724e552a014da4e79fa0a7c1e84171e05d 100644 --- a/lib/gitlab/ci/build/policy/kubernetes.rb +++ b/lib/gitlab/ci/build/policy/kubernetes.rb @@ -11,7 +11,7 @@ module Gitlab end end - def satisfied_by?(pipeline, seed = nil) + def satisfied_by?(pipeline, context = nil) pipeline.has_kubernetes_active? end end diff --git a/lib/gitlab/ci/build/policy/refs.rb b/lib/gitlab/ci/build/policy/refs.rb index c3005303fd81da86010dddd323482bddf15da6db..afe0ccb361e8400245c2e74159e26f1efa8870e9 100644 --- a/lib/gitlab/ci/build/policy/refs.rb +++ b/lib/gitlab/ci/build/policy/refs.rb @@ -9,7 +9,7 @@ module Gitlab @patterns = Array(refs) end - def satisfied_by?(pipeline, seed = nil) + def satisfied_by?(pipeline, context = nil) @patterns.any? do |pattern| pattern, path = pattern.split('@', 2) diff --git a/lib/gitlab/ci/build/policy/specification.rb b/lib/gitlab/ci/build/policy/specification.rb index ceb5210cfb5a0b3f34748a6ee6521dd2160f6972..1394340ce1f3300074f3c58f228629decf12b7a0 100644 --- a/lib/gitlab/ci/build/policy/specification.rb +++ b/lib/gitlab/ci/build/policy/specification.rb @@ -17,7 +17,7 @@ module Gitlab @spec = spec end - def satisfied_by?(pipeline, seed = nil) + def satisfied_by?(pipeline, context = nil) raise NotImplementedError end end diff --git a/lib/gitlab/ci/build/policy/variables.rb b/lib/gitlab/ci/build/policy/variables.rb index e9c8864123f4c8e8ee5489c32b991fe832b48ee9..7b1ce6330f0c5539cd6692deb250f92324694792 100644 --- a/lib/gitlab/ci/build/policy/variables.rb +++ b/lib/gitlab/ci/build/policy/variables.rb @@ -9,8 +9,8 @@ module Gitlab @expressions = Array(expressions) end - def satisfied_by?(pipeline, seed) - variables = seed.scoped_variables_hash + def satisfied_by?(pipeline, context) + variables = context.variables statements = @expressions.map do |statement| ::Gitlab::Ci::Pipeline::Expression::Statement diff --git a/lib/gitlab/ci/build/rules.rb b/lib/gitlab/ci/build/rules.rb index 43399c74457cc10f27a98ca4db107b2f2954f6ea..c705b6f86c7f0ee37e8828ac5335d15dfb894922 100644 --- a/lib/gitlab/ci/build/rules.rb +++ b/lib/gitlab/ci/build/rules.rb @@ -13,17 +13,21 @@ module Gitlab options: { start_in: start_in }.compact }.compact end + + def pass? + self.when != 'never' + end end - def initialize(rule_hashes, default_when = 'on_success') + def initialize(rule_hashes, default_when:) @rule_list = Rule.fabricate_list(rule_hashes) @default_when = default_when end - def evaluate(pipeline, build) + def evaluate(pipeline, context) if @rule_list.nil? Result.new(@default_when) - elsif matched_rule = match_rule(pipeline, build) + elsif matched_rule = match_rule(pipeline, context) Result.new( matched_rule.attributes[:when] || @default_when, matched_rule.attributes[:start_in] @@ -35,8 +39,8 @@ module Gitlab private - def match_rule(pipeline, build) - @rule_list.find { |rule| rule.matches?(pipeline, build) } + def match_rule(pipeline, context) + @rule_list.find { |rule| rule.matches?(pipeline, context) } end end end diff --git a/lib/gitlab/ci/build/rules/rule.rb b/lib/gitlab/ci/build/rules/rule.rb index 8d52158c8d241d73ba2b362e0ddce9a178ade04d..077e4d150fb4de41db5df1d3d369b3cf94c1da36 100644 --- a/lib/gitlab/ci/build/rules/rule.rb +++ b/lib/gitlab/ci/build/rules/rule.rb @@ -23,8 +23,8 @@ module Gitlab end end - def matches?(pipeline, build) - @clauses.all? { |clause| clause.satisfied_by?(pipeline, build) } + def matches?(pipeline, context) + @clauses.all? { |clause| clause.satisfied_by?(pipeline, context) } end end end diff --git a/lib/gitlab/ci/build/rules/rule/clause.rb b/lib/gitlab/ci/build/rules/rule/clause.rb index bf787fe95a6a476cb9b6290b35441504600347ba..6d4bbbb8c21c94115b876889343851b3b16a8d82 100644 --- a/lib/gitlab/ci/build/rules/rule/clause.rb +++ b/lib/gitlab/ci/build/rules/rule/clause.rb @@ -20,7 +20,7 @@ module Gitlab @spec = spec end - def satisfied_by?(pipeline, seed = nil) + def satisfied_by?(pipeline, context = nil) raise NotImplementedError end end diff --git a/lib/gitlab/ci/build/rules/rule/clause/changes.rb b/lib/gitlab/ci/build/rules/rule/clause/changes.rb index 81d2ee6c24c41812b083dc2b039a0776155ac74e..728a66ca87fec624538217830be66e96ca583a8b 100644 --- a/lib/gitlab/ci/build/rules/rule/clause/changes.rb +++ b/lib/gitlab/ci/build/rules/rule/clause/changes.rb @@ -8,7 +8,7 @@ module Gitlab @globs = Array(globs) end - def satisfied_by?(pipeline, seed) + def satisfied_by?(pipeline, context) return true if pipeline.modified_paths.nil? pipeline.modified_paths.any? do |path| diff --git a/lib/gitlab/ci/build/rules/rule/clause/exists.rb b/lib/gitlab/ci/build/rules/rule/clause/exists.rb index 62f8371283f342530fe324279eb9be95829a3414..85e77438f514ad6671aea021461488a88721a352 100644 --- a/lib/gitlab/ci/build/rules/rule/clause/exists.rb +++ b/lib/gitlab/ci/build/rules/rule/clause/exists.rb @@ -15,7 +15,7 @@ module Gitlab @exact_globs, @pattern_globs = globs.partition(&method(:exact_glob?)) end - def satisfied_by?(pipeline, seed) + def satisfied_by?(pipeline, context) paths = worktree_paths(pipeline) exact_matches?(paths) || pattern_matches?(paths) diff --git a/lib/gitlab/ci/build/rules/rule/clause/if.rb b/lib/gitlab/ci/build/rules/rule/clause/if.rb index 18c3b450f9566cda4d09a5e8e50dc15b03c5b99d..6143a736ca650c987b2b42a7f0ed629b95a83e61 100644 --- a/lib/gitlab/ci/build/rules/rule/clause/if.rb +++ b/lib/gitlab/ci/build/rules/rule/clause/if.rb @@ -8,10 +8,9 @@ module Gitlab @expression = expression end - def satisfied_by?(pipeline, seed) - variables = seed.scoped_variables_hash - - ::Gitlab::Ci::Pipeline::Expression::Statement.new(@expression, variables).truthful? + def satisfied_by?(pipeline, context) + ::Gitlab::Ci::Pipeline::Expression::Statement.new( + @expression, context.variables).truthful? end end end diff --git a/lib/gitlab/ci/config/entry/artifacts.rb b/lib/gitlab/ci/config/entry/artifacts.rb index 41613369ca2e85cb69d7ac1bcbd68afff06e2c62..9d8d7675234297f435d8502b61366f357c9fa451 100644 --- a/lib/gitlab/ci/config/entry/artifacts.rb +++ b/lib/gitlab/ci/config/entry/artifacts.rb @@ -12,7 +12,9 @@ module Gitlab include ::Gitlab::Config::Entry::Validatable include ::Gitlab::Config::Entry::Attributable - ALLOWED_KEYS = %i[name untracked paths reports when expire_in].freeze + ALLOWED_KEYS = %i[name untracked paths reports when expire_in expose_as].freeze + EXPOSE_AS_REGEX = /\A\w[-\w ]*\z/.freeze + EXPOSE_AS_ERROR_MESSAGE = "can contain only letters, digits, '-', '_' and spaces" attributes ALLOWED_KEYS @@ -21,11 +23,18 @@ module Gitlab validations do validates :config, type: Hash validates :config, allowed_keys: ALLOWED_KEYS + validates :paths, presence: true, if: :expose_as_present? with_options allow_nil: true do validates :name, type: String validates :untracked, boolean: true validates :paths, array_of_strings: true + validates :paths, array_of_strings: { + with: /\A[^*]*\z/, + message: "can't contain '*' when used with 'expose_as'" + }, if: :expose_as_present? + validates :expose_as, type: String, length: { maximum: 100 }, if: :expose_as_present? + validates :expose_as, format: { with: EXPOSE_AS_REGEX, message: EXPOSE_AS_ERROR_MESSAGE }, if: :expose_as_present? validates :reports, type: Hash validates :when, inclusion: { in: %w[on_success on_failure always], @@ -41,6 +50,12 @@ module Gitlab @config[:reports] = reports_value if @config.key?(:reports) @config end + + def expose_as_present? + return false unless Feature.enabled?(:ci_expose_arbitrary_artifacts_in_mr, default_enabled: true) + + !@config[:expose_as].nil? + end end end end diff --git a/lib/gitlab/ci/config/entry/boolean.rb b/lib/gitlab/ci/config/entry/boolean.rb new file mode 100644 index 0000000000000000000000000000000000000000..10619ef9f8d42877177034584ad1855797eded5b --- /dev/null +++ b/lib/gitlab/ci/config/entry/boolean.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents the interrutible value. + # + class Boolean < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + + validations do + validates :config, boolean: true + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/commands.rb b/lib/gitlab/ci/config/entry/commands.rb index 02e368c1813f9a6373979c161996b8b06e0bdcc7..7a86fca30566e8e4bfdec2c574cab6db31ea1911 100644 --- a/lib/gitlab/ci/config/entry/commands.rb +++ b/lib/gitlab/ci/config/entry/commands.rb @@ -11,11 +11,11 @@ module Gitlab include ::Gitlab::Config::Entry::Validatable validations do - validates :config, array_of_strings_or_string: true + validates :config, string_or_nested_array_of_strings: true end def value - Array(@config) + Array(@config).flatten(1) end end end diff --git a/lib/gitlab/ci/config/entry/default.rb b/lib/gitlab/ci/config/entry/default.rb index 6200d7c7f87b5eb7f43a6900899d69c8ad5cd8c1..83127bde6e4613714690a1e080dc52cb78fe3e1b 100644 --- a/lib/gitlab/ci/config/entry/default.rb +++ b/lib/gitlab/ci/config/entry/default.rb @@ -11,11 +11,10 @@ module Gitlab # class Default < ::Gitlab::Config::Entry::Node include ::Gitlab::Config::Entry::Configurable - - DuplicateError = Class.new(Gitlab::Config::Loader::FormatError) + include ::Gitlab::Config::Entry::Inheritable ALLOWED_KEYS = %i[before_script image services - after_script cache].freeze + after_script cache interruptible].freeze validations do validates :config, allowed_keys: ALLOWED_KEYS @@ -41,31 +40,22 @@ module Gitlab description: 'Configure caching between build jobs.', inherit: true - helpers :before_script, :image, :services, :after_script, :cache - - def compose!(deps = nil) - super(self) + entry :interruptible, Entry::Boolean, + description: 'Set jobs interruptible default value.', + inherit: false - inherit!(deps) - end + helpers :before_script, :image, :services, :after_script, :cache, :interruptible private - def inherit!(deps) - return unless deps - - self.class.nodes.each do |key, factory| - next unless factory.inheritable? + def overwrite_entry(deps, key, current_entry) + inherited_entry = deps[key] - root_entry = deps[key] - next unless root_entry.specified? - - if self[key].specified? - raise DuplicateError, "#{key} is defined in top-level and `default:` entry" - end - - @entries[key] = root_entry + if inherited_entry.specified? && current_entry.specified? + raise InheritError, "#{key} is defined in top-level and `default:` entry" end + + inherited_entry unless current_entry.specified? end end end diff --git a/lib/gitlab/ci/config/entry/files.rb b/lib/gitlab/ci/config/entry/files.rb new file mode 100644 index 0000000000000000000000000000000000000000..d0d6a36d7549ddedc90bfef59d3f09144ccf78c0 --- /dev/null +++ b/lib/gitlab/ci/config/entry/files.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents an array of file paths. + # + class Files < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + + validations do + validates :config, array_of_strings: true + validates :config, length: { + minimum: 1, + maximum: 2, + too_short: 'requires at least %{count} item', + too_long: 'has too many items (maximum is %{count})' + } + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb index 07d5be86b1e9535bb81315d81447e7c163b463ef..c75ae87a98555bcb22ed446fb586f4918df8d148 100644 --- a/lib/gitlab/ci/config/entry/job.rb +++ b/lib/gitlab/ci/config/entry/job.rb @@ -10,6 +10,7 @@ module Gitlab class Job < ::Gitlab::Config::Entry::Node include ::Gitlab::Config::Entry::Configurable include ::Gitlab::Config::Entry::Attributable + include ::Gitlab::Config::Entry::Inheritable ALLOWED_WHEN = %w[on_success on_failure always manual delayed].freeze ALLOWED_KEYS = %i[tags script only except rules type image services @@ -37,7 +38,6 @@ module Gitlab with_options allow_nil: true do validates :tags, array_of_strings: true validates :allow_failure, boolean: true - validates :interruptible, boolean: true validates :parallel, numericality: { only_integer: true, greater_than_or_equal_to: 2, less_than_or_equal_to: 50 } @@ -49,7 +49,6 @@ module Gitlab validates :timeout, duration: { limit: ChronicDuration.output(Project::MAX_BUILD_TIMEOUT) } validates :dependencies, array_of_strings: true - validates :needs, array_of_strings: true validates :extends, array_of_strings_or_string: true validates :rules, array_of_hashes: true end @@ -73,13 +72,16 @@ module Gitlab inherit: true entry :script, Entry::Commands, - description: 'Commands that will be executed in this job.' + description: 'Commands that will be executed in this job.', + inherit: false entry :stage, Entry::Stage, - description: 'Pipeline stage this job will be executed into.' + description: 'Pipeline stage this job will be executed into.', + inherit: false entry :type, Entry::Stage, - description: 'Deprecated: stage this job will be executed into.' + description: 'Deprecated: stage this job will be executed into.', + inherit: false entry :after_script, Entry::Script, description: 'Commands that will be executed when finishing job.', @@ -97,30 +99,50 @@ module Gitlab description: 'Services that will be used to execute this job.', inherit: true + entry :interruptible, Entry::Boolean, + description: 'Set jobs interruptible value.', + inherit: true + entry :only, Entry::Policy, description: 'Refs policy this job will be executed for.', - default: Entry::Policy::DEFAULT_ONLY + default: Entry::Policy::DEFAULT_ONLY, + inherit: false entry :except, Entry::Policy, - description: 'Refs policy this job will be executed for.' + description: 'Refs policy this job will be executed for.', + inherit: false entry :rules, Entry::Rules, - description: 'List of evaluable Rules to determine job inclusion.' + description: 'List of evaluable Rules to determine job inclusion.', + inherit: false, + metadata: { + allowed_when: %w[on_success on_failure always never manual delayed].freeze + } + + entry :needs, Entry::Needs, + description: 'Needs configuration for this job.', + metadata: { allowed_needs: %i[job] }, + inherit: false entry :variables, Entry::Variables, - description: 'Environment variables available for this job.' + description: 'Environment variables available for this job.', + inherit: false entry :artifacts, Entry::Artifacts, - description: 'Artifacts configuration for this job.' + description: 'Artifacts configuration for this job.', + inherit: false entry :environment, Entry::Environment, - description: 'Environment configuration for this job.' + description: 'Environment configuration for this job.', + inherit: false entry :coverage, Entry::Coverage, - description: 'Coverage configuration for this job.' + description: 'Coverage configuration for this job.', + inherit: false entry :retry, Entry::Retry, - description: 'Retry configuration for this job.' + description: 'Retry configuration for this job.', + inherit: false helpers :before_script, :script, :stage, :type, :after_script, :cache, :image, :services, :only, :except, :variables, @@ -155,8 +177,6 @@ module Gitlab @entries.delete(:except) end end - - inherit!(deps) end def name @@ -185,21 +205,8 @@ module Gitlab private - # We inherit config entries from `default:` - # if the entry has the `inherit: true` flag set - def inherit!(deps) - return unless deps - - self.class.nodes.each do |key, factory| - next unless factory.inheritable? - - default_entry = deps.default[key] - job_entry = self[key] - - if default_entry.specified? && !job_entry.specified? - @entries[key] = default_entry - end - end + def overwrite_entry(deps, key, current_entry) + deps.default[key] unless current_entry.specified? end def to_hash diff --git a/lib/gitlab/ci/config/entry/key.rb b/lib/gitlab/ci/config/entry/key.rb index 0c10967e629ed151f82622cc7bae4694679e76ba..f12f09193489bb58a0916bfd2a103d4630ecaf1a 100644 --- a/lib/gitlab/ci/config/entry/key.rb +++ b/lib/gitlab/ci/config/entry/key.rb @@ -7,11 +7,48 @@ module Gitlab ## # Entry that represents a key. # - class Key < ::Gitlab::Config::Entry::Node - include ::Gitlab::Config::Entry::Validatable + class Key < ::Gitlab::Config::Entry::Simplifiable + strategy :SimpleKey, if: -> (config) { config.is_a?(String) || config.is_a?(Symbol) } + strategy :ComplexKey, if: -> (config) { config.is_a?(Hash) } - validations do - validates :config, key: true + class SimpleKey < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + + validations do + validates :config, key: true + end + + def self.default + 'default' + end + + def value + super.to_s + end + end + + class ComplexKey < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Attributable + include ::Gitlab::Config::Entry::Configurable + + ALLOWED_KEYS = %i[files prefix].freeze + REQUIRED_KEYS = %i[files].freeze + + validations do + validates :config, allowed_keys: ALLOWED_KEYS + validates :config, required_keys: REQUIRED_KEYS + end + + entry :files, Entry::Files, + description: 'Files that should be used to build the key' + entry :prefix, Entry::Prefix, + description: 'Prefix that is added to the final cache key' + end + + class UnknownStrategy < ::Gitlab::Config::Entry::Node + def errors + ["#{location} should be a hash, a string or a symbol"] + end end def self.default diff --git a/lib/gitlab/ci/config/entry/need.rb b/lib/gitlab/ci/config/entry/need.rb new file mode 100644 index 0000000000000000000000000000000000000000..b6db546d8ff42ec45b728b2b908e212dd9d3979f --- /dev/null +++ b/lib/gitlab/ci/config/entry/need.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + class Need < ::Gitlab::Config::Entry::Simplifiable + strategy :Job, if: -> (config) { config.is_a?(String) } + + class Job < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + + validations do + validates :config, presence: true + validates :config, type: String + end + + def type + :job + end + + def value + { name: @config } + end + end + + class UnknownStrategy < ::Gitlab::Config::Entry::Node + def type + end + + def value + end + + def errors + ["#{location} has an unsupported type"] + end + end + end + end + end + end +end + +::Gitlab::Ci::Config::Entry::Need.prepend_if_ee('::EE::Gitlab::Ci::Config::Entry::Need') diff --git a/lib/gitlab/ci/config/entry/needs.rb b/lib/gitlab/ci/config/entry/needs.rb new file mode 100644 index 0000000000000000000000000000000000000000..28452aaaa16b48e0d9d75a773dba7a713b0681df --- /dev/null +++ b/lib/gitlab/ci/config/entry/needs.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents a set of needs dependencies. + # + class Needs < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + + validations do + validates :config, presence: true + + validate do + unless config.is_a?(Hash) || config.is_a?(Array) + errors.add(:config, 'can only be a Hash or an Array') + end + end + + validate on: :composed do + extra_keys = value.keys - opt(:allowed_needs) + if extra_keys.any? + errors.add(:config, "uses invalid types: #{extra_keys.join(', ')}") + end + end + end + + def compose!(deps = nil) + super(deps) do + [@config].flatten.each_with_index do |need, index| + @entries[index] = ::Gitlab::Config::Entry::Factory.new(Entry::Need) + .value(need) + .with(key: "need", parent: self, description: "need definition.") # rubocop:disable CodeReuse/ActiveRecord + .create! + end + + @entries.each_value do |entry| + entry.compose!(deps) + end + end + end + + def value + values = @entries.values.select(&:type) + values.group_by(&:type).transform_values do |values| + values.map(&:value) + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/prefix.rb b/lib/gitlab/ci/config/entry/prefix.rb new file mode 100644 index 0000000000000000000000000000000000000000..3244ad6d611133e63b450fc55e6b0878811fce2a --- /dev/null +++ b/lib/gitlab/ci/config/entry/prefix.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents a key prefix. + # + class Prefix < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + + validations do + validates :config, key: true + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/root.rb b/lib/gitlab/ci/config/entry/root.rb index 07022ff7b54d17f99cd6fdfe9ece6aeea207358f..25fb278d9b843f9f3e3532299cd4eeeca85a47a7 100644 --- a/lib/gitlab/ci/config/entry/root.rb +++ b/lib/gitlab/ci/config/entry/root.rb @@ -12,7 +12,7 @@ module Gitlab include ::Gitlab::Config::Entry::Configurable ALLOWED_KEYS = %i[default include before_script image services - after_script variables stages types cache].freeze + after_script variables stages types cache workflow].freeze validations do validates :config, allowed_keys: ALLOWED_KEYS @@ -64,6 +64,9 @@ module Gitlab description: 'Configure caching between build jobs.', reserved: true + entry :workflow, Entry::Workflow, + description: 'List of evaluable rules to determine Pipeline status' + helpers :default, :jobs, :stages, :types, :variables delegate :before_script_value, diff --git a/lib/gitlab/ci/config/entry/rules/rule.rb b/lib/gitlab/ci/config/entry/rules/rule.rb index 5d6d1c026e3a583f8d8b4354ce0d6fb2ae92a7e6..59e0ef583aef860e6006f45d599be70ea902159c 100644 --- a/lib/gitlab/ci/config/entry/rules/rule.rb +++ b/lib/gitlab/ci/config/entry/rules/rule.rb @@ -8,9 +8,9 @@ module Gitlab include ::Gitlab::Config::Entry::Validatable include ::Gitlab::Config::Entry::Attributable - CLAUSES = %i[if changes exists].freeze - ALLOWED_KEYS = %i[if changes exists when start_in].freeze - ALLOWED_WHEN = %w[on_success on_failure always never manual delayed].freeze + CLAUSES = %i[if changes exists].freeze + ALLOWED_KEYS = %i[if changes exists when start_in].freeze + ALLOWABLE_WHEN = %w[on_success on_failure always never manual delayed].freeze attributes :if, :changes, :exists, :when, :start_in @@ -25,7 +25,14 @@ module Gitlab with_options allow_nil: true do validates :if, expression: true validates :changes, :exists, array_of_strings: true, length: { maximum: 50 } - validates :when, allowed_values: { in: ALLOWED_WHEN } + validates :when, allowed_values: { in: ALLOWABLE_WHEN } + end + + validate do + validates_with Gitlab::Config::Entry::Validators::AllowedValuesValidator, + attributes: %i[when], + allow_nil: true, + in: opt(:allowed_when) end end diff --git a/lib/gitlab/ci/config/entry/script.rb b/lib/gitlab/ci/config/entry/script.rb index 9d25a82b52121919baaa177b30e619158533c600..285e18218b3358a76599d13061340928cf7cdada 100644 --- a/lib/gitlab/ci/config/entry/script.rb +++ b/lib/gitlab/ci/config/entry/script.rb @@ -11,7 +11,11 @@ module Gitlab include ::Gitlab::Config::Entry::Validatable validations do - validates :config, array_of_strings: true + validates :config, nested_array_of_strings: true + end + + def value + config.flatten(1) end end end diff --git a/lib/gitlab/ci/config/entry/workflow.rb b/lib/gitlab/ci/config/entry/workflow.rb new file mode 100644 index 0000000000000000000000000000000000000000..a51a3fbdcd2415a062f206ca68e0a5328d239fc9 --- /dev/null +++ b/lib/gitlab/ci/config/entry/workflow.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + class Workflow < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Configurable + + ALLOWED_KEYS = %i[rules].freeze + + validations do + validates :config, type: Hash + validates :config, allowed_keys: ALLOWED_KEYS + validates :config, presence: true + end + + entry :rules, Entry::Rules, + description: 'List of evaluable Rules to determine Pipeline status.', + metadata: { allowed_when: %w[always never] } + end + end + end + end +end diff --git a/lib/gitlab/ci/config/normalizer.rb b/lib/gitlab/ci/config/normalizer.rb index 09f9bf5f69f7bc8b41d74e1e242b59994de3f585..e714ef225f5938c36c839e98dd63dc196cdba077 100644 --- a/lib/gitlab/ci/config/normalizer.rb +++ b/lib/gitlab/ci/config/normalizer.rb @@ -18,8 +18,8 @@ module Gitlab config[:dependencies] = expand_names(config[:dependencies]) end - if config[:needs] - config[:needs] = expand_names(config[:needs]) + if job_needs = config.dig(:needs, :job) + config[:needs][:job] = expand_needs(job_needs) end config @@ -36,6 +36,22 @@ module Gitlab end end + def expand_needs(job_needs) + return unless job_needs + + job_needs.flat_map do |job_need| + job_need_name = job_need[:name].to_sym + + if all_job_names = parallelized_jobs[job_need_name] + all_job_names.map do |job_name| + { name: job_name } + end + else + job_need + end + end + end + def parallelized_jobs strong_memoize(:parallelized_jobs) do @jobs_config.each_with_object({}) do |(job_name, config), hash| diff --git a/lib/gitlab/ci/pipeline/chain/base.rb b/lib/gitlab/ci/pipeline/chain/base.rb index bab1c73e2f1a665fd86ee92757f7f57f8802a4aa..aabdf7ce47d83e35cecb94f60b494b23db78bae5 100644 --- a/lib/gitlab/ci/pipeline/chain/base.rb +++ b/lib/gitlab/ci/pipeline/chain/base.rb @@ -5,7 +5,7 @@ module Gitlab module Pipeline module Chain class Base - attr_reader :pipeline, :command + attr_reader :pipeline, :command, :config delegate :project, :current_user, to: :command diff --git a/lib/gitlab/ci/pipeline/chain/build.rb b/lib/gitlab/ci/pipeline/chain/build.rb index 899df81ea5c0f257c4201838d0c55cff265c8747..9662209f88ee3ee3a4a94465eb86440009b3717d 100644 --- a/lib/gitlab/ci/pipeline/chain/build.rb +++ b/lib/gitlab/ci/pipeline/chain/build.rb @@ -22,8 +22,6 @@ module Gitlab external_pull_request: @command.external_pull_request, variables_attributes: Array(@command.variables_attributes) ) - - @pipeline.set_config_source end def break? diff --git a/lib/gitlab/ci/pipeline/chain/command.rb b/lib/gitlab/ci/pipeline/chain/command.rb index 58f89a6be5e4569e0ee1a46804072057a739b950..c2df419cca07c2d0c702061e3d4dd6efd618efdf 100644 --- a/lib/gitlab/ci/pipeline/chain/command.rb +++ b/lib/gitlab/ci/pipeline/chain/command.rb @@ -10,7 +10,9 @@ 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, + # These attributes are set by Chains during processing: + :config_content, :config_processor, :stage_seeds ) do include Gitlab::Utils::StrongMemoize diff --git a/lib/gitlab/ci/pipeline/chain/config/content.rb b/lib/gitlab/ci/pipeline/chain/config/content.rb new file mode 100644 index 0000000000000000000000000000000000000000..a8cd99b8e922ec763808ee78b7634e5cd4a12b08 --- /dev/null +++ b/lib/gitlab/ci/pipeline/chain/config/content.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Pipeline + module Chain + module Config + class Content < Chain::Base + include Chain::Helpers + + def perform! + return if @command.config_content + + if content = content_from_repo + @command.config_content = content + @pipeline.config_source = :repository_source + # TODO: we should persist ci_config_path + # @pipeline.config_path = ci_config_path + elsif content = content_from_auto_devops + @command.config_content = content + @pipeline.config_source = :auto_devops_source + end + + unless @command.config_content + return error("Missing #{ci_config_path} file") + end + end + + def break? + @pipeline.errors.any? || @pipeline.persisted? + end + + private + + def content_from_repo + return unless project + return unless @pipeline.sha + return unless ci_config_path + + project.repository.gitlab_ci_yml_for(@pipeline.sha, ci_config_path) + rescue GRPC::NotFound, GRPC::Internal + nil + end + + def content_from_auto_devops + return unless project&.auto_devops_enabled? + + Gitlab::Template::GitlabCiYmlTemplate.find('Auto-DevOps').content + end + + def ci_config_path + project.ci_config_path.presence || '.gitlab-ci.yml' + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/chain/config/process.rb b/lib/gitlab/ci/pipeline/chain/config/process.rb new file mode 100644 index 0000000000000000000000000000000000000000..731b0fdb286cea512af98945f0ca9bed8cc8e465 --- /dev/null +++ b/lib/gitlab/ci/pipeline/chain/config/process.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Pipeline + module Chain + module Config + class Process < Chain::Base + include Chain::Helpers + + def perform! + raise ArgumentError, 'missing config content' unless @command.config_content + + @command.config_processor = ::Gitlab::Ci::YamlProcessor.new( + @command.config_content, { + project: project, + sha: @pipeline.sha, + user: current_user + } + ) + rescue Gitlab::Ci::YamlProcessor::ValidationError => ex + error(ex.message, config_error: true) + rescue => ex + Gitlab::Sentry.track_acceptable_exception(ex, extra: { + project_id: project.id, + sha: @pipeline.sha + }) + + error("Undefined error (#{Labkit::Correlation::CorrelationId.current_id})", + config_error: true) + end + + def break? + @pipeline.errors.any? || @pipeline.persisted? + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/chain/evaluate_workflow_rules.rb b/lib/gitlab/ci/pipeline/chain/evaluate_workflow_rules.rb new file mode 100644 index 0000000000000000000000000000000000000000..0ee9485eebc312478c57ad92f1a937af3a10038b --- /dev/null +++ b/lib/gitlab/ci/pipeline/chain/evaluate_workflow_rules.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Pipeline + module Chain + class EvaluateWorkflowRules < Chain::Base + include ::Gitlab::Utils::StrongMemoize + include Chain::Helpers + + def perform! + return unless Feature.enabled?(:workflow_rules, @pipeline.project) + + unless workflow_passed? + error('Pipeline filtered out by workflow rules.') + end + end + + def break? + return false unless Feature.enabled?(:workflow_rules, @pipeline.project) + + !workflow_passed? + end + + private + + def workflow_passed? + strong_memoize(:workflow_passed) do + workflow_rules.evaluate(@pipeline, global_context).pass? + end + end + + def workflow_rules + Gitlab::Ci::Build::Rules.new( + workflow_config[:rules], default_when: 'always') + end + + def global_context + Gitlab::Ci::Build::Context::Global.new( + @pipeline, yaml_variables: workflow_config[:yaml_variables]) + end + + def workflow_config + @command.config_processor.workflow_attributes || {} + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/chain/populate.rb b/lib/gitlab/ci/pipeline/chain/populate.rb index 13eca5a9d28a1aa5e14c0d17467dece096848591..3a40c7b167c0c6d920c7a3dfb8fb2f3043ead76e 100644 --- a/lib/gitlab/ci/pipeline/chain/populate.rb +++ b/lib/gitlab/ci/pipeline/chain/populate.rb @@ -10,29 +10,12 @@ module Gitlab PopulateError = Class.new(StandardError) def perform! - # Allocate next IID. This operation must be outside of transactions of pipeline creations. - pipeline.ensure_project_iid! - - # Protect the pipeline. This is assigned in Populate instead of - # Build to prevent erroring out on ambiguous refs. - pipeline.protected = @command.protected_ref? - - ## - # Populate pipeline with block argument of CreatePipelineService#execute. - # - @command.seeds_block&.call(pipeline) - - ## - # Gather all runtime build/stage errors - # - if seeds_errors = pipeline.stage_seeds.flat_map(&:errors).compact.presence - return error(seeds_errors.join("\n"), config_error: true) - end + raise ArgumentError, 'missing stage seeds' unless @command.stage_seeds ## # Populate pipeline with all stages, and stages with builds. # - pipeline.stages = pipeline.stage_seeds.map(&:to_resource) + pipeline.stages = @command.stage_seeds.map(&:to_resource) if pipeline.stages.none? return error('No stages / jobs for this pipeline.') diff --git a/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs.rb b/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs.rb index 1e09b41731113fb350281aa97a01f387201c5625..9267c72efa40d2b51c7324fc5bd0f2a6bbf61af2 100644 --- a/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs.rb +++ b/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs.rb @@ -6,11 +6,13 @@ module Gitlab module Chain class RemoveUnwantedChatJobs < Chain::Base def perform! - return unless pipeline.config_processor && pipeline.chat? + raise ArgumentError, 'missing config processor' unless @command.config_processor + + return unless pipeline.chat? # When scheduling a chat pipeline we only want to run the build # that matches the chat command. - pipeline.config_processor.jobs.select! do |name, _| + @command.config_processor.jobs.select! do |name, _| name.to_s == command.chat_data[:command].to_s end end diff --git a/lib/gitlab/ci/pipeline/chain/seed.rb b/lib/gitlab/ci/pipeline/chain/seed.rb new file mode 100644 index 0000000000000000000000000000000000000000..2e177cfec7ebcf7799f9fde3a166ea054b2149e7 --- /dev/null +++ b/lib/gitlab/ci/pipeline/chain/seed.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Pipeline + module Chain + class Seed < Chain::Base + include Chain::Helpers + include Gitlab::Utils::StrongMemoize + + def perform! + raise ArgumentError, 'missing config processor' unless @command.config_processor + + # Allocate next IID. This operation must be outside of transactions of pipeline creations. + pipeline.ensure_project_iid! + + # Protect the pipeline. This is assigned in Populate instead of + # Build to prevent erroring out on ambiguous refs. + pipeline.protected = @command.protected_ref? + + ## + # Populate pipeline with block argument of CreatePipelineService#execute. + # + @command.seeds_block&.call(pipeline) + + ## + # Gather all runtime build/stage errors + # + if stage_seeds_errors + return error(stage_seeds_errors.join("\n"), config_error: true) + end + + @command.stage_seeds = stage_seeds + end + + def break? + pipeline.errors.any? + end + + private + + def stage_seeds_errors + stage_seeds.flat_map(&:errors).compact.presence + end + + def stage_seeds + strong_memoize(:stage_seeds) do + seeds = stages_attributes.inject([]) do |previous_stages, attributes| + seed = Gitlab::Ci::Pipeline::Seed::Stage.new(pipeline, attributes, previous_stages) + previous_stages + [seed] + end + + seeds.select(&:included?) + end + end + + def stages_attributes + @command.config_processor.stages_attributes + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/chain/validate/config.rb b/lib/gitlab/ci/pipeline/chain/validate/config.rb deleted file mode 100644 index 28c38cc3d181b877550a338a011d0c4ee0fab518..0000000000000000000000000000000000000000 --- a/lib/gitlab/ci/pipeline/chain/validate/config.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - module Pipeline - module Chain - module Validate - class Config < Chain::Base - include Chain::Helpers - - def perform! - unless @pipeline.config_processor - unless @pipeline.ci_yaml_file - return error("Missing #{@pipeline.ci_yaml_file_path} file") - end - - if @command.save_incompleted && @pipeline.has_yaml_errors? - @pipeline.drop!(:config_error) - end - - error(@pipeline.yaml_errors) - end - end - - def break? - @pipeline.errors.any? || @pipeline.persisted? - end - end - end - end - end - end -end diff --git a/lib/gitlab/ci/pipeline/seed/build.rb b/lib/gitlab/ci/pipeline/seed/build.rb index fc9c540088b7adabbb9aa446b84571eb9671b97e..dce56b226664982068a0ef546111611350752dd6 100644 --- a/lib/gitlab/ci/pipeline/seed/build.rb +++ b/lib/gitlab/ci/pipeline/seed/build.rb @@ -28,7 +28,9 @@ module Gitlab @except = Gitlab::Ci::Build::Policy .fabricate(attributes.delete(:except)) @rules = Gitlab::Ci::Build::Rules - .new(attributes.delete(:rules)) + .new(attributes.delete(:rules), default_when: 'on_success') + @cache = Seed::Build::Cache + .new(pipeline, attributes.delete(:cache)) end def name @@ -38,7 +40,7 @@ module Gitlab def included? strong_memoize(:inclusion) do if @using_rules - included_by_rules? + rules_result.pass? elsif @using_only || @using_except all_of_only? && none_of_except? else @@ -59,6 +61,7 @@ module Gitlab @seed_attributes .deep_merge(pipeline_attributes) .deep_merge(rules_attributes) + .deep_merge(cache_attributes) end def bridge? @@ -80,26 +83,14 @@ module Gitlab end end - def scoped_variables_hash - strong_memoize(:scoped_variables_hash) do - # This is a temporary piece of technical debt to allow us access - # to the CI variables to evaluate rules before we persist a Build - # with the result. We should refactor away the extra Build.new, - # but be able to get CI Variables directly from the Seed::Build. - ::Ci::Build.new( - @seed_attributes.merge(pipeline_attributes) - ).scoped_variables_hash - end - end - private def all_of_only? - @only.all? { |spec| spec.satisfied_by?(@pipeline, self) } + @only.all? { |spec| spec.satisfied_by?(@pipeline, evaluate_context) } end def none_of_except? - @except.none? { |spec| spec.satisfied_by?(@pipeline, self) } + @except.none? { |spec| spec.satisfied_by?(@pipeline, evaluate_context) } end def needs_errors @@ -141,13 +132,27 @@ module Gitlab } end - def included_by_rules? - rules_attributes[:when] != 'never' + def rules_attributes + return {} unless @using_rules + + rules_result.build_attributes end - def rules_attributes - strong_memoize(:rules_attributes) do - @using_rules ? @rules.evaluate(@pipeline, self).build_attributes : {} + def rules_result + strong_memoize(:rules_result) do + @rules.evaluate(@pipeline, evaluate_context) + end + end + + def evaluate_context + strong_memoize(:evaluate_context) do + Gitlab::Ci::Build::Context::Build.new(@pipeline, @seed_attributes) + end + end + + def cache_attributes + strong_memoize(:cache_attributes) do + @cache.build_attributes end end end diff --git a/lib/gitlab/ci/pipeline/seed/build/cache.rb b/lib/gitlab/ci/pipeline/seed/build/cache.rb new file mode 100644 index 0000000000000000000000000000000000000000..7671035b896e07badfb0384d1840687b5c94e0f0 --- /dev/null +++ b/lib/gitlab/ci/pipeline/seed/build/cache.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Pipeline + module Seed + class Build + class Cache + def initialize(pipeline, cache) + @pipeline = pipeline + local_cache = cache.to_h.deep_dup + @key = local_cache.delete(:key) + @paths = local_cache.delete(:paths) + @policy = local_cache.delete(:policy) + @untracked = local_cache.delete(:untracked) + + raise ArgumentError, "unknown cache keys: #{local_cache.keys}" if local_cache.any? + end + + def build_attributes + { + options: { + cache: { + key: key_string, + paths: @paths, + policy: @policy, + untracked: @untracked + }.compact.presence + }.compact + } + end + + private + + def key_string + key_from_string || key_from_files + end + + def key_from_string + @key.to_s if @key.is_a?(String) || @key.is_a?(Symbol) + end + + def key_from_files + return unless @key.is_a?(Hash) + + [@key[:prefix], files_digest].select(&:present?).join('-') + end + + def files_digest + hash_of_the_latest_changes || 'default' + end + + def hash_of_the_latest_changes + return unless Feature.enabled?(:ci_file_based_cache, @pipeline.project, default_enabled: true) + + ids = files.map { |path| last_commit_id_for_path(path) } + ids = ids.compact.sort.uniq + + Digest::SHA1.hexdigest(ids.join('-')) if ids.any? + end + + def files + @key[:files] + .to_a + .select(&:present?) + .uniq + end + + def last_commit_id_for_path(path) + @pipeline.project.repository.last_commit_id_for_path(@pipeline.sha, path) + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/status/build/failed.rb b/lib/gitlab/ci/status/build/failed.rb index 961012c2cee03cb220efb11d39df95cbd24b16d2..910d93f54ce942bb4e7a9d1bf5ce5ef164a53c70 100644 --- a/lib/gitlab/ci/status/build/failed.rb +++ b/lib/gitlab/ci/status/build/failed.rb @@ -16,7 +16,9 @@ module Gitlab stale_schedule: 'stale schedule', job_execution_timeout: 'job execution timeout', archived_failure: 'archived failure', - unmet_prerequisites: 'unmet prerequisites' + unmet_prerequisites: 'unmet prerequisites', + scheduler_failure: 'scheduler failure', + data_integrity_failure: 'data integrity failure' }.freeze private_constant :REASONS 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 3cdb7b5420c0f2a31b8e330854fc7a7f07ad3436..a60b00b2ee84140c4d9068b5a26ab28dafd221f4 100644 --- a/lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml @@ -18,7 +18,7 @@ code_quality: --env SOURCE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock - "registry.gitlab.com/gitlab-org/security-products/codequality:12-0-stable" /code + "registry.gitlab.com/gitlab-org/security-products/codequality:12-5-stable" /code artifacts: reports: codequality: gl-code-quality-report.json 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 ae2ff9992f9573eb9c0b64cbb384640c3a1f6c5a..7a672f910dda564ec37d3038a94b8b8ad8a6b00e 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,8 +1,8 @@ -.auto-deploy: - image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:v0.1.0" +.dast-auto-deploy: + image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:v0.6.0" dast_environment_deploy: - extends: .auto-deploy + extends: .dast-auto-deploy stage: review script: - auto-deploy check_kube_domain @@ -28,10 +28,10 @@ dast_environment_deploy: variables: - $CI_DEFAULT_BRANCH != $CI_COMMIT_REF_NAME - $DAST_DISABLED || $DAST_DISABLED_FOR_DEFAULT_BRANCH - - $DAST_WEBSITE # we don't need to create a review app if a URL is already given + - $DAST_WEBSITE # we don't need to create a review app if a URL is already given stop_dast_environment: - extends: .auto-deploy + extends: .dast-auto-deploy stage: cleanup variables: GIT_STRATEGY: none diff --git a/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml index a8ec2d4781d0f70377a2433443fddc3f027aeb18..738be44d5f4c0b64c171671648b0cadc7b9cde2a 100644 --- a/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml @@ -1,5 +1,5 @@ .auto-deploy: - image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:v0.1.0" + image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:v0.7.0" review: extends: .auto-deploy diff --git a/lib/gitlab/ci/templates/Security/Container-Scanning.gitlab-ci.yml b/lib/gitlab/ci/templates/Security/Container-Scanning.gitlab-ci.yml index f058468ed8e5246ed592077abd8c6ad9d98ff9b1..ef2fc561201de30e1f16dfc3a0013e09192eca11 100644 --- a/lib/gitlab/ci/templates/Security/Container-Scanning.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Security/Container-Scanning.gitlab-ci.yml @@ -9,16 +9,17 @@ container_scanning: name: registry.gitlab.com/gitlab-org/security-products/analyzers/klar:$CS_MAJOR_VERSION entrypoint: [] variables: - # By default, use the latest clair vulnerabilities database, however, allow it to be overridden here - # with a specific version to provide consistency for integration testing purposes - CLAIR_DB_IMAGE_TAG: latest - # Override this variable in your `.gitlab-ci.yml` file and set it to `fetch` if you want to provide a `clair-whitelist.yaml` file. - # See https://docs.gitlab.com/ee/user/application_security/container_scanning/index.html#overriding-the-container-scanning-template + # By default, use the latest clair vulnerabilities database, however, allow it to be overridden here with a specific image + # to enable container scanning to run offline, or to provide a consistent list of vulnerabilities for integration testing purposes + CLAIR_DB_IMAGE_TAG: "latest" + CLAIR_DB_IMAGE: "arminc/clair-db:$CLAIR_DB_IMAGE_TAG" + # Override the GIT_STRATEGY variable in your `.gitlab-ci.yml` file and set it to `fetch` if you want to provide a `clair-whitelist.yml` + # file. See https://docs.gitlab.com/ee/user/application_security/container_scanning/index.html#overriding-the-container-scanning-template # for details GIT_STRATEGY: none allow_failure: true services: - - name: arminc/clair-db:$CLAIR_DB_IMAGE_TAG + - name: $CLAIR_DB_IMAGE alias: clair-vulnerabilities-db script: # the kubernetes executor currently ignores the Docker image entrypoint value, so the start.sh script must 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 c8930bc62631702c80b6326944d057d08f7aa7ff..4993d22d400ee8b54ed9ee3e56221e5754a39cc1 100644 --- a/lib/gitlab/ci/templates/Security/Dependency-Scanning.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Security/Dependency-Scanning.gitlab-ci.yml @@ -4,6 +4,12 @@ # List of the variables: https://gitlab.com/gitlab-org/security-products/dependency-scanning#settings # How to set: https://docs.gitlab.com/ee/ci/yaml/#variables +variables: + DS_ANALYZER_IMAGE_PREFIX: "registry.gitlab.com/gitlab-org/security-products/analyzers" + DS_DEFAULT_ANALYZERS: "gemnasium, retire.js, gemnasium-python, gemnasium-maven, bundler-audit" + DS_MAJOR_VERSION: 2 + DS_DISABLE_DIND: "false" + dependency_scanning: stage: test image: docker:stable @@ -45,6 +51,7 @@ dependency_scanning: DS_PIP_DEPENDENCY_PATH \ PIP_INDEX_URL \ PIP_EXTRA_INDEX_URL \ + MAVEN_CLI_OPTS \ ) \ --volume "$PWD:/code" \ --volume /var/run/docker.sock:/var/run/docker.sock \ @@ -61,3 +68,63 @@ dependency_scanning: except: variables: - $DEPENDENCY_SCANNING_DISABLED + - $DS_DISABLE_DIND == 'true' + +.analyzer: + extends: dependency_scanning + services: [] + except: + variables: + - $DS_DISABLE_DIND == 'false' + script: + - /analyzer run + +gemnasium-dependency_scanning: + extends: .analyzer + image: + name: "$DS_ANALYZER_IMAGE_PREFIX/gemnasium:$DS_MAJOR_VERSION" + only: + variables: + - $GITLAB_FEATURES =~ /\bdependency_scanning\b/ && + $DS_DEFAULT_ANALYZERS =~ /gemnasium/ && + $CI_PROJECT_REPOSITORY_LANGUAGES =~ /ruby|javascript|php/ + +gemnasium-maven-dependency_scanning: + extends: .analyzer + image: + name: "$DS_ANALYZER_IMAGE_PREFIX/gemnasium-maven:$DS_MAJOR_VERSION" + only: + variables: + - $GITLAB_FEATURES =~ /\bdependency_scanning\b/ && + $DS_DEFAULT_ANALYZERS =~ /gemnasium-maven/ && + $CI_PROJECT_REPOSITORY_LANGUAGES =~ /\bjava\b/ + +gemnasium-python-dependency_scanning: + extends: .analyzer + image: + name: "$DS_ANALYZER_IMAGE_PREFIX/gemnasium-python:$DS_MAJOR_VERSION" + only: + variables: + - $GITLAB_FEATURES =~ /\bdependency_scanning\b/ && + $DS_DEFAULT_ANALYZERS =~ /gemnasium-python/ && + $CI_PROJECT_REPOSITORY_LANGUAGES =~ /python/ + +bundler-audit-dependency_scanning: + extends: .analyzer + image: + name: "$DS_ANALYZER_IMAGE_PREFIX/bundler-audit:$DS_MAJOR_VERSION" + only: + variables: + - $GITLAB_FEATURES =~ /\bdependency_scanning\b/ && + $DS_DEFAULT_ANALYZERS =~ /bundler-audit/ && + $CI_PROJECT_REPOSITORY_LANGUAGES =~ /ruby/ + +retire-js-dependency_scanning: + extends: .analyzer + image: + name: "$DS_ANALYZER_IMAGE_PREFIX/retire.js:$DS_MAJOR_VERSION" + only: + variables: + - $GITLAB_FEATURES =~ /\bdependency_scanning\b/ && + $DS_DEFAULT_ANALYZERS =~ /retire.js/ && + $CI_PROJECT_REPOSITORY_LANGUAGES =~ /javascript/ diff --git a/lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml b/lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml index a0c2ab3aa269858d11607216d3e2b9fc40732da4..c81b4efddbc2019d94f0cc22df5b89ae4f6e01e9 100644 --- a/lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml @@ -7,7 +7,7 @@ variables: SAST_ANALYZER_IMAGE_PREFIX: "registry.gitlab.com/gitlab-org/security-products/analyzers" SAST_DEFAULT_ANALYZERS: "bandit, brakeman, gosec, spotbugs, flawfinder, phpcs-security-audit, security-code-scan, nodejs-scan, eslint, tslint, secrets, sobelow, pmd-apex" - SAST_MAJOR_VERSION: 2 + SAST_ANALYZER_IMAGE_TAG: 2 SAST_DISABLE_DIND: "false" sast: @@ -35,45 +35,12 @@ sast: export DOCKER_HOST='tcp://localhost:2375' fi fi - - | # this is required to avoid undesirable reset of Docker image ENV variables being set on build stage - function propagate_env_vars() { - CURRENT_ENV=$(printenv) - - for VAR_NAME; do - echo $CURRENT_ENV | grep "${VAR_NAME}=" > /dev/null && echo "--env $VAR_NAME " - done - } + - | + printenv | grep -E '^(DOCKER_|CI|GITLAB_|FF_|HOME|PWD|OLDPWD|PATH|SHLVL|HOSTNAME)' | cut -d'=' -f1 | \ + (while IFS='\\n' read -r VAR; do unset -v "$VAR"; done; /bin/printenv > .env) - | docker run \ - $(propagate_env_vars \ - SAST_BANDIT_EXCLUDED_PATHS \ - SAST_ANALYZER_IMAGES \ - SAST_ANALYZER_IMAGE_PREFIX \ - SAST_ANALYZER_IMAGE_TAG \ - SAST_DEFAULT_ANALYZERS \ - SAST_PULL_ANALYZER_IMAGES \ - SAST_BRAKEMAN_LEVEL \ - SAST_FLAWFINDER_LEVEL \ - SAST_GITLEAKS_ENTROPY_LEVEL \ - SAST_GOSEC_LEVEL \ - SAST_EXCLUDED_PATHS \ - SAST_DOCKER_CLIENT_NEGOTIATION_TIMEOUT \ - SAST_PULL_ANALYZER_IMAGE_TIMEOUT \ - SAST_RUN_ANALYZER_TIMEOUT \ - SAST_JAVA_VERSION \ - ANT_HOME \ - ANT_PATH \ - GRADLE_PATH \ - JAVA_OPTS \ - JAVA_PATH \ - JAVA_8_VERSION \ - JAVA_11_VERSION \ - MAVEN_CLI_OPTS \ - MAVEN_PATH \ - MAVEN_REPO_PATH \ - SBT_PATH \ - FAIL_NEVER \ - ) \ + --env-file .env \ --volume "$PWD:/code" \ --volume /var/run/docker.sock:/var/run/docker.sock \ "registry.gitlab.com/gitlab-org/security-products/sast:$SAST_VERSION" /app/bin/run /code @@ -94,7 +61,7 @@ sast: bandit-sast: extends: .analyzer image: - name: "$SAST_ANALYZER_IMAGE_PREFIX/bandit:$SAST_MAJOR_VERSION" + name: "$SAST_ANALYZER_IMAGE_PREFIX/bandit:$SAST_ANALYZER_IMAGE_TAG" only: variables: - $GITLAB_FEATURES =~ /\bsast\b/ && @@ -104,7 +71,7 @@ bandit-sast: brakeman-sast: extends: .analyzer image: - name: "$SAST_ANALYZER_IMAGE_PREFIX/brakeman:$SAST_MAJOR_VERSION" + name: "$SAST_ANALYZER_IMAGE_PREFIX/brakeman:$SAST_ANALYZER_IMAGE_TAG" only: variables: - $GITLAB_FEATURES =~ /\bsast\b/ && @@ -114,7 +81,7 @@ brakeman-sast: eslint-sast: extends: .analyzer image: - name: "$SAST_ANALYZER_IMAGE_PREFIX/eslint:$SAST_MAJOR_VERSION" + name: "$SAST_ANALYZER_IMAGE_PREFIX/eslint:$SAST_ANALYZER_IMAGE_TAG" only: variables: - $GITLAB_FEATURES =~ /\bsast\b/ && @@ -124,7 +91,7 @@ eslint-sast: flawfinder-sast: extends: .analyzer image: - name: "$SAST_ANALYZER_IMAGE_PREFIX/flawfinder:$SAST_MAJOR_VERSION" + name: "$SAST_ANALYZER_IMAGE_PREFIX/flawfinder:$SAST_ANALYZER_IMAGE_TAG" only: variables: - $GITLAB_FEATURES =~ /\bsast\b/ && @@ -134,7 +101,7 @@ flawfinder-sast: gosec-sast: extends: .analyzer image: - name: "$SAST_ANALYZER_IMAGE_PREFIX/gosec:$SAST_MAJOR_VERSION" + name: "$SAST_ANALYZER_IMAGE_PREFIX/gosec:$SAST_ANALYZER_IMAGE_TAG" only: variables: - $GITLAB_FEATURES =~ /\bsast\b/ && @@ -144,7 +111,7 @@ gosec-sast: nodejs-scan-sast: extends: .analyzer image: - name: "$SAST_ANALYZER_IMAGE_PREFIX/nodejs-scan:$SAST_MAJOR_VERSION" + name: "$SAST_ANALYZER_IMAGE_PREFIX/nodejs-scan:$SAST_ANALYZER_IMAGE_TAG" only: variables: - $GITLAB_FEATURES =~ /\bsast\b/ && @@ -154,7 +121,7 @@ nodejs-scan-sast: phpcs-security-audit-sast: extends: .analyzer image: - name: "$SAST_ANALYZER_IMAGE_PREFIX/phpcs-security-audit:$SAST_MAJOR_VERSION" + name: "$SAST_ANALYZER_IMAGE_PREFIX/phpcs-security-audit:$SAST_ANALYZER_IMAGE_TAG" only: variables: - $GITLAB_FEATURES =~ /\bsast\b/ && @@ -164,7 +131,7 @@ phpcs-security-audit-sast: pmd-apex-sast: extends: .analyzer image: - name: "$SAST_ANALYZER_IMAGE_PREFIX/pmd-apex:$SAST_MAJOR_VERSION" + name: "$SAST_ANALYZER_IMAGE_PREFIX/pmd-apex:$SAST_ANALYZER_IMAGE_TAG" only: variables: - $GITLAB_FEATURES =~ /\bsast\b/ && @@ -174,7 +141,7 @@ pmd-apex-sast: secrets-sast: extends: .analyzer image: - name: "$SAST_ANALYZER_IMAGE_PREFIX/secrets:$SAST_MAJOR_VERSION" + name: "$SAST_ANALYZER_IMAGE_PREFIX/secrets:$SAST_ANALYZER_IMAGE_TAG" only: variables: - $GITLAB_FEATURES =~ /\bsast\b/ && @@ -183,7 +150,7 @@ secrets-sast: security-code-scan-sast: extends: .analyzer image: - name: "$SAST_ANALYZER_IMAGE_PREFIX/security-code-scan:$SAST_MAJOR_VERSION" + name: "$SAST_ANALYZER_IMAGE_PREFIX/security-code-scan:$SAST_ANALYZER_IMAGE_TAG" only: variables: - $GITLAB_FEATURES =~ /\bsast\b/ && @@ -193,7 +160,7 @@ security-code-scan-sast: sobelow-sast: extends: .analyzer image: - name: "$SAST_ANALYZER_IMAGE_PREFIX/sobelow:$SAST_MAJOR_VERSION" + name: "$SAST_ANALYZER_IMAGE_PREFIX/sobelow:$SAST_ANALYZER_IMAGE_TAG" only: variables: - $GITLAB_FEATURES =~ /\bsast\b/ && @@ -203,7 +170,7 @@ sobelow-sast: spotbugs-sast: extends: .analyzer image: - name: "$SAST_ANALYZER_IMAGE_PREFIX/spotbugs:$SAST_MAJOR_VERSION" + name: "$SAST_ANALYZER_IMAGE_PREFIX/spotbugs:$SAST_ANALYZER_IMAGE_TAG" only: variables: - $GITLAB_FEATURES =~ /\bsast\b/ && @@ -213,7 +180,7 @@ spotbugs-sast: tslint-sast: extends: .analyzer image: - name: "$SAST_ANALYZER_IMAGE_PREFIX/tslint:$SAST_MAJOR_VERSION" + name: "$SAST_ANALYZER_IMAGE_PREFIX/tslint:$SAST_ANALYZER_IMAGE_TAG" only: variables: - $GITLAB_FEATURES =~ /\bsast\b/ && diff --git a/lib/gitlab/ci/yaml_processor.rb b/lib/gitlab/ci/yaml_processor.rb index f6a3abefcfb350d9ff69560a579b3c9473612a8b..833c545fc5b1313bf1b6ff895ee36458a49b54f7 100644 --- a/lib/gitlab/ci/yaml_processor.rb +++ b/lib/gitlab/ci/yaml_processor.rb @@ -39,15 +39,15 @@ module Gitlab when: job[:when] || 'on_success', environment: job[:environment_name], coverage_regex: job[:coverage], - yaml_variables: yaml_variables(name), - needs_attributes: job[:needs]&.map { |need| { name: need } }, + yaml_variables: transform_to_yaml_variables(job_variables(name)), + needs_attributes: job.dig(:needs, :job), interruptible: job[:interruptible], rules: job[:rules], + cache: job[:cache], options: { image: job[:image], services: job[:services], artifacts: job[:artifacts], - cache: job[:cache], dependencies: job[:dependencies], job_timeout: job[:timeout], before_script: job[:before_script], @@ -59,7 +59,7 @@ module Gitlab instance: job[:instance], start_in: job[:start_in], trigger: job[:trigger], - bridge_needs: job[:needs] + bridge_needs: job.dig(:needs, :bridge)&.first }.compact }.compact end @@ -83,6 +83,13 @@ module Gitlab end end + def workflow_attributes + { + rules: @config.dig(:workflow, :rules), + yaml_variables: transform_to_yaml_variables(@variables) + } + end + def self.validation_message(content, opts = {}) return 'Please provide content of .gitlab-ci.yml' if content.blank? @@ -118,20 +125,17 @@ module Gitlab end end - def yaml_variables(name) - variables = (@variables || {}) - .merge(job_variables(name)) + def job_variables(name) + job_variables = @jobs.dig(name.to_sym, :variables) - variables.map do |key, value| - { key: key.to_s, value: value, public: true } - end + @variables.to_h + .merge(job_variables.to_h) end - def job_variables(name) - job = @jobs[name.to_sym] - return {} unless job - - job[:variables] || {} + def transform_to_yaml_variables(variables) + variables.to_h.map do |key, value| + { key: key.to_s, value: value, public: true } + end end def validate_job_stage!(name, job) @@ -159,17 +163,19 @@ module Gitlab end def validate_job_needs!(name, job) - return unless job[:needs] + return unless job.dig(:needs, :job) stage_index = @stages.index(job[:stage]) - job[:needs].each do |need| - raise ValidationError, "#{name} job: undefined need: #{need}" unless @jobs[need.to_sym] + job.dig(:needs, :job).each do |need| + need_job_name = need[:name] + + raise ValidationError, "#{name} job: undefined need: #{need_job_name}" unless @jobs[need_job_name.to_sym] - needs_stage_index = @stages.index(@jobs[need.to_sym][:stage]) + needs_stage_index = @stages.index(@jobs[need_job_name.to_sym][:stage]) unless needs_stage_index.present? && needs_stage_index < stage_index - raise ValidationError, "#{name} job: need #{need} is not defined in prior stages" + raise ValidationError, "#{name} job: need #{need_job_name} is not defined in prior stages" end end end diff --git a/lib/gitlab/cleanup/orphan_job_artifact_files.rb b/lib/gitlab/cleanup/orphan_job_artifact_files.rb index 1b01ca25559efe79f52e81d8422cead3aca9c86c..020de45e5bfd5106f630d7b5573d49839c226194 100644 --- a/lib/gitlab/cleanup/orphan_job_artifact_files.rb +++ b/lib/gitlab/cleanup/orphan_job_artifact_files.rb @@ -8,7 +8,8 @@ module Gitlab ABSOLUTE_ARTIFACT_DIR = ::JobArtifactUploader.root.freeze LOST_AND_FOUND = File.join(ABSOLUTE_ARTIFACT_DIR, '-', 'lost+found').freeze BATCH_SIZE = 500 - DEFAULT_NICENESS = 'Best-effort' + DEFAULT_NICENESS = 'best-effort' + VALID_NICENESS_LEVELS = %w{none realtime best-effort idle}.freeze attr_accessor :batch, :total_found, :total_cleaned attr_reader :limit, :dry_run, :niceness, :logger @@ -16,7 +17,7 @@ module Gitlab def initialize(limit: nil, dry_run: true, niceness: nil, logger: nil) @limit = limit @dry_run = dry_run - @niceness = niceness || DEFAULT_NICENESS + @niceness = (niceness || DEFAULT_NICENESS).downcase @logger = logger || Rails.logger # rubocop:disable Gitlab/RailsLogger @total_found = @total_cleaned = 0 @@ -35,7 +36,7 @@ module Gitlab clean_batch! - log_info("Processed #{total_found} job artifacts to find and clean #{total_cleaned} orphans.") + log_info("Processed #{total_found} job artifact(s) to find and cleaned #{total_cleaned} orphan(s).") end private @@ -75,7 +76,7 @@ module Gitlab def find_artifacts Open3.popen3(*find_command) do |stdin, stdout, stderr, status_thread| stdout.each_line do |line| - yield line + yield line.chomp end log_error(stderr.read.color(:red)) unless status_thread.value.success? @@ -99,7 +100,7 @@ module Gitlab cmd += %w[-type d] if ionice - raise ArgumentError, 'Invalid niceness' unless niceness.match?(/^\w[\w\-]*$/) + raise ArgumentError, 'Invalid niceness' unless VALID_NICENESS_LEVELS.include?(niceness) cmd.unshift(*%W[#{ionice} --class #{niceness}]) end diff --git a/lib/gitlab/cluster/lifecycle_events.rb b/lib/gitlab/cluster/lifecycle_events.rb index 294ffad02ce4d04f1a998be9da09ded2c7593b8b..2b3dc94fc5e0a0a0a19d0ec492eccb6a18ecad41 100644 --- a/lib/gitlab/cluster/lifecycle_events.rb +++ b/lib/gitlab/cluster/lifecycle_events.rb @@ -10,38 +10,39 @@ module Gitlab # # We have the following lifecycle events. # - # - on_master_start: + # - on_before_fork (on master process): # # Unicorn/Puma Cluster: This will be called exactly once, # on startup, before the workers are forked. This is # called in the PARENT/MASTER process. # - # Sidekiq/Puma Single: This is called immediately. + # Sidekiq/Puma Single: This is not called. # - # - on_before_fork: + # - on_master_start (on master process): # # Unicorn/Puma Cluster: This will be called exactly once, # on startup, before the workers are forked. This is # called in the PARENT/MASTER process. # - # Sidekiq/Puma Single: This is not called. + # Sidekiq/Puma Single: This is called immediately. # - # - on_worker_start: + # - on_before_blackout_period (on master process): # - # Unicorn/Puma Cluster: This is called in the worker process - # exactly once before processing requests. + # Unicorn/Puma Cluster: This will be called before a blackout + # period when performing graceful shutdown of master. + # This is called on `master` process. # - # Sidekiq/Puma Single: This is called immediately. + # Sidekiq/Puma Single: This is not called. # - # - on_before_phased_restart: + # - on_before_graceful_shutdown (on master process): # # Unicorn/Puma Cluster: This will be called before a graceful - # shutdown of workers starts happening. + # shutdown of workers starts happening, but after blackout period. # This is called on `master` process. # # Sidekiq/Puma Single: This is not called. # - # - on_before_master_restart: + # - on_before_master_restart (on master process): # # Unicorn: This will be called before a new master is spun up. # This is called on forked master before `execve` to become @@ -53,6 +54,13 @@ module Gitlab # # Sidekiq/Puma Single: This is not called. # + # - on_worker_start (on worker process): + # + # Unicorn/Puma Cluster: This is called in the worker process + # exactly once before processing requests. + # + # Sidekiq/Puma Single: This is called immediately. + # # Blocks will be executed in the order in which they are registered. # class LifecycleEvents @@ -75,9 +83,15 @@ module Gitlab end # Read the config/initializers/cluster_events_before_phased_restart.rb - def on_before_phased_restart(&block) + def on_before_blackout_period(&block) # Defer block execution - (@master_phased_restart ||= []) << block + (@master_blackout_period ||= []) << block + end + + # Read the config/initializers/cluster_events_before_phased_restart.rb + def on_before_graceful_shutdown(&block) + # Defer block execution + (@master_graceful_shutdown ||= []) << block end def on_before_master_restart(&block) @@ -97,27 +111,24 @@ module Gitlab # Lifecycle integration methods (called from unicorn.rb, puma.rb, etc.) # def do_worker_start - @worker_start_hooks&.each do |block| - block.call - end + call(@worker_start_hooks) end def do_before_fork - @before_fork_hooks&.each do |block| - block.call - end + call(@before_fork_hooks) end - def do_before_phased_restart - @master_phased_restart&.each do |block| - block.call - end + def do_before_graceful_shutdown + call(@master_blackout_period) + + blackout_seconds = ::Settings.shutdown.blackout_seconds.to_i + sleep(blackout_seconds) if blackout_seconds > 0 + + call(@master_graceful_shutdown) end def do_before_master_restart - @master_restart_hooks&.each do |block| - block.call - end + call(@master_restart_hooks) end # DEPRECATED @@ -132,6 +143,10 @@ module Gitlab private + def call(hooks) + hooks&.each(&:call) + end + def in_clustered_environment? # Sidekiq doesn't fork return false if Sidekiq.server? diff --git a/lib/gitlab/cluster/mixins/puma_cluster.rb b/lib/gitlab/cluster/mixins/puma_cluster.rb index e9157d9f1e47db5459e623bd6aa0f9cf8e44a334..106c2731c07031f015370b1dcd852c870c9d6e7b 100644 --- a/lib/gitlab/cluster/mixins/puma_cluster.rb +++ b/lib/gitlab/cluster/mixins/puma_cluster.rb @@ -8,8 +8,12 @@ module Gitlab raise 'missing method Puma::Cluster#stop_workers' unless base.method_defined?(:stop_workers) end + # This looks at internal status of `Puma::Cluster` + # https://github.com/puma/puma/blob/v3.12.1/lib/puma/cluster.rb#L333 def stop_workers - Gitlab::Cluster::LifecycleEvents.do_before_phased_restart + if @status == :stop # rubocop:disable Gitlab/ModuleWithInstanceVariables + Gitlab::Cluster::LifecycleEvents.do_before_graceful_shutdown + end super end diff --git a/lib/gitlab/cluster/mixins/unicorn_http_server.rb b/lib/gitlab/cluster/mixins/unicorn_http_server.rb index 765fd0c2baa1a4f459a55c4d0ead98d7473b3f15..440ed02a3557f5986fefab5f9ddfd0dbded59563 100644 --- a/lib/gitlab/cluster/mixins/unicorn_http_server.rb +++ b/lib/gitlab/cluster/mixins/unicorn_http_server.rb @@ -5,11 +5,26 @@ module Gitlab module Mixins module UnicornHttpServer def self.prepended(base) - raise 'missing method Unicorn::HttpServer#reexec' unless base.method_defined?(:reexec) + unless base.method_defined?(:reexec) && base.method_defined?(:stop) + raise 'missing method Unicorn::HttpServer#reexec or Unicorn::HttpServer#stop' + end end def reexec - Gitlab::Cluster::LifecycleEvents.do_before_phased_restart + Gitlab::Cluster::LifecycleEvents.do_before_graceful_shutdown + + super + end + + # The stop on non-graceful shutdown is executed twice: + # `#stop(false)` and `#stop`. + # + # The first stop will wipe-out all workers, so we need to check + # the flag and a list of workers + def stop(graceful = true) + if graceful && @workers.any? # rubocop:disable Gitlab/ModuleWithInstanceVariables + Gitlab::Cluster::LifecycleEvents.do_before_graceful_shutdown + end super end diff --git a/lib/gitlab/cluster/puma_worker_killer_initializer.rb b/lib/gitlab/cluster/puma_worker_killer_initializer.rb index a8440b63baae7a044c11026d0f873b951ffd2788..92c799875b56e0277123e2be91a7f5cc97fb429d 100644 --- a/lib/gitlab/cluster/puma_worker_killer_initializer.rb +++ b/lib/gitlab/cluster/puma_worker_killer_initializer.rb @@ -3,7 +3,12 @@ module Gitlab module Cluster class PumaWorkerKillerInitializer - def self.start(puma_options, puma_per_worker_max_memory_mb: 850, puma_master_max_memory_mb: 550) + def self.start( + puma_options, + puma_per_worker_max_memory_mb: 850, + puma_master_max_memory_mb: 550, + additional_puma_dev_max_memory_mb: 200 + ) require 'puma_worker_killer' PumaWorkerKiller.config do |config| @@ -14,7 +19,11 @@ module Gitlab # The Puma Worker Killer checks the total RAM used by both the master # and worker processes. # https://github.com/schneems/puma_worker_killer/blob/v0.1.0/lib/puma_worker_killer/puma_memory.rb#L57 - config.ram = puma_master_max_memory_mb + (worker_count * puma_per_worker_max_memory_mb) + # + # Additional memory is added when running in `development` + config.ram = puma_master_max_memory_mb + + (worker_count * puma_per_worker_max_memory_mb) + + (Rails.env.development? ? (1 + worker_count) * additional_puma_dev_max_memory_mb : 0) config.frequency = 20 # seconds diff --git a/lib/gitlab/config/entry/configurable.rb b/lib/gitlab/config/entry/configurable.rb index b7ec4b7c4f8203f0fdde0213c7f1057d880de458..bda84dc2cff1f8226e85b7f871f8729610bb6334 100644 --- a/lib/gitlab/config/entry/configurable.rb +++ b/lib/gitlab/config/entry/configurable.rb @@ -29,22 +29,24 @@ module Gitlab def compose!(deps = nil) return unless valid? - self.class.nodes.each do |key, factory| - # If we override the config type validation - # we can end with different config types like String - next unless config.is_a?(Hash) + super do + self.class.nodes.each do |key, factory| + # If we override the config type validation + # we can end with different config types like String + next unless config.is_a?(Hash) - factory - .value(config[key]) - .with(key: key, parent: self) + factory + .value(config[key]) + .with(key: key, parent: self) - entries[key] = factory.create! - end + entries[key] = factory.create! + end - yield if block_given? + yield if block_given? - entries.each_value do |entry| - entry.compose!(deps) + entries.each_value do |entry| + entry.compose!(deps) + end end end # rubocop: enable CodeReuse/ActiveRecord @@ -67,12 +69,13 @@ module Gitlab private # rubocop: disable CodeReuse/ActiveRecord - def entry(key, entry, description: nil, default: nil, inherit: nil, reserved: nil) + def entry(key, entry, description: nil, default: nil, inherit: nil, reserved: nil, metadata: {}) factory = ::Gitlab::Config::Entry::Factory.new(entry) .with(description: description) .with(default: default) .with(inherit: inherit) .with(reserved: reserved) + .metadata(metadata) (@nodes ||= {}).merge!(key.to_sym => factory) end diff --git a/lib/gitlab/config/entry/factory.rb b/lib/gitlab/config/entry/factory.rb index 8f1f4a81bb515b3531430e21abe2e8abe04eaf09..7c5ffaa762115d169f5fe1991a8e382771563705 100644 --- a/lib/gitlab/config/entry/factory.rb +++ b/lib/gitlab/config/entry/factory.rb @@ -9,10 +9,12 @@ module Gitlab class Factory InvalidFactory = Class.new(StandardError) - def initialize(entry) - @entry = entry + attr_reader :entry_class + + def initialize(entry_class) + @entry_class = entry_class @metadata = {} - @attributes = { default: entry.default } + @attributes = { default: entry_class.default } end def value(value) @@ -34,6 +36,10 @@ module Gitlab @attributes[:description] end + def inherit + @attributes[:inherit] + end + def inheritable? @attributes[:inherit] end @@ -52,7 +58,7 @@ module Gitlab if @value.nil? Entry::Unspecified.new(fabricate_unspecified) else - fabricate(@entry, @value) + fabricate(entry_class, @value) end end @@ -68,12 +74,12 @@ module Gitlab if default.nil? fabricate(Entry::Undefined) else - fabricate(@entry, default) + fabricate(entry_class, default) end end - def fabricate(entry, value = nil) - entry.new(value, @metadata) do |node| + def fabricate(entry_class, value = nil) + entry_class.new(value, @metadata) do |node| node.key = @attributes[:key] node.parent = @attributes[:parent] node.default = @attributes[:default] diff --git a/lib/gitlab/config/entry/inheritable.rb b/lib/gitlab/config/entry/inheritable.rb new file mode 100644 index 0000000000000000000000000000000000000000..91ca82e633884ab96a691176157b8abf37218fe1 --- /dev/null +++ b/lib/gitlab/config/entry/inheritable.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Gitlab + module Config + module Entry + ## + # Entry that represents an inheritable configs. + # + module Inheritable + InheritError = Class.new(Gitlab::Config::Loader::FormatError) + + def compose!(deps = nil, &blk) + super(deps, &blk) + + inherit!(deps) + end + + private + + # We inherit config entries from `default:` + # if the entry has the `inherit: true` flag set + def inherit!(deps) + return unless deps + + self.class.nodes.each do |key, factory| + next unless factory.inheritable? + + new_entry = overwrite_entry(deps, key, self[key]) + + entries[key] = new_entry if new_entry&.specified? + end + end + + def overwrite_entry(deps, key, current_entry) + raise NotImplementedError + end + end + end + end +end diff --git a/lib/gitlab/config/entry/node.rb b/lib/gitlab/config/entry/node.rb index e014f15fbd8c305aca0017d21906dab20600fc57..84d3409ed91dcb0678a578599d351753d145b66e 100644 --- a/lib/gitlab/config/entry/node.rb +++ b/lib/gitlab/config/entry/node.rb @@ -112,6 +112,10 @@ module Gitlab @aspects ||= [] end + def self.with_aspect(blk) + self.aspects.append(blk) + end + private attr_reader :entries diff --git a/lib/gitlab/config/entry/simplifiable.rb b/lib/gitlab/config/entry/simplifiable.rb index d58aba07d15f82a020b39b7ff34944c0259ea03b..315f1947e2c9eef1069928e2fe5cdade8634bb73 100644 --- a/lib/gitlab/config/entry/simplifiable.rb +++ b/lib/gitlab/config/entry/simplifiable.rb @@ -4,11 +4,11 @@ module Gitlab module Config module Entry class Simplifiable < SimpleDelegator - EntryStrategy = Struct.new(:name, :condition) + EntryStrategy = Struct.new(:name, :klass, :condition) attr_reader :subject - def initialize(config, **metadata) + def initialize(config, **metadata, &blk) unless self.class.const_defined?(:UnknownStrategy) raise ArgumentError, 'UndefinedStrategy not available!' end @@ -19,14 +19,13 @@ module Gitlab entry = self.class.entry_class(strategy) - @subject = entry.new(config, metadata) + @subject = entry.new(config, metadata, &blk) - yield(@subject) if block_given? super(@subject) end def self.strategy(name, **opts) - EntryStrategy.new(name, opts.fetch(:if)).tap do |strategy| + EntryStrategy.new(name, opts.dig(:class), opts.fetch(:if)).tap do |strategy| strategies.append(strategy) end end @@ -37,7 +36,7 @@ module Gitlab def self.entry_class(strategy) if strategy.present? - self.const_get(strategy.name, false) + strategy.klass || self.const_get(strategy.name, false) else self::UnknownStrategy end diff --git a/lib/gitlab/config/entry/validatable.rb b/lib/gitlab/config/entry/validatable.rb index 1c88c68c11cd71904eaec929e0d4f4b546d9b457..45b852dc2e0f29a55f0c09230e8154a69f916e23 100644 --- a/lib/gitlab/config/entry/validatable.rb +++ b/lib/gitlab/config/entry/validatable.rb @@ -7,14 +7,27 @@ module Gitlab extend ActiveSupport::Concern def self.included(node) - node.aspects.append -> do - @validator = self.class.validator.new(self) - @validator.validate(:new) + node.with_aspect -> do + validate(:new) end end + def validator + @validator ||= self.class.validator.new(self) + end + + def validate(context = nil) + validator.validate(context) + end + + def compose!(deps = nil, &blk) + super(deps, &blk) + + validate(:composed) + end + def errors - @validator.messages + descendants.flat_map(&:errors) # rubocop:disable Gitlab/ModuleWithInstanceVariables + validator.messages + descendants.flat_map(&:errors) end class_methods do diff --git a/lib/gitlab/config/entry/validators.rb b/lib/gitlab/config/entry/validators.rb index 374f929878e2fddd2929985d17b91c8da5368200..d1c23c41d35dc7c10a3e7b85ffb4a779db63b41c 100644 --- a/lib/gitlab/config/entry/validators.rb +++ b/lib/gitlab/config/entry/validators.rb @@ -61,8 +61,15 @@ module Gitlab include LegacyValidationHelpers def validate_each(record, attribute, value) - unless validate_array_of_strings(value) - record.errors.add(attribute, 'should be an array of strings') + valid = validate_array_of_strings(value) + + record.errors.add(attribute, 'should be an array of strings') unless valid + + if valid && options[:with] + unless value.all? { |v| v =~ options[:with] } + message = options[:message] || 'contains elements that do not match the format' + record.errors.add(attribute, message) + end end end end @@ -221,6 +228,34 @@ module Gitlab end end + class NestedArrayOfStringsValidator < ArrayOfStringsOrStringValidator + def validate_each(record, attribute, value) + unless validate_nested_array_of_strings(value) + record.errors.add(attribute, 'should be an array containing strings and arrays of strings') + end + end + + private + + def validate_nested_array_of_strings(values) + values.is_a?(Array) && values.all? { |element| validate_array_of_strings_or_string(element) } + end + end + + class StringOrNestedArrayOfStringsValidator < NestedArrayOfStringsValidator + def validate_each(record, attribute, value) + unless validate_string_or_nested_array_of_strings(value) + record.errors.add(attribute, 'should be a string or an array containing strings and arrays of strings') + end + end + + private + + def validate_string_or_nested_array_of_strings(values) + validate_string(values) || validate_nested_array_of_strings(values) + end + end + class TypeValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) type = options[:with] diff --git a/lib/gitlab/cycle_analytics/group_stage_summary.rb b/lib/gitlab/cycle_analytics/group_stage_summary.rb index a1fc941495dedc3defe4446fb7fec5be32febfbe..26eaaf7df8363300ccd4d32193ea99d1b2497be0 100644 --- a/lib/gitlab/cycle_analytics/group_stage_summary.rb +++ b/lib/gitlab/cycle_analytics/group_stage_summary.rb @@ -3,18 +3,17 @@ module Gitlab module CycleAnalytics class GroupStageSummary - attr_reader :group, :from, :current_user, :options + attr_reader :group, :current_user, :options def initialize(group, options:) @group = group - @from = options[:from] @current_user = options[:current_user] @options = options end def data - [serialize(Summary::Group::Issue.new(group: group, from: from, current_user: current_user, options: options)), - serialize(Summary::Group::Deploy.new(group: group, from: from, options: options))] + [serialize(Summary::Group::Issue.new(group: group, current_user: current_user, options: options)), + serialize(Summary::Group::Deploy.new(group: group, options: options))] end private diff --git a/lib/gitlab/cycle_analytics/summary/group/base.rb b/lib/gitlab/cycle_analytics/summary/group/base.rb index 48d8164bde1372758b3e0cc7f09aa13069f15b9b..f1d20d5aefa5f88dd6104740230cacefeceb72c8 100644 --- a/lib/gitlab/cycle_analytics/summary/group/base.rb +++ b/lib/gitlab/cycle_analytics/summary/group/base.rb @@ -5,11 +5,10 @@ module Gitlab module Summary module Group class Base - attr_reader :group, :from, :options + attr_reader :group, :options - def initialize(group:, from:, options:) + def initialize(group:, options:) @group = group - @from = from @options = options end diff --git a/lib/gitlab/cycle_analytics/summary/group/deploy.rb b/lib/gitlab/cycle_analytics/summary/group/deploy.rb index 78d677cf5585e03be3732a0010ddfa542a2a5f72..11a9152cf0c2a24bd2fb902b38d9ce5263eb4e00 100644 --- a/lib/gitlab/cycle_analytics/summary/group/deploy.rb +++ b/lib/gitlab/cycle_analytics/summary/group/deploy.rb @@ -20,7 +20,8 @@ module Gitlab def find_deployments deployments = Deployment.joins(:project).merge(Project.inside_path(group.full_path)) deployments = deployments.where(projects: { id: options[:projects] }) if options[:projects] - deployments = deployments.where("deployments.created_at > ?", from) + deployments = deployments.where("deployments.created_at > ?", options[:from]) + deployments = deployments.where("deployments.created_at < ?", options[:to]) if options[:to] deployments.success.count end end diff --git a/lib/gitlab/cycle_analytics/summary/group/issue.rb b/lib/gitlab/cycle_analytics/summary/group/issue.rb index 9daae8531d89ea1607e50192358de60fb6c96d1b..4d5ee1d43cab996c9f92a391c4869b0ed74672ba 100644 --- a/lib/gitlab/cycle_analytics/summary/group/issue.rb +++ b/lib/gitlab/cycle_analytics/summary/group/issue.rb @@ -5,11 +5,10 @@ module Gitlab module Summary module Group class Issue < Group::Base - attr_reader :group, :from, :current_user, :options + attr_reader :group, :current_user, :options - def initialize(group:, from:, current_user:, options:) + def initialize(group:, current_user:, options:) @group = group - @from = from @current_user = current_user @options = options end @@ -25,10 +24,19 @@ module Gitlab private def find_issues - issues = IssuesFinder.new(current_user, group_id: group.id, include_subgroups: true, created_after: from).execute + issues = IssuesFinder.new(current_user, finder_params).execute issues = issues.where(projects: { id: options[:projects] }) if options[:projects] issues.count end + + def finder_params + { + group_id: group.id, + include_subgroups: true, + created_after: options[:from], + created_before: options[:to] + }.compact + end end end end diff --git a/lib/gitlab/daemon.rb b/lib/gitlab/daemon.rb index 8a2538938926e222f51884341ed6799c13fa039a..ddb9d907640502fd202fffdf674f3dda4adfdb21 100644 --- a/lib/gitlab/daemon.rb +++ b/lib/gitlab/daemon.rb @@ -28,6 +28,10 @@ module Gitlab true end + def thread_name + self.class.name.demodulize.underscore + end + def start return unless enabled? @@ -35,7 +39,10 @@ module Gitlab break thread if thread? if start_working - @thread = Thread.new { run_thread } + @thread = Thread.new do + Thread.current.name = thread_name + run_thread + end end end end diff --git a/lib/gitlab/danger/helper.rb b/lib/gitlab/danger/helper.rb index f22fc41a6d84273719a2e5feda18807079274cfc..0e7e0c40a8a74a74f9b82c04fd15c4c8ffe5dfdb 100644 --- a/lib/gitlab/danger/helper.rb +++ b/lib/gitlab/danger/helper.rb @@ -93,8 +93,8 @@ module Gitlab docs: "~documentation", # Docs are reviewed along DevOps stages, so don't need roulette for now. none: "", qa: "~QA", - test: "~test for `spec/features/*`", - engineering_productivity: "Engineering Productivity for CI config review" + test: "~test ~Quality for `spec/features/*`", + engineering_productivity: '~"Engineering Productivity" for CI, Danger' }.freeze CATEGORIES = { %r{\Adoc/} => :none, # To reinstate roulette for documentation, set to `:docs`. @@ -104,7 +104,7 @@ module Gitlab %r{\A(ee/)?public/} => :frontend, %r{\A(ee/)?spec/(javascripts|frontend)/} => :frontend, %r{\A(ee/)?vendor/assets/} => :frontend, - %r{\Ascripts/frontend/} => :frontend, + %r{\A(ee/)?scripts/frontend/} => :frontend, %r{(\A|/)( \.babelrc | \.eslintignore | @@ -130,14 +130,18 @@ module Gitlab %r{\A(app/models/project_authorization|app/services/users/refresh_authorized_projects_service)(/|\.rb)} => :database, %r{\Arubocop/cop/migration(/|\.rb)} => :database, + %r{\A(\.gitlab-ci\.yml\z|\.gitlab\/ci)} => :engineering_productivity, + %r{Dangerfile\z} => :engineering_productivity, + %r{\A(ee/)?(danger/|lib/gitlab/danger/)} => :engineering_productivity, + %r{\A(ee/)?scripts/} => :engineering_productivity, + %r{\A(ee/)?app/(?!assets|views)[^/]+} => :backend, - %r{\A(ee/)?(bin|config|danger|generator_templates|lib|rubocop|scripts)/} => :backend, + %r{\A(ee/)?(bin|config|generator_templates|lib|rubocop)/} => :backend, %r{\A(ee/)?spec/features/} => :test, %r{\A(ee/)?spec/(?!javascripts|frontend)[^/]+} => :backend, %r{\A(ee/)?vendor/(?!assets)[^/]+} => :backend, %r{\A(ee/)?vendor/(languages\.yml|licenses\.csv)\z} => :backend, - %r{\A(\.gitlab-ci\.yml\z|\.gitlab\/ci)} => :engineering_productivity, - %r{\A(Dangerfile|Gemfile|Gemfile.lock|Procfile|Rakefile)\z} => :backend, + %r{\A(Gemfile|Gemfile.lock|Procfile|Rakefile)\z} => :backend, %r{\A[A-Z_]+_VERSION\z} => :backend, %r{\A\.rubocop(_todo)?\.yml\z} => :backend, diff --git a/lib/gitlab/danger/teammate.rb b/lib/gitlab/danger/teammate.rb index 5c2324836d76ee8231783caf80da2b79da60553e..e96f5177195f6923a3939414520f0f7115743a1f 100644 --- a/lib/gitlab/danger/teammate.rb +++ b/lib/gitlab/danger/teammate.rb @@ -67,7 +67,10 @@ module Gitlab area && labels.any?("devops::#{area.downcase}") if kind == :reviewer when :engineering_productivity - role[/Engineering Productivity/] if kind == :reviewer + return false unless role[/Engineering Productivity/] + return true if kind == :reviewer + + capabilities(project).include?("#{kind} backend") else capabilities(project).include?("#{kind} #{category}") end diff --git a/lib/gitlab/data_builder/deployment.rb b/lib/gitlab/data_builder/deployment.rb index f11e032ab843aa888115cb0359448647abccf007..70587b3132adc636115c576c8efb85707a3bc2bc 100644 --- a/lib/gitlab/data_builder/deployment.rb +++ b/lib/gitlab/data_builder/deployment.rb @@ -6,11 +6,17 @@ module Gitlab extend self def build(deployment) + # Deployments will not have a deployable when created using the API. + deployable_url = + if deployment.deployable + Gitlab::UrlBuilder.build(deployment.deployable) + end + { object_kind: 'deployment', status: deployment.status, deployable_id: deployment.deployable_id, - deployable_url: Gitlab::UrlBuilder.build(deployment.deployable), + deployable_url: deployable_url, environment: deployment.environment.name, project: deployment.project.hook_attrs, short_sha: deployment.short_sha, diff --git a/lib/gitlab/data_builder/push.rb b/lib/gitlab/data_builder/push.rb index a83b03f540c9e4f314b090ea2cfeed38bb2c2b71..65cfd47e1e858cc766bedb4fbda23844c9ddc44c 100644 --- a/lib/gitlab/data_builder/push.rb +++ b/lib/gitlab/data_builder/push.rb @@ -19,12 +19,25 @@ module Gitlab user_email: "john@example.com", user_avatar: "https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80", project_id: 15, + project: { + id: 15, + name: "gitlab", + description: "", + web_url: "http://test.example.com/gitlab/gitlab", + avatar_url: "https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80", + git_ssh_url: "git@test.example.com:gitlab/gitlab.git", + git_http_url: "http://test.example.com/gitlab/gitlab.git", + namespace: "gitlab", + visibility_level: 0, + path_with_namespace: "gitlab/gitlab", + default_branch: "master" + }, commits: [ { id: "c5feabde2d8cd023215af4d2ceeb7a64839fc428", message: "Add simple search to projects in public area", timestamp: "2013-05-13T18:18:08+00:00", - url: "https://test.example.com/gitlab/gitlabhq/commit/c5feabde2d8cd023215af4d2ceeb7a64839fc428", + url: "https://test.example.com/gitlab/gitlab/commit/c5feabde2d8cd023215af4d2ceeb7a64839fc428", author: { name: "Test User", email: "test@example.com" @@ -45,7 +58,20 @@ module Gitlab # user_name: String, # user_username: String, # user_email: String - # project_id: String, + # project_id: Fixnum, + # project: { + # id: Fixnum, + # name: String, + # description: String, + # web_url: String, + # avatar_url: String, + # git_ssh_url: String, + # git_http_url: String, + # namespace: String, + # visibility_level: Fixnum, + # path_with_namespace: String, + # default_branch: String + # } # repository: { # name: String, # url: String, diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index ae29546cdaca7ad3a0f933158679b5777c5a2dba..7ea7565f758a04ed12d464b0654651c8bd28147c 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -108,9 +108,7 @@ module Gitlab 'in the body of your migration class' end - if supports_drop_index_concurrently? - options = options.merge({ algorithm: :concurrently }) - end + options = options.merge({ algorithm: :concurrently }) unless index_exists?(table_name, column_name, options) Rails.logger.warn "Index not removed because it does not exist (this may be due to an aborted migration or similar): table_name: #{table_name}, column_name: #{column_name}" # rubocop:disable Gitlab/RailsLogger @@ -136,9 +134,7 @@ module Gitlab 'in the body of your migration class' end - if supports_drop_index_concurrently? - options = options.merge({ algorithm: :concurrently }) - end + options = options.merge({ algorithm: :concurrently }) unless index_exists_by_name?(table_name, index_name) Rails.logger.warn "Index not removed because it does not exist (this may be due to an aborted migration or similar): table_name: #{table_name}, index_name: #{index_name}" # rubocop:disable Gitlab/RailsLogger @@ -150,13 +146,6 @@ module Gitlab end end - # Only available on Postgresql >= 9.2 - def supports_drop_index_concurrently? - version = select_one("SELECT current_setting('server_version_num') AS v")['v'].to_i - - version >= 90200 - end - # Adds a foreign key with only minimal locking on the tables involved. # # This method only requires minimal locking @@ -966,7 +955,7 @@ into similar problems in the future (e.g. when new tables are created). table_name = model_class.quoted_table_name model_class.each_batch(of: batch_size) do |relation| - start_id, end_id = relation.pluck("MIN(#{table_name}.id), MAX(#{table_name}.id)").first + start_id, end_id = relation.pluck("MIN(#{table_name}.id)", "MAX(#{table_name}.id)").first if jobs.length >= BACKGROUND_MIGRATION_JOB_BUFFER_SIZE # Note: This code path generally only helps with many millions of rows diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces.rb index 3e8a9b899983152d3d7aefd13766c6b30ea96508..cea25967801fb9789b1923b6e7ed33d40ffc8147 100644 --- a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces.rb +++ b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces.rb @@ -66,11 +66,13 @@ module Gitlab def move_repositories(namespace, old_full_path, new_full_path) repo_shards_for_namespace(namespace).each do |repository_storage| # Ensure old directory exists before moving it - gitlab_shell.add_namespace(repository_storage, old_full_path) + Gitlab::GitalyClient::NamespaceService.allow do + gitlab_shell.add_namespace(repository_storage, old_full_path) - unless gitlab_shell.mv_namespace(repository_storage, old_full_path, new_full_path) - message = "Exception moving on shard #{repository_storage} from #{old_full_path} to #{new_full_path}" - Rails.logger.error message # rubocop:disable Gitlab/RailsLogger + unless gitlab_shell.mv_namespace(repository_storage, old_full_path, new_full_path) + message = "Exception moving on shard #{repository_storage} from #{old_full_path} to #{new_full_path}" + Rails.logger.error message # rubocop:disable Gitlab/RailsLogger + 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 dfef158cc1d53754ccedb5d1254264eb9bbdb4b2..8cd9694b741047a9c06331da655e45381a872b2e 100644 --- a/lib/gitlab/database_importers/self_monitoring/project/create_service.rb +++ b/lib/gitlab/database_importers/self_monitoring/project/create_service.rb @@ -21,7 +21,6 @@ module Gitlab :create_project, :save_project_id, :add_group_members, - :add_to_whitelist, :add_prometheus_manual_configuration def initialize @@ -126,28 +125,6 @@ module Gitlab end end - def add_to_whitelist(result) - return success(result) unless prometheus_enabled? - return success(result) unless prometheus_listen_address.present? - - uri = parse_url(internal_prometheus_listen_address_uri) - return error(_('Prometheus listen_address in config/gitlab.yml is not a valid URI')) unless uri - - application_settings.add_to_outbound_local_requests_whitelist([uri.normalized_host]) - response = application_settings.save - - if response - # Expire the Gitlab::CurrentSettings cache after updating the whitelist. - # This happens automatically in an after_commit hook, but in migrations, - # the after_commit hook only runs at the end of the migration. - Gitlab::CurrentSettings.expire_current_application_settings - success(result) - else - log_error("Could not add prometheus URL to whitelist, errors: %{errors}" % { errors: application_settings.errors.full_messages }) - error(_('Could not add prometheus URL to whitelist')) - end - end - def add_prometheus_manual_configuration(result) return success(result) unless prometheus_enabled? return success(result) unless prometheus_listen_address.present? @@ -176,19 +153,11 @@ module Gitlab end def prometheus_enabled? - Gitlab.config.prometheus.enable if Gitlab.config.prometheus - rescue Settingslogic::MissingSetting - log_error('prometheus.enable is not present in config/gitlab.yml') - - false + ::Gitlab::Prometheus::Internal.prometheus_enabled? end def prometheus_listen_address - Gitlab.config.prometheus.listen_address.to_s if Gitlab.config.prometheus - rescue Settingslogic::MissingSetting - log_error('Prometheus listen_address is not present in config/gitlab.yml') - - nil + ::Gitlab::Prometheus::Internal.listen_address end def instance_admins @@ -231,23 +200,7 @@ module Gitlab end def internal_prometheus_listen_address_uri - if prometheus_listen_address.starts_with?('0.0.0.0:') - # 0.0.0.0:9090 - port = ':' + prometheus_listen_address.split(':').second - 'http://localhost' + port - - elsif prometheus_listen_address.starts_with?(':') - # :9090 - 'http://localhost' + prometheus_listen_address - - elsif prometheus_listen_address.starts_with?('http') - # https://localhost:9090 - prometheus_listen_address - - else - # localhost:9090 - 'http://' + prometheus_listen_address - end + ::Gitlab::Prometheus::Internal.uri end def prometheus_service_attributes diff --git a/lib/gitlab/devise_failure.rb b/lib/gitlab/devise_failure.rb index 4d27b706e1ef4db3839d23d761b67a69dbe23e63..59a7c4a6660ae2b3062122db0a921efa10be21ec 100644 --- a/lib/gitlab/devise_failure.rb +++ b/lib/gitlab/devise_failure.rb @@ -2,6 +2,8 @@ module Gitlab class DeviseFailure < Devise::FailureApp + include ::SessionsHelper + # If the request format is not known, send a redirect instead of a 401 # response, since this is the outcome we're most likely to want def http_auth? @@ -9,5 +11,11 @@ module Gitlab request_format && super end + + def respond + limit_session_time + + super + end end end diff --git a/lib/gitlab/error_tracking/detailed_error.rb b/lib/gitlab/error_tracking/detailed_error.rb new file mode 100644 index 0000000000000000000000000000000000000000..225280a42f4bd9d59d0372f5965fd4a00a4cfda7 --- /dev/null +++ b/lib/gitlab/error_tracking/detailed_error.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Gitlab + module ErrorTracking + class DetailedError + include ActiveModel::Model + + attr_accessor :count, + :culprit, + :external_base_url, + :external_url, + :first_release_last_commit, + :first_release_short_version, + :first_seen, + :frequency, + :id, + :last_release_last_commit, + :last_release_short_version, + :last_seen, + :message, + :project_id, + :project_name, + :project_slug, + :short_id, + :status, + :title, + :type, + :user_count + end + end +end diff --git a/lib/gitlab/error_tracking/error_event.rb b/lib/gitlab/error_tracking/error_event.rb new file mode 100644 index 0000000000000000000000000000000000000000..c6e0d82f8689b1d45ce254639953b9f7fea61875 --- /dev/null +++ b/lib/gitlab/error_tracking/error_event.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Gitlab + module ErrorTracking + class ErrorEvent + include ActiveModel::Model + + attr_accessor :issue_id, :date_received, :stack_trace_entries + end + end +end diff --git a/lib/gitlab/etag_caching/router.rb b/lib/gitlab/etag_caching/router.rb index 3d14a8dde8d1d304a85434e54e0ed88e51cb0c0a..efddda0ec65f68e514c83c830fc7abfc8ffdfb97 100644 --- a/lib/gitlab/etag_caching/router.rb +++ b/lib/gitlab/etag_caching/router.rb @@ -3,8 +3,6 @@ module Gitlab module EtagCaching class Router - prepend_if_ee('EE::Gitlab::EtagCaching::Router') # rubocop: disable Cop/InjectEnterpriseEditionModule - Route = Struct.new(:regexp, :name) # We enable an ETag for every request matching the regex. # To match a regex the path needs to match the following: @@ -80,3 +78,5 @@ module Gitlab end end end + +Gitlab::EtagCaching::Router.prepend_if_ee('EE::Gitlab::EtagCaching::Router') diff --git a/lib/gitlab/experimentation.rb b/lib/gitlab/experimentation.rb index 895755376ee40bfb48a20eb1859d0c9ddada8b7d..948f720b01bd32f200d5b09080636276fe442628 100644 --- a/lib/gitlab/experimentation.rb +++ b/lib/gitlab/experimentation.rb @@ -14,13 +14,15 @@ module Gitlab signup_flow: { feature_toggle: :experimental_separate_sign_up_flow, environment: ::Gitlab.dev_env_or_com?, - enabled_ratio: 0.1 + enabled_ratio: 0.1, + tracking_category: 'Growth::Acquisition::Experiment::SignUpFlow' } }.freeze # Controller concern that checks if an experimentation_subject_id cookie is present and sets it if absent. # Used for A/B testing of experimental features. Exposes the `experiment_enabled?(experiment_name)` method - # to controllers and views. + # to controllers and views. It returns true when the experiment is enabled and the user is selected as part + # of the experimental group. # module ControllerConcern extend ActiveSupport::Concern @@ -36,22 +38,67 @@ module Gitlab cookies.permanent.signed[:experimentation_subject_id] = { value: SecureRandom.uuid, domain: :all, - secure: ::Gitlab.config.gitlab.https + secure: ::Gitlab.config.gitlab.https, + httponly: true } end def experiment_enabled?(experiment_key) - Experimentation.enabled?(experiment_key, experimentation_subject_index) + Experimentation.enabled_for_user?(experiment_key, experimentation_subject_index) || forced_enabled?(experiment_key) + end + + def track_experiment_event(experiment_key, action) + track_experiment_event_for(experiment_key, action) do |tracking_data| + ::Gitlab::Tracking.event(tracking_data.delete(:category), tracking_data.delete(:action), tracking_data) + end + end + + def frontend_experimentation_tracking_data(experiment_key, action) + track_experiment_event_for(experiment_key, action) do |tracking_data| + gon.push(tracking_data: tracking_data) + end end private + def experimentation_subject_id + cookies.signed[:experimentation_subject_id] + end + def experimentation_subject_index - experimentation_subject_id = cookies.signed[:experimentation_subject_id] return if experimentation_subject_id.blank? experimentation_subject_id.delete('-').hex % 100 end + + def track_experiment_event_for(experiment_key, action) + return unless Experimentation.enabled?(experiment_key) + + yield experimentation_tracking_data(experiment_key, action) + end + + def experimentation_tracking_data(experiment_key, action) + { + category: tracking_category(experiment_key), + action: action, + property: tracking_group(experiment_key), + label: experimentation_subject_id + } + end + + def tracking_category(experiment_key) + Experimentation.experiment(experiment_key).tracking_category + end + + def tracking_group(experiment_key) + return unless Experimentation.enabled?(experiment_key) + + experiment_enabled?(experiment_key) ? 'experimental_group' : 'control_group' + end + + def forced_enabled?(experiment_key) + params.has_key?(:force_experiment) && params[:force_experiment] == experiment_key.to_s + end end class << self @@ -59,18 +106,20 @@ module Gitlab Experiment.new(EXPERIMENTS[key].merge(key: key)) end - def enabled?(experiment_key, experimentation_subject_index) + def enabled?(experiment_key) return false unless EXPERIMENTS.key?(experiment_key) experiment = experiment(experiment_key) + experiment.feature_toggle_enabled? && experiment.enabled_for_environment? + end - experiment.feature_toggle_enabled? && - experiment.enabled_for_environment? && - experiment.enabled_for_experimentation_subject?(experimentation_subject_index) + def enabled_for_user?(experiment_key, experimentation_subject_index) + enabled?(experiment_key) && + experiment(experiment_key).enabled_for_experimentation_subject?(experimentation_subject_index) end end - Experiment = Struct.new(:key, :feature_toggle, :environment, :enabled_ratio, keyword_init: true) do + Experiment = Struct.new(:key, :feature_toggle, :environment, :enabled_ratio, :tracking_category, keyword_init: true) do def feature_toggle_enabled? return Feature.enabled?(key, default_enabled: true) if feature_toggle.nil? diff --git a/lib/gitlab/favicon.rb b/lib/gitlab/favicon.rb index b5d308e462c089e7c3715ad3a09c42a16ae03b58..ce1370bab0f5fd0323957ad372f2601782ab927d 100644 --- a/lib/gitlab/favicon.rb +++ b/lib/gitlab/favicon.rb @@ -7,7 +7,7 @@ module Gitlab image_name = if appearance.favicon.exists? appearance.favicon_path - elsif Gitlab::Utils.to_boolean(ENV['CANARY']) + elsif Gitlab.canary? 'favicon-yellow.png' elsif Rails.env.development? development_favicon diff --git a/lib/gitlab/file_finder.rb b/lib/gitlab/file_finder.rb index 3958814208c2a7965eb9bff110f44813c247cb15..ec9d2df613bd13270dda2040f2bb815bc6c56c15 100644 --- a/lib/gitlab/file_finder.rb +++ b/lib/gitlab/file_finder.rb @@ -15,12 +15,12 @@ module Gitlab def find(query) query = Gitlab::Search::Query.new(query, encode_binary: true) do - filter :filename, matcher: ->(filter, blob) { blob.binary_filename =~ /#{filter[:regex_value]}$/i } - filter :path, matcher: ->(filter, blob) { blob.binary_filename =~ /#{filter[:regex_value]}/i } - filter :extension, matcher: ->(filter, blob) { blob.binary_filename =~ /\.#{filter[:regex_value]}$/i } + filter :filename, matcher: ->(filter, blob) { blob.binary_path =~ /#{filter[:regex_value]}$/i } + filter :path, matcher: ->(filter, blob) { blob.binary_path =~ /#{filter[:regex_value]}/i } + filter :extension, matcher: ->(filter, blob) { blob.binary_path =~ /\.#{filter[:regex_value]}$/i } end - files = find_by_filename(query.term) + find_by_content(query.term) + files = find_by_path(query.term) + find_by_content(query.term) files = query.filter_results(files) if query.filters.any? @@ -35,13 +35,14 @@ module Gitlab end end - def find_by_filename(query) - search_filenames(query).map do |filename| - Gitlab::Search::FoundBlob.new(blob_filename: filename, project: project, ref: ref, repository: repository) + def find_by_path(query) + search_paths(query).map do |path| + Gitlab::Search::FoundBlob.new(blob_path: path, project: project, ref: ref, repository: repository) end end - def search_filenames(query) + # Overriden in Gitlab::WikiFileFinder + def search_paths(query) repository.search_files_by_name(query, ref) end end diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb index 8fac3621df9c8ffe85d579eb157c38687a44195d..6210223917b4a1273547f5a0cf5ad6c2b74366b1 100644 --- a/lib/gitlab/git/commit.rb +++ b/lib/gitlab/git/commit.rb @@ -155,10 +155,6 @@ module Gitlab end end - def extract_signature(repository, commit_id) - repository.gitaly_commit_client.extract_signature(commit_id) - end - def extract_signature_lazily(repository, commit_id) BatchLoader.for(commit_id).batch(key: repository) do |commit_ids, loader, args| batch_signature_extraction(args[:key], commit_ids).each do |commit_id, signature_data| diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index b2c22898079aba3a17f5bfdbb20018bc9a8dac31..4971a18e270800c3e022365d0b4e3b49e083773c 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -25,9 +25,18 @@ module Gitlab InvalidRef = Class.new(StandardError) GitError = Class.new(StandardError) DeleteBranchError = Class.new(StandardError) - CreateTreeError = Class.new(StandardError) TagExistsError = Class.new(StandardError) ChecksumError = Class.new(StandardError) + class CreateTreeError < StandardError + attr_reader :error_code + + def initialize(error_code) + super(self.class.name) + + # The value coming from Gitaly is an uppercase String (e.g., "EMPTY") + @error_code = error_code.downcase.to_sym + end + end # Directory name of repo attr_reader :name diff --git a/lib/gitlab/git/wiki.rb b/lib/gitlab/git/wiki.rb index c1bcd8e934a490106f9835ab4053245bac2fcddf..3025fc6bfdb438bd2495e2e1c3df5b0503df1b15 100644 --- a/lib/gitlab/git/wiki.rb +++ b/lib/gitlab/git/wiki.rb @@ -133,14 +133,6 @@ module Gitlab GollumSlug.generate(title, format) end - def page_formatted_data(title:, dir: nil, version: nil) - version = version&.id - - wrapped_gitaly_errors do - gitaly_wiki_client.get_formatted_data(title: title, dir: dir, version: version) - end - end - private def gitaly_wiki_client diff --git a/lib/gitlab/git_access_result/custom_action.rb b/lib/gitlab/git_access_result/custom_action.rb index a05a4baed82eae014af727493223d1c498c0b248..336f3405f722dfaebfcc575df35d0945de5f323d 100644 --- a/lib/gitlab/git_access_result/custom_action.rb +++ b/lib/gitlab/git_access_result/custom_action.rb @@ -3,7 +3,7 @@ module Gitlab module GitAccessResult class CustomAction - attr_reader :payload, :message + attr_reader :payload, :console_messages # Example of payload: # @@ -16,9 +16,9 @@ module Gitlab # } # } # - def initialize(payload, message) + def initialize(payload, console_messages) @payload = payload - @message = message + @console_messages = console_messages end end end diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb index be695e7e91ad879f74b55c8be4c7cc330b861c4f..5b47853b9c149e44e3b6cf35b97e6fab2ef17503 100644 --- a/lib/gitlab/gitaly_client.rb +++ b/lib/gitlab/gitaly_client.rb @@ -142,18 +142,39 @@ module Gitlab # kwargs.merge(deadline: Time.now + 10) # end # - def self.call(storage, service, rpc, request, remote_storage: nil, timeout: default_timeout) - start = Gitlab::Metrics::System.monotonic_time - request_hash = request.is_a?(Google::Protobuf::MessageExts) ? request.to_h : {} + def self.call(storage, service, rpc, request, remote_storage: nil, timeout: default_timeout, &block) + self.measure_timings(service, rpc, request) do + self.execute(storage, service, rpc, request, remote_storage: remote_storage, timeout: timeout, &block) + end + end + # This method is like GitalyClient.call but should be used with + # Gitaly streaming RPCs. It measures how long the the RPC took to + # produce the full response, not just the initial response. + def self.streaming_call(storage, service, rpc, request, remote_storage: nil, timeout: default_timeout) + self.measure_timings(service, rpc, request) do + response = self.execute(storage, service, rpc, request, remote_storage: remote_storage, timeout: timeout) + + yield(response) + end + end + + def self.execute(storage, service, rpc, request, remote_storage:, timeout:) enforce_gitaly_request_limits(:call) kwargs = request_kwargs(storage, timeout: timeout.to_f, remote_storage: remote_storage) kwargs = yield(kwargs) if block_given? stub(service, storage).__send__(rpc, request, kwargs) # rubocop:disable GitlabSecurity/PublicSend + end + + def self.measure_timings(service, rpc, request) + start = Gitlab::Metrics::System.monotonic_time + + yield ensure duration = Gitlab::Metrics::System.monotonic_time - start + request_hash = request.is_a?(Google::Protobuf::MessageExts) ? request.to_h : {} # Keep track, separately, for the performance bar self.query_time += duration diff --git a/lib/gitlab/gitaly_client/commit_service.rb b/lib/gitlab/gitaly_client/commit_service.rb index b0559729ff3bc48460ea120ae8266dcd2bfb2afd..15318bc817a93c1b8c67f4d826116da0dde0b3a5 100644 --- a/lib/gitlab/gitaly_client/commit_service.rb +++ b/lib/gitlab/gitaly_client/commit_service.rb @@ -200,8 +200,9 @@ module Gitlab to: to ) - response = GitalyClient.call(@repository.storage, :commit_service, :commits_between, request, timeout: GitalyClient.medium_timeout) - consume_commits_response(response) + GitalyClient.streaming_call(@repository.storage, :commit_service, :commits_between, request, timeout: GitalyClient.medium_timeout) do |response| + consume_commits_response(response) + end end def diff_stats(left_commit_sha, right_commit_sha) @@ -224,8 +225,9 @@ module Gitlab ) request.order = opts[:order].upcase if opts[:order].present? - response = GitalyClient.call(@repository.storage, :commit_service, :find_all_commits, request, timeout: GitalyClient.medium_timeout) - consume_commits_response(response) + GitalyClient.streaming_call(@repository.storage, :commit_service, :find_all_commits, request, timeout: GitalyClient.medium_timeout) do |response| + consume_commits_response(response) + end end def list_commits_by_oid(oids) @@ -233,8 +235,9 @@ module Gitlab request = Gitaly::ListCommitsByOidRequest.new(repository: @gitaly_repo, oid: oids) - response = GitalyClient.call(@repository.storage, :commit_service, :list_commits_by_oid, request, timeout: GitalyClient.medium_timeout) - consume_commits_response(response) + GitalyClient.streaming_call(@repository.storage, :commit_service, :list_commits_by_oid, request, timeout: GitalyClient.medium_timeout) do |response| + consume_commits_response(response) + end rescue GRPC::NotFound # If no repository is found, happens mainly during testing [] end @@ -249,8 +252,9 @@ module Gitlab offset: offset.to_i ) - response = GitalyClient.call(@repository.storage, :commit_service, :commits_by_message, request, timeout: GitalyClient.medium_timeout) - consume_commits_response(response) + GitalyClient.streaming_call(@repository.storage, :commit_service, :commits_by_message, request, timeout: GitalyClient.medium_timeout) do |response| + consume_commits_response(response) + end end def languages(ref = nil) @@ -323,9 +327,9 @@ module Gitlab request.paths = encode_repeated(Array(options[:path])) if options[:path].present? - response = GitalyClient.call(@repository.storage, :commit_service, :find_commits, request, timeout: GitalyClient.medium_timeout) - - consume_commits_response(response) + GitalyClient.streaming_call(@repository.storage, :commit_service, :find_commits, request, timeout: GitalyClient.medium_timeout) do |response| + consume_commits_response(response) + end end def filter_shas_with_signatures(shas) @@ -348,25 +352,6 @@ module Gitlab end end - def extract_signature(commit_id) - request = Gitaly::ExtractCommitSignatureRequest.new(repository: @gitaly_repo, commit_id: commit_id) - response = GitalyClient.call(@repository.storage, :commit_service, :extract_commit_signature, request, timeout: GitalyClient.fast_timeout) - - signature = +''.b - signed_text = +''.b - - response.each do |message| - signature << message.signature - signed_text << message.signed_text - end - - return if signature.blank? && signed_text.blank? - - [signature, signed_text] - rescue GRPC::InvalidArgument => ex - raise ArgumentError, ex - end - def get_commit_signatures(commit_ids) request = Gitaly::GetCommitSignaturesRequest.new(repository: @gitaly_repo, commit_ids: commit_ids) response = GitalyClient.call(@repository.storage, :commit_service, :get_commit_signatures, request, timeout: GitalyClient.fast_timeout) diff --git a/lib/gitlab/gitaly_client/namespace_service.rb b/lib/gitlab/gitaly_client/namespace_service.rb index 0be214f30357549619239375211c6d8f0d5a7d23..dbcebec3aa220bc1a93f59085f065a5461c3ade8 100644 --- a/lib/gitlab/gitaly_client/namespace_service.rb +++ b/lib/gitlab/gitaly_client/namespace_service.rb @@ -3,14 +3,23 @@ module Gitlab module GitalyClient class NamespaceService - def initialize(storage) - @storage = storage + extend Gitlab::TemporarilyAllow + + NamespaceServiceAccessError = Class.new(StandardError) + ALLOW_KEY = :allow_namespace + + def self.allow + temporarily_allow(ALLOW_KEY) { yield } end - def exists?(name) - request = Gitaly::NamespaceExistsRequest.new(storage_name: @storage, name: name) + def self.denied? + !temporarily_allowed?(ALLOW_KEY) + end + + def initialize(storage) + raise NamespaceServiceAccessError if self.class.denied? - gitaly_client_call(:namespace_exists, request, timeout: GitalyClient.fast_timeout).exists + @storage = storage end def add(name) diff --git a/lib/gitlab/gitaly_client/operation_service.rb b/lib/gitlab/gitaly_client/operation_service.rb index 6e486c763dae3cdae0613fe5bb7162151ee86346..61c5db4c4df7946a3bc76ed93a5edb79b64a74e0 100644 --- a/lib/gitlab/gitaly_client/operation_service.rb +++ b/lib/gitlab/gitaly_client/operation_service.rb @@ -447,7 +447,7 @@ module Gitlab elsif response.commit_error.presence raise Gitlab::Git::CommitError, response.commit_error elsif response.create_tree_error.presence - raise Gitlab::Git::Repository::CreateTreeError, response.create_tree_error + raise Gitlab::Git::Repository::CreateTreeError, response.create_tree_error_code end Gitlab::Git::OperationService::BranchUpdate.from_gitaly(response.branch_update) diff --git a/lib/gitlab/gitaly_client/wiki_service.rb b/lib/gitlab/gitaly_client/wiki_service.rb index 15e0d7349dd3860f0258399284315744554963a2..9034edb62630b3040086fe97f8677d95e7339088 100644 --- a/lib/gitlab/gitaly_client/wiki_service.rb +++ b/lib/gitlab/gitaly_client/wiki_service.rb @@ -179,18 +179,6 @@ module Gitlab wiki_file end - def get_formatted_data(title:, dir: nil, version: nil) - request = Gitaly::WikiGetFormattedDataRequest.new( - repository: @gitaly_repo, - title: encode_binary(title), - revision: encode_binary(version), - directory: encode_binary(dir) - ) - - response = GitalyClient.call(@repository.storage, :wiki_service, :wiki_get_formatted_data, request, timeout: GitalyClient.medium_timeout) - response.reduce([]) { |memo, msg| memo << msg.data }.join - end - private # If a block is given and the yielded value is truthy, iteration will be diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index f1e31a615a4f4961803b5fb3efbcf351c8343d87..2616a19fdaad54a4d52609681364e5bcfe2154bc 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -42,9 +42,6 @@ module Gitlab # Initialize gon.features with any flags that should be # made globally available to the frontend push_frontend_feature_flag(:suppress_ajax_navigation_errors, default_enabled: true) - - # Flag controls a GFM feature used across many routes. - push_frontend_feature_flag(:gfm_grafana_integration) end # Exposes the state of a feature flag to the frontend code. diff --git a/lib/gitlab/gpg.rb b/lib/gitlab/gpg.rb index 32f61b1d65c67b300fddc6045007533138535a37..1dce26efc658fc6d51f3164f0c958505f082a126 100644 --- a/lib/gitlab/gpg.rb +++ b/lib/gitlab/gpg.rb @@ -4,6 +4,10 @@ module Gitlab module Gpg extend self + CleanupError = Class.new(StandardError) + BG_CLEANUP_RUNTIME_S = 2 + FG_CLEANUP_RUNTIME_S = 0.5 + MUTEX = Mutex.new module CurrentKeyChain @@ -94,16 +98,55 @@ module Gitlab previous_dir = current_home_dir tmp_dir = Dir.mktmpdir GPGME::Engine.home_dir = tmp_dir + tmp_keychains_created.increment + yield ensure - # Ignore any errors when removing the tmp directory, as we may run into a + GPGME::Engine.home_dir = previous_dir + + begin + cleanup_tmp_dir(tmp_dir) + rescue CleanupError => e + # This means we left a GPG-agent process hanging. Logging the problem in + # sentry will make this more visible. + Gitlab::Sentry.track_exception(e, + issue_url: 'https://gitlab.com/gitlab-org/gitlab/issues/20918', + extra: { tmp_dir: tmp_dir }) + end + + tmp_keychains_removed.increment unless File.exist?(tmp_dir) + end + + def cleanup_tmp_dir(tmp_dir) + return FileUtils.remove_entry(tmp_dir, true) if Feature.disabled?(:gpg_cleanup_retries) + + # Retry when removing the tmp directory failed, as we may run into a # race condition: # The `gpg-agent` agent process may clean up some files as well while # `FileUtils.remove_entry` is iterating the directory and removing all # its contained files and directories recursively, which could raise an # error. - FileUtils.remove_entry(tmp_dir, true) - GPGME::Engine.home_dir = previous_dir + # Failing to remove the tmp directory could leave the `gpg-agent` process + # running forever. + Retriable.retriable(max_elapsed_time: cleanup_time, base_interval: 0.1) do + FileUtils.remove_entry(tmp_dir) if File.exist?(tmp_dir) + end + rescue => e + raise CleanupError, e + end + + def cleanup_time + Sidekiq.server? ? BG_CLEANUP_RUNTIME_S : FG_CLEANUP_RUNTIME_S + end + + def tmp_keychains_created + @tmp_keychains_created ||= Gitlab::Metrics.counter(:gpg_tmp_keychains_created_total, + 'The number of temporary GPG keychains created') + end + + def tmp_keychains_removed + @tmp_keychains_removed ||= Gitlab::Metrics.counter(:gpg_tmp_keychains_removed_total, + 'The number of temporary GPG keychains removed') end end end diff --git a/lib/gitlab/grape_logging/loggers/exception_logger.rb b/lib/gitlab/grape_logging/loggers/exception_logger.rb new file mode 100644 index 0000000000000000000000000000000000000000..022eb15d28db4a87d78e4176f8c3089702f856df --- /dev/null +++ b/lib/gitlab/grape_logging/loggers/exception_logger.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Gitlab + module GrapeLogging + module Loggers + class ExceptionLogger < ::GrapeLogging::Loggers::Base + def parameters(request, _) + # grape-logging attempts to pass the logger the exception + # (https://github.com/aserafin/grape_logging/blob/v1.7.0/lib/grape_logging/middleware/request_logger.rb#L63), + # but it appears that the rescue_all in api.rb takes + # precedence so the logger never sees it. We need to + # store and retrieve the exception from the environment. + exception = request.env[::API::Helpers::API_EXCEPTION_ENV] + + return {} unless exception.is_a?(Exception) + + data = { + exception: { + class: exception.class.to_s, + message: exception.message + } + } + + if exception.backtrace + data[:exception][:backtrace] = Gitlab::Profiler.clean_backtrace(exception.backtrace) + end + + data + end + end + end + end +end diff --git a/lib/gitlab/graphql/authorize/instrumentation.rb b/lib/gitlab/graphql/authorize/instrumentation.rb index 15ecc3b04f00ae853edd4ec93ac370a66629324c..f9ff2b30eaef3bf0d0d88e1b92280692cc00efa8 100644 --- a/lib/gitlab/graphql/authorize/instrumentation.rb +++ b/lib/gitlab/graphql/authorize/instrumentation.rb @@ -9,12 +9,16 @@ module Gitlab def instrument(_type, field) service = AuthorizeFieldService.new(field) - if service.authorizations? + if service.authorizations? && !resolver_skips_authorizations?(field) field.redefine { resolve(service.authorized_resolve) } else field end end + + def resolver_skips_authorizations?(field) + field.metadata[:resolver].try(:skip_authorizations?) + end end end end diff --git a/lib/gitlab/graphql/connections.rb b/lib/gitlab/graphql/connections.rb index fbccdfa7b081c5824b9c3b8d62d5720232bb6f1f..38c7d98f37ce1645ddaae01134e21b3e820fde46 100644 --- a/lib/gitlab/graphql/connections.rb +++ b/lib/gitlab/graphql/connections.rb @@ -6,7 +6,11 @@ module Gitlab def self.use(_schema) GraphQL::Relay::BaseConnection.register_connection_implementation( ActiveRecord::Relation, - Gitlab::Graphql::Connections::KeysetConnection + Gitlab::Graphql::Connections::Keyset::Connection + ) + GraphQL::Relay::BaseConnection.register_connection_implementation( + Gitlab::Graphql::FilterableArray, + Gitlab::Graphql::Connections::FilterableArrayConnection ) end end diff --git a/lib/gitlab/graphql/connections/filterable_array_connection.rb b/lib/gitlab/graphql/connections/filterable_array_connection.rb new file mode 100644 index 0000000000000000000000000000000000000000..800f2c949c6a6c4ecc17f4e9ce8fae320a229f81 --- /dev/null +++ b/lib/gitlab/graphql/connections/filterable_array_connection.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module Connections + # FilterableArrayConnection is useful especially for lazy-loaded values. + # It allows us to call a callback only on the slice of array being + # rendered in the "after loaded" phase. For example we can check + # permissions only on a small subset of items. + class FilterableArrayConnection < GraphQL::Relay::ArrayConnection + def paged_nodes + @filtered_nodes ||= nodes.filter_callback.call(super) + end + end + end + end +end diff --git a/lib/gitlab/graphql/connections/keyset/conditions/base_condition.rb b/lib/gitlab/graphql/connections/keyset/conditions/base_condition.rb new file mode 100644 index 0000000000000000000000000000000000000000..22728cc0b6556a0c50cd548a21b3d70d93f83a06 --- /dev/null +++ b/lib/gitlab/graphql/connections/keyset/conditions/base_condition.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module Connections + module Keyset + module Conditions + class BaseCondition + def initialize(arel_table, names, values, operator, before_or_after) + @arel_table, @names, @values, @operator, @before_or_after = arel_table, names, values, operator, before_or_after + end + + def build + raise NotImplementedError + end + + private + + attr_reader :arel_table, :names, :values, :operator, :before_or_after + + def table_condition(attribute, value, operator) + case operator + when '>' + arel_table[attribute].gt(value) + when '<' + arel_table[attribute].lt(value) + when '=' + arel_table[attribute].eq(value) + when 'is_null' + arel_table[attribute].eq(nil) + when 'is_not_null' + arel_table[attribute].not_eq(nil) + end + end + end + end + end + end + end +end diff --git a/lib/gitlab/graphql/connections/keyset/conditions/not_null_condition.rb b/lib/gitlab/graphql/connections/keyset/conditions/not_null_condition.rb new file mode 100644 index 0000000000000000000000000000000000000000..3b56ddb996d584c286fe3107f46755be1e503860 --- /dev/null +++ b/lib/gitlab/graphql/connections/keyset/conditions/not_null_condition.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module Connections + module Keyset + module Conditions + class NotNullCondition < BaseCondition + def build + conditions = [first_attribute_condition] + + # If there is only one order field, we can assume it + # does not contain NULLs, and don't need additional + # conditions + unless names.count == 1 + conditions << [second_attribute_condition, final_condition] + end + + conditions.join + end + + private + + # ex: "(relative_position > 23)" + def first_attribute_condition + <<~SQL + (#{table_condition(names.first, values.first, operator.first).to_sql}) + SQL + end + + # ex: " OR (relative_position = 23 AND id > 500)" + def second_attribute_condition + condition = <<~SQL + OR ( + #{table_condition(names.first, values.first, '=').to_sql} + AND + #{table_condition(names[1], values[1], operator[1]).to_sql} + ) + SQL + + condition + end + + # ex: " OR (relative_position IS NULL)" + def final_condition + if before_or_after == :after + <<~SQL + OR (#{table_condition(names.first, nil, 'is_null').to_sql}) + SQL + end + end + end + end + end + end + end +end diff --git a/lib/gitlab/graphql/connections/keyset/conditions/null_condition.rb b/lib/gitlab/graphql/connections/keyset/conditions/null_condition.rb new file mode 100644 index 0000000000000000000000000000000000000000..71a74936d5d43b228b2b88afb50b4c0bdf4cc8a5 --- /dev/null +++ b/lib/gitlab/graphql/connections/keyset/conditions/null_condition.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module Connections + module Keyset + module Conditions + class NullCondition < BaseCondition + def build + [first_attribute_condition, final_condition].join + end + + private + + # ex: "(relative_position IS NULL AND id > 500)" + def first_attribute_condition + condition = <<~SQL + ( + #{table_condition(names.first, nil, 'is_null').to_sql} + AND + #{table_condition(names[1], values[1], operator[1]).to_sql} + ) + SQL + + condition + end + + # ex: " OR (relative_position IS NOT NULL)" + def final_condition + if before_or_after == :before + <<~SQL + OR (#{table_condition(names.first, nil, 'is_not_null').to_sql}) + SQL + end + end + end + end + end + end + end +end diff --git a/lib/gitlab/graphql/connections/keyset/connection.rb b/lib/gitlab/graphql/connections/keyset/connection.rb new file mode 100644 index 0000000000000000000000000000000000000000..c75ea206edb9ffee08b83f908ec45f153442f807 --- /dev/null +++ b/lib/gitlab/graphql/connections/keyset/connection.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +# Keyset::Connection provides cursor based pagination, to avoid using OFFSET. +# It basically sorts / filters using WHERE sorting_value > cursor. +# We do this for performance reasons (https://gitlab.com/gitlab-org/gitlab-foss/issues/45756), +# as well as for having stable pagination +# https://graphql-ruby.org/pro/cursors.html#whats-the-difference +# https://coderwall.com/p/lkcaag/pagination-you-re-probably-doing-it-wrong +# +# It currently supports sorting on two columns, but the last column must +# be the primary key. If it's not already included, an order on the +# primary key will be added automatically, like `order(id: :desc)` +# +# Issue.order(created_at: :asc).order(:id) +# Issue.order(due_date: :asc) +# +# You can also use `Gitlab::Database.nulls_last_order`: +# +# Issue.reorder(::Gitlab::Database.nulls_last_order('due_date', 'DESC')) +# +# It will tolerate non-attribute ordering, but only attributes determine the cursor. +# For example, this is legitimate: +# +# Issue.order('issues.due_date IS NULL').order(due_date: :asc).order(:id) +# +# but anything more complex has a chance of not working. +# +module Gitlab + module Graphql + module Connections + module Keyset + class Connection < GraphQL::Relay::BaseConnection + include Gitlab::Utils::StrongMemoize + + # TODO https://gitlab.com/gitlab-org/gitlab/issues/35104 + include Gitlab::Graphql::Connections::Keyset::LegacyKeysetConnection + + def cursor_from_node(node) + return legacy_cursor_from_node(node) if use_legacy_pagination? + + encoded_json_from_ordering(node) + end + + def sliced_nodes + return legacy_sliced_nodes if use_legacy_pagination? + + @sliced_nodes ||= + begin + OrderInfo.validate_ordering(ordered_nodes, order_list) + + sliced = ordered_nodes + sliced = slice_nodes(sliced, before, :before) if before.present? + sliced = slice_nodes(sliced, after, :after) if after.present? + + sliced + end + end + + def paged_nodes + # These are the nodes that will be loaded into memory for rendering + # So we're ok loading them into memory here as that's bound to happen + # anyway. Having them ready means we can modify the result while + # rendering the fields. + @paged_nodes ||= load_paged_nodes.to_a + end + + private + + def load_paged_nodes + if first && last + raise Gitlab::Graphql::Errors::ArgumentError.new("Can only provide either `first` or `last`, not both") + end + + if last + sliced_nodes.last(limit_value) + else + sliced_nodes.limit(limit_value) # rubocop: disable CodeReuse/ActiveRecord + end + end + + # rubocop: disable CodeReuse/ActiveRecord + def slice_nodes(sliced, encoded_cursor, before_or_after) + decoded_cursor = ordering_from_encoded_json(encoded_cursor) + builder = QueryBuilder.new(arel_table, order_list, decoded_cursor, before_or_after) + ordering = builder.conditions + + sliced.where(*ordering).where.not(id: decoded_cursor['id']) + end + # rubocop: enable CodeReuse/ActiveRecord + + def limit_value + @limit_value ||= [first, last, max_page_size].compact.min + end + + def ordered_nodes + strong_memoize(:order_nodes) do + unless nodes.primary_key.present? + raise ArgumentError.new('Relation must have a primary key') + end + + list = OrderInfo.build_order_list(nodes) + + # ensure there is a primary key ordering + if list&.last&.attribute_name != nodes.primary_key + nodes.order(arel_table[nodes.primary_key].desc) # rubocop: disable CodeReuse/ActiveRecord + else + nodes + end + end + end + + def order_list + strong_memoize(:order_list) do + OrderInfo.build_order_list(ordered_nodes) + end + end + + def arel_table + nodes.arel_table + end + + # Storing the current order values in the cursor allows us to + # make an intelligent decision on handling NULL values. + # Otherwise we would either need to fetch the record first, + # or fetch it in the SQL, significantly complicating it. + def encoded_json_from_ordering(node) + ordering = { 'id' => node[:id].to_s } + + order_list.each do |field| + field_name = field.attribute_name + ordering[field_name] = node[field_name].to_s + end + + encode(ordering.to_json) + end + + def ordering_from_encoded_json(cursor) + JSON.parse(decode(cursor)) + rescue JSON::ParserError + # for the transition period where a client might request using an + # old style cursor. Once removed, make it an error: + # raise Gitlab::Graphql::Errors::ArgumentError, "Please provide a valid cursor" + # TODO can be removed in next release + # https://gitlab.com/gitlab-org/gitlab/issues/32933 + field_name = order_list.first.attribute_name + + { field_name => decode(cursor) } + end + end + end + end + end +end diff --git a/lib/gitlab/graphql/connections/keyset/legacy_keyset_connection.rb b/lib/gitlab/graphql/connections/keyset/legacy_keyset_connection.rb new file mode 100644 index 0000000000000000000000000000000000000000..baf900d10480161f6e2717c121cd0fda46752a85 --- /dev/null +++ b/lib/gitlab/graphql/connections/keyset/legacy_keyset_connection.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# TODO https://gitlab.com/gitlab-org/gitlab/issues/35104 +module Gitlab + module Graphql + module Connections + module Keyset + module LegacyKeysetConnection + def legacy_cursor_from_node(node) + encode(node[legacy_order_field].to_s) + end + + # rubocop: disable CodeReuse/ActiveRecord + def legacy_sliced_nodes + @sliced_nodes ||= + begin + sliced = nodes + + sliced = sliced.where(legacy_before_slice) if before.present? + sliced = sliced.where(legacy_after_slice) if after.present? + + sliced + end + end + # rubocop: enable CodeReuse/ActiveRecord + + private + + def use_legacy_pagination? + strong_memoize(:feature_disabled) do + Feature.disabled?(:graphql_keyset_pagination, default_enabled: true) + end + end + + def legacy_before_slice + if legacy_sort_direction == :asc + arel_table[legacy_order_field].lt(decode(before)) + else + arel_table[legacy_order_field].gt(decode(before)) + end + end + + def legacy_after_slice + if legacy_sort_direction == :asc + arel_table[legacy_order_field].gt(decode(after)) + else + arel_table[legacy_order_field].lt(decode(after)) + end + end + + def legacy_order_info + @legacy_order_info ||= nodes.order_values.first + end + + def legacy_order_field + @legacy_order_field ||= legacy_order_info&.expr&.name || nodes.primary_key + end + + def legacy_sort_direction + @legacy_order_direction ||= legacy_order_info&.direction || :desc + end + end + end + end + end +end diff --git a/lib/gitlab/graphql/connections/keyset/order_info.rb b/lib/gitlab/graphql/connections/keyset/order_info.rb new file mode 100644 index 0000000000000000000000000000000000000000..4d85e8f79b768f93d464cab901a107b48f14310b --- /dev/null +++ b/lib/gitlab/graphql/connections/keyset/order_info.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module Connections + module Keyset + class OrderInfo + attr_reader :attribute_name, :sort_direction + + def initialize(order_value) + if order_value.is_a?(String) + @attribute_name, @sort_direction = extract_nulls_last_order(order_value) + else + @attribute_name = order_value.expr.name + @sort_direction = order_value.direction + end + end + + def operator_for(before_or_after) + case before_or_after + when :before + sort_direction == :asc ? '<' : '>' + when :after + sort_direction == :asc ? '>' : '<' + end + end + + # Only allow specific node types + def self.build_order_list(relation) + order_list = relation.order_values.select do |value| + supported_order_value?(value) + end + + order_list.map { |info| OrderInfo.new(info) } + end + + def self.validate_ordering(relation, order_list) + if order_list.empty? + raise ArgumentError.new('A minimum of 1 ordering field is required') + end + + if order_list.count > 2 + raise ArgumentError.new('A maximum of 2 ordering fields are allowed') + end + + # make sure the last ordering field is non-nullable + attribute_name = order_list.last&.attribute_name + + if relation.columns_hash[attribute_name].null + raise ArgumentError.new("Column `#{attribute_name}` must not allow NULL") + end + + if order_list.last.attribute_name != relation.primary_key + raise ArgumentError.new("Last ordering field must be the primary key, `#{relation.primary_key}`") + end + end + + def self.supported_order_value?(order_value) + return true if order_value.is_a?(Arel::Nodes::Ascending) || order_value.is_a?(Arel::Nodes::Descending) + return false unless order_value.is_a?(String) + + tokens = order_value.downcase.split + + tokens.last(2) == %w(nulls last) && tokens.count == 4 + end + + private + + def extract_nulls_last_order(order_value) + tokens = order_value.downcase.split + + [tokens.first, (tokens[1] == 'asc' ? :asc : :desc)] + end + end + end + end + end +end diff --git a/lib/gitlab/graphql/connections/keyset/query_builder.rb b/lib/gitlab/graphql/connections/keyset/query_builder.rb new file mode 100644 index 0000000000000000000000000000000000000000..e93c25d85fcc5722ac84798652e278d22a526626 --- /dev/null +++ b/lib/gitlab/graphql/connections/keyset/query_builder.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module Connections + module Keyset + class QueryBuilder + def initialize(arel_table, order_list, decoded_cursor, before_or_after) + @arel_table, @order_list, @decoded_cursor, @before_or_after = arel_table, order_list, decoded_cursor, before_or_after + + if order_list.empty? + raise ArgumentError.new('No ordering scopes have been supplied') + end + end + + # Based on whether the main field we're ordering on is NULL in the + # cursor, we can more easily target our query condition. + # We assume that the last ordering field is unique, meaning + # it will not contain NULLs. + # We currently only support two ordering fields. + # + # Example of the conditions for + # relation: Issue.order(relative_position: :asc).order(id: :asc) + # after cursor: relative_position: 1500, id: 500 + # + # when cursor[relative_position] is not NULL + # + # ("issues"."relative_position" > 1500) + # OR ( + # "issues"."relative_position" = 1500 + # AND + # "issues"."id" > 500 + # ) + # OR ("issues"."relative_position" IS NULL) + # + # when cursor[relative_position] is NULL + # + # "issues"."relative_position" IS NULL + # AND + # "issues"."id" > 500 + # + def conditions + attr_names = order_list.map { |field| field.attribute_name } + attr_values = attr_names.map { |name| decoded_cursor[name] } + + if attr_names.count == 1 && attr_values.first.nil? + raise Gitlab::Graphql::Errors::ArgumentError.new('Before/after cursor invalid: `nil` was provided as only sortable value') + end + + if attr_names.count == 1 || attr_values.first.present? + Keyset::Conditions::NotNullCondition.new(arel_table, attr_names, attr_values, operators, before_or_after).build + else + Keyset::Conditions::NullCondition.new(arel_table, attr_names, attr_values, operators, before_or_after).build + end + end + + private + + attr_reader :arel_table, :order_list, :decoded_cursor, :before_or_after + + def operators + order_list.map { |field| field.operator_for(before_or_after) } + end + end + end + end + end +end diff --git a/lib/gitlab/graphql/connections/keyset_connection.rb b/lib/gitlab/graphql/connections/keyset_connection.rb deleted file mode 100644 index 715963a44c1d6da8be331193920d939f9d565566..0000000000000000000000000000000000000000 --- a/lib/gitlab/graphql/connections/keyset_connection.rb +++ /dev/null @@ -1,85 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Graphql - module Connections - class KeysetConnection < GraphQL::Relay::BaseConnection - def cursor_from_node(node) - encode(node[order_field].to_s) - end - - # rubocop: disable CodeReuse/ActiveRecord - def sliced_nodes - @sliced_nodes ||= - begin - sliced = nodes - - sliced = sliced.where(before_slice) if before.present? - sliced = sliced.where(after_slice) if after.present? - - sliced - end - end - # rubocop: enable CodeReuse/ActiveRecord - - def paged_nodes - # These are the nodes that will be loaded into memory for rendering - # So we're ok loading them into memory here as that's bound to happen - # anyway. Having them ready means we can modify the result while - # rendering the fields. - @paged_nodes ||= load_paged_nodes.to_a - end - - private - - def load_paged_nodes - if first && last - raise Gitlab::Graphql::Errors::ArgumentError.new("Can only provide either `first` or `last`, not both") - end - - if last - sliced_nodes.last(limit_value) - else - sliced_nodes.limit(limit_value) # rubocop: disable CodeReuse/ActiveRecord - end - end - - def before_slice - if sort_direction == :asc - table[order_field].lt(decode(before)) - else - table[order_field].gt(decode(before)) - end - end - - def after_slice - if sort_direction == :asc - table[order_field].gt(decode(after)) - else - table[order_field].lt(decode(after)) - end - end - - def limit_value - @limit_value ||= [first, last, max_page_size].compact.min - end - - def table - nodes.arel_table - end - - def order_info - @order_info ||= nodes.order_values.first - end - - def order_field - @order_field ||= order_info&.expr&.name || nodes.primary_key - end - - def sort_direction - @order_direction ||= order_info&.direction || :desc - end - end - end - end -end diff --git a/lib/gitlab/graphql/filterable_array.rb b/lib/gitlab/graphql/filterable_array.rb new file mode 100644 index 0000000000000000000000000000000000000000..4909d291fd67cb1dd56bb310dfb64c91ac464d20 --- /dev/null +++ b/lib/gitlab/graphql/filterable_array.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + class FilterableArray < Array + attr_reader :filter_callback + + def initialize(filter_callback, *args) + super(args) + @filter_callback = filter_callback + end + end + end +end diff --git a/lib/gitlab/graphql/loaders/pipeline_for_sha_loader.rb b/lib/gitlab/graphql/loaders/pipeline_for_sha_loader.rb deleted file mode 100644 index 70344392138090bcd9d4c672c2a6b43010089f6b..0000000000000000000000000000000000000000 --- a/lib/gitlab/graphql/loaders/pipeline_for_sha_loader.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Graphql - module Loaders - class PipelineForShaLoader - attr_accessor :project, :sha - - def initialize(project, sha) - @project, @sha = project, sha - end - - def find_last - BatchLoader::GraphQL.for(sha).batch(key: project) do |shas, loader, args| - pipelines = args[:key].ci_pipelines.latest_for_shas(shas) - - pipelines.each do |pipeline| - loader.call(pipeline.sha, pipeline) - end - end - end - end - end - end -end diff --git a/lib/gitlab/health_checks/master_check.rb b/lib/gitlab/health_checks/master_check.rb new file mode 100644 index 0000000000000000000000000000000000000000..057bce84ddd8d617c0825e59993021d525dd3aaf --- /dev/null +++ b/lib/gitlab/health_checks/master_check.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Gitlab + module HealthChecks + # This check is registered on master, + # and validated by worker + class MasterCheck + extend SimpleAbstractCheck + + class << self + def register_master + # when we fork, we pass the read pipe to child + # child can then react on whether the other end + # of pipe is still available + @pipe_read, @pipe_write = IO.pipe + end + + def finish_master + close_read + close_write + end + + def register_worker + # fork needs to close the pipe + close_write + end + + private + + def close_read + @pipe_read&.close + @pipe_read = nil + end + + def close_write + @pipe_write&.close + @pipe_write = nil + end + + def metric_prefix + 'master_check' + end + + def successful?(result) + result + end + + def check + # the lack of pipe is a legitimate failure of check + return false unless @pipe_read + + @pipe_read.read_nonblock(1) + + true + rescue IO::EAGAINWaitReadable + # if it is blocked, it means that the pipe is still open + # and there's no data waiting on it + true + rescue EOFError + # the pipe is closed + false + end + end + end + end +end diff --git a/lib/gitlab/import_export.rb b/lib/gitlab/import_export.rb index b2ac60fe825f423e67b6b642ce8da5c333e9ba80..516e7f54a6ec7678122b202f15deb702d4023f5b 100644 --- a/lib/gitlab/import_export.rb +++ b/lib/gitlab/import_export.rb @@ -15,7 +15,7 @@ module Gitlab end def storage_path - File.join(Settings.shared['path'], 'tmp/project_exports') + File.join(Settings.shared['path'], 'tmp/gitlab_exports') end def import_upload_path(filename:) @@ -50,8 +50,8 @@ module Gitlab 'VERSION' end - def export_filename(project:) - basename = "#{Time.now.strftime('%Y-%m-%d_%H-%M-%3N')}_#{project.full_path.tr('/', '_')}" + def export_filename(exportable:) + basename = "#{Time.now.strftime('%Y-%m-%d_%H-%M-%3N')}_#{exportable.full_path.tr('/', '_')}" "#{basename[0..FILENAME_LIMIT]}_export.tar.gz" end @@ -63,6 +63,14 @@ module Gitlab def reset_tokens? true end + + def group_filename + 'group.json' + end + + def group_config_file + Rails.root.join('lib/gitlab/import_export/group_import_export.yml') + end end end diff --git a/lib/gitlab/import_export/config.rb b/lib/gitlab/import_export/config.rb index 6f4919ead4ef854827093f46fd20dbde9645059d..83c4bc473492fd881bf652d9a627f6a355ec9fae 100644 --- a/lib/gitlab/import_export/config.rb +++ b/lib/gitlab/import_export/config.rb @@ -3,7 +3,8 @@ module Gitlab module ImportExport class Config - def initialize + def initialize(config: Gitlab::ImportExport.config_file) + @config = config @hash = parse_yaml @hash.deep_symbolize_keys! @ee_hash = @hash.delete(:ee) || {} @@ -50,7 +51,7 @@ module Gitlab end def parse_yaml - YAML.load_file(Gitlab::ImportExport.config_file) + YAML.load_file(@config) end end end diff --git a/lib/gitlab/import_export/file_importer.rb b/lib/gitlab/import_export/file_importer.rb index 05432f433e78109d64aaf319de585e1a9371def3..2fd12e3aa786a009970e7a5fedb688b4805d9379 100644 --- a/lib/gitlab/import_export/file_importer.rb +++ b/lib/gitlab/import_export/file_importer.rb @@ -60,7 +60,7 @@ module Gitlab def copy_archive return if @archive_file - @archive_file = File.join(@shared.archive_path, Gitlab::ImportExport.export_filename(project: @project)) + @archive_file = File.join(@shared.archive_path, Gitlab::ImportExport.export_filename(exportable: @project)) download_or_copy_upload(@project.import_export_upload.import_file, @archive_file) end diff --git a/lib/gitlab/import_export/group_import_export.yml b/lib/gitlab/import_export/group_import_export.yml new file mode 100644 index 0000000000000000000000000000000000000000..c1900350c8615384701f6c7dbf8f47278fdc3897 --- /dev/null +++ b/lib/gitlab/import_export/group_import_export.yml @@ -0,0 +1,36 @@ +# Model relationships to be included in the group import/export +# +# This list _must_ only contain relationships that are available to both FOSS and +# Enterprise editions. EE specific relationships must be defined in the `ee` section further +# down below. +tree: + group: + - :milestones + - :badges + - labels: + - :priorities + - :boards + - members: + - :user + +included_attributes: + +excluded_attributes: + group: + - :runners_token + - :runners_token_encrypted + +methods: + labels: + - :type + badges: + - :type + +preloads: + +# EE specific relationships and settings to include. All of this will be merged +# into the previous structures if EE is used. +ee: + tree: + group: + - :epics diff --git a/lib/gitlab/import_export/group_project_object_builder.rb b/lib/gitlab/import_export/group_project_object_builder.rb index de1629d0e28831184ecc8b3bfc4ddb6984857df7..b94839363df886527e8102dfa0f0f03f9f02e424 100644 --- a/lib/gitlab/import_export/group_project_object_builder.rb +++ b/lib/gitlab/import_export/group_project_object_builder.rb @@ -49,11 +49,12 @@ module Gitlab ].compact end - # Returns Arel clause `"{table_name}"."project_id" = {project.id}` + # Returns Arel clause `"{table_name}"."project_id" = {project.id}` if project is present + # For example: merge_request has :target_project_id, and we are searching by :iid # 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) + clause = table[:project_id].eq(project.id) if project clause = clause.or(table[:group_id].eq(group.id)) if group clause @@ -103,6 +104,10 @@ module Gitlab klass == Milestone end + def merge_request? + klass == MergeRequest + 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,7 +129,7 @@ module Gitlab # Returns Arel clause for a particular model or `nil`. def where_clause_for_klass - # no-op + return attrs_to_arel(attributes.slice('iid')) if merge_request? end end end diff --git a/lib/gitlab/import_export/group_tree_saver.rb b/lib/gitlab/import_export/group_tree_saver.rb new file mode 100644 index 0000000000000000000000000000000000000000..8d2fb881cc0e21055b1cf9a56e04ca069554d735 --- /dev/null +++ b/lib/gitlab/import_export/group_tree_saver.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Gitlab + module ImportExport + class GroupTreeSaver + attr_reader :full_path + + def initialize(group:, current_user:, shared:, params: {}) + @params = params + @current_user = current_user + @shared = shared + @group = group + @full_path = File.join(@shared.export_path, ImportExport.group_filename) + end + + def save + group_tree = serialize(@group, reader.group_tree) + tree_saver.save(group_tree, @shared.export_path, ImportExport.group_filename) + + true + rescue => e + @shared.error(e) + false + end + + private + + def serialize(group, relations_tree) + group_tree = tree_saver.serialize(group, relations_tree) + + group.children.each do |child| + group_tree['children'] ||= [] + group_tree['children'] << serialize(child, relations_tree) + end + + group_tree + rescue => e + @shared.error(e) + end + + def reader + @reader ||= Gitlab::ImportExport::Reader.new( + shared: @shared, + config: Gitlab::ImportExport::Config.new( + config: Gitlab::ImportExport.group_config_file + ).to_h + ) + end + + def tree_saver + @tree_saver ||= RelationTreeSaver.new + end + end + end +end diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index 141e73e6a4736de505e4595576681f586baa4935..1aafe5804c00c4a7a6a28e68c8fd2779abec8526 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -28,6 +28,7 @@ tree: - label: - :priorities - :issue_assignees + - :zoom_meetings - snippets: - :award_emoji - notes: @@ -147,6 +148,8 @@ excluded_attributes: - :emails_disabled - :max_pages_size - :max_artifacts_size + - :marked_for_deletion_at + - :marked_for_deletion_by_user_id namespaces: - :runners_token - :runners_token_encrypted diff --git a/lib/gitlab/import_export/project_tree_restorer.rb b/lib/gitlab/import_export/project_tree_restorer.rb index 3fa5765fd4a5c2e49b0857df6e670f7fc2a59c7a..c401f96b5c16f0e6ab6b320773db47745d06a18a 100644 --- a/lib/gitlab/import_export/project_tree_restorer.rb +++ b/lib/gitlab/import_export/project_tree_restorer.rb @@ -15,7 +15,6 @@ module Gitlab @user = user @shared = shared @project = project - @saved = true end def restore @@ -33,7 +32,8 @@ module Gitlab ActiveRecord::Base.uncached do ActiveRecord::Base.no_touching do update_project_params! - create_relations + create_project_relations! + post_import! end end @@ -69,81 +69,75 @@ module Gitlab # in the DB. The structure and relationships between models are guessed from # the configuration yaml file too. # Finally, it updates each attribute in the newly imported project. - def create_relations - project_relations.each do |relation_key, relation_definition| - relation_key_s = relation_key.to_s - - if relation_definition.present? - create_sub_relations(relation_key_s, relation_definition, @tree_hash) - elsif @tree_hash[relation_key_s].present? - save_relation_hash(relation_key_s, @tree_hash[relation_key_s]) - end - end + def create_project_relations! + project_relations.each(&method( + :process_project_relation!)) + end + def post_import! @project.merge_requests.set_latest_merge_request_diff_ids! - - @saved end - def save_relation_hash(relation_key, relation_hash_batch) - relation_hash = create_relation(relation_key, relation_hash_batch) + def process_project_relation!(relation_key, relation_definition) + data_hashes = @tree_hash.delete(relation_key) + return unless data_hashes - remove_group_models(relation_hash) if relation_hash.is_a?(Array) + # we do not care if we process array or hash + data_hashes = [data_hashes] unless data_hashes.is_a?(Array) - @saved = false unless @project.append_or_update_attribute(relation_key, relation_hash) + # consume and remove objects from memory + while data_hash = data_hashes.shift + process_project_relation_item!(relation_key, relation_definition, data_hash) + end + end - save_id_mappings(relation_key, relation_hash_batch, relation_hash) + def process_project_relation_item!(relation_key, relation_definition, data_hash) + relation_object = build_relation(relation_key, relation_definition, data_hash) + return unless relation_object + return if group_model?(relation_object) - @project.reset + relation_object.project = @project + relation_object.save! + + save_id_mapping(relation_key, data_hash, relation_object) end # Older, serialized CI pipeline exports may only have a # merge_request_id and not the full hash of the merge request. To # import these pipelines, we need to preserve the mapping between # the old and new the merge request ID. - def save_id_mappings(relation_key, relation_hash_batch, relation_hash) + def save_id_mapping(relation_key, data_hash, relation_object) return unless relation_key == 'merge_requests' - relation_hash = Array(relation_hash) - - Array(relation_hash_batch).each_with_index do |raw_data, index| - merge_requests_mapping[raw_data['id']] = relation_hash[index]['id'] - end - end - - # Remove project models that became group models as we found them at group level. - # This no longer required saving them at the root project level. - # For example, in the case of an existing group label that matched the title. - def remove_group_models(relation_hash) - relation_hash.reject! do |value| - GROUP_MODELS.include?(value.class) && value.group_id - end - end - - def remove_feature_dependent_sub_relations!(_relation_item) - # no-op + merge_requests_mapping[data_hash['id']] = relation_object.id end def project_relations - @project_relations ||= reader.attributes_finder.find_relations_tree(:project) + @project_relations ||= + reader + .attributes_finder + .find_relations_tree(:project) + .deep_stringify_keys end def update_project_params! - Gitlab::Timeless.timeless(@project) do - project_params = @tree_hash.reject do |key, value| - project_relations.include?(key.to_sym) - end + project_params = @tree_hash.reject do |key, value| + project_relations.include?(key) + end - project_params = project_params.merge(present_project_override_params) + project_params = project_params.merge( + present_project_override_params) - # Cleaning all imported and overridden params - project_params = Gitlab::ImportExport::AttributeCleaner.clean( - relation_hash: project_params, - relation_class: Project, - excluded_keys: excluded_keys_for_relation(:project)) + # Cleaning all imported and overridden params + project_params = Gitlab::ImportExport::AttributeCleaner.clean( + relation_hash: project_params, + relation_class: Project, + excluded_keys: excluded_keys_for_relation(:project)) - @project.assign_attributes(project_params) - @project.drop_visibility_level! + @project.assign_attributes(project_params) + @project.drop_visibility_level! + + Gitlab::Timeless.timeless(@project) do @project.save! end end @@ -160,75 +154,61 @@ module Gitlab @project_override_params ||= @project.import_data&.data&.fetch('override_params', nil) || {} end - # Given a relation hash containing one or more models and its relationships, - # loops through each model and each object from a model type and - # and assigns its correspondent attributes hash from +tree_hash+ - # Example: - # +relation_key+ issues, loops through the list of *issues* and for each individual - # issue, finds any subrelations such as notes, creates them and assign them back to the hash - # - # Recursively calls this method if the sub-relation is a hash containing more sub-relations - def create_sub_relations(relation_key, relation_definition, tree_hash, save: true) - return if tree_hash[relation_key].blank? - - tree_array = [tree_hash[relation_key]].flatten - - # Avoid keeping a possible heavy object in memory once we are done with it - while relation_item = tree_array.shift - remove_feature_dependent_sub_relations!(relation_item) - - # The transaction at this level is less speedy than one single transaction - # But we can't have it in the upper level or GC won't get rid of the AR objects - # after we save the batch. - Project.transaction do - process_sub_relation(relation_key, relation_definition, relation_item) - - # For every subrelation that hangs from Project, save the associated records altogether - # This effectively batches all records per subrelation item, only keeping those in memory - # We have to keep in mind that more batch granularity << Memory, but >> Slowness - if save - save_relation_hash(relation_key, [relation_item]) - tree_hash[relation_key].delete(relation_item) - end - end - end - - tree_hash.delete(relation_key) if save + def build_relations(relation_key, relation_definition, data_hashes) + data_hashes.map do |data_hash| + build_relation(relation_key, relation_definition, data_hash) + end.compact end - def process_sub_relation(relation_key, relation_definition, relation_item) - relation_definition.each do |sub_relation_key, sub_relation_definition| - # We just use author to get the user ID, do not attempt to create an instance. - next if sub_relation_key == :author + def build_relation(relation_key, relation_definition, data_hash) + # TODO: This is hack to not create relation for the author + # Rather make `RelationFactory#set_note_author` to take care of that + return data_hash if relation_key == 'author' - sub_relation_key_s = sub_relation_key.to_s + # create relation objects recursively for all sub-objects + relation_definition.each do |sub_relation_key, sub_relation_definition| + transform_sub_relations!(data_hash, sub_relation_key, sub_relation_definition) + end - # create dependent relations if present - if sub_relation_definition.present? - create_sub_relations(sub_relation_key_s, sub_relation_definition, relation_item, save: false) + Gitlab::ImportExport::RelationFactory.create( + relation_sym: relation_key.to_sym, + relation_hash: data_hash, + members_mapper: members_mapper, + merge_requests_mapping: merge_requests_mapping, + user: @user, + project: @project, + excluded_keys: excluded_keys_for_relation(relation_key)) + end + + def transform_sub_relations!(data_hash, sub_relation_key, sub_relation_definition) + sub_data_hash = data_hash[sub_relation_key] + return unless sub_data_hash + + # if object is a hash we can create simple object + # as it means that this is 1-to-1 vs 1-to-many + sub_data_hash = + if sub_data_hash.is_a?(Array) + build_relations( + sub_relation_key, + sub_relation_definition, + sub_data_hash).presence + else + build_relation( + sub_relation_key, + sub_relation_definition, + sub_data_hash) end - # transform relation hash to actual object - sub_relation_hash = relation_item[sub_relation_key_s] - if sub_relation_hash.present? - relation_item[sub_relation_key_s] = create_relation(sub_relation_key, sub_relation_hash) - end + # persist object(s) or delete from relation + if sub_data_hash + data_hash[sub_relation_key] = sub_data_hash + else + data_hash.delete(sub_relation_key) end end - def create_relation(relation_key, relation_hash_list) - relation_array = [relation_hash_list].flatten.map do |relation_hash| - Gitlab::ImportExport::RelationFactory.create( - relation_sym: relation_key.to_sym, - relation_hash: relation_hash, - members_mapper: members_mapper, - merge_requests_mapping: merge_requests_mapping, - user: @user, - project: @project, - excluded_keys: excluded_keys_for_relation(relation_key)) - end.compact - - relation_hash_list.is_a?(Array) ? relation_array : relation_array.first + def group_model?(relation_object) + GROUP_MODELS.include?(relation_object.class) && relation_object.group_id end def reader @@ -241,5 +221,3 @@ module Gitlab end end end - -Gitlab::ImportExport::ProjectTreeRestorer.prepend_if_ee('::EE::Gitlab::ImportExport::ProjectTreeRestorer') diff --git a/lib/gitlab/import_export/project_tree_saver.rb b/lib/gitlab/import_export/project_tree_saver.rb index 63c71105efe3c1e45408a4e2817fcd3209bfc9a9..386a4cfdfc6d5999fe45c05a8272dfab012a72ad 100644 --- a/lib/gitlab/import_export/project_tree_saver.rb +++ b/lib/gitlab/import_export/project_tree_saver.rb @@ -3,25 +3,20 @@ module Gitlab module ImportExport class ProjectTreeSaver - include Gitlab::ImportExport::CommandLineUtil - attr_reader :full_path def initialize(project:, current_user:, shared:, params: {}) - @params = params - @project = project + @params = params + @project = project @current_user = current_user - @shared = shared - @full_path = File.join(@shared.export_path, ImportExport.project_filename) + @shared = shared + @full_path = File.join(@shared.export_path, ImportExport.project_filename) end def save - mkdir_p(@shared.export_path) - - project_tree = serialize_project_tree + project_tree = tree_saver.serialize(@project, reader.project_tree) fix_project_tree(project_tree) - project_tree_json = JSON.generate(project_tree) - File.write(full_path, project_tree_json) + tree_saver.save(project_tree, @shared.export_path, ImportExport.project_filename) true rescue => e @@ -43,16 +38,6 @@ module Gitlab RelationRenameService.add_new_associations(project_tree) end - def serialize_project_tree - if Feature.enabled?(:export_fast_serialize, default_enabled: true) - Gitlab::ImportExport::FastHashSerializer - .new(@project, reader.project_tree) - .execute - else - @project.as_json(reader.project_tree) - end - end - def reader @reader ||= Gitlab::ImportExport::Reader.new(shared: @shared) end @@ -74,6 +59,10 @@ module Gitlab GroupMembersFinder.new(@project.group).execute.where.not(user_id: non_null_user_ids) end + + def tree_saver + @tree_saver ||= RelationTreeSaver.new + end end end end diff --git a/lib/gitlab/import_export/reader.rb b/lib/gitlab/import_export/reader.rb index 9e81c6a3d073653d5797ffd2c9b6f53b608de0fd..1390770acefc9eae5ceb2fb53c8451a4095dae7c 100644 --- a/lib/gitlab/import_export/reader.rb +++ b/lib/gitlab/import_export/reader.rb @@ -5,24 +5,31 @@ module Gitlab class Reader attr_reader :tree, :attributes_finder - def initialize(shared:) - @shared = shared - - @attributes_finder = Gitlab::ImportExport::AttributesFinder.new( - config: ImportExport::Config.new.to_h) + def initialize(shared:, config: ImportExport::Config.new.to_h) + @shared = shared + @config = config + @attributes_finder = Gitlab::ImportExport::AttributesFinder.new(config: @config) end # Outputs a hash in the format described here: http://api.rubyonrails.org/classes/ActiveModel/Serializers/JSON.html # for outputting a project in JSON format, including its relations and sub relations. def project_tree - attributes_finder.find_root(:project) - rescue => e - @shared.error(e) - false + tree_by_key(:project) + end + + def group_tree + tree_by_key(:group) end def group_members_tree - attributes_finder.find_root(:group_members) + tree_by_key(:group_members) + end + + def tree_by_key(key) + attributes_finder.find_root(key) + rescue => e + @shared.error(e) + false end end end diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index ae8025c52efa07e44167c32ccbe1fe3692cb504f..ae6b3c161cea0831c3d42e195785461814dfab63 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -38,10 +38,13 @@ module Gitlab 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].freeze + EXISTING_OBJECT_CHECK = %i[milestone milestones label labels project_label project_labels group_label group_labels project_feature merge_request ProjectCiCdSetting].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` + UNIQUE_RELATIONS = %i[project_feature ProjectCiCdSetting].freeze + def self.create(*args) new(*args).create end @@ -274,7 +277,7 @@ module Gitlab end def setup_pipeline - @relation_hash.fetch('stages').each do |stage| + @relation_hash.fetch('stages', []).each do |stage| stage.statuses.each do |status| status.pipeline = imported_object end @@ -324,8 +327,7 @@ module Gitlab end def find_or_create_object! - return relation_class.find_or_create_by(project_id: @project.id) if @relation_name == :project_feature - return find_or_create_merge_request! if @relation_name == :merge_request + return relation_class.find_or_create_by(project_id: @project.id) if UNIQUE_RELATIONS.include?(@relation_name) # Can't use IDs as validation exists calling `group` or `project` attributes finder_hash = parsed_relation_hash.tap do |hash| @@ -336,11 +338,6 @@ module Gitlab GroupProjectObjectBuilder.build(relation_class, finder_hash) end - - def find_or_create_merge_request! - @project.merge_requests.find_by(iid: parsed_relation_hash['iid']) || - relation_class.new(parsed_relation_hash) - end end end end diff --git a/lib/gitlab/import_export/relation_rename_service.rb b/lib/gitlab/import_export/relation_rename_service.rb index 179bde5e21e791487ed9cb6c53a6cb81899d993e..03aaa6aefc335eaa7803beca2e085dc74ad1a03d 100644 --- a/lib/gitlab/import_export/relation_rename_service.rb +++ b/lib/gitlab/import_export/relation_rename_service.rb @@ -8,7 +8,7 @@ # The behavior of these renamed relationships should be transient and it should # only last one release until you completely remove the renaming from the list. # -# When importing, this class will check the project hash and: +# When importing, this class will check the hash and: # - if only the old relationship name is found, it will rename it with the new one # - if only the new relationship name is found, it will do nothing # - if it finds both, it will use the new relationship data diff --git a/lib/gitlab/import_export/relation_tree_saver.rb b/lib/gitlab/import_export/relation_tree_saver.rb new file mode 100644 index 0000000000000000000000000000000000000000..a0452071ccf9a654596bf555d1c701feaf2a95fa --- /dev/null +++ b/lib/gitlab/import_export/relation_tree_saver.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Gitlab + module ImportExport + class RelationTreeSaver + include Gitlab::ImportExport::CommandLineUtil + + def serialize(exportable, relations_tree) + if Feature.enabled?(:export_fast_serialize, default_enabled: true) + Gitlab::ImportExport::FastHashSerializer + .new(exportable, relations_tree) + .execute + else + exportable.as_json(relations_tree) + end + end + + def save(tree, dir_path, filename) + mkdir_p(dir_path) + + tree_json = JSON.generate(tree) + + File.write(File.join(dir_path, filename), tree_json) + end + end + end +end diff --git a/lib/gitlab/import_export/saver.rb b/lib/gitlab/import_export/saver.rb index bea7a7cce656cb89ba48c8e97d000cd7ed8ba230..ae82c380755228331a35ec013e13113600e77542 100644 --- a/lib/gitlab/import_export/saver.rb +++ b/lib/gitlab/import_export/saver.rb @@ -9,16 +9,16 @@ module Gitlab new(*args).save end - def initialize(project:, shared:) - @project = project - @shared = shared + def initialize(exportable:, shared:) + @exportable = exportable + @shared = shared end def save if compress_and_save remove_export_path - Rails.logger.info("Saved project export #{archive_file}") # rubocop:disable Gitlab/RailsLogger + Rails.logger.info("Saved #{@exportable.class} export #{archive_file}") # rubocop:disable Gitlab/RailsLogger save_upload else @@ -48,11 +48,11 @@ module Gitlab end def archive_file - @archive_file ||= File.join(@shared.archive_path, Gitlab::ImportExport.export_filename(project: @project)) + @archive_file ||= File.join(@shared.archive_path, Gitlab::ImportExport.export_filename(exportable: @exportable)) end def save_upload - upload = ImportExportUpload.find_or_initialize_by(project: @project) + upload = initialize_upload File.open(archive_file) { |file| upload.export_file = file } @@ -62,6 +62,12 @@ module Gitlab def error_message "Unable to save #{archive_file} into #{@shared.export_path}." end + + def initialize_upload + exportable_kind = @exportable.class.name.downcase + + ImportExportUpload.find_or_initialize_by(Hash[exportable_kind, @exportable]) + end end end end diff --git a/lib/gitlab/import_export/shared.rb b/lib/gitlab/import_export/shared.rb index 02d46a1f49876af0f04770e47911c643323ef92e..2539a6828c329a34772c69bfc7257abc59b87986 100644 --- a/lib/gitlab/import_export/shared.rb +++ b/lib/gitlab/import_export/shared.rb @@ -23,21 +23,21 @@ module Gitlab module ImportExport class Shared - attr_reader :errors, :project + attr_reader :errors, :exportable, :logger LOCKS_DIRECTORY = 'locks' - def initialize(project) - @project = project - @errors = [] - @logger = Gitlab::Import::Logger.build + def initialize(exportable) + @exportable = exportable + @errors = [] + @logger = Gitlab::Import::Logger.build end def active_export_count Dir[File.join(base_path, '*')].count { |name| File.basename(name) != LOCKS_DIRECTORY && File.directory?(name) } end - # The path where the project metadata and repository bundle is saved + # The path where the exportable metadata and repository bundle (in case of project) is saved def export_path @export_path ||= Gitlab::ImportExport.export_path(relative_path: relative_path) end @@ -84,11 +84,18 @@ module Gitlab end def relative_archive_path - @relative_archive_path ||= File.join(@project.disk_path, SecureRandom.hex) + @relative_archive_path ||= File.join(relative_base_path, SecureRandom.hex) end def relative_base_path - @project.disk_path + case exportable_type + when 'Project' + @exportable.disk_path + when 'Group' + @exportable.full_path + else + raise Gitlab::ImportExport::Error.new("Unsupported Exportable Type #{@exportable&.class}") + end end def log_error(details) @@ -100,17 +107,24 @@ module Gitlab end def log_base_data - { - importer: 'Import/Export', - import_jid: @project&.import_state&.jid, - project_id: @project&.id, - project_path: @project&.full_path + log = { + importer: 'Import/Export', + exportable_id: @exportable&.id, + exportable_path: @exportable&.full_path } + + log[:import_jid] = @exportable&.import_state&.jid if exportable_type == 'Project' + + log end def filtered_error_message(message) Projects::ImportErrorFilter.filter_message(message) end + + def exportable_type + @exportable.class.name + end end end end diff --git a/lib/gitlab/instrumentation_helper.rb b/lib/gitlab/instrumentation_helper.rb index e6a5facb2a52144fa91a4a4ffb725d87630f5a78..edaa9c645b45a0c6247780db8ede7841f616bfbc 100644 --- a/lib/gitlab/instrumentation_helper.rb +++ b/lib/gitlab/instrumentation_helper.rb @@ -21,5 +21,49 @@ module Gitlab payload[:rugged_duration_ms] = Gitlab::RuggedInstrumentation.query_time_ms end end + + # Returns the queuing duration for a Sidekiq job in seconds, as a float, if the + # `enqueued_at` field or `created_at` field is available. + # + # * If the job doesn't contain sufficient information, returns nil + # * If the job has a start time in the future, returns 0 + # * If the job contains an invalid start time value, returns nil + # @param [Hash] job a Sidekiq job, represented as a hash + def self.queue_duration_for_job(job) + # Old gitlab-shell messages don't provide enqueued_at/created_at attributes + enqueued_at = job['enqueued_at'] || job['created_at'] + return unless enqueued_at + + enqueued_at_time = convert_to_time(enqueued_at) + return unless enqueued_at_time + + # Its possible that if theres clock-skew between two nodes + # this value may be less than zero. In that event, we record the value + # as zero. + [elapsed_by_absolute_time(enqueued_at_time), 0].max + end + + # Calculates the time in seconds, as a float, from + # the provided start time until now + # + # @param [Time] start + def self.elapsed_by_absolute_time(start) + (Time.now - start).to_f.round(6) + end + private_class_method :elapsed_by_absolute_time + + # Convert a representation of a time into a `Time` value + # + # @param time_value String, Float time representation, or nil + def self.convert_to_time(time_value) + return time_value if time_value.is_a?(Time) + return Time.iso8601(time_value) if time_value.is_a?(String) + return Time.at(time_value) if time_value.is_a?(Numeric) && time_value > 0 + rescue ArgumentError + # Swallow invalid dates. Better to loose some observability + # than bring all background processing down because of a date + # formatting bug in a client + end + private_class_method :convert_to_time end end diff --git a/lib/gitlab/kubernetes/config_maps/aws_node_auth.rb b/lib/gitlab/kubernetes/config_maps/aws_node_auth.rb new file mode 100644 index 0000000000000000000000000000000000000000..ef51cee09ca0653c5287f84b89881b7568630061 --- /dev/null +++ b/lib/gitlab/kubernetes/config_maps/aws_node_auth.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Gitlab + module Kubernetes + module ConfigMaps + class AwsNodeAuth + attr_reader :node_role + + def initialize(node_role) + @node_role = node_role + end + + def generate + Kubeclient::Resource.new( + metadata: metadata, + data: data + ) + end + + private + + def metadata + { + 'name' => 'aws-auth', + 'namespace' => 'kube-system' + } + end + + def data + { 'mapRoles' => instance_role_config(node_role) } + end + + def instance_role_config(role) + [{ + 'rolearn' => role, + 'username' => 'system:node:{{EC2PrivateDNSName}}', + 'groups' => [ + 'system:bootstrappers', + 'system:nodes' + ] + }].to_yaml + end + end + end + end +end diff --git a/lib/gitlab/kubernetes/helm.rb b/lib/gitlab/kubernetes/helm.rb index 16ed0cb0f8eefb67b447032065ee18c5d4b62f2a..b5181670b937d26b77728b96f9dc16abd5ecbc45 100644 --- a/lib/gitlab/kubernetes/helm.rb +++ b/lib/gitlab/kubernetes/helm.rb @@ -3,8 +3,8 @@ module Gitlab module Kubernetes module Helm - HELM_VERSION = '2.14.3' - KUBECTL_VERSION = '1.11.10' + HELM_VERSION = '2.16.1' + KUBECTL_VERSION = '1.13.12' NAMESPACE = 'gitlab-managed-apps' SERVICE_ACCOUNT = 'tiller' CLUSTER_ROLE_BINDING = 'tiller-admin' diff --git a/lib/gitlab/kubernetes/helm/install_command.rb b/lib/gitlab/kubernetes/helm/install_command.rb index f572bc43533df5d41fcd691dcef994741c9e549e..ccb053f507d3234d2c9a2a05476e2e337846ba00 100644 --- a/lib/gitlab/kubernetes/helm/install_command.rb +++ b/lib/gitlab/kubernetes/helm/install_command.rb @@ -40,7 +40,7 @@ module Gitlab private def repository_update_command - 'helm repo update' if repository + 'helm repo update' end # Uses `helm upgrade --install` which means we can use this for both diff --git a/lib/gitlab/metrics/dashboard/errors.rb b/lib/gitlab/metrics/dashboard/errors.rb index d41bd2c43c7cadb0bc29b49a18f1e2e19b791e32..264ea0488e77f6686c7ef46b7a4018d0aad26dd2 100644 --- a/lib/gitlab/metrics/dashboard/errors.rb +++ b/lib/gitlab/metrics/dashboard/errors.rb @@ -9,6 +9,7 @@ module Gitlab module Errors DashboardProcessingError = Class.new(StandardError) PanelNotFoundError = Class.new(StandardError) + MissingIntegrationError = Class.new(StandardError) LayoutError = Class.new(DashboardProcessingError) MissingQueryError = Class.new(DashboardProcessingError) @@ -22,6 +23,10 @@ module Gitlab error("#{dashboard_path} could not be found.", :not_found) when PanelNotFoundError error(error.message, :not_found) + when ::Grafana::Client::Error + error(error.message, :service_unavailable) + when MissingIntegrationError + error('Proxy support for this API is not available currently', :bad_request) else raise error end diff --git a/lib/gitlab/metrics/dashboard/finder.rb b/lib/gitlab/metrics/dashboard/finder.rb index 297f109ff812db5d766ac174ee88bdd3b603618c..268112f33a99f6d98db042eb687f8006a87e0ae6 100644 --- a/lib/gitlab/metrics/dashboard/finder.rb +++ b/lib/gitlab/metrics/dashboard/finder.rb @@ -12,6 +12,7 @@ module Gitlab # @param project [Project] # @param user [User] # @param environment [Environment] + # @param options [Hash<Symbol,Any>] # @param options - embedded [Boolean] Determines whether the # dashboard is to be rendered as part of an # issue or location other than the primary @@ -31,6 +32,8 @@ module Gitlab # @param options - cluster [Cluster] # @param options - cluster_type [Symbol] The level of # cluster, one of [:admin, :project, :group] + # @param options - grafana_url [String] URL pointing + # to a grafana dashboard panel # @return [Hash] def find(project, user, options = {}) service_for(options) diff --git a/lib/gitlab/metrics/dashboard/processor.rb b/lib/gitlab/metrics/dashboard/processor.rb index bfdee76a818e7c9647e0fe903418e85e3d203a13..9566e5afb9a49915437bc3c4f1772f332f437753 100644 --- a/lib/gitlab/metrics/dashboard/processor.rb +++ b/lib/gitlab/metrics/dashboard/processor.rb @@ -17,7 +17,10 @@ module Gitlab # Returns a new dashboard hash with the results of # running transforms on the dashboard. + # @return [Hash, nil] def process + return unless @dashboard + @dashboard.deep_symbolize_keys.tap do |dashboard| @sequence.each do |stage| stage.new(@project, dashboard, @params).transform! diff --git a/lib/gitlab/metrics/dashboard/service_selector.rb b/lib/gitlab/metrics/dashboard/service_selector.rb index 10b686fbb81fe183909977d9dccf6699442b67cb..aee7f6685ad87dee42f9953c0a90a096701e170d 100644 --- a/lib/gitlab/metrics/dashboard/service_selector.rb +++ b/lib/gitlab/metrics/dashboard/service_selector.rb @@ -18,6 +18,7 @@ module Gitlab # @return [Gitlab::Metrics::Dashboard::Services::BaseService] def call(params) return SERVICES::CustomMetricEmbedService if custom_metric_embed?(params) + return SERVICES::GrafanaMetricEmbedService if grafana_metric_embed?(params) return SERVICES::DynamicEmbedService if dynamic_embed?(params) return SERVICES::DefaultEmbedService if params[:embedded] return SERVICES::SystemDashboardService if system_dashboard?(params[:dashboard_path]) @@ -40,6 +41,10 @@ module Gitlab SERVICES::CustomMetricEmbedService.valid_params?(params) end + def grafana_metric_embed?(params) + SERVICES::GrafanaMetricEmbedService.valid_params?(params) + end + def dynamic_embed?(params) SERVICES::DynamicEmbedService.valid_params?(params) end diff --git a/lib/gitlab/metrics/dashboard/stages/common_metrics_inserter.rb b/lib/gitlab/metrics/dashboard/stages/common_metrics_inserter.rb index 188912bedb42cf50b0e2cbb618d8a113c9fd8924..62479ed6de45038803a044f2dc8df15e27cb9456 100644 --- a/lib/gitlab/metrics/dashboard/stages/common_metrics_inserter.rb +++ b/lib/gitlab/metrics/dashboard/stages/common_metrics_inserter.rb @@ -9,7 +9,7 @@ module Gitlab # find a corresponding database record. If found, # includes the record's id in the dashboard config. def transform! - common_metrics = ::PrometheusMetric.common + common_metrics = ::PrometheusMetricsFinder.new(common: true).execute for_metrics do |metric| metric_record = common_metrics.find { |m| m.identifier == metric[:id] } diff --git a/lib/gitlab/metrics/dashboard/stages/grafana_formatter.rb b/lib/gitlab/metrics/dashboard/stages/grafana_formatter.rb new file mode 100644 index 0000000000000000000000000000000000000000..ce75c54d01497919d40b5b243567ec76002cbc89 --- /dev/null +++ b/lib/gitlab/metrics/dashboard/stages/grafana_formatter.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +module Gitlab + module Metrics + module Dashboard + module Stages + class GrafanaFormatter < BaseStage + include Gitlab::Utils::StrongMemoize + + CHART_TYPE = 'area-chart' + PROXY_PATH = 'api/v1/query_range' + + # Reformats the specified panel in the Gitlab + # dashboard-yml format + def transform! + InputFormatValidator.new( + grafana_dashboard, + datasource, + panel, + query_params + ).validate! + + new_dashboard = formatted_dashboard + + dashboard.clear + dashboard.merge!(new_dashboard) + end + + private + + def formatted_dashboard + { panel_groups: [{ panels: [formatted_panel] }] } + end + + def formatted_panel + { + title: panel[:title], + type: CHART_TYPE, + y_label: '', # Grafana panels do not include a Y-Axis label + metrics: panel[:targets].map.with_index do |target, idx| + formatted_metric(target, idx) + end + } + end + + def formatted_metric(metric, idx) + { + id: "#{metric[:legendFormat]}_#{idx}", + query_range: format_query(metric), + label: replace_variables(metric[:legendFormat]), + prometheus_endpoint_path: prometheus_endpoint_for_metric(metric) + }.compact + end + + # Panel specified by the url from the Grafana dashboard + def panel + strong_memoize(:panel) do + grafana_dashboard[:dashboard][:panels].find do |panel| + panel[:id].to_s == query_params[:panelId] + end + end + end + + # Grafana url query parameters. Includes information + # on which panel to select and time range. + def query_params + strong_memoize(:query_params) do + Gitlab::Metrics::Dashboard::Url.parse_query(grafana_url) + end + end + + # Endpoint which will return prometheus metric data + # for the metric + def prometheus_endpoint_for_metric(metric) + Gitlab::Routing.url_helpers.project_grafana_api_path( + project, + datasource_id: datasource[:id], + proxy_path: PROXY_PATH, + query: format_query(metric) + ) + end + + # Reformats query for compatibility with prometheus api. + def format_query(metric) + expression = remove_new_lines(metric[:expr]) + expression = replace_variables(expression) + expression = replace_global_variables(expression, metric) + + expression + end + + # Accomodates instance-defined Grafana variables. + # These are variables defined by users, and values + # must be provided in the query parameters. + def replace_variables(expression) + return expression unless grafana_dashboard[:dashboard][:templating] + + grafana_dashboard[:dashboard][:templating][:list] + .sort_by { |variable| variable[:name].length } + .each do |variable| + variable_value = query_params[:"var-#{variable[:name]}"] + + expression = expression.gsub("$#{variable[:name]}", variable_value) + expression = expression.gsub("[[#{variable[:name]}]]", variable_value) + expression = expression.gsub("{{#{variable[:name]}}}", variable_value) + end + + expression + end + + # Replaces Grafana global built-in variables with values. + # Only $__interval and $__from and $__to are supported. + # + # See https://grafana.com/docs/reference/templating/#global-built-in-variables + def replace_global_variables(expression, metric) + expression = expression.gsub('$__interval', metric[:interval]) if metric[:interval] + expression = expression.gsub('$__from', query_params[:from]) + expression = expression.gsub('$__to', query_params[:to]) + + expression + end + + # Removes new lines from expression. + def remove_new_lines(expression) + expression.gsub(/\R+/, '') + end + + # Grafana datasource object corresponding to the + # specified dashboard + def datasource + params[:datasource] + end + + # The specified Grafana dashboard + def grafana_dashboard + params[:grafana_dashboard] + end + + # The URL specifying which Grafana panel to embed + def grafana_url + params[:grafana_url] + end + end + + class InputFormatValidator + include ::Gitlab::Metrics::Dashboard::Errors + + attr_reader :grafana_dashboard, :datasource, :panel, :query_params + + UNSUPPORTED_GRAFANA_GLOBAL_VARS = %w( + $__interval_ms + $__timeFilter + $__name + $timeFilter + $interval + ).freeze + + def initialize(grafana_dashboard, datasource, panel, query_params) + @grafana_dashboard = grafana_dashboard + @datasource = datasource + @panel = panel + @query_params = query_params + end + + def validate! + validate_query_params! + validate_datasource! + validate_panel_type! + validate_variable_definitions! + validate_global_variables! + end + + private + + def validate_datasource! + return if datasource[:access] == 'proxy' && datasource[:type] == 'prometheus' + + raise_error 'Only Prometheus datasources with proxy access in Grafana are supported.' + end + + def validate_query_params! + return if [:panelId, :from, :to].all? { |param| query_params.include?(param) } + + raise_error 'Grafana query parameters must include panelId, from, and to.' + end + + def validate_panel_type! + return if panel[:type] == 'graph' && panel[:lines] + + raise_error 'Panel type must be a line graph.' + end + + def validate_variable_definitions! + return unless grafana_dashboard[:dashboard][:templating] + + return if grafana_dashboard[:dashboard][:templating][:list].all? do |variable| + query_params[:"var-#{variable[:name]}"].present? + end + + raise_error 'All Grafana variables must be defined in the query parameters.' + end + + def validate_global_variables! + return unless panel_contains_unsupported_vars? + + raise_error 'Prometheus must not include' + end + + def panel_contains_unsupported_vars? + panel[:targets].any? do |target| + UNSUPPORTED_GRAFANA_GLOBAL_VARS.any? do |variable| + target[:expr].include?(variable) + end + end + end + + def raise_error(message) + raise DashboardProcessingError.new(message) + end + end + end + end + end +end diff --git a/lib/gitlab/metrics/dashboard/stages/project_metrics_inserter.rb b/lib/gitlab/metrics/dashboard/stages/project_metrics_inserter.rb index 643be309992795bc390e8d349991bbfa8a9049ab..c0f67d445f8b961df6bff2f3722ba6236a9e5646 100644 --- a/lib/gitlab/metrics/dashboard/stages/project_metrics_inserter.rb +++ b/lib/gitlab/metrics/dashboard/stages/project_metrics_inserter.rb @@ -9,7 +9,7 @@ module Gitlab # config. If there are no project-specific metrics, # this will have no effect. def transform! - project.prometheus_metrics.each do |project_metric| + PrometheusMetricsFinder.new(project: project).execute.each do |project_metric| group = find_or_create_panel_group(dashboard[:panel_groups], project_metric) panel = find_or_create_panel(group[:panels], project_metric) find_or_create_metric(panel[:metrics], project_metric) diff --git a/lib/gitlab/metrics/dashboard/url.rb b/lib/gitlab/metrics/dashboard/url.rb index 94f8b2e02b1a718ccfafb1cf4f1bc98541bed7af..712f769bbeb95e89d93be1f6ab32d4f420d8545b 100644 --- a/lib/gitlab/metrics/dashboard/url.rb +++ b/lib/gitlab/metrics/dashboard/url.rb @@ -14,17 +14,31 @@ module Gitlab def regex %r{ (?<url> - #{Regexp.escape(Gitlab.config.gitlab.url)} - \/#{Project.reference_pattern} + #{gitlab_pattern} + #{project_pattern} (?:\/\-)? \/environments \/(?<environment>\d+) \/metrics - (?<query> - \?[a-zA-Z0-9%.()+_=-]+ - (&[a-zA-Z0-9%.()+_=-]+)* - )? - (?<anchor>\#[a-z0-9_-]+)? + #{query_pattern} + #{anchor_pattern} + ) + }x + end + + # Matches dashboard urls for a Grafana embed. + # + # EX - https://<host>/<namespace>/<project>/grafana/metrics_dashboard + def grafana_regex + %r{ + (?<url> + #{gitlab_pattern} + #{project_pattern} + (?:\/\-)? + \/grafana + \/metrics_dashboard + #{query_pattern} + #{anchor_pattern} ) }x end @@ -45,6 +59,24 @@ module Gitlab def build_dashboard_url(*args) Gitlab::Routing.url_helpers.metrics_dashboard_namespace_project_environment_url(*args) end + + private + + def gitlab_pattern + Regexp.escape(Gitlab.config.gitlab.url) + end + + def project_pattern + "\/#{Project.reference_pattern}" + end + + def query_pattern + '(?<query>\?[a-zA-Z0-9%.()+_=-]+(&[a-zA-Z0-9%.()+_=-]+)*)?' + end + + def anchor_pattern + '(?<anchor>\#[a-z0-9_-]+)?' + end end end end diff --git a/lib/gitlab/metrics/exporter/web_exporter.rb b/lib/gitlab/metrics/exporter/web_exporter.rb index 3940f6fa155a3956da8f7c9d9479e4b08a21dc90..b6a27d8556a95206e4c2eaccf436a74aa8409036 100644 --- a/lib/gitlab/metrics/exporter/web_exporter.rb +++ b/lib/gitlab/metrics/exporter/web_exporter.rb @@ -20,6 +20,10 @@ module Gitlab def initialize super + # DEPRECATED: + # these `readiness_checks` are deprecated + # as presenting no value in a way how we run + # application: https://gitlab.com/gitlab-org/gitlab/issues/35343 self.readiness_checks = [ WebExporter::ExporterCheck.new(self), Gitlab::HealthChecks::PumaCheck, @@ -35,6 +39,10 @@ module Gitlab File.join(Rails.root, 'log', 'web_exporter.log') end + def mark_as_not_running! + @running = false + end + private def start_working @@ -43,24 +51,9 @@ module Gitlab end def stop_working - @running = false - wait_in_blackout_period if server && thread.alive? + mark_as_not_running! super end - - def wait_in_blackout_period - return unless blackout_seconds > 0 - - @server.logger.info( - message: 'starting blackout...', - duration_s: blackout_seconds) - - sleep(blackout_seconds) - end - - def blackout_seconds - settings['blackout_seconds'].to_i - end end end end diff --git a/lib/gitlab/metrics/requests_rack_middleware.rb b/lib/gitlab/metrics/requests_rack_middleware.rb index 085e28123a75d7a3f4a9df20183ce1291540000a..b57f9a19f8ee4b0020a4928c1725565833d83724 100644 --- a/lib/gitlab/metrics/requests_rack_middleware.rb +++ b/lib/gitlab/metrics/requests_rack_middleware.rb @@ -35,7 +35,7 @@ module Gitlab def self.initialize_http_request_duration_seconds HTTP_METHODS.each do |method, statuses| statuses.each do |status| - http_request_duration_seconds.get({ method: method, status: status.to_i }) + http_request_duration_seconds.get({ method: method, status: status.to_s }) end end end @@ -49,7 +49,7 @@ module Gitlab status, headers, body = @app.call(env) elapsed = Time.now.to_f - started - RequestsRackMiddleware.http_request_duration_seconds.observe({ method: method, status: status }, elapsed) + RequestsRackMiddleware.http_request_duration_seconds.observe({ method: method, status: status.to_s }, elapsed) [status, headers, body] rescue diff --git a/lib/gitlab/pagination/base.rb b/lib/gitlab/pagination/base.rb new file mode 100644 index 0000000000000000000000000000000000000000..90fa1f8d1ec0a125cab6b0e493b772b2bd24c5da --- /dev/null +++ b/lib/gitlab/pagination/base.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +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 + 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 + end + end +end diff --git a/lib/gitlab/pagination/offset_pagination.rb b/lib/gitlab/pagination/offset_pagination.rb new file mode 100644 index 0000000000000000000000000000000000000000..bf31f252a6bbc69309661a41ea98f9987d47e112 --- /dev/null +++ b/lib/gitlab/pagination/offset_pagination.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Gitlab + module Pagination + class OffsetPagination < Base + attr_reader :request_context + delegate :params, :header, :request, to: :request_context + + def initialize(request_context) + @request_context = request_context + end + + def paginate(relation) + paginate_with_limit_optimization(add_default_order(relation)).tap do |data| + add_pagination_headers(data) + end + end + + private + + def paginate_with_limit_optimization(relation) + pagination_data = relation.page(params[:page]).per(params[:per_page]) + return pagination_data unless pagination_data.is_a?(ActiveRecord::Relation) + return pagination_data unless Feature.enabled?(:api_kaminari_count_with_limit) + + limited_total_count = pagination_data.total_count_with_limit + if limited_total_count > Kaminari::ActiveRecordRelationMethods::MAX_COUNT_LIMIT + # The call to `total_count_with_limit` memoizes `@arel` because of a call to `references_eager_loaded_tables?` + # We need to call `reset` because `without_count` relies on `@arel` being unmemoized + pagination_data.reset.without_count + else + pagination_data + end + end + + def add_default_order(relation) + if relation.is_a?(ActiveRecord::Relation) && relation.order_values.empty? + relation = relation.order(:id) # rubocop: disable CodeReuse/ActiveRecord + end + + relation + end + + def add_pagination_headers(paginated_data) + header 'X-Per-Page', paginated_data.limit_value.to_s + header 'X-Page', paginated_data.current_page.to_s + header 'X-Next-Page', paginated_data.next_page.to_s + header 'X-Prev-Page', paginated_data.prev_page.to_s + header 'Link', pagination_links(paginated_data) + + return if data_without_counts?(paginated_data) + + header 'X-Total', paginated_data.total_count.to_s + header 'X-Total-Pages', total_pages(paginated_data).to_s + end + + def pagination_links(paginated_data) + [].tap do |links| + links << %(<#{page_href(page: paginated_data.prev_page)}>; rel="prev") if paginated_data.prev_page + links << %(<#{page_href(page: paginated_data.next_page)}>; rel="next") if paginated_data.next_page + links << %(<#{page_href(page: 1)}>; rel="first") + + links << %(<#{page_href(page: total_pages(paginated_data))}>; rel="last") unless data_without_counts?(paginated_data) + end.join(', ') + end + + def total_pages(paginated_data) + # Ensure there is in total at least 1 page + [paginated_data.total_pages, 1].max + end + + def data_without_counts?(paginated_data) + paginated_data.is_a?(Kaminari::PaginatableWithoutCount) + end + end + end +end diff --git a/lib/gitlab/project_authorizations.rb b/lib/gitlab/project_authorizations.rb index a9270cd536e08996ab6563a5db7e4cde254cf2c3..4e5e2d4a6a96c90300e6e7a7d2ae25f1b19fb240 100644 --- a/lib/gitlab/project_authorizations.rb +++ b/lib/gitlab/project_authorizations.rb @@ -57,7 +57,7 @@ module Gitlab private # Builds a recursive CTE that gets all the groups the current user has - # access to, including any nested groups. + # access to, including any nested groups and any shared groups. def recursive_cte cte = Gitlab::SQL::RecursiveCTE.new(:namespaces_cte) members = Member.arel_table @@ -68,20 +68,27 @@ module Gitlab .select([namespaces[:id], members[:access_level]]) .except(:order) + if Feature.enabled?(:share_group_with_group) + # 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) + .joins(join_members_on_group_group_links) + end + # Sub groups of any groups the user is a member of. cte << Group.select([ namespaces[:id], greatest(members[:access_level], cte.table[:access_level], 'access_level') ]) .joins(join_cte(cte)) - .joins(join_members) + .joins(join_members_on_namespaces) .except(:order) cte end # Builds a LEFT JOIN to join optional memberships onto the CTE. - def join_members + def join_members_on_namespaces members = Member.arel_table namespaces = Namespace.arel_table @@ -94,6 +101,23 @@ module Gitlab Arel::Nodes::OuterJoin.new(members, Arel::Nodes::On.new(cond)) end + def join_group_group_links + group_group_links = GroupGroupLink.arel_table + namespaces = Namespace.arel_table + + cond = group_group_links[:shared_group_id].eq(namespaces[:id]) + Arel::Nodes::InnerJoin.new(group_group_links, Arel::Nodes::On.new(cond)) + end + + def join_members_on_group_group_links + group_group_links = GroupGroupLink.arel_table + members = Member.arel_table + + cond = group_group_links[:shared_with_group_id].eq(members[:source_id]) + .and(members[:user_id].eq(user.id)) + Arel::Nodes::InnerJoin.new(members, Arel::Nodes::On.new(cond)) + end + # Builds an INNER JOIN to join namespaces onto the CTE. def join_cte(cte) namespaces = Namespace.arel_table diff --git a/lib/gitlab/project_template.rb b/lib/gitlab/project_template.rb index fa1d12038423e346d1dd4a52c79af43f87a9f4d3..279fc4aa3759f07409328c73718d2aeb792c9679 100644 --- a/lib/gitlab/project_template.rb +++ b/lib/gitlab/project_template.rb @@ -53,7 +53,8 @@ module Gitlab ProjectTemplate.new('nfjekyll', 'Netlify/Jekyll', _('A Jekyll site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features.'), 'https://gitlab.com/pages/nfjekyll', 'illustrations/logos/netlify.svg'), ProjectTemplate.new('nfplainhtml', 'Netlify/Plain HTML', _('A plain HTML site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features.'), 'https://gitlab.com/pages/nfplain-html', 'illustrations/logos/netlify.svg'), ProjectTemplate.new('nfgitbook', 'Netlify/GitBook', _('A GitBook site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features.'), 'https://gitlab.com/pages/nfgitbook', 'illustrations/logos/netlify.svg'), - ProjectTemplate.new('nfhexo', 'Netlify/Hexo', _('A Hexo site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features.'), 'https://gitlab.com/pages/nfhexo', 'illustrations/logos/netlify.svg') + ProjectTemplate.new('nfhexo', 'Netlify/Hexo', _('A Hexo site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features.'), 'https://gitlab.com/pages/nfhexo', 'illustrations/logos/netlify.svg'), + ProjectTemplate.new('serverless_framework', 'Serverless Framework/JS', _('A basic page and serverless function that uses AWS Lambda, AWS API Gateway, and GitLab Pages'), 'https://gitlab.com/gitlab-org/project-templates/serverless-framework', 'illustrations/logos/serverless_framework.svg') ].freeze class << self diff --git a/lib/gitlab/prometheus/internal.rb b/lib/gitlab/prometheus/internal.rb new file mode 100644 index 0000000000000000000000000000000000000000..d59352119ba24bbcb3a25c167e9a204a7e75b5cb --- /dev/null +++ b/lib/gitlab/prometheus/internal.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Gitlab + module Prometheus + class Internal + def self.uri + return if listen_address.blank? + + if listen_address.starts_with?('0.0.0.0:') + # 0.0.0.0:9090 + port = ':' + listen_address.split(':').second + 'http://localhost' + port + + elsif listen_address.starts_with?(':') + # :9090 + 'http://localhost' + listen_address + + elsif listen_address.starts_with?('http') + # https://localhost:9090 + listen_address + + else + # localhost:9090 + 'http://' + listen_address + end + end + + def self.listen_address + Gitlab.config.prometheus.listen_address.to_s if Gitlab.config.prometheus + rescue Settingslogic::MissingSetting + Gitlab::AppLogger.error('Prometheus listen_address is not present in config/gitlab.yml') + + nil + end + + def self.prometheus_enabled? + Gitlab.config.prometheus.enable if Gitlab.config.prometheus + rescue Settingslogic::MissingSetting + Gitlab::AppLogger.error('prometheus.enable is not present in config/gitlab.yml') + + false + end + end + end +end diff --git a/lib/gitlab/prometheus/metric_group.rb b/lib/gitlab/prometheus/metric_group.rb index caf0d453b6f3c96ff99b11588e6579cf00506483..1b6f7282eb3491387ce6d63de6f3c1078911da6c 100644 --- a/lib/gitlab/prometheus/metric_group.rb +++ b/lib/gitlab/prometheus/metric_group.rb @@ -11,13 +11,15 @@ module Gitlab validates :name, :priority, :metrics, presence: true def self.common_metrics - all_groups = ::PrometheusMetric.common.group_by(&:group_title).map do |name, metrics| - MetricGroup.new( - name: name, - priority: metrics.map(&:priority).max, - metrics: metrics.map(&:to_query_metric) - ) - end + all_groups = ::PrometheusMetricsFinder.new(common: true).execute + .group_by(&:group_title) + .map do |name, metrics| + MetricGroup.new( + name: name, + priority: metrics.map(&:priority).max, + metrics: metrics.map(&:to_query_metric) + ) + end all_groups.sort_by(&:priority).reverse end diff --git a/lib/gitlab/prometheus/queries/knative_invocation_query.rb b/lib/gitlab/prometheus/queries/knative_invocation_query.rb index 2691abe46d6288bf9884c998a3e951b2a412bfdd..8873608c411348bef643eaead11976e487c6f5a1 100644 --- a/lib/gitlab/prometheus/queries/knative_invocation_query.rb +++ b/lib/gitlab/prometheus/queries/knative_invocation_query.rb @@ -7,11 +7,14 @@ module Gitlab include QueryAdditionalMetrics def query(serverless_function_id) - PrometheusMetric - .find_by_identifier(:system_metrics_knative_function_invocation_count) - .to_query_metric.tap do |q| - q.queries[0][:result] = run_query(q.queries[0][:query_range], context(serverless_function_id)) - end + PrometheusMetricsFinder + .new(identifier: :system_metrics_knative_function_invocation_count, common: true) + .execute + .first + .to_query_metric + .tap do |q| + q.queries[0][:result] = run_query(q.queries[0][:query_range], context(serverless_function_id)) + end end protected diff --git a/lib/gitlab/quick_actions/issuable_actions.rb b/lib/gitlab/quick_actions/issuable_actions.rb index 340ec75c5f1d84d97818dfb08380e85a3d0a14e0..942f90e804070650454c7aad92272e751f73123a 100644 --- a/lib/gitlab/quick_actions/issuable_actions.rb +++ b/lib/gitlab/quick_actions/issuable_actions.rb @@ -234,7 +234,7 @@ module Gitlab "#{comment} #{SHRUG}" end - desc _("Append the comment with %{TABLEFLIP}") % { tableflip: TABLEFLIP } + desc _("Append the comment with %{tableflip}") % { tableflip: TABLEFLIP } params '<Comment>' types Issuable substitution :tableflip do |comment| diff --git a/lib/gitlab/quick_actions/issue_actions.rb b/lib/gitlab/quick_actions/issue_actions.rb index 404e0c31871394b21278e8f894b062c9715e7676..838aefb59f07e23b4817cd5dc6398337a7298a22 100644 --- a/lib/gitlab/quick_actions/issue_actions.rb +++ b/lib/gitlab/quick_actions/issue_actions.rb @@ -174,18 +174,14 @@ module Gitlab params '<Zoom URL>' types Issue condition do - zoom_link_service.can_add_link? + @zoom_service = zoom_link_service + @zoom_service.can_add_link? end parse_params do |link| - zoom_link_service.parse_link(link) + @zoom_service.parse_link(link) end command :zoom do |link| - result = zoom_link_service.add_link(link) - - if result.success? - @updates[:description] = result.payload[:description] - end - + result = @zoom_service.add_link(link) @execution_message[:zoom] = result.message end @@ -194,15 +190,11 @@ module Gitlab execution_message _('Zoom meeting removed') types Issue condition do - zoom_link_service.can_remove_link? + @zoom_service = zoom_link_service + @zoom_service.can_remove_link? end command :remove_zoom do - result = zoom_link_service.remove_link - - if result.success? - @updates[:description] = result.payload[:description] - end - + result = @zoom_service.remove_link @execution_message[:remove_zoom] = result.message end diff --git a/lib/gitlab/redis/wrapper.rb b/lib/gitlab/redis/wrapper.rb index fa1615a5953643b6453b38256a62a30d2f9b55ec..412d00c69394f669d0ee5e512bb6511a3d076cd9 100644 --- a/lib/gitlab/redis/wrapper.rb +++ b/lib/gitlab/redis/wrapper.rb @@ -25,6 +25,8 @@ module Gitlab 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] end size diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb index 3d1f15c72ae82a1d4d2141026ea862d418a10281..e3a434dfe35b0709291260375eb1969cd43bcac8 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -120,13 +120,26 @@ module Gitlab @breakline_regex ||= /\r\n|\r|\n/ end + # https://docs.aws.amazon.com/general/latest/gr/acct-identifiers.html + def aws_account_id_regex + /\A\d{12}\z/ + end + + def aws_account_id_message + 'must be a 12-digit number' + end + # https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html def aws_arn_regex /\Aarn:\S+\z/ end def aws_arn_regex_message - "must be a valid Amazon Resource Name" + 'must be a valid Amazon Resource Name' + end + + def utc_date_regex + @utc_date_regex ||= /\A[0-9]{4}-[0-9]{2}-[0-9]{2}\z/.freeze end end end diff --git a/lib/gitlab/search/found_blob.rb b/lib/gitlab/search/found_blob.rb index fa09ecbdf3053266ef87fcfc69b6dd3daae0642f..360239a84e42876dcec39a19ff562be113610636 100644 --- a/lib/gitlab/search/found_blob.rb +++ b/lib/gitlab/search/found_blob.rb @@ -8,20 +8,20 @@ module Gitlab include BlobLanguageFromGitAttributes include Gitlab::Utils::StrongMemoize - attr_reader :project, :content_match, :blob_filename + attr_reader :project, :content_match, :blob_path - FILENAME_REGEXP = /\A(?<ref>[^:]*):(?<filename>[^\x00]*)\x00/.freeze - CONTENT_REGEXP = /^(?<ref>[^:]*):(?<filename>[^\x00]*)\x00(?<startline>\d+)\x00/.freeze + PATH_REGEXP = /\A(?<ref>[^:]*):(?<path>[^\x00]*)\x00/.freeze + CONTENT_REGEXP = /^(?<ref>[^:]*):(?<path>[^\x00]*)\x00(?<startline>\d+)\x00/.freeze def self.preload_blobs(blobs) - to_fetch = blobs.select { |blob| blob.is_a?(self) && blob.blob_filename } + to_fetch = blobs.select { |blob| blob.is_a?(self) && blob.blob_path } to_fetch.each { |blob| blob.fetch_blob } end def initialize(opts = {}) @id = opts.fetch(:id, nil) - @binary_filename = opts.fetch(:filename, nil) + @binary_path = opts.fetch(:path, nil) @binary_basename = opts.fetch(:basename, nil) @ref = opts.fetch(:ref, nil) @startline = opts.fetch(:startline, nil) @@ -34,7 +34,7 @@ module Gitlab # Allow those to just pass project_id instead. @project_id = opts.fetch(:project_id, nil) @content_match = opts.fetch(:content_match, nil) - @blob_filename = opts.fetch(:blob_filename, nil) + @blob_path = opts.fetch(:blob_path, nil) @repository = opts.fetch(:repository, nil) end @@ -50,16 +50,16 @@ module Gitlab @startline ||= parsed_content[:startline] end - # binary_filename is used for running filters on all matches, - # for grepped results (which use content_match), we get - # filename from the beginning of the grepped result which is faster - # then parsing whole snippet - def binary_filename - @binary_filename ||= content_match ? search_result_filename : parsed_content[:binary_filename] + # binary_path is used for running filters on all matches. + # For grepped results (which use content_match), we get + # the path from the beginning of the grepped result which is faster + # than parsing the whole snippet + def binary_path + @binary_path ||= content_match ? search_result_path : parsed_content[:binary_path] end - def filename - @filename ||= encode_utf8(@binary_filename || parsed_content[:binary_filename]) + def path + @path ||= encode_utf8(@binary_path || parsed_content[:binary_path]) end def basename @@ -70,10 +70,6 @@ module Gitlab @data ||= encode_utf8(@binary_data || parsed_content[:binary_data]) end - def path - filename - end - def project_id @project_id || @project&.id end @@ -83,16 +79,16 @@ module Gitlab end def fetch_blob - path = [ref, blob_filename] - missing_blob = { binary_filename: blob_filename } + path = [ref, blob_path] + missing_blob = { binary_path: blob_path } BatchLoader.for(path).batch(default_value: missing_blob) do |refs, loader| Gitlab::Git::Blob.batch(repository, refs, blob_size_limit: 1024).each do |blob| # if the blob couldn't be fetched for some reason, - # show at least the blob filename + # show at least the blob path data = { id: blob.id, - binary_filename: blob.path, + binary_path: blob.path, binary_basename: path_without_extension(blob.path), ref: ref, startline: 1, @@ -107,8 +103,8 @@ module Gitlab private - def search_result_filename - content_match.match(FILENAME_REGEXP) { |matches| matches[:filename] } + def search_result_path + content_match.match(PATH_REGEXP) { |matches| matches[:path] } end def path_without_extension(path) @@ -119,7 +115,7 @@ module Gitlab strong_memoize(:parsed_content) do if content_match parse_search_result - elsif blob_filename + elsif blob_path fetch_blob else {} @@ -129,7 +125,7 @@ module Gitlab def parse_search_result ref = nil - filename = nil + path = nil basename = nil data = [] @@ -138,17 +134,17 @@ module Gitlab content_match.each_line.each_with_index do |line, index| prefix ||= line.match(CONTENT_REGEXP)&.tap do |matches| ref = matches[:ref] - filename = matches[:filename] + path = matches[:path] startline = matches[:startline] startline = startline.to_i - index - basename = path_without_extension(filename) + basename = path_without_extension(path) end data << line.sub(prefix.to_s, '') end { - binary_filename: filename, + binary_path: path, binary_basename: basename, ref: ref, startline: startline, diff --git a/lib/gitlab/seeder.rb b/lib/gitlab/seeder.rb index 8e2f16271eb44605d5a968e71b81e1b3af96713a..f96346322db5eb6e3b8dcf1337998dff13ca2495 100644 --- a/lib/gitlab/seeder.rb +++ b/lib/gitlab/seeder.rb @@ -14,7 +14,71 @@ end module Gitlab class Seeder + extend ActionView::Helpers::NumberHelper + + ESTIMATED_INSERT_PER_MINUTE = 2_000_000 + MASS_INSERT_ENV = 'MASS_INSERT' + + module ProjectSeed + extend ActiveSupport::Concern + + included do + scope :not_mass_generated, -> do + where.not("path LIKE '#{Gitlab::Seeder::Projects::MASS_INSERT_NAME_START}%'") + end + end + end + + module UserSeed + extend ActiveSupport::Concern + + included do + scope :not_mass_generated, -> do + where.not("username LIKE '#{Gitlab::Seeder::Users::MASS_INSERT_USERNAME_START}%'") + end + end + end + + def self.with_mass_insert(size, model) + humanized_model_name = model.is_a?(String) ? model : model.model_name.human.pluralize(size) + + if !ENV[MASS_INSERT_ENV] && !ENV['CI'] + puts "\nSkipping mass insertion for #{humanized_model_name}." + puts "Consider running the seed with #{MASS_INSERT_ENV}=1" + return + end + + humanized_size = number_with_delimiter(size) + estimative = estimated_time_message(size) + + puts "\nCreating #{humanized_size} #{humanized_model_name}." + puts estimative + + yield + + puts "\n#{number_with_delimiter(size)} #{humanized_model_name} created!" + end + + def self.estimated_time_message(size) + estimated_minutes = (size.to_f / ESTIMATED_INSERT_PER_MINUTE).round + humanized_minutes = 'minute'.pluralize(estimated_minutes) + + if estimated_minutes.zero? + "Rough estimated time: less than a minute â°" + else + "Rough estimated time: #{estimated_minutes} #{humanized_minutes} â°" + end + end + def self.quiet + # Disable database insertion logs so speed isn't limited by ability to print to console + old_logger = ActiveRecord::Base.logger + ActiveRecord::Base.logger = nil + + # Additional seed logic for models. + Project.include(ProjectSeed) + User.include(UserSeed) + mute_notifications mute_mailer @@ -23,6 +87,7 @@ module Gitlab yield SeedFu.quiet = false + ActiveRecord::Base.logger = old_logger puts "\nOK".color(:green) end diff --git a/lib/gitlab/serializer/pagination.rb b/lib/gitlab/serializer/pagination.rb index eb242cc7c20132ed0cf0554ec817bae084975700..bb7571dd66a70846d2bc43b879faaec549274e9d 100644 --- a/lib/gitlab/serializer/pagination.rb +++ b/lib/gitlab/serializer/pagination.rb @@ -4,7 +4,6 @@ module Gitlab module Serializer class Pagination InvalidResourceError = Class.new(StandardError) - include ::API::Helpers::Pagination def initialize(request, response) @request = request @@ -13,13 +12,13 @@ module Gitlab def paginate(resource) if resource.respond_to?(:page) - super(resource) + ::Gitlab::Pagination::OffsetPagination.new(self).paginate(resource) else raise InvalidResourceError end end - # Methods needed by `API::Helpers::Pagination` + # Methods needed by `Gitlab::Pagination::OffsetPagination` # attr_reader :request diff --git a/lib/gitlab/setup_helper.rb b/lib/gitlab/setup_helper.rb index 0d3e78c0a663881522af8792a28efbd2fa063ead..c449c6879bc208b953831370250b1f9cf9e4d0c8 100644 --- a/lib/gitlab/setup_helper.rb +++ b/lib/gitlab/setup_helper.rb @@ -40,6 +40,11 @@ module Gitlab config = { socket_path: address.sub(/\Aunix:/, ''), storage: storages } config[:auth] = { token: 'secret' } if Rails.env.test? + + internal_socket_dir = File.join(gitaly_dir, 'internal_sockets') + FileUtils.mkdir(internal_socket_dir) unless File.exist?(internal_socket_dir) + config[:internal_socket_dir] = internal_socket_dir + config[:'gitaly-ruby'] = { dir: File.join(gitaly_dir, 'ruby') } if gitaly_ruby config[:'gitlab-shell'] = { dir: Gitlab.config.gitlab_shell.path } config[:bin_dir] = Gitlab.config.gitaly.client_path diff --git a/lib/gitlab/shell.rb b/lib/gitlab/shell.rb index 125d0d1cfbbaa78df8e0732916628470fd71990e..28e5d0ba8f5f66bc80d368500eb710101700b0e5 100644 --- a/lib/gitlab/shell.rb +++ b/lib/gitlab/shell.rb @@ -285,18 +285,6 @@ module Gitlab end end - # Check if such directory exists in repositories. - # - # Usage: - # exists?(storage, 'gitlab') - # exists?(storage, 'gitlab/cookies.git') - # - # rubocop: disable CodeReuse/ActiveRecord - def exists?(storage, dir_name) - Gitlab::GitalyClient::NamespaceService.new(storage).exists?(dir_name) - end - # rubocop: enable CodeReuse/ActiveRecord - def repository_exists?(storage, dir_name) Gitlab::Git::Repository.new(storage, dir_name, nil, nil).exists? rescue GRPC::Internal diff --git a/lib/gitlab/sidekiq_daemon/monitor.rb b/lib/gitlab/sidekiq_daemon/monitor.rb index a3d61c69ae1989c93d41275248bf4eb7b962cf7f..0723b514c903a3d303af69f028bbcec5b5350b7f 100644 --- a/lib/gitlab/sidekiq_daemon/monitor.rb +++ b/lib/gitlab/sidekiq_daemon/monitor.rb @@ -4,6 +4,7 @@ module Gitlab module SidekiqDaemon class Monitor < Daemon include ::Gitlab::Utils::StrongMemoize + extend ::Gitlab::Utils::Override NOTIFICATION_CHANNEL = 'sidekiq:cancel:notifications' CANCEL_DEADLINE = 24.hours.seconds @@ -24,6 +25,11 @@ module Gitlab @jobs_mutex = Mutex.new end + override :thread_name + def thread_name + "job_monitor" + end + def within_job(worker_class, jid, queue) jobs_mutex.synchronize do jobs[jid] = { worker_class: worker_class, thread: Thread.current, started_at: Gitlab::Metrics::System.monotonic_time } diff --git a/lib/gitlab/sidekiq_logging/structured_logger.rb b/lib/gitlab/sidekiq_logging/structured_logger.rb index 853fb2777c3722ebed165e56fdfc72a4100170b6..ca9e3b8428cc32cf902da1d2500d85015a51c0da 100644 --- a/lib/gitlab/sidekiq_logging/structured_logger.rb +++ b/lib/gitlab/sidekiq_logging/structured_logger.rb @@ -36,11 +36,8 @@ module Gitlab payload['message'] = "#{base_message(payload)}: start" payload['job_status'] = 'start' - # Old gitlab-shell messages don't provide enqueued_at/created_at attributes - enqueued_at = payload['enqueued_at'] || payload['created_at'] - if enqueued_at - payload['scheduling_latency_s'] = elapsed_by_absolute_time(Time.iso8601(enqueued_at)) - end + scheduling_latency_s = ::Gitlab::InstrumentationHelper.queue_duration_for_job(payload) + payload['scheduling_latency_s'] = scheduling_latency_s if scheduling_latency_s payload end @@ -98,10 +95,6 @@ module Gitlab end end - def elapsed_by_absolute_time(start) - (Time.now.utc - start).to_f.round(6) - end - def elapsed(t0) t1 = get_time { diff --git a/lib/gitlab/sidekiq_middleware/metrics.rb b/lib/gitlab/sidekiq_middleware/metrics.rb index 8af353d8674fa601c625e20e9240462d131fe343..bd819843bd478c629a26372a48f9133127ae5326 100644 --- a/lib/gitlab/sidekiq_middleware/metrics.rb +++ b/lib/gitlab/sidekiq_middleware/metrics.rb @@ -9,43 +9,56 @@ module Gitlab def initialize @metrics = init_metrics + + @metrics[:sidekiq_concurrency].set({}, Sidekiq.options[:concurrency].to_i) end def call(_worker, job, queue) labels = create_labels(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 - - realtime = Benchmark.realtime do + begin yield - end + 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 - job_thread_cputime_end = get_thread_cputime - job_thread_cputime = job_thread_cputime_end - job_thread_cputime_start - @metrics[:sidekiq_jobs_cpu_seconds].observe(labels, job_thread_cputime) + # 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 - @metrics[:sidekiq_jobs_completion_seconds].observe(labels, realtime) - rescue Exception # rubocop: disable Lint/RescueException - @metrics[:sidekiq_jobs_failed_total].increment(labels, 1) - raise - ensure - @metrics[:sidekiq_running_jobs].increment(labels, -1) + # 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_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', {}, :livesum) + 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 diff --git a/lib/gitlab/slash_commands/command.rb b/lib/gitlab/slash_commands/command.rb index 079b59165666e676eb403a430210f88813678430..239479f99d2e74bb3bedcafd16c2e06b3b6a8eb8 100644 --- a/lib/gitlab/slash_commands/command.rb +++ b/lib/gitlab/slash_commands/command.rb @@ -10,6 +10,7 @@ module Gitlab Gitlab::SlashCommands::IssueSearch, Gitlab::SlashCommands::IssueMove, Gitlab::SlashCommands::IssueClose, + Gitlab::SlashCommands::IssueComment, Gitlab::SlashCommands::Deploy, Gitlab::SlashCommands::Run ] diff --git a/lib/gitlab/slash_commands/issue_comment.rb b/lib/gitlab/slash_commands/issue_comment.rb new file mode 100644 index 0000000000000000000000000000000000000000..cbb9c41aab0ad57ad50fe04d975defb735e53571 --- /dev/null +++ b/lib/gitlab/slash_commands/issue_comment.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Gitlab + module SlashCommands + class IssueComment < IssueCommand + def self.match(text) + /\Aissue\s+comment\s+#{Issue.reference_prefix}?(?<iid>\d+)\n*(?<note_body>(.|\n)*)/.match(text) + end + + def self.help_message + 'issue comment <id> *`⇧ Shift`*+*`↵ Enter`* <comment>' + end + + def execute(match) + note_body = match[:note_body].to_s.strip + issue = find_by_iid(match[:iid]) + + return not_found unless issue + return access_denied unless can_create_note?(issue) + + note = create_note(issue: issue, note: note_body) + + if note.persisted? + presenter(note).present + else + presenter(note).display_errors + end + end + + private + + def can_create_note?(issue) + Ability.allowed?(current_user, :create_note, issue) + end + + def not_found + Gitlab::SlashCommands::Presenters::Access.new.not_found + end + + def access_denied + Gitlab::SlashCommands::Presenters::Access.new.generic_access_denied + end + + def create_note(issue:, note:) + note_params = { noteable: issue, note: note } + + Notes::CreateService.new(project, current_user, note_params).execute + end + + def presenter(note) + Gitlab::SlashCommands::Presenters::IssueComment.new(note) + end + end + end +end diff --git a/lib/gitlab/slash_commands/presenters/access.rb b/lib/gitlab/slash_commands/presenters/access.rb index 9ce1bcfb37c8ee016087719e50ce100d9d31d16f..fbc3cf2e049acec274f6e2bf96cebad8d959052b 100644 --- a/lib/gitlab/slash_commands/presenters/access.rb +++ b/lib/gitlab/slash_commands/presenters/access.rb @@ -15,6 +15,10 @@ module Gitlab MESSAGE end + def generic_access_denied + ephemeral_response(text: 'You are not allowed to perform the given chatops command.') + end + def deactivated ephemeral_response(text: <<~MESSAGE) You are not allowed to perform the given chatops command since diff --git a/lib/gitlab/slash_commands/presenters/issue_comment.rb b/lib/gitlab/slash_commands/presenters/issue_comment.rb new file mode 100644 index 0000000000000000000000000000000000000000..cce71e23b2192facb3c3dcd7e73093035028cd5a --- /dev/null +++ b/lib/gitlab/slash_commands/presenters/issue_comment.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Gitlab + module SlashCommands + module Presenters + class IssueComment < Presenters::Base + include Presenters::NoteBase + + def present + ephemeral_response(new_note) + end + + private + + def new_note + { + attachments: [ + { + title: "#{issue.title} · #{issue.to_reference}", + title_link: resource_url, + author_name: author.name, + author_icon: author.avatar_url, + fallback: "New comment on #{issue.to_reference}: #{issue.title}", + pretext: pretext, + color: color, + fields: fields, + mrkdwn_in: [ + :title, + :pretext, + :fields + ] + } + ] + } + end + + def pretext + "I commented on an issue on #{author_profile_link}'s behalf: *#{issue.to_reference}* in #{project_link}" + end + end + end + end +end diff --git a/lib/gitlab/slash_commands/presenters/note_base.rb b/lib/gitlab/slash_commands/presenters/note_base.rb new file mode 100644 index 0000000000000000000000000000000000000000..7758fc740de637764a6c6c1b689abf8ea68de1b6 --- /dev/null +++ b/lib/gitlab/slash_commands/presenters/note_base.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Gitlab + module SlashCommands + module Presenters + module NoteBase + GREEN = '#38ae67' + + def color + GREEN + end + + def issue + resource.noteable + end + + def project + issue.project + end + + def project_link + "[#{project.full_name}](#{project.web_url})" + end + + def author + resource.author + end + + def author_profile_link + "[#{author.to_reference}](#{url_for(author)})" + end + + def fields + [ + { + title: 'Comment', + value: resource.note + } + ] + end + + private + + attr_reader :resource + end + end + end +end diff --git a/lib/gitlab/sourcegraph.rb b/lib/gitlab/sourcegraph.rb new file mode 100644 index 0000000000000000000000000000000000000000..d0f12c8364a0f4aca05bdba4917fda20259647bc --- /dev/null +++ b/lib/gitlab/sourcegraph.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Gitlab + class Sourcegraph + class << self + def feature_conditional? + feature.conditional? + end + + def feature_available? + # The sourcegraph_bundle feature could be conditionally applied, so check if `!off?` + !feature.off? + end + + def feature_enabled?(thing = nil) + feature.enabled?(thing) + end + + private + + def feature + Feature.get(:sourcegraph) + end + end + end +end diff --git a/lib/gitlab/sql/union.rb b/lib/gitlab/sql/union.rb index f05592fc3a330192f415964543a357ab544bf23d..b15f2ca385ab1c7ad05b66c330700bb0e346cd74 100644 --- a/lib/gitlab/sql/union.rb +++ b/lib/gitlab/sql/union.rb @@ -29,7 +29,7 @@ module Gitlab end if fragments.any? - fragments.join("\n#{union_keyword}\n") + "(" + fragments.join(")\n#{union_keyword}\n(") + ")" else 'NULL' end diff --git a/lib/gitlab/task_helpers.rb b/lib/gitlab/task_helpers.rb index 8532845f3cb68dc260a14c32e1cda5443a0d0d40..ac02ec635e4df807a52512ca2f21939b6a1f9ece 100644 --- a/lib/gitlab/task_helpers.rb +++ b/lib/gitlab/task_helpers.rb @@ -158,15 +158,17 @@ module Gitlab end def checkout_or_clone_version(version:, repo:, target_dir:) - version = - if version.starts_with?("=") - version.sub(/\A=/, '') # tag or branch - else - "v#{version}" # tag - end - clone_repo(repo, target_dir) unless Dir.exist?(target_dir) - checkout_version(version, target_dir) + checkout_version(get_version(version), target_dir) + end + + # this function implements the same logic we have in omnibus for dealing with components version + def get_version(component_version) + # If not a valid version string following SemVer it is probably a branch name or a SHA + # commit of one of our own component so it doesn't need `v` prepended + return component_version unless /^\d+\.\d+\.\d+(-rc\d+)?$/.match?(component_version) + + "v#{component_version}" end def clone_repo(repo, target_dir) diff --git a/lib/gitlab/tracking.rb b/lib/gitlab/tracking.rb index 2470685bc00ac3857552d1a8f2a616aee658fb8e..91e2ff0b10d1fa2d91f34fda2c01a666aec880eb 100644 --- a/lib/gitlab/tracking.rb +++ b/lib/gitlab/tracking.rb @@ -45,9 +45,10 @@ module Gitlab namespace: SNOWPLOW_NAMESPACE, hostname: Gitlab::CurrentSettings.snowplow_collector_hostname, cookie_domain: Gitlab::CurrentSettings.snowplow_cookie_domain, - app_id: Gitlab::CurrentSettings.snowplow_site_id, + app_id: Gitlab::CurrentSettings.snowplow_app_id, form_tracking: additional_features, - link_click_tracking: additional_features + link_click_tracking: additional_features, + iglu_registry_url: Gitlab::CurrentSettings.snowplow_iglu_registry_url }.transform_keys! { |key| key.to_s.camelize(:lower).to_sym } end @@ -58,7 +59,7 @@ module Gitlab SnowplowTracker::AsyncEmitter.new(Gitlab::CurrentSettings.snowplow_collector_hostname, protocol: 'https'), SnowplowTracker::Subject.new, SNOWPLOW_NAMESPACE, - Gitlab::CurrentSettings.snowplow_site_id + Gitlab::CurrentSettings.snowplow_app_id ) end end diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index cb492b69fec70596bacb9307124b32d367abddb6..b6effac25c67cd21de430f9321641cb6f64ce00f 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -13,7 +13,8 @@ module Gitlab end def uncached_data - license_usage_data.merge(system_usage_data) + license_usage_data + .merge(system_usage_data) .merge(features_usage_data) .merge(components_usage_data) .merge(cycle_analytics_usage_data) @@ -66,17 +67,23 @@ module Gitlab clusters_disabled: count(::Clusters::Cluster.disabled), project_clusters_disabled: count(::Clusters::Cluster.disabled.project_type), group_clusters_disabled: count(::Clusters::Cluster.disabled.group_type), + clusters_platforms_eks: count(::Clusters::Cluster.aws_installed.enabled), clusters_platforms_gke: count(::Clusters::Cluster.gcp_installed.enabled), clusters_platforms_user: count(::Clusters::Cluster.user_provided.enabled), clusters_applications_helm: count(::Clusters::Applications::Helm.available), clusters_applications_ingress: count(::Clusters::Applications::Ingress.available), clusters_applications_cert_managers: count(::Clusters::Applications::CertManager.available), + clusters_applications_crossplane: count(::Clusters::Applications::Crossplane.available), clusters_applications_prometheus: count(::Clusters::Applications::Prometheus.available), clusters_applications_runner: count(::Clusters::Applications::Runner.available), clusters_applications_knative: count(::Clusters::Applications::Knative.available), + clusters_applications_elastic_stack: count(::Clusters::Applications::ElasticStack.available), in_review_folder: count(::Environment.in_review_folder), + grafana_integrated_projects: count(GrafanaIntegration.enabled), groups: count(Group), issues: count(Issue), + issues_with_associated_zoom_link: count(ZoomMeeting.added_to_issue), + issues_using_zoom_quick_actions: count(ZoomMeeting.select(:issue_id).distinct), keys: count(Key), label_lists: count(List.label), lfs_objects: count(LfsObject), @@ -127,7 +134,9 @@ module Gitlab omniauth_enabled: Gitlab::Auth.omniauth_enabled?, prometheus_metrics_enabled: Gitlab::Metrics.prometheus_metrics_enabled?, reply_by_email_enabled: Gitlab::IncomingEmail.enabled?, - signup_enabled: Gitlab::CurrentSettings.allow_signup? + signup_enabled: Gitlab::CurrentSettings.allow_signup?, + web_ide_clientside_preview_enabled: Gitlab::CurrentSettings.web_ide_clientside_preview_enabled?, + ingress_modsecurity_enabled: Feature.enabled?(:ingress_modsecurity) } end @@ -165,10 +174,13 @@ module Gitlab types = { SlackService: :projects_slack_notifications_active, SlackSlashCommandsService: :projects_slack_slash_active, - PrometheusService: :projects_prometheus_active + PrometheusService: :projects_prometheus_active, + CustomIssueTrackerService: :projects_custom_issue_tracker_active, + JenkinsService: :projects_jenkins_active, + MattermostService: :projects_mattermost_active } - results = count(Service.unscoped.where(type: types.keys, active: true).group(:type), fallback: Hash.new(-1)) + 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) end @@ -183,8 +195,8 @@ module Gitlab projects_jira_active: -1 } - Service.unscoped - .where(type: :JiraService, active: true) + Service.active + .by_type(:JiraService) .includes(:jira_tracker_data) .find_in_batches(batch_size: BATCH_SIZE) do |services| diff --git a/lib/gitlab/usage_data_counters/web_ide_counter.rb b/lib/gitlab/usage_data_counters/web_ide_counter.rb index 0718c1dd761000463830eb346c254d86b3d43a10..c012a6c96dffde9424e55f20be6e9f2581bbe69d 100644 --- a/lib/gitlab/usage_data_counters/web_ide_counter.rb +++ b/lib/gitlab/usage_data_counters/web_ide_counter.rb @@ -8,6 +8,7 @@ module Gitlab COMMITS_COUNT_KEY = 'WEB_IDE_COMMITS_COUNT' MERGE_REQUEST_COUNT_KEY = 'WEB_IDE_MERGE_REQUESTS_COUNT' VIEWS_COUNT_KEY = 'WEB_IDE_VIEWS_COUNT' + PREVIEW_COUNT_KEY = 'WEB_IDE_PREVIEWS_COUNT' class << self def increment_commits_count @@ -34,11 +35,22 @@ module Gitlab total_count(VIEWS_COUNT_KEY) end + def increment_previews_count + return unless Gitlab::CurrentSettings.web_ide_clientside_preview_enabled? + + increment(PREVIEW_COUNT_KEY) + end + + def total_previews_count + total_count(PREVIEW_COUNT_KEY) + end + def totals { web_ide_commits: total_commits_count, web_ide_views: total_views_count, - web_ide_merge_requests: total_merge_requests_count + web_ide_merge_requests: total_merge_requests_count, + web_ide_previews: total_previews_count } end end diff --git a/lib/gitlab/utils/deep_size.rb b/lib/gitlab/utils/deep_size.rb index 562cf09e249cf99d0ffbfac16ec58a643476646c..ed2ceb8af7ce950cb34bfa8fc89ca94b1a6cc651 100644 --- a/lib/gitlab/utils/deep_size.rb +++ b/lib/gitlab/utils/deep_size.rb @@ -25,6 +25,10 @@ module Gitlab !too_big? && !too_deep? end + def self.human_default_max_size + ActiveSupport::NumberHelper.number_to_human_size(DEFAULT_MAX_SIZE) + end + private def evaluate diff --git a/lib/gitlab/wiki_file_finder.rb b/lib/gitlab/wiki_file_finder.rb index e9be6db50da30fdc07aba83136bf231858096d42..a963cc7954f6a27869ec85b952528462b7f9faae 100644 --- a/lib/gitlab/wiki_file_finder.rb +++ b/lib/gitlab/wiki_file_finder.rb @@ -12,12 +12,12 @@ module Gitlab private - def search_filenames(query) + def search_paths(query) safe_query = Regexp.escape(query.tr(' ', '-')) safe_query = Regexp.new(safe_query, Regexp::IGNORECASE) - filenames = repository.ls_files(ref) + paths = repository.ls_files(ref) - filenames.grep(safe_query) + paths.grep(safe_query) end end end diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index db67e4fd479b84b50c23d74a0285ed48ab4c2127..713ca31bbc554351977fbf00832be948917e39ef 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -14,6 +14,7 @@ module Gitlab NOTIFICATION_CHANNEL = 'workhorse:notifications' ALLOWED_GIT_HTTP_ACTIONS = %w[git_receive_pack git_upload_pack info_refs].freeze DETECT_HEADER = 'Gitlab-Workhorse-Detect-Content-Type' + ARCHIVE_FORMATS = %w(zip tar.gz tar.bz2 tar).freeze include JwtAuthenticatable diff --git a/lib/google_api/cloud_platform/client.rb b/lib/google_api/cloud_platform/client.rb index 9085835dee6e9e46e090847937136c43e649bb39..99029b54a69dd84419d4d397d1d64b9db1dc59b4 100644 --- a/lib/google_api/cloud_platform/client.rb +++ b/lib/google_api/cloud_platform/client.rb @@ -12,6 +12,7 @@ module GoogleApi SCOPE = 'https://www.googleapis.com/auth/cloud-platform' LEAST_TOKEN_LIFE_TIME = 10.minutes CLUSTER_MASTER_AUTH_USERNAME = 'admin' + CLUSTER_IPV4_CIDR_BLOCK = '/16' class << self def session_key_for_token @@ -97,7 +98,8 @@ module GoogleApi enabled: legacy_abac }, ip_allocation_policy: { - use_ip_aliases: true + use_ip_aliases: true, + cluster_ipv4_cidr_block: CLUSTER_IPV4_CIDR_BLOCK }, addons_config: enable_addons.each_with_object({}) do |addon, hash| hash[addon] = { disabled: false } diff --git a/lib/grafana/client.rb b/lib/grafana/client.rb index 0765630f9bb41f917caae1287f03f15096135ef1..b419f79bace6ba27855099a8f971f8c3bb6ef9fa 100644 --- a/lib/grafana/client.rb +++ b/lib/grafana/client.rb @@ -11,6 +11,18 @@ module Grafana @token = token end + # @param uid [String] Unique identifier for a Grafana dashboard + def get_dashboard(uid:) + http_get("#{@api_url}/api/dashboards/uid/#{uid}") + end + + # @param name [String] Unique identifier for a Grafana datasource + def get_datasource(name:) + # CGI#escape formats strings such that the Grafana endpoint + # will not recognize the dashboard name. Preferring URI#escape. + http_get("#{@api_url}/api/datasources/name/#{URI.escape(name)}") # rubocop:disable Lint/UriEscapeUnescape + end + # @param datasource_id [String] Grafana ID for the datasource # @param proxy_path [String] Path to proxy - ex) 'api/v1/query_range' def proxy_datasource(datasource_id:, proxy_path:, query: {}) @@ -57,7 +69,7 @@ module Grafana def handle_response(response) return response if response.code == 200 - raise_error "Grafana response status code: #{response.code}" + raise_error "Grafana response status code: #{response.code}, Message: #{response.body}" end def raise_error(message) diff --git a/lib/prometheus/pid_provider.rb b/lib/prometheus/pid_provider.rb index e0f7e7e0a9e0a5fbc153f1c06565d09e38681105..228639357ac09dc09d968bf4e66cbc4e9bb71265 100644 --- a/lib/prometheus/pid_provider.rb +++ b/lib/prometheus/pid_provider.rb @@ -6,7 +6,7 @@ module Prometheus def worker_id if Sidekiq.server? - 'sidekiq' + sidekiq_worker_id elsif defined?(Unicorn::Worker) unicorn_worker_id elsif defined?(::Puma) @@ -18,6 +18,14 @@ module Prometheus private + def sidekiq_worker_id + if worker = ENV['SIDEKIQ_WORKER_ID'] + "sidekiq_#{worker}" + else + 'sidekiq' + end + end + def unicorn_worker_id if matches = process_name.match(/unicorn.*worker\[([0-9]+)\]/) "unicorn_#{matches[1]}" diff --git a/lib/quality/kubernetes_client.rb b/lib/quality/kubernetes_client.rb index 190b48ba7cb50c09e07adc42f21e5d5362bd6f8e..cc899bf9374d5927a6feb47c9a40489bf18e8e17 100644 --- a/lib/quality/kubernetes_client.rb +++ b/lib/quality/kubernetes_client.rb @@ -12,7 +12,16 @@ module Quality @namespace = namespace end - def cleanup(release_name:) + def cleanup(release_name:, wait: true) + selector = case release_name + when String + %(-l release="#{release_name}") + when Array + %(-l 'release in (#{release_name.join(', ')})') + else + raise ArgumentError, 'release_name must be a string or an array' + end + command = [ %(--namespace "#{namespace}"), 'delete', @@ -20,7 +29,8 @@ module Quality '--now', '--ignore-not-found', '--include-uninitialized', - %(-l release="#{release_name}") + %(--wait=#{wait}), + selector ] run_command(command) diff --git a/lib/sentry/client.rb b/lib/sentry/client.rb index 07cca1c8d1e412b9e51ca6cc9f560ca1b7e533b0..6191d69c870fded25b9987e252db7f81525ef719 100644 --- a/lib/sentry/client.rb +++ b/lib/sentry/client.rb @@ -4,6 +4,7 @@ module Sentry class Client Error = Class.new(StandardError) MissingKeysError = Class.new(StandardError) + ResponseInvalidSizeError = Class.new(StandardError) attr_accessor :url, :token @@ -12,9 +13,23 @@ 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(issue_status:, limit:) issues = get_issues(issue_status: issue_status, limit: limit) + validate_size(issues) + handle_mapping_exceptions do map_to_errors(issues) end @@ -30,6 +45,12 @@ module Sentry private + def validate_size(issues) + return if Gitlab::Utils::DeepSize.new(issues).valid? + + raise Client::ResponseInvalidSizeError, "Sentry API response is too big. Limit is #{Gitlab::Utils::DeepSize.human_default_max_size}." + end + def handle_mapping_exceptions(&block) yield rescue KeyError => e @@ -61,6 +82,14 @@ module Sentry }) end + def get_issue(issue_id:) + http_get(issue_api_url(issue_id)) + end + + def get_issue_latest_event(issue_id:) + http_get(issue_latest_event_api_url(issue_id)) + end + def get_projects http_get(projects_api_url) end @@ -88,7 +117,7 @@ module Sentry raise_error "Sentry response status code: #{response.code}" end - response + response.parsed_response end def raise_error(message) @@ -102,6 +131,20 @@ module Sentry projects_url 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!('/') @@ -119,38 +162,87 @@ module Sentry def issue_url(id) issues_url = @url + "/issues/#{id}" - issues_url = ErrorTracking::ProjectErrorTrackingSetting.extract_sentry_external_url(issues_url) - uri = URI(issues_url) + 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 spaces + uri = uri.to_s.gsub(/\/\z/, '') - uri.to_s + uri end - def map_to_error(issue) - id = issue.fetch('id') + def map_to_event(event) + stack_trace = parse_stack_trace(event) - count = issue.fetch('count', nil) + Gitlab::ErrorTracking::ErrorEvent.new( + issue_id: event.dig('groupID'), + date_received: event.dig('dateReceived'), + stack_trace_entries: stack_trace + ) + end - frequency = issue.dig('stats', '24h') - message = issue.dig('metadata', 'value') + def parse_stack_trace(event) + exception_entry = event.dig('entries')&.detect { |h| h['type'] == 'exception' } + return unless exception_entry - external_url = issue_url(id) + 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 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'), + 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: id, + 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: count, - message: message, + count: issue.fetch('count', nil), + message: issue.dig('metadata', 'value'), culprit: issue.fetch('culprit', nil), - external_url: external_url, + external_url: issue_url(issue.fetch('id')), short_id: issue.fetch('shortId', nil), status: issue.fetch('status', nil), - frequency: frequency, + frequency: issue.dig('stats', '24h'), project_id: issue.dig('project', 'id'), project_name: issue.dig('project', 'name'), project_slug: issue.dig('project', 'slug') diff --git a/lib/tasks/dev.rake b/lib/tasks/dev.rake index b1db4dc94a6010f614560aad79360c85afa4c5e7..0488f26318a17b9c9fb65e47c731b198433e5284 100644 --- a/lib/tasks/dev.rake +++ b/lib/tasks/dev.rake @@ -5,6 +5,10 @@ namespace :dev do task setup: :environment do ENV['force'] = 'yes' Rake::Task["gitlab:setup"].invoke + + # Make sure DB statistics are up to date. + ActiveRecord::Base.connection.execute('ANALYZE') + Rake::Task["gitlab:shell:setup"].invoke end diff --git a/lib/tasks/gitlab/graphql.rake b/lib/tasks/gitlab/graphql.rake index 902f22684eeb6f430857a1ad8c639abceb0aeab6..f8ce3cd46a813a57584c3951c7f2f78b3bdc0630 100644 --- a/lib/tasks/gitlab/graphql.rake +++ b/lib/tasks/gitlab/graphql.rake @@ -2,10 +2,24 @@ return if Rails.env.production? +require 'graphql/rake_task' + namespace :gitlab do OUTPUT_DIR = Rails.root.join("doc/api/graphql/reference") TEMPLATES_DIR = 'lib/gitlab/graphql/docs/templates/' + # Defines tasks for dumping the GraphQL schema: + # - gitlab:graphql:schema:dump + # - gitlab:graphql:schema:idl + # - gitlab:graphql:schema:json + GraphQL::RakeTask.new( + schema_name: 'GitlabSchema', + dependencies: [:environment], + directory: OUTPUT_DIR, + idl_outfile: "gitlab_schema.graphql", + json_outfile: "gitlab_schema.json" + ) + namespace :graphql do desc 'GitLab | Generate GraphQL docs' task compile_docs: :environment do @@ -25,11 +39,20 @@ namespace :gitlab do if doc == renderer.contents puts "GraphQL documentation is up to date" else - puts '#' * 10 - puts '#' - puts '# GraphQL documentation is outdated! Please update it by running `bundle exec rake gitlab:graphql:compile_docs`.' - puts '#' - puts '#' * 10 + format_output('GraphQL documentation is outdated! Please update it by running `bundle exec rake gitlab:graphql:compile_docs`.') + abort + end + end + + desc 'GitLab | Check if GraphQL schemas are up to date' + task check_schema: :environment do + idl_doc = File.read(Rails.root.join(OUTPUT_DIR, 'gitlab_schema.graphql')) + json_doc = File.read(Rails.root.join(OUTPUT_DIR, 'gitlab_schema.json')) + + if idl_doc == GitlabSchema.to_definition && json_doc == GitlabSchema.to_json + puts "GraphQL schema is up to date" + else + format_output('GraphQL schema is outdated! Please update it by running `bundle exec rake gitlab:graphql:schema:dump`.') abort end end @@ -42,3 +65,12 @@ def render_options template: Rails.root.join(TEMPLATES_DIR, 'default.md.haml') } end + +def format_output(str) + heading = '#' * 10 + puts heading + puts '#' + puts "# #{str}" + puts '#' + puts heading +end diff --git a/lib/tasks/gitlab/seed.rake b/lib/tasks/gitlab/seed.rake index d76e38b73b5a5d9d948b78350e895a159a3c256f..d758280ba69def6792aa50e0c75bf4819b789a03 100644 --- a/lib/tasks/gitlab/seed.rake +++ b/lib/tasks/gitlab/seed.rake @@ -22,7 +22,7 @@ namespace :gitlab do [project] else - Project.find_each + Project.not_mass_generated.find_each end projects.each do |project| diff --git a/lib/tasks/gitlab/shell.rake b/lib/tasks/gitlab/shell.rake index abd47f018f1c6d3ca4927aca311ba9c553dbd768..a592015963d7d40c7cd17e9cb9b116c62621177b 100644 --- a/lib/tasks/gitlab/shell.rake +++ b/lib/tasks/gitlab/shell.rake @@ -43,7 +43,7 @@ namespace :gitlab do [ %w(bin/install) + repository_storage_paths_args, - %w(bin/compile) + %w(make build) ].each do |cmd| unless Kernel.system(*cmd) raise "command failed: #{cmd.join(' ')}" diff --git a/lib/tasks/gitlab/uploads/legacy.rake b/lib/tasks/gitlab/uploads/legacy.rake index 2eeb694d3416353a759986663785687649f36f27..74db0060b8d1af88c04eafba60326f9dd7aeae38 100644 --- a/lib/tasks/gitlab/uploads/legacy.rake +++ b/lib/tasks/gitlab/uploads/legacy.rake @@ -15,7 +15,7 @@ namespace :gitlab do batch_size = 5000 delay_interval = 5.minutes.to_i - Upload.where(uploader: 'AttachmentUploader').each_batch(of: batch_size) do |relation, index| + Upload.where(uploader: 'AttachmentUploader', model_type: 'Note').each_batch(of: batch_size) do |relation, index| start_id, end_id = relation.pluck('MIN(id), MAX(id)').first delay = index * delay_interval diff --git a/locale/ar_SA/gitlab.po b/locale/ar_SA/gitlab.po index e543747177e16e02ef0431a0cc73bc132dd8f246..322db0c03224db3a00f34597ba798b547b83bf08 100644 --- a/locale/ar_SA/gitlab.po +++ b/locale/ar_SA/gitlab.po @@ -7791,7 +7791,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16734,7 +16734,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/bg/gitlab.po b/locale/bg/gitlab.po index 8190c27e65cc7ea0265718bef7f638840de633bf..0b6e55cab8e0b45ad92b226fbcf1070e66713006 100644 --- a/locale/bg/gitlab.po +++ b/locale/bg/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/bn_BD/gitlab.po b/locale/bn_BD/gitlab.po index 0d25da46afd035ffdede810862fec8dc6118703f..291dca380fcad75ca347ecaebbdc58affcd89282 100644 --- a/locale/bn_BD/gitlab.po +++ b/locale/bn_BD/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/bn_IN/gitlab.po b/locale/bn_IN/gitlab.po index 945f7f66d52313abf5f35f40d45ba1a29a1f5a1d..f95d71eec5dcb6102cf2791ce329d749beb04c69 100644 --- a/locale/bn_IN/gitlab.po +++ b/locale/bn_IN/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/ca_ES/gitlab.po b/locale/ca_ES/gitlab.po index 318efa98afac2440f0f3f5e27f1391af3212581f..a30f508f4b61074a26f30477d29b4ba210180f60 100644 --- a/locale/ca_ES/gitlab.po +++ b/locale/ca_ES/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "Enrere" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/cs_CZ/gitlab.po b/locale/cs_CZ/gitlab.po index 2123f8235dee986defc21d6df420fb70b933ff8a..b9fd29985aea9f7849be1f6b6838a6b73add9e66 100644 --- a/locale/cs_CZ/gitlab.po +++ b/locale/cs_CZ/gitlab.po @@ -7669,7 +7669,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16558,7 +16558,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/cy_GB/gitlab.po b/locale/cy_GB/gitlab.po index f831c880f7dea4e89a014c0e785c180c52c2458b..c09b3a6695cec99c96fa33828b1ac133ef979b36 100644 --- a/locale/cy_GB/gitlab.po +++ b/locale/cy_GB/gitlab.po @@ -7791,7 +7791,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16734,7 +16734,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/da_DK/gitlab.po b/locale/da_DK/gitlab.po index 352219b4745487efd0cc8a3d99e7d350ae966eb5..8747bc5c3c20e63cd0eeeba79f1e96ae033b5ff8 100644 --- a/locale/da_DK/gitlab.po +++ b/locale/da_DK/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po index 57f17ae854d50dae67fbed784feae501c075c2e8..3103adacbf275e7ba0b07ee89b32d5b6b852a34c 100644 --- a/locale/de/gitlab.po +++ b/locale/de/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "Zurück" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/el_GR/gitlab.po b/locale/el_GR/gitlab.po index b1af45f753a66e743dcea199de83443e1b582aee..e1f4ab78994c64cd767657c632dd94c065c0b228 100644 --- a/locale/el_GR/gitlab.po +++ b/locale/el_GR/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/eo/gitlab.po b/locale/eo/gitlab.po index 76d9514c20c4eef9b2d2947cf52c49134ee6a527..e7ac98e864ac4fc41028799628e0e5be244769cc 100644 --- a/locale/eo/gitlab.po +++ b/locale/eo/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po index b5968b6981d25b124839397c3926c30547234653..851123d9c35a9a29bd249bff6afef56a36fcd9ef 100644 --- a/locale/es/gitlab.po +++ b/locale/es/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "Go Micro es un framework para el desarrollo de microservicios." msgid "Go back" msgstr "Volver" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "Volver (mientras busca archivos" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "Para abrir Jaeger y ver fácilmente la trazabilidad desde GitLab, enlace msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "Para mantener el rendimiento, solo se muestran <strong>%{display_size} de %{real_size}</strong> archivos." -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/et_EE/gitlab.po b/locale/et_EE/gitlab.po index 49d881a72a514517b2edb5f9f0146b4c66a0e85e..7cf77be629ac0c02f1d375b6732c9805db7f1470 100644 --- a/locale/et_EE/gitlab.po +++ b/locale/et_EE/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/fa_IR/gitlab.po b/locale/fa_IR/gitlab.po index 413cf75f212bd79eb30870b181be176232da4655..22519e0bfc03604eca74ba07f479dab1897e62bc 100644 --- a/locale/fa_IR/gitlab.po +++ b/locale/fa_IR/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/fil_PH/gitlab.po b/locale/fil_PH/gitlab.po index 314a38468dbde62ae7e149159a422f803a2d7a81..150e5733bdc105ce819bf3272dfdf0f6c17cca69 100644 --- a/locale/fil_PH/gitlab.po +++ b/locale/fil_PH/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/fr/gitlab.po b/locale/fr/gitlab.po index 18d5cc1cd33e391e831cf838d2f90b38415cf348..e1894f867b1224e1c0558728ac9cd75b7a970646 100644 --- a/locale/fr/gitlab.po +++ b/locale/fr/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "Retour" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 5a200bf7cddf19590e88bd053d665557923fb627..f1b14d78292175fc32263c80f4c68725077620fc 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -62,6 +62,9 @@ msgstr "" msgid " or references (e.g. path/to/project!merge_request_id)" msgstr "" +msgid "\"%{path}\" did not exist on \"%{ref}\"" +msgstr "" + msgid "%d comment" msgid_plural "%d comments" msgstr[0] "" @@ -207,6 +210,11 @@ msgstr "" msgid "%{count} more assignees" msgstr "" +msgid "%{count} more release" +msgid_plural "%{count} more releases" +msgstr[0] "" +msgstr[1] "" + msgid "%{count} of %{required} approvals from %{name}" msgstr "" @@ -253,9 +261,6 @@ msgstr "" msgid "%{from} to %{to}" msgstr "" -msgid "%{gitlab_ci_yml} not found in this commit" -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." msgstr "" @@ -316,6 +321,14 @@ msgstr "" msgid "%{percent}%% complete" msgstr "" +msgid "%{primary} (%{secondary})" +msgstr "" + +msgid "%{releases} release" +msgid_plural "%{releases} releases" +msgstr[0] "" +msgstr[1] "" + msgid "%{service_title} activated." msgstr "" @@ -644,6 +657,9 @@ msgstr "" msgid "A Let's Encrypt account will be configured for this GitLab installation using your email address. You will receive emails to warn of expiring certificates." msgstr "" +msgid "A basic page and serverless function that uses AWS Lambda, AWS API Gateway, and GitLab Pages" +msgstr "" + msgid "A default branch cannot be chosen for an empty project." msgstr "" @@ -686,7 +702,7 @@ msgstr "" msgid "A ready-to-go template for use with iOS Swift apps." msgstr "" -msgid "A regular expression that will be used to find the test coverage output in the job trace. Leave blank to disable" +msgid "A regular expression that will be used to find the test coverage output in the job log. Leave blank to disable" msgstr "" msgid "A secure token that identifies an external storage request." @@ -701,6 +717,18 @@ msgstr "" msgid "API Token" msgstr "" +msgid "AWS Access Key" +msgstr "" + +msgid "AWS Access Key. Only required if not using role instance credentials" +msgstr "" + +msgid "AWS Secret Access Key" +msgstr "" + +msgid "AWS Secret Access Key. Only required if not using role instance credentials" +msgstr "" + msgid "Abort" msgstr "" @@ -821,6 +849,9 @@ msgstr "" msgid "Account" msgstr "" +msgid "Account ID" +msgstr "" + msgid "Account and limit" msgstr "" @@ -874,6 +905,9 @@ msgstr "" msgid "Add Kubernetes cluster" msgstr "" +msgid "Add LICENSE" +msgstr "" + msgid "Add README" msgstr "" @@ -922,6 +956,9 @@ msgstr "" msgid "Add an SSH key" msgstr "" +msgid "Add an existing issue to the epic." +msgstr "" + msgid "Add an issue" msgstr "" @@ -979,6 +1016,9 @@ msgstr "" msgid "Add reaction" msgstr "" +msgid "Add request manually" +msgstr "" + msgid "Add to Slack" msgstr "" @@ -1362,6 +1402,9 @@ msgstr "" msgid "All groups and projects" msgstr "" +msgid "All issues for this milestone are closed." +msgstr "" + msgid "All issues for this milestone are closed. You may close this milestone now." msgstr "" @@ -1377,6 +1420,9 @@ msgstr "" msgid "All projects" msgstr "" +msgid "All security scans are enabled because %{linkStart}Auto DevOps%{linkEnd} is enabled on this project" +msgstr "" + msgid "All users" msgstr "" @@ -1392,9 +1438,6 @@ msgstr "" msgid "Allow group owners to manage LDAP-related settings" msgstr "" -msgid "Allow mirrors to be set up for projects" -msgstr "" - msgid "Allow only the selected protocols to be used for Git access." msgstr "" @@ -1407,6 +1450,9 @@ msgstr "" msgid "Allow rendering of PlantUML diagrams in Asciidoc documents." msgstr "" +msgid "Allow repository mirroring to be configured by project maintainers" +msgstr "" + msgid "Allow requests to the local network from hooks and services." msgstr "" @@ -1446,6 +1492,18 @@ msgstr "" msgid "Alternate support URL for help page and help dropdown" msgstr "" +msgid "Amazon EKS" +msgstr "" + +msgid "Amazon EKS integration allows you to provision EKS clusters from GitLab." +msgstr "" + +msgid "Amazon Web Services" +msgstr "" + +msgid "Amazon authentication is not %{link_start}correctly configured%{link_end}. Ask your GitLab administrator if you want to use this service." +msgstr "" + msgid "Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication" msgstr "" @@ -1491,6 +1549,9 @@ msgstr "" msgid "An error occurred when updating the issue weight" msgstr "" +msgid "An error occurred while checking group path" +msgstr "" + msgid "An error occurred while deleting the approvers group" msgstr "" @@ -1515,6 +1576,9 @@ msgstr "" msgid "An error occurred while fetching environments." msgstr "" +msgid "An error occurred while fetching exposed artifacts." +msgstr "" + msgid "An error occurred while fetching folder content." msgstr "" @@ -1590,6 +1654,9 @@ msgstr "" msgid "An error occurred while loading filenames" msgstr "" +msgid "An error occurred while loading issues" +msgstr "" + msgid "An error occurred while loading the file" msgstr "" @@ -1650,6 +1717,9 @@ msgstr "" msgid "An error occurred while updating the comment" msgstr "" +msgid "An error occurred while validating group path" +msgstr "" + msgid "An error occurred while validating username" msgstr "" @@ -1737,6 +1807,9 @@ msgstr "" msgid "Any user" msgstr "" +msgid "App ID" +msgstr "" + msgid "Appearance" msgstr "" @@ -1746,10 +1819,10 @@ msgstr "" msgid "Appearance was successfully updated." msgstr "" -msgid "Append the comment with %{TABLEFLIP}" +msgid "Append the comment with %{shrug}" msgstr "" -msgid "Append the comment with %{shrug}" +msgid "Append the comment with %{tableflip}" msgstr "" msgid "Application" @@ -1886,6 +1959,9 @@ msgstr "" msgid "Archiving the project will make it entirely read-only. It is hidden from the dashboard and doesn't show up in searches. <strong>The repository cannot be committed to, and no issues, comments or other entities can be created.</strong>" msgstr "" +msgid "Are you setting up GitLab for a company?" +msgstr "" + msgid "Are you sure that you want to archive this project?" msgstr "" @@ -2115,6 +2191,9 @@ msgstr "" msgid "Authenticate with GitHub" msgstr "" +msgid "Authenticating" +msgstr "" + msgid "Authentication Log" msgstr "" @@ -2424,18 +2503,15 @@ msgstr "" msgid "BillingPlans|To manage the plan for this group, visit the billing section of %{parent_billing_page_link}." msgstr "" -msgid "BillingPlans|Your GitLab.com trial expired on %{expiration_date}. %{learn_more_text}" +msgid "BillingPlans|Your GitLab.com Gold trial expired on %{expiration_date}. You can restore access to the Gold features at any time by upgrading below." msgstr "" -msgid "BillingPlans|Your GitLab.com trial will <strong>expire after %{expiration_date}</strong>. You can learn more about GitLab.com Gold by reading about our %{features_link}." +msgid "BillingPlans|Your GitLab.com Gold trial will <strong>expire after %{expiration_date}</strong>. You can retain access to the Gold features by upgrading below." msgstr "" msgid "BillingPlans|billed annually at %{price_per_year}" msgstr "" -msgid "BillingPlans|features" -msgstr "" - msgid "BillingPlans|frequently asked questions" msgstr "" @@ -2874,9 +2950,6 @@ msgstr "" msgid "Certificate (PEM)" msgstr "" -msgid "Change Weight" -msgstr "" - msgid "Change assignee" msgstr "" @@ -2886,6 +2959,9 @@ msgstr "" msgid "Change assignee(s)." msgstr "" +msgid "Change branches" +msgstr "" + msgid "Change label" msgstr "" @@ -2949,6 +3025,9 @@ msgstr "" msgid "Changes won't take place until the index is %{link_start}recreated%{link_end}." msgstr "" +msgid "Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}" +msgstr "" + msgid "Changing group path can have unintended side effects." msgstr "" @@ -2958,7 +3037,7 @@ msgstr "" msgid "Chat" msgstr "" -msgid "ChatMessage|%{project_link}: Pipeline %{pipeline_link} of %{ref_type} %{branch_link} by %{user_combined_name} %{humanized_status} in %{duration}" +msgid "ChatMessage|%{project_link}: Pipeline %{pipeline_link} of %{ref_type} %{ref_link} by %{user_combined_name} %{humanized_status} in %{duration}" msgstr "" msgid "ChatMessage|Branch" @@ -2979,7 +3058,7 @@ msgstr "" msgid "ChatMessage|Pipeline #%{pipeline_id} %{humanized_status} in %{duration}" msgstr "" -msgid "ChatMessage|Pipeline %{pipeline_link} of %{ref_type} %{branch_link} by %{user_combined_name} %{humanized_status}" +msgid "ChatMessage|Pipeline %{pipeline_link} of %{ref_type} %{ref_link} by %{user_combined_name} %{humanized_status}" msgstr "" msgid "ChatMessage|Tag" @@ -3030,6 +3109,9 @@ msgstr "" msgid "Checking branch availability..." msgstr "" +msgid "Checking group path availability..." +msgstr "" + msgid "Checking username availability..." msgstr "" @@ -3072,9 +3154,6 @@ msgstr "" msgid "Choose a type..." msgstr "" -msgid "Choose an existing tag, or create a new one" -msgstr "" - msgid "Choose any color." msgstr "" @@ -3237,6 +3316,9 @@ msgstr "" msgid "CiVariable|Validation failed" msgstr "" +msgid "Class" +msgstr "" + msgid "Classification Label (optional)" msgstr "" @@ -3375,6 +3457,9 @@ msgstr "" msgid "ClusterIntegration|%{title} updated successfully." msgstr "" +msgid "ClusterIntegration|A cluster management project can be used to run deployment jobs with Kubernetes <code>cluster-admin</code> privileges." +msgstr "" + msgid "ClusterIntegration|A service token scoped to %{code}kube-system%{end_code} with %{code}cluster-admin%{end_code} privileges." msgstr "" @@ -3447,6 +3532,12 @@ msgstr "" msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster." msgstr "" +msgid "ClusterIntegration|Authenticate with AWS" +msgstr "" + +msgid "ClusterIntegration|Authenticate with Amazon Web Services" +msgstr "" + msgid "ClusterIntegration|Base domain" msgstr "" @@ -3465,10 +3556,13 @@ msgstr "" msgid "ClusterIntegration|Choose a prefix to be used for your namespaces. Defaults to your project path." msgstr "" -msgid "ClusterIntegration|Choose the %{startLink}security groups%{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets." +msgid "ClusterIntegration|Choose the %{startLink}security group %{externalLinkIcon} %{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets." +msgstr "" + +msgid "ClusterIntegration|Choose the %{startLink}subnets %{externalLinkIcon} %{endLink} in your VPC where your worker nodes will run." msgstr "" -msgid "ClusterIntegration|Choose the %{startLink}subnets%{endLink} in your VPC where your worker nodes will run." +msgid "ClusterIntegration|Choose the worker node %{startLink}instance type %{externalLinkIcon} %{endLink}." msgstr "" msgid "ClusterIntegration|Choose which applications to install on your Kubernetes cluster. Helm Tiller is required to install any of the following applications." @@ -3483,6 +3577,9 @@ msgstr "" msgid "ClusterIntegration|Cluster health" msgstr "" +msgid "ClusterIntegration|Cluster management project (alpha)" +msgstr "" + msgid "ClusterIntegration|Cluster name is required." msgstr "" @@ -3501,6 +3598,9 @@ msgstr "" msgid "ClusterIntegration|Copy Jupyter Hostname" msgstr "" +msgid "ClusterIntegration|Copy Kibana Hostname" +msgstr "" + msgid "ClusterIntegration|Copy Knative Endpoint" msgstr "" @@ -3519,6 +3619,9 @@ msgstr "" msgid "ClusterIntegration|Could not load VPCs for the selected region" msgstr "" +msgid "ClusterIntegration|Could not load instance types" +msgstr "" + msgid "ClusterIntegration|Could not load regions from your AWS account" msgstr "" @@ -3531,6 +3634,9 @@ msgstr "" msgid "ClusterIntegration|Create Kubernetes cluster" msgstr "" +msgid "ClusterIntegration|Create a provision role on %{startAwsLink}Amazon Web Services %{externalLinkIcon}%{endLink} using the account and external ID above. %{startMoreInfoLink}More information%{endLink}" +msgstr "" + msgid "ClusterIntegration|Create cluster on" msgstr "" @@ -3543,9 +3649,21 @@ msgstr "" msgid "ClusterIntegration|Create new Cluster on GKE" msgstr "" +msgid "ClusterIntegration|Creating Kubernetes cluster" +msgstr "" + +msgid "ClusterIntegration|Crossplane" +msgstr "" + +msgid "ClusterIntegration|Crossplane enables declarative provisioning of managed services from your cloud of choice using %{kubectl} or %{gitlabIntegrationLink}. Crossplane runs inside your Kubernetes cluster and supports secure connectivity and secrets management between app containers and the cloud services they depend on." +msgstr "" + msgid "ClusterIntegration|Did you know?" msgstr "" +msgid "ClusterIntegration|Elastic Stack" +msgstr "" + msgid "ClusterIntegration|Enable Cloud Run on GKE (beta)" msgstr "" @@ -3555,6 +3673,9 @@ msgstr "" msgid "ClusterIntegration|Enable this setting if using role-based access control (RBAC)." msgstr "" +msgid "ClusterIntegration|Enabled stack" +msgstr "" + msgid "ClusterIntegration|Enter the details for your Amazon EKS Kubernetes cluster" msgstr "" @@ -3567,9 +3688,15 @@ msgstr "" msgid "ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab's Google Kubernetes Engine Integration." msgstr "" +msgid "ClusterIntegration|Failed to configure EKS provider: %{message}" +msgstr "" + msgid "ClusterIntegration|Failed to configure Google Kubernetes Engine Cluster: %{message}" msgstr "" +msgid "ClusterIntegration|Failed to fetch CloudFormation stack: %{message}" +msgstr "" + msgid "ClusterIntegration|Failed to request to Google Cloud Platform: %{message}" msgstr "" @@ -3597,6 +3724,9 @@ msgstr "" msgid "ClusterIntegration|GitLab-managed cluster" msgstr "" +msgid "ClusterIntegration|Gitlab Integration" +msgstr "" + msgid "ClusterIntegration|Google Cloud Platform project" msgstr "" @@ -3642,6 +3772,9 @@ msgstr "" msgid "ClusterIntegration|Instance cluster" msgstr "" +msgid "ClusterIntegration|Instance type" +msgstr "" + msgid "ClusterIntegration|Integrate Kubernetes cluster automation" msgstr "" @@ -3666,6 +3799,9 @@ msgstr "" msgid "ClusterIntegration|Key pair name" msgstr "" +msgid "ClusterIntegration|Kibana Hostname" +msgstr "" + msgid "ClusterIntegration|Knative" msgstr "" @@ -3687,13 +3823,13 @@ msgstr "" msgid "ClusterIntegration|Kubernetes cluster details" msgstr "" -msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..." +msgid "ClusterIntegration|Kubernetes cluster is being created..." msgstr "" msgid "ClusterIntegration|Kubernetes cluster name" msgstr "" -msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine." +msgid "ClusterIntegration|Kubernetes cluster was successfully created." msgstr "" msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way." @@ -3714,7 +3850,7 @@ msgstr "" msgid "ClusterIntegration|Learn more about %{help_link_start}zones%{help_link_end}." msgstr "" -msgid "ClusterIntegration|Learn more about %{startLink}Regions%{endLink}." +msgid "ClusterIntegration|Learn more about %{startLink}Regions %{externalLinkIcon}%{endLink}." msgstr "" msgid "ClusterIntegration|Learn more about Kubernetes" @@ -3741,6 +3877,9 @@ msgstr "" msgid "ClusterIntegration|Loading VPCs" msgstr "" +msgid "ClusterIntegration|Loading instance types" +msgstr "" + msgid "ClusterIntegration|Loading security groups" msgstr "" @@ -3765,6 +3904,9 @@ msgstr "" msgid "ClusterIntegration|No VPCs found" msgstr "" +msgid "ClusterIntegration|No instance type found" +msgstr "" + msgid "ClusterIntegration|No machine types matched your search" msgstr "" @@ -3816,6 +3958,9 @@ msgstr "" msgid "ClusterIntegration|Prometheus is an open-source monitoring system with %{gitlabIntegrationLink} to monitor deployed applications." msgstr "" +msgid "ClusterIntegration|Provision Role ARN" +msgstr "" + msgid "ClusterIntegration|RBAC-enabled cluster" msgstr "" @@ -3858,6 +4003,9 @@ msgstr "" msgid "ClusterIntegration|Search VPCs" msgstr "" +msgid "ClusterIntegration|Search instance types" +msgstr "" + msgid "ClusterIntegration|Search machine types" msgstr "" @@ -3876,7 +4024,7 @@ msgstr "" msgid "ClusterIntegration|Search zones" msgstr "" -msgid "ClusterIntegration|Security groups" +msgid "ClusterIntegration|Security group" msgstr "" msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster" @@ -3888,7 +4036,10 @@ msgstr "" msgid "ClusterIntegration|Select a VPC to choose a subnet" 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%{endLink}." +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 different AWS role" msgstr "" msgid "ClusterIntegration|Select a region to choose a Key Pair" @@ -3897,6 +4048,9 @@ msgstr "" msgid "ClusterIntegration|Select a region to choose a VPC" msgstr "" +msgid "ClusterIntegration|Select a stack to install Crossplane." +msgstr "" + msgid "ClusterIntegration|Select machine type" msgstr "" @@ -3909,10 +4063,10 @@ 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%{endLink}." +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%{endLink}." +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 "" msgid "ClusterIntegration|Select zone" @@ -3933,7 +4087,7 @@ msgstr "" msgid "ClusterIntegration|Something went wrong on our end." msgstr "" -msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine" +msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster" msgstr "" msgid "ClusterIntegration|Something went wrong while installing %{title}" @@ -3948,7 +4102,10 @@ msgstr "" msgid "ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{auto_devops_start}Auto DevOps%{auto_devops_end}. The domain should have a wildcard DNS configured matching the domain." msgstr "" -msgid "ClusterIntegration|Subnet" +msgid "ClusterIntegration|Subnets" +msgstr "" + +msgid "ClusterIntegration|The Amazon Resource Name (ARN) associated with your role. If you do not have a provision role, first create one on %{startAwsLink}Amazon Web Services %{externalLinkIcon}%{endLink} using the above account and external IDs. %{startMoreInfoLink}More information%{endLink}" msgstr "" msgid "ClusterIntegration|The Kubernetes certificate used to authenticate to the cluster." @@ -3969,6 +4126,9 @@ msgstr "" msgid "ClusterIntegration|The associated private key will be deleted and cannot be restored." msgstr "" +msgid "ClusterIntegration|The elastic stack collects logs from all pods in your cluster" +msgstr "" + msgid "ClusterIntegration|The endpoint is in the process of being assigned. Please check your Kubernetes cluster or Quotas on Google Kubernetes Engine if it takes a long time." msgstr "" @@ -4017,6 +4177,9 @@ msgstr "" msgid "ClusterIntegration|You must first install Helm Tiller before installing the applications below" msgstr "" +msgid "ClusterIntegration|You must grant access to your organization’s AWS resources in order to create a new EKS cluster. To grant access, create a provision role using the account and external ID below and provide us the ARN." +msgstr "" + msgid "ClusterIntegration|You must have an RBAC-enabled cluster to install Knative." msgstr "" @@ -4059,6 +4222,9 @@ msgstr "" msgid "ClusterIntergation|Select a subnet" msgstr "" +msgid "ClusterIntergation|Select an instance type" +msgstr "" + msgid "ClusterIntergation|Select key pair" msgstr "" @@ -4196,6 +4362,9 @@ msgstr "" msgid "Commits per weekday" msgstr "" +msgid "Commits to" +msgstr "" + msgid "Commits|An error occurred while fetching merge requests data." msgstr "" @@ -4238,6 +4407,9 @@ msgstr "" msgid "Compare changes with the merge request target branch" msgstr "" +msgid "Compare with previous version" +msgstr "" + msgid "CompareBranches|%{source_branch} and %{target_branch} are the same." msgstr "" @@ -4277,6 +4449,9 @@ msgstr "" msgid "Configure Prometheus" msgstr "" +msgid "Configure Security %{wordBreakOpportunity}and Compliance" +msgstr "" + msgid "Configure Tracing" msgstr "" @@ -4298,7 +4473,7 @@ msgstr "" msgid "Configure paths to be protected by Rack Attack. A web server restart is required after changing these settings." msgstr "" -msgid "Configure push mirrors." +msgid "Configure repository mirroring." msgstr "" msgid "Configure storage path settings." @@ -4334,6 +4509,9 @@ msgstr "" msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled." msgstr "" +msgid "Connecting" +msgstr "" + msgid "Connecting to terminal sync service" msgstr "" @@ -4480,18 +4658,6 @@ msgstr "" msgid "Contributors" msgstr "" -msgid "ContributorsPage|%{startDate} – %{endDate}" -msgstr "" - -msgid "ContributorsPage|Building repository graph." -msgstr "" - -msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits." -msgstr "" - -msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready." -msgstr "" - msgid "Control emails linked to your account" msgstr "" @@ -4516,6 +4682,9 @@ msgstr "" msgid "Copy" msgstr "" +msgid "Copy %{field}" +msgstr "" + msgid "Copy %{http_label} clone URL" msgstr "" @@ -4525,6 +4694,12 @@ msgstr "" msgid "Copy %{proxy_url}" msgstr "" +msgid "Copy Account ID to clipboard" +msgstr "" + +msgid "Copy External ID to clipboard" +msgstr "" + msgid "Copy ID" msgstr "" @@ -4588,9 +4763,6 @@ msgstr "" msgid "Could not add admins as members" msgstr "" -msgid "Could not add prometheus URL to whitelist" -msgstr "" - msgid "Could not authorize chat nickname. Try again!" msgstr "" @@ -4669,12 +4841,18 @@ msgstr "" msgid "Create a new issue" msgstr "" +msgid "Create a new issue and add it to the epic." +msgstr "" + msgid "Create a new repository" msgstr "" msgid "Create a personal access token on your account to pull or push via %{protocol}." msgstr "" +msgid "Create an issue" +msgstr "" + msgid "Create an issue. Issues are created for each alert triggered." msgstr "" @@ -4777,6 +4955,12 @@ msgstr "" msgid "Created a branch and a merge request to resolve this issue." msgstr "" +msgid "Created after" +msgstr "" + +msgid "Created before" +msgstr "" + msgid "Created branch '%{branch_name}' and a merge request to resolve this issue." msgstr "" @@ -4822,6 +5006,9 @@ msgstr "" msgid "Cron syntax" msgstr "" +msgid "Crossplane" +msgstr "" + msgid "Current Branch" msgstr "" @@ -4837,12 +5024,18 @@ msgstr "" msgid "Current password" msgstr "" +msgid "Current vulnerabilities count" +msgstr "" + msgid "CurrentUser|Profile" msgstr "" msgid "CurrentUser|Settings" msgstr "" +msgid "CurrentUser|Start a Gold trial" +msgstr "" + msgid "Custom CI configuration path" msgstr "" @@ -4939,15 +5132,42 @@ msgstr "" msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "" +msgid "CycleAnalyticsEvent|Issue closed" +msgstr "" + msgid "CycleAnalyticsEvent|Issue created" msgstr "" +msgid "CycleAnalyticsEvent|Issue first added to a board" +msgstr "" + +msgid "CycleAnalyticsEvent|Issue first associated with a milestone" +msgstr "" + msgid "CycleAnalyticsEvent|Issue first associated with a milestone or issue first added to a board" msgstr "" msgid "CycleAnalyticsEvent|Issue first mentioned in a commit" msgstr "" +msgid "CycleAnalyticsEvent|Issue label was added" +msgstr "" + +msgid "CycleAnalyticsEvent|Issue label was removed" +msgstr "" + +msgid "CycleAnalyticsEvent|Issue last edited" +msgstr "" + +msgid "CycleAnalyticsEvent|Merge Request label was added" +msgstr "" + +msgid "CycleAnalyticsEvent|Merge Request label was removed" +msgstr "" + +msgid "CycleAnalyticsEvent|Merge request closed" +msgstr "" + msgid "CycleAnalyticsEvent|Merge request created" msgstr "" @@ -4960,6 +5180,9 @@ msgstr "" msgid "CycleAnalyticsEvent|Merge request last build start time" msgstr "" +msgid "CycleAnalyticsEvent|Merge request last edited" +msgstr "" + msgid "CycleAnalyticsEvent|Merge request merged" msgstr "" @@ -4984,6 +5207,12 @@ msgstr "" msgid "CycleAnalyticsStage|Test" msgstr "" +msgid "CycleAnalyticsStage|is not available for the selected group" +msgstr "" + +msgid "CycleAnalyticsStage|should be under a group" +msgstr "" + msgid "CycleAnalytics|%{projectName}" msgid_plural "CycleAnalytics|%d projects selected" msgstr[0] "" @@ -5003,6 +5232,9 @@ msgstr "" msgid "CycleAnalytics|group dropdown filter" msgstr "" +msgid "CycleAnalytics|not allowed for the given start event" +msgstr "" + msgid "CycleAnalytics|project dropdown filter" msgstr "" @@ -5078,6 +5310,9 @@ msgstr "" msgid "Default Branch" msgstr "" +msgid "Default CI configuration path" +msgstr "" + msgid "Default artifacts expiration" msgstr "" @@ -5210,6 +5445,9 @@ msgstr "" msgid "Dependencies" msgstr "" +msgid "Dependencies help page link" +msgstr "" + msgid "Dependencies|%d additional vulnerability not shown" msgid_plural "Dependencies|%d additional vulnerabilities not shown" msgstr[0] "" @@ -5475,6 +5713,9 @@ msgstr "" msgid "Descending" msgstr "" +msgid "Describe the goal of the changes and what reviewers should be aware of." +msgstr "" + msgid "Description" msgstr "" @@ -5562,6 +5803,9 @@ 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 "DesignManagement|We could not delete design(s). Please try again." msgstr "" @@ -5586,6 +5830,9 @@ msgstr "" msgid "Diff limits" msgstr "" +msgid "Difference between start date and now" +msgstr "" + msgid "DiffsCompareBaseBranch|(base)" msgstr "" @@ -5607,9 +5854,6 @@ msgstr "" msgid "Disable" msgstr "" -msgid "Disable email notifications" -msgstr "" - msgid "Disable for this project" msgstr "" @@ -5709,12 +5953,21 @@ msgstr "" msgid "Display name" msgstr "" +msgid "Displays dependencies and known vulnerabilities, based on the %{linkStart}latest pipeline%{linkEnd} scan" +msgstr "" + +msgid "Do not display offers from third parties within GitLab" +msgstr "" + msgid "Do you want to customize how Google Code email addresses and usernames are imported into GitLab?" msgstr "" msgid "Dockerfile" msgstr "" +msgid "Documentation" +msgstr "" + msgid "Documentation for popular identity providers" msgstr "" @@ -5799,6 +6052,9 @@ msgstr "" msgid "Due date" msgstr "" +msgid "Duration" +msgstr "" + msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below." msgstr "" @@ -5847,6 +6103,9 @@ msgstr "" msgid "Edit comment" msgstr "" +msgid "Edit dashboard" +msgstr "" + msgid "Edit description" msgstr "" @@ -5892,6 +6151,9 @@ msgstr "" msgid "Elasticsearch" msgstr "" +msgid "Elasticsearch AWS IAM credentials" +msgstr "" + msgid "Elasticsearch indexing restrictions" msgstr "" @@ -6000,6 +6262,9 @@ msgstr "" msgid "Enable Incident Management inbound alert limit" msgstr "" +msgid "Enable PlantUML" +msgstr "" + msgid "Enable Pseudonymizer data collection" msgstr "" @@ -6102,13 +6367,13 @@ msgstr "" msgid "Ensure your %{linkStart}environment is part of the deploy stage%{linkEnd} of your CI pipeline to track deployments to your cluster." msgstr "" -msgid "Enter IP address range" +msgid "Enter Admin Mode" msgstr "" -msgid "Enter a number" +msgid "Enter IP address range" msgstr "" -msgid "Enter admin mode" +msgid "Enter a number" msgstr "" msgid "Enter at least three characters to search" @@ -6162,6 +6427,12 @@ msgstr "" msgid "Environment:" msgstr "" +msgid "EnvironmentDashboard|API" +msgstr "" + +msgid "EnvironmentDashboard|Created through the Deployment API" +msgstr "" + msgid "Environments" msgstr "" @@ -6240,15 +6511,24 @@ msgstr "" msgid "Environments|Job" msgstr "" +msgid "Environments|Learn about environments" +msgstr "" + msgid "Environments|Learn more about stopping environments" msgstr "" msgid "Environments|New environment" msgstr "" +msgid "Environments|No deployed environments" +msgstr "" + msgid "Environments|No deployments yet" msgstr "" +msgid "Environments|No pods to display" +msgstr "" + msgid "Environments|Note that this action will stop the environment, but it will %{emphasisStart}not%{emphasisEnd} have an effect on any existing deployment due to no “stop environment action†being defined in the %{ciConfigLinkStart}.gitlab-ci.yml%{ciConfigLinkEnd} file." msgstr "" @@ -6306,10 +6586,10 @@ msgstr "" msgid "Environments|This action will relaunch the job for commit %{linkStart}%{commitId}%{linkEnd}, putting the environment in a previous version. Are you sure you want to continue?" msgstr "" -msgid "Environments|This action will run the job defined by %{name} for commit %{linkStart}%{commitId}%{linkEnd} putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?" +msgid "Environments|This action will run the job defined by %{environment_name} for commit %{commit_id}, putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?" msgstr "" -msgid "Environments|This action will run the job defined by staging for commit %{commit_id}, putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?" +msgid "Environments|This action will run the job defined by %{name} for commit %{linkStart}%{commitId}%{linkEnd} putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?" msgstr "" msgid "Environments|Updated" @@ -6414,6 +6694,9 @@ msgstr "" msgid "Error" msgstr "" +msgid "Error Details" +msgstr "" + msgid "Error Tracking" msgstr "" @@ -6426,7 +6709,7 @@ msgstr "" msgid "Error deleting %{issuableType}" msgstr "" -msgid "Error fetching contributors data." +msgid "Error details" msgstr "" msgid "Error fetching diverging counts for branches. Please try again." @@ -6666,6 +6949,9 @@ msgstr "" msgid "Except policy:" msgstr "" +msgid "Excluding merge commits. Limited to 6,000 commits." +msgstr "" + msgid "Existing" msgstr "" @@ -6747,6 +7033,9 @@ msgstr "" msgid "External Classification Policy Authorization" msgstr "" +msgid "External ID" +msgstr "" + msgid "External URL" msgstr "" @@ -6843,6 +7132,9 @@ msgstr "" msgid "Failed to deploy to" msgstr "" +msgid "Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later." +msgstr "" + msgid "Failed to get ref." msgstr "" @@ -6852,6 +7144,9 @@ msgstr "" msgid "Failed to load emoji list." msgstr "" +msgid "Failed to load error details from Sentry." +msgstr "" + msgid "Failed to load errors from Sentry. Error message: %{errorMessage}" msgstr "" @@ -6861,6 +7156,9 @@ msgstr "" msgid "Failed to load related branches" msgstr "" +msgid "Failed to load stacktrace." +msgstr "" + msgid "Failed to mark this issue as a duplicate because referenced issue was not found." msgstr "" @@ -7041,6 +7339,9 @@ msgstr "" msgid "FeatureFlags|Get started with feature flags" msgstr "" +msgid "FeatureFlags|ID" +msgstr "" + msgid "FeatureFlags|Inactive" msgstr "" @@ -7190,6 +7491,9 @@ msgstr "" msgid "Filter by two-factor authentication" msgstr "" +msgid "Filter by user" +msgstr "" + msgid "Filter projects" msgstr "" @@ -7247,6 +7551,9 @@ msgstr "" msgid "First name" msgstr "" +msgid "First seen" +msgstr "" + msgid "Fixed date" msgstr "" @@ -7370,6 +7677,9 @@ msgstr "" msgid "From %{providerTitle}" msgstr "" +msgid "From %{source_title} into" +msgstr "" + msgid "From Bitbucket" msgstr "" @@ -7391,9 +7701,6 @@ msgstr "" msgid "From merge request merge until deploy to production" msgstr "" -msgid "From milestones:" -msgstr "" - msgid "From the Kubernetes cluster details view, install Runner from the applications list" msgstr "" @@ -7451,15 +7758,24 @@ msgstr "" msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage." msgstr "" +msgid "GeoNodes|Attachments" +msgstr "" + msgid "GeoNodes|Checksummed" msgstr "" +msgid "GeoNodes|Container repositories" +msgstr "" + msgid "GeoNodes|Data is out of date from %{timeago}" msgstr "" msgid "GeoNodes|Data replication lag" msgstr "" +msgid "GeoNodes|Design repositories" +msgstr "" + msgid "GeoNodes|Does not match the primary storage configuration" msgstr "" @@ -7481,6 +7797,12 @@ msgstr "" msgid "GeoNodes|Internal URL" msgstr "" +msgid "GeoNodes|Job artifacts" +msgstr "" + +msgid "GeoNodes|LFS objects" +msgstr "" + msgid "GeoNodes|Last event ID processed by cursor" msgstr "" @@ -7502,18 +7824,6 @@ msgstr "" msgid "GeoNodes|Loading nodes" msgstr "" -msgid "GeoNodes|Local LFS objects" -msgstr "" - -msgid "GeoNodes|Local attachments" -msgstr "" - -msgid "GeoNodes|Local container repositories" -msgstr "" - -msgid "GeoNodes|Local job artifacts" -msgstr "" - msgid "GeoNodes|New node" msgstr "" @@ -7937,6 +8247,9 @@ msgstr "" msgid "GitLabPages|%{domain} is not verified. To learn how to verify ownership, visit your %{link_start}domain details%{link_end}." msgstr "" +msgid "GitLabPages|Access Control is enabled for this Pages website; only authorized users will be able to access it. To make your website publicly available, navigate to your project's %{strong_start}Settings > General > Visibility%{strong_end} and select %{strong_start}Everyone%{strong_end} in pages section. Read the %{link_start}documentation%{link_end} for more information." +msgstr "" + msgid "GitLabPages|Access pages" msgstr "" @@ -7949,10 +8262,10 @@ msgstr "" msgid "GitLabPages|Configure pages" msgstr "" -msgid "GitLabPages|Details" +msgid "GitLabPages|Domains" msgstr "" -msgid "GitLabPages|Domains" +msgid "GitLabPages|Edit" msgstr "" msgid "GitLabPages|Expired" @@ -7961,6 +8274,9 @@ msgstr "" msgid "GitLabPages|Force HTTPS (requires valid certificates)" msgstr "" +msgid "GitLabPages|GitLab Pages are disabled for this project. You can enable them on your project's %{strong_start}Settings > General > Visibility%{strong_end} page." +msgstr "" + msgid "GitLabPages|It may take up to 30 minutes before the site is available after the first deployment." msgstr "" @@ -8045,7 +8361,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -8156,6 +8472,9 @@ msgstr "" msgid "Golden Tanuki" msgstr "" +msgid "Google Cloud Platform" +msgstr "" + msgid "Google Code import" msgstr "" @@ -8174,6 +8493,27 @@ msgstr "" msgid "Grafana URL" msgstr "" +msgid "GrafanaIntegration|API Token" +msgstr "" + +msgid "GrafanaIntegration|Active" +msgstr "" + +msgid "GrafanaIntegration|Embed Grafana charts in GitLab issues." +msgstr "" + +msgid "GrafanaIntegration|Enter the Grafana API Token." +msgstr "" + +msgid "GrafanaIntegration|Enter the base URL of the Grafana instance." +msgstr "" + +msgid "GrafanaIntegration|Grafana Authentication" +msgstr "" + +msgid "GrafanaIntegration|Grafana URL" +msgstr "" + msgid "Grant access" msgstr "" @@ -8237,12 +8577,24 @@ msgstr "" msgid "Group name" msgstr "" +msgid "Group overview" +msgstr "" + msgid "Group overview content" msgstr "" +msgid "Group path is already taken. Suggestions: " +msgstr "" + +msgid "Group path is available." +msgstr "" + msgid "Group pipeline minutes were successfully reset." msgstr "" +msgid "Group variables (inherited)" +msgstr "" + msgid "Group was successfully updated." msgstr "" @@ -8285,6 +8637,9 @@ msgstr "" msgid "GroupSAML|Configuration" msgstr "" +msgid "GroupSAML|Copy SAML Response XML" +msgstr "" + msgid "GroupSAML|Enable SAML authentication for this group." msgstr "" @@ -8318,6 +8673,18 @@ 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 "" +msgid "GroupSAML|NameID" +msgstr "" + +msgid "GroupSAML|NameID Format" +msgstr "" + +msgid "GroupSAML|SAML Response Output" +msgstr "" + +msgid "GroupSAML|SAML Response XML" +msgstr "" + msgid "GroupSAML|SAML Single Sign On" msgstr "" @@ -8345,12 +8712,24 @@ msgstr "" msgid "GroupSAML|Toggle SAML authentication" msgstr "" +msgid "GroupSAML|Valid SAML Response" +msgstr "" + msgid "GroupSAML|With group managed accounts enabled, all the users without a group managed account will be excluded from the group." msgstr "" msgid "GroupSAML|Your SCIM token" msgstr "" +msgid "GroupSAML|must match stored NameID of \"%{extern_uid}\" as we use this to identify users. If the NameID changes users will be unable to sign in." +msgstr "" + +msgid "GroupSAML|should be \"persistent\"" +msgstr "" + +msgid "GroupSAML|should be a random persistent ID, emails are discouraged" +msgstr "" + msgid "GroupSettings|Auto DevOps pipeline was updated for the group" msgstr "" @@ -8444,6 +8823,9 @@ msgstr "" msgid "GroupSettings|cannot be disabled when the parent group \"Share with group lock\" is enabled, except by the owner of the parent group" msgstr "" +msgid "GroupSettings|cannot change when group contains projects with NPM packages" +msgstr "" + msgid "GroupSettings|remove the share with group lock from %{ancestor_group_name}" msgstr "" @@ -8614,6 +8996,9 @@ msgstr[1] "" msgid "Hide values" msgstr "" +msgid "Hiding all labels" +msgstr "" + msgid "Highest number of requests per minute for each raw path, default to 300. To disable throttling set to 0." msgstr "" @@ -8755,7 +9140,7 @@ msgstr "" msgid "If disabled, a diverged local branch will not be automatically updated with commits from its remote counterpart, to prevent local data loss. If the default branch (%{default_branch}) has diverged and cannot be updated, mirroring will fail. Other diverged branches are silently ignored." msgstr "" -msgid "If disabled, only admins will be able to set up mirrors in projects." +msgid "If disabled, only admins will be able to configure repository mirroring." msgstr "" msgid "If disabled, the access level will depend on the user's permissions in the project." @@ -8782,6 +9167,9 @@ msgstr "" msgid "If your HTTP repository is not publicly accessible, add your credentials." msgstr "" +msgid "Iglu registry URL (optional)" +msgstr "" + msgid "ImageDiffViewer|2-up" msgstr "" @@ -8926,7 +9314,7 @@ msgstr "" msgid "In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index." msgstr "" -msgid "In order to tailor your experience with GitLab<br>we would like to know a bit more about you." +msgid "In order to tailor your experience with GitLab we<br>would like to know a bit more about you." msgstr "" msgid "In the next step, you'll be able to select the projects you want to import." @@ -8983,6 +9371,9 @@ msgstr "" msgid "Information about additional Pages templates and how to install them can be found in our %{pages_getting_started_guide}." msgstr "" +msgid "Inherited:" +msgstr "" + msgid "Inline" msgstr "" @@ -9090,6 +9481,9 @@ msgstr "" msgid "Invalid date" msgstr "" +msgid "Invalid date format. Please use UTC format as YYYY-MM-DD" +msgstr "" + msgid "Invalid feature" msgstr "" @@ -9342,7 +9736,7 @@ msgstr "" msgid "Job is stuck. Check runners." msgstr "" -msgid "Job traces and artifacts" +msgid "Job logs and artifacts" msgstr "" msgid "Job was retried" @@ -9429,6 +9823,9 @@ msgstr "" msgid "June" msgstr "" +msgid "Key" +msgstr "" + msgid "Key (PEM)" msgstr "" @@ -9453,7 +9850,7 @@ msgstr "" msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}" msgstr "" -msgid "Kubernetes cluster integration was not removed." +msgid "Kubernetes cluster integration and resources are being removed." msgstr "" msgid "Kubernetes cluster integration was successfully removed." @@ -9471,6 +9868,9 @@ msgstr "" msgid "Kubernetes error: %{error_code}" msgstr "" +msgid "Kubernetes popover" +msgstr "" + msgid "LDAP" msgstr "" @@ -9683,7 +10083,7 @@ msgstr "" msgid "Leave" msgstr "" -msgid "Leave admin mode" +msgid "Leave Admin Mode" msgstr "" msgid "Leave edit mode? All unsaved changes will be lost." @@ -9760,11 +10160,21 @@ msgid_plural "LicenseCompliance|License Compliance detected %d licenses for the msgstr[0] "" msgstr[1] "" +msgid "LicenseCompliance|License Compliance detected %d license for the source branch only; approval required" +msgid_plural "LicenseCompliance|License Compliance detected %d licenses for the source branch only; approval required" +msgstr[0] "" +msgstr[1] "" + msgid "LicenseCompliance|License Compliance detected %d new license" msgid_plural "LicenseCompliance|License Compliance detected %d new licenses" msgstr[0] "" msgstr[1] "" +msgid "LicenseCompliance|License Compliance detected %d new license; approval required" +msgid_plural "LicenseCompliance|License Compliance detected %d new licenses; approval required" +msgstr[0] "" +msgstr[1] "" + msgid "LicenseCompliance|License Compliance detected no licenses for the source branch only" msgstr "" @@ -9813,6 +10223,27 @@ msgstr "" msgid "Licenses" msgstr "" +msgid "License|Buy license" +msgstr "" + +msgid "License|License" +msgstr "" + +msgid "License|You can restore access to the Gold features at any time by upgrading." +msgstr "" + +msgid "License|You can start a free trial of GitLab Ultimate without any obligation or payment details." +msgstr "" + +msgid "License|You do not have a license." +msgstr "" + +msgid "License|Your License" +msgstr "" + +msgid "License|Your free trial of GitLab Ultimate expired on %{trial_ends_on}." +msgstr "" + msgid "Limit display of time tracking units to hours." msgstr "" @@ -9863,6 +10294,9 @@ msgstr "" msgid "Loading contribution stats for group members" msgstr "" +msgid "Loading files, directories, and submodules in the path %{path} for commit reference %{ref}" +msgstr "" + msgid "Loading functions timed out. Please reload the page to try again." msgstr "" @@ -9932,6 +10366,9 @@ msgstr "" msgid "Logs" msgstr "" +msgid "Logs|To see the pod logs, deploy your code to an environment." +msgstr "" + msgid "MD5" msgstr "" @@ -10223,6 +10660,9 @@ msgstr "" msgid "Merge in progress" msgstr "" +msgid "Merge options" +msgstr "" + msgid "Merge request" msgstr "" @@ -10331,13 +10771,10 @@ msgstr "" msgid "MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}" msgstr "" -msgid "MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitId}%{linkEnd}" -msgstr "" - -msgid "MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}" +msgid "MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitDisplay}%{linkEnd}" msgstr "" -msgid "MergeRequest| %{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}" +msgid "MergeRequests|started a thread on commit %{linkStart}%{commitDisplay}%{linkEnd}" msgstr "" msgid "MergeRequest|Error dismissing suggestion popover. Please try again." @@ -10430,9 +10867,6 @@ msgstr "" msgid "Metrics|Label of the y-axis (usually the unit). The x-axis always represents time." msgstr "" -msgid "Metrics|Learn about environments" -msgstr "" - msgid "Metrics|Legend label (optional)" msgstr "" @@ -10448,9 +10882,6 @@ msgstr "" msgid "Metrics|New metric" msgstr "" -msgid "Metrics|No deployed environments" -msgstr "" - msgid "Metrics|PromQL query is valid" msgstr "" @@ -10463,6 +10894,9 @@ msgstr "" msgid "Metrics|There was an error fetching the environments data, please try again" msgstr "" +msgid "Metrics|There was an error fetching the logs, please try again" +msgstr "" + msgid "Metrics|There was an error getting deployment information." msgstr "" @@ -10478,9 +10912,6 @@ msgstr "" msgid "Metrics|Unexpected deployment data response from prometheus endpoint" msgstr "" -msgid "Metrics|Unexpected metrics data response from prometheus endpoint" -msgstr "" - msgid "Metrics|Unit label" msgstr "" @@ -10490,6 +10921,9 @@ msgstr "" msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response." msgstr "" +msgid "Metrics|Validating query" +msgstr "" + msgid "Metrics|Y-axis label" msgstr "" @@ -10511,6 +10945,9 @@ msgstr "" msgid "Metrics|e.g. req/sec" msgstr "" +msgid "Microsoft Azure" +msgstr "" + msgid "Migrated %{success_count}/%{total_count} files." msgstr "" @@ -10741,6 +11178,9 @@ msgstr "" msgid "Naming, visibility" msgstr "" +msgid "Navigate to the project to close the milestone." +msgstr "" + msgid "Nav|Help" msgstr "" @@ -10995,7 +11435,7 @@ msgstr "" msgid "No issues for the selected time period." msgstr "" -msgid "No job trace" +msgid "No job log" msgstr "" msgid "No jobs to show" @@ -11037,7 +11477,7 @@ msgstr "" msgid "No preview for this file type" msgstr "" -msgid "No prioritised labels with such name or description" +msgid "No prioritized labels with such name or description" msgstr "" msgid "No public groups" @@ -11142,9 +11582,6 @@ msgstr "" msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token." msgstr "" -msgid "Note: the container registry is always visible when a project is public" -msgstr "" - msgid "NoteForm|Note" msgstr "" @@ -11274,6 +11711,9 @@ msgstr "" msgid "Number of changes (branches or tags) in a single push to determine whether webhooks and services will be fired or not. Webhooks and services won't be submitted if it surpasses that value." msgstr "" +msgid "Number of commits" +msgstr "" + msgid "Number of commits per MR" msgstr "" @@ -11474,6 +11914,9 @@ msgstr "" msgid "Or you can choose one of the suggested colors below" msgstr "" +msgid "Origin" +msgstr "" + msgid "Other Labels" msgstr "" @@ -11486,9 +11929,6 @@ msgstr "" msgid "Other visibility settings have been disabled by the administrator." msgstr "" -msgid "Our Privacy Policy has changed, please visit %{privacy_policy_link} to review these changes." -msgstr "" - msgid "Outbound requests" msgstr "" @@ -11507,24 +11947,72 @@ msgstr "" msgid "Owner" msgstr "" +msgid "Package deleted successfully" +msgstr "" + msgid "Package information" msgstr "" msgid "Package was removed" msgstr "" +msgid "PackageRegistry|Copy npm command" +msgstr "" + +msgid "PackageRegistry|Copy npm setup command" +msgstr "" + +msgid "PackageRegistry|Copy yarn command" +msgstr "" + +msgid "PackageRegistry|Copy yarn setup command" +msgstr "" + msgid "PackageRegistry|Delete Package Version" msgstr "" +msgid "PackageRegistry|Delete package" +msgstr "" + +msgid "PackageRegistry|Installation" +msgstr "" + +msgid "PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab." +msgstr "" + +msgid "PackageRegistry|Package installation" +msgstr "" + +msgid "PackageRegistry|Registry Setup" +msgstr "" + +msgid "PackageRegistry|Remove package" +msgstr "" + +msgid "PackageRegistry|There are no packages yet" +msgstr "" + msgid "PackageRegistry|There was a problem fetching the details for this package." msgstr "" msgid "PackageRegistry|Unable to load package" msgstr "" +msgid "PackageRegistry|You are about to delete <b>%{packageName}</b>, this operation is irreversible, are you sure?" +msgstr "" + msgid "PackageRegistry|You are about to delete version %{boldStart}%{version}%{boldEnd} of %{boldStart}%{name}%{boldEnd}. Are you sure?" msgstr "" +msgid "PackageRegistry|You may also need to setup authentication using an auth token. %{linkStart}See the documentation%{linkEnd} to find out more." +msgstr "" + +msgid "PackageRegistry|npm" +msgstr "" + +msgid "PackageRegistry|yarn" +msgstr "" + msgid "Packages" msgstr "" @@ -11582,6 +12070,12 @@ msgstr "" msgid "Part of merge request changes" msgstr "" +msgid "Participants" +msgstr "" + +msgid "Passed" +msgstr "" + msgid "Password" msgstr "" @@ -11987,7 +12481,7 @@ msgstr "" msgid "Please check the configuration file to ensure that it is available and the YAML is valid" msgstr "" -msgid "Please check your email (%{email}) to verify that you own this address. Didn't receive it? %{resend_link}. Wrong email address? %{update_link}." +msgid "Please check your email (%{email}) to verify that you own this address and unlock the power of CI/CD. Didn't receive it? %{resend_link}. Wrong email address? %{update_link}." msgstr "" msgid "Please choose a group URL with no special characters." @@ -12089,6 +12583,9 @@ msgstr "" msgid "Please wait while we import the repository for you. Refresh at will." msgstr "" +msgid "Pod logs" +msgstr "" + msgid "Pod not found" msgstr "" @@ -12110,6 +12607,9 @@ msgstr "" msgid "Preferences|Choose what content you want to see on a project’s overview page." msgstr "" +msgid "Preferences|Customize integrations with third party services." +msgstr "" + msgid "Preferences|Customize the appearance of the application header and navigation sidebar." msgstr "" @@ -12119,9 +12619,15 @@ msgstr "" msgid "Preferences|Display time in 24-hour format" msgstr "" +msgid "Preferences|Enable integrated code intelligence on code views" +msgstr "" + msgid "Preferences|For example: 30 mins ago." msgstr "" +msgid "Preferences|Integrations" +msgstr "" + msgid "Preferences|Layout width" msgstr "" @@ -12134,6 +12640,9 @@ msgstr "" msgid "Preferences|Show whitespace in diffs" msgstr "" +msgid "Preferences|Sourcegraph" +msgstr "" + msgid "Preferences|Syntax highlighting theme" msgstr "" @@ -12659,6 +13168,9 @@ msgstr "" msgid "Project details" msgstr "" +msgid "Project does not exist or you don't have permission to perform this action" +msgstr "" + msgid "Project export could not be deleted." msgstr "" @@ -12683,6 +13195,12 @@ msgstr "" msgid "Project name" msgstr "" +msgid "Project order will not be saved as local storage is not available." +msgstr "" + +msgid "Project overview" +msgstr "" + msgid "Project slug" msgstr "" @@ -12776,94 +13294,175 @@ msgstr "" msgid "ProjectService|Project services" msgstr "" -msgid "ProjectService|Project services allow you to integrate GitLab with other applications" +msgid "ProjectService|Project services allow you to integrate GitLab with other applications" +msgstr "" + +msgid "ProjectService|Service" +msgstr "" + +msgid "ProjectService|Services" +msgstr "" + +msgid "ProjectService|Settings" +msgstr "" + +msgid "ProjectService|To set up this service:" +msgstr "" + +msgid "ProjectSettings|Additional merge request capabilities that influence how and when merges will be performed" +msgstr "" + +msgid "ProjectSettings|All discussions must be resolved" +msgstr "" + +msgid "ProjectSettings|Allow users to request access" +msgstr "" + +msgid "ProjectSettings|Automatically resolve merge request diff discussions when they become outdated" +msgstr "" + +msgid "ProjectSettings|Badges" +msgstr "" + +msgid "ProjectSettings|Build, test, and deploy your changes" +msgstr "" + +msgid "ProjectSettings|Choose your merge method, merge options, and merge checks." +msgstr "" + +msgid "ProjectSettings|Choose your merge method, merge options, merge checks, and set up a default description template for merge requests." +msgstr "" + +msgid "ProjectSettings|Contact an admin to change this setting." +msgstr "" + +msgid "ProjectSettings|Container registry" +msgstr "" + +msgid "ProjectSettings|Customize your project badges." +msgstr "" + +msgid "ProjectSettings|Disable email notifications" +msgstr "" + +msgid "ProjectSettings|Enable 'Delete source branch' option by default" +msgstr "" + +msgid "ProjectSettings|Every merge creates a merge commit" +msgstr "" + +msgid "ProjectSettings|Every project can have its own space to store its Docker images" +msgstr "" + +msgid "ProjectSettings|Every project can have its own space to store its packages" +msgstr "" + +msgid "ProjectSettings|Everyone" +msgstr "" + +msgid "ProjectSettings|Existing merge requests and protected branches are not affected" +msgstr "" + +msgid "ProjectSettings|Failed to protect the tag" +msgstr "" + +msgid "ProjectSettings|Failed to update tag!" +msgstr "" + +msgid "ProjectSettings|Fast-forward merge" +msgstr "" + +msgid "ProjectSettings|Fast-forward merges only" +msgstr "" + +msgid "ProjectSettings|Git Large File Storage" msgstr "" -msgid "ProjectService|Service" +msgid "ProjectSettings|Internal" msgstr "" -msgid "ProjectService|Services" +msgid "ProjectSettings|Issues" msgstr "" -msgid "ProjectService|Settings" +msgid "ProjectSettings|Learn more about badges." msgstr "" -msgid "ProjectService|To set up this service:" +msgid "ProjectSettings|Lightweight issue tracking system for this project" msgstr "" -msgid "ProjectSettings|Additional merge request capabilities that influence how and when merges will be performed" +msgid "ProjectSettings|Manages large files such as audio, video, and graphics files" msgstr "" -msgid "ProjectSettings|All discussions must be resolved" +msgid "ProjectSettings|Merge checks" msgstr "" -msgid "ProjectSettings|Automatically resolve merge request diff discussions when they become outdated" +msgid "ProjectSettings|Merge commit" msgstr "" -msgid "ProjectSettings|Badges" +msgid "ProjectSettings|Merge commit with semi-linear history" msgstr "" -msgid "ProjectSettings|Choose your merge method, merge options, and merge checks." +msgid "ProjectSettings|Merge method" msgstr "" -msgid "ProjectSettings|Choose your merge method, merge options, merge checks, and set up a default description template for merge requests." +msgid "ProjectSettings|Merge options" msgstr "" -msgid "ProjectSettings|Contact an admin to change this setting." +msgid "ProjectSettings|Merge pipelines will try to validate the post-merge result prior to merging" msgstr "" -msgid "ProjectSettings|Customize your project badges." +msgid "ProjectSettings|Merge requests" msgstr "" -msgid "ProjectSettings|Every merge creates a merge commit" +msgid "ProjectSettings|No merge commits are created" msgstr "" -msgid "ProjectSettings|Failed to protect the tag" +msgid "ProjectSettings|Note: the container registry is always visible when a project is public" msgstr "" -msgid "ProjectSettings|Failed to update tag!" +msgid "ProjectSettings|Only signed commits can be pushed to this repository." msgstr "" -msgid "ProjectSettings|Fast-forward merge" +msgid "ProjectSettings|Packages" msgstr "" -msgid "ProjectSettings|Fast-forward merges only" +msgid "ProjectSettings|Pages" msgstr "" -msgid "ProjectSettings|Learn more about badges." +msgid "ProjectSettings|Pages for project documentation" msgstr "" -msgid "ProjectSettings|Merge checks" +msgid "ProjectSettings|Pipelines" msgstr "" -msgid "ProjectSettings|Merge commit" +msgid "ProjectSettings|Pipelines must succeed" msgstr "" -msgid "ProjectSettings|Merge commit with semi-linear history" +msgid "ProjectSettings|Pipelines need to be configured to enable this feature." msgstr "" -msgid "ProjectSettings|Merge method" +msgid "ProjectSettings|Private" msgstr "" -msgid "ProjectSettings|Merge options" +msgid "ProjectSettings|Project visibility" msgstr "" -msgid "ProjectSettings|Merge pipelines will try to validate the post-merge result prior to merging" +msgid "ProjectSettings|Public" msgstr "" -msgid "ProjectSettings|No merge commits are created" +msgid "ProjectSettings|Repository" msgstr "" -msgid "ProjectSettings|Only signed commits can be pushed to this repository." +msgid "ProjectSettings|Share code pastes with others out of Git repository" msgstr "" -msgid "ProjectSettings|Pipelines must succeed" +msgid "ProjectSettings|Show link to create/view merge request when pushing from the command line" msgstr "" -msgid "ProjectSettings|Pipelines need to be configured to enable this feature." +msgid "ProjectSettings|Snippets" msgstr "" -msgid "ProjectSettings|Show link to create/view merge request when pushing from the command line" +msgid "ProjectSettings|Submit changes to be merged upstream" msgstr "" msgid "ProjectSettings|These checks must pass before merge requests can be merged" @@ -12878,15 +13477,27 @@ msgstr "" msgid "ProjectSettings|This setting will be applied to all projects unless overridden by an admin." msgstr "" +msgid "ProjectSettings|This setting will override user notification preferences for all project members." +msgstr "" + msgid "ProjectSettings|This will dictate the commit history when you merge a merge request" msgstr "" msgid "ProjectSettings|Users can only push commits to this repository that were committed with one of their own verified emails." msgstr "" +msgid "ProjectSettings|View and edit files in this project" +msgstr "" + msgid "ProjectSettings|When conflicts arise the user is given the option to rebase" msgstr "" +msgid "ProjectSettings|Wiki" +msgstr "" + +msgid "ProjectSettings|With GitLab Pages you can host your static websites on GitLab" +msgstr "" + msgid "ProjectTemplates|.NET Core" msgstr "" @@ -12932,6 +13543,9 @@ msgstr "" msgid "ProjectTemplates|Ruby on Rails" msgstr "" +msgid "ProjectTemplates|Serverless Framework/JS" +msgstr "" + msgid "ProjectTemplates|Spring" msgstr "" @@ -13028,9 +13642,6 @@ msgstr "" msgid "ProjectsNew|Want to house several dependent projects under the same namespace? %{link_start}Create a group.%{link_end}" msgstr "" -msgid "Prometheus listen_address in config/gitlab.yml is not a valid URI" -msgstr "" - msgid "PrometheusAlerts|%{count} alerts applied" msgstr "" @@ -13178,12 +13789,24 @@ msgstr "" msgid "Promotions|Epics let you manage your portfolio of projects more efficiently and with less effort by tracking groups of issues that share a theme, across projects and milestones." msgstr "" +msgid "Promotions|Learn more" +msgstr "" + +msgid "Promotions|See the other features in the %{subscription_link_start}bronze plan%{subscription_link_end}" +msgstr "" + msgid "Promotions|This feature is locked." msgstr "" msgid "Promotions|Upgrade plan" msgstr "" +msgid "Promotions|Weighting your issue" +msgstr "" + +msgid "Promotions|When you have a lot of issues, it can be hard to get an overview. By adding a weight to your issues, you can get a better idea of the effort, cost, required time, or value of each, and so better manage them." +msgstr "" + msgid "Prompt users to upload SSH keys" msgstr "" @@ -13507,6 +14130,9 @@ msgstr "" msgid "Regex pattern" msgstr "" +msgid "Region that Elasticsearch is configured" +msgstr "" + msgid "Register" msgstr "" @@ -13522,12 +14148,6 @@ msgstr "" msgid "Register Universal Two-Factor (U2F) Device" msgstr "" -msgid "Register and see your runners for this group." -msgstr "" - -msgid "Register and see your runners for this project." -msgstr "" - msgid "Register for GitLab" msgstr "" @@ -13562,7 +14182,9 @@ msgid "Related merge requests" msgstr "" msgid "Release" -msgstr "" +msgid_plural "Releases" +msgstr[0] "" +msgstr[1] "" msgid "Release notes" msgstr "" @@ -13810,6 +14432,9 @@ msgstr "" msgid "Report abuse to admin" msgstr "" +msgid "Reported %{timeAgo} by %{reportedBy}" +msgstr "" + msgid "Reporting" msgstr "" @@ -13894,7 +14519,7 @@ msgstr "" msgid "Repository maintenance" msgstr "" -msgid "Repository mirror" +msgid "Repository mirroring" msgstr "" msgid "Repository static objects" @@ -14113,6 +14738,9 @@ msgstr "" msgid "Rollback" msgstr "" +msgid "Rook" +msgstr "" + msgid "Run CI/CD pipelines for external repositories" msgstr "" @@ -14158,6 +14786,9 @@ msgstr "" msgid "Runners activated for this project" msgstr "" +msgid "Runners are processes that pick up and execute jobs for GitLab. Here you can register and see your Runners for this project." +msgstr "" + msgid "Runners can be placed on separate users, servers, and even on your local machine." msgstr "" @@ -14576,9 +15207,30 @@ msgstr "" msgid "Security Reports|While it's rare to have no vulnerabilities for your group, it can happen. In any event, we ask that you please double check your settings to make sure you've set up your dashboard correctly." msgstr "" +msgid "Security configuration help link" +msgstr "" + msgid "Security dashboard" msgstr "" +msgid "SecurityConfiguration|Configured" +msgstr "" + +msgid "SecurityConfiguration|Feature" +msgstr "" + +msgid "SecurityConfiguration|Feature documentation" +msgstr "" + +msgid "SecurityConfiguration|Not yet configured" +msgstr "" + +msgid "SecurityConfiguration|Secure features" +msgstr "" + +msgid "SecurityConfiguration|Status" +msgstr "" + msgid "SecurityDashboard| The security dashboard displays the latest security report. Use it to find and fix vulnerabilities." msgstr "" @@ -14663,6 +15315,9 @@ msgstr "" msgid "Select Page" msgstr "" +msgid "Select Stack" +msgstr "" + msgid "Select a file from the left sidebar to begin editing. Afterwards, you'll be able to commit your changes." msgstr "" @@ -14702,6 +15357,9 @@ msgstr "" msgid "Select an existing Kubernetes cluster or create a new one" msgstr "" +msgid "Select branch" +msgstr "" + msgid "Select branch/tag" msgstr "" @@ -14756,6 +15414,9 @@ msgstr "" msgid "Select user" msgstr "" +msgid "Select your role" +msgstr "" + msgid "Selected levels cannot be used by non-admin users for groups, projects or snippets. If the public level is restricted, user profiles are only visible to logged in users." msgstr "" @@ -14780,6 +15441,9 @@ msgstr "" msgid "Sentry API URL" msgstr "" +msgid "Sentry event" +msgstr "" + msgid "Sep" msgstr "" @@ -15121,6 +15785,9 @@ msgstr "" msgid "Showing all issues" msgstr "" +msgid "Showing all labels" +msgstr "" + msgid "Showing last %{size} of log -" msgstr "" @@ -15211,9 +15878,6 @@ msgstr "" msgid "Single or combined queries" msgstr "" -msgid "Site ID" -msgstr "" - msgid "Size" msgstr "" @@ -15229,6 +15893,9 @@ msgstr "" msgid "Skip this for now" msgstr "" +msgid "Skipped" +msgstr "" + msgid "Slack application" msgstr "" @@ -15331,6 +15998,9 @@ msgstr "" msgid "Something went wrong while closing the %{issuable}. Please try again later" msgstr "" +msgid "Something went wrong while deleting the package." +msgstr "" + msgid "Something went wrong while deleting the source branch. Please try again." msgstr "" @@ -15346,18 +16016,30 @@ msgstr "" msgid "Something went wrong while fetching comments. Please try again." msgstr "" +msgid "Something went wrong while fetching description changes. Please try again." +msgstr "" + msgid "Something went wrong while fetching group member contributions" msgstr "" msgid "Something went wrong while fetching latest comments." msgstr "" +msgid "Something went wrong while fetching projects" +msgstr "" + msgid "Something went wrong while fetching related merge requests." msgstr "" msgid "Something went wrong while fetching the environments for this merge request. Please try again." msgstr "" +msgid "Something went wrong while fetching the package." +msgstr "" + +msgid "Something went wrong while fetching the packages list." +msgstr "" + msgid "Something went wrong while fetching the projects." msgstr "" @@ -15574,6 +16256,51 @@ msgstr "" msgid "Source project cannot be found." msgstr "" +msgid "Sourcegraph" +msgstr "" + +msgid "SourcegraphAdmin|Block on private and internal projects" +msgstr "" + +msgid "SourcegraphAdmin|Configure the URL to a Sourcegraph instance which can read your GitLab projects." +msgstr "" + +msgid "SourcegraphAdmin|Enable Sourcegraph" +msgstr "" + +msgid "SourcegraphAdmin|Enable code intelligence powered by %{link_start}Sourcegraph%{link_end} on your GitLab instance's code views and merge requests." +msgstr "" + +msgid "SourcegraphAdmin|If checked, only public projects will have code intelligence and communicate with Sourcegraph." +msgstr "" + +msgid "SourcegraphAdmin|More information" +msgstr "" + +msgid "SourcegraphAdmin|Save changes" +msgstr "" + +msgid "SourcegraphAdmin|Sourcegraph URL" +msgstr "" + +msgid "SourcegraphAdmin|e.g. https://sourcegraph.example.com" +msgstr "" + +msgid "SourcegraphPreferences|This feature is experimental and currently limited to certain projects." +msgstr "" + +msgid "SourcegraphPreferences|This feature is experimental and limited to public projects." +msgstr "" + +msgid "SourcegraphPreferences|This feature is experimental." +msgstr "" + +msgid "SourcegraphPreferences|Uses %{link_start}Sourcegraph.com%{link_end}." +msgstr "" + +msgid "SourcegraphPreferences|Uses a custom %{link_start}Sourcegraph instance%{link_end}." +msgstr "" + msgid "Spam Logs" msgstr "" @@ -15598,6 +16325,9 @@ msgstr "" msgid "Squash commits" msgstr "" +msgid "Stack trace" +msgstr "" + msgid "Stage" msgstr "" @@ -15658,7 +16388,7 @@ msgstr "" msgid "Start a %{new_merge_request} with these changes" msgstr "" -msgid "Start a Free Trial" +msgid "Start a Free Gold Trial" msgstr "" msgid "Start a new discussion..." @@ -15778,6 +16508,9 @@ msgstr "" msgid "StorageSize|Unknown" msgstr "" +msgid "Subgroup overview" +msgstr "" + msgid "SubgroupCreationLevel|Allowed to create subgroups" msgstr "" @@ -15841,6 +16574,15 @@ msgstr "" msgid "Subscription" msgstr "" +msgid "Subscription deletion failed." +msgstr "" + +msgid "Subscription successfully created." +msgstr "" + +msgid "Subscription successfully deleted." +msgstr "" + msgid "SubscriptionTable|Billing" msgstr "" @@ -16015,6 +16757,12 @@ msgstr "" msgid "Suggestions:" msgstr "" +msgid "Suite" +msgstr "" + +msgid "Summary" +msgstr "" + msgid "Sunday" msgstr "" @@ -16207,9 +16955,6 @@ msgstr "" msgid "Terms of Service and Privacy Policy" msgstr "" -msgid "Test SAML SSO" -msgstr "" - msgid "Test coverage parsing" msgstr "" @@ -16243,6 +16988,39 @@ msgstr "" msgid "TestHooks|Ensure the wiki is enabled and has pages." msgstr "" +msgid "TestReports|%{count} errors" +msgstr "" + +msgid "TestReports|%{count} failures" +msgstr "" + +msgid "TestReports|%{count} jobs" +msgstr "" + +msgid "TestReports|%{rate}%{sign} success rate" +msgstr "" + +msgid "TestReports|Test suites" +msgstr "" + +msgid "TestReports|Tests" +msgstr "" + +msgid "TestReports|There are no test cases to display." +msgstr "" + +msgid "TestReports|There are no test suites to show." +msgstr "" + +msgid "TestReports|There are no tests to show." +msgstr "" + +msgid "TestReports|There was an error fetching the test reports." +msgstr "" + +msgid "Tests" +msgstr "" + msgid "Thank you for signing up for your free trial! You will get additional instructions in your inbox shortly." msgstr "" @@ -16278,6 +17056,9 @@ msgstr "" msgid "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." msgstr "" +msgid "The URL to use for connecting to Elasticsearch. Use a comma-separated list to support clustering (e.g., \"http://localhost:9200, http://localhost:9201\")." +msgstr "" + msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS." msgstr "" @@ -16296,12 +17077,18 @@ msgstr "" msgid "The collection of events added to the data gathered for that stage." 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 "" + msgid "The connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination." 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 default CI configuration path for new projects." +msgstr "" + msgid "The dependency list details information about the components used within your project." msgstr "" @@ -16347,6 +17134,9 @@ msgstr "" msgid "The group and its projects can only be viewed by members." msgstr "" +msgid "The group has already been shared with this group" +msgstr "" + msgid "The group settings for %{group_links} require you to enable Two-Factor Authentication for your account. You can %{leave_group_links}." msgstr "" @@ -16605,6 +17395,9 @@ msgstr "" msgid "There was a problem communicating with your device." msgstr "" +msgid "There was a problem saving your custom stage, please try again" +msgstr "" + msgid "There was a problem sending the confirmation email" msgstr "" @@ -16623,7 +17416,13 @@ msgstr "" msgid "There was an error fetching configuration for charts" msgstr "" -msgid "There was an error fetching data for the form" +msgid "There was an error fetching cycle analytics stages." +msgstr "" + +msgid "There was an error fetching data for the selected stage" +msgstr "" + +msgid "There was an error fetching label data for the selected group" msgstr "" msgid "There was an error gathering the chart data" @@ -16665,12 +17464,18 @@ msgstr "" msgid "There was an error while fetching cycle analytics data." msgstr "" +msgid "There was an error while fetching cycle analytics summary data." +msgstr "" + msgid "There was an error with the reCAPTCHA. Please solve the reCAPTCHA again." msgstr "" msgid "These existing issues have a similar title. It might be better to comment there instead of creating another similar issue." msgstr "" +msgid "These variables are configured in the parent group settings, and will be active in the current project in addition to the project variables." +msgstr "" + msgid "They can be managed using the %{link}." msgstr "" @@ -16905,6 +17710,9 @@ msgstr "" msgid "This option is disabled as you don't have write permissions for the current branch" msgstr "" +msgid "This option is only available on GitLab.com" +msgstr "" + msgid "This page is unavailable because you are not allowed to read information across multiple projects." msgstr "" @@ -16929,6 +17737,9 @@ msgstr "" msgid "This project does not have billing enabled. To create a cluster, <a href=%{linkToBilling} target=\"_blank\" rel=\"noopener noreferrer\">enable billing <i class=\"fa fa-external-link\" aria-hidden=\"true\"></i></a> and try again." msgstr "" +msgid "This project path either does not exist or is private." +msgstr "" + msgid "This repository" msgstr "" @@ -16941,9 +17752,6 @@ msgstr "" msgid "This setting can be overridden in each project." msgstr "" -msgid "This setting will override user notification preferences for all project members." -msgstr "" - msgid "This setting will update the hostname that is used to generate private commit emails. %{learn_more}" msgstr "" @@ -17275,7 +18083,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." @@ -17419,6 +18227,9 @@ msgstr "" msgid "Total: %{total}" msgstr "" +msgid "Trace" +msgstr "" + msgid "Tracing" msgstr "" @@ -17446,6 +18257,9 @@ msgstr "" msgid "TransferGroup|Database is not supported." msgstr "" +msgid "TransferGroup|Group contains projects with NPM packages." +msgstr "" + msgid "TransferGroup|Group is already a root group." msgstr "" @@ -17473,6 +18287,9 @@ msgstr "" msgid "TransferProject|Project with same name or path in target namespace already exists" msgstr "" +msgid "TransferProject|Root namespace can't be updated if project has NPM packages" +msgstr "" + msgid "TransferProject|Transfer failed, please contact an admin." msgstr "" @@ -17578,6 +18395,9 @@ msgstr "" msgid "URL of the external storage that will serve the repository static objects (e.g. archives, blobs, ...)." msgstr "" +msgid "URL or request ID" +msgstr "" + msgid "Unable to apply suggestions to a deleted line." msgstr "" @@ -17797,6 +18617,9 @@ msgstr "" msgid "Updated %{updated_at} by %{updated_by}" msgstr "" +msgid "Updated at" +msgstr "" + msgid "Updated to" msgstr "" @@ -17821,9 +18644,6 @@ msgstr "" msgid "Upgrade your plan to activate Group Webhooks." msgstr "" -msgid "Upgrade your plan to activate Issue weight." -msgstr "" - msgid "Upgrade your plan to improve Issue boards." msgstr "" @@ -18139,6 +18959,9 @@ msgstr "" msgid "UserOnboardingTour|Take a look. Here's a nifty menu for quickly creating issues, merge requests, snippets, projects and groups. Click on it and select \"New project\" from the \"GitLab\" section to get started." msgstr "" +msgid "UserOnboardingTour|Thanks for taking the guided tour. Remember, if you want to go through it again, you can start %{emphasisStart}Learn GitLab%{emphasisEnd} in the help menu on the top right." +msgstr "" + msgid "UserOnboardingTour|Thanks for the feedback! %{thumbsUp}" msgstr "" @@ -18343,6 +19166,9 @@ msgstr "" msgid "Verified" msgstr "" +msgid "Verify SAML Configuration" +msgstr "" + msgid "Version" msgstr "" @@ -18393,7 +19219,7 @@ msgstr "" msgid "View job" msgstr "" -msgid "View job trace" +msgid "View job log" msgstr "" msgid "View jobs" @@ -18501,6 +19327,9 @@ msgstr "" msgid "VulnerabilityChart|%{formattedStartDate} to today" msgstr "" +msgid "VulnerabilityChart|Severity" +msgstr "" + msgid "Vulnerability|Class" msgstr "" @@ -18618,6 +19447,9 @@ msgstr "" msgid "Welcome to GitLab" msgstr "" +msgid "Welcome to GitLab @%{username}!" +msgstr "" + msgid "Welcome to the Guided GitLab Tour" msgstr "" @@ -18962,6 +19794,9 @@ msgstr "" msgid "You can see your chat accounts." msgstr "" +msgid "You can set up as many Runners as you need to run your jobs." +msgstr "" + msgid "You can set up jobs to only use Runners with specific tags. Separate tags with commas." msgstr "" @@ -18971,6 +19806,9 @@ msgstr "" msgid "You can test your .gitlab-ci.yml in %{linkStart}CI Lint%{linkEnd}." msgstr "" +msgid "You can try again using %{begin_link}basic search%{end_link}" +msgstr "" + msgid "You cannot access the raw file. Please wait a minute." msgstr "" @@ -19073,6 +19911,9 @@ msgstr "" msgid "You may also add variables that are made available to the running application by prepending the variable key with <code>K8S_SECRET_</code>." msgstr "" +msgid "You may close the milestone now." +msgstr "" + msgid "You must accept our Terms of Service and privacy policy in order to register an account" msgstr "" @@ -19088,6 +19929,9 @@ msgstr "" msgid "You must provide your current password in order to change it." msgstr "" +msgid "You must select a stack for configuring your cloud provider. Learn more about" +msgstr "" + msgid "You need a different license to enable FileLocks feature" msgstr "" @@ -19319,6 +20163,9 @@ msgstr "" msgid "a deleted user" msgstr "" +msgid "a design" +msgstr "" + msgid "added %{created_at_timeago}" msgstr "" @@ -19713,6 +20560,9 @@ msgstr "" msgid "design" msgstr "" +msgid "designs" +msgstr "" + msgid "detached" msgstr "" @@ -19772,6 +20622,9 @@ msgstr "" msgid "failed to dismiss associated finding(id=%{finding_id}): %{message}" msgstr "" +msgid "finding is not found or is already attached to a vulnerability" +msgstr "" + msgid "for %{link_to_merge_request} with %{link_to_merge_request_source_branch}" msgstr "" @@ -19885,6 +20738,9 @@ msgstr "" msgid "leave %{group_name}" msgstr "" +msgid "limit of %{project_limit} reached" +msgstr "" + msgid "locked by %{path_lock_user_name} %{created_at}" msgstr "" @@ -19950,9 +20806,6 @@ msgstr "" msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB" msgstr "" -msgid "mrWidget|Added to the merge train at position %{mergeTrainPosition}" -msgstr "" - msgid "mrWidget|Added to the merge train by" msgstr "" @@ -20034,6 +20887,9 @@ msgstr "" msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line" msgstr "" +msgid "mrWidget|In the merge train at position %{mergeTrainPosition}" +msgstr "" + msgid "mrWidget|Loading deployment statistics" msgstr "" @@ -20241,6 +21097,9 @@ msgstr "" msgid "nounSeries|%{item}, and %{lastItem}" msgstr "" +msgid "opened %{timeAgoString} by %{user}" +msgstr "" + msgid "or %{link_start}create a new Google account%{link_end}" msgstr "" @@ -20377,6 +21236,9 @@ msgstr "" msgid "started" msgstr "" +msgid "started a discussion on %{design_link}" +msgstr "" + msgid "started on %{milestone_start_date}" msgstr "" @@ -20395,6 +21257,9 @@ msgstr "" msgid "syntax is incorrect" msgstr "" +msgid "tag name" +msgstr "" + msgid "this document" msgstr "" @@ -20478,10 +21343,5 @@ msgstr "" msgid "with %{additions} additions, %{deletions} deletions." msgstr "" -msgid "within %d minute " -msgid_plural "within %d minutes " -msgstr[0] "" -msgstr[1] "" - msgid "yaml invalid" msgstr "" diff --git a/locale/gl_ES/gitlab.po b/locale/gl_ES/gitlab.po index 40738f1c5769a3abea664805aec208edb7a4bf2f..9c5817a6bbc76398d7c0bdb27a40685b797ca205 100644 --- a/locale/gl_ES/gitlab.po +++ b/locale/gl_ES/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/he_IL/gitlab.po b/locale/he_IL/gitlab.po index bc16e8b528b196c3951e80d525953344f25fa311..40a8dc37d2d3df6955c4c497c1804c5ee4fc0d91 100644 --- a/locale/he_IL/gitlab.po +++ b/locale/he_IL/gitlab.po @@ -7669,7 +7669,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16558,7 +16558,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/hi_IN/gitlab.po b/locale/hi_IN/gitlab.po index 0f51e91d18077226ef1d3bc1db6246804138f348..9d986e6453466caa530ab24810d62458385d3791 100644 --- a/locale/hi_IN/gitlab.po +++ b/locale/hi_IN/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/hr_HR/gitlab.po b/locale/hr_HR/gitlab.po index 42ec800d56035603ba43127c1a8fd3faf886bba0..b24fd8c146898f74d0e7ecb54d4acd8235a1ddeb 100644 --- a/locale/hr_HR/gitlab.po +++ b/locale/hr_HR/gitlab.po @@ -7608,7 +7608,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16470,7 +16470,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/hu_HU/gitlab.po b/locale/hu_HU/gitlab.po index c58c10829c33430831461f0d6a0ec76ea9ce1e44..3b25068e22a670a187f26867dd841aabf011e2c2 100644 --- a/locale/hu_HU/gitlab.po +++ b/locale/hu_HU/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/id_ID/gitlab.po b/locale/id_ID/gitlab.po index 2df79fe4a427b47fec9526ff5d51c991fb21c7b7..860c508e2bc3ff4e31d6b45d1ccd138b6c411f76 100644 --- a/locale/id_ID/gitlab.po +++ b/locale/id_ID/gitlab.po @@ -7486,7 +7486,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16294,7 +16294,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/it/gitlab.po b/locale/it/gitlab.po index dcf713158064ca8736d531b5f89394208c748e6f..c1e48014704f470d4d7f8bcd862d271aeab0972a 100644 --- a/locale/it/gitlab.po +++ b/locale/it/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/ja/gitlab.po b/locale/ja/gitlab.po index 9d4f49abbd4cc24e0b12d20aa75fc55b6391ac7d..0f974ba2df7e8c9f4a72c9bc30d6b5c416fe3527 100644 --- a/locale/ja/gitlab.po +++ b/locale/ja/gitlab.po @@ -7486,7 +7486,7 @@ msgstr "" msgid "Go back" msgstr "å‰ã«æˆ»ã‚‹" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16294,7 +16294,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/ka_GE/gitlab.po b/locale/ka_GE/gitlab.po index 4807ae7d883b4cb24c1abbbe57dbda6938b4ac5a..8f01df42567aea719f18a5bc86a290b640e08dbe 100644 --- a/locale/ka_GE/gitlab.po +++ b/locale/ka_GE/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/ko/gitlab.po b/locale/ko/gitlab.po index f51f12903069b9278a54b2d8183de5a1536bdb6c..67cb7a1a8c0f5f5a9a2e8a9b344ff8935e4733f8 100644 --- a/locale/ko/gitlab.po +++ b/locale/ko/gitlab.po @@ -7486,7 +7486,7 @@ msgstr "" msgid "Go back" msgstr "뒤로 가기" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16294,7 +16294,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/mn_MN/gitlab.po b/locale/mn_MN/gitlab.po index a29ea775eab5cc345ee543063bc4e8ed14e825e9..fa697d182456ecf2aca9fa59573bd5cfda518263 100644 --- a/locale/mn_MN/gitlab.po +++ b/locale/mn_MN/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/nb_NO/gitlab.po b/locale/nb_NO/gitlab.po index d7ea32e6542ad9a9714d9d658385b3606765ddc4..44ff0f9513d0d495e8856a085c78199132e60277 100644 --- a/locale/nb_NO/gitlab.po +++ b/locale/nb_NO/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/nl_NL/gitlab.po b/locale/nl_NL/gitlab.po index 03a0b331986e7498f74b43c11682442c5a00f4fb..971289364496589040628d2b97e327c89b0f0d21 100644 --- a/locale/nl_NL/gitlab.po +++ b/locale/nl_NL/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/pa_IN/gitlab.po b/locale/pa_IN/gitlab.po index 6fb77c5e65211d00e6cc0ca280ba081f97fc75c9..9674b40f3cd0456eb6bfca22bff82d64e9c3527b 100644 --- a/locale/pa_IN/gitlab.po +++ b/locale/pa_IN/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/pl_PL/gitlab.po b/locale/pl_PL/gitlab.po index bda1ec45c8bfe3af61abf5a7204477bcdd102565..3f3656e9c2b128c7bec4d9a3aa7ec54ccf48e983 100644 --- a/locale/pl_PL/gitlab.po +++ b/locale/pl_PL/gitlab.po @@ -7669,7 +7669,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16558,7 +16558,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/pt_BR/gitlab.po b/locale/pt_BR/gitlab.po index 93912e40b6dda2c401d3d495885798194ac6b185..120127de15fd49dc7eb76ef6d09c8960325d6169 100644 --- a/locale/pt_BR/gitlab.po +++ b/locale/pt_BR/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "Go Micro é um framework para desenvolvimento de microsserviços." msgid "Go back" msgstr "Voltar" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/pt_PT/gitlab.po b/locale/pt_PT/gitlab.po index 6e13301ff565dfe17673ee4bad4f353210544d78..ef265dc1b8cc69bd6a88c2602be78d25c65c4280 100644 --- a/locale/pt_PT/gitlab.po +++ b/locale/pt_PT/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/ro_RO/gitlab.po b/locale/ro_RO/gitlab.po index 6b40862685ddcf9c7c1bff21d4d760874f608514..c433d8f5ba2603facc7d5a76327d9dd606a01393 100644 --- a/locale/ro_RO/gitlab.po +++ b/locale/ro_RO/gitlab.po @@ -7608,7 +7608,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16470,7 +16470,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/ru/gitlab.po b/locale/ru/gitlab.po index f0d5c6afc5f5273bbb5fde130c2425a2248e2ab3..0571d4745a2198fb44ae42291ab0fcaf847b80ad 100644 --- a/locale/ru/gitlab.po +++ b/locale/ru/gitlab.po @@ -7669,7 +7669,7 @@ msgstr "Go Micro — Ñто фреймворк Ð´Ð»Ñ Ñ€Ð°Ð·Ñ€Ð°Ð±Ð¾Ñ‚ÐºÐ¸ ми msgid "Go back" msgstr "ВернутьÑÑ" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16558,7 +16558,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/sk_SK/gitlab.po b/locale/sk_SK/gitlab.po index ac90c490f1aeea6cbe78d7360b0606dc37eb6414..a2a7bc42f0cc9e4533bd28311cbb7f8dd3af79ca 100644 --- a/locale/sk_SK/gitlab.po +++ b/locale/sk_SK/gitlab.po @@ -7669,7 +7669,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16558,7 +16558,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/sq_AL/gitlab.po b/locale/sq_AL/gitlab.po index 30ac40fcfd5b9bc5ace1bb3a16be65dfd6552abe..072729c9ea2ee22256660ad6f134abea3d58555f 100644 --- a/locale/sq_AL/gitlab.po +++ b/locale/sq_AL/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/sr_CS/gitlab.po b/locale/sr_CS/gitlab.po index 14a837f695743fba54267a0a72e48a6be918a9a0..d8c8708cdc7cb0ca64bf982801a6586ad11cc69b 100644 --- a/locale/sr_CS/gitlab.po +++ b/locale/sr_CS/gitlab.po @@ -7608,7 +7608,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16470,7 +16470,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/sr_SP/gitlab.po b/locale/sr_SP/gitlab.po index ba836cc6b0bfa8d51f3c937264d38f8064846caf..8dc6be6c1ea19942657685c620f9cd879af75cc6 100644 --- a/locale/sr_SP/gitlab.po +++ b/locale/sr_SP/gitlab.po @@ -7608,7 +7608,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16470,7 +16470,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/sv_SE/gitlab.po b/locale/sv_SE/gitlab.po index 1f7ad2d35fbf66c3cacb5093073c07127cf60c11..17699450a60ef7f75bb3537ac0c50e237b5894d9 100644 --- a/locale/sv_SE/gitlab.po +++ b/locale/sv_SE/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/sw_KE/gitlab.po b/locale/sw_KE/gitlab.po index 9ba58b0642524029cf324b6b8ead956819c78ed7..230cb6fc2e6dead10c8e9661f10be41cfce5b986 100644 --- a/locale/sw_KE/gitlab.po +++ b/locale/sw_KE/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/tr_TR/gitlab.po b/locale/tr_TR/gitlab.po index e2434b056a6cd116800b2d129243f42de09bf104..6958899c85d4b7ab7b9e6f2660f83d33bb55c5b9 100644 --- a/locale/tr_TR/gitlab.po +++ b/locale/tr_TR/gitlab.po @@ -7547,7 +7547,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16382,7 +16382,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/uk/gitlab.po b/locale/uk/gitlab.po index 8511ce52d398f6a78977d8ec96f016b0d70d7783..be2c217ad8e59a22f588c69c7023d7bc1d731a18 100644 --- a/locale/uk/gitlab.po +++ b/locale/uk/gitlab.po @@ -7669,7 +7669,7 @@ msgstr "Go Micro — це фреймворк Ð´Ð»Ñ Ñ€Ð¾Ð·Ñ€Ð¾Ð±ÐºÐ¸ мікро msgid "Go back" msgstr "ПовернутиÑÑ" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "Перейти назад (при пошуку файлів" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16558,7 +16558,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "Ð”Ð»Ñ Ð·Ð±ÐµÑ€ÐµÐ¶ÐµÐ½Ð½Ñ ÑˆÐ²Ð¸Ð´ÐºÐ¾Ð´Ñ–Ñ— відображаютьÑÑ Ð»Ð¸ÑˆÐµ <strong>%{display_size} із %{real_size}</strong> файлів." -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/vi_VN/gitlab.po b/locale/vi_VN/gitlab.po index a72493498385156ebe75ec6ec08d33ad86461eeb..6369e4772891ba84097d3f098c4e5de1ab3b54e8 100644 --- a/locale/vi_VN/gitlab.po +++ b/locale/vi_VN/gitlab.po @@ -7486,7 +7486,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16294,7 +16294,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/zh_CN/gitlab.po b/locale/zh_CN/gitlab.po index 4b19f016a59b62eae7078ae92116be570b135859..2c6a67d6ecc833be1109764dc2df0aae54993f01 100644 --- a/locale/zh_CN/gitlab.po +++ b/locale/zh_CN/gitlab.po @@ -7486,7 +7486,7 @@ msgstr "Go Micro是一个微æœåŠ¡å¼€å‘的框架。" msgid "Go back" msgstr "返回" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16294,7 +16294,7 @@ msgstr "请将%{link} 页é¢è¿žæŽ¥åˆ°æ‚¨çš„ Jaeger æœåŠ¡å™¨ï¼Œä»¥ä¾¿åœ¨ GitLab msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "为了ä¿æŒæ€§èƒ½ï¼Œä»…显示文件ä¸çš„ <strong>%{display_size}/%{real_size}</strong>。" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/zh_HK/gitlab.po b/locale/zh_HK/gitlab.po index e1050515e4d92a9e05fbb5ed07bbf7f6665abaed..13f814fe62e3925821b5f749ea525792f44ee405 100644 --- a/locale/zh_HK/gitlab.po +++ b/locale/zh_HK/gitlab.po @@ -7486,7 +7486,7 @@ msgstr "" msgid "Go back" msgstr "" -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16294,7 +16294,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/locale/zh_TW/gitlab.po b/locale/zh_TW/gitlab.po index 89f48dcb07f68137b724a0b75234ca17b024118a..f533e52c6f1492b3e5bbfa4f238302e2e076bb95 100644 --- a/locale/zh_TW/gitlab.po +++ b/locale/zh_TW/gitlab.po @@ -7486,7 +7486,7 @@ msgstr "" msgid "Go back" msgstr "上一é " -msgid "Go back (while searching for files" +msgid "Go back (while searching for files)" msgstr "" msgid "Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board." @@ -16294,7 +16294,7 @@ msgstr "" msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed." msgstr "" -msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private." +msgid "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private." msgstr "" msgid "To protect this issue's confidentiality, a private fork of this project was selected." diff --git a/package.json b/package.json index 0d951a58406848712efd35cd99e68efe69a450b4..016f4f96e21b29aaf034b317c7241e4f663e7024 100644 --- a/package.json +++ b/package.json @@ -37,11 +37,13 @@ "@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.78.0", - "@gitlab/ui": "5.36.0", - "@gitlab/visual-review-tools": "1.0.3", - "apollo-cache-inmemory": "^1.5.1", - "apollo-client": "^2.5.1", + "@gitlab/svgs": "^1.82.0", + "@gitlab/ui": "7.11.0", + "@gitlab/visual-review-tools": "1.2.0", + "@sentry/browser": "^5.7.1", + "@sourcegraph/code-host-integration": "^0.0.13", + "apollo-cache-inmemory": "^1.6.3", + "apollo-client": "^2.6.4", "apollo-link": "^1.2.11", "apollo-link-batch-http": "^1.2.11", "apollo-upload-client": "^10.0.0", @@ -74,7 +76,7 @@ "d3-time-format": "^2.1.1", "d3-transition": "^1.1.1", "dateformat": "^3.0.3", - "deckar01-task_list": "^2.2.0", + "deckar01-task_list": "^2.2.1", "diff": "^3.4.0", "document-register-element": "1.13.1", "dropzone": "^4.2.0", @@ -98,7 +100,7 @@ "jszip-utils": "^0.0.2", "katex": "^0.10.0", "marked": "^0.3.12", - "mermaid": "^8.2.6", + "mermaid": "^8.4.2", "monaco-editor": "^0.15.6", "monaco-editor-webpack-plugin": "^1.7.0", "mousetrap": "^1.4.6", @@ -109,9 +111,8 @@ "prosemirror-markdown": "^1.3.0", "prosemirror-model": "^1.6.4", "raphael": "^2.2.7", - "raven-js": "^3.22.1", "raw-loader": "^3.1.0", - "sanitize-html": "^1.16.1", + "sanitize-html": "^1.20.0", "select2": "3.5.2-browserify", "sha1": "^1.1.1", "smooshpack": "^0.0.54", @@ -174,6 +175,7 @@ "jasmine-diff": "^0.1.3", "jasmine-jquery": "^2.1.1", "jest": "^24.1.0", + "jest-canvas-mock": "^2.1.2", "jest-environment-jsdom": "^24.0.0", "jest-junit": "^6.3.0", "jest-util": "^24.0.0", diff --git a/qa/Gemfile b/qa/Gemfile index f04ecb13879a56f536808829b65d5c280b069fa8..5266fc57b0a6fdd072a0416c02d5e3fee086ccd9 100644 --- a/qa/Gemfile +++ b/qa/Gemfile @@ -2,16 +2,21 @@ source 'https://rubygems.org' gem 'gitlab-qa' gem 'activesupport', '5.2.3' # This should stay in sync with the root's Gemfile -gem 'pry-byebug', '~> 3.5.1', platform: :mri gem 'capybara', '~> 2.16.1' gem 'capybara-screenshot', '~> 1.0.18' gem 'rake', '~> 12.3.0' gem 'rspec', '~> 3.7' gem 'selenium-webdriver', '~> 3.12' gem 'airborne', '~> 0.2.13' -gem 'nokogiri', '~> 1.10.4' +gem 'nokogiri', '~> 1.10.5' gem 'rspec-retry', '~> 0.6.1' gem 'rspec_junit_formatter', '~> 0.4.1' gem 'faker', '~> 1.6', '>= 1.6.6' gem 'knapsack', '~> 1.17' gem 'parallel_tests', '~> 2.29' + +group :test do + gem 'pry-byebug', '~> 3.5.1', platform: :mri + gem "ruby-debug-ide", "~> 0.7.0" + gem "debase", "~> 0.2.4.1" +end diff --git a/qa/Gemfile.lock b/qa/Gemfile.lock index d582d77c5cd45b1082c9b4b3ca056d0516ceffe6..84eab990c956d58f42e5bf61cfd8b3db5cda7067 100644 --- a/qa/Gemfile.lock +++ b/qa/Gemfile.lock @@ -29,6 +29,9 @@ GEM ffi (~> 1.0, >= 1.0.11) coderay (1.1.2) concurrent-ruby (1.1.5) + debase (0.2.4.1) + debase-ruby_core_source (>= 0.10.2) + debase-ruby_core_source (0.10.6) diff-lcs (1.3) domain_name (0.5.20170404) unf (>= 0.0.5, < 1.0.0) @@ -52,7 +55,7 @@ GEM mini_portile2 (2.4.0) minitest (5.11.3) netrc (0.11.0) - nokogiri (1.10.4) + nokogiri (1.10.5) mini_portile2 (~> 2.4.0) parallel (1.17.0) parallel_tests (2.29.0) @@ -67,7 +70,7 @@ GEM rack (2.0.6) rack-test (0.8.2) rack (>= 1.0, < 3) - rake (12.3.0) + rake (12.3.3) rest-client (2.0.2) http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) @@ -89,6 +92,8 @@ GEM rspec-support (3.7.0) rspec_junit_formatter (0.4.1) rspec-core (>= 2, < 4, != 2.12.0) + ruby-debug-ide (0.7.0) + rake (>= 0.8.1) rubyzip (1.2.2) selenium-webdriver (3.141.0) childprocess (~> 0.5) @@ -110,16 +115,18 @@ DEPENDENCIES airborne (~> 0.2.13) capybara (~> 2.16.1) capybara-screenshot (~> 1.0.18) + debase (~> 0.2.4.1) faker (~> 1.6, >= 1.6.6) gitlab-qa knapsack (~> 1.17) - nokogiri (~> 1.10.4) + nokogiri (~> 1.10.5) parallel_tests (~> 2.29) pry-byebug (~> 3.5.1) rake (~> 12.3.0) rspec (~> 3.7) rspec-retry (~> 0.6.1) rspec_junit_formatter (~> 0.4.1) + ruby-debug-ide (~> 0.7.0) selenium-webdriver (~> 3.12) BUNDLED WITH diff --git a/qa/qa.rb b/qa/qa.rb index a628c0e0e3ff10f52f69ae91c1e09b42622c7182..6397e4216d94224b3263b1f1a1d9d798eccaa6fd 100644 --- a/qa/qa.rb +++ b/qa/qa.rb @@ -9,6 +9,14 @@ require_relative '../lib/gitlab/utils' require_relative '../config/initializers/0_inject_enterprise_edition_module' module QA + ## + # Helper classes to represent frequently used sequences of actions + # (e.g., login) + # + module Flow + autoload :Login, 'qa/flow/login' + end + ## # GitLab QA runtime classes, mostly singletons. # @@ -157,6 +165,7 @@ module QA module Dashboard autoload :Projects, 'qa/page/dashboard/projects' autoload :Groups, 'qa/page/dashboard/groups' + autoload :Welcome, 'qa/page/dashboard/welcome' module Snippet autoload :New, 'qa/page/dashboard/snippet/new' @@ -331,6 +340,7 @@ module QA module Component autoload :IpLimits, 'qa/page/admin/settings/component/ip_limits' + autoload :OutboundRequests, 'qa/page/admin/settings/component/outbound_requests' autoload :RepositoryStorage, 'qa/page/admin/settings/component/repository_storage' autoload :AccountAndLimit, 'qa/page/admin/settings/component/account_and_limit' autoload :PerformanceBar, 'qa/page/admin/settings/component/performance_bar' @@ -406,7 +416,9 @@ module QA module DockerRun autoload :Base, 'qa/service/docker_run/base' + autoload :Jenkins, 'qa/service/docker_run/jenkins' autoload :LDAP, 'qa/service/docker_run/ldap' + autoload :Maven, 'qa/service/docker_run/maven' autoload :NodeJs, 'qa/service/docker_run/node_js' autoload :GitlabRunner, 'qa/service/docker_run/gitlab_runner' end @@ -419,6 +431,7 @@ module QA autoload :Config, 'qa/specs/config' autoload :Runner, 'qa/specs/runner' autoload :ParallelRunner, 'qa/specs/parallel_runner' + autoload :LoopRunner, 'qa/specs/loop_runner' module Helpers autoload :Quarantine, 'qa/specs/helpers/quarantine' @@ -436,6 +449,17 @@ module QA end end + module Jenkins + module Page + autoload :Base, 'qa/vendor/jenkins/page/base' + autoload :Login, 'qa/vendor/jenkins/page/login' + autoload :Configure, 'qa/vendor/jenkins/page/configure' + autoload :NewCredentials, 'qa/vendor/jenkins/page/new_credentials' + autoload :NewJob, 'qa/vendor/jenkins/page/new_job' + autoload :ConfigureJob, 'qa/vendor/jenkins/page/configure_job' + end + end + module Github module Page autoload :Base, 'qa/vendor/github/page/base' diff --git a/qa/qa/flow/login.rb b/qa/qa/flow/login.rb new file mode 100644 index 0000000000000000000000000000000000000000..d84dfaa9377975bc625a701b5dcd31d431c272bc --- /dev/null +++ b/qa/qa/flow/login.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module QA + module Flow + module Login + module_function + + def while_signed_in(as: nil) + Page::Main::Menu.perform(&:sign_out_if_signed_in) + + sign_in(as: as) + + yield + + Page::Main::Menu.perform(&:sign_out) + end + + def while_signed_in_as_admin + while_signed_in(as: Runtime::User.admin) do + yield + end + end + + def sign_in(as: nil) + Runtime::Browser.visit(:gitlab, Page::Main::Login) + Page::Main::Login.perform { |login| login.sign_in_using_credentials(user: as) } + end + + def sign_in_as_admin + sign_in(as: Runtime::User.admin) + end + + def sign_in_unless_signed_in(as: nil) + sign_in(as: as) unless Page::Main::Menu.perform(&:signed_in?) + end + end + end +end diff --git a/qa/qa/page/admin/overview/users/show.rb b/qa/qa/page/admin/overview/users/show.rb index 11ea7bcabc83b04e20a903f4839eb145a4129fad..f15ef0492fc4fadd44dbecaef1aa4e4888bcc44e 100644 --- a/qa/qa/page/admin/overview/users/show.rb +++ b/qa/qa/page/admin/overview/users/show.rb @@ -10,9 +10,19 @@ module QA element :impersonate_user_link end + view 'app/views/admin/users/show.html.haml' do + element :confirm_user_button + end + def click_impersonate_user click_element(:impersonate_user_link) end + + def confirm_user + accept_confirm do + click_element :confirm_user_button + end + end end end end diff --git a/qa/qa/page/admin/settings/component/outbound_requests.rb b/qa/qa/page/admin/settings/component/outbound_requests.rb new file mode 100644 index 0000000000000000000000000000000000000000..248ea5b6715a088bd943ec768451928ed9c60d56 --- /dev/null +++ b/qa/qa/page/admin/settings/component/outbound_requests.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module QA + module Page + module Admin + module Settings + module Component + class OutboundRequests < Page::Base + view 'app/views/admin/application_settings/_outbound.html.haml' do + element :allow_requests_from_services_checkbox + element :save_changes_button + end + + def allow_requests_to_local_network_from_services + check_allow_requests_to_local_network_from_services_checkbox + click_save_changes_button + end + + private + + def check_allow_requests_to_local_network_from_services_checkbox + check_element :allow_requests_from_services_checkbox + end + + def click_save_changes_button + click_element :save_changes_button + end + end + end + end + end + end +end diff --git a/qa/qa/page/admin/settings/network.rb b/qa/qa/page/admin/settings/network.rb index fdb8fcda2819101570278900cca836340254c6db..83566d3d1ca590556252ac5588fdb41f4c9367ef 100644 --- a/qa/qa/page/admin/settings/network.rb +++ b/qa/qa/page/admin/settings/network.rb @@ -9,6 +9,7 @@ module QA view 'app/views/admin/application_settings/network.html.haml' do element :ip_limits_section + element :outbound_requests_section end def expand_ip_limits(&block) @@ -16,6 +17,12 @@ module QA Component::IpLimits.perform(&block) end end + + def expand_outbound_requests(&block) + expand_section(:outbound_requests_section) do + Component::OutboundRequests.perform(&block) + end + end end end end diff --git a/qa/qa/page/base.rb b/qa/qa/page/base.rb index 71df90f2f42b5252ee2765ca9860a7a8c8212232..ed4d33dc7a30f0728cf122df942eaa99e69910fb 100644 --- a/qa/qa/page/base.rb +++ b/qa/qa/page/base.rb @@ -111,12 +111,18 @@ module QA element.select value end - def has_element?(name, text: nil, wait: Capybara.default_max_wait_time) - has_css?(element_selector_css(name), wait: wait, text: text) + 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 + + has_css?(element_selector_css(name, kwargs), text: text, wait: wait) end - def has_no_element?(name, text: nil, wait: Capybara.default_max_wait_time) - has_no_css?(element_selector_css(name), wait: wait, text: text) + 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 + + has_no_css?(element_selector_css(name, kwargs), wait: wait, text: text) end def has_text?(text) @@ -135,6 +141,40 @@ module QA has_no_css?('.fa-spinner.block-loading', wait: Capybara.default_max_wait_time) end + def has_loaded_all_images? + # I don't know of a foolproof way to wait for all images to load + # 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 + current_total_images = all("img").size + result = previous_total_images == current_total_images + previous_total_images = current_total_images + result + end + + # Retry until all images found can be fetched via HTTP, and + # check that the image has a non-zero natural width (a broken + # img tag could have a width, but wouldn't have a natural width) + + # Unfortunately, this doesn't account for SVGs. They're rendered + # as HTML, so there doesn't seem to be a way to check that they + # display properly via Selenium. However, if the SVG couldn't be + # rendered (e.g., because the file doesn't exist), the whole page + # won't display properly, so we should catch that with the test + # this method is called from. + + # The user's avatar is an img, which could be a gravatar image, + # so we skip that by only checking for images hosted internally + retry_until(sleep_interval: 1) do + all("img").all? do |image| + next true unless URI(image['src']).host == URI(page.current_url).host + + asset_exists?(image['src']) && image['naturalWidth'].to_i > 0 + end + end + end + def wait_for_animated_element(name) # It would be ideal if we could detect when the animation is complete # but in some cases there's nothing we can easily access via capybara @@ -165,8 +205,8 @@ module QA scroll_to(element_selector_css(name), *args) end - def element_selector_css(name) - Page::Element.new(name).selector_css + def element_selector_css(name, *attributes) + Page::Element.new(name, *attributes).selector_css end def click_link_with_text(text) diff --git a/qa/qa/page/component/select2.rb b/qa/qa/page/component/select2.rb index d05c44d22b2c8f461d1ecba4a8a1d32a1d9c67a7..8fe6a4a75b37f152d0a185403e506db569561bbb 100644 --- a/qa/qa/page/component/select2.rb +++ b/qa/qa/page/component/select2.rb @@ -20,12 +20,20 @@ module QA def search_and_select(item_text) find('.select2-input').set(item_text) + + wait_for_search_to_complete + select_item(item_text) end def expand_select_list find('span.select2-arrow').click end + + def wait_for_search_to_complete + has_css?('.select2-active') + has_no_css?('.select2-active', wait: 30) + end end end end diff --git a/qa/qa/page/dashboard/projects.rb b/qa/qa/page/dashboard/projects.rb index 378ac793f7b7a731347d1c8d9daec9cf3d1e5a2b..c103bc26a363714c9c27aab5a4bd9bc08834f9dd 100644 --- a/qa/qa/page/dashboard/projects.rb +++ b/qa/qa/page/dashboard/projects.rb @@ -18,6 +18,10 @@ module QA '/' end + def clear_project_filter + fill_element(:project_filter_form, "") + end + private def filter_by_name(name) diff --git a/qa/qa/page/dashboard/welcome.rb b/qa/qa/page/dashboard/welcome.rb new file mode 100644 index 0000000000000000000000000000000000000000..b54205780d924f57954906a05aea3032c495d8c5 --- /dev/null +++ b/qa/qa/page/dashboard/welcome.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module QA + module Page + module Dashboard + class Welcome < Page::Base + view 'app/views/dashboard/projects/_zero_authorized_projects.html.haml' do + element :welcome_title_content + end + + def has_welcome_title?(text) + has_element?(:welcome_title_content, text: text) + end + end + end + end +end diff --git a/qa/qa/page/element.rb b/qa/qa/page/element.rb index 9e6fd2fdd4ffc00b3d91e96003642853fef0b20d..6bfdf98587baa967bb11a71110e8bd1099ffbc7f 100644 --- a/qa/qa/page/element.rb +++ b/qa/qa/page/element.rb @@ -28,7 +28,7 @@ module QA end def selector_css - %Q([data-qa-selector="#{@name}"],.#{selector}) + %Q([data-qa-selector="#{@name}"]#{additional_selectors},.#{selector}) end def expression @@ -42,6 +42,14 @@ module QA def matches?(line) !!(line =~ /["']#{name}['"]|#{expression}/) end + + private + + def additional_selectors + @attributes.dup.delete_if { |attr| attr == :pattern || attr == :required }.map do |key, value| + %Q([data-qa-#{key.to_s.tr('_', '-')}="#{value}"]) + end.join + end end end end diff --git a/qa/qa/page/file/shared/commit_button.rb b/qa/qa/page/file/shared/commit_button.rb index d8e751dd7b6fc4baa76fbbb0ab02047dd4d6813f..559b4c6ceea8f73666e9ef72334fcde2b06c1c55 100644 --- a/qa/qa/page/file/shared/commit_button.rb +++ b/qa/qa/page/file/shared/commit_button.rb @@ -13,6 +13,10 @@ module QA def commit_changes click_element(:commit_button) + + wait(reload: false, max: 60) do + finished_loading? + end end end end diff --git a/qa/qa/page/group/show.rb b/qa/qa/page/group/show.rb index d4c4be0d6ca7be4d74409324be3ed6e1d4aac407..e1f319da134fb62c53bc5cbf05960b512d03cc77 100644 --- a/qa/qa/page/group/show.rb +++ b/qa/qa/page/group/show.rb @@ -18,6 +18,10 @@ module QA element :no_result_text, 'No groups or projects matched your search' # rubocop:disable QA/ElementWithPattern end + view 'app/views/shared/members/_access_request_links.html.haml' do + element :leave_group_link + end + def click_subgroup(name) click_link name end @@ -42,6 +46,12 @@ module QA click_element :new_in_group_button end + def leave_group + accept_alert do + click_element :leave_group_link + end + end + private def select_kind(kind) diff --git a/qa/qa/page/main/menu.rb b/qa/qa/page/main/menu.rb index 024f56db8e270582ee8d43f7026a941fce7da36c..49c48568e680b5f9a64bc68aeafa4f6fea990f24 100644 --- a/qa/qa/page/main/menu.rb +++ b/qa/qa/page/main/menu.rb @@ -20,7 +20,7 @@ module QA element :admin_area_link element :projects_dropdown, required: true element :groups_dropdown, required: true - element :more_dropdown, required: true + element :more_dropdown element :snippets_link end diff --git a/qa/qa/page/project/issue/index.rb b/qa/qa/page/project/issue/index.rb index befee25b37aefcb57a45db1b7d60e74581f5cb8f..a6ccee4353bcbb41b5e436e1b1191335b8ceb35c 100644 --- a/qa/qa/page/project/issue/index.rb +++ b/qa/qa/page/project/issue/index.rb @@ -36,6 +36,10 @@ module QA def click_closed_issues_link click_element :closed_issues_link end + + def has_issue?(issue) + has_element? :issue, issue_title: issue.to_s + end end end end diff --git a/qa/qa/page/project/issue/show.rb b/qa/qa/page/project/issue/show.rb index d2732eb7dd2753ecdb04b264cddcdd3409a75502..6ec80b7c9cc4c04d263a931063ee8787a8432117 100644 --- a/qa/qa/page/project/issue/show.rb +++ b/qa/qa/page/project/issue/show.rb @@ -108,6 +108,10 @@ module QA find_element(:more_assignees_link) end + def noteable_note_item + find_element(:noteable_note_item) + end + def select_all_activities_filter select_filter_with_text('Show all activity') end @@ -161,7 +165,15 @@ module QA def select_user(username) find("#{element_selector_css(:assignee_block)} input").set(username) - find('.dropdown-menu-user-link', text: "@#{username}").click + + dropdown_menu_user_link_selector = '.dropdown-menu-user-link' + at_username = "@#{username}" + ten_seconds = 10 + + wait(reload: false, max: ten_seconds, interval: 1) do + has_css?(dropdown_menu_user_link_selector, wait: ten_seconds, text: at_username) + end + find(dropdown_menu_user_link_selector, text: at_username).click end def wait_assignees_block_finish_loading diff --git a/qa/qa/page/project/pipeline/index.rb b/qa/qa/page/project/pipeline/index.rb index fae7818f871318218fb16ded14a7b1e8d19bf406..b52f3e99a3692e06bbabf1d41390441b1143ae22 100644 --- a/qa/qa/page/project/pipeline/index.rb +++ b/qa/qa/page/project/pipeline/index.rb @@ -7,6 +7,10 @@ module QA::Page element :pipeline_link, 'class="js-pipeline-url-link' # rubocop:disable QA/ElementWithPattern end + view 'app/assets/javascripts/pipelines/components/pipelines_table_row.vue' do + element :pipeline_commit_status + end + def click_on_latest_pipeline css = '.js-pipeline-url-link' @@ -16,6 +20,14 @@ module QA::Page link.click end + + def wait_for_latest_pipeline_success + wait(reload: false, max: 300) do + within_element_by_index(:pipeline_commit_status, 0) do + has_text?('passed') + end + end + end end end end diff --git a/qa/qa/page/project/settings/ci_cd.rb b/qa/qa/page/project/settings/ci_cd.rb index 45040cf46602ee57a8e0f323531c8f4c2d3555bb..46f93fad61edf27c6c0c3e6636008f9579cdeb55 100644 --- a/qa/qa/page/project/settings/ci_cd.rb +++ b/qa/qa/page/project/settings/ci_cd.rb @@ -35,3 +35,5 @@ module QA end end end + +QA::Page::Project::Settings::CICD.prepend_if_ee('QA::EE::Page::Project::Settings::CICD') diff --git a/qa/qa/page/project/sub_menus/settings.rb b/qa/qa/page/project/sub_menus/settings.rb index 1cd39fcff584fc017a894586e3ec482769dd332e..8be442ba35d7f704000c48210c55af9c091261f2 100644 --- a/qa/qa/page/project/sub_menus/settings.rb +++ b/qa/qa/page/project/sub_menus/settings.rb @@ -13,6 +13,7 @@ module QA element :settings_item element :link_members_settings element :general_settings_link + element :integrations_settings_link end end end @@ -55,6 +56,14 @@ module QA end end + def go_to_integrations_settings + hover_settings do + within_submenu do + click_element :integrations_settings_link + end + end + end + private def hover_settings diff --git a/qa/qa/resource/base.rb b/qa/qa/resource/base.rb index 88069df6ade39fc5ec0aeca3a1cc59e45f459b5b..ae20ca1a98eda6f92d252b8f17872ac09111b698 100644 --- a/qa/qa/resource/base.rb +++ b/qa/qa/resource/base.rb @@ -64,7 +64,12 @@ module QA end def visit! - visit(web_url) + Runtime::Logger.debug(%Q[Visiting #{self.class.name} at "#{web_url}"]) if Runtime::Env.debug? + + Support::Retrier.retry_until do + visit(web_url) + wait { current_url.include?(URI.parse(web_url).path.split('/').last || web_url) } + end end def populate(*attributes) @@ -72,7 +77,9 @@ module QA end def wait(max: 60, interval: 0.1) - QA::Support::Waiter.wait(max: max, interval: interval) + QA::Support::Waiter.wait(max: max, interval: interval) do + yield + end end private diff --git a/qa/qa/resource/group.rb b/qa/qa/resource/group.rb index e11bd5728fb934f8d01e5cb484ee0819f191a23e..7511396251d89b84879ca343c4e8f977ce669221 100644 --- a/qa/qa/resource/group.rb +++ b/qa/qa/resource/group.rb @@ -8,7 +8,10 @@ module QA attr_accessor :path, :description attribute :sandbox do - Sandbox.fabricate! + Sandbox.fabricate_via_api! do |sandbox| + sandbox.user = user + sandbox.api_client = api_client + end end attribute :id diff --git a/qa/qa/resource/issue.rb b/qa/qa/resource/issue.rb index 0817a9de06f2d23b752bc0b5eb78cb3339fc209e..3bcff6a10ac2acee7e8c96442a892f92dbc972cb 100644 --- a/qa/qa/resource/issue.rb +++ b/qa/qa/resource/issue.rb @@ -38,6 +38,10 @@ 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/members.rb b/qa/qa/resource/members.rb index d70a290752384fe1f531bbfc193c067dab09f75b..c738a91a77f37e8aa5e8cef89ceb4f4735577bb1 100644 --- a/qa/qa/resource/members.rb +++ b/qa/qa/resource/members.rb @@ -11,6 +11,10 @@ module QA post Runtime::API::Request.new(api_client, api_members_path).url, { user_id: user.id, access_level: access_level } end + def list_members + JSON.parse(get(Runtime::API::Request.new(api_client, api_members_path).url).body) + end + def api_members_path "#{api_get_path}/members" end diff --git a/qa/qa/resource/merge_request.rb b/qa/qa/resource/merge_request.rb index fe7eeeed37a6931f59df9a4413479206667611ff..1a6de8de456de4073b90a40880f4879c807879fa 100644 --- a/qa/qa/resource/merge_request.rb +++ b/qa/qa/resource/merge_request.rb @@ -26,8 +26,6 @@ module QA end attribute :target do - project.visit! - Repository::ProjectPush.fabricate! do |resource| resource.project = project resource.branch_name = 'master' diff --git a/qa/qa/resource/project.rb b/qa/qa/resource/project.rb index caaa766e982bcf44bd170386d990c6892e7f5957..3bebe2aaeda4f93d41031b8c9e101dace9af4a44 100644 --- a/qa/qa/resource/project.rb +++ b/qa/qa/resource/project.rb @@ -9,6 +9,7 @@ module QA include Members attr_writer :initialize_with_readme + attr_writer :auto_devops_enabled attr_writer :visibility attribute :id @@ -47,6 +48,7 @@ module QA @standalone = false @description = 'My awesome project' @initialize_with_readme = false + @auto_devops_enabled = true @visibility = 'public' end @@ -101,7 +103,8 @@ module QA name: name, description: description, visibility: @visibility, - initialize_with_readme: @initialize_with_readme + initialize_with_readme: @initialize_with_readme, + auto_devops_enabled: @auto_devops_enabled } unless @standalone diff --git a/qa/qa/resource/runner.rb b/qa/qa/resource/runner.rb index 1be2429bc0491e41c65a72b5739e63fc114aaf29..102c1ec83f5e5fc770117956ff2c7022063adf16 100644 --- a/qa/qa/resource/runner.rb +++ b/qa/qa/resource/runner.rb @@ -36,7 +36,6 @@ module QA runner.tags = tags runner.image = image runner.config = config if config - runner.run_untagged = true runner.register! end end diff --git a/qa/qa/resource/sandbox.rb b/qa/qa/resource/sandbox.rb index 6ee3dcf350fb507c0a3f10f3f9d7d18b6b49138e..6c87fcb377a272d5be5c39857aeff7ea636b64c3 100644 --- a/qa/qa/resource/sandbox.rb +++ b/qa/qa/resource/sandbox.rb @@ -7,6 +7,8 @@ module QA # creating it if it doesn't yet exist. # class Sandbox < Base + include Members + attr_accessor :path attribute :id diff --git a/qa/qa/resource/user.rb b/qa/qa/resource/user.rb index dcf145c9882fb74a7c497ead3cd3f48c9e3f0db1..bdbe5f3ef513acff8d9a9dd3287c1ef911af0396 100644 --- a/qa/qa/resource/user.rb +++ b/qa/qa/resource/user.rb @@ -7,16 +7,21 @@ module QA class User < Base attr_reader :unique_id attr_writer :username, :password - attr_accessor :provider, :extern_uid + attr_accessor :admin, :provider, :extern_uid attribute :id attribute :name attribute :email def initialize + @admin = false @unique_id = SecureRandom.hex(8) end + def admin? + api_resource&.dig(:is_admin) || false + end + def username @username || "qa-user-#{unique_id}" end @@ -71,6 +76,16 @@ module QA super end + def api_delete + super + + QA::Runtime::Logger.debug("Deleted user '#{username}'") if Runtime::Env.debug? + end + + def api_delete_path + "/users/#{id}" + end + def api_get_path "/users/#{fetch_id(username)}" end @@ -81,6 +96,7 @@ module QA def api_post_body { + admin: admin, email: email, password: password, username: username, @@ -93,6 +109,7 @@ module QA if Runtime::Env.signup_disabled? self.fabricate_via_api! do |user| user.username = username + user.password = password end else self.fabricate! diff --git a/qa/qa/runtime/api/client.rb b/qa/qa/runtime/api/client.rb index 1b0adbc9053abe33519c267e8975a7673eb0ae48..83fbb8f15d2332da7729db40fc90066a3b1985f0 100644 --- a/qa/qa/runtime/api/client.rb +++ b/qa/qa/runtime/api/client.rb @@ -46,19 +46,24 @@ module QA end def create_personal_access_token - Page::Main::Menu.perform(&:sign_out) if @is_new_session && Page::Main::Menu.perform { |p| p.has_personal_area?(wait: 0) } + signed_in_initially = Page::Main::Menu.perform(&:signed_in?) - unless Page::Main::Menu.perform { |p| p.has_personal_area?(wait: 0) } - Runtime::Browser.visit(@address, Page::Main::Login) - Page::Main::Login.perform { |login| login.sign_in_using_credentials(user: @user) } - end + Page::Main::Menu.perform(&:sign_out) if @is_new_session && signed_in_initially + + Flow::Login.sign_in_unless_signed_in(as: @user) token = Resource::PersonalAccessToken.fabricate!.access_token # If this is a new session, that tests that follow could fail if they - # try to sign in without starting a new session + # try to sign in without starting a new session. + # Also, if the browser wasn't already signed in, leaving it + # signed in could cause tests to fail when they try to sign + # in again. For example, that would happen if a test has a + # before(:context) block that fabricates via the API, and + # it's the first test to run so it creates an access token + # # Sign out so the tests can successfully sign in - Page::Main::Menu.perform(&:sign_out) if @is_new_session + Page::Main::Menu.perform(&:sign_out) if @is_new_session || !signed_in_initially token end diff --git a/qa/qa/runtime/browser.rb b/qa/qa/runtime/browser.rb index 4789b380377ff11ee65762aaed1b2a94f80fb26a..7e45e5e86eab3a9af620b128f77df7d1ed4e40c4 100644 --- a/qa/qa/runtime/browser.rb +++ b/qa/qa/runtime/browser.rb @@ -19,6 +19,12 @@ module QA self.class.configure! end + def self.blank_page? + ['', 'about:blank', 'data:,'].include?(Capybara.current_session.driver.browser.current_url) + rescue + true + end + ## # Visit a page that belongs to a GitLab instance under given address. # @@ -51,13 +57,13 @@ module QA Capybara.register_driver QA::Runtime::Env.browser do |app| capabilities = Selenium::WebDriver::Remote::Capabilities.send(QA::Runtime::Env.browser, - # This enables access to logs with `page.driver.manage.get_log(:browser)` - loggingPrefs: { - browser: "ALL", - client: "ALL", - driver: "ALL", - server: "ALL" - }) + # This enables access to logs with `page.driver.manage.get_log(:browser)` + loggingPrefs: { + browser: "ALL", + client: "ALL", + driver: "ALL", + server: "ALL" + }) if QA::Runtime::Env.accept_insecure_certs? capabilities['acceptInsecureCerts'] = true diff --git a/qa/qa/runtime/env.rb b/qa/qa/runtime/env.rb index b4047ef508867d911ae5da234a519a3689e29033..bcd2a2254696157894ce50937d4ec27abea68d9a 100644 --- a/qa/qa/runtime/env.rb +++ b/qa/qa/runtime/env.rb @@ -261,6 +261,10 @@ module QA ENV['QA_RUNTIME_SCENARIO_ATTRIBUTES'] end + def gitlab_qa_loop_runner_minutes + ENV.fetch('GITLAB_QA_LOOP_RUNNER_MINUTES', 1).to_i + end + private def remote_grid_credentials diff --git a/qa/qa/runtime/feature.rb b/qa/qa/runtime/feature.rb index b74f343ba7b0a159099e75b5d5a8fc21c7a34d9f..8c19436ee12b679cc3805c5e7993c7b0b746f21d 100644 --- a/qa/qa/runtime/feature.rb +++ b/qa/qa/runtime/feature.rb @@ -7,6 +7,7 @@ module QA extend Support::Api SetFeatureError = Class.new(RuntimeError) + AuthorizationError = Class.new(RuntimeError) def enable(key) QA::Runtime::Logger.info("Enabling feature: #{key}") @@ -18,6 +19,28 @@ module QA set_feature(key, false) end + def remove(key) + request = Runtime::API::Request.new(api_client, "/features/#{key}") + response = delete(request.url) + unless response.code == QA::Support::Api::HTTP_STATUS_NO_CONTENT + raise SetFeatureError, "Deleting feature flag #{key} failed with `#{response}`." + end + end + + def enable_and_verify(key) + Support::Retrier.retry_on_exception(sleep_interval: 2) do + enable(key) + + is_enabled = false + + QA::Support::Waiter.wait(interval: 1) do + is_enabled = enabled?(key) + end + + raise SetFeatureError, "#{key} was not enabled!" unless is_enabled + end + end + def enabled?(key) feature = JSON.parse(get_features).find { |flag| flag["name"] == key } feature && feature["state"] == "on" @@ -26,7 +49,22 @@ module QA private def api_client - @api_client ||= Runtime::API::Client.new(:gitlab) + @api_client ||= begin + if Runtime::Env.admin_personal_access_token + Runtime::API::Client.new(:gitlab, personal_access_token: Runtime::Env.admin_personal_access_token) + else + 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 AuthorizationError, "Administrator access is required to enable/disable feature flags. User '#{user.username}' is not an administrator." + end + + Runtime::API::Client.new(:gitlab, user: user) + end + end end def set_feature(key, value) diff --git a/qa/qa/runtime/fixtures.rb b/qa/qa/runtime/fixtures.rb index f91218ea0b589f6f75c6fa6faea4c24e3b356348..ed051b18a9a7520b12a2bbdc8e574637a829ef58 100644 --- a/qa/qa/runtime/fixtures.rb +++ b/qa/qa/runtime/fixtures.rb @@ -30,7 +30,7 @@ module QA yield dir ensure - FileUtils.remove_entry(dir) + FileUtils.remove_entry(dir, true) end private diff --git a/qa/qa/runtime/user.rb b/qa/qa/runtime/user.rb index 3c26a3ad6913e06f1373274d5c43cafebffe84d7..c50fcc25304e6c6aeecbb83c10a4cb14bc6b3241 100644 --- a/qa/qa/runtime/user.rb +++ b/qa/qa/runtime/user.rb @@ -5,6 +5,10 @@ module QA module User extend self + def admin + Struct.new(:username, :password).new(admin_username, admin_password) + end + def default_username 'root' end diff --git a/qa/qa/scenario/shared_attributes.rb b/qa/qa/scenario/shared_attributes.rb index 52f50ec8c277c2d47cd9ae5050e3edcfb9d6941d..bb45c4ce4cb75269d06a935b5de63c0b25745e52 100644 --- a/qa/qa/scenario/shared_attributes.rb +++ b/qa/qa/scenario/shared_attributes.rb @@ -8,6 +8,7 @@ module QA attribute :gitlab_address, '--address URL', 'Address of the instance to test' attribute :enable_feature, '--enable-feature FEATURE_FLAG', 'Enable a feature before running tests' attribute :parallel, '--parallel', 'Execute tests in parallel' + attribute :loop, '--loop', 'Execute test repeatedly' end end end diff --git a/qa/qa/service/docker_run/jenkins.rb b/qa/qa/service/docker_run/jenkins.rb new file mode 100644 index 0000000000000000000000000000000000000000..00b632824845e3cf6fa1ddd3485ffd64c37185ad --- /dev/null +++ b/qa/qa/service/docker_run/jenkins.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module QA + module Service + module DockerRun + class Jenkins < Base + def initialize + @image = 'registry.gitlab.com/gitlab-org/gitlab-qa/jenkins-gitlab:version1' + @name = 'jenkins-server' + @port = '8080' + super() + end + + def host_address + "http://#{host_name}:#{@port}" + end + + def host_name + return 'localhost' unless QA::Runtime::Env.running_in_ci? + + super + end + + def register! + command = <<~CMD.tr("\n", ' ') + docker run -d --rm + --network #{network} + --hostname #{host_name} + --name #{@name} + --env JENKINS_HOME=jenkins_home + --publish #{@port}:8080 + --publish 50000:50000 + #{@image} + CMD + + command.gsub!("--network #{network} ", '') unless QA::Runtime::Env.running_in_ci? + + shell command + end + end + end + end +end diff --git a/qa/qa/service/docker_run/maven.rb b/qa/qa/service/docker_run/maven.rb new file mode 100644 index 0000000000000000000000000000000000000000..8bdea20963da28c5913d220eab54224c651a3493 --- /dev/null +++ b/qa/qa/service/docker_run/maven.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module QA + module Service + module DockerRun + class Maven < Base + def initialize(volume_host_path) + @image = 'maven:3.6.2-ibmjava-8-alpine' + @name = "qa-maven-#{SecureRandom.hex(8)}" + @volume_host_path = volume_host_path + + super() + end + + def publish! + # When we run the tests via gitlab-qa, we use docker-in-docker + # which means that host of a volume mount would be the host that + # started the gitlab-qa QA container (e.g., the CI runner), + # not the gitlab-qa container itself. That means we can't + # mount a volume from the file system inside the gitlab-qa + # container. + # + # Instead, we copy the files into the container. + shell <<~CMD.tr("\n", ' ') + docker run -d --rm + --network #{network} + --hostname #{host_name} + --name #{@name} + --volume #{@volume_host_path}:/home/maven + #{@image} sh -c "sleep 300" + CMD + shell "docker cp #{@volume_host_path}/. #{@name}:/home/maven" + shell "docker exec -t #{@name} sh -c 'cd /home/maven && mvn deploy -s settings.xml'" + + # Stop the container when `mvn deploy` is finished otherwise + # the sleeping container will hold onto the files in @volume_host_path, + # which causes problems when they're created in a tmp dir + # that we want to delete + shell "docker stop #{@name}" + end + end + end + end +end diff --git a/qa/qa/specs/features/browser_ui/1_manage/login/login_via_instance_wide_saml_sso_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/login/login_via_instance_wide_saml_sso_spec.rb index 101143399f67cc360dbf9c912ce559d15cda1057..ad67f02eaca5aa819e7c6d6634b2110b24bdd9df 100644 --- a/qa/qa/specs/features/browser_ui/1_manage/login/login_via_instance_wide_saml_sso_spec.rb +++ b/qa/qa/specs/features/browser_ui/1_manage/login/login_via_instance_wide_saml_sso_spec.rb @@ -8,7 +8,9 @@ module QA Page::Main::Login.perform(&:sign_in_with_saml) - Vendor::SAMLIdp::Page::Login.perform(&:login) + Vendor::SAMLIdp::Page::Login.perform do |login_page| + login_page.login('user1', 'user1pass') + end expect(page).to have_content('Welcome to GitLab') end diff --git a/qa/qa/specs/features/browser_ui/1_manage/project/dashboard_images_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/project/dashboard_images_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6a5bc6173e0644060c4c759f8aae263b260bfbbb --- /dev/null +++ b/qa/qa/specs/features/browser_ui/1_manage/project/dashboard_images_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'nokogiri' + +module QA + context 'Manage' do + describe 'Check for broken images', :requires_admin do + before(:context) do + admin = QA::Resource::User.new.tap do |user| + user.username = QA::Runtime::User.admin_username + user.password = QA::Runtime::User.admin_password + end + @api_client = Runtime::API::Client.new(:gitlab, user: admin) + @new_user = Resource::User.fabricate_via_api! do |user| + user.api_client = @api_client + end + @new_admin = Resource::User.fabricate_via_api! do |user| + user.admin = true + user.api_client = @api_client + end + + Page::Main::Menu.perform(&:sign_out_if_signed_in) + end + + after(:context) do + @new_user.remove_via_api! + @new_admin.remove_via_api! + end + + shared_examples 'loads all images' do + it 'loads all images' do + Runtime::Browser.visit(:gitlab, Page::Main::Login) + Page::Main::Login.perform { |login| login.sign_in_using_credentials(user: new_user) } + + Page::Dashboard::Welcome.perform do |welcome| + expect(welcome).to have_welcome_title("Welcome to GitLab") + + # This would be better if it were a visual validation test + expect(welcome).to have_loaded_all_images + end + end + end + + context 'when logged in as a new user' do + it_behaves_like 'loads all images' do + let(:new_user) { @new_user } + end + end + + context 'when logged in as a new admin' do + it_behaves_like 'loads all images' do + let(:new_user) { @new_admin } + end + end + 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 55e15b19200331d9897944a305a19024a91436bf..69389672a6d7610afffba55186673787dac07674 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 @@ -2,13 +2,12 @@ module QA context 'Plan' do - describe 'check xss occurence in @mentions in issues' do + describe 'check xss occurence in @mentions in issues', :requires_admin do it 'user mentions a user in comment' do QA::Runtime::Env.personal_access_token = QA::Runtime::Env.admin_personal_access_token unless QA::Runtime::Env.personal_access_token - Runtime::Browser.visit(:gitlab, Page::Main::Login) - Page::Main::Login.perform(&:sign_in_using_admin_credentials) + Flow::Login.sign_in_as_admin end user = Resource::User.fabricate_via_api! do |user| @@ -20,9 +19,7 @@ module QA Page::Main::Menu.perform(&:sign_out) if Page::Main::Menu.perform { |p| p.has_personal_area?(wait: 0) } - Runtime::Browser.visit(:gitlab, Page::Main::Login) - - Page::Main::Login.perform(&:sign_in_using_credentials) + Flow::Login.sign_in project = Resource::Project.fabricate_via_api! do |resource| resource.name = 'xss-test-for-mentions-project' @@ -42,7 +39,7 @@ module QA Page::Project::Issue::Show.perform do |show| show.select_all_activities_filter - show.comment('cc-ing you here @eve') + show.comment("cc-ing you here @#{user.username}") expect do expect(show).to have_content("cc-ing you here") 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 2bcc89cb3380b949f47c78679bd14a7e53d2c920..dc7fa9f385950ad488a3b39236d7bb0c26458375 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 @@ -7,8 +7,7 @@ module QA let(:commit_message) { 'Closes' } before do - Runtime::Browser.visit(:gitlab, Page::Main::Login) - Page::Main::Login.perform(&:sign_in_using_credentials) + Flow::Login.sign_in issue = Resource::Issue.fabricate_via_api! do |issue| issue.title = issue_title 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 ad70f6813fbcc306e7303f33d6fdb230f1daaa59..77fcc4e9b6ac64263aa7c1dff55946a48cacb556 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 @@ -6,8 +6,7 @@ module QA let(:my_first_reply) { 'My first reply' } before do - Runtime::Browser.visit(:gitlab, Page::Main::Login) - Page::Main::Login.perform(&:sign_in_using_credentials) + Flow::Login.sign_in issue = Resource::Issue.fabricate_via_api! do |issue| issue.title = 'issue title' 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 0b1bd00ac8d84ce60ebf39692e2e2b4129a30296..77489c0ecf558f74bbc662e9e3a385c44ea375a3 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 @@ -4,8 +4,7 @@ module QA context 'Plan' do describe 'Issue comments' do before do - Runtime::Browser.visit(:gitlab, Page::Main::Login) - Page::Main::Login.perform(&:sign_in_using_credentials) + Flow::Login.sign_in issue = Resource::Issue.fabricate_via_api! do |issue| issue.title = 'issue title' 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 04ae4963d3af3d9b194bf4e8c9dde501a682610f..254efb741b317fa34f851132ca3f428180908d90 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 @@ -6,23 +6,25 @@ module QA let(:issue_title) { 'issue title' } before do - Runtime::Browser.visit(:gitlab, Page::Main::Login) - Page::Main::Login.perform(&:sign_in_using_credentials) + Flow::Login.sign_in end it 'user creates an issue' do - Resource::Issue.fabricate_via_browser_ui! do |issue| + issue = Resource::Issue.fabricate_via_browser_ui! do |issue| issue.title = issue_title end Page::Project::Menu.perform(&:click_issues) - expect(page).to have_content(issue_title) + Page::Project::Issue::Index.perform do |index| + expect(index).to have_issue(issue) + end end context 'when using attachments in comments', :object_storage do + let(:gif_file_name) { 'banana_sample.gif' } let(:file_to_attach) do - File.absolute_path(File.join('spec', 'fixtures', 'banana_sample.gif')) + File.absolute_path(File.join('spec', 'fixtures', gif_file_name)) end before do @@ -37,15 +39,7 @@ module QA Page::Project::Issue::Show.perform do |show| show.comment('See attached banana for scale', attachment: file_to_attach) - show.refresh - - image_url = find('a[href$="banana_sample.gif"]')[:href] - - found = show.wait(reload: false) do - show.asset_exists?(image_url) - end - - expect(found).to be_truthy + expect(show.noteable_note_item.find("img[src$='#{gif_file_name}']")).to be_visible end end end 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 317e31feea83c35d9c953668a5026ba127c085b7..a4f6b0bb1bf7c2ebb96853ae84ad2c8a98c76122 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 @@ -6,8 +6,7 @@ module QA let(:issue_title) { 'issue title' } before do - Runtime::Browser.visit(:gitlab, Page::Main::Login) - Page::Main::Login.perform(&:sign_in_using_credentials) + Flow::Login.sign_in issue = Resource::Issue.fabricate_via_api! do |issue| issue.title = issue_title diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/issue_suggestions_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/issue_suggestions_spec.rb index c42c2cedde0ff1085d7a664680149e5e33d83da8..e15afd1f576a65c7bb4648b51ba217113e0921de 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/issue_suggestions_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/issue_suggestions_spec.rb @@ -6,8 +6,7 @@ module QA let(:issue_title) { 'Issue Lists are awesome' } before do - Runtime::Browser.visit(:gitlab, Page::Main::Login) - Page::Main::Login.perform(&:sign_in_using_credentials) + Flow::Login.sign_in project = Resource::Project.fabricate_via_api! do |resource| resource.name = 'project-for-issue-suggestions' 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 45c14d0537cacbef20cdfc8ac8a13ab89112af5d..b1a80ad75cd3a988d084d8e14df411708913b049 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 @@ -4,8 +4,7 @@ module QA context 'Plan', :smoke do describe 'mention' do before do - Runtime::Browser.visit(:gitlab, Page::Main::Login) - Page::Main::Login.perform(&:sign_in_using_credentials) + Flow::Login.sign_in @user = Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_1, Runtime::Env.gitlab_qa_password_1) 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 891cef6c420ca03dcd76c22067a3a9d926e81c39..0eaec61b2fa2ecc6298cb99f9203aae08d43d51e 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,9 +4,6 @@ module QA context 'Create' do describe 'Download merge request patch and diff' do before(:context) do - Runtime::Browser.visit(:gitlab, Page::Main::Login) - Page::Main::Login.perform(&:sign_in_using_credentials) - project = Resource::Project.fabricate_via_api! do |project| project.name = 'project' end @@ -19,6 +16,8 @@ module QA end it 'views the merge request email patches' do + Flow::Login.sign_in + @merge_request.visit! Page::MergeRequest::Show.perform(&:view_email_patches) @@ -28,6 +27,8 @@ module QA end it 'views the merge request plain diff' do + Flow::Login.sign_in + @merge_request.visit! Page::MergeRequest::Show.perform(&:view_plain_diff) diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/add_file_template_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/add_file_template_spec.rb index e42d538fdf8273c1ffd7099c41a7f8ecb7f59faf..d2fd1d743fb31163a948bbd4e3305aecf2274cce 100644 --- a/qa/qa/specs/features/browser_ui/3_create/repository/add_file_template_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/repository/add_file_template_spec.rb @@ -1,27 +1,17 @@ # frozen_string_literal: true module QA - context 'Create' do + # Failure issue: https://gitlab.com/gitlab-org/gitlab/issues/34551 + context 'Create', :quarantine do describe 'File templates' do include Runtime::Fixtures - def login - unless Page::Main::Menu.perform(&:signed_in?) - Runtime::Browser.visit(:gitlab, Page::Main::Login) - Page::Main::Login.perform(&:sign_in_using_credentials) - end - end - before(:all) do - login - - @project = Resource::Project.fabricate! do |project| + @project = Resource::Project.fabricate_via_api! do |project| project.name = 'file-template-project' project.description = 'Add file templates via the Files view' project.initialize_with_readme = true end - - Page::Main::Menu.perform(&:sign_out) end templates = [ @@ -55,7 +45,8 @@ module QA it "user adds #{template[:file_name]} via file template #{template[:name]}" do content = fetch_template_from_api(template[:api_path], template[:api_key]) - login + Flow::Login.sign_in + @project.visit! Page::Project::Show.perform(&:create_new_file!) diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/add_list_delete_branches_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/add_list_delete_branches_spec.rb index bb1e3ced333e7a6dd685c0b57d638a1c0a2b33dc..3306c5f5c50c5a6055872ae1cdd5de03424ce603 100644 --- a/qa/qa/specs/features/browser_ui/3_create/repository/add_list_delete_branches_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/repository/add_list_delete_branches_spec.rb @@ -66,24 +66,22 @@ module QA expect(page).to have_content(commit_message_of_second_branch) expect(page).to have_content(commit_message_of_third_branch) - Page::Project::Branches::Show.perform do |branches| - expect(branches).to have_branch_with_badge(second_branch, 'merged') - end + Page::Project::Branches::Show.perform do |branches_page| + expect(branches_page).to have_branch_with_badge(second_branch, 'merged') - Page::Project::Branches::Show.perform do |branches_view| - branches_view.delete_branch(third_branch) - expect(branches_view).to have_no_branch(third_branch) - end + branches_page.delete_branch(third_branch) + + expect(branches_page).to have_no_branch(third_branch) + + branches_page.delete_merged_branches - Page::Project::Branches::Show.perform(&:delete_merged_branches) + expect(branches_page).to have_content( + 'Merged branches are being deleted. This can take some time depending on the number of branches. Please refresh the page to see changes.' + ) - expect(page).to have_content( - 'Merged branches are being deleted. This can take some time depending on the number of branches. Please refresh the page to see changes.' - ) + branches_page.refresh - page.refresh - Page::Project::Branches::Show.perform do |branches_view| - expect(branches_view).to have_no_branch(second_branch, reload: true) + expect(branches_page).to have_no_branch(second_branch, reload: true) end end end diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/clone_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/clone_spec.rb index f2584f55a608d4ae8c1fb7275751e1c7ad9962ae..0650c8395c70ee9cc1c2f397b54138929eb5577b 100644 --- a/qa/qa/specs/features/browser_ui/3_create/repository/clone_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/repository/clone_spec.rb @@ -4,14 +4,10 @@ module QA context 'Create' do describe 'Git clone over HTTP', :ldap_no_tls do before(:all) do - Runtime::Browser.visit(:gitlab, Page::Main::Login) - Page::Main::Login.perform(&:sign_in_using_credentials) - - @project = Resource::Project.fabricate! do |scenario| + @project = Resource::Project.fabricate_via_api! do |scenario| scenario.name = 'project-with-code' scenario.description = 'project for git clone tests' end - @project.visit! Git::Repository.perform do |repository| repository.uri = @project.repository_http_location.uri diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/user_views_commit_diff_patch_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/user_views_commit_diff_patch_spec.rb index b2eef38f8960c41db4377ee3fb8a17111c3ee1a1..aee62bacfa86c526e1fdfdbb85af3afe066b50f7 100644 --- a/qa/qa/specs/features/browser_ui/3_create/repository/user_views_commit_diff_patch_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/repository/user_views_commit_diff_patch_spec.rb @@ -4,9 +4,6 @@ module QA context 'Create' do describe 'Commit data' do before(:context) do - Runtime::Browser.visit(:gitlab, Page::Main::Login) - Page::Main::Login.perform(&:sign_in_using_credentials) - # Get the user's details to confirm they're included in the email patch @user = Resource::User.fabricate_via_api! do |user| user.username = Runtime::User.username @@ -34,9 +31,11 @@ module QA end def view_commit + Flow::Login.sign_in + @project.visit! - Page::Project::Show.perform do |page| # rubocop:disable QA/AmbiguousPageObjectName - page.click_commit(@commit_message) + Page::Project::Show.perform do |show| + show.click_commit(@commit_message) 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 0a89f0c9d418151ffa155e02b259b37477b68a7b..318adc3c272b62e0badc5b3028826c0c4af24d0a 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,25 +1,17 @@ # frozen_string_literal: true module QA - context 'Create' do + # Failure issue: https://gitlab.com/gitlab-org/gitlab/issues/34551 + context 'Create', :quarantine do describe 'Web IDE file templates' do include Runtime::Fixtures - def login - Runtime::Browser.visit(:gitlab, Page::Main::Login) - Page::Main::Login.perform(&:sign_in_using_credentials) - end - before(:all) do - login - - @project = Resource::Project.fabricate! do |project| + @project = Resource::Project.fabricate_via_api! do |project| project.name = 'file-template-project' project.description = 'Add file templates via the Web IDE' project.initialize_with_readme = true end - - Page::Main::Menu.perform(&:sign_out) end templates = [ @@ -53,7 +45,8 @@ module QA it "user adds #{template[:file_name]} via file template #{template[:name]}" do content = fetch_template_from_api(template[:api_path], template[:api_key]) - login + Flow::Login.sign_in + @project.visit! Page::Project::Show.perform(&:open_web_ide!) 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 e45ce438fc23416b903c60243cd08b10083d89bc..9dc4bcc8a0388546d421990729996967c467b3a8 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 @@ -6,6 +6,10 @@ module QA context 'Release', :docker 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 + Runtime::Browser.visit(:gitlab, Page::Main::Login) Page::Main::Login.perform(&:sign_in_using_credentials) @@ -26,6 +30,7 @@ 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 3f99ae644c7fce2970ae0f90c7a0a68722213c48..e9a3b0f75e6273b729fb0915cfe094719fe92a81 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 @@ -55,7 +55,8 @@ module QA end end - describe 'Auto DevOps support', :orchestrated, :kubernetes do + # https://gitlab.com/gitlab-org/gitlab/issues/35156 + 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/non_devops/performance_bar_spec.rb b/qa/qa/specs/features/browser_ui/non_devops/performance_bar_spec.rb index 4fca2db3d0fefa380d326762d1b99c3901a43226..187c4a2a248c555a2b07044c017925ba9909332a 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 @@ -2,7 +2,7 @@ module QA context 'Performance bar' do - context 'when logged in as an admin user' do + context 'when logged in as an admin user', :requires_admin do before do Runtime::Browser.visit(:gitlab, Page::Main::Login) Page::Main::Login.perform(&:sign_in_using_admin_credentials) diff --git a/qa/qa/specs/loop_runner.rb b/qa/qa/specs/loop_runner.rb new file mode 100644 index 0000000000000000000000000000000000000000..f97f5cbbd81a4737792a52a181dc5b4e7f0f5f6a --- /dev/null +++ b/qa/qa/specs/loop_runner.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module QA + module Specs + module LoopRunner + module_function + + def run(args) + start = Time.now + loop_duration = 60 * QA::Runtime::Env.gitlab_qa_loop_runner_minutes + + while Time.now - start < loop_duration + RSpec::Core::Runner.run(args.flatten, $stderr, $stdout).tap do |status| + abort if status.nonzero? + end + RSpec.clear_examples + end + end + end + end +end diff --git a/qa/qa/specs/runner.rb b/qa/qa/specs/runner.rb index 6aa08cf77b416e69e485c66d28deb8a1953a6c46..ac73cc00dbf88323b2d2187f408737871b335866 100644 --- a/qa/qa/specs/runner.rb +++ b/qa/qa/specs/runner.rb @@ -63,6 +63,8 @@ module QA if Runtime::Scenario.attributes[:parallel] ParallelRunner.run(args.flatten) + elsif Runtime::Scenario.attributes[:loop] + LoopRunner.run(args.flatten) else RSpec::Core::Runner.run(args.flatten, $stderr, $stdout).tap do |status| abort if status.nonzero? diff --git a/qa/qa/support/api.rb b/qa/qa/support/api.rb index d0ff1f8bc2c065e1b82fc5448951f5fc1f5a9f68..cd496efb4db7f7568c8aad55cd6f813928fa391c 100644 --- a/qa/qa/support/api.rb +++ b/qa/qa/support/api.rb @@ -14,7 +14,7 @@ module QA payload: payload, verify_ssl: false) rescue RestClient::ExceptionWithResponse => e - e.response + return_response_or_raise(e) end def get(url, raw_response: false) @@ -24,7 +24,7 @@ module QA verify_ssl: false, raw_response: raw_response) rescue RestClient::ExceptionWithResponse => e - e.response + return_response_or_raise(e) end def put(url, payload) @@ -34,7 +34,7 @@ module QA payload: payload, verify_ssl: false) rescue RestClient::ExceptionWithResponse => e - e.response + return_response_or_raise(e) end def delete(url) @@ -43,7 +43,7 @@ module QA url: url, verify_ssl: false) rescue RestClient::ExceptionWithResponse => e - e.response + return_response_or_raise(e) end def head(url) @@ -52,12 +52,18 @@ module QA url: url, verify_ssl: false) rescue RestClient::ExceptionWithResponse => e - e.response + return_response_or_raise(e) end def parse_body(response) JSON.parse(response.body, symbolize_names: true) end + + def return_response_or_raise(error) + raise error unless error.respond_to?(:response) && error.response + + error.response + end end end end diff --git a/qa/qa/vendor/github/page/login.rb b/qa/qa/vendor/github/page/login.rb index f6e72bb01f98d6c7f7621d04a3ecb8116a4f380d..e581edcb7c7d2fa98ec4f24fd36cb6a470e65e74 100644 --- a/qa/qa/vendor/github/page/login.rb +++ b/qa/qa/vendor/github/page/login.rb @@ -12,11 +12,15 @@ module QA fill_in 'password', with: QA::Runtime::Env.github_password click_on 'Sign in' - otp = OnePassword::CLI.new.otp + Support::Retrier.retry_until(exit_on_failure: true, sleep_interval: 35) do + otp = OnePassword::CLI.new.otp - fill_in 'otp', with: otp + fill_in 'otp', with: otp - click_on 'Verify' + click_on 'Verify' + + !has_text?('Two-factor authentication failed', wait: 1.0) + end click_on 'Authorize gitlab-qa' if has_button?('Authorize gitlab-qa') end diff --git a/qa/qa/vendor/jenkins/page/base.rb b/qa/qa/vendor/jenkins/page/base.rb new file mode 100644 index 0000000000000000000000000000000000000000..8dfbe7570f8708a9c4e728ad526eac6c761d2bed --- /dev/null +++ b/qa/qa/vendor/jenkins/page/base.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module QA + module Vendor + module Jenkins + module Page + class Base + include Capybara::DSL + include Scenario::Actable + + attr_reader :path + + class << self + attr_accessor :host + end + + def visit! + page.visit URI.join(Base.host, path).to_s + end + end + end + end + end +end diff --git a/qa/qa/vendor/jenkins/page/configure.rb b/qa/qa/vendor/jenkins/page/configure.rb new file mode 100644 index 0000000000000000000000000000000000000000..8851a2564fdc658369004396f8515febf4635486 --- /dev/null +++ b/qa/qa/vendor/jenkins/page/configure.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'capybara/dsl' + +module QA + module Vendor + module Jenkins + module Page + class Configure < Page::Base + def initialize + @path = 'configure' + end + + def visit_and_setup_gitlab_connection(gitlab_host, token_description) + visit! + fill_in '_.name', with: 'GitLab' + find('.setting-name', text: "Gitlab host URL").find(:xpath, "..").find('input').set gitlab_host + + dropdown_element = find('.setting-name', text: "Credentials").find(:xpath, "..").find('select') + + QA::Support::Retrier.retry_until(exit_on_failure: true) do + dropdown_element.select "GitLab API token (#{token_description})" + dropdown_element.value != '' + end + + yield if block_given? + + click_save + end + + def click_test_connection + click_on 'Test Connection' + end + + def has_success? + has_css?('div.ok', text: "Success") + end + + private + + def click_save + click_on 'Save' + end + end + end + end + end +end diff --git a/qa/qa/vendor/jenkins/page/configure_job.rb b/qa/qa/vendor/jenkins/page/configure_job.rb new file mode 100644 index 0000000000000000000000000000000000000000..ab16e895fa94c59672474e6a836f1aa6a1a74858 --- /dev/null +++ b/qa/qa/vendor/jenkins/page/configure_job.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'capybara/dsl' + +module QA + module Vendor + module Jenkins + module Page + class ConfigureJob < Page::Base + attr_accessor :job_name + + def initialize + @path = "/job/#{@job_name}/configure" + end + + def configure(scm_url:) + set_git_source_code_management_url(scm_url) + click_build_when_change_is_pushed_to_gitlab + set_publish_status_to_gitlab + click_save + end + + private + + def set_git_source_code_management_url(repository_url) + select_git_source_code_management + set_repository_url(repository_url) + end + + def click_build_when_change_is_pushed_to_gitlab + find('label', text: 'Build when a change is pushed to GitLab').find(:xpath, "..").find('input').click + end + + def set_publish_status_to_gitlab + click_add_post_build_action + select_publish_build_status_to_gitlab + end + + def click_save + click_on 'Save' + end + + def select_git_source_code_management + find('#radio-block-1').click + end + + def set_repository_url(repository_url) + find('.setting-name', text: "Repository URL").find(:xpath, "..").find('input').set repository_url + end + + def click_add_post_build_action + click_on "Add post-build action" + end + + def select_publish_build_status_to_gitlab + click_link "Publish build status to GitLab" + end + end + end + end + end +end diff --git a/qa/qa/vendor/jenkins/page/login.rb b/qa/qa/vendor/jenkins/page/login.rb new file mode 100644 index 0000000000000000000000000000000000000000..7b3558b25e203d40d075f27f848af9fd0e1bd436 --- /dev/null +++ b/qa/qa/vendor/jenkins/page/login.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'capybara/dsl' + +module QA + module Vendor + module Jenkins + module Page + class Login < Page::Base + def initialize + @path = 'login' + end + + def visit! + super + + QA::Support::Retrier.retry_until(sleep_interval: 3, reload_page: page, max_attempts: 20, exit_on_failure: true) do + page.has_text? 'Welcome to Jenkins!' + end + end + + def login + fill_in 'j_username', with: 'admin' + fill_in 'j_password', with: 'password' + click_on 'Sign in' + end + end + end + end + end +end diff --git a/qa/qa/vendor/jenkins/page/new_credentials.rb b/qa/qa/vendor/jenkins/page/new_credentials.rb new file mode 100644 index 0000000000000000000000000000000000000000..bdef1a13fd45cc9c1a75b8dbe26747b3fb9cac99 --- /dev/null +++ b/qa/qa/vendor/jenkins/page/new_credentials.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'capybara/dsl' + +module QA + module Vendor + module Jenkins + module Page + class NewCredentials < Page::Base + def initialize + @path = 'credentials/store/system/domain/_/newCredentials' + end + + def visit_and_set_gitlab_api_token(api_token, description) + visit! + wait_for_page_to_load + select_gitlab_api_token + set_api_token(api_token) + set_description(description) + click_ok + end + + private + + def select_gitlab_api_token + find('.setting-name', text: "Kind").find(:xpath, "..").find('select').select "GitLab API token" + end + + def set_api_token(api_token) + fill_in '_.apiToken', with: api_token + end + + def set_description(description) + fill_in '_.description', with: description + end + + def click_ok + click_on 'OK' + end + + def wait_for_page_to_load + QA::Support::Waiter.wait(interval: 1.0) do + page.has_css?('.setting-name', text: "Description") + end + end + end + end + end + end +end diff --git a/qa/qa/vendor/jenkins/page/new_job.rb b/qa/qa/vendor/jenkins/page/new_job.rb new file mode 100644 index 0000000000000000000000000000000000000000..11fa4ca8a53066da8165bc406edc199eefe50a1d --- /dev/null +++ b/qa/qa/vendor/jenkins/page/new_job.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'capybara/dsl' + +module QA + module Vendor + module Jenkins + module Page + class NewJob < Page::Base + def initialize + @path = 'newJob' + end + + def visit_and_create_new_job_with_name(new_job_name) + visit! + set_new_job_name(new_job_name) + click_free_style_project + click_ok + end + + private + + def set_new_job_name(new_job_name) + fill_in 'name', with: new_job_name + end + + def click_free_style_project + find('.hudson_model_FreeStyleProject').click + end + + def click_ok + click_on 'OK' + end + end + end + end + end +end diff --git a/qa/qa/vendor/saml_idp/page/login.rb b/qa/qa/vendor/saml_idp/page/login.rb index 1b8c926532a5fb1c2b0e87ecab21389ff22528cb..9ebcabe15fc3f0165f34b59efdfb679f72026e7b 100644 --- a/qa/qa/vendor/saml_idp/page/login.rb +++ b/qa/qa/vendor/saml_idp/page/login.rb @@ -7,18 +7,22 @@ module QA module SAMLIdp module Page class Login < Page::Base - def login - fill_in 'username', with: 'user1' - fill_in 'password', with: 'user1pass' + def login(username, password) + QA::Runtime::Logger.debug("Logging into SAMLIdp with username: #{username} and password:#{password}") if QA::Runtime::Env.debug? + + fill_in 'username', with: username + fill_in 'password', with: password click_on 'Login' end - def login_if_required - login if login_required? + def login_if_required(username, password) + login(username, password) if login_required? end def login_required? - page.has_text?('Enter your username and password') + login_required = page.has_text?('Enter your username and password') + QA::Runtime::Logger.debug("login_required: #{login_required}") if QA::Runtime::Env.debug? + login_required end end end diff --git a/qa/spec/page/element_spec.rb b/qa/spec/page/element_spec.rb index ff5e118cefa8010f00d0eba3f236d784be3a495b..3f64743ffac52b55e2964b39130d5df8860fe4b3 100644 --- a/qa/spec/page/element_spec.rb +++ b/qa/spec/page/element_spec.rb @@ -117,5 +117,23 @@ describe QA::Page::Element do it 'properly translates to a data-qa-selector' do expect(subject.selector_css).to include(%q([data-qa-selector="my_element"])) end + + context 'additional selectors' do + let(:element) { described_class.new(:my_element, index: 3, another_match: 'something') } + let(:required_element) { described_class.new(:my_element, required: true, index: 3) } + + it 'matches on additional data-qa properties' do + expect(element.selector_css).to include(%q([data-qa-selector="my_element"][data-qa-index="3"])) + end + + it 'doesnt conflict with element requirement' do + expect(required_element).to be_required + expect(required_element.selector_css).not_to include(%q(data-qa-required)) + end + + it 'translates snake_case to kebab-case' do + expect(element.selector_css).to include(%q(data-qa-another-match)) + end + end end end diff --git a/qa/spec/resource/base_spec.rb b/qa/spec/resource/base_spec.rb index 4a6b76c869f63c7e95318a155247f35d1e1c9582..fe84b3d024ae254623d6ad49363b21e6f62bc127 100644 --- a/qa/spec/resource/base_spec.rb +++ b/qa/spec/resource/base_spec.rb @@ -269,6 +269,8 @@ describe QA::Resource::Base do end it 'calls #visit with the underlying #web_url' do + allow(resource).to receive(:current_url).and_return(subject.current_url) + resource.web_url = subject.current_url resource.visit! diff --git a/qa/spec/spec_helper.rb b/qa/spec/spec_helper.rb index 363980acc3384c0f10e4bf9e2afe94ff85c664a9..42f1e6f292aee3e788c0491587e7f820b4f1803f 100644 --- a/qa/spec/spec_helper.rb +++ b/qa/spec/spec_helper.rb @@ -20,7 +20,25 @@ RSpec.configure do |config| QA::Specs::Helpers::Quarantine.configure_rspec config.before do |example| - QA::Runtime::Logger.debug("Starting test: #{example.full_description}") if QA::Runtime::Env.debug? + QA::Runtime::Logger.debug("\nStarting test: #{example.full_description}\n") if QA::Runtime::Env.debug? + end + + config.after(:context) do + if !QA::Runtime::Browser.blank_page? && QA::Page::Main::Menu.perform(&:signed_in?) + QA::Page::Main::Menu.perform(&:sign_out) + raise( + <<~ERROR + The test left the browser signed in. + + Usually, Capybara prevents this from happening but some things can + interfere. For example, if it has an `after(:context)` block that logs + in, the browser will stay logged in and this will cause the next test + to fail. + + Please make sure the test does not leave the browser signed in. + ERROR + ) + end end config.expect_with :rspec do |expectations| diff --git a/rubocop/cop/rspec/any_instance_of.rb b/rubocop/cop/rspec/any_instance_of.rb new file mode 100644 index 0000000000000000000000000000000000000000..a939af36c13054a5b65ddbe74c44e34bd7fc12e5 --- /dev/null +++ b/rubocop/cop/rspec/any_instance_of.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module RuboCop + module Cop + module RSpec + # This cop checks for `allow_any_instance_of` or `expect_any_instance_of` + # usage in specs. + # + # @example + # + # # bad + # allow_any_instance_of(User).to receive(:invalidate_issue_cache_counts) + # + # # bad + # expect_any_instance_of(User).to receive(:invalidate_issue_cache_counts) + # + # # good + # allow_next_instance_of(User) do |instance| + # allow(instance).to receive(:invalidate_issue_cache_counts) + # end + # + # # good + # expect_next_instance_of(User) do |instance| + # expect(instance).to receive(:invalidate_issue_cache_counts) + # end + # + class AnyInstanceOf < RuboCop::Cop::Cop + MESSAGE_EXPECT = 'Do not use `expect_any_instance_of` method, use `expect_next_instance_of` instead.' + MESSAGE_ALLOW = 'Do not use `allow_any_instance_of` method, use `allow_next_instance_of` instead.' + + def_node_search :expect_any_instance_of?, <<~PATTERN + (send (send nil? :expect_any_instance_of ...) ...) + PATTERN + def_node_search :allow_any_instance_of?, <<~PATTERN + (send (send nil? :allow_any_instance_of ...) ...) + PATTERN + + def on_send(node) + if expect_any_instance_of?(node) + add_offense(node, location: :expression, message: MESSAGE_EXPECT) + elsif allow_any_instance_of?(node) + add_offense(node, location: :expression, message: MESSAGE_ALLOW) + end + end + + def autocorrect(node) + replacement = + if expect_any_instance_of?(node) + replacement_any_instance_of(node, 'expect') + elsif allow_any_instance_of?(node) + replacement_any_instance_of(node, 'allow') + end + + lambda do |corrector| + corrector.replace(node.loc.expression, replacement) + end + end + + private + + def replacement_any_instance_of(node, rspec_prefix) + method_call = + node.receiver.source.sub( + "#{rspec_prefix}_any_instance_of", + "#{rspec_prefix}_next_instance_of") + + block = <<~RUBY.chomp + do |instance| + #{rspec_prefix}(instance).#{node.method_name} #{node.children.last.source} + end + RUBY + + "#{method_call} #{block}" + end + end + end + end +end diff --git a/rubocop/rubocop.rb b/rubocop/rubocop.rb index 70679aa1e780b93983314ef1845c92c6523feb39..159892ae0c1ea510f47c939337a9e532a65fab50 100644 --- a/rubocop/rubocop.rb +++ b/rubocop/rubocop.rb @@ -32,6 +32,7 @@ require_relative 'cop/migration/timestamps' require_relative 'cop/migration/update_column_in_batches' require_relative 'cop/migration/update_large_table' require_relative 'cop/project_path_helper' +require_relative 'cop/rspec/any_instance_of' require_relative 'cop/rspec/be_success_matcher' require_relative 'cop/rspec/env_assignment' require_relative 'cop/rspec/factories_in_migration_specs' diff --git a/scripts/lint-doc.sh b/scripts/lint-doc.sh index d097c2aee91ba8e15d9cb1d89aea3518861940fd..7f5d6130fe4d42129bb3ee00a4bb9f375763dede 100755 --- a/scripts/lint-doc.sh +++ b/scripts/lint-doc.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash cd "$(dirname "$0")/.." +echo "=> Linting documents at path $(pwd) as $(whoami)..." # Use long options (e.g. --header instead of -H) for curl examples in documentation. echo '=> Checking for cURL short options...' @@ -25,7 +26,7 @@ fi # Make sure no files in doc/ are executable EXEC_PERM_COUNT=$(find doc/ -type f -perm 755 | wc -l) -echo '=> Checking for executable permissions...' +echo "=> Checking $(pwd)/doc for executable permissions..." if [ "${EXEC_PERM_COUNT}" -ne 0 ] then echo '✖ ERROR: Executable permissions should not be used in documentation! Use `chmod 644` to the files in question:' >&2 diff --git a/scripts/notify-slack b/scripts/notify-slack deleted file mode 100755 index 5907fd8b986452cd917ab7467d5952c2024c5817..0000000000000000000000000000000000000000 --- a/scripts/notify-slack +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash -# 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). - -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 diff --git a/scripts/review_apps/automated_cleanup.rb b/scripts/review_apps/automated_cleanup.rb index 9edc1a2b85722d3d6a4b71e952e7dbe73283df96..c7ab8829088c99f742da58d3ea7f92cb6b94247a 100755 --- a/scripts/review_apps/automated_cleanup.rb +++ b/scripts/review_apps/automated_cleanup.rb @@ -58,10 +58,16 @@ class AutomatedCleanup checked_environments = [] delete_threshold = threshold_time(days: days_for_delete) stop_threshold = threshold_time(days: days_for_stop) + deployments_look_back_threshold = threshold_time(days: days_for_delete * 5) + + releases_to_delete = [] + + gitlab.deployments(project_path, per_page: DEPLOYMENTS_PER_PAGE, sort: 'desc').auto_paginate do |deployment| + break if Time.parse(deployment.created_at) < deployments_look_back_threshold - gitlab.deployments(project_path, per_page: DEPLOYMENTS_PER_PAGE).auto_paginate do |deployment| environment = deployment.environment + next unless environment next unless environment.name.start_with?('review/') next if checked_environments.include?(environment.slug) @@ -71,7 +77,7 @@ class AutomatedCleanup if deployed_at < delete_threshold delete_environment(environment, deployment) release = Quality::HelmClient::Release.new(environment.slug, 1, deployed_at.to_s, nil, nil, review_apps_namespace) - delete_helm_release(release) + releases_to_delete << release elsif deployed_at < stop_threshold stop_environment(environment, deployment) else @@ -80,6 +86,8 @@ class AutomatedCleanup checked_environments << environment.slug end + + delete_helm_releases(releases_to_delete) end def perform_helm_releases_cleanup!(days:) @@ -87,13 +95,20 @@ class AutomatedCleanup threshold_day = threshold_time(days: days) + releases_to_delete = [] + helm_releases.each do |release| + # Prevents deleting `dns-gitlab-review-app` releases or other unrelated releases + next unless release.name.start_with?('review-') + if release.status == 'FAILED' || release.last_update < threshold_day - delete_helm_release(release) + releases_to_delete << release else print_release_state(subject: 'Release', release_name: release.name, release_date: release.last_update, action: 'leaving') end end + + delete_helm_releases(releases_to_delete) end private @@ -114,10 +129,17 @@ class AutomatedCleanup helm.releases(args: args) end - def delete_helm_release(release) - print_release_state(subject: 'Release', release_name: release.name, release_status: release.status, release_date: release.last_update, action: 'cleaning') - helm.delete(release_name: release.name) - kubernetes.cleanup(release_name: release.name) + def delete_helm_releases(releases) + return if releases.empty? + + releases.each do |release| + print_release_state(subject: 'Release', release_name: release.name, release_status: release.status, release_date: release.last_update, action: 'cleaning') + end + + releases_names = releases.map(&:name) + helm.delete(release_name: releases_names) + kubernetes.cleanup(release_name: releases_names, wait: false) + rescue Quality::HelmClient::CommandFailedError => ex raise ex unless ignore_exception?(ex.message, IGNORED_HELM_ERRORS) diff --git a/scripts/review_apps/base-config.yaml b/scripts/review_apps/base-config.yaml index 573a5ccde115d39a44a671a2e2a21f4cc05fb85b..7aaa7544c192000817668d137449c2d38bb84186 100644 --- a/scripts/review_apps/base-config.yaml +++ b/scripts/review_apps/base-config.yaml @@ -14,11 +14,11 @@ gitlab: gitaly: resources: requests: - cpu: 300m - memory: 200M + cpu: 1200m + memory: 240M limits: - cpu: 600m - memory: 420M + cpu: 1800m + memory: 360M persistence: size: 10G gitlab-exporter: @@ -35,22 +35,25 @@ gitlab: gitlab-shell: resources: requests: - cpu: 125m - memory: 20M + cpu: 500m + memory: 25M limits: - cpu: 250m - memory: 40M + cpu: 750m + memory: 37.5M maxReplicas: 3 hpa: targetAverageValue: 130m + deployment: + livenessProbe: + timeoutSeconds: 5 sidekiq: resources: requests: - cpu: 300m - memory: 800M + cpu: 650m + memory: 880M limits: - cpu: 400m - memory: 1.6G + cpu: 975m + memory: 1320M task-runner: resources: requests: @@ -62,11 +65,11 @@ gitlab: unicorn: resources: requests: - cpu: 400m - memory: 1.4G + cpu: 500m + memory: 1540M limits: - cpu: 800m - memory: 2.8G + cpu: 750m + memory: 2310M deployment: readinessProbe: initialDelaySeconds: 5 # Default is 0 @@ -75,11 +78,11 @@ gitlab: workhorse: resources: requests: - cpu: 175m - memory: 100M + cpu: 250m + memory: 50M limits: - cpu: 350m - memory: 200M + cpu: 375m + memory: 75M readinessProbe: initialDelaySeconds: 5 # Default is 0 periodSeconds: 15 # Default is 10 @@ -87,11 +90,11 @@ gitlab: gitlab-runner: resources: requests: - cpu: 355m - memory: 300M + cpu: 450m + memory: 100M limits: - cpu: 710m - memory: 600M + cpu: 675m + memory: 150M minio: resources: requests: @@ -108,10 +111,10 @@ nginx-ingress: resources: requests: cpu: 100m - memory: 250M + memory: 450M limits: cpu: 200m - memory: 500M + memory: 675M minAvailable: 1 service: enableHttp: false @@ -133,10 +136,11 @@ postgresql: enabled: false resources: requests: - cpu: 250m - memory: 256M + cpu: 300m + memory: 250M limits: - cpu: 500m + cpu: 450m + memory: 375M prometheus: install: false redis: @@ -157,8 +161,8 @@ registry: minReplicas: 1 resources: requests: - cpu: 50m - memory: 32M - limits: cpu: 100m - memory: 64M + memory: 30M + limits: + cpu: 200m + memory: 45M diff --git a/scripts/review_apps/review-apps.sh b/scripts/review_apps/review-apps.sh index 51768d07860895160ebbeae3a5d5efc516ce3dd9..ed872783856da65046a737119578d3c552e10c55 100755 --- a/scripts/review_apps/review-apps.sh +++ b/scripts/review_apps/review-apps.sh @@ -1,5 +1,4 @@ [[ "$TRACE" ]] && set -x -export TILLER_NAMESPACE="$KUBE_NAMESPACE" function deploy_exists() { local namespace="${1}" @@ -14,16 +13,18 @@ function deploy_exists() { } function previous_deploy_failed() { - local deploy="${1}" + local namespace="${1}" + local deploy="${2}" + echoinfo "Checking for previous deployment of ${deploy}" true - helm status "${deploy}" >/dev/null 2>&1 + helm status --tiller-namespace "${namespace}" "${deploy}" >/dev/null 2>&1 local status=$? # if `status` is `0`, deployment exists, has a status if [ $status -eq 0 ]; then echoinfo "Previous deployment found, checking status..." - deployment_status=$(helm status "${deploy}" | grep ^STATUS | cut -d' ' -f2) + deployment_status=$(helm status --tiller-namespace "${namespace}" "${deploy}" | grep ^STATUS | cut -d' ' -f2) echoinfo "Previous deployment state: ${deployment_status}" if [[ "$deployment_status" == "FAILED" || "$deployment_status" == "PENDING_UPGRADE" || "$deployment_status" == "PENDING_INSTALL" ]]; then status=0; @@ -37,16 +38,17 @@ function previous_deploy_failed() { } function delete_release() { - if [ -z "$CI_ENVIRONMENT_SLUG" ]; then + local namespace="${KUBE_NAMESPACE}" + local deploy="${CI_ENVIRONMENT_SLUG}" + + if [ -z "$deploy" ]; then echoerr "No release given, aborting the delete!" return fi - local name="$CI_ENVIRONMENT_SLUG" - - echoinfo "Deleting release '$name'..." true + echoinfo "Deleting release '$deploy'..." true - helm delete --purge "$name" + helm delete --purge --tiller-namespace "${namespace}" "${deploy}" } function delete_failed_release() { @@ -59,7 +61,7 @@ function delete_failed_release() { echoinfo "No Review App with ${CI_ENVIRONMENT_SLUG} is currently deployed." else # Cleanup and previous installs, as FAILED and PENDING_UPGRADE will cause errors with `upgrade` - if previous_deploy_failed "$CI_ENVIRONMENT_SLUG" ; then + if previous_deploy_failed "${KUBE_NAMESPACE}" "$CI_ENVIRONMENT_SLUG" ; then echoinfo "Review App deployment in bad state, cleaning up $CI_ENVIRONMENT_SLUG" delete_release else @@ -117,6 +119,7 @@ function ensure_namespace() { } function install_tiller() { + local TILLER_NAMESPACE="$KUBE_NAMESPACE" echoinfo "Checking deployment/tiller-deploy status in the ${TILLER_NAMESPACE} namespace..." true echoinfo "Initiating the Helm client..." @@ -131,11 +134,12 @@ function install_tiller() { --override "spec.template.spec.tolerations[0].key"="dedicated" \ --override "spec.template.spec.tolerations[0].operator"="Equal" \ --override "spec.template.spec.tolerations[0].value"="helm" \ - --override "spec.template.spec.tolerations[0].effect"="NoSchedule" + --override "spec.template.spec.tolerations[0].effect"="NoSchedule" \ + --tiller-namespace "${TILLER_NAMESPACE}" kubectl rollout status -n "$TILLER_NAMESPACE" -w "deployment/tiller-deploy" - if ! helm version --debug; then + if ! helm version --debug --tiller-namespace "${TILLER_NAMESPACE}"; then echo "Failed to init Tiller." return 1 fi @@ -147,7 +151,7 @@ function install_external_dns() { domain=$(echo "${REVIEW_APPS_DOMAIN}" | awk -F. '{printf "%s.%s", $(NF-1), $NF}') echoinfo "Installing external DNS for domain ${domain}..." true - if ! deploy_exists "${KUBE_NAMESPACE}" "${release_name}" || previous_deploy_failed "${release_name}" ; then + if ! deploy_exists "${KUBE_NAMESPACE}" "${release_name}" || previous_deploy_failed "${KUBE_NAMESPACE}" "${release_name}" ; then echoinfo "Installing external-dns Helm chart" helm repo update # Default requested: CPU => 0, memory => 0 @@ -179,6 +183,17 @@ function create_application_secret() { "${CI_ENVIRONMENT_SLUG}-gitlab-initial-root-password" \ --from-literal="password=${REVIEW_APPS_ROOT_PASSWORD}" \ --dry-run -o json | kubectl apply -f - + + if [ -z "${REVIEW_APPS_EE_LICENSE}" ]; then echo "License not found" && return; fi + + echoinfo "Creating the ${CI_ENVIRONMENT_SLUG}-gitlab-license secret in the ${KUBE_NAMESPACE} namespace..." true + + echo "${REVIEW_APPS_EE_LICENSE}" > /tmp/license.gitlab + + kubectl create secret generic -n "$KUBE_NAMESPACE" \ + "${CI_ENVIRONMENT_SLUG}-gitlab-license" \ + --from-file=license=/tmp/license.gitlab \ + --dry-run -o json | kubectl apply -f - } function download_chart() { @@ -195,9 +210,19 @@ function download_chart() { helm dependency build . } +function base_config_changed() { + if [ -z "${CI_MERGE_REQUEST_IID}" ]; then return; fi + + curl "${CI_API_V4_URL}/projects/${CI_MERGE_REQUEST_PROJECT_ID}/merge_requests/${CI_MERGE_REQUEST_IID}/changes" | jq '.changes | any(.old_path == "scripts/review_apps/base-config.yaml")' +} + function deploy() { local name="$CI_ENVIRONMENT_SLUG" local edition="${GITLAB_EDITION-ce}" + local base_config_file_ref="master" + if [[ "$(base_config_changed)" == "true" ]]; then base_config_file_ref="$CI_COMMIT_SHA"; fi + local base_config_file="https://gitlab.com/gitlab-org/gitlab/raw/${base_config_file_ref}/scripts/review_apps/base-config.yaml" + echoinfo "Deploying ${name}..." true IMAGE_REPOSITORY="registry.gitlab.com/gitlab-org/build/cng-mirror" @@ -239,12 +264,20 @@ HELM_CMD=$(cat << EOF EOF ) +if [ -n "${REVIEW_APPS_EE_LICENSE}" ]; then +HELM_CMD=$(cat << EOF + ${HELM_CMD} \ + --set global.gitlab.license.secret="${CI_ENVIRONMENT_SLUG}-gitlab-license" +EOF +) +fi + HELM_CMD=$(cat << EOF - $HELM_CMD \ + ${HELM_CMD} \ --namespace="$KUBE_NAMESPACE" \ - --version="$CI_PIPELINE_ID-$CI_JOB_ID" \ - -f "../scripts/review_apps/base-config.yaml" \ - "$name" . + --version="${CI_PIPELINE_ID}-${CI_JOB_ID}" \ + -f "${base_config_file}" \ + "${name}" . EOF ) @@ -263,34 +296,3 @@ function display_deployment_debug() { echoinfo "Unsuccessful Jobs for release ${CI_ENVIRONMENT_SLUG}" kubectl get jobs -n "$KUBE_NAMESPACE" -lrelease=${CI_ENVIRONMENT_SLUG} --field-selector=status.successful!=1 } - -function add_license() { - if [ -z "${REVIEW_APPS_EE_LICENSE}" ]; then echo "License not found" && return; fi - - task_runner_pod=$(get_pod "task-runner"); - if [ -z "${task_runner_pod}" ]; then echo "Task runner pod not found" && return; fi - - echoinfo "Installing license..." true - - echo "${REVIEW_APPS_EE_LICENSE}" > /tmp/license.gitlab - kubectl -n "$KUBE_NAMESPACE" cp /tmp/license.gitlab "${task_runner_pod}":/tmp/license.gitlab - rm /tmp/license.gitlab - - kubectl -n "$KUBE_NAMESPACE" exec -it "${task_runner_pod}" -- /srv/gitlab/bin/rails runner -e production \ - ' - content = File.read("/tmp/license.gitlab").strip; - FileUtils.rm_f("/tmp/license.gitlab"); - - unless License.where(data:content).empty? - puts "License already exists"; - Kernel.exit 0; - end - - unless License.new(data: content).save - puts "Could not add license"; - Kernel.exit 0; - end - - puts "License added"; - ' -} diff --git a/scripts/static-analysis b/scripts/static-analysis index 602cd847a71e23ca5c3bbaafd464161b1dae6aa9..b7f7100c3651082fa20f765aeb00a0b765b635b8 100755 --- a/scripts/static-analysis +++ b/scripts/static-analysis @@ -26,17 +26,35 @@ def emit_errors(static_analysis) end end -tasks = [ - %w[bin/rake lint:all], - %w[bundle exec license_finder], - %w[yarn run eslint], - %w[yarn run stylelint], - %w[yarn run prettier-all], - %w[bundle exec rubocop --parallel], - %w[scripts/lint-conflicts.sh], - %w[scripts/lint-rugged] -] +def jobs_to_run(node_index, node_total) + all_tasks = [ + %w[bin/rake lint:all], + %w[bundle exec license_finder], + %w[yarn run eslint], + %w[yarn run stylelint], + %w[yarn run prettier-all], + %w[bundle exec rubocop --parallel], + %w[scripts/lint-conflicts.sh], + %w[scripts/lint-rugged] + ] + case node_total + when 1 + all_tasks + when 2 + rake_lint_all, *rest_jobs = all_tasks + case node_index + when 1 + [rake_lint_all] + else + rest_jobs + end + else + raise "Parallelization > 2 (currently set to #{node_total}) isn't supported yet!" + end +end + +tasks = jobs_to_run((ENV['CI_NODE_INDEX'] || 1).to_i, (ENV['CI_NODE_TOTAL'] || 1).to_i) static_analysis = Gitlab::Popen::Runner.new static_analysis.run(tasks) do |cmd, &run| diff --git a/scripts/sync-stable-branch.sh b/scripts/sync-stable-branch.sh new file mode 100644 index 0000000000000000000000000000000000000000..fc62453d7435661dc42fe283641211344a1c1491 --- /dev/null +++ b/scripts/sync-stable-branch.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +# This script triggers a merge train job to sync an EE stable branch to its +# corresponding CE stable branch. + +set -e + +if [[ "$MERGE_TRAIN_TRIGGER_TOKEN" == '' ]] +then + echo 'The variable MERGE_TRAIN_TRIGGER_TOKEN must be set to a non-empy 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' + exit 1 +fi + +if [[ "$CI_COMMIT_REF_NAME" == '' ]] +then + echo 'The variable CI_COMMIT_REF_NAME must be set to a non-empy value' + exit 1 +fi + +curl -X POST \ + -F token="$MERGE_TRAIN_TRIGGER_TOKEN" \ + -F ref=master \ + -F "variables[MERGE_FOSS]=1" \ + -F "variables[SOURCE_BRANCH]=$CI_COMMIT_REF_NAME" \ + -F "variables[TARGET_BRANCH]=${CI_COMMIT_REF_NAME/-ee/}" \ + "$MERGE_TRAIN_TRIGGER_URL" diff --git a/scripts/trigger-build b/scripts/trigger-build index badbb5620211f866477675b040d2bb5700b41695..74c1df258c0e2a18282edd8c666c61787fa8406f 100755 --- a/scripts/trigger-build +++ b/scripts/trigger-build @@ -17,7 +17,7 @@ module Trigger end class Base - def invoke!(post_comment: false) + def invoke!(post_comment: false, downstream_job_name: nil) pipeline = Gitlab.run_trigger( downstream_project_path, trigger_token, @@ -28,7 +28,18 @@ module Trigger puts "Waiting for downstream pipeline status" Trigger::CommitComment.post!(pipeline, access_token) if post_comment - Trigger::Pipeline.new(downstream_project_path, pipeline.id, access_token) + downstream_job = + if downstream_job_name + Gitlab.pipeline_jobs(downstream_project_path, pipeline.id).auto_paginate.find do |potential_job| + potential_job.name == downstream_job_name + end + end + + if downstream_job + Trigger::Job.new(downstream_project_path, downstream_job.id, access_token) + else + Trigger::Pipeline.new(downstream_project_path, pipeline.id, access_token) + end end private @@ -187,6 +198,14 @@ module Trigger attr_reader :project, :id, :api_token + def self.unscoped_class_name + name.split('::').last + end + + def self.gitlab_api_method_name + unscoped_class_name.downcase + end + def initialize(project, id, api_token) @project = project @id = id @@ -199,17 +218,17 @@ module Trigger def wait! loop do - raise "Pipeline timed out after waiting for #{duration} minutes!" if timeout? + raise "#{self.class.unscoped_class_name} timed out after waiting for #{duration} minutes!" if timeout? case status when :created, :pending, :running print "." sleep INTERVAL when :success - puts "Pipeline succeeded in #{duration} minutes!" + puts "#{self.class.unscoped_class_name} succeeded in #{duration} minutes!" break else - raise "Pipeline did not succeed!" + raise "#{self.class.unscoped_class_name} did not succeed!" end STDOUT.flush @@ -225,7 +244,7 @@ module Trigger end def status - Gitlab.pipeline(project, id).status.to_sym + Gitlab.public_send(self.class.gitlab_api_method_name, project, id).status.to_sym # rubocop:disable GitlabSecurity/PublicSend rescue Gitlab::Error::Error => error puts "Ignoring the following error: #{error}" # Ignore GitLab API hiccups. If GitLab is really down, we'll hit the job @@ -233,11 +252,13 @@ module Trigger :running end end + + Job = Class.new(Pipeline) end case ARGV[0] when 'omnibus' - Trigger::Omnibus.new.invoke!(post_comment: true).wait! + Trigger::Omnibus.new.invoke!(post_comment: true, downstream_job_name: 'Trigger:qa-test').wait! when 'cng' Trigger::CNG.new.invoke!.wait! else diff --git a/spec/controllers/abuse_reports_controller_spec.rb b/spec/controllers/abuse_reports_controller_spec.rb index e360ab68cf26b9a071142d1c3263be9244a37b00..e573ef4be49b04c9d2d45e96a4a93b88cf7d5c37 100644 --- a/spec/controllers/abuse_reports_controller_spec.rb +++ b/spec/controllers/abuse_reports_controller_spec.rb @@ -49,7 +49,9 @@ describe AbuseReportsController do end it 'calls notify' do - expect_any_instance_of(AbuseReport).to receive(:notify) + expect_next_instance_of(AbuseReport) do |instance| + expect(instance).to receive(:notify) + end post :create, params: { abuse_report: attrs } end diff --git a/spec/controllers/admin/clusters_controller_spec.rb b/spec/controllers/admin/clusters_controller_spec.rb index 233710b9fc36ce26c848d948397cb8991c062f34..ebae931764d4e13b2f9b4ab133089d3b89190337 100644 --- a/spec/controllers/admin/clusters_controller_spec.rb +++ b/spec/controllers/admin/clusters_controller_spec.rb @@ -73,7 +73,7 @@ describe Admin::ClustersController do end describe 'GET #new' do - def get_new(provider: 'gke') + def get_new(provider: 'gcp') get :new, params: { provider: provider } end @@ -227,16 +227,17 @@ describe Admin::ClustersController do describe 'security' do before do - allow_any_instance_of(described_class) - .to receive(:token_in_session).and_return('token') - allow_any_instance_of(described_class) - .to receive(:expires_at_in_session).and_return(1.hour.since.to_i.to_s) - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:projects_zones_clusters_create) do - OpenStruct.new( - self_link: 'projects/gcp-project-12345/zones/us-central1-a/operations/ope-123', - status: 'RUNNING' - ) + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:token_in_session).and_return('token') + allow(instance).to receive(:expires_at_in_session).and_return(1.hour.since.to_i.to_s) + end + allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |instance| + allow(instance).to receive(:projects_zones_clusters_create) do + OpenStruct.new( + self_link: 'projects/gcp-project-12345/zones/us-central1-a/operations/ope-123', + status: 'RUNNING' + ) + end end allow(WaitForClusterCreationWorker).to receive(:perform_in).and_return(nil) @@ -248,6 +249,69 @@ describe Admin::ClustersController do end end + describe 'POST #create_aws' do + let(:params) do + { + cluster: { + name: 'new-cluster', + provider_aws_attributes: { + key_name: 'key', + role_arn: 'arn:role', + region: 'region', + vpc_id: 'vpc', + instance_type: 'instance type', + num_nodes: 3, + security_group_id: 'security group', + subnet_ids: %w(subnet1 subnet2) + } + } + } + end + + def post_create_aws + post :create_aws, params: params + end + + it 'creates a new cluster' do + expect(ClusterProvisionWorker).to receive(:perform_async) + expect { post_create_aws }.to change { Clusters::Cluster.count } + .and change { Clusters::Providers::Aws.count } + + cluster = Clusters::Cluster.instance_type.first + + expect(response.status).to eq(201) + expect(response.location).to eq(admin_cluster_path(cluster)) + expect(cluster).to be_aws + expect(cluster).to be_kubernetes + end + + context 'params are invalid' do + let(:params) do + { + cluster: { name: '' } + } + end + + it 'does not create a cluster' do + expect { post_create_aws }.not_to change { Clusters::Cluster.count } + + expect(response.status).to eq(422) + expect(response.content_type).to eq('application/json') + expect(response.body).to include('is invalid') + end + end + + describe 'security' do + before do + allow(WaitForClusterCreationWorker).to receive(:perform_in) + end + + it { expect { post_create_aws }.to be_allowed_for(:admin) } + it { expect { post_create_aws }.to be_denied_for(:user) } + it { expect { post_create_aws }.to be_denied_for(:external) } + end + end + describe 'POST #create_user' do let(:params) do { @@ -318,6 +382,72 @@ describe Admin::ClustersController do end end + describe 'POST authorize AWS role for EKS cluster' do + let(:role_arn) { 'arn:aws:iam::123456789012:role/role-name' } + let(:role_external_id) { '12345' } + + let(:params) do + { + cluster: { + role_arn: role_arn, + role_external_id: role_external_id + } + } + end + + def go + post :authorize_aws_role, params: params + end + + it 'creates an Aws::Role record' do + expect { go }.to change { Aws::Role.count } + + expect(response.status).to eq 201 + + role = Aws::Role.last + expect(role.user).to eq admin + expect(role.role_arn).to eq role_arn + expect(role.role_external_id).to eq role_external_id + end + + context 'role cannot be created' do + let(:role_arn) { 'invalid-role' } + + it 'does not create a record' do + expect { go }.not_to change { Aws::Role.count } + + expect(response.status).to eq 422 + end + end + + describe 'security' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end + end + + describe 'DELETE revoke AWS role for EKS cluster' do + let!(:role) { create(:aws_role, user: admin) } + + def go + delete :revoke_aws_role + end + + it 'deletes the Aws::Role record' do + expect { go }.to change { Aws::Role.count } + + expect(response.status).to eq 204 + expect(admin.reload_aws_role).to be_nil + end + + describe 'security' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end + end + describe 'GET #cluster_status' do let(:cluster) { create(:cluster, :providing_by_gcp, :instance) } @@ -338,7 +468,9 @@ describe Admin::ClustersController do end it 'invokes schedule_status_update on each application' do - expect_any_instance_of(Clusters::Applications::Ingress).to receive(:schedule_status_update) + expect_next_instance_of(Clusters::Applications::Ingress) do |instance| + expect(instance).to receive(:schedule_status_update) + end get_cluster_status end diff --git a/spec/controllers/admin/identities_controller_spec.rb b/spec/controllers/admin/identities_controller_spec.rb index 68695afdb616a64a1b689c9bbce79f65d26c718a..256aafe09f8b7462025c385aba9de8e5db908bb1 100644 --- a/spec/controllers/admin/identities_controller_spec.rb +++ b/spec/controllers/admin/identities_controller_spec.rb @@ -13,7 +13,9 @@ describe Admin::IdentitiesController do let(:user) { create(:omniauth_user, provider: 'ldapmain', extern_uid: 'uid=myuser,ou=people,dc=example,dc=com') } it 'repairs ldap blocks' do - expect_any_instance_of(RepairLdapBlockedUserService).to receive(:execute) + expect_next_instance_of(RepairLdapBlockedUserService) do |instance| + expect(instance).to receive(:execute) + end put :update, params: { user_id: user.username, id: user.ldap_identity.id, identity: { provider: 'twitter' } } end @@ -23,7 +25,9 @@ describe Admin::IdentitiesController do let(:user) { create(:omniauth_user, provider: 'ldapmain', extern_uid: 'uid=myuser,ou=people,dc=example,dc=com') } it 'repairs ldap blocks' do - expect_any_instance_of(RepairLdapBlockedUserService).to receive(:execute) + expect_next_instance_of(RepairLdapBlockedUserService) do |instance| + expect(instance).to receive(:execute) + end delete :destroy, params: { user_id: user.username, id: user.ldap_identity.id } end diff --git a/spec/controllers/admin/spam_logs_controller_spec.rb b/spec/controllers/admin/spam_logs_controller_spec.rb index 3bc49023357140ab50175457b31fa706b9ce725d..baf4216dcde93ef8b6eb1dff459dc32521bd542d 100644 --- a/spec/controllers/admin/spam_logs_controller_spec.rb +++ b/spec/controllers/admin/spam_logs_controller_spec.rb @@ -27,7 +27,7 @@ describe Admin::SpamLogsController do expect(response).to have_gitlab_http_status(200) end - it 'removes user and his spam logs when removing the user' do + it 'removes user and his spam logs when removing the user', :sidekiq_might_not_need_inline do delete :destroy, params: { id: first_spam.id, remove_user: true } expect(flash[:notice]).to eq "User #{user.username} was successfully removed." @@ -39,7 +39,9 @@ describe Admin::SpamLogsController do describe '#mark_as_ham' do before do - allow_any_instance_of(AkismetService).to receive(:submit_ham).and_return(true) + allow_next_instance_of(AkismetService) do |instance| + allow(instance).to receive(:submit_ham).and_return(true) + end end it 'submits the log as ham' do post :mark_as_ham, params: { id: first_spam.id } diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index afe21c8b34a6e3a363e8ec8f6cd2785e39730278..50ba7418d2c647675ad52461e3a0076edc63a78e 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -35,7 +35,7 @@ describe Admin::UsersController do end end - describe 'DELETE #user with projects' do + describe 'DELETE #user with projects', :sidekiq_might_not_need_inline do let(:project) { create(:project, namespace: user.namespace) } let!(:issue) { create(:issue, author: user) } diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 993e4020a75b81cad7d8b3d0d6e305f5f86266db..4a10e7b53250c56609ef57f1c866e05c82e1540d 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -96,30 +96,14 @@ describe ApplicationController do request.path = '/-/peek' end - # TODO: - # remove line below once `privacy_policy_update_callout` - # feature flag is removed and `gon` reverts back to - # to not setting any variables. - if Gitlab.ee? - it_behaves_like 'setting gon variables' - else - it_behaves_like 'not setting gon variables' - end + it_behaves_like 'not setting gon variables' end end context 'with json format' do let(:format) { :json } - # TODO: - # remove line below once `privacy_policy_update_callout` - # feature flag is removed and `gon` reverts back to - # to not setting any variables. - if Gitlab.ee? - it_behaves_like 'setting gon variables' - else - it_behaves_like 'not setting gon variables' - end + it_behaves_like 'not setting gon variables' end end @@ -655,7 +639,7 @@ describe ApplicationController do context 'given a 422 error page' do controller do def index - render 'errors/omniauth_error', layout: 'errors', status: 422 + render 'errors/omniauth_error', layout: 'errors', status: :unprocessable_entity end end @@ -669,7 +653,7 @@ describe ApplicationController do context 'given a 500 error page' do controller do def index - render 'errors/omniauth_error', layout: 'errors', status: 500 + render 'errors/omniauth_error', layout: 'errors', status: :internal_server_error end end @@ -683,7 +667,7 @@ describe ApplicationController do context 'given a 200 success page' do controller do def index - render 'errors/omniauth_error', layout: 'errors', status: 200 + render 'errors/omniauth_error', layout: 'errors', status: :ok end end @@ -843,7 +827,7 @@ describe ApplicationController do end end - describe '#require_role' do + describe '#required_signup_info' do controller(described_class) do def index; end end @@ -852,7 +836,7 @@ describe ApplicationController do let(:experiment_enabled) { true } before do - stub_experiment(signup_flow: experiment_enabled) + stub_experiment_for_user(signup_flow: experiment_enabled) end context 'experiment enabled and user with required role' do @@ -865,7 +849,7 @@ describe ApplicationController do it { is_expected.to redirect_to users_sign_up_welcome_path } end - context 'experiment enabled and user without a role' do + context 'experiment enabled and user without a required role' do before do sign_in(user) get :index @@ -874,7 +858,7 @@ describe ApplicationController do it { is_expected.not_to redirect_to users_sign_up_welcome_path } end - context 'experiment disabled and user with required role' do + context 'experiment disabled' do let(:experiment_enabled) { false } before do diff --git a/spec/controllers/concerns/confirm_email_warning_spec.rb b/spec/controllers/concerns/confirm_email_warning_spec.rb index 0c598a360af3b819cdabdb0e4fcc469314ddce81..25429cdd149ed85cb3b145b6277225b78884e68a 100644 --- a/spec/controllers/concerns/confirm_email_warning_spec.rb +++ b/spec/controllers/concerns/confirm_email_warning_spec.rb @@ -19,7 +19,7 @@ describe ConfirmEmailWarning do RSpec::Matchers.define :set_confirm_warning_for do |email| match do |response| - expect(response).to set_flash.now[:warning].to include("Please check your email (#{email}) to verify that you own this address.") + expect(response).to set_flash.now[:warning].to include("Please check your email (#{email}) to verify that you own this address and unlock the power of CI/CD.") end end diff --git a/spec/controllers/concerns/metrics_dashboard_spec.rb b/spec/controllers/concerns/metrics_dashboard_spec.rb index a71e34fd1cacf11342eb14c1c9200b81bcfd7f2f..ff2b6fbb8eca368e24f449d10e42cdbf05857cb3 100644 --- a/spec/controllers/concerns/metrics_dashboard_spec.rb +++ b/spec/controllers/concerns/metrics_dashboard_spec.rb @@ -3,9 +3,11 @@ require 'spec_helper' describe MetricsDashboard do + include MetricsDashboardHelpers + describe 'GET #metrics_dashboard' do let_it_be(:user) { create(:user) } - let_it_be(:project) { create(:project, :repository) } + let_it_be(:project) { project_with_dashboard('.gitlab/dashboards/test.yml') } let_it_be(:environment) { create(:environment, project: project) } before do @@ -31,11 +33,13 @@ describe MetricsDashboard do end context 'when params are provided' do + let(:params) { { environment: environment } } + before do allow(controller).to receive(:project).and_return(project) allow(controller) .to receive(:metrics_dashboard_params) - .and_return(environment: environment) + .and_return(params) end it 'returns the specified dashboard' do @@ -43,6 +47,15 @@ describe MetricsDashboard do expect(json_response).not_to have_key('all_dashboards') end + context 'when the params are in an alternate format' do + let(:params) { ActionController::Parameters.new({ environment: environment }).permit! } + + it 'returns the specified dashboard' do + expect(json_response['dashboard']['dashboard']).to eq('Environment metrics') + expect(json_response).not_to have_key('all_dashboards') + end + end + context 'when parameters are provided and the list of all dashboards is required' do before do allow(controller).to receive(:include_all_dashboards?).and_return(true) @@ -52,6 +65,36 @@ describe MetricsDashboard do expect(json_response['dashboard']['dashboard']).to eq('Environment metrics') expect(json_response).to have_key('all_dashboards') end + + context 'in all_dashboard list' do + let(:system_dashboard) { json_response['all_dashboards'].find { |dashboard| dashboard["system_dashboard"] == true } } + let(:project_dashboard) { json_response['all_dashboards'].find { |dashboard| dashboard["system_dashboard"] == false } } + + it 'includes project_blob_path only for project dashboards' do + expect(system_dashboard['project_blob_path']).to be_nil + expect(project_dashboard['project_blob_path']).to eq("/#{project.namespace.path}/#{project.name}/blob/master/.gitlab/dashboards/test.yml") + end + + describe 'project permissions' do + using RSpec::Parameterized::TableSyntax + + where(:can_collaborate, :system_can_edit, :project_can_edit) do + false | false | false + true | false | true + end + + with_them do + before do + allow(controller).to receive(:can_collaborate_with_project?).and_return(can_collaborate) + end + + it "sets can_edit appropriately" do + expect(system_dashboard["can_edit"]).to eq(system_can_edit) + expect(project_dashboard["can_edit"]).to eq(project_can_edit) + end + end + end + end end end end diff --git a/spec/controllers/concerns/redirects_for_missing_path_on_tree_spec.rb b/spec/controllers/concerns/redirects_for_missing_path_on_tree_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..903100ba93fd50a65b5ed46b5c80cc31bb930571 --- /dev/null +++ b/spec/controllers/concerns/redirects_for_missing_path_on_tree_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe RedirectsForMissingPathOnTree, type: :controller do + controller(ActionController::Base) do + include Gitlab::Routing.url_helpers + include RedirectsForMissingPathOnTree + + def fake + redirect_to_tree_root_for_missing_path(Project.find(params[:project_id]), params[:ref], params[:file_path]) + end + end + + let(:project) { create(:project) } + + before do + routes.draw { get 'fake' => 'anonymous#fake' } + end + + describe '#redirect_to_root_path' do + it 'redirects to the tree path with a notice' do + long_file_path = ('a/b/' * 30) + 'foo.txt' + truncated_file_path = '...b/' + ('a/b/' * 12) + 'foo.txt' + expected_message = "\"#{truncated_file_path}\" did not exist on \"theref\"" + + get :fake, params: { project_id: project.id, ref: 'theref', file_path: long_file_path } + + expect(response).to redirect_to project_tree_path(project, 'theref') + expect(response.flash[:notice]).to eq(expected_message) + end + end +end diff --git a/spec/controllers/concerns/renders_commits_spec.rb b/spec/controllers/concerns/renders_commits_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..79350847383a1c250cef5cc04bda92b4188e3e97 --- /dev/null +++ b/spec/controllers/concerns/renders_commits_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe RendersCommits do + let_it_be(:project) { create(:project, :public, :repository) } + let_it_be(:merge_request) { create(:merge_request, source_project: project) } + let_it_be(:user) { create(:user) } + + controller(ApplicationController) do + # `described_class` is not available in this context + include RendersCommits # rubocop:disable RSpec/DescribedClass + + def index + @merge_request = MergeRequest.find(params[:id]) + @commits = set_commits_for_rendering( + @merge_request.recent_commits.with_latest_pipeline(@merge_request.source_branch), + commits_count: @merge_request.commits_count + ) + + render json: { html: view_to_html_string('projects/merge_requests/_commits') } + end + end + + before do + sign_in(user) + end + + def go + get :index, params: { id: merge_request.id } + end + + it 'sets instance variables for counts' do + stub_const("MergeRequestDiff::COMMITS_SAFE_SIZE", 10) + + go + + expect(assigns[:total_commit_count]).to eq(29) + expect(assigns[:hidden_commit_count]).to eq(19) + expect(assigns[:commits].size).to eq(10) + end + + context 'rendering commits' do + render_views + + it 'avoids N + 1' do + stub_const("MergeRequestDiff::COMMITS_SAFE_SIZE", 5) + + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do + go + end.count + + stub_const("MergeRequestDiff::COMMITS_SAFE_SIZE", 15) + + expect do + go + end.not_to exceed_all_query_limit(control_count) + end + end +end diff --git a/spec/controllers/concerns/sourcegraph_gon_spec.rb b/spec/controllers/concerns/sourcegraph_gon_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4fb7e37d148c8a6e063c42852c6286232d9728fe --- /dev/null +++ b/spec/controllers/concerns/sourcegraph_gon_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe SourcegraphGon 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) } + let_it_be(:internal_project) { create(:project, :internal) } + + let(:sourcegraph_url) { 'http://sourcegraph.gitlab.com' } + let(:feature_enabled) { true } + let(:sourcegraph_enabled) { true } + let(:sourcegraph_public_only) { false } + let(:format) { :html } + let(:user) { enabled_user } + let(:project) { internal_project } + + controller(ApplicationController) do + include SourcegraphGon # rubocop:disable RSpec/DescribedClass + + def index + head :ok + end + end + + before do + Feature.get(:sourcegraph).enable(feature_enabled) + + stub_application_setting(sourcegraph_url: sourcegraph_url, sourcegraph_enabled: sourcegraph_enabled, sourcegraph_public_only: sourcegraph_public_only) + + allow(controller).to receive(:project).and_return(project) + + Gon.clear + + sign_in user if user + end + + after do + Feature.get(:sourcegraph).disable + end + + subject do + get :index, format: format + + Gon.sourcegraph + end + + shared_examples 'enabled' do + it { is_expected.to eq({ url: sourcegraph_url }) } + end + + shared_examples 'disabled' do + it { is_expected.to be_nil } + end + + context 'with feature enabled, application enabled, and user enabled' do + it_behaves_like 'enabled' + end + + context 'with feature enabled for specific project' do + let(:feature_enabled) { project } + + it_behaves_like 'enabled' + end + + context 'with feature enabled for different project' do + let(:feature_enabled) { create(:project) } + + it_behaves_like 'disabled' + end + + context 'with feature disabled' do + let(:feature_enabled) { false } + + it_behaves_like 'disabled' + end + + context 'with admin settings disabled' do + let(:sourcegraph_enabled) { false } + + it_behaves_like 'disabled' + end + + context 'with public only' do + let(:sourcegraph_public_only) { true } + + context 'with internal project' do + let(:project) { internal_project } + + it_behaves_like 'disabled' + end + + context 'with public project' do + let(:project) { public_project } + + it_behaves_like 'enabled' + end + end + + context 'with user disabled' do + let(:user) { disabled_user } + + it_behaves_like 'disabled' + end + + context 'with no user' do + let(:user) { nil } + + it_behaves_like 'disabled' + end + + context 'with non-html format' do + let(:format) { :json } + + it_behaves_like 'disabled' + end +end diff --git a/spec/controllers/google_api/authorizations_controller_spec.rb b/spec/controllers/google_api/authorizations_controller_spec.rb index 940bf9c68286a7c443b978aa43b132855a80603d..4d200140f164b5919c6992e0a05f9dbf6952c501 100644 --- a/spec/controllers/google_api/authorizations_controller_spec.rb +++ b/spec/controllers/google_api/authorizations_controller_spec.rb @@ -13,8 +13,9 @@ describe GoogleApi::AuthorizationsController do before do sign_in(user) - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:get_token).and_return([token, expires_at]) + allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |instance| + allow(instance).to receive(:get_token).and_return([token, expires_at]) + end end shared_examples_for 'access denied' do diff --git a/spec/controllers/groups/clusters_controller_spec.rb b/spec/controllers/groups/clusters_controller_spec.rb index 51a6dcca6405c632bd451fe754d19f119d180064..d027405703bb695e3fe82aaf3cd47b7da14f40b9 100644 --- a/spec/controllers/groups/clusters_controller_spec.rb +++ b/spec/controllers/groups/clusters_controller_spec.rb @@ -85,7 +85,7 @@ describe Groups::ClustersController do end describe 'GET new' do - def go(provider: 'gke') + def go(provider: 'gcp') get :new, params: { group_id: group, provider: provider } end @@ -372,6 +372,150 @@ describe Groups::ClustersController do end end + describe 'POST #create_aws' do + let(:params) do + { + cluster: { + name: 'new-cluster', + provider_aws_attributes: { + key_name: 'key', + role_arn: 'arn:role', + region: 'region', + vpc_id: 'vpc', + instance_type: 'instance type', + num_nodes: 3, + security_group_id: 'security group', + subnet_ids: %w(subnet1 subnet2) + } + } + } + end + + def post_create_aws + post :create_aws, params: params.merge(group_id: group) + end + + it 'creates a new cluster' do + expect(ClusterProvisionWorker).to receive(:perform_async) + expect { post_create_aws }.to change { Clusters::Cluster.count } + .and change { Clusters::Providers::Aws.count } + + cluster = group.clusters.first + + expect(response.status).to eq(201) + expect(response.location).to eq(group_cluster_path(group, cluster)) + expect(cluster).to be_aws + expect(cluster).to be_kubernetes + end + + context 'params are invalid' do + let(:params) do + { + cluster: { name: '' } + } + end + + it 'does not create a cluster' do + expect { post_create_aws }.not_to change { Clusters::Cluster.count } + + expect(response.status).to eq(422) + expect(response.content_type).to eq('application/json') + expect(response.body).to include('is invalid') + end + end + + describe 'security' do + before do + allow(WaitForClusterCreationWorker).to receive(:perform_in) + end + + it { expect { post_create_aws }.to be_allowed_for(:admin) } + it { expect { post_create_aws }.to be_allowed_for(:owner).of(group) } + it { expect { post_create_aws }.to be_allowed_for(:maintainer).of(group) } + it { expect { post_create_aws }.to be_denied_for(:developer).of(group) } + it { expect { post_create_aws }.to be_denied_for(:reporter).of(group) } + it { expect { post_create_aws }.to be_denied_for(:guest).of(group) } + it { expect { post_create_aws }.to be_denied_for(:user) } + it { expect { post_create_aws }.to be_denied_for(:external) } + end + end + + describe 'POST authorize AWS role for EKS cluster' do + let(:role_arn) { 'arn:aws:iam::123456789012:role/role-name' } + let(:role_external_id) { '12345' } + + let(:params) do + { + cluster: { + role_arn: role_arn, + role_external_id: role_external_id + } + } + end + + def go + post :authorize_aws_role, params: params.merge(group_id: group) + end + + it 'creates an Aws::Role record' do + expect { go }.to change { Aws::Role.count } + + expect(response.status).to eq 201 + + role = Aws::Role.last + expect(role.user).to eq user + expect(role.role_arn).to eq role_arn + expect(role.role_external_id).to eq role_external_id + end + + context 'role cannot be created' do + let(:role_arn) { 'invalid-role' } + + it 'does not create a record' do + expect { go }.not_to change { Aws::Role.count } + + expect(response.status).to eq 422 + end + end + + describe 'security' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(group) } + it { expect { go }.to be_allowed_for(:maintainer).of(group) } + it { expect { go }.to be_denied_for(:developer).of(group) } + it { expect { go }.to be_denied_for(:reporter).of(group) } + it { expect { go }.to be_denied_for(:guest).of(group) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end + end + + describe 'DELETE revoke AWS role for EKS cluster' do + let!(:role) { create(:aws_role, user: user) } + + def go + delete :revoke_aws_role, params: { group_id: group } + end + + it 'deletes the Aws::Role record' do + expect { go }.to change { Aws::Role.count } + + expect(response.status).to eq 204 + expect(user.reload_aws_role).to be_nil + end + + describe 'security' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(group) } + it { expect { go }.to be_allowed_for(:maintainer).of(group) } + it { expect { go }.to be_denied_for(:developer).of(group) } + it { expect { go }.to be_denied_for(:reporter).of(group) } + it { expect { go }.to be_denied_for(:guest).of(group) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end + end + describe 'GET cluster_status' do let(:cluster) { create(:cluster, :providing_by_gcp, cluster_type: :group_type, groups: [group]) } diff --git a/spec/controllers/groups/group_links_controller_spec.rb b/spec/controllers/groups/group_links_controller_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8f04822fee6d7746e3a075ff1dda1ceb5c828924 --- /dev/null +++ b/spec/controllers/groups/group_links_controller_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Groups::GroupLinksController do + let(:shared_with_group) { create(:group, :private) } + let(:shared_group) { create(:group, :private) } + let(:user) { create(:user) } + + before do + sign_in(user) + end + + describe '#create' do + let(:shared_with_group_id) { shared_with_group.id } + + subject do + post(:create, + params: { group_id: shared_group, + shared_with_group_id: shared_with_group_id, + shared_group_access: GroupGroupLink.default_access }) + end + + context 'when user has correct access to both groups' do + let(:group_member) { create(:user) } + + before do + shared_with_group.add_developer(user) + shared_group.add_owner(user) + + shared_with_group.add_developer(group_member) + end + + it 'links group with selected group' do + expect { subject }.to change { shared_with_group.shared_groups.include?(shared_group) }.from(false).to(true) + end + + it 'redirects to group links page' do + subject + + expect(response).to(redirect_to(group_group_members_path(shared_group))) + end + + it 'allows access for group member' do + expect { subject }.to change { group_member.can?(:read_group, shared_group) }.from(false).to(true) + end + + context 'when shared with group id is not present' do + let(:shared_with_group_id) { nil } + + it 'redirects to group links page' do + subject + + expect(response).to(redirect_to(group_group_members_path(shared_group))) + expect(flash[:alert]).to eq('Please select a group.') + end + end + + context 'when link is not persisted in the database' do + before do + allow(::Groups::GroupLinks::CreateService).to( + receive_message_chain(:new, :execute) + .and_return({ status: :error, + http_status: 409, + message: 'error' })) + end + + it 'redirects to group links page' do + subject + + expect(response).to(redirect_to(group_group_members_path(shared_group))) + expect(flash[:alert]).to eq('error') + 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 + + context 'when user does not have access to the group' do + before do + shared_group.add_owner(user) + end + + it 'renders 404' do + subject + + expect(response).to have_gitlab_http_status(404) + end + end + + context 'when user does not have admin access to the shared group' do + before do + shared_with_group.add_developer(user) + shared_group.add_developer(user) + end + + it 'renders 404' do + subject + + expect(response).to have_gitlab_http_status(404) + end + end + end +end diff --git a/spec/controllers/groups/milestones_controller_spec.rb b/spec/controllers/groups/milestones_controller_spec.rb index e0a3605d50a425439d4ee0ec60eaa405a6d2ea9c..4f4f9e5143be5a77739b3aecd27ccef5028cc85f 100644 --- a/spec/controllers/groups/milestones_controller_spec.rb +++ b/spec/controllers/groups/milestones_controller_spec.rb @@ -314,6 +314,24 @@ describe Groups::MilestonesController do expect(controller).to set_flash[:notice].to(group_moved_message(redirect_route, group)) end + context 'with an AJAX request' do + it 'redirects to the canonical path but does not set flash message' do + get :merge_requests, params: { group_id: redirect_route.path, id: title }, xhr: true + + expect(response).to redirect_to(merge_requests_group_milestone_path(group.to_param, title)) + expect(controller).not_to set_flash[:notice] + end + end + + context 'with JSON format' do + it 'redirects to the canonical path but does not set flash message' do + get :merge_requests, params: { group_id: redirect_route.path, id: title }, format: :json + + expect(response).to redirect_to(merge_requests_group_milestone_path(group.to_param, title, format: :json)) + expect(controller).not_to set_flash[:notice] + end + end + context 'when the old group path is a substring of the scheme or host' do let(:redirect_route) { group.redirect_routes.create(path: 'http') } diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index 3c39a6468e518f0b376e98c83227130f1a9da562..2ed2b3192988646bfee50c11050a4b708b1be35e 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -45,7 +45,7 @@ describe GroupsController do it { is_expected.to render_template('groups/show') } - it 'assigns events for all the projects in the group' do + it 'assigns events for all the projects in the group', :sidekiq_might_not_need_inline do subject expect(assigns(:events)).to contain_exactly(event) end @@ -125,7 +125,7 @@ describe GroupsController do end context 'as json' do - it 'includes events from all projects in group and subgroups' do + it 'includes events from all projects in group and subgroups', :sidekiq_might_not_need_inline do 2.times do project = create(:project, group: group) create(:event, project: project) @@ -255,7 +255,7 @@ describe GroupsController do end end - describe 'GET #issues' do + describe 'GET #issues', :sidekiq_might_not_need_inline do let(:issue_1) { create(:issue, project: project, title: 'foo') } let(:issue_2) { create(:issue, project: project, title: 'bar') } @@ -304,7 +304,7 @@ describe GroupsController do end end - describe 'GET #merge_requests' do + describe 'GET #merge_requests', :sidekiq_might_not_need_inline do let(:merge_request_1) { create(:merge_request, source_project: project) } let(:merge_request_2) { create(:merge_request, :simple, source_project: project) } diff --git a/spec/controllers/health_controller_spec.rb b/spec/controllers/health_controller_spec.rb deleted file mode 100644 index 8a2291bccd70e6ccb7287aa627d4e94dca07ee66..0000000000000000000000000000000000000000 --- a/spec/controllers/health_controller_spec.rb +++ /dev/null @@ -1,134 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe HealthController do - include StubENV - - let(:token) { Gitlab::CurrentSettings.health_check_access_token } - let(:whitelisted_ip) { '127.0.0.1' } - let(:not_whitelisted_ip) { '127.0.0.2' } - - before do - allow(Settings.monitoring).to receive(:ip_whitelist).and_return([whitelisted_ip]) - stub_storage_settings({}) # Hide the broken storage - stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') - end - - describe '#readiness' do - shared_context 'endpoint responding with readiness data' do - let(:request_params) { {} } - - subject { get :readiness, params: request_params } - - it 'responds with readiness checks data' do - subject - - expect(json_response['db_check']).to contain_exactly({ 'status' => 'ok' }) - expect(json_response['cache_check']).to contain_exactly({ 'status' => 'ok' }) - expect(json_response['queues_check']).to contain_exactly({ 'status' => 'ok' }) - expect(json_response['shared_state_check']).to contain_exactly({ 'status' => 'ok' }) - expect(json_response['gitaly_check']).to contain_exactly( - { 'status' => 'ok', 'labels' => { 'shard' => 'default' } }) - end - - it 'responds with readiness checks data when a failure happens' do - allow(Gitlab::HealthChecks::Redis::RedisCheck).to receive(:readiness).and_return( - Gitlab::HealthChecks::Result.new('redis_check', false, "check error")) - - subject - - expect(json_response['cache_check']).to contain_exactly({ 'status' => 'ok' }) - expect(json_response['redis_check']).to contain_exactly( - { 'status' => 'failed', 'message' => 'check error' }) - - expect(response.status).to eq(503) - expect(response.headers['X-GitLab-Custom-Error']).to eq(1) - end - end - - context 'accessed from whitelisted ip' do - before do - allow(Gitlab::RequestContext).to receive(:client_ip).and_return(whitelisted_ip) - end - - it_behaves_like 'endpoint responding with readiness data' - end - - context 'accessed from not whitelisted ip' do - before do - allow(Gitlab::RequestContext).to receive(:client_ip).and_return(not_whitelisted_ip) - end - - it 'responds with resource not found' do - get :readiness - - expect(response.status).to eq(404) - end - - context 'accessed with valid token' do - context 'token passed in request header' do - before do - request.headers['TOKEN'] = token - end - - it_behaves_like 'endpoint responding with readiness data' - end - end - - context 'token passed as URL param' do - it_behaves_like 'endpoint responding with readiness data' do - let(:request_params) { { token: token } } - end - end - end - end - - describe '#liveness' do - shared_context 'endpoint responding with liveness data' do - subject { get :liveness } - - it 'responds with liveness checks data' do - subject - - expect(json_response).to eq('status' => 'ok') - end - end - - context 'accessed from whitelisted ip' do - before do - allow(Gitlab::RequestContext).to receive(:client_ip).and_return(whitelisted_ip) - end - - it_behaves_like 'endpoint responding with liveness data' - end - - context 'accessed from not whitelisted ip' do - before do - allow(Gitlab::RequestContext).to receive(:client_ip).and_return(not_whitelisted_ip) - end - - it 'responds with resource not found' do - get :liveness - - expect(response.status).to eq(404) - end - - context 'accessed with valid token' do - context 'token passed in request header' do - before do - request.headers['TOKEN'] = token - end - - it_behaves_like 'endpoint responding with liveness data' - end - - context 'token passed as URL param' do - it_behaves_like 'endpoint responding with liveness data' do - subject { get :liveness, params: { token: token } } - end - end - end - end - end -end diff --git a/spec/controllers/import/gitlab_controller_spec.rb b/spec/controllers/import/gitlab_controller_spec.rb index e465eca6c7139bd39d8bb69ff7cffc923c991225..6a3713a1212c58817b7e33f9007def1e0a932bdd 100644 --- a/spec/controllers/import/gitlab_controller_spec.rb +++ b/spec/controllers/import/gitlab_controller_spec.rb @@ -20,8 +20,9 @@ describe Import::GitlabController do describe "GET callback" do it "updates access token" do - allow_any_instance_of(Gitlab::GitlabImport::Client) - .to receive(:get_token).and_return(token) + allow_next_instance_of(Gitlab::GitlabImport::Client) do |instance| + allow(instance).to receive(:get_token).and_return(token) + end stub_omniauth_provider('gitlab') get :callback diff --git a/spec/controllers/import/phabricator_controller_spec.rb b/spec/controllers/import/phabricator_controller_spec.rb index 85085a8e99637aab052e2aa5e02dab095055bc94..a127e3cda3a3341562a954e48a7f7490cd249007 100644 --- a/spec/controllers/import/phabricator_controller_spec.rb +++ b/spec/controllers/import/phabricator_controller_spec.rb @@ -52,7 +52,7 @@ describe Import::PhabricatorController do namespace_id: current_user.namespace_id } end - it 'creates a project to import' do + it 'creates a project to import', :sidekiq_might_not_need_inline do expect_next_instance_of(Gitlab::PhabricatorImport::Importer) do |importer| expect(importer).to receive(:execute) end diff --git a/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb b/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb index 6d588c8f915300de929b05b8be376e7cc5de0980..ceab9754617e67649f1511929b758734d032c3b4 100644 --- a/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb @@ -11,6 +11,14 @@ describe Ldap::OmniauthCallbacksController do expect(request.env['warden']).to be_authenticated end + context 'with sign in prevented' do + let(:ldap_settings) { ldap_setting_defaults.merge(prevent_ldap_sign_in: true) } + + it 'does not allow sign in' do + expect { post provider }.to raise_error(ActionController::UrlGenerationError) + end + end + it 'respects remember me checkbox' do expect do post provider, params: { remember_me: '1' } diff --git a/spec/controllers/metrics_controller_spec.rb b/spec/controllers/metrics_controller_spec.rb index 7fb3578cd0ad0bb04eb3d1659a8ac18623c20422..1d378b9b9dc498309e952e17cdc7eff7864a8c3d 100644 --- a/spec/controllers/metrics_controller_spec.rb +++ b/spec/controllers/metrics_controller_spec.rb @@ -23,7 +23,9 @@ describe MetricsController do allow(Prometheus::Client.configuration).to receive(:multiprocess_files_dir).and_return(metrics_multiproc_dir) allow(Gitlab::Metrics).to receive(:prometheus_metrics_enabled?).and_return(true) allow(Settings.monitoring).to receive(:ip_whitelist).and_return([whitelisted_ip, whitelisted_ip_range]) - allow_any_instance_of(MetricsService).to receive(:metrics_text).and_return("prometheus_counter 1") + allow_next_instance_of(MetricsService) do |instance| + allow(instance).to receive(:metrics_text).and_return("prometheus_counter 1") + end end describe '#index' do diff --git a/spec/controllers/projects/blame_controller_spec.rb b/spec/controllers/projects/blame_controller_spec.rb index f901fd45604902669f91bb1e0f4cdaeb2cc6edd7..dd7c0f45dc277b708c81cdcc8cc95eb81eda1bd9 100644 --- a/spec/controllers/projects/blame_controller_spec.rb +++ b/spec/controllers/projects/blame_controller_spec.rb @@ -25,14 +25,25 @@ describe Projects::BlameController do }) end - context "valid file" do + context "valid branch, valid file" do let(:id) { 'master/files/ruby/popen.rb' } + it { is_expected.to respond_with(:success) } end - context "invalid file" do - let(:id) { 'master/files/ruby/missing_file.rb'} - it { expect(response).to have_gitlab_http_status(404) } + context "valid branch, invalid file" do + let(:id) { 'master/files/ruby/invalid-path.rb' } + + it 'redirects' do + expect(subject) + .to redirect_to("/#{project.full_path}/tree/master") + end + end + + context "invalid branch, valid file" do + let(:id) { 'invalid-branch/files/ruby/missing_file.rb'} + + it { is_expected.to respond_with(:not_found) } end end end diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb index 17964c78e8d65fa4dba1a17872e02cc205bc02f4..78599935910a15ae6f1db79500b9694f588b108c 100644 --- a/spec/controllers/projects/blob_controller_spec.rb +++ b/spec/controllers/projects/blob_controller_spec.rb @@ -24,26 +24,34 @@ describe Projects::BlobController do context "valid branch, valid file" do let(:id) { 'master/README.md' } + it { is_expected.to respond_with(:success) } end context "valid branch, invalid file" do let(:id) { 'master/invalid-path.rb' } - it { is_expected.to respond_with(:not_found) } + + it 'redirects' do + expect(subject) + .to redirect_to("/#{project.full_path}/tree/master") + end end context "invalid branch, valid file" do let(:id) { 'invalid-branch/README.md' } + it { is_expected.to respond_with(:not_found) } end context "binary file" do let(:id) { 'binary-encoding/encoding/binary-1.bin' } + it { is_expected.to respond_with(:success) } end context "Markdown file" do let(:id) { 'master/README.md' } + it { is_expected.to respond_with(:success) } end end @@ -104,6 +112,7 @@ describe Projects::BlobController do context 'redirect to tree' do let(:id) { 'markdown/doc' } + it 'redirects' do expect(subject) .to redirect_to("/#{project.full_path}/tree/markdown/doc") @@ -311,7 +320,7 @@ describe Projects::BlobController do default_params[:project_id] = forked_project end - it 'redirects to blob' do + it 'redirects to blob', :sidekiq_might_not_need_inline do put :update, params: default_params expect(response).to redirect_to(project_blob_path(forked_project, 'master/CHANGELOG')) @@ -319,7 +328,7 @@ describe Projects::BlobController do end context 'when editing on the original repository' do - it "redirects to forked project new merge request" do + it "redirects to forked project new merge request", :sidekiq_might_not_need_inline do default_params[:branch_name] = "fork-test-1" default_params[:create_merge_request] = 1 diff --git a/spec/controllers/projects/clusters_controller_spec.rb b/spec/controllers/projects/clusters_controller_spec.rb index e1f6d571d27f26b03f48118ba83e6c0bcc2cf1bc..5a0512a042e40be83222013a730a9628c91a7e7b 100644 --- a/spec/controllers/projects/clusters_controller_spec.rb +++ b/spec/controllers/projects/clusters_controller_spec.rb @@ -79,7 +79,7 @@ describe Projects::ClustersController do end describe 'GET new' do - def go(provider: 'gke') + def go(provider: 'gcp') get :new, params: { namespace_id: project.namespace, project_id: project, @@ -373,6 +373,150 @@ describe Projects::ClustersController do end end + describe 'POST #create_aws' do + let(:params) do + { + cluster: { + name: 'new-cluster', + provider_aws_attributes: { + key_name: 'key', + role_arn: 'arn:role', + region: 'region', + vpc_id: 'vpc', + instance_type: 'instance type', + num_nodes: 3, + security_group_id: 'security group', + subnet_ids: %w(subnet1 subnet2) + } + } + } + end + + def post_create_aws + post :create_aws, params: params.merge(namespace_id: project.namespace, project_id: project) + end + + it 'creates a new cluster' do + expect(ClusterProvisionWorker).to receive(:perform_async) + expect { post_create_aws }.to change { Clusters::Cluster.count } + .and change { Clusters::Providers::Aws.count } + + cluster = project.clusters.first + + expect(response.status).to eq(201) + expect(response.location).to eq(project_cluster_path(project, cluster)) + expect(cluster).to be_aws + expect(cluster).to be_kubernetes + end + + context 'params are invalid' do + let(:params) do + { + cluster: { name: '' } + } + end + + it 'does not create a cluster' do + expect { post_create_aws }.not_to change { Clusters::Cluster.count } + + expect(response.status).to eq(422) + expect(response.content_type).to eq('application/json') + expect(response.body).to include('is invalid') + end + end + + describe 'security' do + before do + allow(WaitForClusterCreationWorker).to receive(:perform_in) + end + + it { expect { post_create_aws }.to be_allowed_for(:admin) } + it { expect { post_create_aws }.to be_allowed_for(:owner).of(project) } + it { expect { post_create_aws }.to be_allowed_for(:maintainer).of(project) } + it { expect { post_create_aws }.to be_denied_for(:developer).of(project) } + it { expect { post_create_aws }.to be_denied_for(:reporter).of(project) } + it { expect { post_create_aws }.to be_denied_for(:guest).of(project) } + it { expect { post_create_aws }.to be_denied_for(:user) } + it { expect { post_create_aws }.to be_denied_for(:external) } + end + end + + describe 'POST authorize AWS role for EKS cluster' do + let(:role_arn) { 'arn:aws:iam::123456789012:role/role-name' } + let(:role_external_id) { '12345' } + + let(:params) do + { + cluster: { + role_arn: role_arn, + role_external_id: role_external_id + } + } + end + + def go + post :authorize_aws_role, params: params.merge(namespace_id: project.namespace, project_id: project) + end + + it 'creates an Aws::Role record' do + expect { go }.to change { Aws::Role.count } + + expect(response.status).to eq 201 + + role = Aws::Role.last + expect(role.user).to eq user + expect(role.role_arn).to eq role_arn + expect(role.role_external_id).to eq role_external_id + end + + context 'role cannot be created' do + let(:role_arn) { 'invalid-role' } + + it 'does not create a record' do + expect { go }.not_to change { Aws::Role.count } + + expect(response.status).to eq 422 + end + end + + describe 'security' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:maintainer).of(project) } + it { expect { go }.to be_denied_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end + end + + describe 'DELETE revoke AWS role for EKS cluster' do + let!(:role) { create(:aws_role, user: user) } + + def go + delete :revoke_aws_role, params: { namespace_id: project.namespace, project_id: project } + end + + it 'deletes the Aws::Role record' do + expect { go }.to change { Aws::Role.count } + + expect(response.status).to eq 204 + expect(user.reload_aws_role).to be_nil + end + + describe 'security' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:maintainer).of(project) } + it { expect { go }.to be_denied_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end + end + describe 'GET cluster_status' do let(:cluster) { create(:cluster, :providing_by_gcp, projects: [project]) } diff --git a/spec/controllers/projects/discussions_controller_spec.rb b/spec/controllers/projects/discussions_controller_spec.rb index 6ed822bbb10aa9084005759d9f6f03bf8b9c8d22..d59f76c1b32a6c7fac1714160ab6e4aa9c4f2014 100644 --- a/spec/controllers/projects/discussions_controller_spec.rb +++ b/spec/controllers/projects/discussions_controller_spec.rb @@ -104,7 +104,9 @@ describe Projects::DiscussionsController 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 post :resolve, params: request_params end @@ -122,8 +124,10 @@ describe Projects::DiscussionsController do end it "renders discussion with serializer" do - expect_any_instance_of(DiscussionSerializer).to receive(:represent) - .with(instance_of(Discussion), { context: instance_of(described_class), render_truncated_diff_lines: true }) + expect_next_instance_of(DiscussionSerializer) do |instance| + expect(instance).to receive(:represent) + .with(instance_of(Discussion), { context: instance_of(described_class), render_truncated_diff_lines: true }) + end post :resolve, params: request_params end @@ -193,8 +197,10 @@ describe Projects::DiscussionsController do end it "renders discussion with serializer" do - expect_any_instance_of(DiscussionSerializer).to receive(:represent) - .with(instance_of(Discussion), { context: instance_of(described_class), render_truncated_diff_lines: true }) + expect_next_instance_of(DiscussionSerializer) do |instance| + expect(instance).to receive(:represent) + .with(instance_of(Discussion), { context: instance_of(described_class), render_truncated_diff_lines: true }) + end delete :unresolve, params: request_params end diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index 3fe5ff5feeeada02fe0a0f70a885d5d10cbfd95e..7bb956201fda8eac43fed5799edd4f71dbfbbd1f 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -330,11 +330,11 @@ describe Projects::EnvironmentsController do expect(response).to redirect_to(environment_metrics_path(environment)) end - it 'redirects to empty page if no environment exists' do + it 'redirects to empty metrics page if no environment exists' do get :metrics_redirect, params: { namespace_id: project.namespace, project_id: project } expect(response).to be_ok - expect(response).to render_template 'empty' + expect(response).to render_template 'empty_metrics' end end diff --git a/spec/controllers/projects/error_tracking_controller_spec.rb b/spec/controllers/projects/error_tracking_controller_spec.rb index 31868f5f7173167cb2b633b0044f6da2682089ea..8155d6ddafe77ec1c216d5164a7b9eeb03910bc1 100644 --- a/spec/controllers/projects/error_tracking_controller_spec.rb +++ b/spec/controllers/projects/error_tracking_controller_spec.rb @@ -46,17 +46,6 @@ describe Projects::ErrorTrackingController do end describe 'format json' do - shared_examples 'no data' do - it 'returns no data' do - get :index, params: project_params(format: :json) - - expect(response).to have_gitlab_http_status(:ok) - expect(response).to match_response_schema('error_tracking/index') - expect(json_response['external_url']).to be_nil - expect(json_response['errors']).to eq([]) - end - end - let(:list_issues_service) { spy(:list_issues_service) } let(:external_url) { 'http://example.com' } @@ -66,6 +55,19 @@ describe Projects::ErrorTrackingController do .and_return(list_issues_service) end + context 'no data' do + before do + expect(list_issues_service).to receive(:execute) + .and_return(status: :error, http_status: :no_content) + end + + it 'returns no data' do + get :index, params: project_params(format: :json) + + expect(response).to have_gitlab_http_status(:no_content) + end + end + context 'service result is successful' do before do expect(list_issues_service).to receive(:execute) @@ -232,8 +234,186 @@ describe Projects::ErrorTrackingController do end end + describe 'GET #issue_details' do + let_it_be(:issue_id) { 1234 } + + let(:issue_details_service) { spy(:issue_details_service) } + + let(:permitted_params) do + ActionController::Parameters.new( + { issue_id: issue_id.to_s } + ).permit! + end + + before do + expect(ErrorTracking::IssueDetailsService) + .to receive(:new).with(project, user, permitted_params) + .and_return(issue_details_service) + end + + describe 'format json' do + context 'no data' do + before do + expect(issue_details_service).to receive(:execute) + .and_return(status: :error, http_status: :no_content) + 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 + end + + context 'service result is successful' do + before do + expect(issue_details_service).to receive(:execute) + .and_return(status: :success, issue: error) + end + + let(:error) { build(:detailed_error_tracking_error) } + + it 'returns an error' do + get :details, params: issue_params(issue_id: issue_id, format: :json) + + 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) + end + end + + context 'service result is erroneous' do + let(:error_message) { 'error message' } + + context 'without http_status' do + before do + expect(issue_details_service).to receive(:execute) + .and_return(status: :error, message: error_message) + end + + it 'returns 400 with message' do + get :details, params: issue_params(issue_id: issue_id, format: :json) + + 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(issue_details_service).to receive(:execute).and_return( + status: :error, + message: error_message, + http_status: http_status + ) + end + + it 'returns http_status with message' do + get :details, params: issue_params(issue_id: issue_id, format: :json) + + expect(response).to have_gitlab_http_status(http_status) + expect(json_response['message']).to eq(error_message) + end + end + end + end + end + + describe 'GET #stack_trace' do + let_it_be(:issue_id) { 1234 } + + let(:issue_stack_trace_service) { spy(:issue_stack_trace_service) } + + let(:permitted_params) do + ActionController::Parameters.new( + { issue_id: issue_id.to_s } + ).permit! + end + + before do + expect(ErrorTracking::IssueLatestEventService) + .to receive(:new).with(project, user, permitted_params) + .and_return(issue_stack_trace_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, params: issue_params(issue_id: issue_id, format: :json) + + expect(response).to have_gitlab_http_status(:no_content) + end + end + + context 'service result is successful' do + before do + expect(issue_stack_trace_service).to receive(:execute) + .and_return(status: :success, latest_event: error_event) + end + + let(:error_event) { build(:error_tracking_error_event) } + + it 'returns an error' do + get :stack_trace, params: issue_params(issue_id: issue_id, format: :json) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('error_tracking/issue_stack_trace') + expect(json_response['error']).to eq(error_event.as_json) + end + end + + context 'service 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, params: issue_params(issue_id: issue_id, format: :json) + + 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(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, params: issue_params(issue_id: issue_id, format: :json) + + expect(response).to have_gitlab_http_status(http_status) + expect(json_response['message']).to eq(error_message) + end + end + end + end + end + private + def issue_params(opts = {}) + project_params.reverse_merge(opts) + end + def project_params(opts = {}) opts.reverse_merge(namespace_id: project.namespace, project_id: project) end diff --git a/spec/controllers/projects/grafana_api_controller_spec.rb b/spec/controllers/projects/grafana_api_controller_spec.rb index 352a364295b5a31b13f9791f4e6fbbc0d2b3e730..0ef96514961aaf0bad0ca19eb65e65573043af6d 100644 --- a/spec/controllers/projects/grafana_api_controller_spec.rb +++ b/spec/controllers/projects/grafana_api_controller_spec.rb @@ -94,4 +94,75 @@ describe Projects::GrafanaApiController do end end end + + describe 'GET #metrics_dashboard' do + let(:service_result) { { status: :success, dashboard: '{}' } } + let(:params) do + { + format: :json, + embedded: true, + grafana_url: 'https://grafana.example.com', + namespace_id: project.namespace.full_path, + project_id: project.name + } + end + + before do + allow(Gitlab::Metrics::Dashboard::Finder) + .to receive(:find) + .and_return(service_result) + end + + context 'when the result is still processing' do + let(:service_result) { nil } + + it 'returns 204 no content' do + get :metrics_dashboard, params: params + + expect(response).to have_gitlab_http_status(:no_content) + end + end + + context 'when the result was successful' do + it 'returns the dashboard response' do + get :metrics_dashboard, params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to eq({ + 'dashboard' => '{}', + 'status' => 'success' + }) + end + end + + context 'when an error has occurred' do + shared_examples_for 'error response' do |http_status| + it "returns #{http_status}" do + get :metrics_dashboard, params: params + + expect(response).to have_gitlab_http_status(http_status) + expect(json_response['status']).to eq('error') + expect(json_response['message']).to eq('error message') + end + end + + context 'with an error accessing grafana' do + let(:service_result) do + { + http_status: :service_unavailable, + status: :error, + message: 'error message' + } + end + + it_behaves_like 'error response', :service_unavailable + end + + context 'with a processing error' do + let(:service_result) { { status: :error, message: 'error message' } } + + it_behaves_like 'error response', :bad_request + end + end + end end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index d36336a9f67c4dc606240803daaec96d851219d6..8770a5ee303b72f693c4ece8c97bb2c052795a8c 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -1252,7 +1252,7 @@ describe Projects::IssuesController do stub_feature_flags(create_confidential_merge_request: true) end - it 'creates a new merge request' do + it 'creates a new merge request', :sidekiq_might_not_need_inline do expect { create_merge_request }.to change(target_project.merge_requests, :count).by(1) end end diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb index 90ccb88492745966a8eeb72e57beb39b3a3b3972..349d73f13caf612fe760356a14e775a5a13b0898 100644 --- a/spec/controllers/projects/jobs_controller_spec.rb +++ b/spec/controllers/projects/jobs_controller_spec.rb @@ -154,7 +154,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do .and_return(merge_request) end - it 'does not serialize builds in exposed stages' do + it 'does not serialize builds in exposed stages', :sidekiq_might_not_need_inline do get_show_json json_response.dig('pipeline', 'details', 'stages').tap do |stages| @@ -183,7 +183,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do context 'job is cancelable' do let(:job) { create(:ci_build, :running, pipeline: pipeline) } - it 'cancel_path is present with correct redirect' do + it 'cancel_path is present with correct redirect', :sidekiq_might_not_need_inline do expect(response).to have_gitlab_http_status(:ok) expect(response).to match_response_schema('job/job_details') expect(json_response['cancel_path']).to include(CGI.escape(json_response['build_path'])) @@ -193,7 +193,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do context 'with web terminal' do let(:job) { create(:ci_build, :running, :with_runner_session, pipeline: pipeline) } - it 'exposes the terminal path' do + it 'exposes the terminal path', :sidekiq_might_not_need_inline do expect(response).to have_gitlab_http_status(:ok) expect(response).to match_response_schema('job/job_details') expect(json_response['terminal_path']).to match(%r{/terminal}) @@ -268,7 +268,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do project.add_maintainer(user) # Need to be a maintianer to view cluster.path end - it 'exposes the deployment information' do + it 'exposes the deployment information', :sidekiq_might_not_need_inline do get_show_json expect(response).to have_gitlab_http_status(:ok) @@ -292,7 +292,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do sign_in(user) end - it 'user can edit runner' do + it 'user can edit runner', :sidekiq_might_not_need_inline do get_show_json expect(response).to have_gitlab_http_status(:ok) @@ -312,7 +312,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do sign_in(user) end - it 'user can not edit runner' do + it 'user can not edit runner', :sidekiq_might_not_need_inline do get_show_json expect(response).to have_gitlab_http_status(:ok) @@ -331,7 +331,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do sign_in(user) end - it 'user can not edit runner' do + it 'user can not edit runner', :sidekiq_might_not_need_inline do get_show_json expect(response).to have_gitlab_http_status(:ok) @@ -412,7 +412,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do context 'when job has trace' do let(:job) { create(:ci_build, :running, :trace_live, pipeline: pipeline) } - it "has_trace is true" do + it "has_trace is true", :sidekiq_might_not_need_inline do get_show_json expect(response).to match_response_schema('job/job_details') @@ -458,7 +458,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do create(:ci_pipeline_variable, pipeline: pipeline, key: :TRIGGER_KEY_1, value: 'TRIGGER_VALUE_1') end - context 'user is a maintainer' do + context 'user is a maintainer', :sidekiq_might_not_need_inline do before do project.add_maintainer(user) diff --git a/spec/controllers/projects/labels_controller_spec.rb b/spec/controllers/projects/labels_controller_spec.rb index ff089df37f758ec4ba3d117b9d5513c891b70a5d..aee017b211a256e8d1c8e81caf306c4193935001 100644 --- a/spec/controllers/projects/labels_controller_spec.rb +++ b/spec/controllers/projects/labels_controller_spec.rb @@ -204,6 +204,24 @@ describe Projects::LabelsController do expect(response).to redirect_to(project_labels_path(project)) expect(controller).to set_flash[:notice].to(project_moved_message(redirect_route, project)) end + + context 'with an AJAX request' do + it 'redirects to the canonical path but does not set flash message' do + get :index, params: { namespace_id: project.namespace, project_id: project.to_param + 'old' }, xhr: true + + expect(response).to redirect_to(project_labels_path(project)) + expect(controller).not_to set_flash[:notice] + end + end + + context 'with JSON format' do + it 'redirects to the canonical path but does not set flash message' do + get :index, params: { namespace_id: project.namespace, project_id: project.to_param + 'old' }, format: :json + + expect(response).to redirect_to(project_labels_path(project, format: :json)) + expect(controller).not_to set_flash[:notice] + end + end end end end diff --git a/spec/controllers/projects/mattermosts_controller_spec.rb b/spec/controllers/projects/mattermosts_controller_spec.rb index 45125385d9e715bfd0b00cc2c7e7e8aa3f4db145..64440ed585d9710ec936615d37c8c0dc00e7a3a4 100644 --- a/spec/controllers/projects/mattermosts_controller_spec.rb +++ b/spec/controllers/projects/mattermosts_controller_spec.rb @@ -13,8 +13,9 @@ describe Projects::MattermostsController do describe 'GET #new' do before do - allow_any_instance_of(MattermostSlashCommandsService) - .to receive(:list_teams).and_return([]) + allow_next_instance_of(MattermostSlashCommandsService) do |instance| + allow(instance).to receive(:list_teams).and_return([]) + end end it 'accepts the request' do @@ -42,7 +43,9 @@ describe Projects::MattermostsController do context 'no request can be made to mattermost' do it 'shows the error' do - allow_any_instance_of(MattermostSlashCommandsService).to receive(:configure).and_return([false, "error message"]) + allow_next_instance_of(MattermostSlashCommandsService) do |instance| + allow(instance).to receive(:configure).and_return([false, "error message"]) + end expect(subject).to redirect_to(new_project_mattermost_url(project)) end @@ -50,7 +53,9 @@ describe Projects::MattermostsController do context 'the request is succesull' do before do - allow_any_instance_of(Mattermost::Command).to receive(:create).and_return('token') + allow_next_instance_of(Mattermost::Command) do |instance| + allow(instance).to receive(:create).and_return('token') + end end it 'redirects to the new page' do diff --git a/spec/controllers/projects/merge_requests/creations_controller_spec.rb b/spec/controllers/projects/merge_requests/creations_controller_spec.rb index ce977f26ec66eecd5a73151c1e08ca336c171e42..1bbb80f9904ea60aeeb735b754b98cae3b52c131 100644 --- a/spec/controllers/projects/merge_requests/creations_controller_spec.rb +++ b/spec/controllers/projects/merge_requests/creations_controller_spec.rb @@ -85,7 +85,9 @@ describe Projects::MergeRequests::CreationsController do describe 'GET diffs' do context 'when merge request cannot be created' do it 'does not assign diffs var' do - allow_any_instance_of(MergeRequest).to receive(:can_be_created).and_return(false) + allow_next_instance_of(MergeRequest) do |instance| + allow(instance).to receive(:can_be_created).and_return(false) + end get :diffs, params: get_diff_params.merge(format: 'json') diff --git a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb index 5c02e8d6461b4a8e6430ddf12009a54733e1562e..06d9af331890ec05f271b9050692e7a64c9966e8 100644 --- a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb +++ b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb @@ -34,6 +34,16 @@ describe Projects::MergeRequests::DiffsController do it 'saves the preferred diff view in a cookie' do expect(response.cookies['diff_view']).to eq('parallel') end + + it 'only renders the required view', :aggregate_failures do + diff_files_without_deletions = json_response['diff_files'].reject { |f| f['deleted_file'] } + have_no_inline_diff_lines = satisfy('have no inline diff lines') do |diff_file| + !diff_file.has_key?('highlighted_diff_lines') + end + + expect(diff_files_without_deletions).to all(have_key('parallel_diff_lines')) + expect(diff_files_without_deletions).to all(have_no_inline_diff_lines) + end end context 'when the user cannot view the merge request' do @@ -76,7 +86,9 @@ describe Projects::MergeRequests::DiffsController do end it 'serializes merge request diff collection' do - expect_any_instance_of(DiffsSerializer).to receive(:represent).with(an_instance_of(Gitlab::Diff::FileCollection::MergeRequestDiff), an_instance_of(Hash)) + expect_next_instance_of(DiffsSerializer) do |instance| + expect(instance).to receive(:represent).with(an_instance_of(Gitlab::Diff::FileCollection::MergeRequestDiff), an_instance_of(Hash)) + end go end @@ -88,7 +100,9 @@ describe Projects::MergeRequests::DiffsController do end it 'serializes merge request diff collection' do - expect_any_instance_of(DiffsSerializer).to receive(:represent).with(an_instance_of(Gitlab::Diff::FileCollection::MergeRequestDiff), an_instance_of(Hash)) + expect_next_instance_of(DiffsSerializer) do |instance| + expect(instance).to receive(:represent).with(an_instance_of(Gitlab::Diff::FileCollection::MergeRequestDiff), an_instance_of(Hash)) + end go end @@ -259,7 +273,7 @@ describe Projects::MergeRequests::DiffsController do it 'only renders the diffs for the path given' do diff_for_path(old_path: existing_path, new_path: existing_path) - paths = json_response["diff_files"].map { |file| file['new_path'] } + paths = json_response['diff_files'].map { |file| file['new_path'] } expect(paths).to include(existing_path) end @@ -344,6 +358,7 @@ describe Projects::MergeRequests::DiffsController do let(:expected_options) do { merge_request: merge_request, + diff_view: :inline, pagination_data: { current_page: 1, next_page: nil, @@ -367,6 +382,7 @@ describe Projects::MergeRequests::DiffsController do let(:expected_options) do { merge_request: merge_request, + diff_view: :inline, pagination_data: { current_page: 2, next_page: 3, diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index ea7027925578dddad2fa610d3c6a3f58f342452c..9f7fde2f0da3d93b96884e15a9bd80f45523b079 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' describe Projects::MergeRequestsController do include ProjectForksHelper + include Gitlab::Routing let(:project) { create(:project, :repository) } let(:user) { project.owner } @@ -206,7 +207,7 @@ describe Projects::MergeRequestsController do it 'redirects to last_page if page number is larger than number of pages' do get_merge_requests(last_page + 1) - expect(response).to redirect_to(namespace_project_merge_requests_path(page: last_page, state: controller.params[:state], scope: controller.params[:scope])) + expect(response).to redirect_to(project_merge_requests_path(project, page: last_page, state: controller.params[:state], scope: controller.params[:scope])) end it 'redirects to specified page' do @@ -227,7 +228,7 @@ describe Projects::MergeRequestsController do host: external_host } - expect(response).to redirect_to(namespace_project_merge_requests_path(page: last_page, state: controller.params[:state], scope: controller.params[:scope])) + expect(response).to redirect_to(project_merge_requests_path(project, page: last_page, state: controller.params[:state], scope: controller.params[:scope])) end end @@ -404,7 +405,7 @@ describe Projects::MergeRequestsController do end it 'starts the merge immediately with permitted params' do - expect(MergeWorker).to receive(:perform_async).with(merge_request.id, anything, { 'squash' => false }) + expect(MergeWorker).to receive(:perform_async).with(merge_request.id, anything, { 'sha' => merge_request.diff_head_sha }) merge_with_sha end @@ -430,10 +431,15 @@ describe Projects::MergeRequestsController do context 'when a squash commit message is passed' do let(:message) { 'My custom squash commit message' } - it 'passes the same message to SquashService' do - params = { squash: '1', squash_commit_message: message } + it 'passes the same message to SquashService', :sidekiq_might_not_need_inline do + params = { squash: '1', + squash_commit_message: message, + sha: merge_request.diff_head_sha } + expected_squash_params = { squash_commit_message: message, + sha: merge_request.diff_head_sha, + merge_request: merge_request } - expect_next_instance_of(MergeRequests::SquashService, project, user, params.merge(merge_request: merge_request)) do |squash_service| + expect_next_instance_of(MergeRequests::SquashService, project, user, expected_squash_params) do |squash_service| expect(squash_service).to receive(:execute).and_return({ status: :success, squash_sha: SecureRandom.hex(20) @@ -723,7 +729,7 @@ describe Projects::MergeRequestsController do context 'with private builds' do context 'for the target project member' do - it 'does not respond with serialized pipelines' do + it 'does not respond with serialized pipelines', :sidekiq_might_not_need_inline do expect(json_response['pipelines']).to be_empty expect(json_response['count']['all']).to eq(0) expect(response).to include_pagination_headers @@ -733,7 +739,7 @@ describe Projects::MergeRequestsController do context 'for the source project member' do let(:user) { fork_user } - it 'responds with serialized pipelines' do + it 'responds with serialized pipelines', :sidekiq_might_not_need_inline do expect(json_response['pipelines']).to be_present expect(json_response['count']['all']).to eq(1) expect(response).to include_pagination_headers @@ -749,7 +755,7 @@ describe Projects::MergeRequestsController do end context 'for the target project member' do - it 'does not respond with serialized pipelines' do + it 'does not respond with serialized pipelines', :sidekiq_might_not_need_inline do expect(json_response['pipelines']).to be_present expect(json_response['count']['all']).to eq(1) expect(response).to include_pagination_headers @@ -759,7 +765,7 @@ describe Projects::MergeRequestsController do context 'for the source project member' do let(:user) { fork_user } - it 'responds with serialized pipelines' do + it 'responds with serialized pipelines', :sidekiq_might_not_need_inline do expect(json_response['pipelines']).to be_present expect(json_response['count']['all']).to eq(1) expect(response).to include_pagination_headers @@ -770,6 +776,172 @@ describe Projects::MergeRequestsController do end end + describe 'GET exposed_artifacts' do + let(:merge_request) do + create(:merge_request, + :with_merge_request_pipeline, + target_project: project, + source_project: project) + end + + let(:pipeline) do + create(:ci_pipeline, + :success, + project: merge_request.source_project, + ref: merge_request.source_branch, + sha: merge_request.diff_head_sha) + end + + let!(:job) { create(:ci_build, pipeline: pipeline, options: job_options) } + let!(:job_metadata) { create(:ci_job_artifact, :metadata, job: job) } + + before do + allow_any_instance_of(MergeRequest) + .to receive(:find_exposed_artifacts) + .and_return(report) + + allow_any_instance_of(MergeRequest) + .to receive(:actual_head_pipeline) + .and_return(pipeline) + end + + subject do + get :exposed_artifacts, params: { + namespace_id: project.namespace.to_param, + project_id: project, + id: merge_request.iid + }, + format: :json + end + + describe 'permissions on a public project with private CI/CD' do + let(:project) { create :project, :repository, :public, :builds_private } + let(:report) { { status: :parsed, data: [] } } + let(:job_options) { {} } + + context 'while signed out' do + before do + sign_out(user) + end + + it 'responds with a 404' do + subject + + expect(response).to have_gitlab_http_status(404) + expect(response.body).to be_blank + end + end + + context 'while signed in as an unrelated user' do + before do + sign_in(create(:user)) + end + + it 'responds with a 404' do + subject + + expect(response).to have_gitlab_http_status(404) + expect(response.body).to be_blank + end + end + end + + context 'when pipeline has jobs with exposed artifacts' do + let(:job_options) do + { + artifacts: { + paths: ['ci_artifacts.txt'], + expose_as: 'Exposed artifact' + } + } + end + + context 'when fetching exposed artifacts is in progress' do + let(:report) { { status: :parsing } } + + it 'sends polling interval' do + expect(Gitlab::PollingInterval).to receive(:set_header) + + subject + end + + it 'returns 204 HTTP status' do + subject + + expect(response).to have_gitlab_http_status(:no_content) + end + end + + context 'when fetching exposed artifacts is completed' do + let(:data) do + Ci::GenerateExposedArtifactsReportService.new(project, user) + .execute(nil, pipeline) + end + + let(:report) { { status: :parsed, data: data } } + + it 'returns exposed artifacts' do + subject + + expect(response).to have_gitlab_http_status(200) + expect(json_response['status']).to eq('parsed') + expect(json_response['data']).to eq([{ + 'job_name' => 'test', + 'job_path' => project_job_path(project, job), + 'url' => file_project_job_artifacts_path(project, job, 'ci_artifacts.txt'), + 'text' => 'Exposed artifact' + }]) + end + end + + context 'when feature flag :ci_expose_arbitrary_artifacts_in_mr is disabled' do + let(:job_options) do + { + artifacts: { + paths: ['ci_artifacts.txt'], + expose_as: 'Exposed artifact' + } + } + end + let(:report) { double } + + before do + stub_feature_flags(ci_expose_arbitrary_artifacts_in_mr: false) + end + + it 'does not send polling interval' do + expect(Gitlab::PollingInterval).not_to receive(:set_header) + + subject + end + + it 'returns 204 HTTP status' do + subject + + expect(response).to have_gitlab_http_status(:no_content) + end + end + end + + context 'when pipeline does not have jobs with exposed artifacts' do + let(:report) { double } + let(:job_options) do + { + artifacts: { + paths: ['ci_artifacts.txt'] + } + } + end + + it 'returns no content' do + subject + + expect(response).to have_gitlab_http_status(204) + expect(response.body).to be_empty + end + end + end + describe 'GET test_reports' do let(:merge_request) do create(:merge_request, @@ -879,23 +1051,6 @@ describe Projects::MergeRequestsController do expect(json_response).to eq({ 'status_reason' => 'Failed to parse test reports' }) end end - - context 'when something went wrong on our system' do - let(:comparison_status) { {} } - - it 'does not send polling interval' do - expect(Gitlab::PollingInterval).not_to receive(:set_header) - - subject - end - - it 'returns 500 HTTP status' do - subject - - expect(response).to have_gitlab_http_status(:internal_server_error) - expect(json_response).to eq({ 'status_reason' => 'Unknown error' }) - end - end end describe 'POST remove_wip' do @@ -1019,13 +1174,13 @@ describe Projects::MergeRequestsController do create(:merge_request, source_project: forked, target_project: project, target_branch: 'master', head_pipeline: pipeline) end - it 'links to the environment on that project' do + it 'links to the environment on that project', :sidekiq_might_not_need_inline do get_ci_environments_status expect(json_response.first['url']).to match /#{forked.full_path}/ end - context "when environment_target is 'merge_commit'" do + context "when environment_target is 'merge_commit'", :sidekiq_might_not_need_inline do it 'returns nothing' do get_ci_environments_status(environment_target: 'merge_commit') @@ -1056,13 +1211,13 @@ describe Projects::MergeRequestsController do # we're trying to reduce the overall number of queries for this method. # set a hard limit for now. https://gitlab.com/gitlab-org/gitlab-foss/issues/52287 - it 'keeps queries in check' do + it 'keeps queries in check', :sidekiq_might_not_need_inline do control_count = ActiveRecord::QueryRecorder.new { get_ci_environments_status }.count expect(control_count).to be <= 137 end - it 'has no N+1 SQL issues for environments', :request_store, retry: 0 do + it 'has no N+1 SQL issues for environments', :request_store, :sidekiq_might_not_need_inline, retry: 0 do # First run to insert test data from lets, which does take up some 30 queries get_ci_environments_status @@ -1225,6 +1380,33 @@ describe Projects::MergeRequestsController do end end + context 'with SELECT FOR UPDATE lock' do + before do + stub_feature_flags(merge_request_rebase_nowait_lock: false) + end + + it 'executes rebase' do + allow_any_instance_of(MergeRequest).to receive(:with_lock).with(true).and_call_original + expect(RebaseWorker).to receive(:perform_async) + + post_rebase + + expect(response.status).to eq(200) + end + end + + context 'with NOWAIT lock' do + it 'returns a 409' do + allow_any_instance_of(MergeRequest).to receive(:with_lock).with('FOR UPDATE NOWAIT').and_raise(ActiveRecord::LockWaitTimeout) + expect(RebaseWorker).not_to receive(:perform_async) + + post_rebase + + expect(response.status).to eq(409) + expect(json_response['merge_error']).to eq(MergeRequest::REBASE_LOCK_MESSAGE) + end + end + context 'with a forked project' do let(:forked_project) { fork_project(project, fork_owner, repository: true) } let(:fork_owner) { create(:user) } @@ -1253,7 +1435,7 @@ describe Projects::MergeRequestsController do sign_in(fork_owner) end - it 'returns 200' do + it 'returns 200', :sidekiq_might_not_need_inline do expect_rebase_worker_for(fork_owner) post_rebase diff --git a/spec/controllers/projects/mirrors_controller_spec.rb b/spec/controllers/projects/mirrors_controller_spec.rb index fb3dd75460a22c8ec2fc3356d3a1c61ee537bc71..e14686970a1d551a4874339728ac09c2e85d99db 100644 --- a/spec/controllers/projects/mirrors_controller_spec.rb +++ b/spec/controllers/projects/mirrors_controller_spec.rb @@ -51,10 +51,6 @@ describe Projects::MirrorsController do sign_in(project.owner) end - around do |example| - Sidekiq::Testing.fake! { example.run } - end - context 'With valid URL for a push' do let(:remote_mirror_attributes) do { "0" => { "enabled" => "0", url: 'https://updated.example.com' } } diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index 3ab191c00322cef60f9eb17b386a3ad77ddb9518..e576a3d2d4080f2f5ca89f1ed80e3d7f82383534 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -518,7 +518,7 @@ describe Projects::NotesController do project.id && Project.maximum(:id).succ end - it 'returns a 404' do + it 'returns a 404', :sidekiq_might_not_need_inline do create! expect(response).to have_gitlab_http_status(404) end @@ -527,13 +527,13 @@ describe Projects::NotesController do context 'when the user has no access to the fork' do let(:fork_visibility) { Gitlab::VisibilityLevel::PRIVATE } - it 'returns a 404' do + it 'returns a 404', :sidekiq_might_not_need_inline do create! expect(response).to have_gitlab_http_status(404) end end - context 'when the user has access to the fork' do + context 'when the user has access to the fork', :sidekiq_might_not_need_inline do let!(:discussion) { forked_project.notes.find_discussion(existing_comment.discussion_id) } let(:fork_visibility) { Gitlab::VisibilityLevel::PUBLIC } @@ -785,7 +785,9 @@ describe Projects::NotesController 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 post :resolve, params: request_params end diff --git a/spec/controllers/projects/pages_domains_controller_spec.rb b/spec/controllers/projects/pages_domains_controller_spec.rb index 032f4f1418f4864e761cee13b80c7442e8db1320..3987bebb124b504821cd3c24eb9a74598ce01090 100644 --- a/spec/controllers/projects/pages_domains_controller_spec.rb +++ b/spec/controllers/projects/pages_domains_controller_spec.rb @@ -32,10 +32,10 @@ describe Projects::PagesDomainsController do get(:show, params: request_params.merge(id: pages_domain.domain)) end - it "displays the 'show' page" do + it "redirects to the 'edit' page" do make_request - expect(response).to have_gitlab_http_status(200) - expect(response).to render_template('show') + + expect(response).to redirect_to(edit_project_pages_domain_path(project, pages_domain.domain)) end context 'when user is developer' do @@ -69,7 +69,7 @@ describe Projects::PagesDomainsController do created_domain = PagesDomain.reorder(:id).last expect(created_domain).to be_present - expect(response).to redirect_to(project_pages_domain_path(project, created_domain)) + expect(response).to redirect_to(edit_project_pages_domain_path(project, created_domain)) end end @@ -160,7 +160,7 @@ describe Projects::PagesDomainsController do post :verify, params: params - expect(response).to redirect_to project_pages_domain_path(project, pages_domain) + expect(response).to redirect_to edit_project_pages_domain_path(project, pages_domain) expect(flash[:notice]).to eq('Successfully verified domain ownership') end @@ -169,7 +169,7 @@ describe Projects::PagesDomainsController do post :verify, params: params - expect(response).to redirect_to project_pages_domain_path(project, pages_domain) + expect(response).to redirect_to edit_project_pages_domain_path(project, pages_domain) expect(flash[:alert]).to eq('Failed to verify domain ownership') end @@ -190,6 +190,56 @@ describe Projects::PagesDomainsController do end end + describe 'DELETE #clean_certificate' do + subject do + delete(:clean_certificate, params: request_params.merge(id: pages_domain.domain)) + end + + it 'redirects to edit page' do + subject + + expect(response).to redirect_to(edit_project_pages_domain_path(project, pages_domain)) + end + + it 'removes certificate' do + expect do + subject + end.to change { pages_domain.reload.certificate }.to(nil) + .and change { pages_domain.reload.key }.to(nil) + end + + it 'sets certificate source to user_provided' do + pages_domain.update!(certificate_source: :gitlab_provided) + + expect do + subject + end.to change { pages_domain.reload.certificate_source }.from("gitlab_provided").to("user_provided") + end + + context 'when pages_https_only is set' do + before do + project.update!(pages_https_only: true) + stub_pages_setting(external_https: '127.0.0.1') + end + + it 'does not remove certificate' do + subject + + pages_domain.reload + expect(pages_domain.certificate).to be_present + expect(pages_domain.key).to be_present + end + + it 'redirects to edit page with a flash message' do + subject + + expect(flash[:alert]).to include('Certificate') + expect(flash[:alert]).to include('Key') + expect(response).to redirect_to(edit_project_pages_domain_path(project, pages_domain)) + end + end + end + context 'pages disabled' do before do allow(Gitlab.config.pages).to receive(:enabled).and_return(false) diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index e3ad36f8d2427ffeb808dd40be926f4d536114ba..3c7f69f0e6e86cbe1b36320f898597c67d3d9b7e 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -93,7 +93,7 @@ describe Projects::PipelinesController do end context 'when performing gitaly calls', :request_store do - it 'limits the Gitaly requests' do + it 'limits the Gitaly requests', :sidekiq_might_not_need_inline do # Isolate from test preparation (Repository#exists? is also cached in RequestStore) RequestStore.end! RequestStore.clear! @@ -149,7 +149,7 @@ describe Projects::PipelinesController do end describe 'GET show.json' do - let(:pipeline) { create(:ci_pipeline_with_one_job, project: project) } + let(:pipeline) { create(:ci_pipeline, project: project) } it 'returns the pipeline' do get_pipeline_json @@ -571,7 +571,7 @@ describe Projects::PipelinesController do format: :json end - it 'cancels a pipeline without returning any content' do + it 'cancels a pipeline without returning any content', :sidekiq_might_not_need_inline do expect(response).to have_gitlab_http_status(:no_content) expect(pipeline.reload).to be_canceled end diff --git a/spec/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb index 2f473d395add2d708b51aef49229c520905d395d..072df1f50602d5c70f8f0f58d21ce8a130a0cb48 100644 --- a/spec/controllers/projects/project_members_controller_spec.rb +++ b/spec/controllers/projects/project_members_controller_spec.rb @@ -45,7 +45,9 @@ describe Projects::ProjectMembersController do end it 'adds user to members' do - expect_any_instance_of(Members::CreateService).to receive(:execute).and_return(status: :success) + expect_next_instance_of(Members::CreateService) do |instance| + expect(instance).to receive(:execute).and_return(status: :success) + end post :create, params: { namespace_id: project.namespace, @@ -59,7 +61,9 @@ describe Projects::ProjectMembersController do end it 'adds no user to members' do - expect_any_instance_of(Members::CreateService).to receive(:execute).and_return(status: :failure, message: 'Message') + expect_next_instance_of(Members::CreateService) do |instance| + expect(instance).to receive(:execute).and_return(status: :failure, message: 'Message') + end post :create, params: { namespace_id: project.namespace, diff --git a/spec/controllers/projects/prometheus/metrics_controller_spec.rb b/spec/controllers/projects/prometheus/metrics_controller_spec.rb index 17f9483be98d1689e138e4fd1fe76dd7384c0c22..afdb8bbc983a2b7237ef848eae5363b04e5bd2bc 100644 --- a/spec/controllers/projects/prometheus/metrics_controller_spec.rb +++ b/spec/controllers/projects/prometheus/metrics_controller_spec.rb @@ -85,7 +85,9 @@ describe Projects::Prometheus::MetricsController do end it 'calls prometheus adapter service' do - expect_any_instance_of(::Prometheus::AdapterService).to receive(:prometheus_adapter) + expect_next_instance_of(::Prometheus::AdapterService) do |instance| + expect(instance).to receive(:prometheus_adapter) + end subject.__send__(:prometheus_adapter) end diff --git a/spec/controllers/projects/releases_controller_spec.rb b/spec/controllers/projects/releases_controller_spec.rb index 5b9d21d3d5b04f23e40445b9966b1fcee4fc66d4..562119d967f8eb443d88a4d1b3a82d88286132fb 100644 --- a/spec/controllers/projects/releases_controller_spec.rb +++ b/spec/controllers/projects/releases_controller_spec.rb @@ -3,10 +3,36 @@ require 'spec_helper' describe Projects::ReleasesController do - let!(:project) { create(:project, :repository, :public) } - let!(:user) { create(:user) } + let!(:project) { create(:project, :repository, :public) } + let!(:private_project) { create(:project, :repository, :private) } + let(:user) { developer } + let(:developer) { create(:user) } + let(:reporter) { create(:user) } + let!(:release_1) { create(:release, project: project, released_at: Time.zone.parse('2018-10-18')) } + let!(:release_2) { create(:release, project: project, released_at: Time.zone.parse('2019-10-19')) } - describe 'GET #index' do + before do + project.add_developer(developer) + project.add_reporter(reporter) + end + + shared_examples_for 'successful request' do + it 'renders a 200' do + subject + + expect(response).to have_gitlab_http_status(:success) + end + end + + shared_examples_for 'not found' do + it 'renders 404' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + shared_examples 'common access controls' do it 'renders a 200' do get_index @@ -14,36 +40,135 @@ describe Projects::ReleasesController do end context 'when the project is private' do - let!(:project) { create(:project, :repository, :private) } + let(:project) { private_project } + + before do + sign_in(user) + end + + context 'when user is a developer' do + let(:user) { developer } - it 'renders a 302' do - get_index + it 'renders a 200 for a logged in developer' do + sign_in(user) - expect(response.status).to eq(302) + get_index + + expect(response.status).to eq(200) + end end - it 'renders a 200 for a logged in developer' do - project.add_developer(user) - sign_in(user) + context 'when user is an external user' do + let(:user) { create(:user) } - get_index + it 'renders a 404 when logged in but not in the project' do + sign_in(user) - expect(response.status).to eq(200) + get_index + + expect(response.status).to eq(404) + end end + end + end - it 'renders a 404 when logged in but not in the project' do - sign_in(user) + describe 'GET #index' do + before do + get_index + end - get_index + context 'as html' do + let(:format) { :html } - expect(response.status).to eq(404) + it 'returns a text/html content_type' do + expect(response.content_type).to eq 'text/html' end + + it_behaves_like 'common access controls' + + context 'when the project is private and the user is not logged in' do + let(:project) { private_project } + + it 'returns a redirect' do + expect(response).to have_gitlab_http_status(:redirect) + end + end + end + + context 'as json' do + let(:format) { :json } + + it 'returns an application/json content_type' do + expect(response.content_type).to eq 'application/json' + end + + it "returns the project's releases as JSON, ordered by released_at" do + expect(response.body).to eq([release_2, release_1].to_json) + end + + it_behaves_like 'common access controls' + + context 'when the project is private and the user is not logged in' do + let(:project) { private_project } + + it 'returns a redirect' do + expect(response).to have_gitlab_http_status(:redirect) + end + end + end + end + + describe 'GET #edit' do + subject do + get :edit, params: { namespace_id: project.namespace, project_id: project, tag: tag } + end + + before do + sign_in(user) + end + + let!(:release) { create(:release, project: project) } + let(:tag) { CGI.escape(release.tag) } + + it_behaves_like 'successful request' + + context 'when tag name contains slash' do + let!(:release) { create(:release, project: project, tag: 'awesome/v1.0') } + let(:tag) { CGI.escape(release.tag) } + + it_behaves_like 'successful request' + + it 'is accesible at a URL encoded path' do + expect(edit_project_release_path(project, release)) + .to eq("/#{project.namespace.path}/#{project.name}/-/releases/awesome%252Fv1.0/edit") + end + end + + context 'when feature flag `release_edit_page` is disabled' do + before do + stub_feature_flags(release_edit_page: false) + end + + it_behaves_like 'not found' + end + + context 'when release does not exist' do + let!(:release) { } + let(:tag) { 'non-existent-tag' } + + it_behaves_like 'not found' + end + + context 'when user is a reporter' do + let(:user) { reporter } + + it_behaves_like 'not found' end end private def get_index - get :index, params: { namespace_id: project.namespace, project_id: project } + get :index, params: { namespace_id: project.namespace, project_id: project, format: format } end end diff --git a/spec/controllers/projects/serverless/functions_controller_spec.rb b/spec/controllers/projects/serverless/functions_controller_spec.rb index eccc8e1d5de7ea9c9b617f34825686cc247c1fd3..73fb0fad646a15f975af8d654909386c17a3addd 100644 --- a/spec/controllers/projects/serverless/functions_controller_spec.rb +++ b/spec/controllers/projects/serverless/functions_controller_spec.rb @@ -13,6 +13,10 @@ describe Projects::Serverless::FunctionsController do let(:environment) { create(:environment, project: project) } let!(:deployment) { create(:deployment, :success, environment: environment, cluster: cluster) } let(:knative_services_finder) { environment.knative_services_finder } + let(:function_description) { 'A serverless function' } + let(:knative_stub_options) do + { namespace: namespace.namespace, name: cluster.project.name, description: function_description } + end let(:namespace) do create(:cluster_kubernetes_namespace, @@ -114,40 +118,33 @@ describe Projects::Serverless::FunctionsController do expect(response).to have_gitlab_http_status(200) expect(json_response).to include( - "name" => project.name, - "url" => "http://#{project.name}.#{namespace.namespace}.example.com", - "podcount" => 1 + 'name' => project.name, + 'url' => "http://#{project.name}.#{namespace.namespace}.example.com", + 'description' => function_description, + 'podcount' => 1 ) end end - context 'on Knative 0.5' do + context 'on Knative 0.5.0' do + before do + prepare_knative_stubs(knative_05_service(knative_stub_options)) + end + + include_examples 'GET #show with valid data' + end + + context 'on Knative 0.6.0' do before do - stub_kubeclient_service_pods - stub_reactive_cache(knative_services_finder, - { - services: kube_knative_services_body( - legacy_knative: true, - namespace: namespace.namespace, - name: cluster.project.name - )["items"], - pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"] - }, - *knative_services_finder.cache_args) + prepare_knative_stubs(knative_06_service(knative_stub_options)) end include_examples 'GET #show with valid data' end - context 'on Knative 0.6 or 0.7' do + context 'on Knative 0.7.0' do before do - stub_kubeclient_service_pods - stub_reactive_cache(knative_services_finder, - { - services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"], - pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"] - }, - *knative_services_finder.cache_args) + prepare_knative_stubs(knative_07_service(knative_stub_options)) end include_examples 'GET #show with valid data' @@ -172,11 +169,12 @@ describe Projects::Serverless::FunctionsController do expect(response).to have_gitlab_http_status(200) expect(json_response).to match({ - "knative_installed" => "checking", - "functions" => [ + 'knative_installed' => 'checking', + 'functions' => [ a_hash_including( - "name" => project.name, - "url" => "http://#{project.name}.#{namespace.namespace}.example.com" + 'name' => project.name, + 'url' => "http://#{project.name}.#{namespace.namespace}.example.com", + 'description' => function_description ) ] }) @@ -189,36 +187,38 @@ describe Projects::Serverless::FunctionsController do end end - context 'on Knative 0.5' do + context 'on Knative 0.5.0' do before do - stub_kubeclient_service_pods - stub_reactive_cache(knative_services_finder, - { - services: kube_knative_services_body( - legacy_knative: true, - namespace: namespace.namespace, - name: cluster.project.name - )["items"], - pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"] - }, - *knative_services_finder.cache_args) + prepare_knative_stubs(knative_05_service(knative_stub_options)) end include_examples 'GET #index with data' end - context 'on Knative 0.6 or 0.7' do + context 'on Knative 0.6.0' do before do - stub_kubeclient_service_pods - stub_reactive_cache(knative_services_finder, - { - services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"], - pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"] - }, - *knative_services_finder.cache_args) + prepare_knative_stubs(knative_06_service(knative_stub_options)) end include_examples 'GET #index with data' end + + context 'on Knative 0.7.0' do + before do + prepare_knative_stubs(knative_07_service(knative_stub_options)) + end + + include_examples 'GET #index with data' + end + end + + def prepare_knative_stubs(knative_service) + stub_kubeclient_service_pods + stub_reactive_cache(knative_services_finder, + { + services: [knative_service], + pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"] + }, + *knative_services_finder.cache_args) end end diff --git a/spec/controllers/projects/settings/ci_cd_controller_spec.rb b/spec/controllers/projects/settings/ci_cd_controller_spec.rb index c67e7f7dadd7825bff23fa15c82f08f3de2aafdd..98f8826397f8a747c14c7818aae7e4f25a526910 100644 --- a/spec/controllers/projects/settings/ci_cd_controller_spec.rb +++ b/spec/controllers/projects/settings/ci_cd_controller_spec.rb @@ -125,7 +125,9 @@ describe Projects::Settings::CiCdController do context 'when run_auto_devops_pipeline is true' do before do - expect_any_instance_of(Projects::UpdateService).to receive(:run_auto_devops_pipeline?).and_return(true) + expect_next_instance_of(Projects::UpdateService) do |instance| + expect(instance).to receive(:run_auto_devops_pipeline?).and_return(true) + end end context 'when the project repository is empty' do @@ -159,7 +161,9 @@ describe Projects::Settings::CiCdController do context 'when run_auto_devops_pipeline is not true' do before do - expect_any_instance_of(Projects::UpdateService).to receive(:run_auto_devops_pipeline?).and_return(false) + expect_next_instance_of(Projects::UpdateService) do |instance| + expect(instance).to receive(:run_auto_devops_pipeline?).and_return(false) + end end it 'does not queue a CreatePipelineWorker' do diff --git a/spec/controllers/projects/settings/operations_controller_spec.rb b/spec/controllers/projects/settings/operations_controller_spec.rb index 0b34656e9e2e2badecca4279d20a098af2425daa..667a6336952470c5964074d5748cc9414f008dbd 100644 --- a/spec/controllers/projects/settings/operations_controller_spec.rb +++ b/spec/controllers/projects/settings/operations_controller_spec.rb @@ -186,7 +186,8 @@ describe Projects::Settings::OperationsController do { grafana_integration_attributes: { grafana_url: 'https://grafana.gitlab.com', - token: 'eyJrIjoicDRlRTREdjhhOEZ5WjZPWXUzazJOSW0zZHJUejVOd3IiLCJuIjoiVGVzdCBLZXkiLCJpZCI6MX0=' + token: 'eyJrIjoicDRlRTREdjhhOEZ5WjZPWXUzazJOSW0zZHJUejVOd3IiLCJuIjoiVGVzdCBLZXkiLCJpZCI6MX0=', + enabled: 'true' } } end diff --git a/spec/controllers/projects/snippets_controller_spec.rb b/spec/controllers/projects/snippets_controller_spec.rb index 042a5542786044ba47c13a2a492a0bd40e76e24b..d372a94db56c092ad15447fa1e3afdca4327915b 100644 --- a/spec/controllers/projects/snippets_controller_spec.rb +++ b/spec/controllers/projects/snippets_controller_spec.rb @@ -92,7 +92,9 @@ describe Projects::SnippetsController do context 'when the snippet is spam' do before do - allow_any_instance_of(AkismetService).to receive(:spam?).and_return(true) + allow_next_instance_of(AkismetService) do |instance| + allow(instance).to receive(:spam?).and_return(true) + end end context 'when the snippet is private' do @@ -170,7 +172,9 @@ describe Projects::SnippetsController do context 'when the snippet is spam' do before do - allow_any_instance_of(AkismetService).to receive(:spam?).and_return(true) + allow_next_instance_of(AkismetService) do |instance| + allow(instance).to receive(:spam?).and_return(true) + end end context 'when the snippet is private' do @@ -278,7 +282,9 @@ describe Projects::SnippetsController do let(:snippet) { create(:project_snippet, :private, project: project, author: user) } before do - allow_any_instance_of(AkismetService).to receive_messages(submit_spam: true) + allow_next_instance_of(AkismetService) do |instance| + allow(instance).to receive_messages(submit_spam: true) + end stub_application_setting(akismet_enabled: true) end diff --git a/spec/controllers/projects/tree_controller_spec.rb b/spec/controllers/projects/tree_controller_spec.rb index 7f7cabe3b0cabfc0581f5189be87c33601e903df..c0c11db5dd632938a4799f6c1485c3df6b49a4de 100644 --- a/spec/controllers/projects/tree_controller_spec.rb +++ b/spec/controllers/projects/tree_controller_spec.rb @@ -30,46 +30,61 @@ describe Projects::TreeController do context "valid branch, no path" do let(:id) { 'master' } + it { is_expected.to respond_with(:success) } end context "valid branch, valid path" do let(:id) { 'master/encoding/' } + it { is_expected.to respond_with(:success) } end context "valid branch, invalid path" do let(:id) { 'master/invalid-path/' } - it { is_expected.to respond_with(:not_found) } + + it 'redirects' do + expect(subject) + .to redirect_to("/#{project.full_path}/tree/master") + end end context "invalid branch, valid path" do let(:id) { 'invalid-branch/encoding/' } + it { is_expected.to respond_with(:not_found) } end context "valid empty branch, invalid path" do let(:id) { 'empty-branch/invalid-path/' } - it { is_expected.to respond_with(:not_found) } + + it 'redirects' do + expect(subject) + .to redirect_to("/#{project.full_path}/tree/empty-branch") + end end context "valid empty branch" do let(:id) { 'empty-branch' } + it { is_expected.to respond_with(:success) } end context "invalid SHA commit ID" do let(:id) { 'ff39438/.gitignore' } + it { is_expected.to respond_with(:not_found) } end context "valid SHA commit ID" do let(:id) { '6d39438' } + it { is_expected.to respond_with(:success) } end context "valid SHA commit ID with path" do let(:id) { '6d39438/.gitignore' } + it { expect(response).to have_gitlab_http_status(302) } end end @@ -108,6 +123,7 @@ describe Projects::TreeController do context 'redirect to blob' do let(:id) { 'master/README.md' } + it 'redirects' do redirect_url = "/#{project.full_path}/blob/master/README.md" expect(subject) diff --git a/spec/controllers/projects/usage_ping_controller_spec.rb b/spec/controllers/projects/usage_ping_controller_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a9abbff160dcc24755e2e5349d5e073bf4048489 --- /dev/null +++ b/spec/controllers/projects/usage_ping_controller_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Projects::UsagePingController do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + + describe 'POST #web_ide_clientside_preview' do + subject { post :web_ide_clientside_preview, params: { namespace_id: project.namespace, project_id: project } } + + before do + sign_in(user) if user + end + + context 'when web ide clientside preview is enabled' do + before do + stub_application_setting(web_ide_clientside_preview_enabled: true) + end + + context 'when the user is not authenticated' do + let(:user) { nil } + + it 'returns 302' do + subject + + expect(response).to have_gitlab_http_status(302) + end + end + + context 'when the user does not have access to the project' do + it 'returns 404' do + subject + + expect(response).to have_gitlab_http_status(404) + end + end + + context 'when the user has access to the project' do + let(:user) { project.owner } + + it 'increments the counter' do + expect do + subject + end.to change { Gitlab::UsageDataCounters::WebIdeCounter.total_previews_count }.by(1) + end + end + end + + context 'when web ide clientside preview is not enabled' do + let(:user) { project.owner } + + before do + stub_application_setting(web_ide_clientside_preview_enabled: false) + end + + it 'returns 404' do + subject + + expect(response).to have_gitlab_http_status(404) + end + end + end +end diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index e0df9556eb8bfe645212d0b76c6511a02d715a9e..ff0259cd40d87ba03cf8a8367438fe57019372f3 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -653,7 +653,7 @@ describe ProjectsController do describe "#destroy" do let(:admin) { create(:admin) } - it "redirects to the dashboard" do + it "redirects to the dashboard", :sidekiq_might_not_need_inline do controller.instance_variable_set(:@project, project) sign_in(admin) @@ -674,7 +674,7 @@ describe ProjectsController do target_project: project) end - it "closes all related merge requests" do + it "closes all related merge requests", :sidekiq_might_not_need_inline do project.merge_requests << merge_request sign_in(admin) @@ -927,6 +927,30 @@ describe ProjectsController do expect(json_response['body']).to match(/\!#{merge_request.iid} \(closed\)/) end end + + context 'when path parameter is provided' do + let(:project_with_repo) { create(:project, :repository) } + let(:preview_markdown_params) do + { + namespace_id: project_with_repo.namespace, + id: project_with_repo, + text: "\n", + path: 'files/images/README.md' + } + end + + before do + project_with_repo.add_maintainer(user) + end + + it 'renders JSON body with image links expanded' do + expanded_path = "/#{project_with_repo.full_path}/raw/master/files/images/logo-white.png" + + post :preview_markdown, params: preview_markdown_params + + expect(json_response['body']).to include(expanded_path) + end + end end describe '#ensure_canonical_path' do diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb index ebeed94c274e4a1c99b14433c42eee0b13c59322..c5cfdd32619224c91c3f7e5b72a81bbee4ca9f53 100644 --- a/spec/controllers/registrations_controller_spec.rb +++ b/spec/controllers/registrations_controller_spec.rb @@ -9,6 +9,51 @@ describe RegistrationsController do stub_feature_flags(invisible_captcha: false) end + describe '#new' do + subject { get :new } + + 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 + + it 'tracks the event with the right parameters' do + expect(Gitlab::Tracking).to receive(:event).with( + 'Growth::Acquisition::Experiment::SignUpFlow', + 'start', + label: anything, + property: 'experimental_group' + ) + subject + end + + it 'renders new template and sets the resource variable' do + expect(subject).to render_template(:new) + expect(response).to have_gitlab_http_status(200) + expect(assigns(:resource)).to be_a(User) + end + end + + context 'with the experimental signup flow enabled and the user is part of the control group' do + before do + stub_experiment(signup_flow: true) + stub_experiment_for_user(signup_flow: false) + end + + it 'does not track the event' do + expect(Gitlab::Tracking).not_to receive(:event) + subject + end + + it 'renders new template and sets the resource variable' do + subject + expect(response).to have_gitlab_http_status(302) + expect(response).to redirect_to(new_user_session_path(anchor: 'register-pane')) + end + end + end + describe '#create' do let(:base_user_params) { { name: 'new_user', username: 'new_username', email: 'new@user.com', password: 'Any_password' } } let(:user_params) { { user: base_user_params } } @@ -217,6 +262,37 @@ describe RegistrationsController do end end + describe 'tracking data' do + context 'with the experimental signup flow enabled and the user is part of the control group' do + before do + stub_experiment(signup_flow: true) + stub_experiment_for_user(signup_flow: false) + end + + it 'tracks the event with the right parameters' do + expect(Gitlab::Tracking).to receive(:event).with( + 'Growth::Acquisition::Experiment::SignUpFlow', + 'end', + label: anything, + property: 'control_group' + ) + post :create, params: user_params + end + 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 + + it 'does not track the event' do + expect(Gitlab::Tracking).not_to receive(:event) + post :create, params: user_params + end + end + end + it "logs a 'User Created' message" do stub_feature_flags(registrations_recaptcha: false) @@ -304,4 +380,22 @@ describe RegistrationsController do end end end + + describe '#update_registration' do + before do + stub_experiment(signup_flow: true) + stub_experiment_for_user(signup_flow: true) + sign_in(create(:user)) + end + + it 'tracks the event with the right parameters' do + expect(Gitlab::Tracking).to receive(:event).with( + 'Growth::Acquisition::Experiment::SignUpFlow', + 'end', + label: anything, + property: 'experimental_group' + ) + patch :update_registration, params: { user: { name: 'New name', role: 'software_developer', setup_for_company: 'false' } } + end + end end diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb index 2108cf1c8ae0157e7b6433c39a517d1194587aeb..1e47df150b46e64018e4f5bcfeec165b33aab7ab 100644 --- a/spec/controllers/sessions_controller_spec.rb +++ b/spec/controllers/sessions_controller_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' describe SessionsController do include DeviseHelpers + include LdapHelpers describe '#new' do before do @@ -34,6 +35,63 @@ describe SessionsController do end end end + + context 'with LDAP enabled' do + before do + stub_ldap_setting(enabled: true) + end + + it 'assigns ldap_servers' do + get(:new) + + expect(assigns[:ldap_servers].first.to_h).to include('label' => 'ldap', 'provider_name' => 'ldapmain') + end + + context 'with sign_in disabled' do + before do + stub_ldap_setting(prevent_ldap_sign_in: true) + end + + it 'assigns no ldap_servers' do + get(:new) + + expect(assigns[:ldap_servers]).to eq [] + end + end + end + + describe 'tracking data' do + context 'when the user is part of the experimental group' do + before do + stub_experiment_for_user(signup_flow: true) + end + + it 'doesn\'t pass tracking parameters to the frontend' do + get(:new) + expect(Gon.tracking_data).to be_nil + end + end + + context 'with the experimental signup flow enabled and the user is part of the control group' do + before do + stub_experiment(signup_flow: true) + stub_experiment_for_user(signup_flow: false) + allow_any_instance_of(described_class).to receive(:experimentation_subject_id).and_return('uuid') + end + + it 'passes the right tracking parameters to the frontend' do + get(:new) + expect(Gon.tracking_data).to eq( + { + category: 'Growth::Acquisition::Experiment::SignUpFlow', + action: 'start', + label: 'uuid', + property: 'control_group' + } + ) + end + end + end end describe '#create' do diff --git a/spec/controllers/snippets_controller_spec.rb b/spec/controllers/snippets_controller_spec.rb index e892c736c69f6698775975b7ec7d3b88e150faf6..054d448c28deef929dba606f7a9f6c519e7de4ad 100644 --- a/spec/controllers/snippets_controller_spec.rb +++ b/spec/controllers/snippets_controller_spec.rb @@ -251,7 +251,9 @@ describe SnippetsController do context 'when the snippet is spam' do before do - allow_any_instance_of(AkismetService).to receive(:spam?).and_return(true) + allow_next_instance_of(AkismetService) do |instance| + allow(instance).to receive(:spam?).and_return(true) + end end context 'when the snippet is private' do @@ -323,7 +325,9 @@ describe SnippetsController do context 'when the snippet is spam' do before do - allow_any_instance_of(AkismetService).to receive(:spam?).and_return(true) + allow_next_instance_of(AkismetService) do |instance| + allow(instance).to receive(:spam?).and_return(true) + end end context 'when the snippet is private' do @@ -431,7 +435,9 @@ describe SnippetsController do let(:snippet) { create(:personal_snippet, :public, author: user) } before do - allow_any_instance_of(AkismetService).to receive_messages(submit_spam: true) + allow_next_instance_of(AkismetService) do |instance| + allow(instance).to receive_messages(submit_spam: true) + end stub_application_setting(akismet_enabled: true) end diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 5566df0c21670647ac4b9433abd89832c2fc2461..bbbb9691f53466b7a4b253f9b82164234d50938e 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -174,7 +174,9 @@ describe UsersController do let(:user) { create(:user) } before do - allow_any_instance_of(User).to receive(:contributed_projects_ids).and_return([project.id]) + allow_next_instance_of(User) do |instance| + allow(instance).to receive(:contributed_projects_ids).and_return([project.id]) + end sign_in(user) project.add_developer(user) @@ -348,6 +350,48 @@ describe UsersController do end end + describe 'GET #suggests' do + context 'when user exists' do + it 'returns JSON indicating the user exists and a suggestion' do + get :suggests, params: { username: user.username } + + expected_json = { exists: true, suggests: ["#{user.username}1"] }.to_json + expect(response.body).to eq(expected_json) + end + + context 'when the casing is different' do + let(:user) { create(:user, username: 'CamelCaseUser') } + + it 'returns JSON indicating the user exists and a suggestion' do + get :suggests, params: { username: user.username.downcase } + + expected_json = { exists: true, suggests: ["#{user.username.downcase}1"] }.to_json + expect(response.body).to eq(expected_json) + end + end + end + + context 'when the user does not exist' do + it 'returns JSON indicating the user does not exist' do + get :suggests, params: { username: 'foo' } + + expected_json = { exists: false, suggests: [] }.to_json + expect(response.body).to eq(expected_json) + end + + context 'when a user changed their username' do + let(:redirect_route) { user.namespace.redirect_routes.create(path: 'old-username') } + + it 'returns JSON indicating a user by that username does not exist' do + get :suggests, params: { username: 'old-username' } + + expected_json = { exists: false, suggests: [] }.to_json + expect(response.body).to eq(expected_json) + end + end + end + end + describe '#ensure_canonical_path' do before do sign_in(user) diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb index 53f4a2610927f880030f5a57b24a030a41097cb2..e8b3086880166258dd47c52e591a1eafd1362eb8 100644 --- a/spec/db/schema_spec.rb +++ b/spec/db/schema_spec.rb @@ -13,7 +13,7 @@ describe 'Database schema' do # EE: edit the ee/spec/db/schema_support.rb IGNORED_FK_COLUMNS = { abuse_reports: %w[reporter_id user_id], - application_settings: %w[performance_bar_allowed_group_id slack_app_id snowplow_site_id], + application_settings: %w[performance_bar_allowed_group_id slack_app_id snowplow_app_id eks_account_id eks_access_key_id], approvers: %w[target_id user_id], approvals: %w[user_id], approver_groups: %w[target_id], @@ -120,9 +120,55 @@ describe 'Database schema' do end end + # These pre-existing enums have limits > 2 bytes + IGNORED_LIMIT_ENUMS = { + 'Analytics::CycleAnalytics::GroupStage' => %w[start_event_identifier end_event_identifier], + 'Analytics::CycleAnalytics::ProjectStage' => %w[start_event_identifier end_event_identifier], + 'Ci::Bridge' => %w[failure_reason], + 'Ci::Build' => %w[failure_reason], + 'Ci::BuildMetadata' => %w[timeout_source], + 'Ci::BuildTraceChunk' => %w[data_store], + 'Ci::JobArtifact' => %w[file_type], + 'Ci::Pipeline' => %w[source config_source failure_reason], + 'Ci::Runner' => %w[access_level], + 'Ci::Stage' => %w[status], + 'Clusters::Applications::Ingress' => %w[ingress_type], + 'Clusters::Cluster' => %w[platform_type provider_type], + 'CommitStatus' => %w[failure_reason], + 'GenericCommitStatus' => %w[failure_reason], + 'Gitlab::DatabaseImporters::CommonMetrics::PrometheusMetric' => %w[group], + 'InternalId' => %w[usage], + 'List' => %w[list_type], + 'NotificationSetting' => %w[level], + 'Project' => %w[auto_cancel_pending_pipelines], + 'ProjectAutoDevops' => %w[deploy_strategy], + 'PrometheusMetric' => %w[group], + 'ResourceLabelEvent' => %w[action], + 'User' => %w[layout dashboard project_view], + 'UserCallout' => %w[feature_name], + 'PrometheusAlert' => %w[operator] + }.freeze + + context 'for enums' do + ApplicationRecord.descendants.each do |model| + describe model do + let(:ignored_enums) { ignored_limit_enums(model.name) } + let(:enums) { model.defined_enums.keys - ignored_enums } + + it 'uses smallint for enums' do + expect(model).to use_smallint_for_enums(enums) + end + end + end + end + private def ignored_fk_columns(column) IGNORED_FK_COLUMNS.fetch(column, []) end + + def ignored_limit_enums(model) + IGNORED_LIMIT_ENUMS.fetch(model, []) + end end diff --git a/spec/dependencies/omniauth_saml_spec.rb b/spec/dependencies/omniauth_saml_spec.rb index 8a685648c71596a25f5df679ad299eb65f96486d..e0ea9c38e697eca0beb9fd0b5cf758f6c6297b57 100644 --- a/spec/dependencies/omniauth_saml_spec.rb +++ b/spec/dependencies/omniauth_saml_spec.rb @@ -14,7 +14,9 @@ describe 'processing of SAMLResponse in dependencies' do before do allow(saml_strategy).to receive(:session).and_return(session_mock) - allow_any_instance_of(OneLogin::RubySaml::Response).to receive(:is_valid?).and_return(true) + allow_next_instance_of(OneLogin::RubySaml::Response) do |instance| + allow(instance).to receive(:is_valid?).and_return(true) + end saml_strategy.send(:handle_response, mock_saml_response, {}, settings ) { } end diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb index fefd89728e67d93d69b2083d2179708330cd7437..e2ec9d496bc42b8522b9ff9a31d0454f5ddbaa44 100644 --- a/spec/factories/ci/pipelines.rb +++ b/spec/factories/ci/pipelines.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true FactoryBot.define do + # TODO: we can remove this factory in favour of :ci_pipeline factory :ci_empty_pipeline, class: Ci::Pipeline do source { :push } ref { 'master' } @@ -10,20 +11,6 @@ FactoryBot.define do project - factory :ci_pipeline_without_jobs do - after(:build) do |pipeline| - pipeline.instance_variable_set(:@ci_yaml_file, YAML.dump({})) - end - end - - factory :ci_pipeline_with_one_job do - after(:build) do |pipeline| - allow(pipeline).to receive(:ci_yaml_file) do - pipeline.instance_variable_set(:@ci_yaml_file, YAML.dump({ rspec: { script: "ls" } })) - end - end - end - # Persist merge request head_pipeline_id # on pipeline factories to avoid circular references transient { head_pipeline_of { nil } } @@ -34,24 +21,8 @@ FactoryBot.define do end factory :ci_pipeline do - transient { config { nil } } - - after(:build) do |pipeline, evaluator| - if evaluator.config - pipeline.instance_variable_set(:@ci_yaml_file, YAML.dump(evaluator.config)) - - # Populates pipeline with errors - pipeline.config_processor if evaluator.config - else - pipeline.instance_variable_set(:@ci_yaml_file, File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))) - end - end - trait :invalid do - config do - { rspec: nil } - end - + yaml_errors { 'invalid YAML' } failure_reason { :config_error } end @@ -95,6 +66,17 @@ FactoryBot.define do end end + trait :with_exposed_artifacts do + status { :success } + + after(:build) do |pipeline, evaluator| + pipeline.builds << build(:ci_build, :artifacts, + pipeline: pipeline, + project: pipeline.project, + options: { artifacts: { expose_as: 'the artifact', paths: ['ci_artifacts.txt'] } }) + end + end + trait :with_job do after(:build) do |pipeline, evaluator| pipeline.builds << build(:ci_build, pipeline: pipeline, project: pipeline.project) diff --git a/spec/factories/clusters/applications/helm.rb b/spec/factories/clusters/applications/helm.rb index c7ec7c11743d8127837398d04f973e182ebb54d7..0e59f8cb9ec51bb13b574b77ba2f0c20393454af 100644 --- a/spec/factories/clusters/applications/helm.rb +++ b/spec/factories/clusters/applications/helm.rb @@ -79,6 +79,15 @@ FactoryBot.define do cluster factory: %i(cluster with_installed_helm provided_by_gcp) end + 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 + stack { 'gcp' } + cluster factory: %i(cluster with_installed_helm provided_by_gcp) + end + factory :clusters_applications_prometheus, class: Clusters::Applications::Prometheus do cluster factory: %i(cluster with_installed_helm provided_by_gcp) end diff --git a/spec/factories/clusters/clusters.rb b/spec/factories/clusters/clusters.rb index 63f33633a3ce5c68a1eac5a0a1736937518e7807..609e7e201871d53269551447c6fa039e5794cdb5 100644 --- a/spec/factories/clusters/clusters.rb +++ b/spec/factories/clusters/clusters.rb @@ -93,5 +93,25 @@ FactoryBot.define do trait :not_managed do managed { false } end + + trait :cleanup_not_started do + cleanup_status { 1 } + end + + trait :cleanup_uninstalling_applications do + cleanup_status { 2 } + end + + trait :cleanup_removing_project_namespaces do + cleanup_status { 3 } + end + + trait :cleanup_removing_service_account do + cleanup_status { 4 } + end + + trait :cleanup_errored do + cleanup_status { 5 } + end end end diff --git a/spec/factories/clusters/platforms/kubernetes.rb b/spec/factories/clusters/platforms/kubernetes.rb index 2757498e36b7221b97466a71c63322dd6acbb235..dbcb838e9dacaa0d6924456dea72ca05f669101d 100644 --- a/spec/factories/clusters/platforms/kubernetes.rb +++ b/spec/factories/clusters/platforms/kubernetes.rb @@ -2,7 +2,7 @@ FactoryBot.define do factory :cluster_platform_kubernetes, class: Clusters::Platforms::Kubernetes do - cluster + association :cluster, platform_type: :kubernetes, provider_type: :user namespace { nil } api_url { 'https://kubernetes.example.com' } token { 'a' * 40 } diff --git a/spec/factories/clusters/providers/aws.rb b/spec/factories/clusters/providers/aws.rb index f4bc61455c501b1f33203960d1f367237de521c3..e4b10aa5f33b3ac63996685e0ba5afa97d7cdacd 100644 --- a/spec/factories/clusters/providers/aws.rb +++ b/spec/factories/clusters/providers/aws.rb @@ -2,8 +2,7 @@ FactoryBot.define do factory :cluster_provider_aws, class: Clusters::Providers::Aws do - cluster - created_by_user factory: :user + association :cluster, platform_type: :kubernetes, provider_type: :aws role_arn { 'arn:aws:iam::123456789012:role/role-name' } vpc_id { 'vpc-00000000000000000' } diff --git a/spec/factories/clusters/providers/gcp.rb b/spec/factories/clusters/providers/gcp.rb index 83b65dc808700d8a945b12c6ae74208cbb7be2da..216c4d4fa31164e0a4c5be4f18e623bdf31decb9 100644 --- a/spec/factories/clusters/providers/gcp.rb +++ b/spec/factories/clusters/providers/gcp.rb @@ -2,7 +2,7 @@ FactoryBot.define do factory :cluster_provider_gcp, class: Clusters::Providers::Gcp do - cluster + association :cluster, platform_type: :kubernetes, provider_type: :gcp gcp_project_id { 'test-gcp-project' } trait :scheduled do diff --git a/spec/factories/commit_statuses.rb b/spec/factories/commit_statuses.rb index 3ce71a1b05deb62cee1d44d7d2efb41e03c9e7e2..5d635d93ff2e786e15f75dc171c4b2fc232a0093 100644 --- a/spec/factories/commit_statuses.rb +++ b/spec/factories/commit_statuses.rb @@ -7,7 +7,7 @@ FactoryBot.define do stage_idx { 0 } status { 'success' } description { 'commit status'} - pipeline factory: :ci_pipeline_with_one_job + pipeline factory: :ci_pipeline started_at { 'Tue, 26 Jan 2016 08:21:42 +0100'} finished_at { 'Tue, 26 Jan 2016 08:23:42 +0100'} diff --git a/spec/factories/deployments.rb b/spec/factories/deployments.rb index f4da206990c3cfb0052dca420708386453db2de8..f8738d28d833473c93e8bb7ccd5ee3f958832709 100644 --- a/spec/factories/deployments.rb +++ b/spec/factories/deployments.rb @@ -51,6 +51,10 @@ FactoryBot.define do finished_at { Time.now } end + trait :created do + status { :created } + end + # This trait hooks the state maechine's events trait :succeed do after(:create) do |deployment, evaluator| diff --git a/spec/factories/error_tracking/detailed_error.rb b/spec/factories/error_tracking/detailed_error.rb new file mode 100644 index 0000000000000000000000000000000000000000..cf7de2ece964b04704fa6e0c3cbfebfb7faf03b8 --- /dev/null +++ b/spec/factories/error_tracking/detailed_error.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :detailed_error_tracking_error, class: Gitlab::ErrorTracking::DetailedError do + id { 'id' } + title { 'title' } + type { 'error' } + user_count { 1 } + count { 2 } + first_seen { Time.now } + last_seen { Time.now } + message { 'message' } + culprit { 'culprit' } + external_url { 'http://example.com/id' } + external_base_url { 'http://example.com' } + project_id { 'project1' } + project_name { 'project name' } + project_slug { 'project_name' } + short_id { 'ID' } + status { 'unresolved' } + frequency { [] } + first_release_last_commit { '68c914da9' } + last_release_last_commit { '9ad419c86' } + first_release_short_version { 'abc123' } + last_release_short_version { 'abc123' } + + skip_create + end +end diff --git a/spec/factories/error_tracking/error_event.rb b/spec/factories/error_tracking/error_event.rb new file mode 100644 index 0000000000000000000000000000000000000000..44c127e7bf57cff1b3a7d8ac6eb0ea4ef015953f --- /dev/null +++ b/spec/factories/error_tracking/error_event.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :error_tracking_error_event, class: Gitlab::ErrorTracking::ErrorEvent do + issue_id { 'id' } + date_received { Time.now.iso8601 } + stack_trace_entries do + { + 'stacktrace' => + { + 'frames' => [{ 'file' => 'test.rb' }] + } + } + end + + skip_create + end +end diff --git a/spec/factories/grafana_integrations.rb b/spec/factories/grafana_integrations.rb index c19417f5a900b8ca2132af487d1d5bd9c3b77468..ae819ca828c1d883014b60a0590d6e1fc77e9815 100644 --- a/spec/factories/grafana_integrations.rb +++ b/spec/factories/grafana_integrations.rb @@ -3,7 +3,8 @@ FactoryBot.define do factory :grafana_integration, class: GrafanaIntegration do project - grafana_url { 'https://grafana.com' } + grafana_url { 'https://grafana.example.com' } token { SecureRandom.hex(10) } + enabled { true } end end diff --git a/spec/factories/group_group_links.rb b/spec/factories/group_group_links.rb new file mode 100644 index 0000000000000000000000000000000000000000..0711a15b8dd53219825c1078749c615ec7aa05b9 --- /dev/null +++ b/spec/factories/group_group_links.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :group_group_link do + shared_group { create(:group) } + shared_with_group { create(:group) } + group_access { GroupMember::DEVELOPER } + end +end diff --git a/spec/factories/issues.rb b/spec/factories/issues.rb index 46910078ee50a710a0d6c6b4824f180c36adbd77..24c12a6659976a4fab1e76a23c632aae2707b968 100644 --- a/spec/factories/issues.rb +++ b/spec/factories/issues.rb @@ -6,6 +6,7 @@ FactoryBot.define do project author { project.creator } updated_by { author } + relative_position { RelativePositioning::START_POSITION } trait :confidential do confidential { true } diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb index d16e0c10671d5db65e45fd5a2f5b6527ff2611e9..42248dc1165e7a31b98ee0a8f059cda529310e6e 100644 --- a/spec/factories/merge_requests.rb +++ b/spec/factories/merge_requests.rb @@ -100,6 +100,7 @@ FactoryBot.define do auto_merge_enabled { true } auto_merge_strategy { AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS } merge_user { author } + merge_params { { sha: diff_head_sha } } end trait :remove_source_branch do @@ -120,6 +121,18 @@ FactoryBot.define do end end + trait :with_exposed_artifacts do + after(:build) do |merge_request| + merge_request.head_pipeline = build( + :ci_pipeline, + :success, + :with_exposed_artifacts, + project: merge_request.source_project, + ref: merge_request.source_branch, + sha: merge_request.diff_head_sha) + end + end + trait :with_legacy_detached_merge_request_pipeline do after(:create) do |merge_request| merge_request.pipelines_for_merge_request << create(:ci_pipeline, diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 9477eeb18d4e697a21e393aba7c0b1c56b566bd8..2608f717f1cc00153c691d9e0aa93e3778a6ac1d 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -234,10 +234,7 @@ FactoryBot.define do trait :broken_repo do after(:create) do |project| - raise "Failed to create repository!" unless project.create_repository - - project.gitlab_shell.rm_directory(project.repository_storage, - File.join("#{project.disk_path}.git", 'refs')) + TestEnv.rm_storage_dir(project.repository_storage, "#{project.disk_path}.git/refs") end end diff --git a/spec/factories/zoom_meetings.rb b/spec/factories/zoom_meetings.rb new file mode 100644 index 0000000000000000000000000000000000000000..b280deca012eb73130fb06724018a002cd4af022 --- /dev/null +++ b/spec/factories/zoom_meetings.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :zoom_meeting do + project { issue.project } + issue + url { 'https://zoom.us/j/123456789' } + issue_status { :added } + + trait :added_to_issue do + issue_status { :added } + end + + trait :removed_from_issue do + issue_status { :removed } + end + end +end diff --git a/spec/features/admin/admin_abuse_reports_spec.rb b/spec/features/admin/admin_abuse_reports_spec.rb index 48fff9e57d3e2d1971ee739e5f18bedd2c26f134..93051a8a355b87a6958dcf4171f2940aca4cc0aa 100644 --- a/spec/features/admin/admin_abuse_reports_spec.rb +++ b/spec/features/admin/admin_abuse_reports_spec.rb @@ -51,5 +51,29 @@ describe "Admin::AbuseReports", :js do end end end + + describe 'filtering by user' do + let!(:user2) { create(:user) } + let!(:abuse_report) { create(:abuse_report, user: user) } + let!(:abuse_report_2) { create(:abuse_report, user: user2) } + + it 'shows only single user report' do + visit admin_abuse_reports_path + + page.within '.filter-form' do + click_button 'User' + wait_for_requests + + page.within '.dropdown-menu-user' do + click_link user2.name + end + + wait_for_requests + end + + expect(page).to have_content(user2.name) + expect(page).not_to have_content(user.name) + end + end end end diff --git a/spec/features/admin/admin_projects_spec.rb b/spec/features/admin/admin_projects_spec.rb index 058e548208fd5d73bb399761c79c64adbc72f5c6..7c40ac5bde364f972208f1937a2d55ab1f8f5e84 100644 --- a/spec/features/admin/admin_projects_spec.rb +++ b/spec/features/admin/admin_projects_spec.rb @@ -73,8 +73,9 @@ describe "Admin::Projects" do before do create(:group, name: 'Web') - allow_any_instance_of(Projects::TransferService) - .to receive(:move_uploads_to_new_namespace).and_return(true) + allow_next_instance_of(Projects::TransferService) do |instance| + allow(instance).to receive(:move_uploads_to_new_namespace).and_return(true) + end end it 'transfers project to group web', :js do diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index e1c9364067a280d9975b76e64eff45d312eba3db..99a6165cfc9b154260d1852aa7a957b4988afe37 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_not_mock_admin_mode do include StubENV include TermsHelper + include MobileHelpers let(:admin) { create(:admin) } @@ -450,6 +451,32 @@ describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_not_moc expect(page).to have_link(text: 'Support', href: new_support_url) end end + + it 'Shows admin dashboard links on bigger screen' do + visit root_dashboard_path + + page.within '.navbar' do + expect(page).to have_link(text: 'Admin Area', href: admin_root_path, visible: true) + expect(page).to have_link(text: 'Leave Admin Mode', href: destroy_admin_session_path, visible: true) + end + end + + it 'Relocates admin dashboard links to dropdown list on smaller screen', :js do + resize_screen_xs + visit root_dashboard_path + + page.within '.navbar' do + expect(page).not_to have_link(text: 'Admin Area', href: admin_root_path, visible: true) + expect(page).not_to have_link(text: 'Leave Admin Mode', href: destroy_admin_session_path, visible: true) + end + + find('.header-more').click + + page.within '.navbar' do + expect(page).to have_link(text: 'Admin Area', href: admin_root_path, visible: true) + expect(page).to have_link(text: 'Leave Admin Mode', href: destroy_admin_session_path, visible: true) + end + end end context 'when in admin_mode' do @@ -462,7 +489,7 @@ describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_not_moc it 'can leave admin mode' do page.within('.navbar-sub-nav') do # Select first, link is also included in mobile view list - click_on 'Leave admin mode', match: :first + click_on 'Leave Admin Mode', match: :first expect(page).to have_link(href: new_admin_session_path) end @@ -481,7 +508,7 @@ describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_not_moc before do page.within('.navbar-sub-nav') do # Select first, link is also included in mobile view list - click_on 'Leave admin mode', match: :first + click_on 'Leave Admin Mode', match: :first end end diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index 29f29e589172540bfb4bcdf6efc05ce89f49c220..0c8cd895c001b7fee731bd6fb2097e8d87b759f1 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -179,7 +179,9 @@ describe "Admin::Users" do end it "calls send mail" do - expect_any_instance_of(NotificationService).to receive(:new_user) + expect_next_instance_of(NotificationService) do |instance| + expect(instance).to receive(:new_user) + end click_button "Create user" end diff --git a/spec/features/admin/clusters/eks_spec.rb b/spec/features/admin/clusters/eks_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b262db1ad7c13a7d5c460c01b86a72fd2cf3bc84 --- /dev/null +++ b/spec/features/admin/clusters/eks_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Instance-level AWS EKS Cluster', :js do + let(:user) { create(:admin) } + + before do + sign_in(user) + end + + context 'when user does not have a cluster and visits group clusters page' do + before do + visit admin_clusters_path + + click_link 'Add Kubernetes cluster' + end + + context 'when user creates a cluster on AWS EKS' do + before do + click_link 'Amazon EKS' + end + + it 'user sees a form to create an EKS cluster' do + expect(page).to have_content('Create new Cluster on EKS') + end + end + end +end diff --git a/spec/features/calendar_spec.rb b/spec/features/calendar_spec.rb index 235b6d0fd4071deaf2a71a6942e849707f7a6d60..bac5c9f568e39b78bf6dd412be16990932df4687 100644 --- a/spec/features/calendar_spec.rb +++ b/spec/features/calendar_spec.rb @@ -134,11 +134,9 @@ describe 'Contributions Calendar', :js do shared_examples 'a day with activity' do |contribution_count:| include_context 'visit user page' - it 'displays calendar activity square color for 1 contribution' do + it 'displays calendar activity square for 1 contribution', :sidekiq_might_not_need_inline do expect(find('#js-overview')).to have_selector(get_cell_color_selector(contribution_count), count: 1) - end - it 'displays calendar activity square on the correct date' do today = Date.today.strftime(date_format) expect(find('#js-overview')).to have_selector(get_cell_date_selector(contribution_count, today), count: 1) end @@ -154,7 +152,7 @@ describe 'Contributions Calendar', :js do describe 'issue title is shown on activity page' do include_context 'visit user page' - it 'displays calendar activity log' do + it 'displays calendar activity log', :sidekiq_might_not_need_inline do expect(find('#js-overview .overview-content-list .event-target-title')).to have_content issue_title end end @@ -186,11 +184,11 @@ describe 'Contributions Calendar', :js do end include_context 'visit user page' - it 'displays calendar activity squares for both days' do + it 'displays calendar activity squares for both days', :sidekiq_might_not_need_inline do expect(find('#js-overview')).to have_selector(get_cell_color_selector(1), count: 2) end - it 'displays calendar activity square for yesterday' do + it 'displays calendar activity square for yesterday', :sidekiq_might_not_need_inline do yesterday = Date.yesterday.strftime(date_format) expect(find('#js-overview')).to have_selector(get_cell_date_selector(1, yesterday), count: 1) end diff --git a/spec/features/clusters/installing_applications_shared_examples.rb b/spec/features/clusters/installing_applications_shared_examples.rb index cb8fd8c607cac7b9cfba2c5510547412c2188339..988cd228c1cc23e1066b302d72480c3270ccd132 100644 --- a/spec/features/clusters/installing_applications_shared_examples.rb +++ b/spec/features/clusters/installing_applications_shared_examples.rb @@ -178,6 +178,37 @@ shared_examples "installing applications on a cluster" do end end + 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' + end + end + + it 'shows status transition' do + page.within('.js-cluster-application-row-elastic_stack') do + expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Installing') + + Clusters::Cluster.last.application_elastic_stack.make_installing! + + expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Installing') + + Clusters::Cluster.last.application_elastic_stack.make_installed! + + expect(page).to have_css('.js-cluster-application-uninstall-button', exact_text: 'Uninstall') + end + + expect(page).to have_content('Elastic Stack was successfully installed on your Kubernetes cluster') + end + end + context 'when user installs Ingress' do before do allow(ClusterInstallAppWorker).to receive(:perform_async) diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index 96d8da845cb3f39ebba76e11b7e5fe751ba42c50..f538df89fd38718126f344489e878b8060261c14 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -102,7 +102,7 @@ describe 'Commits' do end describe 'Cancel all builds' do - it 'cancels commit', :js do + it 'cancels commit', :js, :sidekiq_might_not_need_inline do visit pipeline_path(pipeline) click_on 'Cancel running' expect(page).to have_content 'canceled' @@ -110,7 +110,7 @@ describe 'Commits' do end describe 'Cancel build' do - it 'cancels build', :js do + it 'cancels build', :js, :sidekiq_might_not_need_inline do visit pipeline_path(pipeline) find('.js-btn-cancel-pipeline').click expect(page).to have_content 'canceled' @@ -157,39 +157,6 @@ describe 'Commits' do end end end - - describe '.gitlab-ci.yml not found warning' do - before do - project.add_reporter(user) - end - - context 'ci builds enabled' do - it 'does not show warning' do - visit pipeline_path(pipeline) - - expect(page).not_to have_content '.gitlab-ci.yml not found in this commit' - end - - it 'shows warning' do - stub_ci_pipeline_yaml_file(nil) - - visit pipeline_path(pipeline) - - expect(page).to have_content '.gitlab-ci.yml not found in this commit' - end - end - - context 'ci builds disabled' do - it 'does not show warning' do - stub_ci_builds_disabled - stub_ci_pipeline_yaml_file(nil) - - visit pipeline_path(pipeline) - - expect(page).not_to have_content '.gitlab-ci.yml not found in this commit' - end - end - end end context 'viewing commits for a branch' do diff --git a/spec/features/container_registry_spec.rb b/spec/features/container_registry_spec.rb index 03a2402a2d64eb30ddd5106e66410a005d286de7..28b68e699e8b98e2aede73dd8496cc0c7459bd27 100644 --- a/spec/features/container_registry_spec.rb +++ b/spec/features/container_registry_spec.rb @@ -42,7 +42,7 @@ describe 'Container Registry', :js do expect(page).to have_content('my/image') end - it 'user removes entire container repository' do + it 'user removes entire container repository', :sidekiq_might_not_need_inline do visit_container_registry expect_any_instance_of(ContainerRepository).to receive(:delete_tags!).and_return(true) diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb index 07f0864fb3baa426aa2cc6272433f89a9bfecfe1..0fc4841ee0e261e2f30d190498ee117e76b1d56d 100644 --- a/spec/features/cycle_analytics_spec.rb +++ b/spec/features/cycle_analytics_spec.rb @@ -40,7 +40,9 @@ describe 'Cycle Analytics', :js do context "when there's cycle analytics data" do before do - allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue]) + allow_next_instance_of(Gitlab::ReferenceExtractor) do |instance| + allow(instance).to receive(:issues).and_return([issue]) + end project.add_maintainer(user) @build = create_cycle(user, project, issue, mr, milestone, pipeline) @@ -56,7 +58,7 @@ describe 'Cycle Analytics', :js do expect(deploys_counter).to have_content('1') end - it 'shows data on each stage' do + it 'shows data on each stage', :sidekiq_might_not_need_inline do expect_issue_to_be_present click_stage('Plan') @@ -99,7 +101,9 @@ describe 'Cycle Analytics', :js do project.add_developer(user) project.add_guest(guest) - allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue]) + allow_next_instance_of(Gitlab::ReferenceExtractor) do |instance| + allow(instance).to receive(:issues).and_return([issue]) + end create_cycle(user, project, issue, mr, milestone, pipeline) deploy_master(user, project) diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb index 973d5a2dcfc08d8334b577f24406ef7b942f5171..f10cdf6da1e77210efc0d8d7fbd5501026b2c581 100644 --- a/spec/features/dashboard/projects_spec.rb +++ b/spec/features/dashboard/projects_spec.rb @@ -216,8 +216,7 @@ describe 'Dashboard Projects' do expect(page).to have_selector('.merge-request-form') expect(current_path).to eq project_new_merge_request_path(project) expect(find('#merge_request_target_project_id', visible: false).value).to eq project.id.to_s - expect(find('input#merge_request_source_branch', visible: false).value).to eq 'feature' - expect(find('input#merge_request_target_branch', visible: false).value).to eq 'master' + expect(page).to have_content "From feature into master" end end diff --git a/spec/features/explore/groups_spec.rb b/spec/features/explore/groups_spec.rb index 81c77a29ecd7b79eec3208470913134fc0318379..eff63d6a788dd48dccb4eee3ab79546a2c41409d 100644 --- a/spec/features/explore/groups_spec.rb +++ b/spec/features/explore/groups_spec.rb @@ -26,6 +26,10 @@ describe 'Explore Groups', :js do end end + before do + stub_feature_flags({ vue_issuables_list: { enabled: false, thing: group } }) + end + shared_examples 'renders public and internal projects' do it do visit_page diff --git a/spec/features/global_search_spec.rb b/spec/features/global_search_spec.rb index 00fa85930b111f300ed7c20466027a205f3fc4d0..c499fac6bc0dc444af6ff9c1555e4e624cfad460 100644 --- a/spec/features/global_search_spec.rb +++ b/spec/features/global_search_spec.rb @@ -21,7 +21,9 @@ describe 'Global search' do describe 'I search through the issues and I see pagination' do before do - allow_any_instance_of(Gitlab::SearchResults).to receive(:per_page).and_return(1) + allow_next_instance_of(Gitlab::SearchResults) do |instance| + allow(instance).to receive(:per_page).and_return(1) + end create_list(:issue, 2, project: project, title: 'initial') end diff --git a/spec/features/groups/clusters/eks_spec.rb b/spec/features/groups/clusters/eks_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b6942304c228567bd5c0541a29f5859d512daa69 --- /dev/null +++ b/spec/features/groups/clusters/eks_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Group AWS EKS Cluster', :js do + let(:group) { create(:group) } + let(:user) { create(:user) } + + before do + group.add_maintainer(user) + gitlab_sign_in(user) + + allow(Groups::ClustersController).to receive(:STATUS_POLLING_INTERVAL) { 100 } + allow_any_instance_of(Clusters::Kubernetes::CreateOrUpdateNamespaceService).to receive(:execute) + allow_any_instance_of(Clusters::Cluster).to receive(:retrieve_connection_status).and_return(:connected) + end + + context 'when user does not have a cluster and visits group clusters page' do + before do + visit group_clusters_path(group) + + click_link 'Add Kubernetes cluster' + end + + context 'when user creates a cluster on AWS EKS' do + before do + click_link 'Amazon EKS' + end + + it 'user sees a form to create an EKS cluster' do + expect(page).to have_content('Create new Cluster on EKS') + end + end + end +end diff --git a/spec/features/groups/clusters/user_spec.rb b/spec/features/groups/clusters/user_spec.rb index 8891866c1f8b28b5d9f2bced4311aaee7a4b857e..e06f2efe1837442e53c73c373442c367699b62ee 100644 --- a/spec/features/groups/clusters/user_spec.rb +++ b/spec/features/groups/clusters/user_spec.rb @@ -13,8 +13,12 @@ describe 'User Cluster', :js do gitlab_sign_in(user) allow(Groups::ClustersController).to receive(:STATUS_POLLING_INTERVAL) { 100 } - allow_any_instance_of(Clusters::Kubernetes::CreateOrUpdateNamespaceService).to receive(:execute) - allow_any_instance_of(Clusters::Cluster).to receive(:retrieve_connection_status).and_return(:connected) + allow_next_instance_of(Clusters::Kubernetes::CreateOrUpdateNamespaceService) do |instance| + allow(instance).to receive(:execute) + end + allow_next_instance_of(Clusters::Cluster) do |instance| + allow(instance).to receive(:retrieve_connection_status).and_return(:connected) + end end context 'when user does not have a cluster and visits cluster index page' do diff --git a/spec/features/groups/group_page_with_external_authorization_service_spec.rb b/spec/features/groups/group_page_with_external_authorization_service_spec.rb index c05c3f4f3d6143342eacc6ab0d47f0a84b7b92c0..823c8cc8fad608ff382c44664944d631f469d194 100644 --- a/spec/features/groups/group_page_with_external_authorization_service_spec.rb +++ b/spec/features/groups/group_page_with_external_authorization_service_spec.rb @@ -15,7 +15,7 @@ describe 'The group page' do def expect_all_sidebar_links within('.nav-sidebar') do - expect(page).to have_link('Overview') + expect(page).to have_link('Group overview') expect(page).to have_link('Details') expect(page).to have_link('Activity') expect(page).to have_link('Issues') @@ -44,7 +44,7 @@ describe 'The group page' do visit group_path(group) within('.nav-sidebar') do - expect(page).to have_link('Overview') + expect(page).to have_link('Group overview') expect(page).to have_link('Details') expect(page).not_to have_link('Activity') expect(page).not_to have_link('Contribution Analytics') diff --git a/spec/features/groups/issues_spec.rb b/spec/features/groups/issues_spec.rb index 5d87c9d7be87bf3a9f4c77e3546197b1702caa69..b9b233026fd2a89cd26e33b0889911920ad50b55 100644 --- a/spec/features/groups/issues_spec.rb +++ b/spec/features/groups/issues_spec.rb @@ -11,6 +11,10 @@ describe 'Group issues page' do let(:project_with_issues_disabled) { create(:project, :issues_disabled, group: group) } let(:path) { issues_group_path(group) } + before do + stub_feature_flags({ vue_issuables_list: { enabled: false, thing: group } }) + end + context 'with shared examples' do let(:issuable) { create(:issue, project: project, title: "this is my created issuable")} diff --git a/spec/features/groups/milestone_spec.rb b/spec/features/groups/milestone_spec.rb index 17738905e8dce22de135d3decd547fe4a8c26a97..65ef0af5be3e690bcda10ffc611bd3f78fa9ddcf 100644 --- a/spec/features/groups/milestone_spec.rb +++ b/spec/features/groups/milestone_spec.rb @@ -3,9 +3,9 @@ require 'spec_helper' describe 'Group milestones' do - let(:group) { create(:group) } - let!(:project) { create(:project_empty_repo, group: group) } - let(:user) { create(:group_member, :maintainer, user: create(:user), group: group ).user } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project_empty_repo, group: group) } + let_it_be(:user) { create(:group_member, :maintainer, user: create(:user), group: group ).user } around do |example| Timecop.freeze { example.run } @@ -71,9 +71,9 @@ describe 'Group milestones' do end context 'when milestones exists' do - let!(:other_project) { create(:project_empty_repo, group: group) } + let_it_be(:other_project) { create(:project_empty_repo, group: group) } - let!(:active_project_milestone1) do + let_it_be(:active_project_milestone1) do create( :milestone, project: project, @@ -83,12 +83,12 @@ describe 'Group milestones' do description: 'Lorem Ipsum is simply dummy text' ) end - let!(:active_project_milestone2) { create(:milestone, project: other_project, state: 'active', title: 'v1.1') } - let!(:closed_project_milestone1) { create(:milestone, project: project, state: 'closed', title: 'v2.0') } - let!(:closed_project_milestone2) { create(:milestone, project: other_project, state: 'closed', title: 'v2.0') } - let!(:active_group_milestone) { create(:milestone, group: group, state: 'active', title: 'GL-113') } - let!(:closed_group_milestone) { create(:milestone, group: group, state: 'closed') } - let!(:issue) do + let_it_be(:active_project_milestone2) { create(:milestone, project: other_project, state: 'active', title: 'v1.1') } + let_it_be(:closed_project_milestone1) { create(:milestone, project: project, state: 'closed', title: 'v2.0') } + let_it_be(:closed_project_milestone2) { create(:milestone, project: other_project, state: 'closed', title: 'v2.0') } + let_it_be(:active_group_milestone) { create(:milestone, group: group, state: 'active', title: 'GL-113') } + let_it_be(:closed_group_milestone) { create(:milestone, group: group, state: 'closed') } + let_it_be(:issue) do create :issue, project: project, assignees: [user], author: user, milestone: active_project_milestone1 end @@ -143,38 +143,111 @@ describe 'Group milestones' do expect(page).to have_content('Issues 1 Open: 1 Closed: 0') expect(page).to have_link(issue.title, href: project_issue_path(issue.project, issue)) end + end + end + + describe 'milestone tabs', :js do + context 'for a legacy group milestone' do + let_it_be(:milestone) { create(:milestone, project: project) } + let_it_be(:label) { create(:label, project: project) } + let_it_be(:issue) { create(:labeled_issue, project: project, milestone: milestone, labels: [label], assignees: [create(:user)]) } + let_it_be(:mr) { create(:merge_request, source_project: project, milestone: milestone) } + + before do + visit group_milestone_path(group, milestone.title, title: milestone.title) + end + + it 'renders the issues tab' do + within('#tab-issues') do + expect(page).to have_content issue.title + end + end + + it 'renders the merge requests tab' do + within('.js-milestone-tabs') do + click_link('Merge Requests') + end - describe 'labels' do - before do - create(:label, project: project, title: 'bug') do |label| - issue.labels << label - end + within('#tab-merge-requests') do + expect(page).to have_content mr.title + end + end + + it 'renders the participants tab' do + within('.js-milestone-tabs') do + click_link('Participants') + end - create(:label, project: project, title: 'feature') do |label| - issue.labels << label - end + within('#tab-participants') do + expect(page).to have_content issue.assignees.first.name end + end - it 'renders labels' do - click_link 'v1.0' + it 'renders the labels tab' do + within('.js-milestone-tabs') do + click_link('Labels') + end - page.within('#tab-issues') do - expect(page).to have_content 'bug' - expect(page).to have_content 'feature' - end + within('#tab-labels') do + expect(page).to have_content label.title end + end + end + + context 'for a group milestone' do + let_it_be(:other_project) { create(:project_empty_repo, group: group) } + let_it_be(:milestone) { create(:milestone, group: group) } - it 'renders labels list', :js do - click_link 'v1.0' + let_it_be(:project_label) { create(:label, project: project) } + let_it_be(:other_project_label) { create(:label, project: other_project) } - page.within('.content .nav-links') do - page.find(:xpath, "//a[@href='#tab-labels']").click - end + let_it_be(:project_issue) { create(:labeled_issue, project: project, milestone: milestone, labels: [project_label], assignees: [create(:user)]) } + let_it_be(:other_project_issue) { create(:labeled_issue, project: other_project, milestone: milestone, labels: [other_project_label], assignees: [create(:user)]) } + + let_it_be(:project_mr) { create(:merge_request, source_project: project, milestone: milestone) } + let_it_be(:other_project_mr) { create(:merge_request, source_project: other_project, milestone: milestone) } + + before do + visit group_milestone_path(group, milestone) + end + + it 'renders the issues tab' do + within('#tab-issues') do + expect(page).to have_content project_issue.title + expect(page).to have_content other_project_issue.title + end + end + + it 'renders the merge requests tab' do + within('.js-milestone-tabs') do + click_link('Merge Requests') + end + + within('#tab-merge-requests') do + expect(page).to have_content project_mr.title + expect(page).to have_content other_project_mr.title + end + end + + it 'renders the participants tab' do + within('.js-milestone-tabs') do + click_link('Participants') + end + + within('#tab-participants') do + expect(page).to have_content project_issue.assignees.first.name + expect(page).to have_content other_project_issue.assignees.first.name + end + end + + it 'renders the labels tab' do + within('.js-milestone-tabs') do + click_link('Labels') + end - page.within('#tab-labels') do - expect(page).to have_content 'bug' - expect(page).to have_content 'feature' - end + within('#tab-labels') do + expect(page).to have_content project_label.title + expect(page).to have_content other_project_label.title end end end diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb index ca994c95df858c15e72c9dfa98518ad17f26f0cc..e958ebb1275fa7866ee4beb2315337fa2f891e6d 100644 --- a/spec/features/groups_spec.rb +++ b/spec/features/groups_spec.rb @@ -189,7 +189,7 @@ describe 'Group' do expect(page).to have_selector '#confirm_name_input:focus' end - it 'removes group' do + it 'removes group', :sidekiq_might_not_need_inline do expect { remove_with_confirm('Remove group', group.path) }.to change {Group.count}.by(-1) expect(group.members.all.count).to be_zero expect(page).to have_content "scheduled for deletion" @@ -237,14 +237,28 @@ describe 'Group' do let!(:group) { create(:group) } let!(:nested_group) { create(:group, parent: group) } let!(:project) { create(:project, namespace: group) } - let!(:path) { group_path(group) } it 'renders projects and groups on the page' do - visit path + visit group_path(group) wait_for_requests expect(page).to have_content(nested_group.name) expect(page).to have_content(project.name) + expect(page).to have_link('Group overview') + end + + it 'renders subgroup page with the text "Subgroup overview"' do + visit group_path(nested_group) + wait_for_requests + + expect(page).to have_link('Subgroup overview') + end + + it 'renders project page with the text "Project overview"' do + visit project_path(project) + wait_for_requests + + expect(page).to have_link('Project overview') end end diff --git a/spec/features/import/manifest_import_spec.rb b/spec/features/import/manifest_import_spec.rb index e947125754456b6eb5bc94dfd9abfb75bdf33100..89bf69dea7d0d156b632bd58e599a8d31e6ec0f7 100644 --- a/spec/features/import/manifest_import_spec.rb +++ b/spec/features/import/manifest_import_spec.rb @@ -24,7 +24,7 @@ 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' do + it 'imports successfully imports a project', :sidekiq_might_not_need_inline do visit new_import_manifest_path attach_file('manifest', Rails.root.join('spec/fixtures/aosp_manifest.xml')) diff --git a/spec/features/issuables/markdown_references/internal_references_spec.rb b/spec/features/issuables/markdown_references/internal_references_spec.rb index f3b534bca49b893000487fcc9d62730fa6d2473c..efd84cf67b0c64bd624e4d52a175ef2e7bd814c0 100644 --- a/spec/features/issuables/markdown_references/internal_references_spec.rb +++ b/spec/features/issuables/markdown_references/internal_references_spec.rb @@ -64,7 +64,7 @@ describe "Internal references", :js do visit(project_issue_path(public_project, public_project_issue)) end - it "shows references" do + it "shows references", :sidekiq_might_not_need_inline do page.within("#merge-requests .merge-requests-title") do expect(page).to have_content("Related merge requests") expect(page).to have_css(".mr-count-badge") @@ -133,7 +133,7 @@ describe "Internal references", :js do visit(project_merge_request_path(public_project, public_project_merge_request)) end - it "shows references" do + it "shows references", :sidekiq_might_not_need_inline do expect(page).to have_content("mentioned in merge request #{private_project_merge_request.to_reference(public_project)}") .and have_content(private_project_user.name) end diff --git a/spec/features/issuables/markdown_references/jira_spec.rb b/spec/features/issuables/markdown_references/jira_spec.rb index 8085918f5336c630b8ba179321ce908ea7fefaf2..c5818691b3c9ccaf9b5fc7339b43ba5f84b36634 100644 --- a/spec/features/issuables/markdown_references/jira_spec.rb +++ b/spec/features/issuables/markdown_references/jira_spec.rb @@ -17,7 +17,9 @@ describe "Jira", :js do stub_request(:get, "https://jira.example.com/rest/api/2/issue/JIRA-5") stub_request(:post, "https://jira.example.com/rest/api/2/issue/JIRA-5/comment") - allow_any_instance_of(JIRA::Resource::Issue).to receive(:remotelink).and_return(remotelink) + allow_next_instance_of(JIRA::Resource::Issue) do |instance| + allow(instance).to receive(:remotelink).and_return(remotelink) + end sign_in(user) @@ -46,7 +48,7 @@ describe "Jira", :js do end end - it "creates a note on the referenced issues" do + it "creates a note on the referenced issues", :sidekiq_might_not_need_inline do click_button("Comment") wait_for_requests diff --git a/spec/features/issuables/sorting_list_spec.rb b/spec/features/issuables/sorting_list_spec.rb index b4531f5da4e011897f3ebf3b28cbe8a80484e673..b7813c8ba30441043de8635dcb54e166f3efb1d5 100644 --- a/spec/features/issuables/sorting_list_spec.rb +++ b/spec/features/issuables/sorting_list_spec.rb @@ -57,7 +57,7 @@ describe 'Sort Issuable List' do it 'is "last updated"' do visit_merge_requests_with_state(project, 'merged') - expect(find('.issues-other-filters')).to have_content('Last updated') + expect(find('.filter-dropdown-container')).to have_content('Last updated') expect(first_merge_request).to include(last_updated_issuable.title) expect(last_merge_request).to include(first_updated_issuable.title) end @@ -69,7 +69,7 @@ describe 'Sort Issuable List' do it 'is "last updated"' do visit_merge_requests_with_state(project, 'closed') - expect(find('.issues-other-filters')).to have_content('Last updated') + expect(find('.filter-dropdown-container')).to have_content('Last updated') expect(first_merge_request).to include(last_updated_issuable.title) expect(last_merge_request).to include(first_updated_issuable.title) end @@ -81,7 +81,7 @@ describe 'Sort Issuable List' do it 'is "created date"' do visit_merge_requests_with_state(project, 'all') - expect(find('.issues-other-filters')).to have_content('Created date') + expect(find('.filter-dropdown-container')).to have_content('Created date') expect(first_merge_request).to include(last_created_issuable.title) expect(last_merge_request).to include(first_created_issuable.title) end @@ -94,7 +94,7 @@ describe 'Sort Issuable List' do it 'supports sorting in asc and desc order' do visit_merge_requests_with_state(project, 'open') - page.within('.issues-other-filters') do + page.within('.filter-dropdown-container') do click_button('Created date') click_link('Last updated') end @@ -102,7 +102,7 @@ describe 'Sort Issuable List' do expect(first_merge_request).to include(last_updated_issuable.title) expect(last_merge_request).to include(first_updated_issuable.title) - find('.issues-other-filters .filter-dropdown-container .rspec-reverse-sort').click + find('.filter-dropdown-container .rspec-reverse-sort').click expect(first_merge_request).to include(first_updated_issuable.title) expect(last_merge_request).to include(last_updated_issuable.title) @@ -133,7 +133,7 @@ describe 'Sort Issuable List' do it 'is "created date"' do visit_issues project - expect(find('.issues-other-filters')).to have_content('Created date') + expect(find('.filter-dropdown-container')).to have_content('Created date') expect(first_issue).to include(last_created_issuable.title) expect(last_issue).to include(first_created_issuable.title) end @@ -145,7 +145,7 @@ describe 'Sort Issuable List' do it 'is "created date"' do visit_issues_with_state(project, 'open') - expect(find('.issues-other-filters')).to have_content('Created date') + expect(find('.filter-dropdown-container')).to have_content('Created date') expect(first_issue).to include(last_created_issuable.title) expect(last_issue).to include(first_created_issuable.title) end @@ -157,7 +157,7 @@ describe 'Sort Issuable List' do it 'is "last updated"' do visit_issues_with_state(project, 'closed') - expect(find('.issues-other-filters')).to have_content('Last updated') + expect(find('.filter-dropdown-container')).to have_content('Last updated') expect(first_issue).to include(last_updated_issuable.title) expect(last_issue).to include(first_updated_issuable.title) end @@ -169,7 +169,7 @@ describe 'Sort Issuable List' do it 'is "created date"' do visit_issues_with_state(project, 'all') - expect(find('.issues-other-filters')).to have_content('Created date') + expect(find('.filter-dropdown-container')).to have_content('Created date') expect(first_issue).to include(last_created_issuable.title) expect(last_issue).to include(first_created_issuable.title) end @@ -183,7 +183,7 @@ describe 'Sort Issuable List' do end it 'shows the sort order as created date' do - expect(find('.issues-other-filters')).to have_content('Created date') + expect(find('.filter-dropdown-container')).to have_content('Created date') expect(first_issue).to include(last_created_issuable.title) expect(last_issue).to include(first_created_issuable.title) end @@ -196,7 +196,7 @@ describe 'Sort Issuable List' do it 'supports sorting in asc and desc order' do visit_issues_with_state(project, 'open') - page.within('.issues-other-filters') do + page.within('.filter-dropdown-container') do click_button('Created date') click_link('Last updated') end @@ -204,7 +204,7 @@ describe 'Sort Issuable List' do expect(first_issue).to include(last_updated_issuable.title) expect(last_issue).to include(first_updated_issuable.title) - find('.issues-other-filters .filter-dropdown-container .rspec-reverse-sort').click + find('.filter-dropdown-container .rspec-reverse-sort').click expect(first_issue).to include(first_updated_issuable.title) expect(last_issue).to include(last_updated_issuable.title) diff --git a/spec/features/issues/filtered_search/dropdown_hint_spec.rb b/spec/features/issues/filtered_search/dropdown_hint_spec.rb index 1c56902a27d703fbe33da1f002e171ee97538281..bb57d69148bc2edf922200c5a8139ab6bd3480ef 100644 --- a/spec/features/issues/filtered_search/dropdown_hint_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_hint_spec.rb @@ -68,7 +68,7 @@ describe 'Dropdown hint', :js do it 'filters with text' do filtered_search.set('a') - expect(find(js_dropdown_hint)).to have_selector('.filter-dropdown .filter-dropdown-item', count: 5) + expect(find(js_dropdown_hint)).to have_selector('.filter-dropdown .filter-dropdown-item', count: 6) end end @@ -104,6 +104,15 @@ describe 'Dropdown hint', :js do expect_filtered_search_input_empty end + it 'opens the release dropdown when you click on release' do + click_hint('release') + + 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') diff --git a/spec/features/issues/filtered_search/dropdown_release_spec.rb b/spec/features/issues/filtered_search/dropdown_release_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..eea7f2d784886b02aee52762843098a7dbe254c0 --- /dev/null +++ b/spec/features/issues/filtered_search/dropdown_release_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Dropdown release', :js do + include FilteredSearchHelpers + + let!(:project) { create(:project, :repository) } + let!(:user) { create(:user) } + 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 + + before do + project.add_maintainer(user) + sign_in(user) + create(:issue, project: project) + + visit project_issues_path(project) + end + + 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) + 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 + end + end +end diff --git a/spec/features/issues/notes_on_issues_spec.rb b/spec/features/issues/notes_on_issues_spec.rb index 5247baa58a176a30214bf1e22f02f780adcfbe16..74eb699c7ef0404f5205f0ae24183a7d4cee4db2 100644 --- a/spec/features/issues/notes_on_issues_spec.rb +++ b/spec/features/issues/notes_on_issues_spec.rb @@ -23,7 +23,7 @@ describe 'Create notes on issues', :js do submit_comment(note_text) end - it 'creates a note with reference and cross references the issue' do + it 'creates a note with reference and cross references the issue', :sidekiq_might_not_need_inline do page.within('div#notes li.note div.note-text') do expect(page).to have_content(note_text) expect(page.find('a')).to have_content(mention.to_reference) diff --git a/spec/features/issues/user_creates_branch_and_merge_request_spec.rb b/spec/features/issues/user_creates_branch_and_merge_request_spec.rb index be31c45b37364205f44f389c64322e759f2c7dd0..8322a6afa04692e224eb80f1afa5d58f40c93874 100644 --- a/spec/features/issues/user_creates_branch_and_merge_request_spec.rb +++ b/spec/features/issues/user_creates_branch_and_merge_request_spec.rb @@ -67,7 +67,7 @@ describe 'User creates branch and merge request on issue page', :js do end context 'when branch name is auto-generated' do - it 'creates a merge request' do + it 'creates a merge request', :sidekiq_might_not_need_inline do perform_enqueued_jobs do select_dropdown_option('create-mr') @@ -96,7 +96,7 @@ describe 'User creates branch and merge request on issue page', :js do context 'when branch name is custom' do let(:branch_name) { 'custom-branch-name' } - it 'creates a merge request' do + it 'creates a merge request', :sidekiq_might_not_need_inline do perform_enqueued_jobs do select_dropdown_option('create-mr', branch_name) diff --git a/spec/features/issues/user_creates_confidential_merge_request_spec.rb b/spec/features/issues/user_creates_confidential_merge_request_spec.rb index 24089bdeb81fbac180de2888fa69e13250ac2c51..838c0a6349cfcf84d6aae5059d0c20533daaf212 100644 --- a/spec/features/issues/user_creates_confidential_merge_request_spec.rb +++ b/spec/features/issues/user_creates_confidential_merge_request_spec.rb @@ -42,7 +42,7 @@ describe 'User creates confidential merge request on issue page', :js do visit_confidential_issue end - it 'create merge request in fork' do + it 'create merge request in fork', :sidekiq_might_not_need_inline do click_button 'Create confidential merge request' page.within '.create-confidential-merge-request-dropdown-menu' do diff --git a/spec/features/issues/user_creates_issue_spec.rb b/spec/features/issues/user_creates_issue_spec.rb index a71395c0e476745b539853bc50cfa6e5499a098e..39ce3415727187023661e1e6ab57ef9272c51ec0 100644 --- a/spec/features/issues/user_creates_issue_spec.rb +++ b/spec/features/issues/user_creates_issue_spec.rb @@ -92,19 +92,6 @@ describe "User creates issue" do .and have_content(label_titles.first) end end - - context "with Zoom link" do - it "adds Zoom button" do - issue_title = "Issue containing Zoom meeting link" - zoom_url = "https://gitlab.zoom.us/j/123456789" - - fill_in("Title", with: issue_title) - fill_in("Description", with: zoom_url) - click_button("Submit issue") - - expect(page).to have_link('Join Zoom meeting', href: zoom_url) - end - end end context "when signed in as user with special characters in their name" do diff --git a/spec/features/issues/user_toggles_subscription_spec.rb b/spec/features/issues/user_toggles_subscription_spec.rb index 165d41950da9e7e2d485859babfb44d8c0bcb1f9..ba167362511e1fed755a6ca70091763bc9aa1fcb 100644 --- a/spec/features/issues/user_toggles_subscription_spec.rb +++ b/spec/features/issues/user_toggles_subscription_spec.rb @@ -33,7 +33,6 @@ describe "User toggles subscription", :js do it 'is disabled' do expect(page).to have_content('Notifications have been disabled by the project or group owner') - expect(page).to have_selector('.js-emails-disabled', visible: true) expect(page).not_to have_selector('.js-issuable-subscribe-button') end end diff --git a/spec/features/markdown/metrics_spec.rb b/spec/features/markdown/metrics_spec.rb index 4de67cfcdbea2707246accf490c2f6bb3e726e57..e7fec41fae3806ee342d89598f567ebbfc9b3f18 100644 --- a/spec/features/markdown/metrics_spec.rb +++ b/spec/features/markdown/metrics_spec.rb @@ -2,8 +2,9 @@ require 'spec_helper' -describe 'Metrics rendering', :js, :use_clean_rails_memory_store_caching do +describe 'Metrics rendering', :js, :use_clean_rails_memory_store_caching, :sidekiq_might_not_need_inline do include PrometheusHelpers + include GrafanaApiHelpers let(:user) { create(:user) } let(:project) { create(:prometheus_project) } @@ -14,11 +15,7 @@ describe 'Metrics rendering', :js, :use_clean_rails_memory_store_caching do before do configure_host - import_common_metrics - stub_any_prometheus_request_with_response - project.add_developer(user) - sign_in(user) end @@ -26,31 +23,58 @@ describe 'Metrics rendering', :js, :use_clean_rails_memory_store_caching do restore_host end - it 'shows embedded metrics' do - visit project_issue_path(project, issue) + context 'internal metrics embeds' do + before do + import_common_metrics + stub_any_prometheus_request_with_response + end + + it 'shows embedded metrics' do + visit project_issue_path(project, issue) + + expect(page).to have_css('div.prometheus-graph') + expect(page).to have_text('Memory Usage (Total)') + expect(page).to have_text('Core Usage (Total)') + end + + context 'when dashboard params are in included the url' do + let(:metrics_url) { metrics_project_environment_url(project, environment, **chart_params) } - expect(page).to have_css('div.prometheus-graph') - expect(page).to have_text('Memory Usage (Total)') - expect(page).to have_text('Core Usage (Total)') + let(:chart_params) do + { + group: 'System metrics (Kubernetes)', + title: 'Memory Usage (Pod average)', + y_label: 'Memory Used per Pod (MB)' + } + end + + it 'shows embedded metrics for the specific chart' do + visit project_issue_path(project, issue) + + expect(page).to have_css('div.prometheus-graph') + expect(page).to have_text(chart_params[:title]) + expect(page).to have_text(chart_params[:y_label]) + end + end end - context 'when dashboard params are in included the url' do - let(:metrics_url) { metrics_project_environment_url(project, environment, **chart_params) } + context 'grafana metrics embeds' do + let(:grafana_integration) { create(:grafana_integration, project: project) } + let(:grafana_base_url) { grafana_integration.grafana_url } + let(:metrics_url) { valid_grafana_dashboard_link(grafana_base_url) } - let(:chart_params) do - { - group: 'System metrics (Kubernetes)', - title: 'Memory Usage (Pod average)', - y_label: 'Memory Used per Pod (MB)' - } + before do + stub_dashboard_request(grafana_base_url) + stub_datasource_request(grafana_base_url) + stub_all_grafana_proxy_requests(grafana_base_url) end - it 'shows embedded metrics for the specifiec chart' do + it 'shows embedded metrics' do visit project_issue_path(project, issue) expect(page).to have_css('div.prometheus-graph') - expect(page).to have_text(chart_params[:title]) - expect(page).to have_text(chart_params[:y_label]) + expect(page).to have_text('Expired / Evicted') + expect(page).to have_text('expired - test-attribute-value') end end diff --git a/spec/features/merge_request/maintainer_edits_fork_spec.rb b/spec/features/merge_request/maintainer_edits_fork_spec.rb index 030638cba719fd312a5a903a09bcbcb52b82a62a..4e161d530d3f6e85479a9a2c32dce0ead15522a7 100644 --- a/spec/features/merge_request/maintainer_edits_fork_spec.rb +++ b/spec/features/merge_request/maintainer_edits_fork_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe 'a maintainer edits files on a source-branch of an MR from a fork', :js do +describe 'a maintainer edits files on a source-branch of an MR from a fork', :js, :sidekiq_might_not_need_inline do include ProjectForksHelper let(:user) { create(:user, username: 'the-maintainer') } let(:target_project) { create(:project, :public, :repository) } @@ -20,7 +20,7 @@ describe 'a maintainer edits files on a source-branch of an MR from a fork', :js end before do - stub_feature_flags(web_ide_default: false) + stub_feature_flags(web_ide_default: false, single_mr_diff_view: false) target_project.add_maintainer(user) sign_in(user) @@ -32,6 +32,8 @@ 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_accepts_merge_request_spec.rb b/spec/features/merge_request/user_accepts_merge_request_spec.rb index 4d305d433512e9d2c0ec6604dc4d2e16182607d8..5e1ff232b80eb09aa6b28e4e7c829caae1a67e8d 100644 --- a/spec/features/merge_request/user_accepts_merge_request_spec.rb +++ b/spec/features/merge_request/user_accepts_merge_request_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe 'User accepts a merge request', :js do +describe 'User accepts a merge request', :js, :sidekiq_might_not_need_inline do let(:merge_request) { create(:merge_request, :with_diffs, :simple, source_project: project) } let(:project) { create(:project, :public, :repository) } let(:user) { create(:user) } diff --git a/spec/features/merge_request/user_allows_commits_from_memebers_who_can_merge_spec.rb b/spec/features/merge_request/user_allows_commits_from_memebers_who_can_merge_spec.rb index be403abcc4dacff4b64f31f6966d7388eecba957..0ecd32565d07542b5686e11c15b33564e79afb54 100644 --- a/spec/features/merge_request/user_allows_commits_from_memebers_who_can_merge_spec.rb +++ b/spec/features/merge_request/user_allows_commits_from_memebers_who_can_merge_spec.rb @@ -23,7 +23,7 @@ describe 'create a merge request, allowing commits from members who can merge to sign_in(user) end - it 'allows setting possible' do + it 'allows setting possible', :sidekiq_might_not_need_inline do visit_new_merge_request check 'Allow commits from members who can merge to the target branch' @@ -35,7 +35,7 @@ describe 'create a merge request, allowing commits from members who can merge to expect(page).to have_content('Allows commits from members who can merge to the target branch') end - it 'shows a message when one of the projects is private' do + it 'shows a message when one of the projects is private', :sidekiq_might_not_need_inline do source_project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) visit_new_merge_request @@ -43,7 +43,7 @@ describe 'create a merge request, allowing commits from members who can merge to expect(page).to have_content('Not available for private projects') end - it 'shows a message when the source branch is protected' do + it 'shows a message when the source branch is protected', :sidekiq_might_not_need_inline do create(:protected_branch, project: source_project, name: 'fix') visit_new_merge_request 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 19b8a7f74b73d3b025c33a77f7249df96d252623..6a23b6cdf60f7b102ebac52a23b20c8c88846019 100644 --- a/spec/features/merge_request/user_comments_on_diff_spec.rb +++ b/spec/features/merge_request/user_comments_on_diff_spec.rb @@ -13,12 +13,15 @@ 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 e0724a04ea34814c93da76989b456c149842715a..e6634a8ff39ae2cceb4db94c3e352c9ba17db308 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,6 +9,7 @@ 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. @@ -17,6 +18,8 @@ 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_creates_merge_request_spec.rb b/spec/features/merge_request/user_creates_merge_request_spec.rb index f92791cc8105a116c5973fe6255a757b5a5865ac..67f6d8ebe329b790d03cb3424430b9fd75069ea0 100644 --- a/spec/features/merge_request/user_creates_merge_request_spec.rb +++ b/spec/features/merge_request/user_creates_merge_request_spec.rb @@ -25,6 +25,11 @@ describe "User creates a merge request", :js do click_button("Compare branches") + page.within('.merge-request-form') do + expect(page.find('#merge_request_title')['placeholder']).to eq 'Title' + expect(page.find('#merge_request_description')['placeholder']).to eq 'Describe the goal of the changes and what reviewers should be aware of.' + end + fill_in("Title", with: title) click_button("Submit merge request") @@ -36,7 +41,7 @@ describe "User creates a merge request", :js do context "to a forked project" do let(:forked_project) { fork_project(project, user, namespace: user.namespace, repository: true) } - it "creates a merge request" do + it "creates a merge request", :sidekiq_might_not_need_inline do visit(project_new_merge_request_path(forked_project)) expect(page).to have_content("Source branch").and have_content("Target branch") diff --git a/spec/features/merge_request/user_edits_merge_request_spec.rb b/spec/features/merge_request/user_edits_merge_request_spec.rb index 81c56855961e756ce484cc193edeb096c4548d26..821db8a1d5b6c8dab003d4911e42d82b2fe0d48a 100644 --- a/spec/features/merge_request/user_edits_merge_request_spec.rb +++ b/spec/features/merge_request/user_edits_merge_request_spec.rb @@ -17,7 +17,7 @@ describe 'User edits a merge request', :js do end it 'changes the target branch' do - expect(page).to have_content('Target branch') + expect(page).to have_content('From master into feature') select2('merge-test', from: '#merge_request_target_branch') click_button('Save changes') diff --git a/spec/features/merge_request/user_expands_diff_spec.rb b/spec/features/merge_request/user_expands_diff_spec.rb index f7317ec5ca7522fed527bfda6d86d1e8a2957c75..ba7abd3af2ca91139072eba7e593bc058525ce76 100644 --- a/spec/features/merge_request/user_expands_diff_spec.rb +++ b/spec/features/merge_request/user_expands_diff_spec.rb @@ -7,6 +7,8 @@ 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) + allow(Gitlab::Git::Diff).to receive(:size_limit).and_return(100.kilobytes) allow(Gitlab::Git::Diff).to receive(:collapse_limit).and_return(10.kilobytes) @@ -15,6 +17,8 @@ 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="2f6fcd96b88b36ce98c38da085c795a27d92a3dd"]') do click_link 'Click to expand it.' diff --git a/spec/features/merge_request/user_merges_merge_request_spec.rb b/spec/features/merge_request/user_merges_merge_request_spec.rb index da15a4bda4be5a21c7ea668440c9c31b5fa83aaa..32e40740a612e97a6fdd812e371511c0d5e6b0a8 100644 --- a/spec/features/merge_request/user_merges_merge_request_spec.rb +++ b/spec/features/merge_request/user_merges_merge_request_spec.rb @@ -10,7 +10,7 @@ describe "User merges a merge request", :js do end shared_examples "fast forward merge a merge request" do - it "merges a merge request" do + it "merges a merge request", :sidekiq_might_not_need_inline do expect(page).to have_content("Fast-forward merge without a merge commit").and have_button("Merge") page.within(".mr-state-widget") do diff --git a/spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb b/spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb index 4afbf30ece4bfa254a87e7fb48c3a2279e5ca4ca..419f741d0ea8d24720a4dc115480527c0da2c979 100644 --- a/spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb +++ b/spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb @@ -89,12 +89,12 @@ describe 'Merge request > User merges only if pipeline succeeds', :js do context 'when CI skipped' do let(:status) { :skipped } - it 'allows MR to be merged' do + it 'does not allow MR to be merged' do visit project_merge_request_path(project, merge_request) wait_for_requests - expect(page).to have_button 'Merge' + expect(page).not_to have_button 'Merge' end end end diff --git a/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb b/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb index ffc12ffdbaf03840bb42f5d854290661fac1f1e1..e40276f74e4a3b1e940acef8fac4ced39f1f1fc8 100644 --- a/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb +++ b/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb @@ -142,7 +142,7 @@ describe 'Merge request > User merges when pipeline succeeds', :js do refresh end - it 'merges merge request' do + it 'merges merge request', :sidekiq_might_not_need_inline do expect(page).to have_content 'The changes were merged' expect(merge_request.reload).to be_merged end 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 8b16760606ceadf5bd559d1198a7cff2bc2ac75f..6328c0a51335c2264fae748d50b1d08a2dab9d37 100644 --- a/spec/features/merge_request/user_posts_diff_notes_spec.rb +++ b/spec/features/merge_request/user_posts_diff_notes_spec.rb @@ -14,12 +14,15 @@ 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_resolves_conflicts_spec.rb b/spec/features/merge_request/user_resolves_conflicts_spec.rb index e3ee80a47d7ecc209d4fbb31e14984c26230cd5e..f0949fefa3b25a393a0d66a01ad958c278e8d9db 100644 --- a/spec/features/merge_request/user_resolves_conflicts_spec.rb +++ b/spec/features/merge_request/user_resolves_conflicts_spec.rb @@ -9,6 +9,7 @@ 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) @@ -17,7 +18,9 @@ describe 'Merge request > User resolves conflicts', :js do end end - shared_examples "conflicts are resolved in Interactive mode" do + 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 click_button 'Use ours' 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 8b41ef86791bbd6edd6b95deb5717f83c9d4be91..7cb46d90092747a3a141503c91fba87649d2b95d 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 @@ -19,6 +19,12 @@ describe 'Merge request > User resolves diff notes and threads', :js do ) end + before do + stub_feature_flags(single_mr_diff_view: 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_reverts_merge_request_spec.rb b/spec/features/merge_request/user_reverts_merge_request_spec.rb index 71270b13c14b3efd4239c2b27d75eea2f781b875..906ff1d61b2fe12292d864e011f72cbaee406737 100644 --- a/spec/features/merge_request/user_reverts_merge_request_spec.rb +++ b/spec/features/merge_request/user_reverts_merge_request_spec.rb @@ -20,7 +20,7 @@ describe 'User reverts a merge request', :js do visit(merge_request_path(merge_request)) end - it 'reverts a merge request' do + it 'reverts a merge request', :sidekiq_might_not_need_inline do find("a[href='#modal-revert-commit']").click page.within('#modal-revert-commit') do @@ -33,7 +33,7 @@ describe 'User reverts a merge request', :js do wait_for_requests end - it 'does not revert a merge request that was previously reverted' do + it 'does not revert a merge request that was previously reverted', :sidekiq_might_not_need_inline do find("a[href='#modal-revert-commit']").click page.within('#modal-revert-commit') do @@ -51,7 +51,7 @@ describe 'User reverts a merge request', :js do expect(page).to have_content('Sorry, we cannot revert this merge request automatically.') end - it 'reverts a merge request in a new merge request' do + it 'reverts a merge request in a new merge request', :sidekiq_might_not_need_inline do find("a[href='#modal-revert-commit']").click page.within('#modal-revert-commit') do 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 baef831c40e4f25ffd638a94e9627b89004d370d..e882b4011227bbcfee2ee3378f1795481ba6d88d 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 @@ -7,8 +7,8 @@ describe 'Merge request > User sees avatars on diff notes', :js do let(:project) { create(:project, :public, :repository) } let(:user) { project.creator } - let(:merge_request) { create(:merge_request_with_diffs, source_project: project, author: user, title: "Bug NS-04") } - let(:path) { "files/ruby/popen.rb" } + let(:merge_request) { create(:merge_request_with_diffs, source_project: project, author: user, title: 'Bug NS-04') } + let(:path) { 'files/ruby/popen.rb' } let(:position) do Gitlab::Diff::Position.new( old_path: path, @@ -21,12 +21,15 @@ 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) project.add_maintainer(user) sign_in user 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_cherry_pick_modal_spec.rb b/spec/features/merge_request/user_sees_cherry_pick_modal_spec.rb index 1d62f7f070237da1580e1fe7dcb2d6a99422b918..d7675cd06a83a36ee05dd1f24a6501e1d3fec66b 100644 --- a/spec/features/merge_request/user_sees_cherry_pick_modal_spec.rb +++ b/spec/features/merge_request/user_sees_cherry_pick_modal_spec.rb @@ -15,7 +15,7 @@ describe 'Merge request > User cherry-picks', :js do context 'Viewing a merged merge request' do before do - service = MergeRequests::MergeService.new(project, user) + service = MergeRequests::MergeService.new(project, user, sha: merge_request.diff_head_sha) perform_enqueued_jobs do service.execute(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 87fb3f5b3e77e4c159d3fe0d08b6c1e9042eae72..cdffd2ae2f698e57838c8ac1be17b8ead871b89a 100644 --- a/spec/features/merge_request/user_sees_deployment_widget_spec.rb +++ b/spec/features/merge_request/user_sees_deployment_widget_spec.rb @@ -11,7 +11,7 @@ describe 'Merge request > User sees deployment widget', :js do let(:role) { :developer } let(:ref) { merge_request.target_branch } let(:sha) { project.commit(ref).id } - let(:pipeline) { create(:ci_pipeline_without_jobs, sha: sha, project: project, ref: ref) } + let(:pipeline) { create(:ci_pipeline, sha: sha, project: project, ref: ref) } let!(:manual) { } before do @@ -33,7 +33,7 @@ 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_without_jobs, sha: sha, project: project, ref: 'new-patch-1') } + let(:pipeline2) { create(:ci_pipeline, sha: sha, project: project, ref: 'new-patch-1') } let(:build2) { create(:ci_build, :success, pipeline: pipeline2) } let(:environment2) { create(:environment, project: project) } let!(:deployment2) { create(:deployment, environment: environment2, sha: sha, ref: 'new-patch-1', deployable: build2) } diff --git a/spec/features/merge_request/user_sees_diff_spec.rb b/spec/features/merge_request/user_sees_diff_spec.rb index 8eeed7b0843fd48bd9425b5eefda50662535c681..82dd779577cddc875181abf6e0e43d5873e3b6ed 100644 --- a/spec/features/merge_request/user_sees_diff_spec.rb +++ b/spec/features/merge_request/user_sees_diff_spec.rb @@ -9,6 +9,12 @@ describe 'Merge request > User sees diff', :js do let(:project) { create(:project, :public, :repository) } let(:merge_request) { create(:merge_request, source_project: project) } + before do + stub_feature_flags(single_mr_diff_view: 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 } @@ -62,7 +68,7 @@ describe 'Merge request > User sees diff', :js do end context 'as author' do - it 'shows direct edit link' do + it 'shows direct edit link', :sidekiq_might_not_need_inline do sign_in(author_user) visit diffs_project_merge_request_path(project, merge_request) @@ -72,7 +78,7 @@ describe 'Merge request > User sees diff', :js do end context 'as user who needs to fork' do - it 'shows fork/cancel confirmation' do + it 'shows fork/cancel confirmation', :sidekiq_might_not_need_inline do sign_in(user) visit diffs_project_merge_request_path(project, merge_request) diff --git a/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb b/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb index dd5662d83f283d506f15249addfbfef296abfd13..abf159949db64f6e98a4da9ea7a500bce84adab5 100644 --- a/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb +++ b/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb @@ -67,13 +67,13 @@ describe 'Merge request > User sees pipelines triggered by merge request', :js d end end - it 'sees the latest detached merge request pipeline as the head pipeline' do + it 'sees the latest detached merge request pipeline as the head pipeline', :sidekiq_might_not_need_inline do page.within('.ci-widget-content') do expect(page).to have_content("##{detached_merge_request_pipeline.id}") end end - context 'when a user updated a merge request in the parent project' do + context 'when a user updated a merge request in the parent project', :sidekiq_might_not_need_inline do let!(:push_pipeline_2) do Ci::CreatePipelineService.new(project, user, ref: 'feature') .execute(:push) @@ -133,7 +133,7 @@ describe 'Merge request > User sees pipelines triggered by merge request', :js d end end - context 'when a user merges a merge request in the parent project' do + context 'when a user merges a merge request in the parent project', :sidekiq_might_not_need_inline do before do click_button 'Merge when pipeline succeeds' @@ -196,7 +196,7 @@ describe 'Merge request > User sees pipelines triggered by merge request', :js d end end - it 'sees the latest branch pipeline as the head pipeline' do + it 'sees the latest branch pipeline as the head pipeline', :sidekiq_might_not_need_inline do page.within('.ci-widget-content') do expect(page).to have_content("##{push_pipeline.id}") end @@ -204,7 +204,7 @@ describe 'Merge request > User sees pipelines triggered by merge request', :js d end end - context 'when a user created a merge request from a forked project to the parent project' do + context 'when a user created a merge request from a forked project to the parent project', :sidekiq_might_not_need_inline do let(:merge_request) do create(:merge_request, source_project: forked_project, diff --git a/spec/features/merge_request/user_sees_merge_widget_spec.rb b/spec/features/merge_request/user_sees_merge_widget_spec.rb index 6b6226ad1c5e78f04403ce6914bb8df38d0fb508..098f41f120d33eae099344636d30460b97cf47e8 100644 --- a/spec/features/merge_request/user_sees_merge_widget_spec.rb +++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' describe 'Merge request > User sees merge widget', :js do include ProjectForksHelper include TestReportsHelper + include ReactiveCachingHelpers let(:project) { create(:project, :repository) } let(:project_only_mwps) { create(:project, :repository, only_allow_merge_if_pipeline_succeeds: true) } @@ -43,7 +44,7 @@ describe 'Merge request > User sees merge widget', :js do context 'view merge request' do let!(:environment) { create(:environment, project: project) } let(:sha) { project.commit(merge_request.source_branch).sha } - let(:pipeline) { create(:ci_pipeline_without_jobs, status: 'success', sha: sha, project: project, ref: merge_request.source_branch) } + let(:pipeline) { create(:ci_pipeline, status: 'success', sha: sha, project: project, ref: merge_request.source_branch) } let(:build) { create(:ci_build, :success, pipeline: pipeline) } let!(:deployment) do @@ -75,7 +76,7 @@ describe 'Merge request > User sees merge widget', :js do expect(find('.accept-merge-request')['disabled']).not_to be(true) end - it 'allows me to merge, see cherry-pick modal and load branches list' do + it 'allows me to merge, see cherry-pick modal and load branches list', :sidekiq_might_not_need_inline do wait_for_requests click_button 'Merge' @@ -190,7 +191,7 @@ describe 'Merge request > User sees merge widget', :js do end shared_examples 'pipeline widget' do - it 'shows head pipeline information' do + it 'shows head pipeline information', :sidekiq_might_not_need_inline do within '.ci-widget-content' do expect(page).to have_content("Detached merge request pipeline ##{pipeline.id} pending for #{pipeline.short_sha}") end @@ -229,7 +230,7 @@ describe 'Merge request > User sees merge widget', :js do end shared_examples 'pipeline widget' do - it 'shows head pipeline information' do + it 'shows head pipeline information', :sidekiq_might_not_need_inline do within '.ci-widget-content' do expect(page).to have_content("Merged result pipeline ##{pipeline.id} pending for #{pipeline.short_sha}") end @@ -370,7 +371,7 @@ describe 'Merge request > User sees merge widget', :js do visit project_merge_request_path(project, merge_request) end - it 'updates the MR widget' do + it 'updates the MR widget', :sidekiq_might_not_need_inline do click_button 'Merge' page.within('.mr-widget-body') do @@ -416,7 +417,7 @@ describe 'Merge request > User sees merge widget', :js do visit project_merge_request_path(project, merge_request) end - it 'user cannot remove source branch' do + it 'user cannot remove source branch', :sidekiq_might_not_need_inline do expect(page).not_to have_field('remove-source-branch-input') expect(page).to have_content('Deletes source branch') end @@ -435,6 +436,54 @@ describe 'Merge request > User sees merge widget', :js do end end + context 'exposed artifacts' do + subject { visit project_merge_request_path(project, merge_request) } + + context 'when merge request has exposed artifacts' do + let(:merge_request) { create(:merge_request, :with_exposed_artifacts, source_project: project) } + let(:job) { merge_request.head_pipeline.builds.last } + let!(:artifacts_metadata) { create(:ci_job_artifact, :metadata, job: job) } + + context 'when result has not been parsed yet' do + it 'shows parsing status' do + subject + + expect(page).to have_content('Loading artifacts') + end + end + + context 'when result has been parsed' do + before do + allow_any_instance_of(MergeRequest).to receive(:find_exposed_artifacts).and_return( + status: :parsed, data: [ + { + text: "the artifact", + url: "/namespace1/project1/-/jobs/1/artifacts/file/ci_artifacts.txt", + job_path: "/namespace1/project1/-/jobs/1", + job_name: "test" + } + ]) + end + + it 'shows the parsed results' do + subject + + expect(page).to have_content('View exposed artifact') + end + end + end + + context 'when merge request does not have exposed artifacts' do + let(:merge_request) { create(:merge_request, source_project: project) } + + it 'does not show parsing status' do + subject + + expect(page).not_to have_content('Loading artifacts') + end + end + end + context 'when merge request has test reports' do let!(:head_pipeline) do create(:ci_pipeline, @@ -696,7 +745,7 @@ describe 'Merge request > User sees merge widget', :js do context 'when MR has pipeline but user does not have permission' do let(:sha) { project.commit(merge_request.source_branch).sha } - let!(:pipeline) { create(:ci_pipeline_without_jobs, status: 'success', sha: sha, project: project, ref: merge_request.source_branch) } + let!(:pipeline) { create(:ci_pipeline, status: 'success', sha: sha, project: project, ref: merge_request.source_branch) } before do project.update( 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 db0d632cdf20e36289d233a318b83867b97aa057..3d25611e1ea6bf8921f09d61d096b05b366b7a61 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,11 +11,14 @@ 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_notes_from_forked_project_spec.rb b/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb index 0391794649cd49b5cf48fc8ff6fcccda421b0c1a..9c9e0dacb87c3ed5622a941e01565e22a2618630 100644 --- a/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb +++ b/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb @@ -21,7 +21,7 @@ describe 'Merge request > User sees notes from forked project', :js do sign_in(user) end - it 'user can reply to the comment' do + it 'user can reply to the comment', :sidekiq_might_not_need_inline do visit project_merge_request_path(project, merge_request) expect(page).to have_content('A commit comment') diff --git a/spec/features/merge_request/user_sees_pipelines_from_forked_project_spec.rb b/spec/features/merge_request/user_sees_pipelines_from_forked_project_spec.rb index 3e15a9c136b8a9c90c6dc7d62935041ae42e4662..d258b98f4a933dc7812351d6acd637473b291a49 100644 --- a/spec/features/merge_request/user_sees_pipelines_from_forked_project_spec.rb +++ b/spec/features/merge_request/user_sees_pipelines_from_forked_project_spec.rb @@ -28,7 +28,7 @@ describe 'Merge request > User sees pipelines from forked project', :js do visit project_merge_request_path(target_project, merge_request) end - it 'user visits a pipelines page' do + it 'user visits a pipelines page', :sidekiq_might_not_need_inline do page.within('.merge-request-tabs') { click_link 'Pipelines' } page.within('.ci-table') do diff --git a/spec/features/merge_request/user_sees_pipelines_spec.rb b/spec/features/merge_request/user_sees_pipelines_spec.rb index 7a8b938486a0483f9976bfafa2d731d7a5339f90..f3d8f2b42f852a131a280274a00009f10431b12e 100644 --- a/spec/features/merge_request/user_sees_pipelines_spec.rb +++ b/spec/features/merge_request/user_sees_pipelines_spec.rb @@ -124,7 +124,7 @@ describe 'Merge request > User sees pipelines', :js do threads.each { |thr| thr.join } end - it 'user sees pipeline in merge request widget' do + it 'user sees pipeline in merge request widget', :sidekiq_might_not_need_inline do visit project_merge_request_path(project, @merge_request) expect(page.find(".ci-widget")).to have_content(TestEnv::BRANCH_SHA['feature']) diff --git a/spec/features/merge_request/user_sees_versions_spec.rb b/spec/features/merge_request/user_sees_versions_spec.rb index 62abcff7bdafae4dcfd2e901d82d7748c322149e..c3fce9761df7956c51c172c8230b5a3b20f4c0f1 100644 --- a/spec/features/merge_request/user_sees_versions_spec.rb +++ b/spec/features/merge_request/user_sees_versions_spec.rb @@ -16,11 +16,15 @@ describe 'Merge request > User sees versions', :js do let!(:params) { {} } 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, 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}']" 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 3d26ff3ed94aca935c6fb3800b1d9abaa5f6e9bd..e2bcdfd1e2bfe2f1864c605625dd323a31f76cc9 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,12 +25,15 @@ 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]}']")) 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 4db067a4e41c9fc3f34b040d34896df54a741808..5e59bc87e68b84e82fa1e3eea3e1693a1cfa2123 100644 --- a/spec/features/merge_request/user_toggles_whitespace_changes_spec.rb +++ b/spec/features/merge_request/user_toggles_whitespace_changes_spec.rb @@ -8,6 +8,7 @@ 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) @@ -15,6 +16,8 @@ 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 2d1eb260236521ec1f18686ca7c85b6440f9ed7a..5a29477e597b02a1d11f654d478708f338c86f4a 100644 --- a/spec/features/merge_request/user_views_diffs_spec.rb +++ b/spec/features/merge_request/user_views_diffs_spec.rb @@ -9,6 +9,7 @@ describe 'User views diffs', :js do let(:project) { create(:project, :public, :repository) } before do + stub_feature_flags(single_mr_diff_view: false) visit(diffs_project_merge_request_path(project, merge_request)) wait_for_requests @@ -16,6 +17,8 @@ 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/user_squashes_merge_request_spec.rb b/spec/features/merge_requests/user_squashes_merge_request_spec.rb index 4fc8c71e47e3b6dcca67696a1333774f38cf6378..a9b96c5bbf571b9a1a9ab74f059e00dd7d0bdcba 100644 --- a/spec/features/merge_requests/user_squashes_merge_request_spec.rb +++ b/spec/features/merge_requests/user_squashes_merge_request_spec.rb @@ -10,7 +10,7 @@ describe 'User squashes a merge request', :js do let!(:original_head) { project.repository.commit('master') } shared_examples 'squash' do - it 'squashes the commits into a single commit, and adds a merge commit' do + it 'squashes the commits into a single commit, and adds a merge commit', :sidekiq_might_not_need_inline do expect(page).to have_content('Merged') latest_master_commits = project.repository.commits_between(original_head.sha, 'master').map(&:raw) @@ -31,7 +31,7 @@ describe 'User squashes a merge request', :js do end shared_examples 'no squash' do - it 'accepts the merge request without squashing' do + it 'accepts the merge request without squashing', :sidekiq_might_not_need_inline do expect(page).to have_content('Merged') expect(project.repository).to be_merged_to_root_ref(source_branch) end @@ -47,7 +47,9 @@ describe 'User squashes a merge request', :js do before do # Prevent source branch from being removed so we can use be_merged_to_root_ref # method to check if squash was performed or not - allow_any_instance_of(MergeRequest).to receive(:force_remove_source_branch?).and_return(false) + allow_next_instance_of(MergeRequest) do |instance| + allow(instance).to receive(:force_remove_source_branch?).and_return(false) + end project.add_maintainer(user) sign_in user diff --git a/spec/features/milestones/user_views_milestones_spec.rb b/spec/features/milestones/user_views_milestones_spec.rb index 0b51ca12997db6cb6e48ef2647cea99341a94e7c..09378cab5e39e2e0bf1ebd3eb7deb139be1a2051 100644 --- a/spec/features/milestones/user_views_milestones_spec.rb +++ b/spec/features/milestones/user_views_milestones_spec.rb @@ -34,4 +34,31 @@ describe "User views milestones" do .and have_content(closed_issue.title) end end + + context "with associated releases" do + set(:first_release) { create(:release, project: project, name: "The first release", milestones: [milestone], released_at: Time.zone.parse('2019-10-07')) } + + context "with a single associated release" do + it "shows the associated release" do + expect(page).to have_content("Release #{first_release.name}") + expect(page).to have_link(first_release.name, href: project_releases_path(project, anchor: first_release.tag)) + end + end + + context "with lots of associated releases" do + set(:second_release) { create(:release, project: project, name: "The second release", milestones: [milestone], released_at: first_release.released_at + 1.day) } + set(:third_release) { create(:release, project: project, name: "The third release", milestones: [milestone], released_at: second_release.released_at + 1.day) } + set(:fourth_release) { create(:release, project: project, name: "The fourth release", milestones: [milestone], released_at: third_release.released_at + 1.day) } + set(:fifth_release) { create(:release, project: project, name: "The fifth release", milestones: [milestone], released_at: fourth_release.released_at + 1.day) } + + it "shows the associated releases and the truncation text" do + expect(page).to have_content("Releases #{fifth_release.name} • #{fourth_release.name} • #{third_release.name} • 2 more releases") + + expect(page).to have_link(fifth_release.name, href: project_releases_path(project, anchor: fifth_release.tag)) + expect(page).to have_link(fourth_release.name, href: project_releases_path(project, anchor: fourth_release.tag)) + expect(page).to have_link(third_release.name, href: project_releases_path(project, anchor: third_release.tag)) + expect(page).to have_link("2 more releases", href: project_releases_path(project)) + end + end + end end diff --git a/spec/features/populate_new_pipeline_vars_with_params_spec.rb b/spec/features/populate_new_pipeline_vars_with_params_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..5fe80e73e38b93a77498073f8837d8d73d2d3449 --- /dev/null +++ b/spec/features/populate_new_pipeline_vars_with_params_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe "Populate new pipeline CI variables with url params", :js do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:page_path) { new_project_pipeline_path(project) } + + before do + sign_in(user) + project.add_maintainer(user) + + visit "#{page_path}?var[key1]=value1&file_var[key2]=value2" + end + + it "var[key1]=value1 populates env_var variable correctly" do + page.within('.ci-variable-list .js-row:nth-child(1)') do + expect(find('.js-ci-variable-input-variable-type').value).to eq('env_var') + expect(find('.js-ci-variable-input-key').value).to eq('key1') + expect(find('.js-ci-variable-input-value').text).to eq('value1') + end + end + + it "file_var[key2]=value2 populates file variable correctly" do + page.within('.ci-variable-list .js-row:nth-child(2)') do + expect(find('.js-ci-variable-input-variable-type').value).to eq('file') + expect(find('.js-ci-variable-input-key').value).to eq('key2') + expect(find('.js-ci-variable-input-value').text).to eq('value2') + end + end +end diff --git a/spec/features/profile_spec.rb b/spec/features/profile_spec.rb index e80a3cd32cc48846044b9dd69d38d3820e5db861..0147963c0a32ab8de23ce930a1a08569faf22603 100644 --- a/spec/features/profile_spec.rb +++ b/spec/features/profile_spec.rb @@ -22,7 +22,7 @@ describe 'Profile account page', :js do expect(User.exists?(user.id)).to be_truthy end - it 'deletes user', :js do + it 'deletes user', :js, :sidekiq_might_not_need_inline do click_button 'Delete account' fill_in 'password', with: '12345678' diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb index 0905ab0aef82958cc62984fd1f6008c6a31d1cde..9839b3d6c80d4632456f6058f0c359d1d0d12eed 100644 --- a/spec/features/profiles/user_edit_profile_spec.rb +++ b/spec/features/profiles/user_edit_profile_spec.rb @@ -23,6 +23,7 @@ describe 'User edit profile' do fill_in 'user_location', with: 'Ukraine' fill_in 'user_bio', with: 'I <3 GitLab' fill_in 'user_organization', with: 'GitLab' + select 'Data Analyst', from: 'user_role' submit_settings expect(user.reload).to have_attributes( @@ -31,7 +32,8 @@ describe 'User edit profile' do twitter: 'testtwitter', website_url: 'testurl', bio: 'I <3 GitLab', - organization: 'GitLab' + organization: 'GitLab', + role: 'data_analyst' ) expect(find('#user_location').value).to eq 'Ukraine' diff --git a/spec/features/project_group_variables_spec.rb b/spec/features/project_group_variables_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c1f1c4429373bfe1768531e40e9bec4996eb225f --- /dev/null +++ b/spec/features/project_group_variables_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Project group variables', :js do + let(:user) { create(:user) } + let(:group) { create(:group) } + let(:subgroup) { create(:group, parent: group) } + let(:subgroup_nested) { create(:group, parent: subgroup) } + let(:project) { create(:project, group: group) } + let(:project2) { create(:project, group: subgroup) } + let(:project3) { create(:project, group: subgroup_nested) } + let(:key1) { 'test_key' } + let(:key2) { 'test_key2' } + let(:key3) { 'test_key3' } + let!(:ci_variable) { create(:ci_group_variable, group: group, key: key1) } + let!(:ci_variable2) { create(:ci_group_variable, group: subgroup, key: key2) } + let!(:ci_variable3) { create(:ci_group_variable, group: subgroup_nested, key: key3) } + let(:project_path) { project_settings_ci_cd_path(project) } + let(:project2_path) { project_settings_ci_cd_path(project2) } + let(:project3_path) { project_settings_ci_cd_path(project3) } + + before do + sign_in(user) + project.add_maintainer(user) + group.add_owner(user) + end + + it 'project in group shows inherited vars from ancestor group' do + visit project_path + expect(page).to have_content(key1) + expect(page).to have_content(group.name) + end + + it 'project in subgroup shows inherited vars from all ancestor groups' do + visit project2_path + expect(page).to have_content(key1) + expect(page).to have_content(key2) + expect(page).to have_content(group.name) + expect(page).to have_content(subgroup.name) + end + + it 'project in nested subgroup shows inherited vars from all ancestor groups' do + visit project3_path + expect(page).to have_content(key1) + expect(page).to have_content(key2) + expect(page).to have_content(key3) + expect(page).to have_content(group.name) + expect(page).to have_content(subgroup.name) + expect(page).to have_content(subgroup_nested.name) + end + + it 'project origin keys link to ancestor groups ci_cd settings' do + visit project_path + find('.group-origin-link').click + page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do + expect(find('.js-ci-variable-input-key').value).to eq(key1) + end + end +end diff --git a/spec/features/projects/badges/pipeline_badge_spec.rb b/spec/features/projects/badges/pipeline_badge_spec.rb index f2c57d702a51afa7e579f965253c1cd2c648d0ef..af936c8088698758260172a17a399ff7f7059fa0 100644 --- a/spec/features/projects/badges/pipeline_badge_spec.rb +++ b/spec/features/projects/badges/pipeline_badge_spec.rb @@ -22,7 +22,7 @@ describe 'Pipeline Badge' do let!(:job) { create(:ci_build, pipeline: pipeline) } context 'when the pipeline was successful' do - it 'displays so on the badge' do + it 'displays so on the badge', :sidekiq_might_not_need_inline do job.success visit pipeline_project_badges_path(project, ref: ref, format: :svg) @@ -33,7 +33,7 @@ describe 'Pipeline Badge' do end context 'when the pipeline failed' do - it 'shows displays so on the badge' do + it 'shows displays so on the badge', :sidekiq_might_not_need_inline do job.drop visit pipeline_project_badges_path(project, ref: ref, format: :svg) @@ -52,7 +52,7 @@ describe 'Pipeline Badge' do allow(job).to receive(:prerequisites).and_return([double]) end - it 'displays the preparing badge' do + it 'displays the preparing badge', :sidekiq_might_not_need_inline do job.enqueue visit pipeline_project_badges_path(project, ref: ref, format: :svg) @@ -63,7 +63,7 @@ describe 'Pipeline Badge' do end context 'when the pipeline is running' do - it 'shows displays so on the badge' do + it 'shows displays so on the badge', :sidekiq_might_not_need_inline do create(:ci_build, pipeline: pipeline, name: 'second build', status_event: 'run') visit pipeline_project_badges_path(project, ref: ref, format: :svg) diff --git a/spec/features/projects/blobs/edit_spec.rb b/spec/features/projects/blobs/edit_spec.rb index 3b32d213754354f45bfbb3d1a6af07c734391c1e..0a5bc64b42972f68fa67fc97985a40ec89dd06af 100644 --- a/spec/features/projects/blobs/edit_spec.rb +++ b/spec/features/projects/blobs/edit_spec.rb @@ -12,9 +12,11 @@ describe 'Editing file blob', :js do let(:readme_file_path) { 'README.md' } before do - stub_feature_flags(web_ide_default: false) + stub_feature_flags(web_ide_default: false, single_mr_diff_view: false) end + it_behaves_like 'rendering a single diff version' + context 'as a developer' do let(:user) { create(:user) } let(:role) { :developer } @@ -27,14 +29,14 @@ describe 'Editing file blob', :js do def edit_and_commit(commit_changes: true) wait_for_requests find('.js-edit-blob').click - fill_editor(content: "class NextFeature\\nend\\n") + fill_editor(content: 'class NextFeature\\nend\\n') if commit_changes click_button 'Commit changes' end end - def fill_editor(content: "class NextFeature\\nend\\n") + def fill_editor(content: 'class NextFeature\\nend\\n') wait_for_requests find('#editor') execute_script("ace.edit('editor').setValue('#{content}')") @@ -60,6 +62,13 @@ describe 'Editing file blob', :js do expect(page).to have_content 'NextFeature' end + it 'editing a template file in a sub directory does not change path' do + project.repository.create_file(user, 'ci/.gitlab-ci.yml', 'test', message: 'testing', branch_name: branch) + visit project_edit_blob_path(project, tree_join(branch, 'ci/.gitlab-ci.yml')) + + expect(find_by_id('file_path').value).to eq('ci/.gitlab-ci.yml') + end + context 'from blob file path' do before do visit project_blob_path(project, tree_join(branch, file_path)) @@ -88,13 +97,13 @@ describe 'Editing file blob', :js do context 'when rendering the preview' do it 'renders content with CommonMark' do visit project_edit_blob_path(project, tree_join(branch, readme_file_path)) - fill_editor(content: "1. one\\n - sublist\\n") + fill_editor(content: '1. one\\n - sublist\\n') click_link 'Preview' wait_for_requests # the above generates two separate lists (not embedded) in CommonMark - expect(page).to have_content("sublist") - expect(page).not_to have_xpath("//ol//li//ul") + expect(page).to have_content('sublist') + expect(page).not_to have_xpath('//ol//li//ul') end end end diff --git a/spec/features/projects/clusters/eks_spec.rb b/spec/features/projects/clusters/eks_spec.rb index 758dccd6e493f1a1977b2ac1205c848f3a309efc..e0ebccd85ac176bbdc269b81856518540fcc8eef 100644 --- a/spec/features/projects/clusters/eks_spec.rb +++ b/spec/features/projects/clusters/eks_spec.rb @@ -10,6 +10,7 @@ describe 'AWS EKS Cluster', :js do project.add_maintainer(user) gitlab_sign_in(user) allow(Projects::ClustersController).to receive(:STATUS_POLLING_INTERVAL) { 100 } + stub_application_setting(eks_integration_enabled: true) end context 'when user does not have a cluster and visits cluster index page' do @@ -27,7 +28,7 @@ describe 'AWS EKS Cluster', :js do end it 'user sees a form to create an EKS cluster' do - expect(page).to have_selector(:css, '.js-create-eks-cluster') + expect(page).to have_content('Create new Cluster on EKS') end end end diff --git a/spec/features/projects/clusters/gcp_spec.rb b/spec/features/projects/clusters/gcp_spec.rb index b5ab9faa14b667f754ebf85adaac170216d1a4e2..bdc946a9c98bbb9e01794c8b50240a80eeafba7f 100644 --- a/spec/features/projects/clusters/gcp_spec.rb +++ b/spec/features/projects/clusters/gcp_spec.rb @@ -67,17 +67,17 @@ describe 'Gcp Cluster', :js do it 'user sees a cluster details page and creation status' do subject - expect(page).to have_content('Kubernetes cluster is being created on Google Kubernetes Engine...') + expect(page).to have_content('Kubernetes cluster is being created...') Clusters::Cluster.last.provider.make_created! - expect(page).to have_content('Kubernetes cluster was successfully created on Google Kubernetes Engine') + expect(page).to have_content('Kubernetes cluster was successfully created') end it 'user sees a error if something wrong during creation' do subject - expect(page).to have_content('Kubernetes cluster is being created on Google Kubernetes Engine...') + expect(page).to have_content('Kubernetes cluster is being created...') Clusters::Cluster.last.provider.make_errored!('Something wrong!') diff --git a/spec/features/projects/clusters/user_spec.rb b/spec/features/projects/clusters/user_spec.rb index 84f2e3e09ae8d8f9e4ac19d9a2f89898717557d0..bdaeda839260f58a91bff24536feb793340bf2c5 100644 --- a/spec/features/projects/clusters/user_spec.rb +++ b/spec/features/projects/clusters/user_spec.rb @@ -13,8 +13,12 @@ describe 'User Cluster', :js do gitlab_sign_in(user) allow(Projects::ClustersController).to receive(:STATUS_POLLING_INTERVAL) { 100 } - allow_any_instance_of(Clusters::Kubernetes::CreateOrUpdateNamespaceService).to receive(:execute) - allow_any_instance_of(Clusters::Cluster).to receive(:retrieve_connection_status).and_return(:connected) + allow_next_instance_of(Clusters::Kubernetes::CreateOrUpdateNamespaceService) do |instance| + allow(instance).to receive(:execute) + end + allow_next_instance_of(Clusters::Cluster) do |instance| + allow(instance).to receive(:retrieve_connection_status).and_return(:connected) + end end context 'when user does not have a cluster and visits cluster index page' do diff --git a/spec/features/projects/commit/cherry_pick_spec.rb b/spec/features/projects/commit/cherry_pick_spec.rb index 46a6f62ba14e83bd692c4f88e6732cc64a4db0ad..34b15aeaa25d0fb1169dbd91c964306a5f2c4cc3 100644 --- a/spec/features/projects/commit/cherry_pick_spec.rb +++ b/spec/features/projects/commit/cherry_pick_spec.rb @@ -55,12 +55,16 @@ describe 'Cherry-pick Commits' do end end - context "I cherry-pick a commit in a new merge request" do + context "I cherry-pick a commit in a new merge request", :js do it do + find('.header-action-buttons a.dropdown-toggle').click find("a[href='#modal-cherry-pick-commit']").click page.within('#modal-cherry-pick-commit') do click_button 'Cherry-pick' end + + wait_for_requests + expect(page).to have_content("The commit has been successfully cherry-picked into cherry-pick-#{master_pickable_commit.short_id}. You can now submit a merge request to get this change into the original branch.") expect(page).to have_content("From cherry-pick-#{master_pickable_commit.short_id} into master") end diff --git a/spec/features/projects/commits/user_browses_commits_spec.rb b/spec/features/projects/commits/user_browses_commits_spec.rb index 131d9097f48aa4793d7148490c9bbe9f5ffd106c..b22715a44f0509b05f85609a7cf4a2f7b7ee570d 100644 --- a/spec/features/projects/commits/user_browses_commits_spec.rb +++ b/spec/features/projects/commits/user_browses_commits_spec.rb @@ -56,8 +56,6 @@ describe 'User browses commits' do project.enable_ci create(:ci_build, pipeline: pipeline) - - allow_any_instance_of(Ci::Pipeline).to receive(:ci_yaml_file).and_return('') end it 'renders commit ci info' do @@ -94,8 +92,12 @@ describe 'User browses commits' do let(:commit) { create(:commit, project: project) } it 'renders successfully' do - allow_any_instance_of(Gitlab::Diff::File).to receive(:blob).and_return(nil) - allow_any_instance_of(Gitlab::Diff::File).to receive(:binary?).and_return(true) + allow_next_instance_of(Gitlab::Diff::File) do |instance| + allow(instance).to receive(:blob).and_return(nil) + end + allow_next_instance_of(Gitlab::Diff::File) do |instance| + allow(instance).to receive(:binary?).and_return(true) + end visit(project_commit_path(project, commit)) diff --git a/spec/features/projects/compare_spec.rb b/spec/features/projects/compare_spec.rb index 34bde29c8dad37e61d3bd0fa9b074b6dce2de995..df5cec80ae42152cc09c59651950060d07fb103d 100644 --- a/spec/features/projects/compare_spec.rb +++ b/spec/features/projects/compare_spec.rb @@ -107,7 +107,9 @@ describe "Compare", :js do visit project_compare_index_path(project, from: "feature", to: "master") allow(Commit).to receive(:max_diff_options).and_return(max_files: 3) - allow_any_instance_of(DiffHelper).to receive(:render_overflow_warning?).and_return(true) + allow_next_instance_of(DiffHelper) do |instance| + allow(instance).to receive(:render_overflow_warning?).and_return(true) + end click_button('Compare') @@ -136,7 +138,7 @@ describe "Compare", :js do def select_using_dropdown(dropdown_type, selection, commit: false) dropdown = find(".js-compare-#{dropdown_type}-dropdown") dropdown.find(".compare-dropdown-toggle").click - # find input before using to wait for the inputs visiblity + # find input before using to wait for the inputs visibility dropdown.find('.dropdown-menu') dropdown.fill_in("Filter by Git revision", with: selection) wait_for_requests @@ -144,7 +146,7 @@ describe "Compare", :js do if commit dropdown.find('input[type="search"]').send_keys(:return) else - # find before all to wait for the items visiblity + # find before all to wait for the items visibility dropdown.find("a[data-ref=\"#{selection}\"]", match: :first) dropdown.all("a[data-ref=\"#{selection}\"]").last.click end diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb index dd690699ff63a1561875bf473d80b96302bb7580..3eab13cb8206b52ce45cb633f415d515f02d990c 100644 --- a/spec/features/projects/environments/environment_spec.rb +++ b/spec/features/projects/environments/environment_spec.rb @@ -175,8 +175,9 @@ describe 'Environment' do # # In EE we have to stub EE::Environment since it overwrites # the "terminals" method. - allow_any_instance_of(Gitlab.ee? ? EE::Environment : Environment) - .to receive(:terminals) { nil } + allow_next_instance_of(Gitlab.ee? ? EE::Environment : Environment) do |instance| + allow(instance).to receive(:terminals) { nil } + end visit terminal_project_environment_path(project, environment) end diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb index 1a2302b3d0ca3f43ab1e36e3d6db880a459119da..74c2758c30f9306dc1549dfc733588a7ca93caa7 100644 --- a/spec/features/projects/environments/environments_spec.rb +++ b/spec/features/projects/environments/environments_spec.rb @@ -71,7 +71,9 @@ describe 'Environments page', :js do let!(:application_prometheus) { create(:clusters_applications_prometheus, :installed, cluster: cluster) } before do - allow_any_instance_of(Kubeclient::Client).to receive(:proxy_url).and_raise(Kubeclient::HttpError.new(401, 'Unauthorized', nil)) + allow_next_instance_of(Kubeclient::Client) do |instance| + allow(instance).to receive(:proxy_url).and_raise(Kubeclient::HttpError.new(401, 'Unauthorized', nil)) + end end it 'shows one environment without error' do diff --git a/spec/features/projects/features_visibility_spec.rb b/spec/features/projects/features_visibility_spec.rb index 9ec61743a119cb7d5f7fccaddf6b926242c86e4a..5553e496e7aacf4d9831587bf22b947edc37c45f 100644 --- a/spec/features/projects/features_visibility_spec.rb +++ b/spec/features/projects/features_visibility_spec.rb @@ -42,7 +42,9 @@ describe 'Edit Project Settings' do context 'When external issue tracker is enabled and issues enabled on project settings' do it 'does not hide issues tab' do - allow_any_instance_of(Project).to receive(:external_issue_tracker).and_return(JiraService.new) + allow_next_instance_of(Project) do |instance| + allow(instance).to receive(:external_issue_tracker).and_return(JiraService.new) + end visit project_path(project) @@ -54,7 +56,9 @@ describe 'Edit Project Settings' do it 'hides issues tab' do project.issues_enabled = false project.save! - allow_any_instance_of(Project).to receive(:external_issue_tracker).and_return(JiraService.new) + allow_next_instance_of(Project) do |instance| + allow(instance).to receive(:external_issue_tracker).and_return(JiraService.new) + end visit project_path(project) diff --git a/spec/features/projects/files/files_sort_submodules_with_folders_spec.rb b/spec/features/projects/files/files_sort_submodules_with_folders_spec.rb index 0e43f2fd26b23a4c3182cd4297f9bb3f2c8cbb91..622764487d8fb9ca9f4f19be186540bc3163e3ed 100644 --- a/spec/features/projects/files/files_sort_submodules_with_folders_spec.rb +++ b/spec/features/projects/files/files_sort_submodules_with_folders_spec.rb @@ -7,13 +7,11 @@ describe 'Projects > Files > User views files page' do let(:user) { project.owner } before do - stub_feature_flags(vue_file_list: false) - sign_in user visit project_tree_path(project, project.repository.root_ref) end - it 'user sees folders and submodules sorted together, followed by files' do + it 'user sees folders and submodules sorted together, followed by files', :js do rows = all('td.tree-item-file-name').map(&:text) tree = project.repository.tree diff --git a/spec/features/projects/files/project_owner_creates_license_file_spec.rb b/spec/features/projects/files/project_owner_creates_license_file_spec.rb index 943c6e0e95949a1e6f2ff9bc31fb161556a5c0e2..9fccb3441d660b7dba3075f7fa42ee0d4d43fd29 100644 --- a/spec/features/projects/files/project_owner_creates_license_file_spec.rb +++ b/spec/features/projects/files/project_owner_creates_license_file_spec.rb @@ -7,7 +7,6 @@ describe 'Projects > Files > Project owner creates a license file', :js do let(:project_maintainer) { project.owner } before do - stub_feature_flags(vue_file_list: false) project.repository.delete_file(project_maintainer, 'LICENSE', message: 'Remove LICENSE', branch_name: 'master') sign_in(project_maintainer) @@ -39,7 +38,7 @@ describe 'Projects > Files > Project owner creates a license file', :js do end it 'project maintainer creates a license file from the "Add license" link' do - click_link 'Add license' + click_link 'Add LICENSE' expect(page).to have_content('New file') expect(current_path).to eq( diff --git a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb index 9f63b312146cb631456f88adbb27c055345d0ef8..ad6c565c8f9630d0eac133e797d1acebe797ee48 100644 --- a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb +++ b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb @@ -12,7 +12,7 @@ describe 'Projects > Files > Project owner sees a link to create a license file it 'project maintainer creates a license file from a template' do visit project_path(project) - click_on 'Add license' + click_on 'Add LICENSE' expect(page).to have_content('New file') expect(current_path).to eq( diff --git a/spec/features/projects/files/user_browses_files_spec.rb b/spec/features/projects/files/user_browses_files_spec.rb index 0b3f905b5dea2e9250f29c8272a66d08b3ebbe73..10672bbec68b75f447271e56fd42b185c23c09e3 100644 --- a/spec/features/projects/files/user_browses_files_spec.rb +++ b/spec/features/projects/files/user_browses_files_spec.rb @@ -13,23 +13,22 @@ describe "User browses files" do let(:user) { project.owner } before do - stub_feature_flags(vue_file_list: false) sign_in(user) end - it "shows last commit for current directory" do + it "shows last commit for current directory", :js do visit(tree_path_root_ref) click_link("files") last_commit = project.repository.last_commit_for_path(project.default_branch, "files") - page.within(".blob-commit-info") do + page.within(".commit-detail") do expect(page).to have_content(last_commit.short_id).and have_content(last_commit.author_name) end end - context "when browsing the master branch" do + context "when browsing the master branch", :js do before do visit(tree_path_root_ref) end @@ -124,8 +123,7 @@ describe "User browses files" do expect(current_path).to eq(project_tree_path(project, "markdown/doc/raketasks")) expect(page).to have_content("backup_restore.md").and have_content("maintenance.md") - click_link("shop") - click_link("Maintenance") + click_link("maintenance.md") expect(current_path).to eq(project_blob_path(project, "markdown/doc/raketasks/maintenance.md")) expect(page).to have_content("bundle exec rake gitlab:env:info RAILS_ENV=production") @@ -144,7 +142,7 @@ describe "User browses files" do # rubocop:disable Lint/Void # Test the full URLs of links instead of relative paths by `have_link(text: "...", href: "...")`. - find("a", text: /^empty$/)["href"] == project_tree_url(project, "markdown/d") + find("a", text: "..")["href"] == project_tree_url(project, "markdown/d") # rubocop:enable Lint/Void page.within(".tree-table") do @@ -168,7 +166,7 @@ describe "User browses files" do end end - context "when browsing a specific ref" do + context "when browsing a specific ref", :js do let(:ref) { project_tree_path(project, "6d39438") } before do @@ -180,7 +178,7 @@ describe "User browses files" do expect(page).to have_content(".gitignore").and have_content("LICENSE") end - it "shows files from a repository with apostroph in its name", :js do + it "shows files from a repository with apostroph in its name" do first(".js-project-refs-dropdown").click page.within(".project-refs-form") do @@ -191,10 +189,10 @@ describe "User browses files" do visit(project_tree_path(project, "'test'")) - expect(page).to have_css(".tree-commit-link").and have_no_content("Loading commit data...") + expect(page).not_to have_selector(".tree-commit .animation-container") end - it "shows the code with a leading dot in the directory", :js do + it "shows the code with a leading dot in the directory" do first(".js-project-refs-dropdown").click page.within(".project-refs-form") do @@ -203,7 +201,7 @@ describe "User browses files" do visit(project_tree_path(project, "fix/.testdir")) - expect(page).to have_css(".tree-commit-link").and have_no_content("Loading commit data...") + expect(page).not_to have_selector(".tree-commit .animation-container") end it "does not show the permalink link" do @@ -221,7 +219,7 @@ describe "User browses files" do click_link(".gitignore") end - it "shows a file content", :js do + it "shows a file content" do expect(page).to have_content("*.rbc") 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 08ebeed2cdd388403d84b3b858f0cb267f375f2b..618290416bd3aeaa11e5985ad8dd652b039eceb9 100644 --- a/spec/features/projects/files/user_browses_lfs_files_spec.rb +++ b/spec/features/projects/files/user_browses_lfs_files_spec.rb @@ -7,8 +7,6 @@ describe 'Projects > Files > User browses LFS files' do let(:user) { project.owner } before do - stub_feature_flags(vue_file_list: false) - sign_in(user) end diff --git a/spec/features/projects/files/user_creates_directory_spec.rb b/spec/features/projects/files/user_creates_directory_spec.rb index 19d95c87c6c54c8d6e73ee4cebe4b2a5fa7b0325..b87650662171a4549f683696fb2c9cedb216be89 100644 --- a/spec/features/projects/files/user_creates_directory_spec.rb +++ b/spec/features/projects/files/user_creates_directory_spec.rb @@ -13,8 +13,6 @@ describe 'Projects > Files > User creates a directory', :js do let(:user) { create(:user) } before do - stub_feature_flags(vue_file_list: false) - project.add_developer(user) sign_in(user) visit project_tree_path(project, 'master') @@ -71,7 +69,7 @@ describe 'Projects > Files > User creates a directory', :js do visit(project2_tree_path_root_ref) end - it 'creates a directory in a forked project' do + it 'creates a directory in a forked project', :sidekiq_might_not_need_inline do find('.add-to-tree').click click_link('New directory') diff --git a/spec/features/projects/files/user_creates_files_spec.rb b/spec/features/projects/files/user_creates_files_spec.rb index 74c037641cd096a417532fb391cadbb8f8662e51..eb9a4d8cb09b6223807a1f20b20f77c29023c32a 100644 --- a/spec/features/projects/files/user_creates_files_spec.rb +++ b/spec/features/projects/files/user_creates_files_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe 'Projects > Files > User creates files' do +describe 'Projects > Files > User creates files', :js do let(:fork_message) do "You're not allowed to make changes to this project directly. "\ "A fork of this project has been created that you can make changes in, so you can submit a merge request." @@ -14,7 +14,6 @@ describe 'Projects > Files > User creates files' do let(:user) { create(:user) } before do - stub_feature_flags(vue_file_list: false) stub_feature_flags(web_ide_default: false) project.add_maintainer(user) @@ -42,7 +41,7 @@ describe 'Projects > Files > User creates files' do visit(project2_tree_path_root_ref) end - it 'opens new file page on a forked project' do + it 'opens new file page on a forked project', :sidekiq_might_not_need_inline do find('.add-to-tree').click click_link('New file') @@ -68,8 +67,7 @@ describe 'Projects > Files > User creates files' do file_name = find('#file_name') file_name.set options[:file_name] || 'README.md' - file_content = find('#file-content', visible: false) - file_content.set options[:file_content] || 'Some content' + find('.ace_text-input', visible: false).send_keys.native.send_keys options[:file_content] || 'Some content' click_button 'Commit changes' end @@ -89,7 +87,7 @@ describe 'Projects > Files > User creates files' do expect(page).to have_content 'Path cannot include directory traversal' end - it 'creates and commit a new file', :js do + it 'creates and commit a new file' do find('#editor') execute_script("ace.edit('editor').setValue('*.rbca')") fill_in(:file_name, with: 'not_a_file.md') @@ -105,7 +103,7 @@ describe 'Projects > Files > User creates files' do expect(page).to have_content('*.rbca') end - it 'creates and commit a new file with new lines at the end of file', :js do + it 'creates and commit a new file with new lines at the end of file' do find('#editor') execute_script('ace.edit("editor").setValue("Sample\n\n\n")') fill_in(:file_name, with: 'not_a_file.md') @@ -122,7 +120,7 @@ describe 'Projects > Files > User creates files' do expect(evaluate_script('ace.edit("editor").getValue()')).to eq("Sample\n\n\n") end - it 'creates and commit a new file with a directory name', :js do + it 'creates and commit a new file with a directory name' do fill_in(:file_name, with: 'foo/bar/baz.txt') expect(page).to have_selector('.file-editor') @@ -139,7 +137,7 @@ describe 'Projects > Files > User creates files' do expect(page).to have_content('*.rbca') end - it 'creates and commit a new file specifying a new branch', :js do + it 'creates and commit a new file specifying a new branch' do expect(page).to have_selector('.file-editor') find('#editor') @@ -159,7 +157,7 @@ describe 'Projects > Files > User creates files' do end end - context 'when an user does not have write access' do + context 'when an user does not have write access', :sidekiq_might_not_need_inline do before do project2.add_reporter(user) visit(project2_tree_path_root_ref) @@ -174,7 +172,7 @@ describe 'Projects > Files > User creates files' do expect(page).to have_content(message) end - it 'creates and commit new file in forked project', :js do + it 'creates and commit new file in forked project' do expect(page).to have_selector('.file-editor') find('#editor') diff --git a/spec/features/projects/files/user_deletes_files_spec.rb b/spec/features/projects/files/user_deletes_files_spec.rb index fd4783cfb6b6de6489678b23064ea407015efa1f..0f543e47631291e717e6b438b5def68c387010a4 100644 --- a/spec/features/projects/files/user_deletes_files_spec.rb +++ b/spec/features/projects/files/user_deletes_files_spec.rb @@ -14,8 +14,6 @@ describe 'Projects > Files > User deletes files', :js do let(:user) { create(:user) } before do - stub_feature_flags(vue_file_list: false) - sign_in(user) end @@ -47,7 +45,7 @@ describe 'Projects > Files > User deletes files', :js do wait_for_requests end - it 'deletes the file in a forked project', :js do + it 'deletes the file in a forked project', :js, :sidekiq_might_not_need_inline do click_link('.gitignore') expect(page).to have_content('.gitignore') diff --git a/spec/features/projects/files/user_edits_files_spec.rb b/spec/features/projects/files/user_edits_files_spec.rb index 56430721ed6089cddc2729d29537a23287e078c4..374a7fb79364b239d768be3c5de3842cc63e9299 100644 --- a/spec/features/projects/files/user_edits_files_spec.rb +++ b/spec/features/projects/files/user_edits_files_spec.rb @@ -12,7 +12,6 @@ describe 'Projects > Files > User edits files', :js do before do stub_feature_flags(web_ide_default: false) - stub_feature_flags(vue_file_list: false) sign_in(user) end @@ -136,7 +135,7 @@ describe 'Projects > Files > User edits files', :js do ) end - it 'inserts a content of a file in a forked project' do + it 'inserts a content of a file in a forked project', :sidekiq_might_not_need_inline do click_link('.gitignore') click_button('Edit') @@ -154,7 +153,7 @@ describe 'Projects > Files > User edits files', :js do expect(evaluate_script('ace.edit("editor").getValue()')).to eq('*.rbca') end - it 'opens the Web IDE in a forked project' do + it 'opens the Web IDE in a forked project', :sidekiq_might_not_need_inline do click_link('.gitignore') click_button('Web IDE') @@ -168,7 +167,7 @@ describe 'Projects > Files > User edits files', :js do expect(page).to have_css('.ide .multi-file-tab', text: '.gitignore') end - it 'commits an edited file in a forked project' do + it 'commits an edited file in a forked project', :sidekiq_might_not_need_inline do click_link('.gitignore') find('.js-edit-blob').click @@ -199,7 +198,7 @@ describe 'Projects > Files > User edits files', :js do wait_for_requests end - it 'links to the forked project for editing' do + it 'links to the forked project for editing', :sidekiq_might_not_need_inline do click_link('.gitignore') find('.js-edit-blob').click diff --git a/spec/features/projects/files/user_reads_pipeline_status_spec.rb b/spec/features/projects/files/user_reads_pipeline_status_spec.rb index 15f8fa7438dea5649d2f13b6ba64a9d954262678..9d38c44b6ef6faa85a1358517db930eb7618a3ac 100644 --- a/spec/features/projects/files/user_reads_pipeline_status_spec.rb +++ b/spec/features/projects/files/user_reads_pipeline_status_spec.rb @@ -9,8 +9,6 @@ describe 'user reads pipeline status', :js do let(:x110_pipeline) { create_pipeline('x1.1.0', 'failed') } before do - stub_feature_flags(vue_file_list: false) - project.add_maintainer(user) project.repository.add_tag(user, 'x1.1.0', 'v1.1.0') @@ -25,7 +23,7 @@ describe 'user reads pipeline status', :js do visit project_tree_path(project, expected_pipeline.ref) wait_for_requests - page.within('.blob-commit-info') do + page.within('.commit-detail') do expect(page).to have_link('', href: project_pipeline_path(project, expected_pipeline)) expect(page).to have_selector(".ci-status-icon-#{expected_pipeline.status}") end diff --git a/spec/features/projects/files/user_replaces_files_spec.rb b/spec/features/projects/files/user_replaces_files_spec.rb index d50bc0a7d18864f4da97ec70ba2a305929678eb2..4c54bbdcd67318fe92d307fbc0cfcbd8530e89af 100644 --- a/spec/features/projects/files/user_replaces_files_spec.rb +++ b/spec/features/projects/files/user_replaces_files_spec.rb @@ -16,8 +16,6 @@ describe 'Projects > Files > User replaces files', :js do let(:user) { create(:user) } before do - stub_feature_flags(vue_file_list: false) - sign_in(user) end @@ -55,7 +53,7 @@ describe 'Projects > Files > User replaces files', :js do wait_for_requests end - it 'replaces an existed file with a new one in a forked project' do + it 'replaces an existed file with a new one in a forked project', :sidekiq_might_not_need_inline do click_link('.gitignore') expect(page).to have_content('.gitignore') diff --git a/spec/features/projects/files/user_uploads_files_spec.rb b/spec/features/projects/files/user_uploads_files_spec.rb index 74b5d7c5041f92382f0d78dc57404f07eb79fad4..35a3835ff12c34b94cdbcf05dbf6909b581051d1 100644 --- a/spec/features/projects/files/user_uploads_files_spec.rb +++ b/spec/features/projects/files/user_uploads_files_spec.rb @@ -16,8 +16,6 @@ describe 'Projects > Files > User uploads files' do let(:project2_tree_path_root_ref) { project_tree_path(project2, project2.repository.root_ref) } before do - stub_feature_flags(vue_file_list: false) - project.add_maintainer(user) sign_in(user) end @@ -76,7 +74,7 @@ describe 'Projects > Files > User uploads files' do visit(project2_tree_path_root_ref) end - it 'uploads and commit a new file to a forked project', :js do + it 'uploads and commit a new file to a forked project', :js, :sidekiq_might_not_need_inline do find('.add-to-tree').click click_link('Upload file') diff --git a/spec/features/projects/fork_spec.rb b/spec/features/projects/fork_spec.rb index 6792a6e2af05f2ba3a9cff2cac95980567cbb785..0f97032eefa9e366b562dd34dc6a3df08d55c5bf 100644 --- a/spec/features/projects/fork_spec.rb +++ b/spec/features/projects/fork_spec.rb @@ -27,7 +27,7 @@ describe 'Project fork' do expect(page).to have_css('a.disabled', text: 'Fork') end - it 'forks the project' do + it 'forks the project', :sidekiq_might_not_need_inline do visit project_path(project) click_link 'Fork' @@ -174,7 +174,7 @@ describe 'Project fork' do expect(page).to have_css('.fork-thumbnail.disabled') end - it 'links to the fork if the project was already forked within that namespace' do + it 'links to the fork if the project was already forked within that namespace', :sidekiq_might_not_need_inline do forked_project = fork_project(project, user, namespace: group, repository: true) visit new_project_fork_path(project) diff --git a/spec/features/projects/forks/fork_list_spec.rb b/spec/features/projects/forks/fork_list_spec.rb index 2dbe3d90bad4a9e9c7ea8a7a7178b0de2efbfafd..3b63d9a4c2dbbfd94f91f138bb4092fb25e5d95c 100644 --- a/spec/features/projects/forks/fork_list_spec.rb +++ b/spec/features/projects/forks/fork_list_spec.rb @@ -15,7 +15,7 @@ describe 'listing forks of a project' do sign_in(user) end - it 'shows the forked project in the list with commit as description' do + it 'shows the forked project in the list with commit as description', :sidekiq_might_not_need_inline do visit project_forks_path(source) page.within('li.project-row') do diff --git a/spec/features/projects/graph_spec.rb b/spec/features/projects/graph_spec.rb index 6082eb033744aa5327f912e4ab312f7bfbdfc18c..5dabaf20952552d7d27464c9991c8513e045609c 100644 --- a/spec/features/projects/graph_spec.rb +++ b/spec/features/projects/graph_spec.rb @@ -29,12 +29,6 @@ describe 'Project Graph', :js do end end - it 'renders graphs' do - visit project_graph_path(project, 'master') - - expect(page).to have_selector('.stat-graph', visible: false) - end - context 'commits graph' do before do visit commits_project_graph_path(project, 'master') diff --git a/spec/features/projects/import_export/export_file_spec.rb b/spec/features/projects/import_export/export_file_spec.rb index 7618a2bdea3f8b611d38727981455febebee4050..c15a3250221bf3fee5b02eae47fae9b5a209a8d5 100644 --- a/spec/features/projects/import_export/export_file_spec.rb +++ b/spec/features/projects/import_export/export_file_spec.rb @@ -26,7 +26,9 @@ describe 'Import/Export - project export integration test', :js do let(:project) { setup_project } before do - allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path) + allow_next_instance_of(Gitlab::ImportExport) do |instance| + allow(instance).to receive(:storage_path).and_return(export_path) + end end after do @@ -38,7 +40,7 @@ describe 'Import/Export - project export integration test', :js do sign_in(user) end - it 'exports a project successfully' do + it 'exports a project successfully', :sidekiq_might_not_need_inline do visit edit_project_path(project) expect(page).to have_content('Export project') diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb index 6f96da60a31b4f0b105316aa246e2b72de2672a4..33c7182c084570004bf7df61214c7d08b2b4b9e8 100644 --- a/spec/features/projects/import_export/import_file_spec.rb +++ b/spec/features/projects/import_export/import_file_spec.rb @@ -11,7 +11,9 @@ describe 'Import/Export - project import integration test', :js do before do stub_uploads_object_storage(FileUploader) - allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path) + allow_next_instance_of(Gitlab::ImportExport) do |instance| + allow(instance).to receive(:storage_path).and_return(export_path) + end gitlab_sign_in(user) end @@ -27,7 +29,7 @@ describe 'Import/Export - project import integration test', :js do let(:project_path) { 'test-project-name' + randomHex } context 'prefilled the path' do - it 'user imports an exported project successfully' do + it 'user imports an exported project successfully', :sidekiq_might_not_need_inline do visit new_project_path fill_in :project_name, with: project_name, visible: true @@ -53,7 +55,7 @@ describe 'Import/Export - project import integration test', :js do end context 'path is not prefilled' do - it 'user imports an exported project successfully' do + it 'user imports an exported project successfully', :sidekiq_might_not_need_inline do visit new_project_path click_import_project_tab click_link 'GitLab export' diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb index f5d5bc7f5b901f2f021c68034dd561e7f3bac7ca..c9568dbb7ceee6822bddc310b449916ff761fc53 100644 --- a/spec/features/projects/jobs_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -166,7 +166,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do let(:source_project) { fork_project(project, user, repository: true) } let(:target_project) { project } - it 'shows merge request iid and source branch' do + it 'shows merge request iid and source branch', :sidekiq_might_not_need_inline do visit project_job_path(source_project, job) within '.js-pipeline-info' do @@ -214,7 +214,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do let(:source_project) { fork_project(project, user, repository: true) } let(:target_project) { project } - it 'shows merge request iid and source branch' do + it 'shows merge request iid and source branch', :sidekiq_might_not_need_inline do visit project_job_path(source_project, job) within '.js-pipeline-info' do diff --git a/spec/features/projects/labels/search_labels_spec.rb b/spec/features/projects/labels/search_labels_spec.rb index 2d5a138c3cc99dcfa5436040a036c7b4d4af016e..e2eec7400ff22b2b883bd810e19e030585e13824 100644 --- a/spec/features/projects/labels/search_labels_spec.rb +++ b/spec/features/projects/labels/search_labels_spec.rb @@ -68,7 +68,7 @@ describe 'Search for labels', :js do find('#label-search').native.send_keys(:enter) page.within('.prioritized-labels') do - expect(page).to have_content('No prioritised labels with such name or description') + expect(page).to have_content('No prioritized labels with such name or description') end page.within('.other-labels') do diff --git a/spec/features/projects/members/member_leaves_project_spec.rb b/spec/features/projects/members/member_leaves_project_spec.rb index fb1165838c710b90c14a698b4b473a47bba98b79..cb7a405e8219d73fad7ad5f273b1b41763737b0b 100644 --- a/spec/features/projects/members/member_leaves_project_spec.rb +++ b/spec/features/projects/members/member_leaves_project_spec.rb @@ -20,7 +20,7 @@ describe 'Projects > Members > Member leaves project' do expect(project.users.exists?(user.id)).to be_falsey end - it 'user leaves project by url param', :js do + it 'user leaves project by url param', :js, :quarantine do visit project_path(project, leave: 1) page.accept_confirm diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb index a77f0bdcbe9a0458eae891ce766023d1ec734634..7e7faca97419b554a08d3f63fea570e142733684 100644 --- a/spec/features/projects/members/user_requests_access_spec.rb +++ b/spec/features/projects/members/user_requests_access_spec.rb @@ -26,7 +26,6 @@ describe 'Projects > Members > User requests access', :js do expect(ActionMailer::Base.deliveries.last.subject).to eq "Request to join the #{project.full_name} project" expect(project.requesters.exists?(user_id: user)).to be_truthy - expect(page).to have_content 'Your request for access has been queued for review.' expect(page).to have_content 'Withdraw Access Request' expect(page).not_to have_content 'Leave Project' @@ -64,7 +63,6 @@ describe 'Projects > Members > User requests access', :js do accept_confirm { click_link 'Withdraw Access Request' } - expect(page).to have_content 'Your access request to the project has been withdrawn.' expect(page).not_to have_content 'Withdraw Access Request' expect(page).to have_content 'Request Access' end diff --git a/spec/features/projects/milestones/milestone_spec.rb b/spec/features/projects/milestones/milestone_spec.rb index 5e94b2f721e7ce81238a50365e3b576d13b5b010..fb9667cd67dc487279b97487710415e2f8a5a116 100644 --- a/spec/features/projects/milestones/milestone_spec.rb +++ b/spec/features/projects/milestones/milestone_spec.rb @@ -7,6 +7,18 @@ describe 'Project milestone' do let(:project) { create(:project, name: 'test', namespace: user.namespace) } let(:milestone) { create(:milestone, project: project) } + def toggle_sidebar + find('.milestone-sidebar .gutter-toggle').click + end + + def sidebar_release_block + find('.milestone-sidebar .block.releases') + end + + def sidebar_release_block_collapsed_icon + find('.milestone-sidebar .block.releases .sidebar-collapsed-icon') + end + before do sign_in(user) end @@ -39,15 +51,16 @@ describe 'Project milestone' do context 'when project has disabled issues' do before do + create(:issue, project: project, milestone: milestone) project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED) + visit project_milestone_path(project, milestone) end - it 'hides issues tab' do + it 'does not show any issues under the issues tab' do within('#content-body') do - expect(page).not_to have_link 'Issues', href: '#tab-issues' - expect(page).to have_selector '.nav-links li a.active', count: 1 - expect(find('.nav-links li a.active')).to have_content 'Merge Requests' + expect(find('.nav-links li a.active')).to have_content 'Issues' + expect(page).not_to have_selector '.issuable-row' end end @@ -75,17 +88,96 @@ describe 'Project milestone' do describe 'the collapsed sidebar' do before do - find('.milestone-sidebar .gutter-toggle').click + toggle_sidebar end it 'shows the total MR and issue counts' do find('.milestone-sidebar .block', match: :first) aggregate_failures 'MR and issue blocks' do - expect(find('.milestone-sidebar .block.issues')).to have_content 1 - expect(find('.milestone-sidebar .block.merge-requests')).to have_content 0 + expect(find('.milestone-sidebar .block.issues')).to have_content '1' + expect(find('.milestone-sidebar .block.merge-requests')).to have_content '0' end end end end + + context 'when the milestone is not associated with a release' do + before do + visit project_milestone_path(project, milestone) + end + + it 'shows "None" in the "Releases" section' do + expect(sidebar_release_block).to have_content 'Releases None' + end + + describe 'when the sidebar is collapsed' do + before do + toggle_sidebar + end + + it 'shows "0" in the "Releases" section' do + expect(sidebar_release_block).to have_content '0' + end + + it 'has a tooltip that reads "Releases"' do + expect(sidebar_release_block_collapsed_icon['title']).to eq 'Releases' + end + end + end + + context 'when the milestone is associated with one release' do + before do + create(:release, project: project, name: 'Version 5', milestones: [milestone]) + + visit project_milestone_path(project, milestone) + end + + it 'shows "Version 5" in the "Release" section' do + expect(sidebar_release_block).to have_content 'Release Version 5' + end + + describe 'when the sidebar is collapsed' do + before do + toggle_sidebar + end + + it 'shows "1" in the "Releases" section' do + expect(sidebar_release_block).to have_content '1' + end + + it 'has a tooltip that reads "1 release"' do + expect(sidebar_release_block_collapsed_icon['title']).to eq '1 release' + end + end + end + + context 'when the milestone is associated with multiple releases' do + before do + (5..10).each do |num| + released_at = Time.zone.parse('2019-10-04') + num.months + create(:release, project: project, name: "Version #{num}", milestones: [milestone], released_at: released_at) + end + + visit project_milestone_path(project, milestone) + end + + it 'shows a shortened list of releases in the "Releases" section' do + expect(sidebar_release_block).to have_content 'Releases Version 10 • Version 9 • Version 8 • 3 more releases' + end + + describe 'when the sidebar is collapsed' do + before do + toggle_sidebar + end + + it 'shows "6" in the "Releases" section' do + expect(sidebar_release_block).to have_content '6' + end + + it 'has a tooltip that reads "6 releases"' do + expect(sidebar_release_block_collapsed_icon['title']).to eq '6 releases' + end + end + end end diff --git a/spec/features/projects/pages_lets_encrypt_spec.rb b/spec/features/projects/pages_lets_encrypt_spec.rb index 8b5964b2eeea07c4530b0cce26204e9224d216cc..d09014e915df231a7e0865be2ebdbb5936c66ae8 100644 --- a/spec/features/projects/pages_lets_encrypt_spec.rb +++ b/spec/features/projects/pages_lets_encrypt_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' describe "Pages with Let's Encrypt", :https_pages_enabled do include LetsEncryptHelpers - let(:project) { create(:project) } + let(:project) { create(:project, pages_https_only: false) } let(:user) { create(:user) } let(:role) { :maintainer } let(:certificate_pem) { attributes_for(:pages_domain)[:certificate] } @@ -18,7 +18,21 @@ describe "Pages with Let's Encrypt", :https_pages_enabled do project.add_role(user, role) sign_in(user) project.namespace.update(owner: user) - allow_any_instance_of(Project).to receive(:pages_deployed?) { true } + allow_next_instance_of(Project) do |instance| + allow(instance).to receive(:pages_deployed?) { true } + end + end + + it "creates new domain with Let's Encrypt enabled by default" do + visit new_project_pages_domain_path(project) + + fill_in 'Domain', with: 'my.test.domain.com' + + expect(find("#pages_domain_auto_ssl_enabled", visible: false).value).to eq 'true' + click_button 'Create New Domain' + + expect(page).to have_content('my.test.domain.com') + expect(PagesDomain.find_by_domain('my.test.domain.com').auto_ssl_enabled).to eq(true) end context 'when the auto SSL management is initially disabled' do @@ -32,14 +46,14 @@ describe "Pages with Let's Encrypt", :https_pages_enabled do expect(domain.auto_ssl_enabled).to eq false expect(find("#pages_domain_auto_ssl_enabled", visible: false).value).to eq 'false' - expect(page).to have_field 'Certificate (PEM)', type: 'textarea' - expect(page).to have_field 'Key (PEM)', type: 'textarea' + expect(page).to have_selector '.card-header', text: 'Certificate' + expect(page).to have_text domain.subject find('.js-auto-ssl-toggle-container .project-feature-toggle').click expect(find("#pages_domain_auto_ssl_enabled", visible: false).value).to eq 'true' - expect(page).not_to have_field 'Certificate (PEM)', type: 'textarea' - expect(page).not_to have_field 'Key (PEM)', type: 'textarea' + expect(page).not_to have_selector '.card-header', text: 'Certificate' + expect(page).not_to have_text domain.subject click_on 'Save Changes' @@ -65,9 +79,6 @@ describe "Pages with Let's Encrypt", :https_pages_enabled do expect(page).to have_field 'Certificate (PEM)', type: 'textarea' expect(page).to have_field 'Key (PEM)', type: 'textarea' - fill_in 'Certificate (PEM)', with: certificate_pem - fill_in 'Key (PEM)', with: certificate_key - click_on 'Save Changes' expect(domain.reload.auto_ssl_enabled).to eq false @@ -79,7 +90,8 @@ describe "Pages with Let's Encrypt", :https_pages_enabled do it 'user do not see private key' do visit edit_project_pages_domain_path(project, domain) - expect(find_field('Key (PEM)', visible: :all, disabled: :all).value).to be_blank + expect(page).not_to have_selector '.card-header', text: 'Certificate' + expect(page).not_to have_text domain.subject end end @@ -96,12 +108,23 @@ describe "Pages with Let's Encrypt", :https_pages_enabled do end context 'when certificate is provided by user' do - let(:domain) { create(:pages_domain, project: project) } + let(:domain) { create(:pages_domain, project: project, auto_ssl_enabled: false) } + + it 'user sees certificate subject' do + visit edit_project_pages_domain_path(project, domain) + + expect(page).to have_selector '.card-header', text: 'Certificate' + expect(page).to have_text domain.subject + end - it 'user sees private key' do + it 'user can delete the certificate', :js do visit edit_project_pages_domain_path(project, domain) - expect(find_field('Key (PEM)').value).not_to be_blank + expect(page).to have_selector '.card-header', text: 'Certificate' + expect(page).to have_text domain.subject + within('.card') { accept_confirm { click_on 'Remove' } } + expect(page).to have_field 'Certificate (PEM)', with: '' + expect(page).to have_field 'Key (PEM)', with: '' end end end diff --git a/spec/features/projects/pages_spec.rb b/spec/features/projects/pages_spec.rb index d55e9d1280113d31a050fab1b8d55edea0c6d427..3c4b5b2c4ca641d64f443f65e465fe678c4e96cd 100644 --- a/spec/features/projects/pages_spec.rb +++ b/spec/features/projects/pages_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' shared_examples 'pages settings editing' do - let(:project) { create(:project) } + let_it_be(:project) { create(:project, pages_https_only: false) } let(:user) { create(:user) } let(:role) { :maintainer } @@ -30,12 +30,52 @@ shared_examples 'pages settings editing' do expect(page).to have_content('Access pages') end + context 'when pages are disabled in the project settings' do + it 'renders disabled warning' do + project.project_feature.update!(pages_access_level: ProjectFeature::DISABLED) + + visit project_pages_path(project) + + expect(page).to have_content('GitLab Pages are disabled for this project') + end + end + it 'renders first deployment warning' do visit project_pages_path(project) expect(page).to have_content('It may take up to 30 minutes before the site is available after the first deployment.') end + shared_examples 'does not render access control warning' do + it 'does not render access control warning' do + visit project_pages_path(project) + + expect(page).not_to have_content('Access Control is enabled for this Pages website') + end + end + + include_examples 'does not render access control warning' + + context 'when access control is enabled in gitlab settings' do + before do + stub_pages_setting(access_control: true) + end + + it 'renders access control warning' do + visit project_pages_path(project) + + expect(page).to have_content('Access Control is enabled for this Pages website') + end + + context 'when pages are public' do + before do + project.project_feature.update!(pages_access_level: ProjectFeature::PUBLIC) + end + + include_examples 'does not render access control warning' + end + end + context 'when support for external domains is disabled' do it 'renders message that support is disabled' do visit project_pages_path(project) @@ -93,7 +133,7 @@ shared_examples 'pages settings editing' do end end - context 'when pages are exposed on external HTTPS address', :https_pages_enabled do + context 'when pages are exposed on external HTTPS address', :https_pages_enabled, :js do let(:certificate_pem) do <<~PEM -----BEGIN CERTIFICATE----- @@ -138,6 +178,11 @@ shared_examples 'pages settings editing' do visit new_project_pages_domain_path(project) fill_in 'Domain', with: 'my.test.domain.com' + + if ::Gitlab::LetsEncrypt.enabled? + find('.js-auto-ssl-toggle-container .project-feature-toggle').click + end + fill_in 'Certificate (PEM)', with: certificate_pem fill_in 'Key (PEM)', with: certificate_key click_button 'Create New Domain' @@ -145,27 +190,49 @@ shared_examples 'pages settings editing' do expect(page).to have_content('my.test.domain.com') end + describe 'with dns verification enabled' do + before do + stub_application_setting(pages_domain_verification_enabled: true) + end + + it 'shows the DNS verification record' do + domain = create(:pages_domain, project: project) + + visit project_pages_path(project) + + within('#content-body') { click_link 'Edit' } + expect(page).to have_field :domain_verification, with: "#{domain.verification_domain} TXT #{domain.keyed_verification_code}" + end + end + describe 'updating the certificate for an existing domain' do let!(:domain) do - create(:pages_domain, project: project) + create(:pages_domain, project: project, auto_ssl_enabled: false) end it 'allows the certificate to be updated' do visit project_pages_path(project) - within('#content-body') { click_link 'Details' } - click_link 'Edit' + within('#content-body') { click_link 'Edit' } click_button 'Save Changes' expect(page).to have_content('Domain was updated') end context 'when the certificate is invalid' do + let!(:domain) do + create(:pages_domain, :without_certificate, :without_key, project: project) + end + it 'tells the user what the problem is' do visit project_pages_path(project) - within('#content-body') { click_link 'Details' } - click_link 'Edit' + within('#content-body') { click_link 'Edit' } + + if ::Gitlab::LetsEncrypt.enabled? + find('.js-auto-ssl-toggle-container .project-feature-toggle').click + end + fill_in 'Certificate (PEM)', with: 'invalid data' click_button 'Save Changes' @@ -174,6 +241,27 @@ shared_examples 'pages settings editing' do expect(page).to have_content("Key doesn't match the certificate") end end + + it 'allows the certificate to be removed', :js do + visit project_pages_path(project) + + within('#content-body') { click_link 'Edit' } + + accept_confirm { click_link 'Remove' } + + expect(page).to have_field('Certificate (PEM)', with: '') + expect(page).to have_field('Key (PEM)', with: '') + domain.reload + expect(domain.certificate).to be_nil + expect(domain.key).to be_nil + end + + it 'shows the DNS CNAME record' do + visit project_pages_path(project) + + within('#content-body') { click_link 'Edit' } + expect(page).to have_field :domain_dns, with: "#{domain.domain} CNAME #{domain.project.pages_subdomain}.#{Settings.pages.host}." + end end end end @@ -210,7 +298,7 @@ shared_examples 'pages settings editing' do end end - describe 'HTTPS settings', :js, :https_pages_enabled do + describe 'HTTPS settings', :https_pages_enabled do before do project.namespace.update(owner: user) @@ -318,18 +406,21 @@ shared_examples 'pages settings editing' do expect(page).to have_link('Remove pages') - click_link 'Remove pages' + accept_confirm { click_link 'Remove pages' } - expect(project.pages_deployed?).to be_falsey + expect(page).to have_content('Pages were removed') + expect(project.reload.pages_deployed?).to be_falsey end end end end -describe 'Pages' do +describe 'Pages', :js do include LetsEncryptHelpers - include_examples 'pages settings editing' + context 'when editing normally' do + include_examples 'pages settings editing' + end context 'when letsencrypt support is enabled' do before do diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index 04adb1ec6afce4dcac311f2c23072f2119fd3c13..94fac9a2eb5d99ef7c15157708143a6b421fd8b5 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -128,7 +128,7 @@ describe 'Pipeline', :js do end end - it 'cancels the running build and shows retry button' do + it 'cancels the running build and shows retry button', :sidekiq_might_not_need_inline do find('#ci-badge-deploy .ci-action-icon-container').click page.within('#ci-badge-deploy') do @@ -146,7 +146,7 @@ describe 'Pipeline', :js do end end - it 'cancels the preparing build and shows retry button' do + it 'cancels the preparing build and shows retry button', :sidekiq_might_not_need_inline do find('#ci-badge-deploy .ci-action-icon-container').click page.within('#ci-badge-deploy') do @@ -186,7 +186,7 @@ describe 'Pipeline', :js do end end - it 'unschedules the delayed job and shows play button as a manual job' do + it 'unschedules the delayed job and shows play button as a manual job', :sidekiq_might_not_need_inline do find('#ci-badge-delayed-job .ci-action-icon-container').click page.within('#ci-badge-delayed-job') do @@ -305,7 +305,9 @@ describe 'Pipeline', :js do find('.js-retry-button').click end - it { expect(page).not_to have_content('Retry') } + it 'does not show a "Retry" button', :sidekiq_might_not_need_inline do + expect(page).not_to have_content('Retry') + end end end @@ -321,7 +323,9 @@ describe 'Pipeline', :js do click_on 'Cancel running' end - it { expect(page).not_to have_content('Cancel running') } + it 'does not show a "Cancel running" button', :sidekiq_might_not_need_inline do + expect(page).not_to have_content('Cancel running') + end end end @@ -400,7 +404,7 @@ describe 'Pipeline', :js do visit project_pipeline_path(source_project, pipeline) end - it 'shows the pipeline information' do + it 'shows the pipeline information', :sidekiq_might_not_need_inline do within '.pipeline-info' do expect(page).to have_content("#{pipeline.statuses.count} jobs " \ "for !#{merge_request.iid} " \ @@ -473,7 +477,7 @@ describe 'Pipeline', :js do visit project_pipeline_path(source_project, pipeline) end - it 'shows the pipeline information' do + it 'shows the pipeline information', :sidekiq_might_not_need_inline do within '.pipeline-info' do expect(page).to have_content("#{pipeline.statuses.count} jobs " \ "for !#{merge_request.iid} " \ @@ -651,7 +655,9 @@ describe 'Pipeline', :js do find('.js-retry-button').click end - it { expect(page).not_to have_content('Retry') } + it 'does not show a "Retry" button', :sidekiq_might_not_need_inline do + expect(page).not_to have_content('Retry') + end end end @@ -663,7 +669,9 @@ describe 'Pipeline', :js do click_on 'Cancel running' end - it { expect(page).not_to have_content('Cancel running') } + it 'does not show a "Cancel running" button', :sidekiq_might_not_need_inline do + expect(page).not_to have_content('Cancel running') + end end end @@ -778,10 +786,10 @@ describe 'Pipeline', :js do expect(page).to have_content(failed_build.stage) end - it 'does not show trace' do + it 'does not show log' do subject - expect(page).to have_content('No job trace') + expect(page).to have_content('No job log') end end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index 76d8ad1638bac165d3b021ca971e20ba8685c2dc..f6eeb8d7065bbc945ef3be31732b2e89aee4a85e 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -133,14 +133,14 @@ describe 'Pipelines', :js do wait_for_requests end - it 'indicated that pipelines was canceled' do + it 'indicated that pipelines was canceled', :sidekiq_might_not_need_inline do expect(page).not_to have_selector('.js-pipelines-cancel-button') expect(page).to have_selector('.ci-canceled') end end end - context 'when pipeline is retryable' do + context 'when pipeline is retryable', :sidekiq_might_not_need_inline do let!(:build) do create(:ci_build, pipeline: pipeline, stage: 'test') @@ -185,33 +185,29 @@ describe 'Pipelines', :js do visit project_pipelines_path(source_project) end - shared_examples_for 'showing detached merge request pipeline information' do - it 'shows detached tag for the pipeline' do + shared_examples_for 'detached merge request pipeline' do + it 'shows pipeline information without pipeline ref', :sidekiq_might_not_need_inline do within '.pipeline-tags' do expect(page).to have_content('detached') end - end - it 'shows the link of the merge request' do within '.branch-commit' do expect(page).to have_link(merge_request.iid, href: project_merge_request_path(project, merge_request)) end - end - it 'does not show the ref of the pipeline' do within '.branch-commit' do expect(page).not_to have_link(pipeline.ref) end end end - it_behaves_like 'showing detached merge request pipeline information' + it_behaves_like 'detached merge request pipeline' context 'when source project is a forked project' do let(:source_project) { fork_project(project, user, repository: true) } - it_behaves_like 'showing detached merge request pipeline information' + it_behaves_like 'detached merge request pipeline' end end @@ -233,20 +229,16 @@ describe 'Pipelines', :js do end shared_examples_for 'Correct merge request pipeline information' do - it 'does not show detached tag for the pipeline' do + it 'does not show detached tag for the pipeline, and shows the link of the merge request, and does not show the ref of the pipeline', :sidekiq_might_not_need_inline do within '.pipeline-tags' do expect(page).not_to have_content('detached') end - end - it 'shows the link of the merge request' do within '.branch-commit' do expect(page).to have_link(merge_request.iid, href: project_merge_request_path(project, merge_request)) end - end - it 'does not show the ref of the pipeline' do within '.branch-commit' do expect(page).not_to have_link(pipeline.ref) end @@ -429,7 +421,7 @@ describe 'Pipelines', :js do find('.js-modal-primary-action').click end - it 'indicates that pipeline was canceled' do + it 'indicates that pipeline was canceled', :sidekiq_might_not_need_inline do expect(page).not_to have_selector('.js-pipelines-cancel-button') expect(page).to have_selector('.ci-canceled') end @@ -452,7 +444,7 @@ describe 'Pipelines', :js do expect(page).not_to have_selector('.js-pipelines-retry-button') end - it 'has failed pipeline' do + it 'has failed pipeline', :sidekiq_might_not_need_inline do expect(page).to have_selector('.ci-failed') end end diff --git a/spec/features/projects/settings/operations_settings_spec.rb b/spec/features/projects/settings/operations_settings_spec.rb index d96e243d96b1454d06f2083c252a454ab6d40509..9bbeb0eb260055e3813d94dd4b0753c8b5bc31e4 100644 --- a/spec/features/projects/settings/operations_settings_spec.rb +++ b/spec/features/projects/settings/operations_settings_spec.rb @@ -102,5 +102,30 @@ describe 'Projects > Settings > For a forked project', :js do end end end + + context 'grafana integration settings form' do + it 'successfully fills and completes the form' do + visit project_settings_operations_path(project) + + wait_for_requests + + within '.js-grafana-integration' do + click_button('Expand') + end + + expect(page).to have_content('Grafana URL') + expect(page).to have_content('API Token') + expect(page).to have_button('Save Changes') + + fill_in('grafana-url', with: 'http://gitlab-test.grafana.net') + fill_in('grafana-token', with: 'token') + + click_button('Save Changes') + + wait_for_requests + + assert_text('Your changes have been saved') + end + end end end diff --git a/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb b/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb index 9f09c5c45015a8762d354964c169f3430e7dd539..c0089e3c28c2267bb19e782c0819d85b9d4e0571 100644 --- a/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb +++ b/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb @@ -107,4 +107,27 @@ describe 'Projects > Settings > User manages merge request settings' do expect(project.printing_merge_request_link_enabled).to be(false) end end + + describe 'Checkbox to remove source branch after merge', :js do + it 'is initially checked' do + checkbox = find_field('project_remove_source_branch_after_merge') + expect(checkbox).to be_checked + end + + it 'when unchecked sets :remove_source_branch_after_merge to false' do + uncheck('project_remove_source_branch_after_merge') + within('.merge-request-settings-form') do + find('.qa-save-merge-request-changes') + click_on('Save changes') + end + + find('.flash-notice') + checkbox = find_field('project_remove_source_branch_after_merge') + + expect(checkbox).not_to be_checked + + project.reload + expect(project.remove_source_branch_after_merge).to be(false) + end + end end diff --git a/spec/features/projects/show/user_sees_collaboration_links_spec.rb b/spec/features/projects/show/user_sees_collaboration_links_spec.rb index bbb3a066ed5bd733caa3aff6056361bb8ba8d914..ff133b58f89a63473df7160ce87b28ebc3df96da 100644 --- a/spec/features/projects/show/user_sees_collaboration_links_spec.rb +++ b/spec/features/projects/show/user_sees_collaboration_links_spec.rb @@ -2,12 +2,11 @@ require 'spec_helper' -describe 'Projects > Show > Collaboration links' do +describe 'Projects > Show > Collaboration links', :js do let(:project) { create(:project, :repository) } let(:user) { create(:user) } before do - stub_feature_flags(vue_file_list: false) project.add_developer(user) sign_in(user) end @@ -17,15 +16,21 @@ describe 'Projects > Show > Collaboration links' do # The navigation bar page.within('.header-new') do + find('.qa-new-menu-toggle').click + aggregate_failures 'dropdown links in the navigation bar' do expect(page).to have_link('New issue') expect(page).to have_link('New merge request') expect(page).to have_link('New snippet', href: new_project_snippet_path(project)) end + + find('.qa-new-menu-toggle').click end # The dropdown above the tree page.within('.repo-breadcrumb') do + find('.qa-add-to-tree').click + aggregate_failures 'dropdown links above the repo tree' do expect(page).to have_link('New file') expect(page).to have_link('Upload file') @@ -45,23 +50,19 @@ describe 'Projects > Show > Collaboration links' do visit project_path(project) page.within('.header-new') do + find('.qa-new-menu-toggle').click + aggregate_failures 'dropdown links' do expect(page).not_to have_link('New issue') expect(page).not_to have_link('New merge request') expect(page).not_to have_link('New snippet', href: new_project_snippet_path(project)) end - end - page.within('.repo-breadcrumb') do - aggregate_failures 'dropdown links' do - expect(page).not_to have_link('New file') - expect(page).not_to have_link('Upload file') - expect(page).not_to have_link('New directory') - expect(page).not_to have_link('New branch') - expect(page).not_to have_link('New tag') - end + find('.qa-new-menu-toggle').click end + expect(page).not_to have_selector('.qa-add-to-tree') + expect(page).not_to have_link('Web IDE') end end diff --git a/spec/features/projects/show/user_sees_last_commit_ci_status_spec.rb b/spec/features/projects/show/user_sees_last_commit_ci_status_spec.rb index fdc238d55cf74f6d898220abafaf816787a0232a..cf1a679102c7cd59beb69998161f4853cfc35d47 100644 --- a/spec/features/projects/show/user_sees_last_commit_ci_status_spec.rb +++ b/spec/features/projects/show/user_sees_last_commit_ci_status_spec.rb @@ -5,10 +5,6 @@ require 'spec_helper' describe 'Projects > Show > User sees last commit CI status' do set(:project) { create(:project, :repository, :public) } - before do - stub_feature_flags(vue_file_list: false) - end - it 'shows the project README', :js do project.enable_ci pipeline = create(:ci_pipeline, project: project, sha: project.commit.sha, ref: 'master') @@ -16,9 +12,9 @@ describe 'Projects > Show > User sees last commit CI status' do visit project_path(project) - page.within '.blob-commit-info' do + page.within '.commit-detail' do expect(page).to have_content(project.commit.sha[0..6]) - expect(page).to have_link('Pipeline: skipped') + expect(page).to have_selector('[aria-label="Commit: skipped"]') end end end diff --git a/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb b/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb index c136d7607fdef5d2c8c5c0ddf3b8ff20229cff5b..41c3c6b5770dee154fce4961648c0384b8fdbbbd 100644 --- a/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb +++ b/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb @@ -59,8 +59,8 @@ describe 'Projects > Show > User sees setup shortcut buttons' do end it '"Add license" button linked to new file populated for a license' do - page.within('.project-stats') do - expect(page).to have_link('Add license', href: presenter.add_license_path) + page.within('.project-buttons') do + expect(page).to have_link('Add LICENSE', href: presenter.add_license_path) end end end @@ -175,7 +175,7 @@ describe 'Projects > Show > User sees setup shortcut buttons' do expect(project.repository.license_blob).not_to be_nil page.within('.project-buttons') do - expect(page).not_to have_link('Add license') + expect(page).not_to have_link('Add LICENSE') end end diff --git a/spec/features/projects/tree/tree_show_spec.rb b/spec/features/projects/tree/tree_show_spec.rb index ca616be341d2d9d55aaa9776514a2e15e6f079fb..180ffac4d4d4bf6cec755d37a0db8fb8df58c534 100644 --- a/spec/features/projects/tree/tree_show_spec.rb +++ b/spec/features/projects/tree/tree_show_spec.rb @@ -10,7 +10,6 @@ describe 'Projects tree', :js do let(:test_sha) { '7975be0116940bf2ad4321f79d02a55c5f7779aa' } before do - stub_feature_flags(vue_file_list: false) project.add_maintainer(user) sign_in(user) end diff --git a/spec/features/projects/view_on_env_spec.rb b/spec/features/projects/view_on_env_spec.rb index beb32104809c84f896c6f08128979b86f60b6a4f..832985f1a301842f929960e1fae12771b29321cc 100644 --- a/spec/features/projects/view_on_env_spec.rb +++ b/spec/features/projects/view_on_env_spec.rb @@ -9,9 +9,13 @@ describe 'View on environment', :js do let(:user) { project.creator } before do + stub_feature_flags(single_mr_diff_view: 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 @@ -26,7 +30,7 @@ describe 'View on environment', :js do user, start_branch: branch_name, branch_name: branch_name, - commit_message: "Add .gitlab/route-map.yml", + commit_message: 'Add .gitlab/route-map.yml', file_path: '.gitlab/route-map.yml', file_content: route_map ).execute @@ -37,9 +41,9 @@ describe 'View on environment', :js do user, start_branch: branch_name, branch_name: branch_name, - commit_message: "Update feature", + commit_message: 'Update feature', file_path: file_path, - file_content: "# Noop" + file_content: '# Noop' ).execute end diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index 67ae26d8d1ef049fa532494ee616ffc3172fedd6..90e48f3c23085b2a69739794e755e29adcaf89b3 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -6,10 +6,6 @@ describe 'Project' do include ProjectForksHelper include MobileHelpers - before do - stub_feature_flags(vue_file_list: false) - end - describe 'creating from template' do let(:user) { create(:user) } let(:template) { Gitlab::ProjectTemplate.find(:rails) } @@ -190,7 +186,7 @@ describe 'Project' do sign_in user end - it 'shows a link to the source project when it is available' do + it 'shows a link to the source project when it is available', :sidekiq_might_not_need_inline do visit project_path(forked_project) expect(page).to have_content('Forked from') @@ -206,7 +202,7 @@ describe 'Project' do expect(page).not_to have_content('Forked from') end - it 'shows the name of the deleted project when the source was deleted' do + it 'shows the name of the deleted project when the source was deleted', :sidekiq_might_not_need_inline do forked_project Projects::DestroyService.new(base_project, base_project.owner).execute @@ -218,7 +214,7 @@ describe 'Project' do context 'a fork of a fork' do let(:fork_of_fork) { fork_project(forked_project, user, repository: true) } - it 'links to the base project if the source project is removed' do + it 'links to the base project if the source project is removed', :sidekiq_might_not_need_inline do fork_of_fork Projects::DestroyService.new(forked_project, user).execute @@ -263,7 +259,7 @@ describe 'Project' do expect(page).to have_selector '#confirm_name_input:focus' end - it 'removes a project' do + it 'removes a project', :sidekiq_might_not_need_inline do expect { remove_with_confirm('Remove project', project.path) }.to change { Project.count }.by(-1) expect(page).to have_content "Project '#{project.full_name}' is in the process of being deleted." expect(Project.all.count).to be_zero @@ -272,7 +268,7 @@ describe 'Project' do end end - describe 'tree view (default view is set to Files)' do + describe 'tree view (default view is set to Files)', :js do let(:user) { create(:user, project_view: 'files') } let(:project) { create(:forked_project_with_submodules) } @@ -285,19 +281,19 @@ describe 'Project' do it 'has working links to files' do click_link('PROCESS.md') - expect(page.status_code).to eq(200) + expect(page).to have_selector('.file-holder') end it 'has working links to directories' do click_link('encoding') - expect(page.status_code).to eq(200) + expect(page).to have_selector('.breadcrumb-item', text: 'encoding') end it 'has working links to submodules' do click_link('645f6c4c') - expect(page.status_code).to eq(200) + expect(page).to have_selector('.qa-branches-select', text: '645f6c4c82fd3f5e06f67134450a570b795e55a6') end context 'for signed commit on default branch', :js do diff --git a/spec/features/raven_js_spec.rb b/spec/features/raven_js_spec.rb deleted file mode 100644 index 38699f0cc1b521288f3b480a0d280df4b74079c3..0000000000000000000000000000000000000000 --- a/spec/features/raven_js_spec.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe 'RavenJS' do - let(:raven_path) { '/raven.chunk.js' } - - it 'does not load raven if sentry is disabled' do - visit new_user_session_path - - expect(has_requested_raven).to eq(false) - end - - it 'loads raven if sentry is enabled' do - stub_sentry_settings - - visit new_user_session_path - - expect(has_requested_raven).to eq(true) - end - - def has_requested_raven - page.all('script', visible: false).one? do |elm| - elm[:src] =~ /#{raven_path}$/ - end - end -end diff --git a/spec/features/search/user_uses_header_search_field_spec.rb b/spec/features/search/user_uses_header_search_field_spec.rb index 7e7c09e4a1341cf87cb97f97fae4f7c9baace260..7b969aea54750aaada2149acf8ddba64aff38818 100644 --- a/spec/features/search/user_uses_header_search_field_spec.rb +++ b/spec/features/search/user_uses_header_search_field_spec.rb @@ -26,10 +26,20 @@ describe 'User uses header search field', :js do end end + context 'when using the keyboard shortcut' do + before do + find('#search.js-autocomplete-disabled') + find('body').native.send_keys('s') + end + + it 'shows the category search dropdown' do + expect(page).to have_selector('.dropdown-header', text: /#{scope_name}/i) + end + end + context 'when clicking the search field' do before do - page.find('#search').click - wait_for_all_requests + page.find('#search.js-autocomplete-disabled').click end it 'shows category search dropdown' do @@ -78,15 +88,21 @@ describe 'User uses header search field', :js do end context 'when entering text into the search field' do - before do + it 'does not display the category search dropdown' do page.within('.search-input-wrap') do fill_in('search', with: scope_name.first(4)) end - end - it 'does not display the category search dropdown' do expect(page).not_to have_selector('.dropdown-header', text: /#{scope_name}/i) end + + it 'hides the dropdown when there are no results' do + page.within('.search-input-wrap') do + fill_in('search', with: 'a_search_term_with_no_results') + end + + expect(page).not_to have_selector('.dropdown-menu') + end end end diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index 768b883a90e643e6d033918a764b671522ea6e03..9c1c81918fabe2b0aebf143553a0947223f14cad 100644 --- a/spec/features/security/project/internal_access_spec.rb +++ b/spec/features/security/project/internal_access_spec.rb @@ -264,7 +264,9 @@ describe "Internal Project Access" do before do # Speed increase - allow_any_instance_of(Project).to receive(:branches).and_return([]) + allow_next_instance_of(Project) do |instance| + allow(instance).to receive(:branches).and_return([]) + end end it { is_expected.to be_allowed_for(:admin) } @@ -283,7 +285,9 @@ describe "Internal Project Access" do before do # Speed increase - allow_any_instance_of(Project).to receive(:tags).and_return([]) + allow_next_instance_of(Project) do |instance| + allow(instance).to receive(:tags).and_return([]) + end end it { is_expected.to be_allowed_for(:admin) } diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb index c2d44c05a229dfed9d2fe42dac321559c9edc8a2..dbaf97bc3fd49f5b27ddda74fc34c2fd9fcc599f 100644 --- a/spec/features/security/project/private_access_spec.rb +++ b/spec/features/security/project/private_access_spec.rb @@ -236,7 +236,9 @@ describe "Private Project Access" do before do # Speed increase - allow_any_instance_of(Project).to receive(:branches).and_return([]) + allow_next_instance_of(Project) do |instance| + allow(instance).to receive(:branches).and_return([]) + end end it { is_expected.to be_allowed_for(:admin) } @@ -255,7 +257,9 @@ describe "Private Project Access" do before do # Speed increase - allow_any_instance_of(Project).to receive(:tags).and_return([]) + allow_next_instance_of(Project) do |instance| + allow(instance).to receive(:tags).and_return([]) + end end it { is_expected.to be_allowed_for(:admin) } diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index 19f01257713eb007a1c26f19375dd7fc2fd45eb0..35cbc195f4fa4a34c7b82ab4b0ae75c6a48ff019 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -477,7 +477,9 @@ describe "Public Project Access" do before do # Speed increase - allow_any_instance_of(Project).to receive(:branches).and_return([]) + allow_next_instance_of(Project) do |instance| + allow(instance).to receive(:branches).and_return([]) + end end it { is_expected.to be_allowed_for(:admin) } @@ -496,7 +498,9 @@ describe "Public Project Access" do before do # Speed increase - allow_any_instance_of(Project).to receive(:tags).and_return([]) + allow_next_instance_of(Project) do |instance| + allow(instance).to receive(:tags).and_return([]) + end end it { is_expected.to be_allowed_for(:admin) } diff --git a/spec/features/sentry_js_spec.rb b/spec/features/sentry_js_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b39c4f0a0ae95f18bd3a12cf288bf56e41aa65d2 --- /dev/null +++ b/spec/features/sentry_js_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Sentry' do + let(:sentry_path) { '/sentry.chunk.js' } + + it 'does not load sentry if sentry is disabled' do + allow(Gitlab.config.sentry).to receive(:enabled).and_return(false) + visit new_user_session_path + + expect(has_requested_sentry).to eq(false) + end + + it 'loads sentry if sentry is enabled' do + stub_sentry_settings + + visit new_user_session_path + + expect(has_requested_sentry).to eq(true) + end + + def has_requested_sentry + page.all('script', visible: false).one? do |elm| + elm[:src] =~ /#{sentry_path}$/ + end + end +end diff --git a/spec/features/signed_commits_spec.rb b/spec/features/signed_commits_spec.rb index 70e6978a7b66e434b13d40242786dcb25967bf9e..f56bd055224fdd822c0f93acb9393fcaf8872d3c 100644 --- a/spec/features/signed_commits_spec.rb +++ b/spec/features/signed_commits_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' describe 'GPG signed commits' do let(:project) { create(:project, :public, :repository) } - it 'changes from unverified to verified when the user changes his email to match the gpg key' do + it 'changes from unverified to verified when the user changes his email to match the gpg key', :sidekiq_might_not_need_inline do ref = GpgHelpers::SIGNED_AND_AUTHORED_SHA user = create(:user, email: 'unrelated.user@example.org') @@ -30,7 +30,7 @@ describe 'GPG signed commits' do expect(page).to have_button 'Verified' end - it 'changes from unverified to verified when the user adds the missing gpg key' do + it 'changes from unverified to verified when the user adds the missing gpg key', :sidekiq_might_not_need_inline do ref = GpgHelpers::SIGNED_AND_AUTHORED_SHA user = create(:user, email: GpgHelpers::User1.emails.first) @@ -152,4 +152,26 @@ describe 'GPG signed commits' do end end end + + context 'view signed commit on the tree view', :js do + shared_examples 'a commit with a signature' do + before do + visit project_tree_path(project, 'signed-commits') + end + + it 'displays commit signature' do + expect(page).to have_button 'Unverified' + + click_on 'Unverified' + + within '.popover' do + expect(page).to have_content 'This commit was signed with an unverified signature' + end + end + end + + context 'with vue tree view enabled' do + it_behaves_like 'a commit with a signature' + end + end end diff --git a/spec/features/tags/developer_deletes_tag_spec.rb b/spec/features/tags/developer_deletes_tag_spec.rb index 82b416c3a7fa058efb1668d7d77b9e6ee1d57792..0fc62a578f9a24cf6eb2717c9c52fb17704a06e2 100644 --- a/spec/features/tags/developer_deletes_tag_spec.rb +++ b/spec/features/tags/developer_deletes_tag_spec.rb @@ -39,8 +39,10 @@ describe 'Developer deletes tag' do context 'when pre-receive hook fails', :js do before do - allow_any_instance_of(Gitlab::GitalyClient::OperationService).to receive(:rm_tag) - .and_raise(Gitlab::Git::PreReceiveError, 'GitLab: Do not delete tags') + allow_next_instance_of(Gitlab::GitalyClient::OperationService) do |instance| + allow(instance).to receive(:rm_tag) + .and_raise(Gitlab::Git::PreReceiveError, 'GitLab: Do not delete tags') + end end it 'shows the error message' do diff --git a/spec/features/unsubscribe_links_spec.rb b/spec/features/unsubscribe_links_spec.rb index 2f8b715289c334913c5e6f931f8f00cc65b14e53..cf30776786b3611aa7f659c6526b3deb7ab615fa 100644 --- a/spec/features/unsubscribe_links_spec.rb +++ b/spec/features/unsubscribe_links_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe 'Unsubscribe links' do +describe 'Unsubscribe links', :sidekiq_might_not_need_inline do include Warden::Test::Helpers let(:recipient) { create(:user) } diff --git a/spec/features/user_sees_revert_modal_spec.rb b/spec/features/user_sees_revert_modal_spec.rb index 24b4f8dd4aa81c5a944f5f3ba0f6c1bc2e04f732..c0cffe885decda8648dac3f422f5b81b0da935a1 100644 --- a/spec/features/user_sees_revert_modal_spec.rb +++ b/spec/features/user_sees_revert_modal_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe 'Merge request > User sees revert modal', :js do +describe 'Merge request > User sees revert modal', :js, :sidekiq_might_not_need_inline do let(:project) { create(:project, :public, :repository) } let(:user) { project.creator } let(:merge_request) { create(:merge_request, source_project: project) } diff --git a/spec/features/users/anonymous_sessions_spec.rb b/spec/features/users/anonymous_sessions_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e87ee39a3f4d00a66f0509614612ed0ef2042057 --- /dev/null +++ b/spec/features/users/anonymous_sessions_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Session TTLs', :clean_gitlab_redis_shared_state do + it 'creates a session with a short TTL when login fails' do + visit new_user_session_path + # The session key only gets created after a post + fill_in 'user_login', with: 'non-existant@gitlab.org' + fill_in 'user_password', with: '12345678' + click_button 'Sign in' + + expect(page).to have_content('Invalid Login or password') + + expect_single_session_with_expiration(Settings.gitlab['unauthenticated_session_expire_delay']) + end + + it 'increases the TTL when the login succeeds' do + user = create(:user) + gitlab_sign_in(user) + + expect(page).to have_content(user.name) + + expect_single_session_with_expiration(Settings.gitlab['session_expire_delay'] * 60) + end + + def expect_single_session_with_expiration(expiration) + session_keys = get_session_keys + + expect(session_keys.size).to eq(1) + expect(get_ttl(session_keys.first)).to eq expiration + end + + def get_session_keys + Gitlab::Redis::SharedState.with { |redis| redis.scan_each(match: 'session:gitlab:*').to_a } + end + + def get_ttl(key) + Gitlab::Redis::SharedState.with { |redis| redis.ttl(key) } + end +end diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb index d1f3b3f4076605b12b85ebea7cca2b55deaa3e23..b7c54bb6de8e86bb7dc19d9ea85c5aa38916765d 100644 --- a/spec/features/users/login_spec.rb +++ b/spec/features/users/login_spec.rb @@ -806,7 +806,7 @@ describe 'Login' do gitlab_sign_in(user) expect(current_path).to eq root_path - expect(page).to have_content("Please check your email (#{user.email}) to verify that you own this address.") + expect(page).to have_content("Please check your email (#{user.email}) to verify that you own this address and unlock the power of CI/CD.") end context "when not having confirmed within Devise's allow_unconfirmed_access_for time" do diff --git a/spec/features/users/signup_spec.rb b/spec/features/users/signup_spec.rb index 562d6fcab1b5b85486270901e776a30362b6ad3a..3b19bd423a4c5ba3991af054cbf60620bc6aa0cf 100644 --- a/spec/features/users/signup_spec.rb +++ b/spec/features/users/signup_spec.rb @@ -222,7 +222,7 @@ shared_examples 'Signup' do expect(current_path).to eq users_sign_up_welcome_path else expect(current_path).to eq dashboard_projects_path - expect(page).to have_content("Please check your email (#{new_user.email}) to verify that you own this address.") + expect(page).to have_content("Please check your email (#{new_user.email}) to verify that you own this address and unlock the power of CI/CD.") end end end @@ -379,7 +379,9 @@ shared_examples 'Signup' do before do InvisibleCaptcha.timestamp_enabled = true stub_application_setting(recaptcha_enabled: true) - allow_any_instance_of(RegistrationsController).to receive(:verify_recaptcha).and_return(false) + allow_next_instance_of(RegistrationsController) do |instance| + allow(instance).to receive(:verify_recaptcha).and_return(false) + end end after do @@ -413,6 +415,7 @@ end describe 'With original flow' do before do stub_experiment(signup_flow: false) + stub_experiment_for_user(signup_flow: false) end it_behaves_like 'Signup' @@ -421,6 +424,7 @@ end describe 'With experimental flow' do before do stub_experiment(signup_flow: true) + stub_experiment_for_user(signup_flow: true) end it_behaves_like 'Signup' @@ -439,11 +443,13 @@ describe 'With experimental flow' do 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) end end diff --git a/spec/finders/abuse_reports_finder_spec.rb b/spec/finders/abuse_reports_finder_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c84a645ca086d57bcff43c228fc8cb9eb6cab77b --- /dev/null +++ b/spec/finders/abuse_reports_finder_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe AbuseReportsFinder, '#execute' do + let(:params) { {} } + let!(:user1) { create(:user) } + let!(:user2) { create(:user) } + let!(:abuse_report_1) { create(:abuse_report, user: user1) } + let!(:abuse_report_2) { create(:abuse_report, user: user2) } + + subject { described_class.new(params).execute } + + context 'empty params' do + it 'returns all abuse reports' do + expect(subject).to match_array([abuse_report_1, abuse_report_2]) + end + end + + context 'params[:user_id] is present' do + let(:params) { { user_id: user2 } } + + it 'returns abuse reports for the specified user' do + expect(subject).to match_array([abuse_report_2]) + end + end +end diff --git a/spec/finders/branches_finder_spec.rb b/spec/finders/branches_finder_spec.rb index 1a33bdf11d7f28c9ca6fff7a22b3e6ca9862ae09..70b5da0cc3c2186fbb1e9a0cfc92e262c6ed3cb5 100644 --- a/spec/finders/branches_finder_spec.rb +++ b/spec/finders/branches_finder_spec.rb @@ -73,58 +73,76 @@ describe BranchesFinder do expect(result.count).to eq(3) expect(result.map(&:name)).to eq(%w{csv fix lfs}) end - end - context 'filter and sort' do - it 'filters branches by name and sorts by recently_updated' do - params = { sort: 'updated_desc', search: 'feat' } + it 'filters branches by name that begins with' do + params = { search: '^feature_' } branches_finder = described_class.new(repository, params) result = branches_finder.execute expect(result.first.name).to eq('feature_conflict') - expect(result.count).to eq(2) + expect(result.count).to eq(1) end - it 'filters branches by name and sorts by recently_updated, with exact matches first' do - params = { sort: 'updated_desc', search: 'feature' } + it 'filters branches by name that ends with' do + params = { search: 'feature$' } branches_finder = described_class.new(repository, params) result = branches_finder.execute expect(result.first.name).to eq('feature') - expect(result.second.name).to eq('feature_conflict') - expect(result.count).to eq(2) + expect(result.count).to eq(1) end - it 'filters branches by name and sorts by last_updated' do - params = { sort: 'updated_asc', search: 'feature' } + it 'filters branches by nonexistent name that begins with' do + params = { search: '^nope' } branches_finder = described_class.new(repository, params) result = branches_finder.execute - expect(result.first.name).to eq('feature') - expect(result.count).to eq(2) + expect(result.count).to eq(0) end - it 'filters branches by name that begins with' do - params = { search: '^feature_' } + it 'filters branches by nonexistent name that ends with' do + params = { search: 'nope$' } + branches_finder = described_class.new(repository, params) + + result = branches_finder.execute + + expect(result.count).to eq(0) + end + end + + context 'filter and sort' do + it 'filters branches by name and sorts by recently_updated' do + params = { sort: 'updated_desc', search: 'feat' } branches_finder = described_class.new(repository, params) result = branches_finder.execute expect(result.first.name).to eq('feature_conflict') - expect(result.count).to eq(1) + expect(result.count).to eq(2) end - it 'filters branches by name that ends with' do - params = { search: 'feature$' } + it 'filters branches by name and sorts by recently_updated, with exact matches first' do + params = { sort: 'updated_desc', search: 'feature' } branches_finder = described_class.new(repository, params) result = branches_finder.execute expect(result.first.name).to eq('feature') - expect(result.count).to eq(1) + expect(result.second.name).to eq('feature_conflict') + expect(result.count).to eq(2) + end + + it 'filters branches by name and sorts by last_updated' do + params = { sort: 'updated_asc', search: 'feature' } + branches_finder = described_class.new(repository, params) + + result = branches_finder.execute + + expect(result.first.name).to eq('feature') + expect(result.count).to eq(2) end end end diff --git a/spec/finders/container_repositories_finder_spec.rb b/spec/finders/container_repositories_finder_spec.rb index deec62d65981a99bdf55d70bc94662438a4fb485..08c241186d66ee7b9f0bbe5e096a3ea2574e2285 100644 --- a/spec/finders/container_repositories_finder_spec.rb +++ b/spec/finders/container_repositories_finder_spec.rb @@ -3,42 +3,50 @@ require 'spec_helper' describe ContainerRepositoriesFinder do + let_it_be(:reporter) { create(:user) } + let_it_be(:guest) { create(:user) } + let(:group) { create(:group) } let(:project) { create(:project, group: group) } - let(:project_repository) { create(:container_repository, project: project) } + let!(:project_repository) { create(:container_repository, project: project) } + + before do + group.add_reporter(reporter) + project.add_reporter(reporter) + end describe '#execute' do - let(:id) { nil } + context 'with authorized user' do + subject { described_class.new(user: reporter, subject: subject_object).execute } - subject { described_class.new(id: id, container_type: container_type).execute } + context 'when subject_type is group' do + let(:subject_object) { group } + let(:other_project) { create(:project, group: group) } - context 'when container_type is group' do - let(:other_project) { create(:project, group: group) } + let(:other_repository) do + create(:container_repository, name: 'test_repository2', project: other_project) + end - let(:other_repository) do - create(:container_repository, name: 'test_repository2', project: other_project) + it { is_expected.to match_array([project_repository, other_repository]) } end - let(:container_type) { :group } - let(:id) { group.id } + context 'when subject_type is project' do + let(:subject_object) { project } - it { is_expected.to match_array([project_repository, other_repository]) } - end + it { is_expected.to match_array([project_repository]) } + end - context 'when container_type is project' do - let(:container_type) { :project } - let(:id) { project.id } + context 'with invalid subject_type' do + let(:subject_object) { "invalid type" } - it { is_expected.to match_array([project_repository]) } + it { expect { subject }.to raise_exception('invalid subject_type') } + end end - context 'with invalid id' do - let(:container_type) { :project } - let(:id) { 123456789 } + context 'with unauthorized user' do + subject { described_class.new(user: guest, subject: group).execute } - it 'raises an error' do - expect { subject.execute }.to raise_error(ActiveRecord::RecordNotFound) - end + it { is_expected.to be nil } end end end diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index c27ce263bf0eee231ad501839d75700e05dda7e1..6c10a61727963e05300839ca7f3455fc7d763e9d 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -163,6 +163,20 @@ describe IssuesFinder do end end + context 'filtering by nonexistent author ID and issue term using CTE for search' do + let(:params) do + { + author_id: 'does-not-exist', + search: 'git', + attempt_group_search_optimizations: true + } + end + + it 'returns no results' do + expect(issues).to be_empty + end + end + context 'filtering by milestone' do let(:params) { { milestone_title: milestone.title } } diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb index a396284f1e940171fad5184ca415db3df386103c..bc85a622119394dbcb501d7f9a6b04638d4211d4 100644 --- a/spec/finders/merge_requests_finder_spec.rb +++ b/spec/finders/merge_requests_finder_spec.rb @@ -23,6 +23,18 @@ describe MergeRequestsFinder do expect(merge_requests).to contain_exactly(merge_request1) end + it 'filters by nonexistent author ID and MR term using CTE for search' do + params = { + author_id: 'does-not-exist', + search: 'git', + attempt_group_search_optimizations: true + } + + merge_requests = described_class.new(user, params).execute + + expect(merge_requests).to be_empty + end + it 'filters by projects' do params = { projects: [project2.id, project3.id] } diff --git a/spec/finders/projects_finder_spec.rb b/spec/finders/projects_finder_spec.rb index 4ec12b5a7f75fc73d3a34819bf30fda25883ef72..a9344cd593a07f6ab585c2ec79c4397ad3cb1c5d 100644 --- a/spec/finders/projects_finder_spec.rb +++ b/spec/finders/projects_finder_spec.rb @@ -2,7 +2,9 @@ require 'spec_helper' -describe ProjectsFinder do +describe ProjectsFinder, :do_not_mock_admin_mode do + include AdminModeHelper + describe '#execute' do let(:user) { create(:user) } let(:group) { create(:group, :public) } @@ -56,6 +58,31 @@ describe ProjectsFinder do it { is_expected.to eq([internal_project]) } end + describe 'with id_after' do + context 'only returns projects with a project id greater than given' do + let(:params) { { id_after: internal_project.id }} + + it { is_expected.to eq([public_project]) } + end + end + + describe 'with id_before' do + context 'only returns projects with a project id less than given' do + let(:params) { { id_before: public_project.id }} + + it { is_expected.to eq([internal_project]) } + end + end + + describe 'with both id_before and id_after' do + context 'only returns projects with a project id less than given' do + let!(:projects) { create_list(:project, 5, :public) } + let(:params) { { id_after: projects.first.id, id_before: projects.last.id }} + + it { is_expected.to contain_exactly(*projects[1..-2]) } + end + end + describe 'filter by visibility_level' do before do private_project.add_maintainer(user) @@ -188,5 +215,21 @@ describe ProjectsFinder do it { is_expected.to eq([internal_project, public_project]) } end + + describe 'with admin user' do + let(:user) { create(:admin) } + + context 'admin mode enabled' do + before do + enable_admin_mode!(current_user) + end + + it { is_expected.to match_array([public_project, internal_project, private_project, shared_project]) } + end + + context 'admin mode disabled' do + it { is_expected.to match_array([public_project, internal_project]) } + end + end end end diff --git a/spec/finders/prometheus_metrics_finder_spec.rb b/spec/finders/prometheus_metrics_finder_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..41b2e700e1ec8fe2f3f16e1dca58d1d0aa4baad3 --- /dev/null +++ b/spec/finders/prometheus_metrics_finder_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe PrometheusMetricsFinder do + describe '#execute' do + let(:finder) { described_class.new(params) } + let(:params) { {} } + + subject { finder.execute } + + context 'with params' do + let_it_be(:project) { create(:project) } + let_it_be(:project_metric) { create(:prometheus_metric, project: project) } + let_it_be(:common_metric) { create(:prometheus_metric, :common) } + let_it_be(:unique_metric) do + create( + :prometheus_metric, + :common, + title: 'Unique title', + y_label: 'Unique y_label', + group: :kubernetes, + identifier: 'identifier', + created_at: 5.minutes.ago + ) + end + + context 'with appropriate indexes' do + before do + allow_any_instance_of(described_class).to receive(:appropriate_index?).and_return(true) + end + + context 'with project' do + let(:params) { { project: project } } + + it { is_expected.to eq([project_metric]) } + end + + context 'with group' do + let(:params) { { group: project_metric.group } } + + it { is_expected.to contain_exactly(common_metric, project_metric) } + end + + context 'with title' do + let(:params) { { title: project_metric.title } } + + it { is_expected.to contain_exactly(project_metric, common_metric) } + end + + context 'with y_label' do + let(:params) { { y_label: project_metric.y_label } } + + it { is_expected.to contain_exactly(project_metric, common_metric) } + end + + context 'with common' do + let(:params) { { common: true } } + + it { is_expected.to contain_exactly(common_metric, unique_metric) } + end + + context 'with ordered' do + let(:params) { { ordered: true } } + + it { is_expected.to eq([unique_metric, project_metric, common_metric]) } + end + + context 'with indentifier' do + let(:params) { { identifier: unique_metric.identifier } } + + it 'raises an error' do + expect { subject }.to raise_error( + ArgumentError, + ':identifier must be scoped to a :project or :common' + ) + end + + context 'with common' do + let(:params) { { identifier: unique_metric.identifier, common: true } } + + it { is_expected.to contain_exactly(unique_metric) } + end + + context 'with id' do + let(:params) { { id: 14, identifier: 'string' } } + + it 'raises an error' do + expect { subject }.to raise_error( + ArgumentError, + 'Only one of :identifier, :id is permitted' + ) + end + end + end + + context 'with id' do + let(:params) { { id: common_metric.id } } + + it { is_expected.to contain_exactly(common_metric) } + end + + context 'with multiple params' do + let(:params) do + { + group: project_metric.group, + title: project_metric.title, + y_label: project_metric.y_label, + common: true, + ordered: true + } + end + + it { is_expected.to contain_exactly(common_metric) } + end + end + + context 'without an appropriate index' do + let(:params) do + { + title: project_metric.title, + ordered: true + } + end + + it 'raises an error' do + expect { subject }.to raise_error( + ArgumentError, + 'An index should exist for params: [:title]' + ) + end + end + end + + context 'without params' do + it 'raises an error' do + expect { subject }.to raise_error( + ArgumentError, + 'Please provide one or more of: [:project, :group, :title, :y_label, :identifier, :id, :common, :ordered]' + ) + end + end + end +end diff --git a/spec/finders/releases_finder_spec.rb b/spec/finders/releases_finder_spec.rb index 5ffb8c74bf561506cc575dcfad6fc9926952c04b..b9c67361f4576adb16e00676f1f85b25c9421c25 100644 --- a/spec/finders/releases_finder_spec.rb +++ b/spec/finders/releases_finder_spec.rb @@ -8,8 +8,7 @@ describe ReleasesFinder do let(:repository) { project.repository } let(:v1_0_0) { create(:release, project: project, tag: 'v1.0.0') } let(:v1_1_0) { create(:release, project: project, tag: 'v1.1.0') } - - subject { described_class.new(project, user)} + let(:finder) { described_class.new(project, user) } before do v1_0_0.update_attribute(:released_at, 2.days.ago) @@ -17,11 +16,13 @@ describe ReleasesFinder do end describe '#execute' do + subject { finder.execute(**args) } + + let(:args) { {} } + context 'when the user is not part of the project' do it 'returns no releases' do - releases = subject.execute - - expect(releases).to be_empty + is_expected.to be_empty end end @@ -31,11 +32,25 @@ describe ReleasesFinder do end it 'sorts by release date' do - releases = subject.execute + is_expected.to be_present + expect(subject.size).to eq(2) + expect(subject).to eq([v1_1_0, v1_0_0]) + end + + it 'preloads associations' do + expect(Release).to receive(:preloaded).once.and_call_original + + subject + end + + context 'when preload is false' do + let(:args) { { preload: false } } + + it 'does not preload associations' do + expect(Release).not_to receive(:preloaded) - expect(releases).to be_present - expect(releases.size).to eq(2) - expect(releases).to eq([v1_1_0, v1_0_0]) + subject + end end end end diff --git a/spec/finders/tags_finder_spec.rb b/spec/finders/tags_finder_spec.rb index 85f970b71c400bbef9e3611b6d9534bdda5df0f3..e9f29ab24412399ef76da042425e1d51cc788b32 100644 --- a/spec/finders/tags_finder_spec.rb +++ b/spec/finders/tags_finder_spec.rb @@ -54,6 +54,44 @@ describe TagsFinder do expect(result.count).to eq(0) end + + it 'filters tags by name that begins with' do + params = { search: '^v1.0' } + tags_finder = described_class.new(repository, params) + + result = tags_finder.execute + + expect(result.first.name).to eq('v1.0.0') + expect(result.count).to eq(1) + end + + it 'filters tags by name that ends with' do + params = { search: '0.0$' } + tags_finder = described_class.new(repository, params) + + result = tags_finder.execute + + expect(result.first.name).to eq('v1.0.0') + expect(result.count).to eq(1) + end + + it 'filters tags by nonexistent name that begins with' do + params = { search: '^nope' } + tags_finder = described_class.new(repository, params) + + result = tags_finder.execute + + expect(result.count).to eq(0) + end + + it 'filters tags by nonexistent name that ends with' do + params = { search: 'nope$' } + tags_finder = described_class.new(repository, params) + + result = tags_finder.execute + + expect(result.count).to eq(0) + end end context 'filter and sort' do diff --git a/spec/finders/todos_finder_spec.rb b/spec/finders/todos_finder_spec.rb index 044e135fa0bef5cd8e582f734cf4915089c24125..a837e7af251d2db6454cec784894c403046ccb5c 100644 --- a/spec/finders/todos_finder_spec.rb +++ b/spec/finders/todos_finder_spec.rb @@ -36,10 +36,18 @@ describe TodosFinder do expect(todos).to match_array([todo1, todo2]) end - it 'returns correct todos when filtered by a type' do - todos = finder.new(user, { type: 'Issue' }).execute + context 'when filtering by type' do + it 'returns correct todos when filtered by a type' do + todos = finder.new(user, { type: 'Issue' }).execute - expect(todos).to match_array([todo1]) + expect(todos).to match_array([todo1]) + end + + it 'returns the correct todos when filtering for multiple types' do + todos = finder.new(user, { type: %w[Issue MergeRequest] }).execute + + expect(todos).to match_array([todo1, todo2]) + end end context 'when filtering for actions' do @@ -53,12 +61,10 @@ describe TodosFinder do expect(todos).to match_array([todo2]) end - context 'multiple actions' do - it 'returns the expected todos' do - todos = finder.new(user, { action_id: [Todo::DIRECTLY_ADDRESSED, Todo::ASSIGNED] }).execute + it 'returns the expected todos when filtering for multiple action ids' do + todos = finder.new(user, { action_id: [Todo::DIRECTLY_ADDRESSED, Todo::ASSIGNED] }).execute - expect(todos).to match_array([todo2, todo1]) - end + expect(todos).to match_array([todo2, todo1]) end end @@ -69,12 +75,10 @@ describe TodosFinder do expect(todos).to match_array([todo2]) end - context 'multiple actions' do - it 'returns the expected todos' do - todos = finder.new(user, { action: [:directly_addressed, :assigned] }).execute + it 'returns the expected todos when filtering for multiple action names' do + todos = finder.new(user, { action: [:directly_addressed, :assigned] }).execute - expect(todos).to match_array([todo2, todo1]) - end + expect(todos).to match_array([todo2, todo1]) end end end @@ -136,6 +140,51 @@ describe TodosFinder do end end end + + context 'by state' do + let!(:todo1) { create(:todo, user: user, group: group, target: issue, state: :done) } + let!(:todo2) { create(:todo, user: user, group: group, target: issue, state: :pending) } + + it 'returns the expected items when no state is provided' do + todos = finder.new(user, {}).execute + + expect(todos).to match_array([todo2]) + end + + it 'returns the expected items when a state is provided' do + todos = finder.new(user, { state: :done }).execute + + expect(todos).to match_array([todo1]) + end + + it 'returns the expected items when multiple states are provided' do + todos = finder.new(user, { state: [:pending, :done] }).execute + + expect(todos).to match_array([todo1, todo2]) + end + end + + context 'by project' do + let_it_be(:project1) { create(:project) } + let_it_be(:project2) { create(:project) } + let_it_be(:project3) { create(:project) } + + let!(:todo1) { create(:todo, user: user, project: project1, state: :pending) } + let!(:todo2) { create(:todo, user: user, project: project2, state: :pending) } + let!(:todo3) { create(:todo, user: user, project: project3, state: :pending) } + + it 'returns the expected todos for one project' do + todos = finder.new(user, { project_id: project2.id }).execute + + expect(todos).to match_array([todo2]) + end + + it 'returns the expected todos for many projects' do + todos = finder.new(user, { project_id: [project2.id, project1.id] }).execute + + expect(todos).to match_array([todo2, todo1]) + end + end end context 'external authorization' do @@ -207,6 +256,19 @@ describe TodosFinder do end end + describe '.todo_types' do + it 'returns the expected types' do + expected_result = + if Gitlab.ee? + %w[Epic Issue MergeRequest] + else + %w[Issue MergeRequest] + end + + expect(described_class.todo_types).to contain_exactly(*expected_result) + end + end + describe '#any_for_target?' do it 'returns true if there are any todos for the given target' do todo = create(:todo, :pending) diff --git a/spec/fixtures/api/schemas/cluster_status.json b/spec/fixtures/api/schemas/cluster_status.json index 695175689b95b68ce25ee8cb72f25b6c5d0bf95c..f978baa202670997474c044d5dd299fe79ad15ce 100644 --- a/spec/fixtures/api/schemas/cluster_status.json +++ b/spec/fixtures/api/schemas/cluster_status.json @@ -35,7 +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"] }, "update_available": { "type": ["boolean", "null"] }, "can_uninstall": { "type": "boolean" } }, diff --git a/spec/fixtures/api/schemas/entities/merge_request_sidebar_extras.json b/spec/fixtures/api/schemas/entities/merge_request_sidebar_extras.json index 682e345d5f58a6d95e9e62e669011ff00cf3e3e1..11076ec73ded711d7d812592a65516d335197790 100644 --- a/spec/fixtures/api/schemas/entities/merge_request_sidebar_extras.json +++ b/spec/fixtures/api/schemas/entities/merge_request_sidebar_extras.json @@ -3,6 +3,8 @@ "properties" : { "id": { "type": "integer" }, "iid": { "type": "integer" }, + "project_emails_disabled": { "type": "boolean" }, + "subscribe_disabled_description": { "type": "string" }, "subscribed": { "type": "boolean" }, "time_estimate": { "type": "integer" }, "total_time_spent": { "type": "integer" }, diff --git a/spec/fixtures/api/schemas/error_tracking/error.json b/spec/fixtures/api/schemas/error_tracking/error.json index df2c02d7d5da01e291a1d0ae8a923893b3e92913..3f65105681e4d2c42ab4c5736f12aed53de46f2d 100644 --- a/spec/fixtures/api/schemas/error_tracking/error.json +++ b/spec/fixtures/api/schemas/error_tracking/error.json @@ -4,7 +4,14 @@ "external_url", "last_seen", "message", - "type" + "type", + "title", + "project_id", + "project_name", + "project_slug", + "short_id", + "status", + "frequency" ], "properties" : { "id": { "type": "string"}, @@ -15,7 +22,14 @@ "culprit": { "type": "string" }, "count": { "type": "integer"}, "external_url": { "type": "string" }, - "user_count": { "type": "integer"} + "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"} }, - "additionalProperties": true + "additionalProperties": false } diff --git a/spec/fixtures/api/schemas/error_tracking/error_detailed.json b/spec/fixtures/api/schemas/error_tracking/error_detailed.json new file mode 100644 index 0000000000000000000000000000000000000000..40d6773f0e630cac38b4da4476ce639a25201dd2 --- /dev/null +++ b/spec/fixtures/api/schemas/error_tracking/error_detailed.json @@ -0,0 +1,45 @@ +{ + "type": "object", + "required" : [ + "external_url", + "external_base_url", + "last_seen", + "message", + "type", + "title", + "project_id", + "project_name", + "project_slug", + "short_id", + "status", + "frequency", + "first_release_last_commit", + "last_release_last_commit", + "first_release_short_version", + "last_release_short_version" + ], + "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"}, + "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"}, + "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"] } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/error_tracking/error_stack_trace.json b/spec/fixtures/api/schemas/error_tracking/error_stack_trace.json new file mode 100644 index 0000000000000000000000000000000000000000..a684dd0496a4ae1774f065d3774e79150c9bd4ee --- /dev/null +++ b/spec/fixtures/api/schemas/error_tracking/error_stack_trace.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "required": [ + "issue_id", + "stack_trace_entries", + "date_received" + ], + "properties": { + "issue_id": { "type": ["string", "integer"] }, + "stack_trace_entries": { "type": "object" }, + "date_received": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/error_tracking/issue_detailed.json b/spec/fixtures/api/schemas/error_tracking/issue_detailed.json new file mode 100644 index 0000000000000000000000000000000000000000..b5adea6fc623aebbaef86be03246fd52ade2dc52 --- /dev/null +++ b/spec/fixtures/api/schemas/error_tracking/issue_detailed.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { "$ref": "error_detailed.json" } + }, + "additionalProperties": false +} + diff --git a/spec/fixtures/api/schemas/error_tracking/issue_stack_trace.json b/spec/fixtures/api/schemas/error_tracking/issue_stack_trace.json new file mode 100644 index 0000000000000000000000000000000000000000..7ec1ae63609a80d52feaee6738541257f5e926d4 --- /dev/null +++ b/spec/fixtures/api/schemas/error_tracking/issue_stack_trace.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { "$ref": "error_stack_trace.json" } + }, + "additionalProperties": false +} + diff --git a/spec/fixtures/api/schemas/public_api/v4/blobs.json b/spec/fixtures/api/schemas/public_api/v4/blobs.json index a812815838f0ca403c2dfaf5c8ee5742160083b4..5dcefb42367907b51949faebfd1a57724dbd9939 100644 --- a/spec/fixtures/api/schemas/public_api/v4/blobs.json +++ b/spec/fixtures/api/schemas/public_api/v4/blobs.json @@ -5,6 +5,7 @@ "properties" : { "basename": { "type": "string" }, "data": { "type": "string" }, + "path": { "type": ["string"] }, "filename": { "type": ["string"] }, "id": { "type": ["string", "null"] }, "project_id": { "type": "integer" }, @@ -12,7 +13,7 @@ "startline": { "type": "integer" } }, "required": [ - "basename", "data", "filename", "id", "ref", "startline", "project_id" + "basename", "data", "path", "filename", "id", "ref", "startline", "project_id" ], "additionalProperties": false } diff --git a/spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json b/spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json index ed8ed9085c0eab2a2803c288afd594c2e3b65fbf..721b8d4641f95d27f5f8f6dd3b2bf22ef084ace8 100644 --- a/spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json +++ b/spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json @@ -7,6 +7,7 @@ "verified": { "type": "boolean" }, "verification_code": { "type": ["string", "null"] }, "enabled_until": { "type": ["date", "null"] }, + "auto_ssl_enabled": { "type": "boolean" }, "certificate_expiration": { "type": "object", "properties": { @@ -17,6 +18,6 @@ "additionalProperties": false } }, - "required": ["domain", "url", "project_id", "verified", "verification_code", "enabled_until"], + "required": ["domain", "url", "project_id", "verified", "verification_code", "enabled_until", "auto_ssl_enabled"], "additionalProperties": false } diff --git a/spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json b/spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json index b57d544f8969b196855760ca68cbd1a341613432..3dd80a6f11b99245735e3bc533b6b22b15de6ea9 100644 --- a/spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json +++ b/spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json @@ -6,6 +6,7 @@ "verified": { "type": "boolean" }, "verification_code": { "type": ["string", "null"] }, "enabled_until": { "type": ["date", "null"] }, + "auto_ssl_enabled": { "type": "boolean" }, "certificate": { "type": "object", "properties": { @@ -18,6 +19,6 @@ "additionalProperties": false } }, - "required": ["domain", "url", "verified", "verification_code", "enabled_until"], + "required": ["domain", "url", "verified", "verification_code", "enabled_until", "auto_ssl_enabled"], "additionalProperties": false } diff --git a/spec/fixtures/api/schemas/public_api/v4/release.json b/spec/fixtures/api/schemas/public_api/v4/release.json index 2bdc8bc711cf0d70d228b79ba80cf37d5d424aa6..c83eefeb7ed12cd915cd86ed56903979f715a73f 100644 --- a/spec/fixtures/api/schemas/public_api/v4/release.json +++ b/spec/fixtures/api/schemas/public_api/v4/release.json @@ -38,10 +38,11 @@ "additionalProperties": false }, "_links": { - "required": ["merge_requests_url", "issues_url"], + "required": ["merge_requests_url", "issues_url", "edit_url"], "properties": { "merge_requests_url": { "type": "string" }, - "issues_url": { "type": "string" } + "issues_url": { "type": "string" }, + "edit_url": { "type": "string"} } } }, diff --git a/spec/fixtures/api/schemas/public_api/v4/release/release_for_guest.json b/spec/fixtures/api/schemas/public_api/v4/release/release_for_guest.json index bce74892059371d1452ff7a9cf68b67fe330676a..dd65a4c7cdb680d9d087302602077f85daaf3b0d 100644 --- a/spec/fixtures/api/schemas/public_api/v4/release/release_for_guest.json +++ b/spec/fixtures/api/schemas/public_api/v4/release/release_for_guest.json @@ -26,10 +26,11 @@ "additionalProperties": false }, "_links": { - "required": ["merge_requests_url", "issues_url"], + "required": ["merge_requests_url", "issues_url", "edit_url"], "properties": { "merge_requests_url": { "type": "string" }, - "issues_url": { "type": "string" } + "issues_url": { "type": "string" }, + "edit_url": { "type": "string"} } } }, diff --git a/spec/fixtures/api/schemas/release.json b/spec/fixtures/api/schemas/release.json index 86f0f27606c90e4adeea335418b68928a8b06d90..b0296e5e62dfa5da7f4beffb65ec864329617d03 100644 --- a/spec/fixtures/api/schemas/release.json +++ b/spec/fixtures/api/schemas/release.json @@ -1,9 +1,10 @@ { "type": "object", - "required": ["name", "tag_name"], + "required": ["tag_name", "description"], "properties": { "name": { "type": "string" }, "tag_name": { "type": "string" }, + "ref": { "type": "string "}, "description": { "type": "string" }, "description_html": { "type": "string" }, "created_at": { "type": "date" }, diff --git a/spec/fixtures/grafana/dashboard_response.json b/spec/fixtures/grafana/dashboard_response.json new file mode 100644 index 0000000000000000000000000000000000000000..c0dd77e2fdcbd6e5590ddccb15b8b4c6ea638ba3 --- /dev/null +++ b/spec/fixtures/grafana/dashboard_response.json @@ -0,0 +1,764 @@ +{ + "meta": { + "type": "db", + "canSave": true, + "canEdit": true, + "canAdmin": true, + "canStar": true, + "slug": "gitlab-omnibus-redis", + "url": "/-/grafana/d/XDaNK6amz/gitlab-omnibus-redis", + "expires": "0001-01-01T00:00:00Z", + "created": "2019-10-04T13:43:20Z", + "updated": "2019-10-04T13:43:20Z", + "updatedBy": "Anonymous", + "createdBy": "Anonymous", + "version": 1, + "hasAcl": false, + "isFolder": false, + "folderId": 1, + "folderTitle": "GitLab Omnibus", + "folderUrl": "/-/grafana/dashboards/f/l2EpNh2Zk/gitlab-omnibus", + "provisioned": true, + "provisionedExternalId": "redis.json" + }, + "dashboard": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "description": "GitLab Omnibus dashboard for Redis servers", + "editable": true, + "gnetId": 763, + "graphTooltip": 0, + "id": 3, + "iteration": 1556027798221, + "links": [], + "panels": [ + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": ["rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)"], + "datasource": "GitLab Omnibus", + "decimals": 0, + "editable": true, + "error": false, + "format": "dtdurations", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { "h": 3, "w": 4, "x": 0, "y": 0 }, + "id": 9, + "interval": null, + "isNew": true, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { "name": "value to text", "value": 1 }, + { "name": "range to text", "value": 2 } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [{ "from": "null", "text": "N/A", "to": "null" }], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "addr", + "targets": [ + { + "expr": "avg(time() - redis_start_time_seconds{instance=~\"$instance\"})", + "format": "time_series", + "instant": true, + "interval": "", + "intervalFactor": 2, + "legendFormat": "", + "metric": "", + "refId": "A", + "step": 1800 + } + ], + "thresholds": "", + "title": "Uptime", + "type": "singlestat", + "valueFontSize": "70%", + "valueMaps": [{ "op": "=", "text": "N/A", "value": "null" }], + "valueName": "current" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": ["rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)"], + "datasource": "GitLab Omnibus", + "decimals": 0, + "editable": true, + "error": false, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { "h": 3, "w": 4, "x": 4, "y": 0 }, + "hideTimeOverride": true, + "id": 12, + "interval": null, + "isNew": true, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { "name": "value to text", "value": 1 }, + { "name": "range to text", "value": 2 } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [{ "from": "null", "text": "N/A", "to": "null" }], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "tableColumn": "", + "targets": [ + { + "expr": "sum(\n avg_over_time(redis_connected_clients{instance=~\"$instance\"}[$__interval])\n)", + "format": "time_series", + "interval": "1m", + "intervalFactor": 2, + "legendFormat": "", + "metric": "", + "refId": "A", + "step": 2 + } + ], + "thresholds": "", + "timeFrom": "1m", + "timeShift": null, + "title": "Clients", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [{ "op": "=", "text": "N/A", "value": "null" }], + "valueName": "avg" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "GitLab Omnibus", + "editable": true, + "error": false, + "fill": 1, + "grid": {}, + "gridPos": { "h": 6, "w": 8, "x": 8, "y": 0 }, + "id": 2, + "isNew": true, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(\n rate(redis_commands_processed_total{instance=~\"$instance\"}[$__interval])\n)", + "format": "time_series", + "interval": "1m", + "intervalFactor": 2, + "legendFormat": "", + "metric": "A", + "refId": "A", + "step": 240, + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Commands Executed", + "tooltip": { "msResolution": false, "shared": true, "sort": 0, "value_type": "cumulative" }, + "type": "graph", + "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, + "yaxes": [ + { "format": "reqps", "label": null, "logBase": 1, "max": null, "min": "0", "show": true }, + { "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": true } + ], + "yaxis": { "align": false, "alignLevel": null } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "GitLab Omnibus", + "decimals": 2, + "editable": true, + "error": false, + "fill": 1, + "grid": {}, + "gridPos": { "h": 6, "w": 8, "x": 16, "y": 0 }, + "id": 1, + "isNew": true, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "paceLength": 10, + "percentage": true, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(\n rate(redis_keyspace_hits_total{instance=~\"$instance\"}[$__interval])\n)", + "format": "time_series", + "hide": false, + "interval": "1m", + "intervalFactor": 1, + "legendFormat": "hits", + "metric": "", + "refId": "A", + "step": 240, + "target": "" + }, + { + "expr": "sum(\n rate(redis_keyspace_misses_total{instance=~\"$instance\"}[$__interval])\n)", + "format": "time_series", + "hide": false, + "interval": "1m", + "intervalFactor": 1, + "legendFormat": "misses", + "metric": "", + "refId": "B", + "step": 240, + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Hits, Misses per Second", + "tooltip": { "msResolution": false, "shared": true, "sort": 0, "value_type": "individual" }, + "type": "graph", + "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, + "yaxes": [ + { "format": "short", "label": "", "logBase": 1, "max": null, "min": 0, "show": true }, + { "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": true } + ], + "yaxis": { "align": false, "alignLevel": null } + }, + { + "aliasColors": { "max": "#BF1B00" }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "GitLab Omnibus", + "editable": true, + "error": false, + "fill": 1, + "grid": {}, + "gridPos": { "h": 10, "w": 8, "x": 0, "y": 3 }, + "id": 7, + "isNew": true, + "legend": { + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null as zero", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [{ "alias": "/max - .*/", "dashes": true }], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "redis_memory_used_bytes{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "used - {{instance}}", + "metric": "", + "refId": "A", + "step": 240, + "target": "" + }, + { + "expr": "redis_config_maxmemory{instance=~\"$instance\"} \u003e 0", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "max - {{instance}}", + "refId": "B", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Memory Usage", + "tooltip": { "msResolution": false, "shared": true, "sort": 0, "value_type": "cumulative" }, + "type": "graph", + "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, + "yaxes": [ + { "format": "bytes", "label": null, "logBase": 1, "max": null, "min": 0, "show": true }, + { "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": true } + ], + "yaxis": { "align": false, "alignLevel": null } + }, + { + "aliasColors": { + "evicts": "#890F02", + "memcached_items_evicted_total{instance=\"172.17.0.1:9150\",job=\"prometheus\"}": "#890F02", + "reclaims": "#3F6833" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "GitLab Omnibus", + "editable": true, + "error": false, + "fill": 1, + "grid": {}, + "gridPos": { "h": 7, "w": 8, "x": 8, "y": 6 }, + "id": 8, + "isNew": true, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [{ "alias": "reclaims", "yaxis": 2 }], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(rate(redis_expired_keys_total{instance=~\"$instance\"}[$__interval]))", + "format": "time_series", + "interval": "1m", + "intervalFactor": 2, + "legendFormat": "expired - {{ test_attribute }}", + "metric": "", + "refId": "A", + "step": 240, + "target": "" + }, + { + "expr": "sum(rate(redis_evicted_keys_total{instance=~\"$instance\"}[$__interval]))", + "format": "time_series", + "interval": "1m", + "intervalFactor": 2, + "legendFormat": "evicted", + "refId": "B", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Expired / Evicted", + "tooltip": { "msResolution": false, "shared": true, "sort": 0, "value_type": "cumulative" }, + "type": "graph", + "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, + "yaxes": [ + { "format": "short", "label": null, "logBase": 1, "max": null, "min": "0", "show": true }, + { "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": true } + ], + "yaxis": { "align": false, "alignLevel": null } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "GitLab Omnibus", + "editable": true, + "error": false, + "fill": 1, + "grid": {}, + "gridPos": { "h": 7, "w": 8, "x": 16, "y": 6 }, + "id": 10, + "isNew": true, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(\n rate(redis_net_input_bytes_total{instance=~\"$instance\"}[$__interval])\n)", + "format": "time_series", + "interval": "1m", + "intervalFactor": 2, + "legendFormat": "In", + "refId": "A", + "step": 240 + }, + { + "expr": "sum(\n rate(redis_net_output_bytes_total{instance=~\"$instance\"}[$__interval])\n)", + "format": "time_series", + "interval": "1m", + "intervalFactor": 2, + "legendFormat": "Out", + "refId": "B", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Network I/O", + "tooltip": { "msResolution": true, "shared": true, "sort": 0, "value_type": "cumulative" }, + "type": "graph", + "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, + "yaxes": [ + { "format": "Bps", "label": null, "logBase": 1, "max": null, "min": "0", "show": true }, + { "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": true } + ], + "yaxis": { "align": false, "alignLevel": null } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "GitLab Omnibus", + "editable": true, + "error": false, + "fill": 8, + "grid": {}, + "gridPos": { "h": 7, "w": 16, "x": 0, "y": 13 }, + "id": 14, + "isNew": true, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "connected", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "expr": "sum without (instance) (\n rate(redis_commands_total{instance=~\"$instance\"}[$__interval])\n) \u003e 0", + "format": "time_series", + "interval": "1m", + "intervalFactor": 2, + "legendFormat": "{{ cmd }}", + "metric": "redis_command_calls_total", + "refId": "A", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Command Calls / sec", + "tooltip": { "msResolution": true, "shared": true, "sort": 2, "value_type": "individual" }, + "type": "graph", + "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, + "yaxes": [ + { "format": "short", "label": null, "logBase": 1, "max": null, "min": "0", "show": true }, + { "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": true } + ], + "yaxis": { "align": false, "alignLevel": null } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "GitLab Omnibus", + "editable": true, + "error": false, + "fill": 7, + "grid": {}, + "gridPos": { "h": 7, "w": 8, "x": 16, "y": 13 }, + "id": 13, + "isNew": true, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "expr": "sum(redis_db_keys{instance=~\"$instance\"} - redis_db_keys_expiring{instance=~\"$instance\"}) ", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "not expiring", + "refId": "A", + "step": 240, + "target": "" + }, + { + "expr": "sum(redis_db_keys_expiring{instance=~\"$instance\"})", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "expiring", + "metric": "", + "refId": "B", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Expiring vs Not-Expiring Keys", + "tooltip": { "msResolution": false, "shared": true, "sort": 0, "value_type": "individual" }, + "type": "graph", + "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, + "yaxes": [ + { "format": "short", "label": null, "logBase": 1, "max": null, "min": "0", "show": true }, + { "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": true } + ], + "yaxis": { "align": false, "alignLevel": null } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "GitLab Omnibus", + "editable": true, + "error": false, + "fill": 7, + "grid": {}, + "gridPos": { "h": 7, "w": 16, "x": 0, "y": 20 }, + "id": 5, + "isNew": true, + "legend": { + "alignAsTable": true, + "avg": false, + "current": true, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (db) (\n redis_db_keys{instance=~\"$instance\"}\n)", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{ db }} ", + "refId": "A", + "step": 240, + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Items per DB", + "tooltip": { "msResolution": false, "shared": true, "sort": 0, "value_type": "individual" }, + "type": "graph", + "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, + "yaxes": [ + { "format": "none", "label": null, "logBase": 1, "max": null, "min": "0", "show": true }, + { "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": true } + ], + "yaxis": { "align": false, "alignLevel": null } + } + ], + "refresh": "1m", + "schemaVersion": 18, + "style": "dark", + "tags": ["redis"], + "templating": { + "list": [ + { + "allValue": null, + "current": { "tags": [], "text": "All", "value": "$__all" }, + "datasource": "GitLab Omnibus", + "definition": "", + "hide": 0, + "includeAll": true, + "label": null, + "multi": false, + "name": "instance", + "options": [], + "query": "label_values(up{job=\"redis\"}, instance)", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { "from": "now-24h", "to": "now" }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + }, + "timezone": "", + "title": "GitLab Omnibus - Redis", + "uid": "XDaNK6amz", + "version": 1 + } +} diff --git a/spec/fixtures/grafana/datasource_response.json b/spec/fixtures/grafana/datasource_response.json new file mode 100644 index 0000000000000000000000000000000000000000..07c075beb3565d8dab7b966681fb450323e94777 --- /dev/null +++ b/spec/fixtures/grafana/datasource_response.json @@ -0,0 +1,21 @@ +{ + "id": 1, + "orgId": 1, + "name": "GitLab Omnibus", + "type": "prometheus", + "typeLogoUrl": "", + "access": "proxy", + "url": "http://localhost:9090", + "password": "", + "user": "", + "database": "", + "basicAuth": false, + "basicAuthUser": "", + "basicAuthPassword": "", + "withCredentials": false, + "isDefault": true, + "jsonData": {}, + "secureJsonFields": {}, + "version": 1, + "readOnly": true +} diff --git a/spec/fixtures/grafana/expected_grafana_embed.json b/spec/fixtures/grafana/expected_grafana_embed.json new file mode 100644 index 0000000000000000000000000000000000000000..72fb5477b9e1bd41d0d58af4ce5476f1248c1a52 --- /dev/null +++ b/spec/fixtures/grafana/expected_grafana_embed.json @@ -0,0 +1,27 @@ +{ + "panel_groups": [ + { + "panels": [ + { + "title": "Network I/O", + "type": "area-chart", + "y_label": "", + "metrics": [ + { + "id": "In_0", + "query_range": "sum( rate(redis_net_input_bytes_total{instance=~\"localhost:9121\"}[1m]))", + "label": "In", + "prometheus_endpoint_path": "/foo/bar/-/grafana/proxy/1/api/v1/query_range?query=sum%28++rate%28redis_net_input_bytes_total%7Binstance%3D~%22localhost%3A9121%22%7D%5B1m%5D%29%29" + }, + { + "id": "Out_1", + "query_range": "sum( rate(redis_net_output_bytes_total{instance=~\"localhost:9121\"}[1m]))", + "label": "Out", + "prometheus_endpoint_path": "/foo/bar/-/grafana/proxy/1/api/v1/query_range?query=sum%28++rate%28redis_net_output_bytes_total%7Binstance%3D~%22localhost%3A9121%22%7D%5B1m%5D%29%29" + } + ] + } + ] + } + ] +} diff --git a/spec/fixtures/grafana/proxy_response.json b/spec/fixtures/grafana/proxy_response.json new file mode 100644 index 0000000000000000000000000000000000000000..b9f34abcaaf06c2db44efb7370e6bac6925dc2dd --- /dev/null +++ b/spec/fixtures/grafana/proxy_response.json @@ -0,0 +1,459 @@ +{ + "status": "success", + "data": { + "resultType": "matrix", + "result": [ + { + "metric": { + "test_attribute": "test-attribute-value" + }, + "values": [ + [1570768177, "54"], + [1570768237, "54"], + [1570768297, "54"], + [1570768357, "54"], + [1570768417, "54"], + [1570768477, "54"], + [1570768537, "54"], + [1570768597, "54"], + [1570768657, "54"], + [1570768717, "54"], + [1570768777, "54"], + [1570768837, "54"], + [1570768897, "54"], + [1570768957, "54"], + [1570769017, "54"], + [1570769077, "54"], + [1570769377, "54"], + [1570769437, "54"], + [1570769497, "54"], + [1570769557, "54"], + [1570769617, "54"], + [1570769677, "54"], + [1570769737, "54"], + [1570769797, "54"], + [1570769857, "54"], + [1570769917, "54"], + [1570769977, "54"], + [1570770037, "54"], + [1570770097, "54"], + [1570770157, "54"], + [1570770217, "54"], + [1570770277, "54"], + [1570770337, "54"], + [1570770397, "54"], + [1570770457, "54"], + [1570770517, "54"], + [1570770577, "54"], + [1570770637, "54"], + [1570770697, "54"], + [1570770757, "54"], + [1570770817, "54"], + [1570770877, "54"], + [1570770937, "54"], + [1570770997, "54"], + [1570771057, "54"], + [1570771117, "54"], + [1570771177, "54"], + [1570771237, "54"], + [1570771297, "54"], + [1570771357, "54"], + [1570771417, "54"], + [1570771477, "54"], + [1570771537, "54"], + [1570771597, "54"], + [1570771657, "54"], + [1570771717, "54"], + [1570771777, "54"], + [1570771837, "54"], + [1570771897, "54"], + [1570771957, "54"], + [1570772017, "54"], + [1570772077, "54"], + [1570772137, "54"], + [1570772197, "54"], + [1570772257, "54"], + [1570772317, "54"], + [1570772377, "54"], + [1570772437, "54"], + [1570772497, "54"], + [1570772557, "54"], + [1570772617, "54"], + [1570772677, "54"], + [1570772737, "54"], + [1570772797, "54"], + [1570772857, "54"], + [1570772917, "54"], + [1570772977, "54"], + [1570773037, "54"], + [1570773097, "54"], + [1570773157, "54"], + [1570773217, "54"], + [1570773277, "54"], + [1570773337, "54"], + [1570773397, "54"], + [1570773457, "54"], + [1570773517, "54"], + [1570773577, "54"], + [1570773637, "54"], + [1570773697, "54"], + [1570773757, "54"], + [1570773817, "54"], + [1570773877, "54"], + [1570773937, "54"], + [1570773997, "54"], + [1570774057, "54"], + [1570774117, "54"], + [1570774177, "54"], + [1570774237, "54"], + [1570774297, "54"], + [1570774357, "54"], + [1570774417, "54"], + [1570774477, "54"], + [1570774537, "54"], + [1570774597, "54"], + [1570774657, "54"], + [1570774717, "54"], + [1570774777, "54"], + [1570774837, "54"], + [1570774897, "54"], + [1570774957, "54"], + [1570775017, "54"], + [1570775077, "54"], + [1570775137, "54"], + [1570776937, "54"], + [1570776997, "54"], + [1570777057, "54"], + [1570777117, "54"], + [1570777177, "54"], + [1570777237, "54"], + [1570777297, "54"], + [1570777357, "54"], + [1570777417, "54"], + [1570777477, "54"], + [1570777537, "54"], + [1570777597, "54"], + [1570777657, "54"], + [1570777717, "54"], + [1570778017, "54"], + [1570778077, "54"], + [1570778137, "54"], + [1570778197, "54"], + [1570778257, "54"], + [1570778317, "54"], + [1570778377, "54"], + [1570778437, "54"], + [1570778497, "54"], + [1570778557, "54"], + [1570778617, "54"], + [1570778677, "54"], + [1570778737, "54"], + [1570778797, "54"], + [1570778857, "54"], + [1570778917, "54"], + [1570778977, "54"], + [1570779037, "54"], + [1570779097, "54"], + [1570779157, "54"], + [1570779217, "54"], + [1570779277, "54"], + [1570779337, "54"], + [1570779397, "54"], + [1570779457, "54"], + [1570779517, "54"], + [1570779577, "54"], + [1570779637, "54"], + [1570779697, "54"], + [1570779757, "54"], + [1570779817, "54"], + [1570779877, "54"], + [1570779937, "54"], + [1570779997, "54"], + [1570780057, "54"], + [1570780117, "54"], + [1570780177, "54"], + [1570780237, "54"], + [1570780297, "54"], + [1570780357, "54"], + [1570780417, "54"], + [1570780477, "54"], + [1570780537, "54"], + [1570780597, "54"], + [1570780657, "54"], + [1570780717, "54"], + [1570780777, "54"], + [1570780837, "54"], + [1570780897, "54"], + [1570780957, "54"], + [1570781017, "54"], + [1570781077, "54"], + [1570781137, "54"], + [1570781197, "54"], + [1570781257, "54"], + [1570781317, "54"], + [1570781377, "54"], + [1570781437, "54"], + [1570781497, "54"], + [1570781557, "54"], + [1570781617, "54"], + [1570781677, "54"], + [1570781737, "54"], + [1570781797, "54"], + [1570781857, "54"], + [1570781917, "54"], + [1570781977, "54"], + [1570782037, "54"], + [1570782097, "54"], + [1570782157, "54"], + [1570782217, "54"], + [1570782277, "54"], + [1570782337, "54"], + [1570782397, "54"], + [1570782457, "54"], + [1570782517, "54"], + [1570782577, "54"], + [1570782637, "54"], + [1570782697, "54"], + [1570782757, "54"], + [1570782817, "54"], + [1570782877, "54"], + [1570782937, "54"], + [1570782997, "54"], + [1570783057, "54"], + [1570783117, "54"], + [1570783177, "54"], + [1570783237, "54"], + [1570783297, "54"], + [1570783357, "54"], + [1570783417, "54"], + [1570783477, "54"], + [1570783537, "54"], + [1570783597, "54"], + [1570783657, "54"], + [1570783717, "54"], + [1570783777, "54"], + [1570783837, "54"], + [1570783897, "54"], + [1570783957, "54"], + [1570784017, "54"], + [1570784077, "54"], + [1570784137, "54"], + [1570784197, "54"], + [1570784257, "54"], + [1570784317, "54"], + [1570784377, "54"], + [1570784437, "54"], + [1570784497, "54"], + [1570784557, "54"], + [1570784617, "54"], + [1570784677, "54"], + [1570784737, "54"], + [1570784797, "54"], + [1570784857, "54"], + [1570784917, "54"], + [1570784977, "54"], + [1570785037, "54"], + [1570785097, "54"], + [1570785157, "54"], + [1570785217, "54"], + [1570785277, "54"], + [1570785337, "54"], + [1570785397, "54"], + [1570785457, "54"], + [1570785517, "54"], + [1570785577, "54"], + [1570785637, "54"], + [1570785697, "54"], + [1570785757, "54"], + [1570785817, "54"], + [1570785877, "54"], + [1570785937, "54"], + [1570785997, "54"], + [1570786057, "54"], + [1570786117, "54"], + [1570786177, "54"], + [1570786237, "54"], + [1570786297, "54"], + [1570786357, "54"], + [1570786417, "54"], + [1570786477, "54"], + [1570786537, "54"], + [1570786597, "54"], + [1570786657, "54"], + [1570786717, "54"], + [1570786777, "54"], + [1570786837, "54"], + [1570786897, "54"], + [1570786957, "53"], + [1570787017, "54"], + [1570787077, "54"], + [1570787137, "54"], + [1570787197, "54"], + [1570787257, "54"], + [1570787317, "54"], + [1570787377, "54"], + [1570787437, "54"], + [1570787497, "54"], + [1570787557, "54"], + [1570787617, "54"], + [1570787677, "54"], + [1570787737, "54"], + [1570787797, "54"], + [1570787857, "54"], + [1570787917, "54"], + [1570787977, "54"], + [1570788037, "54"], + [1570788097, "54"], + [1570788157, "54"], + [1570788217, "54"], + [1570788277, "54"], + [1570788337, "54"], + [1570788397, "54"], + [1570788457, "54"], + [1570788517, "54"], + [1570788577, "54"], + [1570788637, "54"], + [1570788697, "54"], + [1570788757, "54"], + [1570788817, "54"], + [1570788877, "54"], + [1570788937, "54"], + [1570788997, "54"], + [1570789057, "54"], + [1570789117, "54"], + [1570789177, "54"], + [1570789237, "54"], + [1570789297, "54"], + [1570789357, "54"], + [1570789417, "54"], + [1570789477, "54"], + [1570789537, "54"], + [1570789597, "54"], + [1570789657, "54"], + [1570789717, "54"], + [1570789777, "54"], + [1570789837, "54"], + [1570789897, "54"], + [1570789957, "54"], + [1570790017, "54"], + [1570790077, "54"], + [1570790137, "54"], + [1570790197, "54"], + [1570790257, "54"], + [1570790317, "54"], + [1570790377, "54"], + [1570790437, "54"], + [1570790497, "54"], + [1570790557, "54"], + [1570790617, "54"], + [1570790677, "54"], + [1570790737, "54"], + [1570790797, "54"], + [1570790857, "54"], + [1570790917, "54"], + [1570790977, "54"], + [1570791037, "54"], + [1570791097, "54"], + [1570791157, "54"], + [1570791217, "54"], + [1570791277, "54"], + [1570791337, "54"], + [1570791397, "54"], + [1570791457, "54"], + [1570791517, "54"], + [1570791577, "54"], + [1570791637, "54"], + [1570791697, "54"], + [1570791757, "54"], + [1570791817, "54"], + [1570791877, "54"], + [1570791937, "54"], + [1570791997, "54"], + [1570792057, "54"], + [1570792117, "54"], + [1570792177, "54"], + [1570792237, "54"], + [1570792297, "54"], + [1570792357, "54"], + [1570792417, "54"], + [1570792477, "54"], + [1570792537, "54"], + [1570792597, "54"], + [1570792657, "54"], + [1570792717, "54"], + [1570792777, "54"], + [1570792837, "54"], + [1570792897, "54"], + [1570792957, "54"], + [1570793017, "54"], + [1570793077, "54"], + [1570793137, "54"], + [1570793197, "54"], + [1570793257, "54"], + [1570793317, "54"], + [1570793377, "54"], + [1570793437, "54"], + [1570793497, "54"], + [1570793557, "54"], + [1570793617, "54"], + [1570793677, "54"], + [1570793737, "54"], + [1570793797, "54"], + [1570793857, "54"], + [1570793917, "54"], + [1570793977, "54"], + [1570794037, "54"], + [1570794097, "54"], + [1570794157, "54"], + [1570794217, "54"], + [1570794277, "54"], + [1570794337, "54"], + [1570794397, "54"], + [1570794457, "54"], + [1570794517, "54"], + [1570794577, "54"], + [1570794637, "54"], + [1570794697, "54"], + [1570794757, "54"], + [1570794817, "54"], + [1570794877, "54"], + [1570794937, "54"], + [1570794997, "54"], + [1570795057, "54"], + [1570795117, "54"], + [1570795177, "54"], + [1570795237, "54"], + [1570795297, "54"], + [1570795357, "54"], + [1570795417, "54"], + [1570795477, "54"], + [1570795537, "54"], + [1570795597, "54"], + [1570795657, "54"], + [1570795717, "54"], + [1570795777, "54"], + [1570795837, "54"], + [1570795897, "54"], + [1570795957, "54"], + [1570796017, "54"], + [1570796077, "54"], + [1570796137, "54"], + [1570796197, "54"], + [1570796257, "54"], + [1570796317, "54"], + [1570796377, "54"], + [1570796437, "55"], + [1570796497, "54"], + [1570796557, "54"], + [1570796617, "54"], + [1570796677, "54"], + [1570796737, "54"], + [1570796797, "54"], + [1570796857, "54"], + [1570796917, "54"], + [1570796977, "54"] + ] + } + ] + } +} diff --git a/spec/fixtures/grafana/simplified_dashboard_response.json b/spec/fixtures/grafana/simplified_dashboard_response.json new file mode 100644 index 0000000000000000000000000000000000000000..b450fda082b257938ed46120b17f519141fe457e --- /dev/null +++ b/spec/fixtures/grafana/simplified_dashboard_response.json @@ -0,0 +1,40 @@ +{ + "dashboard": { + "panels": [ + { + "datasource": "GitLab Omnibus", + "id": 8, + "lines": true, + "targets": [ + { + "expr": "sum(\n rate(redis_net_input_bytes_total{instance=~\"$instance\"}[$__interval])\n)", + "format": "time_series", + "interval": "1m", + "legendFormat": "In", + "refId": "A" + }, + { + "expr": "sum(\n rate(redis_net_output_bytes_total{instance=~\"[[instance]]\"}[$__interval])\n)", + "format": "time_series", + "interval": "1m", + "legendFormat": "Out", + "refId": "B" + } + ], + "title": "Network I/O", + "type": "graph", + "yaxes": [{ "format": "Bps" }, { "format": "short" }] + } + ], + "templating": { + "list": [ + { + "current": { + "value": "localhost:9121" + }, + "name": "instance" + } + ] + } + } +} diff --git a/spec/fixtures/group_export.tar.gz b/spec/fixtures/group_export.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..83e360d7cc2ee8fa30ee94ace547aca071b6a6d3 Binary files /dev/null and b/spec/fixtures/group_export.tar.gz differ diff --git a/spec/fixtures/lib/gitlab/import_export/project.json b/spec/fixtures/lib/gitlab/import_export/complex/project.json similarity index 99% rename from spec/fixtures/lib/gitlab/import_export/project.json rename to spec/fixtures/lib/gitlab/import_export/complex/project.json index fbd752b740399b652eb462c2599aa4a6a6ea9b54..31805a54f2f339cef1d4628caf9acb7138d0b67f 100644 --- a/spec/fixtures/lib/gitlab/import_export/project.json +++ b/spec/fixtures/lib/gitlab/import_export/complex/project.json @@ -80,6 +80,17 @@ "issue_id": 40 } ], + "zoom_meetings": [ + { + "id": 1, + "project_id": 5, + "issue_id": 40, + "url": "https://zoom.us/j/123456789", + "issue_status": 1, + "created_at": "2016-06-14T15:02:04.418Z", + "updated_at": "2016-06-14T15:02:04.418Z" + } + ], "milestone": { "id": 1, "title": "test milestone", @@ -2249,7 +2260,41 @@ ] } ], - "snippets": [], + "snippets": [ + { + "id": 1, + "title": "Test snippet title", + "content": "x = 1", + "author_id": 1, + "project_id": 1, + "created_at": "2019-11-05T15:06:06.579Z", + "updated_at": "2019-11-05T15:06:06.579Z", + "file_name": "", + "visibility_level": 20, + "description": "Test snippet description", + "award_emoji": [ + { + "id": 1, + "name": "thumbsup", + "user_id": 1, + "awardable_type": "Snippet", + "awardable_id": 1, + "created_at": "2019-11-05T15:37:21.287Z", + "updated_at": "2019-11-05T15:37:21.287Z" + }, + { + "id": 2, + "name": "coffee", + "user_id": 1, + "awardable_type": "Snippet", + "awardable_id": 1, + "created_at": "2019-11-05T15:37:24.645Z", + "updated_at": "2019-11-05T15:37:24.645Z" + } + ], + "notes": [] + } + ], "releases": [], "project_members": [ { @@ -6669,6 +6714,25 @@ ] } ] + }, + { + "id": 41, + "project_id": 5, + "ref": "master", + "sha": "2ea1f3dec713d940208fb5ce4a38765ecb5d3f73", + "before_sha": null, + "push_data": null, + "created_at": "2016-03-22T15:20:35.763Z", + "updated_at": "2016-03-22T15:20:35.763Z", + "tag": null, + "yaml_errors": null, + "committed_at": null, + "status": "failed", + "started_at": null, + "finished_at": null, + "duration": null, + "stages": [ + ] } ], "triggers": [ diff --git a/spec/fixtures/lib/gitlab/import_export/project.group.json b/spec/fixtures/lib/gitlab/import_export/group/project.json similarity index 100% rename from spec/fixtures/lib/gitlab/import_export/project.group.json rename to spec/fixtures/lib/gitlab/import_export/group/project.json diff --git a/spec/fixtures/lib/gitlab/import_export/project.light.json b/spec/fixtures/lib/gitlab/import_export/light/project.json similarity index 100% rename from spec/fixtures/lib/gitlab/import_export/project.light.json rename to spec/fixtures/lib/gitlab/import_export/light/project.json diff --git a/spec/fixtures/lib/gitlab/import_export/project.milestone-iid.json b/spec/fixtures/lib/gitlab/import_export/milestone-iid/project.json similarity index 100% rename from spec/fixtures/lib/gitlab/import_export/project.milestone-iid.json rename to spec/fixtures/lib/gitlab/import_export/milestone-iid/project.json diff --git a/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/metrics.json b/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/metrics.json index 9c1be32645ab9969e8ac858fff29dbcbff692603..ac40f2dcd134f0c0652956bbc4555241c88aa045 100644 --- a/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/metrics.json +++ b/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/metrics.json @@ -1,7 +1,6 @@ { "type": "object", "required": [ - "unit", "label", "prometheus_endpoint_path" ], diff --git a/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/panels.json b/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/panels.json index 1548daacd643d035bef78e3b1b8f48bf7f8fb3b6..a16f1ef592f611caa5989c8fbf67cb550c022305 100644 --- a/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/panels.json +++ b/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/panels.json @@ -3,7 +3,6 @@ "required": [ "title", "y_label", - "weight", "metrics" ], "properties": { diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index 62ba0d36982afe920d870e89d2abc5d19dd16db1..cef50bf553caa781afd91d8c6a04a1cf6e0292d6 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -467,6 +467,26 @@ describe('Api', () => { }); }); + describe('user projects', () => { + it('fetches all projects that belong to a particular user', done => { + const query = 'dummy query'; + const options = { unused: 'option' }; + const userId = '123456'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}/projects`; + mock.onGet(expectedUrl).reply(200, [ + { + name: 'test', + }, + ]); + + Api.userProjects(userId, query, options, response => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + done(); + }); + }); + }); + describe('commitPipelines', () => { it('fetches pipelines for a given commit', done => { const projectId = 'example/foobar'; diff --git a/spec/frontend/boards/components/issue_time_estimate_spec.js b/spec/frontend/boards/components/issue_time_estimate_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0a16dfbc0095f511960ca957da37c9396cf51c94 --- /dev/null +++ b/spec/frontend/boards/components/issue_time_estimate_spec.js @@ -0,0 +1,81 @@ +import IssueTimeEstimate from '~/boards/components/issue_time_estimate.vue'; +import boardsStore from '~/boards/stores/boards_store'; +import { shallowMount } from '@vue/test-utils'; + +describe('Issue Time Estimate component', () => { + let wrapper; + + beforeEach(() => { + boardsStore.create(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when limitToHours is false', () => { + beforeEach(() => { + boardsStore.timeTracking.limitToHours = false; + wrapper = shallowMount(IssueTimeEstimate, { + propsData: { + estimate: 374460, + }, + sync: false, + }); + }); + + it('renders the correct time estimate', () => { + expect( + wrapper + .find('time') + .text() + .trim(), + ).toEqual('2w 3d 1m'); + }); + + it('renders expanded time estimate in tooltip', () => { + expect(wrapper.find('.js-issue-time-estimate').text()).toContain('2 weeks 3 days 1 minute'); + }); + + it('prevents tooltip xss', done => { + const alertSpy = jest.spyOn(window, 'alert'); + wrapper.setProps({ estimate: 'Foo <script>alert("XSS")</script>' }); + wrapper.vm.$nextTick(() => { + expect(alertSpy).not.toHaveBeenCalled(); + expect( + wrapper + .find('time') + .text() + .trim(), + ).toEqual('0m'); + expect(wrapper.find('.js-issue-time-estimate').text()).toContain('0m'); + done(); + }); + }); + }); + + describe('when limitToHours is true', () => { + beforeEach(() => { + boardsStore.timeTracking.limitToHours = true; + wrapper = shallowMount(IssueTimeEstimate, { + propsData: { + estimate: 374460, + }, + sync: false, + }); + }); + + it('renders the correct time estimate', () => { + expect( + wrapper + .find('time') + .text() + .trim(), + ).toEqual('104h 1m'); + }); + + it('renders expanded time estimate in tooltip', () => { + expect(wrapper.find('.js-issue-time-estimate').text()).toContain('104 hours 1 minute'); + }); + }); +}); diff --git a/spec/frontend/boards/issue_card_spec.js b/spec/frontend/boards/issue_card_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..ebe97769ab7c9a72d92741cbcf4118069f5865ae --- /dev/null +++ b/spec/frontend/boards/issue_card_spec.js @@ -0,0 +1,307 @@ +/* global ListAssignee, ListLabel, ListIssue */ +import { mount } from '@vue/test-utils'; +import _ from 'underscore'; +import '~/boards/models/label'; +import '~/boards/models/assignee'; +import '~/boards/models/issue'; +import '~/boards/models/list'; +import IssueCardInner from '~/boards/components/issue_card_inner.vue'; +import { listObj } from '../../javascripts/boards/mock_data'; +import store from '~/boards/stores'; + +describe('Issue card component', () => { + const user = new ListAssignee({ + id: 1, + name: 'testing 123', + username: 'test', + avatar: 'test_image', + }); + + const label1 = new ListLabel({ + id: 3, + title: 'testing 123', + color: 'blue', + text_color: 'white', + description: 'test', + }); + + let wrapper; + let issue; + let list; + + beforeEach(() => { + list = { ...listObj, type: 'label' }; + issue = new ListIssue({ + title: 'Testing', + id: 1, + iid: 1, + confidential: false, + labels: [list.label], + assignees: [], + reference_path: '#1', + real_path: '/test/1', + weight: 1, + }); + wrapper = mount(IssueCardInner, { + propsData: { + list, + issue, + issueLinkBase: '/test', + rootPath: '/', + }, + store, + sync: false, + }); + }); + + it('renders issue title', () => { + expect(wrapper.find('.board-card-title').text()).toContain(issue.title); + }); + + it('includes issue base in link', () => { + expect(wrapper.find('.board-card-title a').attributes('href')).toContain('/test'); + }); + + it('includes issue title on link', () => { + expect(wrapper.find('.board-card-title a').attributes('title')).toBe(issue.title); + }); + + it('does not render confidential icon', () => { + expect(wrapper.find('.fa-eye-flash').exists()).toBe(false); + }); + + it('renders confidential icon', done => { + wrapper.setProps({ + issue: { + ...wrapper.props('issue'), + confidential: true, + }, + }); + wrapper.vm.$nextTick(() => { + expect(wrapper.find('.confidential-icon').exists()).toBe(true); + done(); + }); + }); + + it('renders issue ID with #', () => { + expect(wrapper.find('.board-card-number').text()).toContain(`#${issue.id}`); + }); + + describe('assignee', () => { + it('does not render assignee', () => { + expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(false); + }); + + describe('exists', () => { + beforeEach(done => { + wrapper.setProps({ + issue: { + ...wrapper.props('issue'), + assignees: [user], + }, + }); + + wrapper.vm.$nextTick(done); + }); + + it('renders assignee', () => { + expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(true); + }); + + it('sets title', () => { + expect(wrapper.find('.js-assignee-tooltip').text()).toContain(`${user.name}`); + }); + + it('sets users path', () => { + expect(wrapper.find('.board-card-assignee a').attributes('href')).toBe('/test'); + }); + + it('renders avatar', () => { + expect(wrapper.find('.board-card-assignee img').exists()).toBe(true); + }); + }); + + describe('assignee default avatar', () => { + beforeEach(done => { + wrapper.setProps({ + issue: { + ...wrapper.props('issue'), + assignees: [ + new ListAssignee( + { + id: 1, + name: 'testing 123', + username: 'test', + }, + 'default_avatar', + ), + ], + }, + }); + + wrapper.vm.$nextTick(done); + }); + + it('displays defaults avatar if users avatar is null', () => { + expect(wrapper.find('.board-card-assignee img').exists()).toBe(true); + expect(wrapper.find('.board-card-assignee img').attributes('src')).toBe( + 'default_avatar?width=24', + ); + }); + }); + }); + + describe('multiple assignees', () => { + beforeEach(done => { + wrapper.setProps({ + issue: { + ...wrapper.props('issue'), + assignees: [ + new ListAssignee({ + id: 2, + name: 'user2', + username: 'user2', + avatar: 'test_image', + }), + new ListAssignee({ + id: 3, + name: 'user3', + username: 'user3', + avatar: 'test_image', + }), + new ListAssignee({ + id: 4, + name: 'user4', + username: 'user4', + avatar: 'test_image', + }), + ], + }, + }); + + wrapper.vm.$nextTick(done); + }); + + it('renders all three assignees', () => { + expect(wrapper.findAll('.board-card-assignee .avatar').length).toEqual(3); + }); + + describe('more than three assignees', () => { + beforeEach(done => { + const { assignees } = wrapper.props('issue'); + assignees.push( + new ListAssignee({ + id: 5, + name: 'user5', + username: 'user5', + avatar: 'test_image', + }), + ); + + wrapper.setProps({ + issue: { + ...wrapper.props('issue'), + assignees, + }, + }); + wrapper.vm.$nextTick(done); + }); + + it('renders more avatar counter', () => { + expect( + wrapper + .find('.board-card-assignee .avatar-counter') + .text() + .trim(), + ).toEqual('+2'); + }); + + it('renders two assignees', () => { + expect(wrapper.findAll('.board-card-assignee .avatar').length).toEqual(2); + }); + + it('renders 99+ avatar counter', done => { + const assignees = [ + ...wrapper.props('issue').assignees, + ..._.range(5, 103).map( + i => + new ListAssignee({ + id: i, + name: 'name', + username: 'username', + avatar: 'test_image', + }), + ), + ]; + wrapper.setProps({ + issue: { + ...wrapper.props('issue'), + assignees, + }, + }); + + wrapper.vm.$nextTick(() => { + expect( + wrapper + .find('.board-card-assignee .avatar-counter') + .text() + .trim(), + ).toEqual('99+'); + done(); + }); + }); + }); + }); + + describe('labels', () => { + beforeEach(done => { + issue.addLabel(label1); + wrapper.setProps({ issue: { ...issue } }); + + wrapper.vm.$nextTick(done); + }); + + it('does not render list label but renders all other labels', () => { + expect(wrapper.findAll('.badge').length).toBe(1); + }); + + it('renders label', () => { + const nodes = wrapper + .findAll('.badge') + .wrappers.map(label => label.attributes('data-original-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, + ); + }); + + it('sets background color of button', () => { + const nodes = wrapper + .findAll('.badge') + .wrappers.map(label => label.element.style.backgroundColor); + + expect(nodes.includes(label1.color)).toBe(true); + }); + + it('does not render label if label does not have an ID', done => { + issue.addLabel( + new ListLabel({ + title: 'closed', + }), + ); + wrapper.setProps({ issue: { ...issue } }); + wrapper.vm + .$nextTick() + .then(() => { + expect(wrapper.findAll('.badge').length).toBe(1); + expect(wrapper.text()).not.toContain('closed'); + done(); + }) + .catch(done.fail); + }); + }); +}); diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..38b2333e679f0cffb8d5198a8abc6c172e78b591 --- /dev/null +++ b/spec/frontend/boards/stores/getters_spec.js @@ -0,0 +1,21 @@ +import getters from '~/boards/stores/getters'; + +describe('Boards - Getters', () => { + describe('getLabelToggleState', () => { + it('should return "on" when isShowingLabels is true', () => { + const state = { + isShowingLabels: true, + }; + + expect(getters.getLabelToggleState(state)).toBe('on'); + }); + + it('should return "off" when isShowingLabels is false', () => { + const state = { + isShowingLabels: false, + }; + + expect(getters.getLabelToggleState(state)).toBe('off'); + }); + }); +}); diff --git a/spec/frontend/clusters/clusters_bundle_spec.js b/spec/frontend/clusters/clusters_bundle_spec.js index 517d8781600776fda7a93d0e3d4b93f669bc78bb..199e11401a96d27a3281ef26acc696c9df01b576 100644 --- a/spec/frontend/clusters/clusters_bundle_spec.js +++ b/spec/frontend/clusters/clusters_bundle_spec.js @@ -10,8 +10,10 @@ import axios from '~/lib/utils/axios_utils'; import { loadHTMLFixture } from 'helpers/fixtures'; import { setTestTimeout } from 'helpers/timeout'; import $ from 'jquery'; +import initProjectSelectDropdown from '~/project_select'; jest.mock('~/lib/utils/poll'); +jest.mock('~/project_select'); const { INSTALLING, INSTALLABLE, INSTALLED, UNINSTALLING } = APPLICATION_STATUS; @@ -44,6 +46,7 @@ describe('Clusters', () => { afterEach(() => { cluster.destroy(); mock.restore(); + jest.clearAllMocks(); }); describe('class constructor', () => { @@ -55,6 +58,10 @@ describe('Clusters', () => { it('should call initPolling on construct', () => { expect(cluster.initPolling).toHaveBeenCalled(); }); + + it('should call initProjectSelectDropdown on construct', () => { + expect(initProjectSelectDropdown).toHaveBeenCalled(); + }); }); describe('toggle', () => { @@ -279,16 +286,21 @@ describe('Clusters', () => { }); describe('installApplication', () => { - it.each(APPLICATIONS)('tries to install %s', applicationId => { - jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce(); + it.each(APPLICATIONS)('tries to install %s', (applicationId, done) => { + jest.spyOn(cluster.service, 'installApplication').mockResolvedValue(); cluster.store.state.applications[applicationId].status = INSTALLABLE; - cluster.installApplication({ id: applicationId }); - - expect(cluster.store.state.applications[applicationId].status).toEqual(INSTALLING); - expect(cluster.store.state.applications[applicationId].requestReason).toEqual(null); - expect(cluster.service.installApplication).toHaveBeenCalledWith(applicationId, undefined); + // eslint-disable-next-line promise/valid-params + cluster + .installApplication({ id: applicationId }) + .then(() => { + expect(cluster.store.state.applications[applicationId].status).toEqual(INSTALLING); + expect(cluster.store.state.applications[applicationId].requestReason).toEqual(null); + expect(cluster.service.installApplication).toHaveBeenCalledWith(applicationId, undefined); + done(); + }) + .catch(); }); it('sets error request status when the request fails', () => { diff --git a/spec/frontend/clusters/components/applications_spec.js b/spec/frontend/clusters/components/applications_spec.js index fbcab078993e59792a9fed33ff85fc456dc6400e..49bda9539fdaf3ce6ef85b3c8d0d6d66feada4bb 100644 --- a/spec/frontend/clusters/components/applications_spec.js +++ b/spec/frontend/clusters/components/applications_spec.js @@ -6,6 +6,7 @@ import { APPLICATIONS_MOCK_STATE } from '../services/mock_data'; import eventHub from '~/clusters/event_hub'; import { shallowMount } from '@vue/test-utils'; import KnativeDomainEditor from '~/clusters/components/knative_domain_editor.vue'; +import CrossplaneProviderStack from '~/clusters/components/crossplane_provider_stack.vue'; describe('Applications', () => { let vm; @@ -13,6 +14,10 @@ describe('Applications', () => { beforeEach(() => { Applications = Vue.extend(applications); + + gon.features = gon.features || {}; + gon.features.enableClusterApplicationElasticStack = true; + gon.features.enableClusterApplicationCrossplane = true; }); afterEach(() => { @@ -39,6 +44,10 @@ describe('Applications', () => { expect(vm.$el.querySelector('.js-cluster-application-row-cert_manager')).not.toBeNull(); }); + it('renders a row for Crossplane', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-crossplane')).not.toBeNull(); + }); + it('renders a row for Prometheus', () => { expect(vm.$el.querySelector('.js-cluster-application-row-prometheus')).not.toBeNull(); }); @@ -54,6 +63,10 @@ describe('Applications', () => { it('renders a row for Knative', () => { expect(vm.$el.querySelector('.js-cluster-application-row-knative')).not.toBeNull(); }); + + it('renders a row for Elastic Stack', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull(); + }); }); describe('Group cluster applications', () => { @@ -76,6 +89,10 @@ describe('Applications', () => { expect(vm.$el.querySelector('.js-cluster-application-row-cert_manager')).not.toBeNull(); }); + it('renders a row for Crossplane', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-crossplane')).not.toBeNull(); + }); + it('renders a row for Prometheus', () => { expect(vm.$el.querySelector('.js-cluster-application-row-prometheus')).not.toBeNull(); }); @@ -91,6 +108,10 @@ describe('Applications', () => { it('renders a row for Knative', () => { expect(vm.$el.querySelector('.js-cluster-application-row-knative')).not.toBeNull(); }); + + it('renders a row for Elastic Stack', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull(); + }); }); describe('Instance cluster applications', () => { @@ -113,6 +134,10 @@ describe('Applications', () => { expect(vm.$el.querySelector('.js-cluster-application-row-cert_manager')).not.toBeNull(); }); + it('renders a row for Crossplane', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-crossplane')).not.toBeNull(); + }); + it('renders a row for Prometheus', () => { expect(vm.$el.querySelector('.js-cluster-application-row-prometheus')).not.toBeNull(); }); @@ -128,6 +153,10 @@ describe('Applications', () => { it('renders a row for Knative', () => { expect(vm.$el.querySelector('.js-cluster-application-row-knative')).not.toBeNull(); }); + + it('renders a row for Elastic Stack', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull(); + }); }); describe('Ingress application', () => { @@ -164,10 +193,12 @@ describe('Applications', () => { }, helm: { title: 'Helm Tiller' }, cert_manager: { title: 'Cert-Manager' }, + crossplane: { title: 'Crossplane', stack: '' }, runner: { title: 'GitLab Runner' }, prometheus: { title: 'Prometheus' }, jupyter: { title: 'JupyterHub', hostname: '' }, knative: { title: 'Knative', hostname: '' }, + elastic_stack: { title: 'Elastic Stack', kibana_hostname: '' }, }, }); @@ -260,7 +291,11 @@ describe('Applications', () => { }, }); - expect(vm.$el.querySelector('.js-hostname').getAttribute('readonly')).toEqual(null); + expect( + vm.$el + .querySelector('.js-cluster-application-row-jupyter .js-hostname') + .getAttribute('readonly'), + ).toEqual(null); }); }); @@ -273,7 +308,9 @@ describe('Applications', () => { }, }); - expect(vm.$el.querySelector('.js-hostname')).toBe(null); + expect(vm.$el.querySelector('.js-cluster-application-row-jupyter .js-hostname')).toBe( + null, + ); }); }); @@ -287,7 +324,11 @@ describe('Applications', () => { }, }); - expect(vm.$el.querySelector('.js-hostname').getAttribute('readonly')).toEqual('readonly'); + expect( + vm.$el + .querySelector('.js-cluster-application-row-jupyter .js-hostname') + .getAttribute('readonly'), + ).toEqual('readonly'); }); }); @@ -299,7 +340,9 @@ describe('Applications', () => { }); it('does not render input', () => { - expect(vm.$el.querySelector('.js-hostname')).toBe(null); + expect(vm.$el.querySelector('.js-cluster-application-row-jupyter .js-hostname')).toBe( + null, + ); }); it('renders disabled install button', () => { @@ -361,4 +404,110 @@ describe('Applications', () => { }); }); }); + + describe('Crossplane application', () => { + const propsData = { + applications: { + ...APPLICATIONS_MOCK_STATE, + crossplane: { + title: 'Crossplane', + stack: { + code: '', + }, + }, + }, + }; + + let wrapper; + beforeEach(() => { + wrapper = shallowMount(Applications, { propsData }); + }); + afterEach(() => { + wrapper.destroy(); + }); + it('renders the correct Component', () => { + const crossplane = wrapper.find(CrossplaneProviderStack); + expect(crossplane.exists()).toBe(true); + }); + }); + + describe('Elastic Stack application', () => { + describe('with ingress installed with ip & 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, + ); + }); + }); + + describe('with ingress & elastic stack installed', () => { + it('renders readonly input', () => { + 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: '' }, + }, + }); + + 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( + '.js-cluster-application-row-elastic_stack .js-cluster-application-install-button', + ) + .getAttribute('disabled'), + ).toEqual('disabled'); + }); + }); + }); }); diff --git a/spec/frontend/clusters/services/crossplane_provider_stack_spec.js b/spec/frontend/clusters/services/crossplane_provider_stack_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0d234822d7bfc3a1918e313a0b28bf229c60b888 --- /dev/null +++ b/spec/frontend/clusters/services/crossplane_provider_stack_spec.js @@ -0,0 +1,78 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlDropdownItem } from '@gitlab/ui'; +import CrossplaneProviderStack from '~/clusters/components/crossplane_provider_stack.vue'; + +describe('CrossplaneProviderStack component', () => { + let wrapper; + + const defaultProps = { + stacks: [ + { + name: 'Google Cloud Platform', + code: 'gcp', + }, + { + name: 'Amazon Web Services', + code: 'aws', + }, + ], + }; + + function createComponent(props = {}) { + const propsData = { + ...defaultProps, + ...props, + }; + + wrapper = shallowMount(CrossplaneProviderStack, { + propsData, + }); + } + + beforeEach(() => { + const crossplane = { + title: 'crossplane', + stack: '', + }; + createComponent({ crossplane }); + }); + + const findDropdownElements = () => wrapper.findAll(GlDropdownItem); + const findFirstDropdownElement = () => findDropdownElements().at(0); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders all of the available stacks in the dropdown', () => { + const dropdownElements = findDropdownElements(); + + expect(dropdownElements.length).toBe(defaultProps.stacks.length); + + defaultProps.stacks.forEach((stack, index) => + expect(dropdownElements.at(index).text()).toEqual(stack.name), + ); + }); + + it('displays the correct label for the first dropdown item if a stack is selected', () => { + const crossplane = { + title: 'crossplane', + stack: 'gcp', + }; + createComponent({ crossplane }); + expect(wrapper.vm.dropdownText).toBe('Google Cloud Platform'); + }); + + it('emits the "set" event with the selected stack value', () => { + const crossplane = { + title: 'crossplane', + stack: 'gcp', + }; + createComponent({ crossplane }); + findFirstDropdownElement().vm.$emit('click'); + expect(wrapper.emitted().set[0][0].code).toEqual('gcp'); + }); + it('it renders the correct dropdown text when no stack is selected', () => { + expect(wrapper.vm.dropdownText).toBe('Select Stack'); + }); +}); diff --git a/spec/frontend/clusters/services/mock_data.js b/spec/frontend/clusters/services/mock_data.js index 41ad398e924060c119f43938d5d58b1a57bfef7d..016f5a259b5c6f7ebfae6117b4a6da9a59a99c5b 100644 --- a/spec/frontend/clusters/services/mock_data.js +++ b/spec/frontend/clusters/services/mock_data.js @@ -52,6 +52,18 @@ const CLUSTERS_MOCK_DATA = { email: 'test@example.com', can_uninstall: false, }, + { + name: 'crossplane', + status: APPLICATION_STATUS.ERROR, + status_reason: 'Cannot connect', + can_uninstall: false, + }, + { + name: 'elastic_stack', + status: APPLICATION_STATUS.ERROR, + status_reason: 'Cannot connect', + can_uninstall: false, + }, ], }, }, @@ -98,6 +110,17 @@ const CLUSTERS_MOCK_DATA = { status_reason: 'Cannot connect', email: 'test@example.com', }, + { + name: 'crossplane', + status: APPLICATION_STATUS.ERROR, + status_reason: 'Cannot connect', + stack: 'gcp', + }, + { + name: 'elastic_stack', + status: APPLICATION_STATUS.ERROR, + status_reason: 'Cannot connect', + }, ], }, }, @@ -105,11 +128,13 @@ const CLUSTERS_MOCK_DATA = { POST: { '/gitlab-org/gitlab-shell/clusters/1/applications/helm': {}, '/gitlab-org/gitlab-shell/clusters/1/applications/ingress': {}, + '/gitlab-org/gitlab-shell/clusters/1/applications/crossplane': {}, '/gitlab-org/gitlab-shell/clusters/1/applications/cert_manager': {}, '/gitlab-org/gitlab-shell/clusters/1/applications/runner': {}, '/gitlab-org/gitlab-shell/clusters/1/applications/prometheus': {}, '/gitlab-org/gitlab-shell/clusters/1/applications/jupyter': {}, '/gitlab-org/gitlab-shell/clusters/1/applications/knative': {}, + '/gitlab-org/gitlab-shell/clusters/1/applications/elastic_stack': {}, }, }; @@ -126,11 +151,13 @@ const DEFAULT_APPLICATION_STATE = { const APPLICATIONS_MOCK_STATE = { helm: { title: 'Helm Tiller', status: 'installable' }, ingress: { title: 'Ingress', status: 'installable' }, + 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: '' }, }; 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 5ee06eb44c94ad049028e425ee263fbcbe0efb02..71d4daceb753b443bb73eea6c6b45a98c979f724 100644 --- a/spec/frontend/clusters/stores/clusters_store_spec.js +++ b/spec/frontend/clusters/stores/clusters_store_spec.js @@ -71,6 +71,7 @@ describe('Clusters Store', () => { uninstallable: false, uninstallSuccessful: false, uninstallFailed: false, + validationError: null, }, ingress: { title: 'Ingress', @@ -84,6 +85,7 @@ describe('Clusters Store', () => { uninstallable: false, uninstallSuccessful: false, uninstallFailed: false, + validationError: null, }, runner: { title: 'GitLab Runner', @@ -100,6 +102,7 @@ describe('Clusters Store', () => { uninstallable: false, uninstallSuccessful: false, uninstallFailed: false, + validationError: null, }, prometheus: { title: 'Prometheus', @@ -111,6 +114,7 @@ describe('Clusters Store', () => { uninstallable: false, uninstallSuccessful: false, uninstallFailed: false, + validationError: null, }, jupyter: { title: 'JupyterHub', @@ -123,6 +127,7 @@ describe('Clusters Store', () => { uninstallable: false, uninstallSuccessful: false, uninstallFailed: false, + validationError: null, }, knative: { title: 'Knative', @@ -140,6 +145,7 @@ describe('Clusters Store', () => { uninstallFailed: false, updateSuccessful: false, updateFailed: false, + validationError: null, }, cert_manager: { title: 'Cert-Manager', @@ -152,6 +158,32 @@ describe('Clusters Store', () => { uninstallable: false, uninstallSuccessful: false, uninstallFailed: false, + validationError: null, + }, + elastic_stack: { + title: 'Elastic Stack', + status: APPLICATION_STATUS.INSTALLABLE, + installFailed: true, + statusReason: mockResponseData.applications[7].status_reason, + requestReason: null, + kibana_hostname: '', + installed: false, + uninstallable: false, + uninstallSuccessful: false, + uninstallFailed: false, + validationError: null, + }, + crossplane: { + title: 'Crossplane', + status: APPLICATION_STATUS.INSTALLABLE, + installFailed: true, + statusReason: mockResponseData.applications[8].status_reason, + requestReason: null, + installed: false, + uninstallable: false, + uninstallSuccessful: false, + uninstallFailed: false, + validationError: null, }, }, environments: [], @@ -183,5 +215,16 @@ 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/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 47bdc677068d56e8740e506dd0c3bba4cb15abb3..3c603c7f57326a345c6c4064e4ede929d6c29f36 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 @@ -26,7 +26,7 @@ exports[`Confidential merge request project form group component renders empty s > fork the project </a> - and set the forks visiblity to private. + and set the forks visibility to private. </span> <gllink-stub @@ -76,7 +76,7 @@ exports[`Confidential merge request project form group component renders fork dr > fork the project </a> - and set the forks visiblity to private. + and set the forks visibility to private. </span> <gllink-stub diff --git a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..b87afdd7eb43e3f63f7380e19eb6272d9b832f14 --- /dev/null +++ b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap @@ -0,0 +1,47 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Contributors charts should render charts when loading completed and there is chart data 1`] = ` +<div> + <div + class="contributors-charts" + > + <h4> + Commits to master + </h4> + + <span> + Excluding merge commits. Limited to 6,000 commits. + </span> + + <div> + <glareachart-stub + data="[object Object]" + height="264" + option="[object Object]" + /> + </div> + + <div + class="row" + > + <div + class="col-6" + > + <h4> + John + </h4> + + <p> + 2 commits (jawnnypoo@gmail.com) + </p> + + <glareachart-stub + data="[object Object]" + height="216" + option="[object Object]" + /> + </div> + </div> + </div> +</div> +`; diff --git a/spec/frontend/contributors/component/contributors_spec.js b/spec/frontend/contributors/component/contributors_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..fdba09ed26c00727eb123a4977a1971829dfd4e7 --- /dev/null +++ b/spec/frontend/contributors/component/contributors_spec.js @@ -0,0 +1,69 @@ +import Vue from 'vue'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { createStore } from '~/contributors/stores'; +import axios from '~/lib/utils/axios_utils'; +import MockAdapter from 'axios-mock-adapter'; +import ContributorsCharts from '~/contributors/components/contributors.vue'; + +const localVue = createLocalVue(); +let wrapper; +let mock; +let store; +const Component = Vue.extend(ContributorsCharts); +const endpoint = 'contributors'; +const branch = 'master'; +const chartData = [ + { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-05-05' }, + { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-03-03' }, +]; + +function factory() { + mock = new MockAdapter(axios); + jest.spyOn(axios, 'get'); + mock.onGet().reply(200, chartData); + store = createStore(); + + wrapper = shallowMount(Component, { + propsData: { + endpoint, + branch, + }, + stubs: { + GlLoadingIcon: true, + GlAreaChart: true, + }, + store, + }); +} + +describe('Contributors charts', () => { + beforeEach(() => { + factory(); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + it('should fetch chart data when mounted', () => { + expect(axios.get).toHaveBeenCalledWith(endpoint); + }); + + it('should display loader whiled loading data', () => { + wrapper.vm.$store.state.loading = true; + return localVue.nextTick(() => { + expect(wrapper.find('.contributors-loader').exists()).toBe(true); + }); + }); + + 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(() => { + 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/contributors/store/actions_spec.js b/spec/frontend/contributors/store/actions_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..bb017e0ac0fcf3957b527aa6cdc1bd9b4acaa567 --- /dev/null +++ b/spec/frontend/contributors/store/actions_spec.js @@ -0,0 +1,60 @@ +import axios from '~/lib/utils/axios_utils'; +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import flashError from '~/flash'; +import * as actions from '~/contributors/stores/actions'; +import * as types from '~/contributors/stores/mutation_types'; + +jest.mock('~/flash.js'); + +describe('Contributors store actions', () => { + describe('fetchChartData', () => { + let mock; + const endpoint = '/contributors'; + const chartData = { '2017-11': 0, '2017-12': 2 }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + it('should commit SET_CHART_DATA with received response', done => { + mock.onGet().reply(200, chartData); + + testAction( + actions.fetchChartData, + { endpoint }, + {}, + [ + { type: types.SET_LOADING_STATE, payload: true }, + { type: types.SET_CHART_DATA, payload: chartData }, + { type: types.SET_LOADING_STATE, payload: false }, + ], + [], + () => { + mock.restore(); + done(); + }, + ); + }); + + it('should show flash on API error', done => { + mock.onGet().reply(400, 'Not Found'); + + testAction( + actions.fetchChartData, + { endpoint }, + {}, + [{ type: types.SET_LOADING_STATE, payload: true }], + [], + () => { + expect(flashError).toHaveBeenCalledWith(expect.stringMatching('error')); + mock.restore(); + done(); + }, + ); + }); + }); +}); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/spec/frontend/contributors/store/getters_spec.js b/spec/frontend/contributors/store/getters_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..62ae9b36f87cb054452c78946026ec0b838c4fa5 --- /dev/null +++ b/spec/frontend/contributors/store/getters_spec.js @@ -0,0 +1,73 @@ +import * as getters from '~/contributors/stores/getters'; + +describe('Contributors Store Getters', () => { + const state = {}; + + describe('showChart', () => { + it('should NOT show chart if loading', () => { + state.loading = true; + + expect(getters.showChart(state)).toEqual(false); + }); + + it('should NOT show chart there is not data', () => { + state.loading = false; + state.chartData = null; + + expect(getters.showChart(state)).toEqual(false); + }); + + it('should show the chart in case loading complated and there is data', () => { + state.loading = false; + state.chartData = true; + + expect(getters.showChart(state)).toEqual(true); + }); + + describe('parsedData', () => { + let parsed; + + beforeAll(() => { + state.chartData = [ + { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-05-05' }, + { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-05-05' }, + { author_name: 'Carlson', author_email: 'jawnnypoo@gmail.com', date: '2019-03-03' }, + { author_name: 'Carlson', author_email: 'jawnnypoo@gmail.com', date: '2019-05-05' }, + { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-04-04' }, + { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-04-04' }, + { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-03-03' }, + ]; + parsed = getters.parsedData(state); + }); + + it('should group contributions by date ', () => { + expect(parsed.total).toMatchObject({ '2019-05-05': 3, '2019-03-03': 2, '2019-04-04': 2 }); + }); + + it('should group contributions by author ', () => { + expect(parsed.byAuthor).toMatchObject({ + Carlson: { + email: 'jawnnypoo@gmail.com', + commits: 2, + dates: { + '2019-03-03': 1, + '2019-05-05': 1, + }, + }, + John: { + email: 'jawnnypoo@gmail.com', + commits: 5, + dates: { + '2019-03-03': 1, + '2019-04-04': 2, + '2019-05-05': 2, + }, + }, + }); + }); + }); + }); +}); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/spec/frontend/contributors/store/mutations_spec.js b/spec/frontend/contributors/store/mutations_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e9e756d4a65f06e4014c192a43148f4a0e247602 --- /dev/null +++ b/spec/frontend/contributors/store/mutations_spec.js @@ -0,0 +1,40 @@ +import state from '~/contributors/stores/state'; +import mutations from '~/contributors/stores/mutations'; +import * as types from '~/contributors/stores/mutation_types'; + +describe('Contributors mutations', () => { + let stateCopy; + + beforeEach(() => { + stateCopy = state(); + }); + + describe('SET_LOADING_STATE', () => { + it('should set loading flag', () => { + const loading = true; + mutations[types.SET_LOADING_STATE](stateCopy, loading); + + expect(stateCopy.loading).toEqual(loading); + }); + }); + + describe('SET_CHART_DATA', () => { + const chartData = { '2017-11': 0, '2017-12': 2 }; + + it('should set chart data', () => { + mutations[types.SET_CHART_DATA](stateCopy, chartData); + + expect(stateCopy.chartData).toEqual(chartData); + }); + }); + + describe('SET_ACTIVE_BRANCH', () => { + it('should set search query', () => { + const branch = 'feature-branch'; + + mutations[types.SET_ACTIVE_BRANCH](stateCopy, branch); + + expect(stateCopy.branch).toEqual(branch); + }); + }); +}); diff --git a/spec/frontend/contributors/utils_spec.js b/spec/frontend/contributors/utils_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a2b9154329be4ff504f422e340d72fe0dc4dc77e --- /dev/null +++ b/spec/frontend/contributors/utils_spec.js @@ -0,0 +1,21 @@ +import * as utils from '~/contributors/utils'; + +describe('Contributors Util Functions', () => { + describe('xAxisLabelFormatter', () => { + it('should return year if the date is in January', () => { + expect(utils.xAxisLabelFormatter(new Date('01-12-2019'))).toEqual('2019'); + }); + + it('should return month name otherwise', () => { + expect(utils.xAxisLabelFormatter(new Date('12-02-2019'))).toEqual('Dec'); + expect(utils.xAxisLabelFormatter(new Date('07-12-2019'))).toEqual('Jul'); + }); + }); + + describe('dateFormatter', () => { + it('should format provided date to YYYY-MM-DD format', () => { + expect(utils.dateFormatter(new Date('December 17, 1995 03:24:00'))).toEqual('1995-12-17'); + expect(utils.dateFormatter(new Date(1565308800000))).toEqual('2019-08-09'); + }); + }); +}); diff --git a/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js b/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js index 366c2fc7b26961fe5258d816d098fb70921aef43..efbe2635fccdad28404b14a3713b06246ddf3786 100644 --- a/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import ClusterFormDropdown from '~/create_cluster/eks_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'; -import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; +import { GlIcon } from '@gitlab/ui'; describe('ClusterFormDropdown', () => { let vm; @@ -41,24 +41,50 @@ describe('ClusterFormDropdown', () => { .trigger('click'); }); - it('displays selected item label', () => { - expect(vm.find(DropdownButton).props('toggleText')).toEqual(secondItem.name); + it('emits input event with selected item', () => { + expect(vm.emitted('input')[0]).toEqual([secondItem.value]); + }); + }); + + describe('when multiple items are selected', () => { + const value = [1]; + + 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'); + }); + + it('emits input event with an array of selected items', () => { + expect(vm.emitted('input')[1]).toEqual([[firstItem.value, secondItem.value]]); + }); + }); + + describe('when multiple items can be selected', () => { + beforeEach(() => { + vm.setProps({ items, multiple: true, value: firstItem.value }); }); - it('sets selected value to dropdown hidden input', () => { - expect(vm.find(DropdownHiddenInput).props('value')).toEqual(secondItem.value); + it('displays a checked GlIcon next to the item', () => { + expect(vm.find(GlIcon).is('.invisible')).toBe(false); + expect(vm.find(GlIcon).props('name')).toBe('mobile-issue-close'); }); }); describe('when an item is selected and has a custom label property', () => { it('displays selected item custom label', () => { const labelProperty = 'customLabel'; - const selectedItem = { [labelProperty]: 'Name' }; + const label = 'Name'; + const currentValue = 1; + const customLabelItems = [{ [labelProperty]: label, value: currentValue }]; - vm.setProps({ labelProperty }); - vm.setData({ selectedItem }); + vm.setProps({ labelProperty, items: customLabelItems, value: currentValue }); - expect(vm.find(DropdownButton).props('toggleText')).toEqual(selectedItem[labelProperty]); + expect(vm.find(DropdownButton).props('toggleText')).toEqual(label); }); }); diff --git a/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js b/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..4bf3ac430f5f063bdd53fa3e801c2b1af2a8b4fe --- /dev/null +++ b/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js @@ -0,0 +1,91 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; + +import CreateEksCluster from '~/create_cluster/eks_cluster/components/create_eks_cluster.vue'; +import EksClusterConfigurationForm from '~/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue'; +import ServiceCredentialsForm from '~/create_cluster/eks_cluster/components/service_credentials_form.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('CreateEksCluster', () => { + let vm; + let state; + const gitlabManagedClusterHelpPath = 'gitlab-managed-cluster-help-path'; + const accountAndExternalIdsHelpPath = 'account-and-external-id-help-path'; + const createRoleArnHelpPath = 'role-arn-help-path'; + const kubernetesIntegrationHelpPath = 'kubernetes-integration'; + const externalLinkIcon = 'external-link'; + + beforeEach(() => { + state = { hasCredentials: false }; + const store = new Vuex.Store({ + state, + }); + + vm = shallowMount(CreateEksCluster, { + propsData: { + gitlabManagedClusterHelpPath, + accountAndExternalIdsHelpPath, + createRoleArnHelpPath, + externalLinkIcon, + kubernetesIntegrationHelpPath, + }, + localVue, + store, + }); + }); + afterEach(() => vm.destroy()); + + describe('when credentials are provided', () => { + beforeEach(() => { + state.hasCredentials = true; + }); + + it('displays eks cluster configuration form when credentials are valid', () => { + expect(vm.find(EksClusterConfigurationForm).exists()).toBe(true); + }); + + describe('passes to the cluster configuration form', () => { + it('help url for kubernetes integration documentation', () => { + expect(vm.find(EksClusterConfigurationForm).props('gitlabManagedClusterHelpPath')).toBe( + gitlabManagedClusterHelpPath, + ); + }); + + it('help url for gitlab managed cluster documentation', () => { + expect(vm.find(EksClusterConfigurationForm).props('kubernetesIntegrationHelpPath')).toBe( + kubernetesIntegrationHelpPath, + ); + }); + }); + }); + + describe('when credentials are invalid', () => { + beforeEach(() => { + state.hasCredentials = false; + }); + + it('displays service credentials form', () => { + expect(vm.find(ServiceCredentialsForm).exists()).toBe(true); + }); + + describe('passes to the service credentials form', () => { + it('help url for account and external ids', () => { + expect(vm.find(ServiceCredentialsForm).props('accountAndExternalIdsHelpPath')).toBe( + accountAndExternalIdsHelpPath, + ); + }); + + it('external link icon', () => { + expect(vm.find(ServiceCredentialsForm).props('externalLinkIcon')).toBe(externalLinkIcon); + }); + + it('help url to create a role ARN', () => { + expect(vm.find(ServiceCredentialsForm).props('createRoleArnHelpPath')).toBe( + createRoleArnHelpPath, + ); + }); + }); + }); +}); 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 69290f6dfa96608908073c847c03c1cd8be5b0b7..25d613d64edd076ee737ebe58f0e1385c16d8586 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 @@ -4,7 +4,6 @@ import Vue from 'vue'; import { GlFormCheckbox } from '@gitlab/ui'; import EksClusterConfigurationForm from '~/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue'; -import RegionDropdown from '~/create_cluster/eks_cluster/components/region_dropdown.vue'; import eksClusterFormState from '~/create_cluster/eks_cluster/store/state'; import clusterDropdownStoreState from '~/create_cluster/eks_cluster/store/cluster_dropdown/state'; @@ -21,17 +20,21 @@ describe('EksClusterConfigurationForm', () => { let subnetsState; let keyPairsState; let securityGroupsState; + let instanceTypesState; let vpcsActions; let rolesActions; let regionsActions; let subnetsActions; let keyPairsActions; let securityGroupsActions; + let instanceTypesActions; let vm; beforeEach(() => { state = eksClusterFormState(); actions = { + signOut: jest.fn(), + createCluster: jest.fn(), setClusterName: jest.fn(), setEnvironmentScope: jest.fn(), setKubernetesVersion: jest.fn(), @@ -41,6 +44,8 @@ describe('EksClusterConfigurationForm', () => { setRole: jest.fn(), setKeyPair: jest.fn(), setSecurityGroup: jest.fn(), + setInstanceType: jest.fn(), + setNodeCount: jest.fn(), setGitlabManagedCluster: jest.fn(), }; regionsActions = { @@ -61,6 +66,9 @@ describe('EksClusterConfigurationForm', () => { securityGroupsActions = { fetchItems: jest.fn(), }; + instanceTypesActions = { + fetchItems: jest.fn(), + }; rolesState = { ...clusterDropdownStoreState(), }; @@ -79,6 +87,9 @@ describe('EksClusterConfigurationForm', () => { securityGroupsState = { ...clusterDropdownStoreState(), }; + instanceTypesState = { + ...clusterDropdownStoreState(), + }; store = new Vuex.Store({ state, actions, @@ -113,6 +124,11 @@ describe('EksClusterConfigurationForm', () => { state: securityGroupsState, actions: securityGroupsActions, }, + instanceTypes: { + namespaced: true, + state: instanceTypesState, + actions: instanceTypesActions, + }, }, }); }); @@ -124,6 +140,7 @@ describe('EksClusterConfigurationForm', () => { propsData: { gitlabManagedClusterHelpPath: '', kubernetesIntegrationHelpPath: '', + externalLinkIcon: '', }, }); }); @@ -132,15 +149,34 @@ describe('EksClusterConfigurationForm', () => { vm.destroy(); }); + const setAllConfigurationFields = () => { + store.replaceState({ + ...state, + clusterName: 'cluster name', + environmentScope: '*', + selectedRegion: 'region', + selectedRole: 'role', + selectedKeyPair: 'key pair', + selectedVpc: 'vpc', + selectedSubnet: 'subnet', + selectedSecurityGroup: 'group', + selectedInstanceType: 'small-1', + }); + }; + + const findSignOutButton = () => vm.find('.js-sign-out'); + const findCreateClusterButton = () => vm.find('.js-create-cluster'); const findClusterNameInput = () => vm.find('[id=eks-cluster-name]'); const findEnvironmentScopeInput = () => vm.find('[id=eks-environment-scope]'); const findKubernetesVersionDropdown = () => vm.find('[field-id="eks-kubernetes-version"]'); - const findRegionDropdown = () => vm.find(RegionDropdown); + const findRegionDropdown = () => vm.find('[field-id="eks-region"]'); const findKeyPairDropdown = () => vm.find('[field-id="eks-key-pair"]'); const findVpcDropdown = () => vm.find('[field-id="eks-vpc"]'); const findSubnetDropdown = () => vm.find('[field-id="eks-subnet"]'); const findRoleDropdown = () => vm.find('[field-id="eks-role"]'); const findSecurityGroupDropdown = () => vm.find('[field-id="eks-security-group"]'); + const findInstanceTypeDropdown = () => vm.find('[field-id="eks-instance-type"'); + const findNodeCountInput = () => vm.find('[id="eks-node-count"]'); const findGitlabManagedClusterCheckbox = () => vm.find(GlFormCheckbox); describe('when mounted', () => { @@ -151,6 +187,15 @@ describe('EksClusterConfigurationForm', () => { it('fetches available roles', () => { expect(rolesActions.fetchItems).toHaveBeenCalled(); }); + + it('fetches available instance types', () => { + expect(instanceTypesActions.fetchItems).toHaveBeenCalled(); + }); + }); + + it('dispatches signOut action when sign out button is clicked', () => { + findSignOutButton().trigger('click'); + expect(actions.signOut).toHaveBeenCalled(); }); it('sets isLoadingRoles to RoleDropdown loading property', () => { @@ -180,11 +225,13 @@ describe('EksClusterConfigurationForm', () => { }); it('sets regions to RegionDropdown regions property', () => { - expect(findRegionDropdown().props('regions')).toBe(regionsState.items); + expect(findRegionDropdown().props('items')).toBe(regionsState.items); }); it('sets loadingRegionsError to RegionDropdown error property', () => { - expect(findRegionDropdown().props('error')).toBe(regionsState.loadingItemsError); + regionsState.loadingItemsError = new Error(); + + expect(findRegionDropdown().props('hasErrors')).toEqual(true); }); it('disables KeyPairDropdown when no region is selected', () => { @@ -329,6 +376,34 @@ describe('EksClusterConfigurationForm', () => { undefined, ); }); + + it('cleans selected vpc', () => { + expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc: null }, undefined); + }); + + it('cleans selected key pair', () => { + expect(actions.setKeyPair).toHaveBeenCalledWith( + expect.anything(), + { keyPair: null }, + undefined, + ); + }); + + it('cleans selected subnet', () => { + expect(actions.setSubnet).toHaveBeenCalledWith( + expect.anything(), + { subnet: null }, + undefined, + ); + }); + + it('cleans selected security group', () => { + expect(actions.setSecurityGroup).toHaveBeenCalledWith( + expect.anything(), + { securityGroup: null }, + undefined, + ); + }); }); it('dispatches setClusterName when cluster name input changes', () => { @@ -381,8 +456,10 @@ describe('EksClusterConfigurationForm', () => { describe('when vpc is selected', () => { const vpc = { name: 'vpc-1' }; + const region = 'east-1'; beforeEach(() => { + state.selectedRegion = region; findVpcDropdown().vm.$emit('input', vpc); }); @@ -390,14 +467,34 @@ describe('EksClusterConfigurationForm', () => { expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc }, undefined); }); + it('cleans selected subnet', () => { + expect(actions.setSubnet).toHaveBeenCalledWith( + expect.anything(), + { subnet: null }, + undefined, + ); + }); + + it('cleans selected security group', () => { + expect(actions.setSecurityGroup).toHaveBeenCalledWith( + expect.anything(), + { securityGroup: null }, + undefined, + ); + }); + it('dispatches fetchSubnets action', () => { - expect(subnetsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { vpc }, undefined); + expect(subnetsActions.fetchItems).toHaveBeenCalledWith( + expect.anything(), + { vpc, region }, + undefined, + ); }); it('dispatches fetchSecurityGroups action', () => { expect(securityGroupsActions.fetchItems).toHaveBeenCalledWith( expect.anything(), - { vpc }, + { vpc, region }, undefined, ); }); @@ -454,4 +551,76 @@ describe('EksClusterConfigurationForm', () => { ); }); }); + + describe('when instance type is selected', () => { + const instanceType = 'small-1'; + + beforeEach(() => { + findInstanceTypeDropdown().vm.$emit('input', instanceType); + }); + + it('dispatches setInstanceType action', () => { + expect(actions.setInstanceType).toHaveBeenCalledWith( + expect.anything(), + { instanceType }, + undefined, + ); + }); + }); + + it('dispatches setNodeCount when node count input changes', () => { + const nodeCount = 5; + + findNodeCountInput().vm.$emit('input', nodeCount); + + expect(actions.setNodeCount).toHaveBeenCalledWith(expect.anything(), { nodeCount }, undefined); + }); + + describe('when all cluster configuration fields are set', () => { + beforeEach(() => { + setAllConfigurationFields(); + }); + + it('enables create cluster button', () => { + expect(findCreateClusterButton().props('disabled')).toBe(false); + }); + }); + + describe('when at least one cluster configuration field is not set', () => { + beforeEach(() => { + setAllConfigurationFields(); + store.replaceState({ + ...state, + clusterName: '', + }); + }); + + it('disables create cluster button', () => { + expect(findCreateClusterButton().props('disabled')).toBe(true); + }); + }); + + describe('when isCreatingCluster', () => { + beforeEach(() => { + setAllConfigurationFields(); + store.replaceState({ + ...state, + isCreatingCluster: true, + }); + }); + + it('sets create cluster button as loading', () => { + expect(findCreateClusterButton().props('loading')).toBe(true); + }); + }); + + describe('clicking create cluster button', () => { + beforeEach(() => { + findCreateClusterButton().vm.$emit('click'); + }); + + it('dispatches createCluster action', () => { + expect(actions.createCluster).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/create_cluster/eks_cluster/components/region_dropdown_spec.js b/spec/frontend/create_cluster/eks_cluster/components/region_dropdown_spec.js deleted file mode 100644 index 0ebb5026a4bbc710210ba91bb9cb3a0d70747987..0000000000000000000000000000000000000000 --- a/spec/frontend/create_cluster/eks_cluster/components/region_dropdown_spec.js +++ /dev/null @@ -1,55 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; - -import ClusterFormDropdown from '~/create_cluster/eks_cluster/components/cluster_form_dropdown.vue'; -import RegionDropdown from '~/create_cluster/eks_cluster/components/region_dropdown.vue'; - -describe('RegionDropdown', () => { - let vm; - - const getClusterFormDropdown = () => vm.find(ClusterFormDropdown); - - beforeEach(() => { - vm = shallowMount(RegionDropdown); - }); - afterEach(() => vm.destroy()); - - it('renders a cluster-form-dropdown', () => { - expect(getClusterFormDropdown().exists()).toBe(true); - }); - - it('sets regions to cluster-form-dropdown items property', () => { - const regions = [{ name: 'basic' }]; - - vm.setProps({ regions }); - - expect(getClusterFormDropdown().props('items')).toEqual(regions); - }); - - it('sets a loading text', () => { - expect(getClusterFormDropdown().props('loadingText')).toEqual('Loading Regions'); - }); - - it('sets a placeholder', () => { - expect(getClusterFormDropdown().props('placeholder')).toEqual('Select a region'); - }); - - it('sets an empty results text', () => { - expect(getClusterFormDropdown().props('emptyText')).toEqual('No region found'); - }); - - it('sets a search field placeholder', () => { - expect(getClusterFormDropdown().props('searchFieldPlaceholder')).toEqual('Search regions'); - }); - - it('sets hasErrors property', () => { - vm.setProps({ error: {} }); - - expect(getClusterFormDropdown().props('hasErrors')).toEqual(true); - }); - - it('sets an error message', () => { - expect(getClusterFormDropdown().props('errorMessage')).toEqual( - 'Could not load regions from your AWS account', - ); - }); -}); 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 new file mode 100644 index 0000000000000000000000000000000000000000..0be723b48f00d9a3a581d3597c138f3c6138beaf --- /dev/null +++ b/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js @@ -0,0 +1,117 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; + +import ServiceCredentialsForm from '~/create_cluster/eks_cluster/components/service_credentials_form.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; + +import eksClusterState from '~/create_cluster/eks_cluster/store/state'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ServiceCredentialsForm', () => { + let vm; + let state; + let createRoleAction; + const accountId = 'accountId'; + const externalId = 'externalId'; + + beforeEach(() => { + state = Object.assign(eksClusterState(), { + accountId, + externalId, + }); + createRoleAction = jest.fn(); + + const store = new Vuex.Store({ + state, + actions: { + createRole: createRoleAction, + }, + }); + vm = shallowMount(ServiceCredentialsForm, { + propsData: { + accountAndExternalIdsHelpPath: '', + createRoleArnHelpPath: '', + externalLinkIcon: '', + }, + localVue, + store, + }); + }); + afterEach(() => vm.destroy()); + + const findAccountIdInput = () => vm.find('#gitlab-account-id'); + const findCopyAccountIdButton = () => vm.find('.js-copy-account-id-button'); + const findExternalIdInput = () => vm.find('#eks-external-id'); + 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); + }); + + it('allows to copy account id', () => { + expect(findCopyAccountIdButton().props('text')).toBe(accountId); + }); + + it('displays provided external id', () => { + expect(findExternalIdInput().attributes('value')).toBe(externalId); + }); + + it('allows to copy external id', () => { + expect(findCopyExternalIdButton().props('text')).toBe(externalId); + }); + + it('disables submit button when role ARN is not provided', () => { + expect(findSubmitButton().attributes('disabled')).toBeTruthy(); + }); + + it('enables submit button when role ARN is not provided', () => { + vm.setData({ roleArn: '123' }); + + expect(findSubmitButton().attributes('disabled')).toBeFalsy(); + }); + + it('dispatches createRole action when form is submitted', () => { + findForm().trigger('submit'); + + expect(createRoleAction).toHaveBeenCalled(); + }); + + describe('when is creating role', () => { + beforeEach(() => { + vm.setData({ roleArn: '123' }); // set role ARN to enable button + + state.isCreatingRole = true; + }); + + it('disables submit button', () => { + expect(findSubmitButton().props('disabled')).toBe(true); + }); + + it('sets submit button as loading', () => { + expect(findSubmitButton().props('loading')).toBe(true); + }); + + it('displays Authenticating label on submit button', () => { + expect(findSubmitButton().props('label')).toBe('Authenticating'); + }); + }); + + describe('when role can’t be created', () => { + beforeEach(() => { + state.createRoleError = 'Invalid credentials'; + }); + + it('displays invalid role warning banner', () => { + expect(findInvalidCredentials().exists()).toBe(true); + }); + + it('displays invalid role error message', () => { + expect(findInvalidCredentials().text()).toContain(state.createRoleError); + }); + }); +}); diff --git a/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js b/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..25be858dcb302c0e06b5ff5031f9d2cd96e807a8 --- /dev/null +++ b/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js @@ -0,0 +1,152 @@ +import awsServicesFacadeFactory from '~/create_cluster/eks_cluster/services/aws_services_facade'; +import axios from '~/lib/utils/axios_utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; + +describe('awsServicesFacade', () => { + let apiPaths; + let axiosMock; + let awsServices; + let region; + let vpc; + + beforeEach(() => { + apiPaths = { + getKeyPairsPath: '/clusters/aws/api/key_pairs', + getRegionsPath: '/clusters/aws/api/regions', + getRolesPath: '/clusters/aws/api/roles', + getSecurityGroupsPath: '/clusters/aws/api/security_groups', + getSubnetsPath: '/clusters/aws/api/subnets', + getVpcsPath: '/clusters/aws/api/vpcs', + getInstanceTypesPath: '/clusters/aws/api/instance_types', + }; + region = 'west-1'; + vpc = 'vpc-2'; + awsServices = awsServicesFacadeFactory(apiPaths); + axiosMock = new AxiosMockAdapter(axios); + }); + + describe('when fetchRegions succeeds', () => { + let regions; + let regionsOutput; + + beforeEach(() => { + regions = [{ region_name: 'east-1' }, { region_name: 'west-2' }]; + regionsOutput = regions.map(({ region_name: name }) => ({ name, value: name })); + axiosMock.onGet(apiPaths.getRegionsPath).reply(200, { regions }); + }); + + it('return list of roles where each item has a name and value', () => { + expect(awsServices.fetchRegions()).resolves.toEqual(regionsOutput); + }); + }); + + describe('when fetchRoles succeeds', () => { + let roles; + let rolesOutput; + + beforeEach(() => { + roles = [ + { role_name: 'admin', arn: 'aws::admin' }, + { role_name: 'read-only', arn: 'aws::read-only' }, + ]; + rolesOutput = roles.map(({ role_name: name, arn: value }) => ({ name, value })); + axiosMock.onGet(apiPaths.getRolesPath).reply(200, { roles }); + }); + + it('return list of regions where each item has a name and value', () => { + expect(awsServices.fetchRoles()).resolves.toEqual(rolesOutput); + }); + }); + + describe('when fetchKeyPairs succeeds', () => { + let keyPairs; + let keyPairsOutput; + + beforeEach(() => { + keyPairs = [{ key_pair: 'key-pair' }, { key_pair: 'key-pair-2' }]; + keyPairsOutput = keyPairs.map(({ key_name: name }) => ({ name, value: name })); + axiosMock + .onGet(apiPaths.getKeyPairsPath, { params: { region } }) + .reply(200, { key_pairs: keyPairs }); + }); + + it('return list of key pairs where each item has a name and value', () => { + expect(awsServices.fetchKeyPairs({ region })).resolves.toEqual(keyPairsOutput); + }); + }); + + describe('when fetchVpcs succeeds', () => { + let vpcs; + let vpcsOutput; + + beforeEach(() => { + vpcs = [{ vpc_id: 'vpc-1' }, { vpc_id: 'vpc-2' }]; + vpcsOutput = vpcs.map(({ vpc_id: name }) => ({ name, value: name })); + axiosMock.onGet(apiPaths.getVpcsPath, { params: { region } }).reply(200, { vpcs }); + }); + + it('return list of vpcs where each item has a name and value', () => { + expect(awsServices.fetchVpcs({ region })).resolves.toEqual(vpcsOutput); + }); + }); + + describe('when fetchSubnets succeeds', () => { + let subnets; + let subnetsOutput; + + beforeEach(() => { + subnets = [{ subnet_id: 'vpc-1' }, { subnet_id: 'vpc-2' }]; + subnetsOutput = subnets.map(({ subnet_id }) => ({ name: subnet_id, value: subnet_id })); + axiosMock + .onGet(apiPaths.getSubnetsPath, { params: { region, vpc_id: vpc } }) + .reply(200, { subnets }); + }); + + it('return list of subnets where each item has a name and value', () => { + expect(awsServices.fetchSubnets({ region, vpc })).resolves.toEqual(subnetsOutput); + }); + }); + + describe('when fetchSecurityGroups succeeds', () => { + let securityGroups; + let securityGroupsOutput; + + beforeEach(() => { + securityGroups = [ + { group_name: 'admin group', group_id: 'group-1' }, + { group_name: 'basic group', group_id: 'group-2' }, + ]; + securityGroupsOutput = securityGroups.map(({ group_id: value, group_name: name }) => ({ + name, + value, + })); + axiosMock + .onGet(apiPaths.getSecurityGroupsPath, { params: { region, vpc_id: vpc } }) + .reply(200, { security_groups: securityGroups }); + }); + + it('return list of security groups where each item has a name and value', () => { + expect(awsServices.fetchSecurityGroups({ region, vpc })).resolves.toEqual( + securityGroupsOutput, + ); + }); + }); + + describe('when fetchInstanceTypes succeeds', () => { + let instanceTypes; + let instanceTypesOutput; + + beforeEach(() => { + instanceTypes = [{ instance_type_name: 't2.small' }, { instance_type_name: 't2.medium' }]; + instanceTypesOutput = instanceTypes.map(({ instance_type_name }) => ({ + name: instance_type_name, + value: instance_type_name, + })); + axiosMock.onGet(apiPaths.getInstanceTypesPath).reply(200, { instance_types: instanceTypes }); + }); + + it('return list of instance types where each item has a name and value', () => { + expect(awsServices.fetchInstanceTypes()).resolves.toEqual(instanceTypesOutput); + }); + }); +}); diff --git a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js index 1ed7f806804d5d750755d4e0b825fd8948ed9075..cf6c317a2df5df60c80d56de40524c8e9bbfd21f 100644 --- a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js @@ -13,7 +13,20 @@ import { SET_ROLE, SET_SECURITY_GROUP, SET_GITLAB_MANAGED_CLUSTER, + SET_INSTANCE_TYPE, + SET_NODE_COUNT, + REQUEST_CREATE_ROLE, + CREATE_ROLE_SUCCESS, + CREATE_ROLE_ERROR, + REQUEST_CREATE_CLUSTER, + CREATE_CLUSTER_ERROR, + SIGN_OUT, } from '~/create_cluster/eks_cluster/store/mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import MockAdapter from 'axios-mock-adapter'; +import createFlash from '~/flash'; + +jest.mock('~/flash'); describe('EKS Cluster Store Actions', () => { let clusterName; @@ -25,19 +38,43 @@ describe('EKS Cluster Store Actions', () => { let role; let keyPair; let securityGroup; + let instanceType; + let nodeCount; let gitlabManagedCluster; + let mock; + let state; + let newClusterUrl; beforeEach(() => { clusterName = 'my cluster'; environmentScope = 'production'; kubernetesVersion = '11.1'; - region = { name: 'regions-1' }; - vpc = { name: 'vpc-1' }; - subnet = { name: 'subnet-1' }; - role = { name: 'role-1' }; - keyPair = { name: 'key-pair-1' }; - securityGroup = { name: 'default group' }; + region = 'regions-1'; + vpc = 'vpc-1'; + subnet = 'subnet-1'; + role = 'role-1'; + keyPair = 'key-pair-1'; + securityGroup = 'default group'; + instanceType = 'small-1'; + nodeCount = '5'; gitlabManagedCluster = true; + + newClusterUrl = '/clusters/1'; + + state = { + ...createState(), + createRolePath: '/clusters/roles/', + signOutPath: '/aws/signout', + createClusterPath: '/clusters/', + }; + }); + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); }); it.each` @@ -51,10 +88,207 @@ describe('EKS Cluster Store Actions', () => { ${'setVpc'} | ${SET_VPC} | ${{ vpc }} | ${'vpc'} ${'setSubnet'} | ${SET_SUBNET} | ${{ subnet }} | ${'subnet'} ${'setSecurityGroup'} | ${SET_SECURITY_GROUP} | ${{ securityGroup }} | ${'securityGroup'} + ${'setInstanceType'} | ${SET_INSTANCE_TYPE} | ${{ instanceType }} | ${'instance type'} + ${'setNodeCount'} | ${SET_NODE_COUNT} | ${{ nodeCount }} | ${'node count'} ${'setGitlabManagedCluster'} | ${SET_GITLAB_MANAGED_CLUSTER} | ${gitlabManagedCluster} | ${'gitlab managed cluster'} `(`$action commits $mutation with $payloadDescription payload`, data => { const { action, mutation, payload } = data; - testAction(actions[action], payload, createState(), [{ type: mutation, payload }]); + testAction(actions[action], payload, state, [{ type: mutation, payload }]); + }); + + describe('createRole', () => { + const payload = { + roleArn: 'role_arn', + externalId: 'externalId', + }; + + describe('when request succeeds', () => { + beforeEach(() => { + mock + .onPost(state.createRolePath, { + role_arn: payload.roleArn, + role_external_id: payload.externalId, + }) + .reply(201); + }); + + it('dispatches createRoleSuccess action', () => + testAction( + actions.createRole, + payload, + state, + [], + [{ type: 'requestCreateRole' }, { type: 'createRoleSuccess' }], + )); + }); + + describe('when request fails', () => { + let error; + + beforeEach(() => { + error = new Error('Request failed with status code 400'); + mock + .onPost(state.createRolePath, { + role_arn: payload.roleArn, + role_external_id: payload.externalId, + }) + .reply(400, error); + }); + + it('dispatches createRoleError action', () => + testAction( + actions.createRole, + payload, + state, + [], + [{ type: 'requestCreateRole' }, { type: 'createRoleError', payload: { error } }], + )); + }); + }); + + describe('requestCreateRole', () => { + it('commits requestCreaterole mutation', () => { + testAction(actions.requestCreateRole, null, state, [{ type: REQUEST_CREATE_ROLE }]); + }); + }); + + describe('createRoleSuccess', () => { + it('commits createRoleSuccess mutation', () => { + testAction(actions.createRoleSuccess, null, state, [{ type: CREATE_ROLE_SUCCESS }]); + }); + }); + + describe('createRoleError', () => { + it('commits createRoleError mutation', () => { + const payload = { + error: new Error(), + }; + + testAction(actions.createRoleError, payload, state, [{ type: CREATE_ROLE_ERROR, payload }]); + }); + }); + + describe('createCluster', () => { + let requestPayload; + + beforeEach(() => { + requestPayload = { + name: clusterName, + environment_scope: environmentScope, + managed: gitlabManagedCluster, + provider_aws_attributes: { + region, + vpc_id: vpc, + subnet_ids: subnet, + role_arn: role, + key_name: keyPair, + security_group_id: securityGroup, + instance_type: instanceType, + num_nodes: nodeCount, + }, + }; + state = Object.assign(createState(), { + clusterName, + environmentScope, + kubernetesVersion, + selectedRegion: region, + selectedVpc: vpc, + selectedSubnet: subnet, + selectedRole: role, + selectedKeyPair: keyPair, + selectedSecurityGroup: securityGroup, + selectedInstanceType: instanceType, + nodeCount, + gitlabManagedCluster, + }); + }); + + describe('when request succeeds', () => { + beforeEach(() => { + mock.onPost(state.createClusterPath, requestPayload).reply(201, null, { + location: '/clusters/1', + }); + }); + + it('dispatches createClusterSuccess action', () => + testAction( + actions.createCluster, + null, + state, + [], + [ + { type: 'requestCreateCluster' }, + { type: 'createClusterSuccess', payload: newClusterUrl }, + ], + )); + }); + + describe('when request fails', () => { + let response; + + beforeEach(() => { + response = 'Request failed with status code 400'; + mock.onPost(state.createClusterPath, requestPayload).reply(400, response); + }); + + it('dispatches createRoleError action', () => + testAction( + actions.createCluster, + null, + state, + [], + [{ type: 'requestCreateCluster' }, { type: 'createClusterError', payload: response }], + )); + }); + }); + + describe('requestCreateCluster', () => { + it('commits requestCreateCluster mutation', () => { + testAction(actions.requestCreateCluster, null, state, [{ type: REQUEST_CREATE_CLUSTER }]); + }); + }); + + describe('createClusterSuccess', () => { + beforeEach(() => { + jest.spyOn(window.location, 'assign').mockImplementation(() => {}); + }); + afterEach(() => { + window.location.assign.mockRestore(); + }); + + it('redirects to the new cluster URL', () => { + actions.createClusterSuccess(null, newClusterUrl); + + expect(window.location.assign).toHaveBeenCalledWith(newClusterUrl); + }); + }); + + describe('createClusterError', () => { + let payload; + + beforeEach(() => { + payload = { name: ['Create cluster failed'] }; + }); + + it('commits createClusterError mutation', () => { + testAction(actions.createClusterError, payload, state, [ + { type: CREATE_CLUSTER_ERROR, payload }, + ]); + }); + + it('creates a flash that displays the create cluster error', () => { + expect(createFlash).toHaveBeenCalledWith(payload.name[0]); + }); + }); + + describe('signOut', () => { + beforeEach(() => { + mock.onDelete(state.signOutPath).reply(200, null); + }); + + it('commits signOut mutation', () => { + testAction(actions.signOut, null, state, [{ type: SIGN_OUT }]); + }); }); }); diff --git a/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js b/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js index 81b65180fb55d3a05ef2d119e5cc562218d8ece5..0fb392f5eea62a5e000b74dba538d1b613bc4de5 100644 --- a/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js @@ -8,7 +8,15 @@ import { SET_SUBNET, SET_ROLE, SET_SECURITY_GROUP, + SET_INSTANCE_TYPE, + SET_NODE_COUNT, SET_GITLAB_MANAGED_CLUSTER, + REQUEST_CREATE_ROLE, + CREATE_ROLE_SUCCESS, + CREATE_ROLE_ERROR, + REQUEST_CREATE_CLUSTER, + CREATE_CLUSTER_ERROR, + SIGN_OUT, } from '~/create_cluster/eks_cluster/store/mutation_types'; import createState from '~/create_cluster/eks_cluster/store/state'; import mutations from '~/create_cluster/eks_cluster/store/mutations'; @@ -24,6 +32,8 @@ describe('Create EKS cluster store mutations', () => { let role; let keyPair; let securityGroup; + let instanceType; + let nodeCount; let gitlabManagedCluster; beforeEach(() => { @@ -36,6 +46,8 @@ describe('Create EKS cluster store mutations', () => { role = { name: 'role-1' }; keyPair = { name: 'key pair' }; securityGroup = { name: 'default group' }; + instanceType = 'small-1'; + nodeCount = '5'; gitlabManagedCluster = false; state = createState(); @@ -50,8 +62,10 @@ describe('Create EKS cluster store mutations', () => { ${SET_REGION} | ${'selectedRegion'} | ${{ region }} | ${region} | ${'selected region payload'} ${SET_KEY_PAIR} | ${'selectedKeyPair'} | ${{ keyPair }} | ${keyPair} | ${'selected key pair payload'} ${SET_VPC} | ${'selectedVpc'} | ${{ vpc }} | ${vpc} | ${'selected vpc payload'} - ${SET_SUBNET} | ${'selectedSubnet'} | ${{ subnet }} | ${subnet} | ${'selected sybnet payload'} + ${SET_SUBNET} | ${'selectedSubnet'} | ${{ subnet }} | ${subnet} | ${'selected subnet payload'} ${SET_SECURITY_GROUP} | ${'selectedSecurityGroup'} | ${{ securityGroup }} | ${securityGroup} | ${'selected security group payload'} + ${SET_INSTANCE_TYPE} | ${'selectedInstanceType'} | ${{ instanceType }} | ${instanceType} | ${'selected instance type payload'} + ${SET_NODE_COUNT} | ${'nodeCount'} | ${{ nodeCount }} | ${nodeCount} | ${'node count payload'} ${SET_GITLAB_MANAGED_CLUSTER} | ${'gitlabManagedCluster'} | ${{ gitlabManagedCluster }} | ${gitlabManagedCluster} | ${'gitlab managed cluster'} `(`$mutation sets $mutatedProperty to $expectedValueDescription`, data => { const { mutation, mutatedProperty, payload, expectedValue } = data; @@ -59,4 +73,101 @@ describe('Create EKS cluster store mutations', () => { mutations[mutation](state, payload); expect(state[mutatedProperty]).toBe(expectedValue); }); + + describe(`mutation ${REQUEST_CREATE_ROLE}`, () => { + beforeEach(() => { + mutations[REQUEST_CREATE_ROLE](state); + }); + + it('sets isCreatingRole to true', () => { + expect(state.isCreatingRole).toBe(true); + }); + + it('sets createRoleError to null', () => { + expect(state.createRoleError).toBe(null); + }); + + it('sets hasCredentials to false', () => { + expect(state.hasCredentials).toBe(false); + }); + }); + + describe(`mutation ${CREATE_ROLE_SUCCESS}`, () => { + beforeEach(() => { + mutations[CREATE_ROLE_SUCCESS](state); + }); + + it('sets isCreatingRole to false', () => { + expect(state.isCreatingRole).toBe(false); + }); + + it('sets createRoleError to null', () => { + expect(state.createRoleError).toBe(null); + }); + + it('sets hasCredentials to false', () => { + expect(state.hasCredentials).toBe(true); + }); + }); + + describe(`mutation ${CREATE_ROLE_ERROR}`, () => { + const error = new Error(); + + beforeEach(() => { + mutations[CREATE_ROLE_ERROR](state, { error }); + }); + + it('sets isCreatingRole to false', () => { + expect(state.isCreatingRole).toBe(false); + }); + + it('sets createRoleError to the error object', () => { + expect(state.createRoleError).toBe(error); + }); + + it('sets hasCredentials to false', () => { + expect(state.hasCredentials).toBe(false); + }); + }); + + describe(`mutation ${REQUEST_CREATE_CLUSTER}`, () => { + beforeEach(() => { + mutations[REQUEST_CREATE_CLUSTER](state); + }); + + it('sets isCreatingCluster to true', () => { + expect(state.isCreatingCluster).toBe(true); + }); + + it('sets createClusterError to null', () => { + expect(state.createClusterError).toBe(null); + }); + }); + + describe(`mutation ${CREATE_CLUSTER_ERROR}`, () => { + const error = new Error(); + + beforeEach(() => { + mutations[CREATE_CLUSTER_ERROR](state, { error }); + }); + + it('sets isCreatingRole to false', () => { + expect(state.isCreatingCluster).toBe(false); + }); + + it('sets createRoleError to the error object', () => { + expect(state.createClusterError).toBe(error); + }); + }); + + describe(`mutation ${SIGN_OUT}`, () => { + beforeEach(() => { + state.hasCredentials = true; + mutations[SIGN_OUT](state); + }); + + it('sets hasCredentials to false', () => { + expect(state.hasCredentials).toBe(false); + }); + }); }); diff --git a/spec/frontend/projects/gke_cluster_namespace/gke_cluster_namespace_spec.js b/spec/frontend/create_cluster/gke_cluster_namespace/gke_cluster_namespace_spec.js similarity index 95% rename from spec/frontend/projects/gke_cluster_namespace/gke_cluster_namespace_spec.js rename to spec/frontend/create_cluster/gke_cluster_namespace/gke_cluster_namespace_spec.js index 7b8df03d3c3cf0a9c6cbe4139029acc87615d27e..b1c25d8fff770d6306a6b454656710aeb38876ef 100644 --- a/spec/frontend/projects/gke_cluster_namespace/gke_cluster_namespace_spec.js +++ b/spec/frontend/create_cluster/gke_cluster_namespace/gke_cluster_namespace_spec.js @@ -1,4 +1,4 @@ -import initGkeNamespace from '~/projects/gke_cluster_namespace'; +import initGkeNamespace from '~/create_cluster/gke_cluster_namespace'; describe('GKE cluster namespace', () => { const changeEvent = new Event('change'); @@ -14,7 +14,7 @@ describe('GKE cluster namespace', () => { <input class="js-gl-managed" type="checkbox" value="1" checked /> <div class="js-namespace"> <input type="text" /> - </div> + </div> <div class="js-namespace-prefixed"> <input type="text" /> </div> diff --git a/spec/frontend/create_cluster/init_create_cluster_spec.js b/spec/frontend/create_cluster/init_create_cluster_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e7b9a7adde45b95c3b04f8b8e23fa21177b9c4cc --- /dev/null +++ b/spec/frontend/create_cluster/init_create_cluster_spec.js @@ -0,0 +1,73 @@ +import initCreateCluster from '~/create_cluster/init_create_cluster'; +import initGkeDropdowns from '~/create_cluster/gke_cluster'; +import initGkeNamespace from '~/create_cluster/gke_cluster_namespace'; +import PersistentUserCallout from '~/persistent_user_callout'; + +jest.mock('~/create_cluster/gke_cluster', () => jest.fn()); +jest.mock('~/create_cluster/gke_cluster_namespace', () => jest.fn()); +jest.mock('~/persistent_user_callout', () => ({ + factory: jest.fn(), +})); + +describe('initCreateCluster', () => { + let document; + let gon; + + beforeEach(() => { + document = { + body: { dataset: {} }, + querySelector: jest.fn(), + }; + gon = { features: {} }; + }); + afterEach(() => { + initGkeDropdowns.mockReset(); + initGkeNamespace.mockReset(); + PersistentUserCallout.factory.mockReset(); + }); + + describe.each` + pageSuffix | page + ${':clusters:new'} | ${'project:clusters:new'} + ${':clusters:create_gcp'} | ${'groups:clusters:create_gcp'} + ${':clusters:create_user'} | ${'admin:clusters:create_user'} + `('when cluster page ends in $pageSuffix', ({ page }) => { + beforeEach(() => { + document.body.dataset = { page }; + + initCreateCluster(document, gon); + }); + + it('initializes create GKE cluster app', () => { + expect(initGkeDropdowns).toHaveBeenCalled(); + }); + + it('initializes gcp signup offer banner', () => { + expect(PersistentUserCallout.factory).toHaveBeenCalled(); + }); + }); + + describe('when creating a project level cluster', () => { + it('initializes gke namespace app', () => { + document.body.dataset.page = 'project:clusters:new'; + + initCreateCluster(document, gon); + + expect(initGkeNamespace).toHaveBeenCalled(); + }); + }); + + describe.each` + clusterLevel | page + ${'group level'} | ${'groups:clusters:new'} + ${'instance level'} | ${'admin:clusters:create_gcp'} + `('when creating a $clusterLevel cluster', ({ page }) => { + it('does not initialize gke namespace app', () => { + document.body.dataset = { page }; + + initCreateCluster(document, gon); + + expect(initGkeNamespace).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/cycle_analytics/stage_nav_item_spec.js b/spec/frontend/cycle_analytics/stage_nav_item_spec.js index ff079082ca7553dd776d54ba5adfe8e3dca6d523..a7a1d563e1e3bbbf4b88717f21fdd9842f8195ac 100644 --- a/spec/frontend/cycle_analytics/stage_nav_item_spec.js +++ b/spec/frontend/cycle_analytics/stage_nav_item_spec.js @@ -133,45 +133,19 @@ describe('StageNavItem', () => { hasStageName(); }); - it('renders options menu', () => { - expect(wrapper.find('.more-actions-toggle').exists()).toBe(true); + it('does not render options menu', () => { + expect(wrapper.find('.more-actions-toggle').exists()).toBe(false); }); - describe('Default stages', () => { - beforeEach(() => { - wrapper = createComponent( - { canEdit: true, isUserAllowed: true, isDefaultStage: true }, - false, - ); - }); - it('can hide the stage', () => { - expect(wrapper.text()).toContain('Hide stage'); - }); - it('can not edit the stage', () => { - expect(wrapper.text()).not.toContain('Edit stage'); - }); - it('can not remove the stage', () => { - expect(wrapper.text()).not.toContain('Remove stage'); - }); + it('can not edit the stage', () => { + expect(wrapper.text()).not.toContain('Edit stage'); + }); + it('can not remove the stage', () => { + expect(wrapper.text()).not.toContain('Remove stage'); }); - describe('Custom stages', () => { - beforeEach(() => { - wrapper = createComponent( - { canEdit: true, isUserAllowed: true, isDefaultStage: false }, - false, - ); - }); - it('can edit the stage', () => { - expect(wrapper.text()).toContain('Edit stage'); - }); - it('can remove the stage', () => { - expect(wrapper.text()).toContain('Remove stage'); - }); - - it('can not hide the stage', () => { - expect(wrapper.text()).not.toContain('Hide stage'); - }); + it('can not hide the stage', () => { + expect(wrapper.text()).not.toContain('Hide stage'); }); }); }); diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js index 290c0e797cb3d248b6ecd24914d673c37df82156..3c6553f35470696855c2c6684f19978e73b3d61a 100644 --- a/spec/frontend/environment.js +++ b/spec/frontend/environment.js @@ -41,6 +41,12 @@ class CustomEnvironment extends JSDOMEnvironment { this.global.fixturesBasePath = `${ROOT_PATH}/tmp/tests/frontend/fixtures${IS_EE ? '-ee' : ''}`; this.global.staticFixturesBasePath = `${ROOT_PATH}/spec/frontend/fixtures`; + /** + * window.fetch() is required by the apollo-upload-client library otherwise + * a ReferenceError is generated: https://github.com/jaydenseric/apollo-upload-client/issues/100 + */ + this.global.fetch = () => {}; + // Not yet supported by JSDOM: https://github.com/jsdom/jsdom/issues/317 this.global.document.createRange = () => ({ setStart: () => {}, diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..54e8b0848a207f1d48968d10893715313f8b3a6e --- /dev/null +++ b/spec/frontend/error_tracking/components/error_details_spec.js @@ -0,0 +1,105 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlLoadingIcon, GlLink } from '@gitlab/ui'; +import Stacktrace from '~/error_tracking/components/stacktrace.vue'; +import ErrorDetails from '~/error_tracking/components/error_details.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ErrorDetails', () => { + let store; + let wrapper; + let actions; + let getters; + + function mountComponent() { + wrapper = shallowMount(ErrorDetails, { + localVue, + store, + propsData: { + issueDetailsPath: '/123/details', + issueStackTracePath: '/stacktrace', + }, + }); + } + + beforeEach(() => { + actions = { + startPollingDetails: () => {}, + startPollingStacktrace: () => {}, + }; + + getters = { + sentryUrl: () => 'sentry.io', + stacktrace: () => [{ context: [1, 2], lineNo: 53, filename: 'index.js' }], + }; + + const state = { + error: {}, + loading: true, + stacktraceData: {}, + loadingStacktrace: true, + }; + + store = new Vuex.Store({ + modules: { + details: { + namespaced: true, + actions, + state, + getters, + }, + }, + }); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('loading', () => { + beforeEach(() => { + mountComponent(); + }); + + it('should show spinner while loading', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.find(GlLink).exists()).toBe(false); + expect(wrapper.find(Stacktrace).exists()).toBe(false); + }); + }); + + describe('Error details', () => { + it('should show Sentry error details without stacktrace', () => { + store.state.details.loading = false; + store.state.details.error.id = 1; + mountComponent(); + expect(wrapper.find(GlLink).exists()).toBe(true); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.find(Stacktrace).exists()).toBe(false); + }); + + 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); + }); + + 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(); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find(Stacktrace).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 ce8b8908026245b81e1966f05cd5b77bcd572fed..1bbf23cc602f653be3322c8215ceafb0f24700f8 100644 --- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js +++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js @@ -34,7 +34,7 @@ describe('ErrorTrackingList', () => { beforeEach(() => { actions = { - getErrorList: () => {}, + getSentryData: () => {}, startPolling: () => {}, restartPolling: jest.fn().mockName('restartPolling'), }; @@ -45,8 +45,13 @@ describe('ErrorTrackingList', () => { }; store = new Vuex.Store({ - actions, - state, + modules: { + list: { + namespaced: true, + actions, + state, + }, + }, }); }); @@ -70,7 +75,7 @@ describe('ErrorTrackingList', () => { describe('results', () => { beforeEach(() => { - store.state.loading = false; + store.state.list.loading = false; mountComponent(); }); @@ -84,7 +89,7 @@ describe('ErrorTrackingList', () => { describe('no results', () => { beforeEach(() => { - store.state.loading = false; + store.state.list.loading = false; mountComponent(); }); diff --git a/spec/frontend/error_tracking/components/stacktrace_entry_spec.js b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..95958408770358c768d86cae5369fdad474c45e1 --- /dev/null +++ b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js @@ -0,0 +1,49 @@ +import { shallowMount } from '@vue/test-utils'; +import StackTraceEntry from '~/error_tracking/components/stacktrace_entry.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; + +describe('Stacktrace Entry', () => { + let wrapper; + + function mountComponent(props) { + wrapper = shallowMount(StackTraceEntry, { + propsData: { + filePath: 'sidekiq/util.rb', + lines: [ + [22, ' def safe_thread(name, \u0026block)\n'], + [23, ' Thread.new do\n'], + [24, " Thread.current['sidekiq_label'] = name\n"], + [25, ' watchdog(name, \u0026block)\n'], + ], + errorLine: 24, + ...props, + }, + }); + } + + beforeEach(() => { + mountComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + it('should render stacktrace entry collapsed', () => { + expect(wrapper.find(StackTraceEntry).exists()).toBe(true); + expect(wrapper.find(ClipboardButton).exists()).toBe(true); + expect(wrapper.find(Icon).exists()).toBe(true); + expect(wrapper.find(FileIcon).exists()).toBe(true); + expect(wrapper.element.querySelectorAll('table').length).toBe(0); + }); + + it('should render stacktrace entry table expanded', () => { + mountComponent({ expanded: true }); + expect(wrapper.element.querySelectorAll('tr.line_holder').length).toBe(4); + expect(wrapper.element.querySelectorAll('.line_content.old').length).toBe(1); + }); +}); diff --git a/spec/frontend/error_tracking/components/stacktrace_spec.js b/spec/frontend/error_tracking/components/stacktrace_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..4f4a60acba425123a67d6fc9c9c64c7574866562 --- /dev/null +++ b/spec/frontend/error_tracking/components/stacktrace_spec.js @@ -0,0 +1,45 @@ +import { shallowMount } from '@vue/test-utils'; +import Stacktrace from '~/error_tracking/components/stacktrace.vue'; +import StackTraceEntry from '~/error_tracking/components/stacktrace_entry.vue'; + +describe('ErrorDetails', () => { + let wrapper; + + const stackTraceEntry = { + filename: 'sidekiq/util.rb', + context: [ + [22, ' def safe_thread(name, \u0026block)\n'], + [23, ' Thread.new do\n'], + [24, " Thread.current['sidekiq_label'] = name\n"], + [25, ' watchdog(name, \u0026block)\n'], + ], + lineNo: 24, + }; + + function mountComponent(entries) { + wrapper = shallowMount(Stacktrace, { + propsData: { + entries, + }, + }); + } + + describe('Stacktrace', () => { + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + it('should render single Stacktrace entry', () => { + mountComponent([stackTraceEntry]); + expect(wrapper.findAll(StackTraceEntry).length).toBe(1); + }); + + it('should render multiple Stacktrace entry', () => { + const entriesNum = 3; + mountComponent(new Array(entriesNum).fill(stackTraceEntry)); + expect(wrapper.findAll(StackTraceEntry).length).toBe(entriesNum); + }); + }); +}); diff --git a/spec/frontend/error_tracking/store/details/actions_spec.js b/spec/frontend/error_tracking/store/details/actions_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..f72cd1e413bc1665b5d64857e5da65e5a364b0eb --- /dev/null +++ b/spec/frontend/error_tracking/store/details/actions_spec.js @@ -0,0 +1,94 @@ +import axios from '~/lib/utils/axios_utils'; +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import createFlash from '~/flash'; +import * as actions from '~/error_tracking/store/details/actions'; +import * as types from '~/error_tracking/store/details/mutation_types'; + +jest.mock('~/flash.js'); +let mock; + +describe('Sentry error details store actions', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + createFlash.mockClear(); + }); + + describe('startPollingDetails', () => { + const endpoint = '123/details'; + it('should commit SET_ERROR with received response', done => { + const payload = { error: { id: 1 } }; + mock.onGet().reply(200, payload); + testAction( + actions.startPollingDetails, + { endpoint }, + {}, + [ + { type: types.SET_ERROR, payload: payload.error }, + { type: types.SET_LOADING, payload: false }, + ], + [], + () => { + done(); + }, + ); + }); + + it('should show flash on API error', done => { + mock.onGet().reply(400); + + testAction( + actions.startPollingDetails, + { endpoint }, + {}, + [{ type: types.SET_LOADING, payload: false }], + [], + () => { + expect(createFlash).toHaveBeenCalledTimes(1); + done(); + }, + ); + }); + }); + + describe('startPollingStacktrace', () => { + const endpoint = '123/stacktrace'; + it('should commit SET_ERROR with received response', done => { + const payload = { error: [1, 2, 3] }; + mock.onGet().reply(200, payload); + testAction( + actions.startPollingStacktrace, + { endpoint }, + {}, + [ + { type: types.SET_STACKTRACE_DATA, payload: payload.error }, + { type: types.SET_LOADING_STACKTRACE, payload: false }, + ], + [], + () => { + done(); + }, + ); + }); + + it('should show flash on API error', done => { + mock.onGet().reply(400); + + testAction( + actions.startPollingStacktrace, + { endpoint }, + {}, + [{ type: types.SET_LOADING_STACKTRACE, payload: false }], + [], + () => { + expect(createFlash).toHaveBeenCalledTimes(1); + done(); + }, + ); + }); + }); +}); diff --git a/spec/frontend/error_tracking/store/details/getters_spec.js b/spec/frontend/error_tracking/store/details/getters_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..ea57de5872b9d416aa2e63a7feea49b26bb27678 --- /dev/null +++ b/spec/frontend/error_tracking/store/details/getters_spec.js @@ -0,0 +1,13 @@ +import * as getters from '~/error_tracking/store/details/getters'; + +describe('Sentry error details store getters', () => { + const state = { + stacktraceData: { stack_trace_entries: [1, 2] }, + }; + + describe('stacktrace', () => { + it('should get stacktrace', () => { + expect(getters.stacktrace(state)).toEqual([2, 1]); + }); + }); +}); diff --git a/spec/frontend/error_tracking/store/list/getters_spec.js b/spec/frontend/error_tracking/store/list/getters_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..3cd7fa37d4423bb47481a28b2b012b36a4356845 --- /dev/null +++ b/spec/frontend/error_tracking/store/list/getters_spec.js @@ -0,0 +1,33 @@ +import * as getters from '~/error_tracking/store/list/getters'; + +describe('Error Tracking getters', () => { + let state; + + const mockErrors = [ + { title: 'ActiveModel::MissingAttributeError: missing attribute: encrypted_password' }, + { title: 'Grape::Exceptions::MethodNotAllowed: Grape::Exceptions::MethodNotAllowed' }, + { title: 'NoMethodError: undefined method `sanitize_http_headers=' }, + { title: 'NoMethodError: undefined method `pry' }, + ]; + + beforeEach(() => { + state = { + errors: mockErrors, + }; + }); + + describe('search results', () => { + it('should return errors filtered by words in title matching the query', () => { + const filteredErrors = getters.filterErrorsByTitle(state)('NoMethod'); + + expect(filteredErrors).not.toContainEqual(mockErrors[0]); + expect(filteredErrors.length).toBe(2); + }); + + it('should not return results if there is no matching query', () => { + const filteredErrors = getters.filterErrorsByTitle(state)('GitLab'); + + expect(filteredErrors.length).toBe(0); + }); + }); +}); diff --git a/spec/frontend/error_tracking/store/mutation_spec.js b/spec/frontend/error_tracking/store/list/mutation_spec.js similarity index 83% rename from spec/frontend/error_tracking/store/mutation_spec.js rename to spec/frontend/error_tracking/store/list/mutation_spec.js index 8117104bdbc7d0abb9f357ff03b8831b2667d33e..6e021185b4d35a53c2ee9bab2ccdf1f02678bafc 100644 --- a/spec/frontend/error_tracking/store/mutation_spec.js +++ b/spec/frontend/error_tracking/store/list/mutation_spec.js @@ -1,5 +1,5 @@ -import mutations from '~/error_tracking/store/mutations'; -import * as types from '~/error_tracking/store/mutation_types'; +import mutations from '~/error_tracking/store/list/mutations'; +import * as types from '~/error_tracking/store/list/mutation_types'; describe('Error tracking mutations', () => { describe('SET_ERRORS', () => { diff --git a/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js b/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js index 23e57c4bbf102247839baa76783a0a49b1015239..bff8ad0877a5cba447760b225ef5080a27309cfc 100644 --- a/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js +++ b/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js @@ -1,7 +1,9 @@ import Vuex from 'vuex'; import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { GlButton, GlFormInput } from '@gitlab/ui'; +import { GlFormInput } from '@gitlab/ui'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; import ErrorTrackingForm from '~/error_tracking_settings/components/error_tracking_form.vue'; +import createStore from '~/error_tracking_settings/store'; import { defaultProps } from '../mock'; const localVue = createLocalVue(); @@ -9,15 +11,18 @@ localVue.use(Vuex); describe('error tracking settings form', () => { let wrapper; + let store; function mountComponent() { wrapper = shallowMount(ErrorTrackingForm, { localVue, + store, propsData: defaultProps, }); } beforeEach(() => { + store = createStore(); mountComponent(); }); @@ -38,7 +43,7 @@ describe('error tracking settings form', () => { .attributes('id'), ).toBe('error-tracking-token'); - expect(wrapper.findAll(GlButton).exists()).toBe(true); + expect(wrapper.findAll(LoadingButton).exists()).toBe(true); }); it('is rendered with labels and placeholders', () => { @@ -59,9 +64,21 @@ describe('error tracking settings form', () => { }); }); + describe('loading projects', () => { + beforeEach(() => { + store.state.isLoadingProjects = true; + }); + + it('shows loading spinner', () => { + const { label, loading } = wrapper.find(LoadingButton).props(); + expect(loading).toBe(true); + expect(label).toBe('Connecting'); + }); + }); + describe('after a successful connection', () => { beforeEach(() => { - wrapper.setProps({ connectSuccessful: true }); + store.state.connectSuccessful = true; }); it('shows the success checkmark', () => { @@ -77,7 +94,7 @@ describe('error tracking settings form', () => { describe('after an unsuccessful connection', () => { beforeEach(() => { - wrapper.setProps({ connectError: true }); + store.state.connectError = true; }); it('does not show the check mark', () => { diff --git a/spec/frontend/error_tracking_settings/store/actions_spec.js b/spec/frontend/error_tracking_settings/store/actions_spec.js index 1eab0f7470b6af2ba00c4bdaf6cdf34fdfb0d5d8..e12c4e20f5858a213e0ed165ad067ea93dfc85a4 100644 --- a/spec/frontend/error_tracking_settings/store/actions_spec.js +++ b/spec/frontend/error_tracking_settings/store/actions_spec.js @@ -69,7 +69,14 @@ describe('error tracking settings actions', () => { }); it('should request projects correctly', done => { - testAction(actions.requestProjects, null, state, [{ type: types.RESET_CONNECT }], [], done); + testAction( + actions.requestProjects, + null, + state, + [{ type: types.SET_PROJECTS_LOADING, payload: true }, { type: types.RESET_CONNECT }], + [], + done, + ); }); it('should receive projects correctly', done => { @@ -81,6 +88,7 @@ describe('error tracking settings actions', () => { [ { type: types.UPDATE_CONNECT_SUCCESS }, { type: types.RECEIVE_PROJECTS, payload: testPayload }, + { type: types.SET_PROJECTS_LOADING, payload: false }, ], [], done, @@ -93,7 +101,11 @@ describe('error tracking settings actions', () => { actions.receiveProjectsError, testPayload, state, - [{ type: types.UPDATE_CONNECT_ERROR }, { type: types.CLEAR_PROJECTS }], + [ + { type: types.UPDATE_CONNECT_ERROR }, + { type: types.CLEAR_PROJECTS }, + { type: types.SET_PROJECTS_LOADING, payload: false }, + ], [], done, ); diff --git a/spec/frontend/fixtures/merge_requests.rb b/spec/frontend/fixtures/merge_requests.rb index 8fbdb534b3d91e7c6ed5924c83bb9fd086c00ebe..f20c0aa35400218e0cff29c91e1b8a8ad526ab99 100644 --- a/spec/frontend/fixtures/merge_requests.rb +++ b/spec/frontend/fixtures/merge_requests.rb @@ -8,7 +8,23 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, :repository, namespace: namespace, path: 'merge-requests-project') } - let(:merge_request) { create(:merge_request, :with_diffs, source_project: project, target_project: project, description: '- [ ] Task List Item') } + + # rubocop: disable Layout/TrailingWhitespace + let(:merge_request) do + create( + :merge_request, + :with_diffs, + source_project: project, + target_project: project, + description: <<~MARKDOWN.strip_heredoc + - [ ] Task List Item + - [ ] + - [ ] Task List Item 2 + MARKDOWN + ) + end + # rubocop: enable Layout/TrailingWhitespace + let(:merged_merge_request) { create(:merge_request, :merged, source_project: project, target_project: project) } let(:pipeline) do create( diff --git a/spec/frontend/fixtures/static/environments_logs.html b/spec/frontend/fixtures/static/environments_logs.html index ccf9c3641547cfcc1b95c31917d2054578eb36b1..88bb0a3ed419e81a6269a12daf8f447de8b65478 100644 --- a/spec/frontend/fixtures/static/environments_logs.html +++ b/spec/frontend/fixtures/static/environments_logs.html @@ -2,8 +2,8 @@ class="js-kubernetes-logs" data-current-environment-name="production" data-environments-path="/root/my-project/environments.json" - data-logs-page="/root/my-project/environments/1/logs" - data-logs-path="/root/my-project/environments/1/logs.json" + data-project-full-path="root/my-project" + data-environment-id=1 > <div class="build-page-pod-logs"> <div class="build-trace-container prepend-top-default"> diff --git a/spec/frontend/fixtures/static/signin_tabs.html b/spec/frontend/fixtures/static/signin_tabs.html index 7e66ab9394b84b759b5e9c1fa987810968ed743d..247a6b030548d12ab446efef2c73d8575724eb00 100644 --- a/spec/frontend/fixtures/static/signin_tabs.html +++ b/spec/frontend/fixtures/static/signin_tabs.html @@ -5,4 +5,7 @@ <li> <a href="#login-pane">Standard</a> </li> +<li> +<a href="#register-pane">Register</a> +</li> </ul> diff --git a/spec/frontend/fixtures/u2f.rb b/spec/frontend/fixtures/u2f.rb index dded6ce63804c62bf2d5dbb167450d8cc9b4dac7..9710fbbc181143d60e7330a6d4061d71e0e201ab 100644 --- a/spec/frontend/fixtures/u2f.rb +++ b/spec/frontend/fixtures/u2f.rb @@ -34,7 +34,9 @@ context 'U2F' do before do sign_in(user) - allow_any_instance_of(Profiles::TwoFactorAuthsController).to receive(:build_qr_code).and_return('qrcode:blackandwhitesquares') + allow_next_instance_of(Profiles::TwoFactorAuthsController) do |instance| + allow(instance).to receive(:build_qr_code).and_return('qrcode:blackandwhitesquares') + end end it 'u2f/register.html' do 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 new file mode 100644 index 0000000000000000000000000000000000000000..69ad71a1efb4ef084408bb48e662a3bffe646985 --- /dev/null +++ b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`grafana integration component default state to match the default snapshot 1`] = ` +<section + class="settings no-animate js-grafana-integration" + id="grafana" +> + <div + class="settings-header" + > + <h4 + class="js-section-header" + > + + Grafana Authentication + + </h4> + + <glbutton-stub + class="js-settings-toggle" + > + Expand + </glbutton-stub> + + <p + class="js-section-sub-header" + > + + Embed Grafana charts in GitLab issues. + + </p> + </div> + + <div + class="settings-content" + > + <form> + <glformcheckbox-stub + class="mb-4" + id="grafana-integration-enabled" + > + + Active + + </glformcheckbox-stub> + + <glformgroup-stub + description="Enter the base URL of the Grafana instance." + label="Grafana URL" + label-for="grafana-url" + > + <glforminput-stub + id="grafana-url" + placeholder="https://my-url.grafana.net/" + value="http://test.host" + /> + </glformgroup-stub> + + <glformgroup-stub + label="API Token" + label-for="grafana-token" + > + <glforminput-stub + id="grafana-token" + value="someToken" + /> + + <p + class="form-text text-muted" + > + + Enter the Grafana API Token. + + <a + href="https://grafana.com/docs/http_api/auth/#create-api-token" + rel="noopener noreferrer" + target="_blank" + > + + More information + + <icon-stub + class="vertical-align-middle" + name="external-link" + size="16" + /> + </a> + </p> + </glformgroup-stub> + + <glbutton-stub + variant="success" + > + + Save Changes + + </glbutton-stub> + </form> + </div> +</section> +`; diff --git a/spec/frontend/grafana_integration/components/grafana_integration_spec.js b/spec/frontend/grafana_integration/components/grafana_integration_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c098ada0519d9262c2e507351e0fd76649dcacb7 --- /dev/null +++ b/spec/frontend/grafana_integration/components/grafana_integration_spec.js @@ -0,0 +1,125 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import GrafanaIntegration from '~/grafana_integration/components/grafana_integration.vue'; +import { createStore } from '~/grafana_integration/store'; +import axios from '~/lib/utils/axios_utils'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import createFlash from '~/flash'; +import { TEST_HOST } from 'helpers/test_constants'; + +jest.mock('~/lib/utils/url_utility'); +jest.mock('~/flash'); + +describe('grafana integration component', () => { + let wrapper; + let store; + const operationsSettingsEndpoint = `${TEST_HOST}/mock/ops/settings/endpoint`; + const grafanaIntegrationUrl = `${TEST_HOST}`; + const grafanaIntegrationToken = 'someToken'; + + beforeEach(() => { + store = createStore({ + operationsSettingsEndpoint, + grafanaIntegrationUrl, + grafanaIntegrationToken, + }); + }); + + afterEach(() => { + if (wrapper.destroy) { + wrapper.destroy(); + createFlash.mockReset(); + refreshCurrentPage.mockReset(); + } + }); + + describe('default state', () => { + it('to match the default snapshot', () => { + wrapper = shallowMount(GrafanaIntegration, { store }); + + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('renders header text', () => { + wrapper = shallowMount(GrafanaIntegration, { store }); + + expect(wrapper.find('.js-section-header').text()).toBe('Grafana Authentication'); + }); + + describe('expand/collapse button', () => { + it('renders as an expand button by default', () => { + wrapper = shallowMount(GrafanaIntegration, { store }); + + const button = wrapper.find(GlButton); + + expect(button.text()).toBe('Expand'); + }); + }); + + describe('sub-header', () => { + it('renders descriptive text', () => { + wrapper = shallowMount(GrafanaIntegration, { store }); + + expect(wrapper.find('.js-section-sub-header').text()).toContain( + 'Embed Grafana charts in GitLab issues.', + ); + }); + }); + + describe('form', () => { + beforeEach(() => { + jest.spyOn(axios, 'patch').mockImplementation(); + }); + + afterEach(() => { + axios.patch.mockReset(); + }); + + describe('submit button', () => { + const findSubmitButton = () => wrapper.find('.settings-content form').find(GlButton); + + const endpointRequest = [ + operationsSettingsEndpoint, + { + project: { + grafana_integration_attributes: { + grafana_url: grafanaIntegrationUrl, + token: grafanaIntegrationToken, + enabled: false, + }, + }, + }, + ]; + + it('submits form on click', () => { + wrapper = mount(GrafanaIntegration, { store }); + axios.patch.mockResolvedValue(); + + findSubmitButton(wrapper).trigger('click'); + + expect(axios.patch).toHaveBeenCalledWith(...endpointRequest); + return wrapper.vm.$nextTick().then(() => expect(refreshCurrentPage).toHaveBeenCalled()); + }); + + it('creates flash banner on error', () => { + const message = 'mockErrorMessage'; + wrapper = mount(GrafanaIntegration, { store }); + axios.patch.mockRejectedValue({ response: { data: { message } } }); + + findSubmitButton().trigger('click'); + + expect(axios.patch).toHaveBeenCalledWith(...endpointRequest); + return wrapper.vm + .$nextTick() + .then(jest.runAllTicks) + .then(() => + expect(createFlash).toHaveBeenCalledWith( + `There was an error saving your changes. ${message}`, + 'alert', + ), + ); + }); + }); + }); +}); diff --git a/spec/frontend/grafana_integration/store/mutations_spec.js b/spec/frontend/grafana_integration/store/mutations_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..18e8739418932848836ad23e49c737048ad0c633 --- /dev/null +++ b/spec/frontend/grafana_integration/store/mutations_spec.js @@ -0,0 +1,35 @@ +import mutations from '~/grafana_integration/store/mutations'; +import createState from '~/grafana_integration/store/state'; + +describe('grafana integration mutations', () => { + let localState; + + beforeEach(() => { + localState = createState(); + }); + + describe('SET_GRAFANA_URL', () => { + it('sets grafanaUrl', () => { + const mockUrl = 'mockUrl'; + mutations.SET_GRAFANA_URL(localState, mockUrl); + + expect(localState.grafanaUrl).toBe(mockUrl); + }); + }); + + describe('SET_GRAFANA_TOKEN', () => { + it('sets grafanaToken', () => { + const mockToken = 'mockToken'; + mutations.SET_GRAFANA_TOKEN(localState, mockToken); + + expect(localState.grafanaToken).toBe(mockToken); + }); + }); + describe('SET_GRAFANA_ENABLED', () => { + it('updates grafanaEnabled for integration', () => { + mutations.SET_GRAFANA_ENABLED(localState, true); + + expect(localState.grafanaEnabled).toBe(true); + }); + }); +}); diff --git a/spec/frontend/helpers/monitor_helper_spec.js b/spec/frontend/helpers/monitor_helper_spec.js index 2e8bff298c45485c9ce99f72b67e8351bbbf257f..0798ca580e2b46a8d1e690c8dc3b49f47bded8fd 100644 --- a/spec/frontend/helpers/monitor_helper_spec.js +++ b/spec/frontend/helpers/monitor_helper_spec.js @@ -41,5 +41,87 @@ describe('monitor helper', () => { ), ).toEqual([{ ...expectedDataSeries[0], data: [[1, 1]] }]); }); + + it('updates series name from templates', () => { + const config = { + ...defaultConfig, + name: '{{cmd}}', + }; + + const [result] = monitorHelper.makeDataSeries( + [{ metric: { cmd: 'brpop' }, values: series }], + config, + ); + + expect(result.name).toEqual('brpop'); + }); + + it('supports space-padded template expressions', () => { + const config = { + ...defaultConfig, + name: 'backend: {{ backend }}', + }; + + const [result] = monitorHelper.makeDataSeries( + [{ metric: { backend: 'HA Server' }, values: series }], + config, + ); + + expect(result.name).toEqual('backend: HA Server'); + }); + + it('supports repeated template variables', () => { + const config = { ...defaultConfig, name: '{{cmd}}, {{cmd}}' }; + + const [result] = monitorHelper.makeDataSeries( + [{ metric: { cmd: 'brpop' }, values: series }], + config, + ); + + expect(result.name).toEqual('brpop, brpop'); + }); + + it('supports hyphenated template variables', () => { + const config = { ...defaultConfig, name: 'expired - {{ test-attribute }}' }; + + const [result] = monitorHelper.makeDataSeries( + [{ metric: { 'test-attribute': 'test-attribute-value' }, values: series }], + config, + ); + + expect(result.name).toEqual('expired - test-attribute-value'); + }); + + it('updates multiple series names from templates', () => { + const config = { + ...defaultConfig, + name: '{{job}}: {{cmd}}', + }; + + const [result] = monitorHelper.makeDataSeries( + [{ metric: { cmd: 'brpop', job: 'redis' }, values: series }], + config, + ); + + expect(result.name).toEqual('redis: brpop'); + }); + + it('updates name for each series', () => { + const config = { + ...defaultConfig, + name: '{{cmd}}', + }; + + const [firstSeries, secondSeries] = monitorHelper.makeDataSeries( + [ + { metric: { cmd: 'brpop' }, values: series }, + { metric: { cmd: 'zrangebyscore' }, values: series }, + ], + config, + ); + + expect(firstSeries.name).toEqual('brpop'); + expect(secondSeries.name).toEqual('zrangebyscore'); + }); }); }); diff --git a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..5d6c31f01d9e5322e8d56bb6dbcd7b917ee00ff9 --- /dev/null +++ b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`IDE pipeline stage renders stage details & icon 1`] = ` +<div + class="ide-stage card prepend-top-default" +> + <div + class="card-header" + > + <ciicon-stub + cssclasses="" + size="24" + status="[object Object]" + /> + + <strong + class="prepend-left-8 ide-stage-title" + data-container="body" + data-original-title="" + title="" + > + + build + + </strong> + + <div + class="append-right-8 prepend-left-4" + > + <span + class="badge badge-pill" + > + 4 + </span> + </div> + + <icon-stub + class="ide-stage-collapse-icon" + name="angle-down" + size="16" + /> + </div> + + <div + class="card-body" + > + <item-stub + job="[object Object]" + /> + <item-stub + job="[object Object]" + /> + <item-stub + job="[object Object]" + /> + <item-stub + job="[object Object]" + /> + </div> +</div> +`; diff --git a/spec/frontend/ide/components/jobs/stage_spec.js b/spec/frontend/ide/components/jobs/stage_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..2e42ab26d27630d249a796d381ba2565ef89c6e8 --- /dev/null +++ b/spec/frontend/ide/components/jobs/stage_spec.js @@ -0,0 +1,86 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import Stage from '~/ide/components/jobs/stage.vue'; +import Item from '~/ide/components/jobs/item.vue'; +import { stages, jobs } from '../../mock_data'; + +describe('IDE pipeline stage', () => { + let wrapper; + const defaultProps = { + stage: { + ...stages[0], + id: 0, + dropdownPath: stages[0].dropdown_path, + jobs: [...jobs], + isLoading: false, + isCollapsed: false, + }, + }; + + const findHeader = () => wrapper.find({ ref: 'cardHeader' }); + const findJobList = () => wrapper.find({ ref: 'jobList' }); + + const createComponent = props => { + wrapper = shallowMount(Stage, { + propsData: { + ...defaultProps, + ...props, + }, + sync: false, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('emits fetch event when mounted', () => { + createComponent(); + expect(wrapper.emitted().fetch).toBeDefined(); + }); + + it('renders loading icon when no jobs and isLoading is true', () => { + createComponent({ + stage: { ...defaultProps.stage, isLoading: true, jobs: [] }, + }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + + it('emits toggleCollaped event with stage id when clicking header', () => { + const id = 5; + createComponent({ stage: { ...defaultProps.stage, id } }); + findHeader().trigger('click'); + expect(wrapper.emitted().toggleCollapsed[0][0]).toBe(id); + }); + + it('emits clickViewLog entity with job', () => { + const [job] = defaultProps.stage.jobs; + createComponent(); + wrapper + .findAll(Item) + .at(0) + .vm.$emit('clickViewLog', job); + expect(wrapper.emitted().clickViewLog[0][0]).toBe(job); + }); + + it('renders stage details & icon', () => { + createComponent(); + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('when collapsed', () => { + beforeEach(() => { + createComponent({ stage: { ...defaultProps.stage, isCollapsed: true } }); + }); + + it('does not render job list', () => { + expect(findJobList().isVisible()).toBe(false); + }); + + it('sets border bottom class', () => { + expect(findHeader().classes('border-bottom-0')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ide/components/preview/clientside_spec.js b/spec/frontend/ide/components/preview/clientside_spec.js index dfc76628d0c00de2904f19794cd0120435b357e1..6a33f4998c50ad3e5e3e376d3610d2c3ca098e20 100644 --- a/spec/frontend/ide/components/preview/clientside_spec.js +++ b/spec/frontend/ide/components/preview/clientside_spec.js @@ -24,6 +24,9 @@ describe('IDE clientside preview', () => { getFileData: jest.fn().mockReturnValue(Promise.resolve({})), getRawFileData: jest.fn().mockReturnValue(Promise.resolve('')), }; + const storeClientsideActions = { + pingUsage: jest.fn().mockReturnValue(Promise.resolve({})), + }; const waitForCalls = () => new Promise(setImmediate); @@ -42,6 +45,12 @@ describe('IDE clientside preview', () => { ...getters, }, actions: storeActions, + modules: { + clientside: { + namespaced: true, + actions: storeClientsideActions, + }, + }, }); wrapper = shallowMount(Clientside, { @@ -76,7 +85,8 @@ describe('IDE clientside preview', () => { describe('with main entry', () => { beforeEach(() => { createComponent({ getters: { packageJson: dummyPackageJson } }); - return wrapper.vm.initPreview(); + + return waitForCalls(); }); it('creates sandpack manager', () => { @@ -95,6 +105,10 @@ describe('IDE clientside preview', () => { }, ); }); + + it('pings usage', () => { + expect(storeClientsideActions.pingUsage).toHaveBeenCalledTimes(1); + }); }); describe('computed', () => { @@ -178,13 +192,13 @@ describe('IDE clientside preview', () => { }); describe('showOpenInCodeSandbox', () => { - it('returns true when visiblity is public', () => { + it('returns true when visibility is public', () => { createComponent({ getters: { currentProject: () => ({ visibility: 'public' }) } }); expect(wrapper.vm.showOpenInCodeSandbox).toBe(true); }); - it('returns false when visiblity is private', () => { + it('returns false when visibility is private', () => { createComponent({ getters: { currentProject: () => ({ visibility: 'private' }) } }); expect(wrapper.vm.showOpenInCodeSandbox).toBe(false); diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js index 3d5ed4b5c0c49146577541a5e1661ac8c4c91a66..bb0d20bed911371d68294465a418d7ed04d69ad3 100644 --- a/spec/frontend/ide/services/index_spec.js +++ b/spec/frontend/ide/services/index_spec.js @@ -1,11 +1,18 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; import services from '~/ide/services'; import Api from '~/api'; +import { escapeFileUrl } from '~/ide/stores/utils'; jest.mock('~/api'); const TEST_PROJECT_ID = 'alice/wonderland'; const TEST_BRANCH = 'master-patch-123'; const TEST_COMMIT_SHA = '123456789'; +const TEST_FILE_PATH = 'README2.md'; +const TEST_FILE_OLD_PATH = 'OLD_README2.md'; +const TEST_FILE_PATH_SPECIAL = 'READM?ME/abc'; +const TEST_FILE_CONTENTS = 'raw file content'; describe('IDE services', () => { describe('commit', () => { @@ -28,4 +35,80 @@ describe('IDE services', () => { expect(Api.commitMultiple).toHaveBeenCalledWith(TEST_PROJECT_ID, payload); }); }); + + describe('getBaseRawFileData', () => { + let file; + let mock; + + beforeEach(() => { + file = { + mrChange: null, + projectId: TEST_PROJECT_ID, + path: TEST_FILE_PATH, + }; + + jest.spyOn(axios, 'get'); + + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + it('gives back file.baseRaw for files with that property present', () => { + file.baseRaw = TEST_FILE_CONTENTS; + + return services.getBaseRawFileData(file, TEST_COMMIT_SHA).then(content => { + expect(content).toEqual(TEST_FILE_CONTENTS); + }); + }); + + it('gives back file.baseRaw for files for temp files', () => { + file.tempFile = true; + file.baseRaw = TEST_FILE_CONTENTS; + + return services.getBaseRawFileData(file, TEST_COMMIT_SHA).then(content => { + expect(content).toEqual(TEST_FILE_CONTENTS); + }); + }); + + describe.each` + relativeUrlRoot | filePath | isRenamed + ${''} | ${TEST_FILE_PATH} | ${false} + ${''} | ${TEST_FILE_OLD_PATH} | ${true} + ${''} | ${TEST_FILE_PATH_SPECIAL} | ${false} + ${''} | ${TEST_FILE_PATH_SPECIAL} | ${true} + ${'gitlab'} | ${TEST_FILE_OLD_PATH} | ${true} + `( + 'with relativeUrlRoot ($relativeUrlRoot) and filePath ($filePath) and isRenamed ($isRenamed)', + ({ relativeUrlRoot, filePath, isRenamed }) => { + beforeEach(() => { + if (isRenamed) { + file.mrChange = { + renamed_file: true, + old_path: filePath, + }; + } else { + file.path = filePath; + } + + gon.relative_url_root = relativeUrlRoot; + + mock + .onGet( + `${relativeUrlRoot}/${TEST_PROJECT_ID}/raw/${TEST_COMMIT_SHA}/${escapeFileUrl( + filePath, + )}`, + ) + .reply(200, TEST_FILE_CONTENTS); + }); + + it('fetches file content', () => + services.getBaseRawFileData(file, TEST_COMMIT_SHA).then(content => { + expect(content).toEqual(TEST_FILE_CONTENTS); + })); + }, + ); + }); }); diff --git a/spec/frontend/ide/stores/modules/clientside/actions_spec.js b/spec/frontend/ide/stores/modules/clientside/actions_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a47bc0bd711d51f44f5070ffd59ab12dc2432407 --- /dev/null +++ b/spec/frontend/ide/stores/modules/clientside/actions_spec.js @@ -0,0 +1,39 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { TEST_HOST } from 'helpers/test_constants'; +import axios from '~/lib/utils/axios_utils'; +import * as actions from '~/ide/stores/modules/clientside/actions'; + +const TEST_PROJECT_URL = `${TEST_HOST}/lorem/ipsum`; +const TEST_USAGE_URL = `${TEST_PROJECT_URL}/usage_ping/web_ide_clientside_preview`; + +describe('IDE store module clientside actions', () => { + let rootGetters; + let mock; + + beforeEach(() => { + rootGetters = { + currentProject: { + web_url: TEST_PROJECT_URL, + }, + }; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('pingUsage', () => { + it('posts to usage endpoint', done => { + const usageSpy = jest.fn(() => [200]); + + mock.onPost(TEST_USAGE_URL).reply(() => usageSpy()); + + testAction(actions.pingUsage, null, rootGetters, [], [], () => { + expect(usageSpy).toHaveBeenCalled(); + done(); + }); + }); + }); +}); 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 new file mode 100644 index 0000000000000000000000000000000000000000..f57391a6b0dbba74d5846ee9ac55b63a1baa6c73 --- /dev/null +++ b/spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap @@ -0,0 +1,15 @@ +// 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 + 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" +/> +`; + +exports[`Issuables list component with empty issues response with closed state should display a message "There are no closed issues" if there are no closed issues 1`] = `"There are no closed issues"`; + +exports[`Issuables list component with empty issues response with empty query should display the message "There are no open issues" 1`] = `"There are no open issues"`; + +exports[`Issuables list component with empty issues response with query in window location should display "Sorry, your filter produced no results" if filters are too specific 1`] = `"Sorry, your filter produced no results"`; diff --git a/spec/frontend/issuables_list/components/issuable_spec.js b/spec/frontend/issuables_list/components/issuable_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..6148f3c68f223859beaa3be54af749522bbcbc5a --- /dev/null +++ b/spec/frontend/issuables_list/components/issuable_spec.js @@ -0,0 +1,345 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; +import { TEST_HOST } from 'helpers/test_constants'; +import { trimText } from 'helpers/text_helper'; +import initUserPopovers from '~/user_popovers'; +import { formatDate } from '~/lib/utils/datetime_utility'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import Issuable from '~/issuables_list/components/issuable.vue'; +import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; +import { simpleIssue, testAssignees, testLabels } from '../issuable_list_test_data'; + +jest.mock('~/user_popovers'); + +const TEST_NOW = '2019-08-28T20:03:04.713Z'; +const TEST_MONTH_AGO = '2019-07-28'; +const TEST_MONTH_LATER = '2019-09-30'; +const DATE_FORMAT = 'mmm d, yyyy'; +const TEST_USER_NAME = 'Tyler Durden'; +const TEST_BASE_URL = `${TEST_HOST}/issues`; +const TEST_TASK_STATUS = '50 of 100 tasks completed'; +const TEST_MILESTONE = { + title: 'Milestone title', + web_url: `${TEST_HOST}/milestone/1`, +}; +const TEXT_CLOSED = 'CLOSED'; +const TEST_META_COUNT = 100; + +// Use FixedDate so that time sensitive info in snapshots don't fail +class FixedDate extends Date { + constructor(date = TEST_NOW) { + super(date); + } +} + +describe('Issuable component', () => { + let issuable; + let DateOrig; + let wrapper; + + const factory = (props = {}) => { + wrapper = shallowMount(Issuable, { + propsData: { + issuable: simpleIssue, + baseUrl: TEST_BASE_URL, + ...props, + }, + sync: false, + }); + }; + + beforeEach(() => { + issuable = { ...simpleIssue }; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + beforeAll(() => { + DateOrig = window.Date; + window.Date = FixedDate; + }); + + afterAll(() => { + window.Date = DateOrig; + }); + + const findConfidentialIcon = () => wrapper.find('.fa-eye-slash'); + 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 findDueDate = () => wrapper.find('.js-due-date'); + const findLabelContainer = () => wrapper.find('.js-labels'); + const findLabelLinks = () => findLabelContainer().findAll(GlLink); + const findWeight = () => wrapper.find('.js-weight'); + const findAssignees = () => wrapper.find(IssueAssignees); + const findMergeRequestsCount = () => wrapper.find('.js-merge-requests'); + const findUpvotes = () => wrapper.find('.js-upvotes'); + const findDownvotes = () => wrapper.find('.js-downvotes'); + const findNotes = () => wrapper.find('.js-notes'); + const findBulkCheckbox = () => wrapper.find('input.selected-issuable'); + + describe('when mounted', () => { + it('initializes user popovers', () => { + expect(initUserPopovers).not.toHaveBeenCalled(); + + factory(); + + expect(initUserPopovers).toHaveBeenCalledWith([findOpenedAgoContainer().find('a').element]); + }); + }); + + describe('with simple issuable', () => { + beforeEach(() => { + Object.assign(issuable, { + has_tasks: false, + task_status: TEST_TASK_STATUS, + created_at: TEST_MONTH_AGO, + author: { + ...issuable.author, + name: TEST_USER_NAME, + }, + labels: [], + }); + + factory({ issuable }); + }); + + it.each` + desc | finder + ${'bulk editing checkbox'} | ${findBulkCheckbox} + ${'confidential icon'} | ${findConfidentialIcon} + ${'task status'} | ${findTaskStatus} + ${'milestone'} | ${findMilestone} + ${'due date'} | ${findDueDate} + ${'labels'} | ${findLabelContainer} + ${'weight'} | ${findWeight} + ${'merge request count'} | ${findMergeRequestsCount} + ${'upvotes'} | ${findUpvotes} + ${'downvotes'} | ${findDownvotes} + `('does not render $desc', ({ finder }) => { + expect(finder().exists()).toBe(false); + }); + + it('does not have closed text', () => { + expect(wrapper.text()).not.toContain(TEXT_CLOSED); + }); + + it('does not have closed class', () => { + expect(wrapper.classes('closed')).toBe(false); + }); + + it('renders fuzzy opened date and author', () => { + expect(trimText(findOpenedAgoContainer().text())).toEqual( + `opened 1 month ago by ${TEST_USER_NAME}`, + ); + }); + + it('renders no comments', () => { + expect(findNotes().classes('no-comments')).toBe(true); + }); + }); + + describe('with confidential issuable', () => { + beforeEach(() => { + issuable.confidential = true; + + factory({ issuable }); + }); + + it('renders the confidential icon', () => { + expect(findConfidentialIcon().exists()).toBe(true); + }); + }); + + describe('with task status', () => { + beforeEach(() => { + Object.assign(issuable, { + has_tasks: true, + task_status: TEST_TASK_STATUS, + }); + + factory({ issuable }); + }); + + it('renders task status', () => { + expect(findTaskStatus().exists()).toBe(true); + expect(findTaskStatus().text()).toBe(TEST_TASK_STATUS); + }); + }); + + describe.each` + desc | dueDate | expectedTooltipPart + ${'past due'} | ${TEST_MONTH_AGO} | ${'Past due'} + ${'future due'} | ${TEST_MONTH_LATER} | ${'1 month remaining'} + `('with milestone with $desc', ({ dueDate, expectedTooltipPart }) => { + beforeEach(() => { + issuable.milestone = { ...TEST_MILESTONE, due_date: dueDate }; + + factory({ issuable }); + }); + + it('renders milestone', () => { + expect(findMilestone().exists()).toBe(true); + expect( + findMilestone() + .find('.fa-clock-o') + .exists(), + ).toBe(true); + expect(findMilestone().text()).toEqual(TEST_MILESTONE.title); + }); + + it('renders tooltip', () => { + expect(findMilestoneTooltip()).toBe( + `${formatDate(dueDate, DATE_FORMAT)} (${expectedTooltipPart})`, + ); + }); + + it('renders milestone with the correct href', () => { + const { title } = issuable.milestone; + const expected = mergeUrlParams({ milestone_title: title }, TEST_BASE_URL); + + expect(findMilestone().attributes('href')).toBe(expected); + }); + }); + + describe.each` + dueDate | hasClass | desc + ${TEST_MONTH_LATER} | ${false} | ${'with future due date'} + ${TEST_MONTH_AGO} | ${true} | ${'with past due date'} + `('$desc', ({ dueDate, hasClass }) => { + beforeEach(() => { + issuable.due_date = dueDate; + + factory({ issuable }); + }); + + it('renders due date', () => { + expect(findDueDate().exists()).toBe(true); + expect(findDueDate().text()).toBe(formatDate(dueDate, DATE_FORMAT)); + }); + + it(hasClass ? 'has cred class' : 'does not have cred class', () => { + expect(findDueDate().classes('cred')).toEqual(hasClass); + }); + }); + + describe('with labels', () => { + beforeEach(() => { + issuable.labels = [...testLabels]; + + factory({ issuable }); + }); + + it('renders labels', () => { + factory({ issuable }); + + const labels = findLabelLinks().wrappers.map(label => ({ + href: label.attributes('href'), + text: label.text(), + tooltip: label.find('span').attributes('data-original-title'), + })); + + const expected = testLabels.map(label => ({ + href: mergeUrlParams({ 'label_name[]': label.name }, TEST_BASE_URL), + text: label.name, + tooltip: label.description, + })); + + expect(labels).toEqual(expected); + }); + }); + + describe.each` + weight + ${0} + ${10} + ${12345} + `('with weight $weight', ({ weight }) => { + beforeEach(() => { + issuable.weight = weight; + + factory({ issuable }); + }); + + it('renders weight', () => { + expect(findWeight().exists()).toBe(true); + expect(findWeight().text()).toEqual(weight.toString()); + }); + }); + + describe('with closed state', () => { + beforeEach(() => { + issuable.state = 'closed'; + + factory({ issuable }); + }); + + it('renders closed text', () => { + expect(wrapper.text()).toContain(TEXT_CLOSED); + }); + + it('has closed class', () => { + expect(wrapper.classes('closed')).toBe(true); + }); + }); + + describe('with assignees', () => { + beforeEach(() => { + issuable.assignees = testAssignees; + + factory({ issuable }); + }); + + it('renders assignees', () => { + expect(findAssignees().exists()).toBe(true); + expect(findAssignees().props('assignees')).toEqual(testAssignees); + }); + }); + + describe.each` + desc | key | finder + ${'with merge requests count'} | ${'merge_requests_count'} | ${findMergeRequestsCount} + ${'with upvote count'} | ${'upvotes'} | ${findUpvotes} + ${'with downvote count'} | ${'downvotes'} | ${findDownvotes} + ${'with notes count'} | ${'user_notes_count'} | ${findNotes} + `('$desc', ({ key, finder }) => { + beforeEach(() => { + issuable[key] = TEST_META_COUNT; + + factory({ issuable }); + }); + + it('renders merge requests count', () => { + expect(finder().exists()).toBe(true); + expect(finder().text()).toBe(TEST_META_COUNT.toString()); + expect(finder().classes('no-comments')).toBe(false); + }); + }); + + describe('with bulk editing', () => { + describe.each` + selected | desc + ${true} | ${'when selected'} + ${false} | ${'when unselected'} + `('$desc', ({ selected }) => { + beforeEach(() => { + factory({ isBulkEditing: true, selected }); + }); + + it(`renders checked is ${selected}`, () => { + expect(findBulkCheckbox().element.checked).toBe(selected); + }); + + it('emits select when clicked', () => { + expect(wrapper.emitted().select).toBeUndefined(); + + findBulkCheckbox().trigger('click'); + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..e598a9c5a5d4d79244ed257bbd33bbbb2b502987 --- /dev/null +++ b/spec/frontend/issuables_list/components/issuables_list_app_spec.js @@ -0,0 +1,410 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlEmptyState, GlPagination, GlSkeletonLoading } from '@gitlab/ui'; +import flash from '~/flash'; +import waitForPromises from 'helpers/wait_for_promises'; +import { TEST_HOST } from 'helpers/test_constants'; +import IssuablesListApp from '~/issuables_list/components/issuables_list_app.vue'; +import Issuable from '~/issuables_list/components/issuable.vue'; +import issueablesEventBus from '~/issuables_list/eventhub'; +import { PAGE_SIZE, PAGE_SIZE_MANUAL, RELATIVE_POSITION } from '~/issuables_list/constants'; + +jest.mock('~/flash', () => jest.fn()); +jest.mock('~/issuables_list/eventhub'); + +const TEST_LOCATION = `${TEST_HOST}/issues`; +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) => ({ + id: i, + web_url: `url${i}`, + })); + +describe('Issuables list component', () => { + let oldLocation; + let mockAxios; + let wrapper; + let apiSpy; + + const setupApiMock = cb => { + apiSpy = jest.fn(cb); + + mockAxios.onGet(TEST_ENDPOINT).reply(cfg => apiSpy(cfg)); + }; + + const factory = (props = { sortKey: 'priority' }) => { + wrapper = shallowMount(localVue.extend(IssuablesListApp), { + propsData: { + endpoint: TEST_ENDPOINT, + createIssuePath: TEST_CREATE_ISSUES_PATH, + emptySvgPath: TEST_EMPTY_SVG_PATH, + ...props, + }, + localVue, + sync: false, + }); + }; + + const findLoading = () => wrapper.find(GlSkeletonLoading); + const findIssuables = () => wrapper.findAll(Issuable); + const findFirstIssuable = () => findIssuables().wrappers[0]; + const findEmptyState = () => wrapper.find(GlEmptyState); + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + + oldLocation = window.location; + Object.defineProperty(window, 'location', { + writable: true, + value: { href: '', search: '' }, + }); + window.location.href = TEST_LOCATION; + }); + + afterEach(() => { + wrapper.destroy(); + mockAxios.restore(); + jest.clearAllMocks(); + window.location = oldLocation; + }); + + describe('with failed issues response', () => { + beforeEach(() => { + setupApiMock(() => [500]); + + factory(); + + return waitForPromises(); + }); + + it('does not show loading', () => { + expect(wrapper.vm.loading).toBe(false); + }); + + it('flashes an error', () => { + expect(flash).toHaveBeenCalledTimes(1); + }); + }); + + describe('with successful issues response', () => { + beforeEach(() => { + setupApiMock(() => [ + 200, + MOCK_ISSUES.slice(0, PAGE_SIZE), + { + 'x-total': 100, + 'x-page': 2, + }, + ]); + }); + + it('has default props and data', () => { + factory(); + expect(wrapper.vm).toMatchObject({ + // Props + canBulkEdit: false, + createIssuePath: TEST_CREATE_ISSUES_PATH, + emptySvgPath: TEST_EMPTY_SVG_PATH, + + // Data + filters: { + state: 'opened', + }, + isBulkEditing: false, + issuables: [], + loading: true, + page: 1, + selection: {}, + totalItems: 0, + }); + }); + + it('does not call API until mounted', () => { + expect(apiSpy).not.toHaveBeenCalled(); + }); + + describe('when mounted', () => { + beforeEach(() => { + factory(); + }); + + it('calls API', () => { + expect(apiSpy).toHaveBeenCalled(); + }); + + it('shows loading', () => { + expect(findLoading().exists()).toBe(true); + expect(findIssuables().length).toBe(0); + expect(findEmptyState().exists()).toBe(false); + }); + }); + + describe('when finished loading', () => { + beforeEach(() => { + factory(); + + return waitForPromises(); + }); + + it('does not display empty state', () => { + expect(wrapper.vm.issuables.length).toBeGreaterThan(0); + expect(wrapper.vm.emptyState).toEqual({}); + expect(wrapper.contains(GlEmptyState)).toBe(false); + }); + + it('sets the proper page and total items', () => { + expect(wrapper.vm.totalItems).toBe(100); + expect(wrapper.vm.page).toBe(2); + }); + + it('renders one page of issuables and pagination', () => { + expect(findIssuables().length).toBe(PAGE_SIZE); + expect(wrapper.find(GlPagination).exists()).toBe(true); + }); + }); + }); + + describe('with bulk editing enabled', () => { + beforeEach(() => { + issueablesEventBus.$on.mockReset(); + issueablesEventBus.$emit.mockReset(); + + setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); + factory({ canBulkEdit: true }); + + return waitForPromises(); + }); + + it('is not enabled by default', () => { + expect(wrapper.vm.isBulkEditing).toBe(false); + }); + + it('does not select issues by default', () => { + expect(wrapper.vm.selection).toEqual({}); + }); + + it('"Select All" checkbox toggles all visible issuables"', () => { + wrapper.vm.onSelectAll(); + expect(wrapper.vm.selection).toEqual( + wrapper.vm.issuables.reduce((acc, i) => ({ ...acc, [i.id]: true }), {}), + ); + + wrapper.vm.onSelectAll(); + expect(wrapper.vm.selection).toEqual({}); + }); + + it('"Select All checkbox" selects all issuables if only some are selected"', () => { + wrapper.vm.selection = { [wrapper.vm.issuables[0].id]: true }; + wrapper.vm.onSelectAll(); + expect(wrapper.vm.selection).toEqual( + wrapper.vm.issuables.reduce((acc, i) => ({ ...acc, [i.id]: true }), {}), + ); + }); + + it('selects and deselects issuables', () => { + const [i0, i1, i2] = wrapper.vm.issuables; + + expect(wrapper.vm.selection).toEqual({}); + wrapper.vm.onSelectIssuable({ issuable: i0, selected: false }); + expect(wrapper.vm.selection).toEqual({}); + wrapper.vm.onSelectIssuable({ issuable: i1, selected: true }); + expect(wrapper.vm.selection).toEqual({ '1': true }); + wrapper.vm.onSelectIssuable({ issuable: i0, selected: true }); + expect(wrapper.vm.selection).toEqual({ '1': true, '0': true }); + wrapper.vm.onSelectIssuable({ issuable: i2, selected: true }); + expect(wrapper.vm.selection).toEqual({ '1': true, '0': true, '2': true }); + wrapper.vm.onSelectIssuable({ issuable: i2, selected: true }); + expect(wrapper.vm.selection).toEqual({ '1': true, '0': true, '2': true }); + wrapper.vm.onSelectIssuable({ issuable: i0, selected: false }); + expect(wrapper.vm.selection).toEqual({ '1': true, '2': true }); + }); + + it('broadcasts a message to the bulk edit sidebar when a value is added to selection', () => { + issueablesEventBus.$emit.mockReset(); + const i1 = wrapper.vm.issuables[1]; + + wrapper.vm.onSelectIssuable({ issuable: i1, selected: true }); + + return wrapper.vm.$nextTick().then(() => { + expect(issueablesEventBus.$emit).toHaveBeenCalledTimes(1); + expect(issueablesEventBus.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit'); + }); + }); + + it('does not broadcast a message to the bulk edit sidebar when a value is not added to selection', () => { + issueablesEventBus.$emit.mockReset(); + + return wrapper.vm + .$nextTick() + .then(waitForPromises) + .then(() => { + const i1 = wrapper.vm.issuables[1]; + + wrapper.vm.onSelectIssuable({ issuable: i1, selected: false }); + }) + .then(wrapper.vm.$nextTick) + .then(() => { + expect(issueablesEventBus.$emit).toHaveBeenCalledTimes(0); + }); + }); + + it('listens to a message to toggle bulk editing', () => { + expect(wrapper.vm.isBulkEditing).toBe(false); + expect(issueablesEventBus.$on.mock.calls[0][0]).toBe('issuables:toggleBulkEdit'); + issueablesEventBus.$on.mock.calls[0][1](true); // Call the message handler + + return waitForPromises() + .then(() => { + expect(wrapper.vm.isBulkEditing).toBe(true); + issueablesEventBus.$on.mock.calls[0][1](false); + }) + .then(() => { + expect(wrapper.vm.isBulkEditing).toBe(false); + }); + }); + }); + + describe('with query params in window.location', () => { + const query = + '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&utf8=%E2%9C%93&weight=0'; + const expectedFilters = { + assignee_username: 'root', + author_username: 'root', + confidential: 'yes', + my_reaction_emoji: 'airplane', + scope: 'all', + state: 'opened', + utf8: '✓', + weight: '0', + milestone: 'v3.0', + labels: 'Aquapod,Astro', + order_by: 'milestone_due', + sort: 'desc', + }; + + beforeEach(() => { + window.location.href = `${TEST_LOCATION}${query}`; + window.location.search = query; + setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); + factory({ sortKey: 'milestone_due_desc' }); + return waitForPromises(); + }); + + it('applies filters and sorts', () => { + expect(wrapper.vm.hasFilters).toBe(true); + expect(wrapper.vm.filters).toEqual(expectedFilters); + + expect(apiSpy).toHaveBeenCalledWith( + expect.objectContaining({ + params: { + ...expectedFilters, + with_labels_details: true, + page: 1, + per_page: PAGE_SIZE, + }, + }), + ); + }); + + it('passes the base url to issuable', () => { + expect(findFirstIssuable().props('baseUrl')).toEqual(TEST_LOCATION); + }); + }); + + describe('with hash in window.location', () => { + beforeEach(() => { + window.location.href = `${TEST_LOCATION}#stuff`; + setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); + factory(); + return waitForPromises(); + }); + + it('passes the base url to issuable', () => { + expect(findFirstIssuable().props('baseUrl')).toEqual(TEST_LOCATION); + }); + }); + + describe('with manual sort', () => { + beforeEach(() => { + setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); + factory({ sortKey: RELATIVE_POSITION }); + }); + + it('uses manual page size', () => { + expect(apiSpy).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + per_page: PAGE_SIZE_MANUAL, + }), + }), + ); + }); + }); + + describe('with empty issues response', () => { + beforeEach(() => { + setupApiMock(() => [200, []]); + }); + + describe('with query in window location', () => { + beforeEach(() => { + window.location.search = '?weight=Any'; + + factory(); + + return waitForPromises().then(() => wrapper.vm.$nextTick()); + }); + + it('should display "Sorry, your filter produced no results" if filters are too specific', () => { + expect(findEmptyState().props('title')).toMatchSnapshot(); + }); + }); + + describe('with closed state', () => { + beforeEach(() => { + window.location.search = '?state=closed'; + + factory(); + + return waitForPromises().then(() => wrapper.vm.$nextTick()); + }); + + it('should display a message "There are no closed issues" if there are no closed issues', () => { + expect(findEmptyState().props('title')).toMatchSnapshot(); + }); + }); + + describe('with all state', () => { + beforeEach(() => { + window.location.search = '?state=all'; + + factory(); + + return waitForPromises().then(() => wrapper.vm.$nextTick()); + }); + + it('should display a catch-all if there are no issues to show', () => { + expect(findEmptyState().element).toMatchSnapshot(); + }); + }); + + describe('with empty query', () => { + beforeEach(() => { + factory(); + + return wrapper.vm.$nextTick().then(waitForPromises); + }); + + it('should display the message "There are no open issues"', () => { + expect(findEmptyState().props('title')).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/spec/frontend/issuables_list/issuable_list_test_data.js b/spec/frontend/issuables_list/issuable_list_test_data.js new file mode 100644 index 0000000000000000000000000000000000000000..617780fd7367d852494f2544161df39d416ff73e --- /dev/null +++ b/spec/frontend/issuables_list/issuable_list_test_data.js @@ -0,0 +1,72 @@ +export const simpleIssue = { + id: 442, + iid: 31, + title: 'Dismiss Cipher with no integrity', + state: 'opened', + created_at: '2019-08-26T19:06:32.667Z', + updated_at: '2019-08-28T19:53:58.314Z', + labels: [], + milestone: null, + assignees: [], + author: { + id: 3, + name: 'Elnora Bernhard', + username: 'treva.lesch', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/a8c0d9c2882406cf2a9b71494625a796?s=80&d=identicon', + web_url: 'http://localhost:3001/treva.lesch', + }, + assignee: null, + user_notes_count: 0, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: false, + web_url: 'http://localhost:3001/h5bp/html5-boilerplate/issues/31', + has_tasks: false, + weight: null, +}; + +export const testLabels = [ + { + id: 1, + name: 'Tanuki', + description: 'A cute animal', + color: '#ff0000', + text_color: '#ffffff', + }, + { + id: 2, + name: 'Octocat', + description: 'A grotesque mish-mash of whiskers and tentacles', + color: '#333333', + text_color: '#000000', + }, + { + id: 3, + name: 'scoped::label', + description: 'A scoped label', + color: '#00ff00', + text_color: '#ffffff', + }, +]; + +export const testAssignees = [ + { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://localhost:3001/root', + }, + { + id: 22, + name: 'User 0', + username: 'user0', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80&d=identicon', + web_url: 'http://localhost:3001/user0', + }, +]; diff --git a/spec/frontend/issue_show/helpers.js b/spec/frontend/issue_show/helpers.js new file mode 100644 index 0000000000000000000000000000000000000000..5d2ced98ae47dac28cdb1a853289a2e9e063b431 --- /dev/null +++ b/spec/frontend/issue_show/helpers.js @@ -0,0 +1,10 @@ +// eslint-disable-next-line import/prefer-default-export +export const keyboardDownEvent = (code, metaKey = false, ctrlKey = false) => { + const e = new CustomEvent('keydown'); + + e.keyCode = code; + e.metaKey = metaKey; + e.ctrlKey = ctrlKey; + + return e; +}; diff --git a/spec/frontend/jobs/components/log/log_spec.js b/spec/frontend/jobs/components/log/log_spec.js index cc334009982b791cfd3850d5d69857eb29a8eb8f..7c834542a9aa846ab889970bf23ca6c45a6c703d 100644 --- a/spec/frontend/jobs/components/log/log_spec.js +++ b/spec/frontend/jobs/components/log/log_spec.js @@ -60,8 +60,8 @@ describe('Job Log', () => { expect(wrapper.find('.collapsible-line').attributes('role')).toBe('button'); }); - it('renders an icon with the closed state', () => { - expect(wrapper.find('.collapsible-line svg').classes()).toContain('ic-angle-right'); + it('renders an icon with the open state', () => { + expect(wrapper.find('.collapsible-line svg').classes()).toContain('ic-angle-down'); }); describe('on click header section', () => { diff --git a/spec/frontend/jobs/store/utils_spec.js b/spec/frontend/jobs/store/utils_spec.js index 43dacfe622cf2a695e1991b767bdce540dc4dfc2..8819f39dee07cca256a2e26eea03f13d203436df 100644 --- a/spec/frontend/jobs/store/utils_spec.js +++ b/spec/frontend/jobs/store/utils_spec.js @@ -26,7 +26,7 @@ describe('Jobs Store Utils', () => { const parsedHeaderLine = parseHeaderLine(headerLine, 2); expect(parsedHeaderLine).toEqual({ - isClosed: true, + isClosed: false, isHeader: true, line: { ...headerLine, @@ -57,7 +57,7 @@ describe('Jobs Store Utils', () => { it('adds the section duration to the correct header', () => { const parsed = [ { - isClosed: true, + isClosed: false, isHeader: true, line: { section: 'prepare-script', @@ -66,7 +66,7 @@ describe('Jobs Store Utils', () => { lines: [], }, { - isClosed: true, + isClosed: false, isHeader: true, line: { section: 'foo-bar', @@ -85,7 +85,7 @@ describe('Jobs Store Utils', () => { it('does not add the section duration when the headers do not match', () => { const parsed = [ { - isClosed: true, + isClosed: false, isHeader: true, line: { section: 'bar-foo', @@ -94,7 +94,7 @@ describe('Jobs Store Utils', () => { lines: [], }, { - isClosed: true, + isClosed: false, isHeader: true, line: { section: 'foo-bar', @@ -183,7 +183,7 @@ describe('Jobs Store Utils', () => { describe('collpasible section', () => { it('adds a `isClosed` property', () => { - expect(result[1].isClosed).toEqual(true); + expect(result[1].isClosed).toEqual(false); }); it('adds a `isHeader` property', () => { @@ -213,7 +213,7 @@ describe('Jobs Store Utils', () => { const existingLog = [ { isHeader: true, - isClosed: true, + isClosed: false, line: { content: [{ text: 'bar' }], offset: 10, lineNumber: 1 }, }, ]; @@ -263,7 +263,7 @@ describe('Jobs Store Utils', () => { const existingLog = [ { isHeader: true, - isClosed: true, + isClosed: false, lines: [{ offset: 101, content: [{ text: 'foobar' }], lineNumber: 2 }], line: { offset: 10, @@ -435,7 +435,7 @@ describe('Jobs Store Utils', () => { expect(result).toEqual([ { - isClosed: true, + isClosed: false, isHeader: true, line: { offset: 1, @@ -461,7 +461,7 @@ describe('Jobs Store Utils', () => { expect(result).toEqual([ { - isClosed: true, + isClosed: false, isHeader: true, line: { offset: 1, diff --git a/spec/frontend/lib/utils/chart_utils_spec.js b/spec/frontend/lib/utils/chart_utils_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e811b8405fb680d6d6219c97189a45928df71557 --- /dev/null +++ b/spec/frontend/lib/utils/chart_utils_spec.js @@ -0,0 +1,11 @@ +import { firstAndLastY } from '~/lib/utils/chart_utils'; + +describe('Chart utils', () => { + describe('firstAndLastY', () => { + it('returns the first and last y-values of a given data set as an array', () => { + const data = [['', 1], ['', 2], ['', 3]]; + + expect(firstAndLastY(data)).toEqual([1, 3]); + }); + }); +}); diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index e2e7122932065a8d1f3dcc0fb5780dfd44251659..ee27789b6b9e44c730f287fa40a30b2b19919362 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -428,16 +428,57 @@ describe('newDate', () => { }); describe('getDateInPast', () => { - const date = new Date(1563235200000); // 2019-07-16T00:00:00.000Z; + const date = new Date('2019-07-16T00:00:00.000Z'); const daysInPast = 90; it('returns the correct date in the past', () => { const dateInPast = datetimeUtility.getDateInPast(date, daysInPast); - expect(dateInPast).toBe('2019-04-17T00:00:00.000Z'); + const expectedDateInPast = new Date('2019-04-17T00:00:00.000Z'); + + expect(dateInPast).toStrictEqual(expectedDateInPast); }); it('does not modifiy the original date', () => { datetimeUtility.getDateInPast(date, daysInPast); - expect(date).toStrictEqual(new Date(1563235200000)); + 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'); + const d2 = 90; + const range = datetimeUtility.getDatesInRange(d1, d2); + + expect(range).toEqual([]); + }); + + it('returns a range of dates between two given dates', () => { + const d1 = new Date('2019-01-01'); + const d2 = new Date('2019-01-31'); + + const range = datetimeUtility.getDatesInRange(d1, d2); + + expect(range.length).toEqual(31); + }); + + it('applies mapper function if provided fro each item in range', () => { + const d1 = new Date('2019-01-01'); + const d2 = new Date('2019-01-31'); + const formatter = date => date.getDate(); + + const range = datetimeUtility.getDatesInRange(d1, d2, formatter); + + range.forEach((formattedItem, index) => { + expect(formattedItem).toEqual(index + 1); + }); + }); +}); + +describe('secondsToMilliseconds', () => { + it('converts seconds to milliseconds correctly', () => { + expect(datetimeUtility.secondsToMilliseconds(0)).toBe(0); + expect(datetimeUtility.secondsToMilliseconds(60)).toBe(60000); + expect(datetimeUtility.secondsToMilliseconds(123)).toBe(123000); }); }); diff --git a/spec/frontend/lib/utils/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js index 381d7c6f8d9a4c5c36ebbc5d4217aad5f5d579ea..2f8f1092612a61005cd2dd7276929015487c7b5d 100644 --- a/spec/frontend/lib/utils/number_utility_spec.js +++ b/spec/frontend/lib/utils/number_utility_spec.js @@ -7,6 +7,8 @@ import { sum, isOdd, median, + changeInPercent, + formattedChangeInPercent, } from '~/lib/utils/number_utils'; describe('Number Utils', () => { @@ -122,4 +124,42 @@ describe('Number Utils', () => { expect(median(items)).toBe(14.5); }); }); + + describe('changeInPercent', () => { + it.each` + firstValue | secondValue | expectedOutput + ${99} | ${100} | ${1} + ${100} | ${99} | ${-1} + ${0} | ${99} | ${Infinity} + ${2} | ${2} | ${0} + ${-100} | ${-99} | ${1} + `( + 'computes the change between $firstValue and $secondValue in percent', + ({ firstValue, secondValue, expectedOutput }) => { + expect(changeInPercent(firstValue, secondValue)).toBe(expectedOutput); + }, + ); + }); + + describe('formattedChangeInPercent', () => { + it('prepends "%" to the output', () => { + expect(formattedChangeInPercent(1, 2)).toMatch(/%$/); + }); + + it('indicates if the change was a decrease', () => { + expect(formattedChangeInPercent(100, 99)).toContain('-1'); + }); + + it('indicates if the change was an increase', () => { + expect(formattedChangeInPercent(99, 100)).toContain('+1'); + }); + + it('shows "-" per default if the change can not be expressed in an integer', () => { + expect(formattedChangeInPercent(0, 1)).toBe('-'); + }); + + it('shows the given fallback if the change can not be expressed in an integer', () => { + expect(formattedChangeInPercent(0, 1, { nonFiniteResult: '*' })).toBe('*'); + }); + }); }); diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js index b6f1aef9ce4ebdafdf8f92a14932bb8e1c0a89cf..deb6dab772ecf3900d28671fcc4ffa8d1e166d90 100644 --- a/spec/frontend/lib/utils/text_utility_spec.js +++ b/spec/frontend/lib/utils/text_utility_spec.js @@ -90,6 +90,19 @@ describe('text_utility', () => { }); }); + describe('convertToSnakeCase', () => { + it.each` + txt | result + ${'snakeCase'} | ${'snake_case'} + ${'snake Case'} | ${'snake_case'} + ${'snake case'} | ${'snake_case'} + ${'snake_case'} | ${'snake_case'} + ${'snakeCasesnake Case'} | ${'snake_casesnake_case'} + `('converts string $txt to $result string', ({ txt, result }) => { + expect(textUtils.convertToSnakeCase(txt)).toEqual(result); + }); + }); + describe('convertToSentenceCase', () => { it('converts Sentence Case to Sentence case', () => { expect(textUtils.convertToSentenceCase('Hello World')).toBe('Hello world'); diff --git a/spec/javascripts/monitoring/charts/time_series_spec.js b/spec/frontend/monitoring/charts/time_series_spec.js similarity index 70% rename from spec/javascripts/monitoring/charts/time_series_spec.js rename to spec/frontend/monitoring/charts/time_series_spec.js index 5c718135b9038feba0ca5306f55611b34be17591..554535418fe26c7efaf7b4c6d599930d2f8f094a 100644 --- a/spec/javascripts/monitoring/charts/time_series_spec.js +++ b/spec/frontend/monitoring/charts/time_series_spec.js @@ -1,55 +1,77 @@ import { shallowMount } from '@vue/test-utils'; +import { setTestTimeout } from 'helpers/timeout'; import { createStore } from '~/monitoring/stores'; import { GlLink } from '@gitlab/ui'; import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; -import { shallowWrapperContainsSlotText } from 'spec/helpers/vue_test_utils_helper'; +import { shallowWrapperContainsSlotText } from 'helpers/vue_test_utils_helper'; import TimeSeries from '~/monitoring/components/charts/time_series.vue'; import * as types from '~/monitoring/stores/mutation_types'; -import { TEST_HOST } from 'spec/test_constants'; -import MonitoringMock, { deploymentData, mockProjectPath } from '../mock_data'; +import { + deploymentData, + metricsGroupsAPIResponse, + mockedQueryResultPayload, + mockProjectDir, + mockHost, +} from '../mock_data'; + +import * as iconUtils from '~/lib/utils/icon_utils'; + +const mockSvgPathContent = 'mockSvgPathContent'; +const mockWidgets = 'mockWidgets'; + +jest.mock('~/lib/utils/icon_utils', () => ({ + getSvgIconPathContent: jest.fn().mockImplementation( + () => + new Promise(resolve => { + resolve(mockSvgPathContent); + }), + ), +})); describe('Time series component', () => { - const mockSha = 'mockSha'; - const mockWidgets = 'mockWidgets'; - const mockSvgPathContent = 'mockSvgPathContent'; - const projectPath = `${TEST_HOST}${mockProjectPath}`; - const commitUrl = `${projectPath}/commit/${mockSha}`; let mockGraphData; let makeTimeSeriesChart; - let spriteSpy; let store; beforeEach(() => { + setTestTimeout(1000); + store = createStore(); - store.commit(`monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, MonitoringMock.data); + + store.commit( + `monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, + metricsGroupsAPIResponse, + ); + store.commit(`monitoringDashboard/${types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS}`, deploymentData); - [mockGraphData] = store.state.monitoringDashboard.groups[0].metrics; + + // Mock data contains 2 panels, pick the first one + store.commit(`monitoringDashboard/${types.SET_QUERY_RESULT}`, mockedQueryResultPayload); + + [mockGraphData] = store.state.monitoringDashboard.dashboard.panel_groups[0].metrics; makeTimeSeriesChart = (graphData, type) => shallowMount(TimeSeries, { propsData: { graphData: { ...graphData, type }, - containerWidth: 0, deploymentData: store.state.monitoringDashboard.deploymentData, - projectPath, + projectPath: `${mockHost}${mockProjectDir}`, }, slots: { default: mockWidgets, }, sync: false, store, + attachToDocument: true, }); - - spriteSpy = spyOnDependency(TimeSeries, 'getSvgIconPathContent').and.callFake( - () => new Promise(resolve => resolve(mockSvgPathContent)), - ); }); describe('general functions', () => { let timeSeriesChart; - beforeEach(() => { + beforeEach(done => { timeSeriesChart = makeTimeSeriesChart(mockGraphData, 'area-chart'); + timeSeriesChart.vm.$nextTick(done); }); it('renders chart title', () => { @@ -74,18 +96,24 @@ describe('Time series component', () => { describe('methods', () => { describe('formatTooltipText', () => { - const mockDate = deploymentData[0].created_at; - const mockCommitUrl = deploymentData[0].commitUrl; - const generateSeriesData = type => ({ - seriesData: [ - { - seriesName: timeSeriesChart.vm.chartData[0].name, - componentSubType: type, - value: [mockDate, 5.55555], - seriesIndex: 0, - }, - ], - value: mockDate, + let mockDate; + let mockCommitUrl; + let generateSeriesData; + + beforeEach(() => { + mockDate = deploymentData[0].created_at; + mockCommitUrl = deploymentData[0].commitUrl; + generateSeriesData = type => ({ + seriesData: [ + { + seriesName: timeSeriesChart.vm.chartData[0].name, + componentSubType: type, + value: [mockDate, 5.55555], + dataIndex: 0, + }, + ], + value: mockDate, + }); }); describe('when series is of line type', () => { @@ -95,17 +123,21 @@ describe('Time series component', () => { }); it('formats tooltip title', () => { - expect(timeSeriesChart.vm.tooltip.title).toBe('31 May 2017, 9:23PM'); + expect(timeSeriesChart.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM'); }); it('formats tooltip content', () => { - const name = 'Core Usage'; + const name = 'Pod average'; const value = '5.556'; + const dataIndex = 0; const seriesLabel = timeSeriesChart.find(GlChartSeriesLabel); expect(seriesLabel.vm.color).toBe(''); expect(shallowWrapperContainsSlotText(seriesLabel, 'default', name)).toBe(true); - expect(timeSeriesChart.vm.tooltip.content).toEqual([{ name, value, color: undefined }]); + expect(timeSeriesChart.vm.tooltip.content).toEqual([ + { name, value, dataIndex, color: undefined }, + ]); + expect( shallowWrapperContainsSlotText( timeSeriesChart.find(GlAreaChart), @@ -116,13 +148,13 @@ describe('Time series component', () => { }); }); - describe('when series is of scatter type', () => { + describe('when series is of scatter type, for deployments', () => { beforeEach(() => { timeSeriesChart.vm.formatTooltipText(generateSeriesData('scatter')); }); it('formats tooltip title', () => { - expect(timeSeriesChart.vm.tooltip.title).toBe('31 May 2017, 9:23PM'); + expect(timeSeriesChart.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM'); }); it('formats tooltip sha', () => { @@ -144,7 +176,7 @@ describe('Time series component', () => { }); it('gets svg path content', () => { - expect(spriteSpy).toHaveBeenCalledWith(mockSvgName); + expect(iconUtils.getSvgIconPathContent).toHaveBeenCalledWith(mockSvgName); }); it('sets svg path content', () => { @@ -168,7 +200,7 @@ describe('Time series component', () => { const mockWidth = 233; beforeEach(() => { - spyOn(Element.prototype, 'getBoundingClientRect').and.callFake(() => ({ + jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => ({ width: mockWidth, })); timeSeriesChart.vm.onResize(); @@ -212,6 +244,39 @@ describe('Time series component', () => { }); describe('chartOptions', () => { + describe('are extended by `option`', () => { + const mockSeriesName = 'Extra series 1'; + const mockOption = { + option1: 'option1', + option2: 'option2', + }; + + it('arbitrary options', () => { + timeSeriesChart.setProps({ + option: mockOption, + }); + + expect(timeSeriesChart.vm.chartOptions).toEqual(expect.objectContaining(mockOption)); + }); + + it('additional series', () => { + timeSeriesChart.setProps({ + option: { + series: [ + { + name: mockSeriesName, + }, + ], + }, + }); + + const optionSeries = timeSeriesChart.vm.chartOptions.series; + + expect(optionSeries.length).toEqual(2); + expect(optionSeries[0].name).toEqual(mockSeriesName); + }); + }); + describe('yAxis formatter', () => { let format; @@ -228,9 +293,9 @@ describe('Time series component', () => { describe('scatterSeries', () => { it('utilizes deployment data', () => { expect(timeSeriesChart.vm.scatterSeries.data).toEqual([ - ['2017-05-31T21:23:37.881Z', 0], - ['2017-05-30T20:08:04.629Z', 0], - ['2017-05-30T17:42:38.409Z', 0], + ['2019-07-16T10:14:25.589Z', 0], + ['2019-07-16T11:14:25.589Z', 0], + ['2019-07-16T12:14:25.589Z', 0], ]); expect(timeSeriesChart.vm.scatterSeries.symbolSize).toBe(14); @@ -239,7 +304,7 @@ describe('Time series component', () => { describe('yAxisLabel', () => { it('constructs a label for the chart y-axis', () => { - expect(timeSeriesChart.vm.yAxisLabel).toBe('CPU'); + expect(timeSeriesChart.vm.yAxisLabel).toBe('Memory Used per Pod'); }); }); }); @@ -272,6 +337,10 @@ describe('Time series component', () => { timeSeriesAreaChart.vm.$nextTick(done); }); + afterEach(() => { + timeSeriesAreaChart.destroy(); + }); + it('is a Vue instance', () => { expect(glChart.exists()).toBe(true); expect(glChart.isVueInstance()).toBe(true); @@ -297,6 +366,9 @@ describe('Time series component', () => { }); describe('when tooltip is showing deployment data', () => { + const mockSha = 'mockSha'; + const commitUrl = `${mockProjectDir}/commit/${mockSha}`; + beforeEach(done => { timeSeriesAreaChart.vm.tooltip.isDeployment = true; timeSeriesAreaChart.vm.$nextTick(done); diff --git a/spec/frontend/monitoring/components/charts/anomaly_spec.js b/spec/frontend/monitoring/components/charts/anomaly_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..6707d0b1fe83c09c5f2d868c6ea18eaf535b5eec --- /dev/null +++ b/spec/frontend/monitoring/components/charts/anomaly_spec.js @@ -0,0 +1,303 @@ +import Anomaly from '~/monitoring/components/charts/anomaly.vue'; + +import { shallowMount } from '@vue/test-utils'; +import { colorValues } from '~/monitoring/constants'; +import { + anomalyDeploymentData, + mockProjectDir, + anomalyMockGraphData, + anomalyMockResultValues, +} from '../../mock_data'; +import { TEST_HOST } from 'helpers/test_constants'; +import MonitorTimeSeriesChart from '~/monitoring/components/charts/time_series.vue'; + +const mockWidgets = 'mockWidgets'; +const mockProjectPath = `${TEST_HOST}${mockProjectDir}`; + +jest.mock('~/lib/utils/icon_utils'); // mock getSvgIconPathContent + +const makeAnomalyGraphData = (datasetName, template = anomalyMockGraphData) => { + const queries = anomalyMockResultValues[datasetName].map((values, index) => ({ + ...template.queries[index], + result: [ + { + metrics: {}, + values, + }, + ], + })); + return { ...template, queries }; +}; + +describe('Anomaly chart component', () => { + let wrapper; + + const setupAnomalyChart = props => { + wrapper = shallowMount(Anomaly, { + propsData: { ...props }, + slots: { + default: mockWidgets, + }, + sync: false, + }); + }; + const findTimeSeries = () => wrapper.find(MonitorTimeSeriesChart); + const getTimeSeriesProps = () => findTimeSeries().props(); + + describe('wrapped monitor-time-series-chart component', () => { + const dataSetName = 'noAnomaly'; + const dataSet = anomalyMockResultValues[dataSetName]; + const inputThresholds = ['some threshold']; + + beforeEach(() => { + setupAnomalyChart({ + graphData: makeAnomalyGraphData(dataSetName), + deploymentData: anomalyDeploymentData, + thresholds: inputThresholds, + projectPath: mockProjectPath, + }); + }); + + it('is a Vue instance', () => { + expect(findTimeSeries().exists()).toBe(true); + expect(findTimeSeries().isVueInstance()).toBe(true); + }); + + describe('receives props correctly', () => { + describe('graph-data', () => { + it('receives a single "metric" series', () => { + const { graphData } = getTimeSeriesProps(); + expect(graphData.queries.length).toBe(1); + }); + + it('receives "metric" with all data', () => { + const { graphData } = getTimeSeriesProps(); + const query = graphData.queries[0]; + const expectedQuery = makeAnomalyGraphData(dataSetName).queries[0]; + expect(query).toEqual(expectedQuery); + }); + + it('receives the "metric" results', () => { + const { graphData } = getTimeSeriesProps(); + const { result } = graphData.queries[0]; + const { values } = result[0]; + const [metricDataset] = dataSet; + expect(values).toEqual(expect.any(Array)); + + values.forEach(([, y], index) => { + expect(y).toBeCloseTo(metricDataset[index][1]); + }); + }); + }); + + describe('option', () => { + let option; + let series; + + beforeEach(() => { + ({ option } = getTimeSeriesProps()); + ({ series } = option); + }); + + it('contains a boundary band', () => { + expect(series).toEqual(expect.any(Array)); + expect(series.length).toEqual(2); // 1 upper + 1 lower boundaries + expect(series[0].stack).toEqual(series[1].stack); + + series.forEach(s => { + expect(s.type).toBe('line'); + expect(s.lineStyle.width).toBe(0); + expect(s.lineStyle.color).toMatch(/rgba\(.+\)/); + expect(s.lineStyle.color).toMatch(s.color); + expect(s.symbol).toEqual('none'); + }); + }); + + it('upper boundary values are stacked on top of lower boundary', () => { + const [lowerSeries, upperSeries] = series; + const [, upperDataset, lowerDataset] = dataSet; + + lowerSeries.data.forEach(([, y], i) => { + expect(y).toBeCloseTo(lowerDataset[i][1]); + }); + + upperSeries.data.forEach(([, y], i) => { + expect(y).toBeCloseTo(upperDataset[i][1] - lowerDataset[i][1]); + }); + }); + }); + + describe('series-config', () => { + let seriesConfig; + + beforeEach(() => { + ({ seriesConfig } = getTimeSeriesProps()); + }); + + it('display symbols is enabled', () => { + expect(seriesConfig).toEqual( + expect.objectContaining({ + type: 'line', + symbol: 'circle', + showSymbol: true, + symbolSize: expect.any(Function), + itemStyle: { + color: expect.any(Function), + }, + }), + ); + }); + it('does not display anomalies', () => { + const { symbolSize, itemStyle } = seriesConfig; + const [metricDataset] = dataSet; + + metricDataset.forEach((v, dataIndex) => { + const size = symbolSize(null, { dataIndex }); + const color = itemStyle.color({ dataIndex }); + + // normal color and small size + expect(size).toBeCloseTo(0); + expect(color).toBe(colorValues.primaryColor); + }); + }); + + it('can format y values (to use in tooltips)', () => { + expect(parseFloat(wrapper.vm.yValueFormatted(0, 0))).toEqual(dataSet[0][0][1]); + expect(parseFloat(wrapper.vm.yValueFormatted(1, 0))).toEqual(dataSet[1][0][1]); + expect(parseFloat(wrapper.vm.yValueFormatted(2, 0))).toEqual(dataSet[2][0][1]); + }); + }); + + describe('inherited properties', () => { + it('"deployment-data" keeps the same value', () => { + const { deploymentData } = getTimeSeriesProps(); + expect(deploymentData).toEqual(anomalyDeploymentData); + }); + it('"thresholds" keeps the same value', () => { + const { thresholds } = getTimeSeriesProps(); + expect(thresholds).toEqual(inputThresholds); + }); + it('"projectPath" keeps the same value', () => { + const { projectPath } = getTimeSeriesProps(); + expect(projectPath).toEqual(mockProjectPath); + }); + }); + }); + }); + + describe('with no boundary data', () => { + const dataSetName = 'noBoundary'; + const dataSet = anomalyMockResultValues[dataSetName]; + + beforeEach(() => { + setupAnomalyChart({ + graphData: makeAnomalyGraphData(dataSetName), + deploymentData: anomalyDeploymentData, + }); + }); + + describe('option', () => { + let option; + let series; + + beforeEach(() => { + ({ option } = getTimeSeriesProps()); + ({ series } = option); + }); + + it('does not display a boundary band', () => { + expect(series).toEqual(expect.any(Array)); + expect(series.length).toEqual(0); // no boundaries + }); + + it('can format y values (to use in tooltips)', () => { + expect(parseFloat(wrapper.vm.yValueFormatted(0, 0))).toEqual(dataSet[0][0][1]); + expect(wrapper.vm.yValueFormatted(1, 0)).toBe(''); // missing boundary + expect(wrapper.vm.yValueFormatted(2, 0)).toBe(''); // missing boundary + }); + }); + }); + + describe('with one anomaly', () => { + const dataSetName = 'oneAnomaly'; + const dataSet = anomalyMockResultValues[dataSetName]; + + beforeEach(() => { + setupAnomalyChart({ + graphData: makeAnomalyGraphData(dataSetName), + deploymentData: anomalyDeploymentData, + }); + }); + + describe('series-config', () => { + it('displays one anomaly', () => { + const { seriesConfig } = getTimeSeriesProps(); + const { symbolSize, itemStyle } = seriesConfig; + const [metricDataset] = dataSet; + + const bigDots = metricDataset.filter((v, dataIndex) => { + const size = symbolSize(null, { dataIndex }); + return size > 0.1; + }); + const redDots = metricDataset.filter((v, dataIndex) => { + const color = itemStyle.color({ dataIndex }); + return color === colorValues.anomalySymbol; + }); + + expect(bigDots.length).toBe(1); + expect(redDots.length).toBe(1); + }); + }); + }); + + describe('with offset', () => { + const dataSetName = 'negativeBoundary'; + const dataSet = anomalyMockResultValues[dataSetName]; + const expectedOffset = 4; // Lowst point in mock data is -3.70, it gets rounded + + beforeEach(() => { + setupAnomalyChart({ + graphData: makeAnomalyGraphData(dataSetName), + deploymentData: anomalyDeploymentData, + }); + }); + + describe('receives props correctly', () => { + describe('graph-data', () => { + it('receives a single "metric" series', () => { + const { graphData } = getTimeSeriesProps(); + expect(graphData.queries.length).toBe(1); + }); + + it('receives "metric" results and applies the offset to them', () => { + const { graphData } = getTimeSeriesProps(); + const { result } = graphData.queries[0]; + const { values } = result[0]; + const [metricDataset] = dataSet; + expect(values).toEqual(expect.any(Array)); + + values.forEach(([, y], index) => { + expect(y).toBeCloseTo(metricDataset[index][1] + expectedOffset); + }); + }); + }); + }); + + describe('option', () => { + it('upper boundary values are stacked on top of lower boundary, plus the offset', () => { + const { option } = getTimeSeriesProps(); + const { series } = option; + const [lowerSeries, upperSeries] = series; + const [, upperDataset, lowerDataset] = dataSet; + + lowerSeries.data.forEach(([, y], i) => { + expect(y).toBeCloseTo(lowerDataset[i][1] + expectedOffset); + }); + + upperSeries.data.forEach(([, y], i) => { + expect(y).toBeCloseTo(upperDataset[i][1] - lowerDataset[i][1]); + }); + }); + }); + }); +}); 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 be54443567163f64bac10ff7be3d0cb68d612ea3..ca05461c8cf34b87b0d7a85484aee726988047a7 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 @@ -51,6 +51,16 @@ describe('DateTimePicker', () => { }); }); + it('renders dropdown without a selectedTimeWindow set', done => { + createComponent({ + selectedTimeWindow: {}, + }); + dateTimePicker.vm.$nextTick(() => { + expect(dateTimePicker.findAll('input').length).toBe(2); + done(); + }); + }); + it('renders inputs with h/m/s truncated if its all 0s', done => { createComponent({ selectedTimeWindow: { diff --git a/spec/frontend/monitoring/embed/embed_spec.js b/spec/frontend/monitoring/embed/embed_spec.js index 5de1a7c4c3b93c94b8393d837a6a03b4e4956f84..3e22b0858e65e407e9bd20428b291397f259ea7e 100644 --- a/spec/frontend/monitoring/embed/embed_spec.js +++ b/spec/frontend/monitoring/embed/embed_spec.js @@ -61,8 +61,8 @@ describe('Embed', () => { describe('metrics are available', () => { beforeEach(() => { - store.state.monitoringDashboard.groups = groups; - store.state.monitoringDashboard.groups[0].metrics = metricsData; + store.state.monitoringDashboard.dashboard.panel_groups = groups; + store.state.monitoringDashboard.dashboard.panel_groups[0].metrics = metricsData; store.state.monitoringDashboard.metricsWithData = metricsWithData; mountComponent(); diff --git a/spec/frontend/monitoring/embed/mock_data.js b/spec/frontend/monitoring/embed/mock_data.js index df4acb82e95cbbae9c4ac90d9c81255ec48bab64..1685021fd4b6919c000fec607835714190176464 100644 --- a/spec/frontend/monitoring/embed/mock_data.js +++ b/spec/frontend/monitoring/embed/mock_data.js @@ -81,7 +81,9 @@ export const metricsData = [ export const initialState = { monitoringDashboard: {}, - groups: [], + dashboard: { + panel_groups: [], + }, metricsWithData: [], useDashboardEndpoint: true, }; diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js new file mode 100644 index 0000000000000000000000000000000000000000..c42366ab4843cc3a3235418a381d94a2e668ed65 --- /dev/null +++ b/spec/frontend/monitoring/mock_data.js @@ -0,0 +1,465 @@ +export const mockHost = 'http://test.host'; +export const mockProjectDir = '/frontend-fixtures/environments-project'; + +export const anomalyDeploymentData = [ + { + id: 111, + iid: 3, + sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', + ref: { + name: 'master', + }, + created_at: '2019-08-19T22:00:00.000Z', + deployed_at: '2019-08-19T22:01:00.000Z', + tag: false, + 'last?': true, + }, + { + id: 110, + iid: 2, + sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', + ref: { + name: 'master', + }, + created_at: '2019-08-19T23:00:00.000Z', + deployed_at: '2019-08-19T23:00:00.000Z', + tag: false, + 'last?': false, + }, +]; + +export const anomalyMockResultValues = { + noAnomaly: [ + [ + ['2019-08-19T19:00:00.000Z', 1.25], + ['2019-08-19T20:00:00.000Z', 1.45], + ['2019-08-19T21:00:00.000Z', 1.55], + ['2019-08-19T22:00:00.000Z', 1.48], + ], + [ + // upper boundary + ['2019-08-19T19:00:00.000Z', 2], + ['2019-08-19T20:00:00.000Z', 2.55], + ['2019-08-19T21:00:00.000Z', 2.65], + ['2019-08-19T22:00:00.000Z', 3.0], + ], + [ + // lower boundary + ['2019-08-19T19:00:00.000Z', 0.45], + ['2019-08-19T20:00:00.000Z', 0.65], + ['2019-08-19T21:00:00.000Z', 0.7], + ['2019-08-19T22:00:00.000Z', 0.8], + ], + ], + noBoundary: [ + [ + ['2019-08-19T19:00:00.000Z', 1.25], + ['2019-08-19T20:00:00.000Z', 1.45], + ['2019-08-19T21:00:00.000Z', 1.55], + ['2019-08-19T22:00:00.000Z', 1.48], + ], + [ + // empty upper boundary + ], + [ + // empty lower boundary + ], + ], + oneAnomaly: [ + [ + ['2019-08-19T19:00:00.000Z', 1.25], + ['2019-08-19T20:00:00.000Z', 3.45], // anomaly + ['2019-08-19T21:00:00.000Z', 1.55], + ], + [ + // upper boundary + ['2019-08-19T19:00:00.000Z', 2], + ['2019-08-19T20:00:00.000Z', 2.55], + ['2019-08-19T21:00:00.000Z', 2.65], + ], + [ + // lower boundary + ['2019-08-19T19:00:00.000Z', 0.45], + ['2019-08-19T20:00:00.000Z', 0.65], + ['2019-08-19T21:00:00.000Z', 0.7], + ], + ], + negativeBoundary: [ + [ + ['2019-08-19T19:00:00.000Z', 1.25], + ['2019-08-19T20:00:00.000Z', 3.45], // anomaly + ['2019-08-19T21:00:00.000Z', 1.55], + ], + [ + // upper boundary + ['2019-08-19T19:00:00.000Z', 2], + ['2019-08-19T20:00:00.000Z', 2.55], + ['2019-08-19T21:00:00.000Z', 2.65], + ], + [ + // lower boundary + ['2019-08-19T19:00:00.000Z', -1.25], + ['2019-08-19T20:00:00.000Z', -2.65], + ['2019-08-19T21:00:00.000Z', -3.7], // lowest point + ], + ], +}; + +export const anomalyMockGraphData = { + title: 'Requests Per Second Mock Data', + type: 'anomaly-chart', + weight: 3, + metrics: [ + // Not used + ], + queries: [ + { + metricId: '90', + id: 'metric', + query_range: 'MOCK_PROMETHEUS_METRIC_QUERY_RANGE', + unit: 'RPS', + label: 'Metrics RPS', + metric_id: 90, + prometheus_endpoint_path: 'MOCK_METRIC_PEP', + result: [ + { + metric: {}, + values: [['2019-08-19T19:00:00.000Z', 0]], + }, + ], + }, + { + metricId: '91', + id: 'upper', + query_range: '...', + unit: 'RPS', + label: 'Upper Limit Metrics RPS', + metric_id: 91, + prometheus_endpoint_path: 'MOCK_UPPER_PEP', + result: [ + { + metric: {}, + values: [['2019-08-19T19:00:00.000Z', 0]], + }, + ], + }, + { + metricId: '92', + id: 'lower', + query_range: '...', + unit: 'RPS', + label: 'Lower Limit Metrics RPS', + metric_id: 92, + prometheus_endpoint_path: 'MOCK_LOWER_PEP', + result: [ + { + metric: {}, + values: [['2019-08-19T19:00:00.000Z', 0]], + }, + ], + }, + ], +}; + +export const deploymentData = [ + { + id: 111, + iid: 3, + sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', + commitUrl: + 'http://test.host/frontend-fixtures/environments-project/commit/f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', + ref: { + name: 'master', + }, + created_at: '2019-07-16T10:14:25.589Z', + tag: false, + tagUrl: 'http://test.host/frontend-fixtures/environments-project/tags/false', + 'last?': true, + }, + { + id: 110, + iid: 2, + sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', + commitUrl: + 'http://test.host/frontend-fixtures/environments-project/commit/f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', + ref: { + name: 'master', + }, + created_at: '2019-07-16T11:14:25.589Z', + tag: false, + tagUrl: 'http://test.host/frontend-fixtures/environments-project/tags/false', + 'last?': false, + }, + { + id: 109, + iid: 1, + sha: '6511e58faafaa7ad2228990ec57f19d66f7db7c2', + commitUrl: + 'http://test.host/frontend-fixtures/environments-project/commit/6511e58faafaa7ad2228990ec57f19d66f7db7c2', + ref: { + name: 'update2-readme', + }, + created_at: '2019-07-16T12:14:25.589Z', + tag: false, + tagUrl: 'http://test.host/frontend-fixtures/environments-project/tags/false', + 'last?': false, + }, +]; + +export const metricsNewGroupsAPIResponse = [ + { + 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 mockedQueryResultPayload = { + metricId: '17_system_metrics_kubernetes_container_memory_average', + result: [ + { + metric: {}, + values: [ + [1563272065.589, '10.396484375'], + [1563272125.589, '10.333984375'], + [1563272185.589, '10.333984375'], + [1563272245.589, '10.333984375'], + [1563272305.589, '10.333984375'], + [1563272365.589, '10.333984375'], + [1563272425.589, '10.38671875'], + [1563272485.589, '10.333984375'], + [1563272545.589, '10.333984375'], + [1563272605.589, '10.333984375'], + [1563272665.589, '10.333984375'], + [1563272725.589, '10.333984375'], + [1563272785.589, '10.396484375'], + [1563272845.589, '10.333984375'], + [1563272905.589, '10.333984375'], + [1563272965.589, '10.3984375'], + [1563273025.589, '10.337890625'], + [1563273085.589, '10.34765625'], + [1563273145.589, '10.337890625'], + [1563273205.589, '10.337890625'], + [1563273265.589, '10.337890625'], + [1563273325.589, '10.337890625'], + [1563273385.589, '10.337890625'], + [1563273445.589, '10.337890625'], + [1563273505.589, '10.337890625'], + [1563273565.589, '10.337890625'], + [1563273625.589, '10.337890625'], + [1563273685.589, '10.337890625'], + [1563273745.589, '10.337890625'], + [1563273805.589, '10.337890625'], + [1563273865.589, '10.390625'], + [1563273925.589, '10.390625'], + ], + }, + ], +}; + +export const metricsGroupsAPIResponse = [ + { + 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, + }, + ], + }, + ], + }, +]; + +export const environmentData = [ + { + id: 34, + name: 'production', + state: 'available', + external_url: 'http://root-autodevops-deploy.my-fake-domain.com', + environment_type: null, + stop_action: false, + metrics_path: '/root/hello-prometheus/environments/34/metrics', + environment_path: '/root/hello-prometheus/environments/34', + stop_path: '/root/hello-prometheus/environments/34/stop', + terminal_path: '/root/hello-prometheus/environments/34/terminal', + folder_path: '/root/hello-prometheus/environments/folders/production', + created_at: '2018-06-29T16:53:38.301Z', + updated_at: '2018-06-29T16:57:09.825Z', + last_deployment: { + id: 127, + }, + }, + { + id: 35, + name: 'review/noop-branch', + state: 'available', + external_url: 'http://root-autodevops-deploy-review-noop-branc-die93w.my-fake-domain.com', + environment_type: 'review', + stop_action: true, + metrics_path: '/root/hello-prometheus/environments/35/metrics', + environment_path: '/root/hello-prometheus/environments/35', + stop_path: '/root/hello-prometheus/environments/35/stop', + terminal_path: '/root/hello-prometheus/environments/35/terminal', + folder_path: '/root/hello-prometheus/environments/folders/review', + created_at: '2018-07-03T18:39:41.702Z', + updated_at: '2018-07-03T18:44:54.010Z', + last_deployment: { + id: 128, + }, + }, + { + id: 36, + name: 'no-deployment/noop-branch', + state: 'available', + created_at: '2018-07-04T18:39:41.702Z', + updated_at: '2018-07-04T18:44:54.010Z', + }, +]; + +export const metricsDashboardResponse = { + dashboard: { + dashboard: 'Environment metrics', + priority: 1, + panel_groups: [ + { + group: 'System metrics (Kubernetes)', + priority: 5, + panels: [ + { + title: 'Memory Usage (Total)', + type: 'area-chart', + y_label: 'Total Memory Used', + weight: 4, + metrics: [ + { + id: 'system_metrics_kubernetes_container_memory_total', + query_range: + 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) /1024/1024/1024', + label: 'Total', + unit: 'GB', + metric_id: 12, + prometheus_endpoint_path: 'http://test', + }, + ], + }, + { + 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: 'Memory Usage (Pod average)', + type: 'line-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}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024', + label: 'Pod average', + unit: 'MB', + metric_id: 14, + }, + ], + }, + ], + }, + ], + }, + status: 'success', +}; + +export const dashboardGitResponse = [ + { + default: true, + display_name: 'Default', + can_edit: false, + project_blob_path: null, + path: 'config/prometheus/common_metrics.yml', + }, + { + default: false, + display_name: 'Custom Dashboard 1', + can_edit: true, + project_blob_path: `${mockProjectDir}/blob/master/dashboards/.gitlab/dashboards/dashboard_1.yml`, + path: '.gitlab/dashboards/dashboard_1.yml', + }, + { + default: false, + display_name: 'Custom Dashboard 2', + can_edit: true, + 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 new file mode 100644 index 0000000000000000000000000000000000000000..54a63e7f61fbd24b329812b8585d434eeb50fb1f --- /dev/null +++ b/spec/frontend/monitoring/panel_type_spec.js @@ -0,0 +1,166 @@ +import { shallowMount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import { setTestTimeout } from 'helpers/timeout'; +import axios from '~/lib/utils/axios_utils'; +import PanelType from '~/monitoring/components/panel_type.vue'; +import EmptyChart from '~/monitoring/components/charts/empty_chart.vue'; +import TimeSeriesChart from '~/monitoring/components/charts/time_series.vue'; +import AnomalyChart from '~/monitoring/components/charts/anomaly.vue'; +import { graphDataPrometheusQueryRange } from '../../javascripts/monitoring/mock_data'; +import { anomalyMockGraphData } from '../../frontend/monitoring/mock_data'; +import { createStore } from '~/monitoring/stores'; + +global.IS_EE = true; +global.URL.createObjectURL = jest.fn(); + +describe('Panel Type component', () => { + let axiosMock; + let store; + let panelType; + const dashboardWidth = 100; + const exampleText = 'example_text'; + + beforeEach(() => { + setTestTimeout(1000); + axiosMock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + axiosMock.reset(); + }); + + describe('When no graphData is available', () => { + let glEmptyChart; + // Deep clone object before modifying + const graphDataNoResult = JSON.parse(JSON.stringify(graphDataPrometheusQueryRange)); + graphDataNoResult.queries[0].result = []; + + beforeEach(() => { + panelType = shallowMount(PanelType, { + propsData: { + clipboardText: 'dashboard_link', + dashboardWidth, + graphData: graphDataNoResult, + }, + sync: false, + attachToDocument: true, + }); + }); + + afterEach(() => { + panelType.destroy(); + }); + + describe('Empty Chart component', () => { + beforeEach(() => { + glEmptyChart = panelType.find(EmptyChart); + }); + + it('is a Vue instance', () => { + expect(glEmptyChart.isVueInstance()).toBe(true); + }); + + it('it receives a graph title', () => { + const props = glEmptyChart.props(); + + expect(props.graphTitle).toBe(panelType.vm.graphData.title); + }); + }); + }); + + describe('when Graph data is available', () => { + const propsData = { + clipboardText: exampleText, + dashboardWidth, + graphData: graphDataPrometheusQueryRange, + }; + + beforeEach(done => { + store = createStore(); + panelType = shallowMount(PanelType, { + propsData, + store, + sync: false, + attachToDocument: true, + }); + panelType.vm.$nextTick(done); + }); + + afterEach(() => { + panelType.destroy(); + }); + + describe('Time Series Chart panel type', () => { + it('is rendered', () => { + expect(panelType.find(TimeSeriesChart).isVueInstance()).toBe(true); + expect(panelType.find(TimeSeriesChart).exists()).toBe(true); + }); + + it('sets clipboard text on the dropdown', () => { + const link = () => panelType.find('.js-chart-link'); + const clipboardText = () => link().element.dataset.clipboardText; + + expect(clipboardText()).toBe(exampleText); + }); + }); + + describe('Anomaly Chart panel type', () => { + beforeEach(done => { + panelType.setProps({ + graphData: anomalyMockGraphData, + }); + panelType.vm.$nextTick(done); + }); + + it('is rendered with an anomaly chart', () => { + expect(panelType.find(AnomalyChart).isVueInstance()).toBe(true); + expect(panelType.find(AnomalyChart).exists()).toBe(true); + }); + }); + }); + + describe('when downloading metrics data as CSV', () => { + beforeEach(done => { + graphDataPrometheusQueryRange.y_label = 'metric'; + store = createStore(); + panelType = shallowMount(PanelType, { + propsData: { + clipboardText: exampleText, + dashboardWidth, + graphData: graphDataPrometheusQueryRange, + }, + store, + sync: false, + attachToDocument: true, + }); + panelType.vm.$nextTick(done); + }); + + afterEach(() => { + panelType.destroy(); + }); + + describe('csvText', () => { + it('converts metrics data from json to csv', () => { + const header = `timestamp,${graphDataPrometheusQueryRange.y_label}`; + const data = graphDataPrometheusQueryRange.queries[0].result[0].values; + const firstRow = `${data[0][0]},${data[0][1]}`; + const secondRow = `${data[1][0]},${data[1][1]}`; + + expect(panelType.vm.csvText).toBe(`${header}\r\n${firstRow}\r\n${secondRow}\r\n`); + }); + }); + + describe('downloadCsv', () => { + it('produces a link with a Blob', () => { + expect(global.URL.createObjectURL).toHaveBeenLastCalledWith(expect.any(Blob)); + expect(global.URL.createObjectURL).toHaveBeenLastCalledWith( + expect.objectContaining({ + size: panelType.vm.csvText.length, + type: 'text/plain', + }), + ); + }); + }); + }); +}); diff --git a/spec/javascripts/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js similarity index 65% rename from spec/javascripts/monitoring/store/actions_spec.js rename to spec/frontend/monitoring/store/actions_spec.js index 1bd74f592825ac679d2af631c1311b69c569ac35..d4bc613ffea9936dcb0d593f759a905e63d4f888 100644 --- a/spec/javascripts/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -1,8 +1,14 @@ -import axios from '~/lib/utils/axios_utils'; import MockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'helpers/test_constants'; +import testAction from 'helpers/vuex_action_helper'; +import axios from '~/lib/utils/axios_utils'; +import statusCodes from '~/lib/utils/http_status'; +import { backOff } from '~/lib/utils/common_utils'; + import store from '~/monitoring/stores'; import * as types from '~/monitoring/stores/mutation_types'; import { + backOffRequest, fetchDashboard, receiveMetricsDashboardSuccess, receiveMetricsDashboardFailure, @@ -15,8 +21,6 @@ import { setGettingStartedEmptyState, } from '~/monitoring/stores/actions'; import storeState from '~/monitoring/stores/state'; -import testAction from 'spec/helpers/vuex_action_helper'; -import { resetStore } from '../helpers'; import { deploymentData, environmentData, @@ -25,55 +29,108 @@ import { dashboardGitResponse, } from '../mock_data'; -describe('Monitoring store actions', () => { +jest.mock('~/lib/utils/common_utils'); + +const resetStore = str => { + str.replaceState({ + showEmptyState: true, + emptyState: 'loading', + groups: [], + }); +}; + +const MAX_REQUESTS = 3; + +describe('Monitoring store helpers', () => { let mock; + // Mock underlying `backOff` function to remove in-built delay. + backOff.mockImplementation( + callback => + new Promise((resolve, reject) => { + const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg)); + const next = () => callback(next, stop); + callback(next, stop); + }), + ); + beforeEach(() => { mock = new MockAdapter(axios); }); afterEach(() => { - resetStore(store); mock.restore(); }); + describe('backOffRequest', () => { + it('returns immediately when recieving a 200 status code', () => { + mock.onGet(TEST_HOST).reply(200); + + return backOffRequest(() => axios.get(TEST_HOST)).then(() => { + expect(mock.history.get.length).toBe(1); + }); + }); + + it(`repeats the network call ${MAX_REQUESTS} times when receiving a 204 response`, done => { + mock.onGet(TEST_HOST).reply(statusCodes.NO_CONTENT, {}); + + backOffRequest(() => axios.get(TEST_HOST)) + .then(done.fail) + .catch(() => { + expect(mock.history.get.length).toBe(MAX_REQUESTS); + done(); + }); + }); + }); +}); + +describe('Monitoring store actions', () => { + let mock; + beforeEach(() => { + mock = new MockAdapter(axios); + }); + afterEach(() => { + resetStore(store); + mock.restore(); + }); describe('requestMetricsData', () => { it('sets emptyState to loading', () => { - const commit = jasmine.createSpy(); + const commit = jest.fn(); const { state } = store; - - requestMetricsData({ state, commit }); - + requestMetricsData({ + state, + commit, + }); expect(commit).toHaveBeenCalledWith(types.REQUEST_METRICS_DATA); }); }); - describe('fetchDeploymentsData', () => { it('commits RECEIVE_DEPLOYMENTS_DATA_SUCCESS on error', done => { - const dispatch = jasmine.createSpy(); + const dispatch = jest.fn(); const { state } = store; state.deploymentsEndpoint = '/success'; - mock.onGet(state.deploymentsEndpoint).reply(200, { deployments: deploymentData, }); - - fetchDeploymentsData({ state, dispatch }) + fetchDeploymentsData({ + state, + dispatch, + }) .then(() => { expect(dispatch).toHaveBeenCalledWith('receiveDeploymentsDataSuccess', deploymentData); done(); }) .catch(done.fail); }); - it('commits RECEIVE_DEPLOYMENTS_DATA_FAILURE on error', done => { - const dispatch = jasmine.createSpy(); + const dispatch = jest.fn(); const { state } = store; state.deploymentsEndpoint = '/error'; - mock.onGet(state.deploymentsEndpoint).reply(500); - - fetchDeploymentsData({ state, dispatch }) + fetchDeploymentsData({ + state, + dispatch, + }) .then(() => { expect(dispatch).toHaveBeenCalledWith('receiveDeploymentsDataFailure'); done(); @@ -81,33 +138,33 @@ describe('Monitoring store actions', () => { .catch(done.fail); }); }); - describe('fetchEnvironmentsData', () => { it('commits RECEIVE_ENVIRONMENTS_DATA_SUCCESS on error', done => { - const dispatch = jasmine.createSpy(); + const dispatch = jest.fn(); const { state } = store; state.environmentsEndpoint = '/success'; - mock.onGet(state.environmentsEndpoint).reply(200, { environments: environmentData, }); - - fetchEnvironmentsData({ state, dispatch }) + fetchEnvironmentsData({ + state, + dispatch, + }) .then(() => { expect(dispatch).toHaveBeenCalledWith('receiveEnvironmentsDataSuccess', environmentData); done(); }) .catch(done.fail); }); - it('commits RECEIVE_ENVIRONMENTS_DATA_FAILURE on error', done => { - const dispatch = jasmine.createSpy(); + const dispatch = jest.fn(); const { state } = store; state.environmentsEndpoint = '/error'; - mock.onGet(state.environmentsEndpoint).reply(500); - - fetchEnvironmentsData({ state, dispatch }) + fetchEnvironmentsData({ + state, + dispatch, + }) .then(() => { expect(dispatch).toHaveBeenCalledWith('receiveEnvironmentsDataFailure'); done(); @@ -115,14 +172,11 @@ describe('Monitoring store actions', () => { .catch(done.fail); }); }); - describe('Set endpoints', () => { let mockedState; - beforeEach(() => { mockedState = storeState(); }); - it('should commit SET_ENDPOINTS mutation', done => { testAction( setEndpoints, @@ -147,42 +201,45 @@ describe('Monitoring store actions', () => { ); }); }); - describe('Set empty states', () => { let mockedState; - beforeEach(() => { mockedState = storeState(); }); - it('should commit SET_METRICS_ENDPOINT mutation', done => { testAction( setGettingStartedEmptyState, null, mockedState, - [{ type: types.SET_GETTING_STARTED_EMPTY_STATE }], + [ + { + type: types.SET_GETTING_STARTED_EMPTY_STATE, + }, + ], [], done, ); }); }); - describe('fetchDashboard', () => { let dispatch; let state; const response = metricsDashboardResponse; - beforeEach(() => { - dispatch = jasmine.createSpy(); + dispatch = jest.fn(); state = storeState(); state.dashboardEndpoint = '/dashboard'; }); - it('dispatches receive and success actions', done => { const params = {}; mock.onGet(state.dashboardEndpoint).reply(200, response); - - fetchDashboard({ state, dispatch }, params) + fetchDashboard( + { + state, + dispatch, + }, + params, + ) .then(() => { expect(dispatch).toHaveBeenCalledWith('requestMetricsDashboard'); expect(dispatch).toHaveBeenCalledWith('receiveMetricsDashboardSuccess', { @@ -193,12 +250,16 @@ describe('Monitoring store actions', () => { }) .catch(done.fail); }); - it('dispatches failure action', done => { const params = {}; mock.onGet(state.dashboardEndpoint).reply(500); - - fetchDashboard({ state, dispatch }, params) + fetchDashboard( + { + state, + dispatch, + }, + params, + ) .then(() => { expect(dispatch).toHaveBeenCalledWith( 'receiveMetricsDashboardFailure', @@ -209,77 +270,92 @@ describe('Monitoring store actions', () => { .catch(done.fail); }); }); - describe('receiveMetricsDashboardSuccess', () => { let commit; let dispatch; let state; - beforeEach(() => { - commit = jasmine.createSpy(); - dispatch = jasmine.createSpy(); + commit = jest.fn(); + dispatch = jest.fn(); state = storeState(); }); - it('stores groups ', () => { const params = {}; const response = metricsDashboardResponse; - - receiveMetricsDashboardSuccess({ state, commit, dispatch }, { response, params }); - + receiveMetricsDashboardSuccess( + { + state, + commit, + dispatch, + }, + { + response, + params, + }, + ); expect(commit).toHaveBeenCalledWith( types.RECEIVE_METRICS_DATA_SUCCESS, metricsDashboardResponse.dashboard.panel_groups, ); - expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetrics', params); }); - it('sets the dashboards loaded from the repository', () => { const params = {}; const response = metricsDashboardResponse; - response.all_dashboards = dashboardGitResponse; - receiveMetricsDashboardSuccess({ state, commit, dispatch }, { response, params }); - + receiveMetricsDashboardSuccess( + { + state, + commit, + dispatch, + }, + { + response, + params, + }, + ); expect(commit).toHaveBeenCalledWith(types.SET_ALL_DASHBOARDS, dashboardGitResponse); }); }); - describe('receiveMetricsDashboardFailure', () => { let commit; - beforeEach(() => { - commit = jasmine.createSpy(); + commit = jest.fn(); }); - it('commits failure action', () => { - receiveMetricsDashboardFailure({ commit }); - + receiveMetricsDashboardFailure({ + commit, + }); expect(commit).toHaveBeenCalledWith(types.RECEIVE_METRICS_DATA_FAILURE, undefined); }); - it('commits failure action with error', () => { - receiveMetricsDashboardFailure({ commit }, 'uh-oh'); - + receiveMetricsDashboardFailure( + { + commit, + }, + 'uh-oh', + ); expect(commit).toHaveBeenCalledWith(types.RECEIVE_METRICS_DATA_FAILURE, 'uh-oh'); }); }); - describe('fetchPrometheusMetrics', () => { let commit; let dispatch; - beforeEach(() => { - commit = jasmine.createSpy(); - dispatch = jasmine.createSpy(); + commit = jest.fn(); + dispatch = jest.fn(); }); - it('commits empty state when state.groups is empty', done => { const state = storeState(); const params = {}; - - fetchPrometheusMetrics({ state, commit, dispatch }, params) + fetchPrometheusMetrics( + { + state, + commit, + dispatch, + }, + params, + ) .then(() => { expect(commit).toHaveBeenCalledWith(types.SET_NO_DATA_EMPTY_STATE); expect(dispatch).not.toHaveBeenCalled(); @@ -287,49 +363,54 @@ describe('Monitoring store actions', () => { }) .catch(done.fail); }); - it('dispatches fetchPrometheusMetric for each panel query', done => { const params = {}; const state = storeState(); - state.groups = metricsDashboardResponse.dashboard.panel_groups; - - const metric = state.groups[0].panels[0].metrics[0]; - - fetchPrometheusMetrics({ state, commit, dispatch }, params) + state.dashboard.panel_groups = metricsDashboardResponse.dashboard.panel_groups; + const metric = state.dashboard.panel_groups[0].panels[0].metrics[0]; + fetchPrometheusMetrics( + { + state, + commit, + dispatch, + }, + params, + ) .then(() => { - expect(dispatch.calls.count()).toEqual(3); - expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { metric, params }); + expect(dispatch).toHaveBeenCalledTimes(3); + expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { + metric, + params, + }); done(); }) .catch(done.fail); - done(); }); }); - describe('fetchPrometheusMetric', () => { it('commits prometheus query result', done => { - const commit = jasmine.createSpy(); + const commit = jest.fn(); const params = { start: '2019-08-06T12:40:02.184Z', end: '2019-08-06T20:40:02.184Z', }; const metric = metricsDashboardResponse.dashboard.panel_groups[0].panels[0].metrics[0]; const state = storeState(); - - const data = metricsGroupsAPIResponse.data[0].metrics[0].queries[0]; - const response = { data }; + const data = metricsGroupsAPIResponse[0].panels[0].metrics[0]; + const response = { + data, + }; mock.onGet('http://test').reply(200, response); - - fetchPrometheusMetric({ state, commit }, { metric, params }); - - setTimeout(() => { - expect(commit).toHaveBeenCalledWith(types.SET_QUERY_RESULT, { - metricId: metric.metric_id, - result: data.result, - }); - done(); - }); + fetchPrometheusMetric({ state, commit }, { metric, params }) + .then(() => { + expect(commit).toHaveBeenCalledWith(types.SET_QUERY_RESULT, { + metricId: metric.metric_id, + result: data.result, + }); + done(); + }) + .catch(done.fail); }); }); }); diff --git a/spec/javascripts/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js similarity index 63% rename from spec/javascripts/monitoring/store/mutations_spec.js rename to spec/frontend/monitoring/store/mutations_spec.js index bdddd83358cc529b9bf6d51e53ddf6c165f38452..fdad290a8d6b427a4e35299648b45c20485cdf72 100644 --- a/spec/javascripts/monitoring/store/mutations_spec.js +++ b/spec/frontend/monitoring/store/mutations_spec.js @@ -11,104 +11,62 @@ import { uniqMetricsId } from '~/monitoring/stores/utils'; describe('Monitoring mutations', () => { let stateCopy; - beforeEach(() => { stateCopy = state(); }); - - describe(types.RECEIVE_METRICS_DATA_SUCCESS, () => { + describe('RECEIVE_METRICS_DATA_SUCCESS', () => { let groups; - beforeEach(() => { - stateCopy.groups = []; - groups = metricsGroupsAPIResponse.data; + stateCopy.dashboard.panel_groups = []; + groups = metricsGroupsAPIResponse; }); - - it('normalizes values', () => { + it('adds a key to the group', () => { mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups); - - const expectedTimestamp = '2017-05-25T08:22:34.925Z'; - const expectedValue = 0.0010794445585559514; - const [timestamp, value] = stateCopy.groups[0].metrics[0].queries[0].result[0].values[0]; - - expect(timestamp).toEqual(expectedTimestamp); - expect(value).toEqual(expectedValue); + expect(stateCopy.dashboard.panel_groups[0].key).toBe('system-metrics-kubernetes--0'); }); - - it('contains two groups that contains, one of which has two queries sorted by priority', () => { + it('normalizes values', () => { mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups); - - expect(stateCopy.groups).toBeDefined(); - expect(stateCopy.groups.length).toEqual(2); - expect(stateCopy.groups[0].metrics.length).toEqual(2); + const expectedLabel = 'Pod average'; + const { label, query_range } = stateCopy.dashboard.panel_groups[0].metrics[0].metrics[0]; + expect(label).toEqual(expectedLabel); + expect(query_range.length).toBeGreaterThan(0); }); - - it('assigns queries a metric id', () => { + it('contains one group, which it has two panels and one metrics property', () => { mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups); - - expect(stateCopy.groups[1].metrics[0].queries[0].metricId).toEqual('100'); + expect(stateCopy.dashboard.panel_groups).toBeDefined(); + expect(stateCopy.dashboard.panel_groups.length).toEqual(1); + expect(stateCopy.dashboard.panel_groups[0].panels.length).toEqual(2); + expect(stateCopy.dashboard.panel_groups[0].panels[0].metrics.length).toEqual(1); + expect(stateCopy.dashboard.panel_groups[0].panels[1].metrics.length).toEqual(1); }); - - it('removes the data if all the values from a query are not defined', () => { + it('assigns queries a metric id', () => { mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups); - - expect(stateCopy.groups[1].metrics[0].queries[0].result.length).toEqual(0); - }); - - it('assigns metric id of null if metric has no id', () => { - stateCopy.groups = []; - const noId = groups.map(group => ({ - ...group, - ...{ - metrics: group.metrics.map(metric => { - const { id, ...metricWithoutId } = metric; - - return metricWithoutId; - }), - }, - })); - - mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, noId); - - stateCopy.groups.forEach(group => { - group.metrics.forEach(metric => { - expect(metric.queries.every(query => query.metricId === null)).toBe(true); - }); - }); + expect(stateCopy.dashboard.panel_groups[0].metrics[0].queries[0].metricId).toEqual( + '17_system_metrics_kubernetes_container_memory_average', + ); }); - - describe('dashboard endpoint enabled', () => { + describe('dashboard endpoint', () => { const dashboardGroups = metricsDashboardResponse.dashboard.panel_groups; - - beforeEach(() => { - stateCopy.useDashboardEndpoint = true; - }); - it('aliases group panels to metrics for backwards compatibility', () => { mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups); - - expect(stateCopy.groups[0].metrics[0]).toBeDefined(); + expect(stateCopy.dashboard.panel_groups[0].metrics[0]).toBeDefined(); }); - it('aliases panel metrics to queries for backwards compatibility', () => { mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups); - - expect(stateCopy.groups[0].metrics[0].queries).toBeDefined(); + expect(stateCopy.dashboard.panel_groups[0].metrics[0].queries).toBeDefined(); }); }); }); - describe(types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS, () => { + describe('RECEIVE_DEPLOYMENTS_DATA_SUCCESS', () => { it('stores the deployment data', () => { stateCopy.deploymentData = []; mutations[types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS](stateCopy, deploymentData); - expect(stateCopy.deploymentData).toBeDefined(); expect(stateCopy.deploymentData.length).toEqual(3); expect(typeof stateCopy.deploymentData[0]).toEqual('object'); }); }); - describe('SET_ENDPOINTS', () => { it('should set all the endpoints', () => { mutations[types.SET_ENDPOINTS](stateCopy, { @@ -118,7 +76,6 @@ describe('Monitoring mutations', () => { dashboardEndpoint: 'dashboard.json', projectPath: '/gitlab-org/gitlab-foss', }); - expect(stateCopy.metricsEndpoint).toEqual('additional_metrics.json'); expect(stateCopy.environmentsEndpoint).toEqual('environments.json'); expect(stateCopy.deploymentsEndpoint).toEqual('deployments.json'); @@ -126,51 +83,59 @@ describe('Monitoring mutations', () => { expect(stateCopy.projectPath).toEqual('/gitlab-org/gitlab-foss'); }); }); - describe('SET_QUERY_RESULT', () => { const metricId = 12; const id = 'system_metrics_kubernetes_container_memory_total'; - const result = [{ values: [[0, 1], [1, 1], [1, 3]] }]; - + const result = [ + { + values: [[0, 1], [1, 1], [1, 3]], + }, + ]; beforeEach(() => { - stateCopy.useDashboardEndpoint = true; const dashboardGroups = metricsDashboardResponse.dashboard.panel_groups; mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups); }); - it('clears empty state', () => { mutations[types.SET_QUERY_RESULT](stateCopy, { metricId, result, }); - expect(stateCopy.showEmptyState).toBe(false); }); - it('sets metricsWithData value', () => { - const uniqId = uniqMetricsId({ metric_id: metricId, id }); + const uniqId = uniqMetricsId({ + metric_id: metricId, + id, + }); mutations[types.SET_QUERY_RESULT](stateCopy, { metricId: uniqId, result, }); - expect(stateCopy.metricsWithData).toEqual([uniqId]); }); - it('does not store empty results', () => { mutations[types.SET_QUERY_RESULT](stateCopy, { metricId, result: [], }); - expect(stateCopy.metricsWithData).toEqual([]); }); }); - describe('SET_ALL_DASHBOARDS', () => { - it('stores the dashboards loaded from the git repository', () => { - mutations[types.SET_ALL_DASHBOARDS](stateCopy, dashboardGitResponse); + it('stores `undefined` dashboards as an empty array', () => { + mutations[types.SET_ALL_DASHBOARDS](stateCopy, undefined); + expect(stateCopy.allDashboards).toEqual([]); + }); + + it('stores `null` dashboards as an empty array', () => { + mutations[types.SET_ALL_DASHBOARDS](stateCopy, null); + + expect(stateCopy.allDashboards).toEqual([]); + }); + + it('stores dashboards loaded from the git repository', () => { + mutations[types.SET_ALL_DASHBOARDS](stateCopy, dashboardGitResponse); expect(stateCopy.allDashboards).toEqual(dashboardGitResponse); }); }); diff --git a/spec/javascripts/monitoring/store/utils_spec.js b/spec/frontend/monitoring/store/utils_spec.js similarity index 100% rename from spec/javascripts/monitoring/store/utils_spec.js rename to spec/frontend/monitoring/store/utils_spec.js diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..45b99b71e06218861bc96fda93b3324276dd53a2 --- /dev/null +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -0,0 +1,331 @@ +import $ from 'jquery'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import Autosize from 'autosize'; +import axios from '~/lib/utils/axios_utils'; +import createStore from '~/notes/stores'; +import CommentForm from '~/notes/components/comment_form.vue'; +import * as constants from '~/notes/constants'; +import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; +import { trimText } from 'helpers/text_helper'; +import { keyboardDownEvent } from '../../issue_show/helpers'; +import { + loggedOutnoteableData, + notesDataMock, + userDataMock, + noteableDataMock, +} from '../../notes/mock_data'; + +jest.mock('autosize'); +jest.mock('~/commons/nav/user_merge_requests'); +jest.mock('~/gl_form'); + +describe('issue_comment_form component', () => { + let store; + let wrapper; + let axiosMock; + + const setupStore = (userData, noteableData) => { + store.dispatch('setUserData', userData); + store.dispatch('setNoteableData', noteableData); + store.dispatch('setNotesData', notesDataMock); + }; + + const mountComponent = (noteableType = 'issue') => { + wrapper = mount(CommentForm, { + propsData: { + noteableType, + }, + store, + sync: false, + }); + }; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + store = createStore(); + }); + + afterEach(() => { + axiosMock.restore(); + wrapper.destroy(); + jest.clearAllMocks(); + }); + + describe('user is logged in', () => { + beforeEach(() => { + setupStore(userDataMock, noteableDataMock); + + mountComponent(); + }); + + it('should render user avatar with link', () => { + expect(wrapper.find('.timeline-icon .user-avatar-link').attributes('href')).toEqual( + userDataMock.path, + ); + }); + + describe('handleSave', () => { + it('should request to save note when note is entered', () => { + wrapper.vm.note = 'hello world'; + jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(new Promise(() => {})); + jest.spyOn(wrapper.vm, 'resizeTextarea'); + jest.spyOn(wrapper.vm, 'stopPolling'); + + wrapper.vm.handleSave(); + + expect(wrapper.vm.isSubmitting).toEqual(true); + expect(wrapper.vm.note).toEqual(''); + expect(wrapper.vm.saveNote).toHaveBeenCalled(); + expect(wrapper.vm.stopPolling).toHaveBeenCalled(); + expect(wrapper.vm.resizeTextarea).toHaveBeenCalled(); + }); + + it('should toggle issue state when no note', () => { + jest.spyOn(wrapper.vm, 'toggleIssueState'); + + wrapper.vm.handleSave(); + + expect(wrapper.vm.toggleIssueState).toHaveBeenCalled(); + }); + + it('should disable action button whilst submitting', done => { + const saveNotePromise = Promise.resolve(); + wrapper.vm.note = 'hello world'; + jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(saveNotePromise); + jest.spyOn(wrapper.vm, 'stopPolling'); + + const actionButton = wrapper.find('.js-action-button'); + + wrapper.vm.handleSave(); + + wrapper.vm + .$nextTick() + .then(() => { + expect(actionButton.vm.disabled).toBeTruthy(); + }) + .then(saveNotePromise) + .then(wrapper.vm.$nextTick) + .then(() => { + expect(actionButton.vm.disabled).toBeFalsy(); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('textarea', () => { + it('should render textarea with placeholder', () => { + expect(wrapper.find('.js-main-target-form textarea').attributes('placeholder')).toEqual( + 'Write a comment or drag your files here…', + ); + }); + + it('should make textarea disabled while requesting', done => { + const $submitButton = $(wrapper.find('.js-comment-submit-button').element); + wrapper.vm.note = 'hello world'; + jest.spyOn(wrapper.vm, 'stopPolling'); + jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(new Promise(() => {})); + + wrapper.vm.$nextTick(() => { + // Wait for wrapper.vm.note change triggered. It should enable $submitButton. + $submitButton.trigger('click'); + + wrapper.vm.$nextTick(() => { + // Wait for wrapper.isSubmitting triggered. It should disable textarea. + expect(wrapper.find('.js-main-target-form textarea').attributes('disabled')).toBe( + 'disabled', + ); + done(); + }); + }); + }); + + it('should support quick actions', () => { + expect( + wrapper.find('.js-main-target-form textarea').attributes('data-supports-quick-actions'), + ).toBe('true'); + }); + + it('should link to markdown docs', () => { + const { markdownDocsPath } = notesDataMock; + + expect( + wrapper + .find(`a[href="${markdownDocsPath}"]`) + .text() + .trim(), + ).toEqual('Markdown'); + }); + + it('should link to quick actions docs', () => { + const { quickActionsDocsPath } = notesDataMock; + + expect( + wrapper + .find(`a[href="${quickActionsDocsPath}"]`) + .text() + .trim(), + ).toEqual('quick actions'); + }); + + it('should resize textarea after note discarded', done => { + jest.spyOn(wrapper.vm, 'discard'); + + wrapper.vm.note = 'foo'; + wrapper.vm.discard(); + + wrapper.vm.$nextTick(() => { + expect(Autosize.update).toHaveBeenCalled(); + done(); + }); + }); + + describe('edit mode', () => { + it('should enter edit mode when arrow up is pressed', () => { + jest.spyOn(wrapper.vm, 'editCurrentUserLastNote'); + wrapper.find('.js-main-target-form textarea').value = 'Foo'; + wrapper + .find('.js-main-target-form textarea') + .element.dispatchEvent(keyboardDownEvent(38, true)); + + expect(wrapper.vm.editCurrentUserLastNote).toHaveBeenCalled(); + }); + + it('inits autosave', () => { + expect(wrapper.vm.autosave).toBeDefined(); + expect(wrapper.vm.autosave.key).toEqual(`autosave/Note/Issue/${noteableDataMock.id}`); + }); + }); + + describe('event enter', () => { + it('should save note when cmd+enter is pressed', () => { + jest.spyOn(wrapper.vm, 'handleSave'); + wrapper.find('.js-main-target-form textarea').value = 'Foo'; + wrapper + .find('.js-main-target-form textarea') + .element.dispatchEvent(keyboardDownEvent(13, true)); + + expect(wrapper.vm.handleSave).toHaveBeenCalled(); + }); + + it('should save note when ctrl+enter is pressed', () => { + jest.spyOn(wrapper.vm, 'handleSave'); + wrapper.find('.js-main-target-form textarea').value = 'Foo'; + wrapper + .find('.js-main-target-form textarea') + .element.dispatchEvent(keyboardDownEvent(13, false, true)); + + expect(wrapper.vm.handleSave).toHaveBeenCalled(); + }); + }); + }); + + describe('actions', () => { + it('should be possible to close the issue', () => { + expect( + wrapper + .find('.btn-comment-and-close') + .text() + .trim(), + ).toEqual('Close issue'); + }); + + it('should render comment button as disabled', () => { + expect(wrapper.find('.js-comment-submit-button').attributes('disabled')).toEqual( + 'disabled', + ); + }); + + it('should enable comment button if it has note', done => { + wrapper.vm.note = 'Foo'; + wrapper.vm.$nextTick(() => { + expect(wrapper.find('.js-comment-submit-button').attributes('disabled')).toBeFalsy(); + done(); + }); + }); + + it('should update buttons texts when it has note', done => { + wrapper.vm.note = 'Foo'; + wrapper.vm.$nextTick(() => { + expect( + wrapper + .find('.btn-comment-and-close') + .text() + .trim(), + ).toEqual('Comment & close issue'); + + done(); + }); + }); + + it('updates button text with noteable type', done => { + wrapper.setProps({ noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE }); + + wrapper.vm.$nextTick(() => { + expect( + wrapper + .find('.btn-comment-and-close') + .text() + .trim(), + ).toEqual('Close merge request'); + done(); + }); + }); + + describe('when clicking close/reopen button', () => { + it('should disable button and show a loading spinner', done => { + const toggleStateButton = wrapper.find('.js-action-button'); + + toggleStateButton.trigger('click'); + wrapper.vm.$nextTick(() => { + expect(toggleStateButton.element.disabled).toEqual(true); + expect(toggleStateButton.find('.js-loading-button-icon').exists()).toBe(true); + + done(); + }); + }); + }); + + describe('when toggling state', () => { + it('should update MR count', done => { + jest.spyOn(wrapper.vm, 'closeIssue').mockResolvedValue(); + + wrapper.vm.toggleIssueState(); + + wrapper.vm.$nextTick(() => { + expect(refreshUserMergeRequestCounts).toHaveBeenCalled(); + + done(); + }); + }); + }); + }); + + describe('issue is confidential', () => { + it('shows information warning', done => { + store.dispatch('setNoteableData', Object.assign(noteableDataMock, { confidential: true })); + wrapper.vm.$nextTick(() => { + expect(wrapper.find('.confidential-issue-warning')).toBeDefined(); + done(); + }); + }); + }); + }); + + describe('user is not logged in', () => { + beforeEach(() => { + setupStore(null, loggedOutnoteableData); + + mountComponent(); + }); + + it('should render signed out widget', () => { + expect(trimText(wrapper.text())).toEqual('Please register or sign in to reply'); + }); + + it('should not render submission form', () => { + expect(wrapper.find('textarea').exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/notes/components/diff_discussion_header_spec.js b/spec/frontend/notes/components/diff_discussion_header_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..f90147f910533860553dc2699b21a9c5b37eefc4 --- /dev/null +++ b/spec/frontend/notes/components/diff_discussion_header_spec.js @@ -0,0 +1,141 @@ +import { mount, createLocalVue } from '@vue/test-utils'; + +import createStore from '~/notes/stores'; +import diffDiscussionHeader from '~/notes/components/diff_discussion_header.vue'; + +import { discussionMock } from '../../../javascripts/notes/mock_data'; +import mockDiffFile from '../../diffs/mock_data/diff_discussions'; + +const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; + +describe('diff_discussion_header component', () => { + let store; + let wrapper; + + preloadFixtures(discussionWithTwoUnresolvedNotes); + + beforeEach(() => { + window.mrTabs = {}; + store = createStore(); + + const localVue = createLocalVue(); + wrapper = mount(diffDiscussionHeader, { + store, + propsData: { discussion: discussionMock }, + localVue, + sync: false, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should render user avatar', () => { + const discussion = { ...discussionMock }; + discussion.diff_file = mockDiffFile; + discussion.diff_discussion = true; + + wrapper.setProps({ discussion }); + + expect(wrapper.find('.user-avatar-link').exists()).toBe(true); + }); + + describe('action text', () => { + const commitId = 'razupaltuff'; + const truncatedCommitId = commitId.substr(0, 8); + let commitElement; + + beforeEach(done => { + store.state.diffs = { + projectPath: 'something', + }; + + wrapper.setProps({ + discussion: { + ...discussionMock, + for_commit: true, + commit_id: commitId, + diff_discussion: true, + diff_file: { + ...mockDiffFile, + }, + }, + }); + + wrapper.vm + .$nextTick() + .then(() => { + commitElement = wrapper.find('.commit-sha'); + }) + .then(done) + .catch(done.fail); + }); + + describe('for diff threads without a commit id', () => { + it('should show started a thread on the diff text', done => { + Object.assign(wrapper.vm.discussion, { + for_commit: false, + commit_id: null, + }); + + wrapper.vm.$nextTick(() => { + expect(wrapper.text()).toContain('started a thread on the diff'); + + done(); + }); + }); + + it('should show thread on older version text', done => { + Object.assign(wrapper.vm.discussion, { + for_commit: false, + commit_id: null, + active: false, + }); + + wrapper.vm.$nextTick(() => { + expect(wrapper.text()).toContain('started a thread on an old version of the diff'); + + done(); + }); + }); + }); + + describe('for commit threads', () => { + it('should display a monospace started a thread on commit', () => { + expect(wrapper.text()).toContain(`started a thread on commit ${truncatedCommitId}`); + expect(commitElement.exists()).toBe(true); + expect(commitElement.text()).toContain(truncatedCommitId); + }); + }); + + describe('for diff thread with a commit id', () => { + it('should display started thread on commit header', done => { + wrapper.vm.discussion.for_commit = false; + + wrapper.vm.$nextTick(() => { + expect(wrapper.text()).toContain(`started a thread on commit ${truncatedCommitId}`); + + expect(commitElement).not.toBe(null); + + done(); + }); + }); + + it('should display outdated change on commit header', done => { + wrapper.vm.discussion.for_commit = false; + wrapper.vm.discussion.active = false; + + wrapper.vm.$nextTick(() => { + expect(wrapper.text()).toContain( + `started a thread on an outdated change in commit ${truncatedCommitId}`, + ); + + expect(commitElement).not.toBe(null); + + done(); + }); + }); + }); + }); +}); diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js index d3c8cf72376feedf934436e019a40ffae2229165..91f9dab2530a7e234d55bc5df3f7782e40fbe7a9 100644 --- a/spec/frontend/notes/components/discussion_actions_spec.js +++ b/spec/frontend/notes/components/discussion_actions_spec.js @@ -1,6 +1,6 @@ import createStore from '~/notes/stores'; import { shallowMount, mount, createLocalVue } from '@vue/test-utils'; -import { discussionMock } from '../../../javascripts/notes/mock_data'; +import { discussionMock } from '../../notes/mock_data'; import DiscussionActions from '~/notes/components/discussion_actions.vue'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; import ResolveDiscussionButton from '~/notes/components/discussion_resolve_button.vue'; diff --git a/spec/frontend/notes/components/discussion_notes_spec.js b/spec/frontend/notes/components/discussion_notes_spec.js index 58d367077e85493b8f7b5171d8f929ab5a98b5a1..f77236b14bc16ca28fcb25d84a6d46de4489bbcc 100644 --- a/spec/frontend/notes/components/discussion_notes_spec.js +++ b/spec/frontend/notes/components/discussion_notes_spec.js @@ -8,11 +8,7 @@ import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_sys 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 '../../../javascripts/notes/mock_data'; +import { noteableDataMock, discussionMock, notesDataMock } from '../../notes/mock_data'; const localVue = createLocalVue(); diff --git a/spec/frontend/notes/components/note_app_spec.js b/spec/frontend/notes/components/note_app_spec.js index a8ec47fd44fd7e369035214be3a33933556780ca..3716b349210d6772e18616c1996402d725164cc4 100644 --- a/spec/frontend/notes/components/note_app_spec.js +++ b/spec/frontend/notes/components/note_app_spec.js @@ -9,7 +9,8 @@ import createStore from '~/notes/stores'; import '~/behaviors/markdown/render_gfm'; import { setTestTimeout } from 'helpers/timeout'; // TODO: use generated fixture (https://gitlab.com/gitlab-org/gitlab-foss/issues/62491) -import * as mockData from '../../../javascripts/notes/mock_data'; +import * as mockData from '../../notes/mock_data'; +import * as urlUtility from '~/lib/utils/url_utility'; setTestTimeout(1000); @@ -54,7 +55,9 @@ describe('note_app', () => { components: { NotesApp, }, - template: '<div class="js-vue-notes-event"><notes-app v-bind="$attrs" /></div>', + template: `<div class="js-vue-notes-event"> + <notes-app ref="notesApp" v-bind="$attrs" /> + </div>`, }, { attachToDocument: true, @@ -313,4 +316,23 @@ describe('note_app', () => { }); }); }); + + describe('mounted', () => { + beforeEach(() => { + axiosMock.onAny().reply(mockData.getIndividualNoteResponse); + wrapper = mountComponent(); + return waitForDiscussionsRequest(); + }); + + it('should listen hashchange event', () => { + const notesApp = wrapper.find(NotesApp); + const hash = 'some dummy hash'; + jest.spyOn(urlUtility, 'getLocationHash').mockReturnValueOnce(hash); + const setTargetNoteHash = jest.spyOn(notesApp.vm, 'setTargetNoteHash'); + + window.dispatchEvent(new Event('hashchange'), hash); + + expect(setTargetNoteHash).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/notes/mock_data.js b/spec/frontend/notes/mock_data.js new file mode 100644 index 0000000000000000000000000000000000000000..01cb70d395cbcd8d589f29dfe8cf00b4d20f4310 --- /dev/null +++ b/spec/frontend/notes/mock_data.js @@ -0,0 +1,1255 @@ +// Copied to ee/spec/frontend/notes/mock_data.js + +export const notesDataMock = { + discussionsPath: '/gitlab-org/gitlab-foss/issues/26/discussions.json', + lastFetchedAt: 1501862675, + markdownDocsPath: '/help/user/markdown', + newSessionPath: '/users/sign_in?redirect_to_referer=yes', + notesPath: '/gitlab-org/gitlab-foss/noteable/issue/98/notes', + quickActionsDocsPath: '/help/user/project/quick_actions', + registerPath: '/users/sign_in?redirect_to_referer=yes#register-pane', + prerenderedNotesCount: 1, + closePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=close', + reopenPath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=reopen', + canAwardEmoji: true, +}; + +export const userDataMock = { + avatar_url: 'mock_path', + id: 1, + name: 'Root', + path: '/root', + state: 'active', + username: 'root', +}; + +export const noteableDataMock = { + assignees: [], + author_id: 1, + branch_name: null, + confidential: false, + create_note_path: '/gitlab-org/gitlab-foss/notes?target_id=98&target_type=issue', + created_at: '2017-02-07T10:11:18.395Z', + current_user: { + can_create_note: true, + can_update: true, + can_award_emoji: true, + }, + description: '', + due_date: null, + human_time_estimate: null, + human_total_time_spent: null, + id: 98, + iid: 26, + labels: [], + lock_version: null, + milestone: null, + milestone_id: null, + moved_to_id: null, + preview_note_path: '/gitlab-org/gitlab-foss/preview_markdown?target_id=98&target_type=Issue', + project_id: 2, + state: 'opened', + time_estimate: 0, + title: '14', + total_time_spent: 0, + noteable_note_url: '/group/project/merge_requests/1#note_1', + updated_at: '2017-08-04T09:53:01.226Z', + updated_by_id: 1, + web_url: '/gitlab-org/gitlab-foss/issues/26', + noteableType: 'issue', +}; + +export const lastFetchedAt = '1501862675'; + +export const individualNote = { + expanded: true, + id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', + individual_note: true, + notes: [ + { + id: '1390', + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: 'test', + path: '/root', + }, + created_at: '2017-08-01T17: 09: 33.762Z', + updated_at: '2017-08-01T17: 09: 33.762Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: null, + human_access: 'Owner', + note: 'sdfdsaf', + note_html: "<p dir='auto'>sdfdsaf</p>", + current_user: { + can_edit: true, + can_award_emoji: true, + }, + discussion_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', + emoji_awardable: true, + award_emoji: [ + { name: 'baseball', user: { id: 1, name: 'Root', username: 'root' } }, + { name: 'art', user: { id: 1, name: 'Root', username: 'root' } }, + ], + toggle_award_path: '/gitlab-org/gitlab-foss/notes/1390/toggle_award_emoji', + noteable_note_url: '/group/project/merge_requests/1#note_1', + note_url: '/group/project/merge_requests/1#note_1', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390&user_id=1', + path: '/gitlab-org/gitlab-foss/notes/1390', + }, + ], + reply_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', +}; + +export const note = { + id: '546', + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2017-08-10T15:24:03.087Z', + updated_at: '2017-08-10T15:24:03.087Z', + system: false, + noteable_id: 67, + noteable_type: 'Issue', + noteable_iid: 7, + type: null, + human_access: 'Owner', + note: 'Vel id placeat reprehenderit sit numquam.', + note_html: '<p dir="auto">Vel id placeat reprehenderit sit numquam.</p>', + current_user: { + can_edit: true, + can_award_emoji: true, + }, + discussion_id: 'd3842a451b7f3d9a5dfce329515127b2d29a4cd0', + emoji_awardable: true, + award_emoji: [ + { + name: 'baseball', + user: { + id: 1, + name: 'Administrator', + username: 'root', + }, + }, + { + name: 'bath_tone3', + user: { + id: 1, + name: 'Administrator', + username: 'root', + }, + }, + ], + toggle_award_path: '/gitlab-org/gitlab-foss/notes/546/toggle_award_emoji', + note_url: '/group/project/merge_requests/1#note_1', + noteable_note_url: '/group/project/merge_requests/1#note_1', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_546&user_id=1', + path: '/gitlab-org/gitlab-foss/notes/546', +}; + +export const discussionMock = { + id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', + reply_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', + expanded: true, + notes: [ + { + id: '1395', + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-02T10:51:58.559Z', + updated_at: '2017-08-02T10:51:58.559Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: 'DiscussionNote', + human_access: 'Owner', + note: 'THIS IS A DICUSSSION!', + note_html: "<p dir='auto'>THIS IS A DICUSSSION!</p>", + current_user: { + can_edit: true, + can_award_emoji: true, + can_resolve: true, + }, + discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', + emoji_awardable: true, + award_emoji: [], + noteable_note_url: '/group/project/merge_requests/1#note_1', + toggle_award_path: '/gitlab-org/gitlab-foss/notes/1395/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1395&user_id=1', + path: '/gitlab-org/gitlab-foss/notes/1395', + }, + { + id: '1396', + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-02T10:56:50.980Z', + updated_at: '2017-08-03T14:19:35.691Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: 'DiscussionNote', + human_access: 'Owner', + note: 'sadfasdsdgdsf', + note_html: "<p dir='auto'>sadfasdsdgdsf</p>", + last_edited_at: '2017-08-03T14:19:35.691Z', + last_edited_by: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + current_user: { + can_edit: true, + can_award_emoji: true, + can_resolve: true, + }, + discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', + emoji_awardable: true, + award_emoji: [], + toggle_award_path: '/gitlab-org/gitlab-foss/notes/1396/toggle_award_emoji', + noteable_note_url: '/group/project/merge_requests/1#note_1', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1396&user_id=1', + path: '/gitlab-org/gitlab-foss/notes/1396', + }, + { + id: '1437', + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-03T18:11:18.780Z', + updated_at: '2017-08-04T09:52:31.062Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: 'DiscussionNote', + human_access: 'Owner', + note: 'adsfasf Should disappear', + note_html: "<p dir='auto'>adsfasf Should disappear</p>", + last_edited_at: '2017-08-04T09:52:31.062Z', + last_edited_by: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + current_user: { + can_edit: true, + can_award_emoji: true, + can_resolve: true, + }, + discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', + emoji_awardable: true, + award_emoji: [], + noteable_note_url: '/group/project/merge_requests/1#note_1', + toggle_award_path: '/gitlab-org/gitlab-foss/notes/1437/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1437&user_id=1', + path: '/gitlab-org/gitlab-foss/notes/1437', + }, + ], + individual_note: false, + resolvable: true, + active: true, +}; + +export const loggedOutnoteableData = { + id: '98', + iid: 26, + author_id: 1, + description: '', + lock_version: 1, + milestone_id: null, + state: 'opened', + title: 'asdsa', + updated_by_id: 1, + created_at: '2017-02-07T10:11:18.395Z', + updated_at: '2017-08-08T10:22:51.564Z', + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + milestone: null, + labels: [], + branch_name: null, + confidential: false, + assignees: [ + { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + web_url: 'http://localhost:3000/root', + }, + ], + due_date: null, + moved_to_id: null, + project_id: 2, + web_url: '/gitlab-org/gitlab-foss/issues/26', + current_user: { + can_create_note: false, + can_update: false, + }, + noteable_note_url: '/group/project/merge_requests/1#note_1', + create_note_path: '/gitlab-org/gitlab-foss/notes?target_id=98&target_type=issue', + preview_note_path: '/gitlab-org/gitlab-foss/preview_markdown?target_id=98&target_type=Issue', +}; + +export const collapseNotesMock = [ + { + expanded: true, + id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', + individual_note: true, + notes: [ + { + id: '1390', + attachment: null, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: 'test', + path: '/root', + }, + created_at: '2018-02-26T18:07:41.071Z', + updated_at: '2018-02-26T18:07:41.071Z', + system: true, + system_note_icon_name: 'pencil', + noteable_id: 98, + noteable_type: 'Issue', + type: null, + human_access: 'Owner', + note: 'changed the description', + note_html: '<p dir="auto">changed the description</p>', + current_user: { can_edit: false }, + discussion_id: 'b97fb7bda470a65b3e009377a9032edec0a4dd05', + emoji_awardable: false, + path: '/h5bp/html5-boilerplate/notes/1057', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fh5bp%2Fhtml5-boilerplate%2Fissues%2F10%23note_1057&user_id=1', + }, + ], + }, + { + expanded: true, + id: 'ffde43f25984ad7f2b4275135e0e2846875336c0', + individual_note: true, + notes: [ + { + id: '1391', + attachment: null, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: 'test', + path: '/root', + }, + created_at: '2018-02-26T18:13:24.071Z', + updated_at: '2018-02-26T18:13:24.071Z', + system: true, + system_note_icon_name: 'pencil', + noteable_id: 99, + noteable_type: 'Issue', + type: null, + human_access: 'Owner', + note: 'changed the description', + note_html: '<p dir="auto">changed the description</p>', + current_user: { can_edit: false }, + discussion_id: '3eb958b4d81dec207ec3537a2f3bd8b9f271bb34', + emoji_awardable: false, + path: '/h5bp/html5-boilerplate/notes/1057', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fh5bp%2Fhtml5-boilerplate%2Fissues%2F10%23note_1057&user_id=1', + }, + ], + }, +]; + +export const INDIVIDUAL_NOTE_RESPONSE_MAP = { + GET: { + '/gitlab-org/gitlab-foss/issues/26/discussions.json': [ + { + id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', + reply_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', + expanded: true, + notes: [ + { + id: '1390', + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-01T17:09:33.762Z', + updated_at: '2017-08-01T17:09:33.762Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: null, + human_access: 'Owner', + note: 'sdfdsaf', + note_html: '\u003cp dir="auto"\u003esdfdsaf\u003c/p\u003e', + current_user: { + can_edit: true, + can_award_emoji: true, + }, + discussion_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', + emoji_awardable: true, + award_emoji: [ + { + name: 'baseball', + user: { + id: 1, + name: 'Root', + username: 'root', + }, + }, + { + name: 'art', + user: { + id: 1, + name: 'Root', + username: 'root', + }, + }, + ], + noteable_note_url: '/group/project/merge_requests/1#note_1', + toggle_award_path: '/gitlab-org/gitlab-foss/notes/1390/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390\u0026user_id=1', + path: '/gitlab-org/gitlab-foss/notes/1390', + }, + ], + individual_note: true, + }, + { + id: '70d5c92a4039a36c70100c6691c18c27e4b0a790', + reply_id: '70d5c92a4039a36c70100c6691c18c27e4b0a790', + expanded: true, + notes: [ + { + id: '1391', + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-02T10:51:38.685Z', + updated_at: '2017-08-02T10:51:38.685Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: null, + human_access: 'Owner', + note: 'New note!', + note_html: '\u003cp dir="auto"\u003eNew note!\u003c/p\u003e', + current_user: { + can_edit: true, + can_award_emoji: true, + }, + discussion_id: '70d5c92a4039a36c70100c6691c18c27e4b0a790', + emoji_awardable: true, + award_emoji: [], + noteable_note_url: '/group/project/merge_requests/1#note_1', + toggle_award_path: '/gitlab-org/gitlab-foss/notes/1391/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1391\u0026user_id=1', + path: '/gitlab-org/gitlab-foss/notes/1391', + }, + ], + individual_note: true, + }, + ], + '/gitlab-org/gitlab-foss/noteable/issue/98/notes': { + last_fetched_at: 1512900838, + notes: [], + }, + }, + PUT: { + '/gitlab-org/gitlab-foss/notes/1471': { + commands_changes: null, + valid: true, + id: '1471', + attachment: null, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-08T16:53:00.666Z', + updated_at: '2017-12-10T11:03:21.876Z', + system: false, + noteable_id: 124, + noteable_type: 'Issue', + noteable_iid: 29, + type: 'DiscussionNote', + human_access: 'Owner', + note: 'Adding a comment', + note_html: '\u003cp dir="auto"\u003eAdding a comment\u003c/p\u003e', + last_edited_at: '2017-12-10T11:03:21.876Z', + last_edited_by: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + current_user: { + can_edit: true, + can_award_emoji: true, + }, + discussion_id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052', + emoji_awardable: true, + award_emoji: [], + noteable_note_url: '/group/project/merge_requests/1#note_1', + toggle_award_path: '/gitlab-org/gitlab-foss/notes/1471/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1', + path: '/gitlab-org/gitlab-foss/notes/1471', + }, + }, +}; + +export const DISCUSSION_NOTE_RESPONSE_MAP = { + ...INDIVIDUAL_NOTE_RESPONSE_MAP, + GET: { + ...INDIVIDUAL_NOTE_RESPONSE_MAP.GET, + '/gitlab-org/gitlab-foss/issues/26/discussions.json': [ + { + id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052', + reply_id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052', + expanded: true, + notes: [ + { + id: '1471', + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-08T16:53:00.666Z', + updated_at: '2017-08-08T16:53:00.666Z', + system: false, + noteable_id: 124, + noteable_type: 'Issue', + noteable_iid: 29, + type: 'DiscussionNote', + human_access: 'Owner', + note: 'Adding a comment', + note_html: '\u003cp dir="auto"\u003eAdding a comment\u003c/p\u003e', + current_user: { + can_edit: true, + can_award_emoji: true, + }, + discussion_id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052', + emoji_awardable: true, + award_emoji: [], + toggle_award_path: '/gitlab-org/gitlab-foss/notes/1471/toggle_award_emoji', + noteable_note_url: '/group/project/merge_requests/1#note_1', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1', + path: '/gitlab-org/gitlab-foss/notes/1471', + }, + ], + individual_note: false, + }, + ], + }, +}; + +export function getIndividualNoteResponse(config) { + return [200, INDIVIDUAL_NOTE_RESPONSE_MAP[config.method.toUpperCase()][config.url]]; +} + +export function getDiscussionNoteResponse(config) { + return [200, DISCUSSION_NOTE_RESPONSE_MAP[config.method.toUpperCase()][config.url]]; +} + +export const notesWithDescriptionChanges = [ + { + id: '39b271c2033e9ed43d8edb393702f65f7a830459', + reply_id: '39b271c2033e9ed43d8edb393702f65f7a830459', + expanded: true, + notes: [ + { + id: '901', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:05:36.117Z', + updated_at: '2018-05-29T12:05:36.117Z', + system: false, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + note_html: + '<p dir="auto">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>', + current_user: { can_edit: true, can_award_emoji: true }, + resolved: false, + resolved_by: null, + discussion_id: '39b271c2033e9ed43d8edb393702f65f7a830459', + emoji_awardable: true, + award_emoji: [], + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_901&user_id=1', + human_access: 'Owner', + toggle_award_path: '/gitlab-org/gitlab-shell/notes/901/toggle_award_emoji', + path: '/gitlab-org/gitlab-shell/notes/901', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, + { + id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795', + reply_id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795', + expanded: true, + notes: [ + { + id: '902', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:05:58.694Z', + updated_at: '2018-05-29T12:05:58.694Z', + system: false, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: + 'Varius vel pharetra vel turpis nunc eget lorem. Ipsum dolor sit amet consectetur adipiscing.', + note_html: + '<p dir="auto">Varius vel pharetra vel turpis nunc eget lorem. Ipsum dolor sit amet consectetur adipiscing.</p>', + current_user: { can_edit: true, can_award_emoji: true }, + resolved: false, + resolved_by: null, + discussion_id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795', + emoji_awardable: true, + award_emoji: [], + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_902&user_id=1', + human_access: 'Owner', + toggle_award_path: '/gitlab-org/gitlab-shell/notes/902/toggle_award_emoji', + path: '/gitlab-org/gitlab-shell/notes/902', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, + { + id: '7f1feda384083eb31763366e6392399fde6f3f31', + reply_id: '7f1feda384083eb31763366e6392399fde6f3f31', + expanded: true, + notes: [ + { + id: '903', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:06:05.772Z', + updated_at: '2018-05-29T12:06:05.772Z', + system: true, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: 'changed the description', + note_html: '<p dir="auto">changed the description</p>', + current_user: { can_edit: false, can_award_emoji: true }, + resolved: false, + resolved_by: null, + system_note_icon_name: 'pencil-square', + discussion_id: '7f1feda384083eb31763366e6392399fde6f3f31', + emoji_awardable: false, + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_903&user_id=1', + human_access: 'Owner', + path: '/gitlab-org/gitlab-shell/notes/903', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, + { + id: '091865fe3ae20f0045234a3d103e3b15e73405b5', + reply_id: '091865fe3ae20f0045234a3d103e3b15e73405b5', + expanded: true, + notes: [ + { + id: '904', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:06:16.112Z', + updated_at: '2018-05-29T12:06:16.112Z', + system: false, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: 'Ullamcorper eget nulla facilisi etiam', + note_html: '<p dir="auto">Ullamcorper eget nulla facilisi etiam</p>', + current_user: { can_edit: true, can_award_emoji: true }, + resolved: false, + resolved_by: null, + discussion_id: '091865fe3ae20f0045234a3d103e3b15e73405b5', + emoji_awardable: true, + award_emoji: [], + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_904&user_id=1', + human_access: 'Owner', + toggle_award_path: '/gitlab-org/gitlab-shell/notes/904/toggle_award_emoji', + path: '/gitlab-org/gitlab-shell/notes/904', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, + { + id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', + reply_id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', + expanded: true, + notes: [ + { + id: '905', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:06:28.851Z', + updated_at: '2018-05-29T12:06:28.851Z', + system: true, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: 'changed the description', + note_html: '<p dir="auto">changed the description</p>', + current_user: { can_edit: false, can_award_emoji: true }, + resolved: false, + resolved_by: null, + system_note_icon_name: 'pencil-square', + discussion_id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', + emoji_awardable: false, + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_905&user_id=1', + human_access: 'Owner', + path: '/gitlab-org/gitlab-shell/notes/905', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, + { + id: '70411b08cdfc01f24187a06d77daa33464cb2620', + reply_id: '70411b08cdfc01f24187a06d77daa33464cb2620', + expanded: true, + notes: [ + { + id: '906', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:20:02.925Z', + updated_at: '2018-05-29T12:20:02.925Z', + system: true, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: 'changed the description', + note_html: '<p dir="auto">changed the description</p>', + current_user: { can_edit: false, can_award_emoji: true }, + resolved: false, + resolved_by: null, + system_note_icon_name: 'pencil-square', + discussion_id: '70411b08cdfc01f24187a06d77daa33464cb2620', + emoji_awardable: false, + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_906&user_id=1', + human_access: 'Owner', + path: '/gitlab-org/gitlab-shell/notes/906', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, +]; + +export const collapsedSystemNotes = [ + { + id: '39b271c2033e9ed43d8edb393702f65f7a830459', + reply_id: '39b271c2033e9ed43d8edb393702f65f7a830459', + expanded: true, + notes: [ + { + id: '901', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:05:36.117Z', + updated_at: '2018-05-29T12:05:36.117Z', + system: false, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + note_html: + '<p dir="auto">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>', + current_user: { can_edit: true, can_award_emoji: true }, + resolved: false, + resolved_by: null, + discussion_id: '39b271c2033e9ed43d8edb393702f65f7a830459', + emoji_awardable: true, + award_emoji: [], + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_901&user_id=1', + human_access: 'Owner', + toggle_award_path: '/gitlab-org/gitlab-shell/notes/901/toggle_award_emoji', + path: '/gitlab-org/gitlab-shell/notes/901', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, + { + id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795', + reply_id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795', + expanded: true, + notes: [ + { + id: '902', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:05:58.694Z', + updated_at: '2018-05-29T12:05:58.694Z', + system: false, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: + 'Varius vel pharetra vel turpis nunc eget lorem. Ipsum dolor sit amet consectetur adipiscing.', + note_html: + '<p dir="auto">Varius vel pharetra vel turpis nunc eget lorem. Ipsum dolor sit amet consectetur adipiscing.</p>', + current_user: { can_edit: true, can_award_emoji: true }, + resolved: false, + resolved_by: null, + discussion_id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795', + emoji_awardable: true, + award_emoji: [], + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_902&user_id=1', + human_access: 'Owner', + toggle_award_path: '/gitlab-org/gitlab-shell/notes/902/toggle_award_emoji', + path: '/gitlab-org/gitlab-shell/notes/902', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, + { + id: '091865fe3ae20f0045234a3d103e3b15e73405b5', + reply_id: '091865fe3ae20f0045234a3d103e3b15e73405b5', + expanded: true, + notes: [ + { + id: '904', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:06:16.112Z', + updated_at: '2018-05-29T12:06:16.112Z', + system: false, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: 'Ullamcorper eget nulla facilisi etiam', + note_html: '<p dir="auto">Ullamcorper eget nulla facilisi etiam</p>', + current_user: { can_edit: true, can_award_emoji: true }, + resolved: false, + resolved_by: null, + discussion_id: '091865fe3ae20f0045234a3d103e3b15e73405b5', + emoji_awardable: true, + award_emoji: [], + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_904&user_id=1', + human_access: 'Owner', + toggle_award_path: '/gitlab-org/gitlab-shell/notes/904/toggle_award_emoji', + path: '/gitlab-org/gitlab-shell/notes/904', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, + { + id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', + reply_id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', + expanded: true, + notes: [ + { + id: '905', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:06:28.851Z', + updated_at: '2018-05-29T12:06:28.851Z', + system: true, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + start_description_version_id: undefined, + note: 'changed the description', + note_html: '<p dir="auto">changed the description</p>', + current_user: { can_edit: false, can_award_emoji: true }, + resolved: false, + resolved_by: null, + system_note_icon_name: 'pencil-square', + discussion_id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', + emoji_awardable: false, + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_905&user_id=1', + human_access: 'Owner', + path: '/gitlab-org/gitlab-shell/notes/905', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, + { + id: '70411b08cdfc01f24187a06d77daa33464cb2620', + reply_id: '70411b08cdfc01f24187a06d77daa33464cb2620', + expanded: true, + notes: [ + { + id: '906', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:20:02.925Z', + updated_at: '2018-05-29T12:20:02.925Z', + system: true, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: 'changed the description', + note_html: '<p dir="auto">changed the description</p>', + current_user: { can_edit: false, can_award_emoji: true }, + resolved: false, + resolved_by: null, + system_note_icon_name: 'pencil-square', + discussion_id: '70411b08cdfc01f24187a06d77daa33464cb2620', + emoji_awardable: false, + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_906&user_id=1', + human_access: 'Owner', + path: '/gitlab-org/gitlab-shell/notes/906', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, +]; + +export const discussion1 = { + id: 'abc1', + resolvable: true, + resolved: false, + active: true, + diff_file: { + file_path: 'about.md', + }, + position: { + new_line: 50, + old_line: null, + }, + notes: [ + { + created_at: '2018-07-04T16:25:41.749Z', + }, + ], +}; + +export const resolvedDiscussion1 = { + id: 'abc1', + resolvable: true, + resolved: true, + diff_file: { + file_path: 'about.md', + }, + position: { + new_line: 50, + old_line: null, + }, + notes: [ + { + created_at: '2018-07-04T16:25:41.749Z', + }, + ], +}; + +export const discussion2 = { + id: 'abc2', + resolvable: true, + resolved: false, + active: true, + diff_file: { + file_path: 'README.md', + }, + position: { + new_line: null, + old_line: 20, + }, + notes: [ + { + created_at: '2018-07-04T12:05:41.749Z', + }, + ], +}; + +export const discussion3 = { + id: 'abc3', + resolvable: true, + active: true, + resolved: false, + diff_file: { + file_path: 'README.md', + }, + position: { + new_line: 21, + old_line: null, + }, + notes: [ + { + created_at: '2018-07-05T17:25:41.749Z', + }, + ], +}; + +export const unresolvableDiscussion = { + resolvable: false, +}; + +export const discussionFiltersMock = [ + { + title: 'Show all activity', + value: 0, + }, + { + title: 'Show comments only', + value: 1, + }, + { + title: 'Show system notes only', + value: 2, + }, +]; diff --git a/spec/frontend/performance_bar/components/add_request_spec.js b/spec/frontend/performance_bar/components/add_request_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..cef264f3915d1cbcd4371ae87ac7691e1c03412f --- /dev/null +++ b/spec/frontend/performance_bar/components/add_request_spec.js @@ -0,0 +1,62 @@ +import AddRequest from '~/performance_bar/components/add_request.vue'; +import { shallowMount } from '@vue/test-utils'; + +describe('add request form', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallowMount(AddRequest); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('hides the input on load', () => { + expect(wrapper.find('input').exists()).toBe(false); + }); + + describe('when clicking the button', () => { + beforeEach(() => { + wrapper.find('button').trigger('click'); + }); + + it('shows the form', () => { + expect(wrapper.find('input').exists()).toBe(true); + }); + + describe('when pressing escape', () => { + beforeEach(() => { + wrapper.find('input').trigger('keyup.esc'); + }); + + it('hides the input', () => { + expect(wrapper.find('input').exists()).toBe(false); + }); + }); + + describe('when submitting the form', () => { + beforeEach(() => { + wrapper.find('input').setValue('http://gitlab.example.com/users/root/calendar.json'); + wrapper.find('input').trigger('keyup.enter'); + }); + + it('emits an event to add the request', () => { + expect(wrapper.emitted()['add-request']).toBeTruthy(); + expect(wrapper.emitted()['add-request'][0]).toEqual([ + 'http://gitlab.example.com/users/root/calendar.json', + ]); + }); + + it('hides the input', () => { + expect(wrapper.find('input').exists()).toBe(false); + }); + + it('clears the value for next time', () => { + wrapper.find('button').trigger('click'); + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..38ffe98c79bee75e2a45b0196bf12a589ba57204 --- /dev/null +++ b/spec/frontend/pipelines/graph/action_component_spec.js @@ -0,0 +1,75 @@ +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import ActionComponent from '~/pipelines/components/graph/action_component.vue'; + +describe('pipeline graph action component', () => { + let wrapper; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + + mock.onPost('foo.json').reply(200); + + wrapper = mount(ActionComponent, { + propsData: { + tooltipText: 'bar', + link: 'foo', + actionIcon: 'cancel', + }, + sync: false, + }); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + it('should render the provided title as a bootstrap tooltip', () => { + expect(wrapper.attributes('data-original-title')).toBe('bar'); + }); + + it('should update bootstrap tooltip when title changes', done => { + wrapper.setProps({ tooltipText: 'changed' }); + + wrapper.vm + .$nextTick() + .then(() => { + expect(wrapper.attributes('data-original-title')).toBe('changed'); + }) + .then(done) + .catch(done.fail); + }); + + it('should render an svg', () => { + expect(wrapper.find('.ci-action-icon-wrapper')).toBeDefined(); + expect(wrapper.find('svg')).toBeDefined(); + }); + + describe('on click', () => { + it('emits `pipelineActionRequestComplete` after a successful request', done => { + jest.spyOn(wrapper.vm, '$emit'); + + wrapper.find('button').trigger('click'); + + waitForPromises() + .then(() => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('pipelineActionRequestComplete'); + done(); + }) + .catch(done.fail); + }); + + it('renders a loading icon while waiting for request', done => { + wrapper.find('button').trigger('click'); + + wrapper.vm.$nextTick(() => { + expect(wrapper.find('.js-action-icon-loading').exists()).toBe(true); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/pipelines/pipeline_triggerer_spec.js b/spec/frontend/pipelines/pipeline_triggerer_spec.js similarity index 90% rename from spec/javascripts/pipelines/pipeline_triggerer_spec.js rename to spec/frontend/pipelines/pipeline_triggerer_spec.js index 8cf290f2663f7fd47fc9ff20c3ae378a0c581ba8..45ac278dd385442df5187b668eff81f26da21db4 100644 --- a/spec/javascripts/pipelines/pipeline_triggerer_spec.js +++ b/spec/frontend/pipelines/pipeline_triggerer_spec.js @@ -17,6 +17,7 @@ describe('Pipelines Triggerer', () => { const createComponent = () => { wrapper = mount(pipelineTriggerer, { propsData: mockData, + sync: false, }); }; @@ -49,6 +50,8 @@ describe('Pipelines Triggerer', () => { }, }); - expect(wrapper.find('.js-pipeline-url-api').text()).toEqual('API'); + wrapper.vm.$nextTick(() => { + expect(wrapper.find('.js-pipeline-url-api').text()).toEqual('API'); + }); }); }); diff --git a/spec/javascripts/pipelines/pipelines_table_row_spec.js b/spec/frontend/pipelines/pipelines_table_row_spec.js similarity index 53% rename from spec/javascripts/pipelines/pipelines_table_row_spec.js rename to spec/frontend/pipelines/pipelines_table_row_spec.js index d47504d2f546cef2820a64a536590183f1aa7e1f..1c785ec6ffee8cd1d7129a76f285b56e31d12733 100644 --- a/spec/javascripts/pipelines/pipelines_table_row_spec.js +++ b/spec/frontend/pipelines/pipelines_table_row_spec.js @@ -1,22 +1,21 @@ -import Vue from 'vue'; -import tableRowComp from '~/pipelines/components/pipelines_table_row.vue'; +import { mount } from '@vue/test-utils'; +import PipelinesTableRowComponent from '~/pipelines/components/pipelines_table_row.vue'; import eventHub from '~/pipelines/event_hub'; describe('Pipelines Table Row', () => { const jsonFixtureName = 'pipelines/pipelines.json'; - const buildComponent = pipeline => { - const PipelinesTableRowComponent = Vue.extend(tableRowComp); - return new PipelinesTableRowComponent({ - el: document.querySelector('.test-dom-element'), + + const createWrapper = pipeline => + mount(PipelinesTableRowComponent, { propsData: { pipeline, autoDevopsHelpPath: 'foo', viewType: 'root', }, - }).$mount(); - }; + sync: false, + }); - let component; + let wrapper; let pipeline; let pipelineWithoutAuthor; let pipelineWithoutCommit; @@ -32,28 +31,29 @@ describe('Pipelines Table Row', () => { }); afterEach(() => { - component.$destroy(); + wrapper.destroy(); + wrapper = null; }); it('should render a table row', () => { - component = buildComponent(pipeline); + wrapper = createWrapper(pipeline); - expect(component.$el.getAttribute('class')).toContain('gl-responsive-table-row'); + expect(wrapper.attributes('class')).toContain('gl-responsive-table-row'); }); describe('status column', () => { beforeEach(() => { - component = buildComponent(pipeline); + wrapper = createWrapper(pipeline); }); it('should render a pipeline link', () => { - expect( - component.$el.querySelector('.table-section.commit-link a').getAttribute('href'), - ).toEqual(pipeline.path); + expect(wrapper.find('.table-section.commit-link a').attributes('href')).toEqual( + pipeline.path, + ); }); it('should render status text', () => { - expect(component.$el.querySelector('.table-section.commit-link a').textContent).toContain( + expect(wrapper.find('.table-section.commit-link a').text()).toContain( pipeline.details.status.text, ); }); @@ -61,33 +61,32 @@ describe('Pipelines Table Row', () => { describe('information column', () => { beforeEach(() => { - component = buildComponent(pipeline); + wrapper = createWrapper(pipeline); }); it('should render a pipeline link', () => { - expect( - component.$el.querySelector('.table-section:nth-child(2) a').getAttribute('href'), - ).toEqual(pipeline.path); + expect(wrapper.find('.table-section:nth-child(2) a').attributes('href')).toEqual( + pipeline.path, + ); }); it('should render pipeline ID', () => { - expect( - component.$el.querySelector('.table-section:nth-child(2) a > span').textContent, - ).toEqual(`#${pipeline.id}`); + expect(wrapper.find('.table-section:nth-child(2) a > span').text()).toEqual( + `#${pipeline.id}`, + ); }); describe('when a user is provided', () => { it('should render user information', () => { expect( - component.$el - .querySelector('.table-section:nth-child(3) .js-pipeline-url-user') - .getAttribute('href'), + wrapper.find('.table-section:nth-child(3) .js-pipeline-url-user').attributes('href'), ).toEqual(pipeline.user.path); expect( - component.$el - .querySelector('.table-section:nth-child(3) .js-user-avatar-image-toolip') - .textContent.trim(), + wrapper + .find('.table-section:nth-child(3) .js-user-avatar-image-toolip') + .text() + .trim(), ).toEqual(pipeline.user.name); }); }); @@ -95,40 +94,47 @@ describe('Pipelines Table Row', () => { describe('commit column', () => { it('should render link to commit', () => { - component = buildComponent(pipeline); + wrapper = createWrapper(pipeline); - const commitLink = component.$el.querySelector('.branch-commit .commit-sha'); + const commitLink = wrapper.find('.branch-commit .commit-sha'); - expect(commitLink.getAttribute('href')).toEqual(pipeline.commit.commit_path); + expect(commitLink.attributes('href')).toEqual(pipeline.commit.commit_path); }); const findElements = () => { - const commitTitleElement = component.$el.querySelector('.branch-commit .commit-title'); - const commitAuthorElement = commitTitleElement.querySelector('a.avatar-image-container'); + const commitTitleElement = wrapper.find('.branch-commit .commit-title'); + const commitAuthorElement = commitTitleElement.find('a.avatar-image-container'); - if (!commitAuthorElement) { - return { commitAuthorElement }; + if (!commitAuthorElement.exists()) { + return { + commitAuthorElement, + }; } - const commitAuthorLink = commitAuthorElement.getAttribute('href'); + const commitAuthorLink = commitAuthorElement.attributes('href'); const commitAuthorName = commitAuthorElement - .querySelector('.js-user-avatar-image-toolip') - .textContent.trim(); - - return { commitAuthorElement, commitAuthorLink, commitAuthorName }; + .find('.js-user-avatar-image-toolip') + .text() + .trim(); + + return { + commitAuthorElement, + commitAuthorLink, + commitAuthorName, + }; }; it('renders nothing without commit', () => { expect(pipelineWithoutCommit.commit).toBe(null); - component = buildComponent(pipelineWithoutCommit); + wrapper = createWrapper(pipelineWithoutCommit); const { commitAuthorElement } = findElements(); - expect(commitAuthorElement).toBe(null); + expect(commitAuthorElement.exists()).toBe(false); }); it('renders commit author', () => { - component = buildComponent(pipeline); + wrapper = createWrapper(pipeline); const { commitAuthorLink, commitAuthorName } = findElements(); expect(commitAuthorLink).toEqual(pipeline.commit.author.path); @@ -137,8 +143,8 @@ describe('Pipelines Table Row', () => { it('renders commit with unregistered author', () => { expect(pipelineWithoutAuthor.commit.author).toBe(null); - component = buildComponent(pipelineWithoutAuthor); + wrapper = createWrapper(pipelineWithoutAuthor); const { commitAuthorLink, commitAuthorName } = findElements(); expect(commitAuthorLink).toEqual(`mailto:${pipelineWithoutAuthor.commit.author_email}`); @@ -148,13 +154,12 @@ describe('Pipelines Table Row', () => { describe('stages column', () => { beforeEach(() => { - component = buildComponent(pipeline); + wrapper = createWrapper(pipeline); }); it('should render an icon for each stage', () => { expect( - component.$el.querySelectorAll('.table-section:nth-child(4) .js-builds-dropdown-button') - .length, + wrapper.findAll('.table-section:nth-child(4) .js-builds-dropdown-button').length, ).toEqual(pipeline.details.stages.length); }); }); @@ -172,44 +177,49 @@ describe('Pipelines Table Row', () => { withActions.cancel_path = '/cancel'; withActions.retry_path = '/retry'; - component = buildComponent(withActions); + wrapper = createWrapper(withActions); }); it('should render the provided actions', () => { - expect(component.$el.querySelector('.js-pipelines-retry-button')).not.toBeNull(); - expect(component.$el.querySelector('.js-pipelines-cancel-button')).not.toBeNull(); - const dropdownMenu = component.$el.querySelectorAll('.dropdown-menu'); + expect(wrapper.find('.js-pipelines-retry-button').exists()).toBe(true); + expect(wrapper.find('.js-pipelines-cancel-button').exists()).toBe(true); + const dropdownMenu = wrapper.find('.dropdown-menu'); - expect(dropdownMenu).toContainText(scheduledJobAction.name); + expect(dropdownMenu.text()).toContain(scheduledJobAction.name); }); it('emits `retryPipeline` event when retry button is clicked and toggles loading', () => { eventHub.$on('retryPipeline', endpoint => { - expect(endpoint).toEqual('/retry'); + expect(endpoint).toBe('/retry'); }); - component.$el.querySelector('.js-pipelines-retry-button').click(); - - expect(component.isRetrying).toEqual(true); + wrapper.find('.js-pipelines-retry-button').trigger('click'); + expect(wrapper.vm.isRetrying).toBe(true); }); it('emits `openConfirmationModal` event when cancel button is clicked and toggles loading', () => { eventHub.$once('openConfirmationModal', data => { const { id, ref, commit } = pipeline; - expect(data.endpoint).toEqual('/cancel'); - expect(data.pipeline).toEqual(jasmine.objectContaining({ id, ref, commit })); + expect(data.endpoint).toBe('/cancel'); + expect(data.pipeline).toEqual( + expect.objectContaining({ + id, + ref, + commit, + }), + ); }); - component.$el.querySelector('.js-pipelines-cancel-button').click(); + wrapper.find('.js-pipelines-cancel-button').trigger('click'); }); it('renders a loading icon when `cancelingPipeline` matches pipeline id', done => { - component.cancelingPipeline = pipeline.id; - component + wrapper.setProps({ cancelingPipeline: pipeline.id }); + wrapper.vm .$nextTick() .then(() => { - expect(component.isCancelling).toEqual(true); + expect(wrapper.vm.isCancelling).toBe(true); }) .then(done) .catch(done.fail); diff --git a/spec/frontend/pipelines/test_reports/mock_data.js b/spec/frontend/pipelines/test_reports/mock_data.js new file mode 100644 index 0000000000000000000000000000000000000000..b0f22bc63fbe25eabec675828c0da201e7b465c6 --- /dev/null +++ b/spec/frontend/pipelines/test_reports/mock_data.js @@ -0,0 +1,123 @@ +import { formatTime } from '~/lib/utils/datetime_utility'; +import { TestStatus } from '~/pipelines/constants'; + +export const testCases = [ + { + classname: 'spec.test_spec', + execution_time: 0.000748, + name: 'Test#subtract when a is 1 and b is 2 raises an error', + stack_trace: null, + status: TestStatus.SUCCESS, + system_output: null, + }, + { + classname: 'spec.test_spec', + execution_time: 0.000064, + name: 'Test#subtract when a is 2 and b is 1 returns correct result', + stack_trace: null, + status: TestStatus.SUCCESS, + system_output: null, + }, + { + classname: 'spec.test_spec', + execution_time: 0.009292, + name: 'Test#sum when a is 1 and b is 2 returns summary', + stack_trace: null, + status: TestStatus.FAILED, + system_output: + "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in <top (required)>'", + }, + { + classname: 'spec.test_spec', + execution_time: 0.00018, + name: 'Test#sum when a is 100 and b is 200 returns summary', + stack_trace: null, + status: TestStatus.FAILED, + system_output: + "Failure/Error: is_expected.to eq(300)\n\n expected: 300\n got: -100\n\n (compared using ==)\n./spec/test_spec.rb:21:in `block (4 levels) in <top (required)>'", + }, + { + classname: 'spec.test_spec', + execution_time: 0, + name: 'Test#skipped text', + stack_trace: null, + status: TestStatus.SKIPPED, + system_output: null, + }, +]; + +export const testCasesFormatted = [ + { + ...testCases[2], + icon: 'status_failed_borderless', + formattedTime: formatTime(testCases[0].execution_time * 1000), + }, + { + ...testCases[3], + icon: 'status_failed_borderless', + formattedTime: formatTime(testCases[1].execution_time * 1000), + }, + { + ...testCases[4], + icon: 'status_skipped_borderless', + formattedTime: formatTime(testCases[2].execution_time * 1000), + }, + { + ...testCases[0], + icon: 'status_success_borderless', + formattedTime: formatTime(testCases[3].execution_time * 1000), + }, + { + ...testCases[1], + icon: 'status_success_borderless', + formattedTime: formatTime(testCases[4].execution_time * 1000), + }, +]; + +export const testSuites = [ + { + error_count: 0, + failed_count: 2, + name: 'rspec:osx', + skipped_count: 0, + success_count: 2, + test_cases: testCases, + total_count: 4, + total_time: 60, + }, + { + error_count: 0, + failed_count: 10, + name: 'rspec:osx', + skipped_count: 0, + success_count: 50, + test_cases: [], + total_count: 60, + total_time: 0.010284, + }, +]; + +export const testSuitesFormatted = testSuites.map(x => ({ + ...x, + formattedTime: formatTime(x.total_time * 1000), +})); + +export const testReports = { + error_count: 0, + failed_count: 2, + skipped_count: 0, + success_count: 2, + test_suites: testSuites, + total_count: 4, + total_time: 0.010284, +}; + +export const testReportsWithNoSuites = { + error_count: 0, + failed_count: 2, + skipped_count: 0, + success_count: 2, + test_suites: [], + total_count: 4, + total_time: 0.010284, +}; diff --git a/spec/frontend/pipelines/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c1721e1223451c6f64d7f919a93cc4212b79018c --- /dev/null +++ b/spec/frontend/pipelines/test_reports/stores/actions_spec.js @@ -0,0 +1,109 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import * as actions from '~/pipelines/stores/test_reports/actions'; +import * as types from '~/pipelines/stores/test_reports/mutation_types'; +import { TEST_HOST } from '../../../helpers/test_constants'; +import testAction from '../../../helpers/vuex_action_helper'; +import createFlash from '~/flash'; +import { testReports } from '../mock_data'; + +jest.mock('~/flash.js'); + +describe('Actions TestReports Store', () => { + let mock; + let state; + + const endpoint = `${TEST_HOST}/test_reports.json`; + const defaultState = { + endpoint, + testReports: {}, + selectedSuite: {}, + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + state = defaultState; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('fetch reports', () => { + beforeEach(() => { + mock.onGet(`${TEST_HOST}/test_reports.json`).replyOnce(200, testReports, {}); + }); + + it('sets testReports and shows tests', done => { + testAction( + actions.fetchReports, + null, + state, + [{ type: types.SET_REPORTS, payload: testReports }], + [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], + done, + ); + }); + + it('should create flash on API error', done => { + testAction( + actions.fetchReports, + null, + { + endpoint: null, + }, + [], + [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], + () => { + expect(createFlash).toHaveBeenCalled(); + done(); + }, + ); + }); + }); + + describe('set selected suite', () => { + const selectedSuite = testReports.test_suites[0]; + + it('sets selectedSuite', done => { + testAction( + actions.setSelectedSuite, + selectedSuite, + state, + [{ type: types.SET_SELECTED_SUITE, payload: selectedSuite }], + [], + done, + ); + }); + }); + + describe('remove selected suite', () => { + it('sets selectedSuite to {}', done => { + testAction( + actions.removeSelectedSuite, + {}, + state, + [{ type: types.SET_SELECTED_SUITE, payload: {} }], + [], + done, + ); + }); + }); + + describe('toggles loading', () => { + it('sets isLoading to true', done => { + testAction(actions.toggleLoading, {}, state, [{ type: types.TOGGLE_LOADING }], [], done); + }); + + it('toggles isLoading to false', done => { + testAction( + actions.toggleLoading, + {}, + { ...state, isLoading: true }, + [{ type: types.TOGGLE_LOADING }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/stores/getters_spec.js b/spec/frontend/pipelines/test_reports/stores/getters_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e630a005409b664d237b04f659fde2c8a402c6da --- /dev/null +++ b/spec/frontend/pipelines/test_reports/stores/getters_spec.js @@ -0,0 +1,54 @@ +import * as getters from '~/pipelines/stores/test_reports/getters'; +import { testReports, testSuitesFormatted, testCasesFormatted } from '../mock_data'; + +describe('Getters TestReports Store', () => { + let state; + + const defaultState = { + testReports, + selectedSuite: testReports.test_suites[0], + }; + + const emptyState = { + testReports: {}, + selectedSuite: {}, + }; + + beforeEach(() => { + state = { + testReports, + }; + }); + + const setupState = (testState = defaultState) => { + state = testState; + }; + + describe('getTestSuites', () => { + it('should return the test suites', () => { + setupState(); + + expect(getters.getTestSuites(state)).toEqual(testSuitesFormatted); + }); + + it('should return an empty array when testReports is empty', () => { + setupState(emptyState); + + expect(getters.getTestSuites(state)).toEqual([]); + }); + }); + + describe('getSuiteTests', () => { + it('should return the test cases inside the suite', () => { + setupState(); + + expect(getters.getSuiteTests(state)).toEqual(testCasesFormatted); + }); + + it('should return an empty array when testReports is empty', () => { + setupState(emptyState); + + expect(getters.getSuiteTests(state)).toEqual([]); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..ad5b7f911637abe3420c821240f47a9901a16840 --- /dev/null +++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js @@ -0,0 +1,63 @@ +import * as types from '~/pipelines/stores/test_reports/mutation_types'; +import mutations from '~/pipelines/stores/test_reports/mutations'; +import { testReports, testSuites } from '../mock_data'; + +describe('Mutations TestReports Store', () => { + let mockState; + + const defaultState = { + endpoint: '', + testReports: {}, + selectedSuite: {}, + isLoading: false, + }; + + beforeEach(() => { + mockState = defaultState; + }); + + describe('set endpoint', () => { + it('should set endpoint', () => { + const expectedState = Object.assign({}, mockState, { endpoint: 'foo' }); + mutations[types.SET_ENDPOINT](mockState, 'foo'); + + expect(mockState.endpoint).toEqual(expectedState.endpoint); + }); + }); + + describe('set reports', () => { + it('should set testReports', () => { + const expectedState = Object.assign({}, mockState, { testReports }); + mutations[types.SET_REPORTS](mockState, testReports); + + expect(mockState.testReports).toEqual(expectedState.testReports); + }); + }); + + describe('set selected suite', () => { + it('should set selectedSuite', () => { + const expectedState = Object.assign({}, mockState, { selectedSuite: testSuites[0] }); + mutations[types.SET_SELECTED_SUITE](mockState, testSuites[0]); + + expect(mockState.selectedSuite).toEqual(expectedState.selectedSuite); + }); + }); + + describe('toggle loading', () => { + it('should set to true', () => { + const expectedState = Object.assign({}, mockState, { isLoading: true }); + mutations[types.TOGGLE_LOADING](mockState); + + expect(mockState.isLoading).toEqual(expectedState.isLoading); + }); + + it('should toggle back to false', () => { + const expectedState = Object.assign({}, mockState, { isLoading: false }); + mockState.isLoading = true; + + mutations[types.TOGGLE_LOADING](mockState); + + expect(mockState.isLoading).toEqual(expectedState.isLoading); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/test_reports_spec.js b/spec/frontend/pipelines/test_reports/test_reports_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..4d6422745a9d9397899916b12b08b6115b0d0a44 --- /dev/null +++ b/spec/frontend/pipelines/test_reports/test_reports_spec.js @@ -0,0 +1,64 @@ +import Vuex from 'vuex'; +import TestReports from '~/pipelines/components/test_reports/test_reports.vue'; +import { shallowMount } from '@vue/test-utils'; +import { testReports } from './mock_data'; +import * as actions from '~/pipelines/stores/test_reports/actions'; + +describe('Test reports app', () => { + let wrapper; + let store; + + const loadingSpinner = () => wrapper.find('.js-loading-spinner'); + const testsDetail = () => wrapper.find('.js-tests-detail'); + const noTestsToShow = () => wrapper.find('.js-no-tests-to-show'); + + const createComponent = (state = {}) => { + store = new Vuex.Store({ + state: { + isLoading: false, + selectedSuite: {}, + testReports, + ...state, + }, + actions, + }); + + wrapper = shallowMount(TestReports, { + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when loading', () => { + beforeEach(() => createComponent({ isLoading: true })); + + it('shows the loading spinner', () => { + expect(noTestsToShow().exists()).toBe(false); + expect(testsDetail().exists()).toBe(false); + expect(loadingSpinner().exists()).toBe(true); + }); + }); + + describe('when the api returns no data', () => { + beforeEach(() => createComponent({ testReports: {} })); + + it('displays that there are no tests to show', () => { + const noTests = noTestsToShow(); + + expect(noTests.exists()).toBe(true); + expect(noTests.text()).toBe('There are no tests to show.'); + }); + }); + + describe('when the api returns data', () => { + beforeEach(() => createComponent()); + + it('sets testReports and shows tests', () => { + expect(wrapper.vm.testReports).toBeTruthy(); + expect(wrapper.vm.showTests).toBeTruthy(); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..b4305719ea87e0b8ec33a5ff052ef97fa04678b1 --- /dev/null +++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js @@ -0,0 +1,77 @@ +import Vuex from 'vuex'; +import SuiteTable from '~/pipelines/components/test_reports/test_suite_table.vue'; +import * as getters from '~/pipelines/stores/test_reports/getters'; +import { TestStatus } from '~/pipelines/constants'; +import { shallowMount } from '@vue/test-utils'; +import { testSuites, testCases } from './mock_data'; + +describe('Test reports suite table', () => { + let wrapper; + let store; + + const noCasesMessage = () => wrapper.find('.js-no-test-cases'); + const allCaseRows = () => wrapper.findAll('.js-case-row'); + const findCaseRowAtIndex = index => wrapper.findAll('.js-case-row').at(index); + const findIconForRow = (row, status) => row.find(`.ci-status-icon-${status}`); + + const createComponent = (suite = testSuites[0]) => { + store = new Vuex.Store({ + state: { + selectedSuite: suite, + }, + getters, + }); + + wrapper = shallowMount(SuiteTable, { + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('should not render', () => { + beforeEach(() => createComponent([])); + + it('a table when there are no test cases', () => { + expect(noCasesMessage().exists()).toBe(true); + }); + }); + + describe('when a test suite is supplied', () => { + beforeEach(() => createComponent()); + + it('renders the correct number of rows', () => { + expect(allCaseRows().length).toBe(testCases.length); + }); + + it('renders the failed tests first', () => { + const failedCaseNames = testCases + .filter(x => x.status === TestStatus.FAILED) + .map(x => x.name); + + const skippedCaseNames = testCases + .filter(x => x.status === TestStatus.SKIPPED) + .map(x => x.name); + + expect(findCaseRowAtIndex(0).text()).toContain(failedCaseNames[0]); + expect(findCaseRowAtIndex(1).text()).toContain(failedCaseNames[1]); + expect(findCaseRowAtIndex(2).text()).toContain(skippedCaseNames[0]); + }); + + it('renders the correct icon for each status', () => { + const failedTest = testCases.findIndex(x => x.status === TestStatus.FAILED); + const skippedTest = testCases.findIndex(x => x.status === TestStatus.SKIPPED); + const successTest = testCases.findIndex(x => x.status === TestStatus.SUCCESS); + + const failedRow = findCaseRowAtIndex(failedTest); + const skippedRow = findCaseRowAtIndex(skippedTest); + const successRow = findCaseRowAtIndex(successTest); + + expect(findIconForRow(failedRow, TestStatus.FAILED).exists()).toBe(true); + expect(findIconForRow(skippedRow, TestStatus.SKIPPED).exists()).toBe(true); + expect(findIconForRow(successRow, TestStatus.SUCCESS).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/test_summary_spec.js b/spec/frontend/pipelines/test_reports/test_summary_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..19a7755dbdc3e26997aa3458b650b1ae95f500af --- /dev/null +++ b/spec/frontend/pipelines/test_reports/test_summary_spec.js @@ -0,0 +1,78 @@ +import Summary from '~/pipelines/components/test_reports/test_summary.vue'; +import { mount } from '@vue/test-utils'; +import { testSuites } from './mock_data'; + +describe('Test reports summary', () => { + let wrapper; + + const backButton = () => wrapper.find('.js-back-button'); + const totalTests = () => wrapper.find('.js-total-tests'); + const failedTests = () => wrapper.find('.js-failed-tests'); + const erroredTests = () => wrapper.find('.js-errored-tests'); + const successRate = () => wrapper.find('.js-success-rate'); + const duration = () => wrapper.find('.js-duration'); + + const defaultProps = { + report: testSuites[0], + showBack: false, + }; + + const createComponent = props => { + wrapper = mount(Summary, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + describe('should not render', () => { + beforeEach(() => { + createComponent(); + }); + + it('a back button by default', () => { + expect(backButton().exists()).toBe(false); + }); + }); + + describe('should render', () => { + beforeEach(() => { + createComponent(); + }); + + it('a back button and emit on-back-click event', () => { + createComponent({ + showBack: true, + }); + + expect(backButton().exists()).toBe(true); + }); + }); + + describe('when a report is supplied', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays the correct total', () => { + expect(totalTests().text()).toBe('4 jobs'); + }); + + it('displays the correct failure count', () => { + expect(failedTests().text()).toBe('2 failures'); + }); + + it('displays the correct error count', () => { + expect(erroredTests().text()).toBe('0 errors'); + }); + + it('calculates and displays percentages correctly', () => { + expect(successRate().text()).toBe('50% success rate'); + }); + + it('displays the correctly formatted duration', () => { + expect(duration().text()).toBe('00:01:00'); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e7599d5cdbc188ec6c390b4e8dac91b6312e3bdb --- /dev/null +++ b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js @@ -0,0 +1,54 @@ +import Vuex from 'vuex'; +import SummaryTable from '~/pipelines/components/test_reports/test_summary_table.vue'; +import * as getters from '~/pipelines/stores/test_reports/getters'; +import { mount, createLocalVue } from '@vue/test-utils'; +import { testReports, testReportsWithNoSuites } from './mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Test reports summary table', () => { + let wrapper; + let store; + + const allSuitesRows = () => wrapper.findAll('.js-suite-row'); + const noSuitesToShow = () => wrapper.find('.js-no-tests-suites'); + + const defaultProps = { + testReports, + }; + + const createComponent = (reports = null) => { + store = new Vuex.Store({ + state: { + testReports: reports || testReports, + }, + getters, + }); + + wrapper = mount(SummaryTable, { + propsData: defaultProps, + store, + localVue, + }); + }; + + describe('when test reports are supplied', () => { + beforeEach(() => createComponent()); + + it('renders the correct number of rows', () => { + expect(noSuitesToShow().exists()).toBe(false); + expect(allSuitesRows().length).toBe(testReports.test_suites.length); + }); + }); + + describe('when there are no test suites', () => { + beforeEach(() => { + createComponent({ testReportsWithNoSuites }); + }); + + it('displays the no suites to show message', () => { + expect(noSuitesToShow().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/project_find_file_spec.js b/spec/frontend/project_find_file_spec.js index 8102033139fe55ee83de4a20b8f716e0f9e627d9..e60f9f62747e090d30a46e886ebdae5679743722 100644 --- a/spec/frontend/project_find_file_spec.js +++ b/spec/frontend/project_find_file_spec.js @@ -3,6 +3,9 @@ import $ from 'jquery'; import ProjectFindFile from '~/project_find_file'; import axios from '~/lib/utils/axios_utils'; import { TEST_HOST } from 'helpers/test_constants'; +import sanitize from 'sanitize-html'; + +jest.mock('sanitize-html', () => jest.fn(val => val)); const BLOB_URL_TEMPLATE = `${TEST_HOST}/namespace/project/blob/master`; const FILE_FIND_URL = `${TEST_HOST}/namespace/project/files/master?format=json`; @@ -38,31 +41,31 @@ describe('ProjectFindFile', () => { href: el.querySelector('a').href, })); + const files = [ + 'fileA.txt', + 'fileB.txt', + 'fi#leC.txt', + 'folderA/fileD.txt', + 'folder#B/fileE.txt', + 'folde?rC/fil#F.txt', + ]; + beforeEach(() => { // Create a mock adapter for stubbing axios API requests mock = new MockAdapter(axios); element = $(TEMPLATE); + mock.onGet(FILE_FIND_URL).replyOnce(200, files); + getProjectFindFileInstance(); // This triggers a load / axios call + subsequent render in the constructor }); afterEach(() => { // Reset the mock adapter mock.restore(); + sanitize.mockClear(); }); it('loads and renders elements from remote server', done => { - const files = [ - 'fileA.txt', - 'fileB.txt', - 'fi#leC.txt', - 'folderA/fileD.txt', - 'folder#B/fileE.txt', - 'folde?rC/fil#F.txt', - ]; - mock.onGet(FILE_FIND_URL).replyOnce(200, files); - - getProjectFindFileInstance(); // This triggers a load / axios call + subsequent render in the constructor - setImmediate(() => { expect(findFiles()).toEqual( files.map(text => ({ @@ -74,4 +77,14 @@ describe('ProjectFindFile', () => { done(); }); }); + + it('sanitizes search text', done => { + const searchText = element.find('.file-finder-input').val(); + + setImmediate(() => { + expect(sanitize).toHaveBeenCalledTimes(1); + expect(sanitize).toHaveBeenCalledWith(searchText); + done(); + }); + }); }); diff --git a/spec/frontend/registry/components/collapsible_container_spec.js b/spec/frontend/registry/components/collapsible_container_spec.js index f93ebab1a4d02ce2c47d11f9c30173ae97f1a4a4..d035055afd3b01784b42da489fe8705fa6bfecdc 100644 --- a/spec/frontend/registry/components/collapsible_container_spec.js +++ b/spec/frontend/registry/components/collapsible_container_spec.js @@ -1,10 +1,11 @@ import Vue from 'vue'; import Vuex from 'vuex'; import { mount, createLocalVue } from '@vue/test-utils'; -import collapsibleComponent from '~/registry/components/collapsible_container.vue'; -import { repoPropsData } from '../mock_data'; import createFlash from '~/flash'; +import Tracking from '~/tracking'; +import collapsibleComponent from '~/registry/components/collapsible_container.vue'; import * as getters from '~/registry/stores/getters'; +import { repoPropsData } from '../mock_data'; jest.mock('~/flash.js'); @@ -16,9 +17,10 @@ describe('collapsible registry container', () => { let wrapper; let store; - const findDeleteBtn = w => w.find('.js-remove-repo'); - const findContainerImageTags = w => w.find('.container-image-tags'); - const findToggleRepos = w => w.findAll('.js-toggle-repo'); + const findDeleteBtn = (w = wrapper) => w.find('.js-remove-repo'); + const findContainerImageTags = (w = wrapper) => w.find('.container-image-tags'); + const findToggleRepos = (w = wrapper) => w.findAll('.js-toggle-repo'); + const findDeleteModal = (w = wrapper) => w.find({ ref: 'deleteModal' }); const mountWithStore = config => mount(collapsibleComponent, { ...config, store, localVue }); @@ -124,4 +126,45 @@ describe('collapsible registry container', () => { expect(deleteBtn.exists()).toBe(false); }); }); + + describe('tracking', () => { + const category = 'mock_page'; + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + wrapper.vm.deleteItem = jest.fn().mockResolvedValue(); + wrapper.vm.fetchRepos = jest.fn(); + wrapper.setData({ + tracking: { + ...wrapper.vm.tracking, + category, + }, + }); + }); + + it('send an event when delete button is clicked', () => { + const deleteBtn = findDeleteBtn(); + deleteBtn.trigger('click'); + expect(Tracking.event).toHaveBeenCalledWith(category, 'click_button', { + label: 'registry_repository_delete', + category, + }); + }); + it('send an event when cancel is pressed on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('cancel'); + expect(Tracking.event).toHaveBeenCalledWith(category, 'cancel_delete', { + label: 'registry_repository_delete', + category, + }); + }); + it('send an event when confirm is clicked on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('ok'); + + expect(Tracking.event).toHaveBeenCalledWith(category, 'confirm_delete', { + label: 'registry_repository_delete', + category, + }); + }); + }); }); diff --git a/spec/frontend/registry/components/table_registry_spec.js b/spec/frontend/registry/components/table_registry_spec.js index 7cb7c012d9d26dafdaf1abbf62b40d735e2c4eec..ab88caf44e12601f3987caf6f4cfebbfc845a7cd 100644 --- a/spec/frontend/registry/components/table_registry_spec.js +++ b/spec/frontend/registry/components/table_registry_spec.js @@ -1,10 +1,14 @@ import Vue from 'vue'; import Vuex from 'vuex'; -import tableRegistry from '~/registry/components/table_registry.vue'; import { mount, createLocalVue } from '@vue/test-utils'; +import createFlash from '~/flash'; +import Tracking from '~/tracking'; +import tableRegistry from '~/registry/components/table_registry.vue'; import { repoPropsData } from '../mock_data'; import * as getters from '~/registry/stores/getters'; +jest.mock('~/flash'); + const [firstImage, secondImage] = repoPropsData.list; const localVue = createLocalVue(); @@ -15,11 +19,12 @@ describe('table registry', () => { let wrapper; let store; - const findSelectAllCheckbox = w => w.find('.js-select-all-checkbox > input'); - const findSelectCheckboxes = w => w.findAll('.js-select-checkbox > input'); - const findDeleteButton = w => w.find('.js-delete-registry'); - const findDeleteButtonsRow = w => w.findAll('.js-delete-registry-row'); - const findPagination = w => w.find('.js-registry-pagination'); + const findSelectAllCheckbox = (w = wrapper) => w.find('.js-select-all-checkbox > input'); + const findSelectCheckboxes = (w = wrapper) => w.findAll('.js-select-checkbox > input'); + const findDeleteButton = (w = wrapper) => w.find({ ref: 'bulkDeleteButton' }); + const findDeleteButtonsRow = (w = wrapper) => w.findAll('.js-delete-registry-row'); + const findPagination = (w = wrapper) => w.find('.js-registry-pagination'); + const findDeleteModal = (w = wrapper) => w.find({ ref: 'deleteModal' }); const bulkDeletePath = 'path'; const mountWithStore = config => mount(tableRegistry, { ...config, store, localVue }); @@ -139,7 +144,7 @@ describe('table registry', () => { }, }); wrapper.vm.handleMultipleDelete(); - expect(wrapper.vm.showError).toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalled(); }); }); @@ -169,6 +174,27 @@ describe('table registry', () => { }); }); + describe('modal event handlers', () => { + beforeEach(() => { + wrapper.vm.handleSingleDelete = jest.fn(); + wrapper.vm.handleMultipleDelete = jest.fn(); + }); + it('on ok when one item is selected should call singleDelete', () => { + wrapper.setData({ itemsToBeDeleted: [0] }); + wrapper.vm.onDeletionConfirmed(); + + expect(wrapper.vm.handleSingleDelete).toHaveBeenCalledWith(repoPropsData.list[0]); + expect(wrapper.vm.handleMultipleDelete).not.toHaveBeenCalled(); + }); + it('on ok when multiple items are selected should call muultiDelete', () => { + wrapper.setData({ itemsToBeDeleted: [0, 1, 2] }); + wrapper.vm.onDeletionConfirmed(); + + expect(wrapper.vm.handleMultipleDelete).toHaveBeenCalled(); + expect(wrapper.vm.handleSingleDelete).not.toHaveBeenCalled(); + }); + }); + describe('pagination', () => { const repo = { repoPropsData, @@ -265,4 +291,83 @@ describe('table registry', () => { expect(deleteBtns.length).toBe(0); }); }); + + describe('event tracking', () => { + const mockPageName = 'mock_page'; + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + wrapper.vm.handleSingleDelete = jest.fn(); + wrapper.vm.handleMultipleDelete = jest.fn(); + document.body.dataset.page = mockPageName; + }); + + afterEach(() => { + document.body.dataset.page = null; + }); + + describe('single tag delete', () => { + beforeEach(() => { + wrapper.setData({ itemsToBeDeleted: [0] }); + }); + + it('send an event when delete button is clicked', () => { + const deleteBtn = findDeleteButtonsRow(); + deleteBtn.at(0).trigger('click'); + expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'click_button', { + label: 'registry_tag_delete', + property: 'foo', + }); + }); + it('send an event when cancel is pressed on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('cancel'); + expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'cancel_delete', { + label: 'registry_tag_delete', + property: 'foo', + }); + }); + it('send an event when confirm is clicked on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('ok'); + + expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'confirm_delete', { + label: 'registry_tag_delete', + property: 'foo', + }); + }); + }); + describe('bulk tag delete', () => { + beforeEach(() => { + const items = [0, 1, 2]; + wrapper.setData({ itemsToBeDeleted: items, selectedItems: items }); + }); + + it('send an event when delete button is clicked', () => { + const deleteBtn = findDeleteButton(); + deleteBtn.vm.$emit('click'); + expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'click_button', { + label: 'bulk_registry_tag_delete', + property: 'foo', + }); + }); + it('send an event when cancel is pressed on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('cancel'); + expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'cancel_delete', { + label: 'bulk_registry_tag_delete', + property: 'foo', + }); + }); + it('send an event when confirm is clicked on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('ok'); + + expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'confirm_delete', { + label: 'bulk_registry_tag_delete', + property: 'foo', + }); + }); + }); + }); }); diff --git a/spec/frontend/releases/detail/components/app_spec.js b/spec/frontend/releases/detail/components/app_spec.js index f8eb33a69a8822d1ec4f298f5357622389008a50..4726f18c8fac91cccc5286436a1e4c2066e3cc46 100644 --- a/spec/frontend/releases/detail/components/app_spec.js +++ b/spec/frontend/releases/detail/components/app_spec.js @@ -8,15 +8,17 @@ describe('Release detail component', () => { let wrapper; let releaseClone; let actions; + let state; beforeEach(() => { gon.api_version = 'v4'; releaseClone = JSON.parse(JSON.stringify(convertObjectPropsToCamelCase(release))); - const state = { + state = { release: releaseClone, markdownDocsPath: 'path/to/markdown/docs', + updateReleaseApiDocsPath: 'path/to/update/release/api/docs', }; actions = { @@ -46,6 +48,21 @@ describe('Release detail component', () => { expect(wrapper.find('#git-ref').element.value).toBe(releaseClone.tagName); }); + it('renders the correct help text under the "Tag name" field', () => { + const helperText = wrapper.find('#tag-name-help'); + const helperTextLink = helperText.find('a'); + const helperTextLinkAttrs = helperTextLink.attributes(); + + expect(helperText.text()).toBe( + 'Changing a Release tag is only supported via Releases API. More information', + ); + expect(helperTextLink.text()).toBe('More information'); + expect(helperTextLinkAttrs.href).toBe(state.updateReleaseApiDocsPath); + expect(helperTextLinkAttrs.rel).toContain('noopener'); + expect(helperTextLinkAttrs.rel).toContain('noreferrer'); + expect(helperTextLinkAttrs.target).toBe('_blank'); + }); + it('renders the correct release title in the "Release title" field', () => { expect(wrapper.find('#release-title').element.value).toBe(releaseClone.name); }); diff --git a/spec/frontend/releases/list/components/__snapshots__/release_block_spec.js.snap b/spec/frontend/releases/list/components/__snapshots__/release_block_spec.js.snap deleted file mode 100644 index 8f2c0427c8351efce3c0671c7de56dcd63687b41..0000000000000000000000000000000000000000 --- a/spec/frontend/releases/list/components/__snapshots__/release_block_spec.js.snap +++ /dev/null @@ -1,332 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Release block with default props matches the snapshot 1`] = ` -<div - class="card release-block" - id="v0.3" -> - <div - class="card-body" - > - <div - class="d-flex align-items-start" - > - <h2 - class="card-title mt-0 mr-auto" - > - - New release - - <!----> - </h2> - - <a - class="btn btn-default js-edit-button ml-2" - data-original-title="Edit this release" - href="http://0.0.0.0:3001/root/release-test/-/releases/v0.3/edit" - title="" - > - <svg - aria-hidden="true" - class="s16 ic-pencil" - > - <use - xlink:href="#pencil" - /> - </svg> - </a> - </div> - - <div - class="card-subtitle d-flex flex-wrap text-secondary" - > - <div - class="append-right-8" - > - <svg - aria-hidden="true" - class="align-middle s16 ic-commit" - > - <use - xlink:href="#commit" - /> - </svg> - - <span - data-original-title="Initial commit" - title="" - > - c22b0728 - </span> - </div> - - <div - class="append-right-8" - > - <svg - aria-hidden="true" - class="align-middle s16 ic-tag" - > - <use - xlink:href="#tag" - /> - </svg> - - <span - data-original-title="Tag" - title="" - > - v0.3 - </span> - </div> - - <div - class="js-milestone-list-label" - > - <svg - aria-hidden="true" - class="align-middle s16 ic-flag" - > - <use - xlink:href="#flag" - /> - </svg> - - <span - class="js-label-text" - > - Milestones - </span> - </div> - - <a - class="append-right-4 prepend-left-4 js-milestone-link" - data-original-title="The 13.6 milestone!" - href="http://0.0.0.0:3001/root/release-test/-/milestones/2" - title="" - > - - 13.6 - - </a> - - • - - <a - class="append-right-4 prepend-left-4 js-milestone-link" - data-original-title="The 13.5 milestone!" - href="http://0.0.0.0:3001/root/release-test/-/milestones/1" - title="" - > - - 13.5 - - </a> - - <!----> - - <div - class="append-right-4" - > - - • - - <span - data-original-title="Aug 26, 2019 5:54pm GMT+0000" - title="" - > - - released 1 month ago - - </span> - </div> - - <div - class="d-flex" - > - - by - - <a - class="user-avatar-link prepend-left-4" - href="" - > - <span> - <img - alt="root's avatar" - class="avatar s20 " - data-original-title="" - data-src="https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon" - height="20" - src="https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon" - title="" - width="20" - /> - - <div - aria-hidden="true" - class="js-user-avatar-image-toolip d-none" - style="display: none;" - > - <div> - root - </div> - </div> - </span> - <!----> - </a> - </div> - </div> - - <div - class="card-text prepend-top-default" - > - <b> - - Assets - - <span - class="js-assets-count badge badge-pill" - > - 5 - </span> - </b> - - <ul - class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list" - > - <li - class="append-bottom-8" - > - <a - class="" - data-original-title="Download asset" - href="https://google.com" - title="" - > - <svg - aria-hidden="true" - class="align-middle append-right-4 align-text-bottom s16 ic-package" - > - <use - xlink:href="#package" - /> - </svg> - - my link - - <span> - (external source) - </span> - </a> - </li> - <li - class="append-bottom-8" - > - <a - class="" - data-original-title="Download asset" - href="https://gitlab.com/gitlab-org/gitlab-foss/-/jobs/artifacts/v11.6.0-rc4/download?job=rspec-mysql+41%2F50" - title="" - > - <svg - aria-hidden="true" - class="align-middle append-right-4 align-text-bottom s16 ic-package" - > - <use - xlink:href="#package" - /> - </svg> - - my second link - - <!----> - </a> - </li> - </ul> - - <div - class="dropdown" - > - <button - aria-expanded="false" - aria-haspopup="true" - class="btn btn-link" - data-toggle="dropdown" - type="button" - > - <svg - aria-hidden="true" - class="align-top append-right-4 s16 ic-doc-code" - > - <use - xlink:href="#doc-code" - /> - </svg> - - Source code - - <svg - aria-hidden="true" - class="s16 ic-arrow-down" - > - <use - xlink:href="#arrow-down" - /> - </svg> - </button> - - <div - class="js-sources-dropdown dropdown-menu" - > - <li> - <a - class="" - href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.zip" - > - Download zip - </a> - </li> - <li> - <a - class="" - href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.gz" - > - Download tar.gz - </a> - </li> - <li> - <a - class="" - href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.bz2" - > - Download tar.bz2 - </a> - </li> - <li> - <a - class="" - href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar" - > - Download tar - </a> - </li> - </div> - </div> - </div> - - <div - class="card-text prepend-top-default" - > - <div> - <p - data-sourcepos="1:1-1:21" - dir="auto" - > - A super nice release! - </p> - </div> - </div> - </div> -</div> -`; diff --git a/spec/frontend/releases/list/components/release_block_footer_spec.js b/spec/frontend/releases/list/components/release_block_footer_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..172147f1cc846de0a1719ac6992495e904751a3e --- /dev/null +++ b/spec/frontend/releases/list/components/release_block_footer_spec.js @@ -0,0 +1,163 @@ +import { mount } from '@vue/test-utils'; +import ReleaseBlockFooter from '~/releases/list/components/release_block_footer.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import { GlLink } from '@gitlab/ui'; +import { trimText } from 'helpers/text_helper'; +import { release } from '../../mock_data'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +jest.mock('~/vue_shared/mixins/timeago', () => ({ + methods: { + timeFormated() { + return '7 fortnightes ago'; + }, + tooltipTitle() { + return 'February 30, 2401'; + }, + }, +})); + +describe('Release block footer', () => { + let wrapper; + let releaseClone; + + const factory = (props = {}) => { + wrapper = mount(ReleaseBlockFooter, { + propsData: { + ...convertObjectPropsToCamelCase(releaseClone), + ...props, + }, + sync: false, + }); + + return wrapper.vm.$nextTick(); + }; + + beforeEach(() => { + releaseClone = JSON.parse(JSON.stringify(release)); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const commitInfoSection = () => wrapper.find('.js-commit-info'); + const commitInfoSectionLink = () => commitInfoSection().find(GlLink); + const tagInfoSection = () => wrapper.find('.js-tag-info'); + const tagInfoSectionLink = () => tagInfoSection().find(GlLink); + const authorDateInfoSection = () => wrapper.find('.js-author-date-info'); + + describe('with all props provided', () => { + beforeEach(() => factory()); + + it('renders the commit icon', () => { + const commitIcon = commitInfoSection().find(Icon); + + expect(commitIcon.exists()).toBe(true); + expect(commitIcon.props('name')).toBe('commit'); + }); + + it('renders the commit SHA with a link', () => { + const commitLink = commitInfoSectionLink(); + + expect(commitLink.exists()).toBe(true); + expect(commitLink.text()).toBe(releaseClone.commit.short_id); + expect(commitLink.attributes('href')).toBe(releaseClone.commit_path); + }); + + it('renders the tag icon', () => { + const commitIcon = tagInfoSection().find(Icon); + + expect(commitIcon.exists()).toBe(true); + expect(commitIcon.props('name')).toBe('tag'); + }); + + it('renders the tag name with a link', () => { + const commitLink = tagInfoSection().find(GlLink); + + expect(commitLink.exists()).toBe(true); + expect(commitLink.text()).toBe(releaseClone.tag_name); + expect(commitLink.attributes('href')).toBe(releaseClone.tag_path); + }); + + it('renders the author and creation time info', () => { + expect(trimText(authorDateInfoSection().text())).toBe( + `Created 7 fortnightes ago by ${releaseClone.author.username}`, + ); + }); + + it("renders the author's avatar image", () => { + const avatarImg = authorDateInfoSection().find('img'); + + expect(avatarImg.exists()).toBe(true); + expect(avatarImg.attributes('src')).toBe(releaseClone.author.avatar_url); + }); + + it("renders a link to the author's profile", () => { + const authorLink = authorDateInfoSection().find(GlLink); + + expect(authorLink.exists()).toBe(true); + expect(authorLink.attributes('href')).toBe(releaseClone.author.web_url); + }); + }); + + describe('without any commit info', () => { + beforeEach(() => factory({ commit: undefined })); + + it('does not render any commit info', () => { + expect(commitInfoSection().exists()).toBe(false); + }); + }); + + describe('without a commit URL', () => { + beforeEach(() => factory({ commitPath: undefined })); + + it('renders the commit SHA as plain text (instead of a link)', () => { + expect(commitInfoSectionLink().exists()).toBe(false); + expect(commitInfoSection().text()).toBe(releaseClone.commit.short_id); + }); + }); + + describe('without a tag name', () => { + beforeEach(() => factory({ tagName: undefined })); + + it('does not render any tag info', () => { + expect(tagInfoSection().exists()).toBe(false); + }); + }); + + describe('without a tag URL', () => { + beforeEach(() => factory({ tagPath: undefined })); + + it('renders the tag name as plain text (instead of a link)', () => { + expect(tagInfoSectionLink().exists()).toBe(false); + expect(tagInfoSection().text()).toBe(releaseClone.tag_name); + }); + }); + + describe('without any author info', () => { + beforeEach(() => factory({ author: undefined })); + + it('renders the release date without the author name', () => { + expect(trimText(authorDateInfoSection().text())).toBe('Created 7 fortnightes ago'); + }); + }); + + describe('without a released at date', () => { + beforeEach(() => factory({ releasedAt: undefined })); + + it('renders the author name without the release date', () => { + expect(trimText(authorDateInfoSection().text())).toBe( + `Created by ${releaseClone.author.username}`, + ); + }); + }); + + describe('without a release date or author info', () => { + beforeEach(() => factory({ author: undefined, releasedAt: undefined })); + + it('does not render any author or release date info', () => { + expect(authorDateInfoSection().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/releases/list/components/release_block_spec.js b/spec/frontend/releases/list/components/release_block_spec.js index ac51c3af11a26586c9bd44bc10a544ed27e584f7..b63ef068d8e3e0817ea78bf11d9846460b927491 100644 --- a/spec/frontend/releases/list/components/release_block_spec.js +++ b/spec/frontend/releases/list/components/release_block_spec.js @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils'; import ReleaseBlock from '~/releases/list/components/release_block.vue'; +import ReleaseBlockFooter from '~/releases/list/components/release_block_footer.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { first } from 'underscore'; import { release } from '../../mock_data'; @@ -21,14 +22,16 @@ describe('Release block', () => { let wrapper; let releaseClone; - const factory = (releaseProp, releaseEditPageFeatureFlag = true) => { + const factory = (releaseProp, featureFlags = {}) => { wrapper = mount(ReleaseBlock, { propsData: { release: releaseProp, }, provide: { glFeatures: { - releaseEditPage: releaseEditPageFeatureFlag, + releaseEditPage: true, + releaseIssueSummary: true, + ...featureFlags, }, }, sync: false, @@ -39,41 +42,25 @@ describe('Release block', () => { const milestoneListLabel = () => wrapper.find('.js-milestone-list-label'); const editButton = () => wrapper.find('.js-edit-button'); - const RealDate = Date; beforeEach(() => { - // timeago.js calls Date(), so let's mock that case to avoid time-dependent test failures. - const constantDate = new Date('2019-10-25T00:12:00'); - - /* eslint no-global-assign:off */ - global.Date = jest.fn((...props) => - props.length ? new RealDate(...props) : new RealDate(constantDate), - ); - - Object.assign(Date, RealDate); - releaseClone = JSON.parse(JSON.stringify(release)); }); afterEach(() => { wrapper.destroy(); - global.Date = RealDate; }); describe('with default props', () => { beforeEach(() => factory(release)); - it('matches the snapshot', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - it("renders the block with an id equal to the release's tag name", () => { expect(wrapper.attributes().id).toBe('v0.3'); }); it('renders an edit button that links to the "Edit release" page', () => { expect(editButton().exists()).toBe(true); - expect(editButton().attributes('href')).toBe(release._links.edit); + expect(editButton().attributes('href')).toBe(release._links.edit_url); }); it('renders release name', () => { @@ -158,6 +145,10 @@ describe('Release block', () => { expect(milestoneLink.attributes('data-original-title')).toBe(milestone.description); }); + + it('renders the footer', () => { + expect(wrapper.find(ReleaseBlockFooter).exists()).toBe(true); + }); }); it('renders commit sha', () => { @@ -180,7 +171,7 @@ describe('Release block', () => { }); }); - it("does not render an edit button if release._links.edit isn't a string", () => { + it("does not render an edit button if release._links.edit_url isn't a string", () => { delete releaseClone._links; return factory(releaseClone).then(() => { @@ -189,7 +180,7 @@ describe('Release block', () => { }); it('does not render an edit button if the releaseEditPage feature flag is disabled', () => - factory(releaseClone, false).then(() => { + factory(releaseClone, { releaseEditPage: false }).then(() => { expect(editButton().exists()).toBe(false); })); diff --git a/spec/frontend/releases/mock_data.js b/spec/frontend/releases/mock_data.js index b2ebf1174d499d57b639b85049d4794d551eb40a..61d95b86b1c3a46564bc42a38b585fc0d823bb76 100644 --- a/spec/frontend/releases/mock_data.js +++ b/spec/frontend/releases/mock_data.js @@ -30,6 +30,7 @@ export const milestones = [ export const release = { name: 'New release', tag_name: 'v0.3', + tag_path: '/root/release-test/-/tags/v0.3', description: 'A super nice release!', description_html: '<p data-sourcepos="1:1-1:21" dir="auto">A super nice release!</p>', created_at: '2019-08-26T17:54:04.952Z', @@ -56,6 +57,7 @@ export const release = { committer_email: 'admin@example.com', committed_date: '2019-08-26T17:47:07.000Z', }, + commit_path: '/root/release-test/commit/c22b0728d1b465f82898c884d32b01aa642f96c1', upcoming_release: false, milestones, assets: { @@ -95,6 +97,6 @@ export const release = { ], }, _links: { - edit: 'http://0.0.0.0:3001/root/release-test/-/releases/v0.3/edit', + edit_url: 'http://0.0.0.0:3001/root/release-test/-/releases/v0.3/edit', }, }; 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 new file mode 100644 index 0000000000000000000000000000000000000000..31a1cd230604c8bcd379f7b8e9c4b84d4c0793e7 --- /dev/null +++ b/spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap @@ -0,0 +1,75 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Repository directory download links component renders downloads links for path app 1`] = ` +<section + class="border-top pt-1 mt-1" +> + <h5 + class="m-0 dropdown-bold-header" + > + Download this directory + </h5> + + <div + class="dropdown-menu-content" + > + <div + class="btn-group ml-0 w-100" + > + <gllink-stub + class="btn btn-xs btn-primary" + href="http://test.com/?path=app" + > + + zip + + </gllink-stub> + <gllink-stub + class="btn btn-xs" + href="http://test.com/?path=app" + > + + tar + + </gllink-stub> + </div> + </div> +</section> +`; + +exports[`Repository directory download links component renders downloads links for path app/assets 1`] = ` +<section + class="border-top pt-1 mt-1" +> + <h5 + class="m-0 dropdown-bold-header" + > + Download this directory + </h5> + + <div + class="dropdown-menu-content" + > + <div + class="btn-group ml-0 w-100" + > + <gllink-stub + class="btn btn-xs btn-primary" + href="http://test.com/?path=app/assets" + > + + zip + + </gllink-stub> + <gllink-stub + class="btn btn-xs" + href="http://test.com/?path=app/assets" + > + + tar + + </gllink-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 08173f4f0c4216ec0ec5f233e2ff4e57ec0c3639..706c26403c076056d66ec5735608fa6eb9b6d4e2 100644 --- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap @@ -62,19 +62,23 @@ exports[`Repository last commit component renders commit widget 1`] = ` > <!----> - <gllink-stub - class="js-commit-pipeline" - data-original-title="Commit: failed" - href="https://test.com/pipeline" - title="" + <div + class="ci-status-link" > - <ciicon-stub - aria-label="Commit: failed" - cssclasses="" - size="24" - status="[object Object]" - /> - </gllink-stub> + <gllink-stub + class="js-commit-pipeline" + data-original-title="Commit: failed" + href="https://test.com/pipeline" + title="" + > + <ciicon-stub + aria-label="Commit: failed" + cssclasses="" + size="24" + status="[object Object]" + /> + </gllink-stub> + </div> <div class="commit-sha-group d-flex" @@ -165,19 +169,23 @@ exports[`Repository last commit component renders the signature HTML as returned </button> </div> - <gllink-stub - class="js-commit-pipeline" - data-original-title="Commit: failed" - href="https://test.com/pipeline" - title="" + <div + class="ci-status-link" > - <ciicon-stub - aria-label="Commit: failed" - cssclasses="" - size="24" - status="[object Object]" - /> - </gllink-stub> + <gllink-stub + class="js-commit-pipeline" + data-original-title="Commit: failed" + href="https://test.com/pipeline" + title="" + > + <ciicon-stub + aria-label="Commit: failed" + cssclasses="" + size="24" + status="[object Object]" + /> + </gllink-stub> + </div> <div class="commit-sha-group d-flex" diff --git a/spec/frontend/repository/components/directory_download_links_spec.js b/spec/frontend/repository/components/directory_download_links_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..4d70b44de08828cbf7c1f07ee61f7dc8a59185ac --- /dev/null +++ b/spec/frontend/repository/components/directory_download_links_spec.js @@ -0,0 +1,29 @@ +import { shallowMount } from '@vue/test-utils'; +import DirectoryDownloadLinks from '~/repository/components/directory_download_links.vue'; + +let vm; + +function factory(currentPath) { + vm = shallowMount(DirectoryDownloadLinks, { + propsData: { + currentPath, + links: [{ text: 'zip', path: 'http://test.com/' }, { text: 'tar', path: 'http://test.com/' }], + }, + }); +} + +describe('Repository directory download links component', () => { + afterEach(() => { + vm.destroy(); + }); + + it.each` + path + ${'app'} + ${'app/assets'} + `('renders downloads links for path $path', ({ path }) => { + factory(path); + + expect(vm.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js index 01b56d453e6d461a2c0f7ac98aefa5a850a72603..e07ad4cf46b6eb707111844825be18ae573f4592 100644 --- a/spec/frontend/repository/components/last_commit_spec.js +++ b/spec/frontend/repository/components/last_commit_spec.js @@ -17,7 +17,7 @@ function createCommitData(data = {}) { avatarUrl: 'https://test.com', webUrl: 'https://test.com/test', }, - latestPipeline: { + pipeline: { detailedStatus: { detailsPath: 'https://test.com/pipeline', icon: 'failed', @@ -74,7 +74,7 @@ describe('Repository last commit component', () => { }); it('hides pipeline components when pipeline does not exist', () => { - factory(createCommitData({ latestPipeline: null })); + factory(createCommitData({ pipeline: null })); expect(vm.find('.js-commit-pipeline').exists()).toBe(false); }); diff --git a/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap b/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..a5e3eb4bce1434ea693a086cfc60061069882341 --- /dev/null +++ b/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Repository file preview component renders file HTML 1`] = ` +<article + class="file-holder limited-width-container readme-holder" +> + <div + class="file-title" + > + <i + aria-hidden="true" + class="fa fa-file-text-o fa-fw" + /> + + <gllink-stub + href="http://test.com" + > + <strong> + README.md + </strong> + </gllink-stub> + </div> + + <div + class="blob-viewer" + > + <div> + <div + class="blob" + > + test + </div> + </div> + </div> +</article> +`; diff --git a/spec/frontend/repository/components/preview/index_spec.js b/spec/frontend/repository/components/preview/index_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0112e6310f44c1fd0ae03c725744922572b89a60 --- /dev/null +++ b/spec/frontend/repository/components/preview/index_spec.js @@ -0,0 +1,49 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import Preview from '~/repository/components/preview/index.vue'; + +let vm; +let $apollo; + +function factory(blob) { + $apollo = { + query: jest.fn().mockReturnValue(Promise.resolve({})), + }; + + vm = shallowMount(Preview, { + propsData: { + blob, + }, + mocks: { + $apollo, + }, + }); +} + +describe('Repository file preview component', () => { + afterEach(() => { + vm.destroy(); + }); + + it('renders file HTML', () => { + factory({ + webUrl: 'http://test.com', + name: 'README.md', + }); + + vm.setData({ readme: { html: '<div class="blob">test</div>' } }); + + expect(vm.element).toMatchSnapshot(); + }); + + it('renders loading icon', () => { + factory({ + webUrl: 'http://test.com', + name: 'README.md', + }); + + vm.setData({ loading: 1 }); + + 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 d55dc553031cb3df3e6a790555b79cff45583be0..f8e65a51297c148add4fcc8cc7746a80d0600d71 100644 --- a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap +++ b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap @@ -25,6 +25,8 @@ exports[`Repository table row component renders table row 1`] = ` <!----> <!----> + + <!----> </td> <td diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js index 827927e6d9ac6e2208ec29dd085f5dd967d3a1b5..41450becabb46e40c569209e56854b56cc2235a6 100644 --- a/spec/frontend/repository/components/table/index_spec.js +++ b/spec/frontend/repository/components/table/index_spec.js @@ -1,18 +1,36 @@ import { shallowMount } from '@vue/test-utils'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlSkeletonLoading } from '@gitlab/ui'; import Table from '~/repository/components/table/index.vue'; +import TableRow from '~/repository/components/table/row.vue'; let vm; let $apollo; -function factory(path, data = () => ({})) { - $apollo = { - query: jest.fn().mockReturnValue(Promise.resolve({ data: data() })), - }; - +const MOCK_BLOBS = [ + { + id: '123abc', + sha: '123abc', + flatPath: 'blob', + name: 'blob.md', + type: 'blob', + webUrl: 'http://test.com', + }, + { + id: '124abc', + sha: '124abc', + flatPath: 'blob2', + name: 'blob2.md', + type: 'blob', + webUrl: 'http://test.com', + }, +]; + +function factory({ path, isLoading = false, entries = {} }) { vm = shallowMount(Table, { propsData: { path, + isLoading, + entries, }, mocks: { $apollo, @@ -31,50 +49,30 @@ describe('Repository table component', () => { ${'app/assets'} | ${'master'} ${'/'} | ${'test'} `('renders table caption for $ref in $path', ({ path, ref }) => { - factory(path); + factory({ path }); vm.setData({ ref }); - expect(vm.find('caption').text()).toEqual( + expect(vm.find('.table').attributes('aria-label')).toEqual( `Files, directories, and submodules in the path ${path} for commit reference ${ref}`, ); }); it('shows loading icon', () => { - factory('/'); - - vm.setData({ isLoadingFiles: true }); + factory({ path: '/', isLoading: true }); - expect(vm.find(GlLoadingIcon).isVisible()).toBe(true); + expect(vm.find(GlSkeletonLoading).exists()).toBe(true); }); - describe('normalizeData', () => { - it('normalizes edge nodes', () => { - const output = vm.vm.normalizeData('blobs', [{ node: '1' }, { node: '2' }]); - - expect(output).toEqual(['1', '2']); + it('renders table rows', () => { + factory({ + path: '/', + entries: { + blobs: MOCK_BLOBS, + }, }); - }); - - describe('hasNextPage', () => { - it('returns undefined when hasNextPage is false', () => { - const output = vm.vm.hasNextPage({ - trees: { pageInfo: { hasNextPage: false } }, - submodules: { pageInfo: { hasNextPage: false } }, - blobs: { pageInfo: { hasNextPage: false } }, - }); - expect(output).toBe(undefined); - }); - - it('returns pageInfo object when hasNextPage is true', () => { - const output = vm.vm.hasNextPage({ - trees: { pageInfo: { hasNextPage: false } }, - submodules: { pageInfo: { hasNextPage: false } }, - blobs: { pageInfo: { hasNextPage: true, nextCursor: 'test' } }, - }); - - expect(output).toEqual({ hasNextPage: true, nextCursor: 'test' }); - }); + expect(vm.find(TableRow).exists()).toBe(true); + expect(vm.findAll(TableRow).length).toBe(2); }); }); diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index e539c5609750a39bc30153e647537e3ca74f6c5e..aa0b9385f1accd845a035add6cb9831715614a95 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -2,6 +2,7 @@ import { shallowMount, RouterLinkStub } from '@vue/test-utils'; import { GlBadge, GlLink } 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'; jest.mock('~/lib/utils/url_utility'); @@ -40,6 +41,7 @@ describe('Repository table row component', () => { it('renders table row', () => { factory({ id: '1', + sha: '123', path: 'test', type: 'file', currentPath: '/', @@ -56,6 +58,7 @@ describe('Repository table row component', () => { `('renders a $componentName for type $type', ({ type, component }) => { factory({ id: '1', + sha: '123', path: 'test', type, currentPath: '/', @@ -72,6 +75,7 @@ describe('Repository table row component', () => { `('pushes new router if type $type is tree', ({ type, pushes }) => { factory({ id: '1', + sha: '123', path: 'test', type, currentPath: '/', @@ -94,6 +98,7 @@ describe('Repository table row component', () => { `('calls visitUrl if $type is not tree', ({ type, pushes }) => { factory({ id: '1', + sha: '123', path: 'test', type, currentPath: '/', @@ -104,13 +109,14 @@ describe('Repository table row component', () => { if (pushes) { expect(visitUrl).not.toHaveBeenCalled(); } else { - expect(visitUrl).toHaveBeenCalledWith('https://test.com'); + expect(visitUrl).toHaveBeenCalledWith('https://test.com', undefined); } }); it('renders commit ID for submodule', () => { factory({ id: '1', + sha: '123', path: 'test', type: 'commit', currentPath: '/', @@ -122,6 +128,7 @@ describe('Repository table row component', () => { it('renders link with href', () => { factory({ id: '1', + sha: '123', path: 'test', type: 'blob', url: 'https://test.com', @@ -134,6 +141,7 @@ describe('Repository table row component', () => { it('renders LFS badge', () => { factory({ id: '1', + sha: '123', path: 'test', type: 'commit', currentPath: '/', @@ -146,6 +154,7 @@ describe('Repository table row component', () => { it('renders commit and web links with href for submodule', () => { factory({ id: '1', + sha: '123', path: 'test', type: 'commit', url: 'https://test.com', @@ -156,4 +165,18 @@ describe('Repository table row component', () => { 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', () => { + factory({ + id: '1', + sha: '123', + path: 'test', + type: 'tree', + currentPath: '/', + }); + + vm.setData({ commit: { lockLabel: 'Locked by Root', committedDate: '2019-01-01' } }); + + expect(vm.find(Icon).exists()).toBe(true); + }); }); diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..148e307a5d4b487e9298d218104a5f9bb438f91c --- /dev/null +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -0,0 +1,71 @@ +import { shallowMount } from '@vue/test-utils'; +import TreeContent from '~/repository/components/tree_content.vue'; +import FilePreview from '~/repository/components/preview/index.vue'; + +let vm; +let $apollo; + +function factory(path, data = () => ({})) { + $apollo = { + query: jest.fn().mockReturnValue(Promise.resolve({ data: data() })), + }; + + vm = shallowMount(TreeContent, { + propsData: { + path, + }, + mocks: { + $apollo, + }, + }); +} + +describe('Repository table component', () => { + afterEach(() => { + vm.destroy(); + }); + + it('renders file preview', () => { + factory('/'); + + vm.setData({ entries: { blobs: [{ name: 'README.md' }] } }); + + expect(vm.find(FilePreview).exists()).toBe(true); + }); + + describe('normalizeData', () => { + it('normalizes edge nodes', () => { + factory('/'); + + const output = vm.vm.normalizeData('blobs', [{ node: '1' }, { node: '2' }]); + + expect(output).toEqual(['1', '2']); + }); + }); + + describe('hasNextPage', () => { + it('returns undefined when hasNextPage is false', () => { + factory('/'); + + const output = vm.vm.hasNextPage({ + trees: { pageInfo: { hasNextPage: false } }, + submodules: { pageInfo: { hasNextPage: false } }, + blobs: { pageInfo: { hasNextPage: false } }, + }); + + expect(output).toBe(undefined); + }); + + it('returns pageInfo object when hasNextPage is true', () => { + factory('/'); + + const output = vm.vm.hasNextPage({ + trees: { pageInfo: { hasNextPage: false } }, + submodules: { pageInfo: { hasNextPage: false } }, + blobs: { pageInfo: { hasNextPage: true, nextCursor: 'test' } }, + }); + + expect(output).toEqual({ hasNextPage: true, nextCursor: 'test' }); + }); + }); +}); diff --git a/spec/frontend/repository/log_tree_spec.js b/spec/frontend/repository/log_tree_spec.js index a3a766eca418a90103d4f4fc55b43c08735d3771..9199c7266808615e557a453c45f0f9cdbc49fb10 100644 --- a/spec/frontend/repository/log_tree_spec.js +++ b/spec/frontend/repository/log_tree_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; -import { normalizeData, resolveCommit, fetchLogsTree } from '~/repository/log_tree'; +import { resolveCommit, fetchLogsTree } from '~/repository/log_tree'; const mockData = [ { @@ -15,22 +15,6 @@ const mockData = [ }, ]; -describe('normalizeData', () => { - it('normalizes data into LogTreeCommit object', () => { - expect(normalizeData(mockData)).toEqual([ - { - sha: '123', - message: 'testing message', - committedDate: '2019-01-01', - commitPath: 'https://test.com', - fileName: 'index.js', - type: 'blob', - __typename: 'LogTreeCommit', - }, - ]); - }); -}); - describe('resolveCommit', () => { it('calls resolve when commit found', () => { const resolver = { @@ -57,7 +41,7 @@ describe('fetchLogsTree', () => { jest.spyOn(axios, 'get'); - global.gon = { gitlab_url: 'https://test.com' }; + global.gon = { relative_url_root: '' }; client = { readQuery: () => ({ @@ -80,10 +64,9 @@ describe('fetchLogsTree', () => { it('calls axios get', () => fetchLogsTree(client, '', '0', resolver).then(() => { - expect(axios.get).toHaveBeenCalledWith( - 'https://test.com/gitlab-org/gitlab-foss/refs/master/logs_tree', - { params: { format: 'json', offset: '0' } }, - ); + expect(axios.get).toHaveBeenCalledWith('/gitlab-org/gitlab-foss/refs/master/logs_tree/', { + params: { format: 'json', offset: '0' }, + }); })); it('calls axios get once', () => diff --git a/spec/frontend/repository/pages/index_spec.js b/spec/frontend/repository/pages/index_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c0afb7931b15aff54843642e14a588d011547cbe --- /dev/null +++ b/spec/frontend/repository/pages/index_spec.js @@ -0,0 +1,42 @@ +import { shallowMount } from '@vue/test-utils'; +import IndexPage from '~/repository/pages/index.vue'; +import TreePage from '~/repository/pages/tree.vue'; +import { updateElementsVisibility } from '~/repository/utils/dom'; + +jest.mock('~/repository/utils/dom'); + +describe('Repository index page component', () => { + let wrapper; + + function factory() { + wrapper = shallowMount(IndexPage); + } + + afterEach(() => { + wrapper.destroy(); + + updateElementsVisibility.mockClear(); + }); + + it('calls updateElementsVisibility on mounted', () => { + factory(); + + expect(updateElementsVisibility).toHaveBeenCalledWith('.js-show-on-project-root', true); + }); + + it('calls updateElementsVisibility after destroy', () => { + factory(); + wrapper.destroy(); + + expect(updateElementsVisibility.mock.calls.pop()).toEqual(['.js-show-on-project-root', false]); + }); + + it('renders TreePage', () => { + factory(); + + const child = wrapper.find(TreePage); + + expect(child.exists()).toBe(true); + expect(child.props()).toEqual({ path: '/' }); + }); +}); diff --git a/spec/frontend/repository/pages/tree_spec.js b/spec/frontend/repository/pages/tree_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..36662696c91b280506645f8143811dec7dba67c6 --- /dev/null +++ b/spec/frontend/repository/pages/tree_spec.js @@ -0,0 +1,60 @@ +import { shallowMount } from '@vue/test-utils'; +import TreePage from '~/repository/pages/tree.vue'; +import { updateElementsVisibility } from '~/repository/utils/dom'; + +jest.mock('~/repository/utils/dom'); + +describe('Repository tree page component', () => { + let wrapper; + + function factory(path) { + wrapper = shallowMount(TreePage, { propsData: { path } }); + } + + afterEach(() => { + wrapper.destroy(); + + updateElementsVisibility.mockClear(); + }); + + describe('when root path', () => { + beforeEach(() => { + factory('/'); + }); + + it('shows root elements', () => { + expect(updateElementsVisibility.mock.calls).toEqual([ + ['.js-show-on-root', true], + ['.js-hide-on-root', false], + ]); + }); + + describe('when changed', () => { + beforeEach(() => { + updateElementsVisibility.mockClear(); + + wrapper.setProps({ path: '/test' }); + }); + + it('hides root elements', () => { + expect(updateElementsVisibility.mock.calls).toEqual([ + ['.js-show-on-root', false], + ['.js-hide-on-root', true], + ]); + }); + }); + }); + + describe('when non-root path', () => { + beforeEach(() => { + factory('/test'); + }); + + it('hides root elements', () => { + expect(updateElementsVisibility.mock.calls).toEqual([ + ['.js-show-on-root', false], + ['.js-hide-on-root', true], + ]); + }); + }); +}); diff --git a/spec/frontend/repository/utils/commit_spec.js b/spec/frontend/repository/utils/commit_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..2d75358106ce5716b54b4c11cf0697947b5ee4bf --- /dev/null +++ b/spec/frontend/repository/utils/commit_spec.js @@ -0,0 +1,30 @@ +import { normalizeData } from '~/repository/utils/commit'; + +const mockData = [ + { + commit: { + id: '123', + message: 'testing message', + committed_date: '2019-01-01', + }, + commit_path: `https://test.com`, + file_name: 'index.js', + type: 'blob', + }, +]; + +describe('normalizeData', () => { + it('normalizes data into LogTreeCommit object', () => { + expect(normalizeData(mockData)).toEqual([ + { + sha: '123', + message: 'testing message', + committedDate: '2019-01-01', + commitPath: 'https://test.com', + fileName: 'index.js', + type: 'blob', + __typename: 'LogTreeCommit', + }, + ]); + }); +}); diff --git a/spec/frontend/repository/utils/dom_spec.js b/spec/frontend/repository/utils/dom_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..678d444904d63f8c7c9b25dbdf94c317f6bd06e2 --- /dev/null +++ b/spec/frontend/repository/utils/dom_spec.js @@ -0,0 +1,20 @@ +import { setHTMLFixture } from '../../helpers/fixtures'; +import { updateElementsVisibility } from '~/repository/utils/dom'; + +describe('updateElementsVisibility', () => { + it('adds hidden class', () => { + setHTMLFixture('<div class="js-test"></div>'); + + updateElementsVisibility('.js-test', false); + + expect(document.querySelector('.js-test').classList).toContain('hidden'); + }); + + it('removes hidden class', () => { + setHTMLFixture('<div class="hidden js-test"></div>'); + + updateElementsVisibility('.js-test', true); + + expect(document.querySelector('.js-test').classList).not.toContain('hidden'); + }); +}); diff --git a/spec/frontend/repository/utils/readme_spec.js b/spec/frontend/repository/utils/readme_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..6b7876c8947784d451aca2a9da5a21a2b1b176a9 --- /dev/null +++ b/spec/frontend/repository/utils/readme_spec.js @@ -0,0 +1,33 @@ +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', + }); + + expect(readmeFile([{ name: 'README' }, { name: 'index.md' }])).toEqual({ + name: 'index.md', + }); + }); + }); + + describe('plain files', () => { + it('returns plain file', () => { + expect(readmeFile([{ name: 'README' }, { name: 'TEST.md' }])).toEqual({ + name: 'README', + }); + + expect(readmeFile([{ name: 'readme' }, { name: 'TEST.md' }])).toEqual({ + name: 'readme', + }); + }); + }); + + describe('non-previewable file', () => { + it('returns undefined', () => { + expect(readmeFile([{ name: 'index.js' }, { name: 'TEST.md' }])).toBe(undefined); + }); + }); +}); diff --git a/spec/frontend/repository/utils/title_spec.js b/spec/frontend/repository/utils/title_spec.js index c4879716fd7291992040c1a5e84549ed283cd596..63035933424423f2648b3fcc7a7571ccc60b038c 100644 --- a/spec/frontend/repository/utils/title_spec.js +++ b/spec/frontend/repository/utils/title_spec.js @@ -8,8 +8,8 @@ describe('setTitle', () => { ${'app/assets'} | ${'app/assets'} ${'app/assets/javascripts'} | ${'app/assets/javascripts'} `('sets document title as $title for $path', ({ path, title }) => { - setTitle(path, 'master', 'GitLab'); + setTitle(path, 'master', 'GitLab Org / GitLab'); - expect(document.title).toEqual(`${title} · master · GitLab`); + expect(document.title).toEqual(`${title} · master · GitLab Org / GitLab · GitLab`); }); }); diff --git a/spec/javascripts/raven/index_spec.js b/spec/frontend/sentry/index_spec.js similarity index 62% rename from spec/javascripts/raven/index_spec.js rename to spec/frontend/sentry/index_spec.js index 6b9fe9236243c102eb03c24cbc0857077538cf2e..82b6c445d9674d438397c4acb337d655b40cea66 100644 --- a/spec/javascripts/raven/index_spec.js +++ b/spec/frontend/sentry/index_spec.js @@ -1,8 +1,8 @@ -import RavenConfig from '~/raven/raven_config'; -import index from '~/raven/index'; +import SentryConfig from '~/sentry/sentry_config'; +import index from '~/sentry/index'; -describe('RavenConfig options', () => { - const sentryDsn = 'sentryDsn'; +describe('SentryConfig options', () => { + const dsn = 'https://123@sentry.gitlab.test/123'; const currentUserId = 'currentUserId'; const gitlabUrl = 'gitlabUrl'; const environment = 'test'; @@ -11,7 +11,7 @@ describe('RavenConfig options', () => { beforeEach(() => { window.gon = { - sentry_dsn: sentryDsn, + sentry_dsn: dsn, sentry_environment: environment, current_user_id: currentUserId, gitlab_url: gitlabUrl, @@ -20,14 +20,14 @@ describe('RavenConfig options', () => { process.env.HEAD_COMMIT_SHA = revision; - spyOn(RavenConfig, 'init'); + jest.spyOn(SentryConfig, 'init').mockImplementation(); indexReturnValue = index(); }); it('should init with .sentryDsn, .currentUserId, .whitelistUrls and environment', () => { - expect(RavenConfig.init).toHaveBeenCalledWith({ - sentryDsn, + expect(SentryConfig.init).toHaveBeenCalledWith({ + dsn, currentUserId, whitelistUrls: [gitlabUrl, 'webpack-internal://'], environment, @@ -38,7 +38,7 @@ describe('RavenConfig options', () => { }); }); - it('should return RavenConfig', () => { - expect(indexReturnValue).toBe(RavenConfig); + it('should return SentryConfig', () => { + expect(indexReturnValue).toBe(SentryConfig); }); }); diff --git a/spec/frontend/sentry/sentry_config_spec.js b/spec/frontend/sentry/sentry_config_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..62b8bbd50a2a732da376aba252f8cc3588ef0fe3 --- /dev/null +++ b/spec/frontend/sentry/sentry_config_spec.js @@ -0,0 +1,214 @@ +import * as Sentry from '@sentry/browser'; +import SentryConfig from '~/sentry/sentry_config'; + +describe('SentryConfig', () => { + describe('IGNORE_ERRORS', () => { + it('should be an array of strings', () => { + const areStrings = SentryConfig.IGNORE_ERRORS.every(error => typeof error === 'string'); + + expect(areStrings).toBe(true); + }); + }); + + describe('BLACKLIST_URLS', () => { + it('should be an array of regexps', () => { + const areRegExps = SentryConfig.BLACKLIST_URLS.every(url => url instanceof RegExp); + + expect(areRegExps).toBe(true); + }); + }); + + describe('SAMPLE_RATE', () => { + it('should be a finite number', () => { + expect(typeof SentryConfig.SAMPLE_RATE).toEqual('number'); + }); + }); + + describe('init', () => { + const options = { + currentUserId: 1, + }; + + beforeEach(() => { + jest.spyOn(SentryConfig, 'configure'); + jest.spyOn(SentryConfig, 'bindSentryErrors'); + jest.spyOn(SentryConfig, 'setUser'); + + SentryConfig.init(options); + }); + + it('should set the options property', () => { + expect(SentryConfig.options).toEqual(options); + }); + + it('should call the configure method', () => { + expect(SentryConfig.configure).toHaveBeenCalled(); + }); + + it('should call the error bindings method', () => { + expect(SentryConfig.bindSentryErrors).toHaveBeenCalled(); + }); + + it('should call setUser', () => { + expect(SentryConfig.setUser).toHaveBeenCalled(); + }); + + it('should not call setUser if there is no current user ID', () => { + jest.clearAllMocks(); + + options.currentUserId = undefined; + + SentryConfig.init(options); + + expect(SentryConfig.setUser).not.toHaveBeenCalled(); + }); + }); + + describe('configure', () => { + const sentryConfig = {}; + const options = { + dsn: 'https://123@sentry.gitlab.test/123', + whitelistUrls: ['//gitlabUrl', 'webpack-internal://'], + environment: 'test', + release: 'revision', + tags: { + revision: 'revision', + }, + }; + + beforeEach(() => { + jest.spyOn(Sentry, 'init').mockImplementation(); + + sentryConfig.options = options; + sentryConfig.IGNORE_ERRORS = 'ignore_errors'; + sentryConfig.BLACKLIST_URLS = 'blacklist_urls'; + + SentryConfig.configure.call(sentryConfig); + }); + + it('should call Sentry.init', () => { + expect(Sentry.init).toHaveBeenCalledWith({ + dsn: options.dsn, + release: options.release, + tags: options.tags, + sampleRate: 0.95, + whitelistUrls: options.whitelistUrls, + environment: 'test', + ignoreErrors: sentryConfig.IGNORE_ERRORS, + blacklistUrls: sentryConfig.BLACKLIST_URLS, + }); + }); + + it('should set environment from options', () => { + sentryConfig.options.environment = 'development'; + + SentryConfig.configure.call(sentryConfig); + + expect(Sentry.init).toHaveBeenCalledWith({ + dsn: options.dsn, + release: options.release, + tags: options.tags, + sampleRate: 0.95, + whitelistUrls: options.whitelistUrls, + environment: 'development', + ignoreErrors: sentryConfig.IGNORE_ERRORS, + blacklistUrls: sentryConfig.BLACKLIST_URLS, + }); + }); + }); + + describe('setUser', () => { + let sentryConfig; + + beforeEach(() => { + sentryConfig = { options: { currentUserId: 1 } }; + jest.spyOn(Sentry, 'setUser'); + + SentryConfig.setUser.call(sentryConfig); + }); + + it('should call .setUser', () => { + expect(Sentry.setUser).toHaveBeenCalledWith({ + id: sentryConfig.options.currentUserId, + }); + }); + }); + + describe('handleSentryErrors', () => { + let event; + let req; + let config; + let err; + + beforeEach(() => { + event = {}; + req = { status: 'status', responseText: 'Unknown response text', statusText: 'statusText' }; + config = { type: 'type', url: 'url', data: 'data' }; + err = {}; + + jest.spyOn(Sentry, 'captureMessage'); + + SentryConfig.handleSentryErrors(event, req, config, err); + }); + + it('should call Sentry.captureMessage', () => { + expect(Sentry.captureMessage).toHaveBeenCalledWith(err, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: req.responseText, + error: err, + event, + }, + }); + }); + + describe('if no err is provided', () => { + beforeEach(() => { + jest.clearAllMocks(); + + SentryConfig.handleSentryErrors(event, req, config); + }); + + it('should use req.statusText as the error value', () => { + expect(Sentry.captureMessage).toHaveBeenCalledWith(req.statusText, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: req.responseText, + error: req.statusText, + event, + }, + }); + }); + }); + + describe('if no req.responseText is provided', () => { + beforeEach(() => { + req.responseText = undefined; + + jest.clearAllMocks(); + + SentryConfig.handleSentryErrors(event, req, config, err); + }); + + it('should use `Unknown response text` as the response', () => { + expect(Sentry.captureMessage).toHaveBeenCalledWith(err, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: 'Unknown response text', + error: err, + event, + }, + }); + }); + }); + }); +}); 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 452d4cd07cc430fe36223a2eb71b504d5289fb5a..d0d1af56872d479b5d3b267824113cf67a3e83a2 100644 --- a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js +++ b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js @@ -24,6 +24,7 @@ describe('AssigneeAvatarLink component', () => { }; wrapper = shallowMount(AssigneeAvatarLink, { + attachToDocument: true, 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 ff0c8d181b5b40961a07c554f3732fdf4459932c..c88ae1968751ef25d317e93958de1eebe765cd5d 100644 --- a/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js +++ b/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js @@ -16,6 +16,7 @@ describe('CollapsedAssigneeList component', () => { }; wrapper = shallowMount(CollapsedAssigneeList, { + attachToDocument: true, 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 6398351834c1b5d06047068f860c666bc4a25d0a..1de21f30d21133775d044aad7d8eb67021f786d0 100644 --- a/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js +++ b/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js @@ -18,6 +18,7 @@ describe('UncollapsedAssigneeList component', () => { }; wrapper = mount(UncollapsedAssigneeList, { + attachToDocument: true, sync: false, propsData, }); 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 new file mode 100644 index 0000000000000000000000000000000000000000..95296de5a5dafa67294be1d5b7a0eced7ccb9c9f --- /dev/null +++ b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SplitButton renders actionItems 1`] = ` +<gldropdown-stub + menu-class="dropdown-menu-selectable " + split="true" + text="professor" +> + <gldropdownitem-stub + active="true" + active-class="is-active" + > + <strong> + professor + </strong> + + <div> + very symphonic + </div> + </gldropdownitem-stub> + + <gldropdowndivider-stub /> + <gldropdownitem-stub + active-class="is-active" + > + <strong> + captain + </strong> + + <div> + warp drive + </div> + </gldropdownitem-stub> + + <!----> +</gldropdown-stub> +`; diff --git a/spec/javascripts/vue_shared/components/commit_spec.js b/spec/frontend/vue_shared/components/commit_spec.js similarity index 58% rename from spec/javascripts/vue_shared/components/commit_spec.js rename to spec/frontend/vue_shared/components/commit_spec.js index f89627e727b00e901b020a059f3c4fd844a514e6..77d8e00cf001cacba11b7cf17230dc600dec2fbe 100644 --- a/spec/javascripts/vue_shared/components/commit_spec.js +++ b/spec/frontend/vue_shared/components/commit_spec.js @@ -1,22 +1,27 @@ -import Vue from 'vue'; -import commitComp from '~/vue_shared/components/commit.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import { shallowMount } from '@vue/test-utils'; +import CommitComponent from '~/vue_shared/components/commit.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; describe('Commit component', () => { let props; - let component; - let CommitComponent; + let wrapper; - beforeEach(() => { - CommitComponent = Vue.extend(commitComp); - }); + const findUserAvatar = () => wrapper.find(UserAvatarLink); + + const createComponent = propsData => { + wrapper = shallowMount(CommitComponent, { + propsData, + sync: false, + }); + }; afterEach(() => { - component.$destroy(); + wrapper.destroy(); }); it('should render a fork icon if it does not represent a tag', () => { - component = mountComponent(CommitComponent, { + createComponent({ tag: false, commitRef: { name: 'master', @@ -34,7 +39,12 @@ describe('Commit component', () => { }, }); - expect(component.$el.querySelector('.icon-container').children).toContain('svg'); + expect( + wrapper + .find('.icon-container') + .find(Icon) + .exists(), + ).toBe(true); }); describe('Given all the props', () => { @@ -56,68 +66,51 @@ describe('Commit component', () => { username: 'jschatz1', }, }; - - component = mountComponent(CommitComponent, props); + createComponent(props); }); it('should render a tag icon if it represents a tag', () => { - expect(component.$el.querySelector('.icon-container svg.ic-tag')).not.toBeNull(); + expect(wrapper.find('icon-stub[name="tag"]').exists()).toBe(true); }); it('should render a link to the ref url', () => { - expect(component.$el.querySelector('.ref-name').getAttribute('href')).toEqual( - props.commitRef.ref_url, - ); + expect(wrapper.find('.ref-name').attributes('href')).toBe(props.commitRef.ref_url); }); it('should render the ref name', () => { - expect(component.$el.querySelector('.ref-name').textContent).toContain(props.commitRef.name); + expect(wrapper.find('.ref-name').text()).toContain(props.commitRef.name); }); it('should render the commit short sha with a link to the commit url', () => { - expect(component.$el.querySelector('.commit-sha').getAttribute('href')).toEqual( - props.commitUrl, - ); + expect(wrapper.find('.commit-sha').attributes('href')).toEqual(props.commitUrl); - expect(component.$el.querySelector('.commit-sha').textContent).toContain(props.shortSha); + expect(wrapper.find('.commit-sha').text()).toContain(props.shortSha); }); it('should render icon for commit', () => { - expect( - component.$el.querySelector('.js-commit-icon use').getAttribute('xlink:href'), - ).toContain('commit'); + expect(wrapper.find('icon-stub[name="commit"]').exists()).toBe(true); }); describe('Given commit title and author props', () => { it('should render a link to the author profile', () => { - expect( - component.$el.querySelector('.commit-title .avatar-image-container').getAttribute('href'), - ).toEqual(props.author.path); + const userAvatar = findUserAvatar(); + + expect(userAvatar.props('linkHref')).toBe(props.author.path); }); it('Should render the author avatar with title and alt attributes', () => { - expect( - component.$el - .querySelector('.commit-title .avatar-image-container .js-user-avatar-image-toolip') - .textContent.trim(), - ).toContain(props.author.username); - - expect( - component.$el - .querySelector('.commit-title .avatar-image-container img') - .getAttribute('alt'), - ).toContain(`${props.author.username}'s avatar`); + const userAvatar = findUserAvatar(); + + expect(userAvatar.exists()).toBe(true); + + expect(userAvatar.props('imgAlt')).toBe(`${props.author.username}'s avatar`); }); }); it('should render the commit title', () => { - expect(component.$el.querySelector('a.commit-row-message').getAttribute('href')).toEqual( - props.commitUrl, - ); + expect(wrapper.find('.commit-row-message').attributes('href')).toEqual(props.commitUrl); - expect(component.$el.querySelector('a.commit-row-message').textContent).toContain( - props.title, - ); + expect(wrapper.find('.commit-row-message').text()).toContain(props.title); }); }); @@ -136,9 +129,9 @@ describe('Commit component', () => { author: {}, }; - component = mountComponent(CommitComponent, props); + createComponent(props); - expect(component.$el.querySelector('.commit-title span').textContent).toContain( + expect(wrapper.find('.commit-title span').text()).toContain( "Can't find HEAD commit for this branch", ); }); @@ -159,16 +152,16 @@ describe('Commit component', () => { author: {}, }; - component = mountComponent(CommitComponent, props); - const refEl = component.$el.querySelector('.ref-name'); + createComponent(props); + const refEl = wrapper.find('.ref-name'); - expect(refEl.textContent).toContain('master'); + expect(refEl.text()).toContain('master'); - expect(refEl.href).toBe(props.commitRef.ref_url); + expect(refEl.attributes('href')).toBe(props.commitRef.ref_url); - expect(refEl.getAttribute('data-original-title')).toBe(props.commitRef.name); + expect(refEl.attributes('data-original-title')).toBe(props.commitRef.name); - expect(component.$el.querySelector('.icon-container .ic-branch')).not.toBeNull(); + expect(wrapper.find('icon-stub[name="branch"]').exists()).toBe(true); }); }); @@ -192,16 +185,16 @@ describe('Commit component', () => { author: {}, }; - component = mountComponent(CommitComponent, props); - const refEl = component.$el.querySelector('.ref-name'); + createComponent(props); + const refEl = wrapper.find('.ref-name'); - expect(refEl.textContent).toContain('1234'); + expect(refEl.text()).toContain('1234'); - expect(refEl.href).toBe(props.mergeRequestRef.path); + expect(refEl.attributes('href')).toBe(props.mergeRequestRef.path); - expect(refEl.getAttribute('data-original-title')).toBe(props.mergeRequestRef.title); + expect(refEl.attributes('data-original-title')).toBe(props.mergeRequestRef.title); - expect(component.$el.querySelector('.icon-container .ic-git-merge')).not.toBeNull(); + expect(wrapper.find('icon-stub[name="git-merge"]').exists()).toBe(true); }); }); @@ -226,9 +219,9 @@ describe('Commit component', () => { showRefInfo: false, }; - component = mountComponent(CommitComponent, props); + createComponent(props); - expect(component.$el.querySelector('.ref-name')).toBeNull(); + expect(wrapper.find('.ref-name').exists()).toBe(false); }); }); }); diff --git a/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..3ad8f3aec7c089aa7e9950f8d2312d3ccbc9fb3f --- /dev/null +++ b/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js @@ -0,0 +1,45 @@ +import { shallowMount } from '@vue/test-utils'; + +import ImageViewer from '~/vue_shared/components/content_viewer/viewers/image_viewer.vue'; +import { GREEN_BOX_IMAGE_URL } from 'spec/test_constants'; + +describe('Image Viewer', () => { + const requiredProps = { + path: GREEN_BOX_IMAGE_URL, + renderInfo: true, + }; + let wrapper; + let imageInfo; + + function createElement({ props, includeRequired = true } = {}) { + const data = includeRequired ? { ...requiredProps, ...props } : { ...props }; + + wrapper = shallowMount(ImageViewer, { + propsData: data, + }); + imageInfo = wrapper.find('.image-info'); + } + + describe('file sizes', () => { + it('should show the humanized file size when `renderInfo` is true and there is size info', () => { + createElement({ props: { fileSize: 1024 } }); + + expect(imageInfo.text()).toContain('1.00 KiB'); + }); + + it('should not show the humanized file size when `renderInfo` is true and there is no size', () => { + const FILESIZE_RE = /\d+(\.\d+)?\s*([KMGTP]i)*B/; + + createElement({ props: { fileSize: 0 } }); + + // It shouldn't show any filesize info + expect(imageInfo.text()).not.toMatch(FILESIZE_RE); + }); + + it('should not show any image information when `renderInfo` is false', () => { + createElement({ props: { renderInfo: false } }); + + expect(imageInfo.exists()).toBe(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 d1de98f4a15c45339a6edffc36b47bbacb5bb400..9e6b5286899adde45b813dc34e7e4646aa16ea38 100644 --- a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js @@ -1,114 +1,129 @@ -import Vue from 'vue'; - +import { shallowMount } from '@vue/test-utils'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; import { mockAssigneesList } from '../../../../javascripts/boards/mock_data'; -const createComponent = (assignees = mockAssigneesList, cssClass = '') => { - const Component = Vue.extend(IssueAssignees); - - return mountComponent(Component, { - assignees, - cssClass, - }); -}; +const TEST_CSS_CLASSES = 'test-classes'; +const TEST_MAX_VISIBLE = 4; +const TEST_ICON_SIZE = 16; describe('IssueAssigneesComponent', () => { + let wrapper; let vm; - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('data', () => { - it('returns default data props', () => { - expect(vm.maxVisibleAssignees).toBe(2); - expect(vm.maxAssigneeAvatars).toBe(3); - expect(vm.maxAssignees).toBe(99); + const factory = props => { + wrapper = shallowMount(IssueAssignees, { + propsData: { + assignees: mockAssigneesList, + ...props, + }, + sync: false, }); + vm = wrapper.vm; // eslint-disable-line + }; + + const findTooltipText = () => wrapper.find('.js-assignee-tooltip').text(); + const findAvatars = () => wrapper.findAll(UserAvatarLink); + const findOverflowCounter = () => wrapper.find('.avatar-counter'); + + it('returns default data props', () => { + factory({ assignees: mockAssigneesList }); + expect(vm.iconSize).toBe(24); + expect(vm.maxVisible).toBe(3); + expect(vm.maxAssignees).toBe(99); }); - describe('computed', () => { - describe('countOverLimit', () => { - it('should return difference between assignees count and maxVisibleAssignees', () => { - expect(vm.countOverLimit).toBe(mockAssigneesList.length - vm.maxVisibleAssignees); - }); - }); - - describe('assigneesToShow', () => { - it('should return assignees containing only 2 items when count more than maxAssigneeAvatars', () => { - expect(vm.assigneesToShow.length).toBe(2); - }); - - it('should return all assignees as it is when count less than maxAssigneeAvatars', () => { - vm.assignees = mockAssigneesList.slice(0, 3); // Set 3 Assignees - - expect(vm.assigneesToShow.length).toBe(3); - }); - }); - - describe('assigneesCounterTooltip', () => { - it('should return string containing count of remaining assignees when count more than maxAssigneeAvatars', () => { - expect(vm.assigneesCounterTooltip).toBe('3 more assignees'); - }); - }); - - describe('shouldRenderAssigneesCounter', () => { - it('should return `false` when assignees count less than maxAssigneeAvatars', () => { - vm.assignees = mockAssigneesList.slice(0, 3); // Set 3 Assignees - - expect(vm.shouldRenderAssigneesCounter).toBe(false); - }); - - it('should return `true` when assignees count more than maxAssigneeAvatars', () => { - expect(vm.shouldRenderAssigneesCounter).toBe(true); + describe.each` + numAssignees | maxVisible | expectedShown | expectedHidden + ${0} | ${3} | ${0} | ${''} + ${1} | ${3} | ${1} | ${''} + ${2} | ${3} | ${2} | ${''} + ${3} | ${3} | ${3} | ${''} + ${4} | ${3} | ${2} | ${'+2'} + ${5} | ${2} | ${1} | ${'+4'} + ${1000} | ${5} | ${4} | ${'99+'} + `( + 'with assignees ($numAssignees) and maxVisible ($maxVisible)', + ({ numAssignees, maxVisible, expectedShown, expectedHidden }) => { + beforeEach(() => { + factory({ assignees: Array(numAssignees).fill({}), maxVisible }); }); - }); - describe('assigneeCounterLabel', () => { - it('should return count of additional assignees total assignees count more than maxAssigneeAvatars', () => { - expect(vm.assigneeCounterLabel).toBe('+3'); + if (expectedShown) { + it('shows assignee avatars', () => { + expect(findAvatars().length).toEqual(expectedShown); + }); + } else { + it('does not show assignee avatars', () => { + expect(findAvatars().length).toEqual(0); + }); + } + + if (expectedHidden) { + it('shows overflow counter', () => { + const hiddenCount = numAssignees - expectedShown; + + expect(findOverflowCounter().exists()).toBe(true); + expect(findOverflowCounter().text()).toEqual(expectedHidden.toString()); + expect(findOverflowCounter().attributes('data-original-title')).toEqual( + `${hiddenCount} more assignees`, + ); + }); + } else { + it('does not show overflow counter', () => { + expect(findOverflowCounter().exists()).toBe(false); + }); + } + }, + ); + + describe('when mounted', () => { + beforeEach(() => { + factory({ + imgCssClasses: TEST_CSS_CLASSES, + maxVisible: TEST_MAX_VISIBLE, + iconSize: TEST_ICON_SIZE, }); }); - }); - describe('methods', () => { - describe('avatarUrlTitle', () => { - it('returns string containing alt text for assignee avatar', () => { - expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Avatar for Terrell Graham'); - }); + it('computes alt text for assignee avatar', () => { + expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Avatar for Terrell Graham'); }); - }); - describe('template', () => { it('renders component root element with class `issue-assignees`', () => { - expect(vm.$el.classList.contains('issue-assignees')).toBe(true); + expect(wrapper.element.classList.contains('issue-assignees')).toBe(true); }); - it('renders assignee avatars', () => { - expect(vm.$el.querySelectorAll('.user-avatar-link').length).toBe(2); + it('renders assignee', () => { + const data = findAvatars().wrappers.map(x => ({ + ...x.props(), + })); + + const expected = mockAssigneesList.slice(0, TEST_MAX_VISIBLE - 1).map(x => + expect.objectContaining({ + linkHref: x.web_url, + imgAlt: `Avatar for ${x.name}`, + imgCssClasses: TEST_CSS_CLASSES, + imgSrc: x.avatar_url, + imgSize: TEST_ICON_SIZE, + }), + ); + + expect(data).toEqual(expected); }); - it('renders assignee tooltips', () => { - const tooltipText = vm.$el - .querySelectorAll('.user-avatar-link')[0] - .querySelector('.js-assignee-tooltip').innerText; - - expect(tooltipText).toContain('Assignee'); - expect(tooltipText).toContain('Terrell Graham'); - expect(tooltipText).toContain('@monserrate.gleichner'); - }); + describe('assignee tooltips', () => { + it('renders "Assignee" header', () => { + expect(findTooltipText()).toContain('Assignee'); + }); - it('renders additional assignees count', () => { - const avatarCounterEl = vm.$el.querySelector('.avatar-counter'); + it('renders assignee name', () => { + expect(findTooltipText()).toContain('Terrell Graham'); + }); - expect(avatarCounterEl.innerText.trim()).toBe('+3'); - expect(avatarCounterEl.getAttribute('data-original-title')).toBe('3 more assignees'); + it('renders assignee @username', () => { + expect(findTooltipText()).toContain('@monserrate.gleichner'); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js index eafff7f681e9c32e0f1072c292ef660e817ba0bb..45f131194ca294d43420f32cfabb5ab3b8ee2485 100644 --- a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import issuePlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; import createStore from '~/notes/stores'; -import { userDataMock } from '../../../../javascripts/notes/mock_data'; +import { userDataMock } from '../../../notes/mock_data'; describe('issue placeholder system note component', () => { let store; 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 a65e3eb294adfd0a26fb186b032a59c32dfeddca..c2e8359f78dc6d33f89843978df81bb3bc58af48 100644 --- a/spec/frontend/vue_shared/components/notes/system_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/system_note_spec.js @@ -57,7 +57,7 @@ describe('system note component', () => { // we need to strip them because they break layout of commit lists in system notes: // https://gitlab.com/gitlab-org/gitlab-foss/uploads/b07a10670919254f0220d3ff5c1aa110/jqzI.png it('removes wrapping paragraph from note HTML', () => { - expect(vm.$el.querySelector('.system-note-message').innerHTML).toEqual('<span>closed</span>'); + expect(vm.$el.querySelector('.system-note-message').innerHTML).toContain('<span>closed</span>'); }); it('should initMRPopovers onMount', () => { diff --git a/spec/frontend/vue_shared/components/slot_switch_spec.js b/spec/frontend/vue_shared/components/slot_switch_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..cff955c05b21d57040751386de54b1b60a16f460 --- /dev/null +++ b/spec/frontend/vue_shared/components/slot_switch_spec.js @@ -0,0 +1,56 @@ +import { shallowMount } from '@vue/test-utils'; + +import SlotSwitch from '~/vue_shared/components/slot_switch'; + +describe('SlotSwitch', () => { + const slots = { + first: '<a>AGP</a>', + second: '<p>PCI</p>', + }; + + let wrapper; + + const createComponent = propsData => { + wrapper = shallowMount(SlotSwitch, { + propsData, + slots, + sync: false, + }); + }; + + const getChildrenHtml = () => wrapper.findAll('* *').wrappers.map(c => c.html()); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + it('throws an error if activeSlotNames is missing', () => { + expect(createComponent).toThrow('[Vue warn]: Missing required prop: "activeSlotNames"'); + }); + + it('renders no slots if activeSlotNames is empty', () => { + createComponent({ + activeSlotNames: [], + }); + + expect(getChildrenHtml().length).toBe(0); + }); + + it('renders one slot if activeSlotNames contains single slot name', () => { + createComponent({ + activeSlotNames: ['first'], + }); + + expect(getChildrenHtml()).toEqual([slots.first]); + }); + + it('renders multiple slots if activeSlotNames contains multiple slot names', () => { + createComponent({ + activeSlotNames: Object.keys(slots), + }); + + expect(getChildrenHtml()).toEqual(Object.values(slots)); + }); +}); diff --git a/spec/frontend/vue_shared/components/split_button_spec.js b/spec/frontend/vue_shared/components/split_button_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..520abb02cf7fc6e2412c46716dbcabd80e901e58 --- /dev/null +++ b/spec/frontend/vue_shared/components/split_button_spec.js @@ -0,0 +1,104 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; + +import SplitButton from '~/vue_shared/components/split_button.vue'; + +const mockActionItems = [ + { + eventName: 'concert', + title: 'professor', + description: 'very symphonic', + }, + { + eventName: 'apocalypse', + title: 'captain', + description: 'warp drive', + }, +]; + +describe('SplitButton', () => { + let wrapper; + + const createComponent = propsData => { + wrapper = shallowMount(SplitButton, { + propsData, + sync: false, + }); + }; + + const findDropdown = () => wrapper.find(GlDropdown); + const findDropdownItem = (index = 0) => + findDropdown() + .findAll(GlDropdownItem) + .at(index); + const selectItem = index => { + findDropdownItem(index).vm.$emit('click'); + + return wrapper.vm.$nextTick(); + }; + const clickToggleButton = () => { + findDropdown().vm.$emit('click'); + + return wrapper.vm.$nextTick(); + }; + + it('fails for empty actionItems', () => { + const actionItems = []; + expect(() => createComponent({ actionItems })).toThrow(); + }); + + it('fails for single actionItems', () => { + const actionItems = [mockActionItems[0]]; + expect(() => createComponent({ actionItems })).toThrow(); + }); + + it('renders actionItems', () => { + createComponent({ actionItems: mockActionItems }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('toggle button text', () => { + beforeEach(() => { + createComponent({ actionItems: mockActionItems }); + }); + + it('defaults to first actionItems title', () => { + expect(findDropdown().props().text).toBe(mockActionItems[0].title); + }); + + it('changes to selected actionItems title', () => + selectItem(1).then(() => { + expect(findDropdown().props().text).toBe(mockActionItems[1].title); + })); + }); + + describe('emitted event', () => { + let eventHandler; + + beforeEach(() => { + createComponent({ actionItems: mockActionItems }); + }); + + const addEventHandler = ({ eventName }) => { + eventHandler = jest.fn(); + wrapper.vm.$once(eventName, () => eventHandler()); + }; + + it('defaults to first actionItems event', () => { + addEventHandler(mockActionItems[0]); + + return clickToggleButton().then(() => { + expect(eventHandler).toHaveBeenCalled(); + }); + }); + + it('changes to selected actionItems event', () => + selectItem(1) + .then(() => addEventHandler(mockActionItems[1])) + .then(clickToggleButton) + .then(() => { + expect(eventHandler).toHaveBeenCalled(); + })); + }); +}); diff --git a/spec/javascripts/vue_shared/components/table_pagination_spec.js b/spec/frontend/vue_shared/components/table_pagination_spec.js similarity index 65% rename from spec/javascripts/vue_shared/components/table_pagination_spec.js rename to spec/frontend/vue_shared/components/table_pagination_spec.js index 258530f32f7cafb768c8c8d88befe73f60aac733..0a9ff36b2fb00348665ba9e5c85e8f2af3e01e1f 100644 --- a/spec/javascripts/vue_shared/components/table_pagination_spec.js +++ b/spec/frontend/vue_shared/components/table_pagination_spec.js @@ -1,26 +1,37 @@ -import Vue from 'vue'; -import paginationComp from '~/vue_shared/components/pagination/table_pagination.vue'; +import { shallowMount } from '@vue/test-utils'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; describe('Pagination component', () => { - let component; - let PaginationComponent; + let wrapper; let spy; - let mountComponent; + + const mountComponent = props => { + wrapper = shallowMount(TablePagination, { + sync: false, + propsData: props, + }); + }; + + const findFirstButtonLink = () => wrapper.find('.js-first-button .page-link'); + const findPreviousButton = () => wrapper.find('.js-previous-button'); + const findPreviousButtonLink = () => wrapper.find('.js-previous-button .page-link'); + const findNextButton = () => wrapper.find('.js-next-button'); + const findNextButtonLink = () => wrapper.find('.js-next-button .page-link'); + const findLastButtonLink = () => wrapper.find('.js-last-button .page-link'); + const findPages = () => wrapper.findAll('.page'); + const findSeparator = () => wrapper.find('.separator'); beforeEach(() => { - spy = jasmine.createSpy('spy'); - PaginationComponent = Vue.extend(paginationComp); + spy = jest.fn(); + }); - mountComponent = function(props) { - return new PaginationComponent({ - propsData: props, - }).$mount(); - }; + afterEach(() => { + wrapper.destroy(); }); describe('render', () => { it('should not render anything', () => { - component = mountComponent({ + mountComponent({ pageInfo: { nextPage: NaN, page: 1, @@ -32,12 +43,12 @@ describe('Pagination component', () => { change: spy, }); - expect(component.$el.childNodes.length).toEqual(0); + expect(wrapper.isEmpty()).toBe(true); }); describe('prev button', () => { it('should be disabled and non clickable', () => { - component = mountComponent({ + mountComponent({ pageInfo: { nextPage: 2, page: 1, @@ -49,17 +60,13 @@ describe('Pagination component', () => { change: spy, }); - expect( - component.$el.querySelector('.js-previous-button').classList.contains('disabled'), - ).toEqual(true); - - component.$el.querySelector('.js-previous-button .page-link').click(); - + expect(findPreviousButton().classes()).toContain('disabled'); + findPreviousButtonLink().trigger('click'); expect(spy).not.toHaveBeenCalled(); }); it('should be disabled and non clickable when total and totalPages are NaN', () => { - component = mountComponent({ + mountComponent({ pageInfo: { nextPage: 2, page: 1, @@ -70,18 +77,13 @@ describe('Pagination component', () => { }, change: spy, }); - - expect( - component.$el.querySelector('.js-previous-button').classList.contains('disabled'), - ).toEqual(true); - - component.$el.querySelector('.js-previous-button .page-link').click(); - + expect(findPreviousButton().classes()).toContain('disabled'); + findPreviousButtonLink().trigger('click'); expect(spy).not.toHaveBeenCalled(); }); it('should be enabled and clickable', () => { - component = mountComponent({ + mountComponent({ pageInfo: { nextPage: 3, page: 2, @@ -92,14 +94,12 @@ describe('Pagination component', () => { }, change: spy, }); - - component.$el.querySelector('.js-previous-button .page-link').click(); - + findPreviousButtonLink().trigger('click'); expect(spy).toHaveBeenCalledWith(1); }); it('should be enabled and clickable when total and totalPages are NaN', () => { - component = mountComponent({ + mountComponent({ pageInfo: { nextPage: 3, page: 2, @@ -110,16 +110,14 @@ describe('Pagination component', () => { }, change: spy, }); - - component.$el.querySelector('.js-previous-button .page-link').click(); - + findPreviousButtonLink().trigger('click'); expect(spy).toHaveBeenCalledWith(1); }); }); describe('first button', () => { it('should call the change callback with the first page', () => { - component = mountComponent({ + mountComponent({ pageInfo: { nextPage: 3, page: 2, @@ -130,18 +128,14 @@ describe('Pagination component', () => { }, change: spy, }); - - const button = component.$el.querySelector('.js-first-button .page-link'); - - expect(button.textContent.trim()).toEqual('« First'); - - button.click(); - + const button = findFirstButtonLink(); + expect(button.text().trim()).toEqual('« First'); + button.trigger('click'); expect(spy).toHaveBeenCalledWith(1); }); it('should call the change callback with the first page when total and totalPages are NaN', () => { - component = mountComponent({ + mountComponent({ pageInfo: { nextPage: 3, page: 2, @@ -152,20 +146,16 @@ describe('Pagination component', () => { }, change: spy, }); - - const button = component.$el.querySelector('.js-first-button .page-link'); - - expect(button.textContent.trim()).toEqual('« First'); - - button.click(); - + const button = findFirstButtonLink(); + expect(button.text().trim()).toEqual('« First'); + button.trigger('click'); expect(spy).toHaveBeenCalledWith(1); }); }); describe('last button', () => { it('should call the change callback with the last page', () => { - component = mountComponent({ + mountComponent({ pageInfo: { nextPage: 3, page: 2, @@ -176,18 +166,14 @@ describe('Pagination component', () => { }, change: spy, }); - - const button = component.$el.querySelector('.js-last-button .page-link'); - - expect(button.textContent.trim()).toEqual('Last »'); - - button.click(); - + const button = findLastButtonLink(); + expect(button.text().trim()).toEqual('Last »'); + button.trigger('click'); expect(spy).toHaveBeenCalledWith(5); }); it('should not render', () => { - component = mountComponent({ + mountComponent({ pageInfo: { nextPage: 3, page: 2, @@ -198,14 +184,13 @@ describe('Pagination component', () => { }, change: spy, }); - - expect(component.$el.querySelector('.js-last-button .page-link')).toBeNull(); + expect(findLastButtonLink().exists()).toBe(false); }); }); describe('next button', () => { it('should be disabled and non clickable', () => { - component = mountComponent({ + mountComponent({ pageInfo: { nextPage: NaN, page: 5, @@ -216,16 +201,17 @@ describe('Pagination component', () => { }, change: spy, }); - - expect(component.$el.querySelector('.js-next-button').textContent.trim()).toEqual('Next ›'); - - component.$el.querySelector('.js-next-button .page-link').click(); - + expect( + findNextButton() + .text() + .trim(), + ).toEqual('Next ›'); + findNextButtonLink().trigger('click'); expect(spy).not.toHaveBeenCalled(); }); it('should be disabled and non clickable when total and totalPages are NaN', () => { - component = mountComponent({ + mountComponent({ pageInfo: { nextPage: NaN, page: 5, @@ -236,16 +222,17 @@ describe('Pagination component', () => { }, change: spy, }); - - expect(component.$el.querySelector('.js-next-button').textContent.trim()).toEqual('Next ›'); - - component.$el.querySelector('.js-next-button .page-link').click(); - + expect( + findNextButton() + .text() + .trim(), + ).toEqual('Next ›'); + findNextButtonLink().trigger('click'); expect(spy).not.toHaveBeenCalled(); }); it('should be enabled and clickable', () => { - component = mountComponent({ + mountComponent({ pageInfo: { nextPage: 4, page: 3, @@ -256,14 +243,12 @@ describe('Pagination component', () => { }, change: spy, }); - - component.$el.querySelector('.js-next-button .page-link').click(); - + findNextButtonLink().trigger('click'); expect(spy).toHaveBeenCalledWith(4); }); it('should be enabled and clickable when total and totalPages are NaN', () => { - component = mountComponent({ + mountComponent({ pageInfo: { nextPage: 4, page: 3, @@ -274,16 +259,14 @@ describe('Pagination component', () => { }, change: spy, }); - - component.$el.querySelector('.js-next-button .page-link').click(); - + findNextButtonLink().trigger('click'); expect(spy).toHaveBeenCalledWith(4); }); }); describe('numbered buttons', () => { it('should render 5 pages', () => { - component = mountComponent({ + mountComponent({ pageInfo: { nextPage: 4, page: 3, @@ -294,12 +277,11 @@ describe('Pagination component', () => { }, change: spy, }); - - expect(component.$el.querySelectorAll('.page').length).toEqual(5); + expect(findPages().length).toEqual(5); }); it('should not render any page', () => { - component = mountComponent({ + mountComponent({ pageInfo: { nextPage: 4, page: 3, @@ -310,14 +292,13 @@ describe('Pagination component', () => { }, change: spy, }); - - expect(component.$el.querySelectorAll('.page').length).toEqual(0); + expect(findPages().length).toEqual(0); }); }); describe('spread operator', () => { it('should render', () => { - component = mountComponent({ + mountComponent({ pageInfo: { nextPage: 4, page: 3, @@ -328,12 +309,15 @@ describe('Pagination component', () => { }, change: spy, }); - - expect(component.$el.querySelector('.separator').textContent.trim()).toEqual('...'); + expect( + findSeparator() + .text() + .trim(), + ).toEqual('...'); }); it('should not render', () => { - component = mountComponent({ + mountComponent({ pageInfo: { nextPage: 4, page: 3, @@ -344,8 +328,7 @@ describe('Pagination component', () => { }, change: spy, }); - - expect(component.$el.querySelector('.separator')).toBeNull(); + expect(findSeparator().exists()).toBe(false); }); }); }); 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 new file mode 100644 index 0000000000000000000000000000000000000000..2f87359a4a640a05a902edcc8c27ea5ad8bd0abf --- /dev/null +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js @@ -0,0 +1,108 @@ +import { shallowMount } from '@vue/test-utils'; +import { placeholderImage } from '~/lazy_loader'; +import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; +import defaultAvatarUrl from 'images/no_avatar.png'; + +jest.mock('images/no_avatar.png', () => 'default-avatar-url'); + +const DEFAULT_PROPS = { + size: 99, + imgSrc: 'myavatarurl.com', + imgAlt: 'mydisplayname', + cssClasses: 'myextraavatarclass', + tooltipText: 'tooltip text', + tooltipPlacement: 'bottom', +}; + +describe('User Avatar Image Component', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Initialization', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { + ...DEFAULT_PROPS, + }, + sync: false, + }); + }); + + it('should have <img> as a child element', () => { + const imageElement = wrapper.find('img'); + + expect(imageElement.exists()).toBe(true); + expect(imageElement.attributes('src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`); + expect(imageElement.attributes('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`); + expect(imageElement.attributes('alt')).toBe(DEFAULT_PROPS.imgAlt); + }); + + it('should properly render img css', () => { + const classes = wrapper.find('img').classes(); + expect(classes).toEqual(expect.arrayContaining(['avatar', 's99', DEFAULT_PROPS.cssClasses])); + expect(classes).not.toContain('lazy'); + }); + }); + + describe('Initialization when lazy', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { + ...DEFAULT_PROPS, + lazy: true, + }, + sync: false, + }); + }); + + it('should add lazy attributes', () => { + const imageElement = wrapper.find('img'); + + expect(imageElement.classes()).toContain('lazy'); + expect(imageElement.attributes('src')).toBe(placeholderImage); + expect(imageElement.attributes('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`); + }); + }); + + describe('Initialization without src', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { sync: false }); + }); + + it('should have default avatar image', () => { + const imageElement = wrapper.find('img'); + + expect(imageElement.attributes('src')).toBe(`${defaultAvatarUrl}?width=20`); + }); + }); + + describe('dynamic tooltip content', () => { + const props = DEFAULT_PROPS; + const slots = { + default: ['Action!'], + }; + + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { propsData: { props }, slots, sync: false }); + }); + + it('renders the tooltip slot', () => { + expect(wrapper.find('.js-user-avatar-image-toolip').exists()).toBe(true); + }); + + it('renders the tooltip content', () => { + expect(wrapper.find('.js-user-avatar-image-toolip').text()).toContain(slots.default[0]); + }); + + 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('data-placement')).not.toBeDefined(); + expect(avatarImg.attributes('data-container')).not.toBeDefined(); + }); + }); +}); 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 new file mode 100644 index 0000000000000000000000000000000000000000..fc2eb6329b0f70fca7ee75b34df67fe196083cfa --- /dev/null +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -0,0 +1,186 @@ +import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue'; +import { mount } from '@vue/test-utils'; + +const DEFAULT_PROPS = { + loaded: true, + user: { + username: 'root', + name: 'Administrator', + location: 'Vienna', + bio: null, + organization: null, + status: null, + }, +}; + +describe('User Popover Component', () => { + const fixtureTemplate = 'merge_requests/diff_comment.html'; + preloadFixtures(fixtureTemplate); + + let wrapper; + + beforeEach(() => { + loadFixtures(fixtureTemplate); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Empty', () => { + beforeEach(() => { + wrapper = mount(UserPopover, { + propsData: { + target: document.querySelector('.js-user-link'), + user: { + name: null, + username: null, + location: null, + bio: null, + organization: null, + status: null, + }, + }, + sync: false, + }); + }); + + it('should return skeleton loaders', () => { + expect(wrapper.findAll('.animation-container').length).toBe(4); + }); + }); + + describe('basic data', () => { + it('should show basic fields', () => { + wrapper = mount(UserPopover, { + propsData: { + ...DEFAULT_PROPS, + target: document.querySelector('.js-user-link'), + }, + sync: false, + }); + + expect(wrapper.text()).toContain(DEFAULT_PROPS.user.name); + expect(wrapper.text()).toContain(DEFAULT_PROPS.user.username); + expect(wrapper.text()).toContain(DEFAULT_PROPS.user.location); + }); + + it('shows icon for location', () => { + const iconEl = wrapper.find('.js-location svg'); + + expect(iconEl.find('use').element.getAttribute('xlink:href')).toContain('location'); + }); + }); + + describe('job data', () => { + it('should show only bio if no organization is available', () => { + const testProps = Object.assign({}, DEFAULT_PROPS); + testProps.user.bio = 'Engineer'; + + wrapper = mount(UserPopover, { + propsData: { + ...testProps, + target: document.querySelector('.js-user-link'), + }, + sync: false, + }); + + 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'; + + wrapper = mount(UserPopover, { + propsData: { + ...testProps, + target: document.querySelector('.js-user-link'), + }, + sync: false, + }); + + 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 = mount(UserPopover, { + propsData: { + ...DEFAULT_PROPS, + target: document.querySelector('.js-user-link'), + }, + sync: false, + }); + + 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 = mount(UserPopover, { + propsData: { + ...DEFAULT_PROPS, + target: document.querySelector('.js-user-link'), + }, + sync: false, + }); + + expect(wrapper.find('.js-bio').text()).toContain('Manager & Team Lead'); + expect(wrapper.find('.js-organization').text()).toContain('Me & my <funky> Company'); + }); + + it('shows icon for bio', () => { + const iconEl = wrapper.find('.js-bio svg'); + + expect(iconEl.find('use').element.getAttribute('xlink:href')).toContain('profile'); + }); + + it('shows icon for organization', () => { + const iconEl = wrapper.find('.js-organization svg'); + + expect(iconEl.find('use').element.getAttribute('xlink:href')).toContain('work'); + }); + }); + + describe('status data', () => { + it('should show only message', () => { + const testProps = Object.assign({}, DEFAULT_PROPS); + testProps.user.status = { message_html: 'Hello World' }; + + wrapper = mount(UserPopover, { + propsData: { + ...DEFAULT_PROPS, + target: document.querySelector('.js-user-link'), + }, + sync: false, + }); + + 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 = mount(UserPopover, { + propsData: { + ...DEFAULT_PROPS, + target: document.querySelector('.js-user-link'), + status: { emoji: 'basketball_player', message_html: 'Hello World' }, + }, + sync: false, + }); + + expect(wrapper.text()).toContain('Hello World'); + expect(wrapper.html()).toContain('<gl-emoji data-name="basketball_player"'); + }); + }); +}); diff --git a/spec/graphql/features/authorization_spec.rb b/spec/graphql/features/authorization_spec.rb index 9a60ff3b78c35f55568fbc945593bfc7d00c5970..7ad6a622b4b676abb11c87aaee776a501b1b52f3 100644 --- a/spec/graphql/features/authorization_spec.rb +++ b/spec/graphql/features/authorization_spec.rb @@ -259,7 +259,8 @@ describe 'Gitlab::Graphql::Authorization' do let(:project_type) do |type| type_factory do |type| type.graphql_name 'FakeProjectType' - type.field :test_issues, issue_type.connection_type, null: false, resolve: -> (_, _, _) { Issue.where(project: [visible_project, other_project]) } + type.field :test_issues, issue_type.connection_type, null: false, + resolve: -> (_, _, _) { Issue.where(project: [visible_project, other_project]).order(id: :asc) } end end let(:query_type) do diff --git a/spec/graphql/gitlab_schema_spec.rb b/spec/graphql/gitlab_schema_spec.rb index 0a27bbecfef35df5e3663383caefa4576f905751..dcf3c9890478a8c68505ee8f298c6fe94ce28724 100644 --- a/spec/graphql/gitlab_schema_spec.rb +++ b/spec/graphql/gitlab_schema_spec.rb @@ -36,7 +36,7 @@ describe GitlabSchema do it 'paginates active record relations using `Gitlab::Graphql::Connections::KeysetConnection`' do connection = GraphQL::Relay::BaseConnection::CONNECTION_IMPLEMENTATIONS[ActiveRecord::Relation.name] - expect(connection).to eq(Gitlab::Graphql::Connections::KeysetConnection) + expect(connection).to eq(Gitlab::Graphql::Connections::Keyset::Connection) end describe '.execute' do diff --git a/spec/graphql/mutations/merge_requests/set_assignees_spec.rb b/spec/graphql/mutations/merge_requests/set_assignees_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e8da0e25b7d03f77d21135da3a64455b8971ed52 --- /dev/null +++ b/spec/graphql/mutations/merge_requests/set_assignees_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mutations::MergeRequests::SetAssignees do + let(:merge_request) { create(:merge_request) } + let(:user) { create(:user) } + subject(:mutation) { described_class.new(object: nil, context: { current_user: user }) } + + describe '#resolve' do + let(:assignee) { create(:user) } + let(:assignee2) { create(:user) } + let(:assignee_usernames) { [assignee.username] } + let(:mutated_merge_request) { subject[:merge_request] } + subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, assignee_usernames: assignee_usernames) } + + before do + merge_request.project.add_developer(assignee) + merge_request.project.add_developer(assignee2) + end + + it 'raises an error if the resource is not accessible to the user' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + + context 'when the user can update the merge request' do + before do + merge_request.project.add_developer(user) + end + + it 'replaces the assignee' do + merge_request.assignees = [assignee2] + merge_request.save! + + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.assignees).to contain_exactly(assignee) + expect(subject[:errors]).to be_empty + end + + it 'returns errors merge request could not be updated' do + # Make the merge request invalid + merge_request.allow_broken = true + merge_request.update!(source_project: nil) + + expect(subject[:errors]).not_to be_empty + end + + context 'when passing an empty assignee list' do + let(:assignee_usernames) { [] } + + before do + merge_request.assignees = [assignee] + merge_request.save! + end + + it 'removes all assignees' do + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.assignees).to eq([]) + expect(subject[:errors]).to be_empty + end + end + + context 'when passing "append" as true' do + subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, assignee_usernames: assignee_usernames, operation_mode: Types::MutationOperationModeEnum.enum[:append]) } + + before do + merge_request.assignees = [assignee2] + merge_request.save! + + # In CE, APPEND is a NOOP as you can't have multiple assignees + # We test multiple assignment in EE specs + stub_licensed_features(multiple_merge_request_assignees: false) + end + + it 'is a NO-OP in FOSS' do + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.assignees).to contain_exactly(assignee2) + expect(subject[:errors]).to be_empty + end + end + + context 'when passing "remove" as true' do + before do + merge_request.assignees = [assignee] + merge_request.save! + end + + it 'removes named assignee' do + mutated_merge_request = mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, assignee_usernames: assignee_usernames, operation_mode: Types::MutationOperationModeEnum.enum[:remove])[:merge_request] + + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.assignees).to eq([]) + expect(subject[:errors]).to be_empty + end + + it 'does not remove unnamed assignee' do + mutated_merge_request = mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, assignee_usernames: [assignee2.username], operation_mode: Types::MutationOperationModeEnum.enum[:remove])[:merge_request] + + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.assignees).to contain_exactly(assignee) + expect(subject[:errors]).to be_empty + end + end + end + end +end diff --git a/spec/graphql/mutations/merge_requests/set_labels_spec.rb b/spec/graphql/mutations/merge_requests/set_labels_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3729251bab7e9d089c35713117aa48969236723d --- /dev/null +++ b/spec/graphql/mutations/merge_requests/set_labels_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mutations::MergeRequests::SetLabels do + let(:merge_request) { create(:merge_request) } + let(:user) { create(:user) } + subject(:mutation) { described_class.new(object: nil, context: { current_user: user }) } + + describe '#resolve' do + let(:label) { create(:label, project: merge_request.project) } + let(:label2) { create(:label, project: merge_request.project) } + let(:label_ids) { [label.to_global_id] } + let(:mutated_merge_request) { subject[:merge_request] } + subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, label_ids: label_ids) } + + it 'raises an error if the resource is not accessible to the user' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + + context 'when the user can update the merge request' do + before do + merge_request.project.add_developer(user) + end + + it 'sets the labels, removing all others' do + merge_request.update!(labels: [label2]) + + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.labels).to contain_exactly(label) + expect(subject[:errors]).to be_empty + end + + it 'returns errors merge request could not be updated' do + # Make the merge request invalid + merge_request.allow_broken = true + merge_request.update!(source_project: nil) + + expect(subject[:errors]).not_to be_empty + end + + context 'when passing an empty array' do + let(:label_ids) { [] } + + it 'removes all labels' do + merge_request.update!(labels: [label]) + + expect(mutated_merge_request.labels).to be_empty + end + end + + context 'when passing operation_mode as APPEND' do + subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, label_ids: label_ids, operation_mode: Types::MutationOperationModeEnum.enum[:append]) } + + it 'sets the labels, without removing others' do + merge_request.update!(labels: [label2]) + + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.labels).to contain_exactly(label, label2) + expect(subject[:errors]).to be_empty + end + end + + context 'when passing operation_mode as REMOVE' do + subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, label_ids: label_ids, operation_mode: Types::MutationOperationModeEnum.enum[:remove])} + + it 'removes the labels, without removing others' do + merge_request.update!(labels: [label, label2]) + + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.labels).to contain_exactly(label2) + expect(subject[:errors]).to be_empty + end + end + end + end +end diff --git a/spec/graphql/mutations/merge_requests/set_locked_spec.rb b/spec/graphql/mutations/merge_requests/set_locked_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..51249854378ae71f25c106578b942b213a8b25fa --- /dev/null +++ b/spec/graphql/mutations/merge_requests/set_locked_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mutations::MergeRequests::SetLocked do + let(:merge_request) { create(:merge_request) } + let(:user) { create(:user) } + subject(:mutation) { described_class.new(object: nil, context: { current_user: user }) } + + describe '#resolve' do + let(:locked) { true } + let(:mutated_merge_request) { subject[:merge_request] } + subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, locked: locked) } + + it 'raises an error if the resource is not accessible to the user' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + + context 'when the user can update the merge request' do + before do + merge_request.project.add_developer(user) + end + + it 'returns the merge request as discussion locked' do + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request).to be_discussion_locked + expect(subject[:errors]).to be_empty + end + + it 'returns errors merge request could not be updated' do + # Make the merge request invalid + merge_request.allow_broken = true + merge_request.update!(source_project: nil) + + expect(subject[:errors]).not_to be_empty + end + + context 'when passing locked as false' do + let(:locked) { false } + + it 'unlocks the discussion' do + merge_request.update(discussion_locked: true) + + expect(mutated_merge_request).not_to be_discussion_locked + end + end + end + end +end diff --git a/spec/graphql/mutations/merge_requests/set_milestone_spec.rb b/spec/graphql/mutations/merge_requests/set_milestone_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c2792a4bc25596d2a8fc9107987377cb5577b578 --- /dev/null +++ b/spec/graphql/mutations/merge_requests/set_milestone_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mutations::MergeRequests::SetMilestone do + let(:merge_request) { create(:merge_request) } + let(:user) { create(:user) } + subject(:mutation) { described_class.new(object: nil, context: { current_user: user }) } + + describe '#resolve' do + let(:milestone) { create(:milestone, project: merge_request.project) } + let(:mutated_merge_request) { subject[:merge_request] } + subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, milestone: milestone) } + + it 'raises an error if the resource is not accessible to the user' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + + context 'when the user can update the merge request' do + before do + merge_request.project.add_developer(user) + end + + it 'returns the merge request with the milestone' do + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.milestone).to eq(milestone) + expect(subject[:errors]).to be_empty + end + + it 'returns errors merge request could not be updated' do + # Make the merge request invalid + merge_request.allow_broken = true + merge_request.update!(source_project: nil) + + expect(subject[:errors]).not_to be_empty + end + + context 'when passing milestone_id as nil' do + let(:milestone) { nil } + + it 'removes the milestone' do + merge_request.update!(milestone: create(:milestone, project: merge_request.project)) + + expect(mutated_merge_request.milestone).to eq(nil) + end + + it 'does not do anything if the MR already does not have a milestone' do + expect(mutated_merge_request.milestone).to eq(nil) + end + end + end + end +end diff --git a/spec/graphql/mutations/merge_requests/set_subscription_spec.rb b/spec/graphql/mutations/merge_requests/set_subscription_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..116a77abcc0d7ac39e986cdc4fb7dc148dcf4103 --- /dev/null +++ b/spec/graphql/mutations/merge_requests/set_subscription_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mutations::MergeRequests::SetSubscription do + let(:merge_request) { create(:merge_request) } + let(:project) { merge_request.project } + let(:user) { create(:user) } + subject(:mutation) { described_class.new(object: nil, context: { current_user: user }) } + + describe '#resolve' do + let(:subscribe) { true } + let(:mutated_merge_request) { subject[:merge_request] } + subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, subscribed_state: subscribe) } + + it 'raises an error if the resource is not accessible to the user' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + + context 'when the user can update the merge request' do + before do + merge_request.project.add_developer(user) + end + + it 'returns the merge request as discussion locked' do + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.subscribed?(user, project)).to eq(true) + expect(subject[:errors]).to be_empty + end + + context 'when passing subscribe as false' do + let(:subscribe) { false } + + it 'unsubscribes from the discussion' do + merge_request.subscribe(user, project) + + expect(mutated_merge_request.subscribed?(user, project)).to eq(false) + end + end + end + end +end diff --git a/spec/graphql/mutations/todos/mark_done_spec.rb b/spec/graphql/mutations/todos/mark_done_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..761b153d5d1db2d83c226be869412f1bbe0d756a --- /dev/null +++ b/spec/graphql/mutations/todos/mark_done_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mutations::Todos::MarkDone do + let_it_be(:current_user) { create(:user) } + let_it_be(:author) { create(:user) } + let_it_be(:other_user) { create(:user) } + + let_it_be(:todo1) { create(:todo, user: current_user, author: author, state: :pending) } + let_it_be(:todo2) { create(:todo, user: current_user, author: author, state: :done) } + + let_it_be(:other_user_todo) { create(:todo, user: other_user, author: author, state: :pending) } + + let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }) } + + describe '#resolve' do + it 'marks a single todo as done' do + result = mark_done_mutation(todo1) + + expect(todo1.reload.state).to eq('done') + expect(todo2.reload.state).to eq('done') + expect(other_user_todo.reload.state).to eq('pending') + + todo = result[:todo] + expect(todo.id).to eq(todo1.id) + expect(todo.state).to eq('done') + end + + it 'handles a todo which is already done as expected' do + result = mark_done_mutation(todo2) + + expect(todo1.reload.state).to eq('pending') + expect(todo2.reload.state).to eq('done') + expect(other_user_todo.reload.state).to eq('pending') + + todo = result[:todo] + expect(todo.id).to eq(todo2.id) + expect(todo.state).to eq('done') + end + + it 'ignores requests for todos which do not belong to the current user' do + expect { mark_done_mutation(other_user_todo) }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + + expect(todo1.reload.state).to eq('pending') + expect(todo2.reload.state).to eq('done') + expect(other_user_todo.reload.state).to eq('pending') + end + + it 'ignores invalid GIDs' do + expect { mutation.resolve(id: 'invalid_gid') }.to raise_error(Gitlab::Graphql::Errors::ArgumentError) + + expect(todo1.reload.state).to eq('pending') + expect(todo2.reload.state).to eq('done') + expect(other_user_todo.reload.state).to eq('pending') + end + end + + def mark_done_mutation(todo) + mutation.resolve(id: global_id_of(todo)) + end + + def global_id_of(todo) + todo.to_global_id.to_s + end +end diff --git a/spec/graphql/resolvers/base_resolver_spec.rb b/spec/graphql/resolvers/base_resolver_spec.rb index c162fdbbb470595a9b4da655577b73ad7951d2e0..a212bd07f35adc3cfb7ac83f5c32b92e04a3168f 100644 --- a/spec/graphql/resolvers/base_resolver_spec.rb +++ b/spec/graphql/resolvers/base_resolver_spec.rb @@ -13,6 +13,14 @@ describe Resolvers::BaseResolver do end end + let(:last_resolver) do + Class.new(described_class) do + def resolve(**args) + [1, 2] + end + end + end + describe '.single' do it 'returns a subclass from the resolver' do expect(resolver.single.superclass).to eq(resolver) @@ -29,6 +37,22 @@ describe Resolvers::BaseResolver do end end + describe '.last' do + it 'returns a subclass from the resolver' do + expect(last_resolver.last.superclass).to eq(last_resolver) + end + + it 'returns the same subclass every time' do + expect(last_resolver.last.object_id).to eq(last_resolver.last.object_id) + end + + it 'returns a resolver that gives the last result from the original resolver' do + result = resolve(last_resolver.last) + + expect(result).to eq(2) + end + end + context 'when field is a connection' do it 'increases complexity based on arguments' do field = Types::BaseField.new(name: 'test', type: GraphQL::STRING_TYPE.connection_type, resolver_class: described_class, null: false, max_page_size: 1) diff --git a/spec/graphql/resolvers/commit_pipelines_resolver_spec.rb b/spec/graphql/resolvers/commit_pipelines_resolver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..93da877d714008d9f01278c650b47470950bb153 --- /dev/null +++ b/spec/graphql/resolvers/commit_pipelines_resolver_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Resolvers::CommitPipelinesResolver do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let(:commit) { create(:commit, project: project) } + let_it_be(:current_user) { create(:user) } + + let!(:pipeline) do + create( + :ci_pipeline, + project: project, + sha: commit.id, + ref: 'master', + status: 'success' + ) + end + let!(:pipeline2) do + create( + :ci_pipeline, + project: project, + sha: commit.id, + ref: 'master', + status: 'failed' + ) + end + let!(:pipeline3) do + create( + :ci_pipeline, + project: project, + sha: commit.id, + ref: 'my_branch', + status: 'failed' + ) + end + + before do + commit.project.add_developer(current_user) + end + + def resolve_pipelines + resolve(described_class, obj: commit, ctx: { current_user: current_user }, args: { ref: 'master' }) + end + + it 'resolves pipelines for commit and ref' do + pipelines = resolve_pipelines + + expect(pipelines).to eq([pipeline2, pipeline]) + end +end diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb index 2232c9b7d7bc750272855e64fafb01842bdd96e7..bf9106643ebdef83606f69ec4ed48c6d7194835f 100644 --- a/spec/graphql/resolvers/issues_resolver_spec.rb +++ b/spec/graphql/resolvers/issues_resolver_spec.rb @@ -72,8 +72,46 @@ describe Resolvers::IssuesResolver do expect(resolve_issues(search: 'foo')).to contain_exactly(issue2) end - it 'sort issues' do - expect(resolve_issues(sort: 'created_desc')).to eq [issue2, issue1] + describe 'sorting' do + context 'when sorting by created' do + it 'sorts issues ascending' do + expect(resolve_issues(sort: 'created_asc')).to eq [issue1, issue2] + end + + it 'sorts issues descending' do + expect(resolve_issues(sort: 'created_desc')).to eq [issue2, issue1] + end + end + + context 'when sorting by due date' do + let(:project) { create(:project) } + + let!(:due_issue1) { create(:issue, project: project, due_date: 3.days.from_now) } + let!(:due_issue2) { create(:issue, project: project, due_date: nil) } + let!(:due_issue3) { create(:issue, project: project, due_date: 2.days.ago) } + let!(:due_issue4) { create(:issue, project: project, due_date: nil) } + + it 'sorts issues ascending' do + expect(resolve_issues(sort: :due_date_asc)).to eq [due_issue3, due_issue1, due_issue4, due_issue2] + end + + it 'sorts issues descending' do + expect(resolve_issues(sort: :due_date_desc)).to eq [due_issue1, due_issue3, due_issue4, due_issue2] + end + end + + context 'when sorting by relative position' do + let(:project) { create(:project) } + + let!(:relative_issue1) { create(:issue, project: project, relative_position: 2000) } + let!(:relative_issue2) { create(:issue, project: project, relative_position: nil) } + let!(:relative_issue3) { create(:issue, project: project, relative_position: 1000) } + let!(:relative_issue4) { create(:issue, project: project, relative_position: nil) } + + it 'sorts issues ascending' do + expect(resolve_issues(sort: :relative_position_asc)).to eq [relative_issue3, relative_issue1, relative_issue4, relative_issue2] + end + end end it 'returns issues user can see' do diff --git a/spec/graphql/types/base_enum_spec.rb b/spec/graphql/types/base_enum_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3eadb492cf5f5140a87e9e3e03f7bd1ff9aa338f --- /dev/null +++ b/spec/graphql/types/base_enum_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Types::BaseEnum do + describe '#enum' do + let(:enum) do + Class.new(described_class) do + value 'TEST', value: 3 + value 'other' + value 'NORMAL' + end + end + + it 'adds all enum values to #enum' do + expect(enum.enum.keys).to contain_exactly('test', 'other', 'normal') + expect(enum.enum.values).to contain_exactly(3, 'other', 'NORMAL') + end + + it 'is a HashWithIndefferentAccess' do + expect(enum.enum).to be_a(HashWithIndifferentAccess) + end + end +end diff --git a/spec/graphql/types/commit_type_spec.rb b/spec/graphql/types/commit_type_spec.rb index 1ff1c97f8dbac517ab643ad8282da1945e2f8e34..1c3b46ecfdecad2a0d14e0f12ddf18a5a37d1148 100644 --- a/spec/graphql/types/commit_type_spec.rb +++ b/spec/graphql/types/commit_type_spec.rb @@ -10,7 +10,7 @@ describe GitlabSchema.types['Commit'] do it 'contains attributes related to commit' do expect(described_class).to have_graphql_fields( :id, :sha, :title, :description, :message, :authored_date, - :author, :web_url, :latest_pipeline, :signature_html + :author_name, :author, :web_url, :latest_pipeline, :pipelines, :signature_html ) end end diff --git a/spec/graphql/types/extended_issue_type_spec.rb b/spec/graphql/types/extended_issue_type_spec.rb deleted file mode 100644 index 72ce53ae1be944cc978defd28e91eb44029f7c10..0000000000000000000000000000000000000000 --- a/spec/graphql/types/extended_issue_type_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe GitlabSchema.types['ExtendedIssue'] do - it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Issue) } - - it { expect(described_class.graphql_name).to eq('ExtendedIssue') } - - it { expect(described_class).to require_graphql_authorizations(:read_issue) } - - it { expect(described_class.interfaces).to include(Types::Notes::NoteableType.to_graphql) } - - it 'has specific fields' do - fields = Types::IssueType.fields.keys + [:subscribed] - - fields.each do |field_name| - expect(described_class).to have_graphql_field(field_name) - end - end -end diff --git a/spec/graphql/types/issue_sort_enum_spec.rb b/spec/graphql/types/issue_sort_enum_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1b6aa6d6069bcd91a95294a461ca8779b51249f0 --- /dev/null +++ b/spec/graphql/types/issue_sort_enum_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe GitlabSchema.types['IssueSort'] do + it { expect(described_class.graphql_name).to eq('IssueSort') } + + it_behaves_like 'common sort values' + + it 'exposes all the existing issue sort values' do + expect(described_class.values.keys).to include(*%w[DUE_DATE_ASC DUE_DATE_DESC RELATIVE_POSITION_ASC]) + end +end diff --git a/spec/graphql/types/issue_type_spec.rb b/spec/graphql/types/issue_type_spec.rb index 8aa2385ddaa17e4dc2a1aeec709d9f954709dc70..daa2224ef204f073f79cf87a0d26623cfe17079c 100644 --- a/spec/graphql/types/issue_type_spec.rb +++ b/spec/graphql/types/issue_type_spec.rb @@ -14,7 +14,7 @@ describe GitlabSchema.types['Issue'] do it 'has specific fields' do fields = %i[iid title description state reference author assignees participants labels milestone due_date confidential discussion_locked upvotes downvotes user_notes_count web_path web_url relative_position - time_estimate total_time_spent closed_at created_at updated_at task_completion_status] + subscribed time_estimate total_time_spent closed_at created_at updated_at task_completion_status] fields.each do |field_name| expect(described_class).to have_graphql_field(field_name) diff --git a/spec/graphql/types/label_type_spec.rb b/spec/graphql/types/label_type_spec.rb index 8e7b2c69effa0b5535c64ef1ece7bc26cbeab17e..a023a75eeffe4f50695c505d604dece5054b65d0 100644 --- a/spec/graphql/types/label_type_spec.rb +++ b/spec/graphql/types/label_type_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe GitlabSchema.types['Label'] do it 'has the correct fields' do - expected_fields = [:description, :description_html, :title, :color, :text_color] + expected_fields = [:id, :description, :description_html, :title, :color, :text_color] is_expected.to have_graphql_fields(*expected_fields) end diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb index cfd0f8ec7a7df278b1e16f3732aead962c4a6a45..19a433f090e7a8cd1afe7218cdfe61cc85009521 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 ] is_expected.to have_graphql_fields(*expected_fields) @@ -32,7 +33,7 @@ describe GitlabSchema.types['Project'] do subject { described_class.fields['issue'] } it 'returns issue' do - is_expected.to have_graphql_type(Types::ExtendedIssueType) + is_expected.to have_graphql_type(Types::IssueType) is_expected.to have_graphql_resolver(Resolvers::IssuesResolver.single) end end diff --git a/spec/graphql/types/tree/blob_type_spec.rb b/spec/graphql/types/tree/blob_type_spec.rb index 22c11aff90afd4795d2425cde8101c8ca1c1e86f..516c862b9c69f5e5e806cc95bfcbb47064bc5205 100644 --- a/spec/graphql/types/tree/blob_type_spec.rb +++ b/spec/graphql/types/tree/blob_type_spec.rb @@ -5,5 +5,5 @@ require 'spec_helper' describe Types::Tree::BlobType do it { expect(described_class.graphql_name).to eq('Blob') } - it { expect(described_class).to have_graphql_fields(:id, :name, :type, :path, :flat_path, :web_url, :lfs_oid) } + it { expect(described_class).to have_graphql_fields(:id, :sha, :name, :type, :path, :flat_path, :web_url, :lfs_oid) } end diff --git a/spec/graphql/types/tree/submodule_type_spec.rb b/spec/graphql/types/tree/submodule_type_spec.rb index 768eccba68c842863de2031db84195923f2299ce..81f7ad825a1cc541f566666977ef74cf1f13dc21 100644 --- a/spec/graphql/types/tree/submodule_type_spec.rb +++ b/spec/graphql/types/tree/submodule_type_spec.rb @@ -5,5 +5,5 @@ require 'spec_helper' describe Types::Tree::SubmoduleType do it { expect(described_class.graphql_name).to eq('Submodule') } - it { expect(described_class).to have_graphql_fields(:id, :name, :type, :path, :flat_path, :web_url, :tree_url) } + it { expect(described_class).to have_graphql_fields(:id, :sha, :name, :type, :path, :flat_path, :web_url, :tree_url) } end diff --git a/spec/graphql/types/tree/tree_entry_type_spec.rb b/spec/graphql/types/tree/tree_entry_type_spec.rb index ea1b6426034b56882b97383d0558ed1b16c96d37..228a4be09498e170ec8fca261c19d3d1c9dea2cf 100644 --- a/spec/graphql/types/tree/tree_entry_type_spec.rb +++ b/spec/graphql/types/tree/tree_entry_type_spec.rb @@ -5,5 +5,5 @@ require 'spec_helper' describe Types::Tree::TreeEntryType do it { expect(described_class.graphql_name).to eq('TreeEntry') } - it { expect(described_class).to have_graphql_fields(:id, :name, :type, :path, :flat_path, :web_url) } + it { expect(described_class).to have_graphql_fields(:id, :sha, :name, :type, :path, :flat_path, :web_url) } end diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index e8c438e459b60d4b4b7ed374aa12730b0f4a8a4b..d3d25d3cb74e0d1cd218fe22a27bd230d6e93bfd 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -210,7 +210,9 @@ describe ApplicationHelper do let(:user) { create(:user, static_object_token: 'hunter1') } before do - allow_any_instance_of(ApplicationSetting).to receive(:static_objects_external_storage_url).and_return('https://cdn.gitlab.com') + allow_next_instance_of(ApplicationSetting) do |instance| + allow(instance).to receive(:static_objects_external_storage_url).and_return('https://cdn.gitlab.com') + end allow(helper).to receive(:current_user).and_return(user) end diff --git a/spec/helpers/application_settings_helper_spec.rb b/spec/helpers/application_settings_helper_spec.rb index 705523f1110a613552f612abda399a69bf710621..8303c4eafbe146690bf412f94ec8ce119dd37e8e 100644 --- a/spec/helpers/application_settings_helper_spec.rb +++ b/spec/helpers/application_settings_helper_spec.rb @@ -36,4 +36,27 @@ describe ApplicationSettingsHelper do it_behaves_like 'when HTTP protocol is in use', 'https' it_behaves_like 'when HTTP protocol is in use', 'http' + + context 'with tracking parameters' do + it { expect(visible_attributes).to include(*%i(snowplow_collector_hostname snowplow_cookie_domain snowplow_enabled snowplow_app_id)) } + end + + describe '.integration_expanded?' do + let(:application_setting) { build(:application_setting) } + + it 'is expanded' do + application_setting.plantuml_enabled = true + application_setting.valid? + helper.instance_variable_set(:@application_setting, application_setting) + + expect(helper.integration_expanded?('plantuml_')).to be_truthy + end + + it 'is not expanded' do + application_setting.valid? + helper.instance_variable_set(:@application_setting, application_setting) + + expect(helper.integration_expanded?('plantuml_')).to be_falsey + end + end end diff --git a/spec/helpers/auth_helper_spec.rb b/spec/helpers/auth_helper_spec.rb index aae515def0c0b99f6dc3bf2d0ef0037e1d2e11aa..cb7c670198d6d3584b808f85b66bb4fd8ea609d2 100644 --- a/spec/helpers/auth_helper_spec.rb +++ b/spec/helpers/auth_helper_spec.rb @@ -54,6 +54,23 @@ describe AuthHelper do end end + describe 'any_form_based_providers_enabled?' do + before do + allow(Gitlab::Auth::LDAP::Config).to receive(:enabled?).and_return(true) + end + + it 'detects form-based providers' do + allow(helper).to receive(:auth_providers) { [:twitter, :ldapmain] } + expect(helper.any_form_based_providers_enabled?).to be(true) + end + + it 'ignores ldap providers when ldap web sign in is disabled' do + allow(helper).to receive(:auth_providers) { [:twitter, :ldapmain] } + allow(helper).to receive(:ldap_sign_in_enabled?).and_return(false) + expect(helper.any_form_based_providers_enabled?).to be(false) + end + end + describe 'enabled_button_based_providers' do before do allow(helper).to receive(:auth_providers) { [:twitter, :github] } diff --git a/spec/helpers/clusters_helper_spec.rb b/spec/helpers/clusters_helper_spec.rb index 4ea0f76fc287f44ec9978043fa6b81c5d9ad3e6c..1ee638ddf0438e1e8d5712a0f2a8d7b5dbea9616 100644 --- a/spec/helpers/clusters_helper_spec.rb +++ b/spec/helpers/clusters_helper_spec.rb @@ -30,4 +30,60 @@ describe ClustersHelper do end end end + + describe '#create_new_cluster_label' do + subject { helper.create_new_cluster_label(provider: provider) } + + context 'GCP provider' do + let(:provider) { 'gcp' } + + it { is_expected.to eq('Create new Cluster on GKE') } + end + + context 'AWS provider' do + let(:provider) { 'aws' } + + it { is_expected.to eq('Create new Cluster on EKS') } + end + + context 'other provider' do + let(:provider) { 'other' } + + it { is_expected.to eq('Create new Cluster') } + end + + context 'no provider' do + let(:provider) { nil } + + it { is_expected.to eq('Create new Cluster') } + end + end + + describe '#render_new_provider_form' do + subject { helper.new_cluster_partial(provider: provider) } + + context 'GCP provider' do + let(:provider) { 'gcp' } + + it { is_expected.to eq('clusters/clusters/gcp/new') } + end + + context 'AWS provider' do + let(:provider) { 'aws' } + + it { is_expected.to eq('clusters/clusters/aws/new') } + end + + context 'other provider' do + let(:provider) { 'other' } + + it { is_expected.to eq('clusters/clusters/cloud_providers/cloud_provider_selector') } + end + + context 'no provider' do + let(:provider) { nil } + + it { is_expected.to eq('clusters/clusters/cloud_providers/cloud_provider_selector') } + end + end end diff --git a/spec/helpers/dashboard_helper_spec.rb b/spec/helpers/dashboard_helper_spec.rb index c899c2d98531a526d39aa8d397ab3631b08a53d6..8a4ea33ac7c7224d7885709a90f9f58ac3e0d1de 100644 --- a/spec/helpers/dashboard_helper_spec.rb +++ b/spec/helpers/dashboard_helper_spec.rb @@ -25,39 +25,62 @@ describe DashboardHelper do end describe '#feature_entry' do - context 'when implicitly enabled' do - it 'considers feature enabled by default' do - entry = feature_entry('Demo', href: 'demo.link') + shared_examples "a feature is enabled" do + it { is_expected.to include('<p aria-label="Demo: status on">') } + end + + shared_examples "a feature is disabled" do + it { is_expected.to include('<p aria-label="Demo: status off">') } + end - expect(entry).to include('<p aria-label="Demo: status on">') - expect(entry).to include('<a href="demo.link">Demo</a>') + shared_examples "a feature without link" do + it do + is_expected.not_to have_link('Demo') + is_expected.not_to have_link('Documentation') end end + shared_examples "a feature with configuration" do + it { is_expected.to have_link('Demo', href: 'demo.link') } + end + + shared_examples "a feature with documentation" do + it { is_expected.to have_link('Documentation', href: 'doc.link') } + end + + context 'when implicitly enabled' do + subject { feature_entry('Demo') } + + it_behaves_like 'a feature is enabled' + end + context 'when explicitly enabled' do - it 'returns a link' do - entry = feature_entry('Demo', href: 'demo.link', enabled: true) + context 'without links' do + subject { feature_entry('Demo', enabled: true) } - expect(entry).to include('<p aria-label="Demo: status on">') - expect(entry).to include('<a href="demo.link">Demo</a>') + it_behaves_like 'a feature is enabled' + it_behaves_like 'a feature without link' end - it 'returns text if href is not provided' do - entry = feature_entry('Demo', enabled: true) + context 'with configure link' do + subject { feature_entry('Demo', href: 'demo.link', enabled: true) } - expect(entry).to include('<p aria-label="Demo: status on">') - expect(entry).not_to match(/<a[^>]+>/) + it_behaves_like 'a feature with configuration' + end + + context 'with configure and documentation links' do + subject { feature_entry('Demo', href: 'demo.link', doc_href: 'doc.link', enabled: true) } + + it_behaves_like 'a feature with configuration' + it_behaves_like 'a feature with documentation' end end context 'when disabled' do - it 'returns text without link' do - entry = feature_entry('Demo', href: 'demo.link', enabled: false) + subject { feature_entry('Demo', href: 'demo.link', enabled: false) } - expect(entry).to include('<p aria-label="Demo: status off">') - expect(entry).not_to match(/<a[^>]+>/) - expect(entry).to include('Demo') - end + it_behaves_like 'a feature is disabled' + it_behaves_like 'a feature without link' end end diff --git a/spec/helpers/environments_helper_spec.rb b/spec/helpers/environments_helper_spec.rb index 2b8bf9319fc2f392fa8af7dfdf8d87e45d58c0b2..a50c8e9bf8e1ca89cb7938eb8d7ccd885fadb67c 100644 --- a/spec/helpers/environments_helper_spec.rb +++ b/spec/helpers/environments_helper_spec.rb @@ -32,6 +32,7 @@ describe EnvironmentsHelper do 'project-path' => project_path(project), 'tags-path' => project_tags_path(project), 'has-metrics' => "#{environment.has_metrics?}", + 'prometheus-status' => "#{environment.prometheus_status}", 'external-dashboard-url' => nil ) end diff --git a/spec/helpers/gitlab_routing_helper_spec.rb b/spec/helpers/gitlab_routing_helper_spec.rb index bf043f3f0137189ed47b29292fe76c8684fead30..38699108b06c6ceac9a215e3bec4a0fc3e35bc74 100644 --- a/spec/helpers/gitlab_routing_helper_spec.rb +++ b/spec/helpers/gitlab_routing_helper_spec.rb @@ -75,6 +75,12 @@ describe GitlabRoutingHelper do expect(preview_markdown_path(group)).to eq("/groups/#{group.path}/preview_markdown") end + it 'returns group preview markdown path for a group parent with args' do + group = create(:group) + + expect(preview_markdown_path(group, { type_id: 5 })).to eq("/groups/#{group.path}/preview_markdown?type_id=5") + end + it 'returns project preview markdown path for a project parent' do expect(preview_markdown_path(project)).to eq("/#{project.full_path}/preview_markdown") end diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index 2f67ea457a0089fff7d89b74bbd9355cb3acceef..1af8b7390bbddad0a0e685b47e3a563bfe87be6c 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -203,42 +203,53 @@ describe IssuablesHelper do end describe '#zoomMeetingUrl in issue' do - let(:issue) { create(:issue, author: user, description: description) } + let(:issue) { create(:issue, author: user) } before do assign(:project, issue.project) end - context 'no zoom links in the issue description' do - let(:description) { 'issue text' } - - it 'does not set zoomMeetingUrl' do - expect(helper.issuable_initial_data(issue)) - .not_to include(:zoomMeetingUrl) + shared_examples 'sets zoomMeetingUrl to nil' do + specify do + expect(helper.issuable_initial_data(issue)[:zoomMeetingUrl]) + .to be_nil end end - context 'no zoom links in the issue description if it has link but not a zoom link' do - let(:description) { 'issue text https://stackoverflow.com/questions/22' } + context 'with no "added" zoom mettings' do + it_behaves_like 'sets zoomMeetingUrl to nil' + + context 'with multiple removed meetings' do + before do + create(:zoom_meeting, issue: issue, issue_status: :removed) + create(:zoom_meeting, issue: issue, issue_status: :removed) + end - it 'does not set zoomMeetingUrl' do - expect(helper.issuable_initial_data(issue)) - .not_to include(:zoomMeetingUrl) + it_behaves_like 'sets zoomMeetingUrl to nil' end end - context 'with two zoom links in description' do - let(:description) do - <<~TEXT - issue text and - zoom call on https://zoom.us/j/123456789 this url - and new zoom url https://zoom.us/s/lastone and some more text - TEXT + context 'with "added" zoom meeting' do + before do + create(:zoom_meeting, issue: issue) end - it 'sets zoomMeetingUrl value to the last url' do - expect(helper.issuable_initial_data(issue)) - .to include(zoomMeetingUrl: 'https://zoom.us/s/lastone') + shared_examples 'sets zoomMeetingUrl to canonical meeting url' do + specify do + expect(helper.issuable_initial_data(issue)) + .to include(zoomMeetingUrl: 'https://zoom.us/j/123456789') + end + end + + it_behaves_like 'sets zoomMeetingUrl to canonical meeting url' + + context 'with muliple "removed" zoom meetings' do + before do + create(:zoom_meeting, issue: issue, issue_status: :removed) + create(:zoom_meeting, issue: issue, issue_status: :removed) + end + + it_behaves_like 'sets zoomMeetingUrl to canonical meeting url' end end end diff --git a/spec/helpers/markup_helper_spec.rb b/spec/helpers/markup_helper_spec.rb index 32851249b2e25e488f0278e9825d8daece6e98e2..5ca5f5703cf963316a3b77d4af9d786b8b931e01 100644 --- a/spec/helpers/markup_helper_spec.rb +++ b/spec/helpers/markup_helper_spec.rb @@ -89,6 +89,35 @@ describe MarkupHelper do end end end + + context 'when text contains a relative link to an image in the repository' do + let(:image_file) { "logo-white.png" } + let(:text_with_relative_path) { "\n" } + let(:generated_html) { helper.markdown(text_with_relative_path, requested_path: requested_path) } + + subject { Nokogiri::HTML.parse(generated_html) } + + context 'when requested_path is provided in the context' do + let(:requested_path) { 'files/images/README.md' } + + it 'returns the correct HTML for the image' do + expanded_path = "/#{project.full_path}/raw/master/files/images/#{image_file}" + + expect(subject.css('a')[0].attr('href')).to eq(expanded_path) + expect(subject.css('img')[0].attr('data-src')).to eq(expanded_path) + end + end + + context 'when requested_path parameter is not provided' do + let(:requested_path) { nil } + + it 'returns the link to the image path as a relative path' do + expanded_path = "/#{project.full_path}/master/./#{image_file}" + + expect(subject.css('a')[0].attr('href')).to eq(expanded_path) + end + end + end end describe '#markdown_field' do @@ -210,7 +239,7 @@ describe MarkupHelper do it 'replaces commit message with emoji to link' do actual = link_to_markdown(':book: Book', '/foo') expect(actual) - .to eq '<gl-emoji title="open book" data-name="book" data-unicode-version="6.0">📖</gl-emoji><a href="/foo"> Book</a>' + .to eq '<a href="/foo"><gl-emoji title="open book" data-name="book" data-unicode-version="6.0">📖</gl-emoji></a><a href="/foo"> Book</a>' end end @@ -232,6 +261,12 @@ describe MarkupHelper do expect(doc.css('a')[0].attr('href')).to eq link expect(doc.css('a')[0].text).to eq 'This should finally fix ' end + + it "escapes HTML passed as an emoji" do + rendered = '<gl-emoji><div class="test">test</div></gl-emoji>' + expect(helper.link_to_html(rendered, '/foo')) + .to eq '<a href="/foo"><gl-emoji><div class="test">test</div></gl-emoji></a>' + end end describe '#render_wiki_content' do diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index 1fa3c639603a02da215b5501d6191d9f69cb5911..cd1b1f91e9f7ac6f0c00ea37f93eada5b938acf4 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -938,4 +938,22 @@ describe ProjectsHelper do it { is_expected.to eq(grafana_integration.token) } end end + + describe '#grafana_integration_enabled?' do + let(:project) { create(:project) } + + before do + helper.instance_variable_set(:@project, project) + end + + subject { helper.grafana_integration_enabled? } + + it { is_expected.to eq(nil) } + + context 'grafana integration exists' do + let!(:grafana_integration) { create(:grafana_integration, project: project) } + + it { is_expected.to eq(grafana_integration.enabled) } + end + end end diff --git a/spec/helpers/releases_helper_spec.rb b/spec/helpers/releases_helper_spec.rb index 3b4973677ef76d32d5f32b2269aec069edf55b1e..3f56c1896420641acd5e6f631112d77806620a9f 100644 --- a/spec/helpers/releases_helper_spec.rb +++ b/spec/helpers/releases_helper_spec.rb @@ -17,9 +17,11 @@ describe ReleasesHelper do context 'url helpers' do let(:project) { build(:project, namespace: create(:group)) } + let(:release) { create(:release, project: project) } before do helper.instance_variable_set(:@project, project) + helper.instance_variable_set(:@release, release) end describe '#data_for_releases_page' do @@ -28,5 +30,17 @@ describe ReleasesHelper do expect(helper.data_for_releases_page.keys).to eq(keys) end end + + describe '#data_for_edit_release_page' do + it 'has the needed data to display the "edit release" page' do + keys = %i(project_id + tag_name + markdown_preview_path + markdown_docs_path + releases_page_path + update_release_api_docs_path) + expect(helper.data_for_edit_release_page.keys).to eq(keys) + end + end end end diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb index 9e9f87b3407e64f2eb7edc9d98c521cc9bb7d444..bef6fbe3d5f1975ba814e40d9a81d4343814ac38 100644 --- a/spec/helpers/search_helper_spec.rb +++ b/spec/helpers/search_helper_spec.rb @@ -167,6 +167,7 @@ describe SearchHelper do expect(search_filter_input_options('')[:data]['runner-tags-endpoint']).to eq(tag_list_admin_runners_path) expect(search_filter_input_options('')[:data]['labels-endpoint']).to eq(project_labels_path(@project)) expect(search_filter_input_options('')[:data]['milestones-endpoint']).to eq(project_milestones_path(@project)) + expect(search_filter_input_options('')[:data]['releases-endpoint']).to eq(project_releases_path(@project)) end it 'includes autocomplete=off flag' do @@ -271,4 +272,50 @@ describe SearchHelper do expect(link).to have_css('li[data-foo="bar"]') end end + + describe '#show_user_search_tab?' do + subject { show_user_search_tab? } + + context 'when users_search feature is disabled' do + before do + stub_feature_flags(users_search: false) + end + + it { is_expected.to eq(false) } + end + + context 'when project search' do + before do + @project = :some_project + + expect(self).to receive(:project_search_tabs?) + .with(:members) + .and_return(:value) + end + + it 'delegates to project_search_tabs?' do + expect(subject).to eq(:value) + end + end + + context 'when not project search' do + context 'when current_user can read_users_list' do + before do + allow(self).to receive(:current_user).and_return(:the_current_user) + allow(self).to receive(:can?).with(:the_current_user, :read_users_list).and_return(true) + end + + it { is_expected.to eq(true) } + end + + context 'when current_user cannot read_users_list' do + before do + allow(self).to receive(:current_user).and_return(:the_current_user) + allow(self).to receive(:can?).with(:the_current_user, :read_users_list).and_return(false) + end + + it { is_expected.to eq(false) } + end + end + end end diff --git a/spec/helpers/snippets_helper_spec.rb b/spec/helpers/snippets_helper_spec.rb index 66c8d576a4c9c6f1b8c8bbc18688191d593c38e3..d88e151a11c65e217d656662da87100b804f097c 100644 --- a/spec/helpers/snippets_helper_spec.rb +++ b/spec/helpers/snippets_helper_spec.rb @@ -3,33 +3,217 @@ require 'spec_helper' describe SnippetsHelper do + include Gitlab::Routing include IconsHelper - describe '#embedded_snippet_raw_button' do - it 'gives view raw button of embedded snippets for project snippets' do - @snippet = create(:project_snippet, :public) + let_it_be(:public_personal_snippet) { create(:personal_snippet, :public) } + let_it_be(:public_project_snippet) { create(:project_snippet, :public) } + + describe '#reliable_snippet_path' do + subject { reliable_snippet_path(snippet) } + + context 'personal snippets' do + let(:snippet) { public_personal_snippet } + + context 'public' do + it 'returns a full path' do + expect(subject).to eq("/snippets/#{snippet.id}") + end + end + end + + context 'project snippets' do + let(:snippet) { public_project_snippet } + + it 'returns a full path' do + expect(subject).to eq("/#{snippet.project.full_path}/snippets/#{snippet.id}") + end + end + end + + describe '#reliable_snippet_url' do + subject { reliable_snippet_url(snippet) } + + context 'personal snippets' do + let(:snippet) { public_personal_snippet } + + context 'public' do + it 'returns a full url' do + expect(subject).to eq("http://test.host/snippets/#{snippet.id}") + end + end + end + + context 'project snippets' do + let(:snippet) { public_project_snippet } + + it 'returns a full url' do + expect(subject).to eq("http://test.host/#{snippet.project.full_path}/snippets/#{snippet.id}") + end + end + end + + describe '#reliable_raw_snippet_path' do + subject { reliable_raw_snippet_path(snippet) } + + context 'personal snippets' do + let(:snippet) { public_personal_snippet } - expect(embedded_snippet_raw_button.to_s).to eq("<a class=\"btn\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Open raw\" href=\"#{raw_project_snippet_url(@snippet.project, @snippet)}\">#{external_snippet_icon('doc-code')}</a>") + context 'public' do + it 'returns a full path' do + expect(subject).to eq("/snippets/#{snippet.id}/raw") + end + end end - it 'gives view raw button of embedded snippets for personal snippets' do + context 'project snippets' do + let(:snippet) { public_project_snippet } + + it 'returns a full path' do + expect(subject).to eq("/#{snippet.project.full_path}/snippets/#{snippet.id}/raw") + end + end + end + + describe '#reliable_raw_snippet_url' do + subject { reliable_raw_snippet_url(snippet) } + + context 'personal snippets' do + let(:snippet) { public_personal_snippet } + + context 'public' do + it 'returns a full url' do + expect(subject).to eq("http://test.host/snippets/#{snippet.id}/raw") + end + end + end + + context 'project snippets' do + let(:snippet) { public_project_snippet } + + it 'returns a full url' do + expect(subject).to eq("http://test.host/#{snippet.project.full_path}/snippets/#{snippet.id}/raw") + end + end + end + + describe '#embedded_raw_snippet_button' do + subject { embedded_raw_snippet_button.to_s } + + it 'returns view raw button of embedded snippets for personal snippets' do @snippet = create(:personal_snippet, :public) - expect(embedded_snippet_raw_button.to_s).to eq("<a class=\"btn\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Open raw\" href=\"#{raw_snippet_url(@snippet)}\">#{external_snippet_icon('doc-code')}</a>") + expect(subject).to eq(download_link("http://test.host/snippets/#{@snippet.id}/raw")) + end + + it 'returns view raw button of embedded snippets for project snippets' do + @snippet = create(:project_snippet, :public) + + expect(subject).to eq(download_link("http://test.host/#{@snippet.project.path_with_namespace}/snippets/#{@snippet.id}/raw")) + end + + def download_link(url) + "<a class=\"btn\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Open raw\" href=\"#{url}\">#{external_snippet_icon('doc-code')}</a>" end end describe '#embedded_snippet_download_button' do - it 'gives download button of embedded snippets for project snippets' do + subject { embedded_snippet_download_button } + + it 'returns download button of embedded snippets for personal snippets' do + @snippet = create(:personal_snippet, :public) + + expect(subject).to eq(download_link("http://test.host/snippets/#{@snippet.id}/raw")) + end + + it 'returns download button of embedded snippets for project snippets' do @snippet = create(:project_snippet, :public) - expect(embedded_snippet_download_button.to_s).to eq("<a class=\"btn\" target=\"_blank\" title=\"Download\" rel=\"noopener noreferrer\" href=\"#{raw_project_snippet_url(@snippet.project, @snippet, inline: false)}\">#{external_snippet_icon('download')}</a>") + expect(subject).to eq(download_link("http://test.host/#{@snippet.project.path_with_namespace}/snippets/#{@snippet.id}/raw")) end - it 'gives download button of embedded snippets for personal snippets' do - @snippet = create(:personal_snippet, :public) + def download_link(url) + "<a class=\"btn\" target=\"_blank\" title=\"Download\" rel=\"noopener noreferrer\" href=\"#{url}?inline=false\">#{external_snippet_icon('download')}</a>" + end + end + + describe '#snippet_embed_tag' do + subject { snippet_embed_tag(snippet) } + + context 'personal snippets' do + let(:snippet) { public_personal_snippet } + + context 'public' do + it 'returns a script tag with the snippet full url' do + expect(subject).to eq(script_embed("http://test.host/snippets/#{snippet.id}")) + end + end + end + + context 'project snippets' do + let(:snippet) { public_project_snippet } + + it 'returns a script tag with the snippet full url' do + expect(subject).to eq(script_embed("http://test.host/#{snippet.project.path_with_namespace}/snippets/#{snippet.id}")) + end + end + + def script_embed(url) + "<script src=\"#{url}.js\"></script>" + end + end + + describe '#download_raw_snippet_button' do + subject { download_raw_snippet_button(snippet) } + + context 'with personal snippet' do + let(:snippet) { public_personal_snippet } + + it 'returns the download button' do + expect(subject).to eq(download_link("/snippets/#{snippet.id}/raw")) + end + end + + context 'with project snippet' do + let(:snippet) { public_project_snippet } + + it 'returns the download button' do + expect(subject).to eq(download_link("/#{snippet.project.path_with_namespace}/snippets/#{snippet.id}/raw")) + end + end + + def download_link(url) + "<a target=\"_blank\" rel=\"noopener noreferrer\" class=\"btn btn-sm has-tooltip\" title=\"Download\" data-container=\"body\" href=\"#{url}?inline=false\"><i aria-hidden=\"true\" data-hidden=\"true\" class=\"fa fa-download\"></i></a>" + end + end + + describe '#snippet_badge' do + let(:snippet) { build(:personal_snippet, visibility) } + + subject { snippet_badge(snippet) } + + context 'when snippet is private' do + let(:visibility) { :private } + + it 'returns the snippet badge' do + expect(subject).to eq "<span class=\"badge badge-gray\"><i class=\"fa fa-lock\"></i> private</span>" + end + end + + context 'when snippet is public' do + let(:visibility) { :public } + + it 'does not return anything' do + expect(subject).to be_nil + end + end + + context 'when snippet is internal' do + let(:visibility) { :internal } - expect(embedded_snippet_download_button.to_s).to eq("<a class=\"btn\" target=\"_blank\" title=\"Download\" rel=\"noopener noreferrer\" href=\"#{raw_snippet_url(@snippet, inline: false)}\">#{external_snippet_icon('download')}</a>") + it 'does not return anything' do + expect(subject).to be_nil + end end end end diff --git a/spec/helpers/sourcegraph_helper_spec.rb b/spec/helpers/sourcegraph_helper_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..830bbb3129fef082581772ca48b6a53a48b132e3 --- /dev/null +++ b/spec/helpers/sourcegraph_helper_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe SourcegraphHelper do + describe '#sourcegraph_url_message' do + let(:sourcegraph_url) { 'http://sourcegraph.example.com' } + + before do + allow(Gitlab::CurrentSettings).to receive(:sourcegraph_url).and_return(sourcegraph_url) + allow(Gitlab::CurrentSettings).to receive(:sourcegraph_url_is_com?).and_return(is_com) + end + + subject { helper.sourcegraph_url_message } + + context 'with .com sourcegraph url' do + let(:is_com) { true } + + it { is_expected.to have_text('Uses Sourcegraph.com') } + it { is_expected.to have_link('Sourcegraph.com', href: sourcegraph_url) } + end + + context 'with custom sourcegraph url' do + let(:is_com) { false } + + it { is_expected.to have_text('Uses a custom Sourcegraph instance') } + it { is_expected.to have_link('Sourcegraph instance', href: sourcegraph_url) } + + context 'with unsafe url' do + let(:sourcegraph_url) { '\" onload=\"alert(1);\"' } + + it { is_expected.to have_link('Sourcegraph instance', href: sourcegraph_url) } + end + end + end + + context '#sourcegraph_experimental_message' do + let(:feature_conditional) { false } + let(:public_only) { false } + + before do + allow(Gitlab::CurrentSettings).to receive(:sourcegraph_public_only).and_return(public_only) + allow(Gitlab::Sourcegraph).to receive(:feature_conditional?).and_return(feature_conditional) + end + + subject { helper.sourcegraph_experimental_message } + + context 'when not limited by feature or public only' do + it { is_expected.to eq "This feature is experimental." } + end + + context 'when limited by feature' do + let(:feature_conditional) { true } + + it { is_expected.to eq "This feature is experimental and currently limited to certain projects." } + end + + context 'when limited by public only' do + let(:public_only) { true } + + it { is_expected.to eq "This feature is experimental and limited to public projects." } + end + end +end diff --git a/spec/helpers/users_helper_spec.rb b/spec/helpers/users_helper_spec.rb index 59abe8c09e127e757c754aa4d333e1c7190cb64e..172ead158fb56db340899b2c68f02fba372b4ecd 100644 --- a/spec/helpers/users_helper_spec.rb +++ b/spec/helpers/users_helper_spec.rb @@ -76,6 +76,10 @@ describe UsersHelper do allow(helper).to receive(:can?).and_return(false) end + after do + expect(items).not_to include(:start_trial) + end + it 'includes all default items' do expect(items).to include(:help, :sign_out) end diff --git a/spec/initializers/6_validations_spec.rb b/spec/initializers/6_validations_spec.rb index 73fbd4c7a441854b35b703ec20c1bec037f6d442..248f967311b8ed88e7c20ce5874ca4f0faaeb711 100644 --- a/spec/initializers/6_validations_spec.rb +++ b/spec/initializers/6_validations_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require_relative '../../config/initializers/6_validations.rb' diff --git a/spec/initializers/action_mailer_hooks_spec.rb b/spec/initializers/action_mailer_hooks_spec.rb index 3826ed9b00ac3af7b7c91b9fc51ac53ed2ee0212..ce6e1ed0fa22e8ab63011a8646a6f50e01fc440a 100644 --- a/spec/initializers/action_mailer_hooks_spec.rb +++ b/spec/initializers/action_mailer_hooks_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'ActionMailer hooks' do diff --git a/spec/initializers/asset_proxy_setting_spec.rb b/spec/initializers/asset_proxy_setting_spec.rb index 42e4d4aa5943faf62f8c68b98375e29fd4a07d3a..7eab5de155b43cf65670fd5ee5651089aa12afec 100644 --- a/spec/initializers/asset_proxy_setting_spec.rb +++ b/spec/initializers/asset_proxy_setting_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'Asset proxy settings initialization' do diff --git a/spec/initializers/attr_encrypted_no_db_connection_spec.rb b/spec/initializers/attr_encrypted_no_db_connection_spec.rb index 2da9f1cbd9686e65bc5fd77119878ff594a9f19d..14e0e1f21678f9a7a718a03c26ada6719e4fa216 100644 --- a/spec/initializers/attr_encrypted_no_db_connection_spec.rb +++ b/spec/initializers/attr_encrypted_no_db_connection_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'GitLab monkey-patches to AttrEncrypted' do diff --git a/spec/initializers/database_config_spec.rb b/spec/initializers/database_config_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a5a074f5884de1feae74c33414db819fc5c5917c --- /dev/null +++ b/spec/initializers/database_config_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Database config initializer' do + subject do + load Rails.root.join('config/initializers/database_config.rb') + end + + before do + allow(ActiveRecord::Base).to receive(:establish_connection) + end + + context "when using Puma" do + let(:puma) { double('puma') } + let(:puma_options) { { max_threads: 8 } } + + before do + stub_const("Puma", puma) + allow(puma).to receive_message_chain(:cli_config, :options).and_return(puma_options) + end + + context "and no existing pool size is set" do + before do + stub_database_config(pool_size: nil) + end + + it "sets it to the max number of worker threads" do + expect { subject }.to change { Gitlab::Database.config['pool'] }.from(nil).to(8) + 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) + end + + it "sets it to the max number of worker threads" do + expect { subject }.to change { Gitlab::Database.config['pool'] }.from(7).to(8) + 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) + end + + it "keeps the configured pool size" do + expect { subject }.not_to change { Gitlab::Database.config['pool'] } + end + end + end + + context "when not using Puma" do + before do + stub_database_config(pool_size: 7) + end + + it "does nothing" do + expect { subject }.not_to change { Gitlab::Database.config['pool'] } + end + end + + def stub_database_config(pool_size:) + config = { + 'adapter' => 'postgresql', + 'host' => 'db.host.com', + 'pool' => pool_size + }.compact + + allow(Gitlab::Database).to receive(:config).and_return(config) + end +end diff --git a/spec/initializers/direct_upload_support_spec.rb b/spec/initializers/direct_upload_support_spec.rb index e51d404e030b630ee034dc8cdc1f5d2f227bde2c..4b3fe871cefaad504ddd4e1e0da2fc4e108e1b83 100644 --- a/spec/initializers/direct_upload_support_spec.rb +++ b/spec/initializers/direct_upload_support_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'Direct upload support' do diff --git a/spec/initializers/doorkeeper_spec.rb b/spec/initializers/doorkeeper_spec.rb index 1a78196e33dd18ab326460f0458eaa1c100d25e2..47c196cb3a3ce0cf2930910d9e45ebaf6f191428 100644 --- a/spec/initializers/doorkeeper_spec.rb +++ b/spec/initializers/doorkeeper_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require_relative '../../config/initializers/doorkeeper' diff --git a/spec/initializers/fog_google_https_private_urls_spec.rb b/spec/initializers/fog_google_https_private_urls_spec.rb index 08346b71feecc9915c241ce9ac119e8a4bd56950..8a0d7ad8f1571c7946e307db2409339a5bf0aecc 100644 --- a/spec/initializers/fog_google_https_private_urls_spec.rb +++ b/spec/initializers/fog_google_https_private_urls_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'Fog::Storage::GoogleXML::File', :fog_requests do diff --git a/spec/initializers/lograge_spec.rb b/spec/initializers/lograge_spec.rb index c2c1960eeabe00e2715185238293c4ce6f2cd951..9267231390d274ace63eef824e3ca9b5fcc05b39 100644 --- a/spec/initializers/lograge_spec.rb +++ b/spec/initializers/lograge_spec.rb @@ -68,4 +68,52 @@ describe 'lograge', type: :request do subject end end + + context 'with a log subscriber' do + let(:subscriber) { Lograge::RequestLogSubscriber.new } + + let(:event) do + ActiveSupport::Notifications::Event.new( + 'process_action.action_controller', + Time.now, + Time.now, + 2, + status: 200, + controller: 'HomeController', + action: 'index', + format: 'application/json', + method: 'GET', + path: '/home?foo=bar', + params: {}, + db_runtime: 0.02, + view_runtime: 0.01 + ) + end + + let(:log_output) { StringIO.new } + let(:logger) do + Logger.new(log_output).tap { |logger| logger.formatter = ->(_, _, _, msg) { msg } } + end + + describe 'with an exception' do + let(:exception) { RuntimeError.new('bad request') } + let(:backtrace) { caller } + + before do + allow(exception).to receive(:backtrace).and_return(backtrace) + event.payload[:exception_object] = exception + Lograge.logger = logger + end + + it 'adds exception data to log' do + subscriber.process_action(event) + + log_data = JSON.parse(log_output.string) + + 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)) + end + end + end end diff --git a/spec/initializers/rest-client-hostname_override_spec.rb b/spec/initializers/rest-client-hostname_override_spec.rb index 3707e001d41f5f4931df0ea477d58a9c25d2beaa..90a0305c9a93fed409ad6bc97c6a6cd2cd51b1a4 100644 --- a/spec/initializers/rest-client-hostname_override_spec.rb +++ b/spec/initializers/rest-client-hostname_override_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'rest-client dns rebinding protection' do diff --git a/spec/initializers/secret_token_spec.rb b/spec/initializers/secret_token_spec.rb index 726ce07a2d1563288b33cf6c611aee8617830464..c29f46e7779f34163427805d203c1d1504584fa3 100644 --- a/spec/initializers/secret_token_spec.rb +++ b/spec/initializers/secret_token_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require_relative '../../config/initializers/01_secret_token' diff --git a/spec/initializers/settings_spec.rb b/spec/initializers/settings_spec.rb index 57f5adbbc40d2a68b584d43e218396dbc431bb9d..6cb45b4c86bd515ba4fc7cf8f9fc759ae483604b 100644 --- a/spec/initializers/settings_spec.rb +++ b/spec/initializers/settings_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require_relative '../../config/initializers/1_settings' unless defined?(Settings) diff --git a/spec/initializers/trusted_proxies_spec.rb b/spec/initializers/trusted_proxies_spec.rb index 02a9446ad7bb1c880496e48611505e883a2d0ce8..a2bd0ff9f1cf2eae2e24fc6738ca1dfec5e1dfce 100644 --- a/spec/initializers/trusted_proxies_spec.rb +++ b/spec/initializers/trusted_proxies_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'trusted_proxies' do diff --git a/spec/initializers/zz_metrics_spec.rb b/spec/initializers/zz_metrics_spec.rb index 3eaccfe8d8b9cc6ff9e0863ff5a43de5037810af..b9a1919ceae46347a94f9e6f6cc316ca3a7ee118 100644 --- a/spec/initializers/zz_metrics_spec.rb +++ b/spec/initializers/zz_metrics_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'instrument_classes' do diff --git a/spec/javascripts/boards/board_card_spec.js b/spec/javascripts/boards/board_card_spec.js index 9f441ca319e5a0ec77c23f565c8d87bdded2b9a9..51433a582122a2d9b8e0c37361ee4597c3bea965 100644 --- a/spec/javascripts/boards/board_card_spec.js +++ b/spec/javascripts/boards/board_card_spec.js @@ -10,6 +10,7 @@ import eventHub from '~/boards/eventhub'; import '~/boards/models/label'; import '~/boards/models/assignee'; import '~/boards/models/list'; +import store from '~/boards/stores'; import boardsStore from '~/boards/stores/boards_store'; import boardCard from '~/boards/components/board_card.vue'; import { listObj, boardsMockInterceptor, mockBoardService } from './mock_data'; @@ -40,6 +41,7 @@ describe('Board card', () => { list.issues[0].labels.push(label1); vm = new BoardCardComp({ + store, propsData: { list, issue: list.issues[0], diff --git a/spec/javascripts/boards/board_list_common_spec.js b/spec/javascripts/boards/board_list_common_spec.js index cb337e4cc83d9d50a7ce0c0ed5479a80147dd056..ada7589b79592de5a6d98f8ade1ee19a30624205 100644 --- a/spec/javascripts/boards/board_list_common_spec.js +++ b/spec/javascripts/boards/board_list_common_spec.js @@ -10,11 +10,17 @@ import BoardList from '~/boards/components/board_list.vue'; import '~/boards/models/issue'; import '~/boards/models/list'; import { listObj, boardsMockInterceptor, mockBoardService } from './mock_data'; +import store from '~/boards/stores'; import boardsStore from '~/boards/stores/boards_store'; window.Sortable = Sortable; -export default function createComponent({ done, listIssueProps = {}, componentProps = {} }) { +export default function createComponent({ + done, + listIssueProps = {}, + componentProps = {}, + listProps = {}, +}) { const el = document.createElement('div'); document.body.appendChild(el); @@ -24,7 +30,7 @@ export default function createComponent({ done, listIssueProps = {}, componentPr boardsStore.create(); const BoardListComp = Vue.extend(BoardList); - const list = new List(listObj); + const list = new List({ ...listObj, ...listProps }); const issue = new ListIssue({ title: 'Testing', id: 1, @@ -34,11 +40,14 @@ export default function createComponent({ done, listIssueProps = {}, componentPr assignees: [], ...listIssueProps, }); - list.issuesSize = 1; + if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) { + list.issuesSize = 1; + } list.issues.push(issue); const component = new BoardListComp({ el, + store, propsData: { disabled: false, list, diff --git a/spec/javascripts/boards/board_list_spec.js b/spec/javascripts/boards/board_list_spec.js index 6774a46ed58a631bc860806ffe2168f2325ab218..37e96e97279823d87c6884047442bdaece3fd21b 100644 --- a/spec/javascripts/boards/board_list_spec.js +++ b/spec/javascripts/boards/board_list_spec.js @@ -1,156 +1,210 @@ +/* global List */ + import Vue from 'vue'; import eventHub from '~/boards/eventhub'; import createComponent from './board_list_common_spec'; +import waitForPromises from '../helpers/wait_for_promises'; + +import '~/boards/models/list'; describe('Board list component', () => { let mock; let component; + let getIssues; + function generateIssues(compWrapper) { + for (let i = 1; i < 20; i += 1) { + const issue = Object.assign({}, compWrapper.list.issues[0]); + issue.id += i; + compWrapper.list.issues.push(issue); + } + } - beforeEach(done => { - ({ mock, component } = createComponent({ done })); - }); + describe('When Expanded', () => { + beforeEach(done => { + getIssues = spyOn(List.prototype, 'getIssues').and.returnValue(new Promise(() => {})); + ({ mock, component } = createComponent({ done })); + }); - afterEach(() => { - mock.restore(); - }); + afterEach(() => { + mock.restore(); + component.$destroy(); + }); - it('renders component', () => { - expect(component.$el.classList.contains('board-list-component')).toBe(true); - }); + it('loads first page of issues', done => { + waitForPromises() + .then(() => { + expect(getIssues).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); - it('renders loading icon', done => { - component.loading = true; + it('renders component', () => { + expect(component.$el.classList.contains('board-list-component')).toBe(true); + }); + + it('renders loading icon', done => { + component.loading = true; - Vue.nextTick(() => { - expect(component.$el.querySelector('.board-list-loading')).not.toBeNull(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.board-list-loading')).not.toBeNull(); - done(); + done(); + }); }); - }); - it('renders issues', () => { - expect(component.$el.querySelectorAll('.board-card').length).toBe(1); - }); + it('renders issues', () => { + expect(component.$el.querySelectorAll('.board-card').length).toBe(1); + }); - it('sets data attribute with issue id', () => { - expect(component.$el.querySelector('.board-card').getAttribute('data-issue-id')).toBe('1'); - }); + it('sets data attribute with issue id', () => { + expect(component.$el.querySelector('.board-card').getAttribute('data-issue-id')).toBe('1'); + }); - it('shows new issue form', done => { - component.toggleForm(); + it('shows new issue form', done => { + component.toggleForm(); - Vue.nextTick(() => { - expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); - expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); + expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); - done(); + done(); + }); }); - }); - it('shows new issue form after eventhub event', done => { - eventHub.$emit(`hide-issue-form-${component.list.id}`); + it('shows new issue form after eventhub event', done => { + eventHub.$emit(`hide-issue-form-${component.list.id}`); - Vue.nextTick(() => { - expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); - expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); + expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); - done(); + done(); + }); }); - }); - it('does not show new issue form for closed list', done => { - component.list.type = 'closed'; - component.toggleForm(); + it('does not show new issue form for closed list', done => { + component.list.type = 'closed'; + component.toggleForm(); - Vue.nextTick(() => { - expect(component.$el.querySelector('.board-new-issue-form')).toBeNull(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.board-new-issue-form')).toBeNull(); - done(); + done(); + }); }); - }); - it('shows count list item', done => { - component.showCount = true; + it('shows count list item', done => { + component.showCount = true; - Vue.nextTick(() => { - expect(component.$el.querySelector('.board-list-count')).not.toBeNull(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.board-list-count')).not.toBeNull(); - expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( - 'Showing all issues', - ); + expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( + 'Showing all issues', + ); - done(); + done(); + }); }); - }); - it('sets data attribute with invalid id', done => { - component.showCount = true; + it('sets data attribute with invalid id', done => { + component.showCount = true; - Vue.nextTick(() => { - expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe( - '-1', - ); + Vue.nextTick(() => { + expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe( + '-1', + ); - done(); + done(); + }); }); - }); - it('shows how many more issues to load', done => { - component.showCount = true; - component.list.issuesSize = 20; + it('shows how many more issues to load', done => { + component.showCount = true; + component.list.issuesSize = 20; - Vue.nextTick(() => { - expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( - 'Showing 1 of 20 issues', - ); + Vue.nextTick(() => { + expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( + 'Showing 1 of 20 issues', + ); - done(); + done(); + }); }); - }); - - it('loads more issues after scrolling', done => { - spyOn(component.list, 'nextPage'); - component.$refs.list.style.height = '100px'; - component.$refs.list.style.overflow = 'scroll'; - for (let i = 1; i < 20; i += 1) { - const issue = Object.assign({}, component.list.issues[0]); - issue.id += i; - component.list.issues.push(issue); - } + it('loads more issues after scrolling', done => { + spyOn(component.list, 'nextPage'); + component.$refs.list.style.height = '100px'; + component.$refs.list.style.overflow = 'scroll'; + generateIssues(component); + + Vue.nextTick(() => { + component.$refs.list.scrollTop = 20000; + + waitForPromises() + .then(() => { + expect(component.list.nextPage).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + }); - Vue.nextTick(() => { - component.$refs.list.scrollTop = 20000; + it('does not load issues if already loading', done => { + component.list.nextPage = spyOn(component.list, 'nextPage').and.returnValue( + new Promise(() => {}), + ); - setTimeout(() => { - expect(component.list.nextPage).toHaveBeenCalled(); + component.onScroll(); + component.onScroll(); - done(); - }); + waitForPromises() + .then(() => { + expect(component.list.nextPage).toHaveBeenCalledTimes(1); + }) + .then(done) + .catch(done.fail); }); - }); - it('does not load issues if already loading', () => { - component.list.nextPage = spyOn(component.list, 'nextPage').and.returnValue( - new Promise(() => {}), - ); + it('shows loading more spinner', done => { + component.showCount = true; + component.list.loadingMore = true; - component.onScroll(); - component.onScroll(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull(); - expect(component.list.nextPage).toHaveBeenCalledTimes(1); + done(); + }); + }); }); - it('shows loading more spinner', done => { - component.showCount = true; - component.list.loadingMore = true; + describe('When Collapsed', () => { + beforeEach(done => { + getIssues = spyOn(List.prototype, 'getIssues').and.returnValue(new Promise(() => {})); + ({ mock, component } = createComponent({ + done, + listProps: { type: 'closed', collapsed: true, issuesSize: 50 }, + })); + generateIssues(component); + component.scrollHeight = spyOn(component, 'scrollHeight').and.returnValue(0); + }); - Vue.nextTick(() => { - expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull(); + afterEach(() => { + mock.restore(); + component.$destroy(); + }); - done(); + it('does not load all issues', done => { + waitForPromises() + .then(() => { + // Initial getIssues from list constructor + expect(getIssues).toHaveBeenCalledTimes(1); + }) + .then(done) + .catch(done.fail); }); }); }); diff --git a/spec/javascripts/boards/components/boards_selector_spec.js b/spec/javascripts/boards/components/boards_selector_spec.js index 473cc0612eae7cfe37f42bee91040c5fd2b505c6..d1f36a0a6523729cf216addd0f437cff39d24736 100644 --- a/spec/javascripts/boards/components/boards_selector_spec.js +++ b/spec/javascripts/boards/components/boards_selector_spec.js @@ -1,5 +1,4 @@ import Vue from 'vue'; -import BoardService from '~/boards/services/board_service'; import BoardsSelector from '~/boards/components/boards_selector.vue'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { TEST_HOST } from 'spec/test_constants'; @@ -37,7 +36,6 @@ describe('BoardsSelector', () => { bulkUpdatePath: '', boardId: '', }); - window.gl.boardService = new BoardService(); allBoardsResponse = Promise.resolve({ data: boards, @@ -46,8 +44,8 @@ describe('BoardsSelector', () => { data: recentBoards, }); - spyOn(BoardService.prototype, 'allBoards').and.returnValue(allBoardsResponse); - spyOn(BoardService.prototype, 'recentBoards').and.returnValue(recentBoardsResponse); + spyOn(boardsStore, 'allBoards').and.returnValue(allBoardsResponse); + spyOn(boardsStore, 'recentBoards').and.returnValue(recentBoardsResponse); const Component = Vue.extend(BoardsSelector); vm = mountComponent( @@ -94,7 +92,6 @@ describe('BoardsSelector', () => { afterEach(() => { vm.$destroy(); - window.gl.boardService = undefined; }); describe('filtering', () => { diff --git a/spec/javascripts/boards/components/issue_time_estimate_spec.js b/spec/javascripts/boards/components/issue_time_estimate_spec.js deleted file mode 100644 index de48e3f6091ca49d579c91d7a230dde413bd8d4f..0000000000000000000000000000000000000000 --- a/spec/javascripts/boards/components/issue_time_estimate_spec.js +++ /dev/null @@ -1,70 +0,0 @@ -import Vue from 'vue'; -import IssueTimeEstimate from '~/boards/components/issue_time_estimate.vue'; -import boardsStore from '~/boards/stores/boards_store'; -import mountComponent from '../../helpers/vue_mount_component_helper'; - -describe('Issue Time Estimate component', () => { - let vm; - - beforeEach(() => { - boardsStore.create(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('when limitToHours is false', () => { - beforeEach(() => { - boardsStore.timeTracking.limitToHours = false; - - const Component = Vue.extend(IssueTimeEstimate); - vm = mountComponent(Component, { - estimate: 374460, - }); - }); - - it('renders the correct time estimate', () => { - expect(vm.$el.querySelector('time').textContent.trim()).toEqual('2w 3d 1m'); - }); - - it('renders expanded time estimate in tooltip', () => { - expect(vm.$el.querySelector('.js-issue-time-estimate').textContent).toContain( - '2 weeks 3 days 1 minute', - ); - }); - - it('prevents tooltip xss', done => { - const alertSpy = spyOn(window, 'alert'); - vm.estimate = 'Foo <script>alert("XSS")</script>'; - - vm.$nextTick(() => { - expect(alertSpy).not.toHaveBeenCalled(); - expect(vm.$el.querySelector('time').textContent.trim()).toEqual('0m'); - expect(vm.$el.querySelector('.js-issue-time-estimate').textContent).toContain('0m'); - done(); - }); - }); - }); - - describe('when limitToHours is true', () => { - beforeEach(() => { - boardsStore.timeTracking.limitToHours = true; - - const Component = Vue.extend(IssueTimeEstimate); - vm = mountComponent(Component, { - estimate: 374460, - }); - }); - - it('renders the correct time estimate', () => { - expect(vm.$el.querySelector('time').textContent.trim()).toEqual('104h 1m'); - }); - - it('renders expanded time estimate in tooltip', () => { - expect(vm.$el.querySelector('.js-issue-time-estimate').textContent).toContain( - '104 hours 1 minute', - ); - }); - }); -}); diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js deleted file mode 100644 index 314e051665e2c9d9ee3917f8e20a471cb9c179a3..0000000000000000000000000000000000000000 --- a/spec/javascripts/boards/issue_card_spec.js +++ /dev/null @@ -1,292 +0,0 @@ -/* global ListAssignee */ -/* global ListLabel */ -/* global ListIssue */ - -import Vue from 'vue'; - -import '~/boards/models/label'; -import '~/boards/models/assignee'; -import '~/boards/models/issue'; -import '~/boards/models/list'; -import IssueCardInner from '~/boards/components/issue_card_inner.vue'; -import { listObj } from './mock_data'; - -describe('Issue card component', () => { - const user = new ListAssignee({ - id: 1, - name: 'testing 123', - username: 'test', - avatar: 'test_image', - }); - const label1 = new ListLabel({ - id: 3, - title: 'testing 123', - color: 'blue', - text_color: 'white', - description: 'test', - }); - let component; - let issue; - let list; - - beforeEach(() => { - setFixtures('<div class="test-container"></div>'); - - list = { - ...listObj, - type: 'label', - }; - issue = new ListIssue({ - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - labels: [list.label], - assignees: [], - reference_path: '#1', - real_path: '/test/1', - weight: 1, - }); - - component = new Vue({ - el: document.querySelector('.test-container'), - components: { - 'issue-card': IssueCardInner, - }, - data() { - return { - list, - issue, - issueLinkBase: '/test', - rootPath: '/', - }; - }, - template: ` - <issue-card - :issue="issue" - :list="list" - :issue-link-base="issueLinkBase" - :root-path="rootPath"></issue-card> - `, - }); - }); - - it('renders issue title', () => { - expect(component.$el.querySelector('.board-card-title').textContent).toContain(issue.title); - }); - - it('includes issue base in link', () => { - expect(component.$el.querySelector('.board-card-title a').getAttribute('href')).toContain( - '/test', - ); - }); - - it('includes issue title on link', () => { - expect(component.$el.querySelector('.board-card-title a').getAttribute('title')).toBe( - issue.title, - ); - }); - - it('does not render confidential icon', () => { - expect(component.$el.querySelector('.fa-eye-flash')).toBeNull(); - }); - - it('renders confidential icon', done => { - component.issue.confidential = true; - - Vue.nextTick(() => { - expect(component.$el.querySelector('.confidential-icon')).not.toBeNull(); - done(); - }); - }); - - it('renders issue ID with #', () => { - expect(component.$el.querySelector('.board-card-number').textContent).toContain(`#${issue.id}`); - }); - - describe('assignee', () => { - it('does not render assignee', () => { - expect(component.$el.querySelector('.board-card-assignee .avatar')).toBeNull(); - }); - - describe('exists', () => { - beforeEach(done => { - component.issue.assignees = [user]; - - Vue.nextTick(() => done()); - }); - - it('renders assignee', () => { - expect(component.$el.querySelector('.board-card-assignee .avatar')).not.toBeNull(); - }); - - it('sets title', () => { - expect(component.$el.querySelector('.js-assignee-tooltip').textContent).toContain( - `${user.name}`, - ); - }); - - it('sets users path', () => { - expect(component.$el.querySelector('.board-card-assignee a').getAttribute('href')).toBe( - '/test', - ); - }); - - it('renders avatar', () => { - expect(component.$el.querySelector('.board-card-assignee img')).not.toBeNull(); - }); - }); - - describe('assignee default avatar', () => { - beforeEach(done => { - component.issue.assignees = [ - new ListAssignee( - { - id: 1, - name: 'testing 123', - username: 'test', - }, - 'default_avatar', - ), - ]; - - Vue.nextTick(done); - }); - - it('displays defaults avatar if users avatar is null', () => { - expect(component.$el.querySelector('.board-card-assignee img')).not.toBeNull(); - expect(component.$el.querySelector('.board-card-assignee img').getAttribute('src')).toBe( - 'default_avatar?width=24', - ); - }); - }); - }); - - describe('multiple assignees', () => { - beforeEach(done => { - component.issue.assignees = [ - new ListAssignee({ - id: 2, - name: 'user2', - username: 'user2', - avatar: 'test_image', - }), - new ListAssignee({ - id: 3, - name: 'user3', - username: 'user3', - avatar: 'test_image', - }), - new ListAssignee({ - id: 4, - name: 'user4', - username: 'user4', - avatar: 'test_image', - }), - ]; - - Vue.nextTick(() => done()); - }); - - it('renders all three assignees', () => { - expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(3); - }); - - describe('more than three assignees', () => { - beforeEach(done => { - component.issue.assignees.push( - new ListAssignee({ - id: 5, - name: 'user5', - username: 'user5', - avatar: 'test_image', - }), - ); - - Vue.nextTick(() => done()); - }); - - it('renders more avatar counter', () => { - expect( - component.$el.querySelector('.board-card-assignee .avatar-counter').innerText.trim(), - ).toEqual('+2'); - }); - - it('renders two assignees', () => { - expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(2); - }); - - it('renders 99+ avatar counter', done => { - for (let i = 5; i < 104; i += 1) { - const u = new ListAssignee({ - id: i, - name: 'name', - username: 'username', - avatar: 'test_image', - }); - component.issue.assignees.push(u); - } - - Vue.nextTick(() => { - expect( - component.$el.querySelector('.board-card-assignee .avatar-counter').innerText.trim(), - ).toEqual('99+'); - done(); - }); - }); - }); - }); - - describe('labels', () => { - beforeEach(done => { - component.issue.addLabel(label1); - - Vue.nextTick(() => done()); - }); - - it('does not render list label but renders all other labels', () => { - expect(component.$el.querySelectorAll('.badge').length).toBe(1); - }); - - it('renders label', () => { - const nodes = []; - component.$el.querySelectorAll('.badge').forEach(label => { - nodes.push(label.getAttribute('data-original-title')); - }); - - expect(nodes.includes(label1.description)).toBe(true); - }); - - it('sets label description as title', () => { - expect(component.$el.querySelector('.badge').getAttribute('data-original-title')).toContain( - label1.description, - ); - }); - - it('sets background color of button', () => { - const nodes = []; - component.$el.querySelectorAll('.badge').forEach(label => { - nodes.push(label.style.backgroundColor); - }); - - expect(nodes.includes(label1.color)).toBe(true); - }); - - it('does not render label if label does not have an ID', done => { - component.issue.addLabel( - new ListLabel({ - title: 'closed', - }), - ); - - Vue.nextTick() - .then(() => { - expect(component.$el.querySelectorAll('.badge').length).toBe(1); - expect(component.$el.textContent).not.toContain('closed'); - - done(); - }) - .catch(done.fail); - }); - }); -}); diff --git a/spec/javascripts/bootstrap_jquery_spec.js b/spec/javascripts/bootstrap_jquery_spec.js index 35340a3bc42af153c5beee0209952dd7577fe307..6957cf40301973c11fbea97da13cc51d50ad3fff 100644 --- a/spec/javascripts/bootstrap_jquery_spec.js +++ b/spec/javascripts/bootstrap_jquery_spec.js @@ -1,5 +1,3 @@ -/* eslint-disable no-var */ - import $ from 'jquery'; import '~/commons/bootstrap'; @@ -10,15 +8,13 @@ describe('Bootstrap jQuery extensions', function() { }); it('adds the disabled attribute', function() { - var $input; - $input = $('input').first(); + const $input = $('input').first(); $input.disable(); expect($input).toHaveAttr('disabled', 'disabled'); }); return it('adds the disabled class', function() { - var $input; - $input = $('input').first(); + const $input = $('input').first(); $input.disable(); expect($input).toHaveClass('disabled'); @@ -30,15 +26,13 @@ describe('Bootstrap jQuery extensions', function() { }); it('removes the disabled attribute', function() { - var $input; - $input = $('input').first(); + const $input = $('input').first(); $input.enable(); expect($input).not.toHaveAttr('disabled'); }); return it('removes the disabled class', function() { - var $input; - $input = $('input').first(); + const $input = $('input').first(); $input.enable(); expect($input).not.toHaveClass('disabled'); diff --git a/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js b/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js index b2fe315f6c62de670ecac79148362ca1b4ae5643..b53e30b68966be56fcfaeca5eb5d56742d66fc02 100644 --- a/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js +++ b/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import AjaxFormVariableList from '~/ci_variable_list/ajax_variable_list'; -const VARIABLE_PATCH_ENDPOINT = 'http://test.host/frontend-fixtures/builds-project/variables'; +const VARIABLE_PATCH_ENDPOINT = 'http://test.host/frontend-fixtures/builds-project/-/variables'; const HIDE_CLASS = 'hide'; describe('AjaxFormVariableList', () => { diff --git a/spec/javascripts/diffs/components/diff_file_spec.js b/spec/javascripts/diffs/components/diff_file_spec.js index 3ca2d1dc934ba7e45b1509e4f12ef3d61002808a..6ffdb6ba85d349d610b7ad09e744f6c7890c9f03 100644 --- a/spec/javascripts/diffs/components/diff_file_spec.js +++ b/spec/javascripts/diffs/components/diff_file_spec.js @@ -3,14 +3,15 @@ import DiffFileComponent from '~/diffs/components/diff_file.vue'; import { diffViewerModes, diffViewerErrors } from '~/ide/constants'; import { createStore } from 'ee_else_ce/mr_notes/stores'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; -import diffFileMockData from '../mock_data/diff_file'; +import diffFileMockDataReadable from '../mock_data/diff_file'; +import diffFileMockDataUnreadable from '../mock_data/diff_file_unreadable'; describe('DiffFile', () => { let vm; beforeEach(() => { vm = createComponentWithStore(Vue.extend(DiffFileComponent), createStore(), { - file: JSON.parse(JSON.stringify(diffFileMockData)), + file: JSON.parse(JSON.stringify(diffFileMockDataReadable)), canCurrentUserFork: false, }).$mount(); }); @@ -81,6 +82,24 @@ describe('DiffFile', () => { }); }); + it('should be collapsable for unreadable files', done => { + vm.$destroy(); + vm = createComponentWithStore(Vue.extend(DiffFileComponent), createStore(), { + file: JSON.parse(JSON.stringify(diffFileMockDataUnreadable)), + canCurrentUserFork: false, + }).$mount(); + + vm.renderIt = false; + vm.isCollapsed = true; + + vm.$nextTick(() => { + expect(vm.$el.innerText).toContain('This diff is collapsed'); + expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1); + + done(); + }); + }); + it('should be collapsed for renamed files', done => { vm.renderIt = true; vm.isCollapsed = false; @@ -184,5 +203,31 @@ describe('DiffFile', () => { .then(done) .catch(done.fail); }); + + it('does not call handleLoadCollapsedDiff if collapsed changed & file is unreadable', done => { + vm.$destroy(); + vm = createComponentWithStore(Vue.extend(DiffFileComponent), createStore(), { + file: JSON.parse(JSON.stringify(diffFileMockDataUnreadable)), + canCurrentUserFork: false, + }).$mount(); + + spyOn(vm, 'handleLoadCollapsedDiff'); + + vm.file.highlighted_diff_lines = undefined; + vm.file.parallel_diff_lines = []; + vm.isCollapsed = true; + + vm.$nextTick() + .then(() => { + vm.isCollapsed = false; + + return vm.$nextTick(); + }) + .then(() => { + expect(vm.handleLoadCollapsedDiff).not.toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); }); }); diff --git a/spec/javascripts/diffs/mock_data/diff_file_unreadable.js b/spec/javascripts/diffs/mock_data/diff_file_unreadable.js new file mode 100644 index 0000000000000000000000000000000000000000..8c2df45988e8c828213c0fd3b4ee7e95cde0a6fb --- /dev/null +++ b/spec/javascripts/diffs/mock_data/diff_file_unreadable.js @@ -0,0 +1,244 @@ +export default { + submodule: false, + submodule_link: null, + blob: { + id: '9e10516ca50788acf18c518a231914a21e5f16f7', + path: 'CHANGELOG', + name: 'CHANGELOG', + mode: '100644', + readable_text: false, + icon: 'file-text-o', + }, + blob_path: 'CHANGELOG', + blob_name: 'CHANGELOG', + blob_icon: '<i aria-hidden="true" data-hidden="true" class="fa fa-file-text-o fa-fw"></i>', + file_hash: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a', + file_path: 'CHANGELOG', + new_file: false, + deleted_file: false, + renamed_file: false, + old_path: 'CHANGELOG', + new_path: 'CHANGELOG', + mode_changed: false, + a_mode: '100644', + b_mode: '100644', + text: true, + viewer: { + name: 'text', + error: null, + collapsed: false, + }, + added_lines: 0, + removed_lines: 0, + diff_refs: { + base_sha: 'e63f41fe459e62e1228fcef60d7189127aeba95a', + start_sha: 'd9eaefe5a676b820c57ff18cf5b68316025f7962', + head_sha: 'c48ee0d1bf3b30453f5b32250ce03134beaa6d13', + }, + content_sha: 'c48ee0d1bf3b30453f5b32250ce03134beaa6d13', + stored_externally: null, + external_storage: null, + old_path_html: 'CHANGELOG', + new_path_html: 'CHANGELOG', + edit_path: '/gitlab-org/gitlab-test/edit/spooky-stuff/CHANGELOG', + view_path: '/gitlab-org/gitlab-test/blob/spooky-stuff/CHANGELOG', + replaced_view_path: null, + collapsed: false, + renderIt: false, + too_large: false, + context_lines_path: + '/gitlab-org/gitlab-test/blob/c48ee0d1bf3b30453f5b32250ce03134beaa6d13/CHANGELOG/diff', + highlighted_diff_lines: [ + { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_1', + type: 'new', + old_line: null, + new_line: 1, + discussions: [], + text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', + rich_text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', + meta_data: null, + }, + { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_2', + type: 'new', + old_line: null, + new_line: 2, + discussions: [], + text: '+<span id="LC2" class="line" lang="plaintext"></span>\n', + rich_text: '+<span id="LC2" class="line" lang="plaintext"></span>\n', + meta_data: null, + }, + { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_3', + type: null, + old_line: 1, + new_line: 3, + discussions: [], + text: ' <span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', + rich_text: ' <span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', + meta_data: null, + }, + { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_2_4', + type: null, + old_line: 2, + new_line: 4, + discussions: [], + text: ' <span id="LC4" class="line" lang="plaintext"></span>\n', + rich_text: ' <span id="LC4" class="line" lang="plaintext"></span>\n', + meta_data: null, + }, + { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_3_5', + type: null, + old_line: 3, + new_line: 5, + discussions: [], + text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', + rich_text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', + meta_data: null, + }, + { + line_code: null, + type: 'match', + old_line: null, + new_line: null, + discussions: [], + text: '', + rich_text: '', + meta_data: { + old_pos: 3, + new_pos: 5, + }, + }, + ], + parallel_diff_lines: [ + { + left: { + type: 'empty-cell', + }, + right: { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_1', + type: 'new', + old_line: null, + new_line: 1, + discussions: [], + text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', + rich_text: '<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', + meta_data: null, + }, + }, + { + left: { + type: 'empty-cell', + }, + right: { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_2', + type: 'new', + old_line: null, + new_line: 2, + discussions: [], + text: '+<span id="LC2" class="line" lang="plaintext"></span>\n', + rich_text: '<span id="LC2" class="line" lang="plaintext"></span>\n', + meta_data: null, + }, + }, + { + left: { + line_Code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_3', + type: null, + old_line: 1, + new_line: 3, + discussions: [], + text: ' <span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', + rich_text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', + meta_data: null, + }, + right: { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_3', + type: null, + old_line: 1, + new_line: 3, + discussions: [], + text: ' <span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', + rich_text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', + meta_data: null, + }, + }, + { + left: { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_2_4', + type: null, + old_line: 2, + new_line: 4, + discussions: [], + text: ' <span id="LC4" class="line" lang="plaintext"></span>\n', + rich_text: '<span id="LC4" class="line" lang="plaintext"></span>\n', + meta_data: null, + }, + right: { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_2_4', + type: null, + old_line: 2, + new_line: 4, + discussions: [], + text: ' <span id="LC4" class="line" lang="plaintext"></span>\n', + rich_text: '<span id="LC4" class="line" lang="plaintext"></span>\n', + meta_data: null, + }, + }, + { + left: { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_3_5', + type: null, + old_line: 3, + new_line: 5, + discussions: [], + text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', + rich_text: '<span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', + meta_data: null, + }, + right: { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_3_5', + type: null, + old_line: 3, + new_line: 5, + discussions: [], + text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', + rich_text: '<span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', + meta_data: null, + }, + }, + { + left: { + line_code: null, + type: 'match', + old_line: null, + new_line: null, + discussions: [], + text: '', + rich_text: '', + meta_data: { + old_pos: 3, + new_pos: 5, + }, + }, + right: { + line_code: null, + type: 'match', + old_line: null, + new_line: null, + discussions: [], + text: '', + rich_text: '', + meta_data: { + old_pos: 3, + new_pos: 5, + }, + }, + }, + ], + discussions: [], + renderingLines: false, +}; diff --git a/spec/javascripts/dropzone_input_spec.js b/spec/javascripts/dropzone_input_spec.js index ef899612b08c3919651af16e26baa3f1200d46b2..125dcdb3763f36c18855bdb9de8f99abd93b3965 100644 --- a/spec/javascripts/dropzone_input_spec.js +++ b/spec/javascripts/dropzone_input_spec.js @@ -13,54 +13,68 @@ const TEMPLATE = `<form class="gfm-form" data-uploads-path="${TEST_UPLOAD_PATH}" </form>`; describe('dropzone_input', () => { - let form; - let dropzone; - let xhr; - let oldXMLHttpRequest; + it('returns null when failed to initialize', () => { + const dropzone = dropzoneInput($('<form class="gfm-form"></form>')); - beforeEach(() => { - form = $(TEMPLATE); + expect(dropzone).toBeNull(); + }); - dropzone = dropzoneInput(form); + it('returns valid dropzone when successfully initialize', () => { + const dropzone = dropzoneInput($(TEMPLATE)); - xhr = jasmine.createSpyObj(Object.keys(XMLHttpRequest.prototype)); - oldXMLHttpRequest = window.XMLHttpRequest; - window.XMLHttpRequest = () => xhr; + expect(dropzone.version).toBeTruthy(); }); - afterEach(() => { - window.XMLHttpRequest = oldXMLHttpRequest; - }); + describe('shows error message', () => { + let form; + let dropzone; + let xhr; + let oldXMLHttpRequest; - it('shows error message, when AJAX fails with json', () => { - xhr = { - ...xhr, - statusCode: 400, - readyState: 4, - responseText: JSON.stringify({ message: TEST_ERROR_MESSAGE }), - getResponseHeader: () => 'application/json', - }; + beforeEach(() => { + form = $(TEMPLATE); - dropzone.processFile(TEST_FILE); + dropzone = dropzoneInput(form); - xhr.onload(); + xhr = jasmine.createSpyObj(Object.keys(XMLHttpRequest.prototype)); + oldXMLHttpRequest = window.XMLHttpRequest; + window.XMLHttpRequest = () => xhr; + }); - expect(form.find('.uploading-error-message').text()).toEqual(TEST_ERROR_MESSAGE); - }); + afterEach(() => { + window.XMLHttpRequest = oldXMLHttpRequest; + }); + + it('when AJAX fails with json', () => { + xhr = { + ...xhr, + statusCode: 400, + readyState: 4, + responseText: JSON.stringify({ message: TEST_ERROR_MESSAGE }), + getResponseHeader: () => 'application/json', + }; + + dropzone.processFile(TEST_FILE); + + xhr.onload(); + + expect(form.find('.uploading-error-message').text()).toEqual(TEST_ERROR_MESSAGE); + }); - it('shows error message, when AJAX fails with text', () => { - xhr = { - ...xhr, - statusCode: 400, - readyState: 4, - responseText: TEST_ERROR_MESSAGE, - getResponseHeader: () => 'text/plain', - }; + it('when AJAX fails with text', () => { + xhr = { + ...xhr, + statusCode: 400, + readyState: 4, + responseText: TEST_ERROR_MESSAGE, + getResponseHeader: () => 'text/plain', + }; - dropzone.processFile(TEST_FILE); + dropzone.processFile(TEST_FILE); - xhr.onload(); + xhr.onload(); - expect(form.find('.uploading-error-message').text()).toEqual(TEST_ERROR_MESSAGE); + expect(form.find('.uploading-error-message').text()).toEqual(TEST_ERROR_MESSAGE); + }); }); }); diff --git a/spec/javascripts/frequent_items/components/app_spec.js b/spec/javascripts/frequent_items/components/app_spec.js index 36dd8604d0885d209335e7d4bf3f59237c387c98..da0427d650aac6d9b416b90472c7c6e80ffb7713 100644 --- a/spec/javascripts/frequent_items/components/app_spec.js +++ b/spec/javascripts/frequent_items/components/app_spec.js @@ -247,7 +247,7 @@ describe('Frequent Items App Component', () => { .then(() => { expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe( - mockSearchedProjects.length, + mockSearchedProjects.data.length, ); }) .then(done) diff --git a/spec/javascripts/frequent_items/mock_data.js b/spec/javascripts/frequent_items/mock_data.js index 3ca5b4c744664c1083081252d2764b8ac0dd2885..7f7d7b1cdbfb1dfbfbc6eb6c68ea6e57c005785f 100644 --- a/spec/javascripts/frequent_items/mock_data.js +++ b/spec/javascripts/frequent_items/mock_data.js @@ -68,7 +68,7 @@ export const mockFrequentGroups = [ }, ]; -export const mockSearchedGroups = [mockRawGroup]; +export const mockSearchedGroups = { data: [mockRawGroup] }; export const mockProcessedSearchedGroups = [mockGroup]; export const mockProject = { @@ -135,7 +135,7 @@ export const mockFrequentProjects = [ }, ]; -export const mockSearchedProjects = [mockRawProject]; +export const mockSearchedProjects = { data: [mockRawProject] }; export const mockProcessedSearchedProjects = [mockProject]; export const unsortedFrequentItems = [ diff --git a/spec/javascripts/frequent_items/store/actions_spec.js b/spec/javascripts/frequent_items/store/actions_spec.js index 0a8525e77d6715e3a81a62d7ada1fecc213d8a59..7b065b69cce1952c05c33b7fb1f5c112aa7e68f9 100644 --- a/spec/javascripts/frequent_items/store/actions_spec.js +++ b/spec/javascripts/frequent_items/store/actions_spec.js @@ -169,7 +169,7 @@ describe('Frequent Items Dropdown Store Actions', () => { }); it('should dispatch `receiveSearchedItemsSuccess`', done => { - mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects); + mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects, {}); testAction( actions.fetchSearchedItems, @@ -178,7 +178,10 @@ describe('Frequent Items Dropdown Store Actions', () => { [], [ { type: 'requestSearchedItems' }, - { type: 'receiveSearchedItemsSuccess', payload: mockSearchedProjects }, + { + type: 'receiveSearchedItemsSuccess', + payload: { data: mockSearchedProjects, headers: {} }, + }, ], done, ); diff --git a/spec/javascripts/graphs/stat_graph_contributors_graph_spec.js b/spec/javascripts/graphs/stat_graph_contributors_graph_spec.js deleted file mode 100644 index 563d134ca816d60b6b3e0bd9e16183e56b6a0810..0000000000000000000000000000000000000000 --- a/spec/javascripts/graphs/stat_graph_contributors_graph_spec.js +++ /dev/null @@ -1,152 +0,0 @@ -/* eslint-disable jasmine/no-suite-dupes, vars-on-top, no-var */ - -import { scaleLinear, scaleTime } from 'd3-scale'; -import { timeParse } from 'd3-time-format'; -import { - ContributorsGraph, - ContributorsMasterGraph, -} from '~/pages/projects/graphs/show/stat_graph_contributors_graph'; - -const d3 = { scaleLinear, scaleTime, timeParse }; - -describe('ContributorsGraph', function() { - describe('#set_x_domain', function() { - it('set the x_domain', function() { - ContributorsGraph.set_x_domain(20); - - expect(ContributorsGraph.prototype.x_domain).toEqual(20); - }); - }); - - describe('#set_y_domain', function() { - it('sets the y_domain', function() { - ContributorsGraph.set_y_domain([{ commits: 30 }]); - - expect(ContributorsGraph.prototype.y_domain).toEqual([0, 30]); - }); - }); - - describe('#init_x_domain', function() { - it('sets the initial x_domain', function() { - ContributorsGraph.init_x_domain([{ date: '2013-01-31' }, { date: '2012-01-31' }]); - - expect(ContributorsGraph.prototype.x_domain).toEqual(['2012-01-31', '2013-01-31']); - }); - }); - - describe('#init_y_domain', function() { - it('sets the initial y_domain', function() { - ContributorsGraph.init_y_domain([{ commits: 30 }]); - - expect(ContributorsGraph.prototype.y_domain).toEqual([0, 30]); - }); - }); - - describe('#init_domain', function() { - it('calls init_x_domain and init_y_domain', function() { - spyOn(ContributorsGraph, 'init_x_domain'); - spyOn(ContributorsGraph, 'init_y_domain'); - ContributorsGraph.init_domain(); - - expect(ContributorsGraph.init_x_domain).toHaveBeenCalled(); - expect(ContributorsGraph.init_y_domain).toHaveBeenCalled(); - }); - }); - - describe('#set_dates', function() { - it('sets the dates', function() { - ContributorsGraph.set_dates('2013-12-01'); - - expect(ContributorsGraph.prototype.dates).toEqual('2013-12-01'); - }); - }); - - describe('#set_x_domain', function() { - it("sets the instance's x domain using the prototype's x_domain", function() { - ContributorsGraph.prototype.x_domain = 20; - var instance = new ContributorsGraph(); - instance.x = d3 - .scaleTime() - .range([0, 100]) - .clamp(true); - spyOn(instance.x, 'domain'); - instance.set_x_domain(); - - expect(instance.x.domain).toHaveBeenCalledWith(20); - }); - }); - - describe('#set_y_domain', function() { - it("sets the instance's y domain using the prototype's y_domain", function() { - ContributorsGraph.prototype.y_domain = 30; - var instance = new ContributorsGraph(); - instance.y = d3 - .scaleLinear() - .range([100, 0]) - .nice(); - spyOn(instance.y, 'domain'); - instance.set_y_domain(); - - expect(instance.y.domain).toHaveBeenCalledWith(30); - }); - }); - - describe('#set_domain', function() { - it('calls set_x_domain and set_y_domain', function() { - var instance = new ContributorsGraph(); - spyOn(instance, 'set_x_domain'); - spyOn(instance, 'set_y_domain'); - instance.set_domain(); - - expect(instance.set_x_domain).toHaveBeenCalled(); - expect(instance.set_y_domain).toHaveBeenCalled(); - }); - }); - - describe('#set_data', function() { - it('sets the data', function() { - var instance = new ContributorsGraph(); - instance.set_data('20'); - - expect(instance.data).toEqual('20'); - }); - }); -}); - -describe('ContributorsMasterGraph', function() { - // TODO: fix or remove - // describe("#process_dates", function () { - // it("gets and parses dates", function () { - // var graph = new ContributorsMasterGraph(); - // var data = 'random data here'; - // spyOn(graph, 'parse_dates'); - // spyOn(graph, 'get_dates').andReturn("get"); - // spyOn(ContributorsGraph,'set_dates').andCallThrough(); - // graph.process_dates(data); - // expect(graph.parse_dates).toHaveBeenCalledWith(data); - // expect(graph.get_dates).toHaveBeenCalledWith(data); - // expect(ContributorsGraph.set_dates).toHaveBeenCalledWith("get"); - // }); - // }); - - describe('#get_dates', function() { - it('plucks the date field from data collection', function() { - var graph = new ContributorsMasterGraph(); - var data = [{ date: '2013-01-01' }, { date: '2012-12-15' }]; - - expect(graph.get_dates(data)).toEqual(['2013-01-01', '2012-12-15']); - }); - }); - - describe('#parse_dates', function() { - it('parses the dates', function() { - var graph = new ContributorsMasterGraph(); - var parseDate = d3.timeParse('%Y-%m-%d'); - var data = [{ date: '2013-01-01' }, { date: '2012-12-15' }]; - var correct = [{ date: parseDate(data[0].date) }, { date: parseDate(data[1].date) }]; - graph.parse_dates(data); - - expect(data).toEqual(correct); - }); - }); -}); diff --git a/spec/javascripts/graphs/stat_graph_contributors_spec.js b/spec/javascripts/graphs/stat_graph_contributors_spec.js deleted file mode 100644 index 2ebb6845a8b3ec8521542b40fd202290b8e27c5f..0000000000000000000000000000000000000000 --- a/spec/javascripts/graphs/stat_graph_contributors_spec.js +++ /dev/null @@ -1,28 +0,0 @@ -import ContributorsStatGraph from '~/pages/projects/graphs/show/stat_graph_contributors'; -import { ContributorsGraph } from '~/pages/projects/graphs/show/stat_graph_contributors_graph'; - -import { setLanguage } from '../helpers/locale_helper'; - -describe('ContributorsStatGraph', () => { - describe('change_date_header', () => { - beforeAll(() => { - setLanguage('de'); - }); - - afterAll(() => { - setLanguage(null); - }); - - it('uses the locale to display date ranges', () => { - ContributorsGraph.init_x_domain([{ date: '2013-01-31' }, { date: '2012-01-31' }]); - setFixtures('<div id="date_header"></div>'); - const graph = new ContributorsStatGraph(); - - graph.change_date_header(); - - expect(document.getElementById('date_header').innerText).toBe( - '31. Januar 2012 – 31. Januar 2013', - ); - }); - }); -}); diff --git a/spec/javascripts/graphs/stat_graph_contributors_util_spec.js b/spec/javascripts/graphs/stat_graph_contributors_util_spec.js deleted file mode 100644 index 511b660c67103673c9c4098be431ab1c8d990b9c..0000000000000000000000000000000000000000 --- a/spec/javascripts/graphs/stat_graph_contributors_util_spec.js +++ /dev/null @@ -1,298 +0,0 @@ -/* eslint-disable no-var, camelcase, vars-on-top */ - -import ContributorsStatGraphUtil from '~/pages/projects/graphs/show/stat_graph_contributors_util'; - -describe('ContributorsStatGraphUtil', function() { - describe('#parse_log', function() { - it('returns a correctly parsed log', function() { - var fake_log = [ - { - author_email: 'karlo@email.com', - author_name: 'Karlo Soriano', - date: '2013-05-09', - additions: 471, - }, - { - author_email: 'dzaporozhets@email.com', - author_name: 'Dmitriy Zaporozhets', - date: '2013-05-08', - additions: 6, - deletions: 1, - }, - { - author_email: 'dzaporozhets@email.com', - author_name: 'Dmitriy Zaporozhets', - date: '2013-05-08', - additions: 19, - deletions: 3, - }, - { - author_email: 'dzaporozhets@email.com', - author_name: 'Dmitriy Zaporozhets', - date: '2013-05-08', - additions: 29, - deletions: 3, - }, - ]; - - var correct_parsed_log = { - total: [ - { date: '2013-05-09', additions: 471, deletions: 0, commits: 1 }, - { date: '2013-05-08', additions: 54, deletions: 7, commits: 3 }, - ], - by_author: [ - { - author_name: 'Karlo Soriano', - author_email: 'karlo@email.com', - '2013-05-09': { date: '2013-05-09', additions: 471, deletions: 0, commits: 1 }, - }, - { - author_name: 'Dmitriy Zaporozhets', - author_email: 'dzaporozhets@email.com', - '2013-05-08': { date: '2013-05-08', additions: 54, deletions: 7, commits: 3 }, - }, - ], - }; - - expect(ContributorsStatGraphUtil.parse_log(fake_log)).toEqual(correct_parsed_log); - }); - }); - - describe('#store_data', function() { - var fake_entry = { author: 'Karlo Soriano', date: '2013-05-09', additions: 471 }; - var fake_total = {}; - var fake_by_author = {}; - - it('calls #store_commits', function() { - spyOn(ContributorsStatGraphUtil, 'store_commits'); - ContributorsStatGraphUtil.store_data(fake_entry, fake_total, fake_by_author); - - expect(ContributorsStatGraphUtil.store_commits).toHaveBeenCalled(); - }); - - it('calls #store_additions', function() { - spyOn(ContributorsStatGraphUtil, 'store_additions'); - ContributorsStatGraphUtil.store_data(fake_entry, fake_total, fake_by_author); - - expect(ContributorsStatGraphUtil.store_additions).toHaveBeenCalled(); - }); - - it('calls #store_deletions', function() { - spyOn(ContributorsStatGraphUtil, 'store_deletions'); - ContributorsStatGraphUtil.store_data(fake_entry, fake_total, fake_by_author); - - expect(ContributorsStatGraphUtil.store_deletions).toHaveBeenCalled(); - }); - }); - - // TODO: fix or remove - // describe("#store_commits", function () { - // var fake_total = "fake_total"; - // var fake_by_author = "fake_by_author"; - // - // it("calls #add twice with arguments fake_total and fake_by_author respectively", function () { - // spyOn(ContributorsStatGraphUtil, 'add'); - // ContributorsStatGraphUtil.store_commits(fake_total, fake_by_author); - // expect(ContributorsStatGraphUtil.add.argsForCall).toEqual([["fake_total", "commits", 1], ["fake_by_author", "commits", 1]]); - // }); - // }); - - describe('#add', function() { - it('adds 1 to current test_field in collection', function() { - var fake_collection = { test_field: 10 }; - ContributorsStatGraphUtil.add(fake_collection, 'test_field', 1); - - expect(fake_collection.test_field).toEqual(11); - }); - - it('inits and adds 1 if test_field in collection is not defined', function() { - var fake_collection = {}; - ContributorsStatGraphUtil.add(fake_collection, 'test_field', 1); - - expect(fake_collection.test_field).toEqual(1); - }); - }); - - // TODO: fix or remove - // describe("#store_additions", function () { - // var fake_entry = {additions: 10}; - // var fake_total= "fake_total"; - // var fake_by_author = "fake_by_author"; - // it("calls #add twice with arguments fake_total and fake_by_author respectively", function () { - // spyOn(ContributorsStatGraphUtil, 'add'); - // ContributorsStatGraphUtil.store_additions(fake_entry, fake_total, fake_by_author); - // expect(ContributorsStatGraphUtil.add.argsForCall).toEqual([["fake_total", "additions", 10], ["fake_by_author", "additions", 10]]); - // }); - // }); - - // TODO: fix or remove - // describe("#store_deletions", function () { - // var fake_entry = {deletions: 10}; - // var fake_total= "fake_total"; - // var fake_by_author = "fake_by_author"; - // it("calls #add twice with arguments fake_total and fake_by_author respectively", function () { - // spyOn(ContributorsStatGraphUtil, 'add'); - // ContributorsStatGraphUtil.store_deletions(fake_entry, fake_total, fake_by_author); - // expect(ContributorsStatGraphUtil.add.argsForCall).toEqual([["fake_total", "deletions", 10], ["fake_by_author", "deletions", 10]]); - // }); - // }); - - describe('#add_date', function() { - it('adds a date field to the collection', function() { - var fake_date = '2013-10-02'; - var fake_collection = {}; - ContributorsStatGraphUtil.add_date(fake_date, fake_collection); - - expect(fake_collection[fake_date].date).toEqual('2013-10-02'); - }); - }); - - describe('#add_author', function() { - it('adds an author field to the collection', function() { - var fake_author = { author_name: 'Author', author_email: 'fake@email.com' }; - var fake_author_collection = {}; - var fake_email_collection = {}; - ContributorsStatGraphUtil.add_author( - fake_author, - fake_author_collection, - fake_email_collection, - ); - - expect(fake_author_collection[fake_author.author_name].author_name).toEqual('Author'); - expect(fake_email_collection[fake_author.author_email].author_name).toEqual('Author'); - }); - }); - - describe('#get_total_data', function() { - it('returns the collection sorted via specified field', function() { - var fake_parsed_log = { - total: [ - { date: '2013-05-09', additions: 471, deletions: 0, commits: 1 }, - { date: '2013-05-08', additions: 54, deletions: 7, commits: 3 }, - ], - by_author: [ - { - author: 'Karlo Soriano', - '2013-05-09': { date: '2013-05-09', additions: 471, deletions: 0, commits: 1 }, - }, - { - author: 'Dmitriy Zaporozhets', - '2013-05-08': { date: '2013-05-08', additions: 54, deletions: 7, commits: 3 }, - }, - ], - }; - var correct_total_data = [ - { date: '2013-05-08', commits: 3 }, - { date: '2013-05-09', commits: 1 }, - ]; - - expect(ContributorsStatGraphUtil.get_total_data(fake_parsed_log, 'commits')).toEqual( - correct_total_data, - ); - }); - }); - - describe('#pick_field', function() { - it('returns the collection with only the specified field and date', function() { - var fake_parsed_log_total = [ - { date: '2013-05-09', additions: 471, deletions: 0, commits: 1 }, - { date: '2013-05-08', additions: 54, deletions: 7, commits: 3 }, - ]; - ContributorsStatGraphUtil.pick_field(fake_parsed_log_total, 'commits'); - var correct_pick_field_data = [ - { date: '2013-05-09', commits: 1 }, - { date: '2013-05-08', commits: 3 }, - ]; - - expect(ContributorsStatGraphUtil.pick_field(fake_parsed_log_total, 'commits')).toEqual( - correct_pick_field_data, - ); - }); - }); - - describe('#get_author_data', function() { - it('returns the log by author sorted by specified field', function() { - var fake_parsed_log = { - total: [ - { date: '2013-05-09', additions: 471, deletions: 0, commits: 1 }, - { date: '2013-05-08', additions: 54, deletions: 7, commits: 3 }, - ], - by_author: [ - { - author_name: 'Karlo Soriano', - author_email: 'karlo@email.com', - '2013-05-09': { date: '2013-05-09', additions: 471, deletions: 0, commits: 1 }, - }, - { - author_name: 'Dmitriy Zaporozhets', - author_email: 'dzaporozhets@email.com', - '2013-05-08': { date: '2013-05-08', additions: 54, deletions: 7, commits: 3 }, - }, - ], - }; - var correct_author_data = [ - { - author_name: 'Dmitriy Zaporozhets', - author_email: 'dzaporozhets@email.com', - dates: { '2013-05-08': 3 }, - deletions: 7, - additions: 54, - commits: 3, - }, - { - author_name: 'Karlo Soriano', - author_email: 'karlo@email.com', - dates: { '2013-05-09': 1 }, - deletions: 0, - additions: 471, - commits: 1, - }, - ]; - - expect(ContributorsStatGraphUtil.get_author_data(fake_parsed_log, 'commits')).toEqual( - correct_author_data, - ); - }); - }); - - describe('#parse_log_entry', function() { - it('adds the corresponding info from the log entry to the author', function() { - var fake_log_entry = { - author_name: 'Karlo Soriano', - author_email: 'karlo@email.com', - '2013-05-09': { date: '2013-05-09', additions: 471, deletions: 0, commits: 1 }, - }; - var correct_parsed_log = { - author_name: 'Karlo Soriano', - author_email: 'karlo@email.com', - dates: { '2013-05-09': 1 }, - deletions: 0, - additions: 471, - commits: 1, - }; - - expect(ContributorsStatGraphUtil.parse_log_entry(fake_log_entry, 'commits', null)).toEqual( - correct_parsed_log, - ); - }); - }); - - describe('#in_range', function() { - var date = '2013-05-09'; - it('returns true if date_range is null', function() { - expect(ContributorsStatGraphUtil.in_range(date, null)).toEqual(true); - }); - - it('returns true if date is in range', function() { - var date_range = [new Date('2013-01-01'), new Date('2013-12-12')]; - - expect(ContributorsStatGraphUtil.in_range(date, date_range)).toEqual(true); - }); - - it('returns false if date is not in range', function() { - var date_range = [new Date('1999-12-01'), new Date('2000-12-01')]; - - expect(ContributorsStatGraphUtil.in_range(date, date_range)).toEqual(false); - }); - }); -}); diff --git a/spec/javascripts/ide/components/jobs/stage_spec.js b/spec/javascripts/ide/components/jobs/stage_spec.js deleted file mode 100644 index fc3831f2d0572cdf187679e8403f9caf5f96244e..0000000000000000000000000000000000000000 --- a/spec/javascripts/ide/components/jobs/stage_spec.js +++ /dev/null @@ -1,95 +0,0 @@ -import Vue from 'vue'; -import Stage from '~/ide/components/jobs/stage.vue'; -import { stages, jobs } from '../../mock_data'; - -describe('IDE pipeline stage', () => { - const Component = Vue.extend(Stage); - let vm; - let stage; - - beforeEach(() => { - stage = { - ...stages[0], - id: 0, - dropdownPath: stages[0].dropdown_path, - jobs: [...jobs], - isLoading: false, - isCollapsed: false, - }; - - vm = new Component({ - propsData: { stage }, - }); - - spyOn(vm, '$emit'); - - vm.$mount(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - it('emits fetch event when mounted', () => { - expect(vm.$emit).toHaveBeenCalledWith('fetch', vm.stage); - }); - - it('renders stages details', () => { - expect(vm.$el.textContent).toContain(vm.stage.name); - }); - - it('renders CI icon', () => { - expect(vm.$el.querySelector('.ic-status_failed')).not.toBe(null); - }); - - describe('collapsed', () => { - it('emits event when clicking header', done => { - vm.$el.querySelector('.card-header').click(); - - vm.$nextTick(() => { - expect(vm.$emit).toHaveBeenCalledWith('toggleCollapsed', vm.stage.id); - - done(); - }); - }); - - it('toggles collapse status when collapsed', done => { - vm.stage.isCollapsed = true; - - vm.$nextTick(() => { - expect(vm.$el.querySelector('.card-body').style.display).toBe('none'); - - done(); - }); - }); - - it('sets border bottom class when collapsed', done => { - vm.stage.isCollapsed = true; - - vm.$nextTick(() => { - expect(vm.$el.querySelector('.card-header').classList).toContain('border-bottom-0'); - - done(); - }); - }); - }); - - it('renders jobs count', () => { - expect(vm.$el.querySelector('.badge').textContent).toContain('4'); - }); - - it('renders loading icon when no jobs and isLoading is true', done => { - vm.stage.isLoading = true; - vm.stage.jobs = []; - - vm.$nextTick(() => { - expect(vm.$el.querySelector('.loading-container')).not.toBe(null); - - done(); - }); - }); - - it('renders list of jobs', () => { - expect(vm.$el.querySelectorAll('.ide-job-item').length).toBe(4); - }); -}); diff --git a/spec/javascripts/ide/components/repo_editor_spec.js b/spec/javascripts/ide/components/repo_editor_spec.js index d1b43df74b9ba0f2c8289e03bde2afc80b667faf..21fb5449858a96444b39095af1c785a643c63dd4 100644 --- a/spec/javascripts/ide/components/repo_editor_spec.js +++ b/spec/javascripts/ide/components/repo_editor_spec.js @@ -261,10 +261,10 @@ describe('RepoEditor', () => { }); it('updates state when model content changed', done => { - vm.model.setValue('testing 123'); + vm.model.setValue('testing 123\n'); setTimeout(() => { - expect(vm.file.content).toBe('testing 123'); + expect(vm.file.content).toBe('testing 123\n'); done(); }); diff --git a/spec/javascripts/ide/stores/actions/file_spec.js b/spec/javascripts/ide/stores/actions/file_spec.js index 021c30760949edaabd8e25c7c326cb51f96c63ae..03d1125c23a699f42e3d2a5281c1d4c134cc7b4c 100644 --- a/spec/javascripts/ide/stores/actions/file_spec.js +++ b/spec/javascripts/ide/stores/actions/file_spec.js @@ -182,13 +182,25 @@ describe('IDE store file actions', () => { spyOn(service, 'getFileData').and.callThrough(); localFile = file(`newCreate-${Math.random()}`); - localFile.url = `project/getFileDataURL`; store.state.entries[localFile.path] = localFile; + + store.state.currentProjectId = 'test/test'; + store.state.currentBranchId = 'master'; + + store.state.projects['test/test'] = { + branches: { + master: { + commit: { + id: '7297abc', + }, + }, + }, + }; }); describe('success', () => { beforeEach(() => { - mock.onGet(`${RELATIVE_URL_ROOT}/project/getFileDataURL`).replyOnce( + mock.onGet(`${RELATIVE_URL_ROOT}/test/test/7297abc/${localFile.path}`).replyOnce( 200, { blame_path: 'blame_path', @@ -210,7 +222,7 @@ describe('IDE store file actions', () => { .dispatch('getFileData', { path: localFile.path }) .then(() => { expect(service.getFileData).toHaveBeenCalledWith( - `${RELATIVE_URL_ROOT}/project/getFileDataURL`, + `${RELATIVE_URL_ROOT}/test/test/7297abc/${localFile.path}`, ); done(); @@ -229,12 +241,11 @@ describe('IDE store file actions', () => { .catch(done.fail); }); - it('sets document title', done => { + it('sets document title with the branchId', done => { store .dispatch('getFileData', { path: localFile.path }) .then(() => { - expect(document.title).toBe('testing getFileData'); - + expect(document.title).toBe(`${localFile.path} · master · test/test · GitLab`); done(); }) .catch(done.fail); @@ -283,7 +294,7 @@ describe('IDE store file actions', () => { localFile.path = 'new-shiny-file'; store.state.entries[localFile.path] = localFile; - mock.onGet(`${RELATIVE_URL_ROOT}/project/getFileDataURL`).replyOnce( + mock.onGet(`${RELATIVE_URL_ROOT}/test/test/7297abc/old-dull-file`).replyOnce( 200, { blame_path: 'blame_path', @@ -304,7 +315,7 @@ describe('IDE store file actions', () => { store .dispatch('getFileData', { path: localFile.path }) .then(() => { - expect(document.title).toBe('testing new-shiny-file'); + expect(document.title).toBe(`new-shiny-file · master · test/test · GitLab`); done(); }) @@ -314,14 +325,17 @@ describe('IDE store file actions', () => { describe('error', () => { beforeEach(() => { - mock.onGet(`project/getFileDataURL`).networkError(); + mock.onGet(`${RELATIVE_URL_ROOT}/test/test/7297abc/${localFile.path}`).networkError(); }); it('dispatches error action', done => { const dispatch = jasmine.createSpy('dispatch'); actions - .getFileData({ state: store.state, commit() {}, dispatch }, { path: localFile.path }) + .getFileData( + { state: store.state, commit() {}, dispatch, getters: store.getters }, + { path: localFile.path }, + ) .then(() => { expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { text: 'An error occurred whilst loading the file.', @@ -455,17 +469,33 @@ describe('IDE store file actions', () => { beforeEach(() => { tmpFile = file('tmpFile'); + tmpFile.content = '\n'; + tmpFile.raw = '\n'; store.state.entries[tmpFile.path] = tmpFile; }); it('updates file content', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content\n', + }) + .then(() => { + expect(tmpFile.content).toBe('content\n'); + + done(); + }) + .catch(done.fail); + }); + + it('adds a newline to the end of the file if it doesnt already exist', done => { store .dispatch('changeFileContent', { path: tmpFile.path, content: 'content', }) .then(() => { - expect(tmpFile.content).toBe('content'); + expect(tmpFile.content).toBe('content\n'); done(); }) @@ -510,12 +540,12 @@ describe('IDE store file actions', () => { store .dispatch('changeFileContent', { path: tmpFile.path, - content: 'content', + content: 'content\n', }) .then(() => store.dispatch('changeFileContent', { path: tmpFile.path, - content: '', + content: '\n', }), ) .then(() => { diff --git a/spec/javascripts/ide/stores/actions/merge_request_spec.js b/spec/javascripts/ide/stores/actions/merge_request_spec.js index 4dd0c1150ebef3982923dc411fa7db478b02b8bd..a8894c644be688b3380d0be6d7eae3bc1141bbc1 100644 --- a/spec/javascripts/ide/stores/actions/merge_request_spec.js +++ b/spec/javascripts/ide/stores/actions/merge_request_spec.js @@ -356,8 +356,30 @@ describe('IDE store merge request actions', () => { changes: [], }; store.state.entries = { - foo: {}, - bar: {}, + foo: { + type: 'blob', + }, + bar: { + type: 'blob', + }, + }; + + store.state.currentProjectId = 'test/test'; + store.state.currentBranchId = 'master'; + + store.state.projects['test/test'] = { + branches: { + master: { + commit: { + id: '7297abc', + }, + }, + abcbranch: { + commit: { + id: '29020fc', + }, + }, + }, }; const originalDispatch = store.dispatch; @@ -415,9 +437,11 @@ describe('IDE store merge request actions', () => { it('updates activity bar view and gets file data, if changes are found', done => { store.state.entries.foo = { url: 'test', + type: 'blob', }; store.state.entries.bar = { url: 'test', + type: 'blob', }; testMergeRequestChanges.changes = [ diff --git a/spec/javascripts/ide/stores/actions/tree_spec.js b/spec/javascripts/ide/stores/actions/tree_spec.js index 0c3c4147501b651a4a3df4819736507dd890994e..e2d8cc195aef611b19767e98a44ea509db7741f7 100644 --- a/spec/javascripts/ide/stores/actions/tree_spec.js +++ b/spec/javascripts/ide/stores/actions/tree_spec.js @@ -31,7 +31,10 @@ describe('Multi-file store tree actions', () => { web_url: '', branches: { master: { - workingReference: '1', + workingReference: '12345678', + commit: { + id: '12345678', + }, }, }, }; @@ -61,7 +64,7 @@ describe('Multi-file store tree actions', () => { store .dispatch('getFiles', basicCallParameters) .then(() => { - expect(service.getFiles).toHaveBeenCalledWith('', 'master'); + expect(service.getFiles).toHaveBeenCalledWith('', '12345678'); done(); }) @@ -99,8 +102,18 @@ describe('Multi-file store tree actions', () => { store.state.projects = { 'abc/def': { web_url: `${gl.TEST_HOST}/files`, + branches: { + 'master-testing': { + commit: { + id: '12345', + }, + }, + }, }, }; + const getters = { + findBranch: () => store.state.projects['abc/def'].branches['master-testing'], + }; mock.onGet(/(.*)/).replyOnce(500); @@ -109,6 +122,7 @@ describe('Multi-file store tree actions', () => { commit() {}, dispatch, state: store.state, + getters, }, { projectId: 'abc/def', diff --git a/spec/javascripts/ide/stores/getters_spec.js b/spec/javascripts/ide/stores/getters_spec.js index 73a8d993a133fbed747e8f1ddea5d96a85495d52..558674cc84575d74e358db9ebb5d8365b81cbc08 100644 --- a/spec/javascripts/ide/stores/getters_spec.js +++ b/spec/javascripts/ide/stores/getters_spec.js @@ -163,20 +163,57 @@ describe('IDE store getters', () => { describe('currentBranch', () => { it('returns current projects branch', () => { - const localGetters = { - currentProject: { - branches: { - master: { - name: 'master', - }, + localState.currentProjectId = 'abcproject'; + localState.currentBranchId = 'master'; + localState.projects.abcproject = { + name: 'abcproject', + branches: { + master: { + name: 'master', }, }, }; + const localGetters = { + findBranch: jasmine.createSpy('findBranchSpy'), + }; + getters.currentBranch(localState, localGetters); + + expect(localGetters.findBranch).toHaveBeenCalledWith('abcproject', 'master'); + }); + }); + + describe('findProject', () => { + it('returns the project matching the id', () => { + localState.currentProjectId = 'abcproject'; + localState.projects.abcproject = { + name: 'abcproject', + }; + + expect(getters.findProject(localState)('abcproject').name).toBe('abcproject'); + }); + }); + + describe('findBranch', () => { + let result; + + it('returns the selected branch from a project', () => { + localState.currentProjectId = 'abcproject'; localState.currentBranchId = 'master'; + localState.projects.abcproject = { + name: 'abcproject', + branches: { + master: { + name: 'master', + }, + }, + }; + const localGetters = { + findProject: () => localState.projects.abcproject, + }; - expect(getters.currentBranch(localState, localGetters)).toEqual({ - name: 'master', - }); + result = getters.findBranch(localState, localGetters)('abcproject', 'master'); + + expect(result.name).toBe('master'); }); }); diff --git a/spec/javascripts/ide/stores/modules/commit/actions_spec.js b/spec/javascripts/ide/stores/modules/commit/actions_spec.js index 95d927065f09e811873a95ca54be19e5506cf5ac..d464f30b947a3be9f64b68a320b22ef3ee745602 100644 --- a/spec/javascripts/ide/stores/modules/commit/actions_spec.js +++ b/spec/javascripts/ide/stores/modules/commit/actions_spec.js @@ -292,6 +292,8 @@ describe('IDE commit module actions', () => { type: 'blob', active: true, lastCommitSha: TEST_COMMIT_SHA, + content: '\n', + raw: '\n', }; Object.assign(store.state, { @@ -359,7 +361,7 @@ describe('IDE commit module actions', () => { { action: commitActionTypes.update, file_path: jasmine.anything(), - content: undefined, + content: '\n', encoding: jasmine.anything(), last_commit_id: undefined, previous_path: undefined, @@ -386,7 +388,7 @@ describe('IDE commit module actions', () => { { action: commitActionTypes.update, file_path: jasmine.anything(), - content: undefined, + content: '\n', encoding: jasmine.anything(), last_commit_id: TEST_COMMIT_SHA, previous_path: undefined, diff --git a/spec/javascripts/ide/stores/utils_spec.js b/spec/javascripts/ide/stores/utils_spec.js index a477d4fc200650abb6a27d045726205d588abd76..37290864e3d432f169952326b494883c720952e9 100644 --- a/spec/javascripts/ide/stores/utils_spec.js +++ b/spec/javascripts/ide/stores/utils_spec.js @@ -11,6 +11,23 @@ describe('Multi-file store utils', () => { }); }); + describe('setPageTitleForFile', () => { + it('sets the document page title for the file passed', () => { + const f = { + path: 'README.md', + }; + + const state = { + currentBranchId: 'master', + currentProjectId: 'test/test', + }; + + utils.setPageTitleForFile(state, f); + + expect(document.title).toBe('README.md · master · test/test · GitLab'); + }); + }); + describe('findIndexOfFile', () => { let localState; @@ -597,4 +614,17 @@ describe('Multi-file store utils', () => { }); }); }); + + describe('addFinalNewlineIfNeeded', () => { + it('adds a newline if it doesnt already exist', () => { + [ + { input: 'some text', output: 'some text\n' }, + { input: 'some text\n', output: 'some text\n' }, + { input: 'some text\n\n', output: 'some text\n\n' }, + { input: 'some\n text', output: 'some\n text\n' }, + ].forEach(({ input, output }) => { + expect(utils.addFinalNewlineIfNeeded(input)).toEqual(output); + }); + }); + }); }); diff --git a/spec/javascripts/issue_show/helpers.js b/spec/javascripts/issue_show/helpers.js index 5d2ced98ae47dac28cdb1a853289a2e9e063b431..951acfd4e10d2ae6e8717cc437f0e52b8c9d984b 100644 --- a/spec/javascripts/issue_show/helpers.js +++ b/spec/javascripts/issue_show/helpers.js @@ -1,10 +1 @@ -// eslint-disable-next-line import/prefer-default-export -export const keyboardDownEvent = (code, metaKey = false, ctrlKey = false) => { - const e = new CustomEvent('keydown'); - - e.keyCode = code; - e.metaKey = metaKey; - e.ctrlKey = ctrlKey; - - return e; -}; +export * from '../../frontend/issue_show/helpers.js'; diff --git a/spec/javascripts/lib/utils/tick_formats_spec.js b/spec/javascripts/lib/utils/tick_formats_spec.js deleted file mode 100644 index 283989b4fc873dfe46cb4fc418fb317055e08cf3..0000000000000000000000000000000000000000 --- a/spec/javascripts/lib/utils/tick_formats_spec.js +++ /dev/null @@ -1,40 +0,0 @@ -import { dateTickFormat, initDateFormats } from '~/lib/utils/tick_formats'; - -import { setLanguage } from '../../helpers/locale_helper'; - -describe('tick formats', () => { - describe('dateTickFormat', () => { - beforeAll(() => { - setLanguage('de'); - initDateFormats(); - }); - - afterAll(() => { - setLanguage(null); - }); - - it('returns year for first of January', () => { - const tick = dateTickFormat(new Date('2001-01-01')); - - expect(tick).toBe('2001'); - }); - - it('returns month for first of February', () => { - const tick = dateTickFormat(new Date('2001-02-01')); - - expect(tick).toBe('Februar'); - }); - - it('returns day and month for second of February', () => { - const tick = dateTickFormat(new Date('2001-02-02')); - - expect(tick).toBe('2. Feb.'); - }); - - it('ignores time', () => { - const tick = dateTickFormat(new Date('2001-02-02 12:34:56')); - - expect(tick).toBe('2. Feb.'); - }); - }); -}); diff --git a/spec/javascripts/merge_request_spec.js b/spec/javascripts/merge_request_spec.js index 72d6e832acad6c963792a31e25a431ac97e64511..54071ccc5c24fb07d455bf83c6ef87e50fbc23b5 100644 --- a/spec/javascripts/merge_request_spec.js +++ b/spec/javascripts/merge_request_spec.js @@ -1,5 +1,3 @@ -/* eslint-disable no-return-assign */ - import $ from 'jquery'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; @@ -22,7 +20,8 @@ describe('MergeRequest', function() { .onPatch(`${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`) .reply(200, {}); - return (this.merge = new MergeRequest()); + this.merge = new MergeRequest(); + return this.merge; }); afterEach(() => { @@ -34,10 +33,30 @@ describe('MergeRequest', function() { const changeEvent = document.createEvent('HTMLEvents'); changeEvent.initEvent('change', true, true); $('input[type=checkbox]') + .first() + .attr('checked', true)[0] + .dispatchEvent(changeEvent); + setTimeout(() => { + expect($('.js-task-list-field').val()).toBe( + '- [x] Task List Item\n- [ ] \n- [ ] Task List Item 2\n', + ); + done(); + }); + }); + + it('ensure that task with only spaces does not get checked incorrectly', done => { + // fixed in 'deckar01-task_list', '2.2.1' gem + spyOn($, 'ajax').and.stub(); + const changeEvent = document.createEvent('HTMLEvents'); + changeEvent.initEvent('change', true, true); + $('input[type=checkbox]') + .last() .attr('checked', true)[0] .dispatchEvent(changeEvent); setTimeout(() => { - expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); + expect($('.js-task-list-field').val()).toBe( + '- [ ] Task List Item\n- [ ] \n- [x] Task List Item 2\n', + ); done(); }); }); @@ -59,7 +78,7 @@ describe('MergeRequest', function() { `${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`, { merge_request: { - description: '- [ ] Task List Item', + description: '- [ ] Task List Item\n- [ ] \n- [ ] Task List Item 2\n', lock_version: 0, update_task: { line_number: lineNumber, line_source: lineSource, index, checked }, }, @@ -70,7 +89,8 @@ describe('MergeRequest', function() { }); }); - it('shows an error notification when tasklist update failed', done => { + // eslint-disable-next-line jasmine/no-disabled-tests + xit('shows an error notification when tasklist update failed', done => { mock .onPatch(`${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`) .reply(409, {}); diff --git a/spec/javascripts/merge_request_tabs_spec.js b/spec/javascripts/merge_request_tabs_spec.js index b424cbc866d6b26b2db1a8a67ac0ab26741bc355..73b1ea4d36f0e7772ac64f579125820d6a5aa9cd 100644 --- a/spec/javascripts/merge_request_tabs_spec.js +++ b/spec/javascripts/merge_request_tabs_spec.js @@ -1,4 +1,3 @@ -/* eslint-disable no-var */ import $ from 'jquery'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; @@ -11,9 +10,9 @@ import initMrPage from './helpers/init_vue_mr_page_helper'; describe('MergeRequestTabs', function() { let mrPageMock; - var stubLocation = {}; - var setLocation = function(stubs) { - var defaults = { + const stubLocation = {}; + const setLocation = function(stubs) { + const defaults = { pathname: '', search: '', hash: '', @@ -44,9 +43,9 @@ describe('MergeRequestTabs', function() { }); describe('opensInNewTab', function() { - var tabUrl; - var windowTarget = '_blank'; + const windowTarget = '_blank'; let clickTabParams; + let tabUrl; beforeEach(function() { loadFixtures('merge_requests/merge_request_with_task_list.html'); @@ -193,11 +192,10 @@ describe('MergeRequestTabs', function() { }); it('replaces the current history state', function() { - var newState; setLocation({ pathname: '/foo/bar/merge_requests/1', }); - newState = this.subject('commits'); + const newState = this.subject('commits'); expect(this.spies.history).toHaveBeenCalledWith( { diff --git a/spec/javascripts/monitoring/charts/heatmap_spec.js b/spec/javascripts/monitoring/charts/heatmap_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..9a98fc6fb053e3120b8c83913bc0a4adf90bc2d1 --- /dev/null +++ b/spec/javascripts/monitoring/charts/heatmap_spec.js @@ -0,0 +1,69 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlHeatmap } from '@gitlab/ui/dist/charts'; +import Heatmap from '~/monitoring/components/charts/heatmap.vue'; +import { graphDataPrometheusQueryRangeMultiTrack } from '../mock_data'; + +describe('Heatmap component', () => { + let heatmapChart; + let store; + + beforeEach(() => { + heatmapChart = shallowMount(Heatmap, { + propsData: { + graphData: graphDataPrometheusQueryRangeMultiTrack, + containerWidth: 100, + }, + store, + }); + }); + + afterEach(() => { + heatmapChart.destroy(); + }); + + describe('wrapped components', () => { + describe('GitLab UI heatmap chart', () => { + let glHeatmapChart; + + beforeEach(() => { + glHeatmapChart = heatmapChart.find(GlHeatmap); + }); + + it('is a Vue instance', () => { + expect(glHeatmapChart.isVueInstance()).toBe(true); + }); + + it('should display a label on the x axis', () => { + expect(heatmapChart.vm.xAxisName).toBe(graphDataPrometheusQueryRangeMultiTrack.x_label); + }); + + it('should display a label on the y axis', () => { + expect(heatmapChart.vm.yAxisName).toBe(graphDataPrometheusQueryRangeMultiTrack.y_label); + }); + + // According to the echarts docs https://echarts.apache.org/en/option.html#series-heatmap.data + // each row of the heatmap chart is represented by an array inside another parent array + // e.g. [[0, 0, 10]], the format represents the column, the row and finally the value + // corresponding to the cell + + it('should return chartData with a length of x by y, with a length of 3 per array', () => { + const row = heatmapChart.vm.chartData[0]; + + expect(row.length).toBe(3); + expect(heatmapChart.vm.chartData.length).toBe(30); + }); + + it('returns a series of labels for the x axis', () => { + const { xAxisLabels } = heatmapChart.vm; + + expect(xAxisLabels.length).toBe(5); + }); + + it('returns a series of labels for the y axis', () => { + const { yAxisLabels } = heatmapChart.vm; + + expect(yAxisLabels.length).toBe(6); + }); + }); + }); +}); diff --git a/spec/javascripts/monitoring/components/dashboard_spec.js b/spec/javascripts/monitoring/components/dashboard_spec.js index 75df2ce3103afe2839c00525061a4c6414c70dc2..0f20171726c5436d8f3a5ff16a01882c869584ab 100644 --- a/spec/javascripts/monitoring/components/dashboard_spec.js +++ b/spec/javascripts/monitoring/components/dashboard_spec.js @@ -7,11 +7,12 @@ 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 MonitoringMock, { +import { metricsGroupsAPIResponse, + mockedQueryResultPayload, + mockedQueryResultPayloadCoresTotal, mockApiEndpoint, environmentData, - singleGroupResponse, dashboardGitResponse, } from '../mock_data'; @@ -44,12 +45,33 @@ const resetSpy = spy => { export default propsData; +function setupComponentStore(component) { + component.$store.commit( + `monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, + metricsGroupsAPIResponse, + ); + + // Load 2 panels to the dashboard + component.$store.commit( + `monitoringDashboard/${types.SET_QUERY_RESULT}`, + mockedQueryResultPayload, + ); + component.$store.commit( + `monitoringDashboard/${types.SET_QUERY_RESULT}`, + mockedQueryResultPayloadCoresTotal, + ); + + component.$store.commit( + `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, + environmentData, + ); +} + describe('Dashboard', () => { let DashboardComponent; let mock; let store; let component; - let mockGraphData; beforeEach(() => { setFixtures(` @@ -100,6 +122,32 @@ describe('Dashboard', () => { }); }); + describe('cluster health', () => { + let wrapper; + + beforeEach(done => { + wrapper = shallowMount(DashboardComponent, { + localVue, + sync: false, + propsData: { ...propsData, hasMetrics: true }, + store, + }); + + // 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(() => { @@ -123,25 +171,6 @@ describe('Dashboard', () => { }); }); - it('hides the legend when showLegend is false', done => { - component = new DashboardComponent({ - el: document.querySelector('.prometheus-graphs'), - propsData: { - ...propsData, - hasMetrics: true, - showLegend: false, - }, - store, - }); - - setTimeout(() => { - expect(component.showEmptyState).toEqual(false); - expect(component.$el.querySelector('.legend-group')).toEqual(null); - expect(component.$el.querySelector('.prometheus-graph-group')).toBeTruthy(); - done(); - }); - }); - it('hides the group panels when showPanels is false', done => { component = new DashboardComponent({ el: document.querySelector('.prometheus-graphs'), @@ -153,52 +182,66 @@ describe('Dashboard', () => { store, }); - setTimeout(() => { - expect(component.showEmptyState).toEqual(false); - expect(component.$el.querySelector('.prometheus-panel')).toEqual(null); - expect(component.$el.querySelector('.prometheus-graph-group')).toBeTruthy(); - done(); - }); + 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); }); - it('renders the environments dropdown with a number of environments', done => { - component = new DashboardComponent({ - el: document.querySelector('.prometheus-graphs'), - propsData: { - ...propsData, - hasMetrics: true, - showPanels: false, - }, - store, + 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); }); - component.$store.commit( - `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, - environmentData, - ); - component.$store.commit( - `monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, - singleGroupResponse, - ); + it('renders the environments dropdown with a number of environments', done => { + Vue.nextTick() + .then(() => { + const dropdownMenuEnvironments = component.$el.querySelectorAll( + '.js-environments-dropdown .dropdown-item', + ); - 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); - 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); + } + }); - Array.from(dropdownMenuEnvironments).forEach((value, index) => { - if (environmentData[index].metrics_path) { - expect(value).toHaveAttr('href', environmentData[index].metrics_path); - } - }); + done(); + }) + .catch(done.fail); + }); - 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 => { @@ -207,15 +250,17 @@ describe('Dashboard', () => { propsData: { ...propsData, hasMetrics: true, - showPanels: false, }, store, }); - component.$store.commit(`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, []); component.$store.commit( `monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, - singleGroupResponse, + metricsGroupsAPIResponse, + ); + component.$store.commit( + `monitoringDashboard/${types.SET_QUERY_RESULT}`, + mockedQueryResultPayload, ); Vue.nextTick() @@ -230,7 +275,7 @@ describe('Dashboard', () => { .catch(done.fail); }); - it('renders the environments dropdown with a single active element', done => { + it('renders the datetimepicker dropdown', done => { component = new DashboardComponent({ el: document.querySelector('.prometheus-graphs'), propsData: { @@ -241,64 +286,16 @@ describe('Dashboard', () => { store, }); - component.$store.commit( - `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, - environmentData, - ); - component.$store.commit( - `monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, - singleGroupResponse, - ); + setupComponentStore(component); Vue.nextTick() .then(() => { - const dropdownItems = component.$el.querySelectorAll( - '.js-environments-dropdown .dropdown-item.active', - ); - - expect(dropdownItems.length).toEqual(1); + expect(component.$el.querySelector('.js-time-window-dropdown')).not.toBeNull(); done(); }) .catch(done.fail); }); - it('hides the dropdown', done => { - component = new DashboardComponent({ - el: document.querySelector('.prometheus-graphs'), - propsData: { - ...propsData, - hasMetrics: true, - showPanels: false, - environmentsEndpoint: '', - }, - store, - }); - - Vue.nextTick(() => { - const dropdownIsActiveElement = component.$el.querySelectorAll('.environments'); - - expect(dropdownIsActiveElement.length).toEqual(0); - done(); - }); - }); - - it('renders the datetimepicker dropdown', done => { - component = new DashboardComponent({ - el: document.querySelector('.prometheus-graphs'), - propsData: { - ...propsData, - hasMetrics: true, - showPanels: false, - }, - store, - }); - - setTimeout(() => { - expect(component.$el.querySelector('.js-time-window-dropdown')).not.toBeNull(); - done(); - }); - }); - it('fetches the metrics data with proper time window', done => { component = new DashboardComponent({ el: document.querySelector('.prometheus-graphs'), @@ -347,14 +344,21 @@ describe('Dashboard', () => { el: document.querySelector('.prometheus-graphs'), propsData: { ...propsData, hasMetrics: true }, store, + sync: false, }); - setTimeout(() => { - const selectedTimeWindow = component.$el.querySelector('.js-time-window-dropdown .active'); + setupComponentStore(component); - expect(selectedTimeWindow.textContent.trim()).toEqual('30 minutes'); - done(); - }); + 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 => { @@ -381,29 +385,36 @@ describe('Dashboard', () => { describe('drag and drop function', () => { let wrapper; let expectedPanelCount; // also called metrics, naming to be improved: https://gitlab.com/gitlab-org/gitlab/issues/31565 + 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(done => { + beforeEach(() => { mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); - expectedPanelCount = metricsGroupsAPIResponse.data.reduce( - (acc, d) => d.metrics.length + acc, + expectedPanelCount = metricsGroupsAPIResponse.reduce( + (acc, group) => group.panels.length + acc, 0, ); - store.dispatch('monitoringDashboard/setFeatureFlags', { additionalPanelTypesEnabled: true }); + }); + beforeEach(done => { wrapper = shallowMount(DashboardComponent, { localVue, sync: false, propsData: { ...propsData, hasMetrics: true }, store, + attachToDocument: true, }); - // not using $nextTicket becuase we must wait for the dashboard - // to be populated with the mock data results. - setTimeout(done); + setupComponentStore(wrapper.vm); + + wrapper.vm.$nextTick(done); + }); + + afterEach(() => { + wrapper.destroy(); }); it('wraps vuedraggable', () => { @@ -442,6 +453,28 @@ describe('Dashboard', () => { expect(findEnabledDraggables()).toEqual(findDraggables()); }); + it('metrics can be swapped', done => { + const firstDraggable = findDraggables().at(0); + const mockMetrics = [...metricsGroupsAPIResponse[0].panels]; + const value = () => firstDraggable.props('value'); + + expect(value().length).toBe(mockMetrics.length); + value().forEach((metric, i) => { + expect(metric.title).toBe(mockMetrics[i].title); + }); + + // swap two elements and `input` them + [mockMetrics[0], mockMetrics[1]] = [mockMetrics[1], mockMetrics[0]]; + firstDraggable.vm.$emit('input', mockMetrics); + + firstDraggable.vm.$nextTick(() => { + value().forEach((metric, i) => { + expect(metric.title).toBe(mockMetrics[i].title); + }); + done(); + }); + }); + it('shows a remove button, which removes a panel', done => { expect(findFirstDraggableRemoveButton().isEmpty()).toBe(false); @@ -449,8 +482,6 @@ describe('Dashboard', () => { findFirstDraggableRemoveButton().trigger('click'); wrapper.vm.$nextTick(() => { - // At present graphs will not be removed in backend - // See https://gitlab.com/gitlab-org/gitlab/issues/27835 expect(findDraggablePanels().length).toEqual(expectedPanelCount - 1); done(); }); @@ -466,10 +497,6 @@ describe('Dashboard', () => { }); }); }); - - afterEach(() => { - wrapper.destroy(); - }); }); // https://gitlab.com/gitlab-org/gitlab-ce/issues/66922 @@ -539,42 +566,93 @@ describe('Dashboard', () => { }); }); - describe('when the window resizes', () => { + describe('responds to window resizes', () => { + let promPanel; + let promGroup; + let panelToggle; + let chart; beforeEach(() => { mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); - jasmine.clock().install(); - }); - afterEach(() => { - jasmine.clock().uninstall(); - }); - - it('sets elWidth to page width when the sidebar is resized', done => { component = new DashboardComponent({ el: document.querySelector('.prometheus-graphs'), propsData: { ...propsData, hasMetrics: true, - showPanels: false, + showPanels: true, }, store, }); - expect(component.elWidth).toEqual(0); + setupComponentStore(component); - const pageLayoutEl = document.querySelector('.layout-page'); - pageLayoutEl.classList.add('page-with-icon-sidebar'); + return Vue.nextTick().then(() => { + promPanel = component.$el.querySelector('.prometheus-panel'); + promGroup = promPanel.querySelector('.prometheus-graph-group'); + panelToggle = promPanel.querySelector('.js-graph-group-toggle'); + chart = promGroup.querySelector('.position-relative svg'); + }); + }); - Vue.nextTick() - .then(() => { - jasmine.clock().tick(1000); - return Vue.nextTick(); - }) - .then(() => { - expect(component.elWidth).toEqual(pageLayoutEl.clientWidth); - done(); - }) - .catch(done.fail); + 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', () => { + let wrapper; + const findEditLink = () => wrapper.find('.js-edit-link'); + + beforeEach(done => { + mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); + + wrapper = shallowMount(DashboardComponent, { + localVue, + sync: false, + attachToDocument: true, + propsData: { ...propsData, hasMetrics: true }, + store, + }); + + 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(); + }); }); }); @@ -619,20 +697,6 @@ describe('Dashboard', () => { store, }); - component.$store.dispatch('monitoringDashboard/setFeatureFlags', { - prometheusEndpoint: false, - }); - - component.$store.commit( - `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, - environmentData, - ); - - component.$store.commit( - `monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, - singleGroupResponse, - ); - component.$store.commit( `monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, dashboardGitResponse, @@ -648,36 +712,4 @@ describe('Dashboard', () => { }); }); }); - - describe('when downloading metrics data as CSV', () => { - beforeEach(() => { - component = new DashboardComponent({ - propsData: { - ...propsData, - }, - store, - }); - store.commit( - `monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, - MonitoringMock.data, - ); - [mockGraphData] = component.$store.state.monitoringDashboard.groups[0].metrics; - }); - - describe('csvText', () => { - it('converts metrics data from json to csv', () => { - const header = `timestamp,${mockGraphData.y_label}`; - const data = mockGraphData.queries[0].result[0].values; - const firstRow = `${data[0][0]},${data[0][1]}`; - - expect(component.csvText(mockGraphData)).toMatch(`^${header}\r\n${firstRow}`); - }); - }); - - describe('downloadCsv', () => { - it('produces a link with a Blob', () => { - expect(component.downloadCsv(mockGraphData)).toContain(`blob:`); - }); - }); - }); }); diff --git a/spec/javascripts/monitoring/mock_data.js b/spec/javascripts/monitoring/mock_data.js index 17e7314e214cf7453f84fd160ae6f494d6a608f3..f9cc839bde653a3dd9dd4a3cda2639058c7941ab 100644 --- a/spec/javascripts/monitoring/mock_data.js +++ b/spec/javascripts/monitoring/mock_data.js @@ -1,943 +1,103 @@ -export const mockApiEndpoint = `${gl.TEST_HOST}/monitoring/mock`; +import { + anomalyMockGraphData as importedAnomalyMockGraphData, + metricsGroupsAPIResponse as importedMetricsGroupsAPIResponse, + environmentData as importedEnvironmentData, + dashboardGitResponse as importedDashboardGitResponse, +} from '../../frontend/monitoring/mock_data'; -export const mockProjectPath = '/frontend-fixtures/environments-project'; +export const anomalyMockGraphData = importedAnomalyMockGraphData; +export const metricsGroupsAPIResponse = importedMetricsGroupsAPIResponse; +export const environmentData = importedEnvironmentData; +export const dashboardGitResponse = importedDashboardGitResponse; -export const metricsGroupsAPIResponse = { - success: true, - data: [ - { - group: 'Kubernetes', - priority: 1, - metrics: [ - { - id: 5, - title: 'Memory usage', - weight: 1, - queries: [ - { - query_range: 'avg(container_memory_usage_bytes{%{environment_filter}}) / 2^20', - label: 'Memory', - unit: 'MiB', - result: [ - { - metric: {}, - values: [ - [1495700554.925, '8.0390625'], - [1495700614.925, '8.0390625'], - [1495700674.925, '8.0390625'], - [1495700734.925, '8.0390625'], - [1495700794.925, '8.0390625'], - [1495700854.925, '8.0390625'], - [1495700914.925, '8.0390625'], - [1495700974.925, '8.0390625'], - [1495701034.925, '8.0390625'], - [1495701094.925, '8.0390625'], - [1495701154.925, '8.0390625'], - [1495701214.925, '8.0390625'], - [1495701274.925, '8.0390625'], - [1495701334.925, '8.0390625'], - [1495701394.925, '8.0390625'], - [1495701454.925, '8.0390625'], - [1495701514.925, '8.0390625'], - [1495701574.925, '8.0390625'], - [1495701634.925, '8.0390625'], - [1495701694.925, '8.0390625'], - [1495701754.925, '8.0390625'], - [1495701814.925, '8.0390625'], - [1495701874.925, '8.0390625'], - [1495701934.925, '8.0390625'], - [1495701994.925, '8.0390625'], - [1495702054.925, '8.0390625'], - [1495702114.925, '8.0390625'], - [1495702174.925, '8.0390625'], - [1495702234.925, '8.0390625'], - [1495702294.925, '8.0390625'], - [1495702354.925, '8.0390625'], - [1495702414.925, '8.0390625'], - [1495702474.925, '8.0390625'], - [1495702534.925, '8.0390625'], - [1495702594.925, '8.0390625'], - [1495702654.925, '8.0390625'], - [1495702714.925, '8.0390625'], - [1495702774.925, '8.0390625'], - [1495702834.925, '8.0390625'], - [1495702894.925, '8.0390625'], - [1495702954.925, '8.0390625'], - [1495703014.925, '8.0390625'], - [1495703074.925, '8.0390625'], - [1495703134.925, '8.0390625'], - [1495703194.925, '8.0390625'], - [1495703254.925, '8.03515625'], - [1495703314.925, '8.03515625'], - [1495703374.925, '8.03515625'], - [1495703434.925, '8.03515625'], - [1495703494.925, '8.03515625'], - [1495703554.925, '8.03515625'], - [1495703614.925, '8.03515625'], - [1495703674.925, '8.03515625'], - [1495703734.925, '8.03515625'], - [1495703794.925, '8.03515625'], - [1495703854.925, '8.03515625'], - [1495703914.925, '8.03515625'], - [1495703974.925, '8.03515625'], - [1495704034.925, '8.03515625'], - [1495704094.925, '8.03515625'], - [1495704154.925, '8.03515625'], - [1495704214.925, '7.9296875'], - [1495704274.925, '7.9296875'], - [1495704334.925, '7.9296875'], - [1495704394.925, '7.9296875'], - [1495704454.925, '7.9296875'], - [1495704514.925, '7.9296875'], - [1495704574.925, '7.9296875'], - [1495704634.925, '7.9296875'], - [1495704694.925, '7.9296875'], - [1495704754.925, '7.9296875'], - [1495704814.925, '7.9296875'], - [1495704874.925, '7.9296875'], - [1495704934.925, '7.9296875'], - [1495704994.925, '7.9296875'], - [1495705054.925, '7.9296875'], - [1495705114.925, '7.9296875'], - [1495705174.925, '7.9296875'], - [1495705234.925, '7.9296875'], - [1495705294.925, '7.9296875'], - [1495705354.925, '7.9296875'], - [1495705414.925, '7.9296875'], - [1495705474.925, '7.9296875'], - [1495705534.925, '7.9296875'], - [1495705594.925, '7.9296875'], - [1495705654.925, '7.9296875'], - [1495705714.925, '7.9296875'], - [1495705774.925, '7.9296875'], - [1495705834.925, '7.9296875'], - [1495705894.925, '7.9296875'], - [1495705954.925, '7.9296875'], - [1495706014.925, '7.9296875'], - [1495706074.925, '7.9296875'], - [1495706134.925, '7.9296875'], - [1495706194.925, '7.9296875'], - [1495706254.925, '7.9296875'], - [1495706314.925, '7.9296875'], - [1495706374.925, '7.9296875'], - [1495706434.925, '7.9296875'], - [1495706494.925, '7.9296875'], - [1495706554.925, '7.9296875'], - [1495706614.925, '7.9296875'], - [1495706674.925, '7.9296875'], - [1495706734.925, '7.9296875'], - [1495706794.925, '7.9296875'], - [1495706854.925, '7.9296875'], - [1495706914.925, '7.9296875'], - [1495706974.925, '7.9296875'], - [1495707034.925, '7.9296875'], - [1495707094.925, '7.9296875'], - [1495707154.925, '7.9296875'], - [1495707214.925, '7.9296875'], - [1495707274.925, '7.9296875'], - [1495707334.925, '7.9296875'], - [1495707394.925, '7.9296875'], - [1495707454.925, '7.9296875'], - [1495707514.925, '7.9296875'], - [1495707574.925, '7.9296875'], - [1495707634.925, '7.9296875'], - [1495707694.925, '7.9296875'], - [1495707754.925, '7.9296875'], - [1495707814.925, '7.9296875'], - [1495707874.925, '7.9296875'], - [1495707934.925, '7.9296875'], - [1495707994.925, '7.9296875'], - [1495708054.925, '7.9296875'], - [1495708114.925, '7.9296875'], - [1495708174.925, '7.9296875'], - [1495708234.925, '7.9296875'], - [1495708294.925, '7.9296875'], - [1495708354.925, '7.9296875'], - [1495708414.925, '7.9296875'], - [1495708474.925, '7.9296875'], - [1495708534.925, '7.9296875'], - [1495708594.925, '7.9296875'], - [1495708654.925, '7.9296875'], - [1495708714.925, '7.9296875'], - [1495708774.925, '7.9296875'], - [1495708834.925, '7.9296875'], - [1495708894.925, '7.9296875'], - [1495708954.925, '7.8984375'], - [1495709014.925, '7.8984375'], - [1495709074.925, '7.8984375'], - [1495709134.925, '7.8984375'], - [1495709194.925, '7.8984375'], - [1495709254.925, '7.89453125'], - [1495709314.925, '7.89453125'], - [1495709374.925, '7.89453125'], - [1495709434.925, '7.89453125'], - [1495709494.925, '7.89453125'], - [1495709554.925, '7.89453125'], - [1495709614.925, '7.89453125'], - [1495709674.925, '7.89453125'], - [1495709734.925, '7.89453125'], - [1495709794.925, '7.89453125'], - [1495709854.925, '7.89453125'], - [1495709914.925, '7.89453125'], - [1495709974.925, '7.89453125'], - [1495710034.925, '7.89453125'], - [1495710094.925, '7.89453125'], - [1495710154.925, '7.89453125'], - [1495710214.925, '7.89453125'], - [1495710274.925, '7.89453125'], - [1495710334.925, '7.89453125'], - [1495710394.925, '7.89453125'], - [1495710454.925, '7.89453125'], - [1495710514.925, '7.89453125'], - [1495710574.925, '7.89453125'], - [1495710634.925, '7.89453125'], - [1495710694.925, '7.89453125'], - [1495710754.925, '7.89453125'], - [1495710814.925, '7.89453125'], - [1495710874.925, '7.89453125'], - [1495710934.925, '7.89453125'], - [1495710994.925, '7.89453125'], - [1495711054.925, '7.89453125'], - [1495711114.925, '7.89453125'], - [1495711174.925, '7.8515625'], - [1495711234.925, '7.8515625'], - [1495711294.925, '7.8515625'], - [1495711354.925, '7.8515625'], - [1495711414.925, '7.8515625'], - [1495711474.925, '7.8515625'], - [1495711534.925, '7.8515625'], - [1495711594.925, '7.8515625'], - [1495711654.925, '7.8515625'], - [1495711714.925, '7.8515625'], - [1495711774.925, '7.8515625'], - [1495711834.925, '7.8515625'], - [1495711894.925, '7.8515625'], - [1495711954.925, '7.8515625'], - [1495712014.925, '7.8515625'], - [1495712074.925, '7.8515625'], - [1495712134.925, '7.8515625'], - [1495712194.925, '7.8515625'], - [1495712254.925, '7.8515625'], - [1495712314.925, '7.8515625'], - [1495712374.925, '7.8515625'], - [1495712434.925, '7.83203125'], - [1495712494.925, '7.83203125'], - [1495712554.925, '7.83203125'], - [1495712614.925, '7.83203125'], - [1495712674.925, '7.83203125'], - [1495712734.925, '7.83203125'], - [1495712794.925, '7.83203125'], - [1495712854.925, '7.83203125'], - [1495712914.925, '7.83203125'], - [1495712974.925, '7.83203125'], - [1495713034.925, '7.83203125'], - [1495713094.925, '7.83203125'], - [1495713154.925, '7.83203125'], - [1495713214.925, '7.83203125'], - [1495713274.925, '7.83203125'], - [1495713334.925, '7.83203125'], - [1495713394.925, '7.8125'], - [1495713454.925, '7.8125'], - [1495713514.925, '7.8125'], - [1495713574.925, '7.8125'], - [1495713634.925, '7.8125'], - [1495713694.925, '7.8125'], - [1495713754.925, '7.8125'], - [1495713814.925, '7.8125'], - [1495713874.925, '7.8125'], - [1495713934.925, '7.8125'], - [1495713994.925, '7.8125'], - [1495714054.925, '7.8125'], - [1495714114.925, '7.8125'], - [1495714174.925, '7.8125'], - [1495714234.925, '7.8125'], - [1495714294.925, '7.8125'], - [1495714354.925, '7.80859375'], - [1495714414.925, '7.80859375'], - [1495714474.925, '7.80859375'], - [1495714534.925, '7.80859375'], - [1495714594.925, '7.80859375'], - [1495714654.925, '7.80859375'], - [1495714714.925, '7.80859375'], - [1495714774.925, '7.80859375'], - [1495714834.925, '7.80859375'], - [1495714894.925, '7.80859375'], - [1495714954.925, '7.80859375'], - [1495715014.925, '7.80859375'], - [1495715074.925, '7.80859375'], - [1495715134.925, '7.80859375'], - [1495715194.925, '7.80859375'], - [1495715254.925, '7.80859375'], - [1495715314.925, '7.80859375'], - [1495715374.925, '7.80859375'], - [1495715434.925, '7.80859375'], - [1495715494.925, '7.80859375'], - [1495715554.925, '7.80859375'], - [1495715614.925, '7.80859375'], - [1495715674.925, '7.80859375'], - [1495715734.925, '7.80859375'], - [1495715794.925, '7.80859375'], - [1495715854.925, '7.80859375'], - [1495715914.925, '7.80078125'], - [1495715974.925, '7.80078125'], - [1495716034.925, '7.80078125'], - [1495716094.925, '7.80078125'], - [1495716154.925, '7.80078125'], - [1495716214.925, '7.796875'], - [1495716274.925, '7.796875'], - [1495716334.925, '7.796875'], - [1495716394.925, '7.796875'], - [1495716454.925, '7.796875'], - [1495716514.925, '7.796875'], - [1495716574.925, '7.796875'], - [1495716634.925, '7.796875'], - [1495716694.925, '7.796875'], - [1495716754.925, '7.796875'], - [1495716814.925, '7.796875'], - [1495716874.925, '7.79296875'], - [1495716934.925, '7.79296875'], - [1495716994.925, '7.79296875'], - [1495717054.925, '7.79296875'], - [1495717114.925, '7.79296875'], - [1495717174.925, '7.7890625'], - [1495717234.925, '7.7890625'], - [1495717294.925, '7.7890625'], - [1495717354.925, '7.7890625'], - [1495717414.925, '7.7890625'], - [1495717474.925, '7.7890625'], - [1495717534.925, '7.7890625'], - [1495717594.925, '7.7890625'], - [1495717654.925, '7.7890625'], - [1495717714.925, '7.7890625'], - [1495717774.925, '7.7890625'], - [1495717834.925, '7.77734375'], - [1495717894.925, '7.77734375'], - [1495717954.925, '7.77734375'], - [1495718014.925, '7.77734375'], - [1495718074.925, '7.77734375'], - [1495718134.925, '7.7421875'], - [1495718194.925, '7.7421875'], - [1495718254.925, '7.7421875'], - [1495718314.925, '7.7421875'], - ], - }, - ], - }, - ], - }, - { - id: 6, - title: 'CPU usage', - y_label: 'CPU', - weight: 1, - queries: [ - { - appearance: { - line: { - width: 2, - }, - }, - query_range: - 'avg(rate(container_cpu_usage_seconds_total{%{environment_filter}}[2m])) * 100', - label: 'Core Usage', - unit: 'Cores', - result: [ - { - metric: {}, - values: [ - [1495700554.925, '0.0010794445585559514'], - [1495700614.925, '0.003927214935433527'], - [1495700674.925, '0.0053045219047619975'], - [1495700734.925, '0.0048892095238097155'], - [1495700794.925, '0.005827140952381137'], - [1495700854.925, '0.00569846906219937'], - [1495700914.925, '0.004972616802849382'], - [1495700974.925, '0.005117509523809902'], - [1495701034.925, '0.00512389061919564'], - [1495701094.925, '0.005199100501890691'], - [1495701154.925, '0.005415746394885837'], - [1495701214.925, '0.005607682788146286'], - [1495701274.925, '0.005641300000000118'], - [1495701334.925, '0.0071166279368766495'], - [1495701394.925, '0.0063242138095234044'], - [1495701454.925, '0.005793314698235304'], - [1495701514.925, '0.00703934942237556'], - [1495701574.925, '0.006357007076123191'], - [1495701634.925, '0.003753167300126738'], - [1495701694.925, '0.005018469678430698'], - [1495701754.925, '0.0045217153371887'], - [1495701814.925, '0.006140104285714119'], - [1495701874.925, '0.004818684285714102'], - [1495701934.925, '0.005079509718955242'], - [1495701994.925, '0.005059981142498263'], - [1495702054.925, '0.005269098389538773'], - [1495702114.925, '0.005269954285714175'], - [1495702174.925, '0.014199241435795856'], - [1495702234.925, '0.01511936843111017'], - [1495702294.925, '0.0060933692920682875'], - [1495702354.925, '0.004945682380952493'], - [1495702414.925, '0.005641266666666565'], - [1495702474.925, '0.005223752857142996'], - [1495702534.925, '0.005743098505699831'], - [1495702594.925, '0.00538493380952391'], - [1495702654.925, '0.005507793883751339'], - [1495702714.925, '0.005666705714285466'], - [1495702774.925, '0.006231530000000112'], - [1495702834.925, '0.006570768635394899'], - [1495702894.925, '0.005551146666666895'], - [1495702954.925, '0.005602604737098058'], - [1495703014.925, '0.00613993580402159'], - [1495703074.925, '0.004770258764368832'], - [1495703134.925, '0.005512376671364914'], - [1495703194.925, '0.005254436666666674'], - [1495703254.925, '0.0050109839141320505'], - [1495703314.925, '0.0049478019256960016'], - [1495703374.925, '0.0037666860965123463'], - [1495703434.925, '0.004813526061656314'], - [1495703494.925, '0.005047748095238278'], - [1495703554.925, '0.00386494081008772'], - [1495703614.925, '0.004304037408111405'], - [1495703674.925, '0.004999466661587168'], - [1495703734.925, '0.004689140476190834'], - [1495703794.925, '0.004746126153582475'], - [1495703854.925, '0.004482706382572302'], - [1495703914.925, '0.004032808931864524'], - [1495703974.925, '0.005728319047618988'], - [1495704034.925, '0.004436139179627006'], - [1495704094.925, '0.004553455714285617'], - [1495704154.925, '0.003455244285714341'], - [1495704214.925, '0.004742244761904621'], - [1495704274.925, '0.005366978571428422'], - [1495704334.925, '0.004257954837665058'], - [1495704394.925, '0.005431603259831257'], - [1495704454.925, '0.0052009214498621986'], - [1495704514.925, '0.004317201904761618'], - [1495704574.925, '0.004307384285714157'], - [1495704634.925, '0.004789801146644822'], - [1495704694.925, '0.0051429795906706485'], - [1495704754.925, '0.005322495714285479'], - [1495704814.925, '0.004512809333244233'], - [1495704874.925, '0.004953843582568726'], - [1495704934.925, '0.005812690120858119'], - [1495704994.925, '0.004997024285714838'], - [1495705054.925, '0.005246216154439592'], - [1495705114.925, '0.0063494966618726795'], - [1495705174.925, '0.005306004342898225'], - [1495705234.925, '0.005081412857142978'], - [1495705294.925, '0.00511409523809522'], - [1495705354.925, '0.0047861001481192'], - [1495705414.925, '0.005107688228042962'], - [1495705474.925, '0.005271929582294012'], - [1495705534.925, '0.004453254502681249'], - [1495705594.925, '0.005799134293959226'], - [1495705654.925, '0.005340865929502478'], - [1495705714.925, '0.004911654761904942'], - [1495705774.925, '0.005888234873953261'], - [1495705834.925, '0.005565283333332954'], - [1495705894.925, '0.005522869047618869'], - [1495705954.925, '0.005177549737621646'], - [1495706014.925, '0.0053145810232096465'], - [1495706074.925, '0.004751095238095275'], - [1495706134.925, '0.006242077142856976'], - [1495706194.925, '0.00621034406957871'], - [1495706254.925, '0.006887592738978596'], - [1495706314.925, '0.006328128779726213'], - [1495706374.925, '0.007488363809523927'], - [1495706434.925, '0.006193758571428157'], - [1495706494.925, '0.0068798371839706935'], - [1495706554.925, '0.005757034340423128'], - [1495706614.925, '0.004571388497294698'], - [1495706674.925, '0.00620283044923395'], - [1495706734.925, '0.005607562380952455'], - [1495706794.925, '0.005506969933620308'], - [1495706854.925, '0.005621118095238131'], - [1495706914.925, '0.004876606098698849'], - [1495706974.925, '0.0047871205988517206'], - [1495707034.925, '0.00526405939458784'], - [1495707094.925, '0.005716323800605852'], - [1495707154.925, '0.005301459523809575'], - [1495707214.925, '0.0051613042857144905'], - [1495707274.925, '0.005384792857142714'], - [1495707334.925, '0.005259719047619222'], - [1495707394.925, '0.00584101142857182'], - [1495707454.925, '0.0060066121920326326'], - [1495707514.925, '0.006359978571428453'], - [1495707574.925, '0.006315876322151109'], - [1495707634.925, '0.005590012517198831'], - [1495707694.925, '0.005517419877137072'], - [1495707754.925, '0.006089813430348506'], - [1495707814.925, '0.00466754476190479'], - [1495707874.925, '0.006059954380517721'], - [1495707934.925, '0.005085657142856972'], - [1495707994.925, '0.005897665238095296'], - [1495708054.925, '0.0062282023199555885'], - [1495708114.925, '0.00526214553236979'], - [1495708174.925, '0.0044803300000000644'], - [1495708234.925, '0.005421443333333592'], - [1495708294.925, '0.005694326244512144'], - [1495708354.925, '0.005527721904761457'], - [1495708414.925, '0.005988819523809819'], - [1495708474.925, '0.005484704285714448'], - [1495708534.925, '0.005041123649230085'], - [1495708594.925, '0.005717767639612059'], - [1495708654.925, '0.005412954417342863'], - [1495708714.925, '0.005833343333333254'], - [1495708774.925, '0.005448135238094969'], - [1495708834.925, '0.005117341428571432'], - [1495708894.925, '0.005888345825277833'], - [1495708954.925, '0.005398543809524135'], - [1495709014.925, '0.005325611428571416'], - [1495709074.925, '0.005848668571428527'], - [1495709134.925, '0.005135003105145044'], - [1495709194.925, '0.0054551400000003'], - [1495709254.925, '0.005319472937322171'], - [1495709314.925, '0.00585677857142792'], - [1495709374.925, '0.0062146261904759215'], - [1495709434.925, '0.0067105060904182265'], - [1495709494.925, '0.005829691904762108'], - [1495709554.925, '0.005719280952381261'], - [1495709614.925, '0.005682603793416407'], - [1495709674.925, '0.0055272846277326934'], - [1495709734.925, '0.0057123680952386735'], - [1495709794.925, '0.00520597958075818'], - [1495709854.925, '0.005584358957263837'], - [1495709914.925, '0.005601104275197466'], - [1495709974.925, '0.005991657142857066'], - [1495710034.925, '0.00553722238095218'], - [1495710094.925, '0.005127883122696293'], - [1495710154.925, '0.005498111927534584'], - [1495710214.925, '0.005609934069084202'], - [1495710274.925, '0.00459206285714307'], - [1495710334.925, '0.0047910828571428084'], - [1495710394.925, '0.0056014671288845685'], - [1495710454.925, '0.005686936791078528'], - [1495710514.925, '0.00444480476190448'], - [1495710574.925, '0.005780394696738921'], - [1495710634.925, '0.0053107227550210365'], - [1495710694.925, '0.005096031495761817'], - [1495710754.925, '0.005451377979091524'], - [1495710814.925, '0.005328136666667083'], - [1495710874.925, '0.006020612857143043'], - [1495710934.925, '0.0061063585714285365'], - [1495710994.925, '0.006018346015752312'], - [1495711054.925, '0.005069130952381193'], - [1495711114.925, '0.005458406190476052'], - [1495711174.925, '0.00577219190476179'], - [1495711234.925, '0.005760814645658314'], - [1495711294.925, '0.005371875716579101'], - [1495711354.925, '0.0064232666666665834'], - [1495711414.925, '0.009369806836906667'], - [1495711474.925, '0.008956864761904692'], - [1495711534.925, '0.005266849368559271'], - [1495711594.925, '0.005335111364934262'], - [1495711654.925, '0.006461778319586945'], - [1495711714.925, '0.004687939890762393'], - [1495711774.925, '0.004438831245760684'], - [1495711834.925, '0.005142786666666613'], - [1495711894.925, '0.007257734212054963'], - [1495711954.925, '0.005621991904761494'], - [1495712014.925, '0.007868689999999862'], - [1495712074.925, '0.00910970215275738'], - [1495712134.925, '0.006151004285714278'], - [1495712194.925, '0.005447120924961522'], - [1495712254.925, '0.005150705153929503'], - [1495712314.925, '0.006358108714969314'], - [1495712374.925, '0.0057725354795696475'], - [1495712434.925, '0.005232139047619015'], - [1495712494.925, '0.004932809617949037'], - [1495712554.925, '0.004511607508499662'], - [1495712614.925, '0.00440487701522666'], - [1495712674.925, '0.005479113333333174'], - [1495712734.925, '0.004726317619047547'], - [1495712794.925, '0.005582041102958029'], - [1495712854.925, '0.006381481216082099'], - [1495712914.925, '0.005474260014095208'], - [1495712974.925, '0.00567597142857188'], - [1495713034.925, '0.0064741233333332985'], - [1495713094.925, '0.005467475714285271'], - [1495713154.925, '0.004868648393824457'], - [1495713214.925, '0.005254923286444893'], - [1495713274.925, '0.005599217150312865'], - [1495713334.925, '0.005105413720618919'], - [1495713394.925, '0.007246073333333279'], - [1495713454.925, '0.005990312380952272'], - [1495713514.925, '0.005594601853351101'], - [1495713574.925, '0.004739258673727054'], - [1495713634.925, '0.003932121428571783'], - [1495713694.925, '0.005018188268459395'], - [1495713754.925, '0.004538238095237985'], - [1495713814.925, '0.00561816643265435'], - [1495713874.925, '0.0063132584495033586'], - [1495713934.925, '0.00442385238095213'], - [1495713994.925, '0.004181795887658453'], - [1495714054.925, '0.004437759047619037'], - [1495714114.925, '0.006421748157178241'], - [1495714174.925, '0.006525143809523842'], - [1495714234.925, '0.004715904935144247'], - [1495714294.925, '0.005966040152763461'], - [1495714354.925, '0.005614535466921674'], - [1495714414.925, '0.004934375119415906'], - [1495714474.925, '0.0054122933333327385'], - [1495714534.925, '0.004926540699612279'], - [1495714594.925, '0.006124649517134237'], - [1495714654.925, '0.004629427092013995'], - [1495714714.925, '0.005117951257607005'], - [1495714774.925, '0.004868774512685422'], - [1495714834.925, '0.005310093333333399'], - [1495714894.925, '0.0054907752286127345'], - [1495714954.925, '0.004597678117351089'], - [1495715014.925, '0.0059622552380952'], - [1495715074.925, '0.005352457072655368'], - [1495715134.925, '0.005491630952381143'], - [1495715194.925, '0.006391770078379791'], - [1495715254.925, '0.005933472857142518'], - [1495715314.925, '0.005301314285714163'], - [1495715374.925, '0.0058352959724814165'], - [1495715434.925, '0.006154755147867044'], - [1495715494.925, '0.009391935637482038'], - [1495715554.925, '0.007846462857142592'], - [1495715614.925, '0.00477608215316353'], - [1495715674.925, '0.006132865238094998'], - [1495715734.925, '0.006159762457649516'], - [1495715794.925, '0.005957307073265968'], - [1495715854.925, '0.006652319091792501'], - [1495715914.925, '0.005493557402895287'], - [1495715974.925, '0.0058652434829145166'], - [1495716034.925, '0.005627400430468021'], - [1495716094.925, '0.006240656190475609'], - [1495716154.925, '0.006305997676168624'], - [1495716214.925, '0.005388057732783248'], - [1495716274.925, '0.0052814916048421244'], - [1495716334.925, '0.00699498614272497'], - [1495716394.925, '0.00627768693035141'], - [1495716454.925, '0.0042411487048161145'], - [1495716514.925, '0.005348647473627653'], - [1495716574.925, '0.0047176657142853975'], - [1495716634.925, '0.004437898571428686'], - [1495716694.925, '0.004923527366927261'], - [1495716754.925, '0.005131935066048421'], - [1495716814.925, '0.005046949523809611'], - [1495716874.925, '0.00547184095238092'], - [1495716934.925, '0.005224140016380444'], - [1495716994.925, '0.005297991171665292'], - [1495717054.925, '0.005492965995623498'], - [1495717114.925, '0.005754660000000403'], - [1495717174.925, '0.005949557138639285'], - [1495717234.925, '0.006091816112534666'], - [1495717294.925, '0.005554210080192063'], - [1495717354.925, '0.006411504395279871'], - [1495717414.925, '0.006319643996609606'], - [1495717474.925, '0.005539174405717675'], - [1495717534.925, '0.0053157078842772255'], - [1495717594.925, '0.005247480952381066'], - [1495717654.925, '0.004820141620396252'], - [1495717714.925, '0.005906173868322844'], - [1495717774.925, '0.006173117219570961'], - [1495717834.925, '0.005963340952380661'], - [1495717894.925, '0.005698976627681527'], - [1495717954.925, '0.004751279096346378'], - [1495718014.925, '0.005733142379359711'], - [1495718074.925, '0.004831689010348035'], - [1495718134.925, '0.005188370476191092'], - [1495718194.925, '0.004793227554547938'], - [1495718254.925, '0.003997442857142731'], - [1495718314.925, '0.004386040132951264'], - ], - }, - ], - }, - ], - }, - ], - }, +export const mockApiEndpoint = `${gl.TEST_HOST}/monitoring/mock`; + +export const mockedQueryResultPayload = { + metricId: '17_system_metrics_kubernetes_container_memory_average', + result: [ { - group: 'NGINX', - priority: 2, - metrics: [ - { - id: 100, - title: 'Http Error Rate', - weight: 100, - queries: [ - { - query_range: - 'sum(rate(nginx_upstream_responses_total{status_code="5xx", upstream=~"nginx-test-8691397-production-.*"}[2m])) / sum(rate(nginx_upstream_responses_total{upstream=~"nginx-test-8691397-production-.*"}[2m])) * 100', - label: '5xx errors', - unit: '%', - result: [ - { - metric: {}, - values: [ - [1495700554.925, NaN], - [1495700614.925, NaN], - [1495700674.925, NaN], - [1495700734.925, NaN], - [1495700794.925, NaN], - [1495700854.925, NaN], - [1495700914.925, NaN], - ], - }, - ], - }, - ], - }, + metric: {}, + values: [ + [1563272065.589, '10.396484375'], + [1563272125.589, '10.333984375'], + [1563272185.589, '10.333984375'], + [1563272245.589, '10.333984375'], + [1563272305.589, '10.333984375'], + [1563272365.589, '10.333984375'], + [1563272425.589, '10.38671875'], + [1563272485.589, '10.333984375'], + [1563272545.589, '10.333984375'], + [1563272605.589, '10.333984375'], + [1563272665.589, '10.333984375'], + [1563272725.589, '10.333984375'], + [1563272785.589, '10.396484375'], + [1563272845.589, '10.333984375'], + [1563272905.589, '10.333984375'], + [1563272965.589, '10.3984375'], + [1563273025.589, '10.337890625'], + [1563273085.589, '10.34765625'], + [1563273145.589, '10.337890625'], + [1563273205.589, '10.337890625'], + [1563273265.589, '10.337890625'], + [1563273325.589, '10.337890625'], + [1563273385.589, '10.337890625'], + [1563273445.589, '10.337890625'], + [1563273505.589, '10.337890625'], + [1563273565.589, '10.337890625'], + [1563273625.589, '10.337890625'], + [1563273685.589, '10.337890625'], + [1563273745.589, '10.337890625'], + [1563273805.589, '10.337890625'], + [1563273865.589, '10.390625'], + [1563273925.589, '10.390625'], ], }, ], - last_update: '2017-05-25T13:18:34.949Z', }; -export const singleGroupResponse = [ - { - group: 'System metrics (Kubernetes)', - priority: 5, - metrics: [ - { - title: 'Memory Usage (Total)', - weight: 0, - y_label: 'Total Memory Used', - queries: [ - { - query_range: - 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^production-(.*)",namespace="autodevops-deploy-33"}) by (job)) without (job) /1024/1024/1024', - unit: 'GB', - label: 'Total', - result: [ - { - metric: {}, - values: [ - [1558453960.079, '0.0357666015625'], - [1558454020.079, '0.035675048828125'], - [1558454080.079, '0.035152435302734375'], - [1558454140.079, '0.035221099853515625'], - [1558454200.079, '0.0352325439453125'], - [1558454260.079, '0.03479766845703125'], - [1558454320.079, '0.034793853759765625'], - [1558454380.079, '0.034931182861328125'], - [1558454440.079, '0.034816741943359375'], - [1558454500.079, '0.034816741943359375'], - [1558454560.079, '0.034816741943359375'], - ], - }, - ], - }, - ], - id: 15, - }, - ], - }, -]; - -export default metricsGroupsAPIResponse; - -export const deploymentData = [ - { - id: 111, - iid: 3, - sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', - commitUrl: - 'http://test.host/frontend-fixtures/environments-project/commit/f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', - ref: { - name: 'master', - }, - created_at: '2017-05-31T21:23:37.881Z', - tag: false, - tagUrl: 'http://test.host/frontend-fixtures/environments-project/tags/false', - 'last?': true, - }, - { - id: 110, - iid: 2, - sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', - commitUrl: - 'http://test.host/frontend-fixtures/environments-project/commit/f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', - ref: { - name: 'master', - }, - created_at: '2017-05-30T20:08:04.629Z', - tag: false, - tagUrl: 'http://test.host/frontend-fixtures/environments-project/tags/false', - 'last?': false, - }, - { - id: 109, - iid: 1, - sha: '6511e58faafaa7ad2228990ec57f19d66f7db7c2', - commitUrl: - 'http://test.host/frontend-fixtures/environments-project/commit/6511e58faafaa7ad2228990ec57f19d66f7db7c2', - ref: { - name: 'update2-readme', - }, - created_at: '2017-05-30T17:42:38.409Z', - tag: false, - tagUrl: 'http://test.host/frontend-fixtures/environments-project/tags/false', - 'last?': false, - }, -]; - -export const statePaths = { - settingsPath: '/root/hello-prometheus/services/prometheus/edit', - clustersPath: '/root/hello-prometheus/clusters', - documentationPath: '/help/administration/monitoring/prometheus/index.md', -}; - -export const queryWithoutData = { - title: 'HTTP Error rate', - weight: 10, - y_label: 'Http Error Rate', - queries: [ +export const mockedQueryResultPayloadCoresTotal = { + metricId: '13_system_metrics_kubernetes_container_cores_total', + result: [ { - query_range: - 'sum(rate(nginx_upstream_responses_total{status_code="5xx", upstream=~"nginx-test-8691397-production-.*"}[2m])) / sum(rate(nginx_upstream_responses_total{upstream=~"nginx-test-8691397-production-.*"}[2m])) * 100', - label: '5xx errors', - unit: '%', - result: [], + metric: {}, + values: [ + [1563272065.589, '9.396484375'], + [1563272125.589, '9.333984375'], + [1563272185.589, '9.333984375'], + [1563272245.589, '9.333984375'], + [1563272305.589, '9.333984375'], + [1563272365.589, '9.333984375'], + [1563272425.589, '9.38671875'], + [1563272485.589, '9.333984375'], + [1563272545.589, '9.333984375'], + [1563272605.589, '9.333984375'], + [1563272665.589, '9.333984375'], + [1563272725.589, '9.333984375'], + [1563272785.589, '9.396484375'], + [1563272845.589, '9.333984375'], + [1563272905.589, '9.333984375'], + [1563272965.589, '9.3984375'], + [1563273025.589, '9.337890625'], + [1563273085.589, '9.34765625'], + [1563273145.589, '9.337890625'], + [1563273205.589, '9.337890625'], + [1563273265.589, '9.337890625'], + [1563273325.589, '9.337890625'], + [1563273385.589, '9.337890625'], + [1563273445.589, '9.337890625'], + [1563273505.589, '9.337890625'], + [1563273565.589, '9.337890625'], + [1563273625.589, '9.337890625'], + [1563273685.589, '9.337890625'], + [1563273745.589, '9.337890625'], + [1563273805.589, '9.337890625'], + [1563273865.589, '9.390625'], + [1563273925.589, '9.390625'], + ], }, ], }; -export function convertDatesMultipleSeries(multipleSeries) { - const convertedMultiple = multipleSeries; - multipleSeries.forEach((column, index) => { - let convertedResult = []; - convertedResult = column.queries[0].result.map(resultObj => { - const convertedMetrics = {}; - convertedMetrics.values = resultObj.values.map(val => ({ - time: new Date(val.time), - value: val.value, - })); - convertedMetrics.metric = resultObj.metric; - return convertedMetrics; - }); - convertedMultiple[index].queries[0].result = convertedResult; - }); - return convertedMultiple; -} - -export const environmentData = [ - { - id: 34, - name: 'production', - state: 'available', - external_url: 'http://root-autodevops-deploy.my-fake-domain.com', - environment_type: null, - stop_action: false, - metrics_path: '/root/hello-prometheus/environments/34/metrics', - environment_path: '/root/hello-prometheus/environments/34', - stop_path: '/root/hello-prometheus/environments/34/stop', - terminal_path: '/root/hello-prometheus/environments/34/terminal', - folder_path: '/root/hello-prometheus/environments/folders/production', - created_at: '2018-06-29T16:53:38.301Z', - updated_at: '2018-06-29T16:57:09.825Z', - last_deployment: { - id: 127, - }, - }, - { - id: 35, - name: 'review/noop-branch', - state: 'available', - external_url: 'http://root-autodevops-deploy-review-noop-branc-die93w.my-fake-domain.com', - environment_type: 'review', - stop_action: true, - metrics_path: '/root/hello-prometheus/environments/35/metrics', - environment_path: '/root/hello-prometheus/environments/35', - stop_path: '/root/hello-prometheus/environments/35/stop', - terminal_path: '/root/hello-prometheus/environments/35/terminal', - folder_path: '/root/hello-prometheus/environments/folders/review', - created_at: '2018-07-03T18:39:41.702Z', - updated_at: '2018-07-03T18:44:54.010Z', - last_deployment: { - id: 128, - }, - }, - { - id: 36, - name: 'no-deployment/noop-branch', - state: 'available', - created_at: '2018-07-04T18:39:41.702Z', - updated_at: '2018-07-04T18:44:54.010Z', - }, -]; - -export const metricsDashboardResponse = { - dashboard: { - dashboard: 'Environment metrics', - priority: 1, - panel_groups: [ - { - group: 'System metrics (Kubernetes)', - priority: 5, - panels: [ - { - title: 'Memory Usage (Total)', - type: 'area-chart', - y_label: 'Total Memory Used', - weight: 4, - metrics: [ - { - id: 'system_metrics_kubernetes_container_memory_total', - query_range: - 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) /1024/1024/1024', - label: 'Total', - unit: 'GB', - metric_id: 12, - prometheus_endpoint_path: 'http://test', - }, - ], - }, - { - 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: 'Memory Usage (Pod average)', - type: 'line-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}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024', - label: 'Pod average', - unit: 'MB', - metric_id: 14, - }, - ], - }, - ], - }, - ], - }, - status: 'success', -}; - -export const dashboardGitResponse = [ - { - path: 'config/prometheus/common_metrics.yml', - display_name: 'Common Metrics', - default: true, - }, - { - path: '.gitlab/dashboards/super.yml', - display_name: 'Custom Dashboard 1', - default: false, - }, -]; - export const graphDataPrometheusQuery = { title: 'Super Chart A2', type: 'single-stat', @@ -975,7 +135,7 @@ export const graphDataPrometheusQuery = { export const graphDataPrometheusQueryRange = { title: 'Super Chart A1', - type: 'area', + type: 'area-chart', weight: 2, metrics: [ { @@ -991,7 +151,7 @@ export const graphDataPrometheusQueryRange = { ], queries: [ { - metricId: null, + metricId: '10', id: 'metric_a1', metric_id: 2, query_range: @@ -1009,3 +169,82 @@ export const graphDataPrometheusQueryRange = { }, ], }; + +export const graphDataPrometheusQueryRangeMultiTrack = { + title: 'Super Chart A3', + type: 'heatmap', + weight: 3, + x_label: 'Status Code', + y_label: 'Time', + metrics: [], + queries: [ + { + metricId: '1', + id: 'response_metrics_nginx_ingress_throughput_status_code', + query_range: + 'sum(rate(nginx_upstream_responses_total{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[60m])) by (status_code)', + unit: 'req / sec', + label: 'Status Code', + metric_id: 1, + prometheus_endpoint_path: + '/root/rails_nodb/environments/3/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', + result: [ + { + metric: { status_code: '1xx' }, + values: [ + ['2019-08-30T15:00:00.000Z', 0], + ['2019-08-30T16:00:00.000Z', 2], + ['2019-08-30T17:00:00.000Z', 0], + ['2019-08-30T18:00:00.000Z', 0], + ['2019-08-30T19:00:00.000Z', 0], + ['2019-08-30T20:00:00.000Z', 3], + ], + }, + { + metric: { status_code: '2xx' }, + values: [ + ['2019-08-30T15:00:00.000Z', 1], + ['2019-08-30T16:00:00.000Z', 3], + ['2019-08-30T17:00:00.000Z', 6], + ['2019-08-30T18:00:00.000Z', 10], + ['2019-08-30T19:00:00.000Z', 8], + ['2019-08-30T20:00:00.000Z', 6], + ], + }, + { + metric: { status_code: '3xx' }, + values: [ + ['2019-08-30T15:00:00.000Z', 1], + ['2019-08-30T16:00:00.000Z', 2], + ['2019-08-30T17:00:00.000Z', 3], + ['2019-08-30T18:00:00.000Z', 3], + ['2019-08-30T19:00:00.000Z', 2], + ['2019-08-30T20:00:00.000Z', 1], + ], + }, + { + metric: { status_code: '4xx' }, + values: [ + ['2019-08-30T15:00:00.000Z', 2], + ['2019-08-30T16:00:00.000Z', 0], + ['2019-08-30T17:00:00.000Z', 0], + ['2019-08-30T18:00:00.000Z', 2], + ['2019-08-30T19:00:00.000Z', 0], + ['2019-08-30T20:00:00.000Z', 2], + ], + }, + { + metric: { status_code: '5xx' }, + values: [ + ['2019-08-30T15:00:00.000Z', 0], + ['2019-08-30T16:00:00.000Z', 1], + ['2019-08-30T17:00:00.000Z', 0], + ['2019-08-30T18:00:00.000Z', 0], + ['2019-08-30T19:00:00.000Z', 0], + ['2019-08-30T20:00:00.000Z', 2], + ], + }, + ], + }, + ], +}; diff --git a/spec/javascripts/monitoring/panel_type_spec.js b/spec/javascripts/monitoring/panel_type_spec.js deleted file mode 100644 index a2366e74d4372b860acb66f0b21e05b936de4586..0000000000000000000000000000000000000000 --- a/spec/javascripts/monitoring/panel_type_spec.js +++ /dev/null @@ -1,79 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import PanelType from '~/monitoring/components/panel_type.vue'; -import EmptyChart from '~/monitoring/components/charts/empty_chart.vue'; -import TimeSeriesChart from '~/monitoring/components/charts/time_series.vue'; -import { graphDataPrometheusQueryRange } from './mock_data'; -import { createStore } from '~/monitoring/stores'; - -describe('Panel Type component', () => { - let store; - let panelType; - const dashboardWidth = 100; - - describe('When no graphData is available', () => { - let glEmptyChart; - // Deep clone object before modifying - const graphDataNoResult = JSON.parse(JSON.stringify(graphDataPrometheusQueryRange)); - graphDataNoResult.queries[0].result = []; - - beforeEach(() => { - panelType = shallowMount(PanelType, { - propsData: { - clipboardText: 'dashboard_link', - dashboardWidth, - graphData: graphDataNoResult, - }, - }); - }); - - afterEach(() => { - panelType.destroy(); - }); - - describe('Empty Chart component', () => { - beforeEach(() => { - glEmptyChart = panelType.find(EmptyChart); - }); - - it('is a Vue instance', () => { - expect(glEmptyChart.isVueInstance()).toBe(true); - }); - - it('it receives a graph title', () => { - const props = glEmptyChart.props(); - - expect(props.graphTitle).toBe(panelType.vm.graphData.title); - }); - }); - }); - - describe('when Graph data is available', () => { - const exampleText = 'example_text'; - - beforeEach(() => { - store = createStore(); - panelType = shallowMount(PanelType, { - propsData: { - clipboardText: exampleText, - dashboardWidth, - graphData: graphDataPrometheusQueryRange, - }, - store, - }); - }); - - describe('Time Series Chart panel type', () => { - it('is rendered', () => { - expect(panelType.find(TimeSeriesChart).isVueInstance()).toBe(true); - expect(panelType.find(TimeSeriesChart).exists()).toBe(true); - }); - - it('sets clipboard text on the dropdown', () => { - const link = () => panelType.find('.js-chart-link'); - const clipboardText = () => link().element.dataset.clipboardText; - - expect(clipboardText()).toBe(exampleText); - }); - }); - }); -}); diff --git a/spec/javascripts/monitoring/shared/prometheus_header_spec.js b/spec/javascripts/monitoring/shared/prometheus_header_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..9f916a4dfbb43552c711d8940e920d93afdc3d5a --- /dev/null +++ b/spec/javascripts/monitoring/shared/prometheus_header_spec.js @@ -0,0 +1,26 @@ +import { shallowMount } from '@vue/test-utils'; +import PrometheusHeader from '~/monitoring/components/shared/prometheus_header.vue'; + +describe('Prometheus Header component', () => { + let prometheusHeader; + + beforeEach(() => { + prometheusHeader = shallowMount(PrometheusHeader, { + propsData: { + graphTitle: 'graph header', + }, + }); + }); + + afterEach(() => { + prometheusHeader.destroy(); + }); + + describe('Prometheus header component', () => { + it('should show a title', () => { + const title = prometheusHeader.vm.$el.querySelector('.js-graph-title').textContent; + + expect(title).toBe('graph header'); + }); + }); +}); diff --git a/spec/javascripts/monitoring/utils_spec.js b/spec/javascripts/monitoring/utils_spec.js index 512dd2a0eb3e88017e8da1f7909882e1ef5ac7a3..202b4ec8f2e6ad157fd813bf3f54ff806a1475ad 100644 --- a/spec/javascripts/monitoring/utils_spec.js +++ b/spec/javascripts/monitoring/utils_spec.js @@ -7,9 +7,14 @@ import { stringToISODate, ISODateToString, isValidDate, + graphDataValidatorForAnomalyValues, } from '~/monitoring/utils'; import { timeWindows, timeWindowsKeyNames } from '~/monitoring/constants'; -import { graphDataPrometheusQuery, graphDataPrometheusQueryRange } from './mock_data'; +import { + graphDataPrometheusQuery, + graphDataPrometheusQueryRange, + anomalyMockGraphData, +} from './mock_data'; describe('getTimeDiff', () => { function secondsBetween({ start, end }) { @@ -307,3 +312,34 @@ describe('isDateTimePickerInputValid', () => { }); }); }); + +describe('graphDataValidatorForAnomalyValues', () => { + let oneQuery; + let threeQueries; + let fourQueries; + beforeEach(() => { + oneQuery = graphDataPrometheusQuery; + threeQueries = anomalyMockGraphData; + + const queries = [...threeQueries.queries]; + queries.push(threeQueries.queries[0]); + fourQueries = { + ...anomalyMockGraphData, + queries, + }; + }); + /* + * Anomaly charts can accept results for exactly 3 queries, + */ + it('validates passes with the right query format', () => { + expect(graphDataValidatorForAnomalyValues(threeQueries)).toBe(true); + }); + + it('validation fails for wrong format, 1 metric', () => { + expect(graphDataValidatorForAnomalyValues(oneQuery)).toBe(false); + }); + + it('validation fails for wrong format, more than 3 metrics', () => { + expect(graphDataValidatorForAnomalyValues(fourQueries)).toBe(false); + }); +}); diff --git a/spec/javascripts/notes/components/comment_form_spec.js b/spec/javascripts/notes/components/comment_form_spec.js deleted file mode 100644 index 88c867469921a3912021e5824f785c2ff1293d17..0000000000000000000000000000000000000000 --- a/spec/javascripts/notes/components/comment_form_spec.js +++ /dev/null @@ -1,301 +0,0 @@ -import $ from 'jquery'; -import Vue from 'vue'; -import Autosize from 'autosize'; -import createStore from '~/notes/stores'; -import CommentForm from '~/notes/components/comment_form.vue'; -import * as constants from '~/notes/constants'; -import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data'; -import { keyboardDownEvent } from '../../issue_show/helpers'; - -describe('issue_comment_form component', () => { - let store; - let vm; - const Component = Vue.extend(CommentForm); - let mountComponent; - - beforeEach(() => { - store = createStore(); - mountComponent = (noteableType = 'issue') => - new Component({ - propsData: { - noteableType, - }, - store, - }).$mount(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('user is logged in', () => { - beforeEach(() => { - store.dispatch('setUserData', userDataMock); - store.dispatch('setNoteableData', noteableDataMock); - store.dispatch('setNotesData', notesDataMock); - - vm = mountComponent(); - }); - - it('should render user avatar with link', () => { - expect(vm.$el.querySelector('.timeline-icon .user-avatar-link').getAttribute('href')).toEqual( - userDataMock.path, - ); - }); - - describe('handleSave', () => { - it('should request to save note when note is entered', () => { - vm.note = 'hello world'; - spyOn(vm, 'saveNote').and.returnValue(new Promise(() => {})); - spyOn(vm, 'resizeTextarea'); - spyOn(vm, 'stopPolling'); - - vm.handleSave(); - - expect(vm.isSubmitting).toEqual(true); - expect(vm.note).toEqual(''); - expect(vm.saveNote).toHaveBeenCalled(); - expect(vm.stopPolling).toHaveBeenCalled(); - expect(vm.resizeTextarea).toHaveBeenCalled(); - }); - - it('should toggle issue state when no note', () => { - spyOn(vm, 'toggleIssueState'); - - vm.handleSave(); - - expect(vm.toggleIssueState).toHaveBeenCalled(); - }); - - it('should disable action button whilst submitting', done => { - const saveNotePromise = Promise.resolve(); - vm.note = 'hello world'; - spyOn(vm, 'saveNote').and.returnValue(saveNotePromise); - spyOn(vm, 'stopPolling'); - - const actionButton = vm.$el.querySelector('.js-action-button'); - - vm.handleSave(); - - Vue.nextTick() - .then(() => { - expect(actionButton.disabled).toBeTruthy(); - }) - .then(saveNotePromise) - .then(Vue.nextTick) - .then(() => { - expect(actionButton.disabled).toBeFalsy(); - }) - .then(done) - .catch(done.fail); - }); - }); - - describe('textarea', () => { - it('should render textarea with placeholder', () => { - expect( - vm.$el.querySelector('.js-main-target-form textarea').getAttribute('placeholder'), - ).toEqual('Write a comment or drag your files here…'); - }); - - it('should make textarea disabled while requesting', done => { - const $submitButton = $(vm.$el.querySelector('.js-comment-submit-button')); - vm.note = 'hello world'; - spyOn(vm, 'stopPolling'); - spyOn(vm, 'saveNote').and.returnValue(new Promise(() => {})); - - vm.$nextTick(() => { - // Wait for vm.note change triggered. It should enable $submitButton. - $submitButton.trigger('click'); - - vm.$nextTick(() => { - // Wait for vm.isSubmitting triggered. It should disable textarea. - expect(vm.$el.querySelector('.js-main-target-form textarea').disabled).toBeTruthy(); - done(); - }); - }); - }); - - it('should support quick actions', () => { - expect( - vm.$el - .querySelector('.js-main-target-form textarea') - .getAttribute('data-supports-quick-actions'), - ).toEqual('true'); - }); - - it('should link to markdown docs', () => { - const { markdownDocsPath } = notesDataMock; - - expect(vm.$el.querySelector(`a[href="${markdownDocsPath}"]`).textContent.trim()).toEqual( - 'Markdown', - ); - }); - - it('should link to quick actions docs', () => { - const { quickActionsDocsPath } = notesDataMock; - - expect( - vm.$el.querySelector(`a[href="${quickActionsDocsPath}"]`).textContent.trim(), - ).toEqual('quick actions'); - }); - - it('should resize textarea after note discarded', done => { - spyOn(Autosize, 'update'); - spyOn(vm, 'discard').and.callThrough(); - - vm.note = 'foo'; - vm.discard(); - - Vue.nextTick(() => { - expect(Autosize.update).toHaveBeenCalled(); - done(); - }); - }); - - describe('edit mode', () => { - it('should enter edit mode when arrow up is pressed', () => { - spyOn(vm, 'editCurrentUserLastNote').and.callThrough(); - vm.$el.querySelector('.js-main-target-form textarea').value = 'Foo'; - vm.$el - .querySelector('.js-main-target-form textarea') - .dispatchEvent(keyboardDownEvent(38, true)); - - expect(vm.editCurrentUserLastNote).toHaveBeenCalled(); - }); - - it('inits autosave', () => { - expect(vm.autosave).toBeDefined(); - expect(vm.autosave.key).toEqual(`autosave/Note/Issue/${noteableDataMock.id}`); - }); - }); - - describe('event enter', () => { - it('should save note when cmd+enter is pressed', () => { - spyOn(vm, 'handleSave').and.callThrough(); - vm.$el.querySelector('.js-main-target-form textarea').value = 'Foo'; - vm.$el - .querySelector('.js-main-target-form textarea') - .dispatchEvent(keyboardDownEvent(13, true)); - - expect(vm.handleSave).toHaveBeenCalled(); - }); - - it('should save note when ctrl+enter is pressed', () => { - spyOn(vm, 'handleSave').and.callThrough(); - vm.$el.querySelector('.js-main-target-form textarea').value = 'Foo'; - vm.$el - .querySelector('.js-main-target-form textarea') - .dispatchEvent(keyboardDownEvent(13, false, true)); - - expect(vm.handleSave).toHaveBeenCalled(); - }); - }); - }); - - describe('actions', () => { - it('should be possible to close the issue', () => { - expect(vm.$el.querySelector('.btn-comment-and-close').textContent.trim()).toEqual( - 'Close issue', - ); - }); - - it('should render comment button as disabled', () => { - expect(vm.$el.querySelector('.js-comment-submit-button').getAttribute('disabled')).toEqual( - 'disabled', - ); - }); - - it('should enable comment button if it has note', done => { - vm.note = 'Foo'; - Vue.nextTick(() => { - expect( - vm.$el.querySelector('.js-comment-submit-button').getAttribute('disabled'), - ).toEqual(null); - done(); - }); - }); - - it('should update buttons texts when it has note', done => { - vm.note = 'Foo'; - Vue.nextTick(() => { - expect(vm.$el.querySelector('.btn-comment-and-close').textContent.trim()).toEqual( - 'Comment & close issue', - ); - - done(); - }); - }); - - it('updates button text with noteable type', done => { - vm.noteableType = constants.MERGE_REQUEST_NOTEABLE_TYPE; - - Vue.nextTick(() => { - expect(vm.$el.querySelector('.btn-comment-and-close').textContent.trim()).toEqual( - 'Close merge request', - ); - done(); - }); - }); - - describe('when clicking close/reopen button', () => { - it('should disable button and show a loading spinner', done => { - const toggleStateButton = vm.$el.querySelector('.js-action-button'); - - toggleStateButton.click(); - Vue.nextTick(() => { - expect(toggleStateButton.disabled).toEqual(true); - expect(toggleStateButton.querySelector('.js-loading-button-icon')).not.toBeNull(); - - done(); - }); - }); - }); - - describe('when toggling state', () => { - it('should update MR count', done => { - spyOn(vm, 'closeIssue').and.returnValue(Promise.resolve()); - - const updateMrCountSpy = spyOnDependency(CommentForm, 'refreshUserMergeRequestCounts'); - vm.toggleIssueState(); - - Vue.nextTick(() => { - expect(updateMrCountSpy).toHaveBeenCalled(); - - done(); - }); - }); - }); - }); - - describe('issue is confidential', () => { - it('shows information warning', done => { - store.dispatch('setNoteableData', Object.assign(noteableDataMock, { confidential: true })); - Vue.nextTick(() => { - expect(vm.$el.querySelector('.confidential-issue-warning')).toBeDefined(); - done(); - }); - }); - }); - }); - - describe('user is not logged in', () => { - beforeEach(() => { - store.dispatch('setUserData', null); - store.dispatch('setNoteableData', loggedOutnoteableData); - store.dispatch('setNotesData', notesDataMock); - - vm = mountComponent(); - }); - - it('should render signed out widget', () => { - expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual( - 'Please register or sign in to reply', - ); - }); - - it('should not render submission form', () => { - expect(vm.$el.querySelector('textarea')).toEqual(null); - }); - }); -}); diff --git a/spec/javascripts/notes/components/noteable_discussion_spec.js b/spec/javascripts/notes/components/noteable_discussion_spec.js index ea5c57b8a7cc213d8b467c73fe0abb9dbdb62996..ea1ed3da1121d7d05ae6eb5def63695793ee387e 100644 --- a/spec/javascripts/notes/components/noteable_discussion_spec.js +++ b/spec/javascripts/notes/components/noteable_discussion_spec.js @@ -1,4 +1,4 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { mount, createLocalVue } from '@vue/test-utils'; import createStore from '~/notes/stores'; import noteableDiscussion from '~/notes/components/noteable_discussion.vue'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; @@ -23,7 +23,7 @@ describe('noteable_discussion component', () => { store.dispatch('setNotesData', notesDataMock); const localVue = createLocalVue(); - wrapper = shallowMount(noteableDiscussion, { + wrapper = mount(noteableDiscussion, { store, propsData: { discussion: discussionMock }, localVue, @@ -35,16 +35,6 @@ describe('noteable_discussion component', () => { wrapper.destroy(); }); - it('should render user avatar', () => { - const discussion = { ...discussionMock }; - discussion.diff_file = mockDiffFile; - discussion.diff_discussion = true; - - wrapper.setProps({ discussion, renderDiffFile: true }); - - expect(wrapper.find('.user-avatar-link').exists()).toBe(true); - }); - it('should not render thread header for non diff threads', () => { expect(wrapper.find('.discussion-header').exists()).toBe(false); }); @@ -134,105 +124,6 @@ describe('noteable_discussion component', () => { }); }); - describe('action text', () => { - const commitId = 'razupaltuff'; - const truncatedCommitId = commitId.substr(0, 8); - let commitElement; - - beforeEach(done => { - store.state.diffs = { - projectPath: 'something', - }; - - wrapper.setProps({ - discussion: { - ...discussionMock, - for_commit: true, - commit_id: commitId, - diff_discussion: true, - diff_file: { - ...mockDiffFile, - }, - }, - renderDiffFile: true, - }); - - wrapper.vm - .$nextTick() - .then(() => { - commitElement = wrapper.find('.commit-sha'); - }) - .then(done) - .catch(done.fail); - }); - - describe('for commit threads', () => { - it('should display a monospace started a thread on commit', () => { - expect(wrapper.text()).toContain(`started a thread on commit ${truncatedCommitId}`); - expect(commitElement.exists()).toBe(true); - expect(commitElement.text()).toContain(truncatedCommitId); - }); - }); - - describe('for diff thread with a commit id', () => { - it('should display started thread on commit header', done => { - wrapper.vm.discussion.for_commit = false; - - wrapper.vm.$nextTick(() => { - expect(wrapper.text()).toContain(`started a thread on commit ${truncatedCommitId}`); - - expect(commitElement).not.toBe(null); - - done(); - }); - }); - - it('should display outdated change on commit header', done => { - wrapper.vm.discussion.for_commit = false; - wrapper.vm.discussion.active = false; - - wrapper.vm.$nextTick(() => { - expect(wrapper.text()).toContain( - `started a thread on an outdated change in commit ${truncatedCommitId}`, - ); - - expect(commitElement).not.toBe(null); - - done(); - }); - }); - }); - - describe('for diff threads without a commit id', () => { - it('should show started a thread on the diff text', done => { - Object.assign(wrapper.vm.discussion, { - for_commit: false, - commit_id: null, - }); - - wrapper.vm.$nextTick(() => { - expect(wrapper.text()).toContain('started a thread on the diff'); - - done(); - }); - }); - - it('should show thread on older version text', done => { - Object.assign(wrapper.vm.discussion, { - for_commit: false, - commit_id: null, - active: false, - }); - - wrapper.vm.$nextTick(() => { - expect(wrapper.text()).toContain('started a thread on an old version of the diff'); - - done(); - }); - }); - }); - }); - describe('for resolved thread', () => { beforeEach(() => { const discussion = getJSONFixture(discussionWithTwoUnresolvedNotes)[0]; @@ -262,6 +153,7 @@ describe('noteable_discussion component', () => { })); wrapper.setProps({ discussion }); + wrapper.vm .$nextTick() .then(done) diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js index dc914ce835500f0ed775e1efb945ab67f5a7a1fb..89e4553092a9fd2d0503e2edc8b8e1291e7f501a 100644 --- a/spec/javascripts/notes/mock_data.js +++ b/spec/javascripts/notes/mock_data.js @@ -1,1255 +1 @@ -// Copied to ee/spec/frontend/notes/mock_data.js - -export const notesDataMock = { - discussionsPath: '/gitlab-org/gitlab-foss/issues/26/discussions.json', - lastFetchedAt: 1501862675, - markdownDocsPath: '/help/user/markdown', - newSessionPath: '/users/sign_in?redirect_to_referer=yes', - notesPath: '/gitlab-org/gitlab-foss/noteable/issue/98/notes', - quickActionsDocsPath: '/help/user/project/quick_actions', - registerPath: '/users/sign_in?redirect_to_referer=yes#register-pane', - prerenderedNotesCount: 1, - closePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=close', - reopenPath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=reopen', - canAwardEmoji: true, -}; - -export const userDataMock = { - avatar_url: 'mock_path', - id: 1, - name: 'Root', - path: '/root', - state: 'active', - username: 'root', -}; - -export const noteableDataMock = { - assignees: [], - author_id: 1, - branch_name: null, - confidential: false, - create_note_path: '/gitlab-org/gitlab-foss/notes?target_id=98&target_type=issue', - created_at: '2017-02-07T10:11:18.395Z', - current_user: { - can_create_note: true, - can_update: true, - can_award_emoji: true, - }, - description: '', - due_date: null, - human_time_estimate: null, - human_total_time_spent: null, - id: 98, - iid: 26, - labels: [], - lock_version: null, - milestone: null, - milestone_id: null, - moved_to_id: null, - preview_note_path: '/gitlab-org/gitlab-foss/preview_markdown?target_id=98&target_type=Issue', - project_id: 2, - state: 'opened', - time_estimate: 0, - title: '14', - total_time_spent: 0, - noteable_note_url: '/group/project/merge_requests/1#note_1', - updated_at: '2017-08-04T09:53:01.226Z', - updated_by_id: 1, - web_url: '/gitlab-org/gitlab-foss/issues/26', - noteableType: 'issue', -}; - -export const lastFetchedAt = '1501862675'; - -export const individualNote = { - expanded: true, - id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', - individual_note: true, - notes: [ - { - id: '1390', - attachment: { - url: null, - filename: null, - image: false, - }, - author: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatar_url: 'test', - path: '/root', - }, - created_at: '2017-08-01T17: 09: 33.762Z', - updated_at: '2017-08-01T17: 09: 33.762Z', - system: false, - noteable_id: 98, - noteable_type: 'Issue', - type: null, - human_access: 'Owner', - note: 'sdfdsaf', - note_html: "<p dir='auto'>sdfdsaf</p>", - current_user: { - can_edit: true, - can_award_emoji: true, - }, - discussion_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', - emoji_awardable: true, - award_emoji: [ - { name: 'baseball', user: { id: 1, name: 'Root', username: 'root' } }, - { name: 'art', user: { id: 1, name: 'Root', username: 'root' } }, - ], - toggle_award_path: '/gitlab-org/gitlab-foss/notes/1390/toggle_award_emoji', - noteable_note_url: '/group/project/merge_requests/1#note_1', - note_url: '/group/project/merge_requests/1#note_1', - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390&user_id=1', - path: '/gitlab-org/gitlab-foss/notes/1390', - }, - ], - reply_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', -}; - -export const note = { - id: '546', - attachment: { - url: null, - filename: null, - image: false, - }, - author: { - id: 1, - name: 'Administrator', - username: 'root', - state: 'active', - avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - path: '/root', - }, - created_at: '2017-08-10T15:24:03.087Z', - updated_at: '2017-08-10T15:24:03.087Z', - system: false, - noteable_id: 67, - noteable_type: 'Issue', - noteable_iid: 7, - type: null, - human_access: 'Owner', - note: 'Vel id placeat reprehenderit sit numquam.', - note_html: '<p dir="auto">Vel id placeat reprehenderit sit numquam.</p>', - current_user: { - can_edit: true, - can_award_emoji: true, - }, - discussion_id: 'd3842a451b7f3d9a5dfce329515127b2d29a4cd0', - emoji_awardable: true, - award_emoji: [ - { - name: 'baseball', - user: { - id: 1, - name: 'Administrator', - username: 'root', - }, - }, - { - name: 'bath_tone3', - user: { - id: 1, - name: 'Administrator', - username: 'root', - }, - }, - ], - toggle_award_path: '/gitlab-org/gitlab-foss/notes/546/toggle_award_emoji', - note_url: '/group/project/merge_requests/1#note_1', - noteable_note_url: '/group/project/merge_requests/1#note_1', - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_546&user_id=1', - path: '/gitlab-org/gitlab-foss/notes/546', -}; - -export const discussionMock = { - id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', - reply_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', - expanded: true, - notes: [ - { - id: '1395', - attachment: { - url: null, - filename: null, - image: false, - }, - author: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatar_url: null, - path: '/root', - }, - created_at: '2017-08-02T10:51:58.559Z', - updated_at: '2017-08-02T10:51:58.559Z', - system: false, - noteable_id: 98, - noteable_type: 'Issue', - type: 'DiscussionNote', - human_access: 'Owner', - note: 'THIS IS A DICUSSSION!', - note_html: "<p dir='auto'>THIS IS A DICUSSSION!</p>", - current_user: { - can_edit: true, - can_award_emoji: true, - can_resolve: true, - }, - discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', - emoji_awardable: true, - award_emoji: [], - noteable_note_url: '/group/project/merge_requests/1#note_1', - toggle_award_path: '/gitlab-org/gitlab-foss/notes/1395/toggle_award_emoji', - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1395&user_id=1', - path: '/gitlab-org/gitlab-foss/notes/1395', - }, - { - id: '1396', - attachment: { - url: null, - filename: null, - image: false, - }, - author: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatar_url: null, - path: '/root', - }, - created_at: '2017-08-02T10:56:50.980Z', - updated_at: '2017-08-03T14:19:35.691Z', - system: false, - noteable_id: 98, - noteable_type: 'Issue', - type: 'DiscussionNote', - human_access: 'Owner', - note: 'sadfasdsdgdsf', - note_html: "<p dir='auto'>sadfasdsdgdsf</p>", - last_edited_at: '2017-08-03T14:19:35.691Z', - last_edited_by: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatar_url: null, - path: '/root', - }, - current_user: { - can_edit: true, - can_award_emoji: true, - can_resolve: true, - }, - discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', - emoji_awardable: true, - award_emoji: [], - toggle_award_path: '/gitlab-org/gitlab-foss/notes/1396/toggle_award_emoji', - noteable_note_url: '/group/project/merge_requests/1#note_1', - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1396&user_id=1', - path: '/gitlab-org/gitlab-foss/notes/1396', - }, - { - id: '1437', - attachment: { - url: null, - filename: null, - image: false, - }, - author: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatar_url: null, - path: '/root', - }, - created_at: '2017-08-03T18:11:18.780Z', - updated_at: '2017-08-04T09:52:31.062Z', - system: false, - noteable_id: 98, - noteable_type: 'Issue', - type: 'DiscussionNote', - human_access: 'Owner', - note: 'adsfasf Should disappear', - note_html: "<p dir='auto'>adsfasf Should disappear</p>", - last_edited_at: '2017-08-04T09:52:31.062Z', - last_edited_by: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatar_url: null, - path: '/root', - }, - current_user: { - can_edit: true, - can_award_emoji: true, - can_resolve: true, - }, - discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', - emoji_awardable: true, - award_emoji: [], - noteable_note_url: '/group/project/merge_requests/1#note_1', - toggle_award_path: '/gitlab-org/gitlab-foss/notes/1437/toggle_award_emoji', - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1437&user_id=1', - path: '/gitlab-org/gitlab-foss/notes/1437', - }, - ], - individual_note: false, - resolvable: true, - active: true, -}; - -export const loggedOutnoteableData = { - id: '98', - iid: 26, - author_id: 1, - description: '', - lock_version: 1, - milestone_id: null, - state: 'opened', - title: 'asdsa', - updated_by_id: 1, - created_at: '2017-02-07T10:11:18.395Z', - updated_at: '2017-08-08T10:22:51.564Z', - time_estimate: 0, - total_time_spent: 0, - human_time_estimate: null, - human_total_time_spent: null, - milestone: null, - labels: [], - branch_name: null, - confidential: false, - assignees: [ - { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatar_url: null, - web_url: 'http://localhost:3000/root', - }, - ], - due_date: null, - moved_to_id: null, - project_id: 2, - web_url: '/gitlab-org/gitlab-foss/issues/26', - current_user: { - can_create_note: false, - can_update: false, - }, - noteable_note_url: '/group/project/merge_requests/1#note_1', - create_note_path: '/gitlab-org/gitlab-foss/notes?target_id=98&target_type=issue', - preview_note_path: '/gitlab-org/gitlab-foss/preview_markdown?target_id=98&target_type=Issue', -}; - -export const collapseNotesMock = [ - { - expanded: true, - id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', - individual_note: true, - notes: [ - { - id: '1390', - attachment: null, - author: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatar_url: 'test', - path: '/root', - }, - created_at: '2018-02-26T18:07:41.071Z', - updated_at: '2018-02-26T18:07:41.071Z', - system: true, - system_note_icon_name: 'pencil', - noteable_id: 98, - noteable_type: 'Issue', - type: null, - human_access: 'Owner', - note: 'changed the description', - note_html: '<p dir="auto">changed the description</p>', - current_user: { can_edit: false }, - discussion_id: 'b97fb7bda470a65b3e009377a9032edec0a4dd05', - emoji_awardable: false, - path: '/h5bp/html5-boilerplate/notes/1057', - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fh5bp%2Fhtml5-boilerplate%2Fissues%2F10%23note_1057&user_id=1', - }, - ], - }, - { - expanded: true, - id: 'ffde43f25984ad7f2b4275135e0e2846875336c0', - individual_note: true, - notes: [ - { - id: '1391', - attachment: null, - author: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatar_url: 'test', - path: '/root', - }, - created_at: '2018-02-26T18:13:24.071Z', - updated_at: '2018-02-26T18:13:24.071Z', - system: true, - system_note_icon_name: 'pencil', - noteable_id: 99, - noteable_type: 'Issue', - type: null, - human_access: 'Owner', - note: 'changed the description', - note_html: '<p dir="auto">changed the description</p>', - current_user: { can_edit: false }, - discussion_id: '3eb958b4d81dec207ec3537a2f3bd8b9f271bb34', - emoji_awardable: false, - path: '/h5bp/html5-boilerplate/notes/1057', - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fh5bp%2Fhtml5-boilerplate%2Fissues%2F10%23note_1057&user_id=1', - }, - ], - }, -]; - -export const INDIVIDUAL_NOTE_RESPONSE_MAP = { - GET: { - '/gitlab-org/gitlab-foss/issues/26/discussions.json': [ - { - id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', - reply_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', - expanded: true, - notes: [ - { - id: '1390', - attachment: { - url: null, - filename: null, - image: false, - }, - author: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatar_url: null, - path: '/root', - }, - created_at: '2017-08-01T17:09:33.762Z', - updated_at: '2017-08-01T17:09:33.762Z', - system: false, - noteable_id: 98, - noteable_type: 'Issue', - type: null, - human_access: 'Owner', - note: 'sdfdsaf', - note_html: '\u003cp dir="auto"\u003esdfdsaf\u003c/p\u003e', - current_user: { - can_edit: true, - can_award_emoji: true, - }, - discussion_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', - emoji_awardable: true, - award_emoji: [ - { - name: 'baseball', - user: { - id: 1, - name: 'Root', - username: 'root', - }, - }, - { - name: 'art', - user: { - id: 1, - name: 'Root', - username: 'root', - }, - }, - ], - noteable_note_url: '/group/project/merge_requests/1#note_1', - toggle_award_path: '/gitlab-org/gitlab-foss/notes/1390/toggle_award_emoji', - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390\u0026user_id=1', - path: '/gitlab-org/gitlab-foss/notes/1390', - }, - ], - individual_note: true, - }, - { - id: '70d5c92a4039a36c70100c6691c18c27e4b0a790', - reply_id: '70d5c92a4039a36c70100c6691c18c27e4b0a790', - expanded: true, - notes: [ - { - id: '1391', - attachment: { - url: null, - filename: null, - image: false, - }, - author: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatar_url: null, - path: '/root', - }, - created_at: '2017-08-02T10:51:38.685Z', - updated_at: '2017-08-02T10:51:38.685Z', - system: false, - noteable_id: 98, - noteable_type: 'Issue', - type: null, - human_access: 'Owner', - note: 'New note!', - note_html: '\u003cp dir="auto"\u003eNew note!\u003c/p\u003e', - current_user: { - can_edit: true, - can_award_emoji: true, - }, - discussion_id: '70d5c92a4039a36c70100c6691c18c27e4b0a790', - emoji_awardable: true, - award_emoji: [], - noteable_note_url: '/group/project/merge_requests/1#note_1', - toggle_award_path: '/gitlab-org/gitlab-foss/notes/1391/toggle_award_emoji', - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1391\u0026user_id=1', - path: '/gitlab-org/gitlab-foss/notes/1391', - }, - ], - individual_note: true, - }, - ], - '/gitlab-org/gitlab-foss/noteable/issue/98/notes': { - last_fetched_at: 1512900838, - notes: [], - }, - }, - PUT: { - '/gitlab-org/gitlab-foss/notes/1471': { - commands_changes: null, - valid: true, - id: '1471', - attachment: null, - author: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatar_url: null, - path: '/root', - }, - created_at: '2017-08-08T16:53:00.666Z', - updated_at: '2017-12-10T11:03:21.876Z', - system: false, - noteable_id: 124, - noteable_type: 'Issue', - noteable_iid: 29, - type: 'DiscussionNote', - human_access: 'Owner', - note: 'Adding a comment', - note_html: '\u003cp dir="auto"\u003eAdding a comment\u003c/p\u003e', - last_edited_at: '2017-12-10T11:03:21.876Z', - last_edited_by: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatar_url: null, - path: '/root', - }, - current_user: { - can_edit: true, - can_award_emoji: true, - }, - discussion_id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052', - emoji_awardable: true, - award_emoji: [], - noteable_note_url: '/group/project/merge_requests/1#note_1', - toggle_award_path: '/gitlab-org/gitlab-foss/notes/1471/toggle_award_emoji', - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1', - path: '/gitlab-org/gitlab-foss/notes/1471', - }, - }, -}; - -export const DISCUSSION_NOTE_RESPONSE_MAP = { - ...INDIVIDUAL_NOTE_RESPONSE_MAP, - GET: { - ...INDIVIDUAL_NOTE_RESPONSE_MAP.GET, - '/gitlab-org/gitlab-foss/issues/26/discussions.json': [ - { - id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052', - reply_id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052', - expanded: true, - notes: [ - { - id: '1471', - attachment: { - url: null, - filename: null, - image: false, - }, - author: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatar_url: null, - path: '/root', - }, - created_at: '2017-08-08T16:53:00.666Z', - updated_at: '2017-08-08T16:53:00.666Z', - system: false, - noteable_id: 124, - noteable_type: 'Issue', - noteable_iid: 29, - type: 'DiscussionNote', - human_access: 'Owner', - note: 'Adding a comment', - note_html: '\u003cp dir="auto"\u003eAdding a comment\u003c/p\u003e', - current_user: { - can_edit: true, - can_award_emoji: true, - }, - discussion_id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052', - emoji_awardable: true, - award_emoji: [], - toggle_award_path: '/gitlab-org/gitlab-foss/notes/1471/toggle_award_emoji', - noteable_note_url: '/group/project/merge_requests/1#note_1', - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1', - path: '/gitlab-org/gitlab-foss/notes/1471', - }, - ], - individual_note: false, - }, - ], - }, -}; - -export function getIndividualNoteResponse(config) { - return [200, INDIVIDUAL_NOTE_RESPONSE_MAP[config.method.toUpperCase()][config.url]]; -} - -export function getDiscussionNoteResponse(config) { - return [200, DISCUSSION_NOTE_RESPONSE_MAP[config.method.toUpperCase()][config.url]]; -} - -export const notesWithDescriptionChanges = [ - { - id: '39b271c2033e9ed43d8edb393702f65f7a830459', - reply_id: '39b271c2033e9ed43d8edb393702f65f7a830459', - expanded: true, - notes: [ - { - id: '901', - type: null, - attachment: null, - author: { - id: 1, - name: 'Administrator', - username: 'root', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - path: '/root', - }, - created_at: '2018-05-29T12:05:36.117Z', - updated_at: '2018-05-29T12:05:36.117Z', - system: false, - noteable_id: 182, - noteable_type: 'Issue', - resolvable: false, - noteable_iid: 12, - note: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', - note_html: - '<p dir="auto">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>', - current_user: { can_edit: true, can_award_emoji: true }, - resolved: false, - resolved_by: null, - discussion_id: '39b271c2033e9ed43d8edb393702f65f7a830459', - emoji_awardable: true, - award_emoji: [], - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_901&user_id=1', - human_access: 'Owner', - toggle_award_path: '/gitlab-org/gitlab-shell/notes/901/toggle_award_emoji', - path: '/gitlab-org/gitlab-shell/notes/901', - }, - ], - individual_note: true, - resolvable: false, - resolved: false, - diff_discussion: false, - }, - { - id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795', - reply_id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795', - expanded: true, - notes: [ - { - id: '902', - type: null, - attachment: null, - author: { - id: 1, - name: 'Administrator', - username: 'root', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - path: '/root', - }, - created_at: '2018-05-29T12:05:58.694Z', - updated_at: '2018-05-29T12:05:58.694Z', - system: false, - noteable_id: 182, - noteable_type: 'Issue', - resolvable: false, - noteable_iid: 12, - note: - 'Varius vel pharetra vel turpis nunc eget lorem. Ipsum dolor sit amet consectetur adipiscing.', - note_html: - '<p dir="auto">Varius vel pharetra vel turpis nunc eget lorem. Ipsum dolor sit amet consectetur adipiscing.</p>', - current_user: { can_edit: true, can_award_emoji: true }, - resolved: false, - resolved_by: null, - discussion_id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795', - emoji_awardable: true, - award_emoji: [], - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_902&user_id=1', - human_access: 'Owner', - toggle_award_path: '/gitlab-org/gitlab-shell/notes/902/toggle_award_emoji', - path: '/gitlab-org/gitlab-shell/notes/902', - }, - ], - individual_note: true, - resolvable: false, - resolved: false, - diff_discussion: false, - }, - { - id: '7f1feda384083eb31763366e6392399fde6f3f31', - reply_id: '7f1feda384083eb31763366e6392399fde6f3f31', - expanded: true, - notes: [ - { - id: '903', - type: null, - attachment: null, - author: { - id: 1, - name: 'Administrator', - username: 'root', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - path: '/root', - }, - created_at: '2018-05-29T12:06:05.772Z', - updated_at: '2018-05-29T12:06:05.772Z', - system: true, - noteable_id: 182, - noteable_type: 'Issue', - resolvable: false, - noteable_iid: 12, - note: 'changed the description', - note_html: '<p dir="auto">changed the description</p>', - current_user: { can_edit: false, can_award_emoji: true }, - resolved: false, - resolved_by: null, - system_note_icon_name: 'pencil-square', - discussion_id: '7f1feda384083eb31763366e6392399fde6f3f31', - emoji_awardable: false, - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_903&user_id=1', - human_access: 'Owner', - path: '/gitlab-org/gitlab-shell/notes/903', - }, - ], - individual_note: true, - resolvable: false, - resolved: false, - diff_discussion: false, - }, - { - id: '091865fe3ae20f0045234a3d103e3b15e73405b5', - reply_id: '091865fe3ae20f0045234a3d103e3b15e73405b5', - expanded: true, - notes: [ - { - id: '904', - type: null, - attachment: null, - author: { - id: 1, - name: 'Administrator', - username: 'root', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - path: '/root', - }, - created_at: '2018-05-29T12:06:16.112Z', - updated_at: '2018-05-29T12:06:16.112Z', - system: false, - noteable_id: 182, - noteable_type: 'Issue', - resolvable: false, - noteable_iid: 12, - note: 'Ullamcorper eget nulla facilisi etiam', - note_html: '<p dir="auto">Ullamcorper eget nulla facilisi etiam</p>', - current_user: { can_edit: true, can_award_emoji: true }, - resolved: false, - resolved_by: null, - discussion_id: '091865fe3ae20f0045234a3d103e3b15e73405b5', - emoji_awardable: true, - award_emoji: [], - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_904&user_id=1', - human_access: 'Owner', - toggle_award_path: '/gitlab-org/gitlab-shell/notes/904/toggle_award_emoji', - path: '/gitlab-org/gitlab-shell/notes/904', - }, - ], - individual_note: true, - resolvable: false, - resolved: false, - diff_discussion: false, - }, - { - id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', - reply_id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', - expanded: true, - notes: [ - { - id: '905', - type: null, - attachment: null, - author: { - id: 1, - name: 'Administrator', - username: 'root', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - path: '/root', - }, - created_at: '2018-05-29T12:06:28.851Z', - updated_at: '2018-05-29T12:06:28.851Z', - system: true, - noteable_id: 182, - noteable_type: 'Issue', - resolvable: false, - noteable_iid: 12, - note: 'changed the description', - note_html: '<p dir="auto">changed the description</p>', - current_user: { can_edit: false, can_award_emoji: true }, - resolved: false, - resolved_by: null, - system_note_icon_name: 'pencil-square', - discussion_id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', - emoji_awardable: false, - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_905&user_id=1', - human_access: 'Owner', - path: '/gitlab-org/gitlab-shell/notes/905', - }, - ], - individual_note: true, - resolvable: false, - resolved: false, - diff_discussion: false, - }, - { - id: '70411b08cdfc01f24187a06d77daa33464cb2620', - reply_id: '70411b08cdfc01f24187a06d77daa33464cb2620', - expanded: true, - notes: [ - { - id: '906', - type: null, - attachment: null, - author: { - id: 1, - name: 'Administrator', - username: 'root', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - path: '/root', - }, - created_at: '2018-05-29T12:20:02.925Z', - updated_at: '2018-05-29T12:20:02.925Z', - system: true, - noteable_id: 182, - noteable_type: 'Issue', - resolvable: false, - noteable_iid: 12, - note: 'changed the description', - note_html: '<p dir="auto">changed the description</p>', - current_user: { can_edit: false, can_award_emoji: true }, - resolved: false, - resolved_by: null, - system_note_icon_name: 'pencil-square', - discussion_id: '70411b08cdfc01f24187a06d77daa33464cb2620', - emoji_awardable: false, - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_906&user_id=1', - human_access: 'Owner', - path: '/gitlab-org/gitlab-shell/notes/906', - }, - ], - individual_note: true, - resolvable: false, - resolved: false, - diff_discussion: false, - }, -]; - -export const collapsedSystemNotes = [ - { - id: '39b271c2033e9ed43d8edb393702f65f7a830459', - reply_id: '39b271c2033e9ed43d8edb393702f65f7a830459', - expanded: true, - notes: [ - { - id: '901', - type: null, - attachment: null, - author: { - id: 1, - name: 'Administrator', - username: 'root', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - path: '/root', - }, - created_at: '2018-05-29T12:05:36.117Z', - updated_at: '2018-05-29T12:05:36.117Z', - system: false, - noteable_id: 182, - noteable_type: 'Issue', - resolvable: false, - noteable_iid: 12, - note: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', - note_html: - '<p dir="auto">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>', - current_user: { can_edit: true, can_award_emoji: true }, - resolved: false, - resolved_by: null, - discussion_id: '39b271c2033e9ed43d8edb393702f65f7a830459', - emoji_awardable: true, - award_emoji: [], - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_901&user_id=1', - human_access: 'Owner', - toggle_award_path: '/gitlab-org/gitlab-shell/notes/901/toggle_award_emoji', - path: '/gitlab-org/gitlab-shell/notes/901', - }, - ], - individual_note: true, - resolvable: false, - resolved: false, - diff_discussion: false, - }, - { - id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795', - reply_id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795', - expanded: true, - notes: [ - { - id: '902', - type: null, - attachment: null, - author: { - id: 1, - name: 'Administrator', - username: 'root', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - path: '/root', - }, - created_at: '2018-05-29T12:05:58.694Z', - updated_at: '2018-05-29T12:05:58.694Z', - system: false, - noteable_id: 182, - noteable_type: 'Issue', - resolvable: false, - noteable_iid: 12, - note: - 'Varius vel pharetra vel turpis nunc eget lorem. Ipsum dolor sit amet consectetur adipiscing.', - note_html: - '<p dir="auto">Varius vel pharetra vel turpis nunc eget lorem. Ipsum dolor sit amet consectetur adipiscing.</p>', - current_user: { can_edit: true, can_award_emoji: true }, - resolved: false, - resolved_by: null, - discussion_id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795', - emoji_awardable: true, - award_emoji: [], - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_902&user_id=1', - human_access: 'Owner', - toggle_award_path: '/gitlab-org/gitlab-shell/notes/902/toggle_award_emoji', - path: '/gitlab-org/gitlab-shell/notes/902', - }, - ], - individual_note: true, - resolvable: false, - resolved: false, - diff_discussion: false, - }, - { - id: '091865fe3ae20f0045234a3d103e3b15e73405b5', - reply_id: '091865fe3ae20f0045234a3d103e3b15e73405b5', - expanded: true, - notes: [ - { - id: '904', - type: null, - attachment: null, - author: { - id: 1, - name: 'Administrator', - username: 'root', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - path: '/root', - }, - created_at: '2018-05-29T12:06:16.112Z', - updated_at: '2018-05-29T12:06:16.112Z', - system: false, - noteable_id: 182, - noteable_type: 'Issue', - resolvable: false, - noteable_iid: 12, - note: 'Ullamcorper eget nulla facilisi etiam', - note_html: '<p dir="auto">Ullamcorper eget nulla facilisi etiam</p>', - current_user: { can_edit: true, can_award_emoji: true }, - resolved: false, - resolved_by: null, - discussion_id: '091865fe3ae20f0045234a3d103e3b15e73405b5', - emoji_awardable: true, - award_emoji: [], - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_904&user_id=1', - human_access: 'Owner', - toggle_award_path: '/gitlab-org/gitlab-shell/notes/904/toggle_award_emoji', - path: '/gitlab-org/gitlab-shell/notes/904', - }, - ], - individual_note: true, - resolvable: false, - resolved: false, - diff_discussion: false, - }, - { - id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', - reply_id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', - expanded: true, - notes: [ - { - id: '905', - type: null, - attachment: null, - author: { - id: 1, - name: 'Administrator', - username: 'root', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - path: '/root', - }, - created_at: '2018-05-29T12:06:28.851Z', - updated_at: '2018-05-29T12:06:28.851Z', - system: true, - noteable_id: 182, - noteable_type: 'Issue', - resolvable: false, - noteable_iid: 12, - note: 'changed the description', - note_html: ' <p dir="auto">changed the description 2 times within 1 minute </p>', - current_user: { can_edit: false, can_award_emoji: true }, - resolved: false, - resolved_by: null, - system_note_icon_name: 'pencil-square', - discussion_id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', - emoji_awardable: false, - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_905&user_id=1', - human_access: 'Owner', - path: '/gitlab-org/gitlab-shell/notes/905', - times_updated: 2, - }, - ], - individual_note: true, - resolvable: false, - resolved: false, - diff_discussion: false, - }, - { - id: '70411b08cdfc01f24187a06d77daa33464cb2620', - reply_id: '70411b08cdfc01f24187a06d77daa33464cb2620', - expanded: true, - notes: [ - { - id: '906', - type: null, - attachment: null, - author: { - id: 1, - name: 'Administrator', - username: 'root', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - path: '/root', - }, - created_at: '2018-05-29T12:20:02.925Z', - updated_at: '2018-05-29T12:20:02.925Z', - system: true, - noteable_id: 182, - noteable_type: 'Issue', - resolvable: false, - noteable_iid: 12, - note: 'changed the description', - note_html: '<p dir="auto">changed the description</p>', - current_user: { can_edit: false, can_award_emoji: true }, - resolved: false, - resolved_by: null, - system_note_icon_name: 'pencil-square', - discussion_id: '70411b08cdfc01f24187a06d77daa33464cb2620', - emoji_awardable: false, - report_abuse_path: - '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_906&user_id=1', - human_access: 'Owner', - path: '/gitlab-org/gitlab-shell/notes/906', - }, - ], - individual_note: true, - resolvable: false, - resolved: false, - diff_discussion: false, - }, -]; - -export const discussion1 = { - id: 'abc1', - resolvable: true, - resolved: false, - active: true, - diff_file: { - file_path: 'about.md', - }, - position: { - new_line: 50, - old_line: null, - }, - notes: [ - { - created_at: '2018-07-04T16:25:41.749Z', - }, - ], -}; - -export const resolvedDiscussion1 = { - id: 'abc1', - resolvable: true, - resolved: true, - diff_file: { - file_path: 'about.md', - }, - position: { - new_line: 50, - old_line: null, - }, - notes: [ - { - created_at: '2018-07-04T16:25:41.749Z', - }, - ], -}; - -export const discussion2 = { - id: 'abc2', - resolvable: true, - resolved: false, - active: true, - diff_file: { - file_path: 'README.md', - }, - position: { - new_line: null, - old_line: 20, - }, - notes: [ - { - created_at: '2018-07-04T12:05:41.749Z', - }, - ], -}; - -export const discussion3 = { - id: 'abc3', - resolvable: true, - active: true, - resolved: false, - diff_file: { - file_path: 'README.md', - }, - position: { - new_line: 21, - old_line: null, - }, - notes: [ - { - created_at: '2018-07-05T17:25:41.749Z', - }, - ], -}; - -export const unresolvableDiscussion = { - resolvable: false, -}; - -export const discussionFiltersMock = [ - { - title: 'Show all activity', - value: 0, - }, - { - title: 'Show comments only', - value: 1, - }, - { - title: 'Show system notes only', - value: 2, - }, -]; +export * from '../../frontend/notes/mock_data.js'; diff --git a/spec/javascripts/notes/stores/collapse_utils_spec.js b/spec/javascripts/notes/stores/collapse_utils_spec.js index 8ede93190886ab787a8748c6d2d1519435969ab3..d3019f4b9a4f0669ed7ad6cbf0ee35128bc65375 100644 --- a/spec/javascripts/notes/stores/collapse_utils_spec.js +++ b/spec/javascripts/notes/stores/collapse_utils_spec.js @@ -1,6 +1,5 @@ import { isDescriptionSystemNote, - changeDescriptionNote, getTimeDifferenceMinutes, collapseSystemNotes, } from '~/notes/stores/collapse_utils'; @@ -24,15 +23,6 @@ describe('Collapse utils', () => { ); }); - it('changes the description to contain the number of changed times', () => { - const changedNote = changeDescriptionNote(mockSystemNote, 3, 5); - - expect(changedNote.times_updated).toEqual(3); - expect(changedNote.note_html.trim()).toContain( - '<p dir="auto">changed the description 3 times within 5 minutes </p>', - ); - }); - it('gets the time difference between two notes', () => { const anotherSystemNote = { created_at: '2018-05-14T21:33:00.000Z', diff --git a/spec/javascripts/pipelines/graph/action_component_spec.js b/spec/javascripts/pipelines/graph/action_component_spec.js deleted file mode 100644 index 321497b35b5c37e24a088ef19f7d7d5c3e43b9c8..0000000000000000000000000000000000000000 --- a/spec/javascripts/pipelines/graph/action_component_spec.js +++ /dev/null @@ -1,81 +0,0 @@ -import Vue from 'vue'; -import MockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; -import actionComponent from '~/pipelines/components/graph/action_component.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; - -describe('pipeline graph action component', () => { - let component; - let mock; - - beforeEach(done => { - const ActionComponent = Vue.extend(actionComponent); - mock = new MockAdapter(axios); - - mock.onPost('foo.json').reply(200); - - component = mountComponent(ActionComponent, { - tooltipText: 'bar', - link: 'foo', - actionIcon: 'cancel', - }); - - Vue.nextTick(done); - }); - - afterEach(() => { - mock.restore(); - component.$destroy(); - }); - - it('should render the provided title as a bootstrap tooltip', () => { - expect(component.$el.getAttribute('data-original-title')).toEqual('bar'); - }); - - it('should update bootstrap tooltip when title changes', done => { - component.tooltipText = 'changed'; - - component - .$nextTick() - .then(() => { - expect(component.$el.getAttribute('data-original-title')).toBe('changed'); - }) - .then(done) - .catch(done.fail); - }); - - it('should render an svg', () => { - expect(component.$el.querySelector('.ci-action-icon-wrapper')).toBeDefined(); - expect(component.$el.querySelector('svg')).toBeDefined(); - }); - - describe('on click', () => { - it('emits `pipelineActionRequestComplete` after a successful request', done => { - spyOn(component, '$emit'); - - component.$el.click(); - - setTimeout(() => { - component - .$nextTick() - .then(() => { - expect(component.$emit).toHaveBeenCalledWith('pipelineActionRequestComplete'); - }) - .catch(done.fail); - - done(); - }, 0); - }); - - it('renders a loading icon while waiting for request', done => { - component.$el.click(); - - component.$nextTick(() => { - expect(component.$el.querySelector('.js-action-icon-loading')).not.toBeNull(); - setTimeout(() => { - done(); - }); - }); - }); - }); -}); diff --git a/spec/javascripts/raven/raven_config_spec.js b/spec/javascripts/raven/raven_config_spec.js deleted file mode 100644 index af634a0c196aaf8075c51c7a0b5ec00fd03b42de..0000000000000000000000000000000000000000 --- a/spec/javascripts/raven/raven_config_spec.js +++ /dev/null @@ -1,254 +0,0 @@ -import Raven from 'raven-js'; -import RavenConfig from '~/raven/raven_config'; - -describe('RavenConfig', () => { - describe('IGNORE_ERRORS', () => { - it('should be an array of strings', () => { - const areStrings = RavenConfig.IGNORE_ERRORS.every(error => typeof error === 'string'); - - expect(areStrings).toBe(true); - }); - }); - - describe('IGNORE_URLS', () => { - it('should be an array of regexps', () => { - const areRegExps = RavenConfig.IGNORE_URLS.every(url => url instanceof RegExp); - - expect(areRegExps).toBe(true); - }); - }); - - describe('SAMPLE_RATE', () => { - it('should be a finite number', () => { - expect(typeof RavenConfig.SAMPLE_RATE).toEqual('number'); - }); - }); - - describe('init', () => { - const options = { - currentUserId: 1, - }; - - beforeEach(() => { - spyOn(RavenConfig, 'configure'); - spyOn(RavenConfig, 'bindRavenErrors'); - spyOn(RavenConfig, 'setUser'); - - RavenConfig.init(options); - }); - - it('should set the options property', () => { - expect(RavenConfig.options).toEqual(options); - }); - - it('should call the configure method', () => { - expect(RavenConfig.configure).toHaveBeenCalled(); - }); - - it('should call the error bindings method', () => { - expect(RavenConfig.bindRavenErrors).toHaveBeenCalled(); - }); - - it('should call setUser', () => { - expect(RavenConfig.setUser).toHaveBeenCalled(); - }); - - it('should not call setUser if there is no current user ID', () => { - RavenConfig.setUser.calls.reset(); - - options.currentUserId = undefined; - - RavenConfig.init(options); - - expect(RavenConfig.setUser).not.toHaveBeenCalled(); - }); - }); - - describe('configure', () => { - let raven; - let ravenConfig; - const options = { - sentryDsn: '//sentryDsn', - whitelistUrls: ['//gitlabUrl', 'webpack-internal://'], - environment: 'test', - release: 'revision', - tags: { - revision: 'revision', - }, - }; - - beforeEach(() => { - ravenConfig = jasmine.createSpyObj('ravenConfig', ['shouldSendSample']); - raven = jasmine.createSpyObj('raven', ['install']); - - spyOn(Raven, 'config').and.returnValue(raven); - - ravenConfig.options = options; - ravenConfig.IGNORE_ERRORS = 'ignore_errors'; - ravenConfig.IGNORE_URLS = 'ignore_urls'; - - RavenConfig.configure.call(ravenConfig); - }); - - it('should call Raven.config', () => { - expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, { - release: options.release, - tags: options.tags, - whitelistUrls: options.whitelistUrls, - environment: 'test', - ignoreErrors: ravenConfig.IGNORE_ERRORS, - ignoreUrls: ravenConfig.IGNORE_URLS, - shouldSendCallback: jasmine.any(Function), - }); - }); - - it('should call Raven.install', () => { - expect(raven.install).toHaveBeenCalled(); - }); - - it('should set environment from options', () => { - ravenConfig.options.environment = 'development'; - - RavenConfig.configure.call(ravenConfig); - - expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, { - release: options.release, - tags: options.tags, - whitelistUrls: options.whitelistUrls, - environment: 'development', - ignoreErrors: ravenConfig.IGNORE_ERRORS, - ignoreUrls: ravenConfig.IGNORE_URLS, - shouldSendCallback: jasmine.any(Function), - }); - }); - }); - - describe('setUser', () => { - let ravenConfig; - - beforeEach(() => { - ravenConfig = { options: { currentUserId: 1 } }; - spyOn(Raven, 'setUserContext'); - - RavenConfig.setUser.call(ravenConfig); - }); - - it('should call .setUserContext', function() { - expect(Raven.setUserContext).toHaveBeenCalledWith({ - id: ravenConfig.options.currentUserId, - }); - }); - }); - - describe('handleRavenErrors', () => { - let event; - let req; - let config; - let err; - - beforeEach(() => { - event = {}; - req = { status: 'status', responseText: 'responseText', statusText: 'statusText' }; - config = { type: 'type', url: 'url', data: 'data' }; - err = {}; - - spyOn(Raven, 'captureMessage'); - - RavenConfig.handleRavenErrors(event, req, config, err); - }); - - it('should call Raven.captureMessage', () => { - expect(Raven.captureMessage).toHaveBeenCalledWith(err, { - extra: { - type: config.type, - url: config.url, - data: config.data, - status: req.status, - response: req.responseText, - error: err, - event, - }, - }); - }); - - describe('if no err is provided', () => { - beforeEach(() => { - Raven.captureMessage.calls.reset(); - - RavenConfig.handleRavenErrors(event, req, config); - }); - - it('should use req.statusText as the error value', () => { - expect(Raven.captureMessage).toHaveBeenCalledWith(req.statusText, { - extra: { - type: config.type, - url: config.url, - data: config.data, - status: req.status, - response: req.responseText, - error: req.statusText, - event, - }, - }); - }); - }); - - describe('if no req.responseText is provided', () => { - beforeEach(() => { - req.responseText = undefined; - - Raven.captureMessage.calls.reset(); - - RavenConfig.handleRavenErrors(event, req, config, err); - }); - - it('should use `Unknown response text` as the response', () => { - expect(Raven.captureMessage).toHaveBeenCalledWith(err, { - extra: { - type: config.type, - url: config.url, - data: config.data, - status: req.status, - response: 'Unknown response text', - error: err, - event, - }, - }); - }); - }); - }); - - describe('shouldSendSample', () => { - let randomNumber; - - beforeEach(() => { - RavenConfig.SAMPLE_RATE = 50; - - spyOn(Math, 'random').and.callFake(() => randomNumber); - }); - - it('should call Math.random', () => { - RavenConfig.shouldSendSample(); - - expect(Math.random).toHaveBeenCalled(); - }); - - it('should return true if the sample rate is greater than the random number * 100', () => { - randomNumber = 0.1; - - expect(RavenConfig.shouldSendSample()).toBe(true); - }); - - it('should return false if the sample rate is less than the random number * 100', () => { - randomNumber = 0.9; - - expect(RavenConfig.shouldSendSample()).toBe(false); - }); - - it('should return true if the sample rate is equal to the random number * 100', () => { - randomNumber = 0.5; - - expect(RavenConfig.shouldSendSample()).toBe(true); - }); - }); -}); diff --git a/spec/javascripts/search_autocomplete_spec.js b/spec/javascripts/search_autocomplete_spec.js index 9702cb56d9992b9bb3cd11113af1c660d24247af..1798f9962e2f0c7089685cb5e43696787545a70d 100644 --- a/spec/javascripts/search_autocomplete_spec.js +++ b/spec/javascripts/search_autocomplete_spec.js @@ -1,4 +1,4 @@ -/* eslint-disable no-var, one-var, no-unused-expressions, consistent-return, no-param-reassign, default-case, no-return-assign, vars-on-top */ +/* eslint-disable no-unused-expressions, consistent-return, no-param-reassign, default-case, no-return-assign */ import $ from 'jquery'; import '~/gl_dropdown'; @@ -6,41 +6,27 @@ import initSearchAutocomplete from '~/search_autocomplete'; import '~/lib/utils/common_utils'; describe('Search autocomplete dropdown', () => { - var assertLinks, - dashboardIssuesPath, - dashboardMRsPath, - groupIssuesPath, - groupMRsPath, - groupName, - mockDashboardOptions, - mockGroupOptions, - mockProjectOptions, - projectIssuesPath, - projectMRsPath, - projectName, - userId, - widget; - var userName = 'root'; + let widget = null; - widget = null; + const userName = 'root'; - userId = 1; + const userId = 1; - dashboardIssuesPath = '/dashboard/issues'; + const dashboardIssuesPath = '/dashboard/issues'; - dashboardMRsPath = '/dashboard/merge_requests'; + const dashboardMRsPath = '/dashboard/merge_requests'; - projectIssuesPath = '/gitlab-org/gitlab-foss/issues'; + const projectIssuesPath = '/gitlab-org/gitlab-foss/issues'; - projectMRsPath = '/gitlab-org/gitlab-foss/merge_requests'; + const projectMRsPath = '/gitlab-org/gitlab-foss/merge_requests'; - groupIssuesPath = '/groups/gitlab-org/issues'; + const groupIssuesPath = '/groups/gitlab-org/issues'; - groupMRsPath = '/groups/gitlab-org/merge_requests'; + const groupMRsPath = '/groups/gitlab-org/merge_requests'; - projectName = 'GitLab Community Edition'; + const projectName = 'GitLab Community Edition'; - groupName = 'Gitlab Org'; + const groupName = 'Gitlab Org'; const removeBodyAttributes = function() { const $body = $('body'); @@ -76,7 +62,7 @@ describe('Search autocomplete dropdown', () => { }; // Mock `gl` object in window for dashboard specific page. App code will need it. - mockDashboardOptions = function() { + const mockDashboardOptions = function() { window.gl || (window.gl = {}); return (window.gl.dashboardOptions = { issuesPath: dashboardIssuesPath, @@ -85,7 +71,7 @@ describe('Search autocomplete dropdown', () => { }; // Mock `gl` object in window for project specific page. App code will need it. - mockProjectOptions = function() { + const mockProjectOptions = function() { window.gl || (window.gl = {}); return (window.gl.projectOptions = { 'gitlab-ce': { @@ -96,7 +82,7 @@ describe('Search autocomplete dropdown', () => { }); }; - mockGroupOptions = function() { + const mockGroupOptions = function() { window.gl || (window.gl = {}); return (window.gl.groupOptions = { 'gitlab-org': { @@ -107,7 +93,7 @@ describe('Search autocomplete dropdown', () => { }); }; - assertLinks = function(list, issuesPath, mrsPath) { + const assertLinks = function(list, issuesPath, mrsPath) { if (issuesPath) { const issuesAssignedToMeLink = `a[href="${issuesPath}/?assignee_username=${userName}"]`; const issuesIHaveCreatedLink = `a[href="${issuesPath}/?author_username=${userName}"]`; @@ -144,29 +130,26 @@ describe('Search autocomplete dropdown', () => { }); it('should show Dashboard specific dropdown menu', function() { - var list; addBodyAttributes(); mockDashboardOptions(); widget.searchInput.triggerHandler('focus'); - list = widget.wrap.find('.dropdown-menu').find('ul'); + const list = widget.wrap.find('.dropdown-menu').find('ul'); return assertLinks(list, dashboardIssuesPath, dashboardMRsPath); }); it('should show Group specific dropdown menu', function() { - var list; addBodyAttributes('group'); mockGroupOptions(); widget.searchInput.triggerHandler('focus'); - list = widget.wrap.find('.dropdown-menu').find('ul'); + const list = widget.wrap.find('.dropdown-menu').find('ul'); return assertLinks(list, groupIssuesPath, groupMRsPath); }); it('should show Project specific dropdown menu', function() { - var list; addBodyAttributes('project'); mockProjectOptions(); widget.searchInput.triggerHandler('focus'); - list = widget.wrap.find('.dropdown-menu').find('ul'); + const list = widget.wrap.find('.dropdown-menu').find('ul'); return assertLinks(list, projectIssuesPath, projectMRsPath); }); @@ -180,26 +163,25 @@ describe('Search autocomplete dropdown', () => { }); it('should not show category related menu if there is text in the input', function() { - var link, list; addBodyAttributes('project'); mockProjectOptions(); widget.searchInput.val('help'); widget.searchInput.triggerHandler('focus'); - list = widget.wrap.find('.dropdown-menu').find('ul'); - link = `a[href='${projectIssuesPath}/?assignee_username=${userName}']`; + const list = widget.wrap.find('.dropdown-menu').find('ul'); + const link = `a[href='${projectIssuesPath}/?assignee_username=${userName}']`; expect(list.find(link).length).toBe(0); }); it('should not submit the search form when selecting an autocomplete row with the keyboard', function() { - var ENTER = 13; - var DOWN = 40; + const ENTER = 13; + const DOWN = 40; addBodyAttributes(); mockDashboardOptions(true); - var submitSpy = spyOnEvent('form', 'submit'); + const submitSpy = spyOnEvent('form', 'submit'); widget.searchInput.triggerHandler('focus'); widget.wrap.trigger($.Event('keydown', { which: DOWN })); - var enterKeyEvent = $.Event('keydown', { which: ENTER }); + const enterKeyEvent = $.Event('keydown', { which: ENTER }); widget.searchInput.trigger(enterKeyEvent); // This does not currently catch failing behavior. For security reasons, // browsers will not trigger default behavior (form submit, in this diff --git a/spec/javascripts/sidebar/subscriptions_spec.js b/spec/javascripts/sidebar/subscriptions_spec.js index a97608d6b8a957dd8f1fcb08383571018f08a997..1256852c472c835214818d31e10c5b8689a6fd51 100644 --- a/spec/javascripts/sidebar/subscriptions_spec.js +++ b/spec/javascripts/sidebar/subscriptions_spec.js @@ -76,4 +76,25 @@ describe('Subscriptions', function() { expect(vm.$emit).toHaveBeenCalledWith('toggleSidebar'); }); + + describe('given project emails are disabled', () => { + const subscribeDisabledDescription = 'Notifications have been disabled'; + + beforeEach(() => { + vm = mountComponent(Subscriptions, { + subscribed: false, + projectEmailsDisabled: true, + subscribeDisabledDescription, + }); + }); + + it('sets the correct display text', () => { + expect(vm.$el.textContent).toContain(subscribeDisabledDescription); + expect(vm.$refs.tooltip.dataset.originalTitle).toBe(subscribeDisabledDescription); + }); + + it('does not render the toggle button', () => { + expect(vm.$refs.toggleButton).toBeUndefined(); + }); + }); }); diff --git a/spec/javascripts/signin_tabs_memoizer_spec.js b/spec/javascripts/signin_tabs_memoizer_spec.js index ef5c774736b615a9a7f2cc9ada68fd52c4820c95..966ae55ce14eedca8b802b4314eeb08475525ad8 100644 --- a/spec/javascripts/signin_tabs_memoizer_spec.js +++ b/spec/javascripts/signin_tabs_memoizer_spec.js @@ -1,5 +1,7 @@ import AccessorUtilities from '~/lib/utils/accessor'; import SigninTabsMemoizer from '~/pages/sessions/new/signin_tabs_memoizer'; +import trackData from '~/pages/sessions/new/index'; +import Tracking from '~/tracking'; describe('SigninTabsMemoizer', () => { const fixtureTemplate = 'static/signin_tabs.html'; @@ -93,6 +95,50 @@ describe('SigninTabsMemoizer', () => { }); }); + describe('trackData', () => { + beforeEach(() => { + spyOn(Tracking, 'event'); + }); + + describe('with tracking data', () => { + beforeEach(() => { + gon.tracking_data = { + category: 'Growth::Acquisition::Experiment::SignUpFlow', + action: 'start', + label: 'uuid', + property: 'control_group', + }; + trackData(); + }); + + it('should track data when the "click" event of the register tab is triggered', () => { + document.querySelector('a[href="#register-pane"]').click(); + + expect(Tracking.event).toHaveBeenCalledWith( + 'Growth::Acquisition::Experiment::SignUpFlow', + 'start', + { + label: 'uuid', + property: 'control_group', + }, + ); + }); + }); + + describe('without tracking data', () => { + beforeEach(() => { + gon.tracking_data = undefined; + trackData(); + }); + + it('should not track data when the "click" event of the register tab is triggered', () => { + document.querySelector('a[href="#register-pane"]').click(); + + expect(Tracking.event).not.toHaveBeenCalled(); + }); + }); + }); + describe('saveData', () => { beforeEach(() => { memo = { diff --git a/spec/javascripts/syntax_highlight_spec.js b/spec/javascripts/syntax_highlight_spec.js index 5438368ccbed4ce313124c7237d9673924c58304..99c47fa31d495bb505bdfe292279bc7b0e6e284f 100644 --- a/spec/javascripts/syntax_highlight_spec.js +++ b/spec/javascripts/syntax_highlight_spec.js @@ -1,11 +1,10 @@ -/* eslint-disable no-var, no-return-assign */ +/* eslint-disable no-return-assign */ import $ from 'jquery'; import syntaxHighlight from '~/syntax_highlight'; describe('Syntax Highlighter', function() { - var stubUserColorScheme; - stubUserColorScheme = function(value) { + const stubUserColorScheme = function(value) { if (window.gon == null) { window.gon = {}; } @@ -40,9 +39,8 @@ describe('Syntax Highlighter', function() { }); it('prevents an infinite loop when no matches exist', function() { - var highlight; setFixtures('<div></div>'); - highlight = function() { + const highlight = function() { return syntaxHighlight($('div')); }; diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js index cb6b158f01c259a12765b6cdfa55cd14bd1b3bdf..859745ee9fc85e19f4634af78d03825253854cd8 100644 --- a/spec/javascripts/test_bundle.js +++ b/spec/javascripts/test_bundle.js @@ -171,38 +171,7 @@ describe('test errors', () => { // see: https://github.com/deepsweet/istanbul-instrumenter-loader/issues/15 if (process.env.BABEL_ENV === 'coverage') { // exempt these files from the coverage report - const troubleMakers = [ - './blob_edit/blob_bundle.js', - './boards/components/modal/empty_state.vue', - './boards/components/modal/footer.js', - './boards/components/modal/header.js', - './cycle_analytics/cycle_analytics_bundle.js', - './cycle_analytics/components/stage_plan_component.js', - './cycle_analytics/components/stage_staging_component.js', - './cycle_analytics/components/stage_test_component.js', - './commit/pipelines/pipelines_bundle.js', - './diff_notes/diff_notes_bundle.js', - './diff_notes/components/jump_to_discussion.js', - './diff_notes/components/resolve_count.js', - './dispatcher.js', - './environments/environments_bundle.js', - './graphs/graphs_bundle.js', - './issuable/time_tracking/time_tracking_bundle.js', - './main.js', - './merge_conflicts/merge_conflicts_bundle.js', - './merge_conflicts/components/inline_conflict_lines.js', - './merge_conflicts/components/parallel_conflict_lines.js', - './monitoring/monitoring_bundle.js', - './network/network_bundle.js', - './network/branch_graph.js', - './profile/profile_bundle.js', - './protected_branches/protected_branches_bundle.js', - './snippet/snippet_bundle.js', - './terminal/terminal_bundle.js', - './users/users_bundle.js', - './issue_show/index.js', - './pages/admin/application_settings/general/index.js', - ]; + const troubleMakers = ['./pages/admin/application_settings/general/index.js']; describe('Uncovered files', function() { const sourceFilesContexts = [require.context('~', true, /\.(js|vue)$/)]; diff --git a/spec/javascripts/u2f/mock_u2f_device.js b/spec/javascripts/u2f/mock_u2f_device.js index 26ddd8ade618d9ed58a986429fcc3665470e85d3..ec8425a4e3e5c7c3c9c34c8f702caebfe4796bbe 100644 --- a/spec/javascripts/u2f/mock_u2f_device.js +++ b/spec/javascripts/u2f/mock_u2f_device.js @@ -1,20 +1,16 @@ -/* eslint-disable no-unused-expressions, no-return-assign, no-param-reassign */ +/* eslint-disable no-unused-expressions */ export default class MockU2FDevice { constructor() { this.respondToAuthenticateRequest = this.respondToAuthenticateRequest.bind(this); this.respondToRegisterRequest = this.respondToRegisterRequest.bind(this); window.u2f || (window.u2f = {}); - window.u2f.register = (function(_this) { - return function(appId, registerRequests, signRequests, callback) { - return (_this.registerCallback = callback); - }; - })(this); - window.u2f.sign = (function(_this) { - return function(appId, challenges, signRequests, callback) { - return (_this.authenticateCallback = callback); - }; - })(this); + window.u2f.register = (appId, registerRequests, signRequests, callback) => { + this.registerCallback = callback; + }; + window.u2f.sign = (appId, challenges, signRequests, callback) => { + this.authenticateCallback = callback; + }; } respondToRegisterRequest(params) { diff --git a/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js b/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js index bdf802052b94eb94336650835545c8a372a9a1ea..16997e9dc67d26da90476881a8ee7a6dc694f17b 100644 --- a/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js +++ b/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js @@ -70,4 +70,30 @@ describe('ContentViewer', () => { done(); }); }); + + it('markdown preview receives the file path as a parameter', done => { + mock = new MockAdapter(axios); + spyOn(axios, 'post').and.callThrough(); + mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).reply(200, { + body: '<b>testing</b>', + }); + + createComponent({ + path: 'test.md', + content: '* Test', + projectPath: 'testproject', + type: 'markdown', + filePath: 'foo/test.md', + }); + + setTimeout(() => { + expect(axios.post).toHaveBeenCalledWith( + `${gon.relative_url_root}/testproject/preview_markdown`, + { path: 'foo/test.md', text: '* Test' }, + jasmine.any(Object), + ); + + done(); + }); + }); }); diff --git a/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js b/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js index 660eaddf01fe4c920a62e928c8e37ff844cc53a7..1acd6b3ebe777a51d9ae855bda4d88bbb263c060 100644 --- a/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js +++ b/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js @@ -1,13 +1,23 @@ import Vue from 'vue'; + import diffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants'; describe('DiffViewer', () => { + const requiredProps = { + diffMode: 'replaced', + diffViewerMode: 'image', + newPath: GREEN_BOX_IMAGE_URL, + newSha: 'ABC', + oldPath: RED_BOX_IMAGE_URL, + oldSha: 'DEF', + }; let vm; function createComponent(props) { const DiffViewer = Vue.extend(diffViewer); + vm = mountComponent(DiffViewer, props); } @@ -20,15 +30,11 @@ describe('DiffViewer', () => { relative_url_root: '', }; - createComponent({ - diffMode: 'replaced', - diffViewerMode: 'image', - newPath: GREEN_BOX_IMAGE_URL, - newSha: 'ABC', - oldPath: RED_BOX_IMAGE_URL, - oldSha: 'DEF', - projectPath: '', - }); + createComponent( + Object.assign({}, requiredProps, { + projectPath: '', + }), + ); setTimeout(() => { expect(vm.$el.querySelector('.deleted img').getAttribute('src')).toBe( @@ -44,14 +50,13 @@ describe('DiffViewer', () => { }); it('renders fallback download diff display', done => { - createComponent({ - diffMode: 'replaced', - diffViewerMode: 'added', - newPath: 'test.abc', - newSha: 'ABC', - oldPath: 'testold.abc', - oldSha: 'DEF', - }); + createComponent( + Object.assign({}, requiredProps, { + diffViewerMode: 'added', + newPath: 'test.abc', + oldPath: 'testold.abc', + }), + ); setTimeout(() => { expect(vm.$el.querySelector('.deleted .file-info').textContent.trim()).toContain( @@ -72,29 +77,28 @@ describe('DiffViewer', () => { }); it('renders renamed component', () => { - createComponent({ - diffMode: 'renamed', - diffViewerMode: 'renamed', - newPath: 'test.abc', - newSha: 'ABC', - oldPath: 'testold.abc', - oldSha: 'DEF', - }); + createComponent( + Object.assign({}, requiredProps, { + diffMode: 'renamed', + diffViewerMode: 'renamed', + newPath: 'test.abc', + oldPath: 'testold.abc', + }), + ); expect(vm.$el.textContent).toContain('File moved'); }); it('renders mode changed component', () => { - createComponent({ - diffMode: 'mode_changed', - diffViewerMode: 'image', - newPath: 'test.abc', - newSha: 'ABC', - oldPath: 'testold.abc', - oldSha: 'DEF', - aMode: '123', - bMode: '321', - }); + createComponent( + Object.assign({}, requiredProps, { + diffMode: 'mode_changed', + newPath: 'test.abc', + oldPath: 'testold.abc', + aMode: '123', + bMode: '321', + }), + ); expect(vm.$el.textContent).toContain('File mode changed from 123 to 321'); }); diff --git a/spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js b/spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js index 97c870f27d9307d61ce0666b9adf09a18608ddd5..0cb26d5000b64e8e0ef77821b18e2d12f8b0de21 100644 --- a/spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js +++ b/spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js @@ -4,6 +4,11 @@ import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants'; describe('ImageDiffViewer', () => { + const requiredProps = { + diffMode: 'replaced', + newPath: GREEN_BOX_IMAGE_URL, + oldPath: RED_BOX_IMAGE_URL, + }; let vm; function createComponent(props) { @@ -45,11 +50,7 @@ describe('ImageDiffViewer', () => { }); it('renders image diff for replaced', done => { - createComponent({ - diffMode: 'replaced', - newPath: GREEN_BOX_IMAGE_URL, - oldPath: RED_BOX_IMAGE_URL, - }); + createComponent(requiredProps); setTimeout(() => { expect(vm.$el.querySelector('.added img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL); @@ -70,11 +71,12 @@ describe('ImageDiffViewer', () => { }); it('renders image diff for new', done => { - createComponent({ - diffMode: 'new', - newPath: GREEN_BOX_IMAGE_URL, - oldPath: '', - }); + createComponent( + Object.assign({}, requiredProps, { + diffMode: 'new', + oldPath: '', + }), + ); setTimeout(() => { expect(vm.$el.querySelector('.added img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL); @@ -84,11 +86,12 @@ describe('ImageDiffViewer', () => { }); it('renders image diff for deleted', done => { - createComponent({ - diffMode: 'deleted', - newPath: '', - oldPath: RED_BOX_IMAGE_URL, - }); + createComponent( + Object.assign({}, requiredProps, { + diffMode: 'deleted', + newPath: '', + }), + ); setTimeout(() => { expect(vm.$el.querySelector('.deleted img').getAttribute('src')).toBe(RED_BOX_IMAGE_URL); @@ -119,11 +122,7 @@ describe('ImageDiffViewer', () => { describe('swipeMode', () => { beforeEach(done => { - createComponent({ - diffMode: 'replaced', - newPath: GREEN_BOX_IMAGE_URL, - oldPath: RED_BOX_IMAGE_URL, - }); + createComponent(requiredProps); setTimeout(() => { done(); @@ -142,11 +141,7 @@ describe('ImageDiffViewer', () => { describe('onionSkin', () => { beforeEach(done => { - createComponent({ - diffMode: 'replaced', - newPath: GREEN_BOX_IMAGE_URL, - oldPath: RED_BOX_IMAGE_URL, - }); + createComponent(requiredProps); setTimeout(() => { done(); diff --git a/spec/javascripts/vue_shared/components/icon_spec.js b/spec/javascripts/vue_shared/components/icon_spec.js index 7390798afa8d84444a28b061480c60918424120d..ecaef414464fb0090293761531ca3a649f3b84c3 100644 --- a/spec/javascripts/vue_shared/components/icon_spec.js +++ b/spec/javascripts/vue_shared/components/icon_spec.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import Icon from '~/vue_shared/components/icon.vue'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { mount } from '@vue/test-utils'; describe('Sprite Icon Component', function() { describe('Initialization', function() { @@ -57,4 +58,16 @@ describe('Sprite Icon Component', function() { expect(Icon.props.name.validator('commit')).toBe(true); }); }); + + it('should call registered listeners when they are triggered', () => { + const clickHandler = jasmine.createSpy('clickHandler'); + const wrapper = mount(Icon, { + propsData: { name: 'commit' }, + listeners: { click: clickHandler }, + }); + + wrapper.find('svg').trigger('click'); + + expect(clickHandler).toHaveBeenCalled(); + }); }); 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 9c2deca585b0086d6279228dad9576c0d8ecf9ac..323a0f030178ea08950a19109521bfb776f14885 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 @@ -3,7 +3,7 @@ import _ from 'underscore'; import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue'; import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; -import { GlSearchBoxByType } from '@gitlab/ui'; +import { GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import { trimText } from 'spec/helpers/text_helper'; @@ -91,6 +91,13 @@ describe('ProjectSelector component', () => { expect(searchInput.attributes('placeholder')).toBe('Search your projects'); }); + it(`triggers a "bottomReached" event when user has scrolled to the bottom of the list`, () => { + spyOn(vm, '$emit'); + wrapper.find(GlInfiniteScroll).vm.$emit('bottomReached'); + + expect(vm.$emit).toHaveBeenCalledWith('bottomReached'); + }); + it(`triggers a "projectClicked" event when a project is clicked`, () => { spyOn(vm, '$emit'); wrapper.find(ProjectListItem).vm.$emit('click', _.first(searchResults)); diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js deleted file mode 100644 index c5045afc5b0d3306cba2fca9b7eea24275b97f74..0000000000000000000000000000000000000000 --- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js +++ /dev/null @@ -1,120 +0,0 @@ -import Vue from 'vue'; -import { placeholderImage } from '~/lazy_loader'; -import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; -import mountComponent, { mountComponentWithSlots } from 'spec/helpers/vue_mount_component_helper'; -import defaultAvatarUrl from '~/../images/no_avatar.png'; - -const DEFAULT_PROPS = { - size: 99, - imgSrc: 'myavatarurl.com', - imgAlt: 'mydisplayname', - cssClasses: 'myextraavatarclass', - tooltipText: 'tooltip text', - tooltipPlacement: 'bottom', -}; - -describe('User Avatar Image Component', function() { - let vm; - let UserAvatarImage; - - beforeEach(() => { - UserAvatarImage = Vue.extend(userAvatarImage); - }); - - describe('Initialization', function() { - beforeEach(function() { - vm = mountComponent(UserAvatarImage, { - ...DEFAULT_PROPS, - }).$mount(); - }); - - it('should return a defined Vue component', function() { - expect(vm).toBeDefined(); - }); - - it('should have <img> as a child element', function() { - const imageElement = vm.$el.querySelector('img'); - - expect(imageElement).not.toBe(null); - expect(imageElement.getAttribute('src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`); - expect(imageElement.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`); - expect(imageElement.getAttribute('alt')).toBe(DEFAULT_PROPS.imgAlt); - }); - - it('should properly compute avatarSizeClass', function() { - expect(vm.avatarSizeClass).toBe('s99'); - }); - - it('should properly render img css', function() { - const { classList } = vm.$el.querySelector('img'); - const containsAvatar = classList.contains('avatar'); - const containsSizeClass = classList.contains('s99'); - const containsCustomClass = classList.contains(DEFAULT_PROPS.cssClasses); - const lazyClass = classList.contains('lazy'); - - expect(containsAvatar).toBe(true); - expect(containsSizeClass).toBe(true); - expect(containsCustomClass).toBe(true); - expect(lazyClass).toBe(false); - }); - }); - - describe('Initialization when lazy', function() { - beforeEach(function() { - vm = mountComponent(UserAvatarImage, { - ...DEFAULT_PROPS, - lazy: true, - }).$mount(); - }); - - it('should add lazy attributes', function() { - const imageElement = vm.$el.querySelector('img'); - const lazyClass = imageElement.classList.contains('lazy'); - - expect(lazyClass).toBe(true); - expect(imageElement.getAttribute('src')).toBe(placeholderImage); - expect(imageElement.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`); - }); - }); - - describe('Initialization without src', function() { - beforeEach(function() { - vm = mountComponent(UserAvatarImage); - }); - - it('should have default avatar image', function() { - const imageElement = vm.$el.querySelector('img'); - - expect(imageElement.getAttribute('src')).toBe(defaultAvatarUrl); - }); - }); - - describe('dynamic tooltip content', () => { - const props = DEFAULT_PROPS; - const slots = { - default: ['Action!'], - }; - - beforeEach(() => { - vm = mountComponentWithSlots(UserAvatarImage, { props, slots }).$mount(); - }); - - it('renders the tooltip slot', () => { - expect(vm.$el.querySelector('.js-user-avatar-image-toolip')).not.toBe(null); - }); - - it('renders the tooltip content', () => { - expect(vm.$el.querySelector('.js-user-avatar-image-toolip').textContent).toContain( - slots.default[0], - ); - }); - - it('does not render tooltip data attributes for on avatar image', () => { - const avatarImg = vm.$el.querySelector('img'); - - expect(avatarImg.dataset.originalTitle).not.toBeDefined(); - expect(avatarImg.dataset.placement).not.toBeDefined(); - expect(avatarImg.dataset.container).not.toBeDefined(); - }); - }); -}); diff --git a/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js b/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js deleted file mode 100644 index c7e0d806d80d686df51dab216cde7e73173881f4..0000000000000000000000000000000000000000 --- a/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js +++ /dev/null @@ -1,167 +0,0 @@ -import Vue from 'vue'; -import userPopover from '~/vue_shared/components/user_popover/user_popover.vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; - -const DEFAULT_PROPS = { - loaded: true, - user: { - username: 'root', - name: 'Administrator', - location: 'Vienna', - bio: null, - organization: null, - status: null, - }, -}; - -const UserPopover = Vue.extend(userPopover); - -describe('User Popover Component', () => { - const fixtureTemplate = 'merge_requests/diff_comment.html'; - preloadFixtures(fixtureTemplate); - - let vm; - - beforeEach(() => { - loadFixtures(fixtureTemplate); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('Empty', () => { - beforeEach(() => { - vm = mountComponent(UserPopover, { - target: document.querySelector('.js-user-link'), - user: { - name: null, - username: null, - location: null, - bio: null, - organization: null, - status: null, - }, - }); - }); - - it('should return skeleton loaders', () => { - expect(vm.$el.querySelectorAll('.animation-container').length).toBe(4); - }); - }); - - describe('basic data', () => { - it('should show basic fields', () => { - vm = mountComponent(UserPopover, { - ...DEFAULT_PROPS, - target: document.querySelector('.js-user-link'), - }); - - expect(vm.$el.textContent).toContain(DEFAULT_PROPS.user.name); - expect(vm.$el.textContent).toContain(DEFAULT_PROPS.user.username); - expect(vm.$el.textContent).toContain(DEFAULT_PROPS.user.location); - }); - - it('shows icon for location', () => { - const iconEl = vm.$el.querySelector('.js-location svg'); - - expect(iconEl.querySelector('use').getAttribute('xlink:href')).toContain('location'); - }); - }); - - describe('job data', () => { - it('should show only bio if no organization is available', () => { - const testProps = Object.assign({}, DEFAULT_PROPS); - testProps.user.bio = 'Engineer'; - - vm = mountComponent(UserPopover, { - ...testProps, - target: document.querySelector('.js-user-link'), - }); - - expect(vm.$el.textContent).toContain('Engineer'); - }); - - it('should show only organization if no bio is available', () => { - const testProps = Object.assign({}, DEFAULT_PROPS); - testProps.user.organization = 'GitLab'; - - vm = mountComponent(UserPopover, { - ...testProps, - target: document.querySelector('.js-user-link'), - }); - - expect(vm.$el.textContent).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'; - - vm = mountComponent(UserPopover, { - ...DEFAULT_PROPS, - target: document.querySelector('.js-user-link'), - }); - - expect(vm.$el.querySelector('.js-bio').textContent).toContain('Engineer'); - expect(vm.$el.querySelector('.js-organization').textContent).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'; - - vm = mountComponent(UserPopover, { - ...DEFAULT_PROPS, - target: document.querySelector('.js-user-link'), - }); - - expect(vm.$el.querySelector('.js-bio').textContent).toContain('Manager & Team Lead'); - expect(vm.$el.querySelector('.js-organization').textContent).toContain( - 'Me & my <funky> Company', - ); - }); - - it('shows icon for bio', () => { - const iconEl = vm.$el.querySelector('.js-bio svg'); - - expect(iconEl.querySelector('use').getAttribute('xlink:href')).toContain('profile'); - }); - - it('shows icon for organization', () => { - const iconEl = vm.$el.querySelector('.js-organization svg'); - - expect(iconEl.querySelector('use').getAttribute('xlink:href')).toContain('work'); - }); - }); - - describe('status data', () => { - it('should show only message', () => { - const testProps = Object.assign({}, DEFAULT_PROPS); - testProps.user.status = { message_html: 'Hello World' }; - - vm = mountComponent(UserPopover, { - ...DEFAULT_PROPS, - target: document.querySelector('.js-user-link'), - }); - - expect(vm.$el.textContent).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' }; - - vm = mountComponent(UserPopover, { - ...DEFAULT_PROPS, - target: document.querySelector('.js-user-link'), - status: { emoji: 'basketball_player', message_html: 'Hello World' }, - }); - - expect(vm.$el.textContent).toContain('Hello World'); - expect(vm.$el.innerHTML).toContain('<gl-emoji data-name="basketball_player"'); - }); - }); -}); diff --git a/spec/lib/api/helpers/pagination_spec.rb b/spec/lib/api/helpers/pagination_spec.rb index b57adb46385690d5fcca2e8ccad877d2f5e4f156..040ff1a8ebeaa57119d7f92ca97ef4f32e5cdb7c 100644 --- a/spec/lib/api/helpers/pagination_spec.rb +++ b/spec/lib/api/helpers/pagination_spec.rb @@ -3,399 +3,20 @@ require 'spec_helper' describe API::Helpers::Pagination do - let(:resource) { Project.all } - let(:custom_port) { 8080 } - let(:incoming_api_projects_url) { "#{Gitlab.config.gitlab.url}:#{custom_port}/api/v4/projects" } + subject { Class.new.include(described_class).new } - before do - stub_config_setting(port: custom_port) - end - - subject do - Class.new.include(described_class).new - end - - describe '#paginate (keyset pagination)' do - let(:value) { spy('return value') } - let(:base_query) do - { - pagination: 'keyset', - foo: 'bar', - bar: 'baz' - } - end - let(:query) { base_query } - - before do - allow(subject).to receive(:header).and_return(value) - allow(subject).to receive(:params).and_return(query) - allow(subject).to receive(:request).and_return(double(url: "#{incoming_api_projects_url}?#{query.to_query}")) - end - - context 'when resource can be paginated' do - let!(:projects) do - [ - create(:project, name: 'One'), - create(:project, name: 'Two'), - create(:project, name: 'Three') - ].sort_by { |e| -e.id } # sort by id desc (this is the default sort order for the API) - end - - describe 'first page' do - let(:query) { base_query.merge(per_page: 2) } - - it 'returns appropriate amount of resources' do - expect(subject.paginate(resource).count).to eq 2 - end - - it 'returns the first two records (by id desc)' do - expect(subject.paginate(resource)).to eq(projects[0..1]) - end - - it 'adds appropriate headers' do - expect_header('X-Per-Page', '2') - expect_header('X-Next-Page', "#{incoming_api_projects_url}?#{query.merge(ks_prev_id: projects[1].id).to_query}") - - expect_header('Link', anything) do |_key, val| - expect(val).to include('rel="next"') - end - - subject.paginate(resource) - end - end - - describe 'second page' do - let(:query) { base_query.merge(per_page: 2, ks_prev_id: projects[1].id) } - - it 'returns appropriate amount of resources' do - expect(subject.paginate(resource).count).to eq 1 - end - - it 'returns the third record' do - expect(subject.paginate(resource)).to eq(projects[2..2]) - end - - it 'adds appropriate headers' do - expect_header('X-Per-Page', '2') - expect_header('X-Next-Page', "#{incoming_api_projects_url}?#{query.merge(ks_prev_id: projects[2].id).to_query}") - - expect_header('Link', anything) do |_key, val| - expect(val).to include('rel="next"') - end - - subject.paginate(resource) - end - end - - describe 'third page' do - let(:query) { base_query.merge(per_page: 2, ks_prev_id: projects[2].id) } - - it 'returns appropriate amount of resources' do - expect(subject.paginate(resource).count).to eq 0 - end - - it 'adds appropriate headers' do - expect_header('X-Per-Page', '2') - expect_no_header('X-Next-Page') - expect(subject).not_to receive(:header).with('Link') - - subject.paginate(resource) - end - end - - context 'if order' do - context 'is not present' do - let(:query) { base_query.merge(per_page: 2) } - - it 'is not present it adds default order(:id) desc' do - resource.order_values = [] - - paginated_relation = subject.paginate(resource) - - expect(resource.order_values).to be_empty - expect(paginated_relation.order_values).to be_present - expect(paginated_relation.order_values.size).to eq(1) - expect(paginated_relation.order_values.first).to be_descending - expect(paginated_relation.order_values.first.expr.name).to eq 'id' - end - end - - context 'is present' do - let(:resource) { Project.all.order(name: :desc) } - let!(:projects) do - [ - create(:project, name: 'One'), - create(:project, name: 'Two'), - create(:project, name: 'Three'), - create(:project, name: 'Three'), # Note the duplicate name - create(:project, name: 'Four'), - create(:project, name: 'Five'), - create(:project, name: 'Six') - ] - - # if we sort this by name descending, id descending, this yields: - # { - # 2 => "Two", - # 4 => "Three", - # 3 => "Three", - # 7 => "Six", - # 1 => "One", - # 5 => "Four", - # 6 => "Five" - # } - # - # (key is the id) - end - - it 'also orders by primary key' do - paginated_relation = subject.paginate(resource) - - expect(paginated_relation.order_values).to be_present - expect(paginated_relation.order_values.size).to eq(2) - expect(paginated_relation.order_values.first).to be_descending - expect(paginated_relation.order_values.first.expr.name).to eq 'name' - expect(paginated_relation.order_values.second).to be_descending - expect(paginated_relation.order_values.second.expr.name).to eq 'id' - end - - it 'returns the right records (first page)' do - result = subject.paginate(resource) - - expect(result.first).to eq(projects[1]) - expect(result.second).to eq(projects[3]) - end - - describe 'second page' do - let(:query) { base_query.merge(ks_prev_id: projects[3].id, ks_prev_name: projects[3].name, per_page: 2) } - - it 'returns the right records (second page)' do - result = subject.paginate(resource) - - expect(result.first).to eq(projects[2]) - expect(result.second).to eq(projects[6]) - end - - it 'returns the right link to the next page' do - expect_header('X-Per-Page', '2') - expect_header('X-Next-Page', "#{incoming_api_projects_url}?#{query.merge(ks_prev_id: projects[6].id, ks_prev_name: projects[6].name).to_query}") - expect_header('Link', anything) do |_key, val| - expect(val).to include('rel="next"') - end - - subject.paginate(resource) - end - end - - describe 'third page' do - let(:query) { base_query.merge(ks_prev_id: projects[6].id, ks_prev_name: projects[6].name, per_page: 5) } - - it 'returns the right records (third page), note increased per_page' do - result = subject.paginate(resource) - - expect(result.size).to eq(3) - expect(result.first).to eq(projects[0]) - expect(result.second).to eq(projects[4]) - expect(result.last).to eq(projects[5]) - end - end - end - end - end - end - - describe '#paginate (default offset-based pagination)' do - let(:value) { spy('return value') } - let(:base_query) { { foo: 'bar', bar: 'baz' } } - let(:query) { base_query } - - before do - allow(subject).to receive(:header).and_return(value) - allow(subject).to receive(:params).and_return(query) - allow(subject).to receive(:request).and_return(double(url: "#{incoming_api_projects_url}?#{query.to_query}")) - end - - context 'when resource can be paginated' do - before do - create_list(:project, 3) - end - - describe 'first page' do - shared_examples 'response with pagination headers' do - it 'adds appropriate headers' do - expect_header('X-Total', '3') - expect_header('X-Total-Pages', '2') - expect_header('X-Per-Page', '2') - expect_header('X-Page', '1') - expect_header('X-Next-Page', '2') - expect_header('X-Prev-Page', '') - - expect_header('Link', anything) do |_key, val| - expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 1).to_query}>; rel="first")) - expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 2).to_query}>; rel="last")) - expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 2).to_query}>; rel="next")) - expect(val).not_to include('rel="prev"') - end - - subject.paginate(resource) - end - end - - shared_examples 'paginated response' do - it 'returns appropriate amount of resources' do - expect(subject.paginate(resource).count).to eq 2 - end - - it 'executes only one SELECT COUNT query' do - expect { subject.paginate(resource) }.to make_queries_matching(/SELECT COUNT/, 1) - end - end - - let(:query) { base_query.merge(page: 1, per_page: 2) } - - context 'when the api_kaminari_count_with_limit feature flag is unset' do - it_behaves_like 'paginated response' - it_behaves_like 'response with pagination headers' - end - - context 'when the api_kaminari_count_with_limit feature flag is disabled' do - before do - stub_feature_flags(api_kaminari_count_with_limit: false) - end - - it_behaves_like 'paginated response' - it_behaves_like 'response with pagination headers' - end - - context 'when the api_kaminari_count_with_limit feature flag is enabled' do - before do - stub_feature_flags(api_kaminari_count_with_limit: true) - end - - context 'when resources count is less than MAX_COUNT_LIMIT' do - before do - stub_const("::Kaminari::ActiveRecordRelationMethods::MAX_COUNT_LIMIT", 4) - end - - it_behaves_like 'paginated response' - it_behaves_like 'response with pagination headers' - end - - context 'when resources count is more than MAX_COUNT_LIMIT' do - before do - stub_const("::Kaminari::ActiveRecordRelationMethods::MAX_COUNT_LIMIT", 2) - end - - it_behaves_like 'paginated response' - - it 'does not return the X-Total and X-Total-Pages headers' do - expect_no_header('X-Total') - expect_no_header('X-Total-Pages') - expect_header('X-Per-Page', '2') - expect_header('X-Page', '1') - expect_header('X-Next-Page', '2') - expect_header('X-Prev-Page', '') + describe '#paginate' do + let(:relation) { double("relation") } + let(:offset_pagination) { double("offset pagination") } + let(:expected_result) { double("result") } - expect_header('Link', anything) do |_key, val| - expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 1).to_query}>; rel="first")) - expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 2).to_query}>; rel="next")) - expect(val).not_to include('rel="last"') - expect(val).not_to include('rel="prev"') - end + 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) - subject.paginate(resource) - end - end - end - end + result = subject.paginate(relation) - describe 'second page' do - let(:query) { base_query.merge(page: 2, per_page: 2) } - - it 'returns appropriate amount of resources' do - expect(subject.paginate(resource).count).to eq 1 - end - - it 'adds appropriate headers' do - expect_header('X-Total', '3') - expect_header('X-Total-Pages', '2') - expect_header('X-Per-Page', '2') - expect_header('X-Page', '2') - expect_header('X-Next-Page', '') - expect_header('X-Prev-Page', '1') - - expect_header('Link', anything) do |_key, val| - expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 1).to_query}>; rel="first")) - expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 2).to_query}>; rel="last")) - expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 1).to_query}>; rel="prev")) - expect(val).not_to include('rel="next"') - end - - subject.paginate(resource) - end - end - - context 'if order' do - it 'is not present it adds default order(:id) if no order is present' do - resource.order_values = [] - - paginated_relation = subject.paginate(resource) - - expect(resource.order_values).to be_empty - expect(paginated_relation.order_values).to be_present - expect(paginated_relation.order_values.first).to be_ascending - expect(paginated_relation.order_values.first.expr.name).to eq 'id' - end - - it 'is present it does not add anything' do - paginated_relation = subject.paginate(resource.order(created_at: :desc)) - - expect(paginated_relation.order_values).to be_present - expect(paginated_relation.order_values.first).to be_descending - expect(paginated_relation.order_values.first.expr.name).to eq 'created_at' - end - end + expect(result).to eq(expected_result) end - - context 'when resource empty' do - describe 'first page' do - let(:query) { base_query.merge(page: 1, per_page: 2) } - - it 'returns appropriate amount of resources' do - expect(subject.paginate(resource).count).to eq 0 - end - - it 'adds appropriate headers' do - expect_header('X-Total', '0') - expect_header('X-Total-Pages', '1') - expect_header('X-Per-Page', '2') - expect_header('X-Page', '1') - expect_header('X-Next-Page', '') - expect_header('X-Prev-Page', '') - - expect_header('Link', anything) do |_key, val| - expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 1).to_query}>; rel="first")) - expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 1).to_query}>; rel="last")) - expect(val).not_to include('rel="prev"') - expect(val).not_to include('rel="next"') - expect(val).not_to include('page=0') - end - - subject.paginate(resource) - end - end - end - end - - def expect_header(*args, &block) - expect(subject).to receive(:header).with(*args, &block) - end - - def expect_no_header(*args, &block) - expect(subject).not_to receive(:header).with(*args) - end - - def expect_message(method) - expect(subject).to receive(method) - .at_least(:once).and_return(value) end end diff --git a/spec/lib/api/helpers_spec.rb b/spec/lib/api/helpers_spec.rb index 0624c25e7343e5a879183b8f5b5c3331a0d5fc14..81c4563feb63640bb49e8323e0c403adc12e3985 100644 --- a/spec/lib/api/helpers_spec.rb +++ b/spec/lib/api/helpers_spec.rb @@ -174,4 +174,18 @@ describe API::Helpers do end end end + + describe '#track_event' do + it "creates a gitlab tracking event" do + expect(Gitlab::Tracking).to receive(:event).with('foo', 'my_event', {}) + + subject.track_event('my_event', category: 'foo') + end + + it "logs an exception" do + expect(Rails.logger).to receive(:warn).with(/Tracking event failed/) + + subject.track_event('my_event', category: nil) + end + end end diff --git a/spec/lib/backup/repository_spec.rb b/spec/lib/backup/repository_spec.rb index bf827fb3914888d96e4fb073cdaa6671cdd52aee..5f120f258cd2e69ef882c69617c570908caee490 100644 --- a/spec/lib/backup/repository_spec.rb +++ b/spec/lib/backup/repository_spec.rb @@ -70,7 +70,7 @@ describe Backup::Repository do end context 'restoring object pools' do - it 'schedules restoring of the pool' do + it 'schedules restoring of the pool', :sidekiq_might_not_need_inline do pool_repository = create(:pool_repository, :failed) pool_repository.delete_object_pool diff --git a/spec/lib/banzai/filter/asset_proxy_filter_spec.rb b/spec/lib/banzai/filter/asset_proxy_filter_spec.rb index 0c4ccbf28f44bab896d69a82058e8a3be08ca9d8..ff2346fe1ba39bcece7e15fb4d3905af44261859 100644 --- a/spec/lib/banzai/filter/asset_proxy_filter_spec.rb +++ b/spec/lib/banzai/filter/asset_proxy_filter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Banzai::Filter::AssetProxyFilter do diff --git a/spec/lib/banzai/filter/inline_grafana_metrics_filter_spec.rb b/spec/lib/banzai/filter/inline_grafana_metrics_filter_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..fd6f8816b63cc8188f7cbddb814211e20dec798d --- /dev/null +++ b/spec/lib/banzai/filter/inline_grafana_metrics_filter_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Banzai::Filter::InlineGrafanaMetricsFilter do + include FilterSpecHelper + + let_it_be(:project) { create(:project) } + let_it_be(:grafana_integration) { create(:grafana_integration, project: project) } + + let(:input) { %(<a href="#{url}">example</a>) } + let(:doc) { filter(input) } + + let(:url) { grafana_integration.grafana_url + dashboard_path } + let(:dashboard_path) do + '/d/XDaNK6amz/gitlab-omnibus-redis' \ + '?from=1570397739557&to=1570484139557' \ + '&var-instance=All&panelId=14' + end + + it 'appends a metrics charts placeholder with dashboard url after metrics links' do + node = doc.at_css('.js-render-metrics') + expect(node).to be_present + + dashboard_url = urls.project_grafana_api_metrics_dashboard_url( + project, + embedded: true, + grafana_url: url, + start: "2019-10-06T21:35:39Z", + end: "2019-10-07T21:35:39Z" + ) + + expect(node.attribute('data-dashboard-url').to_s).to eq(dashboard_url) + end + + context 'when the dashboard link is part of a paragraph' do + let(:paragraph) { %(This is an <a href="#{url}">example</a> of metrics.) } + let(:input) { %(<p>#{paragraph}</p>) } + + it 'appends the charts placeholder after the enclosing paragraph' do + expect(unescape(doc.at_css('p').to_s)).to include(paragraph) + expect(doc.at_css('.js-render-metrics')).to be_present + end + end + + context 'when grafana is not configured' do + before do + allow(project).to receive(:grafana_integration).and_return(nil) + end + + it 'leaves the markdown unchanged' do + expect(unescape(doc.to_s)).to eq(input) + end + end + + context 'when parameters are missing' do + let(:dashboard_path) { '/d/XDaNK6amz/gitlab-omnibus-redis' } + + it 'leaves the markdown unchanged' do + expect(unescape(doc.to_s)).to eq(input) + end + end + + private + + # Nokogiri escapes the URLs, but we don't care about that + # distinction for the purposes of this filter + def unescape(html) + CGI.unescapeHTML(html) + end +end diff --git a/spec/lib/banzai/filter/inline_metrics_redactor_filter_spec.rb b/spec/lib/banzai/filter/inline_metrics_redactor_filter_spec.rb index a99cd7d607671de71f770a2651288d31e9572ea8..745b9133529d94d235da832f0f989ea86f6d0cb3 100644 --- a/spec/lib/banzai/filter/inline_metrics_redactor_filter_spec.rb +++ b/spec/lib/banzai/filter/inline_metrics_redactor_filter_spec.rb @@ -18,30 +18,48 @@ describe Banzai::Filter::InlineMetricsRedactorFilter do end context 'with a metrics charts placeholder' do - let(:input) { %(<div class="js-render-metrics" data-dashboard-url="#{url}"></div>) } + shared_examples_for 'a supported metrics dashboard url' do + context 'no user is logged in' do + it 'redacts the placeholder' do + expect(doc.to_s).to be_empty + end + end - context 'no user is logged in' do - it 'redacts the placeholder' do - expect(doc.to_s).to be_empty + context 'the user does not have permission do see charts' do + let(:doc) { filter(input, current_user: build(:user)) } + + it 'redacts the placeholder' do + expect(doc.to_s).to be_empty + end end - end - context 'the user does not have permission do see charts' do - let(:doc) { filter(input, current_user: build(:user)) } + context 'the user has requisite permissions' do + let(:user) { create(:user) } + let(:doc) { filter(input, current_user: user) } - it 'redacts the placeholder' do - expect(doc.to_s).to be_empty + it 'leaves the placeholder' do + project.add_maintainer(user) + + expect(doc.to_s).to eq input + end end end - context 'the user has requisite permissions' do - let(:user) { create(:user) } - let(:doc) { filter(input, current_user: user) } + let(:input) { %(<div class="js-render-metrics" data-dashboard-url="#{url}"></div>) } - it 'leaves the placeholder' do - project.add_maintainer(user) + it_behaves_like 'a supported metrics dashboard url' + + context 'for a grafana dashboard' do + let(:url) { urls.project_grafana_api_metrics_dashboard_url(project, embedded: true) } + + it_behaves_like 'a supported metrics dashboard url' + end - expect(doc.to_s).to eq input + context 'for an internal non-dashboard url' do + let(:url) { urls.project_url(project) } + + it 'leaves the placeholder' do + expect(doc.to_s).to be_empty end end end diff --git a/spec/lib/banzai/filter/video_link_filter_spec.rb b/spec/lib/banzai/filter/video_link_filter_spec.rb index a395b021f32dc6f6f0b57aadd57aa80f5dcc3aa4..c324c36fe4d8e1576f4f0d72867e5fb7dd74d18b 100644 --- a/spec/lib/banzai/filter/video_link_filter_spec.rb +++ b/spec/lib/banzai/filter/video_link_filter_spec.rb @@ -32,7 +32,7 @@ describe Banzai::Filter::VideoLinkFilter do expect(video.name).to eq 'video' expect(video['src']).to eq src - expect(video['width']).to eq "100%" + expect(video['width']).to eq "400" expect(paragraph.name).to eq 'p' diff --git a/spec/lib/bitbucket/representation/pull_request_spec.rb b/spec/lib/bitbucket/representation/pull_request_spec.rb index 70b51b8efecce5ad385eb8abb0148976f8dbfa91..6a9df0e5099f178a01249027128709606c99697f 100644 --- a/spec/lib/bitbucket/representation/pull_request_spec.rb +++ b/spec/lib/bitbucket/representation/pull_request_spec.rb @@ -20,6 +20,7 @@ describe Bitbucket::Representation::PullRequest do describe '#state' do it { expect(described_class.new({ 'state' => 'MERGED' }).state).to eq('merged') } it { expect(described_class.new({ 'state' => 'DECLINED' }).state).to eq('closed') } + it { expect(described_class.new({ 'state' => 'SUPERSEDED' }).state).to eq('closed') } it { expect(described_class.new({}).state).to eq('opened') } end diff --git a/spec/lib/container_registry/client_spec.rb b/spec/lib/container_registry/client_spec.rb index 3782c30e88aa84eadb040b8078505752d793a899..a493b96b1e444c91c7ae1cae419c7d71f65a5e73 100644 --- a/spec/lib/container_registry/client_spec.rb +++ b/spec/lib/container_registry/client_spec.rb @@ -99,8 +99,8 @@ describe ContainerRegistry::Client do stub_upload('path', 'content', 'sha256:123', 400) end - it 'returns nil' do - expect(subject).to be nil + it 'returns a failure' do + expect(subject).not_to be_success end end end @@ -125,6 +125,14 @@ describe ContainerRegistry::Client do expect(subject).to eq(result_manifest) end + + context 'when upload fails' do + before do + stub_upload('path', "{\n \"config\": {\n }\n}", 'sha256:4435000728ee66e6a80e55637fc22725c256b61de344a2ecdeaac6bdb36e8bc3', 500) + end + + it { is_expected.to be nil } + end end describe '#put_tag' do diff --git a/spec/lib/gitlab/asciidoc_spec.rb b/spec/lib/gitlab/asciidoc_spec.rb index 7c65525b8dc274e513b038a4ac6708229a52008a..415a6e623743ce3e9a3cfb05c67ffcc5611ab301 100644 --- a/spec/lib/gitlab/asciidoc_spec.rb +++ b/spec/lib/gitlab/asciidoc_spec.rb @@ -58,7 +58,7 @@ module Gitlab }, 'image with onerror' => { input: 'image:https://localhost.com/image.png[Alt text" onerror="alert(7)]', - output: "<div>\n<p><span><img src=\"https://localhost.com/image.png\" alt='Alt text\" onerror=\"alert(7)'></span></p>\n</div>" + output: "<div>\n<p><span><a class=\"no-attachment-icon\" href=\"https://localhost.com/image.png\" target=\"_blank\" rel=\"noopener noreferrer\"><img src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt='Alt text\" onerror=\"alert(7)' class=\"lazy\" data-src=\"https://localhost.com/image.png\"></a></span></p>\n</div>" }, 'fenced code with inline script' => { input: '```mypre"><script>alert(3)</script>', @@ -73,6 +73,20 @@ module Gitlab end end + context "images" do + it "does lazy load and link image" do + input = 'image:https://localhost.com/image.png[]' + output = "<div>\n<p><span><a class=\"no-attachment-icon\" href=\"https://localhost.com/image.png\" target=\"_blank\" rel=\"noopener noreferrer\"><img src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"image\" class=\"lazy\" data-src=\"https://localhost.com/image.png\"></a></span></p>\n</div>" + expect(render(input, context)).to include(output) + end + + it "does not automatically link image if link is explicitly defined" do + input = 'image:https://localhost.com/image.png[link=https://gitlab.com]' + output = "<div>\n<p><span><a href=\"https://gitlab.com\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"><img src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"image\" class=\"lazy\" data-src=\"https://localhost.com/image.png\"></a></span></p>\n</div>" + expect(render(input, context)).to include(output) + end + end + context 'with admonition' do it 'preserves classes' do input = <<~ADOC @@ -107,7 +121,7 @@ module Gitlab ADOC output = <<~HTML - <h2>Title</h2> + <h2>Title</h2> HTML expect(render(input, context)).to include(output.strip) @@ -149,15 +163,15 @@ module Gitlab ADOC output = <<~HTML - <div> - <p>This paragraph has a footnote.<sup>[<a id="_footnoteref_1" href="#_footnotedef_1" title="View footnote.">1</a>]</sup></p> - </div> - <div> - <hr> - <div id="_footnotedef_1"> - <a href="#_footnoteref_1">1</a>. This is the text of the footnote. - </div> - </div> + <div> + <p>This paragraph has a footnote.<sup>[<a id="_footnoteref_1" href="#_footnotedef_1" title="View footnote.">1</a>]</sup></p> + </div> + <div> + <hr> + <div id="_footnotedef_1"> + <a href="#_footnoteref_1">1</a>. This is the text of the footnote. + </div> + </div> HTML expect(render(input, context)).to include(output.strip) @@ -183,34 +197,34 @@ module Gitlab ADOC output = <<~HTML - <h1>Title</h1> - <div> - <h2 id="user-content-first-section"> - <a class="anchor" href="#user-content-first-section"></a>First section</h2> - <div> - <div> - <p>This is the first section.</p> - </div> - </div> - </div> - <div> - <h2 id="user-content-second-section"> - <a class="anchor" href="#user-content-second-section"></a>Second section</h2> - <div> - <div> - <p>This is the second section.</p> - </div> - </div> - </div> - <div> - <h2 id="user-content-thunder"> - <a class="anchor" href="#user-content-thunder"></a>Thunder ⚡ !</h2> - <div> - <div> - <p>This is the third section.</p> - </div> - </div> - </div> + <h1>Title</h1> + <div> + <h2 id="user-content-first-section"> + <a class="anchor" href="#user-content-first-section"></a>First section</h2> + <div> + <div> + <p>This is the first section.</p> + </div> + </div> + </div> + <div> + <h2 id="user-content-second-section"> + <a class="anchor" href="#user-content-second-section"></a>Second section</h2> + <div> + <div> + <p>This is the second section.</p> + </div> + </div> + </div> + <div> + <h2 id="user-content-thunder"> + <a class="anchor" href="#user-content-thunder"></a>Thunder ⚡ !</h2> + <div> + <div> + <p>This is the third section.</p> + </div> + </div> + </div> HTML expect(render(input, context)).to include(output.strip) diff --git a/spec/lib/gitlab/auth/ldap/auth_hash_spec.rb b/spec/lib/gitlab/auth/ldap/auth_hash_spec.rb index 05541972f871977b1d5e33edb3f4865fc41881e4..adb8e138ca7e197433a7252ba792322448ff73ef 100644 --- a/spec/lib/gitlab/auth/ldap/auth_hash_spec.rb +++ b/spec/lib/gitlab/auth/ldap/auth_hash_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::Auth::LDAP::AuthHash do @@ -91,7 +93,7 @@ describe Gitlab::Auth::LDAP::AuthHash do let(:given_uid) { 'uid=John Smith,ou=People,dc=example,dc=com' } before do - raw_info[:uid] = ['JOHN'] + raw_info[:uid] = [+'JOHN'] end it 'enabled the username attribute is lower cased' do diff --git a/spec/lib/gitlab/auth/ldap/config_spec.rb b/spec/lib/gitlab/auth/ldap/config_spec.rb index 577dfe51949d4b28393604d03a49a04c6a310b06..e4a90d4018d4f3249f85e1229102f095043d0bef 100644 --- a/spec/lib/gitlab/auth/ldap/config_spec.rb +++ b/spec/lib/gitlab/auth/ldap/config_spec.rb @@ -535,4 +535,23 @@ AtlErSqafbECNDSwS5BX8yDpu5yRBJ4xegO/rNlmb8ICRYkuJapD1xXicFOsmfUK end end end + + describe 'sign_in_enabled?' do + using RSpec::Parameterized::TableSyntax + + where(:enabled, :prevent_ldap_sign_in, :result) do + true | false | true + 'true' | false | true + true | true | false + false | nil | false + end + + with_them do + it do + stub_ldap_setting(enabled: enabled, prevent_ldap_sign_in: prevent_ldap_sign_in) + + expect(described_class.sign_in_enabled?).to eq(result) + end + end + end end diff --git a/spec/lib/gitlab/auth/ldap/person_spec.rb b/spec/lib/gitlab/auth/ldap/person_spec.rb index 1527fe60fb94d360e522a1587ab391856ac9aa7e..985732e69f9627f8720125ea6ae3cc57bc1befcc 100644 --- a/spec/lib/gitlab/auth/ldap/person_spec.rb +++ b/spec/lib/gitlab/auth/ldap/person_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::Auth::LDAP::Person do @@ -135,7 +137,7 @@ describe Gitlab::Auth::LDAP::Person do let(:username_attribute) { 'uid' } before do - entry[username_attribute] = 'JOHN' + entry[username_attribute] = +'JOHN' @person = described_class.new(entry, 'ldapmain') end diff --git a/spec/lib/gitlab/background_migration/legacy_upload_mover_spec.rb b/spec/lib/gitlab/background_migration/legacy_upload_mover_spec.rb index c1eaf1d34332ae738036be8294ebd9c8170677f8..f2de73d5aea9e66ec4174a991a70e699d4bbb194 100644 --- a/spec/lib/gitlab/background_migration/legacy_upload_mover_spec.rb +++ b/spec/lib/gitlab/background_migration/legacy_upload_mover_spec.rb @@ -91,15 +91,26 @@ describe Gitlab::BackgroundMigration::LegacyUploadMover do end end - context 'when no model found for the upload' do + context 'when no note found for the upload' do before do - legacy_upload.model = nil + legacy_upload.model_id = nil + legacy_upload.model_type = 'Note' expect_error_log end it_behaves_like 'legacy upload deletion' end + context 'when upload does not belong to a note' do + before do + legacy_upload.model = create(:appearance) + end + + it 'does not remove the upload' do + expect { described_class.new(legacy_upload).execute }.not_to change { Upload.count } + end + end + context 'when the upload move fails' do before do expect(FileUploader).to receive(:copy_to).and_raise('failed') diff --git a/spec/lib/gitlab/background_migration/legacy_uploads_migrator_spec.rb b/spec/lib/gitlab/background_migration/legacy_uploads_migrator_spec.rb index cabca3dbef908272ed1938ccff048d4cd08df763..85187d039c1ee6e64f0bf478181e234cabdac1f3 100644 --- a/spec/lib/gitlab/background_migration/legacy_uploads_migrator_spec.rb +++ b/spec/lib/gitlab/background_migration/legacy_uploads_migrator_spec.rb @@ -35,6 +35,8 @@ describe Gitlab::BackgroundMigration::LegacyUploadsMigrator do let!(:legacy_upload_no_file) { create_upload(note2, false) } let!(:legacy_upload_legacy_project) { create_upload(note_legacy) } + let!(:appearance) { create(:appearance, :with_logo) } + let(:start_id) { 1 } let(:end_id) { 10000 } @@ -52,12 +54,18 @@ describe Gitlab::BackgroundMigration::LegacyUploadsMigrator do expect(File.exist?(legacy_upload_legacy_project.absolute_path)).to be_falsey end - it 'removes all AttachmentUploader records' do - expect { subject }.to change { Upload.where(uploader: 'AttachmentUploader').count }.from(3).to(0) + it 'removes all Note AttachmentUploader records' do + expect { subject }.to change { Upload.where(uploader: 'AttachmentUploader').count }.from(4).to(1) end it 'creates new uploads for successfully migrated records' do expect { subject }.to change { Upload.where(uploader: 'FileUploader').count }.from(0).to(2) end + + it 'does not remove appearance uploads' do + subject + + expect(appearance.logo.file).to exist + end end # rubocop: enable RSpec/FactoriesInMigrationSpecs diff --git a/spec/lib/gitlab/background_migration/schedule_calculate_wiki_sizes_spec.rb b/spec/lib/gitlab/background_migration/schedule_calculate_wiki_sizes_spec.rb index f877e8cc1b87b8e12dbee1473accab364983a211..399db4ac2593acf5888ff11fb5d023ae756c5353 100644 --- a/spec/lib/gitlab/background_migration/schedule_calculate_wiki_sizes_spec.rb +++ b/spec/lib/gitlab/background_migration/schedule_calculate_wiki_sizes_spec.rb @@ -33,7 +33,7 @@ describe ScheduleCalculateWikiSizes, :migration, :sidekiq do end end - it 'calculates missing wiki sizes' do + it 'calculates missing wiki sizes', :sidekiq_might_not_need_inline do expect(project_statistics.find_by(id: 2).wiki_size).to be_nil expect(project_statistics.find_by(id: 3).wiki_size).to be_nil diff --git a/spec/lib/gitlab/badge/pipeline/status_spec.rb b/spec/lib/gitlab/badge/pipeline/status_spec.rb index 684c6829879db1752b59d9ece544508d6b9328c3..ab8d1f0ec5ba104e6b14545ac0c8babb56062c52 100644 --- a/spec/lib/gitlab/badge/pipeline/status_spec.rb +++ b/spec/lib/gitlab/badge/pipeline/status_spec.rb @@ -26,7 +26,7 @@ describe Gitlab::Badge::Pipeline::Status do end end - context 'pipeline exists' do + context 'pipeline exists', :sidekiq_might_not_need_inline do let!(:pipeline) { create_pipeline(project, sha, branch) } context 'pipeline success' do diff --git a/spec/lib/gitlab/bare_repository_import/importer_spec.rb b/spec/lib/gitlab/bare_repository_import/importer_spec.rb index 2fb9f1a0a08b38284f841d8b3e40a1741ff23fb1..ddb1d3cea218370c1d3ba9fef313c8e3b5ac1af8 100644 --- a/spec/lib/gitlab/bare_repository_import/importer_spec.rb +++ b/spec/lib/gitlab/bare_repository_import/importer_spec.rb @@ -90,7 +90,7 @@ describe Gitlab::BareRepositoryImport::Importer, :seed_helper do hook_path = File.join(repo_path, 'hooks') expect(gitlab_shell.repository_exists?(project.repository_storage, repo_path)).to be(true) - expect(gitlab_shell.exists?(project.repository_storage, hook_path)).to be(true) + expect(TestEnv.storage_dir_exists?(project.repository_storage, hook_path)).to be(true) end context 'hashed storage enabled' do diff --git a/spec/lib/gitlab/bitbucket_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_import/importer_spec.rb index 7f7a285c45305cd671df943547afec85491b8944..b0d07c6e0b079316a5a131689cb4a96daed53f79 100644 --- a/spec/lib/gitlab/bitbucket_import/importer_spec.rb +++ b/spec/lib/gitlab/bitbucket_import/importer_spec.rb @@ -158,6 +158,7 @@ describe Gitlab::BitbucketImport::Importer do expect { subject.execute }.to change { MergeRequest.count }.by(1) merge_request = MergeRequest.first + expect(merge_request.state).to eq('merged') expect(merge_request.notes.count).to eq(2) expect(merge_request.notes.map(&:discussion_id).uniq.count).to eq(1) diff --git a/spec/lib/gitlab/checks/lfs_integrity_spec.rb b/spec/lib/gitlab/checks/lfs_integrity_spec.rb index 88e8f5d74d178050ac9eec64eab669dc99fc504b..505f117034edb78d466ea4bbe5a850075142bf67 100644 --- a/spec/lib/gitlab/checks/lfs_integrity_spec.rb +++ b/spec/lib/gitlab/checks/lfs_integrity_spec.rb @@ -58,7 +58,7 @@ describe Gitlab::Checks::LfsIntegrity do end end - context 'for forked project' do + context 'for forked project', :sidekiq_might_not_need_inline do let(:parent_project) { create(:project, :repository) } let(:project) { fork_project(parent_project, nil, repository: true) } diff --git a/spec/lib/gitlab/ci/ansi2json/style_spec.rb b/spec/lib/gitlab/ci/ansi2json/style_spec.rb index 88a0ca3585933c92831fa82ab9e59fc865beca42..5110c2154154486478454e3595b92256cb9b5f69 100644 --- a/spec/lib/gitlab/ci/ansi2json/style_spec.rb +++ b/spec/lib/gitlab/ci/ansi2json/style_spec.rb @@ -143,6 +143,7 @@ describe Gitlab::Ci::Ansi2json::Style do [[], %w[106], 'term-bg-l-cyan', 'sets bg color light cyan'], [[], %w[107], 'term-bg-l-white', 'sets bg color light white'], # reset + [%w[1], %w[], '', 'resets style from format bold'], [%w[1], %w[0], '', 'resets style from format bold'], [%w[1 3], %w[0], '', 'resets style from format bold and italic'], [%w[1 3 term-fg-l-red term-bg-yellow], %w[0], '', 'resets all formats and colors'], diff --git a/spec/lib/gitlab/ci/ansi2json_spec.rb b/spec/lib/gitlab/ci/ansi2json_spec.rb index 3c6bc46436bd638348b0ae21436d2f2209f450a0..124379fa321f629ca383154e1318d2e927f306c9 100644 --- a/spec/lib/gitlab/ci/ansi2json_spec.rb +++ b/spec/lib/gitlab/ci/ansi2json_spec.rb @@ -12,11 +12,26 @@ describe Gitlab::Ci::Ansi2json do ]) end - it 'adds new line in a separate element' do - expect(convert_json("Hello\nworld")).to eq([ - { offset: 0, content: [{ text: 'Hello' }] }, - { offset: 6, content: [{ text: 'world' }] } - ]) + context 'new lines' do + it 'adds new line when encountering \n' do + expect(convert_json("Hello\nworld")).to eq([ + { offset: 0, content: [{ text: 'Hello' }] }, + { offset: 6, content: [{ text: 'world' }] } + ]) + end + + it 'adds new line when encountering \r\n' do + expect(convert_json("Hello\r\nworld")).to eq([ + { offset: 0, content: [{ text: 'Hello' }] }, + { offset: 7, content: [{ text: 'world' }] } + ]) + end + + it 'replace the current line when encountering \r' do + expect(convert_json("Hello\rworld")).to eq([ + { offset: 0, content: [{ text: 'world' }] } + ]) + end end it 'recognizes color changing ANSI sequences' do @@ -113,10 +128,6 @@ describe Gitlab::Ci::Ansi2json do content: [], section_duration: '01:03', section: 'prepare-script' - }, - { - offset: 63, - content: [] } ]) end @@ -134,10 +145,6 @@ describe Gitlab::Ci::Ansi2json do content: [], section: 'prepare-script', section_duration: '01:03' - }, - { - offset: 56, - content: [] } ]) end @@ -157,7 +164,7 @@ describe Gitlab::Ci::Ansi2json do section_duration: '01:03' }, { - offset: 49, + offset: 91, content: [{ text: 'world' }] } ]) @@ -198,7 +205,7 @@ describe Gitlab::Ci::Ansi2json do expect(convert_json("#{section_start}hello")).to eq([ { offset: 0, - content: [{ text: "#{section_start.gsub("\033[0K", '')}hello" }] + content: [{ text: 'hello' }] } ]) end @@ -211,30 +218,26 @@ describe Gitlab::Ci::Ansi2json do expect(convert_json("#{section_start}hello")).to eq([ { offset: 0, - content: [{ text: "#{section_start.gsub("\033[0K", '').gsub('<', '<')}hello" }] + content: [{ text: 'hello' }] } ]) end end - it 'prevents XSS injection' do - trace = "#{section_start}section_end:1:2<script>alert('XSS Hack!');</script>#{section_end}" + it 'prints HTML tags as is' do + trace = "#{section_start}section_end:1:2<div>hello</div>#{section_end}" expect(convert_json(trace)).to eq([ { offset: 0, - content: [{ text: "section_end:1:2<script>alert('XSS Hack!');</script>" }], + content: [{ text: "section_end:1:2<div>hello</div>" }], section: 'prepare-script', section_header: true }, { - offset: 95, + offset: 75, content: [], section: 'prepare-script', section_duration: '01:03' - }, - { - offset: 95, - content: [] } ]) end @@ -274,7 +277,7 @@ describe Gitlab::Ci::Ansi2json do section_duration: '00:02' }, { - offset: 106, + offset: 155, content: [{ text: 'baz' }], section: 'prepare-script' }, @@ -285,7 +288,7 @@ describe Gitlab::Ci::Ansi2json do section_duration: '01:03' }, { - offset: 158, + offset: 200, content: [{ text: 'world' }] } ]) @@ -318,14 +321,10 @@ describe Gitlab::Ci::Ansi2json do section_duration: '00:02' }, { - offset: 115, + offset: 164, content: [], section: 'prepare-script', section_duration: '01:03' - }, - { - offset: 164, - content: [] } ]) end @@ -380,7 +379,7 @@ describe Gitlab::Ci::Ansi2json do ] end - it 'returns the full line' do + it 'returns the line since last partially processed line' do expect(pass2.lines).to eq(lines) expect(pass2.append).to be_truthy end @@ -399,7 +398,7 @@ describe Gitlab::Ci::Ansi2json do ] end - it 'returns the full line' do + it 'returns the line since last partially processed line' do expect(pass2.lines).to eq(lines) expect(pass2.append).to be_falsey end @@ -416,7 +415,7 @@ describe Gitlab::Ci::Ansi2json do ] end - it 'returns the full line' do + it 'returns a blank line and the next line' do expect(pass2.lines).to eq(lines) expect(pass2.append).to be_falsey end @@ -502,10 +501,6 @@ describe Gitlab::Ci::Ansi2json do content: [], section: 'prepare-script', section_duration: '01:03' - }, - { - offset: 77, - content: [] } ] end diff --git a/spec/lib/gitlab/ci/build/context/build_spec.rb b/spec/lib/gitlab/ci/build/context/build_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3adde213f598c48918f3a569fff11150135c1157 --- /dev/null +++ b/spec/lib/gitlab/ci/build/context/build_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe Gitlab::Ci::Build::Context::Build do + let(:pipeline) { create(:ci_pipeline) } + let(:seed_attributes) { { 'name' => 'some-job' } } + + let(:context) { described_class.new(pipeline, seed_attributes) } + + describe '#variables' do + subject { context.variables } + + it { is_expected.to include('CI_COMMIT_REF_NAME' => 'master') } + it { is_expected.to include('CI_PIPELINE_IID' => pipeline.iid.to_s) } + it { is_expected.to include('CI_PROJECT_PATH' => pipeline.project.full_path) } + it { is_expected.to include('CI_JOB_NAME' => 'some-job') } + it { is_expected.to include('CI_BUILD_REF_NAME' => 'master') } + + context 'without passed build-specific attributes' do + let(:context) { described_class.new(pipeline) } + + it { is_expected.to include('CI_JOB_NAME' => nil) } + it { is_expected.to include('CI_BUILD_REF_NAME' => 'master') } + it { is_expected.to include('CI_PROJECT_PATH' => pipeline.project.full_path) } + end + end +end diff --git a/spec/lib/gitlab/ci/build/context/global_spec.rb b/spec/lib/gitlab/ci/build/context/global_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6bc8f862779eb31991a395f10e6d7f0b98b45b9e --- /dev/null +++ b/spec/lib/gitlab/ci/build/context/global_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe Gitlab::Ci::Build::Context::Global do + let(:pipeline) { create(:ci_pipeline) } + let(:yaml_variables) { {} } + + let(:context) { described_class.new(pipeline, yaml_variables: yaml_variables) } + + describe '#variables' do + subject { context.variables } + + it { is_expected.to include('CI_COMMIT_REF_NAME' => 'master') } + it { is_expected.to include('CI_PIPELINE_IID' => pipeline.iid.to_s) } + it { is_expected.to include('CI_PROJECT_PATH' => pipeline.project.full_path) } + + it { is_expected.not_to have_key('CI_JOB_NAME') } + it { is_expected.not_to have_key('CI_BUILD_REF_NAME') } + + context 'with passed yaml variables' do + let(:yaml_variables) { [{ key: 'SUPPORTED', value: 'parsed', public: true }] } + + it { is_expected.to include('SUPPORTED' => 'parsed') } + end + end +end diff --git a/spec/lib/gitlab/ci/build/policy/variables_spec.rb b/spec/lib/gitlab/ci/build/policy/variables_spec.rb index 7140c14facb58494815c8d06269d27e24534a4af..66f2cb640b9a39cf6839a240a467a9e7949b3efe 100644 --- a/spec/lib/gitlab/ci/build/policy/variables_spec.rb +++ b/spec/lib/gitlab/ci/build/policy/variables_spec.rb @@ -16,7 +16,7 @@ describe Gitlab::Ci::Build::Policy::Variables do let(:seed) do double('build seed', to_resource: ci_build, - scoped_variables_hash: ci_build.scoped_variables_hash + variables: ci_build.scoped_variables_hash ) end @@ -91,7 +91,7 @@ describe Gitlab::Ci::Build::Policy::Variables do let(:seed) do double('bridge seed', to_resource: bridge, - scoped_variables_hash: ci_build.scoped_variables_hash + variables: ci_build.scoped_variables_hash ) end diff --git a/spec/lib/gitlab/ci/build/rules/rule_spec.rb b/spec/lib/gitlab/ci/build/rules/rule_spec.rb index 99852bd4228efedcd2a37dea2ebc63af4079bfff..04cdaa9d0aede0cd918f8a9c045b48de11564e5c 100644 --- a/spec/lib/gitlab/ci/build/rules/rule_spec.rb +++ b/spec/lib/gitlab/ci/build/rules/rule_spec.rb @@ -1,10 +1,12 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::Ci::Build::Rules::Rule do let(:seed) do double('build seed', to_resource: ci_build, - scoped_variables_hash: ci_build.scoped_variables_hash + variables: ci_build.scoped_variables_hash ) end diff --git a/spec/lib/gitlab/ci/build/rules_spec.rb b/spec/lib/gitlab/ci/build/rules_spec.rb index d7793ebc80693607b53e777117969b7707e11cc1..1ebcc4f9414fd213b0524681c8c76ca71ba1002d 100644 --- a/spec/lib/gitlab/ci/build/rules_spec.rb +++ b/spec/lib/gitlab/ci/build/rules_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::Ci::Build::Rules do @@ -7,11 +9,11 @@ describe Gitlab::Ci::Build::Rules do let(:seed) do double('build seed', to_resource: ci_build, - scoped_variables_hash: ci_build.scoped_variables_hash + variables: ci_build.scoped_variables_hash ) end - let(:rules) { described_class.new(rule_list) } + let(:rules) { described_class.new(rule_list, default_when: 'on_success') } describe '.new' do let(:rules_ivar) { rules.instance_variable_get :@rule_list } @@ -60,7 +62,7 @@ describe Gitlab::Ci::Build::Rules do context 'with a specified default when:' do let(:rule_list) { [{ if: '$VAR == null', when: 'always' }] } - let(:rules) { described_class.new(rule_list, 'manual') } + let(:rules) { described_class.new(rule_list, default_when: 'manual') } it 'sets @rule_list to an array of a single rule' do expect(rules_ivar).to be_an(Array) @@ -81,7 +83,7 @@ describe Gitlab::Ci::Build::Rules do it { is_expected.to eq(described_class::Result.new('on_success')) } context 'and when:manual set as the default' do - let(:rules) { described_class.new(rule_list, 'manual') } + let(:rules) { described_class.new(rule_list, default_when: 'manual') } it { is_expected.to eq(described_class::Result.new('manual')) } end @@ -93,7 +95,7 @@ describe Gitlab::Ci::Build::Rules do it { is_expected.to eq(described_class::Result.new('never')) } context 'and when:manual set as the default' do - let(:rules) { described_class.new(rule_list, 'manual') } + let(:rules) { described_class.new(rule_list, default_when: 'manual') } it { is_expected.to eq(described_class::Result.new('never')) } end @@ -157,7 +159,7 @@ describe Gitlab::Ci::Build::Rules do it { is_expected.to eq(described_class::Result.new('never')) } context 'and when:manual set as the default' do - let(:rules) { described_class.new(rule_list, 'manual') } + let(:rules) { described_class.new(rule_list, default_when: 'manual') } it 'does not return the default when:' do expect(subject).to eq(described_class::Result.new('never')) diff --git a/spec/lib/gitlab/ci/config/entry/artifacts_spec.rb b/spec/lib/gitlab/ci/config/entry/artifacts_spec.rb index a7f457e0f5e7f920f2169abee4dce0720b6c8b53..513a9b8f2b4cf05916cc88e19bbff3028c85cf6f 100644 --- a/spec/lib/gitlab/ci/config/entry/artifacts_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/artifacts_spec.rb @@ -28,6 +28,14 @@ describe Gitlab::Ci::Config::Entry::Artifacts do expect(entry.value).to eq config end end + + context "when value includes 'expose_as' keyword" do + let(:config) { { paths: %w[results.txt], expose_as: "Test results" } } + + it 'returns general artifact and report-type artifacts configuration' do + expect(entry.value).to eq config + end + end end context 'when entry value is not correct' do @@ -58,6 +66,84 @@ describe Gitlab::Ci::Config::Entry::Artifacts do .to include 'artifacts reports should be a hash' end end + + context "when 'expose_as' is not a string" do + let(:config) { { paths: %w[results.txt], expose_as: 1 } } + + it 'reports error' do + expect(entry.errors) + .to include 'artifacts expose as should be a string' + end + end + + context "when 'expose_as' is too long" do + let(:config) { { paths: %w[results.txt], expose_as: 'A' * 101 } } + + it 'reports error' do + expect(entry.errors) + .to include 'artifacts expose as is too long (maximum is 100 characters)' + end + end + + context "when 'expose_as' is an empty string" do + let(:config) { { paths: %w[results.txt], expose_as: '' } } + + it 'reports error' do + expect(entry.errors) + .to include 'artifacts expose as ' + Gitlab::Ci::Config::Entry::Artifacts::EXPOSE_AS_ERROR_MESSAGE + end + end + + context "when 'expose_as' contains invalid characters" do + let(:config) do + { paths: %w[results.txt], expose_as: '<script>alert("xss");</script>' } + end + + it 'reports error' do + expect(entry.errors) + .to include 'artifacts expose as ' + Gitlab::Ci::Config::Entry::Artifacts::EXPOSE_AS_ERROR_MESSAGE + end + end + + context "when 'expose_as' is used without 'paths'" do + let(:config) { { expose_as: 'Test results' } } + + it 'reports error' do + expect(entry.errors) + .to include "artifacts paths can't be blank" + end + end + + context "when 'paths' includes '*' and 'expose_as' is defined" do + let(:config) { { expose_as: 'Test results', paths: ['test.txt', 'test*.txt'] } } + + it 'reports error' do + expect(entry.errors) + .to include "artifacts paths can't contain '*' when used with 'expose_as'" + end + end + end + + context 'when feature flag :ci_expose_arbitrary_artifacts_in_mr is disabled' do + before do + stub_feature_flags(ci_expose_arbitrary_artifacts_in_mr: false) + end + + context 'when syntax is correct' do + let(:config) { { expose_as: 'Test results', paths: ['test.txt'] } } + + it 'is valid' do + expect(entry.errors).to be_empty + end + end + + context 'when syntax for :expose_as is incorrect' do + let(:config) { { paths: %w[results.txt], expose_as: '' } } + + it 'is valid' do + expect(entry.errors).to be_empty + end + end end end end diff --git a/spec/lib/gitlab/ci/config/entry/cache_spec.rb b/spec/lib/gitlab/ci/config/entry/cache_spec.rb index 9aab3664e1c3ad59ecdf3771128d01eae5a40c3a..4fa0a57dc82e0b2cfc6e127a358eccb78d28cce9 100644 --- a/spec/lib/gitlab/ci/config/entry/cache_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/cache_spec.rb @@ -12,22 +12,53 @@ describe Gitlab::Ci::Config::Entry::Cache do context 'when entry config value is correct' do let(:policy) { nil } + let(:key) { 'some key' } let(:config) do - { key: 'some key', + { key: key, untracked: true, paths: ['some/path/'], policy: policy } end describe '#value' do - it 'returns hash value' do - expect(entry.value).to eq(key: 'some key', untracked: true, paths: ['some/path/'], policy: 'pull-push') + shared_examples 'hash key value' do + it 'returns hash value' do + expect(entry.value).to eq(key: key, untracked: true, paths: ['some/path/'], policy: 'pull-push') + end + end + + it_behaves_like 'hash key value' + + context 'with files' do + let(:key) { { files: ['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' } } + + it_behaves_like 'hash key value' + end + + context 'with prefix' do + let(:key) { { prefix: 'prefix-value' } } + + it 'key is nil' do + expect(entry.value).to match(a_hash_including(key: nil)) + end end end describe '#valid?' do it { is_expected.to be_valid } + + context 'with files' do + let(:key) { { files: ['a-file', 'other-file'] } } + + it { is_expected.to be_valid } + end end context 'policy is pull-push' do @@ -87,10 +118,44 @@ describe Gitlab::Ci::Config::Entry::Cache do end context 'when descendants are invalid' do - let(:config) { { key: 1 } } + context 'with invalid keys' do + let(:config) { { key: 1 } } - it 'reports error with descendants' do - is_expected.to include 'key config should be a string or symbol' + it 'reports error with descendants' do + is_expected.to include 'key should be a hash, a string or a symbol' + end + end + + context 'with empty key' do + let(:config) { { key: {} } } + + it 'reports error with descendants' do + is_expected.to include 'key config missing required keys: files' + end + end + + context 'with invalid files' do + let(:config) { { key: { files: 'a-file' } } } + + it 'reports error with descendants' do + is_expected.to include 'key:files config should be an array of strings' + end + end + + context 'with prefix without files' do + let(:config) { { key: { prefix: 'a-prefix' } } } + + it 'reports error with descendants' do + is_expected.to include 'key config missing required keys: files' + end + end + + context 'when there is an unknown key present' do + let(:config) { { key: { unknown: 'a-file' } } } + + it 'reports error with descendants' do + is_expected.to include 'key config contains unknown keys: unknown' + end end end diff --git a/spec/lib/gitlab/ci/config/entry/commands_spec.rb b/spec/lib/gitlab/ci/config/entry/commands_spec.rb index 269a34069135c0b4c66ee7327933f820db046a99..8e7f9ab97060d7849ce5419bb5f5339ba20a3eee 100644 --- a/spec/lib/gitlab/ci/config/entry/commands_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/commands_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' describe Gitlab::Ci::Config::Entry::Commands do let(:entry) { described_class.new(config) } - context 'when entry config value is an array' do + context 'when entry config value is an array of strings' do let(:config) { %w(ls pwd) } describe '#value' do @@ -37,13 +37,74 @@ describe Gitlab::Ci::Config::Entry::Commands do end end - context 'when entry value is not valid' do + context 'when entry config value is array of arrays of strings' do + let(:config) { [['ls'], ['pwd', 'echo 1']] } + + describe '#value' do + it 'returns array of strings' do + expect(entry.value).to eq ['ls', 'pwd', 'echo 1'] + end + end + + describe '#errors' do + it 'does not append errors' do + expect(entry.errors).to be_empty + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when entry config value is array of strings and arrays of strings' do + let(:config) { ['ls', ['pwd', 'echo 1']] } + + describe '#value' do + it 'returns array of strings' do + expect(entry.value).to eq ['ls', 'pwd', 'echo 1'] + end + end + + describe '#errors' do + it 'does not append errors' do + expect(entry.errors).to be_empty + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when entry value is integer' do let(:config) { 1 } describe '#errors' do it 'saves errors' do expect(entry.errors) - .to include 'commands config should be an array of strings or a string' + .to include 'commands config should be a string or an array containing strings and arrays of strings' + end + end + end + + context 'when entry value is multi-level nested array' do + let(:config) { [['ls', ['echo 1']], 'pwd'] } + + describe '#errors' do + it 'saves errors' do + expect(entry.errors) + .to include 'commands config should be a string or an array containing strings and arrays of strings' + end + end + + describe '#valid?' do + it 'is not valid' do + expect(entry).not_to be_valid end end end diff --git a/spec/lib/gitlab/ci/config/entry/default_spec.rb b/spec/lib/gitlab/ci/config/entry/default_spec.rb index 27d63dbd4071428f3d6232e7c32fb6f78de5f47e..dad4f408e50c569bb89f82d78244ad9ff4cf9e48 100644 --- a/spec/lib/gitlab/ci/config/entry/default_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/default_spec.rb @@ -5,6 +5,18 @@ require 'spec_helper' describe Gitlab::Ci::Config::Entry::Default do let(:entry) { described_class.new(config) } + it_behaves_like 'with inheritable CI config' do + let(:inheritable_key) { nil } + let(:inheritable_class) { Gitlab::Ci::Config::Entry::Root } + + # These are entries defined in Root + # that we know that we don't want to inherit + # as they do not have sense in context of Default + let(:ignored_inheritable_columns) do + %i[default include variables stages types workflow] + end + end + describe '.nodes' do it 'returns a hash' do expect(described_class.nodes).to be_a(Hash) @@ -14,7 +26,7 @@ describe Gitlab::Ci::Config::Entry::Default do it 'contains the expected node names' do expect(described_class.nodes.keys) .to match_array(%i[before_script image services - after_script cache]) + after_script cache interruptible]) end end end @@ -87,7 +99,7 @@ describe Gitlab::Ci::Config::Entry::Default do it 'raises error' do expect { entry.compose!(deps) }.to raise_error( - Gitlab::Ci::Config::Entry::Default::DuplicateError) + Gitlab::Ci::Config::Entry::Default::InheritError) end end diff --git a/spec/lib/gitlab/ci/config/entry/files_spec.rb b/spec/lib/gitlab/ci/config/entry/files_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2bebbd7b198a54caa0e4b69a1c3062c283f237b7 --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/files_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Files do + let(:entry) { described_class.new(config) } + + describe 'validations' do + context 'when entry config value is valid' do + let(:config) { ['some/file', 'some/path/'] } + + describe '#value' do + it 'returns key value' do + expect(entry.value).to eq config + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + describe '#errors' do + context 'when entry value is not an array' do + let(:config) { 'string' } + + it 'saves errors' do + expect(entry.errors) + .to include 'files config should be an array of strings' + end + end + + context 'when entry value is not an array of strings' do + let(:config) { [1] } + + it 'saves errors' do + expect(entry.errors) + .to include 'files config should be an array of strings' + end + end + + context 'when entry value contains more than two values' do + let(:config) { %w[file1 file2 file3] } + + it 'saves errors' do + expect(entry.errors) + .to include 'files config has too many items (maximum is 2)' + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb index 1c4887e87c4a2e4d727169b05aacb435a45e4ca1..fe83171c57a7ec58de3ab2321bccd4b751be6a1e 100644 --- a/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -5,14 +5,26 @@ require 'spec_helper' describe Gitlab::Ci::Config::Entry::Job do let(:entry) { described_class.new(config, name: :rspec) } + it_behaves_like 'with inheritable CI config' do + let(:inheritable_key) { 'default' } + let(:inheritable_class) { Gitlab::Ci::Config::Entry::Default } + + # These are entries defined in Default + # that we know that we don't want to inherit + # as they do not have sense in context of Job + let(:ignored_inheritable_columns) do + %i[] + end + end + describe '.nodes' do context 'when filtering all the entry/node names' do subject { described_class.nodes.keys } let(:result) do %i[before_script script stage type after_script cache - image services only except rules variables artifacts - environment coverage retry] + image services only except rules needs variables artifacts + environment coverage retry interruptible] end it { is_expected.to match_array result } @@ -372,21 +384,6 @@ describe Gitlab::Ci::Config::Entry::Job do end context 'when has needs' do - context 'that are not a array of strings' do - let(:config) do - { - stage: 'test', - script: 'echo', - needs: 'build-job' - } - end - - it 'returns error about invalid type' do - expect(entry).not_to be_valid - expect(entry.errors).to include 'job needs should be an array of strings' - end - end - context 'when have dependencies that are not subset of needs' do let(:config) do { diff --git a/spec/lib/gitlab/ci/config/entry/key_spec.rb b/spec/lib/gitlab/ci/config/entry/key_spec.rb index a7874447725a2d82df86a9a07b3aed452d72a6b1..327607e226647a275db0dc26aea6dd90666c031b 100644 --- a/spec/lib/gitlab/ci/config/entry/key_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/key_spec.rb @@ -6,38 +6,38 @@ describe Gitlab::Ci::Config::Entry::Key do let(:entry) { described_class.new(config) } describe 'validations' do - shared_examples 'key with slash' do - it 'is invalid' do - expect(entry).not_to be_valid - end + it_behaves_like 'key entry validations', 'simple key' - it 'reports errors with config value' do - expect(entry.errors).to include 'key config cannot contain the "/" character' - end - end + context 'when entry config value is correct' do + context 'when key is a hash' do + let(:config) { { files: ['test'], prefix: 'something' } } - shared_examples 'key with only dots' do - it 'is invalid' do - expect(entry).not_to be_valid - end + describe '#value' do + it 'returns key value' do + expect(entry.value).to match(config) + end + end - it 'reports errors with config value' do - expect(entry.errors).to include 'key config cannot be "." or ".."' + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end end - end - context 'when entry config value is correct' do - let(:config) { 'test' } + context 'when key is a symbol' do + let(:config) { :key } - describe '#value' do - it 'returns key value' do - expect(entry.value).to eq 'test' + describe '#value' do + it 'returns key value' do + expect(entry.value).to eq(config.to_s) + end end - end - describe '#valid?' do - it 'is valid' do - expect(entry).to be_valid + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end end end end @@ -47,53 +47,11 @@ describe Gitlab::Ci::Config::Entry::Key do describe '#errors' do it 'saves errors' do - expect(entry.errors) - .to include 'key config should be a string or symbol' + expect(entry.errors.first) + .to match /should be a hash, a string or a symbol/ end end end - - context 'when entry value contains slash' do - let(:config) { 'key/with/some/slashes' } - - it_behaves_like 'key with slash' - end - - context 'when entry value contains URI encoded slash (%2F)' do - let(:config) { 'key%2Fwith%2Fsome%2Fslashes' } - - it_behaves_like 'key with slash' - end - - context 'when entry value is a dot' do - let(:config) { '.' } - - it_behaves_like 'key with only dots' - end - - context 'when entry value is two dots' do - let(:config) { '..' } - - it_behaves_like 'key with only dots' - end - - context 'when entry value is a URI encoded dot (%2E)' do - let(:config) { '%2e' } - - it_behaves_like 'key with only dots' - end - - context 'when entry value is two URI encoded dots (%2E)' do - let(:config) { '%2E%2e' } - - it_behaves_like 'key with only dots' - end - - context 'when entry value is one dot and one URI encoded dot' do - let(:config) { '.%2e' } - - it_behaves_like 'key with only dots' - end end describe '.default' do diff --git a/spec/lib/gitlab/ci/config/entry/need_spec.rb b/spec/lib/gitlab/ci/config/entry/need_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d119e60490085b92115ab94736e56779b7ab0669 --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/need_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ::Gitlab::Ci::Config::Entry::Need do + subject(:need) { described_class.new(config) } + + context 'when job is specified' do + let(:config) { 'job_name' } + + describe '#valid?' do + it { is_expected.to be_valid } + end + + describe '#value' do + it 'returns job needs configuration' do + expect(need.value).to eq(name: 'job_name') + end + end + end + + context 'when need is empty' do + let(:config) { '' } + + describe '#valid?' do + it { is_expected.not_to be_valid } + end + + describe '#errors' do + it 'is returns an error about an empty config' do + expect(need.errors) + .to contain_exactly("job config can't be blank") + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/needs_spec.rb b/spec/lib/gitlab/ci/config/entry/needs_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f4a76b52d30c0e2c2f37dc3f569b95a94ea582b2 --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/needs_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ::Gitlab::Ci::Config::Entry::Needs do + subject(:needs) { described_class.new(config) } + + before do + needs.metadata[:allowed_needs] = %i[job] + end + + describe 'validations' do + before do + needs.compose! + end + + context 'when entry config value is correct' do + let(:config) { ['job_name'] } + + describe '#valid?' do + it { is_expected.to be_valid } + end + end + + context 'when config value has wrong type' do + let(:config) { 123 } + + describe '#valid?' do + it { is_expected.not_to be_valid } + end + + describe '#errors' do + it 'returns error about incorrect type' do + expect(needs.errors) + .to include('needs config can only be a hash or an array') + end + end + end + + context 'when wrong needs type is used' do + let(:config) { [123] } + + describe '#valid?' do + it { is_expected.not_to be_valid } + end + + describe '#errors' do + it 'returns error about incorrect type' do + expect(needs.errors).to contain_exactly( + 'need has an unsupported type') + end + end + end + end + + describe '.compose!' do + context 'when valid job entries composed' do + let(:config) { %w[first_job_name second_job_name] } + + before do + needs.compose! + end + + describe '#value' do + it 'returns key value' do + expect(needs.value).to eq( + job: [ + { name: 'first_job_name' }, + { name: 'second_job_name' } + ] + ) + end + end + + describe '#descendants' do + it 'creates valid descendant nodes' do + expect(needs.descendants.count).to eq 2 + expect(needs.descendants) + .to all(be_an_instance_of(::Gitlab::Ci::Config::Entry::Need)) + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/prefix_spec.rb b/spec/lib/gitlab/ci/config/entry/prefix_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8132a674488a07d1e50ff1afb9e8dce291b15697 --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/prefix_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Prefix do + let(:entry) { described_class.new(config) } + + describe 'validations' do + it_behaves_like 'key entry validations', :prefix + + context 'when entry value is not correct' do + let(:config) { ['incorrect'] } + + describe '#errors' do + it 'saves errors' do + expect(entry.errors) + .to include 'prefix config should be a string or symbol' + end + end + end + end + + describe '.default' do + it 'returns default key' do + expect(described_class.default).to be_nil + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/root_spec.rb b/spec/lib/gitlab/ci/config/entry/root_spec.rb index 7e1a80414d42e8de435c5d51a0d43fb13ad03169..43bd53b780f9c3395a1b6c0df2f125fa3eb0a71a 100644 --- a/spec/lib/gitlab/ci/config/entry/root_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/root_spec.rb @@ -12,10 +12,14 @@ describe Gitlab::Ci::Config::Entry::Root do context 'when filtering all the entry/node names' do it 'contains the expected node names' do + # No inheritable fields should be added to the `Root` + # + # Inheritable configuration can only be added to `default:` + # + # The purpose of `Root` is have only globally defined configuration. expect(described_class.nodes.keys) - .to match_array(%i[before_script image services - after_script variables cache - stages types include default]) + .to match_array(%i[before_script image services after_script + variables cache stages types include default workflow]) end end end @@ -45,7 +49,7 @@ describe Gitlab::Ci::Config::Entry::Root do end it 'creates node object for each entry' do - expect(root.descendants.count).to eq 10 + expect(root.descendants.count).to eq 11 end it 'creates node object using valid class' do @@ -198,7 +202,7 @@ describe Gitlab::Ci::Config::Entry::Root do describe '#nodes' do it 'instantizes all nodes' do - expect(root.descendants.count).to eq 10 + expect(root.descendants.count).to eq 11 end it 'contains unspecified nodes' do @@ -293,7 +297,7 @@ describe Gitlab::Ci::Config::Entry::Root do describe '#errors' do it 'reports errors from child nodes' do expect(root.errors) - .to include 'before_script config should be an array of strings' + .to include 'before_script config should be an array containing strings and arrays of strings' end end end diff --git a/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb b/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb index 9d4f7153cd0f2abc2da6af618c8e3e5230a295ca..216f5d0c77d9e9b5fb786a1cbba749509317a502 100644 --- a/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb @@ -1,10 +1,22 @@ +# frozen_string_literal: true + require 'fast_spec_helper' require 'gitlab_chronic_duration' require 'support/helpers/stub_feature_flags' require_dependency 'active_model' describe Gitlab::Ci::Config::Entry::Rules::Rule do - let(:entry) { described_class.new(config) } + let(:factory) do + Gitlab::Config::Entry::Factory.new(described_class) + .metadata(metadata) + .value(config) + end + + let(:metadata) do + { allowed_when: %w[on_success on_failure always never manual delayed] } + end + + let(:entry) { factory.create! } describe '.new' do subject { entry } @@ -210,6 +222,112 @@ describe Gitlab::Ci::Config::Entry::Rules::Rule do .to include(/should be a hash/) end end + + context 'when: validation' do + context 'with an invalid boolean when:' do + let(:config) do + { if: '$THIS == "that"', when: false } + end + + it { is_expected.to be_a(described_class) } + it { is_expected.not_to be_valid } + + it 'returns an error about invalid when:' do + expect(subject.errors).to include(/when unknown value: false/) + end + + context 'when composed' do + before do + subject.compose! + end + + it { is_expected.not_to be_valid } + + it 'returns an error about invalid when:' do + expect(subject.errors).to include(/when unknown value: false/) + end + end + end + + context 'with an invalid string when:' do + let(:config) do + { if: '$THIS == "that"', when: 'explode' } + end + + it { is_expected.to be_a(described_class) } + it { is_expected.not_to be_valid } + + it 'returns an error about invalid when:' do + expect(subject.errors).to include(/when unknown value: explode/) + end + + context 'when composed' do + before do + subject.compose! + end + + it { is_expected.not_to be_valid } + + it 'returns an error about invalid when:' do + expect(subject.errors).to include(/when unknown value: explode/) + end + end + end + + context 'with a string passed in metadata but not allowed in the class' do + let(:metadata) { { allowed_when: %w[explode] } } + + let(:config) do + { if: '$THIS == "that"', when: 'explode' } + end + + it { is_expected.to be_a(described_class) } + it { is_expected.not_to be_valid } + + it 'returns an error about invalid when:' do + expect(subject.errors).to include(/when unknown value: explode/) + end + + context 'when composed' do + before do + subject.compose! + end + + it { is_expected.not_to be_valid } + + it 'returns an error about invalid when:' do + expect(subject.errors).to include(/when unknown value: explode/) + end + end + end + + context 'with a string allowed in the class but not passed in metadata' do + let(:metadata) { { allowed_when: %w[always never] } } + + let(:config) do + { if: '$THIS == "that"', when: 'on_success' } + end + + it { is_expected.to be_a(described_class) } + it { is_expected.not_to be_valid } + + it 'returns an error about invalid when:' do + expect(subject.errors).to include(/when unknown value: on_success/) + end + + context 'when composed' do + before do + subject.compose! + end + + it { is_expected.not_to be_valid } + + it 'returns an error about invalid when:' do + expect(subject.errors).to include(/when unknown value: on_success/) + end + end + end + end end describe '#value' do diff --git a/spec/lib/gitlab/ci/config/entry/rules_spec.rb b/spec/lib/gitlab/ci/config/entry/rules_spec.rb index 291e7373daf1b123a3859a9f8357a8b2fe492388..3c05080102399c909123a4b300daab79e12183d5 100644 --- a/spec/lib/gitlab/ci/config/entry/rules_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/rules_spec.rb @@ -1,9 +1,18 @@ +# frozen_string_literal: true + require 'fast_spec_helper' require 'support/helpers/stub_feature_flags' require_dependency 'active_model' describe Gitlab::Ci::Config::Entry::Rules do - let(:entry) { described_class.new(config) } + let(:factory) do + Gitlab::Config::Entry::Factory.new(described_class) + .metadata(metadata) + .value(config) + end + + let(:metadata) { { allowed_when: %w[always never] } } + let(:entry) { factory.create! } describe '.new' do subject { entry } @@ -16,7 +25,7 @@ describe Gitlab::Ci::Config::Entry::Rules do it { is_expected.to be_a(described_class) } it { is_expected.to be_valid } - context 'after #compose!' do + context 'when composed' do before do subject.compose! end @@ -36,7 +45,7 @@ describe Gitlab::Ci::Config::Entry::Rules do it { is_expected.to be_a(described_class) } it { is_expected.to be_valid } - context 'after #compose!' do + context 'when composed' do before do subject.compose! end @@ -52,48 +61,6 @@ describe Gitlab::Ci::Config::Entry::Rules do it { is_expected.not_to be_valid } end - - context 'with an invalid boolean when:' do - let(:config) do - [{ if: '$THIS == "that"', when: false }] - end - - it { is_expected.to be_a(described_class) } - it { is_expected.to be_valid } - - context 'after #compose!' do - before do - subject.compose! - end - - it { is_expected.not_to be_valid } - - it 'returns an error about invalid when:' do - expect(subject.errors).to include(/when unknown value: false/) - end - end - end - - context 'with an invalid string when:' do - let(:config) do - [{ if: '$THIS == "that"', when: 'explode' }] - end - - it { is_expected.to be_a(described_class) } - it { is_expected.to be_valid } - - context 'after #compose!' do - before do - subject.compose! - end - - it { is_expected.not_to be_valid } - - it 'returns an error about invalid when:' do - expect(subject.errors).to include(/when unknown value: explode/) - end - end - end end describe '#value' do diff --git a/spec/lib/gitlab/ci/config/entry/script_spec.rb b/spec/lib/gitlab/ci/config/entry/script_spec.rb index d523243d3b6548e76facbc773fb0cf839dd16851..57dc20ea628f974373925154995ff0ed81af0243 100644 --- a/spec/lib/gitlab/ci/config/entry/script_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/script_spec.rb @@ -6,7 +6,7 @@ describe Gitlab::Ci::Config::Entry::Script do let(:entry) { described_class.new(config) } describe 'validations' do - context 'when entry config value is correct' do + context 'when entry config value is array of strings' do let(:config) { %w(ls pwd) } describe '#value' do @@ -28,13 +28,74 @@ describe Gitlab::Ci::Config::Entry::Script do end end - context 'when entry value is not correct' do + context 'when entry config value is array of arrays of strings' do + let(:config) { [['ls'], ['pwd', 'echo 1']] } + + describe '#value' do + it 'returns array of strings' do + expect(entry.value).to eq ['ls', 'pwd', 'echo 1'] + end + end + + describe '#errors' do + it 'does not append errors' do + expect(entry.errors).to be_empty + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when entry config value is array containing strings and arrays of strings' do + let(:config) { ['ls', ['pwd', 'echo 1']] } + + describe '#value' do + it 'returns array of strings' do + expect(entry.value).to eq ['ls', 'pwd', 'echo 1'] + end + end + + describe '#errors' do + it 'does not append errors' do + expect(entry.errors).to be_empty + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when entry value is string' do let(:config) { 'ls' } describe '#errors' do it 'saves errors' do expect(entry.errors) - .to include 'script config should be an array of strings' + .to include 'script config should be an array containing strings and arrays of strings' + end + end + + describe '#valid?' do + it 'is not valid' do + expect(entry).not_to be_valid + end + end + end + + context 'when entry value is multi-level nested array' do + let(:config) { [['ls', ['echo 1']], 'pwd'] } + + describe '#errors' do + it 'saves errors' do + expect(entry.errors) + .to include 'script config should be an array containing strings and arrays of strings' end end diff --git a/spec/lib/gitlab/ci/config/entry/workflow_spec.rb b/spec/lib/gitlab/ci/config/entry/workflow_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f2832b94bf07be0d5dbc1948a408598deb29b99e --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/workflow_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Workflow do + let(:factory) { Gitlab::Config::Entry::Factory.new(described_class).value(rules_hash) } + let(:config) { factory.create! } + + describe 'validations' do + context 'when work config value is a string' do + let(:rules_hash) { 'build' } + + describe '#valid?' do + it 'is invalid' do + expect(config).not_to be_valid + end + + it 'attaches an error specifying that workflow should point to a hash' do + expect(config.errors).to include('workflow config should be a hash') + end + end + + describe '#value' do + it 'returns the invalid configuration' do + expect(config.value).to eq(rules_hash) + end + end + end + + context 'when work config value is a hash' do + let(:rules_hash) { { rules: [{ if: '$VAR' }] } } + + describe '#valid?' do + it 'is valid' do + expect(config).to be_valid + end + + it 'attaches no errors' do + expect(config.errors).to be_empty + end + end + + describe '#value' do + it 'returns the config' do + expect(config.value).to eq(rules_hash) + end + end + + context 'with an invalid key' do + let(:rules_hash) { { trash: [{ if: '$VAR' }] } } + + describe '#valid?' do + it 'is invalid' do + expect(config).not_to be_valid + end + + it 'attaches an error specifying the unknown key' do + expect(config.errors).to include('workflow config contains unknown keys: trash') + end + end + + describe '#value' do + it 'returns the invalid configuration' do + expect(config.value).to eq(rules_hash) + end + end + end + end + end + + describe '.default' do + it 'is nil' do + expect(described_class.default).to be_nil + end + end +end diff --git a/spec/lib/gitlab/ci/config/normalizer_spec.rb b/spec/lib/gitlab/ci/config/normalizer_spec.rb index 6b766cc37bfcb5a50bee4621371637aab654c590..bf88047838755a37ecd16f84d1b4c577a0979893 100644 --- a/spec/lib/gitlab/ci/config/normalizer_spec.rb +++ b/spec/lib/gitlab/ci/config/normalizer_spec.rb @@ -7,6 +7,16 @@ describe Gitlab::Ci::Config::Normalizer do let(:job_config) { { script: 'rspec', parallel: 5, name: 'rspec' } } let(:config) { { job_name => job_config } } + let(:expanded_job_names) do + [ + "rspec 1/5", + "rspec 2/5", + "rspec 3/5", + "rspec 4/5", + "rspec 5/5" + ] + end + describe '.normalize_jobs' do subject { described_class.new(config).normalize_jobs } @@ -15,9 +25,7 @@ describe Gitlab::Ci::Config::Normalizer do end it 'has parallelized jobs' do - job_names = [:"rspec 1/5", :"rspec 2/5", :"rspec 3/5", :"rspec 4/5", :"rspec 5/5"] - - is_expected.to include(*job_names) + is_expected.to include(*expanded_job_names.map(&:to_sym)) end it 'sets job instance in options' do @@ -43,49 +51,109 @@ describe Gitlab::Ci::Config::Normalizer do let(:job_name) { :"rspec 35/2" } it 'properly parallelizes job names' do - job_names = [:"rspec 35/2 1/5", :"rspec 35/2 2/5", :"rspec 35/2 3/5", :"rspec 35/2 4/5", :"rspec 35/2 5/5"] + job_names = [ + :"rspec 35/2 1/5", + :"rspec 35/2 2/5", + :"rspec 35/2 3/5", + :"rspec 35/2 4/5", + :"rspec 35/2 5/5" + ] is_expected.to include(*job_names) end end - %i[dependencies needs].each do |context| - context "when job has #{context} on parallelized jobs" do + context 'for dependencies' do + context "when job has dependencies on parallelized jobs" do let(:config) do { job_name => job_config, - other_job: { script: 'echo 1', context => [job_name.to_s] } + other_job: { script: 'echo 1', dependencies: [job_name.to_s] } } end - it "parallelizes #{context}" do - job_names = ["rspec 1/5", "rspec 2/5", "rspec 3/5", "rspec 4/5", "rspec 5/5"] - - expect(subject[:other_job][context]).to include(*job_names) + it "parallelizes dependencies" do + expect(subject[:other_job][:dependencies]).to eq(expanded_job_names) end it "does not include original job name in #{context}" do - expect(subject[:other_job][context]).not_to include(job_name) + expect(subject[:other_job][:dependencies]).not_to include(job_name) end end - context "when there are #{context} which are both parallelized and not" do + context "when there are dependencies which are both parallelized and not" do let(:config) do { job_name => job_config, other_job: { script: 'echo 1' }, - final_job: { script: 'echo 1', context => [job_name.to_s, "other_job"] } + final_job: { script: 'echo 1', dependencies: [job_name.to_s, "other_job"] } } end - it "parallelizes #{context}" do + it "parallelizes dependencies" do job_names = ["rspec 1/5", "rspec 2/5", "rspec 3/5", "rspec 4/5", "rspec 5/5"] - expect(subject[:final_job][context]).to include(*job_names) + expect(subject[:final_job][:dependencies]).to include(*job_names) + end + + it "includes the regular job in dependencies" do + expect(subject[:final_job][:dependencies]).to include('other_job') + end + end + end + + context 'for needs' do + let(:expanded_job_attributes) do + expanded_job_names.map do |job_name| + { name: job_name } + end + end + + context "when job has needs on parallelized jobs" do + let(:config) do + { + job_name => job_config, + other_job: { + script: 'echo 1', + needs: { + job: [ + { name: job_name.to_s } + ] + } + } + } + end + + it "parallelizes needs" do + expect(subject.dig(:other_job, :needs, :job)).to eq(expanded_job_attributes) + end + end + + context "when there are dependencies which are both parallelized and not" do + let(:config) do + { + job_name => job_config, + other_job: { + script: 'echo 1' + }, + final_job: { + script: 'echo 1', + needs: { + job: [ + { name: job_name.to_s }, + { name: "other_job" } + ] + } + } + } + end + + it "parallelizes dependencies" do + expect(subject.dig(:final_job, :needs, :job)).to include(*expanded_job_attributes) end - it "includes the regular job in #{context}" do - expect(subject[:final_job][context]).to include('other_job') + it "includes the regular job in dependencies" do + expect(subject.dig(:final_job, :needs, :job)).to include(name: 'other_job') end end end diff --git a/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb index ba4f841cf4386715faaee11ad5607448a8346f5f..a631cd2777b44becbe3fdda69de73b5e3d43c355 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb @@ -11,6 +11,7 @@ describe Gitlab::Ci::Pipeline::Chain::Build do [{ key: 'first', secret_value: 'world' }, { key: 'second', secret_value: 'second_world' }] end + let(:command) do Gitlab::Ci::Pipeline::Chain::Command.new( source: :push, @@ -51,12 +52,6 @@ describe Gitlab::Ci::Pipeline::Chain::Build do .to eq variables_attributes.map(&:with_indifferent_access) end - it 'sets a valid config source' do - step.perform! - - expect(pipeline.repository_source?).to be true - end - it 'returns a valid pipeline' do step.perform! diff --git a/spec/lib/gitlab/ci/pipeline/chain/evaluate_workflow_rules_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/evaluate_workflow_rules_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7b76adaf683f486b983e874dd9f1a2b7d126d38f --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/chain/evaluate_workflow_rules_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Pipeline::Chain::EvaluateWorkflowRules do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:pipeline) { build(:ci_pipeline, project: project) } + + let(:command) do + Gitlab::Ci::Pipeline::Chain::Command.new(project: project, current_user: user) + end + + let(:step) { described_class.new(pipeline, command) } + + describe '#perform!' do + context 'when pipeline has been skipped by workflow configuration' do + before do + allow(step).to receive(:workflow_passed?) + .and_return(false) + + step.perform! + end + + it 'does not save the pipeline' do + expect(pipeline).not_to be_persisted + end + + it 'breaks the chain' do + expect(step.break?).to be true + end + + it 'attaches an error to the pipeline' do + expect(pipeline.errors[:base]).to include('Pipeline filtered out by workflow rules.') + end + end + + context 'when pipeline has not been skipped by workflow configuration' do + before do + allow(step).to receive(:workflow_passed?) + .and_return(true) + + step.perform! + end + + it 'continues the pipeline processing chain' do + expect(step.break?).to be false + end + + it 'does not skip the pipeline' do + expect(pipeline).not_to be_persisted + expect(pipeline).not_to be_skipped + end + + it 'attaches no errors' do + expect(pipeline.errors).to be_empty + end + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb index 9bccd5be4fe7051cc0ec109d2a36e859b818ac20..52e9432dc922ee0f6e1049f4c49b34d53fb6b6b1 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb @@ -7,9 +7,7 @@ describe Gitlab::Ci::Pipeline::Chain::Populate do set(:user) { create(:user) } let(:pipeline) do - build(:ci_pipeline_with_one_job, project: project, - ref: 'master', - user: user) + build(:ci_pipeline, project: project, ref: 'master', user: user) end let(:command) do @@ -20,11 +18,32 @@ describe Gitlab::Ci::Pipeline::Chain::Populate do seeds_block: nil) end + let(:dependencies) do + [ + Gitlab::Ci::Pipeline::Chain::Config::Content.new(pipeline, command), + Gitlab::Ci::Pipeline::Chain::Config::Process.new(pipeline, command), + Gitlab::Ci::Pipeline::Chain::Seed.new(pipeline, command) + ] + end + let(:step) { described_class.new(pipeline, command) } + let(:config) do + { rspec: { script: 'rspec' } } + end + + def run_chain + dependencies.map(&:perform!) + step.perform! + end + + before do + stub_ci_pipeline_yaml_file(YAML.dump(config)) + end + context 'when pipeline doesn not have seeds block' do before do - step.perform! + run_chain end it 'does not persist the pipeline' do @@ -59,12 +78,8 @@ describe Gitlab::Ci::Pipeline::Chain::Populate do } } end - let(:pipeline) do - build(:ci_pipeline, project: project, config: config) - end - before do - step.perform! + run_chain end it 'breaks the chain' do @@ -82,16 +97,16 @@ describe Gitlab::Ci::Pipeline::Chain::Populate do end describe 'pipeline protect' do - subject { step.perform! } - context 'when ref is protected' do before do allow(project).to receive(:protected_for?).with('master').and_return(true) allow(project).to receive(:protected_for?).with('refs/heads/master').and_return(true) + + dependencies.map(&:perform!) end it 'does not protect the pipeline' do - subject + run_chain expect(pipeline.protected).to eq(true) end @@ -99,7 +114,7 @@ describe Gitlab::Ci::Pipeline::Chain::Populate do context 'when ref is not protected' do it 'does not protect the pipeline' do - subject + run_chain expect(pipeline.protected).to eq(false) end @@ -112,7 +127,7 @@ describe Gitlab::Ci::Pipeline::Chain::Populate do end before do - step.perform! + run_chain end it 'breaks the chain' do @@ -144,7 +159,7 @@ describe Gitlab::Ci::Pipeline::Chain::Populate do end it 'populates pipeline with resources described in the seeds block' do - step.perform! + run_chain expect(pipeline).not_to be_persisted expect(pipeline.variables).not_to be_empty @@ -154,7 +169,7 @@ describe Gitlab::Ci::Pipeline::Chain::Populate do end it 'has pipeline iid' do - step.perform! + run_chain expect(pipeline.iid).to be > 0 end @@ -166,7 +181,7 @@ describe Gitlab::Ci::Pipeline::Chain::Populate do end it 'wastes pipeline iid' do - expect { step.perform! }.to raise_error(ActiveRecord::RecordNotSaved) + expect { run_chain }.to raise_error(ActiveRecord::RecordNotSaved) last_iid = InternalId.ci_pipelines .where(project_id: project.id) @@ -181,14 +196,14 @@ describe Gitlab::Ci::Pipeline::Chain::Populate do let(:pipeline) { create(:ci_pipeline, project: project) } it 'raises error' do - expect { step.perform! }.to raise_error(described_class::PopulateError) + expect { run_chain }.to raise_error(described_class::PopulateError) end end context 'when variables policy is specified' do shared_examples_for 'a correct pipeline' do it 'populates pipeline according to used policies' do - step.perform! + run_chain expect(pipeline.stages.size).to eq 1 expect(pipeline.stages.first.statuses.size).to eq 1 @@ -202,10 +217,6 @@ describe Gitlab::Ci::Pipeline::Chain::Populate do prod: { script: 'cap prod', stage: 'deploy', only: ['tags'] } } end - let(:pipeline) do - build(:ci_pipeline, ref: 'master', project: project, config: config) - end - it_behaves_like 'a correct pipeline' context 'when variables expression is specified' do diff --git a/spec/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs_spec.rb index 7c1c016b4bbe51c8dfad86c4c5a406538bb16132..92eadf5548c635127878d0b788a6f0c9c1638e34 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs_spec.rb @@ -2,32 +2,38 @@ require 'spec_helper' -describe Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs do - let(:project) { create(:project, :repository) } +describe ::Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs do + let(:project) { create(:project) } let(:pipeline) do - build(:ci_pipeline_with_one_job, project: project, ref: 'master') + build(:ci_pipeline, project: project) end let(:command) do - double(:command, project: project, chat_data: { command: 'echo' }) + double(:command, + config_processor: double(:processor, + jobs: { echo: double(:job_echo), rspec: double(:job_rspec) }), + project: project, + chat_data: { command: 'echo' }) end describe '#perform!' do - it 'removes unwanted jobs for chat pipelines' do - allow(pipeline).to receive(:chat?).and_return(true) + subject { described_class.new(pipeline, command).perform! } - pipeline.config_processor.jobs[:echo] = double(:job) + it 'removes unwanted jobs for chat pipelines' do + expect(pipeline).to receive(:chat?).and_return(true) - described_class.new(pipeline, command).perform! + subject - expect(pipeline.config_processor.jobs.keys).to eq([:echo]) + expect(command.config_processor.jobs.keys).to eq([:echo]) end - end - it 'does not remove any jobs for non-chat pipelines' do - described_class.new(pipeline, command).perform! + it 'does not remove any jobs for non chat-pipelines' do + expect(pipeline).to receive(:chat?).and_return(false) - expect(pipeline.config_processor.jobs.keys).to eq([:rspec]) + subject + + expect(command.config_processor.jobs.keys).to eq([:echo, :rspec]) + end end end diff --git a/spec/lib/gitlab/ci/pipeline/chain/seed_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/seed_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..aa54f19b26ce1a20e4a22f0fce22ca76c9c4a3f8 --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/chain/seed_spec.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Pipeline::Chain::Seed do + let(:project) { create(:project, :repository) } + let(:user) { create(:user, developer_projects: [project]) } + + let(:command) do + Gitlab::Ci::Pipeline::Chain::Command.new( + project: project, + current_user: user, + origin_ref: 'master', + seeds_block: nil) + end + + def run_chain(pipeline, command) + [ + Gitlab::Ci::Pipeline::Chain::Config::Content.new(pipeline, command), + Gitlab::Ci::Pipeline::Chain::Config::Process.new(pipeline, command) + ].map(&:perform!) + + described_class.new(pipeline, command).perform! + end + + let(:pipeline) { build(:ci_pipeline, project: project) } + + describe '#perform!' do + before do + stub_ci_pipeline_yaml_file(YAML.dump(config)) + run_chain(pipeline, command) + end + + let(:config) do + { rspec: { script: 'rake' } } + end + + it 'allocates next IID' do + expect(pipeline.iid).to be_present + end + + it 'sets the seeds in the command object' do + expect(command.stage_seeds).to all(be_a Gitlab::Ci::Pipeline::Seed::Base) + expect(command.stage_seeds.count).to eq 1 + end + + context 'when no ref policy is specified' do + let(:config) do + { + production: { stage: 'deploy', script: 'cap prod' }, + rspec: { stage: 'test', script: 'rspec' }, + spinach: { stage: 'test', script: 'spinach' } + } + end + + it 'correctly fabricates a stage seeds object' do + seeds = command.stage_seeds + expect(seeds.size).to eq 2 + expect(seeds.first.attributes[:name]).to eq 'test' + expect(seeds.second.attributes[:name]).to eq 'deploy' + expect(seeds.dig(0, 0, :name)).to eq 'rspec' + expect(seeds.dig(0, 1, :name)).to eq 'spinach' + expect(seeds.dig(1, 0, :name)).to eq 'production' + end + end + + context 'when refs policy is specified' do + let(:pipeline) do + build(:ci_pipeline, project: project, ref: 'feature', tag: true) + end + + let(:config) do + { + production: { stage: 'deploy', script: 'cap prod', only: ['master'] }, + spinach: { stage: 'test', script: 'spinach', only: ['tags'] } + } + end + + it 'returns stage seeds only assigned to master' do + seeds = command.stage_seeds + + expect(seeds.size).to eq 1 + expect(seeds.first.attributes[:name]).to eq 'test' + expect(seeds.dig(0, 0, :name)).to eq 'spinach' + end + end + + context 'when source policy is specified' do + let(:pipeline) { create(:ci_pipeline, source: :schedule) } + + let(:config) do + { + production: { stage: 'deploy', script: 'cap prod', only: ['triggers'] }, + spinach: { stage: 'test', script: 'spinach', only: ['schedules'] } + } + end + + it 'returns stage seeds only assigned to schedules' do + seeds = command.stage_seeds + + expect(seeds.size).to eq 1 + expect(seeds.first.attributes[:name]).to eq 'test' + expect(seeds.dig(0, 0, :name)).to eq 'spinach' + end + end + + context 'when kubernetes policy is specified' do + let(:config) do + { + spinach: { stage: 'test', script: 'spinach' }, + production: { + stage: 'deploy', + script: 'cap', + only: { kubernetes: 'active' } + } + } + end + + context 'when kubernetes is active' do + context 'when user configured kubernetes from CI/CD > Clusters' do + let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } + let(:pipeline) { build(:ci_pipeline, project: project) } + + it 'returns seeds for kubernetes dependent job' do + seeds = command.stage_seeds + + expect(seeds.size).to eq 2 + expect(seeds.dig(0, 0, :name)).to eq 'spinach' + expect(seeds.dig(1, 0, :name)).to eq 'production' + end + end + end + + context 'when kubernetes is not active' do + it 'does not return seeds for kubernetes dependent job' do + seeds = command.stage_seeds + + expect(seeds.size).to eq 1 + expect(seeds.dig(0, 0, :name)).to eq 'spinach' + end + end + end + + context 'when variables policy is specified' do + let(:config) do + { + unit: { script: 'minitest', only: { variables: ['$CI_PIPELINE_SOURCE'] } }, + feature: { script: 'spinach', only: { variables: ['$UNDEFINED'] } } + } + end + + it 'returns stage seeds only when variables expression is truthy' do + seeds = command.stage_seeds + + expect(seeds.size).to eq 1 + expect(seeds.dig(0, 0, :name)).to eq 'unit' + end + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/chain/validate/config_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/validate/config_spec.rb deleted file mode 100644 index 79acd3e4f546e4a0643281dd0dfe838d948e906d..0000000000000000000000000000000000000000 --- a/spec/lib/gitlab/ci/pipeline/chain/validate/config_spec.rb +++ /dev/null @@ -1,148 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe Gitlab::Ci::Pipeline::Chain::Validate::Config do - set(:project) { create(:project, :repository) } - set(:user) { create(:user) } - - let(:command) do - Gitlab::Ci::Pipeline::Chain::Command.new( - project: project, - current_user: user, - save_incompleted: true) - end - - let!(:step) { described_class.new(pipeline, command) } - - before do - step.perform! - end - - context 'when pipeline has no YAML configuration' do - let(:pipeline) do - build_stubbed(:ci_pipeline, project: project) - end - - it 'appends errors about missing configuration' do - expect(pipeline.errors.to_a) - .to include 'Missing .gitlab-ci.yml file' - end - - it 'breaks the chain' do - expect(step.break?).to be true - end - end - - context 'when YAML configuration contains errors' do - let(:pipeline) do - build(:ci_pipeline, project: project, config: 'invalid YAML') - end - - it 'appends errors about YAML errors' do - expect(pipeline.errors.to_a) - .to include 'Invalid configuration format' - end - - it 'breaks the chain' do - expect(step.break?).to be true - end - - context 'when saving incomplete pipeline is allowed' do - let(:command) do - double('command', project: project, - current_user: user, - save_incompleted: true) - end - - it 'fails the pipeline' do - expect(pipeline.reload).to be_failed - end - - it 'sets a config error failure reason' do - expect(pipeline.reload.config_error?).to eq true - end - end - - context 'when saving incomplete pipeline is not allowed' do - let(:command) do - double('command', project: project, - current_user: user, - save_incompleted: false) - end - - it 'does not drop pipeline' do - expect(pipeline).not_to be_failed - expect(pipeline).not_to be_persisted - end - end - end - - context 'when pipeline contains configuration validation errors' do - let(:config) do - { - rspec: { - before_script: 10, - script: 'ls -al' - } - } - end - - let(:pipeline) do - build(:ci_pipeline, project: project, config: config) - end - - it 'appends configuration validation errors to pipeline errors' do - expect(pipeline.errors.to_a) - .to include "jobs:rspec:before_script config should be an array of strings" - end - - it 'breaks the chain' do - expect(step.break?).to be true - end - end - - context 'when pipeline is correct and complete' do - let(:pipeline) do - build(:ci_pipeline_with_one_job, project: project) - end - - it 'does not invalidate the pipeline' do - expect(pipeline).to be_valid - end - - it 'does not break the chain' do - expect(step.break?).to be false - end - end - - context 'when pipeline source is merge request' do - before do - stub_ci_pipeline_yaml_file(YAML.dump(config)) - end - - let(:pipeline) { build_stubbed(:ci_pipeline, project: project) } - - let(:merge_request_pipeline) do - build(:ci_pipeline, source: :merge_request_event, project: project) - end - - let(:chain) { described_class.new(merge_request_pipeline, command).tap(&:perform!) } - - context "when config contains 'merge_requests' keyword" do - let(:config) { { rspec: { script: 'echo', only: ['merge_requests'] } } } - - it 'does not break the chain' do - expect(chain).not_to be_break - end - end - - context "when config contains 'merge_request' keyword" do - let(:config) { { rspec: { script: 'echo', only: ['merge_request'] } } } - - it 'does not break the chain' do - expect(chain).not_to be_break - end - end - end -end diff --git a/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6a8b804597c85f60ab41448a0a170fa05e978c15 --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb @@ -0,0 +1,261 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Pipeline::Seed::Build::Cache do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:head_sha) { project.repository.head_commit.id } + let_it_be(:pipeline) { create(:ci_pipeline, project: project, sha: head_sha) } + + let(:processor) { described_class.new(pipeline, config) } + + describe '#build_attributes' do + subject { processor.build_attributes } + + context 'with cache:key' do + let(:config) do + { + key: 'a-key', + paths: ['vendor/ruby'] + } + end + + it { is_expected.to include(options: { cache: config }) } + end + + context 'with cache:key as a symbol' do + let(:config) do + { + key: :a_key, + paths: ['vendor/ruby'] + } + end + + it { is_expected.to include(options: { cache: config.merge(key: "a_key") }) } + end + + context 'with cache:key:files' do + shared_examples 'default key' do + let(:config) do + { key: { files: files } } + end + + it 'uses default key' do + expected = { options: { cache: { key: 'default' } } } + + is_expected.to include(expected) + end + end + + shared_examples 'version and gemfile files' do + let(:config) do + { + key: { + files: files + }, + paths: ['vendor/ruby'] + } + end + + it 'builds a string key' do + expected = { + options: { + cache: { + key: '703ecc8fef1635427a1f86a8a1a308831c122392', + paths: ['vendor/ruby'] + } + } + } + + is_expected.to include(expected) + end + end + + context 'with existing files' do + let(:files) { ['VERSION', 'Gemfile.zip'] } + + it_behaves_like 'version and gemfile files' + end + + context 'with files starting with ./' do + let(:files) { ['Gemfile.zip', './VERSION'] } + + it_behaves_like 'version and gemfile files' + end + + context 'with feature flag disabled' do + let(:files) { ['VERSION', 'Gemfile.zip'] } + + before do + stub_feature_flags(ci_file_based_cache: false) + end + + it_behaves_like 'default key' + end + + context 'with files ending with /' do + let(:files) { ['Gemfile.zip/'] } + + it_behaves_like 'default key' + end + + context 'with new line in filenames' do + let(:files) { ["Gemfile.zip\nVERSION"] } + + it_behaves_like 'default key' + end + + context 'with missing files' do + let(:files) { ['project-gemfile.lock', ''] } + + it_behaves_like 'default key' + end + + context 'with directories' do + shared_examples 'foo/bar directory key' do + let(:config) do + { + key: { + files: files + } + } + end + + it 'builds a string key' do + expected = { + options: { + cache: { key: '74bf43fb1090f161bdd4e265802775dbda2f03d1' } + } + } + + is_expected.to include(expected) + end + end + + context 'with directory' do + let(:files) { ['foo/bar'] } + + it_behaves_like 'foo/bar directory key' + end + + context 'with directory ending in slash' do + let(:files) { ['foo/bar/'] } + + it_behaves_like 'foo/bar directory key' + end + + context 'with directories ending in slash star' do + let(:files) { ['foo/bar/*'] } + + it_behaves_like 'foo/bar directory key' + end + end + end + + context 'with cache:key:prefix' do + context 'without files' do + let(:config) do + { + key: { + prefix: 'a-prefix' + }, + paths: ['vendor/ruby'] + } + end + + it 'adds prefix to default key' do + expected = { + options: { + cache: { + key: 'a-prefix-default', + paths: ['vendor/ruby'] + } + } + } + + is_expected.to include(expected) + end + end + + context 'with existing files' do + let(:config) do + { + key: { + files: ['VERSION', 'Gemfile.zip'], + prefix: 'a-prefix' + }, + paths: ['vendor/ruby'] + } + end + + it 'adds prefix key' do + expected = { + options: { + cache: { + key: 'a-prefix-703ecc8fef1635427a1f86a8a1a308831c122392', + paths: ['vendor/ruby'] + } + } + } + + is_expected.to include(expected) + end + end + + context 'with missing files' do + let(:config) do + { + key: { + files: ['project-gemfile.lock', ''], + prefix: 'a-prefix' + }, + paths: ['vendor/ruby'] + } + end + + it 'adds prefix to default key' do + expected = { + options: { + cache: { + key: 'a-prefix-default', + paths: ['vendor/ruby'] + } + } + } + + is_expected.to include(expected) + end + end + end + + context 'with all cache option keys' do + let(:config) do + { + key: 'a-key', + paths: ['vendor/ruby'], + untracked: true, + policy: 'push' + } + end + + it { is_expected.to include(options: { cache: config }) } + end + + context 'with unknown cache option keys' do + let(:config) do + { + key: 'a-key', + unknown_key: true + } + end + + it { expect { subject }.to raise_error(ArgumentError, /unknown_key/) } + end + + context 'with empty config' do + let(:config) { {} } + + it { is_expected.to include(options: {}) } + 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 945baf47b7bcc0079efa3c42ed9c652cab515229..53dcb6359fe6d4b8be044520de4f6832f25e16eb 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb @@ -4,7 +4,8 @@ require 'spec_helper' describe Gitlab::Ci::Pipeline::Seed::Build do let(:project) { create(:project, :repository) } - let(:pipeline) { create(:ci_empty_pipeline, project: project) } + let(:head_sha) { project.repository.head_commit.id } + let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: head_sha) } let(:attributes) { { name: 'rspec', ref: 'master' } } let(:previous_stages) { [] } @@ -69,6 +70,101 @@ describe Gitlab::Ci::Pipeline::Seed::Build do it { is_expected.to include(when: 'never') } end end + + context 'with cache:key' do + let(:attributes) do + { + name: 'rspec', + ref: 'master', + cache: { + key: 'a-value' + } + } + end + + it { is_expected.to include(options: { cache: { key: 'a-value' } }) } + end + + context 'with cache:key:files' do + let(:attributes) do + { + name: 'rspec', + ref: 'master', + cache: { + key: { + files: ['VERSION'] + } + } + } + end + + it 'includes cache options' do + cache_options = { + options: { + cache: { + key: 'f155568ad0933d8358f66b846133614f76dd0ca4' + } + } + } + + is_expected.to include(cache_options) + end + end + + context 'with cache:key:prefix' do + let(:attributes) do + { + name: 'rspec', + ref: 'master', + cache: { + key: { + prefix: 'something' + } + } + } + end + + it { is_expected.to include(options: { cache: { key: 'something-default' } }) } + end + + context 'with cache:key:files and prefix' do + let(:attributes) do + { + name: 'rspec', + ref: 'master', + cache: { + key: { + files: ['VERSION'], + prefix: 'something' + } + } + } + end + + it 'includes cache options' do + cache_options = { + options: { + cache: { + key: 'something-f155568ad0933d8358f66b846133614f76dd0ca4' + } + } + } + + is_expected.to include(cache_options) + end + end + + context 'with empty cache' do + let(:attributes) do + { + name: 'rspec', + ref: 'master', + cache: {} + } + end + + it { is_expected.to include(options: {}) } + end end describe '#bridge?' do @@ -773,10 +869,4 @@ describe Gitlab::Ci::Pipeline::Seed::Build do end end end - - describe '#scoped_variables_hash' do - subject { seed_build.scoped_variables_hash } - - it { is_expected.to eq(seed_build.to_resource.scoped_variables_hash) } - end end diff --git a/spec/lib/gitlab/ci/status/composite_spec.rb b/spec/lib/gitlab/ci/status/composite_spec.rb index 1725d954b92df22aaae33d646e50be22b876ad77..857483a9e0a93ed23f0eade4081f6447b6acb1de 100644 --- a/spec/lib/gitlab/ci/status/composite_spec.rb +++ b/spec/lib/gitlab/ci/status/composite_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::Ci::Status::Composite do diff --git a/spec/lib/gitlab/ci/trace/stream_spec.rb b/spec/lib/gitlab/ci/trace/stream_spec.rb index 1baea13299b4d26c8a790a4507bfdfd16a306bf2..45b59541ce6d290794c585721b72c9b8e227673e 100644 --- a/spec/lib/gitlab/ci/trace/stream_spec.rb +++ b/spec/lib/gitlab/ci/trace/stream_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::Ci::Trace::Stream, :clean_gitlab_redis_cache do @@ -100,7 +102,7 @@ describe Gitlab::Ci::Trace::Stream, :clean_gitlab_redis_cache do describe '#append' do shared_examples_for 'appends' do it "truncates and append content" do - stream.append("89", 4) + stream.append(+"89", 4) stream.seek(0) expect(stream.size).to eq(6) @@ -108,7 +110,7 @@ describe Gitlab::Ci::Trace::Stream, :clean_gitlab_redis_cache do end it 'appends in binary mode' do - '😺'.force_encoding('ASCII-8BIT').each_char.with_index do |byte, offset| + (+'😺').force_encoding('ASCII-8BIT').each_char.with_index do |byte, offset| stream.append(byte, offset) end @@ -154,7 +156,7 @@ describe Gitlab::Ci::Trace::Stream, :clean_gitlab_redis_cache do describe '#set' do shared_examples_for 'sets' do before do - stream.set("8901") + stream.set(+"8901") end it "overwrite content" do @@ -168,7 +170,7 @@ describe Gitlab::Ci::Trace::Stream, :clean_gitlab_redis_cache do context 'when stream is StringIO' do let(:stream) do described_class.new do - StringIO.new("12345678") + StringIO.new(+"12345678") end end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index cb5ebde16d79a16d0c896c93e27e30875dbd5469..4b1c7483b11abfe2ed4a6487889c23763dfc7bbe 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -108,6 +108,25 @@ module Gitlab it { expect(subject[:interruptible]).to be_falsy } end + + it "returns interruptible when overridden for job" do + config = YAML.dump({ default: { interruptible: true }, + rspec: { script: "rspec" } }) + + config_processor = Gitlab::Ci::YamlProcessor.new(config) + + expect(config_processor.stage_builds_attributes("test").size).to eq(1) + expect(config_processor.stage_builds_attributes("test").first).to eq({ + stage: "test", + stage_idx: 2, + name: "rspec", + options: { script: ["rspec"] }, + interruptible: true, + allow_failure: false, + when: "on_success", + yaml_variables: [] + }) + end end describe 'retry entry' do @@ -249,6 +268,108 @@ module Gitlab end end + describe '#workflow_attributes' do + context 'with disallowed workflow:variables' do + let(:config) do + <<-EOYML + workflow: + rules: + - if: $VAR == "value" + variables: + UNSUPPORTED: "unparsed" + EOYML + end + + it 'parses the workflow:rules configuration' do + expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'workflow config contains unknown keys: variables') + end + end + + context 'with rules and variables' do + let(:config) do + <<-EOYML + variables: + SUPPORTED: "parsed" + + workflow: + rules: + - if: $VAR == "value" + + hello: + script: echo world + EOYML + end + + it 'parses the workflow:rules configuration' do + expect(subject.workflow_attributes[:rules]).to contain_exactly({ if: '$VAR == "value"' }) + end + + it 'parses the root:variables as yaml_variables:' do + expect(subject.workflow_attributes[:yaml_variables]) + .to contain_exactly({ key: 'SUPPORTED', value: 'parsed', public: true }) + end + end + + context 'with rules and no variables' do + let(:config) do + <<-EOYML + workflow: + rules: + - if: $VAR == "value" + + hello: + script: echo world + EOYML + end + + it 'parses the workflow:rules configuration' do + expect(subject.workflow_attributes[:rules]).to contain_exactly({ if: '$VAR == "value"' }) + end + + it 'parses the root:variables as yaml_variables:' do + expect(subject.workflow_attributes[:yaml_variables]).to eq([]) + end + end + + context 'with variables and no rules' do + let(:config) do + <<-EOYML + variables: + SUPPORTED: "parsed" + + hello: + script: echo world + EOYML + end + + it 'parses the workflow:rules configuration' do + expect(subject.workflow_attributes[:rules]).to be_nil + end + + it 'parses the root:variables as yaml_variables:' do + expect(subject.workflow_attributes[:yaml_variables]) + .to contain_exactly({ key: 'SUPPORTED', value: 'parsed', public: true }) + end + end + + context 'with no rules and no variables' do + let(:config) do + <<-EOYML + hello: + script: echo world + EOYML + end + + it 'parses the workflow:rules configuration' do + expect(subject.workflow_attributes[:rules]).to be_nil + end + + it 'parses the root:variables as yaml_variables:' do + expect(subject.workflow_attributes[:yaml_variables]).to eq([]) + end + end + end + describe 'only / except policies validations' do context 'when `only` has an invalid value' do let(:config) { { rspec: { script: "rspec", type: "test", only: only } } } @@ -330,7 +451,7 @@ module Gitlab } end - it "return commands with scripts concencaced" do + it "return commands with scripts concatenated" do expect(subject[:options][:before_script]).to eq(["global script"]) end end @@ -343,7 +464,7 @@ module Gitlab } end - it "return commands with scripts concencaced" do + it "return commands with scripts concatenated" do expect(subject[:options][:before_script]).to eq(["global script"]) end end @@ -356,21 +477,48 @@ module Gitlab } end - it "return commands with scripts concencaced" do + it "return commands with scripts concatenated" do expect(subject[:options][:before_script]).to eq(["local script"]) end end + + context 'when script is array of arrays of strings' do + let(:config) do + { + before_script: [["global script", "echo 1"], ["ls"], "pwd"], + test: { script: ["script"] } + } + end + + it "return commands with scripts concatenated" do + expect(subject[:options][:before_script]).to eq(["global script", "echo 1", "ls", "pwd"]) + end + end end describe "script" do - let(:config) do - { - test: { script: ["script"] } - } + context 'when script is array of strings' do + let(:config) do + { + test: { script: ["script"] } + } + end + + it "return commands with scripts concatenated" do + expect(subject[:options][:script]).to eq(["script"]) + end end - it "return commands with scripts concencaced" do - expect(subject[:options][:script]).to eq(["script"]) + context 'when script is array of arrays of strings' do + let(:config) do + { + test: { script: [["script"], ["echo 1"], "ls"] } + } + end + + it "return commands with scripts concatenated" do + expect(subject[:options][:script]).to eq(["script", "echo 1", "ls"]) + end end end @@ -413,6 +561,19 @@ module Gitlab expect(subject[:options][:after_script]).to eq(["local after_script"]) end end + + context 'when script is array of arrays of strings' do + let(:config) do + { + after_script: [["global script", "echo 1"], ["ls"], "pwd"], + test: { script: ["script"] } + } + end + + it "return after_script in options" do + expect(subject[:options][:after_script]).to eq(["global script", "echo 1", "ls", "pwd"]) + end + end end end @@ -891,7 +1052,7 @@ module Gitlab config_processor = Gitlab::Ci::YamlProcessor.new(config) expect(config_processor.stage_builds_attributes("test").size).to eq(1) - expect(config_processor.stage_builds_attributes("test").first[:options][:cache]).to eq( + expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq( paths: ["logs/", "binaries/"], untracked: true, key: 'key', @@ -903,7 +1064,7 @@ module Gitlab config = YAML.dump( { default: { - cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'key' } + cache: { paths: ["logs/", "binaries/"], untracked: true, key: { files: ['file'] } } }, rspec: { script: "rspec" @@ -913,33 +1074,79 @@ module Gitlab config_processor = Gitlab::Ci::YamlProcessor.new(config) expect(config_processor.stage_builds_attributes("test").size).to eq(1) - expect(config_processor.stage_builds_attributes("test").first[:options][:cache]).to eq( + expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq( paths: ["logs/", "binaries/"], untracked: true, - key: 'key', + key: { files: ['file'] }, policy: 'pull-push' ) end - it "returns cache when defined in a job" do + it 'returns cache key when defined in a job' do config = YAML.dump({ rspec: { - cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'key' }, - script: "rspec" + cache: { paths: ['logs/', 'binaries/'], untracked: true, key: 'key' }, + script: 'rspec' } }) config_processor = Gitlab::Ci::YamlProcessor.new(config) - expect(config_processor.stage_builds_attributes("test").size).to eq(1) - expect(config_processor.stage_builds_attributes("test").first[:options][:cache]).to eq( - paths: ["logs/", "binaries/"], + expect(config_processor.stage_builds_attributes('test').size).to eq(1) + expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq( + paths: ['logs/', 'binaries/'], untracked: true, key: 'key', policy: 'pull-push' ) end + it 'returns cache files' do + config = YAML.dump( + rspec: { + cache: { + paths: ['logs/', 'binaries/'], + untracked: true, + key: { files: ['file'] } + }, + script: 'rspec' + } + ) + + config_processor = Gitlab::Ci::YamlProcessor.new(config) + + expect(config_processor.stage_builds_attributes('test').size).to eq(1) + expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq( + paths: ['logs/', 'binaries/'], + untracked: true, + key: { files: ['file'] }, + policy: 'pull-push' + ) + end + + it 'returns cache files with prefix' do + config = YAML.dump( + rspec: { + cache: { + paths: ['logs/', 'binaries/'], + untracked: true, + key: { files: ['file'], prefix: 'prefix' } + }, + script: 'rspec' + } + ) + + config_processor = Gitlab::Ci::YamlProcessor.new(config) + + expect(config_processor.stage_builds_attributes('test').size).to eq(1) + expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq( + paths: ['logs/', 'binaries/'], + untracked: true, + key: { files: ['file'], prefix: 'prefix' }, + policy: 'pull-push' + ) + end + it "overwrite cache when defined for a job and globally" do config = YAML.dump({ cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'global' }, @@ -952,7 +1159,7 @@ module Gitlab config_processor = Gitlab::Ci::YamlProcessor.new(config) expect(config_processor.stage_builds_attributes("test").size).to eq(1) - expect(config_processor.stage_builds_attributes("test").first[:options][:cache]).to eq( + expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq( paths: ["test/"], untracked: false, key: 'local', @@ -970,6 +1177,7 @@ module Gitlab rspec: { artifacts: { paths: ["logs/", "binaries/"], + expose_as: "Exposed artifacts", untracked: true, name: "custom_name", expire_in: "7d" @@ -993,6 +1201,7 @@ module Gitlab artifacts: { name: "custom_name", paths: ["logs/", "binaries/"], + expose_as: "Exposed artifacts", untracked: true, expire_in: "7d" } @@ -1251,7 +1460,7 @@ module Gitlab end end - describe "Needs" do + describe "Job Needs" do let(:needs) { } let(:dependencies) { } @@ -1259,6 +1468,7 @@ module Gitlab { build1: { stage: 'build', script: 'test' }, build2: { stage: 'build', script: 'test' }, + parallel: { stage: 'build', script: 'test', parallel: 2 }, test1: { stage: 'test', script: 'test', needs: needs, dependencies: dependencies }, test2: { stage: 'test', script: 'test' }, deploy: { stage: 'test', script: 'test' } @@ -1275,7 +1485,7 @@ module Gitlab let(:needs) { %w(build1 build2) } it "does create jobs with valid specification" do - expect(subject.builds.size).to eq(5) + expect(subject.builds.size).to eq(7) expect(subject.builds[0]).to eq( stage: "build", stage_idx: 1, @@ -1287,16 +1497,11 @@ module Gitlab allow_failure: false, yaml_variables: [] ) - expect(subject.builds[2]).to eq( + expect(subject.builds[4]).to eq( stage: "test", stage_idx: 2, name: "test1", - options: { - script: ["test"], - # This does not make sense, there is a follow-up: - # https://gitlab.com/gitlab-org/gitlab-foss/issues/65569 - bridge_needs: %w[build1 build2] - }, + options: { script: ["test"] }, needs_attributes: [ { name: "build1" }, { name: "build2" } @@ -1308,10 +1513,25 @@ module Gitlab end end - context 'needs two builds defined as symbols' do - let(:needs) { [:build1, :build2] } + context 'needs parallel job' do + let(:needs) { %w(parallel) } - it { expect { subject }.not_to raise_error } + it "does create jobs with valid specification" do + expect(subject.builds.size).to eq(7) + expect(subject.builds[4]).to eq( + stage: "test", + stage_idx: 2, + name: "test1", + options: { script: ["test"] }, + needs_attributes: [ + { name: "parallel 1/2" }, + { name: "parallel 2/2" } + ], + when: "on_success", + allow_failure: false, + yaml_variables: [] + ) + end end context 'undefined need' do @@ -1545,28 +1765,42 @@ module Gitlab config = YAML.dump({ before_script: "bundle update", rspec: { script: "test" } }) expect do Gitlab::Ci::YamlProcessor.new(config) - end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "before_script config should be an array of strings") + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "before_script config should be an array containing strings and arrays of strings") end it "returns errors if job before_script parameter is not an array of strings" do config = YAML.dump({ rspec: { script: "test", before_script: [10, "test"] } }) expect do Gitlab::Ci::YamlProcessor.new(config) - end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:before_script config should be an array of strings") + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:before_script config should be an array containing strings and arrays of strings") + end + + it "returns errors if job before_script parameter is multi-level nested array of strings" do + config = YAML.dump({ rspec: { script: "test", before_script: [["ls", ["pwd"]], "test"] } }) + expect do + Gitlab::Ci::YamlProcessor.new(config) + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:before_script config should be an array containing strings and arrays of strings") end it "returns errors if after_script parameter is invalid" do config = YAML.dump({ after_script: "bundle update", rspec: { script: "test" } }) expect do Gitlab::Ci::YamlProcessor.new(config) - end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "after_script config should be an array of strings") + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "after_script config should be an array containing strings and arrays of strings") end it "returns errors if job after_script parameter is not an array of strings" do config = YAML.dump({ rspec: { script: "test", after_script: [10, "test"] } }) expect do Gitlab::Ci::YamlProcessor.new(config) - end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:after_script config should be an array of strings") + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:after_script config should be an array containing strings and arrays of strings") + end + + it "returns errors if job after_script parameter is multi-level nested array of strings" do + config = YAML.dump({ rspec: { script: "test", after_script: [["ls", ["pwd"]], "test"] } }) + expect do + Gitlab::Ci::YamlProcessor.new(config) + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:after_script config should be an array containing strings and arrays of strings") end it "returns errors if image parameter is invalid" do @@ -1776,14 +2010,42 @@ module Gitlab config = YAML.dump({ cache: { key: 1 }, rspec: { script: "test" } }) expect do Gitlab::Ci::YamlProcessor.new(config) - end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "cache:key config should be a string or symbol") + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "cache:key should be a hash, a string or a symbol") end it "returns errors if job cache:key is not an a string" do config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: 1 } } }) expect do Gitlab::Ci::YamlProcessor.new(config) - end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:cache:key config should be a string or symbol") + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:cache:key should be a hash, a string or a symbol") + end + + it 'returns errors if job cache:key:files is not an array of strings' do + config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { files: [1] } } } }) + expect do + Gitlab::Ci::YamlProcessor.new(config) + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:rspec:cache:key:files config should be an array of strings') + end + + it 'returns errors if job cache:key:files is an empty array' do + config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { files: [] } } } }) + expect do + Gitlab::Ci::YamlProcessor.new(config) + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:rspec:cache:key:files config requires at least 1 item') + end + + it 'returns errors if job defines only cache:key:prefix' do + config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { prefix: 'prefix-key' } } } }) + expect do + Gitlab::Ci::YamlProcessor.new(config) + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:rspec:cache:key config missing required keys: files') + end + + it 'returns errors if job cache:key:prefix is not an a string' do + config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { prefix: 1, files: ['file'] } } } }) + expect do + Gitlab::Ci::YamlProcessor.new(config) + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:rspec:cache:key:prefix config should be a string or symbol') end it "returns errors if job cache:untracked is not an array of strings" do diff --git a/spec/lib/gitlab/cleanup/orphan_job_artifact_files_spec.rb b/spec/lib/gitlab/cleanup/orphan_job_artifact_files_spec.rb index 974cc2c4660a5d9d7d8fc2c3f4a9aad48b08425c..fc9792e16d753008ecdea71ed831b795fd25a48c 100644 --- a/spec/lib/gitlab/cleanup/orphan_job_artifact_files_spec.rb +++ b/spec/lib/gitlab/cleanup/orphan_job_artifact_files_spec.rb @@ -21,11 +21,10 @@ describe Gitlab::Cleanup::OrphanJobArtifactFiles do end it 'errors when invalid niceness is given' do + allow(Gitlab::Utils).to receive(:which).with('ionice').and_return('/fake/ionice') cleanup = described_class.new(logger: null_logger, niceness: 'FooBar') - expect(null_logger).to receive(:error).with(/FooBar/) - - cleanup.run! + expect { cleanup.run! }.to raise_error('Invalid niceness') end it 'finds artifacts on disk' do @@ -63,6 +62,8 @@ describe Gitlab::Cleanup::OrphanJobArtifactFiles do def mock_artifacts_found(cleanup, *files) mock = allow(cleanup).to receive(:find_artifacts) - files.each { |file| mock.and_yield(file) } + # Because we shell out to run `find -L ...`, each file actually + # contains a trailing newline + files.each { |file| mock.and_yield("#{file}\n") } end end diff --git a/spec/lib/gitlab/cluster/mixins/puma_cluster_spec.rb b/spec/lib/gitlab/cluster/mixins/puma_cluster_spec.rb index 1eddf488c5dd8bee2b2677d6fbf1d2c49ba3126a..b8ac8c5b95ccd32d72fff2f5850db8a0b49bcaae 100644 --- a/spec/lib/gitlab/cluster/mixins/puma_cluster_spec.rb +++ b/spec/lib/gitlab/cluster/mixins/puma_cluster_spec.rb @@ -8,15 +8,28 @@ describe Gitlab::Cluster::Mixins::PumaCluster do PUMA_STARTUP_TIMEOUT = 30 context 'when running Puma in Cluster-mode' do - %i[USR1 USR2 INT HUP].each do |signal| - it "for #{signal} does execute phased restart block" do + using RSpec::Parameterized::TableSyntax + + where(:signal, :exitstatus, :termsig) do + # executes phased restart block + :USR1 | 140 | nil + :USR2 | 140 | nil + :INT | 140 | nil + :HUP | 140 | nil + + # does not execute phased restart block + :TERM | nil | 15 + end + + with_them do + it 'properly handles process lifecycle' do with_puma(workers: 1) do |pid| Process.kill(signal, pid) child_pid, child_status = Process.wait2(pid) expect(child_pid).to eq(pid) - expect(child_status).to be_exited - expect(child_status.exitstatus).to eq(140) + expect(child_status.exitstatus).to eq(exitstatus) + expect(child_status.termsig).to eq(termsig) end end end @@ -62,8 +75,12 @@ describe Gitlab::Cluster::Mixins::PumaCluster do Puma::Cluster.prepend(#{described_class}) - Gitlab::Cluster::LifecycleEvents.on_before_phased_restart do - exit(140) + mutex = Mutex.new + + Gitlab::Cluster::LifecycleEvents.on_before_blackout_period do + mutex.synchronize do + exit(140) + end end # redirect stderr to stdout diff --git a/spec/lib/gitlab/cluster/mixins/unicorn_http_server_spec.rb b/spec/lib/gitlab/cluster/mixins/unicorn_http_server_spec.rb index 2b3a267991cb44c5edf26930d0ff579d4b0044a0..ebe019924d5bc60b914e2458835606278e0702d3 100644 --- a/spec/lib/gitlab/cluster/mixins/unicorn_http_server_spec.rb +++ b/spec/lib/gitlab/cluster/mixins/unicorn_http_server_spec.rb @@ -5,31 +5,30 @@ require 'spec_helper' # For easier debugging set `UNICORN_DEBUG=1` describe Gitlab::Cluster::Mixins::UnicornHttpServer do - UNICORN_STARTUP_TIMEOUT = 10 + UNICORN_STARTUP_TIMEOUT = 30 context 'when running Unicorn' do - %i[USR2].each do |signal| - it "for #{signal} does execute phased restart block" do - with_unicorn(workers: 1) do |pid| - Process.kill(signal, pid) + using RSpec::Parameterized::TableSyntax - child_pid, child_status = Process.wait2(pid) - expect(child_pid).to eq(pid) - expect(child_status).to be_exited - expect(child_status.exitstatus).to eq(140) - end - end + where(:signal, :exitstatus, :termsig) do + # executes phased restart block + :USR2 | 140 | nil + :QUIT | 140 | nil + + # does not execute phased restart block + :INT | 0 | nil + :TERM | 0 | nil end - %i[QUIT TERM INT].each do |signal| - it "for #{signal} does not execute phased restart block" do + with_them do + it 'properly handles process lifecycle' do with_unicorn(workers: 1) do |pid| Process.kill(signal, pid) child_pid, child_status = Process.wait2(pid) expect(child_pid).to eq(pid) - expect(child_status).to be_exited - expect(child_status.exitstatus).to eq(0) + expect(child_status.exitstatus).to eq(exitstatus) + expect(child_status.termsig).to eq(termsig) end end end @@ -74,8 +73,12 @@ describe Gitlab::Cluster::Mixins::UnicornHttpServer do Unicorn::HttpServer.prepend(#{described_class}) - Gitlab::Cluster::LifecycleEvents.on_before_phased_restart do - exit(140) + mutex = Mutex.new + + Gitlab::Cluster::LifecycleEvents.on_before_blackout_period do + mutex.synchronize do + exit(140) + end end # redirect stderr to stdout diff --git a/spec/lib/gitlab/cycle_analytics/events_spec.rb b/spec/lib/gitlab/cycle_analytics/events_spec.rb index a163de07967b7a0f4158f125f03ac897a4ba7a8d..9eee7e890620a940dfb48677a2222e3a651972d5 100644 --- a/spec/lib/gitlab/cycle_analytics/events_spec.rb +++ b/spec/lib/gitlab/cycle_analytics/events_spec.rb @@ -129,7 +129,7 @@ describe 'cycle analytics events' do end end - describe '#test_events' do + describe '#test_events', :sidekiq_might_not_need_inline do let(:stage) { :test } let(:merge_request) { MergeRequest.first } @@ -234,7 +234,7 @@ describe 'cycle analytics events' do end end - describe '#staging_events' do + describe '#staging_events', :sidekiq_might_not_need_inline do let(:stage) { :staging } let(:merge_request) { MergeRequest.first } @@ -306,7 +306,7 @@ describe 'cycle analytics events' do end end - describe '#production_events' do + describe '#production_events', :sidekiq_might_not_need_inline do let(:stage) { :production } let!(:context) { create(:issue, project: project, created_at: 2.days.ago) } diff --git a/spec/lib/gitlab/cycle_analytics/group_stage_summary_spec.rb b/spec/lib/gitlab/cycle_analytics/group_stage_summary_spec.rb index d5c2f7cc579771c9bbe7ac5068e903b793cffe4b..664009f140f0ef81be8eb04a39ac7c9145a8d6d9 100644 --- a/spec/lib/gitlab/cycle_analytics/group_stage_summary_spec.rb +++ b/spec/lib/gitlab/cycle_analytics/group_stage_summary_spec.rb @@ -44,6 +44,14 @@ describe Gitlab::CycleAnalytics::GroupStageSummary do expect(subject.first[:value]).to eq(2) end end + + context 'when `from` and `to` parameters are provided' do + subject { described_class.new(group, options: { from: 10.days.ago, to: Time.now, current_user: user }).data } + + it 'finds issues from 5 days ago' do + expect(subject.first[:value]).to eq(2) + end + end end context 'with other projects' do @@ -97,6 +105,14 @@ describe Gitlab::CycleAnalytics::GroupStageSummary do expect(subject.second[:value]).to eq(2) end end + + context 'when `from` and `to` parameters are provided' do + subject { described_class.new(group, options: { from: 10.days.ago, to: Time.now, current_user: user }).data } + + it 'finds deployments from 5 days ago' do + expect(subject.second[:value]).to eq(2) + end + end end context 'with other projects' do diff --git a/spec/lib/gitlab/cycle_analytics/usage_data_spec.rb b/spec/lib/gitlab/cycle_analytics/usage_data_spec.rb index e568ea633db7864340b2a85c8b8ea0c5fc503bc7..d4ab9bc225bd03d9eeeef16670c52703a33759db 100644 --- a/spec/lib/gitlab/cycle_analytics/usage_data_spec.rb +++ b/spec/lib/gitlab/cycle_analytics/usage_data_spec.rb @@ -71,7 +71,7 @@ describe Gitlab::CycleAnalytics::UsageData do } end - it 'returns the aggregated usage data of every selected project' do + it 'returns the aggregated usage data of every selected project', :sidekiq_might_not_need_inline do result = subject.to_json expect(result).to have_key(:avg_cycle_analytics) diff --git a/spec/lib/gitlab/danger/helper_spec.rb b/spec/lib/gitlab/danger/helper_spec.rb index 1696d3566adf8914e293b3be4503f65ae949e042..8056418e6972e9d4fa41e0b1096159d16af738cc 100644 --- a/spec/lib/gitlab/danger/helper_spec.rb +++ b/spec/lib/gitlab/danger/helper_spec.rb @@ -178,6 +178,7 @@ describe Gitlab::Danger::Helper do 'app/assets/foo' | :frontend 'app/views/foo' | :frontend 'public/foo' | :frontend + 'scripts/frontend/foo' | :frontend 'spec/javascripts/foo' | :frontend 'spec/frontend/bar' | :frontend 'vendor/assets/foo' | :frontend @@ -193,10 +194,8 @@ describe Gitlab::Danger::Helper do 'app/models/foo' | :backend 'bin/foo' | :backend 'config/foo' | :backend - 'danger/foo' | :backend 'lib/foo' | :backend 'rubocop/foo' | :backend - 'scripts/foo' | :backend 'spec/foo' | :backend 'spec/foo/bar' | :backend @@ -209,16 +208,24 @@ describe Gitlab::Danger::Helper do 'vendor/languages.yml' | :backend 'vendor/licenses.csv' | :backend - 'Dangerfile' | :backend 'Gemfile' | :backend 'Gemfile.lock' | :backend 'Procfile' | :backend 'Rakefile' | :backend 'FOO_VERSION' | :backend + 'Dangerfile' | :engineering_productivity + 'danger/commit_messages/Dangerfile' | :engineering_productivity + 'ee/danger/commit_messages/Dangerfile' | :engineering_productivity + 'danger/commit_messages/' | :engineering_productivity + 'ee/danger/commit_messages/' | :engineering_productivity '.gitlab-ci.yml' | :engineering_productivity '.gitlab/ci/cng.gitlab-ci.yml' | :engineering_productivity '.gitlab/ci/ee-specific-checks.gitlab-ci.yml' | :engineering_productivity + 'scripts/foo' | :engineering_productivity + 'lib/gitlab/danger/foo' | :engineering_productivity + 'ee/lib/gitlab/danger/foo' | :engineering_productivity + 'lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml' | :backend 'ee/FOO_VERSION' | :unknown diff --git a/spec/lib/gitlab/danger/teammate_spec.rb b/spec/lib/gitlab/danger/teammate_spec.rb index bd1c2b10dc82790b246fa52dfda304f702ffb474..35edfa08a63da1df1369f72ff490a5b35e470b16 100644 --- a/spec/lib/gitlab/danger/teammate_spec.rb +++ b/spec/lib/gitlab/danger/teammate_spec.rb @@ -30,7 +30,7 @@ describe Gitlab::Danger::Teammate do expect(subject.maintainer?(project, :frontend, labels)).to be_truthy end - context 'when labels contain Create and the category is test' do + context 'when labels contain devops::create and the category is test' do let(:labels) { ['devops::create'] } context 'when role is Test Automation Engineer, Create' do @@ -79,6 +79,22 @@ describe Gitlab::Danger::Teammate do it '#maintainer? returns false' do expect(subject.maintainer?(project, :engineering_productivity, labels)).to be_falsey end + + context 'when capabilities include maintainer backend' do + let(:capabilities) { ['maintainer backend'] } + + it '#maintainer? returns true' do + expect(subject.maintainer?(project, :engineering_productivity, labels)).to be_truthy + end + end + + context 'when capabilities include trainee_maintainer backend' do + let(:capabilities) { ['trainee_maintainer backend'] } + + it '#traintainer? returns true' do + expect(subject.traintainer?(project, :engineering_productivity, labels)).to be_truthy + end + end end end end diff --git a/spec/lib/gitlab/data_builder/deployment_spec.rb b/spec/lib/gitlab/data_builder/deployment_spec.rb index 0a6e2302b09cc98cf7cfb5598478823092a1b7d0..42d7329494df4f3c933d242e3b5c2122e7aedc01 100644 --- a/spec/lib/gitlab/data_builder/deployment_spec.rb +++ b/spec/lib/gitlab/data_builder/deployment_spec.rb @@ -35,5 +35,12 @@ describe Gitlab::DataBuilder::Deployment do expect(data[:commit_url]).to eq(expected_commit_url) expect(data[:commit_title]).to eq(commit.title) end + + it 'does not include the deployable URL when there is no deployable' do + deployment = create(:deployment, status: :failed, deployable: nil) + data = described_class.build(deployment) + + expect(data[:deployable_url]).to be_nil + end end end diff --git a/spec/lib/gitlab/data_builder/push_spec.rb b/spec/lib/gitlab/data_builder/push_spec.rb index 58509b6946380cbfda41a085a2c65c02e5aa5820..cbc03fc38ebcc2751e6cc6a6e1729ac2330a252e 100644 --- a/spec/lib/gitlab/data_builder/push_spec.rb +++ b/spec/lib/gitlab/data_builder/push_spec.rb @@ -57,6 +57,32 @@ describe Gitlab::DataBuilder::Push do include_examples 'deprecated repository hook data' end + describe '.sample_data' do + let(:data) { described_class.sample_data } + + it { expect(data).to be_a(Hash) } + it { expect(data[:before]).to eq('95790bf891e76fee5e1747ab589903a6a1f80f22') } + it { expect(data[:after]).to eq('da1560886d4f094c3e6c9ef40349f7d38b5d27d7') } + it { expect(data[:ref]).to eq('refs/heads/master') } + it { expect(data[:project_id]).to eq(15) } + it { expect(data[:commits].size).to eq(1) } + it { expect(data[:total_commits_count]).to eq(1) } + it 'contains project data' do + expect(data[:project]).to be_a(Hash) + expect(data[:project][:id]).to eq(15) + expect(data[:project][:name]).to eq('gitlab') + expect(data[:project][:description]).to eq('') + expect(data[:project][:web_url]).to eq('http://test.example.com/gitlab/gitlab') + expect(data[:project][:avatar_url]).to eq('https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80') + expect(data[:project][:git_http_url]).to eq('http://test.example.com/gitlab/gitlab.git') + expect(data[:project][:git_ssh_url]).to eq('git@test.example.com:gitlab/gitlab.git') + expect(data[:project][:namespace]).to eq('gitlab') + expect(data[:project][:visibility_level]).to eq(0) + expect(data[:project][:path_with_namespace]).to eq('gitlab/gitlab') + expect(data[:project][:default_branch]).to eq('master') + end + end + describe '.build' do let(:data) do described_class.build( diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index 49f92f145597fa81ec482657543da81b5beac94c..449eee7a371b318a06dd203f87d04122d574033a 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -142,7 +142,6 @@ describe Gitlab::Database::MigrationHelpers do allow(model).to receive(:transaction_open?).and_return(false) allow(model).to receive(:index_exists?).and_return(true) allow(model).to receive(:disable_statement_timeout).and_call_original - allow(model).to receive(:supports_drop_index_concurrently?).and_return(true) end describe 'by column name' do 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 aab6fbcbbd1b35fc2aff6d4b36f2f13fa170f0fc..5b1a17e734d05ae57668d38a3f70ef15aec68b48 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 @@ -164,15 +164,6 @@ describe Gitlab::DatabaseImporters::SelfMonitoring::Project::CreateService do end it_behaves_like 'has prometheus service', 'http://localhost:9090' - - it 'does not overwrite the existing whitelist' do - application_setting.outbound_local_requests_whitelist = ['example.com'] - - expect(result[:status]).to eq(:success) - expect(application_setting.outbound_local_requests_whitelist).to contain_exactly( - 'example.com', 'localhost' - ) - end end context 'with non default prometheus address' do diff --git a/spec/lib/gitlab/devise_failure_spec.rb b/spec/lib/gitlab/devise_failure_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..eee05c7befdafbe5c2882ca91ba99e431f21f0b8 --- /dev/null +++ b/spec/lib/gitlab/devise_failure_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::DeviseFailure do + let(:env) do + { + 'REQUEST_URI' => 'http://test.host/', + 'HTTP_HOST' => 'test.host', + 'REQUEST_METHOD' => 'GET', + 'warden.options' => { scope: :user }, + 'rack.session' => {}, + 'rack.session.options' => {}, + 'rack.input' => "", + 'warden' => OpenStruct.new(message: nil) + } + end + + let(:response) { described_class.call(env).to_a } + let(:request) { ActionDispatch::Request.new(env) } + + context 'When redirecting' do + it 'sets the expire_after key' do + response + + expect(env['rack.session.options']).to have_key(:expire_after) + end + + it 'returns to the default redirect location' do + expect(response.first).to eq(302) + expect(request.flash[:alert]).to eq('You need to sign in or sign up before continuing.') + expect(response.second['Location']).to eq('http://test.host/users/sign_in') + end + end +end diff --git a/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb b/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb index 35aa663b0a52ea571d0c19114ce09e5fb26e3084..a65214fab615eec0e90d4d5a17bd15acc67c8560 100644 --- a/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb +++ b/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::Email::Hook::SmimeSignatureInterceptor do diff --git a/spec/lib/gitlab/exclusive_lease_helpers_spec.rb b/spec/lib/gitlab/exclusive_lease_helpers_spec.rb index c3b706fc538490f08be29517fd3398b98ec8e70e..747fe369c78130c60b3263eb7aacde3d0a282857 100644 --- a/spec/lib/gitlab/exclusive_lease_helpers_spec.rb +++ b/spec/lib/gitlab/exclusive_lease_helpers_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::ExclusiveLeaseHelpers, :clean_gitlab_redis_shared_state do diff --git a/spec/lib/gitlab/exclusive_lease_spec.rb b/spec/lib/gitlab/exclusive_lease_spec.rb index aed7d8d81cef9b82d1c3c572f5d572a893a183d4..0739f622af546d6986651a53e094da90c4a959b8 100644 --- a/spec/lib/gitlab/exclusive_lease_spec.rb +++ b/spec/lib/gitlab/exclusive_lease_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::ExclusiveLease, :clean_gitlab_redis_shared_state do diff --git a/spec/lib/gitlab/experimentation_spec.rb b/spec/lib/gitlab/experimentation_spec.rb index 2e5fd16d3707d1d33b567f5867eea3c3a939b927..9be6ace3be5414264f429cc6f68483acf71c3d1c 100644 --- a/spec/lib/gitlab/experimentation_spec.rb +++ b/spec/lib/gitlab/experimentation_spec.rb @@ -2,81 +2,194 @@ require 'spec_helper' -describe Gitlab::Experimentation::ControllerConcern, type: :controller do - controller(ApplicationController) do - include Gitlab::Experimentation::ControllerConcern +describe Gitlab::Experimentation do + before do + stub_const('Gitlab::Experimentation::EXPERIMENTS', { + test_experiment: { + feature_toggle: feature_toggle, + environment: environment, + enabled_ratio: enabled_ratio, + tracking_category: 'Team' + } + }) - def index - head :ok - end + stub_feature_flags(feature_toggle => true) end - describe '#set_experimentation_subject_id_cookie' do - before do - get :index + let(:feature_toggle) { :test_experiment_toggle } + let(:environment) { Rails.env.test? } + let(:enabled_ratio) { 0.1 } + + describe Gitlab::Experimentation::ControllerConcern, type: :controller do + controller(ApplicationController) do + include Gitlab::Experimentation::ControllerConcern + + def index + head :ok + end end - context 'cookie is present' do + describe '#set_experimentation_subject_id_cookie' do before do - cookies[:experimentation_subject_id] = 'test' + get :index end - it 'does not change the cookie' do - expect(cookies[:experimentation_subject_id]).to eq 'test' + context 'cookie is present' do + before do + cookies[:experimentation_subject_id] = 'test' + end + + it 'does not change the cookie' do + expect(cookies[:experimentation_subject_id]).to eq 'test' + end end - end - context 'cookie is not present' do - it 'sets a permanent signed cookie' do - expect(cookies.permanent.signed[:experimentation_subject_id]).to be_present + context 'cookie is not present' do + it 'sets a permanent signed cookie' do + expect(cookies.permanent.signed[:experimentation_subject_id]).to be_present + end end end - end - describe '#experiment_enabled?' do - context 'cookie is not present' do - it 'calls Gitlab::Experimentation.enabled? with the name of the experiment and an experimentation_subject_index of nil' do - expect(Gitlab::Experimentation).to receive(:enabled?).with(:test_experiment, nil) - controller.experiment_enabled?(:test_experiment) + 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 + controller.experiment_enabled?(:test_experiment) + end + end + + context 'cookie is present' do + before do + cookies.permanent.signed[:experimentation_subject_id] = 'abcd-1234' + get :index + end + + 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 + controller.experiment_enabled?(:test_experiment) + end + end + + describe 'URL parameter to force enable experiment' do + it 'returns true' do + get :index, params: { force_experiment: :test_experiment } + + expect(controller.experiment_enabled?(:test_experiment)).to be_truthy + end end end - context 'cookie is present' do - before do - cookies.permanent.signed[:experimentation_subject_id] = 'abcd-1234' - get :index + describe '#track_experiment_event' do + context 'when the experiment is enabled' do + before do + stub_experiment(test_experiment: true) + end + + context 'the user is part of the experimental group' do + before do + stub_experiment_for_user(test_experiment: true) + end + + it 'tracks the event with the right parameters' do + expect(Gitlab::Tracking).to receive(:event).with( + 'Team', + 'start', + label: nil, + property: 'experimental_group' + ) + controller.track_experiment_event(:test_experiment, 'start') + end + end + + context 'the user is part of the control group' do + before do + stub_experiment_for_user(test_experiment: false) + end + + it 'tracks the event with the right parameters' do + expect(Gitlab::Tracking).to receive(:event).with( + 'Team', + 'start', + label: nil, + property: 'control_group' + ) + controller.track_experiment_event(:test_experiment, 'start') + end + end end - it 'calls Gitlab::Experimentation.enabled? 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?).with(:test_experiment, 76) - controller.experiment_enabled?(:test_experiment) + context 'when the experiment is disabled' do + before do + stub_experiment(test_experiment: false) + end + + it 'does not track the event' do + expect(Gitlab::Tracking).not_to receive(:event) + controller.track_experiment_event(:test_experiment, 'start') + end end end - end -end -describe Gitlab::Experimentation do - before do - stub_const('Gitlab::Experimentation::EXPERIMENTS', { - test_experiment: { - feature_toggle: feature_toggle, - environment: environment, - enabled_ratio: enabled_ratio - } - }) + describe '#frontend_experimentation_tracking_data' do + context 'when the experiment is enabled' do + before do + stub_experiment(test_experiment: true) + end - stub_feature_flags(feature_toggle => true) - end + context 'the user is part of the experimental group' do + before do + stub_experiment_for_user(test_experiment: true) + end + + it 'pushes the right parameters to gon' do + controller.frontend_experimentation_tracking_data(:test_experiment, 'start') + expect(Gon.tracking_data).to eq( + { + category: 'Team', + action: 'start', + label: nil, + property: 'experimental_group' + } + ) + end + end - let(:feature_toggle) { :test_experiment_toggle } - let(:environment) { Rails.env.test? } - let(:enabled_ratio) { 0.1 } + context 'the user is part of the control group' do + before do + allow_any_instance_of(described_class).to receive(:experiment_enabled?).with(:test_experiment).and_return(false) + end + + it 'pushes the right parameters to gon' do + controller.frontend_experimentation_tracking_data(:test_experiment, 'start') + expect(Gon.tracking_data).to eq( + { + category: 'Team', + action: 'start', + label: nil, + property: 'control_group' + } + ) + end + end + end - describe '.enabled?' do - subject { described_class.enabled?(:test_experiment, experimentation_subject_index) } + context 'when the experiment is disabled' do + before do + stub_experiment(test_experiment: false) + end - let(:experimentation_subject_index) { 9 } + it 'does not push data to gon' do + expect(Gon.method_defined?(:tracking_data)).to be_falsey + controller.track_experiment_event(:test_experiment, 'start') + end + end + end + end + + describe '.enabled?' do + subject { described_class.enabled?(:test_experiment) } context 'feature toggle is enabled, we are on the right environment and we are selected' do it { is_expected.to be_truthy } @@ -84,7 +197,7 @@ describe Gitlab::Experimentation do describe 'experiment is not defined' do it 'returns false' do - expect(described_class.enabled?(:missing_experiment, experimentation_subject_index)).to be_falsey + expect(described_class.enabled?(:missing_experiment)).to be_falsey end end @@ -127,30 +240,52 @@ describe Gitlab::Experimentation do it { is_expected.to be_falsey } end end + end - describe 'enabled ratio' do - context 'enabled ratio is not set' do - let(:enabled_ratio) { nil } + describe '.enabled_for_user?' do + subject { described_class.enabled_for_user?(:test_experiment, experimentation_subject_index) } - it { is_expected.to be_falsey } + let(:experimentation_subject_index) { 9 } + + context 'experiment is disabled' do + before do + allow(described_class).to receive(:enabled?).and_return(false) end - context 'experimentation_subject_index is not set' do - let(:experimentation_subject_index) { nil } + it { is_expected.to be_falsey } + end - it { is_expected.to be_falsey } + context 'experiment is enabled' do + before do + allow(described_class).to receive(:enabled?).and_return(true) end - context 'experimentation_subject_index is an empty string' do - let(:experimentation_subject_index) { '' } + it { is_expected.to be_truthy } + + context 'enabled ratio is not set' do + let(:enabled_ratio) { nil } it { is_expected.to be_falsey } end - context 'experimentation_subject_index outside enabled ratio' do - let(:experimentation_subject_index) { 11 } + describe 'experimentation_subject_index' do + context 'experimentation_subject_index is not set' do + let(:experimentation_subject_index) { nil } - it { is_expected.to be_falsey } + it { is_expected.to be_falsey } + end + + context 'experimentation_subject_index is an empty string' do + let(:experimentation_subject_index) { '' } + + it { is_expected.to be_falsey } + end + + context 'experimentation_subject_index outside enabled ratio' do + let(:experimentation_subject_index) { 11 } + + it { is_expected.to be_falsey } + end end end end diff --git a/spec/lib/gitlab/external_authorization/access_spec.rb b/spec/lib/gitlab/external_authorization/access_spec.rb index 5dc2521b31009f2d0d1f6ca92ac5a708582ca9fc..8a08b2a627558dadbcca754d166ca42a5228c465 100644 --- a/spec/lib/gitlab/external_authorization/access_spec.rb +++ b/spec/lib/gitlab/external_authorization/access_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::ExternalAuthorization::Access, :clean_gitlab_redis_cache do diff --git a/spec/lib/gitlab/external_authorization/cache_spec.rb b/spec/lib/gitlab/external_authorization/cache_spec.rb index 58e7d6267074dfdf84baa1f897968cd538c58698..1f217249f97a3255110ef0bd02948aa2adc1de61 100644 --- a/spec/lib/gitlab/external_authorization/cache_spec.rb +++ b/spec/lib/gitlab/external_authorization/cache_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::ExternalAuthorization::Cache, :clean_gitlab_redis_cache do diff --git a/spec/lib/gitlab/external_authorization/client_spec.rb b/spec/lib/gitlab/external_authorization/client_spec.rb index a87f50b4586b2bf85a6f37d780b867044fd9b2fc..a17d933e3bb90261d2dd06c338c09309c7e50d41 100644 --- a/spec/lib/gitlab/external_authorization/client_spec.rb +++ b/spec/lib/gitlab/external_authorization/client_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::ExternalAuthorization::Client do diff --git a/spec/lib/gitlab/external_authorization/logger_spec.rb b/spec/lib/gitlab/external_authorization/logger_spec.rb index 81f1b2390e6c7281e3f89c34899cce66d76d2aa6..380e765309cb3be14709c5f193c44c8d546b5df3 100644 --- a/spec/lib/gitlab/external_authorization/logger_spec.rb +++ b/spec/lib/gitlab/external_authorization/logger_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::ExternalAuthorization::Logger do diff --git a/spec/lib/gitlab/external_authorization/response_spec.rb b/spec/lib/gitlab/external_authorization/response_spec.rb index 43211043ecaf915412cc288948dfe481543bbf4a..e1f6e9ac1fa5f6762e2f2b232077709a12ef0857 100644 --- a/spec/lib/gitlab/external_authorization/response_spec.rb +++ b/spec/lib/gitlab/external_authorization/response_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::ExternalAuthorization::Response do diff --git a/spec/lib/gitlab/external_authorization_spec.rb b/spec/lib/gitlab/external_authorization_spec.rb index c45fcca3f060656acfa39037cebfd2a236fd0b14..97055e7b3f9a3d7d7ba100c4f475e591b9543cad 100644 --- a/spec/lib/gitlab/external_authorization_spec.rb +++ b/spec/lib/gitlab/external_authorization_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::ExternalAuthorization, :request_store do diff --git a/spec/lib/gitlab/fake_application_settings_spec.rb b/spec/lib/gitlab/fake_application_settings_spec.rb index c81cb83d9f48f02e14e1c64f522caf4a7e417326..6a872185713178ca7a6e887375d756f1f113b6c4 100644 --- a/spec/lib/gitlab/fake_application_settings_spec.rb +++ b/spec/lib/gitlab/fake_application_settings_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::FakeApplicationSettings do diff --git a/spec/lib/gitlab/favicon_spec.rb b/spec/lib/gitlab/favicon_spec.rb index 617c0f88a8908542f4a5539c92aca353beec2292..884425dab3b0d7e10a6b492787120a5c1ba6731d 100644 --- a/spec/lib/gitlab/favicon_spec.rb +++ b/spec/lib/gitlab/favicon_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' RSpec.describe Gitlab::Favicon, :request_store do diff --git a/spec/lib/gitlab/file_detector_spec.rb b/spec/lib/gitlab/file_detector_spec.rb index 4ba9094b24e671dbdb69df8a87808c181fd89c75..f3a9f706e8666e59b623c9a87cc0e8f8d24c215a 100644 --- a/spec/lib/gitlab/file_detector_spec.rb +++ b/spec/lib/gitlab/file_detector_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::FileDetector do diff --git a/spec/lib/gitlab/file_finder_spec.rb b/spec/lib/gitlab/file_finder_spec.rb index b49c5817131e623d12e0fbeb70b196f105211eae..7ea9d43c9f776a39293a5441c4cc61205c0ce5fc 100644 --- a/spec/lib/gitlab/file_finder_spec.rb +++ b/spec/lib/gitlab/file_finder_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::FileFinder do @@ -6,11 +8,11 @@ describe Gitlab::FileFinder do subject { described_class.new(project, project.default_branch) } it_behaves_like 'file finder' do - let(:expected_file_by_name) { 'files/images/wm.svg' } + let(:expected_file_by_path) { 'files/images/wm.svg' } let(:expected_file_by_content) { 'CHANGELOG' } end - it 'filters by name' do + it 'filters by filename' do results = subject.find('files filename:wm.svg') expect(results.count).to eq(1) diff --git a/spec/lib/gitlab/fogbugz_import/client_spec.rb b/spec/lib/gitlab/fogbugz_import/client_spec.rb index dcd1a2d98134b90adcf743546583606c4aafa975..676511211c8c93e6af05526b16e9e75841d000bf 100644 --- a/spec/lib/gitlab/fogbugz_import/client_spec.rb +++ b/spec/lib/gitlab/fogbugz_import/client_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::FogbugzImport::Client do diff --git a/spec/lib/gitlab/gfm/reference_rewriter_spec.rb b/spec/lib/gitlab/gfm/reference_rewriter_spec.rb index 790b0428d193873c36727f59f6f7a361f26a5f4f..026fd1fedde2e2137e8dcc170141976a32a7a9eb 100644 --- a/spec/lib/gitlab/gfm/reference_rewriter_spec.rb +++ b/spec/lib/gitlab/gfm/reference_rewriter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::Gfm::ReferenceRewriter do diff --git a/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb b/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb index eef3b9de476ef46b8bcecfd891cb126a37f787d9..5a930d44dcba54864b8353f8632041d8f42b608e 100644 --- a/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb +++ b/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::Gfm::UploadsRewriter do diff --git a/spec/lib/gitlab/git/commit_spec.rb b/spec/lib/gitlab/git/commit_spec.rb index 23651e3d7f2764c46dc9bd2871a7eb05d89f2be0..cdab712774882249a5d99b7af7eca3f5777538c1 100644 --- a/spec/lib/gitlab/git/commit_spec.rb +++ b/spec/lib/gitlab/git/commit_spec.rb @@ -428,7 +428,9 @@ describe Gitlab::Git::Commit, :seed_helper do end end - shared_examples 'extracting commit signature' do + describe '.extract_signature_lazily' do + subject { described_class.extract_signature_lazily(repository, commit_id).itself } + context 'when the commit is signed' do let(:commit_id) { '0b4bc9a49b562e85de7cc9e834518ea6828729b9' } @@ -492,10 +494,8 @@ describe Gitlab::Git::Commit, :seed_helper do expect { subject }.to raise_error(ArgumentError) end end - end - describe '.extract_signature_lazily' do - describe 'loading signatures in batch once' do + context 'when loading signatures in batch once' do it 'fetches signatures in batch once' do commit_ids = %w[0b4bc9a49b562e85de7cc9e834518ea6828729b9 4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6] signatures = commit_ids.map do |commit_id| @@ -516,16 +516,6 @@ describe Gitlab::Git::Commit, :seed_helper do 2.times { signatures.each(&:itself) } end end - - subject { described_class.extract_signature_lazily(repository, commit_id).itself } - - it_behaves_like 'extracting commit signature' - end - - describe '.extract_signature' do - subject { described_class.extract_signature(repository, commit_id) } - - it_behaves_like 'extracting commit signature' end end diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index 81dc96b538aa37c1853d7dc4bb95bc732583e743..f74cc5623c98623f0f6c0accc097b89db0189968 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GitAccess do diff --git a/spec/lib/gitlab/git_access_wiki_spec.rb b/spec/lib/gitlab/git_access_wiki_spec.rb index 6ba65b5661811fefa6059f92003c43b7d75d0e01..99c9369a2b94db96f433489704f9d889d417d3c8 100644 --- a/spec/lib/gitlab/git_access_wiki_spec.rb +++ b/spec/lib/gitlab/git_access_wiki_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GitAccessWiki do diff --git a/spec/lib/gitlab/git_ref_validator_spec.rb b/spec/lib/gitlab/git_ref_validator_spec.rb index b63389af29f76ba96172f48fbc3f305e114e5a72..1531317c51454a1bdc2977cdf2349bfa1dce82bf 100644 --- a/spec/lib/gitlab/git_ref_validator_spec.rb +++ b/spec/lib/gitlab/git_ref_validator_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GitRefValidator do diff --git a/spec/lib/gitlab/git_spec.rb b/spec/lib/gitlab/git_spec.rb index 505bc470644a67ffc4c00f7b70188f1acd545b87..fbc49e05c37e474078817bd796c3d313517a893c 100644 --- a/spec/lib/gitlab/git_spec.rb +++ b/spec/lib/gitlab/git_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::Git do diff --git a/spec/lib/gitlab/gitaly_client/blob_service_spec.rb b/spec/lib/gitlab/gitaly_client/blob_service_spec.rb index a2770ef2fe464744308feb5853cb09b332e78a2f..887a6baf6594c4eba34343103327c5c80b84c4cb 100644 --- a/spec/lib/gitlab/gitaly_client/blob_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/blob_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GitalyClient::BlobService do diff --git a/spec/lib/gitlab/gitaly_client/blobs_stitcher_spec.rb b/spec/lib/gitlab/gitaly_client/blobs_stitcher_spec.rb index 742b2872c40367b72831bc28e9edd0871423109a..e88b86c71f29dd806d136a0d8d652cc67ed7f7da 100644 --- a/spec/lib/gitlab/gitaly_client/blobs_stitcher_spec.rb +++ b/spec/lib/gitlab/gitaly_client/blobs_stitcher_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GitalyClient::BlobsStitcher do diff --git a/spec/lib/gitlab/gitaly_client/cleanup_service_spec.rb b/spec/lib/gitlab/gitaly_client/cleanup_service_spec.rb index c42332dc27b35a24241fe7b1b3052db24a4d97af..c6c7fa1c38a92af7e49339f9f5224561629e2855 100644 --- a/spec/lib/gitlab/gitaly_client/cleanup_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/cleanup_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GitalyClient::CleanupService do diff --git a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb index 71489adb3730125c355f0ecc09efd1d71ebd19d5..1abdabe17bb38665b29c9b26d35d86f87924d808 100644 --- a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GitalyClient::CommitService do diff --git a/spec/lib/gitlab/gitaly_client/conflict_files_stitcher_spec.rb b/spec/lib/gitlab/gitaly_client/conflict_files_stitcher_spec.rb index a36024637563012e8c3a6984a5763369fca427cd..db734b1c1298c5ec3f525d18c0735d2033dac512 100644 --- a/spec/lib/gitlab/gitaly_client/conflict_files_stitcher_spec.rb +++ b/spec/lib/gitlab/gitaly_client/conflict_files_stitcher_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GitalyClient::ConflictFilesStitcher do diff --git a/spec/lib/gitlab/gitaly_client/conflicts_service_spec.rb b/spec/lib/gitlab/gitaly_client/conflicts_service_spec.rb index 52630ba02237ed4c9da297333323233672a33e48..f19bcae247063038e3563a73d9073abb3ddd4d15 100644 --- a/spec/lib/gitlab/gitaly_client/conflicts_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/conflicts_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GitalyClient::ConflictsService do diff --git a/spec/lib/gitlab/gitaly_client/diff_spec.rb b/spec/lib/gitlab/gitaly_client/diff_spec.rb index ec7ab2fdedba66265321e20e9395f6bdd093f642..d86497da7f5702625601d5359a8b12be3356a79e 100644 --- a/spec/lib/gitlab/gitaly_client/diff_spec.rb +++ b/spec/lib/gitlab/gitaly_client/diff_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GitalyClient::Diff do diff --git a/spec/lib/gitlab/gitaly_client/diff_stitcher_spec.rb b/spec/lib/gitlab/gitaly_client/diff_stitcher_spec.rb index cd3242b93263627f488adbfcad5b886443c67595..c9d42ad32cf050bfed3b96c9caf208efd737c4f0 100644 --- a/spec/lib/gitlab/gitaly_client/diff_stitcher_spec.rb +++ b/spec/lib/gitlab/gitaly_client/diff_stitcher_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GitalyClient::DiffStitcher do diff --git a/spec/lib/gitlab/gitaly_client/health_check_service_spec.rb b/spec/lib/gitlab/gitaly_client/health_check_service_spec.rb index 2c7e5eb5787b18b51cd485cd8b6e51cfc06ddaa7..615bc80fff25386d1dc8678d7672e0c703e03675 100644 --- a/spec/lib/gitlab/gitaly_client/health_check_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/health_check_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GitalyClient::HealthCheckService do diff --git a/spec/lib/gitlab/gitaly_client/operation_service_spec.rb b/spec/lib/gitlab/gitaly_client/operation_service_spec.rb index f38b8d31237bf75912ec345c8ffd4132ca7b02e7..d4337c512798c5feb3e0b7b44c31664d99e12ca2 100644 --- a/spec/lib/gitlab/gitaly_client/operation_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/operation_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GitalyClient::OperationService do @@ -209,10 +211,12 @@ describe Gitlab::GitalyClient::OperationService do end context 'when a create_tree_error is present' do - let(:response) { response_class.new(create_tree_error: "something failed") } + let(:response) { response_class.new(create_tree_error: "something failed", create_tree_error_code: 'EMPTY') } it 'raises a CreateTreeError' do - expect { subject }.to raise_error(Gitlab::Git::Repository::CreateTreeError, "something failed") + expect { subject }.to raise_error(Gitlab::Git::Repository::CreateTreeError) do |error| + expect(error.error_code).to eq(:empty) + end end end diff --git a/spec/lib/gitlab/gitaly_client/ref_service_spec.rb b/spec/lib/gitlab/gitaly_client/ref_service_spec.rb index 0bb6e58215989057433da7cbee147a471852c18c..2b4fe2ea5c044edf7bcd2fb74871fbc46289288a 100644 --- a/spec/lib/gitlab/gitaly_client/ref_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/ref_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GitalyClient::RefService do diff --git a/spec/lib/gitlab/gitaly_client/remote_service_spec.rb b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb index d5508dbff5d2f0ddd91d8ff1ecf8043909262e16..929ff5dee5d4747dc22aeffc7c9d8d65afd93e4c 100644 --- a/spec/lib/gitlab/gitaly_client/remote_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GitalyClient::RemoteService do diff --git a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb index f4b73931f213df4a3109f35263831da6d391358a..503ac57ade687c650abfb9979d56c3e8fef93675 100644 --- a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GitalyClient::RepositoryService do diff --git a/spec/lib/gitlab/gitaly_client/storage_settings_spec.rb b/spec/lib/gitlab/gitaly_client/storage_settings_spec.rb index 2f83e5a5221d56fe47224d392866d185ea54d86f..a6b29489df328d6ccdbcbf4babb009c9e6fc57b2 100644 --- a/spec/lib/gitlab/gitaly_client/storage_settings_spec.rb +++ b/spec/lib/gitlab/gitaly_client/storage_settings_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GitalyClient::StorageSettings do diff --git a/spec/lib/gitlab/gitaly_client/util_spec.rb b/spec/lib/gitlab/gitaly_client/util_spec.rb index 78a5e195ad1ccf70d10177dc9f183d8f2354cc36..f31b7c349ff478c373a252beb54cb47b303e8cb2 100644 --- a/spec/lib/gitlab/gitaly_client/util_spec.rb +++ b/spec/lib/gitlab/gitaly_client/util_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GitalyClient::Util do diff --git a/spec/lib/gitlab/gitaly_client/wiki_service_spec.rb b/spec/lib/gitlab/gitaly_client/wiki_service_spec.rb index 4fa8e97aca0a6127e58035a02d698e852950980a..cb04f9a1637114b3693f1380665405483b717bff 100644 --- a/spec/lib/gitlab/gitaly_client/wiki_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/wiki_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GitalyClient::WikiService do diff --git a/spec/lib/gitlab/gitaly_client_spec.rb b/spec/lib/gitlab/gitaly_client_spec.rb index b8df9ad642a45a0943e03db8158800460ea7253c..b6c0c0ad523503e5d2145f21fd7a551062b6fb80 100644 --- a/spec/lib/gitlab/gitaly_client_spec.rb +++ b/spec/lib/gitlab/gitaly_client_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' # We stub Gitaly in `spec/support/gitaly.rb` for other tests. We don't want @@ -399,6 +401,8 @@ describe Gitlab::GitalyClient do context 'when the request store is active', :request_store do it 'records call details if a RPC is called' do + expect(described_class).to receive(:measure_timings).and_call_original + gitaly_server.server_version expect(described_class.list_call_details).not_to be_empty diff --git a/spec/lib/gitlab/github_import/bulk_importing_spec.rb b/spec/lib/gitlab/github_import/bulk_importing_spec.rb index 91229d9c7d42ff2d94badadb3513f7659c09c0c1..3266ec4ab5010e1cc1d98ce739d148b97a3084c5 100644 --- a/spec/lib/gitlab/github_import/bulk_importing_spec.rb +++ b/spec/lib/gitlab/github_import/bulk_importing_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::BulkImporting do diff --git a/spec/lib/gitlab/github_import/caching_spec.rb b/spec/lib/gitlab/github_import/caching_spec.rb index 70ecdc16da19e56862e3a7f89dcd1b7c85f0a0a9..18c3e382532b2139301634394aca6d5234dcf4d5 100644 --- a/spec/lib/gitlab/github_import/caching_spec.rb +++ b/spec/lib/gitlab/github_import/caching_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Caching, :clean_gitlab_redis_cache do diff --git a/spec/lib/gitlab/github_import/client_spec.rb b/spec/lib/gitlab/github_import/client_spec.rb index 5b2642d94730e4d508768012569a76117b05c908..3b269d64b07c92f8c73b96902b579ada4423c6ce 100644 --- a/spec/lib/gitlab/github_import/client_spec.rb +++ b/spec/lib/gitlab/github_import/client_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Client do diff --git a/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb b/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb index 1568c657a1e79b6dc35b0edb48d3c3d78e415919..484458289afbedee847ef543b3f722f34b9d4bd9 100644 --- a/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Importer::DiffNoteImporter do diff --git a/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb b/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb index 4713c6795bbcedbef3fb2af5ac05e93010bd74ea..23ed21294e3aafd2912de43142e015552cdd4717 100644 --- a/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Importer::DiffNotesImporter do diff --git a/spec/lib/gitlab/github_import/importer/issue_and_label_links_importer_spec.rb b/spec/lib/gitlab/github_import/importer/issue_and_label_links_importer_spec.rb index 665b31ef2441d10416f05a2f72ee6c1f833d243c..399e2d9a5632d3ebeab71a0903734aa4f6678f6d 100644 --- a/spec/lib/gitlab/github_import/importer/issue_and_label_links_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/issue_and_label_links_importer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Importer::IssueAndLabelLinksImporter do diff --git a/spec/lib/gitlab/github_import/importer/issue_importer_spec.rb b/spec/lib/gitlab/github_import/importer/issue_importer_spec.rb index dab5767ece1d41935a88167655b2f62d83e3a8e0..a003ad7e09138ef95c55c1e043d1374ebd73a5f4 100644 --- a/spec/lib/gitlab/github_import/importer/issue_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/issue_importer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Importer::IssueImporter, :clean_gitlab_redis_cache do diff --git a/spec/lib/gitlab/github_import/importer/issues_importer_spec.rb b/spec/lib/gitlab/github_import/importer/issues_importer_spec.rb index e237e79e94be8754b230c60ebd0ff254cef46124..8920ef9fedb94765f5781c6319bdab6ffc84d6e3 100644 --- a/spec/lib/gitlab/github_import/importer/issues_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/issues_importer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Importer::IssuesImporter do diff --git a/spec/lib/gitlab/github_import/importer/label_links_importer_spec.rb b/spec/lib/gitlab/github_import/importer/label_links_importer_spec.rb index e2a71e78574cebcaca0cdb991277815d1df6fe7b..19d40b2f3802b9f7759a618ee30163e68c608cc3 100644 --- a/spec/lib/gitlab/github_import/importer/label_links_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/label_links_importer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Importer::LabelLinksImporter do diff --git a/spec/lib/gitlab/github_import/importer/labels_importer_spec.rb b/spec/lib/gitlab/github_import/importer/labels_importer_spec.rb index 156ef96a0fa33fbea47d4dd09dc39b213be7caa1..2dcf1433154144c4529c66699ea57766d7063801 100644 --- a/spec/lib/gitlab/github_import/importer/labels_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/labels_importer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Importer::LabelsImporter, :clean_gitlab_redis_cache do diff --git a/spec/lib/gitlab/github_import/importer/lfs_object_importer_spec.rb b/spec/lib/gitlab/github_import/importer/lfs_object_importer_spec.rb index 8fd328d9c1e2a5a0e9f1a9b1b2f49e08fc05af34..a02b620f131299826fac14e7797384e95c30c0bc 100644 --- a/spec/lib/gitlab/github_import/importer/lfs_object_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/lfs_object_importer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Importer::LfsObjectImporter do diff --git a/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb b/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb index 50442552eee8ff56999fe2a6c004b0635eef9a18..bec039a48eb0fdd282c2c0bb36563219559bc1a6 100644 --- a/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Importer::LfsObjectsImporter do diff --git a/spec/lib/gitlab/github_import/importer/milestones_importer_spec.rb b/spec/lib/gitlab/github_import/importer/milestones_importer_spec.rb index 120a07ff2b3f49cefb0460b3353b69ae68a3fd7c..eaf63e0e11bef8f135858caa5418f9413e0b32ac 100644 --- a/spec/lib/gitlab/github_import/importer/milestones_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/milestones_importer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Importer::MilestonesImporter, :clean_gitlab_redis_cache do diff --git a/spec/lib/gitlab/github_import/importer/note_importer_spec.rb b/spec/lib/gitlab/github_import/importer/note_importer_spec.rb index 9bdcc42be19d59dc9aeb0b2e2fff14dc66e20808..d2b8ba186c8e43acc49f5f41c0e28addf94ad43d 100644 --- a/spec/lib/gitlab/github_import/importer/note_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/note_importer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Importer::NoteImporter do diff --git a/spec/lib/gitlab/github_import/importer/notes_importer_spec.rb b/spec/lib/gitlab/github_import/importer/notes_importer_spec.rb index f046d13f879f67be2e55ea20b60e50335ec78c53..128f8f95fa0e1ca1d2c14b50e37b57288d9c9c8e 100644 --- a/spec/lib/gitlab/github_import/importer/notes_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/notes_importer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Importer::NotesImporter 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 8331f0b6bc7e51aa33e705f0a22c02e35ac5498a..50c27e7f4b7d72f290146491c07a72e66a36f024 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 @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Importer::PullRequestImporter, :clean_gitlab_redis_cache do diff --git a/spec/lib/gitlab/github_import/importer/pull_requests_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_requests_importer_spec.rb index c51985f00a2bc8776ed256757c2e22363e120da1..e2d810d5ddccecccea5e79a359c8eda403790f84 100644 --- a/spec/lib/gitlab/github_import/importer/pull_requests_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/pull_requests_importer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Importer::PullRequestsImporter do diff --git a/spec/lib/gitlab/github_import/importer/releases_importer_spec.rb b/spec/lib/gitlab/github_import/importer/releases_importer_spec.rb index 6a31c57a73d11b35fccdc0e60dafd9e9977b3eda..f8d53208619bfa1bff8ada39d0f78c7e95dc4ec8 100644 --- a/spec/lib/gitlab/github_import/importer/releases_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/releases_importer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Importer::ReleasesImporter do diff --git a/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb b/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb index 705df1f4fe7adba3a1b0673fef321ee30f21b0ae..c65b28fafbfe2214c6ff9c77ad1e62e0ae1951b1 100644 --- a/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Importer::RepositoryImporter do diff --git a/spec/lib/gitlab/github_import/issuable_finder_spec.rb b/spec/lib/gitlab/github_import/issuable_finder_spec.rb index da69911812a2f74524352b97ae5e741118b33bda..b8a6feb6c73474a0031233b9b4a3598c4d7ef128 100644 --- a/spec/lib/gitlab/github_import/issuable_finder_spec.rb +++ b/spec/lib/gitlab/github_import/issuable_finder_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::IssuableFinder, :clean_gitlab_redis_cache do diff --git a/spec/lib/gitlab/github_import/label_finder_spec.rb b/spec/lib/gitlab/github_import/label_finder_spec.rb index 8ba766944d68bdc176d91b4671158bd094b05880..039ae27ad57dd8ab8baeea5ac3694bb09b1a7c85 100644 --- a/spec/lib/gitlab/github_import/label_finder_spec.rb +++ b/spec/lib/gitlab/github_import/label_finder_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::LabelFinder, :clean_gitlab_redis_cache do diff --git a/spec/lib/gitlab/github_import/markdown_text_spec.rb b/spec/lib/gitlab/github_import/markdown_text_spec.rb index 1ff5b9d66b39bc508de40c7ebe059358270c98d3..a1216db7aacf02c577561d78674f1af5950302fa 100644 --- a/spec/lib/gitlab/github_import/markdown_text_spec.rb +++ b/spec/lib/gitlab/github_import/markdown_text_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::MarkdownText do diff --git a/spec/lib/gitlab/github_import/milestone_finder_spec.rb b/spec/lib/gitlab/github_import/milestone_finder_spec.rb index dff931a2fe8b6746cd00b5b9d5f936a04a592b66..407e2e67ec9e5c24dd21bb7eff539c487a8e5b5b 100644 --- a/spec/lib/gitlab/github_import/milestone_finder_spec.rb +++ b/spec/lib/gitlab/github_import/milestone_finder_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::MilestoneFinder, :clean_gitlab_redis_cache do diff --git a/spec/lib/gitlab/github_import/page_counter_spec.rb b/spec/lib/gitlab/github_import/page_counter_spec.rb index c2613a9a415ae3ebb2b50a5aff5d7ae6de89c6ee..87f3ce45fd3860b512e5c48fc68b7f178cf30bc7 100644 --- a/spec/lib/gitlab/github_import/page_counter_spec.rb +++ b/spec/lib/gitlab/github_import/page_counter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::PageCounter, :clean_gitlab_redis_cache do diff --git a/spec/lib/gitlab/github_import/parallel_importer_spec.rb b/spec/lib/gitlab/github_import/parallel_importer_spec.rb index ecab64a372a01f92edf7e8be4cdf5b6a56a63304..a9b7d3d388c2944d55dd7fe3cfab4fcf8dad3807 100644 --- a/spec/lib/gitlab/github_import/parallel_importer_spec.rb +++ b/spec/lib/gitlab/github_import/parallel_importer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::ParallelImporter do diff --git a/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb b/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb index 98205d3ee258f73ee73ea32dead56edb668282dc..f4d107e3dce2bbe29459604c6de6c42ac079cb45 100644 --- a/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb +++ b/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::ParallelScheduling do diff --git a/spec/lib/gitlab/github_import/representation/diff_note_spec.rb b/spec/lib/gitlab/github_import/representation/diff_note_spec.rb index 7b0a1ea4948bec3ac367f052d196d4e967abeaef..e743a87cdd140a0fd353a72e575433367405da40 100644 --- a/spec/lib/gitlab/github_import/representation/diff_note_spec.rb +++ b/spec/lib/gitlab/github_import/representation/diff_note_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Representation::DiffNote do diff --git a/spec/lib/gitlab/github_import/representation/expose_attribute_spec.rb b/spec/lib/gitlab/github_import/representation/expose_attribute_spec.rb index 15de0fe49fffb89bd3b2fd084c8389293c59140d..e3b48df4ae9341c4002f89d49f243ea2ac6f5d7a 100644 --- a/spec/lib/gitlab/github_import/representation/expose_attribute_spec.rb +++ b/spec/lib/gitlab/github_import/representation/expose_attribute_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Representation::ExposeAttribute do diff --git a/spec/lib/gitlab/github_import/representation/issue_spec.rb b/spec/lib/gitlab/github_import/representation/issue_spec.rb index 99330ce42cb19398320ec113cc054b66fc18412a..741a912e53bc972ae0b6b820dc8eb034a67f4d46 100644 --- a/spec/lib/gitlab/github_import/representation/issue_spec.rb +++ b/spec/lib/gitlab/github_import/representation/issue_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Representation::Issue do diff --git a/spec/lib/gitlab/github_import/representation/note_spec.rb b/spec/lib/gitlab/github_import/representation/note_spec.rb index f2c1c66b3570c1a3a92afc5a8291120fcbe5f5d1..a171a38bc9e7176a5b671dc5a733f05a0e9b3582 100644 --- a/spec/lib/gitlab/github_import/representation/note_spec.rb +++ b/spec/lib/gitlab/github_import/representation/note_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Representation::Note do diff --git a/spec/lib/gitlab/github_import/representation/pull_request_spec.rb b/spec/lib/gitlab/github_import/representation/pull_request_spec.rb index d478e5ae899375e199be13ed776359a4fe4a900f..b6dcd098c9cb1ded7eed42dbe61315fbe4156f68 100644 --- a/spec/lib/gitlab/github_import/representation/pull_request_spec.rb +++ b/spec/lib/gitlab/github_import/representation/pull_request_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Representation::PullRequest do diff --git a/spec/lib/gitlab/github_import/representation/to_hash_spec.rb b/spec/lib/gitlab/github_import/representation/to_hash_spec.rb index c296aa0a45b0d07b7cc13f1f73b03baeb4cd49c7..9c47349b3765bdc4122fc98d5eb40223f62229b1 100644 --- a/spec/lib/gitlab/github_import/representation/to_hash_spec.rb +++ b/spec/lib/gitlab/github_import/representation/to_hash_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Representation::ToHash do diff --git a/spec/lib/gitlab/github_import/representation/user_spec.rb b/spec/lib/gitlab/github_import/representation/user_spec.rb index 4e63e8ea56815fb7952eaad5dc31ec9f612a8918..a7ad6bda3addb91321677f8631282bddc3ada003 100644 --- a/spec/lib/gitlab/github_import/representation/user_spec.rb +++ b/spec/lib/gitlab/github_import/representation/user_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Representation::User do diff --git a/spec/lib/gitlab/github_import/representation_spec.rb b/spec/lib/gitlab/github_import/representation_spec.rb index 0b0610817b0da73b98c18129eadf3e19260753ec..76753a0ff21fe5ec8634339bd640808a436968e7 100644 --- a/spec/lib/gitlab/github_import/representation_spec.rb +++ b/spec/lib/gitlab/github_import/representation_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::Representation do diff --git a/spec/lib/gitlab/github_import/sequential_importer_spec.rb b/spec/lib/gitlab/github_import/sequential_importer_spec.rb index 05d3243f80685fc0bcef565f8d3f40b52e33b686..8b1e8fbf3b773ecef40d239942b986a1aa2fa390 100644 --- a/spec/lib/gitlab/github_import/sequential_importer_spec.rb +++ b/spec/lib/gitlab/github_import/sequential_importer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::SequentialImporter do diff --git a/spec/lib/gitlab/github_import/user_finder_spec.rb b/spec/lib/gitlab/github_import/user_finder_spec.rb index 29f4c00d9c749f0d7e8896327929736daa8466e7..74b5c1c52cd8c7f2d3e4d1b1b3e293ff97911900 100644 --- a/spec/lib/gitlab/github_import/user_finder_spec.rb +++ b/spec/lib/gitlab/github_import/user_finder_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport::UserFinder, :clean_gitlab_redis_cache do diff --git a/spec/lib/gitlab/github_import_spec.rb b/spec/lib/gitlab/github_import_spec.rb index 496244c91bfaa9190bc70ad894c4ff8bd7fff805..c3ddac01c87f85d393fadef8ca114f28fbabeb7b 100644 --- a/spec/lib/gitlab/github_import_spec.rb +++ b/spec/lib/gitlab/github_import_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GithubImport do diff --git a/spec/lib/gitlab/gl_repository_spec.rb b/spec/lib/gitlab/gl_repository_spec.rb index d4b6c62965954f8816c34983a32b4d2187e06c24..3290bef8aa5ff756e2c62affc023810d45447808 100644 --- a/spec/lib/gitlab/gl_repository_spec.rb +++ b/spec/lib/gitlab/gl_repository_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe ::Gitlab::GlRepository do diff --git a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb index 1dfca0b056cbd7a3b7bebdc70a1ce965479faef1..da3077542435c3cd3ad9bca1329817d20eb1bdf8 100644 --- a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb +++ b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb @@ -43,7 +43,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do verification_status: 'verified' end - it 'assigns the gpg key to the signature when the missing gpg key is added' do + it 'assigns the gpg key to the signature when the missing gpg key is added', :sidekiq_might_not_need_inline do # InvalidGpgSignatureUpdater is called by the after_create hook gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key, @@ -86,7 +86,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do verification_status: 'unknown_key' end - it 'updates the signature to being valid when the missing gpg key is added' do + it 'updates the signature to being valid when the missing gpg key is added', :sidekiq_might_not_need_inline do # InvalidGpgSignatureUpdater is called by the after_create hook gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key, @@ -133,7 +133,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do verification_status: 'unknown_key' end - it 'updates the signature to being valid when the user updates the email address' do + it 'updates the signature to being valid when the user updates the email address', :sidekiq_might_not_need_inline do gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key, user: user @@ -152,7 +152,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do ) end - it 'keeps the signature at being invalid when the changed email address is still unrelated' do + it 'keeps the signature at being invalid when the changed email address is still unrelated', :sidekiq_might_not_need_inline do gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key, user: user @@ -192,7 +192,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do verification_status: 'unknown_key' end - it 'updates the signature to being valid when the missing gpg key is added' do + it 'updates the signature to being valid when the missing gpg key is added', :sidekiq_might_not_need_inline do # InvalidGpgSignatureUpdater is called by the after_create hook gpg_key = create(:gpg_key, key: GpgHelpers::User3.public_key, user: user) subkey = gpg_key.subkeys.last diff --git a/spec/lib/gitlab/gpg_spec.rb b/spec/lib/gitlab/gpg_spec.rb index 77d318c9b23c52b33a9e54c09959f7121bf58b79..52d6a86f7d06daa0ad1b3256cf02a79d2414980f 100644 --- a/spec/lib/gitlab/gpg_spec.rb +++ b/spec/lib/gitlab/gpg_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::Gpg do @@ -63,7 +65,7 @@ describe Gitlab::Gpg do it 'downcases the email' do public_key = double(:key) fingerprints = double(:fingerprints) - uid = double(:uid, name: 'Nannie Bernhard', email: 'NANNIE.BERNHARD@EXAMPLE.COM') + uid = double(:uid, name: +'Nannie Bernhard', email: +'NANNIE.BERNHARD@EXAMPLE.COM') raw_key = double(:raw_key, uids: [uid]) allow(Gitlab::Gpg::CurrentKeyChain).to receive(:fingerprints_from_key).with(public_key).and_return(fingerprints) allow(GPGME::Key).to receive(:find).with(:public, anything).and_return([raw_key]) @@ -78,8 +80,8 @@ describe Gitlab::Gpg do it 'rejects non UTF-8 names and addresses' do public_key = double(:key) fingerprints = double(:fingerprints) - email = "\xEEch@test.com".force_encoding('ASCII-8BIT') - uid = double(:uid, name: 'Test User', email: email) + email = (+"\xEEch@test.com").force_encoding('ASCII-8BIT') + uid = double(:uid, name: +'Test User', email: email) raw_key = double(:raw_key, uids: [uid]) allow(Gitlab::Gpg::CurrentKeyChain).to receive(:fingerprints_from_key).with(public_key).and_return(fingerprints) allow(GPGME::Key).to receive(:find).with(:public, anything).and_return([raw_key]) @@ -139,6 +141,96 @@ describe Gitlab::Gpg do end end.not_to raise_error end + + it 'keeps track of created and removed keychains in counters' do + created = Gitlab::Metrics.counter(:gpg_tmp_keychains_created_total, 'The number of temporary GPG keychains') + removed = Gitlab::Metrics.counter(:gpg_tmp_keychains_removed_total, 'The number of temporary GPG keychains') + + initial_created = created.get + initial_removed = removed.get + + described_class.using_tmp_keychain do + expect(created.get).to eq(initial_created + 1) + expect(removed.get).to eq(initial_removed) + end + + expect(removed.get).to eq(initial_removed + 1) + end + + it 'cleans up the tmp directory after finishing' do + tmp_directory = nil + + described_class.using_tmp_keychain do + tmp_directory = described_class.current_home_dir + expect(File.exist?(tmp_directory)).to be true + end + + expect(tmp_directory).not_to be_nil + expect(File.exist?(tmp_directory)).to be false + end + + it 'does not fail if the homedir was deleted while running' do + expect do + described_class.using_tmp_keychain do + FileUtils.remove_entry(described_class.current_home_dir) + end + end.not_to raise_error + end + + shared_examples 'multiple deletion attempts of the tmp-dir' do |seconds| + let(:tmp_dir) do + tmp_dir = Dir.mktmpdir + allow(Dir).to receive(:mktmpdir).and_return(tmp_dir) + tmp_dir + end + + before do + # Stub all the other calls for `remove_entry` + allow(FileUtils).to receive(:remove_entry).with(any_args).and_call_original + end + + it "tries for #{seconds}" do + expect(Retriable).to receive(:retriable).with(a_hash_including(max_elapsed_time: seconds)) + + described_class.using_tmp_keychain {} + end + + it 'tries at least 2 times to remove the tmp dir before raising', :aggregate_failures do + expect(Retriable).to receive(:sleep).at_least(2).times + expect(FileUtils).to receive(:remove_entry).with(tmp_dir).at_least(2).times.and_raise('Deletion failed') + + expect { described_class.using_tmp_keychain { } }.to raise_error(described_class::CleanupError) + end + + it 'does not attempt multiple times when the deletion succeeds' do + expect(Retriable).to receive(:sleep).once + expect(FileUtils).to receive(:remove_entry).with(tmp_dir).once.and_raise('Deletion failed') + expect(FileUtils).to receive(:remove_entry).with(tmp_dir).and_call_original + + expect { described_class.using_tmp_keychain { } }.not_to raise_error + + expect(File.exist?(tmp_dir)).to be false + end + + it 'does not retry when the feature flag is disabled' do + stub_feature_flags(gpg_cleanup_retries: false) + + expect(FileUtils).to receive(:remove_entry).with(tmp_dir, true).and_call_original + expect(Retriable).not_to receive(:retriable) + + described_class.using_tmp_keychain {} + end + end + + it_behaves_like 'multiple deletion attempts of the tmp-dir', described_class::FG_CLEANUP_RUNTIME_S + + context 'when running in Sidekiq' do + before do + allow(Sidekiq).to receive(:server?).and_return(true) + end + + it_behaves_like 'multiple deletion attempts of the tmp-dir', described_class::BG_CLEANUP_RUNTIME_S + end end end diff --git a/spec/lib/gitlab/grape_logging/loggers/exception_logger_spec.rb b/spec/lib/gitlab/grape_logging/loggers/exception_logger_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8d7826c0a5634f837961ce94bfb8799e9057a565 --- /dev/null +++ b/spec/lib/gitlab/grape_logging/loggers/exception_logger_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +describe Gitlab::GrapeLogging::Loggers::ExceptionLogger do + subject { described_class.new } + + let(:mock_request) { OpenStruct.new(env: {}) } + + describe ".parameters" do + describe 'when no exception is available' do + it 'returns an empty hash' do + expect(subject.parameters(mock_request, nil)).to eq({}) + end + end + + describe 'when an exception is available' do + let(:exception) { RuntimeError.new('This is a test') } + let(:mock_request) do + OpenStruct.new( + env: { + ::API::Helpers::API_EXCEPTION_ENV => exception + } + ) + end + + let(:expected) do + { + exception: { + class: 'RuntimeError', + message: 'This is a test' + } + } + end + + it 'returns the correct fields' do + expect(subject.parameters(mock_request, nil)).to eq(expected) + end + + context 'with backtrace' do + before do + current_backtrace = caller + allow(exception).to receive(:backtrace).and_return(current_backtrace) + expected[:exception][:backtrace] = Gitlab::Profiler.clean_backtrace(current_backtrace) + end + + it 'includes the backtrace' do + expect(subject.parameters(mock_request, nil)).to eq(expected) + end + 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 new file mode 100644 index 0000000000000000000000000000000000000000..1fda84f777e0792b087d7f6f14dfe2a07e4f4350 --- /dev/null +++ b/spec/lib/gitlab/graphql/connections/filterable_array_connection_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Graphql::Connections::FilterableArrayConnection do + let(:callback) { proc { |nodes| nodes } } + let(:all_nodes) { Gitlab::Graphql::FilterableArray.new(callback, 1, 2, 3, 4, 5) } + let(:arguments) { {} } + subject(:connection) do + described_class.new(all_nodes, arguments, max_page_size: 3) + end + + describe '#paged_nodes' do + let(:paged_nodes) { subject.paged_nodes } + + it_behaves_like "connection with paged nodes" + + context 'when callback filters some nodes' do + let(:callback) { proc { |nodes| nodes[1..-1] } } + + it 'does not return filtered elements' do + expect(subject.paged_nodes).to contain_exactly(all_nodes[1], all_nodes[2]) + end + end + end +end diff --git a/spec/lib/gitlab/graphql/connections/keyset/conditions/not_null_condition_spec.rb b/spec/lib/gitlab/graphql/connections/keyset/conditions/not_null_condition_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d943540fe1f041a4e3dd00f29696911647e86ed2 --- /dev/null +++ b/spec/lib/gitlab/graphql/connections/keyset/conditions/not_null_condition_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Graphql::Connections::Keyset::Conditions::NotNullCondition do + describe '#build' do + let(:condition) { described_class.new(Issue.arel_table, %w(relative_position id), [1500, 500], ['>', '>'], before_or_after) } + + context 'when there is only one ordering field' do + let(:condition) { described_class.new(Issue.arel_table, ['id'], [500], ['>'], :after) } + + it 'generates a single condition sql' do + expected_sql = <<~SQL + ("issues"."id" > 500) + SQL + + expect(condition.build.squish).to eq expected_sql.squish + end + end + + context 'when :after' do + let(:before_or_after) { :after } + + it 'generates :after sql' do + expected_sql = <<~SQL + ("issues"."relative_position" > 1500) + OR ( + "issues"."relative_position" = 1500 + AND + "issues"."id" > 500 + ) + OR ("issues"."relative_position" IS NULL) + SQL + + expect(condition.build.squish).to eq expected_sql.squish + end + end + + context 'when :before' do + let(:before_or_after) { :before } + + it 'generates :before sql' do + expected_sql = <<~SQL + ("issues"."relative_position" > 1500) + OR ( + "issues"."relative_position" = 1500 + AND + "issues"."id" > 500 + ) + SQL + + expect(condition.build.squish).to eq expected_sql.squish + end + end + end +end diff --git a/spec/lib/gitlab/graphql/connections/keyset/conditions/null_condition_spec.rb b/spec/lib/gitlab/graphql/connections/keyset/conditions/null_condition_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7fce94adb81fcf35845c442d8222dba597eacbc1 --- /dev/null +++ b/spec/lib/gitlab/graphql/connections/keyset/conditions/null_condition_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Graphql::Connections::Keyset::Conditions::NullCondition do + describe '#build' do + let(:condition) { described_class.new(Issue.arel_table, %w(relative_position id), [nil, 500], [nil, '>'], before_or_after) } + + context 'when :after' do + let(:before_or_after) { :after } + + it 'generates sql' do + expected_sql = <<~SQL + ( + "issues"."relative_position" IS NULL + AND + "issues"."id" > 500 + ) + SQL + + expect(condition.build.squish).to eq expected_sql.squish + end + end + + context 'when :before' do + let(:before_or_after) { :before } + + it 'generates :before sql' do + expected_sql = <<~SQL + ( + "issues"."relative_position" IS NULL + AND + "issues"."id" > 500 + ) + OR ("issues"."relative_position" IS NOT NULL) + SQL + + expect(condition.build.squish).to eq expected_sql.squish + end + end + end +end diff --git a/spec/lib/gitlab/graphql/connections/keyset/connection_spec.rb b/spec/lib/gitlab/graphql/connections/keyset/connection_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..9dda2a41ec6325d44543a98fa3afe5b36d6f75b6 --- /dev/null +++ b/spec/lib/gitlab/graphql/connections/keyset/connection_spec.rb @@ -0,0 +1,281 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Graphql::Connections::Keyset::Connection do + let(:nodes) { Project.all.order(id: :asc) } + let(:arguments) { {} } + subject(:connection) do + described_class.new(nodes, arguments, max_page_size: 3) + end + + def encoded_cursor(node) + described_class.new(nodes, {}).cursor_from_node(node) + end + + def decoded_cursor(cursor) + JSON.parse(Base64Bp.urlsafe_decode64(cursor)) + end + + describe '#cursor_from_nodes' do + let(:project) { create(:project) } + let(:cursor) { connection.cursor_from_node(project) } + + it 'returns an encoded ID' do + expect(decoded_cursor(cursor)).to eq('id' => project.id.to_s) + end + + context 'when an order is specified' do + let(:nodes) { Project.order(:updated_at) } + + it 'returns the encoded value of the order' do + expect(decoded_cursor(cursor)).to include('updated_at' => project.updated_at.to_s) + end + + it 'includes the :id even when not specified in the order' do + expect(decoded_cursor(cursor)).to include('id' => project.id.to_s) + end + end + + context 'when multiple orders are specified' do + let(:nodes) { Project.order(:updated_at).order(:created_at) } + + it 'returns the encoded value of the order' do + expect(decoded_cursor(cursor)).to include('updated_at' => project.updated_at.to_s) + end + end + + context 'when multiple orders with SQL are specified' do + let(:nodes) { Project.order(Arel.sql('projects.updated_at IS NULL')).order(:updated_at).order(:id) } + + it 'returns the encoded value of the order' do + expect(decoded_cursor(cursor)).to include('updated_at' => project.updated_at.to_s) + end + end + end + + describe '#sliced_nodes' do + let(:projects) { create_list(:project, 4) } + + context 'when before is passed' do + let(:arguments) { { before: encoded_cursor(projects[1]) } } + + it 'only returns the project before the selected one' do + expect(subject.sliced_nodes).to contain_exactly(projects.first) + end + + context 'when the sort order is descending' do + let(:nodes) { Project.all.order(id: :desc) } + + it 'returns the correct nodes' do + expect(subject.sliced_nodes).to contain_exactly(*projects[2..-1]) + end + end + end + + context 'when after is passed' do + let(:arguments) { { after: encoded_cursor(projects[1]) } } + + it 'only returns the project before the selected one' do + expect(subject.sliced_nodes).to contain_exactly(*projects[2..-1]) + end + + context 'when the sort order is descending' do + let(:nodes) { Project.all.order(id: :desc) } + + it 'returns the correct nodes' do + expect(subject.sliced_nodes).to contain_exactly(projects.first) + end + end + end + + context 'when both before and after are passed' do + let(:arguments) do + { + after: encoded_cursor(projects[1]), + before: encoded_cursor(projects[3]) + } + end + + it 'returns the expected set' do + expect(subject.sliced_nodes).to contain_exactly(projects[2]) + end + end + + context 'when multiple orders are defined' do + let!(:project1) { create(:project, last_repository_check_at: 10.days.ago) } # Asc: project5 Desc: project3 + let!(:project2) { create(:project, last_repository_check_at: nil) } # Asc: project1 Desc: project1 + let!(:project3) { create(:project, last_repository_check_at: 5.days.ago) } # Asc: project3 Desc: project5 + let!(:project4) { create(:project, last_repository_check_at: nil) } # Asc: project2 Desc: project2 + let!(:project5) { create(:project, last_repository_check_at: 20.days.ago) } # Asc: project4 Desc: project4 + + context 'when ascending' do + let(:nodes) do + Project.order(Arel.sql('projects.last_repository_check_at IS NULL')).order(last_repository_check_at: :asc).order(id: :asc) + end + + context 'when no cursor is passed' do + let(:arguments) { {} } + + it 'returns projects in ascending order' do + expect(subject.sliced_nodes).to eq([project5, project1, project3, project2, project4]) + end + end + + context 'when before cursor value is NULL' do + let(:arguments) { { before: encoded_cursor(project4) } } + + it 'returns all projects before the cursor' do + expect(subject.sliced_nodes).to eq([project5, project1, project3, project2]) + end + end + + context 'when before cursor value is not NULL' do + let(:arguments) { { before: encoded_cursor(project3) } } + + it 'returns all projects before the cursor' do + expect(subject.sliced_nodes).to eq([project5, project1]) + end + end + + context 'when after cursor value is NULL' do + let(:arguments) { { after: encoded_cursor(project2) } } + + it 'returns all projects after the cursor' do + expect(subject.sliced_nodes).to eq([project4]) + end + end + + context 'when after cursor value is not NULL' do + let(:arguments) { { after: encoded_cursor(project1) } } + + it 'returns all projects after the cursor' do + expect(subject.sliced_nodes).to eq([project3, project2, project4]) + end + end + + context 'when before and after cursor' do + let(:arguments) { { before: encoded_cursor(project4), after: encoded_cursor(project5) } } + + it 'returns all projects after the cursor' do + expect(subject.sliced_nodes).to eq([project1, project3, project2]) + end + end + end + + context 'when descending' do + let(:nodes) do + Project.order(Arel.sql('projects.last_repository_check_at IS NULL')).order(last_repository_check_at: :desc).order(id: :asc) + end + + context 'when no cursor is passed' do + let(:arguments) { {} } + + it 'only returns projects in descending order' do + expect(subject.sliced_nodes).to eq([project3, project1, project5, project2, project4]) + end + end + + context 'when before cursor value is NULL' do + let(:arguments) { { before: encoded_cursor(project4) } } + + it 'returns all projects before the cursor' do + expect(subject.sliced_nodes).to eq([project3, project1, project5, project2]) + end + end + + context 'when before cursor value is not NULL' do + let(:arguments) { { before: encoded_cursor(project5) } } + + it 'returns all projects before the cursor' do + expect(subject.sliced_nodes).to eq([project3, project1]) + end + end + + context 'when after cursor value is NULL' do + let(:arguments) { { after: encoded_cursor(project2) } } + + it 'returns all projects after the cursor' do + expect(subject.sliced_nodes).to eq([project4]) + end + end + + context 'when after cursor value is not NULL' do + let(:arguments) { { after: encoded_cursor(project1) } } + + it 'returns all projects after the cursor' do + expect(subject.sliced_nodes).to eq([project5, project2, project4]) + end + end + + context 'when before and after cursor' do + let(:arguments) { { before: encoded_cursor(project4), after: encoded_cursor(project3) } } + + it 'returns all projects after the cursor' do + expect(subject.sliced_nodes).to eq([project1, project5, project2]) + end + end + end + end + + # TODO Enable this as part of below issue + # https://gitlab.com/gitlab-org/gitlab/issues/32933 + # context 'when an invalid cursor is provided' do + # let(:arguments) { { before: 'invalidcursor' } } + # + # it 'raises an error' do + # expect { expect(subject.sliced_nodes) }.to raise_error(Gitlab::Graphql::Errors::ArgumentError) + # end + # end + + # TODO Remove this as part of below issue + # https://gitlab.com/gitlab-org/gitlab/issues/32933 + context 'when an old style cursor is provided' do + let(:arguments) { { before: Base64Bp.urlsafe_encode64(projects[1].id.to_s, padding: false) } } + + it 'only returns the project before the selected one' do + expect(subject.sliced_nodes).to contain_exactly(projects.first) + end + end + end + + describe '#paged_nodes' do + let_it_be(:all_nodes) { create_list(:project, 5) } + let(:paged_nodes) { subject.paged_nodes } + + it_behaves_like "connection with paged nodes" + + context 'when both are passed' do + let(:arguments) { { first: 2, last: 2 } } + + it 'raises an error' do + expect { paged_nodes }.to raise_error(Gitlab::Graphql::Errors::ArgumentError) + end + end + + context 'when primary key is not in original order' do + let(:nodes) { Project.order(last_repository_check_at: :desc) } + + it 'is added to end' do + sliced = subject.sliced_nodes + last_order_name = sliced.order_values.last.expr.name + + expect(last_order_name).to eq sliced.primary_key + end + end + + context 'when there is no primary key' do + let(:nodes) { NoPrimaryKey.all } + + it 'raises an error' do + expect(NoPrimaryKey.primary_key).to be_nil + expect { subject.sliced_nodes }.to raise_error(ArgumentError, 'Relation must have a primary key') + end + end + end + + class NoPrimaryKey < ActiveRecord::Base + self.table_name = 'no_primary_key' + self.primary_key = nil + end +end diff --git a/spec/lib/gitlab/graphql/connections/keyset/legacy_keyset_connection_spec.rb b/spec/lib/gitlab/graphql/connections/keyset/legacy_keyset_connection_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..aaf28fed68436e987f28856e152a5ec1aaed2f24 --- /dev/null +++ b/spec/lib/gitlab/graphql/connections/keyset/legacy_keyset_connection_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +# TODO https://gitlab.com/gitlab-org/gitlab/issues/35104 +require 'spec_helper' + +describe Gitlab::Graphql::Connections::Keyset::LegacyKeysetConnection do + describe 'old keyset_connection' do + let(:described_class) { Gitlab::Graphql::Connections::Keyset::Connection } + let(:nodes) { Project.all.order(id: :asc) } + let(:arguments) { {} } + subject(:connection) do + described_class.new(nodes, arguments, max_page_size: 3) + end + + before do + stub_feature_flags(graphql_keyset_pagination: false) + end + + def encoded_property(value) + Base64Bp.urlsafe_encode64(value.to_s, padding: false) + end + + describe '#cursor_from_nodes' do + let(:project) { create(:project) } + + it 'returns an encoded ID' do + expect(connection.cursor_from_node(project)) + .to eq(encoded_property(project.id)) + end + + context 'when an order was specified' do + let(:nodes) { Project.order(:updated_at) } + + it 'returns the encoded value of the order' do + expect(connection.cursor_from_node(project)) + .to eq(encoded_property(project.updated_at)) + end + end + end + + describe '#sliced_nodes' do + let(:projects) { create_list(:project, 4) } + + context 'when before is passed' do + let(:arguments) { { before: encoded_property(projects[1].id) } } + + it 'only returns the project before the selected one' do + expect(subject.sliced_nodes).to contain_exactly(projects.first) + end + + context 'when the sort order is descending' do + let(:nodes) { Project.all.order(id: :desc) } + + it 'returns the correct nodes' do + expect(subject.sliced_nodes).to contain_exactly(*projects[2..-1]) + end + end + end + + context 'when after is passed' do + let(:arguments) { { after: encoded_property(projects[1].id) } } + + it 'only returns the project before the selected one' do + expect(subject.sliced_nodes).to contain_exactly(*projects[2..-1]) + end + + context 'when the sort order is descending' do + let(:nodes) { Project.all.order(id: :desc) } + + it 'returns the correct nodes' do + expect(subject.sliced_nodes).to contain_exactly(projects.first) + end + end + end + + context 'when both before and after are passed' do + let(:arguments) do + { + after: encoded_property(projects[1].id), + before: encoded_property(projects[3].id) + } + end + + it 'returns the expected set' do + expect(subject.sliced_nodes).to contain_exactly(projects[2]) + end + end + end + + describe '#paged_nodes' do + let!(:projects) { create_list(:project, 5) } + + it 'returns the collection limited to max page size' do + expect(subject.paged_nodes.size).to eq(3) + end + + it 'is a loaded memoized array' do + expect(subject.paged_nodes).to be_an(Array) + expect(subject.paged_nodes.object_id).to eq(subject.paged_nodes.object_id) + end + + context 'when `first` is passed' do + let(:arguments) { { first: 2 } } + + it 'returns only the first elements' do + expect(subject.paged_nodes).to contain_exactly(projects.first, projects.second) + end + end + + context 'when `last` is passed' do + let(:arguments) { { last: 2 } } + + it 'returns only the last elements' do + expect(subject.paged_nodes).to contain_exactly(projects[3], projects[4]) + end + end + + context 'when both are passed' do + let(:arguments) { { first: 2, last: 2 } } + + it 'raises an error' do + expect { subject.paged_nodes }.to raise_error(Gitlab::Graphql::Errors::ArgumentError) + end + end + end + end +end diff --git a/spec/lib/gitlab/graphql/connections/keyset/order_info_spec.rb b/spec/lib/gitlab/graphql/connections/keyset/order_info_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..17ddcaefeeb5057d42ac457ef13389d2fe277a95 --- /dev/null +++ b/spec/lib/gitlab/graphql/connections/keyset/order_info_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Graphql::Connections::Keyset::OrderInfo do + describe '#build_order_list' do + let(:order_list) { described_class.build_order_list(relation) } + + context 'when multiple orders with SQL is specified' do + let(:relation) { Project.order(Arel.sql('projects.updated_at IS NULL')).order(:updated_at).order(:id) } + + it 'ignores the SQL order' do + expect(order_list.count).to eq 2 + expect(order_list.first.attribute_name).to eq 'updated_at' + expect(order_list.first.operator_for(:after)).to eq '>' + expect(order_list.last.attribute_name).to eq 'id' + expect(order_list.last.operator_for(:after)).to eq '>' + end + end + + context 'when order contains NULLS LAST' do + let(:relation) { Project.order(Arel.sql('projects.updated_at Asc Nulls Last')).order(:id) } + + it 'does not ignore the SQL order' do + expect(order_list.count).to eq 2 + expect(order_list.first.attribute_name).to eq 'projects.updated_at' + expect(order_list.first.operator_for(:after)).to eq '>' + expect(order_list.last.attribute_name).to eq 'id' + expect(order_list.last.operator_for(:after)).to eq '>' + end + end + + context 'when order contains invalid formatted NULLS LAST ' do + let(:relation) { Project.order(Arel.sql('projects.updated_at created_at Asc Nulls Last')).order(:id) } + + it 'ignores the SQL order' do + expect(order_list.count).to eq 1 + end + end + end + + describe '#validate_ordering' do + let(:order_list) { described_class.build_order_list(relation) } + + context 'when number of ordering fields is 0' do + let(:relation) { Project.all } + + it 'raises an error' do + expect { described_class.validate_ordering(relation, order_list) } + .to raise_error(ArgumentError, 'A minimum of 1 ordering field is required') + end + end + + context 'when number of ordering fields is over 2' do + let(:relation) { Project.order(last_repository_check_at: :desc).order(updated_at: :desc).order(:id) } + + it 'raises an error' do + expect { described_class.validate_ordering(relation, order_list) } + .to raise_error(ArgumentError, 'A maximum of 2 ordering fields are allowed') + end + end + + context 'when the second (or first) column is nullable' do + let(:relation) { Project.order(last_repository_check_at: :desc).order(updated_at: :desc) } + + it 'raises an error' do + expect { described_class.validate_ordering(relation, order_list) } + .to raise_error(ArgumentError, "Column `updated_at` must not allow NULL") + end + end + + context 'for last ordering field' do + let(:relation) { Project.order(namespace_id: :desc) } + + it 'raises error if primary key is not last field' do + expect { described_class.validate_ordering(relation, order_list) } + .to raise_error(ArgumentError, "Last ordering field must be the primary key, `#{relation.primary_key}`") + end + end + end +end diff --git a/spec/lib/gitlab/graphql/connections/keyset/query_builder_spec.rb b/spec/lib/gitlab/graphql/connections/keyset/query_builder_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..59e153d9e079b1a4e16d8de2506f7fa5859aa045 --- /dev/null +++ b/spec/lib/gitlab/graphql/connections/keyset/query_builder_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Graphql::Connections::Keyset::QueryBuilder do + context 'when number of ordering fields is 0' do + it 'raises an error' do + expect { described_class.new(Issue.arel_table, [], {}, :after) } + .to raise_error(ArgumentError, 'No ordering scopes have been supplied') + end + end + + describe '#conditions' do + let(:relation) { Issue.order(relative_position: :desc).order(:id) } + let(:order_list) { Gitlab::Graphql::Connections::Keyset::OrderInfo.build_order_list(relation) } + let(:builder) { described_class.new(arel_table, order_list, decoded_cursor, before_or_after) } + let(:before_or_after) { :after } + + context 'when only a single ordering' do + let(:relation) { Issue.order(id: :desc) } + + context 'when the value is nil' do + let(:decoded_cursor) { { 'id' => nil } } + + it 'raises an error' do + expect { builder.conditions } + .to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'Before/after cursor invalid: `nil` was provided as only sortable value') + end + end + + context 'when value is not nil' do + let(:decoded_cursor) { { 'id' => 100 } } + let(:conditions) { builder.conditions } + + context 'when :after' do + it 'generates the correct condition' do + expect(conditions.strip).to eq '("issues"."id" < 100)' + end + end + + context 'when :before' do + let(:before_or_after) { :before } + + it 'generates the correct condition' do + expect(conditions.strip).to eq '("issues"."id" > 100)' + end + end + end + end + + context 'when two orderings' do + let(:decoded_cursor) { { 'relative_position' => 1500, 'id' => 100 } } + + context 'when no values are nil' do + context 'when :after' do + it 'generates the correct condition' do + conditions = builder.conditions + + expect(conditions).to include '"issues"."relative_position" < 1500' + expect(conditions).to include '"issues"."id" > 100' + expect(conditions).to include 'OR ("issues"."relative_position" IS NULL)' + end + end + + context 'when :before' do + let(:before_or_after) { :before } + + it 'generates the correct condition' do + conditions = builder.conditions + + expect(conditions).to include '("issues"."relative_position" > 1500)' + expect(conditions).to include '"issues"."id" < 100' + expect(conditions).to include '"issues"."relative_position" = 1500' + end + end + end + + context 'when first value is nil' do + let(:decoded_cursor) { { 'relative_position' => nil, 'id' => 100 } } + + context 'when :after' do + it 'generates the correct condition' do + conditions = builder.conditions + + expect(conditions).to include '"issues"."relative_position" IS NULL' + expect(conditions).to include '"issues"."id" > 100' + end + end + + context 'when :before' do + let(:before_or_after) { :before } + + it 'generates the correct condition' do + conditions = builder.conditions + + expect(conditions).to include '"issues"."relative_position" IS NULL' + expect(conditions).to include '"issues"."id" < 100' + expect(conditions).to include 'OR ("issues"."relative_position" IS NOT NULL)' + end + end + end + end + end + + def arel_table + Issue.arel_table + end +end diff --git a/spec/lib/gitlab/graphql/connections/keyset_connection_spec.rb b/spec/lib/gitlab/graphql/connections/keyset_connection_spec.rb deleted file mode 100644 index 4eb121794e1bb33f1b73c533843b097928201c9a..0000000000000000000000000000000000000000 --- a/spec/lib/gitlab/graphql/connections/keyset_connection_spec.rb +++ /dev/null @@ -1,117 +0,0 @@ -require 'spec_helper' - -describe Gitlab::Graphql::Connections::KeysetConnection do - let(:nodes) { Project.all.order(id: :asc) } - let(:arguments) { {} } - subject(:connection) do - described_class.new(nodes, arguments, max_page_size: 3) - end - - def encoded_property(value) - Base64Bp.urlsafe_encode64(value.to_s, padding: false) - end - - describe '#cursor_from_nodes' do - let(:project) { create(:project) } - - it 'returns an encoded ID' do - expect(connection.cursor_from_node(project)) - .to eq(encoded_property(project.id)) - end - - context 'when an order was specified' do - let(:nodes) { Project.order(:updated_at) } - - it 'returns the encoded value of the order' do - expect(connection.cursor_from_node(project)) - .to eq(encoded_property(project.updated_at)) - end - end - end - - describe '#sliced_nodes' do - let(:projects) { create_list(:project, 4) } - - context 'when before is passed' do - let(:arguments) { { before: encoded_property(projects[1].id) } } - - it 'only returns the project before the selected one' do - expect(subject.sliced_nodes).to contain_exactly(projects.first) - end - - context 'when the sort order is descending' do - let(:nodes) { Project.all.order(id: :desc) } - - it 'returns the correct nodes' do - expect(subject.sliced_nodes).to contain_exactly(*projects[2..-1]) - end - end - end - - context 'when after is passed' do - let(:arguments) { { after: encoded_property(projects[1].id) } } - - it 'only returns the project before the selected one' do - expect(subject.sliced_nodes).to contain_exactly(*projects[2..-1]) - end - - context 'when the sort order is descending' do - let(:nodes) { Project.all.order(id: :desc) } - - it 'returns the correct nodes' do - expect(subject.sliced_nodes).to contain_exactly(projects.first) - end - end - end - - context 'when both before and after are passed' do - let(:arguments) do - { - after: encoded_property(projects[1].id), - before: encoded_property(projects[3].id) - } - end - - it 'returns the expected set' do - expect(subject.sliced_nodes).to contain_exactly(projects[2]) - end - end - end - - describe '#paged_nodes' do - let!(:projects) { create_list(:project, 5) } - - it 'returns the collection limited to max page size' do - expect(subject.paged_nodes.size).to eq(3) - end - - it 'is a loaded memoized array' do - expect(subject.paged_nodes).to be_an(Array) - expect(subject.paged_nodes.object_id).to eq(subject.paged_nodes.object_id) - end - - context 'when `first` is passed' do - let(:arguments) { { first: 2 } } - - it 'returns only the first elements' do - expect(subject.paged_nodes).to contain_exactly(projects.first, projects.second) - end - end - - context 'when `last` is passed' do - let(:arguments) { { last: 2 } } - - it 'returns only the last elements' do - expect(subject.paged_nodes).to contain_exactly(projects[3], projects[4]) - end - end - - context 'when both are passed' do - let(:arguments) { { first: 2, last: 2 } } - - it 'raises an error' do - expect { subject.paged_nodes }.to raise_error(Gitlab::Graphql::Errors::ArgumentError) - end - end - end -end diff --git a/spec/lib/gitlab/graphql/loaders/pipeline_for_sha_loader_spec.rb b/spec/lib/gitlab/graphql/loaders/pipeline_for_sha_loader_spec.rb deleted file mode 100644 index 136027736c3fadcdd38f4c42915470abc60ea191..0000000000000000000000000000000000000000 --- a/spec/lib/gitlab/graphql/loaders/pipeline_for_sha_loader_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -require 'spec_helper' - -describe Gitlab::Graphql::Loaders::PipelineForShaLoader do - include GraphqlHelpers - - describe '#find_last' do - it 'batch-resolves latest pipeline' do - project = create(:project, :repository) - pipeline1 = create(:ci_pipeline, project: project, ref: project.default_branch, sha: project.commit.sha) - pipeline2 = create(:ci_pipeline, project: project, ref: project.default_branch, sha: project.commit.sha) - pipeline3 = create(:ci_pipeline, project: project, ref: 'improve/awesome', sha: project.commit('improve/awesome').sha) - - result = batch_sync(max_queries: 1) do - [pipeline1.sha, pipeline3.sha].map { |sha| described_class.new(project, sha).find_last } - end - - expect(result).to contain_exactly(pipeline2, pipeline3) - end - end -end diff --git a/spec/lib/gitlab/group_search_results_spec.rb b/spec/lib/gitlab/group_search_results_spec.rb index 53a91a35ec99931c49a76a8b6749f1b6d0625d09..570b0cb74010670f3a545186395756fe3999bf2a 100644 --- a/spec/lib/gitlab/group_search_results_spec.rb +++ b/spec/lib/gitlab/group_search_results_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::GroupSearchResults do diff --git a/spec/lib/gitlab/hashed_storage/migrator_spec.rb b/spec/lib/gitlab/hashed_storage/migrator_spec.rb index 8e253b5159705b89c6df658fb33f907d09ab2d22..ce7f2c4530d066398e88717189e740ddb645df7f 100644 --- a/spec/lib/gitlab/hashed_storage/migrator_spec.rb +++ b/spec/lib/gitlab/hashed_storage/migrator_spec.rb @@ -42,7 +42,7 @@ describe Gitlab::HashedStorage::Migrator, :sidekiq, :redis do subject.bulk_migrate(start: ids.min, finish: ids.max) end - it 'has all projects migrated and set as writable' do + it 'has all projects migrated and set as writable', :sidekiq_might_not_need_inline do perform_enqueued_jobs do subject.bulk_migrate(start: ids.min, finish: ids.max) end @@ -79,7 +79,7 @@ describe Gitlab::HashedStorage::Migrator, :sidekiq, :redis do subject.bulk_rollback(start: ids.min, finish: ids.max) end - it 'has all projects rolledback and set as writable' do + it 'has all projects rolledback and set as writable', :sidekiq_might_not_need_inline do perform_enqueued_jobs do subject.bulk_rollback(start: ids.min, finish: ids.max) end @@ -108,7 +108,7 @@ describe Gitlab::HashedStorage::Migrator, :sidekiq, :redis do expect { subject.migrate(project) }.not_to raise_error end - it 'migrates project storage' do + it 'migrates project storage', :sidekiq_might_not_need_inline do perform_enqueued_jobs do subject.migrate(project) end @@ -154,7 +154,7 @@ describe Gitlab::HashedStorage::Migrator, :sidekiq, :redis do expect { subject.rollback(project) }.not_to raise_error end - it 'rolls-back project storage' do + it 'rolls-back project storage', :sidekiq_might_not_need_inline do perform_enqueued_jobs do subject.rollback(project) end diff --git a/spec/lib/gitlab/health_checks/master_check_spec.rb b/spec/lib/gitlab/health_checks/master_check_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..91441a7ddc39919999224403f1caf4dbcff35150 --- /dev/null +++ b/spec/lib/gitlab/health_checks/master_check_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' +require_relative './simple_check_shared' + +describe Gitlab::HealthChecks::MasterCheck do + let(:result_class) { Gitlab::HealthChecks::Result } + + SUCCESS_CODE = 100 + FAILURE_CODE = 101 + + before do + described_class.register_master + end + + after do + described_class.finish_master + end + + describe '#readiness' do + context 'when master is running' do + it 'worker does return success' do + _, child_status = run_worker + + expect(child_status.exitstatus).to eq(SUCCESS_CODE) + end + end + + context 'when master finishes early' do + before do + described_class.send(:close_write) + end + + it 'worker does return failure' do + _, child_status = run_worker + + expect(child_status.exitstatus).to eq(FAILURE_CODE) + end + end + + def run_worker + pid = fork do + described_class.register_worker + + exit(described_class.readiness.success ? SUCCESS_CODE : FAILURE_CODE) + end + + Process.wait2(pid) + end + end +end diff --git a/spec/lib/gitlab/highlight_spec.rb b/spec/lib/gitlab/highlight_spec.rb index 4676db6b8d8b1da51ac1ba6f04d29b8770bc528b..5a45d724b83ed865f7c3c855999cc54fecae92fe 100644 --- a/spec/lib/gitlab/highlight_spec.rb +++ b/spec/lib/gitlab/highlight_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::Highlight do diff --git a/spec/lib/gitlab/http_io_spec.rb b/spec/lib/gitlab/http_io_spec.rb index 788bddb8f59d6f041b6b53899fc2a75590480a12..f30528916dcb7bbaf9fa3aad4f4b04d3a066f998 100644 --- a/spec/lib/gitlab/http_io_spec.rb +++ b/spec/lib/gitlab/http_io_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::HttpIO do diff --git a/spec/lib/gitlab/http_spec.rb b/spec/lib/gitlab/http_spec.rb index d3f9be845dd127e8af5e72d0e08c57baa5a25b8c..192816ad057f68f5af11fc382173527f7dd127ff 100644 --- a/spec/lib/gitlab/http_spec.rb +++ b/spec/lib/gitlab/http_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::HTTP do diff --git a/spec/lib/gitlab/i18n_spec.rb b/spec/lib/gitlab/i18n_spec.rb index 785035d993fb30bf2e262143d27020905c9a67ff..2664423af88701b3f0d2ac8b76abcb7d3b55cc57 100644 --- a/spec/lib/gitlab/i18n_spec.rb +++ b/spec/lib/gitlab/i18n_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::I18n do diff --git a/spec/lib/gitlab/identifier_spec.rb b/spec/lib/gitlab/identifier_spec.rb index 1e583f4cee29a783773dabffcfb0e2a9358c19d0..9c7972d4bde1c3b98b67b5cf14ef736bd7a584a8 100644 --- a/spec/lib/gitlab/identifier_spec.rb +++ b/spec/lib/gitlab/identifier_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::Identifier do diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 4fd61383c6b204933602c96219b079ae5b805b4d..8f627fcc24d1c026f6b3418583e1e1f17fdfdf56 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -29,6 +29,9 @@ issues: - prometheus_alerts - prometheus_alert_events - self_managed_prometheus_alert_events +- zoom_meetings +- vulnerability_links +- related_vulnerabilities events: - author - project @@ -119,6 +122,7 @@ merge_requests: - pipelines_for_merge_request - merge_request_assignees - suggestions +- unresolved_notes - assignees - reviews - approval_rules @@ -338,6 +342,7 @@ project: - triggers - pipeline_schedules - environments +- environments_for_dashboard - deployments - project_feature - auto_devops @@ -421,6 +426,12 @@ project: - pages_metadatum - alerts_service - grafana_integration +- remove_source_branch_after_merge +- deleting_user +- upstream_projects +- downstream_projects +- upstream_project_subscriptions +- downstream_project_subscriptions award_emoji: - awardable - user @@ -528,4 +539,6 @@ versions: &version - issue - designs - actions +zoom_meetings: +- issue design_versions: *version diff --git a/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb b/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb index 934e676d0200579a249459c8cafd77d6660b81d3..b190a1007a08cfabbf511a1afe88f0c8ea808911 100644 --- a/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb +++ b/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb @@ -132,10 +132,6 @@ describe Gitlab::ImportExport::FastHashSerializer do end it 'has no when YML attributes but only the DB column' do - allow_any_instance_of(Ci::Pipeline) - .to receive(:ci_yaml_file) - .and_return(File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))) - expect_any_instance_of(Gitlab::Ci::YamlProcessor).not_to receive(:build_attributes) subject diff --git a/spec/lib/gitlab/import_export/fork_spec.rb b/spec/lib/gitlab/import_export/fork_spec.rb index 71fd5a51c3b510e54d0920a70f673920d5c51317..5752fd8fa0da5de269c62c05a26812d843ad6e17 100644 --- a/spec/lib/gitlab/import_export/fork_spec.rb +++ b/spec/lib/gitlab/import_export/fork_spec.rb @@ -47,7 +47,7 @@ describe 'forked project import' do end end - it 'can access the MR' do + it 'can access the MR', :sidekiq_might_not_need_inline do project.merge_requests.first.fetch_ref! expect(project.repository.ref_exists?('refs/merge-requests/1/head')).to be_truthy 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 6a803c48b341de0dcf74632eef3274289bf2ce66..1a5cb7806a3b5cabe1f3bc684a47f48afece71b1 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 @@ -2,7 +2,7 @@ require 'spec_helper' describe Gitlab::ImportExport::GroupProjectObjectBuilder do let(:project) do - create(:project, + create(:project, :repository, :builds_disabled, :issues_disabled, name: 'project', @@ -11,8 +11,8 @@ describe Gitlab::ImportExport::GroupProjectObjectBuilder do end context 'labels' do - it 'finds the right group label' do - group_label = create(:group_label, 'name': 'group label', 'group': project.group) + it 'finds the existing group label' do + group_label = create(:group_label, name: 'group label', group: project.group) expect(described_class.build(Label, 'title' => 'group label', @@ -31,8 +31,8 @@ describe Gitlab::ImportExport::GroupProjectObjectBuilder do end context 'milestones' do - it 'finds the right group milestone' do - milestone = create(:milestone, 'name' => 'group milestone', 'group' => project.group) + it 'finds the existing group milestone' do + milestone = create(:milestone, name: 'group milestone', group: project.group) expect(described_class.build(Milestone, 'title' => 'group milestone', @@ -49,4 +49,30 @@ describe Gitlab::ImportExport::GroupProjectObjectBuilder do expect(milestone.persisted?).to be true end end + + context 'merge_request' do + it 'finds the existing merge_request' do + merge_request = create(:merge_request, title: 'MergeRequest', iid: 7, target_project: project, source_project: project) + expect(described_class.build(MergeRequest, + 'title' => 'MergeRequest', + 'source_project_id' => project.id, + 'target_project_id' => project.id, + 'source_branch' => 'SourceBranch', + 'iid' => 7, + 'target_branch' => 'TargetBranch', + 'author_id' => project.creator.id)).to eq(merge_request) + end + + it 'creates a new merge_request' do + merge_request = described_class.build(MergeRequest, + 'title' => 'MergeRequest', + 'iid' => 8, + 'source_project_id' => project.id, + 'target_project_id' => project.id, + 'source_branch' => 'SourceBranch', + 'target_branch' => 'TargetBranch', + 'author_id' => project.creator.id) + expect(merge_request.persisted?).to be true + end + end end diff --git a/spec/lib/gitlab/import_export/group_tree_saver_spec.rb b/spec/lib/gitlab/import_export/group_tree_saver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b856441981a0c7a02993c56540f55d9259310200 --- /dev/null +++ b/spec/lib/gitlab/import_export/group_tree_saver_spec.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::ImportExport::GroupTreeSaver do + describe 'saves the group tree into a json object' do + let(:shared) { Gitlab::ImportExport::Shared.new(group) } + let(:group_tree_saver) { described_class.new(group: group, current_user: user, shared: shared) } + let(:export_path) { "#{Dir.tmpdir}/group_tree_saver_spec" } + let(:user) { create(:user) } + let!(:group) { setup_group } + + before do + group.add_maintainer(user) + allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path) + end + + after do + FileUtils.rm_rf(export_path) + end + + it 'saves group successfully' do + expect(group_tree_saver.save).to be true + end + + context ':export_fast_serialize feature flag checks' do + before do + expect(Gitlab::ImportExport::Reader).to receive(:new).with(shared: shared, config: group_config).and_return(reader) + expect(reader).to receive(:group_tree).and_return(group_tree) + end + + let(:reader) { instance_double('Gitlab::ImportExport::Reader') } + let(:group_config) { Gitlab::ImportExport::Config.new(config: Gitlab::ImportExport.group_config_file).to_h } + let(:group_tree) do + { + include: [{ milestones: { include: [] } }], + preload: { milestones: nil } + } + end + + context 'when :export_fast_serialize feature is enabled' do + let(:serializer) { instance_double(Gitlab::ImportExport::FastHashSerializer) } + + before do + stub_feature_flags(export_fast_serialize: true) + + expect(Gitlab::ImportExport::FastHashSerializer).to receive(:new).with(group, group_tree).and_return(serializer) + end + + it 'uses FastHashSerializer' do + expect(serializer).to receive(:execute) + + group_tree_saver.save + end + end + + context 'when :export_fast_serialize feature is disabled' do + before do + stub_feature_flags(export_fast_serialize: false) + end + + it 'is serialized via built-in `as_json`' do + expect(group).to receive(:as_json).with(group_tree).and_call_original + + group_tree_saver.save + end + end + end + + # It is mostly duplicated in + # `spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb` + # except: + # context 'with description override' do + # context 'group members' do + # ^ These are specific for the groupTreeSaver + context 'JSON' do + let(:saved_group_json) do + group_tree_saver.save + group_json(group_tree_saver.full_path) + end + + it 'saves the correct json' do + expect(saved_group_json).to include({ 'description' => 'description', 'visibility_level' => 20 }) + end + + it 'has milestones' do + expect(saved_group_json['milestones']).not_to be_empty + end + + it 'has labels' do + expect(saved_group_json['labels']).not_to be_empty + end + + it 'has boards' do + expect(saved_group_json['boards']).not_to be_empty + end + + it 'has group members' do + expect(saved_group_json['members']).not_to be_empty + end + + it 'has priorities associated to labels' do + expect(saved_group_json['labels'].first['priorities']).not_to be_empty + end + + it 'has badges' do + expect(saved_group_json['badges']).not_to be_empty + end + + context 'group children' do + let(:children) { group.children } + + it 'exports group children' do + expect(saved_group_json['children'].length).to eq(children.count) + end + + it 'exports group children of children' do + expect(saved_group_json['children'].first['children'].length).to eq(children.first.children.count) + end + end + + context 'group members' do + let(:user2) { create(:user, email: 'group@member.com') } + let(:member_emails) do + saved_group_json['members'].map do |pm| + pm['user']['email'] + end + end + + before do + group.add_developer(user2) + end + + it 'exports group members as group owner' do + group.add_owner(user) + + expect(member_emails).to include('group@member.com') + end + + context 'as admin' do + let(:user) { create(:admin) } + + it 'exports group members as admin' do + expect(member_emails).to include('group@member.com') + end + + it 'exports group members' do + member_types = saved_group_json['members'].map { |pm| pm['source_type'] } + + expect(member_types).to all(eq('Namespace')) + end + end + end + + context 'group attributes' do + it 'does not contain the runners token' do + expect(saved_group_json).not_to include("runners_token" => 'token') + end + end + end + end + + def setup_group + group = create(:group, description: 'description') + sub_group = create(:group, description: 'description', parent: group) + create(:group, description: 'description', parent: sub_group) + create(:milestone, group: group) + create(:group_badge, group: group) + group_label = create(:group_label, group: group) + create(:label_priority, label: group_label, priority: 1) + create(:board, group: group) + create(:group_badge, group: group) + + group + end + + def group_json(filename) + JSON.parse(IO.read(filename)) + end +end diff --git a/spec/lib/gitlab/import_export/import_export_spec.rb b/spec/lib/gitlab/import_export/import_export_spec.rb index 40a5f2294a2f930103efd71a50468b1b68e2fecd..a6b0dc758cd26c29e9ff368f59d751b0260fe4f9 100644 --- a/spec/lib/gitlab/import_export/import_export_spec.rb +++ b/spec/lib/gitlab/import_export/import_export_spec.rb @@ -6,17 +6,17 @@ describe Gitlab::ImportExport do let(:project) { create(:project, :public, path: 'project-path', namespace: group) } it 'contains the project path' do - expect(described_class.export_filename(project: project)).to include(project.path) + expect(described_class.export_filename(exportable: project)).to include(project.path) end it 'contains the namespace path' do - expect(described_class.export_filename(project: project)).to include(project.namespace.full_path.tr('/', '_')) + expect(described_class.export_filename(exportable: project)).to include(project.namespace.full_path.tr('/', '_')) end it 'does not go over a certain length' do project.path = 'a' * 100 - expect(described_class.export_filename(project: project).length).to be < 70 + expect(described_class.export_filename(exportable: project).length).to be < 70 end end end 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 ebd2c6089ce884e9522f925af777c92da7f45a89..459b1eed1a73f313682d44ab571841f9f7cfceb2 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -2,6 +2,8 @@ require 'spec_helper' include ImportExport::CommonUtil describe Gitlab::ImportExport::ProjectTreeRestorer do + include ImportExport::CommonUtil + let(:shared) { project.import_export_shared } describe 'restore project tree' do @@ -16,7 +18,8 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do RSpec::Mocks.with_temporary_scope do @project = create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project') @shared = @project.import_export_shared - allow(@shared).to receive(:export_path).and_return('spec/fixtures/lib/gitlab/import_export/') + + setup_import_export_config('complex') allow_any_instance_of(Repository).to receive(:fetch_source_branch!).and_return(true) allow_any_instance_of(Gitlab::Git::Repository).to receive(:branch_exists?).and_return(false) @@ -207,10 +210,27 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do expect(@project.project_badges.count).to eq(2) end + it 'has snippets' do + expect(@project.snippets.count).to eq(1) + end + + it 'has award emoji for a snippet' do + award_emoji = @project.snippets.first.award_emoji + + expect(award_emoji.map(&:name)).to contain_exactly('thumbsup', 'coffee') + end + it 'restores the correct service' do expect(CustomIssueTrackerService.first).not_to be_nil end + it 'restores zoom meetings' do + meetings = @project.issues.first.zoom_meetings + + expect(meetings.count).to eq(1) + expect(meetings.first.url).to eq('https://zoom.us/j/123456789') + 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) @@ -250,9 +270,9 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do end it 'has the correct number of pipelines and statuses' do - expect(@project.ci_pipelines.size).to eq(5) + expect(@project.ci_pipelines.size).to eq(6) - @project.ci_pipelines.zip([2, 2, 2, 2, 2]) + @project.ci_pipelines.order(:id).zip([2, 2, 2, 2, 2, 0]) .each do |(pipeline, expected_status_size)| expect(pipeline.statuses.size).to eq(expected_status_size) end @@ -261,7 +281,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 5 + expect(Ci::Pipeline.all.count).to be 6 end it 'restores pipeline stages' do @@ -307,21 +327,33 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do end end - context 'Light JSON' do + context 'project.json file access check' 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(:restored_project_json) { project_tree_restorer.restore } - before do - allow(shared).to receive(:export_path).and_return('spec/fixtures/lib/gitlab/import_export/') + it 'does not read a symlink' do + Dir.mktmpdir do |tmpdir| + setup_symlink(tmpdir, 'project.json') + allow(shared).to receive(:export_path).and_call_original + + expect(project_tree_restorer.restore).to eq(false) + expect(shared.errors).to include('Incorrect JSON format') + end end + end + + context 'Light JSON' 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(:restored_project_json) { project_tree_restorer.restore } context 'with a simple project' do before do - project_tree_restorer.instance_variable_set(:@path, "spec/fixtures/lib/gitlab/import_export/project.light.json") - - restored_project_json + setup_import_export_config('light') + expect(restored_project_json).to eq(true) end it_behaves_like 'restores project correctly', @@ -332,19 +364,6 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do first_issue_labels: 1, services: 1 - context 'project.json file access check' do - it 'does not read a symlink' do - Dir.mktmpdir do |tmpdir| - setup_symlink(tmpdir, 'project.json') - allow(shared).to receive(:export_path).and_call_original - - restored_project_json - - expect(shared.errors).to be_empty - end - end - end - context 'when there is an existing build with build token' do before do create(:ci_build, token: 'abcd') @@ -360,6 +379,10 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do end context 'when the project has overridden params in import data' do + before do + setup_import_export_config('light') + end + it 'handles string versions of visibility_level' do # Project needs to be in a group for visibility level comparison # to happen @@ -368,24 +391,21 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do project.create_import_data(data: { override_params: { visibility_level: Gitlab::VisibilityLevel::INTERNAL.to_s } }) - restored_project_json - + expect(restored_project_json).to eq(true) expect(project.visibility_level).to eq(Gitlab::VisibilityLevel::INTERNAL) end it 'overwrites the params stored in the JSON' do project.create_import_data(data: { override_params: { description: "Overridden" } }) - restored_project_json - + expect(restored_project_json).to eq(true) expect(project.description).to eq("Overridden") end it 'does not allow setting params that are excluded from import_export settings' do project.create_import_data(data: { override_params: { lfs_enabled: true } }) - restored_project_json - + expect(restored_project_json).to eq(true) expect(project.lfs_enabled).to be_falsey end @@ -401,7 +421,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do project.create_import_data(data: { override_params: disabled_access_levels }) - restored_project_json + expect(restored_project_json).to eq(true) aggregate_failures do access_level_keys.each do |key| @@ -422,9 +442,8 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do end before do - project_tree_restorer.instance_variable_set(:@path, "spec/fixtures/lib/gitlab/import_export/project.group.json") - - restored_project_json + setup_import_export_config('group') + expect(restored_project_json).to eq(true) end it_behaves_like 'restores project correctly', @@ -456,11 +475,11 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do end before do - project_tree_restorer.instance_variable_set(:@path, "spec/fixtures/lib/gitlab/import_export/project.light.json") + setup_import_export_config('light') end it 'does not import any templated services' do - restored_project_json + expect(restored_project_json).to eq(true) expect(project.services.where(template: true).count).to eq(0) end @@ -470,8 +489,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do expect_any_instance_of(Gitlab::ImportExport::Shared).not_to receive(:error) - restored_project_json - + expect(restored_project_json).to eq(true) expect(project.labels.count).to eq(1) end @@ -480,8 +498,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do expect_any_instance_of(Gitlab::ImportExport::Shared).not_to receive(:error) - restored_project_json - + expect(restored_project_json).to eq(true) expect(project.group.milestones.count).to eq(1) expect(project.milestones.count).to eq(0) end @@ -497,13 +514,14 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do group: create(:group)) end - it 'preserves the project milestone IID' do - project_tree_restorer.instance_variable_set(:@path, "spec/fixtures/lib/gitlab/import_export/project.milestone-iid.json") + before do + setup_import_export_config('milestone-iid') + end + it 'preserves the project milestone IID' do expect_any_instance_of(Gitlab::ImportExport::Shared).not_to receive(:error) - restored_project_json - + expect(restored_project_json).to eq(true) expect(project.milestones.count).to eq(2) expect(Milestone.find_by_title('Another milestone').iid).to eq(1) expect(Milestone.find_by_title('Group-level milestone').iid).to eq(2) @@ -511,19 +529,21 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do end context 'with external authorization classification labels' do + before do + setup_import_export_config('light') + end + it 'converts empty external classification authorization labels to nil' do project.create_import_data(data: { override_params: { external_authorization_classification_label: "" } }) - restored_project_json - + expect(restored_project_json).to eq(true) expect(project.external_authorization_classification_label).to be_nil end it 'preserves valid external classification authorization labels' do project.create_import_data(data: { override_params: { external_authorization_classification_label: "foobar" } }) - restored_project_json - + expect(restored_project_json).to eq(true) expect(project.external_authorization_classification_label).to eq("foobar") end end diff --git a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb index ff46e062a5dde665a9f7f566804cb20890f5fe0b..97d8b1558262f5038742d56aa3500b2099a2db3a 100644 --- a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb @@ -203,7 +203,6 @@ describe Gitlab::ImportExport::ProjectTreeSaver do end it 'has no when YML attributes but only the DB column' do - allow_any_instance_of(Ci::Pipeline).to receive(:ci_yaml_file).and_return(File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))) expect_any_instance_of(Gitlab::Ci::YamlProcessor).not_to receive(:build_attributes) saved_project_json diff --git a/spec/lib/gitlab/import_export/relation_rename_service_spec.rb b/spec/lib/gitlab/import_export/relation_rename_service_spec.rb index 472bf55d37ed5879f3d61017025fab99265a0228..d62f5725f9eae5830ed431177d5f7b346251d687 100644 --- a/spec/lib/gitlab/import_export/relation_rename_service_spec.rb +++ b/spec/lib/gitlab/import_export/relation_rename_service_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe Gitlab::ImportExport::RelationRenameService do + include ImportExport::CommonUtil + let(:renames) do { 'example_relation1' => 'new_example_relation1', @@ -21,12 +23,12 @@ describe Gitlab::ImportExport::RelationRenameService do context 'when importing' do let(:project_tree_restorer) { Gitlab::ImportExport::ProjectTreeRestorer.new(user: user, shared: shared, project: project) } - let(:import_path) { 'spec/fixtures/lib/gitlab/import_export' } - let(:file_content) { IO.read("#{import_path}/project.json") } - let!(:json_file) { ActiveSupport::JSON.decode(file_content) } + let(:file_content) { IO.read(File.join(shared.export_path, 'project.json')) } + let(:json_file) { ActiveSupport::JSON.decode(file_content) } before do - allow(shared).to receive(:export_path).and_return(import_path) + setup_import_export_config('complex') + allow(ActiveSupport::JSON).to receive(:decode).and_call_original allow(ActiveSupport::JSON).to receive(:decode).with(file_content).and_return(json_file) end @@ -94,15 +96,20 @@ describe Gitlab::ImportExport::RelationRenameService do let(:export_content_path) { project_tree_saver.full_path } let(:export_content_hash) { ActiveSupport::JSON.decode(File.read(export_content_path)) } let(:injected_hash) { renames.values.product([{}]).to_h } + let(:relation_tree_saver) { Gitlab::ImportExport::RelationTreeSaver.new } let(:project_tree_saver) do Gitlab::ImportExport::ProjectTreeSaver.new( project: project, current_user: user, shared: shared) end + before do + allow(project_tree_saver).to receive(:tree_saver).and_return(relation_tree_saver) + end + it 'adds old relationships to the exported file' do # we inject relations with new names that should be rewritten - expect(project_tree_saver).to receive(:serialize_project_tree).and_wrap_original do |method, *args| + expect(relation_tree_saver).to receive(:serialize).and_wrap_original do |method, *args| method.call(*args).merge(injected_hash) end diff --git a/spec/lib/gitlab/import_export/relation_tree_saver_spec.rb b/spec/lib/gitlab/import_export/relation_tree_saver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2fc26c0e3d48e72d7c08f9b2b90e230e566a8465 --- /dev/null +++ b/spec/lib/gitlab/import_export/relation_tree_saver_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::ImportExport::RelationTreeSaver do + let(:exportable) { create(:group) } + let(:relation_tree_saver) { described_class.new } + let(:tree) { {} } + + describe '#serialize' do + context 'when :export_fast_serialize feature is enabled' do + let(:serializer) { instance_double(Gitlab::ImportExport::FastHashSerializer) } + + before do + stub_feature_flags(export_fast_serialize: true) + end + + it 'uses FastHashSerializer' do + expect(Gitlab::ImportExport::FastHashSerializer) + .to receive(:new) + .with(exportable, tree) + .and_return(serializer) + + expect(serializer).to receive(:execute) + + relation_tree_saver.serialize(exportable, tree) + end + end + + context 'when :export_fast_serialize feature is disabled' do + before do + stub_feature_flags(export_fast_serialize: false) + end + + it 'is serialized via built-in `as_json`' do + expect(exportable).to receive(:as_json).with(tree) + + relation_tree_saver.serialize(exportable, tree) + end + end + end +end diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 8ae571a69efbad956d09d31627cac2fac0925f24..04fe985cdb51091c6a96992ded3c6875debaa513 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -185,6 +185,7 @@ MergeRequest: - merge_when_pipeline_succeeds - merge_user_id - merge_commit_sha +- squash_commit_sha - in_progress_merge_commit_sha - lock_version - milestone_id @@ -512,6 +513,7 @@ Project: - request_access_enabled - has_external_wiki - only_allow_merge_if_all_discussions_are_resolved +- remove_source_branch_after_merge - auto_cancel_pending_pipelines - printing_merge_request_link_enabled - resolve_outdated_diff_discussions @@ -537,7 +539,6 @@ Project: - external_webhook_token - pages_https_only - merge_requests_disable_committers_approval -- merge_requests_require_code_owner_approval - require_password_to_approve ProjectTracingSetting: - external_url @@ -752,4 +753,12 @@ DesignManagement::Version: - created_at - sha - issue_id -- user_id +- author_id +ZoomMeeting: +- id +- issue_id +- project_id +- issue_status +- url +- created_at +- updated_at diff --git a/spec/lib/gitlab/import_export/saver_spec.rb b/spec/lib/gitlab/import_export/saver_spec.rb index d185ff2dfcc63f26522984da3d2d2995787c8450..aca639536778e488c3824adadc6c5d4d36c89e6d 100644 --- a/spec/lib/gitlab/import_export/saver_spec.rb +++ b/spec/lib/gitlab/import_export/saver_spec.rb @@ -5,7 +5,7 @@ describe Gitlab::ImportExport::Saver do let!(:project) { create(:project, :public, name: 'project') } let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" } let(:shared) { project.import_export_shared } - subject { described_class.new(project: project, shared: shared) } + subject { described_class.new(exportable: project, shared: shared) } before do allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path) diff --git a/spec/lib/gitlab/import_export/shared_spec.rb b/spec/lib/gitlab/import_export/shared_spec.rb index 626698369737ec80160137453ebea06e0b32672e..fc011f7e1be7827c008491925b5820ee685b7ee2 100644 --- a/spec/lib/gitlab/import_export/shared_spec.rb +++ b/spec/lib/gitlab/import_export/shared_spec.rb @@ -7,7 +7,7 @@ describe Gitlab::ImportExport::Shared do context 'with a repository on disk' do let(:project) { create(:project, :repository) } - let(:base_path) { %(/tmp/project_exports/#{project.disk_path}/) } + let(:base_path) { %(/tmp/gitlab_exports/#{project.disk_path}/) } describe '#archive_path' do it 'uses a random hash to avoid conflicts' do diff --git a/spec/lib/gitlab/import_sources_spec.rb b/spec/lib/gitlab/import_sources_spec.rb index 8060b5d444880baae8f21082eddeac6cfa8c0f93..265241dc2af9769ff727832d789c309bed1feed5 100644 --- a/spec/lib/gitlab/import_sources_spec.rb +++ b/spec/lib/gitlab/import_sources_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::ImportSources do diff --git a/spec/lib/gitlab/incoming_email_spec.rb b/spec/lib/gitlab/incoming_email_spec.rb index 2db62ab983a0db67d4b6e316eed1053e8a570c00..598336d0b31bbd07fc851a2d1ec5d5574a42fd5a 100644 --- a/spec/lib/gitlab/incoming_email_spec.rb +++ b/spec/lib/gitlab/incoming_email_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "spec_helper" describe Gitlab::IncomingEmail do diff --git a/spec/lib/gitlab/insecure_key_fingerprint_spec.rb b/spec/lib/gitlab/insecure_key_fingerprint_spec.rb index 6532579b1c987e4ce239d29d3396df0913ea10d0..7f20ae98b06455e92e004a798109e0d9778e4c6d 100644 --- a/spec/lib/gitlab/insecure_key_fingerprint_spec.rb +++ b/spec/lib/gitlab/insecure_key_fingerprint_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::InsecureKeyFingerprint do diff --git a/spec/lib/gitlab/instrumentation_helper_spec.rb b/spec/lib/gitlab/instrumentation_helper_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c2674638743271ec475014a64b438eb356874021 --- /dev/null +++ b/spec/lib/gitlab/instrumentation_helper_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'rspec-parameterized' + +describe Gitlab::InstrumentationHelper do + using RSpec::Parameterized::TableSyntax + + describe '.queue_duration_for_job' do + where(:enqueued_at, :created_at, :time_now, :expected_duration) do + "2019-06-01T00:00:00.000+0000" | nil | "2019-06-01T02:00:00.000+0000" | 2.hours.to_f + "2019-06-01T02:00:00.000+0000" | nil | "2019-06-01T02:00:00.001+0000" | 0.001 + "2019-06-01T02:00:00.000+0000" | "2019-05-01T02:00:00.000+0000" | "2019-06-01T02:00:01.000+0000" | 1 + nil | "2019-06-01T02:00:00.000+0000" | "2019-06-01T02:00:00.001+0000" | 0.001 + nil | nil | "2019-06-01T02:00:00.001+0000" | nil + "2019-06-01T02:00:00.000+0200" | nil | "2019-06-01T02:00:00.000-0200" | 4.hours.to_f + 1571825569.998168 | nil | "2019-10-23T12:13:16.000+0200" | 26.001832 + 1571825569 | nil | "2019-10-23T12:13:16.000+0200" | 27 + "invalid_date" | nil | "2019-10-23T12:13:16.000+0200" | nil + "" | nil | "2019-10-23T12:13:16.000+0200" | nil + 0 | nil | "2019-10-23T12:13:16.000+0200" | nil + -1 | nil | "2019-10-23T12:13:16.000+0200" | nil + "2019-06-01T02:00:00.000+0000" | nil | "2019-06-01T00:00:00.000+0000" | 0 + Time.at(1571999233) | nil | "2019-10-25T12:29:16.000+0200" | 123 + end + + with_them do + let(:job) { { 'enqueued_at' => enqueued_at, 'created_at' => created_at } } + + it "returns the correct duration" do + Timecop.freeze(Time.iso8601(time_now)) do + expect(described_class.queue_duration_for_job(job)).to eq(expected_duration) + end + end + end + end +end diff --git a/spec/lib/gitlab/issuable_metadata_spec.rb b/spec/lib/gitlab/issuable_metadata_spec.rb index 032467b8b4ee59e9fcd09ff68a6f10bb0cf62ed1..7632bc3060a69f0830e74bfbd6c4f739102aa753 100644 --- a/spec/lib/gitlab/issuable_metadata_spec.rb +++ b/spec/lib/gitlab/issuable_metadata_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::IssuableMetadata do diff --git a/spec/lib/gitlab/issuable_sorter_spec.rb b/spec/lib/gitlab/issuable_sorter_spec.rb index 5bd76bc6081cf655cf0a89943381c18bf2256f16..486e9539b928dedbd110214c974eabac0aabc3d3 100644 --- a/spec/lib/gitlab/issuable_sorter_spec.rb +++ b/spec/lib/gitlab/issuable_sorter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::IssuableSorter do diff --git a/spec/lib/gitlab/issuables_count_for_state_spec.rb b/spec/lib/gitlab/issuables_count_for_state_spec.rb index c262fdfcb61566df216c3bd56259006275366c37..9380aa5347095eeaef9750bdb9fd1dde9f9e9819 100644 --- a/spec/lib/gitlab/issuables_count_for_state_spec.rb +++ b/spec/lib/gitlab/issuables_count_for_state_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::IssuablesCountForState do diff --git a/spec/lib/gitlab/job_waiter_spec.rb b/spec/lib/gitlab/job_waiter_spec.rb index b0b4fdc09bc78315baa39c1cd0e8603b9cce4c26..efa7fd4b97557d05eb33f46a1f5fc2054dd2868f 100644 --- a/spec/lib/gitlab/job_waiter_spec.rb +++ b/spec/lib/gitlab/job_waiter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::JobWaiter do diff --git a/spec/lib/gitlab/json_logger_spec.rb b/spec/lib/gitlab/json_logger_spec.rb index 3d4f9b5db867ff8678974bd39d194962f535f72a..5d544198c407e35ccb0283f3258207c4b4581a87 100644 --- a/spec/lib/gitlab/json_logger_spec.rb +++ b/spec/lib/gitlab/json_logger_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::JsonLogger do diff --git a/spec/lib/gitlab/kubernetes/config_maps/aws_node_auth_spec.rb b/spec/lib/gitlab/kubernetes/config_maps/aws_node_auth_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f701643860a8b697eb245645cac9c63107241697 --- /dev/null +++ b/spec/lib/gitlab/kubernetes/config_maps/aws_node_auth_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Kubernetes::ConfigMaps::AwsNodeAuth do + describe '#generate' do + let(:role) { 'arn:aws:iam::123456789012:role/node-instance-role' } + + let(:name) { 'aws-auth' } + let(:namespace) { 'kube-system' } + let(:role_config) do + [{ + 'rolearn' => role, + 'username' => 'system:node:{{EC2PrivateDNSName}}', + 'groups' => [ + 'system:bootstrappers', + 'system:nodes' + ] + }] + end + + subject { described_class.new(role).generate } + + it 'builds a Kubeclient Resource' do + expect(subject).to be_a(Kubeclient::Resource) + + expect(subject.metadata.name).to eq(name) + expect(subject.metadata.namespace).to eq(namespace) + + expect(YAML.safe_load(subject.data.mapRoles)).to eq(role_config) + end + end +end diff --git a/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb index 9eb3322f1a6e46af3f6aad9c2b20550ffe7fd6b6..e5a361bdab31bd7e117e4297df9964f42087d31d 100644 --- a/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb +++ b/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb @@ -86,33 +86,6 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do end end - context 'when there is no repository' do - let(:repository) { nil } - - it_behaves_like 'helm commands' do - let(:commands) do - <<~EOS - helm init --upgrade - for i in $(seq 1 30); do helm version #{tls_flags} && s=0 && break || s=$?; sleep 1s; echo \"Retrying ($i)...\"; done; (exit $s) - #{helm_install_command} - EOS - end - - let(:helm_install_command) do - <<~EOS.squish - helm upgrade app-name chart-name - --install - --reset-values - #{tls_flags} - --version 1.2.3 - --set rbac.create\\=false,rbac.enabled\\=false - --namespace gitlab-managed-apps - -f /data/helm/app-name/config/values.yaml - EOS - end - end - end - context 'when there is a pre-install script' do let(:preinstall) { ['/bin/date', '/bin/true'] } diff --git a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb index 64cadcc011c78e4a2ae6dc65e15fcba7ca515a82..e1b4bd0b6649567d9205abb0e38c4bb4ed5bef4e 100644 --- a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb +++ b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb @@ -30,7 +30,7 @@ describe Gitlab::Kubernetes::Helm::Pod do it 'generates the appropriate specifications for the container' do container = subject.generate.spec.containers.first expect(container.name).to eq('helm') - expect(container.image).to eq('registry.gitlab.com/gitlab-org/cluster-integration/helm-install-image/releases/2.14.3-kube-1.11.10') + expect(container.image).to eq('registry.gitlab.com/gitlab-org/cluster-integration/helm-install-image/releases/2.16.1-kube-1.13.12') expect(container.env.count).to eq(3) expect(container.env.map(&:name)).to match_array([:HELM_VERSION, :TILLER_NAMESPACE, :COMMAND_SCRIPT]) expect(container.command).to match_array(["/bin/sh"]) diff --git a/spec/lib/gitlab/kubernetes_spec.rb b/spec/lib/gitlab/kubernetes_spec.rb index a7ea942960ba3a36f86bfedb56cce4d1d534794c..31bfd20449d010b9bbaf7f5a900b13205fd1b5f2 100644 --- a/spec/lib/gitlab/kubernetes_spec.rb +++ b/spec/lib/gitlab/kubernetes_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::Kubernetes do diff --git a/spec/lib/gitlab/language_detection_spec.rb b/spec/lib/gitlab/language_detection_spec.rb index 9636fbd401b3823c56570a32877e0c4b1f314be8..f558ce0d527bdbc1c3f2727687c26bd98c847c30 100644 --- a/spec/lib/gitlab/language_detection_spec.rb +++ b/spec/lib/gitlab/language_detection_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::LanguageDetection do diff --git a/spec/lib/gitlab/lazy_spec.rb b/spec/lib/gitlab/lazy_spec.rb index 37a3ac74316a2f8a0652482caa5f3f7797455bed..19758a1858918928ce671a613e9167601849522e 100644 --- a/spec/lib/gitlab/lazy_spec.rb +++ b/spec/lib/gitlab/lazy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::Lazy do diff --git a/spec/lib/gitlab/metrics/dashboard/finder_spec.rb b/spec/lib/gitlab/metrics/dashboard/finder_spec.rb index af5df1fab4310e3da2a9f42093db38926f4032a5..697bedf7362782b9c179c63f714812d0000ceea0 100644 --- a/spec/lib/gitlab/metrics/dashboard/finder_spec.rb +++ b/spec/lib/gitlab/metrics/dashboard/finder_spec.rb @@ -136,7 +136,7 @@ describe Gitlab::Metrics::Dashboard::Finder, :use_clean_rails_memory_store_cachi describe '.find_all_paths' do let(:all_dashboard_paths) { described_class.find_all_paths(project) } - let(:system_dashboard) { { path: system_dashboard_path, display_name: 'Default', default: true } } + let(:system_dashboard) { { path: system_dashboard_path, display_name: 'Default', default: true, system_dashboard: true } } it 'includes only the system dashboard by default' do expect(all_dashboard_paths).to eq([system_dashboard]) @@ -147,7 +147,7 @@ describe Gitlab::Metrics::Dashboard::Finder, :use_clean_rails_memory_store_cachi let(:project) { project_with_dashboard(dashboard_path) } it 'includes system and project dashboards' do - project_dashboard = { path: dashboard_path, display_name: 'test.yml', default: false } + project_dashboard = { path: dashboard_path, display_name: 'test.yml', default: false, system_dashboard: false } expect(all_dashboard_paths).to contain_exactly(system_dashboard, project_dashboard) end diff --git a/spec/lib/gitlab/metrics/dashboard/processor_spec.rb b/spec/lib/gitlab/metrics/dashboard/processor_spec.rb index e2ce1869810c1b414f8196a68a4908f14853f55e..4fa136bc405af3e9887b922ba485c0490c350e32 100644 --- a/spec/lib/gitlab/metrics/dashboard/processor_spec.rb +++ b/spec/lib/gitlab/metrics/dashboard/processor_spec.rb @@ -25,6 +25,14 @@ describe Gitlab::Metrics::Dashboard::Processor do end end + context 'when the dashboard is not present' do + let(:dashboard_yml) { nil } + + it 'returns nil' do + expect(dashboard).to be_nil + end + end + context 'when dashboard config corresponds to common metrics' do let!(:common_metric) { create(:prometheus_metric, :common, identifier: 'metric_a1') } diff --git a/spec/lib/gitlab/metrics/dashboard/service_selector_spec.rb b/spec/lib/gitlab/metrics/dashboard/service_selector_spec.rb index 095d0a2df788213417a3a9ed51733082240ff77e..0d4562f78f169e6e4869f2ca1c8bfb4e6bf6bd67 100644 --- a/spec/lib/gitlab/metrics/dashboard/service_selector_spec.rb +++ b/spec/lib/gitlab/metrics/dashboard/service_selector_spec.rb @@ -75,6 +75,17 @@ describe Gitlab::Metrics::Dashboard::ServiceSelector do it { is_expected.to be Metrics::Dashboard::CustomMetricEmbedService } end + + context 'with a grafana link' do + let(:arguments) do + { + embedded: true, + grafana_url: 'https://grafana.example.com' + } + end + + it { is_expected.to be Metrics::Dashboard::GrafanaMetricEmbedService } + end end end end diff --git a/spec/lib/gitlab/metrics/dashboard/stages/grafana_formatter_spec.rb b/spec/lib/gitlab/metrics/dashboard/stages/grafana_formatter_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..5c2ec6dae6b079174c5ca6dceb61c87da7f1dd33 --- /dev/null +++ b/spec/lib/gitlab/metrics/dashboard/stages/grafana_formatter_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Metrics::Dashboard::Stages::GrafanaFormatter do + include GrafanaApiHelpers + + let_it_be(:namespace) { create(:namespace, name: 'foo') } + let_it_be(:project) { create(:project, namespace: namespace, name: 'bar') } + + describe '#transform!' do + let(:grafana_dashboard) { JSON.parse(fixture_file('grafana/simplified_dashboard_response.json'), symbolize_names: true) } + let(:datasource) { JSON.parse(fixture_file('grafana/datasource_response.json'), symbolize_names: true) } + + let(:dashboard) { described_class.new(project, {}, params).transform! } + + let(:params) do + { + grafana_dashboard: grafana_dashboard, + datasource: datasource, + grafana_url: valid_grafana_dashboard_link('https://grafana.example.com') + } + end + + context 'when the query and resources are configured correctly' do + let(:expected_dashboard) { JSON.parse(fixture_file('grafana/expected_grafana_embed.json'), symbolize_names: true) } + + it 'generates a gitlab-yml formatted dashboard' do + expect(dashboard).to eq(expected_dashboard) + end + end + + context 'when the inputs are invalid' do + shared_examples_for 'processing error' do + it 'raises a processing error' do + expect { dashboard } + .to raise_error(Gitlab::Metrics::Dashboard::Stages::InputFormatValidator::DashboardProcessingError) + end + end + + context 'when the datasource is not proxyable' do + before do + params[:datasource][:access] = 'not-proxy' + end + + it_behaves_like 'processing error' + end + + context 'when query param "panelId" is not specified' do + before do + params[:grafana_url].gsub!('panelId=8', '') + end + + it_behaves_like 'processing error' + end + + context 'when query param "from" is not specified' do + before do + params[:grafana_url].gsub!('from=1570397739557', '') + end + + it_behaves_like 'processing error' + end + + context 'when query param "to" is not specified' do + before do + params[:grafana_url].gsub!('to=1570484139557', '') + end + + it_behaves_like 'processing error' + end + + context 'when the panel is not a graph' do + before do + params[:grafana_dashboard][:dashboard][:panels][0][:type] = 'singlestat' + end + + it_behaves_like 'processing error' + end + + context 'when the panel is not a line graph' do + before do + params[:grafana_dashboard][:dashboard][:panels][0][:lines] = false + end + + it_behaves_like 'processing error' + end + + context 'when the query dashboard includes undefined variables' do + before do + params[:grafana_url].gsub!('&var-instance=localhost:9121', '') + end + + it_behaves_like 'processing error' + end + + context 'when the expression contains unsupported global variables' do + before do + params[:grafana_dashboard][:dashboard][:panels][0][:targets][0][:expr] = 'sum(important_metric[$__interval_ms])' + end + + it_behaves_like 'processing error' + end + end + end +end diff --git a/spec/lib/gitlab/metrics/dashboard/url_spec.rb b/spec/lib/gitlab/metrics/dashboard/url_spec.rb index e0dc6d98efc756028bb138e9fd920033de227872..daaf66cba46be9e77398b522601f0ff2ba63af9c 100644 --- a/spec/lib/gitlab/metrics/dashboard/url_spec.rb +++ b/spec/lib/gitlab/metrics/dashboard/url_spec.rb @@ -3,13 +3,41 @@ require 'spec_helper' describe Gitlab::Metrics::Dashboard::Url do - describe '#regex' do - it 'returns a regular expression' do - expect(described_class.regex).to be_a Regexp - end + shared_examples_for 'a regex which matches the expected url' do + it { is_expected.to be_a Regexp } it 'matches a metrics dashboard link with named params' do - url = Gitlab::Routing.url_helpers.metrics_namespace_project_environment_url( + expect(subject).to match url + + subject.match(url) do |m| + expect(m.named_captures).to eq expected_params + end + end + end + + shared_examples_for 'does not match non-matching urls' do + it 'does not match other gitlab urls that contain the term metrics' do + url = Gitlab::Routing.url_helpers.active_common_namespace_project_prometheus_metrics_url('foo', 'bar', :json) + + expect(subject).not_to match url + end + + it 'does not match other gitlab urls' do + url = Gitlab.config.gitlab.url + + expect(subject).not_to match url + end + + it 'does not match non-gitlab urls' do + url = 'https://www.super_awesome_site.com/' + + expect(subject).not_to match url + end + end + + describe '#regex' do + let(:url) do + Gitlab::Routing.url_helpers.metrics_namespace_project_environment_url( 'foo', 'bar', 1, @@ -18,8 +46,10 @@ describe Gitlab::Metrics::Dashboard::Url do group: 'awesome group', anchor: 'title' ) + end - expected_params = { + let(:expected_params) do + { 'url' => url, 'namespace' => 'foo', 'project' => 'bar', @@ -27,31 +57,40 @@ describe Gitlab::Metrics::Dashboard::Url do 'query' => '?dashboard=config%2Fprometheus%2Fcommon_metrics.yml&group=awesome+group&start=2019-08-02T05%3A43%3A09.000Z', 'anchor' => '#title' } - - expect(described_class.regex).to match url - - described_class.regex.match(url) do |m| - expect(m.named_captures).to eq expected_params - end end - it 'does not match other gitlab urls that contain the term metrics' do - url = Gitlab::Routing.url_helpers.active_common_namespace_project_prometheus_metrics_url('foo', 'bar', :json) + subject { described_class.regex } - expect(described_class.regex).not_to match url - end + it_behaves_like 'a regex which matches the expected url' + it_behaves_like 'does not match non-matching urls' + end - it 'does not match other gitlab urls' do - url = Gitlab.config.gitlab.url + describe '#grafana_regex' do + let(:url) do + Gitlab::Routing.url_helpers.namespace_project_grafana_api_metrics_dashboard_url( + 'foo', + 'bar', + start: '2019-08-02T05:43:09.000Z', + dashboard: 'config/prometheus/common_metrics.yml', + group: 'awesome group', + anchor: 'title' + ) + end - expect(described_class.regex).not_to match url + let(:expected_params) do + { + 'url' => url, + 'namespace' => 'foo', + 'project' => 'bar', + 'query' => '?dashboard=config%2Fprometheus%2Fcommon_metrics.yml&group=awesome+group&start=2019-08-02T05%3A43%3A09.000Z', + 'anchor' => '#title' + } end - it 'does not match non-gitlab urls' do - url = 'https://www.super_awesome_site.com/' + subject { described_class.grafana_regex } - expect(described_class.regex).not_to match url - end + it_behaves_like 'a regex which matches the expected url' + it_behaves_like 'does not match non-matching urls' end describe '#build_dashboard_url' do diff --git a/spec/lib/gitlab/metrics/exporter/web_exporter_spec.rb b/spec/lib/gitlab/metrics/exporter/web_exporter_spec.rb index 99349934e63d750ed013eaff5d3f0d74aef298c8..f22993cf0579ed3a34c2e7e9b265ccfcd15f40d0 100644 --- a/spec/lib/gitlab/metrics/exporter/web_exporter_spec.rb +++ b/spec/lib/gitlab/metrics/exporter/web_exporter_spec.rb @@ -4,61 +4,41 @@ require 'spec_helper' describe Gitlab::Metrics::Exporter::WebExporter do let(:exporter) { described_class.new } - - context 'when blackout seconds is used' do - let(:blackout_seconds) { 0 } - let(:readiness_probe) { exporter.send(:readiness_probe).execute } - - before do - stub_config( - monitoring: { - web_exporter: { - enabled: true, - port: 0, - address: '127.0.0.1', - blackout_seconds: blackout_seconds - } + let(:readiness_probe) { exporter.send(:readiness_probe).execute } + + before do + stub_config( + monitoring: { + web_exporter: { + enabled: true, + port: 0, + address: '127.0.0.1' } - ) - - exporter.start - end - - after do - exporter.stop - end + } + ) - context 'when running server' do - it 'readiness probe returns succesful status' do - expect(readiness_probe.http_status).to eq(200) - expect(readiness_probe.json).to include(status: 'ok') - expect(readiness_probe.json).to include('web_exporter' => [{ 'status': 'ok' }]) - end - end - - context 'when blackout seconds is 10s' do - let(:blackout_seconds) { 10 } + exporter.start + end - it 'readiness probe returns a failure status' do - # during sleep we check the status of readiness probe - expect(exporter).to receive(:sleep).with(10) do - expect(readiness_probe.http_status).to eq(503) - expect(readiness_probe.json).to include(status: 'failed') - expect(readiness_probe.json).to include('web_exporter' => [{ 'status': 'failed' }]) - end + after do + exporter.stop + end - exporter.stop - end + context 'when running server' do + it 'readiness probe returns succesful status' do + expect(readiness_probe.http_status).to eq(200) + expect(readiness_probe.json).to include(status: 'ok') + expect(readiness_probe.json).to include('web_exporter' => [{ 'status': 'ok' }]) end + end - context 'when blackout is disabled' do - let(:blackout_seconds) { 0 } - - it 'readiness probe returns a failure status' do - expect(exporter).not_to receive(:sleep) + describe '#mark_as_not_running!' do + it 'readiness probe returns a failure status' do + exporter.mark_as_not_running! - exporter.stop - end + expect(readiness_probe.http_status).to eq(503) + expect(readiness_probe.json).to include(status: 'failed') + expect(readiness_probe.json).to include('web_exporter' => [{ 'status': 'failed' }]) end end end diff --git a/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb b/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb index f48cd096a98da95030e6058af343da05c2f54f3d..335670278c4e1676c4b4208d9b24c783799b5bd0 100644 --- a/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb +++ b/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb @@ -31,7 +31,7 @@ describe Gitlab::Metrics::RequestsRackMiddleware do end it 'measures execution time' do - expect(described_class).to receive_message_chain(:http_request_duration_seconds, :observe).with({ status: 200, method: 'get' }, a_positive_execution_time) + expect(described_class).to receive_message_chain(:http_request_duration_seconds, :observe).with({ status: '200', method: 'get' }, a_positive_execution_time) Timecop.scale(3600) { subject.call(env) } end @@ -69,7 +69,7 @@ describe Gitlab::Metrics::RequestsRackMiddleware do expected_labels = [] described_class::HTTP_METHODS.each do |method, statuses| statuses.each do |status| - expected_labels << { method: method, status: status.to_i } + expected_labels << { method: method, status: status.to_s } end end diff --git a/spec/lib/gitlab/pagination/offset_pagination_spec.rb b/spec/lib/gitlab/pagination/offset_pagination_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..9c7dd385726a0204689b93c802781673fd1ad8f3 --- /dev/null +++ b/spec/lib/gitlab/pagination/offset_pagination_spec.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Pagination::OffsetPagination do + let(:resource) { Project.all } + let(:custom_port) { 8080 } + let(:incoming_api_projects_url) { "#{Gitlab.config.gitlab.url}:#{custom_port}/api/v4/projects" } + + before do + stub_config_setting(port: custom_port) + end + + let(:request_context) { double("request_context") } + + subject do + described_class.new(request_context) + end + + describe '#paginate' do + let(:value) { spy('return value') } + let(:base_query) { { foo: 'bar', bar: 'baz' } } + let(:query) { base_query } + + before do + allow(request_context).to receive(:header).and_return(value) + allow(request_context).to receive(:params).and_return(query) + allow(request_context).to receive(:request).and_return(double(url: "#{incoming_api_projects_url}?#{query.to_query}")) + end + + context 'when resource can be paginated' do + before do + create_list(:project, 3) + end + + describe 'first page' do + shared_examples 'response with pagination headers' do + it 'adds appropriate headers' do + expect_header('X-Total', '3') + expect_header('X-Total-Pages', '2') + expect_header('X-Per-Page', '2') + expect_header('X-Page', '1') + expect_header('X-Next-Page', '2') + expect_header('X-Prev-Page', '') + + expect_header('Link', anything) do |_key, val| + expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 1).to_query}>; rel="first")) + expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 2).to_query}>; rel="last")) + expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 2).to_query}>; rel="next")) + expect(val).not_to include('rel="prev"') + end + + subject.paginate(resource) + end + end + + shared_examples 'paginated response' do + it 'returns appropriate amount of resources' do + expect(subject.paginate(resource).count).to eq 2 + end + + it 'executes only one SELECT COUNT query' do + expect { subject.paginate(resource) }.to make_queries_matching(/SELECT COUNT/, 1) + end + end + + let(:query) { base_query.merge(page: 1, per_page: 2) } + + context 'when the api_kaminari_count_with_limit feature flag is unset' do + it_behaves_like 'paginated response' + it_behaves_like 'response with pagination headers' + end + + context 'when the api_kaminari_count_with_limit feature flag is disabled' do + before do + stub_feature_flags(api_kaminari_count_with_limit: false) + end + + it_behaves_like 'paginated response' + it_behaves_like 'response with pagination headers' + end + + context 'when the api_kaminari_count_with_limit feature flag is enabled' do + before do + stub_feature_flags(api_kaminari_count_with_limit: true) + end + + context 'when resources count is less than MAX_COUNT_LIMIT' do + before do + stub_const("::Kaminari::ActiveRecordRelationMethods::MAX_COUNT_LIMIT", 4) + end + + it_behaves_like 'paginated response' + it_behaves_like 'response with pagination headers' + end + + context 'when resources count is more than MAX_COUNT_LIMIT' do + before do + stub_const("::Kaminari::ActiveRecordRelationMethods::MAX_COUNT_LIMIT", 2) + end + + it_behaves_like 'paginated response' + + it 'does not return the X-Total and X-Total-Pages headers' do + expect_no_header('X-Total') + expect_no_header('X-Total-Pages') + expect_header('X-Per-Page', '2') + expect_header('X-Page', '1') + expect_header('X-Next-Page', '2') + expect_header('X-Prev-Page', '') + + expect_header('Link', anything) do |_key, val| + expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 1).to_query}>; rel="first")) + expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 2).to_query}>; rel="next")) + expect(val).not_to include('rel="last"') + expect(val).not_to include('rel="prev"') + end + + subject.paginate(resource) + end + end + end + end + + describe 'second page' do + let(:query) { base_query.merge(page: 2, per_page: 2) } + + it 'returns appropriate amount of resources' do + expect(subject.paginate(resource).count).to eq 1 + end + + it 'adds appropriate headers' do + expect_header('X-Total', '3') + expect_header('X-Total-Pages', '2') + expect_header('X-Per-Page', '2') + expect_header('X-Page', '2') + expect_header('X-Next-Page', '') + expect_header('X-Prev-Page', '1') + + expect_header('Link', anything) do |_key, val| + expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 1).to_query}>; rel="first")) + expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 2).to_query}>; rel="last")) + expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 1).to_query}>; rel="prev")) + expect(val).not_to include('rel="next"') + end + + subject.paginate(resource) + end + end + + context 'if order' do + it 'is not present it adds default order(:id) if no order is present' do + resource.order_values = [] + + paginated_relation = subject.paginate(resource) + + expect(resource.order_values).to be_empty + expect(paginated_relation.order_values).to be_present + expect(paginated_relation.order_values.first).to be_ascending + expect(paginated_relation.order_values.first.expr.name).to eq 'id' + end + + it 'is present it does not add anything' do + paginated_relation = subject.paginate(resource.order(created_at: :desc)) + + expect(paginated_relation.order_values).to be_present + expect(paginated_relation.order_values.first).to be_descending + expect(paginated_relation.order_values.first.expr.name).to eq 'created_at' + end + end + end + + context 'when resource empty' do + describe 'first page' do + let(:query) { base_query.merge(page: 1, per_page: 2) } + + it 'returns appropriate amount of resources' do + expect(subject.paginate(resource).count).to eq 0 + end + + it 'adds appropriate headers' do + expect_header('X-Total', '0') + expect_header('X-Total-Pages', '1') + expect_header('X-Per-Page', '2') + expect_header('X-Page', '1') + expect_header('X-Next-Page', '') + expect_header('X-Prev-Page', '') + + expect_header('Link', anything) do |_key, val| + expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 1).to_query}>; rel="first")) + expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 1).to_query}>; rel="last")) + expect(val).not_to include('rel="prev"') + expect(val).not_to include('rel="next"') + expect(val).not_to include('page=0') + end + + subject.paginate(resource) + end + end + end + end + + def expect_header(*args, &block) + expect(subject).to receive(:header).with(*args, &block) + end + + def expect_no_header(*args, &block) + expect(subject).not_to receive(:header).with(*args) + end + + def expect_message(method) + expect(subject).to receive(method) + .at_least(:once).and_return(value) + end +end diff --git a/spec/lib/gitlab/phabricator_import/project_creator_spec.rb b/spec/lib/gitlab/phabricator_import/project_creator_spec.rb index e9455b866acb3716eaad8e3e4f3fe0dc23f8e8f2..fd17284eea2aeb2bbd82947b48a7856737d15bb9 100644 --- a/spec/lib/gitlab/phabricator_import/project_creator_spec.rb +++ b/spec/lib/gitlab/phabricator_import/project_creator_spec.rb @@ -11,7 +11,7 @@ describe Gitlab::PhabricatorImport::ProjectCreator do subject(:creator) { described_class.new(user, params) } describe '#execute' do - it 'creates a project correctly and schedule an import' do + it 'creates a project correctly and schedule an import', :sidekiq_might_not_need_inline do expect_next_instance_of(Gitlab::PhabricatorImport::Importer) do |importer| expect(importer).to receive(:execute) end diff --git a/spec/lib/gitlab/project_authorizations_spec.rb b/spec/lib/gitlab/project_authorizations_spec.rb index 82ccb42f8a6ed6b53afff21eeebfb7b4808517c7..6e5c36172e2622992d5a9172f662c8c19eac8504 100644 --- a/spec/lib/gitlab/project_authorizations_spec.rb +++ b/spec/lib/gitlab/project_authorizations_spec.rb @@ -3,48 +3,55 @@ require 'spec_helper' describe Gitlab::ProjectAuthorizations do - let(:group) { create(:group) } - let!(:owned_project) { create(:project) } - let!(:other_project) { create(:project) } - let!(:group_project) { create(:project, namespace: group) } - - let(:user) { owned_project.namespace.owner } - def map_access_levels(rows) rows.each_with_object({}) do |row, hash| hash[row.project_id] = row.access_level end end - before do - other_project.add_reporter(user) - group.add_developer(user) - end - - let(:authorizations) do + subject(:authorizations) do described_class.new(user).calculate end - it 'returns the correct number of authorizations' do - expect(authorizations.length).to eq(3) - end + context 'user added to group and project' do + let(:group) { create(:group) } + let!(:other_project) { create(:project) } + let!(:group_project) { create(:project, namespace: group) } + let!(:owned_project) { create(:project) } + let(:user) { owned_project.namespace.owner } - it 'includes the correct projects' do - expect(authorizations.pluck(:project_id)) - .to include(owned_project.id, other_project.id, group_project.id) - end + before do + other_project.add_reporter(user) + group.add_developer(user) + end + + it 'returns the correct number of authorizations' do + expect(authorizations.length).to eq(3) + end - it 'includes the correct access levels' do - mapping = map_access_levels(authorizations) + it 'includes the correct projects' do + expect(authorizations.pluck(:project_id)) + .to include(owned_project.id, other_project.id, group_project.id) + end + + it 'includes the correct access levels' do + mapping = map_access_levels(authorizations) - expect(mapping[owned_project.id]).to eq(Gitlab::Access::MAINTAINER) - expect(mapping[other_project.id]).to eq(Gitlab::Access::REPORTER) - expect(mapping[group_project.id]).to eq(Gitlab::Access::DEVELOPER) + expect(mapping[owned_project.id]).to eq(Gitlab::Access::MAINTAINER) + expect(mapping[other_project.id]).to eq(Gitlab::Access::REPORTER) + expect(mapping[group_project.id]).to eq(Gitlab::Access::DEVELOPER) + end end context 'with nested groups' do + let(:group) { create(:group) } let!(:nested_group) { create(:group, parent: group) } let!(:nested_project) { create(:project, namespace: nested_group) } + let(:user) { create(:user) } + + before do + group.add_developer(user) + end it 'includes nested groups' do expect(authorizations.pluck(:project_id)).to include(nested_project.id) @@ -64,4 +71,114 @@ describe Gitlab::ProjectAuthorizations do expect(mapping[nested_project.id]).to eq(Gitlab::Access::MAINTAINER) end end + + context 'with shared groups' do + let(:parent_group_user) { create(:user) } + let(:group_user) { create(:user) } + let(:child_group_user) { create(:user) } + + let_it_be(:group_parent) { create(:group, :private) } + let_it_be(:group) { create(:group, :private, parent: group_parent) } + let_it_be(:group_child) { create(:group, :private, parent: group) } + + let_it_be(:shared_group_parent) { create(:group, :private) } + let_it_be(:shared_group) { create(:group, :private, parent: shared_group_parent) } + let_it_be(:shared_group_child) { create(:group, :private, parent: shared_group) } + + let_it_be(:project_parent) { create(:project, group: shared_group_parent) } + let_it_be(:project) { create(:project, group: shared_group) } + let_it_be(:project_child) { create(:project, group: shared_group_child) } + + before do + group_parent.add_owner(parent_group_user) + group.add_owner(group_user) + group_child.add_owner(child_group_user) + + create(:group_group_link, shared_group: shared_group, shared_with_group: group) + end + + context 'when feature flag share_group_with_group is enabled' do + before do + stub_feature_flags(share_group_with_group: true) + end + + context 'group user' do + let(:user) { group_user } + + it 'creates proper authorizations' do + mapping = map_access_levels(authorizations) + + expect(mapping[project_parent.id]).to be_nil + expect(mapping[project.id]).to eq(Gitlab::Access::DEVELOPER) + expect(mapping[project_child.id]).to eq(Gitlab::Access::DEVELOPER) + end + end + + context 'parent group user' do + let(:user) { parent_group_user } + + it 'creates proper authorizations' do + mapping = map_access_levels(authorizations) + + expect(mapping[project_parent.id]).to be_nil + expect(mapping[project.id]).to be_nil + expect(mapping[project_child.id]).to be_nil + end + end + + context 'child group user' do + let(:user) { child_group_user } + + it 'creates proper authorizations' do + mapping = map_access_levels(authorizations) + + expect(mapping[project_parent.id]).to be_nil + expect(mapping[project.id]).to be_nil + expect(mapping[project_child.id]).to be_nil + end + end + end + + context 'when feature flag share_group_with_group is disabled' do + before do + stub_feature_flags(share_group_with_group: false) + end + + context 'group user' do + let(:user) { group_user } + + it 'creates proper authorizations' do + mapping = map_access_levels(authorizations) + + expect(mapping[project_parent.id]).to be_nil + expect(mapping[project.id]).to be_nil + expect(mapping[project_child.id]).to be_nil + end + end + + context 'parent group user' do + let(:user) { parent_group_user } + + it 'creates proper authorizations' do + mapping = map_access_levels(authorizations) + + expect(mapping[project_parent.id]).to be_nil + expect(mapping[project.id]).to be_nil + expect(mapping[project_child.id]).to be_nil + end + end + + context 'child group user' do + let(:user) { child_group_user } + + it 'creates proper authorizations' do + mapping = map_access_levels(authorizations) + + expect(mapping[project_parent.id]).to be_nil + expect(mapping[project.id]).to be_nil + expect(mapping[project_child.id]).to be_nil + end + end + end + end end diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index d6e50c672e6b2d5365912e32c08df5b95c966e2f..99078f19361285e3904123c691b893088cc0297b 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -79,20 +79,20 @@ describe Gitlab::ProjectSearchResults do end it 'finds by name' do - expect(results.map(&:filename)).to include(expected_file_by_name) + expect(results.map(&:path)).to include(expected_file_by_path) end - it "loads all blobs for filename matches in single batch" 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(&:filename)).to include(*expected) + expect(results.map(&:path)).to include(*expected) end it 'finds by content' do - blob = results.select { |result| result.filename == expected_file_by_content }.flatten.last + blob = results.select { |result| result.path == expected_file_by_content }.flatten.last - expect(blob.filename).to eq(expected_file_by_content) + expect(blob.path).to eq(expected_file_by_content) end end @@ -146,7 +146,7 @@ describe Gitlab::ProjectSearchResults do let(:blob_type) { 'blobs' } let(:disabled_project) { create(:project, :public, :repository, :repository_disabled) } let(:private_project) { create(:project, :public, :repository, :repository_private) } - let(:expected_file_by_name) { 'files/images/wm.svg' } + let(:expected_file_by_path) { 'files/images/wm.svg' } let(:expected_file_by_content) { 'CHANGELOG' } end @@ -169,7 +169,7 @@ describe Gitlab::ProjectSearchResults do let(:blob_type) { 'wiki_blobs' } let(:disabled_project) { create(:project, :public, :wiki_repo, :wiki_disabled) } let(:private_project) { create(:project, :public, :wiki_repo, :wiki_private) } - let(:expected_file_by_name) { 'Files/Title.md' } + let(:expected_file_by_path) { 'Files/Title.md' } let(:expected_file_by_content) { 'CHANGELOG.md' } end diff --git a/spec/lib/gitlab/project_template_spec.rb b/spec/lib/gitlab/project_template_spec.rb index 83acd979a80dd34bf41662875ac6bf07647d348d..5559b1e42911023c3d389eb6b0b3abb6bc5a70cc 100644 --- a/spec/lib/gitlab/project_template_spec.rb +++ b/spec/lib/gitlab/project_template_spec.rb @@ -22,7 +22,8 @@ describe Gitlab::ProjectTemplate do described_class.new('nfjekyll', 'Netlify/Jekyll', _('A Jekyll site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features.'), 'https://gitlab.com/pages/nfjekyll'), described_class.new('nfplainhtml', 'Netlify/Plain HTML', _('A plain HTML site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features.'), 'https://gitlab.com/pages/nfplain-html'), described_class.new('nfgitbook', 'Netlify/GitBook', _('A GitBook site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features.'), 'https://gitlab.com/pages/nfgitbook'), - described_class.new('nfhexo', 'Netlify/Hexo', _('A Hexo site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features.'), 'https://gitlab.com/pages/nfhexo') + described_class.new('nfhexo', 'Netlify/Hexo', _('A Hexo site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features.'), 'https://gitlab.com/pages/nfhexo'), + described_class.new('serverless_framework', 'Serverless Framework/JS', _('A basic page and serverless function that uses AWS Lambda, AWS API Gateway, and GitLab Pages'), 'https://gitlab.com/gitlab-org/project-templates/serverless-framework', 'illustrations/logos/serverless_framework.svg') ] expect(described_class.all).to be_an(Array) diff --git a/spec/lib/gitlab/prometheus/internal_spec.rb b/spec/lib/gitlab/prometheus/internal_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..884bdcb4e9b82b3913fcbf2fb864a06c99abfaa5 --- /dev/null +++ b/spec/lib/gitlab/prometheus/internal_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Prometheus::Internal do + let(:listen_address) { 'localhost:9090' } + + let(:prometheus_settings) do + { + enable: true, + listen_address: listen_address + } + end + + before do + stub_config(prometheus: prometheus_settings) + end + + describe '.uri' do + shared_examples 'returns valid uri' do |uri_string| + it do + expect(described_class.uri).to eq(uri_string) + expect { Addressable::URI.parse(described_class.uri) }.not_to raise_error + end + end + + it_behaves_like 'returns valid uri', 'http://localhost:9090' + + context 'with non default prometheus address' do + let(:listen_address) { 'https://localhost:9090' } + + it_behaves_like 'returns valid uri', 'https://localhost:9090' + + context 'with :9090 symbol' do + let(:listen_address) { :':9090' } + + it_behaves_like 'returns valid uri', 'http://localhost:9090' + end + + context 'with 0.0.0.0:9090' do + let(:listen_address) { '0.0.0.0:9090' } + + it_behaves_like 'returns valid uri', 'http://localhost:9090' + end + end + + context 'when listen_address is nil' do + let(:listen_address) { nil } + + it 'does not fail' do + expect(described_class.uri).to eq(nil) + end + end + + context 'when prometheus listen address is blank in gitlab.yml' do + let(:listen_address) { '' } + + it 'does not configure prometheus' do + expect(described_class.uri).to eq(nil) + end + end + end + + describe 'prometheus_enabled?' do + it 'returns correct value' do + expect(described_class.prometheus_enabled?).to eq(true) + end + + context 'when prometheus setting is disabled in gitlab.yml' do + let(:prometheus_settings) do + { + enable: false, + listen_address: listen_address + } + end + + it 'returns correct value' do + expect(described_class.prometheus_enabled?).to eq(false) + end + end + + context 'when prometheus setting is not present in gitlab.yml' do + before do + allow(Gitlab.config).to receive(:prometheus).and_raise(Settingslogic::MissingSetting) + end + + it 'does not fail' do + expect(described_class.prometheus_enabled?).to eq(false) + end + end + end + + describe '.listen_address' do + it 'returns correct value' do + expect(described_class.listen_address).to eq(listen_address) + end + + context 'when prometheus setting is not present in gitlab.yml' do + before do + allow(Gitlab.config).to receive(:prometheus).and_raise(Settingslogic::MissingSetting) + end + + it 'does not fail' do + expect(described_class.listen_address).to eq(nil) + end + end + end +end diff --git a/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb b/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb index 7f6283715f22742ab9e6b63c7885b842c6427e91..6361893c53c7ebc220b3851935e42454878d2b56 100644 --- a/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb +++ b/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb @@ -13,14 +13,19 @@ describe Gitlab::Prometheus::Queries::KnativeInvocationQuery do context 'verify queries' do before do - allow(PrometheusMetric).to receive(:find_by_identifier).and_return(create(:prometheus_metric, query: prometheus_istio_query('test-name', 'test-ns'))) - allow(client).to receive(:query_range) + create(:prometheus_metric, + :common, + identifier: :system_metrics_knative_function_invocation_count, + query: 'sum(ceil(rate(istio_requests_total{destination_service_namespace="%{kube_namespace}", destination_app=~"%{function_name}.*"}[1m])*60))') end it 'has the query, but no data' do - results = subject.query(serverless_func.id) + expect(client).to receive(:query_range).with( + 'sum(ceil(rate(istio_requests_total{destination_service_namespace="test-ns", destination_app=~"test-name.*"}[1m])*60))', + hash_including(:start, :stop) + ) - expect(results.queries[0][:query_range]).to eql('floor(sum(rate(istio_revision_request_count{destination_configuration="test-name", destination_namespace="test-ns"}[1m])*30))') + subject.query(serverless_func.id) end end end diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb index b557baed258a9f50b867da88f81912f79904a733..1397add9f5a630d69ebe0cc33b9249c897de8f9b 100644 --- a/spec/lib/gitlab/regex_spec.rb +++ b/spec/lib/gitlab/regex_spec.rb @@ -66,6 +66,15 @@ describe Gitlab::Regex do end describe '.aws_account_id_regex' do + subject { described_class.aws_account_id_regex } + + it { is_expected.to match('123456789012') } + it { is_expected.not_to match('12345678901') } + it { is_expected.not_to match('1234567890123') } + it { is_expected.not_to match('12345678901a') } + end + + describe '.aws_arn_regex' do subject { described_class.aws_arn_regex } it { is_expected.to match('arn:aws:iam::123456789012:role/role-name') } @@ -75,4 +84,14 @@ describe Gitlab::Regex do it { is_expected.not_to match('123456789012') } it { is_expected.not_to match('role/role-name') } end + + describe '.utc_date_regex' do + subject { described_class.utc_date_regex } + + it { is_expected.to match('2019-10-20') } + it { is_expected.to match('1990-01-01') } + it { is_expected.not_to match('11-1234-90') } + it { is_expected.not_to match('aa-1234-cc') } + it { is_expected.not_to match('9/9/2018') } + end end diff --git a/spec/lib/gitlab/search/found_blob_spec.rb b/spec/lib/gitlab/search/found_blob_spec.rb index a575f6e2f11a5150b93fe18c492cfb1d2d911d5f..07842faa6389e495878d38d346cc930d95a78a32 100644 --- a/spec/lib/gitlab/search/found_blob_spec.rb +++ b/spec/lib/gitlab/search/found_blob_spec.rb @@ -15,7 +15,6 @@ describe Gitlab::Search::FoundBlob do is_expected.to be_an described_class expect(subject.id).to be_nil expect(subject.path).to eq('CHANGELOG') - expect(subject.filename).to eq('CHANGELOG') expect(subject.basename).to eq('CHANGELOG') expect(subject.ref).to eq('master') expect(subject.startline).to eq(188) @@ -25,12 +24,12 @@ describe Gitlab::Search::FoundBlob do it 'does not parse content if not needed' do expect(subject).not_to receive(:parse_search_result) expect(subject.project_id).to eq(project.id) - expect(subject.binary_filename).to eq('CHANGELOG') + expect(subject.binary_path).to eq('CHANGELOG') end it 'parses content only once when needed' do expect(subject).to receive(:parse_search_result).once.and_call_original - expect(subject.filename).to eq('CHANGELOG') + expect(subject.path).to eq('CHANGELOG') expect(subject.startline).to eq(188) end @@ -38,7 +37,7 @@ describe Gitlab::Search::FoundBlob do let(:search_result) { "master:testdata/project::function1.yaml\x001\x00---\n" } it 'returns a valid FoundBlob' do - expect(subject.filename).to eq('testdata/project::function1.yaml') + expect(subject.path).to eq('testdata/project::function1.yaml') expect(subject.basename).to eq('testdata/project::function1') expect(subject.ref).to eq('master') expect(subject.startline).to eq(1) @@ -50,7 +49,7 @@ describe Gitlab::Search::FoundBlob do let(:search_result) { "master:testdata/foo.txt\x001\x00blah:9:blah" } it 'returns a valid FoundBlob' do - expect(subject.filename).to eq('testdata/foo.txt') + expect(subject.path).to eq('testdata/foo.txt') expect(subject.basename).to eq('testdata/foo') expect(subject.ref).to eq('master') expect(subject.startline).to eq(1) @@ -62,7 +61,7 @@ describe Gitlab::Search::FoundBlob do let(:search_result) { "master:testdata/foo.txt\x001\x00blah\x001\x00foo" } it 'returns a valid FoundBlob' do - expect(subject.filename).to eq('testdata/foo.txt') + expect(subject.path).to eq('testdata/foo.txt') expect(subject.basename).to eq('testdata/foo') expect(subject.ref).to eq('master') expect(subject.startline).to eq(1) @@ -74,7 +73,7 @@ describe Gitlab::Search::FoundBlob do let(:results) { project.repository.search_files_by_content('Role models', 'master') } it 'returns a valid FoundBlob that ends with an empty line' do - expect(subject.filename).to eq('files/markdown/ruby-style-guide.md') + expect(subject.path).to eq('files/markdown/ruby-style-guide.md') expect(subject.basename).to eq('files/markdown/ruby-style-guide') expect(subject.ref).to eq('master') expect(subject.startline).to eq(1) @@ -87,7 +86,7 @@ describe Gitlab::Search::FoundBlob do let(:results) { project.repository.search_files_by_content('файл', 'master') } it 'returns results as UTF-8' do - expect(subject.filename).to eq('encoding/russian.rb') + expect(subject.path).to eq('encoding/russian.rb') expect(subject.basename).to eq('encoding/russian') expect(subject.ref).to eq('master') expect(subject.startline).to eq(1) @@ -99,7 +98,7 @@ describe Gitlab::Search::FoundBlob do let(:results) { project.repository.search_files_by_content('webhook', 'master') } it 'returns results as UTF-8' do - expect(subject.filename).to eq('encoding/テスト.txt') + expect(subject.path).to eq('encoding/テスト.txt') expect(subject.basename).to eq('encoding/テスト') expect(subject.ref).to eq('master') expect(subject.startline).to eq(3) @@ -111,7 +110,7 @@ describe Gitlab::Search::FoundBlob do let(:search_result) { (+"master:encoding/iso8859.txt\x001\x00\xC4\xFC\nmaster:encoding/iso8859.txt\x002\x00\nmaster:encoding/iso8859.txt\x003\x00foo\n").force_encoding(Encoding::ASCII_8BIT) } it 'returns results as UTF-8' do - expect(subject.filename).to eq('encoding/iso8859.txt') + expect(subject.path).to eq('encoding/iso8859.txt') expect(subject.basename).to eq('encoding/iso8859') expect(subject.ref).to eq('master') expect(subject.startline).to eq(1) @@ -124,7 +123,6 @@ describe Gitlab::Search::FoundBlob do let(:search_result) { "master:CONTRIBUTE.md\x005\x00- [Contribute to GitLab](#contribute-to-gitlab)\n" } it { expect(subject.path).to eq('CONTRIBUTE.md') } - it { expect(subject.filename).to eq('CONTRIBUTE.md') } it { expect(subject.basename).to eq('CONTRIBUTE') } end @@ -132,7 +130,6 @@ describe Gitlab::Search::FoundBlob do let(:search_result) { "master:a/b/c.md\x005\x00a b c\n" } it { expect(subject.path).to eq('a/b/c.md') } - it { expect(subject.filename).to eq('a/b/c.md') } it { expect(subject.basename).to eq('a/b/c') } end end @@ -141,7 +138,7 @@ describe Gitlab::Search::FoundBlob do context 'when file is under directory' do let(:path) { 'a/b/c.md' } - subject { described_class.new(blob_filename: path, project: project, ref: 'master') } + subject { described_class.new(blob_path: path, project: project, ref: 'master') } before do allow(Gitlab::Git::Blob).to receive(:batch).and_return([ @@ -150,7 +147,6 @@ describe Gitlab::Search::FoundBlob do end it { expect(subject.path).to eq('a/b/c.md') } - it { expect(subject.filename).to eq('a/b/c.md') } it { expect(subject.basename).to eq('a/b/c') } context 'when filename has multiple extensions' do diff --git a/spec/lib/gitlab/shell_spec.rb b/spec/lib/gitlab/shell_spec.rb index a17e9a312128ddcb739c36b62b6247cb770b1b35..eefc548a4d9ea8a1262e7138f459da24a62d27f1 100644 --- a/spec/lib/gitlab/shell_spec.rb +++ b/spec/lib/gitlab/shell_spec.rb @@ -310,18 +310,18 @@ describe Gitlab::Shell do let(:disk_path) { "#{project.disk_path}.git" } it 'returns true when the command succeeds' do - expect(gitlab_shell.exists?(project.repository_storage, disk_path)).to be(true) + expect(TestEnv.storage_dir_exists?(project.repository_storage, disk_path)).to be(true) expect(gitlab_shell.remove_repository(project.repository_storage, project.disk_path)).to be(true) - expect(gitlab_shell.exists?(project.repository_storage, disk_path)).to be(false) + expect(TestEnv.storage_dir_exists?(project.repository_storage, disk_path)).to be(false) end it 'keeps the namespace directory' do gitlab_shell.remove_repository(project.repository_storage, project.disk_path) - expect(gitlab_shell.exists?(project.repository_storage, disk_path)).to be(false) - expect(gitlab_shell.exists?(project.repository_storage, project.disk_path.gsub(project.name, ''))).to be(true) + expect(TestEnv.storage_dir_exists?(project.repository_storage, disk_path)).to be(false) + expect(TestEnv.storage_dir_exists?(project.repository_storage, project.disk_path.gsub(project.name, ''))).to be(true) end end @@ -332,18 +332,18 @@ describe Gitlab::Shell do old_path = project2.disk_path new_path = "project/new_path" - expect(gitlab_shell.exists?(project2.repository_storage, "#{old_path}.git")).to be(true) - expect(gitlab_shell.exists?(project2.repository_storage, "#{new_path}.git")).to be(false) + expect(TestEnv.storage_dir_exists?(project2.repository_storage, "#{old_path}.git")).to be(true) + expect(TestEnv.storage_dir_exists?(project2.repository_storage, "#{new_path}.git")).to be(false) expect(gitlab_shell.mv_repository(project2.repository_storage, old_path, new_path)).to be_truthy - expect(gitlab_shell.exists?(project2.repository_storage, "#{old_path}.git")).to be(false) - expect(gitlab_shell.exists?(project2.repository_storage, "#{new_path}.git")).to be(true) + expect(TestEnv.storage_dir_exists?(project2.repository_storage, "#{old_path}.git")).to be(false) + expect(TestEnv.storage_dir_exists?(project2.repository_storage, "#{new_path}.git")).to be(true) end it 'returns false when the command fails' do expect(gitlab_shell.mv_repository(project2.repository_storage, project2.disk_path, '')).to be_falsy - expect(gitlab_shell.exists?(project2.repository_storage, "#{project2.disk_path}.git")).to be(true) + expect(TestEnv.storage_dir_exists?(project2.repository_storage, "#{project2.disk_path}.git")).to be(true) end end @@ -401,68 +401,48 @@ describe Gitlab::Shell do describe '#add_namespace' do it 'creates a namespace' do - subject.add_namespace(storage, "mepmep") + Gitlab::GitalyClient::NamespaceService.allow { subject.add_namespace(storage, "mepmep") } - expect(subject.exists?(storage, "mepmep")).to be(true) + expect(TestEnv.storage_dir_exists?(storage, "mepmep")).to be(true) end end - describe '#exists?' do - context 'when the namespace does not exist' do + describe '#repository_exists?' do + context 'when the repository does not exist' do it 'returns false' do - expect(subject.exists?(storage, "non-existing")).to be(false) + expect(subject.repository_exists?(storage, "non-existing.git")).to be(false) end end - context 'when the namespace exists' do + context 'when the repository exists' do it 'returns true' do - subject.add_namespace(storage, "mepmep") + project = create(:project, :repository, :legacy_storage) - expect(subject.exists?(storage, "mepmep")).to be(true) + expect(subject.repository_exists?(storage, project.repository.disk_path + ".git")).to be(true) end end end - describe '#repository_exists?' do - context 'when the storage path does not exist' do - subject { described_class.new.repository_exists?(storage, "non-existing.git") } - - it { is_expected.to be_falsey } - end - - context 'when the repository does not exist' do - let(:project) { create(:project, :repository, :legacy_storage) } - - subject { described_class.new.repository_exists?(storage, "#{project.repository.disk_path}-some-other-repo.git") } - - it { is_expected.to be_falsey } - end - - context 'when the repository exists' do - let(:project) { create(:project, :repository, :legacy_storage) } - - subject { described_class.new.repository_exists?(storage, "#{project.repository.disk_path}.git") } - - it { is_expected.to be_truthy } - end - end - describe '#remove' do it 'removes the namespace' do - subject.add_namespace(storage, "mepmep") - subject.rm_namespace(storage, "mepmep") + Gitlab::GitalyClient::NamespaceService.allow do + subject.add_namespace(storage, "mepmep") + subject.rm_namespace(storage, "mepmep") + end - expect(subject.exists?(storage, "mepmep")).to be(false) + expect(TestEnv.storage_dir_exists?(storage, "mepmep")).to be(false) end end describe '#mv_namespace' do it 'renames the namespace' do - subject.add_namespace(storage, "mepmep") - subject.mv_namespace(storage, "mepmep", "2mep") + Gitlab::GitalyClient::NamespaceService.allow do + subject.add_namespace(storage, "mepmep") + subject.mv_namespace(storage, "mepmep", "2mep") + end - expect(subject.exists?(storage, "mepmep")).to be(false) - expect(subject.exists?(storage, "2mep")).to be(true) + expect(TestEnv.storage_dir_exists?(storage, "mepmep")).to be(false) + expect(TestEnv.storage_dir_exists?(storage, "2mep")).to be(true) end end end diff --git a/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb b/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb index 46fbc069efbe2a3eee321802af650d20993c8c3e..cb870cc996b11dc7719d56bc5585090607df93ef 100644 --- a/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb +++ b/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'spec_helper' +require 'fast_spec_helper' describe Gitlab::SidekiqLogging::StructuredLogger do describe '#call' do diff --git a/spec/lib/gitlab/sidekiq_middleware/correlation_logger_spec.rb b/spec/lib/gitlab/sidekiq_middleware/correlation_logger_spec.rb index 8410467ef1f315fd7b56ce07489dc1b276566a7d..27eea96340238c2591b978453836a23fadf41fb4 100644 --- a/spec/lib/gitlab/sidekiq_middleware/correlation_logger_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/correlation_logger_spec.rb @@ -19,7 +19,7 @@ describe Gitlab::SidekiqMiddleware::CorrelationLogger do end end - it 'injects into payload the correlation id' do + 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 diff --git a/spec/lib/gitlab/sidekiq_middleware/metrics_spec.rb b/spec/lib/gitlab/sidekiq_middleware/metrics_spec.rb index 806112fcb16645b074da1794661f1379bead8b18..0d8cff3a2959bbfc631d5082dd8677ce0d609b82 100644 --- a/spec/lib/gitlab/sidekiq_middleware/metrics_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/metrics_spec.rb @@ -1,69 +1,108 @@ # frozen_string_literal: true -require 'spec_helper' +require 'fast_spec_helper' describe Gitlab::SidekiqMiddleware::Metrics do + let(:middleware) { described_class.new } + let(:concurrency_metric) { double('concurrency metric') } + + let(:queue_duration_seconds) { double('queue duration seconds metric') } + let(:completion_seconds_metric) { double('completion seconds metric') } + let(:user_execution_seconds_metric) { double('user execution seconds metric') } + let(:failed_total_metric) { double('failed total metric') } + let(:retried_total_metric) { double('retried total metric') } + let(:running_jobs_metric) { double('running jobs metric') } + + before do + allow(Gitlab::Metrics).to receive(:histogram).with(:sidekiq_jobs_queue_duration_seconds, anything, anything, anything).and_return(queue_duration_seconds) + allow(Gitlab::Metrics).to receive(:histogram).with(:sidekiq_jobs_completion_seconds, anything, anything, anything).and_return(completion_seconds_metric) + allow(Gitlab::Metrics).to receive(:histogram).with(:sidekiq_jobs_cpu_seconds, anything, anything, anything).and_return(user_execution_seconds_metric) + allow(Gitlab::Metrics).to receive(:counter).with(:sidekiq_jobs_failed_total, anything).and_return(failed_total_metric) + allow(Gitlab::Metrics).to receive(:counter).with(:sidekiq_jobs_retried_total, anything).and_return(retried_total_metric) + allow(Gitlab::Metrics).to receive(:gauge).with(:sidekiq_running_jobs, anything, {}, :all).and_return(running_jobs_metric) + allow(Gitlab::Metrics).to receive(:gauge).with(:sidekiq_concurrency, anything, {}, :all).and_return(concurrency_metric) + + allow(concurrency_metric).to receive(:set) + end + + describe '#initialize' do + it 'sets general metrics' do + expect(concurrency_metric).to receive(:set).with({}, Sidekiq.options[:concurrency].to_i) + + middleware + end + end + + it 'ignore user execution when measured 0' do + allow(completion_seconds_metric).to receive(:observe) + + expect(user_execution_seconds_metric).not_to receive(:observe) + end + describe '#call' do - let(:middleware) { described_class.new } let(:worker) { double(:worker) } - let(:completion_seconds_metric) { double('completion seconds metric') } - let(:user_execution_seconds_metric) { double('user execution seconds metric') } - let(:failed_total_metric) { double('failed total metric') } - let(:retried_total_metric) { double('retried total metric') } - let(:running_jobs_metric) { double('running jobs metric') } + let(:job) { {} } + let(:job_status) { :done } + let(:labels) { { queue: :test } } + let(:labels_with_job_status) { { queue: :test, job_status: job_status } } - before do - allow(Gitlab::Metrics).to receive(:histogram).with(:sidekiq_jobs_completion_seconds, anything, anything, anything).and_return(completion_seconds_metric) - allow(Gitlab::Metrics).to receive(:histogram).with(:sidekiq_jobs_cpu_seconds, anything, anything, anything).and_return(user_execution_seconds_metric) - allow(Gitlab::Metrics).to receive(:counter).with(:sidekiq_jobs_failed_total, anything).and_return(failed_total_metric) - allow(Gitlab::Metrics).to receive(:counter).with(:sidekiq_jobs_retried_total, anything).and_return(retried_total_metric) - allow(Gitlab::Metrics).to receive(:gauge).with(:sidekiq_running_jobs, anything, {}, :livesum).and_return(running_jobs_metric) + let(:thread_cputime_before) { 1 } + let(:thread_cputime_after) { 2 } + let(:thread_cputime_duration) { thread_cputime_after - thread_cputime_before } - allow(running_jobs_metric).to receive(:increment) - end + let(:monotonic_time_before) { 11 } + let(:monotonic_time_after) { 20 } + let(:monotonic_time_duration) { monotonic_time_after - monotonic_time_before } - it 'yields block' do - allow(completion_seconds_metric).to receive(:observe) - allow(user_execution_seconds_metric).to receive(:observe) + let(:queue_duration_for_job) { 0.01 } - expect { |b| middleware.call(worker, {}, :test, &b) }.to yield_control.once - end - - it 'sets metrics' do - labels = { queue: :test } - allow(middleware).to receive(:get_thread_cputime).and_return(1, 3) + before do + allow(middleware).to receive(:get_thread_cputime).and_return(thread_cputime_before, thread_cputime_after) + allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(monotonic_time_before, monotonic_time_after) + allow(Gitlab::InstrumentationHelper).to receive(:queue_duration_for_job).with(job).and_return(queue_duration_for_job) - expect(user_execution_seconds_metric).to receive(:observe).with(labels, 2) expect(running_jobs_metric).to receive(:increment).with(labels, 1) expect(running_jobs_metric).to receive(:increment).with(labels, -1) - expect(completion_seconds_metric).to receive(:observe).with(labels, kind_of(Numeric)) - middleware.call(worker, {}, :test) { nil } + expect(queue_duration_seconds).to receive(:observe).with(labels, queue_duration_for_job) if queue_duration_for_job + expect(user_execution_seconds_metric).to receive(:observe).with(labels_with_job_status, thread_cputime_duration) + expect(completion_seconds_metric).to receive(:observe).with(labels_with_job_status, monotonic_time_duration) + end + + it 'yields block' do + expect { |b| middleware.call(worker, job, :test, &b) }.to yield_control.once + end + + it 'sets queue specific metrics' do + middleware.call(worker, job, :test) { nil } end - it 'ignore user execution when measured 0' do - allow(completion_seconds_metric).to receive(:observe) - allow(middleware).to receive(:get_thread_cputime).and_return(0, 0) + context 'when job_duration is not available' do + let(:queue_duration_for_job) { nil } - expect(user_execution_seconds_metric).not_to receive(:observe) + it 'does not set the queue_duration_seconds histogram' do + middleware.call(worker, job, :test) { nil } + end end context 'when job is retried' do - it 'sets sidekiq_jobs_retried_total metric' do - allow(completion_seconds_metric).to receive(:observe) - expect(user_execution_seconds_metric).to receive(:observe) + let(:job) { { 'retry_count' => 1 } } + it 'sets sidekiq_jobs_retried_total metric' do expect(retried_total_metric).to receive(:increment) - middleware.call(worker, { 'retry_count' => 1 }, :test) { nil } + middleware.call(worker, job, :test) { nil } end end context 'when error is raised' do + let(:job_status) { :fail } + it 'sets sidekiq_jobs_failed_total and reraises' do - expect(failed_total_metric).to receive(:increment) - expect { middleware.call(worker, {}, :test) { raise } }.to raise_error + expect(failed_total_metric).to receive(:increment).with(labels, 1) + + expect { middleware.call(worker, job, :test) { raise StandardError, "Failed" } }.to raise_error(StandardError, "Failed") end end end diff --git a/spec/lib/gitlab/slash_commands/command_spec.rb b/spec/lib/gitlab/slash_commands/command_spec.rb index dc412c80e68c9dc0c233a4a157a99b3fa8c2c62e..5a8c721a63453641e6c89ab4543c4f7080225e4b 100644 --- a/spec/lib/gitlab/slash_commands/command_spec.rb +++ b/spec/lib/gitlab/slash_commands/command_spec.rb @@ -115,5 +115,10 @@ describe Gitlab::SlashCommands::Command do let(:params) { { text: 'issue move #78291 to gitlab/gitlab-ci' } } it { is_expected.to eq(Gitlab::SlashCommands::IssueMove) } end + + context 'IssueComment is triggered' do + let(:params) { { text: "issue comment #503\ncomment body" } } + it { is_expected.to eq(Gitlab::SlashCommands::IssueComment) } + end end end diff --git a/spec/lib/gitlab/slash_commands/issue_comment_spec.rb b/spec/lib/gitlab/slash_commands/issue_comment_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c6f56d10d1fee2f8eb2a5eb73a84fbc849044fbf --- /dev/null +++ b/spec/lib/gitlab/slash_commands/issue_comment_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::SlashCommands::IssueComment do + describe '#execute' do + let(:project) { create(:project, :public) } + let(:issue) { create(:issue, project: project) } + let(:user) { issue.author } + let(:chat_name) { double(:chat_name, user: user) } + let(:regex_match) { described_class.match("issue comment #{issue.iid}\nComment body") } + + subject { described_class.new(project, chat_name).execute(regex_match) } + + context 'when the issue exists' do + context 'when project is private' do + let(:project) { create(:project) } + + context 'when the user is not a member of the project' do + let(:chat_name) { double(:chat_name, user: create(:user)) } + + it 'does not allow the user to comment' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to match('not found') + expect(issue.reload.notes.count).to be_zero + end + end + end + + context 'when the user is not a member of the project' do + let(:chat_name) { double(:chat_name, user: create(:user)) } + + context 'when the discussion is locked in the issue' do + before do + issue.update!(discussion_locked: true) + end + + it 'does not allow the user to comment' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to match('You are not allowed') + expect(issue.reload.notes.count).to be_zero + end + end + end + + context 'when the user can comment on the issue' do + context 'when comment body exists' do + it 'creates a new comment' do + expect { subject }.to change { issue.notes.count }.by(1) + end + + it 'a new comment has a correct body' do + subject + + expect(issue.notes.last.note).to eq('Comment body') + end + end + + context 'when comment body does not exist' do + let(:regex_match) { described_class.match("issue comment #{issue.iid}") } + + it 'does not create a new comment' do + expect { subject }.not_to change { issue.notes.count } + end + + it 'displays the errors' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to match("- Note can't be blank") + end + end + end + end + + context 'when the issue does not exist' do + let(:regex_match) { described_class.match("issue comment 2343242\nComment body") } + + it 'returns not found' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to match('not found') + end + end + end + + describe '.match' do + subject(:match) { described_class.match(command) } + + context 'when a command has an issue ID' do + context 'when command has a comment body' do + let(:command) { "issue comment 503\nComment body" } + + it 'matches an issue ID' do + expect(match[:iid]).to eq('503') + end + + it 'matches an note body' do + expect(match[:note_body]).to eq('Comment body') + end + end + end + + context 'when a command has a reference prefix for issue ID' do + let(:command) { "issue comment #503\nComment body" } + + it 'matches an issue ID' do + expect(match[:iid]).to eq('503') + end + end + + context 'when a command does not have an issue ID' do + let(:command) { 'issue comment' } + + it 'does not match' do + is_expected.to be_nil + end + end + end +end diff --git a/spec/lib/gitlab/slash_commands/presenters/access_spec.rb b/spec/lib/gitlab/slash_commands/presenters/access_spec.rb index c7b834676600457cedd083fbc14fd7db99eb71c1..804184a7173da8244674403385ad81fc7cf48ba0 100644 --- a/spec/lib/gitlab/slash_commands/presenters/access_spec.rb +++ b/spec/lib/gitlab/slash_commands/presenters/access_spec.rb @@ -22,6 +22,16 @@ describe Gitlab::SlashCommands::Presenters::Access do end end + describe '#generic_access_denied' do + subject { described_class.new.generic_access_denied } + + it { is_expected.to be_a(Hash) } + + it_behaves_like 'displays an error message' do + let(:error_message) { 'You are not allowed to perform the given chatops command.' } + end + end + describe '#deactivated' do subject { described_class.new.deactivated } diff --git a/spec/lib/gitlab/slash_commands/presenters/issue_comment_spec.rb b/spec/lib/gitlab/slash_commands/presenters/issue_comment_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b5ef417cb93faf3e053728310ea0b3fcb30e6bbb --- /dev/null +++ b/spec/lib/gitlab/slash_commands/presenters/issue_comment_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::SlashCommands::Presenters::IssueComment do + let_it_be(:project) { create(:project) } + let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:note) { create(:note, project: project, noteable: issue) } + let(:author) { note.author } + + describe '#present' do + let(:attachment) { subject[:attachments].first } + subject { described_class.new(note).present } + + it { is_expected.to be_a(Hash) } + + it 'sets ephemeral response type' do + expect(subject[:response_type]).to be(:ephemeral) + end + + it 'sets the title' do + expect(attachment[:title]).to eq("#{issue.title} · #{issue.to_reference}") + end + + it 'sets the fallback text' do + expect(attachment[:fallback]).to eq("New comment on #{issue.to_reference}: #{issue.title}") + end + + it 'sets the fields' do + expect(attachment[:fields]).to eq([{ title: 'Comment', value: note.note }]) + end + + it 'sets the color' do + expect(attachment[:color]).to eq('#38ae67') + end + end +end diff --git a/spec/lib/gitlab/sourcegraph_spec.rb b/spec/lib/gitlab/sourcegraph_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e081ae3217512fb5ea0b9f950e2cb515d4eeaf29 --- /dev/null +++ b/spec/lib/gitlab/sourcegraph_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Sourcegraph do + let_it_be(:user) { create(:user) } + let(:feature_scope) { true } + + before do + Feature.enable(:sourcegraph, feature_scope) + end + + describe '.feature_conditional?' do + subject { described_class.feature_conditional? } + + context 'when feature is enabled globally' do + it { is_expected.to be_falsey } + end + + context 'when feature is enabled only to a resource' do + let(:feature_scope) { user } + + it { is_expected.to be_truthy } + end + end + + describe '.feature_available?' do + subject { described_class.feature_available? } + + context 'when feature is enabled globally' do + it { is_expected.to be_truthy } + end + + context 'when feature is enabled only to a resource' do + let(:feature_scope) { user } + + it { is_expected.to be_truthy } + end + end + + describe '.feature_enabled?' do + let(:current_user) { nil } + + subject { described_class.feature_enabled?(current_user) } + + context 'when feature is enabled globally' do + it { is_expected.to be_truthy } + end + + context 'when feature is enabled only to a resource' do + let(:feature_scope) { user } + + context 'for the same resource' do + let(:current_user) { user } + + it { is_expected.to be_truthy } + end + + context 'for a different resource' do + let(:current_user) { create(:user) } + + it { is_expected.to be_falsey } + end + end + end +end diff --git a/spec/lib/gitlab/sql/recursive_cte_spec.rb b/spec/lib/gitlab/sql/recursive_cte_spec.rb index 20e36c224b056b434bc846c810fb8b6672db38bf..b15be56dd6d6a8d4e850a1e30ceaaa6b0cd214cd 100644 --- a/spec/lib/gitlab/sql/recursive_cte_spec.rb +++ b/spec/lib/gitlab/sql/recursive_cte_spec.rb @@ -20,7 +20,7 @@ describe Gitlab::SQL::RecursiveCTE do [rel1.except(:order).to_sql, rel2.except(:order).to_sql] end - expect(sql).to eq("#{name} AS (#{sql1}\nUNION\n#{sql2})") + expect(sql).to eq("#{name} AS ((#{sql1})\nUNION\n(#{sql2}))") end end diff --git a/spec/lib/gitlab/sql/union_spec.rb b/spec/lib/gitlab/sql/union_spec.rb index f8f6da19fa54d1cec47eb5da88485d8662d5e7b9..f736614ae53c19cba555be48ed5441692ae3c19f 100644 --- a/spec/lib/gitlab/sql/union_spec.rb +++ b/spec/lib/gitlab/sql/union_spec.rb @@ -14,7 +14,7 @@ describe Gitlab::SQL::Union do it 'returns a String joining relations together using a UNION' do union = described_class.new([relation_1, relation_2]) - expect(union.to_sql).to eq("#{to_sql(relation_1)}\nUNION\n#{to_sql(relation_2)}") + expect(union.to_sql).to eq("(#{to_sql(relation_1)})\nUNION\n(#{to_sql(relation_2)})") end it 'skips Model.none segements' do @@ -22,7 +22,7 @@ describe Gitlab::SQL::Union do union = described_class.new([empty_relation, relation_1, relation_2]) expect {User.where("users.id IN (#{union.to_sql})").to_a}.not_to raise_error - expect(union.to_sql).to eq("#{to_sql(relation_1)}\nUNION\n#{to_sql(relation_2)}") + expect(union.to_sql).to eq("(#{to_sql(relation_1)})\nUNION\n(#{to_sql(relation_2)})") end it 'uses UNION ALL when removing duplicates is disabled' do diff --git a/spec/lib/gitlab/tracking_spec.rb b/spec/lib/gitlab/tracking_spec.rb index 50488dba48c82d6d4c12522efacbfa84736510a2..dc877f20caedb22593ad8770fb136ef29ae440df 100644 --- a/spec/lib/gitlab/tracking_spec.rb +++ b/spec/lib/gitlab/tracking_spec.rb @@ -8,19 +8,23 @@ describe Gitlab::Tracking do stub_application_setting(snowplow_enabled: true) stub_application_setting(snowplow_collector_hostname: 'gitfoo.com') stub_application_setting(snowplow_cookie_domain: '.gitfoo.com') - stub_application_setting(snowplow_site_id: '_abc123_') + stub_application_setting(snowplow_app_id: '_abc123_') + stub_application_setting(snowplow_iglu_registry_url: 'https://example.org') end describe '.snowplow_options' do it 'returns useful client options' do - expect(described_class.snowplow_options(nil)).to eq( + expected_fields = { namespace: 'gl', hostname: 'gitfoo.com', cookieDomain: '.gitfoo.com', appId: '_abc123_', formTracking: true, - linkClickTracking: true - ) + linkClickTracking: true, + igluRegistryUrl: 'https://example.org' + } + + expect(subject.snowplow_options(nil)).to match(expected_fields) end it 'enables features using feature flags' do @@ -29,11 +33,12 @@ describe Gitlab::Tracking do :additional_snowplow_tracking, '_group_' ).and_return(false) - - expect(described_class.snowplow_options('_group_')).to include( + addition_feature_fields = { formTracking: false, linkClickTracking: false - ) + } + + expect(subject.snowplow_options('_group_')).to include(addition_feature_fields) end end diff --git a/spec/lib/gitlab/usage_data_counters/web_ide_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/web_ide_counter_spec.rb index 7a01f7d1de8dec8beaa4b706e0fdf122d14e61fb..96ebeb8ff7666902267f2baea17d8382a3fd22a2 100644 --- a/spec/lib/gitlab/usage_data_counters/web_ide_counter_spec.rb +++ b/spec/lib/gitlab/usage_data_counters/web_ide_counter_spec.rb @@ -34,22 +34,54 @@ describe Gitlab::UsageDataCounters::WebIdeCounter, :clean_gitlab_redis_shared_st it_behaves_like 'counter examples' end + describe 'previews counter' do + let(:setting_enabled) { true } + + before do + stub_application_setting(web_ide_clientside_preview_enabled: setting_enabled) + end + + context 'when web ide clientside preview is enabled' do + let(:increment_counter_method) { :increment_previews_count } + let(:total_counter_method) { :total_previews_count } + + it_behaves_like 'counter examples' + end + + context 'when web ide clientside preview is not enabled' do + let(:setting_enabled) { false } + + it 'does not increment the counter' do + expect(described_class.total_previews_count).to eq(0) + + 2.times { described_class.increment_previews_count } + + expect(described_class.total_previews_count).to eq(0) + end + end + end + describe '.totals' do commits = 5 merge_requests = 3 views = 2 + previews = 4 before do + stub_application_setting(web_ide_clientside_preview_enabled: true) + commits.times { described_class.increment_commits_count } merge_requests.times { described_class.increment_merge_requests_count } views.times { described_class.increment_views_count } + previews.times { described_class.increment_previews_count } end it 'can report all totals' do expect(described_class.totals).to include( web_ide_commits: commits, web_ide_views: views, - web_ide_merge_requests: merge_requests + web_ide_merge_requests: merge_requests, + web_ide_previews: previews ) end end diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index f2e864472c5d66b32e7ca31ffbde135d0b44cbbb..484684eeb65cbe62e86298ea5798d4a0028276e7 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -17,21 +17,41 @@ 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: 'CustomIssueTrackerService', active: true) create(:project_error_tracking_setting, project: projects[0]) create(:project_error_tracking_setting, project: projects[1], enabled: false) - - gcp_cluster = create(:cluster, :provided_by_gcp) - create(:cluster, :provided_by_user) - create(:cluster, :provided_by_user, :disabled) + create_list(:issue, 4, project: projects[0]) + create(:zoom_meeting, project: projects[0], issue: projects[0].issues[0], issue_status: :added) + create_list(:zoom_meeting, 2, project: projects[0], issue: projects[0].issues[1], issue_status: :removed) + create(:zoom_meeting, project: projects[0], issue: projects[0].issues[2], issue_status: :added) + create_list(:zoom_meeting, 2, project: projects[0], issue: projects[0].issues[2], issue_status: :removed) + + # Enabled clusters + gcp_cluster = create(:cluster_provider_gcp, :created).cluster + create(:cluster_provider_aws, :created) + create(:cluster_platform_kubernetes) create(:cluster, :group) + + # Disabled clusters + create(:cluster, :disabled) create(:cluster, :group, :disabled) create(:cluster, :group, :disabled) + + # Applications create(:clusters_applications_helm, :installed, cluster: gcp_cluster) create(:clusters_applications_ingress, :installed, cluster: gcp_cluster) create(:clusters_applications_cert_manager, :installed, cluster: gcp_cluster) create(:clusters_applications_prometheus, :installed, cluster: gcp_cluster) + create(:clusters_applications_crossplane, :installed, cluster: gcp_cluster) create(:clusters_applications_runner, :installed, cluster: gcp_cluster) create(:clusters_applications_knative, :installed, cluster: gcp_cluster) + create(:clusters_applications_elastic_stack, :installed, cluster: gcp_cluster) + + create(:grafana_integration, project: projects[0], enabled: true) + create(:grafana_integration, project: projects[1], enabled: true) + create(:grafana_integration, project: projects[2], enabled: false) ProjectFeature.first.update_attribute('repository_access_level', 0) end @@ -64,6 +84,8 @@ describe Gitlab::UsageData do avg_cycle_analytics influxdb_metrics_enabled prometheus_metrics_enabled + web_ide_clientside_preview_enabled + ingress_modsecurity_enabled )) end @@ -81,6 +103,7 @@ describe Gitlab::UsageData do web_ide_views web_ide_commits web_ide_merge_requests + web_ide_previews navbar_searches cycle_analytics_views productivity_analytics_views @@ -112,17 +135,23 @@ describe Gitlab::UsageData do clusters_disabled project_clusters_disabled group_clusters_disabled + clusters_platforms_eks clusters_platforms_gke clusters_platforms_user clusters_applications_helm clusters_applications_ingress clusters_applications_cert_managers clusters_applications_prometheus + clusters_applications_crossplane clusters_applications_runner clusters_applications_knative + clusters_applications_elastic_stack in_review_folder + grafana_integrated_projects groups issues + issues_with_associated_zoom_link + issues_using_zoom_quick_actions keys label_lists labels @@ -139,6 +168,9 @@ describe Gitlab::UsageData do projects_jira_cloud_active projects_slack_notifications_active projects_slack_slash_active + projects_custom_issue_tracker_active + projects_jenkins_active + projects_mattermost_active projects_prometheus_active projects_with_repositories_enabled projects_with_error_tracking_enabled @@ -172,24 +204,33 @@ describe Gitlab::UsageData do 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_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_with_repositories_enabled]).to eq(3) expect(count_data[:projects_with_error_tracking_enabled]).to eq(1) + expect(count_data[:issues_with_associated_zoom_link]).to eq(2) + expect(count_data[:issues_using_zoom_quick_actions]).to eq(3) - expect(count_data[:clusters_enabled]).to eq(7) - expect(count_data[:project_clusters_enabled]).to eq(6) + expect(count_data[:clusters_enabled]).to eq(4) + expect(count_data[:project_clusters_enabled]).to eq(3) expect(count_data[:group_clusters_enabled]).to eq(1) expect(count_data[:clusters_disabled]).to eq(3) expect(count_data[:project_clusters_disabled]).to eq(1) expect(count_data[:group_clusters_disabled]).to eq(2) expect(count_data[:group_clusters_enabled]).to eq(1) + expect(count_data[:clusters_platforms_eks]).to eq(1) expect(count_data[:clusters_platforms_gke]).to eq(1) expect(count_data[:clusters_platforms_user]).to eq(1) expect(count_data[:clusters_applications_helm]).to eq(1) expect(count_data[:clusters_applications_ingress]).to eq(1) expect(count_data[:clusters_applications_cert_managers]).to eq(1) + expect(count_data[:clusters_applications_crossplane]).to eq(1) expect(count_data[:clusters_applications_prometheus]).to eq(1) expect(count_data[:clusters_applications_runner]).to eq(1) expect(count_data[:clusters_applications_knative]).to eq(1) + expect(count_data[:clusters_applications_elastic_stack]).to eq(1) + expect(count_data[:grafana_integrated_projects]).to eq(2) end it 'works when queries time out' do @@ -232,6 +273,7 @@ describe Gitlab::UsageData do expect(subject[:container_registry_enabled]).to eq(Gitlab.config.registry.enabled) expect(subject[:dependency_proxy_enabled]).to eq(Gitlab.config.dependency_proxy.enabled) expect(subject[:gitlab_shared_runners_enabled]).to eq(Gitlab.config.gitlab_ci.shared_runners_enabled) + expect(subject[:web_ide_clientside_preview_enabled]).to eq(Gitlab::CurrentSettings.web_ide_clientside_preview_enabled?) end end diff --git a/spec/lib/gitlab/user_access_spec.rb b/spec/lib/gitlab/user_access_spec.rb index c25bd14fcba10b40a690739d59bddba30b1f6fd8..4e7c43a6856140bb86e1bfb66813f5fb322265f8 100644 --- a/spec/lib/gitlab/user_access_spec.rb +++ b/spec/lib/gitlab/user_access_spec.rb @@ -148,7 +148,7 @@ describe Gitlab::UserAccess do ) end - it 'allows users that have push access to the canonical project to push to the MR branch' do + it 'allows users that have push access to the canonical project to push to the MR branch', :sidekiq_might_not_need_inline do canonical_project.add_developer(user) expect(access.can_push_to_branch?('awesome-feature')).to be_truthy diff --git a/spec/lib/gitlab/utils/deep_size_spec.rb b/spec/lib/gitlab/utils/deep_size_spec.rb index 47dfc04f46f3dfe1c9cbcb49ddeec8464228e53c..ccd202b33f7ad103ad38f4363ac79b651d8c29e9 100644 --- a/spec/lib/gitlab/utils/deep_size_spec.rb +++ b/spec/lib/gitlab/utils/deep_size_spec.rb @@ -42,4 +42,10 @@ describe Gitlab::Utils::DeepSize do end end end + + describe '.human_default_max_size' do + it 'returns 1 MB' do + expect(described_class.human_default_max_size).to eq('1 MB') + end + end end diff --git a/spec/lib/gitlab/visibility_level_checker_spec.rb b/spec/lib/gitlab/visibility_level_checker_spec.rb index 325ac3c6f31ac21680f82862a66ac820f4f12f2d..fc929d5cbbf16f18c6756cf71ac11112456e9ec1 100644 --- a/spec/lib/gitlab/visibility_level_checker_spec.rb +++ b/spec/lib/gitlab/visibility_level_checker_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::VisibilityLevelChecker do diff --git a/spec/lib/gitlab/wiki_file_finder_spec.rb b/spec/lib/gitlab/wiki_file_finder_spec.rb index fdd95d5e6e6d62bfee521259824d9b47d5cdfa1a..aeba081f3d373e91801bcf8b1a32f42de54ac923 100644 --- a/spec/lib/gitlab/wiki_file_finder_spec.rb +++ b/spec/lib/gitlab/wiki_file_finder_spec.rb @@ -15,7 +15,7 @@ describe Gitlab::WikiFileFinder do it_behaves_like 'file finder' do subject { described_class.new(project, project.wiki.default_branch) } - let(:expected_file_by_name) { 'Files/Title.md' } + let(:expected_file_by_path) { 'Files/Title.md' } let(:expected_file_by_content) { 'CHANGELOG.md' } end end diff --git a/spec/lib/gitlab_spec.rb b/spec/lib/gitlab_spec.rb index 6bf837f1d3fa9ca91936ab322b55e8ac7a6d07ea..9362ff72fbc0fdbe4dd9ab736dce92a0451900c9 100644 --- a/spec/lib/gitlab_spec.rb +++ b/spec/lib/gitlab_spec.rb @@ -96,6 +96,48 @@ describe Gitlab do end end + describe '.canary?' do + it 'is true when CANARY env var is set to true' do + stub_env('CANARY', '1') + + expect(described_class.canary?).to eq true + end + + it 'is false when CANARY env var is set to false' do + stub_env('CANARY', '0') + + expect(described_class.canary?).to eq false + end + end + + describe '.com_and_canary?' do + it 'is true when on .com and canary' do + allow(described_class).to receive_messages(com?: true, canary?: true) + + expect(described_class.com_and_canary?).to eq true + end + + it 'is false when on .com but not on canary' do + allow(described_class).to receive_messages(com?: true, canary?: false) + + expect(described_class.com_and_canary?).to eq false + end + end + + describe '.com_but_not_canary?' do + it 'is false when on .com and canary' do + allow(described_class).to receive_messages(com?: true, canary?: true) + + expect(described_class.com_but_not_canary?).to eq false + end + + it 'is true when on .com but not on canary' do + allow(described_class).to receive_messages(com?: true, canary?: false) + + expect(described_class.com_but_not_canary?).to eq true + end + end + describe '.dev_env_org_or_com?' do it 'is true when on .com' do allow(described_class).to receive_messages(com?: true, org?: false) diff --git a/spec/lib/google_api/cloud_platform/client_spec.rb b/spec/lib/google_api/cloud_platform/client_spec.rb index 0f7f57095df1019f53e4320d11c219201dc08021..473ad639ead238418d27bf825f41e04185d81f85 100644 --- a/spec/lib/google_api/cloud_platform/client_spec.rb +++ b/spec/lib/google_api/cloud_platform/client_spec.rb @@ -104,7 +104,8 @@ describe GoogleApi::CloudPlatform::Client do enabled: legacy_abac }, ip_allocation_policy: { - use_ip_aliases: true + use_ip_aliases: true, + cluster_ipv4_cidr_block: '/16' }, addons_config: addons_config } diff --git a/spec/lib/grafana/client_spec.rb b/spec/lib/grafana/client_spec.rb index bd93a3c59a26f0e715c7d0e1d10ae8b2538472ba..699344e940ef700f242515c9b42ec4b04232dfd1 100644 --- a/spec/lib/grafana/client_spec.rb +++ b/spec/lib/grafana/client_spec.rb @@ -35,7 +35,7 @@ describe Grafana::Client do it 'does not follow redirects' do expect { subject }.to raise_exception( Grafana::Client::Error, - 'Grafana response status code: 302' + 'Grafana response status code: 302, Message: {}' ) expect(redirect_req_stub).to have_been_requested @@ -67,6 +67,30 @@ describe Grafana::Client do end end + describe '#get_dashboard' do + let(:grafana_api_url) { 'https://grafanatest.com/-/grafana-project/api/dashboards/uid/FndfgnX' } + + subject do + client.get_dashboard(uid: 'FndfgnX') + end + + it_behaves_like 'calls grafana api' + it_behaves_like 'no redirects' + it_behaves_like 'handles exceptions' + end + + describe '#get_datasource' do + let(:grafana_api_url) { 'https://grafanatest.com/-/grafana-project/api/datasources/name/Test%20Name' } + + subject do + client.get_datasource(name: 'Test Name') + end + + it_behaves_like 'calls grafana api' + it_behaves_like 'no redirects' + it_behaves_like 'handles exceptions' + end + describe '#proxy_datasource' do let(:grafana_api_url) do 'https://grafanatest.com/-/grafana-project/' \ diff --git a/spec/lib/omni_auth/strategies/saml_spec.rb b/spec/lib/omni_auth/strategies/saml_spec.rb index 3c59de86d98585ebac3f9c4e83a3ee0d550a12a3..73e8687230824543069af211f245e506853da285 100644 --- a/spec/lib/omni_auth/strategies/saml_spec.rb +++ b/spec/lib/omni_auth/strategies/saml_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe OmniAuth::Strategies::SAML, type: :strategy do diff --git a/spec/lib/prometheus/pid_provider_spec.rb b/spec/lib/prometheus/pid_provider_spec.rb index ba843b272548b19d107e98b77fd25f032a150f9b..6fdc11b14c40be76290ae7e862fa3fbd90e7e361 100644 --- a/spec/lib/prometheus/pid_provider_spec.rb +++ b/spec/lib/prometheus/pid_provider_spec.rb @@ -18,7 +18,17 @@ describe Prometheus::PidProvider do expect(Sidekiq).to receive(:server?).and_return(true) end - it { is_expected.to eq 'sidekiq' } + context 'in a clustered setup' do + before do + stub_env('SIDEKIQ_WORKER_ID', '123') + end + + it { is_expected.to eq 'sidekiq_123' } + end + + context 'in a single process setup' do + it { is_expected.to eq 'sidekiq' } + end end context 'when running in Unicorn mode' do diff --git a/spec/lib/quality/helm_client_spec.rb b/spec/lib/quality/helm_client_spec.rb index 7abb9688d5a71843da09934ddc13a4c5a0568c6c..da5ba4c4d99629bdd32aaf61560974c21f9ef162 100644 --- a/spec/lib/quality/helm_client_spec.rb +++ b/spec/lib/quality/helm_client_spec.rb @@ -107,5 +107,25 @@ RSpec.describe Quality::HelmClient do expect(subject.delete(release_name: release_name)).to eq('') end + + context 'with multiple release names' do + let(:release_name) { ['my-release', 'my-release-2'] } + + it 'raises an error if the Helm command fails' do + expect(Gitlab::Popen).to receive(:popen_with_detail) + .with([%(helm delete --tiller-namespace "#{namespace}" --purge #{release_name.join(' ')})]) + .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: false))) + + expect { subject.delete(release_name: release_name) }.to raise_error(described_class::CommandFailedError) + end + + it 'calls helm delete with multiple release names' do + expect(Gitlab::Popen).to receive(:popen_with_detail) + .with([%(helm delete --tiller-namespace "#{namespace}" --purge #{release_name.join(' ')})]) + .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: true))) + + expect(subject.delete(release_name: release_name)).to eq('') + end + end end end diff --git a/spec/lib/quality/kubernetes_client_spec.rb b/spec/lib/quality/kubernetes_client_spec.rb index 4e77dcc97e66b52f48a085dc0cf7c14565a449b6..5bac102ac41dea8c3eebee83706b8e1ab947038e 100644 --- a/spec/lib/quality/kubernetes_client_spec.rb +++ b/spec/lib/quality/kubernetes_client_spec.rb @@ -13,7 +13,7 @@ RSpec.describe Quality::KubernetesClient do expect(Gitlab::Popen).to receive(:popen_with_detail) .with([%(kubectl --namespace "#{namespace}" delete ) \ 'ingress,svc,pdb,hpa,deploy,statefulset,job,pod,secret,configmap,pvc,secret,clusterrole,clusterrolebinding,role,rolebinding,sa ' \ - "--now --ignore-not-found --include-uninitialized -l release=\"#{release_name}\""]) + "--now --ignore-not-found --include-uninitialized --wait=true -l release=\"#{release_name}\""]) .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: false))) expect { subject.cleanup(release_name: release_name) }.to raise_error(described_class::CommandFailedError) @@ -23,11 +23,59 @@ RSpec.describe Quality::KubernetesClient do expect(Gitlab::Popen).to receive(:popen_with_detail) .with([%(kubectl --namespace "#{namespace}" delete ) \ 'ingress,svc,pdb,hpa,deploy,statefulset,job,pod,secret,configmap,pvc,secret,clusterrole,clusterrolebinding,role,rolebinding,sa ' \ - "--now --ignore-not-found --include-uninitialized -l release=\"#{release_name}\""]) + "--now --ignore-not-found --include-uninitialized --wait=true -l release=\"#{release_name}\""]) .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: true))) # We're not verifying the output here, just silencing it expect { subject.cleanup(release_name: release_name) }.to output.to_stdout end + + context 'with multiple releases' do + let(:release_name) { ['my-release', 'my-release-2'] } + + it 'raises an error if the Kubernetes command fails' do + expect(Gitlab::Popen).to receive(:popen_with_detail) + .with([%(kubectl --namespace "#{namespace}" delete ) \ + 'ingress,svc,pdb,hpa,deploy,statefulset,job,pod,secret,configmap,pvc,secret,clusterrole,clusterrolebinding,role,rolebinding,sa ' \ + "--now --ignore-not-found --include-uninitialized --wait=true -l 'release in (#{release_name.join(', ')})'"]) + .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: false))) + + expect { subject.cleanup(release_name: release_name) }.to raise_error(described_class::CommandFailedError) + end + + it 'calls kubectl with the correct arguments' do + expect(Gitlab::Popen).to receive(:popen_with_detail) + .with([%(kubectl --namespace "#{namespace}" delete ) \ + 'ingress,svc,pdb,hpa,deploy,statefulset,job,pod,secret,configmap,pvc,secret,clusterrole,clusterrolebinding,role,rolebinding,sa ' \ + "--now --ignore-not-found --include-uninitialized --wait=true -l 'release in (#{release_name.join(', ')})'"]) + .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: true))) + + # We're not verifying the output here, just silencing it + expect { subject.cleanup(release_name: release_name) }.to output.to_stdout + end + end + + context 'with `wait: false`' do + it 'raises an error if the Kubernetes command fails' do + expect(Gitlab::Popen).to receive(:popen_with_detail) + .with([%(kubectl --namespace "#{namespace}" delete ) \ + 'ingress,svc,pdb,hpa,deploy,statefulset,job,pod,secret,configmap,pvc,secret,clusterrole,clusterrolebinding,role,rolebinding,sa ' \ + "--now --ignore-not-found --include-uninitialized --wait=false -l release=\"#{release_name}\""]) + .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: false))) + + expect { subject.cleanup(release_name: release_name, wait: false) }.to raise_error(described_class::CommandFailedError) + end + + it 'calls kubectl with the correct arguments' do + expect(Gitlab::Popen).to receive(:popen_with_detail) + .with([%(kubectl --namespace "#{namespace}" delete ) \ + 'ingress,svc,pdb,hpa,deploy,statefulset,job,pod,secret,configmap,pvc,secret,clusterrole,clusterrolebinding,role,rolebinding,sa ' \ + "--now --ignore-not-found --include-uninitialized --wait=false -l release=\"#{release_name}\""]) + .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: true))) + + # We're not verifying the output here, just silencing it + expect { subject.cleanup(release_name: release_name, wait: false) }.to output.to_stdout + end + end end end diff --git a/spec/lib/sentry/client_spec.rb b/spec/lib/sentry/client_spec.rb index ca2b17b44e0fc6bd120da556ef2d3ab50e63d333..8101664d34f604fe7fbdbf427a247ae1993f2174 100644 --- a/spec/lib/sentry/client_spec.rb +++ b/spec/lib/sentry/client_spec.rb @@ -192,6 +192,15 @@ describe Sentry::Client do 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 exceptions' end diff --git a/spec/mailers/abuse_report_mailer_spec.rb b/spec/mailers/abuse_report_mailer_spec.rb index 86153071cd382753e111d8db5a3507b16368f87b..fcbffb52849fec047cb2f34a95454ea392beca50 100644 --- a/spec/mailers/abuse_report_mailer_spec.rb +++ b/spec/mailers/abuse_report_mailer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe AbuseReportMailer do diff --git a/spec/mailers/emails/merge_requests_spec.rb b/spec/mailers/emails/merge_requests_spec.rb index 2ad572bb5c75f728828ce8c439b285131bc12811..541acc47172cf908e1df23d93c8ae7e33766263e 100644 --- a/spec/mailers/emails/merge_requests_spec.rb +++ b/spec/mailers/emails/merge_requests_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'email_spec' diff --git a/spec/mailers/emails/pages_domains_spec.rb b/spec/mailers/emails/pages_domains_spec.rb index c52e3c2191d5b1e6d16609c9a236d828f007a799..e360e38256e5952b4adb3e227db9ac381a788255 100644 --- a/spec/mailers/emails/pages_domains_spec.rb +++ b/spec/mailers/emails/pages_domains_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'email_spec' diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb index 1f7be415e35385957aee7ed2a50409e951b2877e..d340f207dc78b2bad49c063d3bae2252eae83f9a 100644 --- a/spec/mailers/emails/profile_spec.rb +++ b/spec/mailers/emails/profile_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'email_spec' diff --git a/spec/mailers/emails/releases_spec.rb b/spec/mailers/emails/releases_spec.rb index 19f404db2a62f3d441d33e5d140fe69017a2e5be..c614c009434d5c7e0144dff8b5fca29687a1e4b1 100644 --- a/spec/mailers/emails/releases_spec.rb +++ b/spec/mailers/emails/releases_spec.rb @@ -18,6 +18,7 @@ describe Emails::Releases do context 'when the release has a name' do it 'shows the correct subject' do + release.name = 'beta-1' expected_subject = "#{release.project.name} | New release: #{release.name} - #{release.tag}" is_expected.to have_subject(expected_subject) end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 1991bac02297f3b45841ad3b21f896f99547634d..cafb96898b3d43e5a093244da3d759fde62b8fa5 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'email_spec' diff --git a/spec/mailers/repository_check_mailer_spec.rb b/spec/mailers/repository_check_mailer_spec.rb index 757d3dfa797af79befd7d1ebecabefad790a1c95..1fd4d28ca53e7fda4d169cdd9fa4c323a62e799d 100644 --- a/spec/mailers/repository_check_mailer_spec.rb +++ b/spec/mailers/repository_check_mailer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe RepositoryCheckMailer do diff --git a/spec/migrations/active_record/schedule_set_confidential_note_events_on_services_spec.rb b/spec/migrations/active_record/schedule_set_confidential_note_events_on_services_spec.rb index 5c6f213e15bbbcf54975a64cbca99fb4e5acb12a..f4155eab1bfeaf46942fdab9bbe8eafc623f8028 100644 --- a/spec/migrations/active_record/schedule_set_confidential_note_events_on_services_spec.rb +++ b/spec/migrations/active_record/schedule_set_confidential_note_events_on_services_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20180122154930_schedule_set_confidential_note_events_on_services.rb') @@ -30,7 +32,7 @@ describe ScheduleSetConfidentialNoteEventsOnServices, :migration, :sidekiq do end end - it 'correctly processes services' do + it 'correctly processes services', :sidekiq_might_not_need_inline do perform_enqueued_jobs do expect(services_table.where(confidential_note_events: nil).count).to eq 4 expect(services_table.where(confidential_note_events: true).count).to eq 1 diff --git a/spec/migrations/active_record/schema_spec.rb b/spec/migrations/active_record/schema_spec.rb index bc246f886858e8659c099c3b4c969ec8c036c678..617e31f359b163c102647184ccf4aca2dfb51376 100644 --- a/spec/migrations/active_record/schema_spec.rb +++ b/spec/migrations/active_record/schema_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' # Check consistency of db/schema.rb version, migrations' timestamps, and the latest migration timestamp diff --git a/spec/migrations/add_default_and_free_plans_spec.rb b/spec/migrations/add_default_and_free_plans_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ae40b5b10c2c1fa327f35c954c77601d66c14873 --- /dev/null +++ b/spec/migrations/add_default_and_free_plans_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'migrate', '20191023152913_add_default_and_free_plans.rb') + +describe AddDefaultAndFreePlans, :migration do + describe 'migrate' do + let(:plans) { table(:plans) } + + context 'when on Gitlab.com' do + before do + expect(Gitlab).to receive(:com?) { true } + end + + it 'creates free and default plans' do + expect { migrate! }.to change { plans.count }.by 2 + + expect(plans.last(2).pluck(:name)).to eq %w[free default] + end + end + + context 'when on self-hosted' do + before do + expect(Gitlab).to receive(:com?) { false } + end + + it 'creates only a default plan' do + expect { migrate! }.to change { plans.count }.by 1 + + expect(plans.last.name).to eq 'default' + end + end + end +end diff --git a/spec/migrations/add_foreign_keys_to_todos_spec.rb b/spec/migrations/add_foreign_keys_to_todos_spec.rb index 2500e2f8333539cac1f989f540ed711c58e9a06b..9932113a003864e1bfb9f717793fb22da03bfb8b 100644 --- a/spec/migrations/add_foreign_keys_to_todos_spec.rb +++ b/spec/migrations/add_foreign_keys_to_todos_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'migrate', '20180201110056_add_foreign_keys_to_todos.rb') diff --git a/spec/migrations/add_not_null_constraint_to_project_mirror_data_foreign_key_spec.rb b/spec/migrations/add_not_null_constraint_to_project_mirror_data_foreign_key_spec.rb index 6fd3cb1f44e711334bd501067c5f6118d4935824..24ae939afa799825744027d21e864f6691d00de6 100644 --- a/spec/migrations/add_not_null_constraint_to_project_mirror_data_foreign_key_spec.rb +++ b/spec/migrations/add_not_null_constraint_to_project_mirror_data_foreign_key_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'migrate', '20180508100222_add_not_null_constraint_to_project_mirror_data_foreign_key.rb') diff --git a/spec/migrations/add_pages_access_level_to_project_feature_spec.rb b/spec/migrations/add_pages_access_level_to_project_feature_spec.rb index 3946602c5be21492d76b89439768dfc2fa46d6a0..a5e2bf2de711c7235b11d739cbab66bf3f590aa1 100644 --- a/spec/migrations/add_pages_access_level_to_project_feature_spec.rb +++ b/spec/migrations/add_pages_access_level_to_project_feature_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'migrate', '20180423204600_add_pages_access_level_to_project_feature.rb') diff --git a/spec/migrations/add_pipeline_build_foreign_key_spec.rb b/spec/migrations/add_pipeline_build_foreign_key_spec.rb index e9413f52f197bf20baa98e117a7d348e29dde8bb..bb40ead9b93eb6cee720d73c7cb8025dddf7827a 100644 --- a/spec/migrations/add_pipeline_build_foreign_key_spec.rb +++ b/spec/migrations/add_pipeline_build_foreign_key_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'migrate', '20180420010016_add_pipeline_build_foreign_key.rb') diff --git a/spec/migrations/add_unique_constraint_to_project_features_project_id_spec.rb b/spec/migrations/add_unique_constraint_to_project_features_project_id_spec.rb index bf299b70a29686459fca4e7218b24678b6df2afc..8b128ff5ab89723855139cf7933a51f6da6bcae6 100644 --- a/spec/migrations/add_unique_constraint_to_project_features_project_id_spec.rb +++ b/spec/migrations/add_unique_constraint_to_project_features_project_id_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20180511174224_add_unique_constraint_to_project_features_project_id.rb') diff --git a/spec/migrations/assure_commits_count_for_merge_request_diff_spec.rb b/spec/migrations/assure_commits_count_for_merge_request_diff_spec.rb index b8c3a3eda4e7a99d2045b367f28e5453f93d9560..ae53b4e6443353846f341757618b0103312f1d55 100644 --- a/spec/migrations/assure_commits_count_for_merge_request_diff_spec.rb +++ b/spec/migrations/assure_commits_count_for_merge_request_diff_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'migrate', '20180425131009_assure_commits_count_for_merge_request_diff.rb') diff --git a/spec/migrations/backfill_store_project_full_path_in_repo_spec.rb b/spec/migrations/backfill_store_project_full_path_in_repo_spec.rb index 65a918d5440c0fb663705bacd4df084763f79363..913b4d3f1140bb8ac153bbd24fc9626925f6256f 100644 --- a/spec/migrations/backfill_store_project_full_path_in_repo_spec.rb +++ b/spec/migrations/backfill_store_project_full_path_in_repo_spec.rb @@ -20,7 +20,7 @@ describe BackfillStoreProjectFullPathInRepo, :migration do describe '#up' do shared_examples_for 'writes the full path to git config' do - it 'writes the git config' do + it 'writes the git config', :sidekiq_might_not_need_inline do expect_next_instance_of(Gitlab::GitalyClient::RepositoryService) do |repository_service| allow(repository_service).to receive(:cleanup) expect(repository_service).to receive(:set_config).with('gitlab.fullpath' => expected_path) @@ -29,7 +29,7 @@ describe BackfillStoreProjectFullPathInRepo, :migration do migration.up end - it 'retries in case of failure' do + it 'retries in case of failure', :sidekiq_might_not_need_inline do repository_service = spy(:repository_service) allow(Gitlab::GitalyClient::RepositoryService).to receive(:new).and_return(repository_service) @@ -40,7 +40,7 @@ describe BackfillStoreProjectFullPathInRepo, :migration do migration.up end - it 'cleans up repository before writing the config' do + it 'cleans up repository before writing the config', :sidekiq_might_not_need_inline do expect_next_instance_of(Gitlab::GitalyClient::RepositoryService) do |repository_service| expect(repository_service).to receive(:cleanup).ordered expect(repository_service).to receive(:set_config).ordered @@ -87,7 +87,7 @@ describe BackfillStoreProjectFullPathInRepo, :migration do context 'project in group' do let!(:project) { projects.create!(namespace_id: group.id, name: 'baz', path: 'baz') } - it 'deletes the gitlab full config value' do + it 'deletes the gitlab full config value', :sidekiq_might_not_need_inline do expect_any_instance_of(Gitlab::GitalyClient::RepositoryService) .to receive(:delete_config).with(['gitlab.fullpath']) diff --git a/spec/migrations/change_default_value_for_dsa_key_restriction_spec.rb b/spec/migrations/change_default_value_for_dsa_key_restriction_spec.rb index 7e61ab9b52ea9511a0108023e7918256b195d8cd..699708ad1d468f83a4b5ee3ebb2e7a32467943d1 100644 --- a/spec/migrations/change_default_value_for_dsa_key_restriction_spec.rb +++ b/spec/migrations/change_default_value_for_dsa_key_restriction_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'migrate', '20180531220618_change_default_value_for_dsa_key_restriction.rb') diff --git a/spec/migrations/cleanup_build_stage_migration_spec.rb b/spec/migrations/cleanup_build_stage_migration_spec.rb index 4d4d02aaa94760b04ac0fa80e146617886b71e4a..532212810c8cd833f478747ce91436de380ccbcd 100644 --- a/spec/migrations/cleanup_build_stage_migration_spec.rb +++ b/spec/migrations/cleanup_build_stage_migration_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'migrate', '20180420010616_cleanup_build_stage_migration.rb') diff --git a/spec/migrations/cleanup_environments_external_url_spec.rb b/spec/migrations/cleanup_environments_external_url_spec.rb index 07ddaf3d38f9120e79a3710c441fdbd3378b8527..bc20f936593c4cef776d0c396fc8518e1942deee 100644 --- a/spec/migrations/cleanup_environments_external_url_spec.rb +++ b/spec/migrations/cleanup_environments_external_url_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'migrate', '20181108091549_cleanup_environments_external_url.rb') diff --git a/spec/migrations/cleanup_stages_position_migration_spec.rb b/spec/migrations/cleanup_stages_position_migration_spec.rb index dde5a7774878a3c195aa90d9c03ed0417fa10837..649fda1bb4ec65aca1f12e50c80de5080d2ace78 100644 --- a/spec/migrations/cleanup_stages_position_migration_spec.rb +++ b/spec/migrations/cleanup_stages_position_migration_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20180604123514_cleanup_stages_position_migration.rb') diff --git a/spec/migrations/create_missing_namespace_for_internal_users_spec.rb b/spec/migrations/create_missing_namespace_for_internal_users_spec.rb index 3fd4c5bc8d6e76e0f52debd1654bc0076d5aaeef..5df08a74e56ca9bfb8daddc4c8a9f7268d830311 100644 --- a/spec/migrations/create_missing_namespace_for_internal_users_spec.rb +++ b/spec/migrations/create_missing_namespace_for_internal_users_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'migrate', '20180413022611_create_missing_namespace_for_internal_users.rb') diff --git a/spec/migrations/drop_duplicate_protected_tags_spec.rb b/spec/migrations/drop_duplicate_protected_tags_spec.rb index acfb68507229374698b8c6e987c6d5c4602abee4..7f0c7efbf6622beff15d4af1040070441ac8a442 100644 --- a/spec/migrations/drop_duplicate_protected_tags_spec.rb +++ b/spec/migrations/drop_duplicate_protected_tags_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'migrate', '20180711103851_drop_duplicate_protected_tags.rb') diff --git a/spec/migrations/enqueue_verify_pages_domain_workers_spec.rb b/spec/migrations/enqueue_verify_pages_domain_workers_spec.rb index abf39317188263404fb50fdaea59b0e3b84fe990..327fb09ffec09c48441c1acb68f6976605280ced 100644 --- a/spec/migrations/enqueue_verify_pages_domain_workers_spec.rb +++ b/spec/migrations/enqueue_verify_pages_domain_workers_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20180216121030_enqueue_verify_pages_domain_workers') diff --git a/spec/migrations/fill_empty_finished_at_in_deployments_spec.rb b/spec/migrations/fill_empty_finished_at_in_deployments_spec.rb index cf5c10f77e11a685a62cfda56a2d0fac58087d44..50ecf083f27811e213b6b03b09aef2d459e00638 100644 --- a/spec/migrations/fill_empty_finished_at_in_deployments_spec.rb +++ b/spec/migrations/fill_empty_finished_at_in_deployments_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20181030135124_fill_empty_finished_at_in_deployments') diff --git a/spec/migrations/fill_file_store_spec.rb b/spec/migrations/fill_file_store_spec.rb index 5ff7aa56ce2ac2d0f1a9db2ec7ef48cb9f9f971f..806c928363475aba9360f73866541090b3288906 100644 --- a/spec/migrations/fill_file_store_spec.rb +++ b/spec/migrations/fill_file_store_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20180424151928_fill_file_store') @@ -21,7 +23,7 @@ describe FillFileStore, :migration do uploads.create!(size: 10, path: 'path', uploader: 'uploader', mount_point: 'file_name', store: nil) end - it 'correctly migrates nullified file_store/store column' do + it 'correctly migrates nullified file_store/store column', :sidekiq_might_not_need_inline do expect(job_artifacts.where(file_store: nil).count).to eq(1) expect(lfs_objects.where(file_store: nil).count).to eq(1) expect(uploads.where(store: nil).count).to eq(1) diff --git a/spec/migrations/fill_productivity_analytics_start_date_spec.rb b/spec/migrations/fill_productivity_analytics_start_date_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7cbba9ef20e79b85f43678f317df2df6428d0fd2 --- /dev/null +++ b/spec/migrations/fill_productivity_analytics_start_date_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'migrate', '20191004081520_fill_productivity_analytics_start_date.rb') + +describe FillProductivityAnalyticsStartDate, :migration do + let(:settings_table) { table('application_settings') } + let(:metrics_table) { table('merge_request_metrics') } + + before do + settings_table.create! + end + + context 'with NO productivity analytics data available' do + it 'sets start_date to NOW' do + expect { migrate! }.to change { + settings_table.first&.productivity_analytics_start_date + }.to(be_like_time(Time.now)) + end + end + + context 'with productivity analytics data available' do + before do + ActiveRecord::Base.transaction do + ActiveRecord::Base.connection.execute('ALTER TABLE merge_request_metrics DISABLE TRIGGER ALL') + metrics_table.create!(merged_at: Time.parse('2019-09-09'), commits_count: nil, merge_request_id: 3) + metrics_table.create!(merged_at: Time.parse('2019-10-10'), commits_count: 5, merge_request_id: 1) + metrics_table.create!(merged_at: Time.parse('2019-11-11'), commits_count: 10, merge_request_id: 2) + ActiveRecord::Base.connection.execute('ALTER TABLE merge_request_metrics ENABLE TRIGGER ALL') + end + end + + it 'set start_date to earliest merged_at value with PA data available' do + expect { migrate! }.to change { + settings_table.first&.productivity_analytics_start_date + }.to(be_like_time(Time.parse('2019-10-10'))) + end + end +end diff --git a/spec/migrations/fix_wrong_pages_access_level_spec.rb b/spec/migrations/fix_wrong_pages_access_level_spec.rb index 75ac5d919b26ddc8fc8c413c6d5d1050936a470d..73d8218b95c88f91d49a9df0d42bec859f7b6633 100644 --- a/spec/migrations/fix_wrong_pages_access_level_spec.rb +++ b/spec/migrations/fix_wrong_pages_access_level_spec.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20190703185326_fix_wrong_pages_access_level.rb') -describe FixWrongPagesAccessLevel, :migration, :sidekiq, schema: 20190628185004 do +describe FixWrongPagesAccessLevel, :migration, :sidekiq_might_not_need_inline, schema: 20190628185004 do using RSpec::Parameterized::TableSyntax let(:migration_class) { described_class::MIGRATION } diff --git a/spec/migrations/generate_lets_encrypt_private_key_spec.rb b/spec/migrations/generate_lets_encrypt_private_key_spec.rb index 773bf5222f01c114e1970ead17f833748014aa15..7746ba46446b9c9ea1f98fbffce8f5be0faf0e11 100644 --- a/spec/migrations/generate_lets_encrypt_private_key_spec.rb +++ b/spec/migrations/generate_lets_encrypt_private_key_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'migrate', '20190524062810_generate_lets_encrypt_private_key.rb') diff --git a/spec/migrations/generate_missing_routes_spec.rb b/spec/migrations/generate_missing_routes_spec.rb index 30ad135d4df6af74034841facfa1a465a8c9ee4d..a4a25951ff068eec060ee34c46c549c53e41b20f 100644 --- a/spec/migrations/generate_missing_routes_spec.rb +++ b/spec/migrations/generate_missing_routes_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'migrate', '20180702134423_generate_missing_routes.rb') diff --git a/spec/migrations/migrate_cluster_configure_worker_sidekiq_queue_spec.rb b/spec/migrations/migrate_cluster_configure_worker_sidekiq_queue_spec.rb index a1f243651b57036426d00165d546cba2b34f6e4e..4e7438fc182840886a03c4e8f1925bf7ef99fc1b 100644 --- a/spec/migrations/migrate_cluster_configure_worker_sidekiq_queue_spec.rb +++ b/spec/migrations/migrate_cluster_configure_worker_sidekiq_queue_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20181219145520_migrate_cluster_configure_worker_sidekiq_queue.rb') diff --git a/spec/migrations/migrate_create_trace_artifact_sidekiq_queue_spec.rb b/spec/migrations/migrate_create_trace_artifact_sidekiq_queue_spec.rb index 66555118a4360224da09938e4ecb54ef50c2b15a..d54aac50dc8e27941f17df14732fb686918bf639 100644 --- a/spec/migrations/migrate_create_trace_artifact_sidekiq_queue_spec.rb +++ b/spec/migrations/migrate_create_trace_artifact_sidekiq_queue_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20180306074045_migrate_create_trace_artifact_sidekiq_queue.rb') diff --git a/spec/migrations/migrate_legacy_artifacts_to_job_artifacts_spec.rb b/spec/migrations/migrate_legacy_artifacts_to_job_artifacts_spec.rb index df82672f2549a6a1f1ee1c9c8a53e6cccd510df2..98bbe0ed5a2bc800fefc547a74d8ebc7aa843acd 100644 --- a/spec/migrations/migrate_legacy_artifacts_to_job_artifacts_spec.rb +++ b/spec/migrations/migrate_legacy_artifacts_to_job_artifacts_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20180816161409_migrate_legacy_artifacts_to_job_artifacts.rb') @@ -40,7 +42,7 @@ describe MigrateLegacyArtifactsToJobArtifacts, :migration, :sidekiq do end end - it 'migrates legacy artifacts to ci_job_artifacts table' do + it 'migrates legacy artifacts to ci_job_artifacts table', :sidekiq_might_not_need_inline do migrate! expect(job_artifacts.order(:job_id, :file_type).pluck('project_id, job_id, file_type, file_store, size, expire_at, file, file_sha256, file_location')) diff --git a/spec/migrations/migrate_object_storage_upload_sidekiq_queue_spec.rb b/spec/migrations/migrate_object_storage_upload_sidekiq_queue_spec.rb index 6ce04805e5d339c921cdf1405ac78877ae7643ec..6a188f348540e8e2496c4f5e4940ad29768c49c0 100644 --- a/spec/migrations/migrate_object_storage_upload_sidekiq_queue_spec.rb +++ b/spec/migrations/migrate_object_storage_upload_sidekiq_queue_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20180603190921_migrate_object_storage_upload_sidekiq_queue.rb') diff --git a/spec/migrations/migrate_storage_migrator_sidekiq_queue_spec.rb b/spec/migrations/migrate_storage_migrator_sidekiq_queue_spec.rb index 94de208e53ec4493a2c1804c21aebe09b30e7a1c..d8f39ce4e71716d5d42be792ba0ae4fe0bd32404 100644 --- a/spec/migrations/migrate_storage_migrator_sidekiq_queue_spec.rb +++ b/spec/migrations/migrate_storage_migrator_sidekiq_queue_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20190124200344_migrate_storage_migrator_sidekiq_queue.rb') diff --git a/spec/migrations/migrate_update_head_pipeline_for_merge_request_sidekiq_queue_spec.rb b/spec/migrations/migrate_update_head_pipeline_for_merge_request_sidekiq_queue_spec.rb index 976f3ce07d7a271e77005a3950736fe2d7838474..e517eef1320ee2f7e2751e8586f64a3c7b21d77f 100644 --- a/spec/migrations/migrate_update_head_pipeline_for_merge_request_sidekiq_queue_spec.rb +++ b/spec/migrations/migrate_update_head_pipeline_for_merge_request_sidekiq_queue_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20180307012445_migrate_update_head_pipeline_for_merge_request_sidekiq_queue.rb') diff --git a/spec/migrations/move_limits_from_plans_spec.rb b/spec/migrations/move_limits_from_plans_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..693d6ecb2c108a7e7b7c0ab757c27e985b80c58c --- /dev/null +++ b/spec/migrations/move_limits_from_plans_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'migrate', '20191030152934_move_limits_from_plans.rb') + +describe MoveLimitsFromPlans, :migration do + let(:plans) { table(:plans) } + let(:plan_limits) { table(:plan_limits) } + + let!(:early_adopter_plan) { plans.create(name: 'early_adopter', title: 'Early adopter', active_pipelines_limit: 10, pipeline_size_limit: 11, active_jobs_limit: 12) } + let!(:gold_plan) { plans.create(name: 'gold', title: 'Gold', active_pipelines_limit: 20, pipeline_size_limit: 21, active_jobs_limit: 22) } + let!(:silver_plan) { plans.create(name: 'silver', title: 'Silver', active_pipelines_limit: 30, pipeline_size_limit: 31, active_jobs_limit: 32) } + let!(:bronze_plan) { plans.create(name: 'bronze', title: 'Bronze', active_pipelines_limit: 40, pipeline_size_limit: 41, active_jobs_limit: 42) } + let!(:free_plan) { plans.create(name: 'free', title: 'Free', active_pipelines_limit: 50, pipeline_size_limit: 51, active_jobs_limit: 52) } + let!(:other_plan) { plans.create(name: 'other', title: 'Other', active_pipelines_limit: nil, pipeline_size_limit: nil, active_jobs_limit: 0) } + + describe 'migrate' do + it 'populates plan_limits from all the records in plans' do + expect { migrate! }.to change { plan_limits.count }.by 6 + end + + it 'copies plan limits and plan.id into to plan_limits table' do + migrate! + + new_data = plan_limits.pluck(:plan_id, :ci_active_pipelines, :ci_pipeline_size, :ci_active_jobs) + expected_data = [ + [early_adopter_plan.id, 10, 11, 12], + [gold_plan.id, 20, 21, 22], + [silver_plan.id, 30, 31, 32], + [bronze_plan.id, 40, 41, 42], + [free_plan.id, 50, 51, 52], + [other_plan.id, 0, 0, 0] + ] + expect(new_data).to contain_exactly(*expected_data) + end + end +end diff --git a/spec/migrations/remove_empty_extern_uid_auth0_identities_spec.rb b/spec/migrations/remove_empty_extern_uid_auth0_identities_spec.rb index 441c4295a4055fc48a31bc7fb2a09df6787e6f11..ad1bcf3773230ae7cdc9f2767892949ae1e026a4 100644 --- a/spec/migrations/remove_empty_extern_uid_auth0_identities_spec.rb +++ b/spec/migrations/remove_empty_extern_uid_auth0_identities_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20180220150310_remove_empty_extern_uid_auth0_identities.rb') diff --git a/spec/migrations/remove_empty_github_service_templates_spec.rb b/spec/migrations/remove_empty_github_service_templates_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c128c8538dbf0ed118a68cdf377cc33329391357 --- /dev/null +++ b/spec/migrations/remove_empty_github_service_templates_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20191021101942_remove_empty_github_service_templates.rb') + +describe RemoveEmptyGithubServiceTemplates, :migration do + subject(:migration) { described_class.new } + + let(:services) do + table(:services).tap do |klass| + klass.class_eval do + serialize :properties, JSON + end + end + end + + before do + services.delete_all + + create_service(properties: nil) + create_service(properties: {}) + create_service(properties: { some: :value }) + create_service(properties: {}, template: false) + create_service(properties: {}, type: 'SomeType') + end + + def all_service_properties + services.where(template: true, type: 'GithubService').pluck(:properties) + end + + it 'correctly migrates up and down service templates' do + reversible_migration do |migration| + migration.before -> do + expect(services.count).to eq(5) + + expect(all_service_properties) + .to match(a_collection_containing_exactly(nil, {}, { 'some' => 'value' })) + end + + migration.after -> do + expect(services.count).to eq(4) + + expect(all_service_properties) + .to match(a_collection_containing_exactly(nil, { 'some' => 'value' })) + end + end + end + + def create_service(params) + data = { template: true, type: 'GithubService' } + data.merge!(params) + + services.create!(data) + end +end diff --git a/spec/migrations/remove_redundant_pipeline_stages_spec.rb b/spec/migrations/remove_redundant_pipeline_stages_spec.rb index 8325f9865942dab4ce8f15312094a4188e812b25..ad905d7eb8a380d641d375dcd23b22bf9d8e7693 100644 --- a/spec/migrations/remove_redundant_pipeline_stages_spec.rb +++ b/spec/migrations/remove_redundant_pipeline_stages_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20180119121225_remove_redundant_pipeline_stages.rb') diff --git a/spec/migrations/reschedule_builds_stages_migration_spec.rb b/spec/migrations/reschedule_builds_stages_migration_spec.rb index 3bfd9dd9f6b08582091f282a6b03c6d704377e36..f9707d8f90b7481f2f4be88447fba96954b5744e 100644 --- a/spec/migrations/reschedule_builds_stages_migration_spec.rb +++ b/spec/migrations/reschedule_builds_stages_migration_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20180405101928_reschedule_builds_stages_migration') diff --git a/spec/migrations/reschedule_commits_count_for_merge_request_diff_spec.rb b/spec/migrations/reschedule_commits_count_for_merge_request_diff_spec.rb index 26489ef58bdcd9513bc986ea6aca10034c81a659..a62650c44fb5a6f3aed34eb00067c029f7475d7b 100644 --- a/spec/migrations/reschedule_commits_count_for_merge_request_diff_spec.rb +++ b/spec/migrations/reschedule_commits_count_for_merge_request_diff_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'migrate', '20180309121820_reschedule_commits_count_for_merge_request_diff') diff --git a/spec/migrations/schedule_digest_personal_access_tokens_spec.rb b/spec/migrations/schedule_digest_personal_access_tokens_spec.rb index 6d155f783423e47eb2db84b639f8b02aee9055fb..ff859d07ff2d4d136d6557482efae116a8409250 100644 --- a/spec/migrations/schedule_digest_personal_access_tokens_spec.rb +++ b/spec/migrations/schedule_digest_personal_access_tokens_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20180913142237_schedule_digest_personal_access_tokens.rb') @@ -32,7 +34,7 @@ describe ScheduleDigestPersonalAccessTokens, :migration, :sidekiq do end end - it 'schedules background migrations' do + it 'schedules background migrations', :sidekiq_might_not_need_inline do perform_enqueued_jobs do plain_text_token = 'token IS NOT NULL' diff --git a/spec/migrations/schedule_fill_valid_time_for_pages_domain_certificates_spec.rb b/spec/migrations/schedule_fill_valid_time_for_pages_domain_certificates_spec.rb index 54f3e264df09c4b03d3a95c3c3aacfab1f5c5202..a0241f1d20c1e82f811fefcef3e76a04d2f34ed9 100644 --- a/spec/migrations/schedule_fill_valid_time_for_pages_domain_certificates_spec.rb +++ b/spec/migrations/schedule_fill_valid_time_for_pages_domain_certificates_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20190524073827_schedule_fill_valid_time_for_pages_domain_certificates.rb') @@ -32,7 +34,7 @@ describe ScheduleFillValidTimeForPagesDomainCertificates, :migration, :sidekiq d end end - it 'sets certificate valid_not_before/not_after' do + it 'sets certificate valid_not_before/not_after', :sidekiq_might_not_need_inline do perform_enqueued_jobs do migrate! diff --git a/spec/migrations/schedule_runners_token_encryption_spec.rb b/spec/migrations/schedule_runners_token_encryption_spec.rb index 97ff6c128f362f667bd9e4de8f6a6620784ad800..6b9538c4d17612e96adabed78d88f27a9b3e382d 100644 --- a/spec/migrations/schedule_runners_token_encryption_spec.rb +++ b/spec/migrations/schedule_runners_token_encryption_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20181121111200_schedule_runners_token_encryption') diff --git a/spec/migrations/schedule_set_confidential_note_events_on_webhooks_spec.rb b/spec/migrations/schedule_set_confidential_note_events_on_webhooks_spec.rb index fa4ddd5fbc7220e576263900ccf78d84da6458ee..845b051517781c81d06496c4d1fb6dcc122e9e0e 100644 --- a/spec/migrations/schedule_set_confidential_note_events_on_webhooks_spec.rb +++ b/spec/migrations/schedule_set_confidential_note_events_on_webhooks_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20180104131052_schedule_set_confidential_note_events_on_webhooks.rb') @@ -30,7 +32,7 @@ describe ScheduleSetConfidentialNoteEventsOnWebhooks, :migration, :sidekiq do end end - it 'correctly processes web hooks' do + it 'correctly processes web hooks', :sidekiq_might_not_need_inline do perform_enqueued_jobs do expect(web_hooks_table.where(confidential_note_events: nil).count).to eq 4 expect(web_hooks_table.where(confidential_note_events: true).count).to eq 1 diff --git a/spec/migrations/schedule_stages_index_migration_spec.rb b/spec/migrations/schedule_stages_index_migration_spec.rb index 710264da375ee0f555921e98db309bae17b57c5d..9ebc648f9d8683a9802d5a06349de15fb5d9eeeb 100644 --- a/spec/migrations/schedule_stages_index_migration_spec.rb +++ b/spec/migrations/schedule_stages_index_migration_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20180420080616_schedule_stages_index_migration') diff --git a/spec/migrations/schedule_sync_issuables_state_id_spec.rb b/spec/migrations/schedule_sync_issuables_state_id_spec.rb index bc94f8820bddce3102eef310db324629e05429be..4f841e8ce04d64658a15b76e26439539978e0a3f 100644 --- a/spec/migrations/schedule_sync_issuables_state_id_spec.rb +++ b/spec/migrations/schedule_sync_issuables_state_id_spec.rb @@ -33,7 +33,7 @@ describe ScheduleSyncIssuablesStateId, :migration, :sidekiq do describe '#up' do context 'issues' do - it 'migrates state column to integer' do + it 'migrates state column to integer', :sidekiq_might_not_need_inline do opened_issue = issues.create!(description: 'first', state: 'opened') closed_issue = issues.create!(description: 'second', state: 'closed') invalid_state_issue = issues.create!(description: 'fourth', state: 'not valid') @@ -55,7 +55,7 @@ describe ScheduleSyncIssuablesStateId, :migration, :sidekiq do end context 'merge requests' do - it 'migrates state column to integer' do + it 'migrates state column to integer', :sidekiq_might_not_need_inline do opened_merge_request = merge_requests.create!(state: 'opened', target_project_id: project.id, target_branch: 'feature1', source_branch: 'master') closed_merge_request = merge_requests.create!(state: 'closed', target_project_id: project.id, target_branch: 'feature2', source_branch: 'master') merged_merge_request = merge_requests.create!(state: 'merged', target_project_id: project.id, target_branch: 'feature3', source_branch: 'master') diff --git a/spec/migrations/schedule_to_archive_legacy_traces_spec.rb b/spec/migrations/schedule_to_archive_legacy_traces_spec.rb index d3eac3c45ea041013d04f660794fdaee9dd10387..a81fb1494c752da7f646eebc1fc2864bd35860b4 100644 --- a/spec/migrations/schedule_to_archive_legacy_traces_spec.rb +++ b/spec/migrations/schedule_to_archive_legacy_traces_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20180529152628_schedule_to_archive_legacy_traces') @@ -23,7 +25,7 @@ describe ScheduleToArchiveLegacyTraces, :migration do create_legacy_trace(@build_running, 'This job is not done yet') end - it 'correctly archive legacy traces' do + it 'correctly archive legacy traces', :sidekiq_might_not_need_inline do expect(job_artifacts.count).to eq(0) expect(File.exist?(legacy_trace_path(@build_success))).to be_truthy expect(File.exist?(legacy_trace_path(@build_failed))).to be_truthy diff --git a/spec/migrations/truncate_user_fullname_spec.rb b/spec/migrations/truncate_user_fullname_spec.rb index 17fd4d9f6883b932ed18be2079ae36e33a03643c..65b870de7b88ce06d5d2265783e35fec51da3bd6 100644 --- a/spec/migrations/truncate_user_fullname_spec.rb +++ b/spec/migrations/truncate_user_fullname_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require Rails.root.join('db', 'migrate', '20190325080727_truncate_user_fullname.rb') diff --git a/spec/models/analytics/cycle_analytics/project_stage_spec.rb b/spec/models/analytics/cycle_analytics/project_stage_spec.rb index 83d6ff754c55e54c9e09ee6ebbb9e4ba33b95b5a..9d18618f63870ff8b35b2ea72a2e417819879b95 100644 --- a/spec/models/analytics/cycle_analytics/project_stage_spec.rb +++ b/spec/models/analytics/cycle_analytics/project_stage_spec.rb @@ -16,8 +16,16 @@ describe Analytics::CycleAnalytics::ProjectStage do end end - it_behaves_like "cycle analytics stage" do + it_behaves_like 'cycle analytics stage' do let(:parent) { create(:project) } let(:parent_name) { :project } end + + context 'relative positioning' do + it_behaves_like 'a class that supports relative positioning' do + let(:project) { create(:project) } + let(:factory) { :cycle_analytics_project_stage } + let(:default_params) { { project: project } } + end + end end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 7bef3d30064c0e333ff27b849f1d9634f089ab3f..ba3b99f4421716772467f849322df6ea8e7c7fbb 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe ApplicationSetting do + using RSpec::Parameterized::TableSyntax + subject(:setting) { described_class.create_from_defaults } it { include(CacheableAttributes) } @@ -64,6 +66,24 @@ describe ApplicationSetting do it { is_expected.not_to allow_value('three').for(:push_event_activities_limit) } it { is_expected.not_to allow_value(nil).for(:push_event_activities_limit) } + context 'when snowplow is enabled' do + before do + setting.snowplow_enabled = true + end + + it { is_expected.not_to allow_value(nil).for(:snowplow_collector_hostname) } + it { is_expected.to allow_value("snowplow.gitlab.com").for(:snowplow_collector_hostname) } + it { is_expected.not_to allow_value('/example').for(:snowplow_collector_hostname) } + it { is_expected.to allow_value('https://example.org').for(:snowplow_iglu_registry_url) } + it { is_expected.not_to allow_value('not-a-url').for(:snowplow_iglu_registry_url) } + it { is_expected.to allow_value(nil).for(:snowplow_iglu_registry_url) } + end + + context 'when snowplow is not enabled' do + it { is_expected.to allow_value(nil).for(:snowplow_collector_hostname) } + it { is_expected.to allow_value(nil).for(:snowplow_iglu_registry_url) } + end + context "when user accepted let's encrypt terms of service" do before do setting.update(lets_encrypt_terms_of_service_accepted: true) @@ -72,6 +92,37 @@ describe ApplicationSetting do it { is_expected.not_to allow_value(nil).for(:lets_encrypt_notification_email) } end + describe 'EKS integration' do + before do + setting.eks_integration_enabled = eks_enabled + end + + context 'integration is disabled' do + let(:eks_enabled) { false } + + it { is_expected.to allow_value(nil).for(:eks_account_id) } + it { is_expected.to allow_value(nil).for(:eks_access_key_id) } + it { is_expected.to allow_value(nil).for(:eks_secret_access_key) } + end + + context 'integration is enabled' do + let(:eks_enabled) { true } + + it { is_expected.to allow_value('123456789012').for(:eks_account_id) } + it { is_expected.not_to allow_value(nil).for(:eks_account_id) } + it { is_expected.not_to allow_value('123').for(:eks_account_id) } + it { is_expected.not_to allow_value('12345678901a').for(:eks_account_id) } + + it { is_expected.to allow_value('access-key-id-12').for(:eks_access_key_id) } + it { is_expected.not_to allow_value('a' * 129).for(:eks_access_key_id) } + it { is_expected.not_to allow_value('short-key').for(:eks_access_key_id) } + it { is_expected.not_to allow_value(nil).for(:eks_access_key_id) } + + it { is_expected.to allow_value('secret-access-key').for(:eks_secret_access_key) } + it { is_expected.not_to allow_value(nil).for(:eks_secret_access_key) } + end + end + describe 'default_artifacts_expire_in' do it 'sets an error if it cannot parse' do setting.update(default_artifacts_expire_in: 'a') @@ -446,6 +497,15 @@ describe ApplicationSetting do it { is_expected.not_to allow_value(nil).for(:static_objects_external_storage_auth_token) } end end + + context 'sourcegraph settings' do + it 'is invalid if sourcegraph is enabled and no url is provided' do + allow(subject).to receive(:sourcegraph_enabled).and_return(true) + + expect(subject.sourcegraph_url).to be_nil + is_expected.to be_invalid + end + end end context 'restrict creating duplicates' do @@ -534,5 +594,24 @@ describe ApplicationSetting do end end + describe '#sourcegraph_url_is_com?' do + where(:url, :is_com) do + 'https://sourcegraph.com' | true + 'https://sourcegraph.com/' | true + 'https://www.sourcegraph.com' | true + 'shttps://www.sourcegraph.com' | false + 'https://sourcegraph.example.com/' | false + 'https://sourcegraph.org/' | false + end + + with_them do + it 'matches the url with sourcegraph.com' do + setting.sourcegraph_url = url + + expect(setting.sourcegraph_url_is_com?).to eq(is_com) + end + end + end + it_behaves_like 'application settings examples' end diff --git a/spec/models/aws/role_spec.rb b/spec/models/aws/role_spec.rb index c40752e40a687118a16344520d30a20cbd34796b..d4165567146f023b7d3afee6cf2c3d35f62c0813 100644 --- a/spec/models/aws/role_spec.rb +++ b/spec/models/aws/role_spec.rb @@ -31,4 +31,56 @@ describe Aws::Role do end end end + + describe 'callbacks' do + describe '#ensure_role_external_id!' do + subject { role.validate } + + context 'for a new record' do + let(:role) { build(:aws_role, role_external_id: nil) } + + it 'calls #ensure_role_external_id!' do + expect(role).to receive(:ensure_role_external_id!) + + subject + end + end + + context 'for an existing record' do + let(:role) { create(:aws_role) } + + it 'does not call #ensure_role_external_id!' do + expect(role).not_to receive(:ensure_role_external_id!) + + subject + end + end + end + end + + describe '#ensure_role_external_id!' do + let(:role) { build(:aws_role, role_external_id: external_id) } + + subject { role.ensure_role_external_id! } + + context 'role_external_id is blank' do + let(:external_id) { nil } + + it 'generates an external ID and assigns it to the record' do + subject + + expect(role.role_external_id).to be_present + end + end + + context 'role_external_id is already set' do + let(:external_id) { 'external-id' } + + it 'does not change the existing external id' do + subject + + expect(role.role_external_id).to eq external_id + end + end + end end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 058305bc04e944693a121f286ff207c7f17fd797..24fa3b9b1ea49bec6e0ca7c716d828d47d6fc99a 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -206,6 +206,35 @@ describe Ci::Build do end end + describe '.with_exposed_artifacts' do + subject { described_class.with_exposed_artifacts } + + let!(:job1) { create(:ci_build) } + let!(:job2) { create(:ci_build, options: options) } + let!(:job3) { create(:ci_build) } + + context 'when some jobs have exposed artifacs and some not' do + let(:options) { { artifacts: { expose_as: 'test', paths: ['test'] } } } + + before do + job1.ensure_metadata.update!(has_exposed_artifacts: nil) + job3.ensure_metadata.update!(has_exposed_artifacts: false) + end + + it 'selects only the jobs with exposed artifacts' do + is_expected.to eq([job2]) + end + end + + context 'when job does not expose artifacts' do + let(:options) { nil } + + it 'returns an empty array' do + is_expected.to be_empty + end + end + end + describe '.with_reports' do subject { described_class.with_reports(Ci::JobArtifact.test_reports) } @@ -1558,7 +1587,7 @@ describe Ci::Build do end end - describe '#retries_max' do + describe '#options_retry_max' do context 'with retries max config option' do subject { create(:ci_build, options: { retry: { max: 1 } }) } @@ -1568,7 +1597,7 @@ describe Ci::Build do end it 'returns the number of configured max retries' do - expect(subject.retries_max).to eq 1 + expect(subject.options_retry_max).to eq 1 end end @@ -1578,7 +1607,7 @@ describe Ci::Build do end it 'returns the number of configured max retries' do - expect(subject.retries_max).to eq 1 + expect(subject.options_retry_max).to eq 1 end end end @@ -1586,16 +1615,16 @@ describe Ci::Build do context 'without retries max config option' do subject { create(:ci_build) } - it 'returns zero' do - expect(subject.retries_max).to eq 0 + it 'returns nil' do + expect(subject.options_retry_max).to be_nil end end context 'when build is degenerated' do subject { create(:ci_build, :degenerated) } - it 'returns zero' do - expect(subject.retries_max).to eq 0 + it 'returns nil' do + expect(subject.options_retry_max).to be_nil end end @@ -1603,17 +1632,17 @@ describe Ci::Build do subject { create(:ci_build, options: { retry: 1 }) } it 'returns the number of configured max retries' do - expect(subject.retries_max).to eq 1 + expect(subject.options_retry_max).to eq 1 end end end - describe '#retry_when' do + describe '#options_retry_when' do context 'with retries when config option' do subject { create(:ci_build, options: { retry: { when: ['some_reason'] } }) } it 'returns the configured when' do - expect(subject.retry_when).to eq ['some_reason'] + expect(subject.options_retry_when).to eq ['some_reason'] end end @@ -1621,7 +1650,7 @@ describe Ci::Build do subject { create(:ci_build) } it 'returns always array' do - expect(subject.retry_when).to eq ['always'] + expect(subject.options_retry_when).to eq ['always'] end end @@ -1629,72 +1658,38 @@ describe Ci::Build do subject { create(:ci_build, options: { retry: 1 }) } it 'returns always array' do - expect(subject.retry_when).to eq ['always'] + expect(subject.options_retry_when).to eq ['always'] end end end describe '#retry_failure?' do - subject { create(:ci_build) } + using RSpec::Parameterized::TableSyntax - context 'when retries max is zero' do - before do - expect(subject).to receive(:retries_max).at_least(:once).and_return(0) - end + let(:build) { create(:ci_build) } - it 'returns false' do - expect(subject.retry_failure?).to eq false - end - end + subject { build.retry_failure? } - context 'when retries max equals retries count' do - before do - expect(subject).to receive(:retries_max).at_least(:once).and_return(1) - expect(subject).to receive(:retries_count).at_least(:once).and_return(1) - end - - it 'returns false' do - expect(subject.retry_failure?).to eq false - end + where(:description, :retry_count, :options, :failure_reason, :result) do + "retries are disabled" | 0 | { max: 0 } | nil | false + "max equals count" | 2 | { max: 2 } | nil | false + "max is higher than count" | 1 | { max: 2 } | nil | true + "matching failure reason" | 0 | { when: %w[api_failure], max: 2 } | :api_failure | true + "not matching with always" | 0 | { when: %w[always], max: 2 } | :api_failure | true + "not matching reason" | 0 | { when: %w[script_error], max: 2 } | :api_failure | false + "scheduler failure override" | 1 | { when: %w[scheduler_failure], max: 1 } | :scheduler_failure | false + "default for scheduler failure" | 1 | {} | :scheduler_failure | true end - context 'when retries max is higher than retries count' do + with_them do before do - expect(subject).to receive(:retries_max).at_least(:once).and_return(2) - expect(subject).to receive(:retries_count).at_least(:once).and_return(1) - end + allow(build).to receive(:retries_count) { retry_count } - context 'and retry when is always' do - before do - expect(subject).to receive(:retry_when).at_least(:once).and_return(['always']) - end - - it 'returns true' do - expect(subject.retry_failure?).to eq true - end - end - - context 'and retry when includes the failure_reason' do - before do - expect(subject).to receive(:failure_reason).at_least(:once).and_return('some_reason') - expect(subject).to receive(:retry_when).at_least(:once).and_return(['some_reason']) - end - - it 'returns true' do - expect(subject.retry_failure?).to eq true - end + build.options[:retry] = options + build.failure_reason = failure_reason end - context 'and retry when does not include failure_reason' do - before do - expect(subject).to receive(:failure_reason).at_least(:once).and_return('some_reason') - expect(subject).to receive(:retry_when).at_least(:once).and_return(['some', 'other failure']) - end - - it 'returns false' do - expect(subject.retry_failure?).to eq false - end - end + it { is_expected.to eq(result) } end end end @@ -1844,6 +1839,14 @@ describe Ci::Build do expect(build.metadata.read_attribute(:config_options)).to be_nil end end + + context 'when options include artifacts:expose_as' do + let(:build) { create(:ci_build, options: { artifacts: { expose_as: 'test' } }) } + + it 'saves the presence of expose_as into build metadata' do + expect(build.metadata).to have_exposed_artifacts + end + end end describe '#other_manual_actions' do @@ -2218,7 +2221,7 @@ describe Ci::Build do { key: 'CI_PAGES_URL', value: project.pages_url, public: true, masked: false }, { key: 'CI_API_V4_URL', value: 'http://localhost/api/v4', public: true, masked: false }, { key: 'CI_PIPELINE_IID', value: pipeline.iid.to_s, public: true, masked: false }, - { key: 'CI_CONFIG_PATH', value: pipeline.ci_yaml_file_path, public: true, masked: false }, + { key: 'CI_CONFIG_PATH', value: pipeline.config_path, public: true, masked: false }, { key: 'CI_PIPELINE_SOURCE', value: pipeline.source, public: true, masked: false }, { key: 'CI_COMMIT_MESSAGE', value: pipeline.git_commit_message, public: true, masked: false }, { key: 'CI_COMMIT_TITLE', value: pipeline.git_commit_title, public: true, masked: false }, @@ -2664,11 +2667,17 @@ describe Ci::Build do it { is_expected.to include(deployment_variable) } end + context 'when project has default CI config path' do + let(:ci_config_path) { { key: 'CI_CONFIG_PATH', value: '.gitlab-ci.yml', public: true, masked: false } } + + it { is_expected.to include(ci_config_path) } + end + context 'when project has custom CI config path' do let(:ci_config_path) { { key: 'CI_CONFIG_PATH', value: 'custom', public: true, masked: false } } before do - project.update(ci_config_path: 'custom') + expect_any_instance_of(Project).to receive(:ci_config_path) { 'custom' } end it { is_expected.to include(ci_config_path) } diff --git a/spec/models/ci/build_trace_chunk_spec.rb b/spec/models/ci/build_trace_chunk_spec.rb index 59db347582ba4c024f2389af33aef2ee3612bcec..96d81f4cc495762468e5d455392df3ce00066d7d 100644 --- a/spec/models/ci/build_trace_chunk_spec.rb +++ b/spec/models/ci/build_trace_chunk_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do @@ -63,7 +65,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do let(:data_store) { :redis } before do - build_trace_chunk.send(:unsafe_set_data!, 'Sample data in redis') + build_trace_chunk.send(:unsafe_set_data!, +'Sample data in redis') end it { is_expected.to eq('Sample data in redis') } @@ -71,7 +73,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do context 'when data_store is database' do let(:data_store) { :database } - let(:raw_data) { 'Sample data in database' } + let(:raw_data) { +'Sample data in database' } it { is_expected.to eq('Sample data in database') } end @@ -80,7 +82,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do let(:data_store) { :fog } before do - build_trace_chunk.send(:unsafe_set_data!, 'Sample data in fog') + build_trace_chunk.send(:unsafe_set_data!, +'Sample data in fog') end it { is_expected.to eq('Sample data in fog') } @@ -90,7 +92,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do describe '#append' do subject { build_trace_chunk.append(new_data, offset) } - let(:new_data) { 'Sample new data' } + let(:new_data) { +'Sample new data' } let(:offset) { 0 } let(:merged_data) { data + new_data.to_s } @@ -143,7 +145,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do end context 'when new_data is empty' do - let(:new_data) { '' } + let(:new_data) { +'' } it 'does not append' do subject @@ -172,7 +174,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do shared_examples_for 'Scheduling sidekiq worker to flush data to persist store' do context 'when new data fulfilled chunk size' do - let(:new_data) { 'a' * described_class::CHUNK_SIZE } + let(:new_data) { +'a' * described_class::CHUNK_SIZE } it 'schedules trace chunk flush worker' do expect(Ci::BuildTraceChunkFlushWorker).to receive(:perform_async).once @@ -180,7 +182,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do subject end - it 'migrates data to object storage' do + it 'migrates data to object storage', :sidekiq_might_not_need_inline do perform_enqueued_jobs do subject @@ -194,7 +196,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do shared_examples_for 'Scheduling no sidekiq worker' do context 'when new data fulfilled chunk size' do - let(:new_data) { 'a' * described_class::CHUNK_SIZE } + let(:new_data) { +'a' * described_class::CHUNK_SIZE } it 'does not schedule trace chunk flush worker' do expect(Ci::BuildTraceChunkFlushWorker).not_to receive(:perform_async) @@ -219,7 +221,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do let(:data_store) { :redis } context 'when there are no data' do - let(:data) { '' } + let(:data) { +'' } it 'has no data' do expect(build_trace_chunk.data).to be_empty @@ -230,7 +232,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do end context 'when there are some data' do - let(:data) { 'Sample data in redis' } + let(:data) { +'Sample data in redis' } before do build_trace_chunk.send(:unsafe_set_data!, data) @@ -249,7 +251,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do let(:data_store) { :database } context 'when there are no data' do - let(:data) { '' } + let(:data) { +'' } it 'has no data' do expect(build_trace_chunk.data).to be_empty @@ -260,7 +262,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do end context 'when there are some data' do - let(:raw_data) { 'Sample data in database' } + let(:raw_data) { +'Sample data in database' } let(:data) { raw_data } it 'has data' do @@ -276,7 +278,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do let(:data_store) { :fog } context 'when there are no data' do - let(:data) { '' } + let(:data) { +'' } it 'has no data' do expect(build_trace_chunk.data).to be_empty @@ -287,7 +289,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do end context 'when there are some data' do - let(:data) { 'Sample data in fog' } + let(:data) { +'Sample data in fog' } before do build_trace_chunk.send(:unsafe_set_data!, data) @@ -332,7 +334,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do context 'when data_store is redis' do let(:data_store) { :redis } - let(:data) { 'Sample data in redis' } + let(:data) { +'Sample data in redis' } before do build_trace_chunk.send(:unsafe_set_data!, data) @@ -343,7 +345,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do context 'when data_store is database' do let(:data_store) { :database } - let(:raw_data) { 'Sample data in database' } + let(:raw_data) { +'Sample data in database' } let(:data) { raw_data } it_behaves_like 'truncates' @@ -351,7 +353,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do context 'when data_store is fog' do let(:data_store) { :fog } - let(:data) { 'Sample data in fog' } + let(:data) { +'Sample data in fog' } before do build_trace_chunk.send(:unsafe_set_data!, data) @@ -368,7 +370,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do let(:data_store) { :redis } context 'when data exists' do - let(:data) { 'Sample data in redis' } + let(:data) { +'Sample data in redis' } before do build_trace_chunk.send(:unsafe_set_data!, data) @@ -386,7 +388,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do let(:data_store) { :database } context 'when data exists' do - let(:raw_data) { 'Sample data in database' } + let(:raw_data) { +'Sample data in database' } let(:data) { raw_data } it { is_expected.to eq(data.bytesize) } @@ -401,7 +403,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do let(:data_store) { :fog } context 'when data exists' do - let(:data) { 'Sample data in fog' } + let(:data) { +'Sample data in fog' } let(:key) { "tmp/builds/#{build.id}/chunks/#{chunk_index}.log" } before do @@ -443,7 +445,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do end context 'when data size reached CHUNK_SIZE' do - let(:data) { 'a' * described_class::CHUNK_SIZE } + let(:data) { +'a' * described_class::CHUNK_SIZE } it 'persists the data' do expect(build_trace_chunk.redis?).to be_truthy @@ -463,7 +465,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do end context 'when data size has not reached CHUNK_SIZE' do - let(:data) { 'Sample data in redis' } + let(:data) { +'Sample data in redis' } it 'does not persist the data and the orignal data is intact' do expect { subject }.to raise_error(described_class::FailedToPersistDataError) @@ -492,7 +494,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do end context 'when data size reached CHUNK_SIZE' do - let(:data) { 'a' * described_class::CHUNK_SIZE } + let(:data) { +'a' * described_class::CHUNK_SIZE } it 'persists the data' do expect(build_trace_chunk.database?).to be_truthy @@ -512,7 +514,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do end context 'when data size has not reached CHUNK_SIZE' do - let(:data) { 'Sample data in database' } + let(:data) { +'Sample data in database' } it 'does not persist the data and the orignal data is intact' do expect { subject }.to raise_error(described_class::FailedToPersistDataError) @@ -561,7 +563,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do end context 'when data size has not reached CHUNK_SIZE' do - let(:data) { 'Sample data in fog' } + let(:data) { +'Sample data in fog' } it 'does not raise error' do expect { subject }.not_to raise_error @@ -582,7 +584,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do end shared_examples_for 'deletes all build_trace_chunk and data in redis' do - it do + it 'deletes all build_trace_chunk and data in redis', :sidekiq_might_not_need_inline do Gitlab::Redis::SharedState.with do |redis| expect(redis.scan_each(match: "gitlab:ci:trace:*:chunks:*").to_a.size).to eq(3) end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index de0ce9932e893dec58fb5d72f9bdb21d9534d492..d24cf3d2115471c4413af2d0a6a3fe6093214367 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -979,141 +979,6 @@ describe Ci::Pipeline, :mailer do end describe 'pipeline stages' do - describe '#stage_seeds' do - let(:pipeline) { build(:ci_pipeline, config: config) } - let(:config) { { rspec: { script: 'rake' } } } - - it 'returns preseeded stage seeds object' do - expect(pipeline.stage_seeds) - .to all(be_a Gitlab::Ci::Pipeline::Seed::Base) - expect(pipeline.stage_seeds.count).to eq 1 - end - - context 'when no refs policy is specified' do - let(:config) do - { production: { stage: 'deploy', script: 'cap prod' }, - rspec: { stage: 'test', script: 'rspec' }, - spinach: { stage: 'test', script: 'spinach' } } - end - - it 'correctly fabricates a stage seeds object' do - seeds = pipeline.stage_seeds - - expect(seeds.size).to eq 2 - expect(seeds.first.attributes[:name]).to eq 'test' - expect(seeds.second.attributes[:name]).to eq 'deploy' - expect(seeds.dig(0, 0, :name)).to eq 'rspec' - expect(seeds.dig(0, 1, :name)).to eq 'spinach' - expect(seeds.dig(1, 0, :name)).to eq 'production' - end - end - - context 'when refs policy is specified' do - let(:pipeline) do - build(:ci_pipeline, ref: 'feature', tag: true, config: config) - end - - let(:config) do - { production: { stage: 'deploy', script: 'cap prod', only: ['master'] }, - spinach: { stage: 'test', script: 'spinach', only: ['tags'] } } - end - - it 'returns stage seeds only assigned to master to master' do - seeds = pipeline.stage_seeds - - expect(seeds.size).to eq 1 - expect(seeds.first.attributes[:name]).to eq 'test' - expect(seeds.dig(0, 0, :name)).to eq 'spinach' - end - end - - context 'when source policy is specified' do - let(:pipeline) { build(:ci_pipeline, source: :schedule, config: config) } - - let(:config) do - { production: { stage: 'deploy', script: 'cap prod', only: ['triggers'] }, - spinach: { stage: 'test', script: 'spinach', only: ['schedules'] } } - end - - it 'returns stage seeds only assigned to schedules' do - seeds = pipeline.stage_seeds - - expect(seeds.size).to eq 1 - expect(seeds.first.attributes[:name]).to eq 'test' - expect(seeds.dig(0, 0, :name)).to eq 'spinach' - end - end - - context 'when kubernetes policy is specified' do - let(:config) do - { - spinach: { stage: 'test', script: 'spinach' }, - production: { - stage: 'deploy', - script: 'cap', - only: { kubernetes: 'active' } - } - } - end - - context 'when kubernetes is active' do - context 'when user configured kubernetes from CI/CD > Clusters' do - let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } - let(:project) { cluster.project } - let(:pipeline) { build(:ci_pipeline, project: project, config: config) } - - it 'returns seeds for kubernetes dependent job' do - seeds = pipeline.stage_seeds - - expect(seeds.size).to eq 2 - expect(seeds.dig(0, 0, :name)).to eq 'spinach' - expect(seeds.dig(1, 0, :name)).to eq 'production' - end - end - end - - context 'when kubernetes is not active' do - it 'does not return seeds for kubernetes dependent job' do - seeds = pipeline.stage_seeds - - expect(seeds.size).to eq 1 - expect(seeds.dig(0, 0, :name)).to eq 'spinach' - end - end - end - - context 'when variables policy is specified' do - let(:config) do - { unit: { script: 'minitest', only: { variables: ['$CI_PIPELINE_SOURCE'] } }, - feature: { script: 'spinach', only: { variables: ['$UNDEFINED'] } } } - end - - it 'returns stage seeds only when variables expression is truthy' do - seeds = pipeline.stage_seeds - - expect(seeds.size).to eq 1 - expect(seeds.dig(0, 0, :name)).to eq 'unit' - end - end - end - - describe '#seeds_size' do - context 'when refs policy is specified' do - let(:config) do - { production: { stage: 'deploy', script: 'cap prod', only: ['master'] }, - spinach: { stage: 'test', script: 'spinach', only: ['tags'] } } - end - - let(:pipeline) do - build(:ci_pipeline, ref: 'feature', tag: true, config: config) - end - - it 'returns real seeds size' do - expect(pipeline.seeds_size).to eq 1 - end - end - end - describe 'legacy stages' do before do create(:commit_status, pipeline: pipeline, @@ -1346,7 +1211,7 @@ describe Ci::Pipeline, :mailer do end end - describe '#duration' do + describe '#duration', :sidekiq_might_not_need_inline do context 'when multiple builds are finished' do before do travel_to(current + 30) do @@ -1422,7 +1287,7 @@ describe Ci::Pipeline, :mailer do end describe '#finished_at' do - it 'updates on transitioning to success' do + it 'updates on transitioning to success', :sidekiq_might_not_need_inline do build.success expect(pipeline.reload.finished_at).not_to be_nil @@ -2102,7 +1967,7 @@ describe Ci::Pipeline, :mailer do it { is_expected.not_to include('created', 'preparing', 'pending') } end - describe '#status' do + describe '#status', :sidekiq_might_not_need_inline do let(:build) do create(:ci_build, :created, pipeline: pipeline, name: 'test') end @@ -2186,161 +2051,6 @@ describe Ci::Pipeline, :mailer do end end - describe '#ci_yaml_file_path' do - subject { pipeline.ci_yaml_file_path } - - %i[unknown_source repository_source].each do |source| - context source.to_s do - before do - pipeline.config_source = described_class.config_sources.fetch(source) - end - - it 'returns the path from project' do - allow(pipeline.project).to receive(:ci_config_path) { 'custom/path' } - - is_expected.to eq('custom/path') - end - - it 'returns default when custom path is nil' do - allow(pipeline.project).to receive(:ci_config_path) { nil } - - is_expected.to eq('.gitlab-ci.yml') - end - - it 'returns default when custom path is empty' do - allow(pipeline.project).to receive(:ci_config_path) { '' } - - is_expected.to eq('.gitlab-ci.yml') - end - end - end - - context 'when pipeline is for auto-devops' do - before do - pipeline.config_source = 'auto_devops_source' - end - - it 'does not return config file' do - is_expected.to be_nil - end - end - end - - describe '#set_config_source' do - context 'when pipelines does not contain needed data and auto devops is disabled' do - before do - stub_application_setting(auto_devops_enabled: false) - end - - it 'defines source to be unknown' do - pipeline.set_config_source - - expect(pipeline).to be_unknown_source - end - end - - context 'when pipeline contains all needed data' do - let(:pipeline) do - create(:ci_pipeline, project: project, - sha: '1234', - ref: 'master', - source: :push) - end - - context 'when the repository has a config file' do - before do - allow(project.repository).to receive(:gitlab_ci_yml_for) - .and_return('config') - end - - it 'defines source to be from repository' do - pipeline.set_config_source - - expect(pipeline).to be_repository_source - end - - context 'when loading an object' do - let(:new_pipeline) { Ci::Pipeline.find(pipeline.id) } - - it 'does not redefine the source' do - # force to overwrite the source - pipeline.unknown_source! - - expect(new_pipeline).to be_unknown_source - end - end - end - - context 'when the repository does not have a config file' do - let(:implied_yml) { Gitlab::Template::GitlabCiYmlTemplate.find('Auto-DevOps').content } - - context 'auto devops enabled' do - before do - allow(project).to receive(:ci_config_path) { 'custom' } - end - - it 'defines source to be auto devops' do - pipeline.set_config_source - - expect(pipeline).to be_auto_devops_source - end - end - end - end - end - - describe '#ci_yaml_file' do - let(:implied_yml) { Gitlab::Template::GitlabCiYmlTemplate.find('Auto-DevOps').content } - - context 'the source is unknown' do - before do - pipeline.unknown_source! - end - - it 'returns the configuration if found' do - allow(pipeline.project.repository).to receive(:gitlab_ci_yml_for) - .and_return('config') - - expect(pipeline.ci_yaml_file).to be_a(String) - expect(pipeline.ci_yaml_file).not_to eq(implied_yml) - expect(pipeline.yaml_errors).to be_nil - end - - it 'sets yaml errors if not found' do - expect(pipeline.ci_yaml_file).to be_nil - expect(pipeline.yaml_errors) - .to start_with('Failed to load CI/CD config file') - end - end - - context 'the source is the repository' do - before do - pipeline.repository_source! - end - - it 'returns the configuration if found' do - allow(pipeline.project.repository).to receive(:gitlab_ci_yml_for) - .and_return('config') - - expect(pipeline.ci_yaml_file).to be_a(String) - expect(pipeline.ci_yaml_file).not_to eq(implied_yml) - expect(pipeline.yaml_errors).to be_nil - end - end - - context 'when the source is auto_devops_source' do - before do - stub_application_setting(auto_devops_enabled: true) - pipeline.auto_devops_source! - end - - it 'finds the implied config' do - expect(pipeline.ci_yaml_file).to eq(implied_yml) - expect(pipeline.yaml_errors).to be_nil - end - end - end - describe '#update_status' do context 'when pipeline is empty' do it 'updates does not change pipeline status' do @@ -2675,7 +2385,7 @@ describe Ci::Pipeline, :mailer do stub_full_request(hook.url, method: :post) end - context 'with multiple builds' do + context 'with multiple builds', :sidekiq_might_not_need_inline do context 'when build is queued' do before do build_a.enqueue @@ -2886,24 +2596,19 @@ describe Ci::Pipeline, :mailer do end describe '#has_yaml_errors?' do - context 'when pipeline has errors' do - let(:pipeline) do - create(:ci_pipeline, config: { rspec: nil }) + context 'when yaml_errors is set' do + before do + pipeline.yaml_errors = 'File not found' end - it 'contains yaml errors' do + it 'returns true if yaml_errors is set' do expect(pipeline).to have_yaml_errors + expect(pipeline.yaml_errors).to include('File not foun') end end - context 'when pipeline does not have errors' do - let(:pipeline) do - create(:ci_pipeline, config: { rspec: { script: 'rake test' } }) - end - - it 'does not contain yaml errors' do - expect(pipeline).not_to have_yaml_errors - end + it 'returns false if yaml_errors is not set' do + expect(pipeline).not_to have_yaml_errors end end @@ -2930,7 +2635,7 @@ describe Ci::Pipeline, :mailer do end shared_examples 'sending a notification' do - it 'sends an email' do + it 'sends an email', :sidekiq_might_not_need_inline do should_only_email(pipeline.user, kind: :bcc) end end diff --git a/spec/models/clusters/applications/cert_manager_spec.rb b/spec/models/clusters/applications/cert_manager_spec.rb index c1933c578bcbc973b99e4689d1faeecb3d4498b3..6b85f9bb1273e0e7cb774ceddf839552fa9ac7aa 100644 --- a/spec/models/clusters/applications/cert_manager_spec.rb +++ b/spec/models/clusters/applications/cert_manager_spec.rb @@ -54,7 +54,7 @@ describe Clusters::Applications::CertManager do 'kubectl label --overwrite namespace gitlab-managed-apps certmanager.k8s.io/disable-validation=true' ]) expect(subject.postinstall).to eq([ - "for i in $(seq 1 30); do kubectl apply -f /data/helm/certmanager/config/cluster_issuer.yaml && s=0 && break || s=$?; sleep 1s; echo \"Retrying ($i)...\"; done; (exit $s)" + "for i in $(seq 1 90); do kubectl apply -f /data/helm/certmanager/config/cluster_issuer.yaml && s=0 && break || s=$?; sleep 1s; echo \"Retrying ($i)...\"; done; (exit $s)" ]) end diff --git a/spec/models/clusters/applications/crossplane_spec.rb b/spec/models/clusters/applications/crossplane_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ebc675497f49c6b4b9c3b2d6201a86744a9c43f0 --- /dev/null +++ b/spec/models/clusters/applications/crossplane_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::Applications::Crossplane do + let(:crossplane) { create(:clusters_applications_crossplane) } + + include_examples 'cluster application core specs', :clusters_applications_crossplane + include_examples 'cluster application status specs', :clusters_applications_crossplane + include_examples 'cluster application version specs', :clusters_applications_crossplane + include_examples 'cluster application initial status specs' + + describe 'validations' do + it { is_expected.to validate_presence_of(:stack) } + end + + describe '#can_uninstall?' do + subject { crossplane.can_uninstall? } + + it { is_expected.to be_truthy } + end + + describe '#install_command' do + let(:stack) { 'gcp' } + + subject { crossplane.install_command } + + it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::InstallCommand) } + + it 'is initialized with crossplane arguments' do + expect(subject.name).to eq('crossplane') + expect(subject.chart).to eq('crossplane/crossplane') + expect(subject.repository).to eq('https://charts.crossplane.io/alpha') + expect(subject.version).to eq('0.4.1') + expect(subject).to be_rbac + end + + context 'application failed to install previously' do + let(:crossplane) { create(:clusters_applications_crossplane, :errored, version: '0.0.1') } + + it 'is initialized with the locked version' do + expect(subject.version).to eq('0.4.1') + end + end + end + + describe '#files' do + let(:application) { crossplane } + let(:values) { subject[:'values.yaml'] } + + subject { application.files } + + it 'includes crossplane specific keys in the values.yaml file' do + expect(values).to include('clusterStacks') + end + end +end diff --git a/spec/models/clusters/applications/elastic_stack_spec.rb b/spec/models/clusters/applications/elastic_stack_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d0e0dd5ad57a099c4ffe1a603d8050c0e85486d6 --- /dev/null +++ b/spec/models/clusters/applications/elastic_stack_spec.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::Applications::ElasticStack do + include KubernetesHelpers + + include_examples 'cluster application core specs', :clusters_applications_elastic_stack + include_examples 'cluster application status specs', :clusters_applications_elastic_stack + 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) } + + subject { elastic_stack.install_command } + + it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::InstallCommand) } + + it 'is initialized with elastic stack arguments' do + expect(subject.name).to eq('elastic-stack') + expect(subject.chart).to eq('stable/elastic-stack') + expect(subject.version).to eq('1.8.0') + expect(subject).to be_rbac + expect(subject.files).to eq(elastic_stack.files) + end + + context 'on a non rbac enabled cluster' do + before do + elastic_stack.cluster.platform_kubernetes.abac! + end + + it { is_expected.not_to be_rbac } + end + + context 'application failed to install previously' do + let(:elastic_stack) { create(:clusters_applications_elastic_stack, :errored, version: '0.0.1') } + + it 'is initialized with the locked version' do + expect(subject.version).to eq('1.8.0') + end + end + 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) } + + subject { elastic_stack.uninstall_command } + + it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::DeleteCommand) } + + it 'is initialized with elastic stack arguments' do + expect(subject.name).to eq('elastic-stack') + expect(subject).to be_rbac + expect(subject.files).to eq(elastic_stack.files) + end + + it 'specifies a post delete command to remove custom resource definitions' do + expect(subject.postdelete).to eq([ + 'kubectl delete pvc --selector release\\=elastic-stack' + ]) + 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 + expect(subject.cluster).to be_nil + expect(subject.elasticsearch_client).to be_nil + end + end + + context "cluster doesn't have kubeclient" do + let(:cluster) { create(:cluster) } + subject { create(:clusters_applications_elastic_stack, cluster: cluster) } + + it 'returns nil' do + expect(subject.elasticsearch_client).to be_nil + end + end + + context 'cluster has kubeclient' do + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:kubernetes_url) { subject.cluster.platform_kubernetes.api_url } + let(:kube_client) { subject.cluster.kubeclient.core_client } + + subject { create(:clusters_applications_elastic_stack, cluster: cluster) } + + before do + subject.cluster.platform_kubernetes.namespace = 'a-namespace' + stub_kubeclient_discover(cluster.platform_kubernetes.api_url) + + create(:cluster_kubernetes_namespace, + cluster: cluster, + cluster_project: cluster.cluster_project, + project: cluster.cluster_project.project) + end + + it 'creates proxy elasticsearch_client' do + expect(subject.elasticsearch_client).to be_instance_of(Elasticsearch::Transport::Client) + end + + it 'copies proxy_url, options and headers from kube client to elasticsearch_client' do + expect(Elasticsearch::Client) + .to(receive(:new)) + .with(url: a_valid_url) + .and_call_original + + client = subject.elasticsearch_client + faraday_connection = client.transport.connections.first.connection + + expect(faraday_connection.headers["Authorization"]).to eq(kube_client.headers[:Authorization]) + expect(faraday_connection.ssl.cert_store).to be_instance_of(OpenSSL::X509::Store) + expect(faraday_connection.ssl.verify).to eq(1) + end + + context 'when cluster is not reachable' do + before do + allow(kube_client).to receive(:proxy_url).and_raise(Kubeclient::HttpError.new(401, 'Unauthorized', nil)) + end + + it 'returns nil' do + expect(subject.elasticsearch_client).to be_nil + end + end + end + end +end diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb index be0c6df7ad6da215f42d2af3dc93b73b1286232d..d7ad7867e1a502af3e940c0600f73dc8586ee830 100644 --- a/spec/models/clusters/applications/ingress_spec.rb +++ b/spec/models/clusters/applications/ingress_spec.rb @@ -21,7 +21,7 @@ describe Clusters::Applications::Ingress do describe '#can_uninstall?' do subject { ingress.can_uninstall? } - it 'returns true if application_jupyter_nil_or_installable? AND external_ip_or_hostname? are true' do + it 'returns true if external ip is set and no application exists' do ingress.external_ip = 'IP' is_expected.to be_truthy @@ -33,6 +33,12 @@ describe Clusters::Applications::Ingress do is_expected.to be_falsey end + it 'returns false if application_elastic_stack_nil_or_installable? is false' do + create(:clusters_applications_elastic_stack, :installed, cluster: ingress.cluster) + + is_expected.to be_falsey + end + it 'returns false if external_ip_or_hostname? is false' do is_expected.to be_falsey end @@ -150,6 +156,21 @@ describe Clusters::Applications::Ingress do it 'includes modsecurity core ruleset enablement' do expect(subject.values).to include("enable-owasp-modsecurity-crs: 'true'") end + + it 'includes modsecurity.conf content' do + expect(subject.values).to include('modsecurity.conf') + # Includes file content from Ingress#modsecurity_config_content + expect(subject.values).to include('SecAuditLog') + + expect(subject.values).to include('extraVolumes') + expect(subject.values).to include('extraVolumeMounts') + end + + it 'includes modsecurity sidecar container' do + expect(subject.values).to include('modsecurity-log-volume') + + expect(subject.values).to include('extraContainers') + end end context 'when ingress_modsecurity is disabled' do @@ -166,6 +187,21 @@ describe Clusters::Applications::Ingress do it 'excludes modsecurity core ruleset enablement' do expect(subject.values).not_to include('enable-owasp-modsecurity-crs') end + + it 'excludes modsecurity.conf content' do + expect(subject.values).not_to include('modsecurity.conf') + # Excludes file content from Ingress#modsecurity_config_content + expect(subject.values).not_to include('SecAuditLog') + + expect(subject.values).not_to include('extraVolumes') + expect(subject.values).not_to include('extraVolumeMounts') + end + + it 'excludes modsecurity sidecar container' do + expect(subject.values).not_to include('modsecurity-log-volume') + + expect(subject.values).not_to include('extraContainers') + end end end end diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb index 48e3b4d6baea3d643cdc2437e9342134ed183127..a163229e15ae8afec2071f6f9db55bae93d2f6ad 100644 --- a/spec/models/clusters/cluster_spec.rb +++ b/spec/models/clusters/cluster_spec.rb @@ -29,6 +29,7 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do it { is_expected.to delegate_method(:status).to(:provider) } it { is_expected.to delegate_method(:status_reason).to(:provider) } it { is_expected.to delegate_method(:on_creation?).to(:provider) } + it { is_expected.to delegate_method(:knative_pre_installed?).to(:provider) } it { is_expected.to delegate_method(:active?).to(:platform_kubernetes).with_prefix } it { is_expected.to delegate_method(:rbac?).to(:platform_kubernetes).with_prefix } it { is_expected.to delegate_method(:available?).to(:application_helm).with_prefix } @@ -55,7 +56,7 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do let!(:cluster) { create(:cluster, enabled: true) } before do - create(:cluster, enabled: false) + create(:cluster, :disabled) end it { is_expected.to contain_exactly(cluster) } @@ -64,7 +65,7 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do describe '.disabled' do subject { described_class.disabled } - let!(:cluster) { create(:cluster, enabled: false) } + let!(:cluster) { create(:cluster, :disabled) } before do create(:cluster, enabled: true) @@ -76,10 +77,10 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do describe '.user_provided' do subject { described_class.user_provided } - let!(:cluster) { create(:cluster, :provided_by_user) } + let!(:cluster) { create(:cluster_platform_kubernetes).cluster } before do - create(:cluster, :provided_by_gcp) + create(:cluster_provider_gcp, :created) end it { is_expected.to contain_exactly(cluster) } @@ -88,7 +89,7 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do describe '.gcp_provided' do subject { described_class.gcp_provided } - let!(:cluster) { create(:cluster, :provided_by_gcp) } + let!(:cluster) { create(:cluster_provider_gcp, :created).cluster } before do create(:cluster, :provided_by_user) @@ -100,7 +101,7 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do describe '.gcp_installed' do subject { described_class.gcp_installed } - let!(:cluster) { create(:cluster, :provided_by_gcp) } + let!(:cluster) { create(:cluster_provider_gcp, :created).cluster } before do create(:cluster, :providing_by_gcp) @@ -112,7 +113,7 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do describe '.aws_provided' do subject { described_class.aws_provided } - let!(:cluster) { create(:cluster, :provided_by_aws) } + let!(:cluster) { create(:cluster_provider_aws, :created).cluster } before do create(:cluster, :provided_by_user) @@ -124,11 +125,11 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do describe '.aws_installed' do subject { described_class.aws_installed } - let!(:cluster) { create(:cluster, :provided_by_aws) } + let!(:cluster) { create(:cluster_provider_aws, :created).cluster } before do - errored_cluster = create(:cluster, :provided_by_aws) - errored_cluster.provider.make_errored!("Error message") + errored_provider = create(:cluster_provider_aws) + errored_provider.make_errored!("Error message") end it { is_expected.to contain_exactly(cluster) } @@ -152,6 +153,16 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do end end + describe '.for_project_namespace' do + subject { described_class.for_project_namespace(namespace_id) } + + let!(:cluster) { create(:cluster, :project) } + let!(:another_cluster) { create(:cluster, :project) } + let(:namespace_id) { cluster.first_project.namespace_id } + + it { is_expected.to contain_exactly(cluster) } + end + describe 'validations' do subject { cluster.valid? } @@ -504,13 +515,15 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do let!(:helm) { create(:clusters_applications_helm, cluster: cluster) } let!(:ingress) { create(:clusters_applications_ingress, cluster: cluster) } let!(:cert_manager) { create(:clusters_applications_cert_manager, cluster: cluster) } + let!(:crossplane) { create(:clusters_applications_crossplane, cluster: cluster) } let!(:prometheus) { create(:clusters_applications_prometheus, cluster: cluster) } let!(:runner) { create(:clusters_applications_runner, cluster: cluster) } let!(:jupyter) { create(:clusters_applications_jupyter, cluster: cluster) } let!(:knative) { create(:clusters_applications_knative, cluster: cluster) } + let!(:elastic_stack) { create(:clusters_applications_elastic_stack, cluster: cluster) } it 'returns a list of created applications' do - is_expected.to contain_exactly(helm, ingress, cert_manager, prometheus, runner, jupyter, knative) + is_expected.to contain_exactly(helm, ingress, cert_manager, crossplane, prometheus, runner, jupyter, knative, elastic_stack) end end end @@ -675,12 +688,36 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do context 'the cluster has a provider' do let(:cluster) { create(:cluster, :provided_by_gcp) } + let(:provider_status) { :errored } before do cluster.provider.make_errored! end - it { is_expected.to eq :errored } + it { is_expected.to eq provider_status } + + context 'when cluster cleanup is ongoing' do + using RSpec::Parameterized::TableSyntax + + where(:status_name, :cleanup_status) do + provider_status | :cleanup_not_started + :cleanup_ongoing | :cleanup_uninstalling_applications + :cleanup_ongoing | :cleanup_removing_project_namespaces + :cleanup_ongoing | :cleanup_removing_service_account + :cleanup_errored | :cleanup_errored + end + + with_them do + it 'returns cleanup_ongoing when uninstalling applications' do + cluster.cleanup_status = described_class + .state_machines[:cleanup_status] + .states[cleanup_status] + .value + + is_expected.to eq status_name + end + end + end end context 'there is a cached connection status' do @@ -704,6 +741,83 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do end end + describe 'cleanup_status state_machine' do + shared_examples 'cleanup_status transition' do + let(:cluster) { create(:cluster, from_state) } + + it 'transitions cleanup_status correctly' do + expect { subject }.to change { cluster.cleanup_status_name } + .from(from_state).to(to_state) + end + + it 'schedules a Clusters::Cleanup::*Worker' do + expect(expected_worker_class).to receive(:perform_async).with(cluster.id) + subject + end + end + + describe '#start_cleanup!' do + let(:expected_worker_class) { Clusters::Cleanup::AppWorker } + let(:to_state) { :cleanup_uninstalling_applications } + + subject { cluster.start_cleanup! } + + context 'when cleanup_status is cleanup_not_started' do + let(:from_state) { :cleanup_not_started } + + it_behaves_like 'cleanup_status transition' + end + + context 'when cleanup_status is errored' do + let(:from_state) { :cleanup_errored } + + it_behaves_like 'cleanup_status transition' + end + end + + describe '#make_cleanup_errored!' do + NON_ERRORED_STATES = Clusters::Cluster.state_machines[:cleanup_status].states.keys - [:cleanup_errored] + + NON_ERRORED_STATES.each do |state| + it "transitions cleanup_status from #{state} to cleanup_errored" do + cluster = create(:cluster, state) + + expect { cluster.make_cleanup_errored! }.to change { cluster.cleanup_status_name } + .from(state).to(:cleanup_errored) + end + + it "sets error message" do + cluster = create(:cluster, state) + + expect { cluster.make_cleanup_errored!("Error Message") }.to change { cluster.cleanup_status_reason } + .from(nil).to("Error Message") + end + end + end + + describe '#continue_cleanup!' do + context 'when cleanup_status is cleanup_uninstalling_applications' do + let(:expected_worker_class) { Clusters::Cleanup::ProjectNamespaceWorker } + let(:from_state) { :cleanup_uninstalling_applications } + let(:to_state) { :cleanup_removing_project_namespaces } + + subject { cluster.continue_cleanup! } + + it_behaves_like 'cleanup_status transition' + end + + context 'when cleanup_status is cleanup_removing_project_namespaces' do + let(:expected_worker_class) { Clusters::Cleanup::ServiceAccountWorker } + let(:from_state) { :cleanup_removing_project_namespaces } + let(:to_state) { :cleanup_removing_service_account } + + subject { cluster.continue_cleanup! } + + it_behaves_like 'cleanup_status transition' + end + end + end + describe '#connection_status' do let(:cluster) { create(:cluster) } let(:status) { :connected } @@ -804,26 +918,4 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do end end end - - describe '#knative_pre_installed?' do - subject { cluster.knative_pre_installed? } - - context 'with a GCP provider without cloud_run' do - let(:cluster) { create(:cluster, :provided_by_gcp) } - - it { is_expected.to be_falsey } - end - - context 'with a GCP provider with cloud_run' do - let(:cluster) { create(:cluster, :provided_by_gcp, :cloud_run_enabled) } - - it { is_expected.to be_truthy } - end - - context 'with a user provider' do - let(:cluster) { create(:cluster, :provided_by_user) } - - it { is_expected.to be_falsey } - end - end end diff --git a/spec/models/clusters/clusters_hierarchy_spec.rb b/spec/models/clusters/clusters_hierarchy_spec.rb index fc35b8257e9503e50493243d101f1fa440d29fa9..1957e1fc5eecba16ba33c196cda44675ccc3c385 100644 --- a/spec/models/clusters/clusters_hierarchy_spec.rb +++ b/spec/models/clusters/clusters_hierarchy_spec.rb @@ -42,6 +42,28 @@ describe Clusters::ClustersHierarchy do it 'returns clusters for project' do expect(base_and_ancestors(cluster.project)).to eq([cluster]) end + + context 'cluster has management project' do + let(:management_project) { create(:project, namespace: cluster.first_project.namespace) } + + before do + cluster.update!(management_project: management_project) + end + + context 'management_project is in same namespace as cluster' do + it 'returns cluster for management_project' do + expect(base_and_ancestors(management_project)).to eq([cluster]) + end + end + + context 'management_project is in a different namespace from cluster' do + let(:management_project) { create(:project) } + + it 'returns nothing' do + expect(base_and_ancestors(management_project)).to be_empty + end + end + end end context 'cluster has management project' do @@ -50,16 +72,12 @@ describe Clusters::ClustersHierarchy do let(:group) { create(:group) } let(:project) { create(:project, group: group) } - let(:management_project) { create(:project) } + let(:management_project) { create(:project, group: group) } it 'returns clusters for management_project' do expect(base_and_ancestors(management_project)).to eq([group_cluster]) end - it 'returns nothing if include_management_project is false' do - expect(base_and_ancestors(management_project, include_management_project: false)).to be_empty - end - it 'returns clusters for project' do expect(base_and_ancestors(project)).to eq([project_cluster, group_cluster]) end @@ -70,17 +88,21 @@ describe Clusters::ClustersHierarchy do end context 'project in nested group with clusters at some levels' do - let!(:child) { create(:cluster, :group, groups: [child_group], management_project: management_project) } - let!(:ancestor) { create(:cluster, :group, groups: [ancestor_group]) } + let!(:child) { create(:cluster, :group, groups: [child_group]) } + let!(:ancestor) { create(:cluster, :group, groups: [ancestor_group], management_project: management_project) } let(:ancestor_group) { create(:group) } let(:parent_group) { create(:group, parent: ancestor_group) } let(:child_group) { create(:group, parent: parent_group) } let(:project) { create(:project, group: child_group) } - let(:management_project) { create(:project) } + let(:management_project) { create(:project, group: child_group) } + + it 'returns clusters for management_project' do + expect(base_and_ancestors(management_project)).to eq([ancestor, child]) + end it 'returns clusters for management_project' do - expect(base_and_ancestors(management_project)).to eq([child]) + expect(base_and_ancestors(management_project, include_management_project: false)).to eq([child, ancestor]) end it 'returns clusters for project' do diff --git a/spec/models/clusters/providers/aws_spec.rb b/spec/models/clusters/providers/aws_spec.rb index ec8159a7ee0f3b065fd23dcaab72789086a239e6..05d6e63288e1f54bea491255ef3876713ebbd379 100644 --- a/spec/models/clusters/providers/aws_spec.rb +++ b/spec/models/clusters/providers/aws_spec.rb @@ -4,7 +4,6 @@ require 'spec_helper' describe Clusters::Providers::Aws do it { is_expected.to belong_to(:cluster) } - it { is_expected.to belong_to(:created_by_user) } it { is_expected.to validate_length_of(:key_name).is_at_least(1).is_at_most(255) } it { is_expected.to validate_length_of(:region).is_at_least(1).is_at_most(255) } @@ -64,13 +63,72 @@ describe Clusters::Providers::Aws do before do expect(provider.access_key_id).to be_present expect(provider.secret_access_key).to be_present + expect(provider.session_token).to be_present end - it 'removes access_key_id and secret_access_key' do + it 'removes access_key_id, secret_access_key and session_token' do subject expect(provider.access_key_id).to be_nil expect(provider.secret_access_key).to be_nil + expect(provider.session_token).to be_nil end end + + describe '#api_client' do + let(:provider) { create(:cluster_provider_aws) } + let(:credentials) { double } + let(:client) { double } + + subject { provider.api_client } + + before do + allow(provider).to receive(:credentials).and_return(credentials) + + expect(Aws::CloudFormation::Client).to receive(:new) + .with(credentials: credentials, region: provider.region) + .and_return(client) + end + + it { is_expected.to eq client } + end + + describe '#credentials' do + let(:provider) { create(:cluster_provider_aws) } + let(:credentials) { double } + + subject { provider.credentials } + + before do + expect(Aws::Credentials).to receive(:new) + .with(provider.access_key_id, provider.secret_access_key, provider.session_token) + .and_return(credentials) + end + + it { is_expected.to eq credentials } + end + + describe '#created_by_user' do + let(:provider) { create(:cluster_provider_aws) } + + subject { provider.created_by_user } + + it { is_expected.to eq provider.cluster.user } + end + + describe '#has_rbac_enabled?' do + let(:provider) { create(:cluster_provider_aws) } + + subject { provider.has_rbac_enabled? } + + it { is_expected.to be_truthy } + end + + describe '#knative_pre_installed?' do + let(:provider) { create(:cluster_provider_aws) } + + subject { provider.knative_pre_installed? } + + it { is_expected.to be_falsey } + end end diff --git a/spec/models/clusters/providers/gcp_spec.rb b/spec/models/clusters/providers/gcp_spec.rb index 15e152519b42adc1739cd174fc9c461667c66d6e..e2fd777d13196d7a3ff7167d7f29f7537cf489a8 100644 --- a/spec/models/clusters/providers/gcp_spec.rb +++ b/spec/models/clusters/providers/gcp_spec.rb @@ -78,12 +78,20 @@ describe Clusters::Providers::Gcp do end end - describe '#legacy_abac?' do - let(:gcp) { build(:cluster_provider_gcp) } + describe '#has_rbac_enabled?' do + subject { gcp.has_rbac_enabled? } + + context 'when cluster is legacy_abac' do + let(:gcp) { create(:cluster_provider_gcp, :abac_enabled) } + + it { is_expected.to be_falsey } + end - subject { gcp } + context 'when cluster is not legacy_abac' do + let(:gcp) { create(:cluster_provider_gcp) } - it { is_expected.not_to be_legacy_abac } + it { is_expected.to be_truthy } + end end describe '#knative_pre_installed?' do diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index 95e9b0d0f92af9d50dd2978d9316ea3590af5f43..1e1b679a32c6dda168a41b6240a91208bfc788a2 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -522,7 +522,7 @@ describe CommitStatus do let(:stage) { Ci::Stage.first } - it 'creates a new stage' do + it 'creates a new stage', :sidekiq_might_not_need_inline do expect { commit_status }.to change { Ci::Stage.count }.by(1) expect(stage.name).to eq 'test' @@ -548,7 +548,7 @@ describe CommitStatus do status: :success) end - it 'uses existing stage' do + it 'uses existing stage', :sidekiq_might_not_need_inline do expect { commit_status }.not_to change { Ci::Stage.count } expect(commit_status.stage_id).to eq stage.id diff --git a/spec/models/concerns/deployment_platform_spec.rb b/spec/models/concerns/deployment_platform_spec.rb index f99bf18768f0121d42f969b3b9ba61eebee85393..9164c3a75c5d72c620abdb7bccab45c4f83d8413 100644 --- a/spec/models/concerns/deployment_platform_spec.rb +++ b/spec/models/concerns/deployment_platform_spec.rb @@ -13,7 +13,11 @@ describe DeploymentPlatform do end context 'when project is the cluster\'s management project ' do - let!(:cluster_with_management_project) { create(:cluster, :provided_by_user, management_project: project) } + let(:another_project) { create(:project, namespace: project.namespace) } + + let!(:cluster_with_management_project) do + create(:cluster, :provided_by_user, projects: [another_project], management_project: project) + end context 'cluster_management_project feature is enabled' do it 'returns the cluster with management project' do @@ -66,7 +70,11 @@ describe DeploymentPlatform do end context 'when project is the cluster\'s management project ' do - let!(:cluster_with_management_project) { create(:cluster, :provided_by_user, management_project: project) } + let(:another_project) { create(:project, namespace: project.namespace) } + + let!(:cluster_with_management_project) do + create(:cluster, :provided_by_user, projects: [another_project], management_project: project) + end context 'cluster_management_project feature is enabled' do it 'returns the cluster with management project' do @@ -130,5 +138,13 @@ describe DeploymentPlatform do end end end + + context 'when instance has configured kubernetes cluster' do + let!(:instance_cluster) { create(:cluster, :provided_by_user, :instance) } + + it 'returns the Kubernetes platform' do + is_expected.to eq(instance_cluster.platform_kubernetes) + end + end end end diff --git a/spec/models/concerns/from_union_spec.rb b/spec/models/concerns/from_union_spec.rb index ee427a667c6d475e6064b4024557e9556e0c2f5a..735e14b47ec73ecfa83ac1e40e2323a4517a1f91 100644 --- a/spec/models/concerns/from_union_spec.rb +++ b/spec/models/concerns/from_union_spec.rb @@ -15,7 +15,7 @@ describe FromUnion do it 'selects from the results of the UNION' do query = model.from_union([model.where(id: 1), model.where(id: 2)]) - expect(query.to_sql).to match(/FROM \(SELECT.+UNION.+SELECT.+\) users/m) + expect(query.to_sql).to match(/FROM \(\(SELECT.+\)\nUNION\n\(SELECT.+\)\) users/m) end it 'supports the use of a custom alias for the sub query' do @@ -24,7 +24,7 @@ describe FromUnion do alias_as: 'kittens' ) - expect(query.to_sql).to match(/FROM \(SELECT.+UNION.+SELECT.+\) kittens/m) + expect(query.to_sql).to match(/FROM \(\(SELECT.+\)\nUNION\n\(SELECT.+\)\) kittens/m) end it 'supports keeping duplicate rows' do @@ -34,7 +34,7 @@ describe FromUnion do ) expect(query.to_sql) - .to match(/FROM \(SELECT.+UNION ALL.+SELECT.+\) users/m) + .to match(/FROM \(\(SELECT.+\)\nUNION ALL\n\(SELECT.+\)\) users/m) end end end diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index e8116f0a301fdc07abc84f5eb5790ad02bcc5d5c..f7bef9e71e27319c1270428f7f0fcd5ba2e7a249 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -111,6 +111,34 @@ describe Issuable do end end + describe '.initialize' do + it 'maps the state to the right state_id' do + described_class::STATE_ID_MAP.each do |key, value| + issuable = MergeRequest.new(state: key) + + expect(issuable.state).to eq(key) + expect(issuable.state_id).to eq(value) + end + end + + it 'maps a string version of the state to the right state_id' do + described_class::STATE_ID_MAP.each do |key, value| + issuable = MergeRequest.new('state' => key) + + expect(issuable.state).to eq(key) + expect(issuable.state_id).to eq(value) + end + end + + it 'gives preference to state_id if present' do + issuable = MergeRequest.new('state' => 'opened', + 'state_id' => described_class::STATE_ID_MAP['merged']) + + expect(issuable.state).to eq('merged') + expect(issuable.state_id).to eq(described_class::STATE_ID_MAP['merged']) + end + end + describe '#milestone_available?' do let(:group) { create(:group) } let(:project) { create(:project, group: group) } diff --git a/spec/models/concerns/noteable_spec.rb b/spec/models/concerns/noteable_spec.rb index f823ac0165f443f5428e5ae3ccc1cf8555118e42..e8991a3a01588adb0411099de1efd5aa4e6f9464 100644 --- a/spec/models/concerns/noteable_spec.rb +++ b/spec/models/concerns/noteable_spec.rb @@ -177,50 +177,6 @@ describe Noteable do end end - describe "#discussions_to_be_resolved?" do - context "when discussions are not resolvable" do - before do - allow(subject).to receive(:discussions_resolvable?).and_return(false) - end - - it "returns false" do - expect(subject.discussions_to_be_resolved?).to be false - end - end - - context "when discussions are resolvable" do - before do - allow(subject).to receive(:discussions_resolvable?).and_return(true) - - allow(first_discussion).to receive(:resolvable?).and_return(true) - allow(second_discussion).to receive(:resolvable?).and_return(false) - allow(third_discussion).to receive(:resolvable?).and_return(true) - end - - context "when all resolvable discussions are resolved" do - before do - allow(first_discussion).to receive(:resolved?).and_return(true) - allow(third_discussion).to receive(:resolved?).and_return(true) - end - - it "returns false" do - expect(subject.discussions_to_be_resolved?).to be false - end - end - - context "when some resolvable discussions are not resolved" do - before do - allow(first_discussion).to receive(:resolved?).and_return(true) - allow(third_discussion).to receive(:resolved?).and_return(false) - end - - it "returns true" do - expect(subject.discussions_to_be_resolved?).to be true - end - end - end - end - describe "#discussions_to_be_resolved" do before do allow(first_discussion).to receive(:to_be_resolved?).and_return(true) diff --git a/spec/models/concerns/redactable_spec.rb b/spec/models/concerns/redactable_spec.rb index 57c7d2cb767fda617eaf8fcc824dc8e05077dbb9..3f6a2e2410caf9a7ffd0e62996d0da6213c1577f 100644 --- a/spec/models/concerns/redactable_spec.rb +++ b/spec/models/concerns/redactable_spec.rb @@ -7,44 +7,6 @@ describe Redactable do stub_commonmark_sourcepos_disabled end - shared_examples 'model with redactable field' do - it 'redacts unsubscribe token' do - model[field] = 'some text /sent_notifications/00000000000000000000000000000000/unsubscribe more text' - - model.save! - - expect(model[field]).to eq 'some text /sent_notifications/REDACTED/unsubscribe more text' - end - - it 'ignores not hexadecimal tokens' do - text = 'some text /sent_notifications/token/unsubscribe more text' - model[field] = text - - model.save! - - expect(model[field]).to eq text - end - - it 'ignores not matching texts' do - text = 'some text /sent_notifications/.*/unsubscribe more text' - model[field] = text - - model.save! - - expect(model[field]).to eq text - end - - it 'redacts the field when saving the model before creating markdown cache' do - model[field] = 'some text /sent_notifications/00000000000000000000000000000000/unsubscribe more text' - - model.save! - - expected = 'some text /sent_notifications/REDACTED/unsubscribe more text' - expect(model[field]).to eq expected - expect(model["#{field}_html"]).to eq "<p dir=\"auto\">#{expected}</p>" - end - end - context 'when model is an issue' do it_behaves_like 'model with redactable field' do let(:model) { create(:issue) } diff --git a/spec/models/concerns/subscribable_spec.rb b/spec/models/concerns/subscribable_spec.rb index 2f88adf08ddfa1095e857770f15f7d69407fbeb3..f189cd7633cebd400e1b94d13af7497f41ae31fd 100644 --- a/spec/models/concerns/subscribable_spec.rb +++ b/spec/models/concerns/subscribable_spec.rb @@ -133,4 +133,60 @@ describe Subscribable, 'Subscribable' do end end end + + describe '#set_subscription' do + shared_examples 'setting subscriptions' do + context 'when desired_state is set to true' do + context 'when a user is subscribed to the resource' do + it 'keeps the user subscribed' do + resource.subscriptions.create(user: user_1, subscribed: true, project: resource_project) + + resource.set_subscription(user_1, true, resource_project) + + expect(resource.subscribed?(user_1, resource_project)).to be_truthy + end + end + + context 'when a user is not subscribed to the resource' do + it 'subscribes the user to the resource' do + expect { resource.set_subscription(user_1, true, resource_project) } + .to change { resource.subscribed?(user_1, resource_project) } + .from(false).to(true) + end + end + end + + context 'when desired_state is set to false' do + context 'when a user is subscribed to the resource' do + it 'unsubscribes the user from the resource' do + resource.subscriptions.create(user: user_1, subscribed: true, project: resource_project) + + expect { resource.set_subscription(user_1, false, resource_project) } + .to change { resource.subscribed?(user_1, resource_project) } + .from(true).to(false) + end + end + + context 'when a user is not subscribed to the resource' do + it 'keeps the user unsubscribed' do + resource.set_subscription(user_1, false, resource_project) + + expect(resource.subscribed?(user_1, resource_project)).to be_falsey + end + end + end + end + + context 'without project' do + let(:resource_project) { nil } + + it_behaves_like 'setting subscriptions' + end + + context 'with project' do + let(:resource_project) { project } + + it_behaves_like 'setting subscriptions' + end + end end diff --git a/spec/models/container_repository_spec.rb b/spec/models/container_repository_spec.rb index eea539746a5bc8bc57afdd5e6dc030c63d7e08a9..0a3065140bf8590cd0c80a16ca6798d0cc29878d 100644 --- a/spec/models/container_repository_spec.rb +++ b/spec/models/container_repository_spec.rb @@ -235,4 +235,36 @@ describe ContainerRepository do expect(repository).not_to be_persisted end end + + describe '.for_group_and_its_subgroups' do + subject { described_class.for_group_and_its_subgroups(test_group) } + + context 'in a group' do + let(:test_group) { group } + + it { is_expected.to contain_exactly(repository) } + end + + context 'with a subgroup' do + let(:test_group) { create(:group) } + let(:another_project) { create(:project, path: 'test', group: test_group) } + + let(:another_repository) do + create(:container_repository, name: 'my_image', project: another_project) + end + + before do + group.parent = test_group + group.save + end + + it { is_expected.to contain_exactly(repository, another_repository) } + end + + context 'group without container_repositories' do + let(:test_group) { create(:group) } + + it { is_expected.to eq([]) } + end + end end diff --git a/spec/models/deployment_merge_request_spec.rb b/spec/models/deployment_merge_request_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..fd5be52d47cdaf43386d66ae3d829ec95f442e2d --- /dev/null +++ b/spec/models/deployment_merge_request_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe DeploymentMergeRequest do + let(:mr) { create(:merge_request, :merged) } + let(:deployment) { create(:deployment, :success, project: project) } + let(:project) { mr.project } + + subject { described_class.new(deployment: deployment, merge_request: mr) } + + it { is_expected.to belong_to(:deployment).required } + it { is_expected.to belong_to(:merge_request).required } +end diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb index 3a0b3c46ad0c99b7abf26ece5b97df521d354275..52c19d4814c07f147e21b68bf38d5d98b8785a45 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -10,6 +10,8 @@ describe Deployment do it { is_expected.to belong_to(:cluster).class_name('Clusters::Cluster') } it { is_expected.to belong_to(:user) } it { is_expected.to belong_to(:deployable) } + it { is_expected.to have_many(:deployment_merge_requests) } + it { is_expected.to have_many(:merge_requests).through(:deployment_merge_requests) } it { is_expected.to delegate_method(:name).to(:environment).with_prefix } it { is_expected.to delegate_method(:commit).to(:project) } @@ -361,4 +363,82 @@ describe Deployment do .to raise_error(ActiveRecord::RecordNotFound) end end + + describe '#previous_deployment' do + it 'returns the previous deployment' do + deploy1 = create(:deployment) + deploy2 = create( + :deployment, + project: deploy1.project, + environment: deploy1.environment + ) + + expect(deploy2.previous_deployment).to eq(deploy1) + end + end + + describe '#link_merge_requests' do + it 'links merge requests with a deployment' do + deploy = create(:deployment) + mr1 = create( + :merge_request, + :merged, + target_project: deploy.project, + source_project: deploy.project + ) + + 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') + deploy2 = create( + :deployment, + :success, + project: deploy1.project, + environment: deploy1.environment, + ref: 'v1.0.1' + ) + + 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') + deploy2 = create( + :deployment, + :success, + project: deploy1.project, + environment: deploy1.environment, + ref: 'v1.0.1' + ) + + expect(deploy2.previous_environment_deployment).to be_nil + end + + it 'ignores deployments for different environments' do + deploy1 = create(:deployment, :success, ref: 'v1.0.0') + preprod = create(:environment, project: deploy1.project, name: 'preprod') + deploy2 = create( + :deployment, + :success, + project: deploy1.project, + environment: preprod, + ref: 'v1.0.1' + ) + + expect(deploy2.previous_environment_deployment).to be_nil + end + end end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 786f3b832c442c440c6ae39a5ef53831d9d8d598..47e39e5fbe5cab6123668d51793139554f00ea1e 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' describe Environment, :use_clean_rails_memory_store_caching do include ReactiveCachingHelpers using RSpec::Parameterized::TableSyntax + include RepoHelpers let(:project) { create(:project, :stubbed_repository) } subject(:environment) { create(:environment, project: project) } @@ -259,7 +260,7 @@ describe Environment, :use_clean_rails_memory_store_caching do let(:head_commit) { project.commit } let(:commit) { project.commit.parent } - it 'returns deployment id for the environment' do + it 'returns deployment id for the environment', :sidekiq_might_not_need_inline do expect(environment.first_deployment_for(commit.id)).to eq deployment1 end @@ -267,7 +268,7 @@ describe Environment, :use_clean_rails_memory_store_caching do expect(environment.first_deployment_for(head_commit.id)).to eq nil end - it 'returns a UTF-8 ref' do + it 'returns a UTF-8 ref', :sidekiq_might_not_need_inline do expect(environment.first_deployment_for(commit.id).ref).to be_utf8 end end @@ -505,6 +506,14 @@ describe Environment, :use_clean_rails_memory_store_caching do end end + context 'when there is a deployment record with failed status' do + let!(:deployment) { create(:deployment, :failed, environment: environment) } + + it 'returns the previous deployment' do + is_expected.to eq(previous_deployment) + end + end + context 'when there is a deployment record with success status' do let!(:deployment) { create(:deployment, :success, environment: environment) } @@ -515,6 +524,131 @@ describe Environment, :use_clean_rails_memory_store_caching do end end + describe '#last_visible_deployment' do + subject { environment.last_visible_deployment } + + before do + allow_any_instance_of(Deployment).to receive(:create_ref) + end + + context 'when there is an old deployment record' do + let!(:previous_deployment) { create(:deployment, :success, environment: environment) } + + context 'when there is a deployment record with created status' do + let!(:deployment) { create(:deployment, environment: environment) } + + it { is_expected.to eq(previous_deployment) } + end + + context 'when there is a deployment record with running status' do + let!(:deployment) { create(:deployment, :running, environment: environment) } + + it { is_expected.to eq(deployment) } + end + + context 'when there is a deployment record with success status' do + let!(:deployment) { create(:deployment, :success, environment: environment) } + + it { is_expected.to eq(deployment) } + end + + context 'when there is a deployment record with failed status' do + let!(:deployment) { create(:deployment, :failed, environment: environment) } + + it { is_expected.to eq(deployment) } + end + + context 'when there is a deployment record with canceled status' do + let!(:deployment) { create(:deployment, :canceled, environment: environment) } + + it { is_expected.to eq(deployment) } + end + end + end + + describe '#last_visible_pipeline' do + let(:user) { create(:user) } + let_it_be(:project) { create(:project, :repository) } + let(:environment) { create(:environment, project: project) } + let(:commit) { project.commit } + + let(:success_pipeline) do + create(:ci_pipeline, :success, project: project, user: user, sha: commit.sha) + end + + let(:failed_pipeline) do + create(:ci_pipeline, :failed, project: project, user: user, sha: commit.sha) + end + + it 'uses the last deployment even if it failed' do + pipeline = create(:ci_pipeline, project: project, user: user, sha: commit.sha) + ci_build = create(:ci_build, project: project, pipeline: pipeline) + create(:deployment, :failed, project: project, environment: environment, deployable: ci_build, sha: commit.sha) + + last_pipeline = environment.last_visible_pipeline + + expect(last_pipeline).to eq(pipeline) + end + + it 'returns nil if there is no deployment' do + create(:ci_build, project: project, pipeline: success_pipeline) + + expect(environment.last_visible_pipeline).to be_nil + end + + it 'does not return an invisible pipeline' do + failed_pipeline = create(:ci_pipeline, project: project, user: user, sha: commit.sha) + ci_build_a = create(:ci_build, project: project, pipeline: failed_pipeline) + create(:deployment, :failed, project: project, environment: environment, deployable: ci_build_a, sha: commit.sha) + pipeline = create(:ci_pipeline, project: project, user: user, sha: commit.sha) + ci_build_b = create(:ci_build, project: project, pipeline: pipeline) + create(:deployment, :created, project: project, environment: environment, deployable: ci_build_b, sha: commit.sha) + + last_pipeline = environment.last_visible_pipeline + + expect(last_pipeline).to eq(failed_pipeline) + end + + context 'for the environment' do + it 'returns the last pipeline' do + pipeline = create(:ci_pipeline, project: project, user: user, sha: commit.sha) + ci_build = create(:ci_build, project: project, pipeline: pipeline) + create(:deployment, :success, project: project, environment: environment, deployable: ci_build, sha: commit.sha) + + last_pipeline = environment.last_visible_pipeline + + expect(last_pipeline).to eq(pipeline) + end + + context 'with multiple deployments' do + it 'returns the last pipeline' do + pipeline_a = create(:ci_pipeline, project: project, user: user) + pipeline_b = create(:ci_pipeline, project: project, user: user) + ci_build_a = create(:ci_build, project: project, pipeline: pipeline_a) + ci_build_b = create(:ci_build, project: project, pipeline: pipeline_b) + create(:deployment, :success, project: project, environment: environment, deployable: ci_build_a) + create(:deployment, :success, project: project, environment: environment, deployable: ci_build_b) + + last_pipeline = environment.last_visible_pipeline + + expect(last_pipeline).to eq(pipeline_b) + end + end + + context 'with multiple pipelines' do + it 'returns the last pipeline' do + create(:ci_build, project: project, pipeline: success_pipeline) + ci_build_b = create(:ci_build, project: project, pipeline: failed_pipeline) + create(:deployment, :failed, project: project, environment: environment, deployable: ci_build_b, sha: commit.sha) + + last_pipeline = environment.last_visible_pipeline + + expect(last_pipeline).to eq(failed_pipeline) + end + end + end + end + describe '#has_terminals?' do subject { environment.has_terminals? } @@ -610,6 +744,12 @@ describe Environment, :use_clean_rails_memory_store_caching do allow(environment).to receive(:deployment_platform).and_return(double) end + context 'reactive cache configuration' do + it 'does not continue to spawn jobs' do + expect(described_class.reactive_cache_lifetime).to be < described_class.reactive_cache_refresh_interval + end + end + context 'reactive cache is empty' do before do stub_reactive_cache(environment, nil) @@ -727,6 +867,51 @@ describe Environment, :use_clean_rails_memory_store_caching do end end + describe '#prometheus_status' do + context 'when a cluster is present' do + context 'when a deployment platform is present' do + let(:cluster) { create(:cluster, :provided_by_user, :project) } + let(:environment) { create(:environment, project: cluster.project) } + + subject { environment.prometheus_status } + + context 'when the prometheus application status is :updating' do + let!(:prometheus) { create(:clusters_applications_prometheus, :updating, cluster: cluster) } + + it { is_expected.to eq(:updating) } + end + + context 'when the prometheus application state is :updated' do + let!(:prometheus) { create(:clusters_applications_prometheus, :updated, cluster: cluster) } + + it { is_expected.to eq(:updated) } + end + + context 'when the prometheus application is not installed' do + it { is_expected.to be_nil } + end + end + + context 'when a deployment platform is not present' do + let(:cluster) { create(:cluster, :project) } + let(:environment) { create(:environment, project: cluster.project) } + + subject { environment.prometheus_status } + + it { is_expected.to be_nil } + end + end + + context 'when a cluster is not present' do + let(:project) { create(:project, :stubbed_repository) } + let(:environment) { create(:environment, project: project) } + + subject { environment.prometheus_status } + + it { is_expected.to be_nil } + end + end + describe '#additional_metrics' do let(:project) { create(:prometheus_project) } let(:metric_params) { [] } diff --git a/spec/models/environment_status_spec.rb b/spec/models/environment_status_spec.rb index 01d331f518bbfe0641c7393f0861cb614d0c6d74..eea81d7c128dfb8122937cae23fbc65e12e1adbb 100644 --- a/spec/models/environment_status_spec.rb +++ b/spec/models/environment_status_spec.rb @@ -113,7 +113,7 @@ describe EnvironmentStatus do head_pipeline: pipeline) end - it 'returns environment status' do + it 'returns environment status', :sidekiq_might_not_need_inline do expect(subject.count).to eq(1) expect(subject[0].environment).to eq(environment) expect(subject[0].merge_request).to eq(merge_request) 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 21e381d9fb7c3776fa9f40a6165a98d6256a633d..dbd3f8ffab30f329607c7f53d1f202a4a6ce9829 100644 --- a/spec/models/error_tracking/project_error_tracking_setting_spec.rb +++ b/spec/models/error_tracking/project_error_tracking_setting_spec.rb @@ -208,6 +208,28 @@ describe ErrorTracking::ProjectErrorTrackingSetting do expect(sentry_client).to have_received(:list_issues) end end + + context 'when sentry client raises Sentry::Client::ResponseInvalidSizeError' do + let(:sentry_client) { spy(:sentry_client) } + let(:error_msg) {"Sentry API response is too big. Limit is #{Gitlab::Utils::DeepSize.human_default_max_size}."} + + 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(Sentry::Client::ResponseInvalidSizeError, error_msg) + end + + it 'returns error' do + expect(result).to eq( + 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 end describe '#list_sentry_projects' do diff --git a/spec/models/evidence_spec.rb b/spec/models/evidence_spec.rb index 00788c2c391ad4a2d48148807d9d98e06be0195e..8f534517fc17a4d4c58746e403dd17bb22e36b83 100644 --- a/spec/models/evidence_spec.rb +++ b/spec/models/evidence_spec.rb @@ -27,7 +27,7 @@ describe Evidence do let(:release) { create(:release, project: project, name: nil) } it 'creates a valid JSON object' do - expect(release.name).to be_nil + expect(release.name).to eq(release.tag) expect(summary_json).to match_schema(schema_file) end end diff --git a/spec/models/grafana_integration_spec.rb b/spec/models/grafana_integration_spec.rb index f8973097a40f74b8eed23085e506d390a15cbe16..615865e17b9e9e59034d82b625baf893bd1bb9c7 100644 --- a/spec/models/grafana_integration_spec.rb +++ b/spec/models/grafana_integration_spec.rb @@ -34,5 +34,36 @@ describe GrafanaIntegration do internal_url ).for(:grafana_url) end + + it 'disallows non-booleans in enabled column' do + is_expected.not_to allow_value( + nil + ).for(:enabled) + end + + it 'allows booleans in enabled column' do + is_expected.to allow_value( + true, + false + ).for(:enabled) + end + end + + describe '.client' do + subject(:grafana_integration) { create(:grafana_integration) } + + context 'with grafana integration disabled' do + it 'returns a grafana client' do + expect(grafana_integration.client).to be_an_instance_of(::Grafana::Client) + end + end + + context 'with grafana integration enabled' do + it 'returns nil' do + grafana_integration.update(enabled: false) + + expect(grafana_integration.client).to be(nil) + end + end end end diff --git a/spec/models/group_group_link_spec.rb b/spec/models/group_group_link_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e4ad5703a109717c614e0d6ff9a7d9e458a7df27 --- /dev/null +++ b/spec/models/group_group_link_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe GroupGroupLink do + let_it_be(:group) { create(:group) } + let_it_be(:shared_group) { create(:group) } + let_it_be(:group_group_link) do + create(:group_group_link, shared_group: shared_group, + shared_with_group: group) + end + + describe 'relations' do + it { is_expected.to belong_to(:shared_group) } + it { is_expected.to belong_to(:shared_with_group) } + end + + describe 'validation' do + it { is_expected.to validate_presence_of(:shared_group) } + + it do + is_expected.to( + validate_uniqueness_of(:shared_group_id) + .scoped_to(:shared_with_group_id) + .with_message('The group has already been shared with this group')) + end + + it { is_expected.to validate_presence_of(:shared_with_group) } + it { is_expected.to validate_presence_of(:group_access) } + + it do + is_expected.to( + validate_inclusion_of(:group_access).in_array(Gitlab::Access.values)) + end + end +end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 520421ac5e3cda42c58322c6aefdb6c3056f45d4..3fa9d71cc7d9348a4d8bbd3aa273d87b39d024cb 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -525,6 +525,128 @@ describe Group do it { expect(subject.parent).to be_kind_of(described_class) } end + describe '#max_member_access_for_user' do + context 'group shared with another group' do + let(:parent_group_user) { create(:user) } + let(:group_user) { create(:user) } + let(:child_group_user) { create(:user) } + + let_it_be(:group_parent) { create(:group, :private) } + let_it_be(:group) { create(:group, :private, parent: group_parent) } + let_it_be(:group_child) { create(:group, :private, parent: group) } + + let_it_be(:shared_group_parent) { create(:group, :private) } + let_it_be(:shared_group) { create(:group, :private, parent: shared_group_parent) } + let_it_be(:shared_group_child) { create(:group, :private, parent: shared_group) } + + before do + group_parent.add_owner(parent_group_user) + group.add_owner(group_user) + group_child.add_owner(child_group_user) + + create(:group_group_link, { shared_with_group: group, + shared_group: shared_group, + group_access: GroupMember::DEVELOPER }) + end + + context 'when feature flag share_group_with_group is enabled' do + before do + stub_feature_flags(share_group_with_group: true) + end + + context 'with user in the group' do + let(:user) { group_user } + + it 'returns correct access level' do + expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::DEVELOPER) + expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::DEVELOPER) + end + end + + context 'with user in the parent group' do + let(:user) { parent_group_user } + + it 'returns correct access level' do + expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + end + end + + context 'with user in the child group' do + let(:user) { child_group_user } + + it 'returns correct access level' do + expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + end + end + end + + context 'when feature flag share_group_with_group is disabled' do + before do + stub_feature_flags(share_group_with_group: false) + end + + context 'with user in the group' do + let(:user) { group_user } + + it 'returns correct access level' do + expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + end + end + + context 'with user in the parent group' do + let(:user) { parent_group_user } + + it 'returns correct access level' do + expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + end + end + + context 'with user in the child group' do + let(:user) { child_group_user } + + it 'returns correct access level' do + expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + end + end + end + end + + context 'multiple groups shared with group' do + let(:user) { create(:user) } + let(:group) { create(:group, :private) } + let(:shared_group_parent) { create(:group, :private) } + let(:shared_group) { create(:group, :private, parent: shared_group_parent) } + + before do + stub_feature_flags(share_group_with_group: true) + + group.add_owner(user) + + create(:group_group_link, { shared_with_group: group, + shared_group: shared_group, + group_access: GroupMember::DEVELOPER }) + create(:group_group_link, { shared_with_group: group, + shared_group: shared_group_parent, + group_access: GroupMember::MAINTAINER }) + end + + it 'returns correct access level' do + expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::MAINTAINER) + end + end + end + describe '#members_with_parents' do let!(:group) { create(:group, :nested) } let!(:maintainer) { group.parent.add_user(create(:user), GroupMember::MAINTAINER) } diff --git a/spec/models/hooks/system_hook_spec.rb b/spec/models/hooks/system_hook_spec.rb index a4d202dc4f8aa10f3b1f2f601a1c052f042d8574..94f1b0cba2e4ca49b890e7929d31d5845081671f 100644 --- a/spec/models/hooks/system_hook_spec.rb +++ b/spec/models/hooks/system_hook_spec.rb @@ -16,7 +16,7 @@ describe SystemHook do end end - describe "execute" do + describe "execute", :sidekiq_might_not_need_inline do let(:system_hook) { create(:system_hook) } let(:user) { create(:user) } let(:project) { create(:project, namespace: user.namespace) } diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 18a1a30eee54c5aa6f1fd3593e741582692a9218..0f78cb4d9b1a19820dc789e3b111b69340f27593 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -423,6 +423,19 @@ describe Issue do issue = create(:issue, title: 'testing-issue', confidential: true) expect(issue.to_branch_name).to match /confidential-issue\z/ end + + context 'issue title longer than 100 characters' do + let(:issue) { create(:issue, iid: 999, title: 'Lorem ipsum dolor sit amet consectetur adipiscing elit Mauris sit amet ipsum id lacus custom fringilla convallis') } + + it "truncates branch name to at most 100 characters" do + expect(issue.to_branch_name.length).to be <= 100 + end + + it "truncates dangling parts of the branch name" do + # 100 characters would've got us "999-lorem...lacus-custom-fri". + expect(issue.to_branch_name).to eq("999-lorem-ipsum-dolor-sit-amet-consectetur-adipiscing-elit-mauris-sit-amet-ipsum-id-lacus-custom") + end + end end describe '#can_be_worked_on?' do diff --git a/spec/models/lfs_object_spec.rb b/spec/models/lfs_object_spec.rb index 47cae5cf197ddd50196bf9bab8c63ed3b322b9e5..44445429d3edcf72b88c9eb15e33523b338ffb78 100644 --- a/spec/models/lfs_object_spec.rb +++ b/spec/models/lfs_object_spec.rb @@ -3,6 +3,18 @@ require 'spec_helper' describe LfsObject do + context 'scopes' do + describe '.not_existing_in_project' do + it 'contains only lfs objects not linked to the project' do + project = create(:project) + create(:lfs_objects_project, project: project) + other_lfs_object = create(:lfs_object) + + expect(described_class.not_linked_to_project(project)).to contain_exactly(other_lfs_object) + end + end + end + it 'has a distinct has_many :projects relation through lfs_objects_projects' do lfs_object = create(:lfs_object) project = create(:project) diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb index b86663fd7d9801734282449c70611517a158ea52..0f7f68e0b38e182343cb3f4ebee9992a40e4f061 100644 --- a/spec/models/merge_request_diff_spec.rb +++ b/spec/models/merge_request_diff_spec.rb @@ -378,6 +378,14 @@ describe MergeRequestDiff do expect(diff_with_commits.commit_shas).not_to be_empty expect(diff_with_commits.commit_shas).to all(match(/\h{40}/)) end + + context 'with limit attribute' do + it 'returns limited number of shas' do + expect(diff_with_commits.commit_shas(limit: 2).size).to eq(2) + expect(diff_with_commits.commit_shas(limit: 100).size).to eq(29) + expect(diff_with_commits.commit_shas.size).to eq(29) + end + end end describe '#compare_with' do diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index ad79bee880141530b4acf350c36f448af9700d34..b5aa05fd8b4c52924dc9b63515c62f79f548533f 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -283,6 +283,16 @@ describe MergeRequest do end end + describe '.by_merge_commit_sha' do + it 'returns merge requests that match the given merge commit' do + mr = create(:merge_request, :merged, merge_commit_sha: '123abc') + + create(:merge_request, :merged, merge_commit_sha: '123def') + + expect(described_class.by_merge_commit_sha('123abc')).to eq([mr]) + end + end + describe '.in_projects' do it 'returns the merge requests for a set of projects' do expect(described_class.in_projects(Project.all)).to eq([subject]) @@ -1190,7 +1200,7 @@ describe MergeRequest do context 'diverged on fork' do subject(:merge_request_fork_with_divergence) { create(:merge_request, :diverged, source_project: forked_project, target_project: project) } - it 'counts commits that are on target branch but not on source branch' do + it 'counts commits that are on target branch but not on source branch', :sidekiq_might_not_need_inline do expect(subject.diverged_commits_count).to eq(29) end end @@ -1251,13 +1261,49 @@ describe MergeRequest do end describe '#commit_shas' do - before do - allow(subject.merge_request_diff).to receive(:commit_shas) - .and_return(['sha1']) + context 'persisted merge request' do + context 'with a limit' do + it 'returns a limited number of commit shas' do + expect(subject.commit_shas(limit: 2)).to eq(%w[ + b83d6e391c22777fca1ed3012fce84f633d7fed0 498214de67004b1da3d820901307bed2a68a8ef6 + ]) + end + end + + context 'without a limit' do + it 'returns all commit shas of the merge request diff' do + expect(subject.commit_shas.size).to eq(29) + end + end end - it 'delegates to merge request diff' do - expect(subject.commit_shas).to eq ['sha1'] + context 'new merge request' do + subject { build(:merge_request) } + + context 'compare commits' do + before do + subject.compare_commits = [ + double(sha: 'sha1'), double(sha: 'sha2') + ] + end + + context 'without a limit' do + it 'returns all shas of compare commits' do + expect(subject.commit_shas).to eq(%w[sha2 sha1]) + end + end + + context 'with a limit' do + it 'returns a limited number of shas' do + expect(subject.commit_shas(limit: 1)).to eq(['sha2']) + end + end + end + + it 'returns diff_head_sha as an array' do + expect(subject.commit_shas).to eq([subject.diff_head_sha]) + expect(subject.commit_shas(limit: 2)).to eq([subject.diff_head_sha]) + end end end @@ -1674,6 +1720,63 @@ describe MergeRequest do end end + describe '#find_exposed_artifacts' do + let(:project) { create(:project, :repository) } + let(:merge_request) { create(:merge_request, :with_test_reports, source_project: project) } + let(:pipeline) { merge_request.head_pipeline } + + subject { merge_request.find_exposed_artifacts } + + context 'when head pipeline has exposed artifacts' do + let!(:job) do + create(:ci_build, options: { artifacts: { expose_as: 'artifact', paths: ['ci_artifacts.txt'] } }, pipeline: pipeline) + end + + let!(:artifacts_metadata) { create(:ci_job_artifact, :metadata, job: job) } + + context 'when reactive cache worker is parsing results asynchronously' do + it 'returns status' do + expect(subject[:status]).to eq(:parsing) + end + end + + context 'when reactive cache worker is inline' do + before do + synchronous_reactive_cache(merge_request) + end + + it 'returns status and data' do + expect(subject[:status]).to eq(:parsed) + end + + context 'when an error occurrs' do + before do + expect_next_instance_of(Ci::FindExposedArtifactsService) do |service| + expect(service).to receive(:for_pipeline) + .and_raise(StandardError.new) + end + end + + it 'returns an error message' do + expect(subject[:status]).to eq(:error) + end + end + + context 'when cached results is not latest' do + before do + allow_next_instance_of(Ci::GenerateExposedArtifactsReportService) do |service| + allow(service).to receive(:latest?).and_return(false) + end + end + + it 'raises and InvalidateReactiveCache error' do + expect { subject }.to raise_error(ReactiveCaching::InvalidateReactiveCache) + end + end + end + end + end + describe '#compare_test_reports' do subject { merge_request.compare_test_reports } @@ -1831,7 +1934,7 @@ describe MergeRequest do context 'when the MR has been merged' do before do MergeRequests::MergeService - .new(subject.target_project, subject.author) + .new(subject.target_project, subject.author, { sha: subject.diff_head_sha }) .execute(subject) end @@ -2081,6 +2184,13 @@ describe MergeRequest do expect { execute }.to raise_error(ActiveRecord::StaleObjectError) end + + it "raises ActiveRecord::LockWaitTimeout after 6 tries" do + expect(merge_request).to receive(:with_lock).exactly(6).times.and_raise(ActiveRecord::LockWaitTimeout) + expect(RebaseWorker).not_to receive(:perform_async) + + expect { execute }.to raise_error(MergeRequest::RebaseLockTimeout) + end end describe '#mergeable?' do @@ -2103,6 +2213,50 @@ describe MergeRequest do end end + describe '#check_mergeability' do + let(:mergeability_service) { double } + + before do + allow(MergeRequests::MergeabilityCheckService).to receive(:new) do + mergeability_service + end + end + + context 'if the merge status is unchecked' do + before do + subject.mark_as_unchecked! + end + + it 'executes MergeabilityCheckService' do + expect(mergeability_service).to receive(:execute) + + subject.check_mergeability + end + end + + context 'if the merge status is checked' do + context 'and feature flag is enabled' do + it 'executes MergeabilityCheckService' do + expect(mergeability_service).not_to receive(:execute) + + subject.check_mergeability + end + end + + context 'and feature flag is disabled' do + before do + stub_feature_flags(merge_requests_conditional_mergeability_check: false) + end + + it 'does not execute MergeabilityCheckService' do + expect(mergeability_service).to receive(:execute) + + subject.check_mergeability + end + end + end + end + describe '#mergeable_state?' do let(:project) { create(:project, :repository) } @@ -2203,7 +2357,7 @@ describe MergeRequest do allow(subject).to receive(:head_pipeline) { pipeline } end - it { expect(subject.mergeable_ci_state?).to be_truthy } + it { expect(subject.mergeable_ci_state?).to be_falsey } end context 'when no pipeline is associated' do @@ -2327,7 +2481,7 @@ describe MergeRequest do create(:deployment, :success, environment: source_environment, ref: 'feature', sha: merge_request.diff_head_sha) end - it 'selects deployed environments' do + it 'selects deployed environments', :sidekiq_might_not_need_inline do expect(merge_request.environments_for(user)).to contain_exactly(source_environment) end @@ -2338,7 +2492,7 @@ describe MergeRequest do create(:deployment, :success, environment: target_environment, tag: true, sha: merge_request.diff_head_sha) end - it 'selects deployed environments' do + it 'selects deployed environments', :sidekiq_might_not_need_inline do expect(merge_request.environments_for(user)).to contain_exactly(source_environment, target_environment) end end @@ -2689,7 +2843,7 @@ describe MergeRequest do describe '#mergeable_with_quick_action?' do def create_pipeline(status) - pipeline = create(:ci_pipeline_with_one_job, + pipeline = create(:ci_pipeline, project: project, ref: merge_request.source_branch, sha: merge_request.diff_head_sha, @@ -2804,9 +2958,9 @@ describe MergeRequest do let(:project) { create(:project, :public, :repository) } let(:merge_request) { create(:merge_request, source_project: project) } - let!(:first_pipeline) { create(:ci_pipeline_without_jobs, pipeline_arguments) } - let!(:last_pipeline) { create(:ci_pipeline_without_jobs, pipeline_arguments) } - let!(:last_pipeline_with_other_ref) { create(:ci_pipeline_without_jobs, pipeline_arguments.merge(ref: 'other')) } + let!(:first_pipeline) { create(:ci_pipeline, pipeline_arguments) } + let!(:last_pipeline) { create(:ci_pipeline, pipeline_arguments) } + let!(:last_pipeline_with_other_ref) { create(:ci_pipeline, pipeline_arguments.merge(ref: 'other')) } it 'returns latest pipeline for the target branch' do expect(merge_request.base_pipeline).to eq(last_pipeline) @@ -2932,7 +3086,7 @@ describe MergeRequest do describe '#unlock_mr' do subject { create(:merge_request, state: 'locked', merge_jid: 123) } - it 'updates merge request head pipeline and sets merge_jid to nil' do + it 'updates merge request head pipeline and sets merge_jid to nil', :sidekiq_might_not_need_inline do pipeline = create(:ci_empty_pipeline, project: subject.project, ref: subject.source_branch, sha: subject.source_branch_sha) subject.unlock_mr @@ -3304,7 +3458,7 @@ describe MergeRequest do end end - describe '.with_open_merge_when_pipeline_succeeds' do + describe '.with_auto_merge_enabled' do let!(:project) { create(:project) } let!(:fork) { fork_project(project) } let!(:merge_request1) do @@ -3316,15 +3470,6 @@ describe MergeRequest do source_branch: 'feature-1') end - let!(:merge_request2) do - create(:merge_request, - :merge_when_pipeline_succeeds, - target_project: project, - target_branch: 'master', - source_project: fork, - source_branch: 'fork-feature-1') - end - let!(:merge_request4) do create(:merge_request, target_project: project, @@ -3333,10 +3478,73 @@ describe MergeRequest do source_branch: 'fork-feature-2') end - let(:query) { described_class.with_open_merge_when_pipeline_succeeds } + let(:query) { described_class.with_auto_merge_enabled } - it { expect(query).to contain_exactly(merge_request1, merge_request2) } + it { expect(query).to contain_exactly(merge_request1) } end it_behaves_like 'versioned description' + + describe '#commits' do + context 'persisted merge request' do + context 'with a limit' do + it 'returns a limited number of commits' do + expect(subject.commits(limit: 2).map(&:sha)).to eq(%w[ + b83d6e391c22777fca1ed3012fce84f633d7fed0 + 498214de67004b1da3d820901307bed2a68a8ef6 + ]) + expect(subject.commits(limit: 3).map(&:sha)).to eq(%w[ + b83d6e391c22777fca1ed3012fce84f633d7fed0 + 498214de67004b1da3d820901307bed2a68a8ef6 + 1b12f15a11fc6e62177bef08f47bc7b5ce50b141 + ]) + end + end + + context 'without a limit' do + it 'returns all commits of the merge request diff' do + expect(subject.commits.size).to eq(29) + end + end + end + + context 'new merge request' do + subject { build(:merge_request) } + + context 'compare commits' do + let(:first_commit) { double } + let(:second_commit) { double } + + before do + subject.compare_commits = [ + first_commit, second_commit + ] + end + + context 'without a limit' do + it 'returns all the compare commits' do + expect(subject.commits.to_a).to eq([second_commit, first_commit]) + end + end + + context 'with a limit' do + it 'returns a limited number of commits' do + expect(subject.commits(limit: 1).to_a).to eq([second_commit]) + end + end + end + end + end + + describe '#recent_commits' do + before do + stub_const("#{MergeRequestDiff}::COMMITS_SAFE_SIZE", 2) + end + + it 'returns the safe number of commits' do + expect(subject.recent_commits.map(&:sha)).to eq(%w[ + b83d6e391c22777fca1ed3012fce84f633d7fed0 498214de67004b1da3d820901307bed2a68a8ef6 + ]) + end + end end diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb index 120ba67f3284c704402a45a543fcce365f6fdde7..45cd276870829069b4bf4bff1d2d69a5ef663a35 100644 --- a/spec/models/milestone_spec.rb +++ b/spec/models/milestone_spec.rb @@ -55,6 +55,17 @@ describe Milestone do end end + describe 'title' do + it { is_expected.to validate_presence_of(:title) } + + it 'is invalid if title would be empty after sanitation' do + milestone = build(:milestone, project: project, title: '<img src=x onerror=prompt(1)>') + + expect(milestone).not_to be_valid + expect(milestone.errors[:title]).to include("can't be blank") + end + end + describe 'milestone_releases' do let(:milestone) { build(:milestone, project: project) } diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 1e06d0fd7b99c4c19c345e50dc4404afdb00bcea..c93e6aafd758300164b76d2afa5d4c2ab2ce46ec 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -281,6 +281,44 @@ describe Namespace do end end + shared_examples 'move_dir without repository storage feature' do |storage_version| + let(:namespace) { create(:namespace) } + let(:gitlab_shell) { namespace.gitlab_shell } + let!(:project) { create(:project_empty_repo, namespace: namespace, storage_version: storage_version) } + + it 'calls namespace service' do + expect(gitlab_shell).to receive(:add_namespace).and_return(true) + expect(gitlab_shell).to receive(:mv_namespace).and_return(true) + + namespace.move_dir + end + end + + shared_examples 'move_dir with repository storage feature' do |storage_version| + let(:namespace) { create(:namespace) } + let(:gitlab_shell) { namespace.gitlab_shell } + let!(:project) { create(:project_empty_repo, namespace: namespace, storage_version: storage_version) } + + it 'does not call namespace service' do + expect(gitlab_shell).not_to receive(:add_namespace) + expect(gitlab_shell).not_to receive(:mv_namespace) + + namespace.move_dir + end + end + + context 'project is without repository storage feature' do + [nil, 0].each do |storage_version| + it_behaves_like 'move_dir without repository storage feature', storage_version + end + end + + context 'project has repository storage feature' do + [1, 2].each do |storage_version| + it_behaves_like 'move_dir with repository storage feature', storage_version + end + end + context 'with subgroups' do let(:parent) { create(:group, name: 'parent', path: 'parent') } let(:new_parent) { create(:group, name: 'new_parent', path: 'new_parent') } diff --git a/spec/models/personal_snippet_spec.rb b/spec/models/personal_snippet_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..276c8e22731b255c2ff7c6c2aca0c2f9dbb649dd --- /dev/null +++ b/spec/models/personal_snippet_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe PersonalSnippet do + describe '#embeddable?' do + [ + { snippet: :public, embeddable: true }, + { snippet: :internal, embeddable: false }, + { snippet: :private, embeddable: false } + ].each do |combination| + it 'returns true when snippet is public' do + snippet = build(:personal_snippet, combination[:snippet]) + + expect(snippet.embeddable?).to eq(combination[:embeddable]) + end + end + end +end diff --git a/spec/models/project_import_state_spec.rb b/spec/models/project_import_state_spec.rb index 8a847bbe24e612f3a19f1909355fed6046f3ca72..0b4dcc62ff6c17bdbc6b819c49ada103c32c30b3 100644 --- a/spec/models/project_import_state_spec.rb +++ b/spec/models/project_import_state_spec.rb @@ -27,7 +27,7 @@ describe ProjectImportState, type: :model do expect(project.wiki.repository).to receive(:after_import).and_call_original end - it 'imports a project' do + it 'imports a project', :sidekiq_might_not_need_inline do expect(RepositoryImportWorker).to receive(:perform_async).and_call_original expect { import_state.schedule }.to change { import_state.jid } diff --git a/spec/models/project_services/chat_message/pipeline_message_spec.rb b/spec/models/project_services/chat_message/pipeline_message_spec.rb index cf7c7bf7e61c60868f62f9bb8d69dcb9a044ca9f..366ef01924e4d0cd81d4833a7ca21dfa734cbfba 100644 --- a/spec/models/project_services/chat_message/pipeline_message_spec.rb +++ b/spec/models/project_services/chat_message/pipeline_message_spec.rb @@ -159,6 +159,45 @@ describe ChatMessage::PipelineMessage do ) end end + + context 'when ref type is tag' do + before do + args[:object_attributes][:tag] = true + args[:object_attributes][:ref] = 'new_tag' + end + + it "returns the pipeline summary in the activity's title" do + expect(subject.activity[:title]).to eq( + "Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ + " of tag [new_tag](http://example.gitlab.com/-/tags/new_tag)" \ + " by The Hacker (hacker) passed" + ) + end + + it "returns the pipeline summary as the attachment's text property" do + expect(subject.attachments.first[:text]).to eq( + "<http://example.gitlab.com|project_name>:" \ + " Pipeline <http://example.gitlab.com/pipelines/123|#123>" \ + " of tag <http://example.gitlab.com/-/tags/new_tag|new_tag>" \ + " by The Hacker (hacker) passed in 02:00:10" + ) + end + + context 'when rendering markdown' do + before do + args[:markdown] = true + end + + it 'returns the pipeline summary as the attachments in markdown format' do + expect(subject.attachments).to eq( + "[project_name](http://example.gitlab.com):" \ + " Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ + " of tag [new_tag](http://example.gitlab.com/-/tags/new_tag)" \ + " by The Hacker (hacker) passed in 02:00:10" + ) + end + end + end end context 'when the fancy_pipeline_slack_notifications feature flag is enabled' do diff --git a/spec/models/project_services/chat_message/push_message_spec.rb b/spec/models/project_services/chat_message/push_message_spec.rb index 2bde0b93fdabc90b30d60535192811f00a169caa..fe0b2fe3440bb535135d0f01997d157a5269ca84 100644 --- a/spec/models/project_services/chat_message/push_message_spec.rb +++ b/spec/models/project_services/chat_message/push_message_spec.rb @@ -96,7 +96,7 @@ describe ChatMessage::PushMessage do context 'without markdown' do it 'returns a message regarding pushes' do expect(subject.pretext).to eq('test.user pushed new tag ' \ - '<http://url.com/commits/new_tag|new_tag> to ' \ + '<http://url.com/-/tags/new_tag|new_tag> to ' \ '<http://url.com|project_name>') expect(subject.attachments).to be_empty end @@ -109,10 +109,10 @@ describe ChatMessage::PushMessage do it 'returns a message regarding pushes' do expect(subject.pretext).to eq( - 'test.user pushed new tag [new_tag](http://url.com/commits/new_tag) to [project_name](http://url.com)') + 'test.user pushed new tag [new_tag](http://url.com/-/tags/new_tag) to [project_name](http://url.com)') expect(subject.attachments).to be_empty expect(subject.activity).to eq( - title: 'test.user pushed new tag [new_tag](http://url.com/commits/new_tag)', + title: 'test.user pushed new tag [new_tag](http://url.com/-/tags/new_tag)', subtitle: 'in [project_name](http://url.com)', text: '[Compare changes](http://url.com/compare/0000000000000000000000000000000000000000...after)', image: 'http://someavatar.com' diff --git a/spec/models/project_services/data_fields_spec.rb b/spec/models/project_services/data_fields_spec.rb index 146db0ae2278e5f7e11cefc3483ddfa9020dbb4e..6b388a7222b651eed857e529e570b2083ac4348d 100644 --- a/spec/models/project_services/data_fields_spec.rb +++ b/spec/models/project_services/data_fields_spec.rb @@ -74,6 +74,12 @@ describe DataFields do expect(service.url_changed?).to be_falsy end end + + describe 'data_fields_present?' do + it 'returns true from the issue tracker service' do + expect(service.data_fields_present?).to be true + end + end end context 'when data are stored in data_fields' do @@ -92,6 +98,18 @@ describe DataFields do end end + context 'when service and data_fields are not persisted' do + let(:service) do + JiraService.new + end + + describe 'data_fields_present?' do + it 'returns true' do + expect(service.data_fields_present?).to be true + end + end + end + context 'when data are stored in properties' do let(:service) { create(:jira_service, :without_properties_callback, properties: properties) } diff --git a/spec/models/project_services/irker_service_spec.rb b/spec/models/project_services/irker_service_spec.rb index 2e1f69646928740b8a6df991265a18b0246559cc..309dc51191bfe2daa70eb3f1f7789906c1cffb46 100644 --- a/spec/models/project_services/irker_service_spec.rb +++ b/spec/models/project_services/irker_service_spec.rb @@ -60,7 +60,7 @@ describe IrkerService do @irker_server.close end - it 'sends valid JSON messages to an Irker listener' do + it 'sends valid JSON messages to an Irker listener', :sidekiq_might_not_need_inline do irker.execute(sample_data) conn = @irker_server.accept diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb index e5ac6ca65d6d01aab6631802a9a2b57aa9d1ff53..bc22818ede7f41950ada22f2c42a28e8e70a73ba 100644 --- a/spec/models/project_services/prometheus_service_spec.rb +++ b/spec/models/project_services/prometheus_service_spec.rb @@ -65,6 +65,37 @@ describe PrometheusService, :use_clean_rails_memory_store_caching do end end end + + context 'with self-monitoring project and internal Prometheus' do + before do + service.api_url = 'http://localhost:9090' + + stub_application_setting(instance_administration_project_id: project.id) + stub_config(prometheus: { enable: true, listen_address: 'localhost:9090' }) + end + + it 'allows self-monitoring project to connect to internal Prometheus' do + aggregate_failures do + ['127.0.0.1', '192.168.2.3'].each do |url| + allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([Addrinfo.tcp(url, 80)]) + + expect(service.can_query?).to be true + end + end + end + + it 'does not allow self-monitoring project to connect to other local URLs' do + service.api_url = 'http://localhost:8000' + + aggregate_failures do + ['127.0.0.1', '192.168.2.3'].each do |url| + allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([Addrinfo.tcp(url, 80)]) + + expect(service.can_query?).to be false + end + end + end + end end end diff --git a/spec/models/project_snippet_spec.rb b/spec/models/project_snippet_spec.rb index e87b4f41f4da4703910e56477c80de5a3a8bf0a0..46025507cb5367d4e538523a96c0c9364f6e4377 100644 --- a/spec/models/project_snippet_spec.rb +++ b/spec/models/project_snippet_spec.rb @@ -10,4 +10,25 @@ describe ProjectSnippet do describe "Validation" do it { is_expected.to validate_presence_of(:project) } end + + describe '#embeddable?' do + [ + { project: :public, snippet: :public, embeddable: true }, + { project: :internal, snippet: :public, embeddable: false }, + { project: :private, snippet: :public, embeddable: false }, + { project: :public, snippet: :internal, embeddable: false }, + { project: :internal, snippet: :internal, embeddable: false }, + { project: :private, snippet: :internal, embeddable: false }, + { project: :public, snippet: :private, embeddable: false }, + { project: :internal, snippet: :private, embeddable: false }, + { project: :private, snippet: :private, embeddable: false } + ].each do |combination| + it 'only returns true when both project and snippet are public' do + project = create(:project, combination[:project]) + snippet = build(:project_snippet, combination[:snippet], project: project) + + expect(snippet.embeddable?).to eq(combination[:embeddable]) + end + end + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 1bda3094e7513ce2e1dd9053e1d10f332aadcfcf..815ab7aa166d0f2effd36c52da34a61343c8d1e0 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -2029,24 +2029,37 @@ describe Project do end describe '#ci_config_path=' do - let(:project) { create(:project) } + using RSpec::Parameterized::TableSyntax - it 'sets nil' do - project.update!(ci_config_path: nil) + let(:project) { create(:project) } - expect(project.ci_config_path).to be_nil + where(:default_ci_config_path, :project_ci_config_path, :expected_ci_config_path) do + nil | :notset | :default + nil | nil | :default + nil | '' | :default + nil | "cust\0om/\0/path" | 'custom//path' + '' | :notset | :default + '' | nil | :default + '' | '' | :default + '' | "cust\0om/\0/path" | 'custom//path' + 'global/path' | :notset | 'global/path' + 'global/path' | nil | :default + 'global/path' | '' | :default + 'global/path' | "cust\0om/\0/path" | 'custom//path' end - it 'sets a string' do - project.update!(ci_config_path: 'foo/.gitlab_ci.yml') - - expect(project.ci_config_path).to eq('foo/.gitlab_ci.yml') - end + with_them do + before do + stub_application_setting(default_ci_config_path: default_ci_config_path) - it 'sets a string but removes all null characters' do - project.update!(ci_config_path: "f\0oo/\0/.gitlab_ci.yml") + if project_ci_config_path != :notset + project.ci_config_path = project_ci_config_path + end + end - expect(project.ci_config_path).to eq('foo//.gitlab_ci.yml') + it 'returns the correct path' do + expect(project.ci_config_path.presence || :default).to eq(expected_ci_config_path) + end end end @@ -3342,22 +3355,6 @@ describe Project do end end - describe '#append_or_update_attribute' do - let(:project) { create(:project) } - - it 'shows full error updating an invalid MR' do - expect { project.append_or_update_attribute(:merge_requests, [create(:merge_request)]) } - .to raise_error(ActiveRecord::RecordInvalid, /Failed to set merge_requests:/) - end - - it 'updates the project successfully' do - merge_request = create(:merge_request, target_project: project, source_project: project) - - expect { project.append_or_update_attribute(:merge_requests, [merge_request]) } - .not_to raise_error - end - end - describe '#update' do let(:project) { create(:project) } @@ -4284,22 +4281,25 @@ describe Project do describe '#check_repository_path_availability' do let(:project) { build(:project, :repository, :legacy_storage) } - subject { project.check_repository_path_availability } context 'when the repository already exists' do let(:project) { create(:project, :repository, :legacy_storage) } - it { is_expected.to be_falsey } + it 'returns false when repository already exists' do + expect(project.check_repository_path_availability).to be_falsey + end end context 'when the repository does not exist' do - it { is_expected.to be_truthy } + it 'returns false when repository already exists' do + expect(project.check_repository_path_availability).to be_truthy + end it 'skips gitlab-shell exists?' do project.skip_disk_validation = true expect(project.gitlab_shell).not_to receive(:repository_exists?) - is_expected.to be_truthy + expect(project.check_repository_path_availability).to be_truthy end end end @@ -4631,7 +4631,7 @@ describe Project do end describe '#any_branch_allows_collaboration?' do - it 'allows access when there are merge requests open allowing collaboration' do + it 'allows access when there are merge requests open allowing collaboration', :sidekiq_might_not_need_inline do expect(project.any_branch_allows_collaboration?(user)) .to be_truthy end @@ -4645,7 +4645,7 @@ describe Project do end describe '#branch_allows_collaboration?' do - it 'allows access if the user can merge the merge request' do + it 'allows access if the user can merge the merge request', :sidekiq_might_not_need_inline do expect(project.branch_allows_collaboration?(user, 'awesome-feature-1')) .to be_truthy end @@ -4899,20 +4899,6 @@ describe Project do end end - describe '.find_without_deleted' do - it 'returns nil if the project is about to be removed' do - project = create(:project, pending_delete: true) - - expect(described_class.find_without_deleted(project.id)).to be_nil - end - - it 'returns a project when it is not about to be removed' do - project = create(:project) - - expect(described_class.find_without_deleted(project.id)).to eq(project) - end - end - describe '.for_group' do it 'returns the projects for a given group' do group = create(:group) diff --git a/spec/models/release_spec.rb b/spec/models/release_spec.rb index 0aac325c2b2f9eff32e90eb2a12fa84ff593d86e..f9c7a14f1f3fa7e1b40c0a7cab8346f368792944 100644 --- a/spec/models/release_spec.rb +++ b/spec/models/release_spec.rb @@ -34,7 +34,7 @@ RSpec.describe Release do expect(existing_release_without_name).to be_valid expect(existing_release_without_name.description).to eq("change") - expect(existing_release_without_name.name).to be_nil + expect(existing_release_without_name.name).not_to be_nil end end @@ -57,14 +57,14 @@ RSpec.describe Release do subject { release.assets_count } it 'returns the number of sources' do - is_expected.to eq(Releases::Source::FORMATS.count) + is_expected.to eq(Gitlab::Workhorse::ARCHIVE_FORMATS.count) end context 'when a links exists' do let!(:link) { create(:release_link, release: release) } it 'counts the link as an asset' do - is_expected.to eq(1 + Releases::Source::FORMATS.count) + is_expected.to eq(1 + Gitlab::Workhorse::ARCHIVE_FORMATS.count) end it "excludes sources count when asked" do @@ -92,7 +92,7 @@ RSpec.describe Release do end end - describe 'evidence' do + describe 'evidence', :sidekiq_might_not_need_inline do describe '#create_evidence!' do context 'when a release is created' do it 'creates one Evidence object too' do @@ -129,4 +129,16 @@ RSpec.describe Release do end end end + + describe '#name' do + context 'name is nil' do + before do + release.update(name: nil) + end + + it 'returns tag' do + expect(release.name).to eq(release.tag) + end + end + end end diff --git a/spec/models/releases/source_spec.rb b/spec/models/releases/source_spec.rb index c52131969625d93aa62031388bddb6223e42e941..c8ac8e31c972d7858cb7e9cfc2d23358347ad20c 100644 --- a/spec/models/releases/source_spec.rb +++ b/spec/models/releases/source_spec.rb @@ -11,7 +11,7 @@ describe Releases::Source do it 'returns all formats of sources' do expect(subject.map(&:format)) - .to match_array(described_class::FORMATS) + .to match_array(Gitlab::Workhorse::ARCHIVE_FORMATS) end end diff --git a/spec/models/remote_mirror_spec.rb b/spec/models/remote_mirror_spec.rb index 63d0bf3f314c0fce59f2374d813f12bab8b49f07..79d45da8a1e03535707ee9d791c6db3a1304c012 100644 --- a/spec/models/remote_mirror_spec.rb +++ b/spec/models/remote_mirror_spec.rb @@ -189,7 +189,7 @@ describe RemoteMirror, :mailer do remote_mirror.project.add_maintainer(user) end - it 'notifies the project maintainers' do + it 'notifies the project maintainers', :sidekiq_might_not_need_inline do perform_enqueued_jobs { subject } should_email(user) diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb index 64077b76f0194f5df97740046e2bc7515e6a87ca..f58bcbebd67f3751180a604f76ae9e80a2aa9bc4 100644 --- a/spec/models/service_spec.rb +++ b/spec/models/service_spec.rb @@ -15,6 +15,26 @@ describe Service do end describe 'Scopes' do + describe '.by_type' do + let!(:service1) { create(:jira_service) } + let!(:service2) { create(:jira_service) } + let!(:service3) { create(:redmine_service) } + + subject { described_class.by_type(type) } + + context 'when type is "JiraService"' do + let(:type) { 'JiraService' } + + it { is_expected.to match_array([service1, service2]) } + end + + context 'when type is "RedmineService"' do + let(:type) { 'RedmineService' } + + it { is_expected.to match_array([service3]) } + end + end + describe '.confidential_note_hooks' do it 'includes services where confidential_note_events is true' do create(:service, active: true, confidential_note_events: true) diff --git a/spec/models/shard_spec.rb b/spec/models/shard_spec.rb index 83104711b5524e97c74f3dd93c51462cf4150a6c..4da86858b54f40422060e5597b503f86fcf2e17f 100644 --- a/spec/models/shard_spec.rb +++ b/spec/models/shard_spec.rb @@ -1,4 +1,5 @@ -# frozen_string_literals: true +# frozen_string_literal: true + require 'spec_helper' describe Shard do diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb index f4dcbfbc190299a7d7da16b4f794593a03ff6622..e4cc89318409f00fade104e5c7915cb88349bbe6 100644 --- a/spec/models/snippet_spec.rb +++ b/spec/models/snippet_spec.rb @@ -451,41 +451,4 @@ describe Snippet do expect(blob.data).to eq(snippet.content) end end - - describe '#embeddable?' do - context 'project snippet' do - [ - { project: :public, snippet: :public, embeddable: true }, - { project: :internal, snippet: :public, embeddable: false }, - { project: :private, snippet: :public, embeddable: false }, - { project: :public, snippet: :internal, embeddable: false }, - { project: :internal, snippet: :internal, embeddable: false }, - { project: :private, snippet: :internal, embeddable: false }, - { project: :public, snippet: :private, embeddable: false }, - { project: :internal, snippet: :private, embeddable: false }, - { project: :private, snippet: :private, embeddable: false } - ].each do |combination| - it 'only returns true when both project and snippet are public' do - project = create(:project, combination[:project]) - snippet = create(:project_snippet, combination[:snippet], project: project) - - expect(snippet.embeddable?).to eq(combination[:embeddable]) - end - end - end - - context 'personal snippet' do - [ - { snippet: :public, embeddable: true }, - { snippet: :internal, embeddable: false }, - { snippet: :private, embeddable: false } - ].each do |combination| - it 'only returns true when snippet is public' do - snippet = create(:personal_snippet, combination[:snippet]) - - expect(snippet.embeddable?).to eq(combination[:embeddable]) - end - end - end - end end diff --git a/spec/models/spam_log_spec.rb b/spec/models/spam_log_spec.rb index e9ea234f75dbe04e2efe826136ecfe9dbff1702e..f4e073dc38fab70e8114712921d4e25d66e5dc0c 100644 --- a/spec/models/spam_log_spec.rb +++ b/spec/models/spam_log_spec.rb @@ -20,7 +20,7 @@ describe SpamLog do expect { spam_log.remove_user(deleted_by: admin) }.to change { spam_log.user.blocked? }.to(true) end - it 'removes the user' do + it 'removes the user', :sidekiq_might_not_need_inline do spam_log = build(:spam_log) user = spam_log.user diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb index 487a1c619c6f66b7a429d5a83e3d3ebf48cef88f..ea09c6caed3446b38dcbf58f9925772f2cdaae1f 100644 --- a/spec/models/todo_spec.rb +++ b/spec/models/todo_spec.rb @@ -150,6 +150,19 @@ describe Todo do end end + describe '#done?' do + let_it_be(:todo1) { create(:todo, state: :pending) } + let_it_be(:todo2) { create(:todo, state: :done) } + + it 'returns true for todos with done state' do + expect(todo2.done?).to be_truthy + end + + it 'returns false for todos with state pending' do + expect(todo1.done?).to be_falsey + end + end + describe '#self_assigned?' do let(:user_1) { build(:user) } @@ -208,6 +221,40 @@ describe Todo do expect(described_class.for_project(project1)).to eq([todo]) end + + it 'returns the todos for many projects' do + project1 = create(:project) + project2 = create(:project) + project3 = create(:project) + + todo1 = create(:todo, project: project1) + todo2 = create(:todo, project: project2) + create(:todo, project: project3) + + expect(described_class.for_project([project2, project1])).to contain_exactly(todo2, todo1) + end + end + + describe '.for_undeleted_projects' do + let(:project1) { create(:project) } + let(:project2) { create(:project) } + let(:project3) { create(:project) } + + let!(:todo1) { create(:todo, project: project1) } + let!(:todo2) { create(:todo, project: project2) } + let!(:todo3) { create(:todo, project: project3) } + + it 'returns the todos for a given project' do + expect(described_class.for_undeleted_projects).to contain_exactly(todo1, todo2, todo3) + end + + context 'when todo belongs to deleted project' do + let(:project2) { create(:project, pending_delete: true) } + + it 'excludes todos of deleted projects' do + expect(described_class.for_undeleted_projects).to contain_exactly(todo1, todo3) + end + end end describe '.for_group' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 8eb2f9b5bc060d0b0eace99d3d941b17d791350e..ee7edb1516c018ead7290a1180f1ecd27b5c750f 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe User do +describe User, :do_not_mock_admin_mode do include ProjectForksHelper include TermsHelper @@ -2797,10 +2797,26 @@ describe User do expect(user.full_private_access?).to be_falsy end - it 'returns true for admin user' do - user = build(:user, :admin) + context 'for admin user' do + include_context 'custom session' - expect(user.full_private_access?).to be_truthy + let(:user) { build(:user, :admin) } + + context 'when admin mode is disabled' do + it 'returns false' do + expect(user.full_private_access?).to be_falsy + end + end + + context 'when admin mode is enabled' do + before do + Gitlab::Auth::CurrentUserMode.new(user).enable_admin_mode!(password: user.password) + end + + it 'returns true' do + expect(user.full_private_access?).to be_truthy + end + end end end diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb index 9014276dcf8c8b43ab6e131280295bcbc2a54153..a7c28519c5aa2d00d93adcd6498860d632228a31 100644 --- a/spec/models/wiki_page_spec.rb +++ b/spec/models/wiki_page_spec.rb @@ -563,17 +563,6 @@ describe WikiPage do end end - describe '#formatted_content' do - it 'returns processed content of the page' do - subject.create({ title: "RDoc", content: "*bold*", format: "rdoc" }) - page = wiki.find_page('RDoc') - - expect(page.formatted_content).to eq("\n<p><strong>bold</strong></p>\n") - - destroy_page('RDoc') - end - end - describe '#hook_attrs' do it 'adds absolute urls for images in the content' do create_page("test page", "test") diff --git a/spec/models/zoom_meeting_spec.rb b/spec/models/zoom_meeting_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3dad957a1ce221276d60ff57e156e06211af6214 --- /dev/null +++ b/spec/models/zoom_meeting_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ZoomMeeting do + let(:project) { build(:project) } + + describe 'Factory' do + subject { build(:zoom_meeting) } + + it { is_expected.to be_valid } + end + + describe 'Associations' do + it { is_expected.to belong_to(:project).required } + it { is_expected.to belong_to(:issue).required } + end + + describe 'scopes' do + let(:issue) { create(:issue, project: project) } + let!(:added_meeting) { create(:zoom_meeting, :added_to_issue, issue: issue) } + let!(:removed_meeting) { create(:zoom_meeting, :removed_from_issue, issue: issue) } + + describe '.added_to_issue' do + it 'gets only added meetings' do + meetings_added = described_class.added_to_issue.pluck(:id) + + expect(meetings_added).to include(added_meeting.id) + expect(meetings_added).not_to include(removed_meeting.id) + end + end + describe '.removed_from_issue' do + it 'gets only removed meetings' do + meetings_removed = described_class.removed_from_issue.pluck(:id) + + expect(meetings_removed).to include(removed_meeting.id) + expect(meetings_removed).not_to include(added_meeting.id) + end + end + end + + describe 'Validations' do + describe 'url' do + it { is_expected.to validate_presence_of(:url) } + it { is_expected.to validate_length_of(:url).is_at_most(255) } + + shared_examples 'invalid Zoom URL' do + it do + expect(subject).to be_invalid + expect(subject.errors[:url]) + .to contain_exactly('must contain one valid Zoom URL') + end + end + + context 'with non-Zoom URL' do + before do + subject.url = %{https://non-zoom.url} + end + + include_examples 'invalid Zoom URL' + end + + context 'with multiple Zoom-URLs' do + before do + subject.url = %{https://zoom.us/j/123 https://zoom.us/j/456} + end + + include_examples 'invalid Zoom URL' + end + end + + describe 'issue association' do + let(:issue) { build(:issue, project: project) } + + subject { build(:zoom_meeting, project: project, issue: issue) } + + context 'for the same project' do + it { is_expected.to be_valid } + end + + context 'for a different project' do + let(:issue) { build(:issue) } + + it do + expect(subject).to be_invalid + expect(subject.errors[:issue]) + .to contain_exactly('must associate the same project') + end + end + end + end + + describe 'limit number of meetings per issue' do + shared_examples 'can add meetings' do + it 'can add new Zoom meetings' do + create(:zoom_meeting, :added_to_issue, issue: issue) + end + end + + shared_examples 'can remove meetings' do + it 'can remove Zoom meetings' do + create(:zoom_meeting, :removed_from_issue, issue: issue) + end + end + + shared_examples 'cannot add meetings' do + it 'fails to add a new meeting' do + expect do + create(:zoom_meeting, :added_to_issue, issue: issue) + end.to raise_error ActiveRecord::RecordNotUnique + end + end + + let(:issue) { create(:issue, project: project) } + + context 'without meetings' do + it_behaves_like 'can add meetings' + end + + context 'when no other meeting is added' do + before do + create(:zoom_meeting, :removed_from_issue, issue: issue) + end + + it_behaves_like 'can add meetings' + end + + context 'when meeting is added' do + before do + create(:zoom_meeting, :added_to_issue, issue: issue) + end + + it_behaves_like 'cannot add meetings' + end + + context 'when meeting is added to another issue' do + let(:another_issue) { create(:issue, project: project) } + + before do + create(:zoom_meeting, :added_to_issue, issue: another_issue) + end + + it_behaves_like 'can add meetings' + end + + context 'when second meeting is removed' do + before do + create(:zoom_meeting, :removed_from_issue, issue: issue) + end + + it_behaves_like 'can remove meetings' + end + end +end diff --git a/spec/policies/application_setting/term_policy_spec.rb b/spec/policies/application_setting/term_policy_spec.rb index 93b5ebf5f726b08f9070ea0f8f5a98adcd163860..21690d4b457782323428478b90a7aa6a26778c55 100644 --- a/spec/policies/application_setting/term_policy_spec.rb +++ b/spec/policies/application_setting/term_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe ApplicationSetting::TermPolicy do diff --git a/spec/policies/base_policy_spec.rb b/spec/policies/base_policy_spec.rb index 09be831dcd533b5bca7f3ff800cab7fc4b5f10ce..81aee4cfcacf7d6c0c1a0a814d0212729d197d84 100644 --- a/spec/policies/base_policy_spec.rb +++ b/spec/policies/base_policy_spec.rb @@ -1,7 +1,10 @@ +# frozen_string_literal: true + require 'spec_helper' -describe BasePolicy do +describe BasePolicy, :do_not_mock_admin_mode do include ExternalAuthorizationServiceHelpers + include AdminModeHelper describe '.class_for' do it 'detects policy class based on the subject ancestors' do @@ -34,8 +37,42 @@ describe BasePolicy do it { is_expected.not_to be_allowed(:read_cross_project) } - it 'allows admins' do - expect(described_class.new(build(:admin), nil)).to be_allowed(:read_cross_project) + context 'for admins' do + let(:current_user) { build(:admin) } + + subject { described_class.new(current_user, nil) } + + it 'allowed when in admin mode' do + enable_admin_mode!(current_user) + + is_expected.to be_allowed(:read_cross_project) + end + + it 'prevented when not in admin mode' do + is_expected.not_to be_allowed(:read_cross_project) + end + end + end + end + + describe 'full private access' do + let(:current_user) { create(:user) } + + subject { described_class.new(current_user, nil) } + + it { is_expected.not_to be_allowed(:read_all_resources) } + + context 'for admins' do + let(:current_user) { build(:admin) } + + it 'allowed when in admin mode' do + enable_admin_mode!(current_user) + + is_expected.to be_allowed(:read_all_resources) + end + + it 'prevented when not in admin mode' do + is_expected.not_to be_allowed(:read_all_resources) end end end diff --git a/spec/policies/ci/build_policy_spec.rb b/spec/policies/ci/build_policy_spec.rb index 79a616899fa12ad52697a29e1bcd0e9e9fe13afb..333f4e560cf16fbe937406821c0695110fc039ab 100644 --- a/spec/policies/ci/build_policy_spec.rb +++ b/spec/policies/ci/build_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Ci::BuildPolicy do diff --git a/spec/policies/ci/pipeline_policy_spec.rb b/spec/policies/ci/pipeline_policy_spec.rb index 126d44d1860cd205b256bce7167a5f5c722b5fa0..293fe1fc5b95114ff050c83aa74516153d4e5c74 100644 --- a/spec/policies/ci/pipeline_policy_spec.rb +++ b/spec/policies/ci/pipeline_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Ci::PipelinePolicy, :models do diff --git a/spec/policies/ci/pipeline_schedule_policy_spec.rb b/spec/policies/ci/pipeline_schedule_policy_spec.rb index 5a56e91cd6993d5c2dcdfb89c9a063766e93a9ff..700d7d1af0a27ce6098d835c9ed26e45936a1050 100644 --- a/spec/policies/ci/pipeline_schedule_policy_spec.rb +++ b/spec/policies/ci/pipeline_schedule_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Ci::PipelineSchedulePolicy, :models do diff --git a/spec/policies/ci/trigger_policy_spec.rb b/spec/policies/ci/trigger_policy_spec.rb index e9a85890082fdf511d1fbef4c7682a1a5cf40768..e936277a3913ddec5819d13914f1420167da2c46 100644 --- a/spec/policies/ci/trigger_policy_spec.rb +++ b/spec/policies/ci/trigger_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Ci::TriggerPolicy do diff --git a/spec/policies/clusters/cluster_policy_spec.rb b/spec/policies/clusters/cluster_policy_spec.rb index cc3dde154dc742e6f59d8c574d168957c23fae67..55c3351a171f4cc234d614ab4eb06049fbe56ee9 100644 --- a/spec/policies/clusters/cluster_policy_spec.rb +++ b/spec/policies/clusters/cluster_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Clusters::ClusterPolicy, :models do diff --git a/spec/policies/deploy_key_policy_spec.rb b/spec/policies/deploy_key_policy_spec.rb index e7263d496139a77fb777ccb6e34cf4825596f358..aca93d8fe85dc226fc880a2ae6aca43253530cba 100644 --- a/spec/policies/deploy_key_policy_spec.rb +++ b/spec/policies/deploy_key_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe DeployKeyPolicy do diff --git a/spec/policies/deploy_token_policy_spec.rb b/spec/policies/deploy_token_policy_spec.rb index cef5a4a22bc038156745f11b66a6e952ab0fab94..43e23ee55ac4c65638c189032df6d99c181122b1 100644 --- a/spec/policies/deploy_token_policy_spec.rb +++ b/spec/policies/deploy_token_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe DeployTokenPolicy do diff --git a/spec/policies/environment_policy_spec.rb b/spec/policies/environment_policy_spec.rb index 0442b032e89adfef324e15102a49c95e3ac7f4b4..3d0f250740c573a976a00065839d35f10842225a 100644 --- a/spec/policies/environment_policy_spec.rb +++ b/spec/policies/environment_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe EnvironmentPolicy do diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb index 880f1bcbc05c39663ea62908b29f98fd961e9233..c18cc245468d9abb1bcb25db69918592d66e773f 100644 --- a/spec/policies/global_policy_spec.rb +++ b/spec/policies/global_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe GlobalPolicy do diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb index aeb09c1dc3ac02829c7ca68b36f25f9e11bdbe01..ae9d125f970cc97af245d5a93667bfdbe5f133d2 100644 --- a/spec/policies/group_policy_spec.rb +++ b/spec/policies/group_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe GroupPolicy do diff --git a/spec/policies/issuable_policy_spec.rb b/spec/policies/issuable_policy_spec.rb index 6d34b0a8b4b0429442abef558ddac254cbabf153..18e35308ecd783a9ad83702ebb272d8a7edf8ceb 100644 --- a/spec/policies/issuable_policy_spec.rb +++ b/spec/policies/issuable_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe IssuablePolicy, models: true do diff --git a/spec/policies/issue_policy_spec.rb b/spec/policies/issue_policy_spec.rb index 25267d36ab88d670c8e40d9254f5998246ba0695..89fcf3c10df1c44300d997c695ec84fbe08b502d 100644 --- a/spec/policies/issue_policy_spec.rb +++ b/spec/policies/issue_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe IssuePolicy do diff --git a/spec/policies/merge_request_policy_spec.rb b/spec/policies/merge_request_policy_spec.rb index af4c9703eb473d1c3cd5becb4dbf295b3bd953e8..287325e96dfa5ffa4d4ef4ef5727b59f97a0ef5a 100644 --- a/spec/policies/merge_request_policy_spec.rb +++ b/spec/policies/merge_request_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe MergeRequestPolicy do diff --git a/spec/policies/namespace_policy_spec.rb b/spec/policies/namespace_policy_spec.rb index 909c17fe8b52059297e90e99d99fb739466e9112..c0a5119c5501e8c065342df30785ac5584c9e19f 100644 --- a/spec/policies/namespace_policy_spec.rb +++ b/spec/policies/namespace_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe NamespacePolicy do diff --git a/spec/policies/note_policy_spec.rb b/spec/policies/note_policy_spec.rb index d18ded8bce9d3b02db05c107f5872888f887d78b..5aee66275d47bd7c179dfd7fbd840b501e1a13b3 100644 --- a/spec/policies/note_policy_spec.rb +++ b/spec/policies/note_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe NotePolicy do diff --git a/spec/policies/personal_snippet_policy_spec.rb b/spec/policies/personal_snippet_policy_spec.rb index 097000ceb6a4d9cd9b0f3e3baf3de157a32c5e53..36b4ac16cf01b1c33b835d7021aa9b9933839dea 100644 --- a/spec/policies/personal_snippet_policy_spec.rb +++ b/spec/policies/personal_snippet_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' # Snippet visibility scenarios are included in more details in spec/support/snippet_visibility.rb @@ -18,6 +20,19 @@ describe PersonalSnippetPolicy do described_class.new(user, snippet) end + shared_examples 'admin access' do + context 'admin user' do + subject { permissions(admin_user) } + + it do + is_expected.to be_allowed(:read_personal_snippet) + is_expected.to be_allowed(:create_note) + is_expected.to be_allowed(:award_emoji) + is_expected.to be_allowed(*author_permissions) + end + end + end + context 'public snippet' do let(:snippet) { create(:personal_snippet, :public) } @@ -53,6 +68,8 @@ describe PersonalSnippetPolicy do is_expected.to be_allowed(*author_permissions) end end + + it_behaves_like 'admin access' end context 'internal snippet' do @@ -101,6 +118,8 @@ describe PersonalSnippetPolicy do is_expected.to be_allowed(*author_permissions) end end + + it_behaves_like 'admin access' end context 'private snippet' do @@ -128,17 +147,6 @@ describe PersonalSnippetPolicy do end end - context 'admin user' do - subject { permissions(admin_user) } - - it do - is_expected.to be_allowed(:read_personal_snippet) - is_expected.to be_disallowed(:create_note) - is_expected.to be_disallowed(:award_emoji) - is_expected.to be_disallowed(*author_permissions) - end - end - context 'external user' do subject { permissions(external_user) } @@ -160,5 +168,7 @@ describe PersonalSnippetPolicy do is_expected.to be_allowed(*author_permissions) end end + + it_behaves_like 'admin access' end end diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index e61a064e82cf8be264a6cba1f9c844b1bf574ce0..ab54d97f2a2e9d942327873fa865e621a95160e2 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe ProjectPolicy do @@ -313,6 +315,31 @@ describe ProjectPolicy do end end + context 'pipeline feature' do + let(:project) { create(:project) } + + describe 'for unconfirmed user' do + let(:unconfirmed_user) { create(:user, confirmed_at: nil) } + subject { described_class.new(unconfirmed_user, project) } + + it 'disallows to modify pipelines' do + expect_disallowed(:create_pipeline) + expect_disallowed(:update_pipeline) + expect_disallowed(:create_pipeline_schedule) + end + end + + describe 'for confirmed user' do + subject { described_class.new(developer, project) } + + it 'allows modify pipelines' do + expect_allowed(:create_pipeline) + expect_allowed(:update_pipeline) + expect_allowed(:create_pipeline_schedule) + end + end + end + context 'builds feature' do context 'when builds are disabled' do subject { described_class.new(owner, project) } diff --git a/spec/policies/project_snippet_policy_spec.rb b/spec/policies/project_snippet_policy_spec.rb index 2e9ef1e89fde986eab79623dcdf60f23f254af14..3c68d33b1f34e0e0c388028e223ae16df9b164ed 100644 --- a/spec/policies/project_snippet_policy_spec.rb +++ b/spec/policies/project_snippet_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' # Snippet visibility scenarios are included in more details in spec/support/snippet_visibility.rb diff --git a/spec/policies/protected_branch_policy_spec.rb b/spec/policies/protected_branch_policy_spec.rb index 1587196754dffb16246e362e4bfda0df77a34005..ea7fd093e380e1ca572cf39e9937cbcda268b620 100644 --- a/spec/policies/protected_branch_policy_spec.rb +++ b/spec/policies/protected_branch_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe ProtectedBranchPolicy do diff --git a/spec/policies/resource_label_event_policy_spec.rb b/spec/policies/resource_label_event_policy_spec.rb index 9206640ea00d9486d2977a0df520554657d779b7..799534d2b0873879b4c15e347738f08cda81a959 100644 --- a/spec/policies/resource_label_event_policy_spec.rb +++ b/spec/policies/resource_label_event_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe ResourceLabelEventPolicy do diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb index 7e0a18242008ff455c5b1f67d1ee24f2b3c8ab78..9da9d2ce49b566a949a647c50c4ad5985614aff8 100644 --- a/spec/policies/user_policy_spec.rb +++ b/spec/policies/user_policy_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe UserPolicy do diff --git a/spec/presenters/ci/bridge_presenter_spec.rb b/spec/presenters/ci/bridge_presenter_spec.rb index 986818a7b9eedc67db8f66e426348bbe9952b5d9..1c2eeced20ca786e076087a6af22543221bc59af 100644 --- a/spec/presenters/ci/bridge_presenter_spec.rb +++ b/spec/presenters/ci/bridge_presenter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Ci::BridgePresenter do diff --git a/spec/presenters/ci/build_presenter_spec.rb b/spec/presenters/ci/build_presenter_spec.rb index e202f7a9b5f129c02e3dffe9303b1faa482f83c2..b6c47f40cebd3f2da0c7b4a7801dddc258fa9eb9 100644 --- a/spec/presenters/ci/build_presenter_spec.rb +++ b/spec/presenters/ci/build_presenter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Ci::BuildPresenter do @@ -267,7 +269,7 @@ describe Ci::BuildPresenter do let(:build) { create(:ci_build, :failed, :script_failure) } context 'when is a script or missing dependency failure' do - let(:failure_reasons) { %w(script_failure missing_dependency_failure archived_failure) } + let(:failure_reasons) { %w(script_failure missing_dependency_failure archived_failure scheduler_failure data_integrity_failure) } it 'returns false' do failure_reasons.each do |failure_reason| diff --git a/spec/presenters/ci/build_runner_presenter_spec.rb b/spec/presenters/ci/build_runner_presenter_spec.rb index fa8791f225741b776b4e99784eb92154355f5a6c..017e94d04f192170db1c9ed907c279b7f9cffc96 100644 --- a/spec/presenters/ci/build_runner_presenter_spec.rb +++ b/spec/presenters/ci/build_runner_presenter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Ci::BuildRunnerPresenter do diff --git a/spec/presenters/ci/group_variable_presenter_spec.rb b/spec/presenters/ci/group_variable_presenter_spec.rb index cb58a757564053c859819d20a0de58a097d8ff19..3b81a425f5be1b6f78fe14f32a87bfd1784ce3cf 100644 --- a/spec/presenters/ci/group_variable_presenter_spec.rb +++ b/spec/presenters/ci/group_variable_presenter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Ci::GroupVariablePresenter do diff --git a/spec/presenters/ci/pipeline_presenter_spec.rb b/spec/presenters/ci/pipeline_presenter_spec.rb index 8cfcd9befb385c2376f8e7d13a6512535b6aa394..eca5d3e05fe3e25a2b435f30b43ecf0e6abc9352 100644 --- a/spec/presenters/ci/pipeline_presenter_spec.rb +++ b/spec/presenters/ci/pipeline_presenter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Ci::PipelinePresenter do diff --git a/spec/presenters/ci/trigger_presenter_spec.rb b/spec/presenters/ci/trigger_presenter_spec.rb index 231b539c188feb2771fc80908e608fc5f13e7de1..ac3967f4f77303ebe558a1a5f3d00697988cad19 100644 --- a/spec/presenters/ci/trigger_presenter_spec.rb +++ b/spec/presenters/ci/trigger_presenter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Ci::TriggerPresenter do diff --git a/spec/presenters/ci/variable_presenter_spec.rb b/spec/presenters/ci/variable_presenter_spec.rb index e3ce88372ea03d20b0d489cba02311a402d00135..70cf2f539b6e80c2e0d2f968ba5df7706b229f3a 100644 --- a/spec/presenters/ci/variable_presenter_spec.rb +++ b/spec/presenters/ci/variable_presenter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Ci::VariablePresenter do diff --git a/spec/presenters/clusters/cluster_presenter_spec.rb b/spec/presenters/clusters/cluster_presenter_spec.rb index 6b988e2645b6a954f3a01350a41a4174bff0ed4d..8bc5374f2db89d86a6d509d02500fa536133a4e8 100644 --- a/spec/presenters/clusters/cluster_presenter_spec.rb +++ b/spec/presenters/clusters/cluster_presenter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Clusters::ClusterPresenter do diff --git a/spec/presenters/commit_status_presenter_spec.rb b/spec/presenters/commit_status_presenter_spec.rb index 2b7742ddbb8a6f24507d17c3cc305f3467fe0e6b..b02497d4c1144d68c8c45eefe249f8deba349241 100644 --- a/spec/presenters/commit_status_presenter_spec.rb +++ b/spec/presenters/commit_status_presenter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe CommitStatusPresenter do diff --git a/spec/presenters/conversational_development_index/metric_presenter_spec.rb b/spec/presenters/conversational_development_index/metric_presenter_spec.rb index b8b68a676e6c53b5b3a41305f43610c85037946e..ac18d5203e52d0cd62b75f4a57a1d9840a275a2e 100644 --- a/spec/presenters/conversational_development_index/metric_presenter_spec.rb +++ b/spec/presenters/conversational_development_index/metric_presenter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe ConversationalDevelopmentIndex::MetricPresenter do diff --git a/spec/presenters/group_clusterable_presenter_spec.rb b/spec/presenters/group_clusterable_presenter_spec.rb index fa77273f6aa2cf119757c2e5ca002c085f3bb903..11a8decc9cc2b7a742329c4b968ef27e1d2c808d 100644 --- a/spec/presenters/group_clusterable_presenter_spec.rb +++ b/spec/presenters/group_clusterable_presenter_spec.rb @@ -43,6 +43,12 @@ describe GroupClusterablePresenter do it { is_expected.to eq(new_group_cluster_path(group)) } end + describe '#authorize_aws_role_path' do + subject { presenter.authorize_aws_role_path } + + it { is_expected.to eq(authorize_aws_role_group_clusters_path(group)) } + end + describe '#create_user_clusters_path' do subject { presenter.create_user_clusters_path } diff --git a/spec/presenters/group_member_presenter_spec.rb b/spec/presenters/group_member_presenter_spec.rb index bb66523a83dcb45832b9e89a2e8621128804d386..382b1881ab7e13185c1ad3848a6337133f2d8cbf 100644 --- a/spec/presenters/group_member_presenter_spec.rb +++ b/spec/presenters/group_member_presenter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe GroupMemberPresenter do diff --git a/spec/presenters/instance_clusterable_presenter_spec.rb b/spec/presenters/instance_clusterable_presenter_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..9f1268379f5403db4fff708cd92928dce6300e18 --- /dev/null +++ b/spec/presenters/instance_clusterable_presenter_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe InstanceClusterablePresenter do + include Gitlab::Routing.url_helpers + + let(:presenter) { described_class.new(instance) } + let(:cluster) { create(:cluster, :provided_by_gcp, :instance) } + let(:instance) { cluster.instance } + + describe '#create_aws_clusters_path' do + subject { described_class.new(instance).create_aws_clusters_path } + + it { is_expected.to eq(create_aws_admin_clusters_path) } + end + + describe '#authorize_aws_role_path' do + subject { described_class.new(instance).authorize_aws_role_path } + + it { is_expected.to eq(authorize_aws_role_admin_clusters_path) } + end + + describe '#revoke_aws_role_path' do + subject { described_class.new(instance).revoke_aws_role_path } + + it { is_expected.to eq(revoke_aws_role_admin_clusters_path) } + end + + describe '#aws_api_proxy_path' do + let(:resource) { 'resource' } + + subject { described_class.new(instance).aws_api_proxy_path(resource) } + + it { is_expected.to eq(aws_proxy_admin_clusters_path(resource: resource)) } + end +end diff --git a/spec/presenters/merge_request_presenter_spec.rb b/spec/presenters/merge_request_presenter_spec.rb index 6408b0bd74883cb92dd1018a2c4435631d8bd296..ce437090d43909a8f4ec250b2dc305eb1968b77e 100644 --- a/spec/presenters/merge_request_presenter_spec.rb +++ b/spec/presenters/merge_request_presenter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe MergeRequestPresenter do diff --git a/spec/presenters/project_clusterable_presenter_spec.rb b/spec/presenters/project_clusterable_presenter_spec.rb index 6786a84243fb2c7075220e6563b6ca7d54e74153..441c2a50fea45fa5063a7e2455523ddcc5c87cf5 100644 --- a/spec/presenters/project_clusterable_presenter_spec.rb +++ b/spec/presenters/project_clusterable_presenter_spec.rb @@ -43,6 +43,12 @@ describe ProjectClusterablePresenter do it { is_expected.to eq(new_project_cluster_path(project)) } end + describe '#authorize_aws_role_path' do + subject { presenter.authorize_aws_role_path } + + it { is_expected.to eq(authorize_aws_role_project_clusters_path(project)) } + end + describe '#create_user_clusters_path' do subject { presenter.create_user_clusters_path } diff --git a/spec/presenters/project_member_presenter_spec.rb b/spec/presenters/project_member_presenter_spec.rb index 73ef113a1c5c88a3bf643da7a4a2cc3ba48625b9..743c89fc7c2ff710f95c82a340925b180171af19 100644 --- a/spec/presenters/project_member_presenter_spec.rb +++ b/spec/presenters/project_member_presenter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe ProjectMemberPresenter do diff --git a/spec/presenters/project_presenter_spec.rb b/spec/presenters/project_presenter_spec.rb index 2a00548c2c3cbe437a048c1e424b36ab6f93139a..ce095d2225f5f7637abca005aeb464c10ff1a417 100644 --- a/spec/presenters/project_presenter_spec.rb +++ b/spec/presenters/project_presenter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe ProjectPresenter do @@ -310,8 +312,8 @@ describe ProjectPresenter 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: true, - label: a_string_including('Add license'), + expect(presenter.license_anchor_data).to have_attributes(is_link: false, + label: a_string_including('Add LICENSE'), link: presenter.add_license_path) end end @@ -320,7 +322,7 @@ 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: true, + expect(presenter.license_anchor_data).to have_attributes(is_link: false, label: a_string_including(presenter.license_short_name), link: presenter.license_path) end @@ -418,6 +420,7 @@ describe ProjectPresenter do it 'orders the items correctly' do allow(project.repository).to receive(:readme).and_return(double(name: 'readme')) + allow(project.repository).to receive(:license_blob).and_return(nil) allow(project.repository).to receive(:changelog).and_return(nil) allow(project.repository).to receive(:contribution_guide).and_return(double(name: 'foo')) allow(presenter).to receive(:filename_path).and_return('fake/path') @@ -431,25 +434,54 @@ describe ProjectPresenter do end end - describe '#empty_repo_statistics_buttons' do - let(:project) { create(:project, :repository) } + 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 - project.add_developer(user) allow(project).to receive(:auto_devops_enabled?).and_return(false) end - it 'orders the items correctly in an empty project' do - expect(empty_repo_statistics_buttons.map(&:label)).to start_with( - a_string_including('New'), - a_string_including('README'), - a_string_including('CHANGELOG'), - a_string_including('CONTRIBUTING'), - a_string_including('CI/CD') - ) + context 'empty repo' do + let(:project) { create(:project, :stubbed_repository)} + + context 'for a guest user' do + it 'orders the items correctly' do + expect(empty_repo_statistics_buttons.map(&:label)).to start_with( + a_string_including('No license') + ) + end + end + + context 'for a developer' do + before do + project.add_developer(user) + end + + it 'orders the items correctly' do + expect(empty_repo_statistics_buttons.map(&:label)).to start_with( + a_string_including('New'), + a_string_including('README'), + a_string_including('LICENSE'), + a_string_including('CHANGELOG'), + a_string_including('CONTRIBUTING'), + a_string_including('CI/CD') + ) + end + end + end + + context 'initialized repo' do + let(:project) { create(:project, :repository) } + + it 'orders the items correctly' do + expect(empty_repo_statistics_buttons.map(&:label)).to start_with( + a_string_including('README'), + a_string_including('License'), + a_string_including('CHANGELOG'), + a_string_including('CONTRIBUTING') + ) + end end end end diff --git a/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb b/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb index b4bf39f3cdb1b1bf76f664fc5ce9b1e109b1efee..de58733c8ea151a79196892d859a86dc284ba260 100644 --- a/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb +++ b/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Projects::Settings::DeployKeysPresenter do diff --git a/spec/presenters/release_presenter_spec.rb b/spec/presenters/release_presenter_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4d9fa7a4d75e9007525c5c0760ee5e6f555d70d1 --- /dev/null +++ b/spec/presenters/release_presenter_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ReleasePresenter do + include Gitlab::Routing.url_helpers + + let_it_be(:project) { create(:project, :repository) } + let(:developer) { create(:user) } + let(:guest) { create(:user) } + let(:user) { developer } + let(:release) { create(:release, project: project) } + let(:presenter) { described_class.new(release, current_user: user) } + + before do + project.add_developer(developer) + project.add_guest(guest) + end + + describe '#commit_path' do + subject { presenter.commit_path } + + it 'returns commit path' do + is_expected.to eq(project_commit_path(project, release.commit.id)) + end + + context 'when commit is not found' do + let(:release) { create(:release, project: project, sha: 'not-found') } + + it { is_expected.to be_nil } + end + + context 'when user is guest' do + let(:user) { guest } + + it { is_expected.to be_nil } + end + end + + describe '#tag_path' do + subject { presenter.tag_path } + + it 'returns tag path' do + is_expected.to eq(project_tag_path(project, release.tag)) + end + + context 'when user is guest' do + let(:user) { guest } + + it { is_expected.to be_nil } + end + end + + describe '#merge_requests_url' do + subject { presenter.merge_requests_url } + + it 'returns merge requests url' do + is_expected.to match /#{project_merge_requests_url(project)}/ + end + + context 'when release_mr_issue_urls feature flag is disabled' do + before do + stub_feature_flags(release_mr_issue_urls: false) + end + + it { is_expected.to be_nil } + end + end + + describe '#issues_url' do + subject { presenter.issues_url } + + it 'returns merge requests url' do + is_expected.to match /#{project_issues_url(project)}/ + end + + context 'when release_mr_issue_urls feature flag is disabled' do + before do + stub_feature_flags(release_mr_issue_urls: false) + end + + it { is_expected.to be_nil } + end + end + + describe '#edit_url' do + subject { presenter.edit_url } + + it 'returns release edit url' do + is_expected.to match /#{edit_project_release_url(project, release)}/ + end + + context 'when release_edit_page feature flag is disabled' do + before do + stub_feature_flags(release_edit_page: false) + end + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/requests/api/access_requests_spec.rb b/spec/requests/api/access_requests_spec.rb index 100f3d33c7b516817ff2a01ccc450e9d53d344f8..3bfca00776fdf57839162294f39ddaef0f3b1b9e 100644 --- a/spec/requests/api/access_requests_spec.rb +++ b/spec/requests/api/access_requests_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::AccessRequests do diff --git a/spec/requests/api/applications_spec.rb b/spec/requests/api/applications_spec.rb index 53fc309675116af2cb9b7c989e679d3793862e68..438d5dbf018c9189b744e356c54632910d166569 100644 --- a/spec/requests/api/applications_spec.rb +++ b/spec/requests/api/applications_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Applications, :api do diff --git a/spec/requests/api/avatar_spec.rb b/spec/requests/api/avatar_spec.rb index 9bc49bd59828298889cea83242b2ed497f35f4db..c8bc7f8a4a2b50c940267412dab613a98c48d351 100644 --- a/spec/requests/api/avatar_spec.rb +++ b/spec/requests/api/avatar_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Avatar do diff --git a/spec/requests/api/award_emoji_spec.rb b/spec/requests/api/award_emoji_spec.rb index 342fcfa104181654e7f6ca3df89f6a09c055828d..80040cddd4d2109dd86d5fed179e1c23ea60a964 100644 --- a/spec/requests/api/award_emoji_spec.rb +++ b/spec/requests/api/award_emoji_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::AwardEmoji do diff --git a/spec/requests/api/badges_spec.rb b/spec/requests/api/badges_spec.rb index 771a78a2d91622b6510ce72f158154438e24a655..ea0a7d4c9b77c801bfa35714c8a9d46bd5cd39f2 100644 --- a/spec/requests/api/badges_spec.rb +++ b/spec/requests/api/badges_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Badges do diff --git a/spec/requests/api/boards_spec.rb b/spec/requests/api/boards_spec.rb index 0b9c0c2ebe93ff114f94a2e5e64462b34990a578..8a67e956165cd027cb55e3a154dfae00995017cd 100644 --- a/spec/requests/api/boards_spec.rb +++ b/spec/requests/api/boards_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Boards do diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb index f9c8b42afa812901d7a982e924b0f1db2a4ffbb5..675b06b057c59921cb4271abdea82c77c18cafa6 100644 --- a/spec/requests/api/branches_spec.rb +++ b/spec/requests/api/branches_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Branches do @@ -117,6 +119,25 @@ describe API::Branches do it_behaves_like 'repository branches' end + + it 'does not submit N+1 DB queries', :request_store do + create(:protected_branch, name: 'master', project: project) + + # Make sure no setup step query is recorded. + get api(route, current_user), params: { per_page: 100 } + + control = ActiveRecord::QueryRecorder.new do + get api(route, current_user), params: { per_page: 100 } + end + + new_branch_name = 'protected-branch' + CreateBranchService.new(project, current_user).execute(new_branch_name, 'master') + create(:protected_branch, name: new_branch_name, project: project) + + expect do + get api(route, current_user), params: { per_page: 100 } + end.not_to exceed_query_limit(control) + end end context 'when authenticated', 'as a guest' do @@ -602,7 +623,7 @@ describe API::Branches do post api(route, user), params: { branch: 'new_design3', ref: 'foo' } expect(response).to have_gitlab_http_status(400) - expect(json_response['message']).to eq('Invalid reference name') + expect(json_response['message']).to eq('Invalid reference name: new_design3') end end diff --git a/spec/requests/api/broadcast_messages_spec.rb b/spec/requests/api/broadcast_messages_spec.rb index 0b48b79219c9dc545f9cd2b962565581f6a1dc11..541acb29857cdc27461c4c47351d9c15a64bce3d 100644 --- a/spec/requests/api/broadcast_messages_spec.rb +++ b/spec/requests/api/broadcast_messages_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::BroadcastMessages do diff --git a/spec/requests/api/commit_statuses_spec.rb b/spec/requests/api/commit_statuses_spec.rb index 6cb02ba2f6ba2d838f55b5dd9be5b24b08976672..639b8e96343b4da698f4a3910e3ea2eaa80ea68d 100644 --- a/spec/requests/api/commit_statuses_spec.rb +++ b/spec/requests/api/commit_statuses_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::CommitStatuses do @@ -278,7 +280,7 @@ describe API::CommitStatuses do } end - it 'update the correct pipeline' do + it 'update the correct pipeline', :sidekiq_might_not_need_inline do subject expect(first_pipeline.reload.status).to eq('created') @@ -302,7 +304,7 @@ describe API::CommitStatuses do expect(json_response['status']).to eq('success') end - it 'retries a commit status' do + it 'retries a commit status', :sidekiq_might_not_need_inline do expect(CommitStatus.count).to eq 2 expect(CommitStatus.first).to be_retried expect(CommitStatus.last.pipeline).to be_success diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb index 90ff1d12bf14e88520bac45581355325366eaf5f..d8da1c001b085bf306d0291726f06e7140c2edf2 100644 --- a/spec/requests/api/commits_spec.rb +++ b/spec/requests/api/commits_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'mime/types' @@ -369,7 +371,7 @@ describe API::Commits do valid_c_params[:start_project] = public_project.id end - it 'adds a new commit to forked_project and returns a 201' do + it 'adds a new commit to forked_project and returns a 201', :sidekiq_might_not_need_inline do expect_request_with_status(201) { post api(url, guest), params: valid_c_params } .to change { last_commit_id(forked_project, valid_c_params[:branch]) } .and not_change { last_commit_id(public_project, valid_c_params[:start_branch]) } @@ -381,14 +383,14 @@ describe API::Commits do valid_c_params[:start_project] = public_project.full_path end - it 'adds a new commit to forked_project and returns a 201' do + it 'adds a new commit to forked_project and returns a 201', :sidekiq_might_not_need_inline do expect_request_with_status(201) { post api(url, guest), params: valid_c_params } .to change { last_commit_id(forked_project, valid_c_params[:branch]) } .and not_change { last_commit_id(public_project, valid_c_params[:start_branch]) } end end - context 'when branch already exists' do + context 'when branch already exists', :sidekiq_might_not_need_inline do before do valid_c_params.delete(:start_branch) valid_c_params[:branch] = 'master' @@ -835,7 +837,7 @@ describe API::Commits do } end - it 'allows pushing to the source branch of the merge request' do + it 'allows pushing to the source branch of the merge request', :sidekiq_might_not_need_inline do post api(url, user), params: push_params('feature') expect(response).to have_gitlab_http_status(:created) @@ -1087,6 +1089,20 @@ describe API::Commits do expect(json_response.first.keys).to include 'diff' end + context 'when hard limits are lower than the number of files' do + before do + allow(Commit).to receive(:max_diff_options).and_return(max_files: 1) + end + + it 'respects the limit' do + get api(route, current_user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response.size).to be <= 1 + end + end + context 'when ref does not exist' do let(:commit_id) { 'unknown' } @@ -1360,6 +1376,12 @@ describe API::Commits do it_behaves_like '400 response' do let(:request) { post api(route, current_user), params: { branch: 'markdown' } } end + + it 'includes an error_code in the response' do + post api(route, current_user), params: { branch: 'markdown' } + + expect(json_response['error_code']).to eq 'empty' + end end context 'when ref contains a dot' do @@ -1417,7 +1439,7 @@ describe API::Commits do let(:project_id) { forked_project.id } - it 'allows access from a maintainer that to the source branch' do + it 'allows access from a maintainer that to the source branch', :sidekiq_might_not_need_inline do post api(route, user), params: { branch: 'feature' } expect(response).to have_gitlab_http_status(:created) @@ -1519,6 +1541,19 @@ describe API::Commits do let(:request) { post api(route, current_user) } end end + + context 'when commit is already reverted in the target branch' do + it 'includes an error_code in the response' do + # First one actually reverts + post api(route, current_user), params: { branch: 'markdown' } + + # Second one is redundant and should be empty + post api(route, current_user), params: { branch: 'markdown' } + + expect(response).to have_gitlab_http_status(400) + expect(json_response['error_code']).to eq 'empty' + end + end end context 'when authenticated', 'as a developer' do diff --git a/spec/requests/api/deploy_keys_spec.rb b/spec/requests/api/deploy_keys_spec.rb index e0cc18abcca81be6f135bbcbd5601c48ebf526fd..4579ccfad8025916c7bc52d08df8e1e3115c8732 100644 --- a/spec/requests/api/deploy_keys_spec.rb +++ b/spec/requests/api/deploy_keys_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::DeployKeys do diff --git a/spec/requests/api/deployments_spec.rb b/spec/requests/api/deployments_spec.rb index ad7be531979016249a1a80677c25ab7f27cbc105..26849c0991d6677ee1cb964722f772c4634c91f8 100644 --- a/spec/requests/api/deployments_spec.rb +++ b/spec/requests/api/deployments_spec.rb @@ -12,9 +12,9 @@ describe API::Deployments do describe 'GET /projects/:id/deployments' do let(:project) { create(:project) } - let!(:deployment_1) { create(:deployment, :success, project: project, iid: 11, ref: 'master', created_at: Time.now) } - let!(:deployment_2) { create(:deployment, :success, project: project, iid: 12, ref: 'feature', created_at: 1.day.ago) } - let!(:deployment_3) { create(:deployment, :success, project: project, iid: 8, ref: 'patch', created_at: 2.days.ago) } + 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) } context 'as member of the project' do it 'returns projects deployments sorted by id asc' do @@ -57,6 +57,8 @@ describe API::Deployments do 'iid' | 'desc' | [:deployment_2, :deployment_1, :deployment_3] 'ref' | 'asc' | [:deployment_2, :deployment_1, :deployment_3] 'ref' | 'desc' | [:deployment_3, :deployment_1, :deployment_2] + 'updated_at' | 'asc' | [:deployment_2, :deployment_3, :deployment_1] + 'updated_at' | 'desc' | [:deployment_1, :deployment_3, :deployment_2] end with_them do @@ -137,14 +139,42 @@ describe API::Deployments do expect(response).to have_gitlab_http_status(500) end + + it 'links any merged merge requests to the deployment' do + mr = create( + :merge_request, + :merged, + target_project: project, + source_project: project, + target_branch: 'master', + source_branch: 'foo' + ) + + post( + api("/projects/#{project.id}/deployments", user), + params: { + environment: 'production', + sha: sha, + ref: 'master', + tag: false, + status: 'success' + } + ) + + deploy = project.deployments.last + + expect(deploy.merge_requests).to eq([mr]) + end end context 'as a developer' do - it 'creates a new deployment' do - developer = create(:user) + let(:developer) { create(:user) } + before do project.add_developer(developer) + end + it 'creates a new deployment' do post( api("/projects/#{project.id}/deployments", developer), params: { @@ -161,6 +191,32 @@ describe API::Deployments do expect(json_response['sha']).to eq(sha) expect(json_response['ref']).to eq('master') end + + it 'links any merged merge requests to the deployment' do + mr = create( + :merge_request, + :merged, + target_project: project, + source_project: project, + target_branch: 'master', + source_branch: 'foo' + ) + + post( + api("/projects/#{project.id}/deployments", developer), + params: { + environment: 'production', + sha: sha, + ref: 'master', + tag: false, + status: 'success' + } + ) + + deploy = project.deployments.last + + expect(deploy.merge_requests).to eq([mr]) + end end context 'as non member' do @@ -182,7 +238,7 @@ describe API::Deployments do end describe 'PUT /projects/:id/deployments/:deployment_id' do - let(:project) { create(:project) } + let(:project) { create(:project, :repository) } let(:build) { create(:ci_build, :failed, project: project) } let(:environment) { create(:environment, project: project) } let(:deploy) do @@ -191,7 +247,8 @@ describe API::Deployments do :failed, project: project, environment: environment, - deployable: nil + deployable: nil, + sha: project.commit.sha ) end @@ -216,6 +273,26 @@ describe API::Deployments do expect(response).to have_gitlab_http_status(200) expect(json_response['status']).to eq('success') end + + it 'links merge requests when the deployment status changes to success', :sidekiq_inline do + mr = create( + :merge_request, + :merged, + target_project: project, + source_project: project, + target_branch: 'master', + source_branch: 'foo' + ) + + put( + api("/projects/#{project.id}/deployments/#{deploy.id}", user), + params: { status: 'success' } + ) + + deploy = project.deployments.last + + expect(deploy.merge_requests).to eq([mr]) + end end context 'as a developer' do diff --git a/spec/requests/api/discussions_spec.rb b/spec/requests/api/discussions_spec.rb index 0420201efe379895331c2d5375b233d3f790c6bb..68f7d407b5487b4a6976ed3b5d53f32ef67e5f45 100644 --- a/spec/requests/api/discussions_spec.rb +++ b/spec/requests/api/discussions_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Discussions do diff --git a/spec/requests/api/doorkeeper_access_spec.rb b/spec/requests/api/doorkeeper_access_spec.rb index cfee3f6c0f82de4ee7d7b7b81e8e4d28b35507d5..2a34e623a7e35443df6525afe9dd16b85c1d3877 100644 --- a/spec/requests/api/doorkeeper_access_spec.rb +++ b/spec/requests/api/doorkeeper_access_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'doorkeeper access' do diff --git a/spec/requests/api/environments_spec.rb b/spec/requests/api/environments_spec.rb index 745f3c55ac827423d38cd9c2f9ba8f1f869124d8..aa273e97209b211359a3973fd433c537a761accb 100644 --- a/spec/requests/api/environments_spec.rb +++ b/spec/requests/api/environments_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Environments do diff --git a/spec/requests/api/events_spec.rb b/spec/requests/api/events_spec.rb index 992fd5e9c660695c325815280861fe28e0c38738..9f8d254a00ca75db5bcf5a2a6b52f3a488b0b071 100644 --- a/spec/requests/api/events_spec.rb +++ b/spec/requests/api/events_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Events do diff --git a/spec/requests/api/features_spec.rb b/spec/requests/api/features_spec.rb index 57a57e69a00b256ec201f670be78e6c150380b99..dfd14f89dbf2ef5903a57de923b99424bcfe7ff4 100644 --- a/spec/requests/api/features_spec.rb +++ b/spec/requests/api/features_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Features do @@ -118,14 +120,13 @@ describe API::Features do post api("/features/#{feature_name}", admin), params: { value: 'true', user: user.username, feature_group: 'perf_team' } expect(response).to have_gitlab_http_status(201) - expect(json_response).to eq( - 'name' => 'my_feature', - 'state' => 'conditional', - 'gates' => [ - { 'key' => 'boolean', 'value' => false }, - { 'key' => 'groups', 'value' => ['perf_team'] }, - { 'key' => 'actors', 'value' => ["User:#{user.id}"] } - ]) + expect(json_response['name']).to eq('my_feature') + expect(json_response['state']).to eq('conditional') + expect(json_response['gates']).to contain_exactly( + { 'key' => 'boolean', 'value' => false }, + { 'key' => 'groups', 'value' => ['perf_team'] }, + { 'key' => 'actors', 'value' => ["User:#{user.id}"] } + ) end end diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb index 21b673575437d64e6f5d47f444ff09e2a76537ca..ec18156f49ffd1600aac39c0a8d7f8138e675ce8 100644 --- a/spec/requests/api/files_spec.rb +++ b/spec/requests/api/files_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Files do diff --git a/spec/requests/api/graphql/current_user/todos_query_spec.rb b/spec/requests/api/graphql/current_user/todos_query_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..82deba0d92c1a1a47ad2ca87f28d46870d4ca7c8 --- /dev/null +++ b/spec/requests/api/graphql/current_user/todos_query_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Query current user todos' do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:commit_todo) { create(:on_commit_todo, user: current_user, project: create(:project, :repository)) } + let_it_be(:issue_todo) { create(:todo, user: current_user, target: create(:issue)) } + let_it_be(:merge_request_todo) { create(:todo, user: current_user, target: create(:merge_request)) } + + let(:fields) do + <<~QUERY + nodes { + #{all_graphql_fields_for('todos'.classify)} + } + QUERY + end + + let(:query) do + graphql_query_for('currentUser', {}, query_graphql_field('todos', {}, fields)) + end + + subject { graphql_data.dig('currentUser', 'todos', 'nodes') } + + before do + post_graphql(query, current_user: current_user) + end + + it_behaves_like 'a working graphql query' + + it 'contains the expected ids' do + is_expected.to include( + a_hash_including('id' => commit_todo.to_global_id.to_s), + a_hash_including('id' => issue_todo.to_global_id.to_s), + a_hash_including('id' => merge_request_todo.to_global_id.to_s) + ) + end + + it 'returns Todos for all target types' do + is_expected.to include( + a_hash_including('targetType' => 'COMMIT'), + a_hash_including('targetType' => 'ISSUE'), + a_hash_including('targetType' => 'MERGEREQUEST') + ) + end +end diff --git a/spec/requests/api/graphql/current_user_query_spec.rb b/spec/requests/api/graphql/current_user_query_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..9db638ea59e4f020ed612a7bebe4cf73d7bad35d --- /dev/null +++ b/spec/requests/api/graphql/current_user_query_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'getting project information' do + include GraphqlHelpers + + let(:query) do + graphql_query_for('currentUser', {}, 'name') + end + + subject { graphql_data['currentUser'] } + + before do + post_graphql(query, current_user: current_user) + end + + context 'when there is a current_user' do + set(:current_user) { create(:user) } + + it_behaves_like 'a working graphql query' + + it { is_expected.to include('name' => current_user.name) } + end + + context 'when there is no current_user' do + let(:current_user) { nil } + + it_behaves_like 'a working graphql query' + + it { is_expected.to be_nil } + end +end diff --git a/spec/requests/api/graphql/gitlab_schema_spec.rb b/spec/requests/api/graphql/gitlab_schema_spec.rb index 1e799a0a42a88ffa95b967520b67d1f497983fc7..2aeb75a10b4bcc8d7350acd899996badf10fc152 100644 --- a/spec/requests/api/graphql/gitlab_schema_spec.rb +++ b/spec/requests/api/graphql/gitlab_schema_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'GitlabSchema configurations' do diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8f908b7bf8878c474038ea7f8650005252c51a3e --- /dev/null +++ b/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Setting assignees of a merge request' do + include GraphqlHelpers + + let(:current_user) { create(:user) } + let(:merge_request) { create(:merge_request) } + let(:project) { merge_request.project } + let(:assignee) { create(:user) } + let(:assignee2) { create(:user) } + let(:input) { { assignee_usernames: [assignee.username] } } + let(:expected_result) do + [{ 'username' => assignee.username }] + end + + let(:mutation) do + variables = { + project_path: project.full_path, + iid: merge_request.iid.to_s + } + graphql_mutation(:merge_request_set_assignees, variables.merge(input), + <<-QL.strip_heredoc + clientMutationId + errors + mergeRequest { + id + assignees { + nodes { + username + } + } + } + QL + ) + end + + def mutation_response + graphql_mutation_response(:merge_request_set_assignees) + end + + def mutation_assignee_nodes + mutation_response['mergeRequest']['assignees']['nodes'] + end + + before do + project.add_developer(current_user) + project.add_developer(assignee) + project.add_developer(assignee2) + end + + it 'returns an error if the user is not allowed to update the merge request' do + post_graphql_mutation(mutation, current_user: create(:user)) + + expect(graphql_errors).not_to be_empty + end + + it 'does not allow members without the right permission to add assignees' do + user = create(:user) + project.add_guest(user) + + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_errors).not_to be_empty + end + + context 'with assignees already assigned' do + before do + merge_request.assignees = [assignee2] + merge_request.save! + end + + it 'replaces the assignee' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_assignee_nodes).to match_array(expected_result) + end + end + + context 'when passing an empty list of assignees' do + let(:input) { { assignee_usernames: [] } } + + before do + merge_request.assignees = [assignee2] + merge_request.save! + end + + it 'removes assignee' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_assignee_nodes).to eq([]) + end + end + + context 'when passing append as true' do + let(:input) { { assignee_usernames: [assignee2.username], operation_mode: Types::MutationOperationModeEnum.enum[:append] } } + + before do + # In CE, APPEND is a NOOP as you can't have multiple assignees + # We test multiple assignment in EE specs + stub_licensed_features(multiple_merge_request_assignees: false) + + merge_request.assignees = [assignee] + merge_request.save! + end + + it 'does not replace the assignee in CE' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_assignee_nodes).to match_array(expected_result) + end + end + + context 'when passing remove as true' do + let(:input) { { assignee_usernames: [assignee.username], operation_mode: Types::MutationOperationModeEnum.enum[:remove] } } + let(:expected_result) { [] } + + before do + merge_request.assignees = [assignee] + merge_request.save! + end + + it 'removes the users in the list, while adding none' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_assignee_nodes).to match_array(expected_result) + end + end +end diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_labels_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_labels_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2112ff0dc74a4eb7a377e4865dc3e6c9b5c1e265 --- /dev/null +++ b/spec/requests/api/graphql/mutations/merge_requests/set_labels_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Setting labels of a merge request' do + include GraphqlHelpers + + let(:current_user) { create(:user) } + let(:merge_request) { create(:merge_request) } + let(:project) { merge_request.project } + let(:label) { create(:label, project: project) } + let(:label2) { create(:label, project: project) } + let(:input) { { label_ids: [GitlabSchema.id_from_object(label).to_s] } } + + let(:mutation) do + variables = { + project_path: project.full_path, + iid: merge_request.iid.to_s + } + graphql_mutation(:merge_request_set_labels, variables.merge(input), + <<-QL.strip_heredoc + clientMutationId + errors + mergeRequest { + id + labels { + nodes { + id + } + } + } + QL + ) + end + + def mutation_response + graphql_mutation_response(:merge_request_set_labels) + end + + def mutation_label_nodes + mutation_response['mergeRequest']['labels']['nodes'] + end + + before do + project.add_developer(current_user) + end + + it 'returns an error if the user is not allowed to update the merge request' do + post_graphql_mutation(mutation, current_user: create(:user)) + + expect(graphql_errors).not_to be_empty + end + + it 'sets the merge request labels, removing existing ones' do + merge_request.update(labels: [label2]) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_label_nodes.count).to eq(1) + expect(mutation_label_nodes[0]['id']).to eq(label.to_global_id.to_s) + end + + context 'when passing label_ids empty array as input' do + let(:input) { { label_ids: [] } } + + it 'removes the merge request labels' do + merge_request.update!(labels: [label]) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_label_nodes.count).to eq(0) + end + end + + context 'when passing operation_mode as APPEND' do + let(:input) { { operation_mode: Types::MutationOperationModeEnum.enum[:append], label_ids: [GitlabSchema.id_from_object(label).to_s] } } + + before do + merge_request.update!(labels: [label2]) + end + + it 'sets the labels, without removing others' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_label_nodes.count).to eq(2) + expect(mutation_label_nodes).to contain_exactly({ 'id' => label.to_global_id.to_s }, { 'id' => label2.to_global_id.to_s }) + end + end + + context 'when passing operation_mode as REMOVE' do + let(:input) { { operation_mode: Types::MutationOperationModeEnum.enum[:remove], label_ids: [GitlabSchema.id_from_object(label).to_s] } } + + before do + merge_request.update!(labels: [label, label2]) + end + + it 'removes the labels, without removing others' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_label_nodes.count).to eq(1) + expect(mutation_label_nodes[0]['id']).to eq(label2.to_global_id.to_s) + end + end +end diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_locked_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_locked_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c45da613591d2099874315d1f3e74cc0b2ea5d7d --- /dev/null +++ b/spec/requests/api/graphql/mutations/merge_requests/set_locked_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Setting locked status of a merge request' do + include GraphqlHelpers + + let(:current_user) { create(:user) } + let(:merge_request) { create(:merge_request) } + let(:project) { merge_request.project } + let(:input) { { locked: true } } + + let(:mutation) do + variables = { + project_path: project.full_path, + iid: merge_request.iid.to_s + } + graphql_mutation(:merge_request_set_locked, variables.merge(input), + <<-QL.strip_heredoc + clientMutationId + errors + mergeRequest { + id + discussionLocked + } + QL + ) + end + + def mutation_response + graphql_mutation_response(:merge_request_set_locked)['mergeRequest']['discussionLocked'] + end + + before do + project.add_developer(current_user) + end + + it 'returns an error if the user is not allowed to update the merge request' do + post_graphql_mutation(mutation, current_user: create(:user)) + + expect(graphql_errors).not_to be_empty + end + + it 'marks the merge request as WIP' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response).to eq(true) + end + + it 'does not do anything if the merge request was already locked' do + merge_request.update!(discussion_locked: true) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response).to eq(true) + end + + context 'when passing locked false as input' do + let(:input) { { locked: false } } + + it 'does not do anything if the merge request was not marked locked' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response).to eq(false) + end + + it 'unmarks the merge request as locked' do + merge_request.update!(discussion_locked: true) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response).to eq(false) + end + end +end diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_milestone_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_milestone_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..bd558edf9c56bdb04fb487dcad555d1b0c5915bc --- /dev/null +++ b/spec/requests/api/graphql/mutations/merge_requests/set_milestone_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Setting milestone of a merge request' do + include GraphqlHelpers + + let(:current_user) { create(:user) } + let(:merge_request) { create(:merge_request) } + let(:project) { merge_request.project } + let(:milestone) { create(:milestone, project: project) } + let(:input) { { milestone_id: GitlabSchema.id_from_object(milestone).to_s } } + + let(:mutation) do + variables = { + project_path: project.full_path, + iid: merge_request.iid.to_s + } + graphql_mutation(:merge_request_set_milestone, variables.merge(input), + <<-QL.strip_heredoc + clientMutationId + errors + mergeRequest { + id + milestone { + id + } + } + QL + ) + end + + def mutation_response + graphql_mutation_response(:merge_request_set_milestone) + end + + before do + project.add_developer(current_user) + end + + it 'returns an error if the user is not allowed to update the merge request' do + post_graphql_mutation(mutation, current_user: create(:user)) + + expect(graphql_errors).not_to be_empty + end + + it 'sets the merge request milestone' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['mergeRequest']['milestone']['id']).to eq(milestone.to_global_id.to_s) + end + + context 'when passing milestone_id nil as input' do + let(:input) { { milestone_id: nil } } + + it 'removes the merge request milestone' do + merge_request.update!(milestone: milestone) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['mergeRequest']['milestone']).to be_nil + end + end +end diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_subscription_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_subscription_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..975735bf2467bee251f1510382e8ccd7dc48e57e --- /dev/null +++ b/spec/requests/api/graphql/mutations/merge_requests/set_subscription_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Setting subscribed status of a merge request' do + include GraphqlHelpers + + let(:current_user) { create(:user) } + let(:merge_request) { create(:merge_request) } + let(:project) { merge_request.project } + let(:input) { { subscribed_state: true } } + + let(:mutation) do + variables = { + project_path: project.full_path, + iid: merge_request.iid.to_s + } + graphql_mutation(:merge_request_set_subscription, variables.merge(input), + <<-QL.strip_heredoc + clientMutationId + errors + mergeRequest { + id + subscribed + } + QL + ) + end + + def mutation_response + graphql_mutation_response(:merge_request_set_subscription)['mergeRequest']['subscribed'] + end + + before do + project.add_developer(current_user) + end + + it 'returns an error if the user is not allowed to update the merge request' do + post_graphql_mutation(mutation, current_user: create(:user)) + + expect(graphql_errors).not_to be_empty + end + + it 'marks the merge request as WIP' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response).to eq(true) + end + + context 'when passing subscribe false as input' do + let(:input) { { subscribed_state: false } } + + it 'unmarks the merge request as subscribed' do + merge_request.subscribe(current_user, project) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response).to eq(false) + end + end +end diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_wip_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_wip_spec.rb index bbc477ba4853bb8aad912806213db1fb8acc3340..4492c51dbd79c0410a43b7746c363ab09317169e 100644 --- a/spec/requests/api/graphql/mutations/merge_requests/set_wip_spec.rb +++ b/spec/requests/api/graphql/mutations/merge_requests/set_wip_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'Setting WIP status of a merge request' do diff --git a/spec/requests/api/graphql/mutations/todos/mark_done_spec.rb b/spec/requests/api/graphql/mutations/todos/mark_done_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..fabbb3aeb4988a1841ebdbff5224049166b1ae5c --- /dev/null +++ b/spec/requests/api/graphql/mutations/todos/mark_done_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Marking todos done' do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:author) { create(:user) } + let_it_be(:other_user) { create(:user) } + + let_it_be(:todo1) { create(:todo, user: current_user, author: author, state: :pending) } + let_it_be(:todo2) { create(:todo, user: current_user, author: author, state: :done) } + + let_it_be(:other_user_todo) { create(:todo, user: other_user, author: author, state: :pending) } + + let(:input) { { id: todo1.to_global_id.to_s } } + + let(:mutation) do + graphql_mutation(:todo_mark_done, input, + <<-QL.strip_heredoc + clientMutationId + errors + todo { + id + state + } + QL + ) + end + + def mutation_response + graphql_mutation_response(:todo_mark_done) + end + + it 'marks a single todo as done' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(todo1.reload.state).to eq('done') + expect(todo2.reload.state).to eq('done') + expect(other_user_todo.reload.state).to eq('pending') + + todo = mutation_response['todo'] + expect(todo['id']).to eq(todo1.to_global_id.to_s) + expect(todo['state']).to eq('done') + end + + context 'when todo is already marked done' do + let(:input) { { id: todo2.to_global_id.to_s } } + + it 'has the expected response' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(todo1.reload.state).to eq('pending') + expect(todo2.reload.state).to eq('done') + expect(other_user_todo.reload.state).to eq('pending') + + todo = mutation_response['todo'] + expect(todo['id']).to eq(todo2.to_global_id.to_s) + expect(todo['state']).to eq('done') + end + end + + context 'when todo does not belong to requesting user' do + let(:input) { { id: other_user_todo.to_global_id.to_s } } + let(:access_error) { 'The resource that you are attempting to access does not exist or you don\'t have permission to perform this action' } + + it 'contains the expected error' do + post_graphql_mutation(mutation, current_user: current_user) + + errors = json_response['errors'] + expect(errors).not_to be_blank + expect(errors.first['message']).to eq(access_error) + + expect(todo1.reload.state).to eq('pending') + expect(todo2.reload.state).to eq('done') + expect(other_user_todo.reload.state).to eq('pending') + end + end + + context 'when using an invalid gid' do + let(:input) { { id: 'invalid_gid' } } + let(:invalid_gid_error) { 'invalid_gid is not a valid GitLab id.' } + + it 'contains the expected error' do + post_graphql_mutation(mutation, current_user: current_user) + + errors = json_response['errors'] + expect(errors).not_to be_blank + expect(errors.first['message']).to eq(invalid_gid_error) + + expect(todo1.reload.state).to eq('pending') + expect(todo2.reload.state).to eq('done') + expect(other_user_todo.reload.state).to eq('pending') + end + end +end diff --git a/spec/requests/api/graphql/project/issues_spec.rb b/spec/requests/api/graphql/project/issues_spec.rb index 4f9f916f22e84512b9b2fe2a77fa22e1f3a8802c..4ce7a3912a3763211ae2608317cc0eebc7435da0 100644 --- a/spec/requests/api/graphql/project/issues_spec.rb +++ b/spec/requests/api/graphql/project/issues_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'getting an issue list for a project' do @@ -62,7 +64,7 @@ describe 'getting an issue list for a project' do end end - it "is expected to check permissions on the first issue only" do + it 'is expected to check permissions on the first issue only' do allow(Ability).to receive(:allowed?).and_call_original # Newest first, we only want to see the newest checked expect(Ability).not_to receive(:allowed?).with(current_user, :read_issue, issues.first) @@ -114,4 +116,141 @@ describe 'getting an issue list for a project' do end end end + + describe 'sorting and pagination' do + let(:start_cursor) { graphql_data['project']['issues']['pageInfo']['startCursor'] } + let(:end_cursor) { graphql_data['project']['issues']['pageInfo']['endCursor'] } + + context 'when sorting by due date' do + let(:sort_project) { create(:project, :public) } + + let!(:due_issue1) { create(:issue, project: sort_project, due_date: 3.days.from_now) } + let!(:due_issue2) { create(:issue, project: sort_project, due_date: nil) } + let!(:due_issue3) { create(:issue, project: sort_project, due_date: 2.days.ago) } + let!(:due_issue4) { create(:issue, project: sort_project, due_date: nil) } + let!(:due_issue5) { create(:issue, project: sort_project, due_date: 1.day.ago) } + + let(:params) { 'sort: DUE_DATE_ASC' } + + def query(issue_params = params) + graphql_query_for( + 'project', + { 'fullPath' => sort_project.full_path }, + <<~ISSUES + issues(#{issue_params}) { + pageInfo { + endCursor + } + edges { + node { + iid + dueDate + } + } + } + ISSUES + ) + end + + before do + post_graphql(query, current_user: current_user) + end + + it_behaves_like 'a working graphql query' + + context 'when ascending' do + it 'sorts issues' do + expect(grab_iids).to eq [due_issue3.iid, due_issue5.iid, due_issue1.iid, due_issue4.iid, due_issue2.iid] + end + + context 'when paginating' do + let(:params) { 'sort: DUE_DATE_ASC, first: 2' } + + it 'sorts issues' do + expect(grab_iids).to eq [due_issue3.iid, due_issue5.iid] + + cursored_query = query("sort: DUE_DATE_ASC, after: \"#{end_cursor}\"") + post_graphql(cursored_query, current_user: current_user) + response_data = JSON.parse(response.body)['data']['project']['issues']['edges'] + + expect(grab_iids(response_data)).to eq [due_issue1.iid, due_issue4.iid, due_issue2.iid] + end + end + end + + context 'when descending' do + let(:params) { 'sort: DUE_DATE_DESC' } + + it 'sorts issues' do + expect(grab_iids).to eq [due_issue1.iid, due_issue5.iid, due_issue3.iid, due_issue4.iid, due_issue2.iid] + end + + context 'when paginating' do + let(:params) { 'sort: DUE_DATE_DESC, first: 2' } + + it 'sorts issues' do + expect(grab_iids).to eq [due_issue1.iid, due_issue5.iid] + + cursored_query = query("sort: DUE_DATE_DESC, after: \"#{end_cursor}\"") + post_graphql(cursored_query, current_user: current_user) + response_data = JSON.parse(response.body)['data']['project']['issues']['edges'] + + expect(grab_iids(response_data)).to eq [due_issue3.iid, due_issue4.iid, due_issue2.iid] + end + end + end + end + + context 'when sorting by relative position' do + let(:sort_project) { create(:project, :public) } + + let!(:relative_issue1) { create(:issue, project: sort_project, relative_position: 2000) } + let!(:relative_issue2) { create(:issue, project: sort_project, relative_position: nil) } + let!(:relative_issue3) { create(:issue, project: sort_project, relative_position: 1000) } + let!(:relative_issue4) { create(:issue, project: sort_project, relative_position: nil) } + let!(:relative_issue5) { create(:issue, project: sort_project, relative_position: 500) } + + let(:params) { 'sort: RELATIVE_POSITION_ASC' } + + def query(issue_params = params) + graphql_query_for( + 'project', + { 'fullPath' => sort_project.full_path }, + "issues(#{issue_params}) { pageInfo { endCursor} edges { node { iid dueDate } } }" + ) + end + + before do + post_graphql(query, current_user: current_user) + end + + it_behaves_like 'a working graphql query' + + context 'when ascending' do + it 'sorts issues' do + expect(grab_iids).to eq [relative_issue5.iid, relative_issue3.iid, relative_issue1.iid, relative_issue4.iid, relative_issue2.iid] + end + + context 'when paginating' do + let(:params) { 'sort: RELATIVE_POSITION_ASC, first: 2' } + + it 'sorts issues' do + expect(grab_iids).to eq [relative_issue5.iid, relative_issue3.iid] + + cursored_query = query("sort: RELATIVE_POSITION_ASC, after: \"#{end_cursor}\"") + post_graphql(cursored_query, current_user: current_user) + response_data = JSON.parse(response.body)['data']['project']['issues']['edges'] + + expect(grab_iids(response_data)).to eq [relative_issue1.iid, relative_issue4.iid, relative_issue2.iid] + end + end + end + end + end + + def grab_iids(data = issues_data) + data.map do |issue| + issue.dig('node', 'iid').to_i + end + end end diff --git a/spec/requests/api/graphql/project/merge_request_spec.rb b/spec/requests/api/graphql/project/merge_request_spec.rb index 74820d391022b33dac62706eaa8d78fe88d2a347..70c21666799c6efa924779effcebcb2c174ece45 100644 --- a/spec/requests/api/graphql/project/merge_request_spec.rb +++ b/spec/requests/api/graphql/project/merge_request_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'getting merge request information nested in a project' do diff --git a/spec/requests/api/graphql/project_query_spec.rb b/spec/requests/api/graphql/project_query_spec.rb index 0727ada46918f47392bfb657e5e04369a783a0f9..fbb22958d5145b07b06889c890d28244e18fc9e2 100644 --- a/spec/requests/api/graphql/project_query_spec.rb +++ b/spec/requests/api/graphql/project_query_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'getting project information' do diff --git a/spec/requests/api/group_boards_spec.rb b/spec/requests/api/group_boards_spec.rb index b400a7f55ef9da5efd1564d45745df6a3239848a..232ec9aca32602154e2075a5374b99c568f494a3 100644 --- a/spec/requests/api/group_boards_spec.rb +++ b/spec/requests/api/group_boards_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::GroupBoards do diff --git a/spec/requests/api/group_clusters_spec.rb b/spec/requests/api/group_clusters_spec.rb index 46e3dd650ccf9d83350931543c7040434cfda3f8..97465647a870156b8bc4d75f1b07d8eed3ec9d31 100644 --- a/spec/requests/api/group_clusters_spec.rb +++ b/spec/requests/api/group_clusters_spec.rb @@ -286,12 +286,15 @@ describe API::GroupClusters do let(:update_params) do { domain: domain, - platform_kubernetes_attributes: platform_kubernetes_attributes + platform_kubernetes_attributes: platform_kubernetes_attributes, + management_project_id: management_project_id } end let(:domain) { 'new-domain.com' } let(:platform_kubernetes_attributes) { {} } + let(:management_project) { create(:project, group: group) } + let(:management_project_id) { management_project.id } let(:cluster) do create(:cluster, :group, :provided_by_gcp, @@ -308,6 +311,8 @@ describe API::GroupClusters do context 'authorized user' do before do + management_project.add_maintainer(current_user) + put api("/groups/#{group.id}/clusters/#{cluster.id}", current_user), params: update_params cluster.reload @@ -320,6 +325,7 @@ describe API::GroupClusters do it 'updates cluster attributes' do expect(cluster.domain).to eq('new-domain.com') + expect(cluster.management_project).to eq(management_project) end end @@ -332,6 +338,7 @@ describe API::GroupClusters do it 'does not update cluster attributes' do expect(cluster.domain).to eq('old-domain.com') + expect(cluster.management_project).to be_nil end it 'returns validation errors' do @@ -339,6 +346,18 @@ describe API::GroupClusters do end end + context 'current user does not have access to management_project_id' do + let(:management_project_id) { create(:project).id } + + it 'responds with 400' do + expect(response).to have_gitlab_http_status(400) + end + + it 'returns validation errors' do + expect(json_response['message']['management_project_id'].first).to match('don\'t have permission') + end + end + context 'with a GCP cluster' do context 'when user tries to change GCP specific fields' do let(:platform_kubernetes_attributes) do diff --git a/spec/requests/api/group_container_repositories_spec.rb b/spec/requests/api/group_container_repositories_spec.rb index 0a41e455d01b6730d87bd23b158ae0bb273e4198..785006253d8a91f5d715ad33a742bcc3e16c6edf 100644 --- a/spec/requests/api/group_container_repositories_spec.rb +++ b/spec/requests/api/group_container_repositories_spec.rb @@ -3,10 +3,10 @@ require 'spec_helper' describe API::GroupContainerRepositories do - set(:group) { create(:group, :private) } - set(:project) { create(:project, :private, group: group) } - let(:reporter) { create(:user) } - let(:guest) { create(:user) } + let_it_be(:group) { create(:group, :private) } + let_it_be(:project) { create(:project, :private, group: group) } + let_it_be(:reporter) { create(:user) } + let_it_be(:guest) { create(:user) } let(:root_repository) { create(:container_repository, :root, project: project) } let(:test_repository) { create(:container_repository, project: project) } @@ -44,6 +44,8 @@ describe API::GroupContainerRepositories do let(:object) { group } end + it_behaves_like 'a gitlab tracking event', described_class.name, 'list_repositories' + context 'with invalid group id' do let(:url) { '/groups/123412341234/registry/repositories' } diff --git a/spec/requests/api/group_export_spec.rb b/spec/requests/api/group_export_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ac4853e5388d1f19440027a6758754e4acc6d593 --- /dev/null +++ b/spec/requests/api/group_export_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe API::GroupExport do + let_it_be(:group) { create(:group) } + let_it_be(:user) { create(:user) } + + let(:path) { "/groups/#{group.id}/export" } + let(:download_path) { "/groups/#{group.id}/export/download" } + + let(:export_path) { "#{Dir.tmpdir}/group_export_spec" } + + before do + allow_next_instance_of(Gitlab::ImportExport) do |import_export| + expect(import_export).to receive(:storage_path).and_return(export_path) + end + end + + after do + FileUtils.rm_rf(export_path, secure: true) + end + + describe 'GET /groups/:group_id/export/download' do + let(:upload) { ImportExportUpload.new(group: group) } + + before do + stub_uploads_object_storage(ImportExportUploader) + + group.add_owner(user) + end + + context 'when export file exists' do + before do + upload.export_file = fixture_file_upload('spec/fixtures/group_export.tar.gz', "`/tar.gz") + upload.save! + end + + it 'downloads exported group archive' do + get api(download_path, user) + + expect(response).to have_gitlab_http_status(200) + end + + context 'when export_file.file does not exist' do + before do + expect_next_instance_of(ImportExportUploader) do |uploader| + expect(uploader).to receive(:file).and_return(nil) + end + end + + it 'returns 404' do + get api(download_path, user) + + expect(response).to have_gitlab_http_status(404) + end + end + end + + context 'when export file does not exist' do + it 'returns 404' do + get api(download_path, user) + + expect(response).to have_gitlab_http_status(404) + end + end + end + + describe 'POST /groups/:group_id/export' do + context 'when user is a group owner' do + before do + group.add_owner(user) + end + + it 'accepts download' do + post api(path, user) + + expect(response).to have_gitlab_http_status(202) + end + end + + context 'when user is not a group owner' do + before do + group.add_developer(user) + end + + it 'forbids the request' do + post api(path, user) + + expect(response).to have_gitlab_http_status(403) + end + end + end +end diff --git a/spec/requests/api/group_milestones_spec.rb b/spec/requests/api/group_milestones_spec.rb index 6980eb7f55d9d96c7da7e1730c075d2132c6632f..3e9b6246434ac132e5c9a7a613508732c5596430 100644 --- a/spec/requests/api/group_milestones_spec.rb +++ b/spec/requests/api/group_milestones_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::GroupMilestones do diff --git a/spec/requests/api/group_variables_spec.rb b/spec/requests/api/group_variables_spec.rb index d50bae3dc47284f0df42cdf3c05aff851b3c6d11..abdc3a403606eca6846b7107e24fb2e5c4103d13 100644 --- a/spec/requests/api/group_variables_spec.rb +++ b/spec/requests/api/group_variables_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::GroupVariables do diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index 902a5ec2a86a0a22a901fc604d8979921332bbf4..cb97398805a84f61fa5575ddb2ade05cae3f3be8 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Groups do diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb index a1a007811fef91fd1ca3461ae49f9d1ea2952ebf..bbfe40041a1d00f57aff35fa9aa1d5a96409cd33 100644 --- a/spec/requests/api/helpers_spec.rb +++ b/spec/requests/api/helpers_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'raven/transports/dummy' require_relative '../../../config/initializers/sentry' diff --git a/spec/requests/api/import_github_spec.rb b/spec/requests/api/import_github_spec.rb index 68df02d4d8d952570a7dc008054eb124742a19b9..3ff7102479c04818745c13201b7292072059c8ce 100644 --- a/spec/requests/api/import_github_spec.rb +++ b/spec/requests/api/import_github_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::ImportGithub do diff --git a/spec/requests/api/internal/base_spec.rb b/spec/requests/api/internal/base_spec.rb index 01a2e33c0d9600eea66f9407c868c13f283dcd4b..fcff2cde730b30db22238767219c0064b1baeba3 100644 --- a/spec/requests/api/internal/base_spec.rb +++ b/spec/requests/api/internal/base_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Internal::Base do @@ -316,6 +318,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-get-all-lfs-pointers-go' => 'true', 'gitaly-feature-inforef-uploadpack-cache' => 'true') expect(user.reload.last_activity_on).to eql(Date.today) end end @@ -335,6 +338,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-get-all-lfs-pointers-go' => 'true', 'gitaly-feature-inforef-uploadpack-cache' => 'true') expect(user.reload.last_activity_on).to be_nil end end @@ -407,7 +411,6 @@ describe API::Internal::Base do context "custom action" do let(:access_checker) { double(Gitlab::GitAccess) } - let(:message) { 'CustomActionError message' } let(:payload) do { 'action' => 'geo_proxy_to_primary', @@ -418,8 +421,8 @@ describe API::Internal::Base do } } end - - let(:custom_action_result) { Gitlab::GitAccessResult::CustomAction.new(payload, message) } + let(:console_messages) { ['informational message'] } + let(:custom_action_result) { Gitlab::GitAccessResult::CustomAction.new(payload, console_messages) } before do project.add_guest(user) @@ -446,8 +449,8 @@ describe API::Internal::Base do expect(response).to have_gitlab_http_status(300) expect(json_response['status']).to be_truthy - expect(json_response['message']).to eql(message) expect(json_response['payload']).to eql(payload) + expect(json_response['gl_console_messages']).to eql(console_messages) expect(user.reload.last_activity_on).to be_nil end end @@ -577,6 +580,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-get-all-lfs-pointers-go' => 'true', 'gitaly-feature-inforef-uploadpack-cache' => 'true') end end diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb index 89ee6f896f9bfb846296ac79fad862dabb117ad4..020e7659a4cbdf8c2366214851ba156a052a6614 100644 --- a/spec/requests/api/jobs_spec.rb +++ b/spec/requests/api/jobs_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Jobs do @@ -595,7 +597,7 @@ describe API::Jobs do context 'find proper job' do shared_examples 'a valid file' do - context 'when artifacts are stored locally' do + context 'when artifacts are stored locally', :sidekiq_might_not_need_inline do let(:download_headers) do { 'Content-Transfer-Encoding' => 'binary', 'Content-Disposition' => @@ -674,7 +676,7 @@ describe API::Jobs do let(:visibility_level) { Gitlab::VisibilityLevel::PUBLIC } let(:public_builds) { true } - it 'allows to access artifacts' do + it 'allows to access artifacts', :sidekiq_might_not_need_inline do expect(response).to have_gitlab_http_status(200) expect(response.headers.to_h) .to include('Content-Type' => 'application/json', @@ -711,7 +713,7 @@ describe API::Jobs do let(:visibility_level) { Gitlab::VisibilityLevel::PRIVATE } let(:public_builds) { true } - it 'returns a specific artifact file for a valid path' do + it 'returns a specific artifact file for a valid path', :sidekiq_might_not_need_inline do expect(Gitlab::Workhorse) .to receive(:send_artifacts_entry) .and_call_original @@ -732,7 +734,7 @@ describe API::Jobs do sha: project.commit('improve/awesome').sha) end - it 'returns a specific artifact file for a valid path' do + it 'returns a specific artifact file for a valid path', :sidekiq_might_not_need_inline do get_artifact_file(artifact, 'improve/awesome') expect(response).to have_gitlab_http_status(200) diff --git a/spec/requests/api/keys_spec.rb b/spec/requests/api/keys_spec.rb index f37d84fddefdc7040261da2979e5e9bbb4d7dff4..6802a0cfdabf650e64f1058c4442b55ece0e9c18 100644 --- a/spec/requests/api/keys_spec.rb +++ b/spec/requests/api/keys_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Keys do diff --git a/spec/requests/api/labels_spec.rb b/spec/requests/api/labels_spec.rb index 7089da3d3519430ef79c179db348944fc05e5966..d027738c8db8bf123e4cf0af762e47a92af85e55 100644 --- a/spec/requests/api/labels_spec.rb +++ b/spec/requests/api/labels_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Labels do diff --git a/spec/requests/api/lint_spec.rb b/spec/requests/api/lint_spec.rb index f52cdf1c459920d2edfda0f10a4ce5ee35c14c23..46d23bd16b982047e0d76d2c0412391fb35ea542 100644 --- a/spec/requests/api/lint_spec.rb +++ b/spec/requests/api/lint_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Lint do diff --git a/spec/requests/api/markdown_spec.rb b/spec/requests/api/markdown_spec.rb index 0cf5c5677b91d8f366d5e577d2e31a0181e1567d..99263f2fc1e92bf217b39b20d94d241886937201 100644 --- a/spec/requests/api/markdown_spec.rb +++ b/spec/requests/api/markdown_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "spec_helper" describe API::Markdown do diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb index eb55d747179dc33ae53076985aed13a1980e5c6f..f2942020e162bfdb475b7bcef561d9dc1496ba11 100644 --- a/spec/requests/api/members_spec.rb +++ b/spec/requests/api/members_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Members do @@ -24,7 +26,7 @@ describe API::Members do shared_examples 'GET /:source_type/:id/members/(all)' do |source_type, all| let(:members_url) do - "/#{source_type.pluralize}/#{source.id}/members".tap do |url| + (+"/#{source_type.pluralize}/#{source.id}/members").tap do |url| url << "/all" if all end end @@ -149,9 +151,15 @@ describe API::Members do expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers expect(json_response).to be_an Array - expect(json_response.map { |u| u['id'] }).to eq [developer.id, maintainer.id, nested_user.id, project_user.id, linked_group_user.id] - expect(json_response.map { |u| u['access_level'] }).to eq [Gitlab::Access::DEVELOPER, Gitlab::Access::OWNER, Gitlab::Access::DEVELOPER, - Gitlab::Access::DEVELOPER, Gitlab::Access::DEVELOPER] + + expected_users_and_access_levels = [ + [developer.id, Gitlab::Access::DEVELOPER], + [maintainer.id, Gitlab::Access::OWNER], + [nested_user.id, Gitlab::Access::DEVELOPER], + [project_user.id, Gitlab::Access::DEVELOPER], + [linked_group_user.id, Gitlab::Access::DEVELOPER] + ] + expect(json_response.map { |u| [u['id'], u['access_level']] }).to match_array(expected_users_and_access_levels) end it 'finds all group members including inherited members' do diff --git a/spec/requests/api/merge_request_diffs_spec.rb b/spec/requests/api/merge_request_diffs_spec.rb index 8a67d98fc4c5d9b1f76ca6ec328e1d8b7c93d1e8..9de76c2fe50a8c9e729bf985c0e715308e86e479 100644 --- a/spec/requests/api/merge_request_diffs_spec.rb +++ b/spec/requests/api/merge_request_diffs_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "spec_helper" describe API::MergeRequestDiffs, 'MergeRequestDiffs' do diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 05160a33e616511ab17e58dc9af4e2db8b77ba1f..c96c80b6998b1d9460aa9678360498c8af9be98e 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "spec_helper" describe API::MergeRequests do @@ -10,7 +12,7 @@ describe API::MergeRequests do let(:project) { create(:project, :public, :repository, creator: user, namespace: user.namespace, only_allow_merge_if_pipeline_succeeds: false) } let(:milestone) { create(:milestone, title: '1.0.0', project: project) } let(:milestone1) { create(:milestone, title: '0.9', project: project) } - let!(:merge_request) { create(:merge_request, :simple, milestone: milestone1, author: user, assignees: [user], source_project: project, target_project: project, title: "Test", created_at: base_time) } + let!(:merge_request) { create(:merge_request, :simple, milestone: milestone1, author: user, assignees: [user], source_project: project, target_project: project, source_branch: 'markdown', title: "Test", created_at: base_time) } let!(:merge_request_closed) { create(:merge_request, state: "closed", milestone: milestone1, author: user, assignees: [user], source_project: project, target_project: project, title: "Closed test", created_at: base_time + 1.second) } let!(:merge_request_merged) { create(:merge_request, state: "merged", author: user, assignees: [user], source_project: project, target_project: project, title: "Merged test", created_at: base_time + 2.seconds, merge_commit_sha: '9999999999999999999999999999999999999999') } let!(:merge_request_locked) { create(:merge_request, state: "locked", milestone: milestone1, author: user, assignees: [user], source_project: project, target_project: project, title: "Locked test", created_at: base_time + 1.second) } @@ -699,16 +701,20 @@ describe API::MergeRequests do expect(json_response.first['id']).to eq merge_request_closed.id end - it 'avoids N+1 queries' do - control = ActiveRecord::QueryRecorder.new do - get api("/projects/#{project.id}/merge_requests", user) - end.count + context 'a project which enforces all discussions to be resolved' do + let!(:project) { create(:project, :repository, only_allow_merge_if_all_discussions_are_resolved: true) } - create(:merge_request, author: user, assignees: [user], source_project: project, target_project: project, created_at: base_time) + it 'avoids N+1 queries' do + control = ActiveRecord::QueryRecorder.new do + get api("/projects/#{project.id}/merge_requests", user) + end.count - expect do - get api("/projects/#{project.id}/merge_requests", user) - end.not_to exceed_query_limit(control) + create(:merge_request, author: user, assignees: [user], source_project: project, target_project: project, created_at: base_time) + + expect do + get api("/projects/#{project.id}/merge_requests", user) + end.not_to exceed_query_limit(control) + end end end @@ -775,6 +781,8 @@ describe API::MergeRequests do expect(json_response['merge_error']).to eq(merge_request.merge_error) expect(json_response['user']['can_merge']).to be_truthy 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 end it 'exposes description and title html when render_html is true' do @@ -921,7 +929,7 @@ describe API::MergeRequests do allow_collaboration: true) end - it 'includes the `allow_collaboration` field' do + it 'includes the `allow_collaboration` field', :sidekiq_might_not_need_inline do get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user) expect(json_response['allow_collaboration']).to be_truthy @@ -1035,14 +1043,12 @@ describe API::MergeRequests do describe 'POST /projects/:id/merge_requests/:merge_request_iid/pipelines' do before do - allow_any_instance_of(Ci::Pipeline) - .to receive(:ci_yaml_file) - .and_return(YAML.dump({ - rspec: { - script: 'ls', - only: ['merge_requests'] - } - })) + stub_ci_pipeline_yaml_file(YAML.dump({ + rspec: { + script: 'ls', + only: ['merge_requests'] + } + })) end let(:project) do @@ -1326,7 +1332,7 @@ describe API::MergeRequests do context 'accepts remove_source_branch parameter' do let(:params) do { title: 'Test merge_request', - source_branch: 'markdown', + source_branch: 'feature_conflict', target_branch: 'master', author: user } end @@ -1406,7 +1412,7 @@ describe API::MergeRequests do expect(response).to have_gitlab_http_status(400) end - it 'allows setting `allow_collaboration`' do + it 'allows setting `allow_collaboration`', :sidekiq_might_not_need_inline do post api("/projects/#{forked_project.id}/merge_requests", user2), params: { title: 'Test merge_request', source_branch: "feature_conflict", target_branch: "master", author: user2, target_project_id: project.id, allow_collaboration: true } expect(response).to have_gitlab_http_status(201) @@ -1438,7 +1444,7 @@ describe API::MergeRequests do end end - it "returns 201 when target_branch is specified and for the same project" do + it "returns 201 when target_branch is specified and for the same project", :sidekiq_might_not_need_inline do post api("/projects/#{forked_project.id}/merge_requests", user2), params: { title: 'Test merge_request', target_branch: 'master', source_branch: 'markdown', author: user2, target_project_id: forked_project.id } expect(response).to have_gitlab_http_status(201) @@ -1486,7 +1492,7 @@ describe API::MergeRequests do end describe "PUT /projects/:id/merge_requests/:merge_request_iid/merge" do - let(:pipeline) { create(:ci_pipeline_without_jobs) } + let(:pipeline) { create(:ci_pipeline) } it "returns merge_request in case of success" do put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user) @@ -1633,6 +1639,21 @@ describe API::MergeRequests do expect(source_repository.branch_exists?(source_branch)).to be_falsy end end + + context "performing a ff-merge with squash" do + let(:merge_request) { create(:merge_request, :rebased, source_project: project, squash: true) } + + before do + project.update(merge_requests_ff_only_enabled: true) + end + + it "records the squash commit SHA and returns it in the response" do + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user) + + expect(response).to have_gitlab_http_status(200) + expect(json_response['squash_commit_sha'].length).to eq(40) + end + end end describe "GET /projects/:id/merge_requests/:merge_request_iid/merge_ref", :clean_gitlab_redis_shared_state do @@ -2152,6 +2173,16 @@ describe API::MergeRequests do expect(response).to have_gitlab_http_status(409) end + + it "returns 409 if rebase can't lock the row" do + allow_any_instance_of(MergeRequest).to receive(:with_lock).and_raise(ActiveRecord::LockWaitTimeout) + expect(RebaseWorker).not_to receive(:perform_async) + + 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) + end end describe 'Time tracking' do diff --git a/spec/requests/api/namespaces_spec.rb b/spec/requests/api/namespaces_spec.rb index 2e376109b42f361836f5203db864c41e18f3d8ca..e0bf1509be34afd27eb7a951f54d4a5de81fef74 100644 --- a/spec/requests/api/namespaces_spec.rb +++ b/spec/requests/api/namespaces_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Namespaces do diff --git a/spec/requests/api/notes_spec.rb b/spec/requests/api/notes_spec.rb index 6c1e30791d21c965d1360d7d8a7aadfafb57b0f4..e57d7699892daffc89a279608110a950add97a8e 100644 --- a/spec/requests/api/notes_spec.rb +++ b/spec/requests/api/notes_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Notes do diff --git a/spec/requests/api/notification_settings_spec.rb b/spec/requests/api/notification_settings_spec.rb index 4ed667ad0dc710cbe398803caecb14a141721c3b..09fc0197c587e8e29c2efbc0aeb0f20b398af3e8 100644 --- a/spec/requests/api/notification_settings_spec.rb +++ b/spec/requests/api/notification_settings_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::NotificationSettings do diff --git a/spec/requests/api/oauth_tokens_spec.rb b/spec/requests/api/oauth_tokens_spec.rb index 3811ec751de4d75485e75c9a6c1276dc568fde9a..8d7b3fa3c091d888c6d3701e3449985831573d3e 100644 --- a/spec/requests/api/oauth_tokens_spec.rb +++ b/spec/requests/api/oauth_tokens_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'OAuth tokens' do diff --git a/spec/requests/api/pages/internal_access_spec.rb b/spec/requests/api/pages/internal_access_spec.rb index 28abe1a8456e4c5e8dda29b7193769b650d20547..821a210a4140559aa807f7b5606a2fa3217ee078 100644 --- a/spec/requests/api/pages/internal_access_spec.rb +++ b/spec/requests/api/pages/internal_access_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe "Internal Project Pages Access" do diff --git a/spec/requests/api/pages/private_access_spec.rb b/spec/requests/api/pages/private_access_spec.rb index 6af441caf744b0c96a980a1f3444909d00ceca42..ec84762b05a7330a72f04e4014b44d3aa9c0fbeb 100644 --- a/spec/requests/api/pages/private_access_spec.rb +++ b/spec/requests/api/pages/private_access_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe "Private Project Pages Access" do diff --git a/spec/requests/api/pages/public_access_spec.rb b/spec/requests/api/pages/public_access_spec.rb index d99224eca5bcbd91b5bebb0ab9cb101e80297761..67b8cfb8fbca52e89fdd08878635caa2c2f1fabc 100644 --- a/spec/requests/api/pages/public_access_spec.rb +++ b/spec/requests/api/pages/public_access_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe "Public Project Pages Access" do diff --git a/spec/requests/api/pages_domains_spec.rb b/spec/requests/api/pages_domains_spec.rb index 326b724666d4bd73a68e0c1a24763adf6c96c776..6b774e9335ec9ea65bc96e4ca6ad464f038afdde 100644 --- a/spec/requests/api/pages_domains_spec.rb +++ b/spec/requests/api/pages_domains_spec.rb @@ -1,15 +1,22 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::PagesDomains do - set(:project) { create(:project, path: 'my.project', pages_https_only: false) } - set(:user) { create(:user) } - set(:admin) { create(:admin) } + let_it_be(:project) { create(:project, path: 'my.project', pages_https_only: false) } + let_it_be(:user) { create(:user) } + let_it_be(:admin) { create(:admin) } - set(:pages_domain) { create(:pages_domain, :without_key, :without_certificate, domain: 'www.domain.test', project: project) } - set(:pages_domain_secure) { create(:pages_domain, domain: 'ssl.domain.test', project: project) } - set(:pages_domain_expired) { create(:pages_domain, :with_expired_certificate, domain: 'expired.domain.test', project: project) } + let_it_be(:pages_domain) { create(:pages_domain, :without_key, :without_certificate, domain: 'www.domain.test', project: project) } + let_it_be(:pages_domain_secure) { create(:pages_domain, domain: 'ssl.domain.test', project: project) } + let_it_be(:pages_domain_with_letsencrypt) { create(:pages_domain, :letsencrypt, domain: 'letsencrypt.domain.test', project: project) } + let_it_be(:pages_domain_expired) { create(:pages_domain, :with_expired_certificate, domain: 'expired.domain.test', project: project) } let(:pages_domain_params) { build(:pages_domain, :without_key, :without_certificate, domain: 'www.other-domain.test').slice(:domain) } + let(:pages_domain_with_letsencrypt_params) do + build(:pages_domain, :without_key, :without_certificate, domain: 'www.other-domain.test', auto_ssl_enabled: true) + .slice(:domain, :auto_ssl_enabled) + end let(:pages_domain_secure_params) { build(:pages_domain, domain: 'ssl.other-domain.test', project: project).slice(:domain, :certificate, :key) } let(:pages_domain_secure_key_missmatch_params) {build(:pages_domain, :with_trusted_chain, project: project).slice(:domain, :certificate, :key) } let(:pages_domain_secure_missing_chain_params) {build(:pages_domain, :with_missing_chain, project: project).slice(:certificate) } @@ -20,6 +27,7 @@ describe API::PagesDomains do let(:route_secure_domain) { "/projects/#{project.id}/pages/domains/#{pages_domain_secure.domain}" } let(:route_expired_domain) { "/projects/#{project.id}/pages/domains/#{pages_domain_expired.domain}" } let(:route_vacant_domain) { "/projects/#{project.id}/pages/domains/www.vacant-domain.test" } + let(:route_letsencrypt_domain) { "/projects/#{project.id}/pages/domains/#{pages_domain_with_letsencrypt.domain}" } before do allow(Gitlab.config.pages).to receive(:enabled).and_return(true) @@ -45,9 +53,10 @@ describe API::PagesDomains do expect(response).to match_response_schema('public_api/v4/pages_domain_basics') expect(response).to include_pagination_headers expect(json_response).to be_an Array - expect(json_response.size).to eq(3) + expect(json_response.size).to eq(4) expect(json_response.last).to have_key('domain') expect(json_response.last).to have_key('project_id') + expect(json_response.last).to have_key('auto_ssl_enabled') expect(json_response.last).to have_key('certificate_expiration') expect(json_response.last['certificate_expiration']['expired']).to be true expect(json_response.first).not_to have_key('certificate_expiration') @@ -71,7 +80,7 @@ describe API::PagesDomains do expect(response).to match_response_schema('public_api/v4/pages_domains') expect(response).to include_pagination_headers expect(json_response).to be_an Array - expect(json_response.size).to eq(3) + expect(json_response.size).to eq(4) expect(json_response.map { |pages_domain| pages_domain['domain'] }).to include(pages_domain.domain) expect(json_response.last).to have_key('domain') end @@ -164,6 +173,7 @@ describe API::PagesDomains do expect(json_response['url']).to eq(pages_domain_secure.url) expect(json_response['certificate']['subject']).to eq(pages_domain_secure.subject) expect(json_response['certificate']['expired']).to be false + expect(json_response['auto_ssl_enabled']).to be false end it 'returns pages domain with an expired certificate' do @@ -173,6 +183,18 @@ describe API::PagesDomains do expect(response).to match_response_schema('public_api/v4/pages_domain/detail') expect(json_response['certificate']['expired']).to be true end + + it 'returns pages domain with letsencrypt' do + get api(route_letsencrypt_domain, user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/pages_domain/detail') + expect(json_response['domain']).to eq(pages_domain_with_letsencrypt.domain) + expect(json_response['url']).to eq(pages_domain_with_letsencrypt.url) + expect(json_response['certificate']['subject']).to eq(pages_domain_with_letsencrypt.subject) + expect(json_response['certificate']['expired']).to be false + expect(json_response['auto_ssl_enabled']).to be true + end end context 'when domain is vacant' do @@ -244,6 +266,7 @@ describe API::PagesDomains do expect(pages_domain.domain).to eq(params[:domain]) expect(pages_domain.certificate).to be_nil expect(pages_domain.key).to be_nil + expect(pages_domain.auto_ssl_enabled).to be false end it 'creates a new secure pages domain' do @@ -255,6 +278,29 @@ describe API::PagesDomains do expect(pages_domain.domain).to eq(params_secure[:domain]) expect(pages_domain.certificate).to eq(params_secure[:certificate]) expect(pages_domain.key).to eq(params_secure[:key]) + expect(pages_domain.auto_ssl_enabled).to be false + end + + it 'creates domain with letsencrypt enabled' do + post api(route, user), params: pages_domain_with_letsencrypt_params + pages_domain = PagesDomain.find_by(domain: json_response['domain']) + + expect(response).to have_gitlab_http_status(201) + expect(response).to match_response_schema('public_api/v4/pages_domain/detail') + expect(pages_domain.domain).to eq(pages_domain_with_letsencrypt_params[:domain]) + expect(pages_domain.auto_ssl_enabled).to be true + end + + it 'creates domain with letsencrypt enabled and provided certificate' do + post api(route, user), params: params_secure.merge(auto_ssl_enabled: true) + pages_domain = PagesDomain.find_by(domain: json_response['domain']) + + expect(response).to have_gitlab_http_status(201) + expect(response).to match_response_schema('public_api/v4/pages_domain/detail') + expect(pages_domain.domain).to eq(params_secure[:domain]) + expect(pages_domain.certificate).to eq(params_secure[:certificate]) + expect(pages_domain.key).to eq(params_secure[:key]) + expect(pages_domain.auto_ssl_enabled).to be true end it 'fails to create pages domain without key' do @@ -321,13 +367,14 @@ describe API::PagesDomains do shared_examples_for 'put pages domain' do it 'updates pages domain removing certificate' do - put api(route_secure_domain, user) + put api(route_secure_domain, user), params: { certificate: nil, key: nil } pages_domain_secure.reload expect(response).to have_gitlab_http_status(200) expect(response).to match_response_schema('public_api/v4/pages_domain/detail') expect(pages_domain_secure.certificate).to be_nil expect(pages_domain_secure.key).to be_nil + expect(pages_domain_secure.auto_ssl_enabled).to be false end it 'updates pages domain adding certificate' do @@ -340,6 +387,37 @@ describe API::PagesDomains do expect(pages_domain.key).to eq(params_secure[:key]) end + it 'updates pages domain adding certificate with letsencrypt' do + put api(route_domain, user), params: params_secure.merge(auto_ssl_enabled: true) + pages_domain.reload + + expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/pages_domain/detail') + expect(pages_domain.certificate).to eq(params_secure[:certificate]) + expect(pages_domain.key).to eq(params_secure[:key]) + expect(pages_domain.auto_ssl_enabled).to be true + end + + it 'updates pages domain enabling letsencrypt' do + put api(route_domain, user), params: { auto_ssl_enabled: true } + pages_domain.reload + + expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/pages_domain/detail') + expect(pages_domain.auto_ssl_enabled).to be true + end + + it 'updates pages domain disabling letsencrypt while preserving the certificate' do + put api(route_letsencrypt_domain, user), params: { auto_ssl_enabled: false } + pages_domain_with_letsencrypt.reload + + expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/pages_domain/detail') + expect(pages_domain_with_letsencrypt.auto_ssl_enabled).to be false + expect(pages_domain_with_letsencrypt.key).to be + expect(pages_domain_with_letsencrypt.certificate).to be + end + it 'updates pages domain with expired certificate' do put api(route_expired_domain, user), params: params_secure pages_domain_expired.reload diff --git a/spec/requests/api/pipeline_schedules_spec.rb b/spec/requests/api/pipeline_schedules_spec.rb index 072bd02f2acbfe525d15713a14bd11e9eabb03fc..5c8ccce2e37066e92c561fce81ad0102f1d5f0c7 100644 --- a/spec/requests/api/pipeline_schedules_spec.rb +++ b/spec/requests/api/pipeline_schedules_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::PipelineSchedules do diff --git a/spec/requests/api/pipelines_spec.rb b/spec/requests/api/pipelines_spec.rb index 3ac63dc381b9fb6e1b423ea40df21a76b27fc19d..cce52cfc1caac4a76317a6f40565cf8b05eba2c0 100644 --- a/spec/requests/api/pipelines_spec.rb +++ b/spec/requests/api/pipelines_spec.rb @@ -673,7 +673,7 @@ describe API::Pipelines do let!(:build) { create(:ci_build, :running, pipeline: pipeline) } context 'authorized user' do - it 'retries failed builds' do + it 'retries failed builds', :sidekiq_might_not_need_inline do post api("/projects/#{project.id}/pipelines/#{pipeline.id}/cancel", user) expect(response).to have_gitlab_http_status(200) diff --git a/spec/requests/api/project_clusters_spec.rb b/spec/requests/api/project_clusters_spec.rb index a7b919de2ef79c791217532f2163040c29247751..04e5923887745124a845dd5736d8da5f193b34ee 100644 --- a/spec/requests/api/project_clusters_spec.rb +++ b/spec/requests/api/project_clusters_spec.rb @@ -281,11 +281,14 @@ describe API::ProjectClusters do let(:api_url) { 'https://kubernetes.example.com' } let(:namespace) { 'new-namespace' } let(:platform_kubernetes_attributes) { { namespace: namespace } } + let(:management_project) { create(:project, namespace: project.namespace) } + let(:management_project_id) { management_project.id } let(:update_params) do { domain: 'new-domain.com', - platform_kubernetes_attributes: platform_kubernetes_attributes + platform_kubernetes_attributes: platform_kubernetes_attributes, + management_project_id: management_project_id } end @@ -310,6 +313,8 @@ describe API::ProjectClusters do context 'authorized user' do before do + management_project.add_maintainer(current_user) + put api("/projects/#{project.id}/clusters/#{cluster.id}", current_user), params: update_params cluster.reload @@ -323,6 +328,7 @@ describe API::ProjectClusters do it 'updates cluster attributes' do expect(cluster.domain).to eq('new-domain.com') expect(cluster.platform_kubernetes.namespace).to eq('new-namespace') + expect(cluster.management_project).to eq(management_project) end end @@ -336,6 +342,7 @@ describe API::ProjectClusters do it 'does not update cluster attributes' do expect(cluster.domain).not_to eq('new_domain.com') expect(cluster.platform_kubernetes.namespace).not_to eq('invalid_namespace') + expect(cluster.management_project).not_to eq(management_project) end it 'returns validation errors' do @@ -343,6 +350,18 @@ describe API::ProjectClusters do end end + context 'current user does not have access to management_project_id' do + let(:management_project_id) { create(:project).id } + + it 'responds with 400' do + expect(response).to have_gitlab_http_status(400) + end + + it 'returns validation errors' do + expect(json_response['message']['management_project_id'].first).to match('don\'t have permission') + end + end + context 'with a GCP cluster' do context 'when user tries to change GCP specific fields' do let(:platform_kubernetes_attributes) do diff --git a/spec/requests/api/project_container_repositories_spec.rb b/spec/requests/api/project_container_repositories_spec.rb index 3ac7ff7656be02db6c09a1f44da6bc905d9e81f4..d04db134db01656c29fcafd13e329d58a1cd00a6 100644 --- a/spec/requests/api/project_container_repositories_spec.rb +++ b/spec/requests/api/project_container_repositories_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::ProjectContainerRepositories do @@ -44,6 +46,7 @@ describe API::ProjectContainerRepositories do it_behaves_like 'rejected container repository access', :guest, :forbidden it_behaves_like 'rejected container repository access', :anonymous, :not_found + it_behaves_like 'a gitlab tracking event', described_class.name, 'list_repositories' it_behaves_like 'returns repositories for allowed users', :reporter, 'project' do let(:object) { project } @@ -55,6 +58,7 @@ describe API::ProjectContainerRepositories do it_behaves_like 'rejected container repository access', :developer, :forbidden it_behaves_like 'rejected container repository access', :anonymous, :not_found + it_behaves_like 'a gitlab tracking event', described_class.name, 'delete_repository' context 'for maintainer' do let(:api_user) { maintainer } @@ -83,6 +87,8 @@ describe API::ProjectContainerRepositories do stub_container_registry_tags(repository: root_repository.path, tags: %w(rootA latest)) end + it_behaves_like 'a gitlab tracking event', described_class.name, 'list_tags' + it 'returns a list of tags' do subject @@ -109,6 +115,7 @@ describe API::ProjectContainerRepositories do it_behaves_like 'rejected container repository access', :developer, :forbidden it_behaves_like 'rejected container repository access', :anonymous, :not_found + it_behaves_like 'a gitlab tracking event', described_class.name, 'delete_tag_bulk' end context 'for maintainer' do @@ -220,6 +227,7 @@ describe API::ProjectContainerRepositories do it 'properly removes tag' do expect(service).to receive(:execute).with(root_repository) { { status: :success } } expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(root_repository.project, api_user, tags: %w[rootA]) { service } + expect(Gitlab::Tracking).to receive(:event).with(described_class.name, 'delete_tag', {}) subject @@ -235,6 +243,7 @@ describe API::ProjectContainerRepositories do it 'properly removes tag' do expect(service).to receive(:execute).with(root_repository) { { status: :success } } expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(root_repository.project, api_user, tags: %w[rootA]) { service } + expect(Gitlab::Tracking).to receive(:event).with(described_class.name, 'delete_tag', {}) subject diff --git a/spec/requests/api/project_events_spec.rb b/spec/requests/api/project_events_spec.rb index 8c2db6e4c626986c5f4b982ed70007142610ec4d..d466dca9884d90bdb374db97fb39f2792661b8de 100644 --- a/spec/requests/api/project_events_spec.rb +++ b/spec/requests/api/project_events_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::ProjectEvents do diff --git a/spec/requests/api/project_export_spec.rb b/spec/requests/api/project_export_spec.rb index 7de8935097a2e00548d10f6540f1aefcbecc302e..605ff888234f54786f27d93e626d8643af119e8f 100644 --- a/spec/requests/api/project_export_spec.rb +++ b/spec/requests/api/project_export_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::ProjectExport do @@ -370,7 +372,7 @@ describe API::ProjectExport do end context 'when overriding description' do - it 'starts' do + it 'starts', :sidekiq_might_not_need_inline do params = { description: "Foo" } expect_any_instance_of(Projects::ImportExport::ExportService).to receive(:execute) diff --git a/spec/requests/api/project_hooks_spec.rb b/spec/requests/api/project_hooks_spec.rb index b88a8b95201bca892b089dc44adb0efaf14aeb8f..06c09b100ac823461c925495d806628c40973a9a 100644 --- a/spec/requests/api/project_hooks_spec.rb +++ b/spec/requests/api/project_hooks_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::ProjectHooks, 'ProjectHooks' do diff --git a/spec/requests/api/project_import_spec.rb b/spec/requests/api/project_import_spec.rb index d2b1fb063b8c7c8910b35e358dfc99265b7adfae..866adbd424e78507bfe33bf8cfa664d6b9813f18 100644 --- a/spec/requests/api/project_import_spec.rb +++ b/spec/requests/api/project_import_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::ProjectImport do @@ -153,7 +155,7 @@ describe API::ProjectImport do expect(import_project.import_data.data['override_params']).to be_empty end - it 'correctly overrides params during the import' do + it 'correctly overrides params during the import', :sidekiq_might_not_need_inline do override_params = { 'description' => 'Hello world' } perform_enqueued_jobs do diff --git a/spec/requests/api/project_milestones_spec.rb b/spec/requests/api/project_milestones_spec.rb index 895f05a98e8eca1ebd1841de59723d699db20605..df6d83c1e652c74cf0f45d5661b76e094660e795 100644 --- a/spec/requests/api/project_milestones_spec.rb +++ b/spec/requests/api/project_milestones_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::ProjectMilestones do diff --git a/spec/requests/api/project_snapshots_spec.rb b/spec/requests/api/project_snapshots_spec.rb index 2857715cdbe5aee9b1446f9add29f8695e412341..cdd44f71649c2b684c097ab1fe5e5a193a2a38e8 100644 --- a/spec/requests/api/project_snapshots_spec.rb +++ b/spec/requests/api/project_snapshots_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::ProjectSnapshots do diff --git a/spec/requests/api/project_snippets_spec.rb b/spec/requests/api/project_snippets_spec.rb index ef0cabad4b06f3619d7ac37391bcd0827b1c57e1..cac3f07d0d014826e7f5156b151b4028a87a5bb2 100644 --- a/spec/requests/api/project_snippets_spec.rb +++ b/spec/requests/api/project_snippets_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::ProjectSnippets do diff --git a/spec/requests/api/project_templates_spec.rb b/spec/requests/api/project_templates_spec.rb index 80e5033dab4af5ce6b9233d9cd431ac103446557..2bf864afe8769cd81b7b3bc6450e5318d5666bd0 100644 --- a/spec/requests/api/project_templates_spec.rb +++ b/spec/requests/api/project_templates_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::ProjectTemplates do diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 99d2a68ef53c2f5c08728d57f9e714b30ab715e8..f1447536e0fef3575dd3d8ae93f81c2fcc97ad4c 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' shared_examples 'languages and percentages JSON response' do @@ -15,7 +17,7 @@ shared_examples 'languages and percentages JSON response' do end context "when the languages haven't been detected yet" do - it 'returns expected language values' do + it 'returns expected language values', :sidekiq_might_not_need_inline do get api("/projects/#{project.id}/languages", user) expect(response).to have_gitlab_http_status(:ok) @@ -360,6 +362,30 @@ describe API::Projects do end end + context 'and using id_after' do + it_behaves_like 'projects response' do + let(:filter) { { id_after: project2.id } } + let(:current_user) { user } + let(:projects) { [public_project, project, project2, project3].select { |p| p.id > project2.id } } + end + end + + context 'and using id_before' do + it_behaves_like 'projects response' do + let(:filter) { { id_before: project2.id } } + let(:current_user) { user } + let(:projects) { [public_project, project, project2, project3].select { |p| p.id < project2.id } } + end + end + + context 'and using both id_after and id_before' do + it_behaves_like 'projects response' do + let(:filter) { { id_before: project2.id, id_after: public_project.id } } + let(:current_user) { user } + let(:projects) { [public_project, project, project2, project3].select { |p| p.id < project2.id && p.id > public_project.id } } + end + end + context 'and membership=true' do it_behaves_like 'projects response' do let(:filter) { { membership: true } } @@ -606,6 +632,7 @@ describe API::Projects do merge_requests_enabled: false, wiki_enabled: false, resolve_outdated_diff_discussions: false, + remove_source_branch_after_merge: true, only_allow_merge_if_pipeline_succeeds: false, request_access_enabled: true, only_allow_merge_if_all_discussions_are_resolved: false, @@ -722,6 +749,22 @@ describe API::Projects do expect(json_response['resolve_outdated_diff_discussions']).to be_truthy end + it 'sets a project as not removing source branches' do + project = attributes_for(:project, remove_source_branch_after_merge: false) + + post api('/projects', user), params: project + + expect(json_response['remove_source_branch_after_merge']).to be_falsey + end + + it 'sets a project as removing source branches' do + project = attributes_for(:project, remove_source_branch_after_merge: true) + + post api('/projects', user), params: project + + expect(json_response['remove_source_branch_after_merge']).to be_truthy + end + it 'sets a project as allowing merge even if build fails' do project = attributes_for(:project, only_allow_merge_if_pipeline_succeeds: false) @@ -829,6 +872,63 @@ describe API::Projects do expect(json_response.map { |project| project['id'] }).to contain_exactly(public_project.id) end + context 'and using id_after' do + let!(:another_public_project) { create(:project, :public, name: 'another_public_project', creator_id: user4.id, namespace: user4.namespace) } + + it 'only returns projects with id_after filter given' do + get api("/users/#{user4.id}/projects?id_after=#{public_project.id}", 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.map { |project| project['id'] }).to contain_exactly(another_public_project.id) + end + + it 'returns both projects without a id_after filter' do + get api("/users/#{user4.id}/projects", 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.map { |project| project['id'] }).to contain_exactly(public_project.id, another_public_project.id) + end + end + + context 'and using id_before' do + let!(:another_public_project) { create(:project, :public, name: 'another_public_project', creator_id: user4.id, namespace: user4.namespace) } + + it 'only returns projects with id_before filter given' do + get api("/users/#{user4.id}/projects?id_before=#{another_public_project.id}", 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.map { |project| project['id'] }).to contain_exactly(public_project.id) + end + + it 'returns both projects without a id_before filter' do + get api("/users/#{user4.id}/projects", 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.map { |project| project['id'] }).to contain_exactly(public_project.id, another_public_project.id) + end + end + + context 'and using both id_before and id_after' do + let!(:more_projects) { create_list(:project, 5, :public, creator_id: user4.id, namespace: user4.namespace) } + + it 'only returns projects with id matching the range' do + get api("/users/#{user4.id}/projects?id_after=#{more_projects.first.id}&id_before=#{more_projects.last.id}", 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.map { |project| project['id'] }).to contain_exactly(*more_projects[1..-2].map(&:id)) + end + end + it 'returns projects filtered by username' do get api("/users/#{user4.username}/projects/", user) @@ -980,6 +1080,22 @@ describe API::Projects do expect(json_response['resolve_outdated_diff_discussions']).to be_truthy end + it 'sets a project as not removing source branches' do + project = attributes_for(:project, remove_source_branch_after_merge: false) + + post api("/projects/user/#{user.id}", admin), params: project + + expect(json_response['remove_source_branch_after_merge']).to be_falsey + end + + it 'sets a project as removing source branches' do + project = attributes_for(:project, remove_source_branch_after_merge: true) + + post api("/projects/user/#{user.id}", admin), params: project + + expect(json_response['remove_source_branch_after_merge']).to be_truthy + end + it 'sets a project as allowing merge even if build fails' do project = attributes_for(:project, only_allow_merge_if_pipeline_succeeds: false) post api("/projects/user/#{user.id}", admin), params: project @@ -1157,6 +1273,7 @@ describe API::Projects do expect(json_response['wiki_access_level']).to be_present expect(json_response['builds_access_level']).to be_present expect(json_response['resolve_outdated_diff_discussions']).to eq(project.resolve_outdated_diff_discussions) + expect(json_response['remove_source_branch_after_merge']).to be_truthy expect(json_response['container_registry_enabled']).to be_present expect(json_response['created_at']).to be_present expect(json_response['last_activity_at']).to be_present diff --git a/spec/requests/api/protected_branches_spec.rb b/spec/requests/api/protected_branches_spec.rb index f90558d77a9d930894dd4289fcd213ac16b2804e..67ce704b3f3f0ff039c1c1b6527897a9299bfd2e 100644 --- a/spec/requests/api/protected_branches_spec.rb +++ b/spec/requests/api/protected_branches_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::ProtectedBranches do diff --git a/spec/requests/api/protected_tags_spec.rb b/spec/requests/api/protected_tags_spec.rb index 41363dcc1c3c1e27d53a90da6eb7eed0e93ba3a1..5a962cd5667402d9387c1de6d7ba0116f73c8125 100644 --- a/spec/requests/api/protected_tags_spec.rb +++ b/spec/requests/api/protected_tags_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::ProtectedTags do diff --git a/spec/requests/api/releases_spec.rb b/spec/requests/api/releases_spec.rb index 99d0ceee76b41868da6d7bbb818e22aad87a8b42..bf05587fe035c77071079532293be263a5112450 100644 --- a/spec/requests/api/releases_spec.rb +++ b/spec/requests/api/releases_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Releases do diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb index 6f4bb525c8977cb1bd1f8c192641f4fb3b02aa67..ba301147d43d5a8b66652b10907b05bfb000afd7 100644 --- a/spec/requests/api/repositories_spec.rb +++ b/spec/requests/api/repositories_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'mime/types' diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb index 70a95663aeaf038fa1ab12f96bfc8342101cbbba..6138036b0af59f3e650620ca6caa5442ad9533a8 100644 --- a/spec/requests/api/runner_spec.rb +++ b/spec/requests/api/runner_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Runner, :clean_gitlab_redis_shared_state do @@ -312,7 +314,7 @@ describe API::Runner, :clean_gitlab_redis_shared_state do let(:root_namespace) { create(:namespace) } let(:namespace) { create(:namespace, parent: root_namespace) } let(:project) { create(:project, namespace: namespace, shared_runners_enabled: false) } - let(:pipeline) { create(:ci_pipeline_without_jobs, project: project, ref: 'master') } + let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') } let(:runner) { create(:ci_runner, :project, projects: [project]) } let(:job) do create(:ci_build, :artifacts, :extended_options, @@ -610,7 +612,7 @@ describe API::Runner, :clean_gitlab_redis_shared_state do end context 'when job is made for merge request' do - let(:pipeline) { create(:ci_pipeline_without_jobs, source: :merge_request_event, project: project, ref: 'feature', merge_request: merge_request) } + let(:pipeline) { create(:ci_pipeline, source: :merge_request_event, project: project, ref: 'feature', merge_request: merge_request) } let!(:job) { create(:ci_build, pipeline: pipeline, name: 'spinach', ref: 'feature', stage: 'test', stage_idx: 0) } let(:merge_request) { create(:merge_request) } diff --git a/spec/requests/api/runners_spec.rb b/spec/requests/api/runners_spec.rb index d26fbee6957d90c63a5f7e7a630cd34f5aad1b99..8daba204d50b9e7f4550e4f7c67635550021bb4f 100644 --- a/spec/requests/api/runners_spec.rb +++ b/spec/requests/api/runners_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Runners do diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb index 8abdcaa2e0e96adcbc3e940ac02761b5e3e56ea9..24d7f1e313c93a0b4716ac0c26d6ab3b393400c0 100644 --- a/spec/requests/api/search_spec.rb +++ b/spec/requests/api/search_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Search do @@ -436,6 +438,7 @@ describe API::Search do expect(response).to have_gitlab_http_status(200) expect(json_response.size).to eq(2) + expect(json_response.first['path']).to eq('PROCESS.md') expect(json_response.first['filename']).to eq('PROCESS.md') end diff --git a/spec/requests/api/services_spec.rb b/spec/requests/api/services_spec.rb index 7153fcc99d7829a3ae361bda038d8c75230fb0c9..a080b59173f8586357947fd1ad3b761212eb7778 100644 --- a/spec/requests/api/services_spec.rb +++ b/spec/requests/api/services_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "spec_helper" describe API::Services do @@ -100,7 +102,7 @@ describe API::Services do expect(json_response['properties'].keys).to match_array(service_instance.api_field_names) end - it "returns empty hash if properties and data fields are empty" do + it "returns empty hash or nil values if properties and data fields are empty" do # deprecated services are not valid for update initialized_service.update_attribute(:properties, {}) @@ -112,7 +114,7 @@ describe API::Services do get api("/projects/#{project.id}/services/#{dashed_service}", user) expect(response).to have_gitlab_http_status(200) - expect(json_response['properties'].keys).to be_empty + expect(json_response['properties'].values.compact).to be_empty end it "returns error when authenticated but not a project owner" do diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb index f3bfb258029daa59a249548f0181ec0bfcad4108..b7586307929a38972a1a6c487600db436bc07acf 100644 --- a/spec/requests/api/settings_spec.rb +++ b/spec/requests/api/settings_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Settings, 'Settings' do @@ -16,6 +18,10 @@ describe API::Settings, 'Settings' do expect(json_response['password_authentication_enabled']).to be_truthy expect(json_response['plantuml_enabled']).to be_falsey expect(json_response['plantuml_url']).to be_nil + expect(json_response['default_ci_config_path']).to be_nil + expect(json_response['sourcegraph_enabled']).to be_falsey + expect(json_response['sourcegraph_url']).to be_nil + expect(json_response['sourcegraph_public_only']).to be_truthy expect(json_response['default_project_visibility']).to be_a String expect(json_response['default_snippet_visibility']).to be_a String expect(json_response['default_group_visibility']).to be_a String @@ -42,17 +48,22 @@ describe API::Settings, 'Settings' do storages = Gitlab.config.repositories.storages .merge({ 'custom' => 'tmp/tests/custom_repositories' }) allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) + Feature.get(:sourcegraph).enable end it "updates application settings" do put api("/application/settings", admin), params: { + default_ci_config_path: 'debian/salsa-ci.yml', default_projects_limit: 3, default_project_creation: 2, password_authentication_enabled_for_web: false, repository_storages: ['custom'], plantuml_enabled: true, plantuml_url: 'http://plantuml.example.com', + sourcegraph_enabled: true, + sourcegraph_url: 'https://sourcegraph.com', + sourcegraph_public_only: false, default_snippet_visibility: 'internal', restricted_visibility_levels: ['public'], default_artifacts_expire_in: '2 days', @@ -78,12 +89,16 @@ describe API::Settings, 'Settings' do } expect(response).to have_gitlab_http_status(200) + expect(json_response['default_ci_config_path']).to eq('debian/salsa-ci.yml') expect(json_response['default_projects_limit']).to eq(3) expect(json_response['default_project_creation']).to eq(::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS) expect(json_response['password_authentication_enabled_for_web']).to be_falsey expect(json_response['repository_storages']).to eq(['custom']) expect(json_response['plantuml_enabled']).to be_truthy expect(json_response['plantuml_url']).to eq('http://plantuml.example.com') + expect(json_response['sourcegraph_enabled']).to be_truthy + expect(json_response['sourcegraph_url']).to eq('https://sourcegraph.com') + expect(json_response['sourcegraph_public_only']).to eq(false) expect(json_response['default_snippet_visibility']).to eq('internal') expect(json_response['restricted_visibility_levels']).to eq(['public']) expect(json_response['default_artifacts_expire_in']).to eq('2 days') @@ -176,7 +191,8 @@ describe API::Settings, 'Settings' do snowplow_collector_hostname: "snowplow.example.com", snowplow_cookie_domain: ".example.com", snowplow_enabled: true, - snowplow_site_id: "site_id" + snowplow_app_id: "app_id", + snowplow_iglu_registry_url: 'https://example.com' } end @@ -220,6 +236,61 @@ describe API::Settings, 'Settings' do end end + context 'EKS integration settings' do + let(:attribute_names) { settings.keys.map(&:to_s) } + let(:sensitive_attributes) { %w(eks_secret_access_key) } + let(:exposed_attributes) { attribute_names - sensitive_attributes } + + let(:settings) do + { + eks_integration_enabled: true, + eks_account_id: '123456789012', + eks_access_key_id: 'access-key-id-12', + eks_secret_access_key: 'secret-access-key' + } + end + + it 'includes attributes in the API' do + get api("/application/settings", admin) + + expect(response).to have_gitlab_http_status(200) + exposed_attributes.each do |attribute| + expect(json_response.keys).to include(attribute) + end + end + + it 'does not include sensitive attributes in the API' do + get api("/application/settings", admin) + + expect(response).to have_gitlab_http_status(200) + sensitive_attributes.each do |attribute| + expect(json_response.keys).not_to include(attribute) + end + end + + it 'allows updating the settings' do + put api("/application/settings", admin), params: settings + + expect(response).to have_gitlab_http_status(200) + settings.each do |attribute, value| + expect(ApplicationSetting.current.public_send(attribute)).to eq(value) + end + end + + context 'EKS integration is enabled but params are blank' do + let(:settings) { Hash[eks_integration_enabled: true] } + + it 'does not update the settings' do + put api("/application/settings", admin), params: settings + + expect(response).to have_gitlab_http_status(400) + expect(json_response['error']).to include('eks_account_id is missing') + expect(json_response['error']).to include('eks_access_key_id is missing') + expect(json_response['error']).to include('eks_secret_access_key is missing') + end + end + end + context "missing plantuml_url value when plantuml_enabled is true" do it "returns a blank parameter error message" do put api("/application/settings", admin), params: { plantuml_enabled: true } @@ -294,5 +365,14 @@ describe API::Settings, 'Settings' do expect(json_response['domain_blacklist']).to eq(['domain3.com', '*.domain4.com']) end end + + context "missing sourcegraph_url value when sourcegraph_enabled is true" do + it "returns a blank parameter error message" do + put api("/application/settings", admin), params: { sourcegraph_enabled: true } + + expect(response).to have_gitlab_http_status(400) + expect(json_response['error']).to eq('sourcegraph_url is missing') + end + end end end diff --git a/spec/requests/api/sidekiq_metrics_spec.rb b/spec/requests/api/sidekiq_metrics_spec.rb index fff9adb7f570a267809e5926b96c995c2a39e01e..438b1475c54c30f355a5db819c0c555ab180c231 100644 --- a/spec/requests/api/sidekiq_metrics_spec.rb +++ b/spec/requests/api/sidekiq_metrics_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::SidekiqMetrics do @@ -23,6 +25,10 @@ describe API::SidekiqMetrics do expect(response).to have_gitlab_http_status(200) expect(json_response).to be_a Hash + expect(json_response['jobs']).to be_a Hash + expect(json_response['jobs'].keys) + .to contain_exactly(*%w[processed failed enqueued dead]) + expect(json_response['jobs'].values).to all(be_an(Integer)) end it 'defines the `compound_metrics` endpoint' do diff --git a/spec/requests/api/snippets_spec.rb b/spec/requests/api/snippets_spec.rb index e7eaaea2418d97e7d57a8177d87883a8e745ea13..36d2a0d7ea7ca2fd3e5bd6e8be432f4191bd5976 100644 --- a/spec/requests/api/snippets_spec.rb +++ b/spec/requests/api/snippets_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Snippets do diff --git a/spec/requests/api/system_hooks_spec.rb b/spec/requests/api/system_hooks_spec.rb index 0e2f3face7171785a772baf9440519e31738dc85..79790b1e9999635d994ab6c0c77a031aaab76e48 100644 --- a/spec/requests/api/system_hooks_spec.rb +++ b/spec/requests/api/system_hooks_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::SystemHooks do diff --git a/spec/requests/api/tags_spec.rb b/spec/requests/api/tags_spec.rb index c4f4a2cb88944362089d018a3164d278092f9311..3c6ec6316646dea5bf8478d0df0fa7128b01dca9 100644 --- a/spec/requests/api/tags_spec.rb +++ b/spec/requests/api/tags_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Tags do diff --git a/spec/requests/api/templates_spec.rb b/spec/requests/api/templates_spec.rb index d1e16ab9ca92aff2451602cd394ae9f38aed7940..b6ba417d8924a066dd3f9bf7f352804ba270fcfa 100644 --- a/spec/requests/api/templates_spec.rb +++ b/spec/requests/api/templates_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Templates do diff --git a/spec/requests/api/todos_spec.rb b/spec/requests/api/todos_spec.rb index 9f0d5ad5d126faa5b77679dd9df0f247fd57dace..4121a0f3f3ab31e1e327f97031032835a18defa4 100644 --- a/spec/requests/api/todos_spec.rb +++ b/spec/requests/api/todos_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Todos do diff --git a/spec/requests/api/triggers_spec.rb b/spec/requests/api/triggers_spec.rb index 8ea3d16a41f6bb3e0e0b201c054f84638dbfce04..fd1104fa97837f9f2c549867b5859c8e58943b8c 100644 --- a/spec/requests/api/triggers_spec.rb +++ b/spec/requests/api/triggers_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Triggers do diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index ee4e783e9acdadbe470d4512bedf0eac1b31f271..1a1e80f1ce3a9cdfda91a9082e2f0b1191482e1a 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Users do @@ -633,32 +635,6 @@ describe API::Users do end end - describe "GET /users/sign_up" do - context 'when experimental signup_flow is active' do - before do - stub_experiment(signup_flow: true) - end - - it "shows sign up page" do - get "/users/sign_up" - expect(response).to have_gitlab_http_status(200) - expect(response).to render_template(:new) - end - end - - context 'when experimental signup_flow is not active' do - before do - stub_experiment(signup_flow: false) - end - - it "redirects to sign in page" do - get "/users/sign_up" - expect(response).to have_gitlab_http_status(302) - expect(response).to redirect_to(new_user_session_path(anchor: 'register-pane')) - end - end - end - describe "PUT /users/:id" do let!(:admin_user) { create(:admin) } @@ -1277,7 +1253,7 @@ describe API::Users do admin end - it "deletes user" do + it "deletes user", :sidekiq_might_not_need_inline do perform_enqueued_jobs { delete api("/users/#{user.id}", admin) } expect(response).to have_gitlab_http_status(204) @@ -1312,7 +1288,7 @@ describe API::Users do end context "hard delete disabled" do - it "moves contributions to the ghost user" do + it "moves contributions to the ghost user", :sidekiq_might_not_need_inline do perform_enqueued_jobs { delete api("/users/#{user.id}", admin) } expect(response).to have_gitlab_http_status(204) @@ -1322,7 +1298,7 @@ describe API::Users do end context "hard delete enabled" do - it "removes contributions" do + it "removes contributions", :sidekiq_might_not_need_inline do perform_enqueued_jobs { delete api("/users/#{user.id}?hard_delete=true", admin) } expect(response).to have_gitlab_http_status(204) diff --git a/spec/requests/api/variables_spec.rb b/spec/requests/api/variables_spec.rb index 69f105b71a8ea3eb552cf60a226c4047b7dd074f..dfecd43cbfa0aa8416c26292142a08d8e1e22c13 100644 --- a/spec/requests/api/variables_spec.rb +++ b/spec/requests/api/variables_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Variables do diff --git a/spec/requests/api/version_spec.rb b/spec/requests/api/version_spec.rb index e06f8bbc09577193683f7f1f78c14f16cbbeaf4e..e2117ca45ee1fb970cf1b58afe272984e0e4e4fe 100644 --- a/spec/requests/api/version_spec.rb +++ b/spec/requests/api/version_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Version do diff --git a/spec/requests/api/wikis_spec.rb b/spec/requests/api/wikis_spec.rb index 97de26650dbd64b77d6f268acfc27361ecfef26f..310caa92eb93de4c7892f9e46bb8a5fbf5501990 100644 --- a/spec/requests/api/wikis_spec.rb +++ b/spec/requests/api/wikis_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' # For every API endpoint we test 3 states of wikis: diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index e58f1b7d9dc6b58810fb26cc02f4b6fcb5adcb2b..1b17d492b0c303168f42e942b10546fc0ce42422 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'Git HTTP requests' do @@ -87,7 +89,7 @@ describe 'Git HTTP requests' do end shared_examples_for 'pulls are allowed' do - it do + it 'allows pulls' do download(path, env) do |response| expect(response).to have_gitlab_http_status(:ok) expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) @@ -96,7 +98,7 @@ describe 'Git HTTP requests' do end shared_examples_for 'pushes are allowed' do - it do + it 'allows pushes', :sidekiq_might_not_need_inline do upload(path, env) do |response| expect(response).to have_gitlab_http_status(:ok) expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) @@ -450,16 +452,22 @@ describe 'Git HTTP requests' do context "when authentication fails" do context "when the user is IP banned" do before do - Gitlab.config.rack_attack.git_basic_auth['enabled'] = true + stub_rack_attack_setting(enabled: true, ip_whitelist: []) end - it "responds with status 401" do + it "responds with status 403" do expect(Rack::Attack::Allow2Ban).to receive(:filter).and_return(true) - allow_any_instance_of(ActionDispatch::Request).to receive(:ip).and_return('1.2.3.4') + expect(Gitlab::AuthLogger).to receive(:error).with({ + message: 'Rack_Attack', + env: :blocklist, + remote_ip: '127.0.0.1', + request_method: 'GET', + path: "/#{path}/info/refs?service=git-upload-pack" + }) clone_get(path, env) - expect(response).to have_gitlab_http_status(:unauthorized) + expect(response).to have_gitlab_http_status(:forbidden) end end end @@ -493,7 +501,7 @@ describe 'Git HTTP requests' do context "when the user isn't blocked" do before do - Gitlab.config.rack_attack.git_basic_auth['enabled'] = true + stub_rack_attack_setting(enabled: true, bantime: 1.minute, findtime: 5.minutes, maxretry: 2, ip_whitelist: []) end it "resets the IP in Rack Attack on download" do @@ -652,9 +660,11 @@ describe 'Git HTTP requests' do response.status end + include_context 'rack attack cache store' + it "repeated attempts followed by successful attempt" do options = Gitlab.config.rack_attack.git_basic_auth - maxretry = options[:maxretry] - 1 + maxretry = options[:maxretry] ip = '1.2.3.4' allow_any_instance_of(ActionDispatch::Request).to receive(:ip).and_return(ip) @@ -666,12 +676,6 @@ describe 'Git HTTP requests' do expect(attempt_login(true)).to eq(200) expect(Rack::Attack::Allow2Ban.banned?(ip)).to be_falsey - - maxretry.times.each do - expect(attempt_login(false)).to eq(401) - end - - Rack::Attack::Allow2Ban.reset(ip, options) end end @@ -843,8 +847,8 @@ describe 'Git HTTP requests' do get "/#{project.full_path}/blob/master/info/refs" end - it "returns not found" do - expect(response).to have_gitlab_http_status(:not_found) + it "redirects" do + expect(response).to have_gitlab_http_status(302) end end end diff --git a/spec/requests/groups/milestones_controller_spec.rb b/spec/requests/groups/milestones_controller_spec.rb index af19d9312841476f13fdfc0abdf3b59dbe62fe4c..977cccad29ff12a22323e7f97747adce27fbf67e 100644 --- a/spec/requests/groups/milestones_controller_spec.rb +++ b/spec/requests/groups/milestones_controller_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Groups::MilestonesController do diff --git a/spec/requests/groups/registry/repositories_controller_spec.rb b/spec/requests/groups/registry/repositories_controller_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..35fdeaab6049b27d9ff49c86fa333aac68f011ca --- /dev/null +++ b/spec/requests/groups/registry/repositories_controller_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Groups::Registry::RepositoriesController do + let_it_be(:group, reload: true) { create(:group) } + let_it_be(:user) { create(:user) } + + before do + stub_container_registry_config(enabled: true) + + group.add_reporter(user) + login_as(user) + end + + describe 'GET groups/:group_id/-/container_registries.json' do + it 'avoids N+1 queries' do + project = create(:project, group: group) + create(:container_repository, project: project) + endpoint = group_container_registries_path(group, format: :json) + + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) { get(endpoint) }.count + + create_list(:project, 2, group: group).each do |project| + create_list(:container_repository, 2, project: project) + end + + expect { get(endpoint) }.not_to exceed_all_query_limit(control_count) + + # sanity check that response is 200 + expect(response).to have_http_status(200) + repositories = json_response + expect(repositories.count).to eq(5) + end + end +end diff --git a/spec/requests/health_controller_spec.rb b/spec/requests/health_controller_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..614128150390baca00208a4c6ae6594d5a1ab6c3 --- /dev/null +++ b/spec/requests/health_controller_spec.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe HealthController do + include StubENV + + let(:token) { Gitlab::CurrentSettings.health_check_access_token } + let(:whitelisted_ip) { '1.1.1.1' } + let(:not_whitelisted_ip) { '2.2.2.2' } + let(:params) { {} } + let(:headers) { {} } + + before do + allow(Settings.monitoring).to receive(:ip_whitelist).and_return([whitelisted_ip]) + stub_storage_settings({}) # Hide the broken storage + stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') + end + + shared_context 'endpoint querying database' do + it 'does query database' do + control_count = ActiveRecord::QueryRecorder.new { subject }.count + + expect(control_count).not_to be_zero + end + end + + shared_context 'endpoint not querying database' do + it 'does not query database' do + control_count = ActiveRecord::QueryRecorder.new { subject }.count + + expect(control_count).to be_zero + end + end + + shared_context 'endpoint not found' do + it 'responds with resource not found' do + subject + + expect(response.status).to eq(404) + end + end + + describe 'GET /-/health' do + subject { get '/-/health', params: params, headers: headers } + + shared_context 'endpoint responding with health data' do + it 'responds with health checks data' do + subject + + expect(response.status).to eq(200) + expect(response.body).to eq('GitLab OK') + end + end + + context 'accessed from whitelisted ip' do + before do + stub_remote_addr(whitelisted_ip) + end + + it_behaves_like 'endpoint responding with health data' + it_behaves_like 'endpoint not querying database' + end + + context 'accessed from not whitelisted ip' do + before do + stub_remote_addr(not_whitelisted_ip) + end + + it_behaves_like 'endpoint not querying database' + it_behaves_like 'endpoint not found' + end + end + + describe 'GET /-/readiness' do + subject { get '/-/readiness', params: params, headers: headers } + + shared_context 'endpoint responding with readiness data' do + context 'when requesting instance-checks' do + it 'responds with readiness checks data' do + expect(Gitlab::HealthChecks::MasterCheck).to receive(:check) { true } + + subject + + expect(json_response).to include({ 'status' => 'ok' }) + expect(json_response['master_check']).to contain_exactly({ 'status' => 'ok' }) + end + + it 'responds with readiness checks data when a failure happens' do + expect(Gitlab::HealthChecks::MasterCheck).to receive(:check) { false } + + subject + + expect(json_response).to include({ 'status' => 'failed' }) + expect(json_response['master_check']).to contain_exactly( + { 'status' => 'failed', 'message' => 'unexpected Master check result: false' }) + + expect(response.status).to eq(503) + expect(response.headers['X-GitLab-Custom-Error']).to eq(1) + end + end + + context 'when requesting all checks' do + before do + params.merge!(all: true) + end + + it 'responds with readiness checks data' do + subject + + expect(json_response['db_check']).to contain_exactly({ 'status' => 'ok' }) + expect(json_response['cache_check']).to contain_exactly({ 'status' => 'ok' }) + expect(json_response['queues_check']).to contain_exactly({ 'status' => 'ok' }) + expect(json_response['shared_state_check']).to contain_exactly({ 'status' => 'ok' }) + expect(json_response['gitaly_check']).to contain_exactly( + { 'status' => 'ok', 'labels' => { 'shard' => 'default' } }) + end + + it 'responds with readiness checks data when a failure happens' do + allow(Gitlab::HealthChecks::Redis::RedisCheck).to receive(:readiness).and_return( + Gitlab::HealthChecks::Result.new('redis_check', false, "check error")) + + subject + + expect(json_response['cache_check']).to contain_exactly({ 'status' => 'ok' }) + expect(json_response['redis_check']).to contain_exactly( + { 'status' => 'failed', 'message' => 'check error' }) + + expect(response.status).to eq(503) + expect(response.headers['X-GitLab-Custom-Error']).to eq(1) + end + end + end + + context 'accessed from whitelisted ip' do + before do + stub_remote_addr(whitelisted_ip) + end + + it_behaves_like 'endpoint not querying database' + it_behaves_like 'endpoint responding with readiness data' + + context 'when requesting all checks' do + before do + params.merge!(all: true) + end + + it_behaves_like 'endpoint querying database' + end + end + + context 'accessed from not whitelisted ip' do + before do + stub_remote_addr(not_whitelisted_ip) + end + + it_behaves_like 'endpoint not querying database' + it_behaves_like 'endpoint not found' + end + + context 'accessed with valid token' do + context 'token passed in request header' do + let(:headers) { { TOKEN: token } } + + it_behaves_like 'endpoint responding with readiness data' + it_behaves_like 'endpoint querying database' + end + + context 'token passed as URL param' do + let(:params) { { token: token } } + + it_behaves_like 'endpoint responding with readiness data' + it_behaves_like 'endpoint querying database' + end + end + end + + describe 'GET /-/liveness' do + subject { get '/-/liveness', params: params, headers: headers } + + shared_context 'endpoint responding with liveness data' do + it 'responds with liveness checks data' do + subject + + expect(json_response).to eq('status' => 'ok') + end + end + + context 'accessed from whitelisted ip' do + before do + stub_remote_addr(whitelisted_ip) + end + + it_behaves_like 'endpoint not querying database' + it_behaves_like 'endpoint responding with liveness data' + end + + context 'accessed from not whitelisted ip' do + before do + stub_remote_addr(not_whitelisted_ip) + end + + it_behaves_like 'endpoint not querying database' + it_behaves_like 'endpoint not found' + + context 'accessed with valid token' do + context 'token passed in request header' do + let(:headers) { { TOKEN: token } } + + it_behaves_like 'endpoint responding with liveness data' + it_behaves_like 'endpoint querying database' + end + + context 'token passed as URL param' do + let(:params) { { token: token } } + + it_behaves_like 'endpoint responding with liveness data' + it_behaves_like 'endpoint querying database' + end + end + end + end + + def stub_remote_addr(ip) + headers.merge!(REMOTE_ADDR: ip) + end +end diff --git a/spec/requests/jwt_controller_spec.rb b/spec/requests/jwt_controller_spec.rb index 8b2c698fee16d0c6b1bb05413813e7d8f260c4c5..c1f9911561297f71223bab3b52073c7b6f5171eb 100644 --- a/spec/requests/jwt_controller_spec.rb +++ b/spec/requests/jwt_controller_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe JwtController do diff --git a/spec/requests/lfs_locks_api_spec.rb b/spec/requests/lfs_locks_api_spec.rb index 11436e5cd0ce3a85985221af68e5651e6f276585..41f541622660ddd6233f448b00b9adce3bf4e816 100644 --- a/spec/requests/lfs_locks_api_spec.rb +++ b/spec/requests/lfs_locks_api_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'Git LFS File Locking API' do diff --git a/spec/requests/oauth_tokens_spec.rb b/spec/requests/oauth_tokens_spec.rb index 3873e754060d13a941580912755c417127cd8c36..bb1c25d686e040e74dae280d9964ffa2fc6a2319 100644 --- a/spec/requests/oauth_tokens_spec.rb +++ b/spec/requests/oauth_tokens_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'OAuth Tokens requests' do diff --git a/spec/requests/openid_connect_spec.rb b/spec/requests/openid_connect_spec.rb index dfa17c5ff27cd4c6d8fda08de48db3f7148c6a0c..bac1a4e18c8dcd1b69f570749eceb4c5494b2d7f 100644 --- a/spec/requests/openid_connect_spec.rb +++ b/spec/requests/openid_connect_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'OpenID Connect requests' do diff --git a/spec/requests/projects/cycle_analytics_events_spec.rb b/spec/requests/projects/cycle_analytics_events_spec.rb index 25390f8a23e26e06b7cfbe99d60076c8ee99af2d..93a1aafde23e7c7d296f96caae00ed4073d284d1 100644 --- a/spec/requests/projects/cycle_analytics_events_spec.rb +++ b/spec/requests/projects/cycle_analytics_events_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'cycle analytics events' do @@ -48,7 +50,7 @@ describe 'cycle analytics events' do expect(json_response['events'].first['iid']).to eq(first_mr_iid) end - it 'lists the test events' do + it 'lists the test events', :sidekiq_might_not_need_inline do get project_cycle_analytics_test_path(project, format: :json) expect(json_response['events']).not_to be_empty @@ -64,14 +66,14 @@ describe 'cycle analytics events' do expect(json_response['events'].first['iid']).to eq(first_mr_iid) end - it 'lists the staging events' do + it 'lists the staging events', :sidekiq_might_not_need_inline do get project_cycle_analytics_staging_path(project, format: :json) expect(json_response['events']).not_to be_empty expect(json_response['events'].first['date']).not_to be_empty end - it 'lists the production events' do + it 'lists the production events', :sidekiq_might_not_need_inline do get project_cycle_analytics_production_path(project, format: :json) first_issue_iid = project.issues.sort_by_attribute(:created_desc).pluck(:iid).first.to_s @@ -81,7 +83,7 @@ describe 'cycle analytics events' do end context 'specific branch' do - it 'lists the test events' do + it 'lists the test events', :sidekiq_might_not_need_inline do branch = project.merge_requests.first.source_branch get project_cycle_analytics_test_path(project, format: :json, branch: branch) diff --git a/spec/requests/rack_attack_global_spec.rb b/spec/requests/rack_attack_global_spec.rb index ca8720cd414e81aa6470865206421a1486be3f3e..4d5055a7e2723f33327ad2ebede66fef4052520a 100644 --- a/spec/requests/rack_attack_global_spec.rb +++ b/spec/requests/rack_attack_global_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'Rack Attack global throttles' do @@ -20,6 +22,7 @@ describe 'Rack Attack global throttles' do } end + let(:request_method) { 'GET' } let(:requests_per_period) { 1 } let(:period_in_seconds) { 10000 } let(:period) { period_in_seconds.seconds } @@ -81,7 +84,7 @@ describe 'Rack Attack global throttles' do expect(response).to have_http_status 200 end - expect_any_instance_of(Rack::Attack::Request).to receive(:ip).and_return('1.2.3.4') + expect_any_instance_of(Rack::Attack::Request).to receive(:ip).at_least(:once).and_return('1.2.3.4') # would be over limit for the same IP get url_that_does_not_require_authentication @@ -141,15 +144,15 @@ describe 'Rack Attack global throttles' do let(:api_partial_url) { '/todos' } context 'with the token in the query string' do - let(:get_args) { [api(api_partial_url, personal_access_token: token)] } - let(:other_user_get_args) { [api(api_partial_url, personal_access_token: other_user_token)] } + let(:request_args) { [api(api_partial_url, personal_access_token: token)] } + let(:other_user_request_args) { [api(api_partial_url, personal_access_token: other_user_token)] } it_behaves_like 'rate-limited token-authenticated requests' end context 'with the token in the headers' do - let(:get_args) { api_get_args_with_token_headers(api_partial_url, personal_access_token_headers(token)) } - let(:other_user_get_args) { api_get_args_with_token_headers(api_partial_url, personal_access_token_headers(other_user_token)) } + let(:request_args) { api_get_args_with_token_headers(api_partial_url, personal_access_token_headers(token)) } + let(:other_user_request_args) { api_get_args_with_token_headers(api_partial_url, personal_access_token_headers(other_user_token)) } it_behaves_like 'rate-limited token-authenticated requests' end @@ -168,15 +171,15 @@ describe 'Rack Attack global throttles' do let(:api_partial_url) { '/todos' } context 'with the token in the query string' do - let(:get_args) { [api(api_partial_url, oauth_access_token: token)] } - let(:other_user_get_args) { [api(api_partial_url, oauth_access_token: other_user_token)] } + let(:request_args) { [api(api_partial_url, oauth_access_token: token)] } + let(:other_user_request_args) { [api(api_partial_url, oauth_access_token: other_user_token)] } it_behaves_like 'rate-limited token-authenticated requests' end context 'with the token in the headers' do - let(:get_args) { api_get_args_with_token_headers(api_partial_url, oauth_token_headers(token)) } - let(:other_user_get_args) { api_get_args_with_token_headers(api_partial_url, oauth_token_headers(other_user_token)) } + let(:request_args) { api_get_args_with_token_headers(api_partial_url, oauth_token_headers(token)) } + let(:other_user_request_args) { api_get_args_with_token_headers(api_partial_url, oauth_token_headers(other_user_token)) } it_behaves_like 'rate-limited token-authenticated requests' end @@ -188,8 +191,8 @@ describe 'Rack Attack global throttles' do let(:throttle_setting_prefix) { 'throttle_authenticated_web' } context 'with the token in the query string' do - let(:get_args) { [rss_url(user), params: nil] } - let(:other_user_get_args) { [rss_url(other_user), params: nil] } + let(:request_args) { [rss_url(user), params: nil] } + let(:other_user_request_args) { [rss_url(other_user), params: nil] } it_behaves_like 'rate-limited token-authenticated requests' end @@ -204,10 +207,13 @@ describe 'Rack Attack global throttles' do end describe 'protected paths' do + let(:request_method) { 'POST' } + context 'unauthenticated requests' do let(:protected_path_that_does_not_require_authentication) do - '/users/confirmation' + '/users/sign_in' end + let(:post_params) { { user: { login: 'username', password: 'password' } } } before do settings_to_set[:throttle_protected_paths_requests_per_period] = requests_per_period # 1 @@ -222,7 +228,7 @@ describe 'Rack Attack global throttles' do it 'allows requests over the rate limit' do (1 + requests_per_period).times do - get protected_path_that_does_not_require_authentication + post protected_path_that_does_not_require_authentication, params: post_params expect(response).to have_http_status 200 end end @@ -236,11 +242,11 @@ describe 'Rack Attack global throttles' do it 'rejects requests over the rate limit' do requests_per_period.times do - get protected_path_that_does_not_require_authentication + post protected_path_that_does_not_require_authentication, params: post_params expect(response).to have_http_status 200 end - expect_rejection { get protected_path_that_does_not_require_authentication } + expect_rejection { post protected_path_that_does_not_require_authentication, params: post_params } end context 'when Omnibus throttle is present' do @@ -251,7 +257,7 @@ describe 'Rack Attack global throttles' do it 'allows requests over the rate limit' do (1 + requests_per_period).times do - get protected_path_that_does_not_require_authentication + post protected_path_that_does_not_require_authentication, params: post_params expect(response).to have_http_status 200 end end @@ -265,11 +271,11 @@ describe 'Rack Attack global throttles' do let(:other_user) { create(:user) } let(:other_user_token) { create(:personal_access_token, user: other_user) } let(:throttle_setting_prefix) { 'throttle_protected_paths' } - let(:api_partial_url) { '/users' } + let(:api_partial_url) { '/user/emails' } let(:protected_paths) do [ - '/api/v4/users' + '/api/v4/user/emails' ] end @@ -279,22 +285,22 @@ describe 'Rack Attack global throttles' do end context 'with the token in the query string' do - let(:get_args) { [api(api_partial_url, personal_access_token: token)] } - let(:other_user_get_args) { [api(api_partial_url, personal_access_token: other_user_token)] } + let(:request_args) { [api(api_partial_url, personal_access_token: token)] } + let(:other_user_request_args) { [api(api_partial_url, personal_access_token: other_user_token)] } it_behaves_like 'rate-limited token-authenticated requests' end context 'with the token in the headers' do - let(:get_args) { api_get_args_with_token_headers(api_partial_url, personal_access_token_headers(token)) } - let(:other_user_get_args) { api_get_args_with_token_headers(api_partial_url, personal_access_token_headers(other_user_token)) } + let(:request_args) { api_get_args_with_token_headers(api_partial_url, personal_access_token_headers(token)) } + let(:other_user_request_args) { api_get_args_with_token_headers(api_partial_url, personal_access_token_headers(other_user_token)) } it_behaves_like 'rate-limited token-authenticated requests' end context 'when Omnibus throttle is present' do - let(:get_args) { [api(api_partial_url, personal_access_token: token)] } - let(:other_user_get_args) { [api(api_partial_url, personal_access_token: other_user_token)] } + let(:request_args) { [api(api_partial_url, personal_access_token: token)] } + let(:other_user_request_args) { [api(api_partial_url, personal_access_token: other_user_token)] } before do settings_to_set[:"#{throttle_setting_prefix}_requests_per_period"] = requests_per_period @@ -308,8 +314,8 @@ describe 'Rack Attack global throttles' do it 'allows requests over the rate limit' do (1 + requests_per_period).times do - get(*get_args) - expect(response).to have_http_status 200 + post(*request_args) + expect(response).not_to have_http_status 429 end end end @@ -318,7 +324,7 @@ describe 'Rack Attack global throttles' do describe 'web requests authenticated with regular login' do let(:throttle_setting_prefix) { 'throttle_protected_paths' } let(:user) { create(:user) } - let(:url_that_requires_authentication) { '/dashboard/snippets' } + let(:url_that_requires_authentication) { '/users/confirmation' } let(:protected_paths) do [ @@ -348,8 +354,8 @@ describe 'Rack Attack global throttles' do it 'allows requests over the rate limit' do (1 + requests_per_period).times do - get url_that_requires_authentication - expect(response).to have_http_status 200 + post url_that_requires_authentication + expect(response).not_to have_http_status 429 end end end diff --git a/spec/requests/request_profiler_spec.rb b/spec/requests/request_profiler_spec.rb index 851affbcf8818709ef874469f5de2d6b8d3a78a6..36ccfc6b40090e2914c8280f801a9d1ef30fbd57 100644 --- a/spec/requests/request_profiler_spec.rb +++ b/spec/requests/request_profiler_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'Request Profiler' do diff --git a/spec/routing/admin_routing_spec.rb b/spec/routing/admin_routing_spec.rb index 77baaef7afd5598897433992f5ea22101839148a..a82bdfe3ce8411f755899982292ca790e0032024 100644 --- a/spec/routing/admin_routing_spec.rb +++ b/spec/routing/admin_routing_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' # block_admin_user PUT /admin/users/:id/block(.:format) admin/users#block diff --git a/spec/routing/environments_spec.rb b/spec/routing/environments_spec.rb index 28b3e79c1ff9fb02a00f8972cd0c68fdfc9e8234..ea172698764aa6e01ffd9dce3af2be6d0d2f1d00 100644 --- a/spec/routing/environments_spec.rb +++ b/spec/routing/environments_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'environments routing' do diff --git a/spec/routing/group_routing_spec.rb b/spec/routing/group_routing_spec.rb index c6b101ae908bcf58d4f4e15b3bb80158a9087edf..2a8454a276dc7169604c9c187e854333fdd904e2 100644 --- a/spec/routing/group_routing_spec.rb +++ b/spec/routing/group_routing_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe "Groups", "routing" do diff --git a/spec/routing/import_routing_spec.rb b/spec/routing/import_routing_spec.rb index 3fdede7914d32f87fc50947a7023dce79c76742e..7e78a1c0cd25ba29df5678de9e881b064da487d7 100644 --- a/spec/routing/import_routing_spec.rb +++ b/spec/routing/import_routing_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' # Shared examples for a resource inside a Project diff --git a/spec/routing/notifications_routing_spec.rb b/spec/routing/notifications_routing_spec.rb index 54ed87b5520b1c58159d336c5d7c6267c06ff866..8c2b29aabcbc88681950353ad539fd66dde52c80 100644 --- a/spec/routing/notifications_routing_spec.rb +++ b/spec/routing/notifications_routing_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "spec_helper" describe "notifications routing" do diff --git a/spec/routing/openid_connect_spec.rb b/spec/routing/openid_connect_spec.rb index 2c3bc08f1a1c025c7b3987430840ea6aef4656b5..704700329302937e8a0ddc4df756500b979103b2 100644 --- a/spec/routing/openid_connect_spec.rb +++ b/spec/routing/openid_connect_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' # oauth_discovery_keys GET /oauth/discovery/keys(.:format) doorkeeper/openid_connect/discovery#keys diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index acdbf064a73a57a582e88dc6e85d806a3a366f85..561c2b572ec5f150eb765eee28d1a8870318d469 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'project routing' do @@ -786,4 +788,10 @@ describe 'project routing' do expect(put("/gitlab/gitlabhq/-/deploy_tokens/1/revoke")).to route_to("projects/deploy_tokens#revoke", namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1') end end + + describe Projects::UsagePingController, 'routing' do + it 'routes to usage_ping#web_ide_clientside_preview' do + expect(post('/gitlab/gitlabhq/usage_ping/web_ide_clientside_preview')).to route_to('projects/usage_ping#web_ide_clientside_preview', namespace_id: 'gitlab', project_id: 'gitlabhq') + end + end end diff --git a/spec/routing/routing_spec.rb b/spec/routing/routing_spec.rb index 1b982fa7744bb4f3e80d33bb68d74fdec26bb66f..6f67cdb12225ca892fe15aefc0d3601f27378ce2 100644 --- a/spec/routing/routing_spec.rb +++ b/spec/routing/routing_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' # user GET /users/:username/ @@ -275,6 +277,33 @@ describe "Authentication", "routing" do it "PUT /users/password" do expect(put("/users/password")).to route_to('passwords#update') end + + context 'with LDAP configured' do + include LdapHelpers + + let(:ldap_settings) { { enabled: true } } + + before do + stub_ldap_setting(ldap_settings) + Rails.application.reload_routes! + end + + after(:all) do + Rails.application.reload_routes! + end + + it 'POST /users/auth/ldapmain/callback' do + expect(post("/users/auth/ldapmain/callback")).to route_to('ldap/omniauth_callbacks#ldapmain') + end + + context 'with LDAP sign-in disabled' do + let(:ldap_settings) { { enabled: true, prevent_ldap_sign_in: true } } + + it 'prevents POST /users/auth/ldapmain/callback' do + expect(post("/users/auth/ldapmain/callback")).not_to be_routable + end + end + end end describe HealthCheckController, 'routing' do diff --git a/spec/rubocop/cop/avoid_break_from_strong_memoize_spec.rb b/spec/rubocop/cop/avoid_break_from_strong_memoize_spec.rb index ac7b1575ec0f458370479368220e7169d5c691f1..62f6c7a3414257b9114bb24ab4c8954e4be8dea5 100644 --- a/spec/rubocop/cop/avoid_break_from_strong_memoize_spec.rb +++ b/spec/rubocop/cop/avoid_break_from_strong_memoize_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' require 'rubocop/rspec/support' diff --git a/spec/rubocop/cop/avoid_return_from_blocks_spec.rb b/spec/rubocop/cop/avoid_return_from_blocks_spec.rb index a5c280a7adc403e7c9d41220bb7fcf9250b2406d..133d286ccd2edf014f00ec6203ff557b5b78a132 100644 --- a/spec/rubocop/cop/avoid_return_from_blocks_spec.rb +++ b/spec/rubocop/cop/avoid_return_from_blocks_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' require 'rubocop/rspec/support' diff --git a/spec/rubocop/cop/destroy_all_spec.rb b/spec/rubocop/cop/destroy_all_spec.rb index b0bc40552b3314911c18f606a2a0ff547fa650ca..ac8aa56e0401cd61ec9423d9bf7248c513b138bb 100644 --- a/spec/rubocop/cop/destroy_all_spec.rb +++ b/spec/rubocop/cop/destroy_all_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' require 'rubocop/rspec/support' diff --git a/spec/rubocop/cop/gitlab/finder_with_find_by_spec.rb b/spec/rubocop/cop/gitlab/finder_with_find_by_spec.rb index 7f689b196c538d51431fb605c2055d569e3fe212..7af98b66218a46f85386d6bceafa8e22b0b47604 100644 --- a/spec/rubocop/cop/gitlab/finder_with_find_by_spec.rb +++ b/spec/rubocop/cop/gitlab/finder_with_find_by_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/rubocop/cop/gitlab/httparty_spec.rb b/spec/rubocop/cop/gitlab/httparty_spec.rb index 510839a21d7aee6b75842bffde7ce54bdfdded78..42da97679ecc2ec23698627f8567b3ddefef51f8 100644 --- a/spec/rubocop/cop/gitlab/httparty_spec.rb +++ b/spec/rubocop/cop/gitlab/httparty_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' require 'rubocop/rspec/support' diff --git a/spec/rubocop/cop/gitlab/module_with_instance_variables_spec.rb b/spec/rubocop/cop/gitlab/module_with_instance_variables_spec.rb index 8e2d5f70353ba8ecd59044199b81041b472d4705..9cb55ced1fa7b4a60f6852cec17258a2b351238b 100644 --- a/spec/rubocop/cop/gitlab/module_with_instance_variables_spec.rb +++ b/spec/rubocop/cop/gitlab/module_with_instance_variables_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' require 'rubocop/rspec/support' diff --git a/spec/rubocop/cop/gitlab/predicate_memoization_spec.rb b/spec/rubocop/cop/gitlab/predicate_memoization_spec.rb index 21fc45846541fb12b2e984fef1e52a8f14723ce1..ae9466368d249b350987cfd2af124e2dec7d314d 100644 --- a/spec/rubocop/cop/gitlab/predicate_memoization_spec.rb +++ b/spec/rubocop/cop/gitlab/predicate_memoization_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' require 'rubocop/rspec/support' diff --git a/spec/rubocop/cop/group_public_or_visible_to_user_spec.rb b/spec/rubocop/cop/group_public_or_visible_to_user_spec.rb index 7b5235a3da75384f8168fce8e5d39ef2516e530e..8e027ad59f73e9f0ef129566fddf281fbbfd5400 100644 --- a/spec/rubocop/cop/group_public_or_visible_to_user_spec.rb +++ b/spec/rubocop/cop/group_public_or_visible_to_user_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' require 'rubocop/rspec/support' diff --git a/spec/rubocop/cop/include_sidekiq_worker_spec.rb b/spec/rubocop/cop/include_sidekiq_worker_spec.rb index f51092878764b00789faba811128f201a10d7153..39965646aff5ba1e7185f67eca88d157e60b3bc8 100644 --- a/spec/rubocop/cop/include_sidekiq_worker_spec.rb +++ b/spec/rubocop/cop/include_sidekiq_worker_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/rubocop/cop/line_break_around_conditional_block_spec.rb b/spec/rubocop/cop/line_break_around_conditional_block_spec.rb index cc933ce12c8d74f9faac7dd2a1c1f362f0b33457..d09de4c661496ceac844bb511cc5732e61bd6b8b 100644 --- a/spec/rubocop/cop/line_break_around_conditional_block_spec.rb +++ b/spec/rubocop/cop/line_break_around_conditional_block_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' require 'rubocop/rspec/support' diff --git a/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb b/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb index 1df1fffb94ee3f828c144086dd3e7e32be2a40f3..419d74c298a4cce9356e83363c3582a185bbff4e 100644 --- a/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb +++ b/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/rubocop/cop/migration/add_concurrent_index_spec.rb b/spec/rubocop/cop/migration/add_concurrent_index_spec.rb index 9c1ebcc0ced789b13160019f1791faa10bdd0e63..9812e64216f516b4469492d8fd031b77ad2d1872 100644 --- a/spec/rubocop/cop/migration/add_concurrent_index_spec.rb +++ b/spec/rubocop/cop/migration/add_concurrent_index_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/rubocop/cop/migration/add_reference_spec.rb b/spec/rubocop/cop/migration/add_reference_spec.rb index 0b56fe8ed83a40f65aa06b13498fae9a9c2a6f80..03348ecc744b6e1ed788c3dd5caf61a016cf47cb 100644 --- a/spec/rubocop/cop/migration/add_reference_spec.rb +++ b/spec/rubocop/cop/migration/add_reference_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/rubocop/cop/migration/add_timestamps_spec.rb b/spec/rubocop/cop/migration/add_timestamps_spec.rb index 33f1bb85af828d5c1f69e7b00a91d0643a5fa751..a3314d878e54339a20448289892c17073c94bdbb 100644 --- a/spec/rubocop/cop/migration/add_timestamps_spec.rb +++ b/spec/rubocop/cop/migration/add_timestamps_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/rubocop/cop/migration/datetime_spec.rb b/spec/rubocop/cop/migration/datetime_spec.rb index f2d9483d8d3377a7501703b4b56500b512b180c6..0a771003100f54277a7ddc0677704327e84c040b 100644 --- a/spec/rubocop/cop/migration/datetime_spec.rb +++ b/spec/rubocop/cop/migration/datetime_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/rubocop/cop/migration/hash_index_spec.rb b/spec/rubocop/cop/migration/hash_index_spec.rb index 5d53dde9a792dc925365ba0ba03efc4ffd603538..e8b05a94653a0652e85dc3ba04651bd57e8a0a30 100644 --- a/spec/rubocop/cop/migration/hash_index_spec.rb +++ b/spec/rubocop/cop/migration/hash_index_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/rubocop/cop/migration/remove_column_spec.rb b/spec/rubocop/cop/migration/remove_column_spec.rb index f1a64f431bdeee42fb4593a076778010ce4da77a..bc2fa04ce64eb17d537f1a551a624ac1e6159e1f 100644 --- a/spec/rubocop/cop/migration/remove_column_spec.rb +++ b/spec/rubocop/cop/migration/remove_column_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/rubocop/cop/migration/remove_concurrent_index_spec.rb b/spec/rubocop/cop/migration/remove_concurrent_index_spec.rb index a23d5d022e3cef9f48f12f6e679851898418ca95..9de4c756f1218a3f08293ca6e300d305d22bfe10 100644 --- a/spec/rubocop/cop/migration/remove_concurrent_index_spec.rb +++ b/spec/rubocop/cop/migration/remove_concurrent_index_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/rubocop/cop/migration/remove_index_spec.rb b/spec/rubocop/cop/migration/remove_index_spec.rb index bbf2227e5120302fd14c1d1e50f9bfbafb2877e1..d343d27484ad7f163725d790bda18c1848841265 100644 --- a/spec/rubocop/cop/migration/remove_index_spec.rb +++ b/spec/rubocop/cop/migration/remove_index_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/rubocop/cop/migration/reversible_add_column_with_default_spec.rb b/spec/rubocop/cop/migration/reversible_add_column_with_default_spec.rb index ba8cd2c6c4ad3f330fe42e31bfc54e09a101e001..b3c5b855004c74e5a7860a676c0ac65cb4f43826 100644 --- a/spec/rubocop/cop/migration/reversible_add_column_with_default_spec.rb +++ b/spec/rubocop/cop/migration/reversible_add_column_with_default_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/rubocop/cop/migration/safer_boolean_column_spec.rb b/spec/rubocop/cop/migration/safer_boolean_column_spec.rb index 1c4f18fbcc301f95b38f35ffce0242da6c954678..915b73ed5a7f0396b969b1a7e245491a981e2d2b 100644 --- a/spec/rubocop/cop/migration/safer_boolean_column_spec.rb +++ b/spec/rubocop/cop/migration/safer_boolean_column_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/rubocop/cop/migration/timestamps_spec.rb b/spec/rubocop/cop/migration/timestamps_spec.rb index cafe255dc9a77e6edda5478ab5797658f51446d6..d03c75e7cfc83b2609c1ecb56973b76510ea89ed 100644 --- a/spec/rubocop/cop/migration/timestamps_spec.rb +++ b/spec/rubocop/cop/migration/timestamps_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/rubocop/cop/migration/update_column_in_batches_spec.rb b/spec/rubocop/cop/migration/update_column_in_batches_spec.rb index cba01400d85ebb4c6006cc2aa50ec3436cb77999..f72efaf2eb2865ed99b4824b769bcc83e0679259 100644 --- a/spec/rubocop/cop/migration/update_column_in_batches_spec.rb +++ b/spec/rubocop/cop/migration/update_column_in_batches_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/rubocop/cop/migration/update_large_table_spec.rb b/spec/rubocop/cop/migration/update_large_table_spec.rb index 5e08eb4f77246d122df90f85fd92f0070917ce51..0463b6550a879d86e632f04b994dd1a793cca68b 100644 --- a/spec/rubocop/cop/migration/update_large_table_spec.rb +++ b/spec/rubocop/cop/migration/update_large_table_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/rubocop/cop/project_path_helper_spec.rb b/spec/rubocop/cop/project_path_helper_spec.rb index 84e6eb7d87f8090269c4aa23e16329b21181d045..1b69030c7989d2a9421f647b6ec1bae88e531259 100644 --- a/spec/rubocop/cop/project_path_helper_spec.rb +++ b/spec/rubocop/cop/project_path_helper_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/rubocop/cop/rspec/any_instance_of_spec.rb b/spec/rubocop/cop/rspec/any_instance_of_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b16f8ac189ce44b7439bf28b71f25bf441a67658 --- /dev/null +++ b/spec/rubocop/cop/rspec/any_instance_of_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_relative '../../../../rubocop/cop/rspec/any_instance_of' + +describe RuboCop::Cop::RSpec::AnyInstanceOf do + include CopHelper + + subject(:cop) { described_class.new } + + context 'when calling allow_any_instance_of' do + let(:source) do + <<~SRC + allow_any_instance_of(User).to receive(:invalidate_issue_cache_counts) + SRC + end + let(:corrected_source) do + <<~SRC + allow_next_instance_of(User) do |instance| + allow(instance).to receive(:invalidate_issue_cache_counts) + end + SRC + end + + it 'registers an offence' do + inspect_source(source) + + expect(cop.offenses.size).to eq(1) + end + + it 'can autocorrect the source' do + expect(autocorrect_source(source)).to eq(corrected_source) + end + end + + context 'when calling expect_any_instance_of' do + let(:source) do + <<~SRC + expect_any_instance_of(User).to receive(:invalidate_issue_cache_counts).with(args).and_return(double) + SRC + end + let(:corrected_source) do + <<~SRC + expect_next_instance_of(User) do |instance| + expect(instance).to receive(:invalidate_issue_cache_counts).with(args).and_return(double) + end + SRC + end + + it 'registers an offence' do + inspect_source(source) + + expect(cop.offenses.size).to eq(1) + end + + it 'can autocorrect the source' do + expect(autocorrect_source(source)).to eq(corrected_source) + end + end +end diff --git a/spec/rubocop/cop/rspec/env_assignment_spec.rb b/spec/rubocop/cop/rspec/env_assignment_spec.rb index 621afbad3ba5aaaaf2b180d07c1b380acb372e3d..2a2bd1434d6ca68c0243949f626cc72577142358 100644 --- a/spec/rubocop/cop/rspec/env_assignment_spec.rb +++ b/spec/rubocop/cop/rspec/env_assignment_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/rubocop/cop/rspec/factories_in_migration_specs_spec.rb b/spec/rubocop/cop/rspec/factories_in_migration_specs_spec.rb index 94324bc615dd3ccb94d2d97608cce274d8d4bdbc..20013519db48545e169cb7a5e3aac21e0cc058a9 100644 --- a/spec/rubocop/cop/rspec/factories_in_migration_specs_spec.rb +++ b/spec/rubocop/cop/rspec/factories_in_migration_specs_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/rubocop/cop/sidekiq_options_queue_spec.rb b/spec/rubocop/cop/sidekiq_options_queue_spec.rb index 7f237d5ffbbe1c01e666a269dd063992d8aca685..c10fd7bd32b38053096f537e1e01ad1ff3f75304 100644 --- a/spec/rubocop/cop/sidekiq_options_queue_spec.rb +++ b/spec/rubocop/cop/sidekiq_options_queue_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'rubocop' diff --git a/spec/serializers/blob_entity_spec.rb b/spec/serializers/blob_entity_spec.rb index c0687d0232ecc8ff539edd4999bcdfae9b626b25..7e3a0a87bd57b3bcf272c246addf003cf5422a58 100644 --- a/spec/serializers/blob_entity_spec.rb +++ b/spec/serializers/blob_entity_spec.rb @@ -15,8 +15,16 @@ describe BlobEntity do context 'as json' do subject { entity.as_json } - it 'exposes needed attributes' do - expect(subject).to include(:readable_text, :url) + it 'contains needed attributes' do + expect(subject).to include({ + id: blob.id, + path: blob.path, + name: blob.name, + mode: "100644", + readable_text: true, + icon: "file-text-o", + url: "/#{project.full_path}/blob/master/bar/branch-test.txt" + }) end end end diff --git a/spec/serializers/diff_file_base_entity_spec.rb b/spec/serializers/diff_file_base_entity_spec.rb index 68c5c665ed633d456fcfbedb5252b9e21df7520f..80f5bc8f159ecda6d12260beb5c82c0935d98398 100644 --- a/spec/serializers/diff_file_base_entity_spec.rb +++ b/spec/serializers/diff_file_base_entity_spec.rb @@ -5,15 +5,15 @@ require 'spec_helper' describe DiffFileBaseEntity do let(:project) { create(:project, :repository) } let(:repository) { project.repository } + let(:entity) { described_class.new(diff_file, options).as_json } context 'diff for a changed submodule' do let(:commit_sha_with_changed_submodule) do "cfe32cf61b73a0d5e9f13e774abde7ff789b1660" end let(:commit) { project.commit(commit_sha_with_changed_submodule) } - let(:diff_file) { commit.diffs.diff_files.to_a.last } let(:options) { { request: {}, submodule_links: Gitlab::SubmoduleLinks.new(repository) } } - let(:entity) { described_class.new(diff_file, options).as_json } + let(:diff_file) { commit.diffs.diff_files.to_a.last } it do expect(entity[:submodule]).to eq(true) @@ -23,4 +23,15 @@ describe DiffFileBaseEntity do ) end end + + context 'contains raw sizes for the blob' do + let(:commit) { project.commit('png-lfs') } + let(:options) { { request: {} } } + let(:diff_file) { commit.diffs.diff_files.to_a.second } + + it do + expect(entity[:old_size]).to eq(1219696) + expect(entity[:new_size]).to eq(132) + end + end end diff --git a/spec/serializers/diff_file_entity_spec.rb b/spec/serializers/diff_file_entity_spec.rb index 0c2e7c1e3ebc71ee4a05175f73b54dff8f9ca6ba..65b62f8aa165fa21cf7680a24216de8015796492 100644 --- a/spec/serializers/diff_file_entity_spec.rb +++ b/spec/serializers/diff_file_entity_spec.rb @@ -11,7 +11,8 @@ describe DiffFileEntity do let(:diff_refs) { commit.diff_refs } let(:diff) { commit.raw_diffs.first } let(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) } - let(:entity) { described_class.new(diff_file, request: {}) } + let(:options) { {} } + let(:entity) { described_class.new(diff_file, options.reverse_merge(request: {})) } subject { entity.as_json } @@ -23,7 +24,7 @@ describe DiffFileEntity do let(:user) { create(:user) } let(:request) { EntityRequest.new(project: project, current_user: user) } let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } - let(:entity) { described_class.new(diff_file, request: request, merge_request: merge_request) } + let(:entity) { described_class.new(diff_file, options.merge(request: request, merge_request: merge_request)) } let(:exposed_urls) { %i(edit_path view_path context_lines_path) } it_behaves_like 'diff file entity' @@ -49,6 +50,8 @@ describe DiffFileEntity do end context '#parallel_diff_lines' do + let(:options) { { diff_view: :parallel } } + it 'exposes parallel diff lines correctly' do response = subject diff --git a/spec/serializers/issuable_sidebar_extras_entity_spec.rb b/spec/serializers/issuable_sidebar_extras_entity_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a1a7c554b4934abe7e5370801b9619ebc1421a35 --- /dev/null +++ b/spec/serializers/issuable_sidebar_extras_entity_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe IssuableSidebarExtrasEntity do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + let(:resource) { create(:issue, project: project) } + let(:request) { double('request', current_user: user) } + + subject { described_class.new(resource, request: request).as_json } + + it 'have subscribe attributes' do + expect(subject).to include(:participants, + :project_emails_disabled, + :subscribe_disabled_description, + :subscribed, + :assignees) + end +end diff --git a/spec/serializers/job_artifact_report_entity_spec.rb b/spec/serializers/job_artifact_report_entity_spec.rb index eef5c16d0fb65a2f78c4f9394f358fb4fa9c7056..3cd12f0e9fe9f69b2955ebe3c4aad46265f6bfaa 100644 --- a/spec/serializers/job_artifact_report_entity_spec.rb +++ b/spec/serializers/job_artifact_report_entity_spec.rb @@ -22,7 +22,7 @@ describe JobArtifactReportEntity do end it 'exposes download path' do - expect(subject[:download_path]).to include("jobs/#{report.job.id}/artifacts/download") + expect(subject[:download_path]).to include("jobs/#{report.job.id}/artifacts/download?file_type=#{report.file_type}") end end end diff --git a/spec/serializers/merge_request_diff_entity_spec.rb b/spec/serializers/merge_request_diff_entity_spec.rb index 062f17963c0580fa70993b2ffaa6726360ab7c5d..59ec0b22158432dd4984d168e70903b31b6edf10 100644 --- a/spec/serializers/merge_request_diff_entity_spec.rb +++ b/spec/serializers/merge_request_diff_entity_spec.rb @@ -7,14 +7,15 @@ describe MergeRequestDiffEntity do let(:request) { EntityRequest.new(project: project) } let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) } let(:merge_request_diffs) { merge_request.merge_request_diffs } + let(:merge_request_diff) { merge_request_diffs.first } let(:entity) do - described_class.new(merge_request_diffs.first, request: request, merge_request: merge_request, merge_request_diffs: merge_request_diffs) + described_class.new(merge_request_diff, request: request, merge_request: merge_request, merge_request_diffs: merge_request_diffs) end - context 'as json' do - subject { entity.as_json } + subject { entity.as_json } + context 'as json' do it 'exposes needed attributes' do expect(subject).to include( :version_index, :created_at, :commits_count, @@ -23,4 +24,16 @@ describe MergeRequestDiffEntity do ) end end + + describe '#short_commit_sha' do + it 'returns short sha' do + expect(subject[:short_commit_sha]).to eq('b83d6e39') + end + + it 'returns nil if head_commit_sha does not exist' do + allow(merge_request_diff).to receive(:head_commit_sha).and_return(nil) + + expect(subject[:short_commit_sha]).to eq(nil) + end + end end diff --git a/spec/serializers/merge_request_widget_entity_spec.rb b/spec/serializers/merge_request_widget_entity_spec.rb index 4872b23d26bdf88fdd40bf854ca82d8f9a0113a4..35940ac062efb2231ec108fd0bf98ed542d48852 100644 --- a/spec/serializers/merge_request_widget_entity_spec.rb +++ b/spec/serializers/merge_request_widget_entity_spec.rb @@ -358,4 +358,26 @@ describe MergeRequestWidgetEntity do end end end + + describe 'exposed_artifacts_path' do + context 'when merge request has exposed artifacts' do + before do + expect(resource).to receive(:has_exposed_artifacts?).and_return(true) + end + + it 'set the path to poll data' do + expect(subject[:exposed_artifacts_path]).to be_present + end + end + + context 'when merge request has no exposed artifacts' do + before do + expect(resource).to receive(:has_exposed_artifacts?).and_return(false) + end + + it 'set the path to poll data' do + expect(subject[:exposed_artifacts_path]).to be_nil + end + end + end end diff --git a/spec/serializers/pipeline_details_entity_spec.rb b/spec/serializers/pipeline_details_entity_spec.rb index b180ede51eb8298631d788436cd7dfbfecaa4833..9ce7c265e43351703712804aea5b1c7b8f59cf21 100644 --- a/spec/serializers/pipeline_details_entity_spec.rb +++ b/spec/serializers/pipeline_details_entity_spec.rb @@ -115,7 +115,7 @@ describe PipelineDetailsEntity do context 'when pipeline has YAML errors' do let(:pipeline) do - create(:ci_pipeline, config: { rspec: { invalid: :value } }) + create(:ci_pipeline, yaml_errors: 'Some error occurred') end it 'contains information about error' do diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb index ce5264ec8bbf53c25ac64efedcb6210350569675..7661c8acc135eb4b17ccf4f7113870ac91c86cd4 100644 --- a/spec/serializers/pipeline_serializer_spec.rb +++ b/spec/serializers/pipeline_serializer_spec.rb @@ -180,7 +180,7 @@ describe PipelineSerializer do # pipeline. With the same ref this check is cached but if refs are # different then there is an extra query per ref # https://gitlab.com/gitlab-org/gitlab-foss/issues/46368 - expected_queries = Gitlab.ee? ? 44 : 41 + expected_queries = Gitlab.ee? ? 41 : 38 expect(recorded.count).to be_within(2).of(expected_queries) expect(recorded.cached_count).to eq(0) 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 ccbb4e7c30d7eb2a817c73530790f27add2d1260..f2cda999932d7baf9ec176d7488a79101669ba71 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 @@ -13,8 +13,7 @@ describe AutoMerge::MergeWhenPipelineSucceedsService do end let(:pipeline) do - create(:ci_pipeline_with_one_job, ref: mr_merge_if_green_enabled.source_branch, - project: project) + create(:ci_pipeline, ref: mr_merge_if_green_enabled.source_branch, project: project) end let(:service) do @@ -226,7 +225,7 @@ describe AutoMerge::MergeWhenPipelineSucceedsService do test.drop end - it 'merges when all stages succeeded' do + it 'merges when all stages succeeded', :sidekiq_might_not_need_inline do expect(MergeWorker).to receive(:perform_async) build.success diff --git a/spec/services/ci/cancel_user_pipelines_service_spec.rb b/spec/services/ci/cancel_user_pipelines_service_spec.rb index 251f21feaef59a779893591c8076c6cd33b9bded..b18bf48a50af3501dcae09a4f09ae83f6503e2b3 100644 --- a/spec/services/ci/cancel_user_pipelines_service_spec.rb +++ b/spec/services/ci/cancel_user_pipelines_service_spec.rb @@ -12,7 +12,7 @@ describe Ci::CancelUserPipelinesService do let(:pipeline) { create(:ci_pipeline, :running, user: user) } let!(:build) { create(:ci_build, :running, pipeline: pipeline) } - it 'cancels all running pipelines and related jobs' do + it 'cancels all running pipelines and related jobs', :sidekiq_might_not_need_inline do subject expect(pipeline.reload).to be_canceled diff --git a/spec/services/ci/create_pipeline_service/cache_spec.rb b/spec/services/ci/create_pipeline_service/cache_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4e0567132ff77fb38c15aee0a398d074f178c3cf --- /dev/null +++ b/spec/services/ci/create_pipeline_service/cache_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Ci::CreatePipelineService do + context 'cache' do + let(:user) { create(:admin) } + let(:ref) { 'refs/heads/master' } + let(:source) { :push } + let(:service) { described_class.new(project, user, { ref: ref }) } + let(:pipeline) { service.execute(source) } + let(:job) { pipeline.builds.find_by(name: 'job') } + let(:project) { create(:project, :custom_repo, files: files) } + + before do + stub_ci_pipeline_yaml_file(config) + end + + context 'with cache:key' do + let(:files) { { 'some-file' => '' } } + + let(:config) do + <<~EOY + job: + script: + - ls + cache: + key: 'a-key' + paths: ['logs/', 'binaries/'] + untracked: true + EOY + end + + it 'uses the provided key' do + expected = { + 'key' => 'a-key', + 'paths' => ['logs/', 'binaries/'], + 'policy' => 'pull-push', + 'untracked' => true + } + + expect(pipeline).to be_persisted + expect(job.cache).to match(a_collection_including(expected)) + end + end + + context 'with cache:key:files' do + let(:config) do + <<~EOY + job: + script: + - ls + cache: + paths: + - logs/ + key: + files: + - file.lock + - missing-file.lock + EOY + end + + context 'when file.lock exists' do + let(:files) { { 'file.lock' => '' } } + + it 'builds a cache key' do + expected = { + 'key' => /[a-f0-9]{40}/, + 'paths' => ['logs/'], + 'policy' => 'pull-push' + } + + expect(pipeline).to be_persisted + expect(job.cache).to match(a_collection_including(expected)) + end + end + + context 'when file.lock does not exist' do + let(:files) { { 'some-file' => '' } } + + it 'uses default cache key' do + expected = { + 'key' => /default/, + 'paths' => ['logs/'], + 'policy' => 'pull-push' + } + + expect(pipeline).to be_persisted + expect(job.cache).to match(a_collection_including(expected)) + end + end + end + + context 'with cache:key:files and prefix' do + let(:config) do + <<~EOY + job: + script: + - ls + cache: + paths: + - logs/ + key: + files: + - file.lock + prefix: '$ENV_VAR' + EOY + end + + context 'when file.lock exists' do + let(:files) { { 'file.lock' => '' } } + + it 'builds a cache key' do + expected = { + 'key' => /\$ENV_VAR-[a-f0-9]{40}/, + 'paths' => ['logs/'], + 'policy' => 'pull-push' + } + + expect(pipeline).to be_persisted + expect(job.cache).to match(a_collection_including(expected)) + end + end + + context 'when file.lock does not exist' do + let(:files) { { 'some-file' => '' } } + + it 'uses default cache key' do + expected = { + 'key' => /\$ENV_VAR-default/, + 'paths' => ['logs/'], + 'policy' => 'pull-push' + } + + expect(pipeline).to be_persisted + expect(job.cache).to match(a_collection_including(expected)) + end + end + end + + context 'with too many files' do + let(:files) { { 'some-file' => '' } } + + let(:config) do + <<~EOY + job: + script: + - ls + cache: + paths: ['logs/', 'binaries/'] + untracked: true + key: + files: + - file.lock + - other-file.lock + - extra-file.lock + prefix: 'some-prefix' + EOY + end + + it 'has errors' do + expect(pipeline).to be_persisted + expect(pipeline.yaml_errors).to eq("jobs:job:cache:key:files config has too many items (maximum is 2)") + expect(job).to be_nil + end + end + end +end diff --git a/spec/services/ci/create_pipeline_service/rules_spec.rb b/spec/services/ci/create_pipeline_service/rules_spec.rb index 40a3b115cb52611224ebbff00a6c920c0c4da3f1..c922266647b871bb7579219b11977466cfe64a03 100644 --- a/spec/services/ci/create_pipeline_service/rules_spec.rb +++ b/spec/services/ci/create_pipeline_service/rules_spec.rb @@ -1,16 +1,16 @@ # frozen_string_literal: true - require 'spec_helper' describe Ci::CreatePipelineService do - context 'rules' do - let(:user) { create(:admin) } - let(:ref) { 'refs/heads/master' } - let(:source) { :push } - let(:service) { described_class.new(project, user, { ref: ref }) } - let(:pipeline) { service.execute(source) } - let(:build_names) { pipeline.builds.pluck(:name) } + let(:user) { create(:admin) } + let(:ref) { 'refs/heads/master' } + let(:source) { :push } + let(:project) { create(:project, :repository) } + let(:service) { described_class.new(project, user, { ref: ref }) } + let(:pipeline) { service.execute(source) } + let(:build_names) { pipeline.builds.pluck(:name) } + context 'job:rules' do before do stub_ci_pipeline_yaml_file(config) allow_any_instance_of(Ci::BuildScheduleWorker).to receive(:perform).and_return(true) @@ -41,6 +41,7 @@ describe Ci::CreatePipelineService do start_in: 4 hours EOY end + let(:regular_job) { pipeline.builds.find_by(name: 'regular-job') } let(:rules_job) { pipeline.builds.find_by(name: 'rules-job') } let(:delayed_job) { pipeline.builds.find_by(name: 'delayed-job') } @@ -91,4 +92,259 @@ describe Ci::CreatePipelineService do end end end + + context 'when workflow:rules are used' do + before do + stub_ci_pipeline_yaml_file(config) + end + + context 'with a single regex-matching if: clause' do + let(:config) do + <<-EOY + workflow: + rules: + - if: $CI_COMMIT_REF_NAME =~ /master/ + - if: $CI_COMMIT_REF_NAME =~ /wip$/ + when: never + - if: $CI_COMMIT_REF_NAME =~ /feature/ + + regular-job: + script: 'echo Hello, World!' + EOY + end + + context 'matching the first rule in the list' do + it 'saves the pipeline' do + expect(pipeline).to be_persisted + end + + it 'sets the pipeline state to pending' do + expect(pipeline).to be_pending + end + end + + context 'matching the last rule in the list' do + let(:ref) { 'refs/heads/feature' } + + it 'saves the pipeline' do + expect(pipeline).to be_persisted + end + + it 'sets the pipeline state to pending' do + expect(pipeline).to be_pending + end + end + + context 'matching the when:never rule' do + let(:ref) { 'refs/heads/wip' } + + it 'does not save the pipeline' do + expect(pipeline).not_to be_persisted + end + + it 'attaches errors' do + expect(pipeline.errors[:base]).to include('Pipeline filtered out by workflow rules.') + end + end + + context 'matching no rules in the list' do + let(:ref) { 'refs/heads/fix' } + + it 'does not save the pipeline' do + expect(pipeline).not_to be_persisted + end + + it 'attaches errors' do + expect(pipeline.errors[:base]).to include('Pipeline filtered out by workflow rules.') + end + end + end + + context 'when root variables are used' do + let(:config) do + <<-EOY + variables: + VARIABLE: value + + workflow: + rules: + - if: $VARIABLE + + regular-job: + script: 'echo Hello, World!' + EOY + end + + context 'matching the first rule in the list' do + it 'saves the pipeline' do + expect(pipeline).to be_persisted + end + + it 'sets the pipeline state to pending' do + expect(pipeline).to be_pending + end + end + end + + context 'with a multiple regex-matching if: clause' do + let(:config) do + <<-EOY + workflow: + rules: + - if: $CI_COMMIT_REF_NAME =~ /master/ + - if: $CI_COMMIT_REF_NAME =~ /^feature/ && $CI_COMMIT_REF_NAME =~ /conflict$/ + when: never + - if: $CI_COMMIT_REF_NAME =~ /feature/ + + regular-job: + script: 'echo Hello, World!' + EOY + end + + context 'with partial match' do + let(:ref) { 'refs/heads/feature' } + + it 'saves the pipeline' do + expect(pipeline).to be_persisted + end + + it 'sets the pipeline state to pending' do + expect(pipeline).to be_pending + end + end + + context 'with complete match' do + let(:ref) { 'refs/heads/feature_conflict' } + + it 'does not save the pipeline' do + expect(pipeline).not_to be_persisted + end + + it 'attaches errors' do + expect(pipeline.errors[:base]).to include('Pipeline filtered out by workflow rules.') + end + end + end + + context 'with job rules' do + let(:config) do + <<-EOY + workflow: + rules: + - if: $CI_COMMIT_REF_NAME =~ /master/ + - if: $CI_COMMIT_REF_NAME =~ /feature/ + + regular-job: + script: 'echo Hello, World!' + rules: + - if: $CI_COMMIT_REF_NAME =~ /wip/ + - if: $CI_COMMIT_REF_NAME =~ /feature/ + EOY + end + + context 'where workflow passes and the job fails' do + let(:ref) { 'refs/heads/master' } + + it 'does not save the pipeline' do + expect(pipeline).not_to be_persisted + end + + it 'attaches an error about no job in the pipeline' do + expect(pipeline.errors[:base]).to include('No stages / jobs for this pipeline.') + end + + context 'with workflow:rules shut off' do + before do + stub_feature_flags(workflow_rules: false) + end + + it 'does not save the pipeline' do + expect(pipeline).not_to be_persisted + end + + it 'attaches an error about no job in the pipeline' do + expect(pipeline.errors[:base]).to include('No stages / jobs for this pipeline.') + end + end + end + + context 'where workflow passes and the job passes' do + let(:ref) { 'refs/heads/feature' } + + it 'saves the pipeline' do + expect(pipeline).to be_persisted + end + + it 'sets the pipeline state to pending' do + expect(pipeline).to be_pending + end + + context 'with workflow:rules shut off' do + before do + stub_feature_flags(workflow_rules: false) + end + + it 'saves the pipeline' do + expect(pipeline).to be_persisted + end + + it 'sets the pipeline state to pending' do + expect(pipeline).to be_pending + end + end + end + + context 'where workflow fails and the job fails' do + let(:ref) { 'refs/heads/fix' } + + it 'does not save the pipeline' do + expect(pipeline).not_to be_persisted + end + + it 'attaches an error about workflow rules' do + expect(pipeline.errors[:base]).to include('Pipeline filtered out by workflow rules.') + end + + context 'with workflow:rules shut off' do + before do + stub_feature_flags(workflow_rules: false) + end + + it 'does not save the pipeline' do + expect(pipeline).not_to be_persisted + end + + it 'attaches an error about job rules' do + expect(pipeline.errors[:base]).to include('No stages / jobs for this pipeline.') + end + end + end + + context 'where workflow fails and the job passes' do + let(:ref) { 'refs/heads/wip' } + + it 'does not save the pipeline' do + expect(pipeline).not_to be_persisted + end + + it 'attaches an error about workflow rules' do + expect(pipeline.errors[:base]).to include('Pipeline filtered out by workflow rules.') + end + + context 'with workflow:rules shut off' do + before do + stub_feature_flags(workflow_rules: false) + end + + it 'saves the pipeline' do + expect(pipeline).to be_persisted + end + + it 'sets the pipeline state to pending' do + expect(pipeline).to be_pending + end + end + end + end + end end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index fd5f72c4c46e21c6b6adad10788151cf35c642e3..de0f484121524078be8b5417917b2415f2b5bc2d 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -65,6 +65,7 @@ describe Ci::CreatePipelineService do expect(pipeline.iid).not_to be_nil expect(pipeline.repository_source?).to be true expect(pipeline.builds.first).to be_kind_of(Ci::Build) + expect(pipeline.yaml_errors).not_to be_present end it 'increments the prometheus counter' do @@ -97,7 +98,7 @@ describe Ci::CreatePipelineService do end context 'when the head pipeline sha equals merge request sha' do - it 'updates head pipeline of each merge request' do + it 'updates head pipeline of each merge request', :sidekiq_might_not_need_inline do merge_request_1 merge_request_2 @@ -140,7 +141,7 @@ describe Ci::CreatePipelineService do let!(:project) { fork_project(target_project, nil, repository: true) } let!(:target_project) { create(:project, :repository) } - it 'updates head pipeline for merge request' do + it 'updates head pipeline for merge request', :sidekiq_might_not_need_inline do merge_request = create(:merge_request, source_branch: 'feature', target_branch: "master", source_project: project, @@ -172,7 +173,7 @@ describe Ci::CreatePipelineService do stub_ci_pipeline_yaml_file('some invalid syntax') end - it 'updates merge request head pipeline reference' do + it 'updates merge request head pipeline reference', :sidekiq_might_not_need_inline do merge_request = create(:merge_request, source_branch: 'master', target_branch: 'feature', source_project: project) @@ -192,7 +193,7 @@ describe Ci::CreatePipelineService do .and_return('some commit [ci skip]') end - it 'updates merge request head pipeline' do + it 'updates merge request head pipeline', :sidekiq_might_not_need_inline do merge_request = create(:merge_request, source_branch: 'master', target_branch: 'feature', source_project: project) @@ -218,21 +219,21 @@ describe Ci::CreatePipelineService do expect(pipeline.reload).to have_attributes(status: 'pending', auto_canceled_by_id: nil) end - it 'auto cancel pending non-HEAD pipelines' do + it 'auto cancel pending non-HEAD pipelines', :sidekiq_might_not_need_inline do pipeline_on_previous_commit pipeline expect(pipeline_on_previous_commit.reload).to have_attributes(status: 'canceled', auto_canceled_by_id: pipeline.id) end - it 'cancels running outdated pipelines' do + it 'cancels running outdated pipelines', :sidekiq_might_not_need_inline do pipeline_on_previous_commit.run head_pipeline = execute_service expect(pipeline_on_previous_commit.reload).to have_attributes(status: 'canceled', auto_canceled_by_id: head_pipeline.id) end - it 'cancel created outdated pipelines' do + it 'cancel created outdated pipelines', :sidekiq_might_not_need_inline do pipeline_on_previous_commit.update(status: 'created') pipeline @@ -346,7 +347,7 @@ describe Ci::CreatePipelineService do context 'when only interruptible builds are running' do context 'when build marked explicitly by interruptible is running' do - it 'cancels running outdated pipelines' do + it 'cancels running outdated pipelines', :sidekiq_might_not_need_inline do pipeline_on_previous_commit .builds .find_by_name('build_1_2') @@ -360,7 +361,7 @@ describe Ci::CreatePipelineService do end context 'when build that is not marked as interruptible is running' do - it 'cancels running outdated pipelines' do + it 'cancels running outdated pipelines', :sidekiq_might_not_need_inline do pipeline_on_previous_commit .builds .find_by_name('build_2_1') @@ -376,7 +377,7 @@ describe Ci::CreatePipelineService do end context 'when an uninterruptible build is running' do - it 'does not cancel running outdated pipelines' 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') @@ -391,7 +392,7 @@ describe Ci::CreatePipelineService do end context 'when an build is waiting on an interruptible scheduled task' do - it 'cancels running outdated pipelines' do + it 'cancels running outdated pipelines', :sidekiq_might_not_need_inline do allow(Ci::BuildScheduleWorker).to receive(:perform_at) pipeline_on_previous_commit @@ -407,7 +408,7 @@ describe Ci::CreatePipelineService do end context 'when a uninterruptible build has finished' do - it 'does not cancel running outdated pipelines' 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') @@ -474,6 +475,66 @@ describe Ci::CreatePipelineService do end end + context 'config evaluation' do + context 'when config is in a file in repository' do + before do + content = YAML.dump(rspec: { script: 'echo' }) + stub_ci_pipeline_yaml_file(content) + end + + it 'pull it from the repository' do + pipeline = execute_service + expect(pipeline).to be_repository_source + expect(pipeline.builds.map(&:name)).to eq ['rspec'] + end + end + + context 'when config is from Auto-DevOps' do + before do + stub_ci_pipeline_yaml_file(nil) + allow_any_instance_of(Project).to receive(:auto_devops_enabled?).and_return(true) + 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] + end + end + + context 'when config is not found' do + before do + stub_ci_pipeline_yaml_file(nil) + end + + it 'attaches errors to the pipeline' do + pipeline = execute_service + + expect(pipeline.errors.full_messages).to eq ['Missing .gitlab-ci.yml file'] + expect(pipeline).not_to be_persisted + end + end + + context 'when an unexpected error is raised' do + before do + expect(Gitlab::Ci::YamlProcessor).to receive(:new) + .and_raise(RuntimeError, 'undefined failure') + end + + it 'saves error in pipeline' do + pipeline = execute_service + + expect(pipeline.yaml_errors).to include('Undefined error') + end + + it 'logs error' do + expect(Gitlab::Sentry).to receive(:track_acceptable_exception).and_call_original + + execute_service + end + end + end + context 'when yaml is invalid' do let(:ci_yaml) { 'invalid: file: fiile' } let(:message) { 'Message' } @@ -539,6 +600,25 @@ describe Ci::CreatePipelineService do end end + context 'when an unexpected error is raised' do + before do + expect(Gitlab::Ci::YamlProcessor).to receive(:new) + .and_raise(RuntimeError, 'undefined failure') + end + + it 'saves error in pipeline' do + pipeline = execute_service + + expect(pipeline.yaml_errors).to include('Undefined error') + end + + it 'logs error' do + expect(Gitlab::Sentry).to receive(:track_acceptable_exception).and_call_original + + execute_service + end + end + context 'when commit contains a [ci skip] directive' do let(:message) { "some message[ci skip]" } @@ -773,8 +853,8 @@ describe Ci::CreatePipelineService do it 'correctly creates builds with auto-retry value configured' do expect(pipeline).to be_persisted - expect(rspec_job.retries_max).to eq 2 - expect(rspec_job.retry_when).to eq ['always'] + expect(rspec_job.options_retry_max).to eq 2 + expect(rspec_job.options_retry_when).to eq ['always'] end end @@ -783,8 +863,8 @@ describe Ci::CreatePipelineService do it 'correctly creates builds with auto-retry value configured' do expect(pipeline).to be_persisted - expect(rspec_job.retries_max).to eq 2 - expect(rspec_job.retry_when).to eq ['runner_system_failure'] + expect(rspec_job.options_retry_max).to eq 2 + expect(rspec_job.options_retry_when).to eq ['runner_system_failure'] end end end @@ -1236,7 +1316,7 @@ describe Ci::CreatePipelineService do let!(:project) { fork_project(target_project, nil, repository: true) } let!(:target_project) { create(:project, :repository) } - it 'creates a legacy detached merge request pipeline in the forked project' do + it 'creates a legacy detached merge request pipeline in the forked project', :sidekiq_might_not_need_inline do expect(pipeline).to be_persisted expect(project.ci_pipelines).to eq([pipeline]) expect(target_project.ci_pipelines).to be_empty diff --git a/spec/services/ci/find_exposed_artifacts_service_spec.rb b/spec/services/ci/find_exposed_artifacts_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f6309822fe079c90a410f7e12624f897c43845f4 --- /dev/null +++ b/spec/services/ci/find_exposed_artifacts_service_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Ci::FindExposedArtifactsService do + include Gitlab::Routing + + let(:metadata) do + Gitlab::Ci::Build::Artifacts::Metadata + .new(metadata_file_stream, path, { recursive: true }) + end + + let(:metadata_file_stream) do + File.open(Rails.root + 'spec/fixtures/ci_build_artifacts_metadata.gz') + end + + let_it_be(:project) { create(:project) } + let(:user) { nil } + + after do + metadata_file_stream&.close + end + + def create_job_with_artifacts(options) + create(:ci_build, pipeline: pipeline, options: options).tap do |job| + create(:ci_job_artifact, :metadata, job: job) + end + end + + describe '#for_pipeline' do + shared_examples 'finds a single match' do + it 'returns the artifact with exact location' do + expect(subject).to eq([{ + text: 'Exposed artifact', + url: file_project_job_artifacts_path(project, job, 'other_artifacts_0.1.2/doc_sample.txt'), + job_name: job.name, + job_path: project_job_path(project, job) + }]) + end + end + + shared_examples 'finds multiple matches' do + it 'returns the path to the artifacts browser' do + expect(subject).to eq([{ + text: 'Exposed artifact', + url: browse_project_job_artifacts_path(project, job), + job_name: job.name, + job_path: project_job_path(project, job) + }]) + end + end + + let_it_be(:pipeline) { create(:ci_pipeline, project: project) } + + subject { described_class.new(project, user).for_pipeline(pipeline) } + + context 'with jobs having at most 1 matching exposed artifact' do + let!(:job) do + create_job_with_artifacts(artifacts: { + expose_as: 'Exposed artifact', + paths: ['other_artifacts_0.1.2/doc_sample.txt', 'something-else.html'] + }) + end + + it_behaves_like 'finds a single match' + end + + context 'with jobs having more than 1 matching exposed artifacts' do + let!(:job) do + create_job_with_artifacts(artifacts: { + expose_as: 'Exposed artifact', + paths: [ + 'ci_artifacts.txt', + 'other_artifacts_0.1.2/doc_sample.txt', + 'something-else.html' + ] + }) + end + + it_behaves_like 'finds multiple matches' + end + + context 'with jobs having more than 1 matching exposed artifacts inside a directory' do + let!(:job) do + create_job_with_artifacts(artifacts: { + expose_as: 'Exposed artifact', + paths: ['tests_encoding/'] + }) + end + + it_behaves_like 'finds multiple matches' + end + + context 'with jobs having paths with glob expression' do + let!(:job) do + create_job_with_artifacts(artifacts: { + expose_as: 'Exposed artifact', + paths: ['other_artifacts_0.1.2/doc_sample.txt', 'tests_encoding/*.*'] + }) + end + + it_behaves_like 'finds a single match' # because those with * are ignored + end + + context 'limiting results' do + let!(:job1) do + create_job_with_artifacts(artifacts: { + expose_as: 'artifact 1', + paths: ['ci_artifacts.txt'] + }) + end + + let!(:job2) do + create_job_with_artifacts(artifacts: { + expose_as: 'artifact 2', + paths: ['tests_encoding/'] + }) + end + + let!(:job3) do + create_job_with_artifacts(artifacts: { + expose_as: 'should not be exposed', + paths: ['other_artifacts_0.1.2/doc_sample.txt'] + }) + end + + subject { described_class.new(project, user).for_pipeline(pipeline, limit: 2) } + + it 'returns first 2 results' do + expect(subject).to eq([ + { + text: 'artifact 1', + url: file_project_job_artifacts_path(project, job1, 'ci_artifacts.txt'), + job_name: job1.name, + job_path: project_job_path(project, job1) + }, + { + text: 'artifact 2', + url: browse_project_job_artifacts_path(project, job2), + job_name: job2.name, + job_path: project_job_path(project, job2) + } + ]) + end + end + end +end diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb index 05adec8b7454bb371a6f6cef89a6ac1529dd8565..991f8cdfac5f7eeec14a1a61fa209fc4fd403e48 100644 --- a/spec/services/ci/process_pipeline_service_spec.rb +++ b/spec/services/ci/process_pipeline_service_spec.rb @@ -27,7 +27,7 @@ describe Ci::ProcessPipelineService, '#execute' do create_build('deploy', stage_idx: 2) end - it 'processes a pipeline' do + it 'processes a pipeline', :sidekiq_might_not_need_inline do expect(process_pipeline).to be_truthy succeed_pending @@ -58,7 +58,7 @@ describe Ci::ProcessPipelineService, '#execute' do create_build('test_job', stage_idx: 1, allow_failure: true) end - it 'automatically triggers a next stage when build finishes' do + 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'] @@ -72,7 +72,7 @@ describe Ci::ProcessPipelineService, '#execute' do end end - context 'when optional manual actions are defined' do + 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) @@ -241,7 +241,7 @@ describe Ci::ProcessPipelineService, '#execute' do end end - context 'when delayed jobs are defined' do + 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) @@ -458,7 +458,7 @@ describe Ci::ProcessPipelineService, '#execute' do process_pipeline end - it 'skips second stage and continues on third stage' do + 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 @@ -502,7 +502,7 @@ describe Ci::ProcessPipelineService, '#execute' do play_manual_action('deploy') end - it 'queues the action and pipeline' do + 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 @@ -510,7 +510,7 @@ describe Ci::ProcessPipelineService, '#execute' do end end - context 'when blocking manual actions are defined' do + 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') @@ -618,7 +618,7 @@ describe Ci::ProcessPipelineService, '#execute' do end end - context 'when second stage has only on_failure jobs' do + 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') @@ -636,7 +636,7 @@ describe Ci::ProcessPipelineService, '#execute' do end end - context 'when failed build in the middle stage is retried' do + 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) @@ -683,7 +683,7 @@ describe Ci::ProcessPipelineService, '#execute' do end end - context 'when builds with auto-retries are configured' do + 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) @@ -712,7 +712,7 @@ describe Ci::ProcessPipelineService, '#execute' do end end - context 'when pipeline with needs is created' do + 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) } diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb index 2f2c525ccc45fbc9a939d6c54913cdc7a9e2ee3e..04334fb8915b9401e8c512ab6de87605edd07434 100644 --- a/spec/services/ci/register_job_service_spec.rb +++ b/spec/services/ci/register_job_service_spec.rb @@ -502,6 +502,57 @@ module Ci end end + context 'when build has data integrity problem' do + let!(:pending_job) do + create(:ci_build, :pending, pipeline: pipeline) + end + + before do + pending_job.update_columns(options: "string") + end + + subject { execute(specific_runner, {}) } + + it 'does drop the build and logs both failures' do + expect(Gitlab::Sentry).to receive(:track_acceptable_exception) + .with(anything, a_hash_including(extra: a_hash_including(build_id: pending_job.id))) + .twice + .and_call_original + + expect(subject).to be_nil + + pending_job.reload + expect(pending_job).to be_failed + expect(pending_job).to be_data_integrity_failure + end + end + + context 'when build fails to be run!' do + let!(:pending_job) do + create(:ci_build, :pending, pipeline: pipeline) + end + + before do + expect_any_instance_of(Ci::Build).to receive(:run!) + .and_raise(RuntimeError, 'scheduler error') + end + + subject { execute(specific_runner, {}) } + + it 'does drop the build and logs failure' do + expect(Gitlab::Sentry).to receive(:track_acceptable_exception) + .with(anything, a_hash_including(extra: a_hash_including(build_id: pending_job.id))) + .once + .and_call_original + + expect(subject).to be_nil + + pending_job.reload + expect(pending_job).to be_failed + expect(pending_job).to be_scheduler_failure + end + end + context 'when an exception is raised during a persistent ref creation' do before do allow_any_instance_of(Ci::PersistentRef).to receive(:exist?) { false } diff --git a/spec/services/clusters/applications/create_service_spec.rb b/spec/services/clusters/applications/create_service_spec.rb index 8dd573c36986ca08a47181deff6d3d020012e6e1..bdacb9ce071ec277579dbf46d5b941b4b9c75a3b 100644 --- a/spec/services/clusters/applications/create_service_spec.rb +++ b/spec/services/clusters/applications/create_service_spec.rb @@ -132,6 +132,34 @@ describe Clusters::Applications::CreateService do expect(subject.hostname).to eq('example.com') end end + + context 'elastic stack application' do + let(:params) do + { + application: 'elastic_stack', + kibana_hostname: 'example.com' + } + end + + before do + create(:clusters_applications_ingress, :installed, external_ip: "127.0.0.0", cluster: cluster) + expect_any_instance_of(Clusters::Applications::ElasticStack) + .to receive(:make_scheduled!) + .and_call_original + end + + it 'creates the application' do + expect do + subject + + 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 context 'invalid application' do diff --git a/spec/services/clusters/aws/fetch_credentials_service_spec.rb b/spec/services/clusters/aws/fetch_credentials_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..726d1c30603b34a66afe88b6ce3887f5a6f5cc8b --- /dev/null +++ b/spec/services/clusters/aws/fetch_credentials_service_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::Aws::FetchCredentialsService do + describe '#execute' do + let(:user) { create(:user) } + let(:provider) { create(:cluster_provider_aws) } + + let(:gitlab_access_key_id) { 'gitlab-access-key-id' } + let(:gitlab_secret_access_key) { 'gitlab-secret-access-key' } + + let(:region) { 'us-east-1' } + let(:gitlab_credentials) { Aws::Credentials.new(gitlab_access_key_id, gitlab_secret_access_key) } + let(:sts_client) { Aws::STS::Client.new(credentials: gitlab_credentials, region: region) } + let(:assumed_role) { instance_double(Aws::AssumeRoleCredentials, credentials: assumed_role_credentials) } + + let(:assumed_role_credentials) { double } + + subject { described_class.new(provision_role, region: region, provider: provider).execute } + + context 'provision role is configured' do + let(:provision_role) { create(:aws_role, user: user) } + + before do + stub_application_setting(eks_access_key_id: gitlab_access_key_id) + stub_application_setting(eks_secret_access_key: gitlab_secret_access_key) + + expect(Aws::Credentials).to receive(:new) + .with(gitlab_access_key_id, gitlab_secret_access_key) + .and_return(gitlab_credentials) + + expect(Aws::STS::Client).to receive(:new) + .with(credentials: gitlab_credentials, region: region) + .and_return(sts_client) + + expect(Aws::AssumeRoleCredentials).to receive(:new) + .with( + client: sts_client, + role_arn: provision_role.role_arn, + role_session_name: session_name, + external_id: provision_role.role_external_id + ).and_return(assumed_role) + end + + context 'provider is specified' do + let(:session_name) { "gitlab-eks-cluster-#{provider.cluster_id}-user-#{user.id}" } + + it { is_expected.to eq assumed_role_credentials } + end + + context 'provider is not specifed' do + let(:provider) { nil } + let(:session_name) { "gitlab-eks-autofill-user-#{user.id}" } + + it { is_expected.to eq assumed_role_credentials } + end + end + + context 'provision role is not configured' do + let(:provision_role) { nil } + + it 'raises an error' do + expect { subject }.to raise_error(described_class::MissingRoleError, 'AWS provisioning role not configured') + end + end + end +end diff --git a/spec/services/clusters/aws/finalize_creation_service_spec.rb b/spec/services/clusters/aws/finalize_creation_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8d7341483e30955478016b41f3fd10ef20e967d0 --- /dev/null +++ b/spec/services/clusters/aws/finalize_creation_service_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::Aws::FinalizeCreationService do + describe '#execute' do + let(:provider) { create(:cluster_provider_aws, :creating) } + let(:platform) { provider.cluster.platform_kubernetes } + + let(:create_service_account_service) { double(execute: true) } + let(:fetch_token_service) { double(execute: gitlab_token) } + let(:kube_client) { double(create_config_map: true) } + let(:cluster_stack) { double(outputs: [endpoint_output, cert_output, node_role_output]) } + let(:node_auth_config_map) { double } + + let(:endpoint_output) { double(output_key: 'ClusterEndpoint', output_value: api_url) } + let(:cert_output) { double(output_key: 'ClusterCertificate', output_value: Base64.encode64(ca_pem)) } + let(:node_role_output) { double(output_key: 'NodeInstanceRole', output_value: node_role) } + + let(:api_url) { 'https://kubernetes.example.com' } + let(:ca_pem) { File.read(Rails.root.join('spec/fixtures/clusters/sample_cert.pem')) } + let(:gitlab_token) { 'gitlab-token' } + let(:iam_token) { 'iam-token' } + let(:node_role) { 'arn::aws::iam::123456789012:role/node-role' } + + subject { described_class.new.execute(provider) } + + before do + allow(Clusters::Kubernetes::CreateOrUpdateServiceAccountService).to receive(:gitlab_creator) + .with(kube_client, rbac: true) + .and_return(create_service_account_service) + + allow(Clusters::Kubernetes::FetchKubernetesTokenService).to receive(:new) + .with( + kube_client, + Clusters::Kubernetes::GITLAB_ADMIN_TOKEN_NAME, + Clusters::Kubernetes::GITLAB_SERVICE_ACCOUNT_NAMESPACE) + .and_return(fetch_token_service) + + allow(Gitlab::Kubernetes::KubeClient).to receive(:new) + .with( + api_url, + auth_options: { bearer_token: iam_token }, + ssl_options: { + verify_ssl: OpenSSL::SSL::VERIFY_PEER, + cert_store: instance_of(OpenSSL::X509::Store) + }, + http_proxy_uri: nil + ) + .and_return(kube_client) + + allow(provider.api_client).to receive(:describe_stacks) + .with(stack_name: provider.cluster.name) + .and_return(double(stacks: [cluster_stack])) + + allow(Kubeclient::AmazonEksCredentials).to receive(:token) + .with(provider.credentials, provider.cluster.name) + .and_return(iam_token) + + allow(Gitlab::Kubernetes::ConfigMaps::AwsNodeAuth).to receive(:new) + .with(node_role).and_return(double(generate: node_auth_config_map)) + end + + it 'configures the provider and platform' do + subject + + expect(provider).to be_created + expect(platform.api_url).to eq(api_url) + expect(platform.ca_pem).to eq(ca_pem) + expect(platform.token).to eq(gitlab_token) + expect(platform).to be_rbac + end + + it 'calls the create_service_account_service' do + expect(create_service_account_service).to receive(:execute).once + + subject + end + + it 'configures cluster node authentication' do + expect(kube_client).to receive(:create_config_map).with(node_auth_config_map).once + + subject + end + + describe 'error handling' do + shared_examples 'provision error' do |message| + it "sets the status to :errored with an appropriate error message" do + subject + + expect(provider).to be_errored + expect(provider.status_reason).to include(message) + end + end + + context 'failed to request stack details from AWS' do + before do + allow(provider.api_client).to receive(:describe_stacks) + .and_raise(Aws::CloudFormation::Errors::ServiceError.new(double, "Error message")) + end + + include_examples 'provision error', 'Failed to fetch CloudFormation stack' + end + + context 'failed to create auth config map' do + before do + allow(kube_client).to receive(:create_config_map) + .and_raise(Kubeclient::HttpError.new(500, 'Error', nil)) + end + + include_examples 'provision error', 'Failed to run Kubeclient' + end + + context 'failed to save records' do + before do + allow(provider.cluster).to receive(:save!) + .and_raise(ActiveRecord::RecordInvalid) + end + + include_examples 'provision error', 'Failed to configure EKS provider' + end + end + end +end diff --git a/spec/services/clusters/aws/provision_service_spec.rb b/spec/services/clusters/aws/provision_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..927ffaef00207d2ba12e91ee113b72cf8461dc4b --- /dev/null +++ b/spec/services/clusters/aws/provision_service_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::Aws::ProvisionService do + describe '#execute' do + let(:provider) { create(:cluster_provider_aws) } + + let(:provision_role) { create(:aws_role, user: provider.created_by_user) } + let(:client) { instance_double(Aws::CloudFormation::Client, create_stack: true) } + let(:cloudformation_template) { double } + let(:credentials) do + instance_double( + Aws::Credentials, + access_key_id: 'key', + secret_access_key: 'secret', + session_token: 'token' + ) + end + + let(:parameters) do + [ + { parameter_key: 'ClusterName', parameter_value: provider.cluster.name }, + { parameter_key: 'ClusterRole', parameter_value: provider.role_arn }, + { parameter_key: 'ClusterControlPlaneSecurityGroup', parameter_value: provider.security_group_id }, + { parameter_key: 'VpcId', parameter_value: provider.vpc_id }, + { parameter_key: 'Subnets', parameter_value: provider.subnet_ids.join(',') }, + { parameter_key: 'NodeAutoScalingGroupDesiredCapacity', parameter_value: provider.num_nodes.to_s }, + { parameter_key: 'NodeInstanceType', parameter_value: provider.instance_type }, + { parameter_key: 'KeyName', parameter_value: provider.key_name } + ] + end + + subject { described_class.new.execute(provider) } + + before do + allow(Clusters::Aws::FetchCredentialsService).to receive(:new) + .with(provision_role, provider: provider, region: provider.region) + .and_return(double(execute: credentials)) + + allow(provider).to receive(:api_client) + .and_return(client) + + allow(File).to receive(:read) + .with(Rails.root.join('vendor', 'aws', 'cloudformation', 'eks_cluster.yaml')) + .and_return(cloudformation_template) + end + + it 'updates the provider status to :creating and configures the provider with credentials' do + subject + + expect(provider).to be_creating + expect(provider.access_key_id).to eq 'key' + expect(provider.secret_access_key).to eq 'secret' + expect(provider.session_token).to eq 'token' + end + + it 'creates a CloudFormation stack' do + expect(client).to receive(:create_stack).with( + stack_name: provider.cluster.name, + template_body: cloudformation_template, + parameters: parameters, + capabilities: ["CAPABILITY_IAM"] + ) + + subject + end + + it 'schedules a worker to monitor creation status' do + expect(WaitForClusterCreationWorker).to receive(:perform_in) + .with(Clusters::Aws::VerifyProvisionStatusService::INITIAL_INTERVAL, provider.cluster_id) + + subject + end + + describe 'error handling' do + shared_examples 'provision error' do |message| + it "sets the status to :errored with an appropriate error message" do + subject + + expect(provider).to be_errored + expect(provider.status_reason).to include(message) + end + end + + context 'invalid state transition' do + before do + allow(provider).to receive(:make_creating).and_return(false) + end + + include_examples 'provision error', 'Failed to update provider record' + end + + context 'AWS role is not configured' do + before do + allow(Clusters::Aws::FetchCredentialsService).to receive(:new) + .and_raise(Clusters::Aws::FetchCredentialsService::MissingRoleError) + end + + include_examples 'provision error', 'Amazon role is not configured' + end + + context 'AWS credentials are not configured' do + before do + allow(Clusters::Aws::FetchCredentialsService).to receive(:new) + .and_raise(Aws::Errors::MissingCredentialsError) + end + + include_examples 'provision error', 'Amazon credentials are not configured' + end + + context 'Authentication failure' do + before do + allow(Clusters::Aws::FetchCredentialsService).to receive(:new) + .and_raise(Aws::STS::Errors::ServiceError.new(double, 'Error message')) + end + + include_examples 'provision error', 'Amazon authentication failed' + end + + context 'CloudFormation failure' do + before do + allow(client).to receive(:create_stack) + .and_raise(Aws::CloudFormation::Errors::ServiceError.new(double, 'Error message')) + end + + include_examples 'provision error', 'Amazon CloudFormation request failed' + end + end + end +end diff --git a/spec/services/clusters/aws/proxy_service_spec.rb b/spec/services/clusters/aws/proxy_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7b0e0512b959b853e542d38df0919bd7d6b044d1 --- /dev/null +++ b/spec/services/clusters/aws/proxy_service_spec.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::Aws::ProxyService do + let(:role) { create(:aws_role) } + let(:credentials) { instance_double(Aws::Credentials) } + let(:client_instance) { instance_double(client) } + + let(:region) { 'region' } + let(:vpc_id) { } + let(:params) do + ActionController::Parameters.new({ + resource: resource, + region: region, + vpc_id: vpc_id + }) + end + + subject { described_class.new(role, params: params).execute } + + context 'external resources' do + before do + allow(Clusters::Aws::FetchCredentialsService).to receive(:new) do + double(execute: credentials) + end + + allow(client).to receive(:new) + .with( + credentials: credentials, region: region, + http_open_timeout: 5, http_read_timeout: 10) + .and_return(client_instance) + end + + shared_examples 'bad request' do + it 'returns an empty hash' do + expect(subject.status).to eq :bad_request + expect(subject.body).to eq({}) + end + end + + describe 'key_pairs' do + let(:client) { Aws::EC2::Client } + let(:resource) { 'key_pairs' } + let(:response) { double(to_hash: :key_pairs) } + + it 'requests a list of key pairs' do + expect(client_instance).to receive(:describe_key_pairs).once.and_return(response) + expect(subject.status).to eq :ok + expect(subject.body).to eq :key_pairs + end + end + + describe 'roles' do + let(:client) { Aws::IAM::Client } + let(:resource) { 'roles' } + let(:response) { double(to_hash: :roles) } + + it 'requests a list of roles' do + expect(client_instance).to receive(:list_roles).once.and_return(response) + expect(subject.status).to eq :ok + expect(subject.body).to eq :roles + end + end + + describe 'regions' do + let(:client) { Aws::EC2::Client } + let(:resource) { 'regions' } + let(:response) { double(to_hash: :regions) } + + it 'requests a list of regions' do + expect(client_instance).to receive(:describe_regions).once.and_return(response) + expect(subject.status).to eq :ok + expect(subject.body).to eq :regions + end + end + + describe 'security_groups' do + let(:client) { Aws::EC2::Client } + let(:resource) { 'security_groups' } + let(:response) { double(to_hash: :security_groups) } + + include_examples 'bad request' + + context 'VPC is specified' do + let(:vpc_id) { 'vpc-1' } + + it 'requests a list of security groups for a VPC' do + expect(client_instance).to receive(:describe_security_groups).once + .with(filters: [{ name: 'vpc-id', values: [vpc_id] }]) + .and_return(response) + expect(subject.status).to eq :ok + expect(subject.body).to eq :security_groups + end + end + end + + describe 'subnets' do + let(:client) { Aws::EC2::Client } + let(:resource) { 'subnets' } + let(:response) { double(to_hash: :subnets) } + + include_examples 'bad request' + + context 'VPC is specified' do + let(:vpc_id) { 'vpc-1' } + + it 'requests a list of subnets for a VPC' do + expect(client_instance).to receive(:describe_subnets).once + .with(filters: [{ name: 'vpc-id', values: [vpc_id] }]) + .and_return(response) + expect(subject.status).to eq :ok + expect(subject.body).to eq :subnets + end + end + end + + describe 'vpcs' do + let(:client) { Aws::EC2::Client } + let(:resource) { 'vpcs' } + let(:response) { double(to_hash: :vpcs) } + + it 'requests a list of VPCs' do + expect(client_instance).to receive(:describe_vpcs).once.and_return(response) + expect(subject.status).to eq :ok + expect(subject.body).to eq :vpcs + end + end + + context 'errors' do + let(:client) { Aws::EC2::Client } + + context 'unknown resource' do + let(:resource) { 'instances' } + + include_examples 'bad request' + end + + context 'client and configuration errors' do + let(:resource) { 'vpcs' } + + before do + allow(client_instance).to receive(:describe_vpcs).and_raise(error) + end + + context 'error fetching credentials' do + let(:error) { Aws::STS::Errors::ServiceError.new(nil, 'error message') } + + include_examples 'bad request' + end + + context 'credentials not configured' do + let(:error) { Aws::Errors::MissingCredentialsError.new('error message') } + + include_examples 'bad request' + end + + context 'role not configured' do + let(:error) { Clusters::Aws::FetchCredentialsService::MissingRoleError.new('error message') } + + include_examples 'bad request' + end + + context 'EC2 error' do + let(:error) { Aws::EC2::Errors::ServiceError.new(nil, 'error message') } + + include_examples 'bad request' + end + + context 'IAM error' do + let(:error) { Aws::IAM::Errors::ServiceError.new(nil, 'error message') } + + include_examples 'bad request' + end + + context 'STS error' do + let(:error) { Aws::STS::Errors::ServiceError.new(nil, 'error message') } + + include_examples 'bad request' + end + end + end + end + + context 'local resources' do + describe 'instance_types' do + let(:resource) { 'instance_types' } + let(:cloudformation_template) { double } + let(:instance_types) { double(dig: %w(t3.small)) } + + before do + allow(File).to receive(:read) + .with(Rails.root.join('vendor', 'aws', 'cloudformation', 'eks_cluster.yaml')) + .and_return(cloudformation_template) + + allow(YAML).to receive(:safe_load) + .with(cloudformation_template) + .and_return(instance_types) + end + + it 'returns a list of instance types' do + expect(subject.status).to eq :ok + expect(subject.body).to have_key(:instance_types) + expect(subject.body[:instance_types]).to match_array([ + instance_type_name: 't3.small' + ]) + end + end + end +end diff --git a/spec/services/clusters/aws/verify_provision_status_service_spec.rb b/spec/services/clusters/aws/verify_provision_status_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b62b0875bf32d19c3f91324e8bf490a9314e6c76 --- /dev/null +++ b/spec/services/clusters/aws/verify_provision_status_service_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::Aws::VerifyProvisionStatusService do + describe '#execute' do + let(:provider) { create(:cluster_provider_aws) } + + let(:stack) { double(stack_status: stack_status, creation_time: creation_time) } + let(:creation_time) { 1.minute.ago } + + subject { described_class.new.execute(provider) } + + before do + allow(provider.api_client).to receive(:describe_stacks) + .with(stack_name: provider.cluster.name) + .and_return(double(stacks: [stack])) + end + + shared_examples 'provision error' do |message| + it "sets the status to :errored with an appropriate error message" do + subject + + expect(provider).to be_errored + expect(provider.status_reason).to include(message) + end + end + + context 'stack creation is still in progress' do + let(:stack_status) { 'CREATE_IN_PROGRESS' } + let(:verify_service) { double(execute: true) } + + it 'schedules a worker to check again later' do + expect(WaitForClusterCreationWorker).to receive(:perform_in) + .with(described_class::POLL_INTERVAL, provider.cluster_id) + + subject + end + + context 'stack creation is taking too long' do + let(:creation_time) { 1.hour.ago } + + include_examples 'provision error', 'Kubernetes cluster creation time exceeds timeout' + end + end + + context 'stack creation is complete' do + let(:stack_status) { 'CREATE_COMPLETE' } + let(:finalize_service) { double(execute: true) } + + it 'finalizes creation' do + expect(Clusters::Aws::FinalizeCreationService).to receive(:new).and_return(finalize_service) + expect(finalize_service).to receive(:execute).with(provider).once + + subject + end + end + + context 'stack creation failed' do + let(:stack_status) { 'CREATE_FAILED' } + + include_examples 'provision error', 'Unexpected status' + end + + context 'error communicating with CloudFormation API' do + let(:stack_status) { 'CREATE_IN_PROGRESS' } + + before do + allow(provider.api_client).to receive(:describe_stacks) + .and_raise(Aws::CloudFormation::Errors::ServiceError.new(double, 'Error message')) + end + + include_examples 'provision error', 'Amazon CloudFormation request failed' + end + end +end diff --git a/spec/services/clusters/destroy_service_spec.rb b/spec/services/clusters/destroy_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c0fcc971500f3c7fb69440b3c1fb8439f130b01c --- /dev/null +++ b/spec/services/clusters/destroy_service_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::DestroyService do + describe '#execute' do + subject { described_class.new(cluster.user, params).execute(cluster) } + + let!(:cluster) { create(:cluster, :project, :provided_by_user) } + + context 'when correct params' do + shared_examples 'only removes cluster' do + it 'does not start cleanup' do + expect(cluster).not_to receive(:start_cleanup) + subject + end + + it 'destroys the cluster' do + subject + expect { cluster.reload }.to raise_error ActiveRecord::RecordNotFound + end + end + + context 'when params are empty' do + let(:params) { {} } + + it_behaves_like 'only removes cluster' + end + + context 'when cleanup param is false' do + let(:params) { { cleanup: 'false' } } + + it_behaves_like 'only removes cluster' + end + + context 'when cleanup param is true' do + let(:params) { { cleanup: 'true' } } + + before do + allow(Clusters::Cleanup::AppWorker).to receive(:perform_async) + end + + it 'does not destroy cluster' do + subject + expect(Clusters::Cluster.where(id: cluster.id).exists?).not_to be_falsey + end + + it 'transition cluster#cleanup_status from cleanup_not_started to uninstalling_applications' do + expect { subject }.to change { cluster.cleanup_status_name } + .from(:cleanup_not_started) + .to(:cleanup_uninstalling_applications) + end + end + 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 5a3b1cd6cfb280f6632023d69b21449e3e0973f3..291e63bbe4a3922df9e61dfa053c222e11b6a63e 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 @@ -37,6 +37,8 @@ describe Clusters::Kubernetes::CreateOrUpdateNamespaceService, '#execute' do stub_kubeclient_put_secret(api_url, "#{namespace}-token", namespace: namespace) stub_kubeclient_put_role(api_url, Clusters::Kubernetes::GITLAB_KNATIVE_SERVING_ROLE_NAME, namespace: namespace) stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_KNATIVE_SERVING_ROLE_BINDING_NAME, namespace: namespace) + stub_kubeclient_put_role(api_url, Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_NAME, namespace: namespace) + stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_BINDING_NAME, namespace: namespace) stub_kubeclient_get_secret( api_url, 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 10dbfc800ff9dc4f495fab68e14e711b6318cfb9..4df73fcc2aec8262e5bb938ee3feb9ca06f36e18 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 @@ -145,6 +145,8 @@ describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService do stub_kubeclient_create_role_binding(api_url, namespace: namespace) stub_kubeclient_put_role(api_url, Clusters::Kubernetes::GITLAB_KNATIVE_SERVING_ROLE_NAME, namespace: namespace) stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_KNATIVE_SERVING_ROLE_BINDING_NAME, namespace: namespace) + stub_kubeclient_put_role(api_url, Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_NAME, namespace: namespace) + stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_BINDING_NAME, namespace: namespace) end it_behaves_like 'creates service account and token' @@ -172,6 +174,31 @@ describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService do ) end + it 'creates a role binding granting crossplane database permissions to the service account' do + subject + + expect(WebMock).to have_requested(:put, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/rolebindings/#{Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_BINDING_NAME}").with( + body: hash_including( + metadata: { + name: Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_BINDING_NAME, + namespace: namespace + }, + roleRef: { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'Role', + name: Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_NAME + }, + subjects: [ + { + kind: 'ServiceAccount', + name: service_account_name, + namespace: namespace + } + ] + ) + ) + end + it 'creates a role and role binding granting knative serving permissions to the service account' do subject @@ -189,6 +216,24 @@ describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService do ) ) end + + it 'creates a role and role binding granting crossplane database permissions to the service account' do + subject + + expect(WebMock).to have_requested(:put, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/roles/#{Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_NAME}").with( + body: hash_including( + metadata: { + name: Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_NAME, + namespace: namespace + }, + rules: [{ + apiGroups: %w(database.crossplane.io), + resources: %w(postgresqlinstances), + verbs: %w(get list create watch) + }] + ) + ) + end end end end diff --git a/spec/services/clusters/update_service_spec.rb b/spec/services/clusters/update_service_spec.rb index 3ee45375dca37559b725d264aae5dc53e5f7a599..fdbed4fa5d8a374611adc56ebcc1d41515e53e63 100644 --- a/spec/services/clusters/update_service_spec.rb +++ b/spec/services/clusters/update_service_spec.rb @@ -90,5 +90,132 @@ describe Clusters::UpdateService do end end end + + context 'when params includes :management_project_id' do + context 'management_project is non-existent' do + let(:params) do + { management_project_id: 0 } + end + + it 'does not update management_project_id' do + is_expected.to eq(false) + + expect(cluster.errors[:management_project_id]).to include('Project does not exist or you don\'t have permission to perform this action') + + cluster.reload + expect(cluster.management_project_id).to be_nil + end + end + + shared_examples 'setting a management project' do + context 'user is authorized to adminster manangement_project' do + before do + management_project.add_maintainer(cluster.user) + end + + let(:params) do + { management_project_id: management_project.id } + end + + it 'updates management_project_id' do + is_expected.to eq(true) + + expect(cluster.management_project).to eq(management_project) + end + end + + context 'user is not authorized to adminster manangement_project' do + let(:params) do + { management_project_id: management_project.id } + end + + it 'does not update management_project_id' do + is_expected.to eq(false) + + expect(cluster.errors[:management_project_id]).to include('Project does not exist or you don\'t have permission to perform this action') + + cluster.reload + expect(cluster.management_project_id).to be_nil + end + end + + context 'cluster already has a management project set' do + before do + cluster.update!(management_project: create(:project)) + end + + let(:params) do + { management_project_id: '' } + end + + it 'unsets management_project_id' do + is_expected.to eq(true) + + cluster.reload + expect(cluster.management_project_id).to be_nil + end + end + end + + context 'project cluster' do + include_examples 'setting a management project' do + let(:management_project) { create(:project, namespace: cluster.first_project.namespace) } + end + + context 'manangement_project is outside of the namespace scope' do + before do + management_project.update(group: create(:group)) + end + + let(:params) do + { management_project_id: management_project.id } + end + + it 'does not update management_project_id' do + is_expected.to eq(false) + + expect(cluster.errors[:management_project_id]).to include('Project does not exist or you don\'t have permission to perform this action') + + cluster.reload + expect(cluster.management_project_id).to be_nil + end + end + end + + context 'group cluster' do + let(:cluster) { create(:cluster, :group) } + + include_examples 'setting a management project' do + let(:management_project) { create(:project, group: cluster.first_group) } + end + + context 'manangement_project is outside of the namespace scope' do + before do + management_project.update(group: create(:group)) + end + + let(:params) do + { management_project_id: management_project.id } + end + + it 'does not update management_project_id' do + is_expected.to eq(false) + + expect(cluster.errors[:management_project_id]).to include('Project does not exist or you don\'t have permission to perform this action') + + cluster.reload + expect(cluster.management_project_id).to be_nil + end + end + end + + context 'instance cluster' do + let(:cluster) { create(:cluster, :instance) } + + include_examples 'setting a management project' do + let(:management_project) { create(:project) } + end + end + end end end diff --git a/spec/services/concerns/merge_requests/assigns_merge_params_spec.rb b/spec/services/concerns/merge_requests/assigns_merge_params_spec.rb index 5b653aa331c53e1f703e50bda7af66c716491b19..9cf7f354191d468c3015ca10cafb5ec3d380c3a6 100644 --- a/spec/services/concerns/merge_requests/assigns_merge_params_spec.rb +++ b/spec/services/concerns/merge_requests/assigns_merge_params_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe MergeRequests::AssignsMergeParams do diff --git a/spec/services/create_branch_service_spec.rb b/spec/services/create_branch_service_spec.rb index 0d34c7f9a8201944403f10d83e77b2e4157a8673..9661173c9e793947a56875164004cf9feab562c5 100644 --- a/spec/services/create_branch_service_spec.rb +++ b/spec/services/create_branch_service_spec.rb @@ -22,5 +22,20 @@ describe CreateBranchService do expect(project.repository.branch_exists?('my-feature')).to be_truthy end end + + context 'when creating a branch fails' do + let(:project) { create(:project_empty_repo) } + + before do + allow(project.repository).to receive(:add_branch).and_return(false) + end + + it 'retruns an error with the branch name' do + result = service.execute('my-feature', 'master') + + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq("Invalid reference name: my-feature") + end + end end end diff --git a/spec/services/deployments/after_create_service_spec.rb b/spec/services/deployments/after_create_service_spec.rb index b34483ea85bf62a9721afcf2b62334136f582e8f..94532ed81ae06d6993c41c0009cf8fcde1e1834d 100644 --- a/spec/services/deployments/after_create_service_spec.rb +++ b/spec/services/deployments/after_create_service_spec.rb @@ -53,6 +53,14 @@ 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 @@ -237,4 +245,30 @@ 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 new file mode 100644 index 0000000000000000000000000000000000000000..ba069658dfd6f14ad42dd01cd42b64c97e341e49 --- /dev/null +++ b/spec/services/deployments/link_merge_requests_service_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Deployments::LinkMergeRequestsService do + describe '#execute' do + context 'when the deployment did not succeed' do + it 'does nothing' do + deploy = create(:deployment, :failed) + + expect(deploy).not_to receive(:link_merge_requests) + + described_class.new(deploy).execute + end + end + + 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') + deploy2 = create( + :deployment, + :success, + sha: 'bar', + project: deploy1.project, + environment: deploy1.environment + ) + + service = described_class.new(deploy2) + + expect(service) + .to receive(:link_merge_requests_for_range) + .with('foo', 'bar') + + service.execute + end + end + + context 'when there are no previous deployments' do + it 'links all merged merge requests' do + deploy = create(:deployment, :success) + service = described_class.new(deploy) + + expect(service).to receive(:link_all_merged_merge_requests) + + service.execute + end + end + end + + 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) + + mr1 = create( + :merge_request, + :merged, + merge_commit_sha: '1e292f8fedd741b75372e19097c76d327140c312', + source_project: project, + target_project: project + ) + + mr2 = create( + :merge_request, + :merged, + merge_commit_sha: '2d1db523e11e777e49377cfb22d368deec3f0793', + source_project: project, + target_project: project + ) + + described_class.new(deploy).link_merge_requests_for_range( + '7975be0116940bf2ad4321f79d02a55c5f7779aa', + 'ddd0f15ae83993f5cb66a927a28673882e99100b' + ) + + expect(deploy.merge_requests).to include(mr1, mr2) + end + end + + 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) + + mr1 = create( + :merge_request, + :merged, + source_project: project, + target_project: project, + source_branch: 'source1', + target_branch: deploy.ref + ) + + mr2 = create( + :merge_request, + :merged, + source_project: project, + target_project: project, + source_branch: 'source2', + target_branch: deploy.ref + ) + + mr3 = create( + :merge_request, + :merged, + source_project: project, + target_project: project, + target_branch: 'foo' + ) + + described_class.new(deploy).link_all_merged_merge_requests + + expect(deploy.merge_requests).to include(mr1, mr2) + expect(deploy.merge_requests).not_to include(mr3) + end + end +end diff --git a/spec/services/deployments/update_service_spec.rb b/spec/services/deployments/update_service_spec.rb index a923099b82cbab3b8f5c22d8a5498f88d7b76d1f..8a918d28ffdd6a02f28141393a31caff703f01cc 100644 --- a/spec/services/deployments/update_service_spec.rb +++ b/spec/services/deployments/update_service_spec.rb @@ -3,13 +3,55 @@ require 'spec_helper' describe Deployments::UpdateService do - let(:deploy) { create(:deployment, :running) } - let(:service) { described_class.new(deploy, status: 'success') } + let(:deploy) { create(:deployment) } describe '#execute' do - it 'updates the status of a deployment' do - expect(service.execute).to eq(true) - expect(deploy.status).to eq('success') + it 'can update the status to running' do + expect(described_class.new(deploy, status: 'running').execute) + .to be_truthy + + expect(deploy).to be_running + end + + it 'can update the status to success' do + expect(described_class.new(deploy, status: 'success').execute) + .to be_truthy + + expect(deploy).to be_success + end + + it 'can update the status to failed' do + expect(described_class.new(deploy, status: 'failed').execute) + .to be_truthy + + expect(deploy).to be_failed + end + + it 'can update the status to canceled' do + expect(described_class.new(deploy, status: 'canceled').execute) + .to be_truthy + + expect(deploy).to be_canceled + end + + it 'returns false when the status is not supported' do + expect(described_class.new(deploy, status: 'kittens').execute) + .to be_falsey + end + + it 'links merge requests when changing the status to success', :sidekiq_inline do + mr = create( + :merge_request, + :merged, + target_project: deploy.project, + source_project: deploy.project, + target_branch: 'master', + source_branch: 'foo' + ) + + described_class.new(deploy, status: 'success').execute + + expect(deploy.merge_requests).to eq([mr]) end end end diff --git a/spec/services/error_tracking/issue_details_service_spec.rb b/spec/services/error_tracking/issue_details_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4d5505bb5a912e515e3c9116fd0eef0ea8d32b84 --- /dev/null +++ b/spec/services/error_tracking/issue_details_service_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ErrorTracking::IssueDetailsService do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + + let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' } + let(:token) { 'test-token' } + let(:result) { subject.execute } + + let(:error_tracking_setting) do + create(:project_error_tracking_setting, api_url: sentry_url, token: token, project: project) + end + + subject { described_class.new(project, user) } + + before do + expect(project).to receive(:error_tracking_setting).at_least(:once).and_return(error_tracking_setting) + + project.add_reporter(user) + end + + describe '#execute' do + context 'with authorized user' do + context 'when issue_details returns a detailed error' do + let(:detailed_error) { build(:detailed_error_tracking_error) } + + before do + expect(error_tracking_setting) + .to receive(:issue_details).and_return(issue: detailed_error) + end + + it 'returns the detailed error' do + expect(result).to eq(status: :success, issue: detailed_error) + end + end + + include_examples 'error tracking service data not ready', :issue_details + include_examples 'error tracking service sentry error handling', :issue_details + include_examples 'error tracking service http status handling', :issue_details + end + + include_examples 'error tracking service unauthorized user' + include_examples 'error tracking service disabled' + end +end diff --git a/spec/services/error_tracking/issue_latest_event_service_spec.rb b/spec/services/error_tracking/issue_latest_event_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..cda15042814d9e88613c9468bc8c8f799026f171 --- /dev/null +++ b/spec/services/error_tracking/issue_latest_event_service_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ErrorTracking::IssueLatestEventService do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + + let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' } + let(:token) { 'test-token' } + let(:result) { subject.execute } + + let(:error_tracking_setting) do + create(:project_error_tracking_setting, api_url: sentry_url, token: token, project: project) + end + + subject { described_class.new(project, user) } + + before do + expect(project).to receive(:error_tracking_setting).at_least(:once).and_return(error_tracking_setting) + + project.add_reporter(user) + end + + describe '#execute' do + context 'with authorized user' do + context 'when issue_latest_event returns an error event' do + let(:error_event) { build(:error_tracking_error_event) } + + before do + expect(error_tracking_setting) + .to receive(:issue_latest_event).and_return(latest_event: error_event) + end + + it 'returns the error event' do + expect(result).to eq(status: :success, latest_event: error_event) + end + end + + include_examples 'error tracking service data not ready', :issue_latest_event + include_examples 'error tracking service sentry error handling', :issue_latest_event + include_examples 'error tracking service http status handling', :issue_latest_event + end + + include_examples 'error tracking service unauthorized user' + include_examples 'error tracking service disabled' + end +end diff --git a/spec/services/error_tracking/list_issues_service_spec.rb b/spec/services/error_tracking/list_issues_service_spec.rb index 3a8f3069911db95e659fb2a93f57b08564fbe598..5b73bc91478f0a363f2b328eed67c61ce43f68f3 100644 --- a/spec/services/error_tracking/list_issues_service_spec.rb +++ b/spec/services/error_tracking/list_issues_service_spec.rb @@ -37,93 +37,20 @@ describe ErrorTracking::ListIssuesService do end end - context 'when list_sentry_issues returns nil' do - before do - expect(error_tracking_setting) - .to receive(:list_sentry_issues).and_return(nil) - end - - it 'result is not ready' do - expect(result).to eq( - status: :error, http_status: :no_content, message: 'Not ready. Try again later') - end - end - - context 'when list_sentry_issues returns error' do - before do - allow(error_tracking_setting) - .to receive(:list_sentry_issues) - .and_return( - error: 'Sentry response status code: 401', - error_type: ErrorTracking::ProjectErrorTrackingSetting::SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE - ) - end - - it 'returns the error' do - expect(result).to eq( - status: :error, - http_status: :bad_request, - message: 'Sentry response status code: 401' - ) - end - end - - context 'when list_sentry_issues returns error with http_status' do - before do - allow(error_tracking_setting) - .to receive(:list_sentry_issues) - .and_return( - error: 'Sentry API response is missing keys. key not found: "id"', - error_type: ErrorTracking::ProjectErrorTrackingSetting::SENTRY_API_ERROR_TYPE_MISSING_KEYS - ) - end - - it 'returns the error with correct http_status' do - expect(result).to eq( - status: :error, - http_status: :internal_server_error, - message: 'Sentry API response is missing keys. key not found: "id"' - ) - end - end + include_examples 'error tracking service data not ready', :list_sentry_issues + include_examples 'error tracking service sentry error handling', :list_sentry_issues + include_examples 'error tracking service http status handling', :list_sentry_issues end - context 'with unauthorized user' do - let(:unauthorized_user) { create(:user) } - - subject { described_class.new(project, unauthorized_user) } - - it 'returns error' do - result = subject.execute - - expect(result).to include( - status: :error, - message: 'Access denied', - http_status: :unauthorized - ) - end - end - - context 'with error tracking disabled' do - before do - error_tracking_setting.enabled = false - end - - it 'raises error' do - result = subject.execute - - expect(result).to include(status: :error, message: 'Error Tracking is not enabled') - end - end + include_examples 'error tracking service unauthorized user' + include_examples 'error tracking service disabled' end - describe '#sentry_external_url' do - let(:external_url) { 'https://sentrytest.gitlab.com/sentry-org/sentry-project' } - - it 'calls ErrorTracking::ProjectErrorTrackingSetting' do - expect(error_tracking_setting).to receive(:sentry_external_url).and_call_original + describe '#external_url' do + it 'calls the project setting sentry_external_url' do + expect(error_tracking_setting).to receive(:sentry_external_url).and_return(sentry_url) - subject.external_url + expect(subject.external_url).to eql sentry_url end end end diff --git a/spec/services/error_tracking/list_projects_service_spec.rb b/spec/services/error_tracking/list_projects_service_spec.rb index a272a604184e14de998f6814cdb3be2753c54dfe..cd4b835e0974aa56a8fc277f01e499d82e3d381d 100644 --- a/spec/services/error_tracking/list_projects_service_spec.rb +++ b/spec/services/error_tracking/list_projects_service_spec.rb @@ -127,7 +127,7 @@ describe ErrorTracking::ListProjectsService do end it 'returns error' do - expect(result).to include(status: :error, message: 'access denied') + expect(result).to include(status: :error, message: 'Access denied', http_status: :unauthorized) end end diff --git a/spec/services/git/branch_hooks_service_spec.rb b/spec/services/git/branch_hooks_service_spec.rb index 085b49f31ab5415187324fea4c7ce83a7b10e9e5..b1c64bc3c0ad441566b52ce11d1547b896e22efe 100644 --- a/spec/services/git/branch_hooks_service_spec.rb +++ b/spec/services/git/branch_hooks_service_spec.rb @@ -345,7 +345,7 @@ describe Git::BranchHooksService do end end - context 'when the project is forked' do + context 'when the project is forked', :sidekiq_might_not_need_inline do let(:upstream_project) { project } let(:forked_project) { fork_project(upstream_project, user, repository: true) } diff --git a/spec/services/git/branch_push_service_spec.rb b/spec/services/git/branch_push_service_spec.rb index bf68eb0af20a1691da26b35be0f20676d82e89df..febd49926824b8678b394b200e82e8dc00a03c0e 100644 --- a/spec/services/git/branch_push_service_spec.rb +++ b/spec/services/git/branch_push_service_spec.rb @@ -246,7 +246,7 @@ describe Git::BranchPushService, services: true do allow(project.repository).to receive(:commits_between).and_return([commit]) end - it "creates a note if a pushed commit mentions an issue" do + it "creates a note if a pushed commit mentions an issue", :sidekiq_might_not_need_inline do expect(SystemNoteService).to receive(:cross_reference).with(issue, commit, commit_author) execute_service(project, user, oldrev: oldrev, newrev: newrev, ref: ref) @@ -260,7 +260,7 @@ describe Git::BranchPushService, services: true do execute_service(project, user, oldrev: oldrev, newrev: newrev, ref: ref) end - it "defaults to the pushing user if the commit's author is not known" do + it "defaults to the pushing user if the commit's author is not known", :sidekiq_might_not_need_inline do allow(commit).to receive_messages( author_name: 'unknown name', author_email: 'unknown@email.com' @@ -270,7 +270,7 @@ describe Git::BranchPushService, services: true do execute_service(project, user, oldrev: oldrev, newrev: newrev, ref: ref) end - it "finds references in the first push to a non-default branch" do + it "finds references in the first push to a non-default branch", :sidekiq_might_not_need_inline do allow(project.repository).to receive(:commits_between).with(blankrev, newrev).and_return([]) allow(project.repository).to receive(:commits_between).with("master", newrev).and_return([commit]) @@ -305,7 +305,7 @@ describe Git::BranchPushService, services: true do end context "while saving the 'first_mentioned_in_commit_at' metric for an issue" do - it 'sets the metric for referenced issues' do + it 'sets the metric for referenced issues', :sidekiq_might_not_need_inline do execute_service(project, user, oldrev: oldrev, newrev: newrev, ref: ref) expect(issue.reload.metrics.first_mentioned_in_commit_at).to be_like_time(commit_time) @@ -344,12 +344,12 @@ describe Git::BranchPushService, services: true do end context "to default branches" do - it "closes issues" do + it "closes issues", :sidekiq_might_not_need_inline do execute_service(project, commit_author, oldrev: oldrev, newrev: newrev, ref: ref) expect(Issue.find(issue.id)).to be_closed end - it "adds a note indicating that the issue is now closed" do + it "adds a note indicating that the issue is now closed", :sidekiq_might_not_need_inline do expect(SystemNoteService).to receive(:change_status).with(issue, project, commit_author, "closed", closing_commit) execute_service(project, commit_author, oldrev: oldrev, newrev: newrev, ref: ref) end @@ -366,7 +366,7 @@ describe Git::BranchPushService, services: true do allow(project).to receive(:default_branch).and_return('not-master') end - it "creates cross-reference notes" do + it "creates cross-reference notes", :sidekiq_might_not_need_inline do expect(SystemNoteService).to receive(:cross_reference).with(issue, closing_commit, commit_author) execute_service(project, user, oldrev: oldrev, newrev: newrev, ref: ref) end @@ -407,7 +407,7 @@ describe Git::BranchPushService, services: true do context "mentioning an issue" do let(:message) { "this is some work.\n\nrelated to JIRA-1" } - it "initiates one api call to jira server to mention the issue" do + it "initiates one api call to jira server to mention the issue", :sidekiq_might_not_need_inline do execute_service(project, user, oldrev: oldrev, newrev: newrev, ref: ref) expect(WebMock).to have_requested(:post, jira_api_comment_url('JIRA-1')).with( @@ -434,7 +434,7 @@ describe Git::BranchPushService, services: true do allow_any_instance_of(JIRA::Resource::Issue).to receive(:key).and_return("JIRA-1") end - context "using right markdown" do + context "using right markdown", :sidekiq_might_not_need_inline do it "initiates one api call to jira server to close the issue" do execute_service(project, commit_author, oldrev: oldrev, newrev: newrev, ref: ref) @@ -473,7 +473,7 @@ describe Git::BranchPushService, services: true do end end - context 'when internal issues are enabled' do + context 'when internal issues are enabled', :sidekiq_might_not_need_inline do let(:issue) { create(:issue, project: project) } let(:message) { "this is some work.\n\ncloses JIRA-1 \n\n closes #{issue.to_reference}" } diff --git a/spec/services/groups/destroy_service_spec.rb b/spec/services/groups/destroy_service_spec.rb index d13739cefd9117753ebd794205471f6c595978d3..055d0243d4babc468493f728fbd222bac112bafa 100644 --- a/spec/services/groups/destroy_service_spec.rb +++ b/spec/services/groups/destroy_service_spec.rb @@ -26,7 +26,7 @@ describe Groups::DestroyService do end shared_examples 'group destruction' do |async| - context 'database records' do + context 'database records', :sidekiq_might_not_need_inline do before do destroy_group(group, user, async) end @@ -37,7 +37,7 @@ describe Groups::DestroyService do it { expect(NotificationSetting.unscoped.all).not_to include(notification_setting) } end - context 'mattermost team' do + context 'mattermost team', :sidekiq_might_not_need_inline do let!(:chat_team) { create(:chat_team, namespace: group) } it 'destroys the team too' do @@ -47,7 +47,7 @@ describe Groups::DestroyService do end end - context 'file system' do + context 'file system', :sidekiq_might_not_need_inline do context 'Sidekiq inline' do before do # Run sidekiq immediately to check that renamed dir will be removed @@ -55,8 +55,8 @@ describe Groups::DestroyService do end it 'verifies that paths have been deleted' do - expect(gitlab_shell.exists?(project.repository_storage, group.path)).to be_falsey - expect(gitlab_shell.exists?(project.repository_storage, remove_path)).to be_falsey + expect(TestEnv.storage_dir_exists?(project.repository_storage, group.path)).to be_falsey + expect(TestEnv.storage_dir_exists?(project.repository_storage, remove_path)).to be_falsey end end end @@ -73,13 +73,13 @@ describe Groups::DestroyService do after do # Clean up stale directories - gitlab_shell.rm_namespace(project.repository_storage, group.path) - gitlab_shell.rm_namespace(project.repository_storage, remove_path) + TestEnv.rm_storage_dir(project.repository_storage, group.path) + TestEnv.rm_storage_dir(project.repository_storage, remove_path) end it 'verifies original paths and projects still exist' do - expect(gitlab_shell.exists?(project.repository_storage, group.path)).to be_truthy - expect(gitlab_shell.exists?(project.repository_storage, remove_path)).to be_falsey + expect(TestEnv.storage_dir_exists?(project.repository_storage, group.path)).to be_truthy + expect(TestEnv.storage_dir_exists?(project.repository_storage, remove_path)).to be_falsey expect(Project.unscoped.count).to eq(1) expect(Group.unscoped.count).to eq(2) end diff --git a/spec/services/groups/group_links/create_service_spec.rb b/spec/services/groups/group_links/create_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..36faa69577eb5b36ba27c1bcc7fec30e68d001da --- /dev/null +++ b/spec/services/groups/group_links/create_service_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Groups::GroupLinks::CreateService, '#execute' do + let(:parent_group_user) { create(:user) } + let(:group_user) { create(:user) } + let(:child_group_user) { create(:user) } + + let_it_be(:group_parent) { create(:group, :private) } + let_it_be(:group) { create(:group, :private, parent: group_parent) } + let_it_be(:group_child) { create(:group, :private, parent: group) } + + let_it_be(:shared_group_parent) { create(:group, :private) } + let_it_be(:shared_group) { create(:group, :private, parent: shared_group_parent) } + let_it_be(:shared_group_child) { create(:group, :private, parent: shared_group) } + + let_it_be(:project_parent) { create(:project, group: shared_group_parent) } + let_it_be(:project) { create(:project, group: shared_group) } + let_it_be(:project_child) { create(:project, group: shared_group_child) } + + let(:opts) do + { + shared_group_access: Gitlab::Access::DEVELOPER, + expires_at: nil + } + end + let(:user) { group_user } + + subject { described_class.new(group, user, opts) } + + before do + group.add_guest(group_user) + shared_group.add_owner(group_user) + end + + it 'adds group to another group' do + expect { subject.execute(shared_group) }.to change { group.shared_group_links.count }.from(0).to(1) + end + + it 'returns false if shared group is blank' do + expect { subject.execute(nil) }.not_to change { group.shared_group_links.count } + end + + context 'user does not have access to group' do + let(:user) { create(:user) } + + before do + shared_group.add_owner(user) + end + + it 'returns error' do + result = subject.execute(shared_group) + + expect(result[:status]).to eq(:error) + expect(result[:http_status]).to eq(404) + end + end + + context 'user does not have admin access to shared group' do + let(:user) { create(:user) } + + before do + group.add_guest(user) + shared_group.add_developer(user) + end + + it 'returns error' do + result = subject.execute(shared_group) + + expect(result[:status]).to eq(:error) + expect(result[:http_status]).to eq(404) + end + end + + context 'group hierarchies' do + before do + group_parent.add_owner(parent_group_user) + group.add_owner(group_user) + group_child.add_owner(child_group_user) + end + + context 'group user' do + let(:user) { group_user } + + it 'create proper authorizations' do + subject.execute(shared_group) + + expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey + expect(Ability.allowed?(user, :read_project, project)).to be_truthy + expect(Ability.allowed?(user, :read_project, project_child)).to be_truthy + end + end + + context 'parent group user' do + let(:user) { parent_group_user } + + it 'create proper authorizations' do + subject.execute(shared_group) + + expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey + expect(Ability.allowed?(user, :read_project, project)).to be_falsey + expect(Ability.allowed?(user, :read_project, project_child)).to be_falsey + end + end + + context 'child group user' do + let(:user) { child_group_user } + + it 'create proper authorizations' do + subject.execute(shared_group) + + expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey + expect(Ability.allowed?(user, :read_project, project)).to be_falsey + expect(Ability.allowed?(user, :read_project, project_child)).to be_falsey + end + end + end +end diff --git a/spec/services/groups/group_links/destroy_service_spec.rb b/spec/services/groups/group_links/destroy_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6f49b6eda94b16bb4e70e338c928ec09a8eab4aa --- /dev/null +++ b/spec/services/groups/group_links/destroy_service_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Groups::GroupLinks::DestroyService, '#execute' do + let(:user) { create(:user) } + + let_it_be(:group) { create(:group, :private) } + let_it_be(:shared_group) { create(:group, :private) } + let_it_be(:project) { create(:project, group: shared_group) } + + subject { described_class.new(nil, nil) } + + context 'single link' do + let!(:link) { create(:group_group_link, shared_group: shared_group, shared_with_group: group) } + + it 'destroys link' do + expect { subject.execute(link) }.to change { GroupGroupLink.count }.from(1).to(0) + end + + it 'revokes project authorization' do + group.add_developer(user) + + expect { subject.execute(link) }.to( + change { Ability.allowed?(user, :read_project, project) }.from(true).to(false)) + end + end + + context 'multiple links' do + let_it_be(:another_group) { create(:group, :private) } + let_it_be(:another_shared_group) { create(:group, :private) } + + let!(:links) do + [ + create(:group_group_link, shared_group: shared_group, shared_with_group: group), + create(:group_group_link, shared_group: shared_group, shared_with_group: another_group), + create(:group_group_link, shared_group: another_shared_group, shared_with_group: group), + create(:group_group_link, shared_group: another_shared_group, shared_with_group: another_group) + ] + end + + it 'updates project authorization once per group' do + expect(GroupGroupLink).to receive(:delete) + expect(group).to receive(:refresh_members_authorized_projects).once + expect(another_group).to receive(:refresh_members_authorized_projects).once + + subject.execute(links) + end + + it 'rolls back changes when error happens' do + group.add_developer(user) + + expect(group).to receive(:refresh_members_authorized_projects).once.and_call_original + expect(another_group).to( + receive(:refresh_members_authorized_projects).and_raise('boom')) + + expect { subject.execute(links) }.to raise_error('boom') + + expect(GroupGroupLink.count).to eq(links.length) + expect(Ability.allowed?(user, :read_project, project)).to be_truthy + end + end +end diff --git a/spec/services/groups/import_export/export_service_spec.rb b/spec/services/groups/import_export/export_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2024e1ed4575aa8ddfd0c9471ebdb4a51b3a685a --- /dev/null +++ b/spec/services/groups/import_export/export_service_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Groups::ImportExport::ExportService do + describe '#execute' do + let!(:user) { create(:user) } + let(:group) { create(:group) } + let(:shared) { Gitlab::ImportExport::Shared.new(group) } + let(:export_path) { shared.export_path } + let(:service) { described_class.new(group: group, user: user, params: { shared: shared }) } + + after do + FileUtils.rm_rf(export_path) + end + + it 'saves the models' do + expect(Gitlab::ImportExport::GroupTreeSaver).to receive(:new).and_call_original + + service.execute + end + + context 'when saver succeeds' do + it 'saves the group in the file system' do + service.execute + + expect(group.import_export_upload.export_file.file).not_to be_nil + expect(File.directory?(export_path)).to eq(false) + expect(File.exist?(shared.archive_path)).to eq(false) + end + end + + context 'when saving services fail' do + before do + allow(service).to receive_message_chain(:tree_exporter, :save).and_return(false) + end + + it 'removes the remaining exported data' do + allow_any_instance_of(Gitlab::ImportExport::Saver).to receive(:compress_and_save).and_return(false) + + expect { service.execute }.to raise_error(Gitlab::ImportExport::Error) + + expect(group.import_export_upload).to be_nil + expect(File.directory?(export_path)).to eq(false) + expect(File.exist?(shared.archive_path)).to eq(false) + end + + it 'notifies logger' do + expect_any_instance_of(Gitlab::Import::Logger).to receive(:error) + + expect { service.execute }.to raise_error(Gitlab::ImportExport::Error) + end + end + end +end diff --git a/spec/services/groups/transfer_service_spec.rb b/spec/services/groups/transfer_service_spec.rb index 5ef1fb1932fdb9ca7f8567cf84ab31047c5ce22f..9a490dfd7795ec65d32053ae3c84e270421d2963 100644 --- a/spec/services/groups/transfer_service_spec.rb +++ b/spec/services/groups/transfer_service_spec.rb @@ -427,20 +427,34 @@ describe Groups::TransferService do end end - context 'when a project in group has container images' do + context 'when a project has container images' do let(:group) { create(:group, :public, :nested) } - let!(:project) { create(:project, :repository, :public, namespace: group) } + let!(:container_repository) { create(:container_repository, project: project) } + + subject { transfer_service.execute(new_parent_group) } before do - stub_container_registry_tags(repository: /image/, tags: %w[rc1]) - create(:container_repository, project: project, name: :image) - create(:group_member, :owner, group: new_parent_group, user: user) + group.add_owner(user) + new_parent_group.add_owner(user) end - it 'does not allow group to be transferred' do - transfer_service.execute(new_parent_group) + context 'within group' do + let(:project) { create(:project, :repository, :public, namespace: group) } + + it 'does not transfer' do + expect(subject).to be false + expect(transfer_service.error).to match(/Docker images in their Container Registry/) + end + end - expect(transfer_service.error).to match(/Docker images in their Container Registry/) + context 'within subgroup' do + let(:subgroup) { create(:group, parent: group) } + let(:project) { create(:project, :repository, :public, namespace: subgroup) } + + it 'does not transfer' do + expect(subject).to be false + expect(transfer_service.error).to match(/Docker images in their Container Registry/) + end end end end diff --git a/spec/services/groups/update_service_spec.rb b/spec/services/groups/update_service_spec.rb index ca8eaf4c9702704cffde1217101a1f2d9ac25bc5..1aa7e06182b430ef33de43e193e93086c222f92c 100644 --- a/spec/services/groups/update_service_spec.rb +++ b/spec/services/groups/update_service_spec.rb @@ -32,6 +32,43 @@ describe Groups::UpdateService do expect(service.execute).to be_falsey end + + context 'when a project has container images' do + let(:params) { { path: SecureRandom.hex } } + let!(:container_repository) { create(:container_repository, project: project) } + + subject { described_class.new(public_group, user, params).execute } + + context 'within group' do + let(:project) { create(:project, group: public_group) } + + context 'with path updates' do + it 'does not allow the update' do + expect(subject).to be false + expect(public_group.errors[:base].first).to match(/Docker images in their Container Registry/) + end + end + + context 'with name updates' do + let(:params) { { name: 'new-name' } } + + it 'allows the update' do + expect(subject).to be true + expect(public_group.reload.name).to eq('new-name') + end + end + end + + context 'within subgroup' do + let(:subgroup) { create(:group, parent: public_group) } + let(:project) { create(:project, group: subgroup) } + + it 'does not allow path updates' do + expect(subject).to be false + expect(public_group.errors[:base].first).to match(/Docker images in their Container Registry/) + end + end + end end context "internal group with internal project" do @@ -148,30 +185,6 @@ describe Groups::UpdateService do end end - context 'projects in group have container images' do - let(:service) { described_class.new(public_group, user, path: SecureRandom.hex) } - let(:project) { create(:project, :internal, group: public_group) } - - before do - stub_container_registry_tags(repository: /image/, tags: %w[rc1]) - create(:container_repository, project: project, name: :image) - end - - it 'does not allow path to be changed' do - result = described_class.new(public_group, user, path: 'new-path').execute - - expect(result).to eq false - expect(public_group.errors[:base].first).to match(/Docker images in their Container Registry/) - end - - it 'allows other settings to be changed' do - result = described_class.new(public_group, user, name: 'new-name').execute - - expect(result).to eq true - expect(public_group.reload.name).to eq('new-name') - end - end - context 'for a subgroup' do let(:subgroup) { create(:group, :private, parent: private_group) } diff --git a/spec/services/import_export_clean_up_service_spec.rb b/spec/services/import_export_clean_up_service_spec.rb index 51720e786dcdc8b1ef87bb77060451287d15a11a..9f811f56f50edde88e3ba4400be1f90dca5d9f6f 100644 --- a/spec/services/import_export_clean_up_service_spec.rb +++ b/spec/services/import_export_clean_up_service_spec.rb @@ -6,7 +6,7 @@ describe ImportExportCleanUpService do describe '#execute' do let(:service) { described_class.new } - let(:tmp_import_export_folder) { 'tmp/project_exports' } + let(:tmp_import_export_folder) { 'tmp/gitlab_exports' } context 'when the import/export directory does not exist' do it 'does not remove any archives' do diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb index 1f7d564b6ec72415bb9ce291986088a9ecf8e0aa..dce62d1d20e3f28841af224c3c6526d4ca0668e1 100644 --- a/spec/services/issues/close_service_spec.rb +++ b/spec/services/issues/close_service_spec.rb @@ -70,7 +70,7 @@ describe Issues::CloseService do end describe '#close_issue' do - context "closed by a merge request" do + context "closed by a merge request", :sidekiq_might_not_need_inline do it 'mentions closure via a merge request' do perform_enqueued_jobs do described_class.new(project, user).close_issue(issue, closed_via: closing_merge_request) @@ -100,7 +100,7 @@ describe Issues::CloseService do end end - context "closed by a commit" do + context "closed by a commit", :sidekiq_might_not_need_inline do it 'mentions closure via a commit' do perform_enqueued_jobs do described_class.new(project, user).close_issue(issue, closed_via: closing_commit) @@ -146,7 +146,7 @@ describe Issues::CloseService do expect(issue.closed_by_id).to be(user.id) end - it 'sends email to user2 about assign of new issue' do + it 'sends email to user2 about assign of new issue', :sidekiq_might_not_need_inline do email = ActionMailer::Base.deliveries.last expect(email.to.first).to eq(user2.email) expect(email.subject).to include(issue.title) diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index 154bfec0da291137f267dc7d43665d886b19d248..604befd7225bddc0d748fb5e8ba88fd3c2012028 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -169,7 +169,7 @@ describe Issues::UpdateService, :mailer do end end - context 'with background jobs processed' do + context 'with background jobs processed', :sidekiq_might_not_need_inline do before do perform_enqueued_jobs do update_issue(opts) @@ -187,7 +187,6 @@ describe Issues::UpdateService, :mailer do it 'creates system note about issue reassign' do note = find_note('assigned to') - expect(note).not_to be_nil expect(note.note).to include "assigned to #{user2.to_reference}" end @@ -202,14 +201,12 @@ describe Issues::UpdateService, :mailer do it 'creates system note about title change' do note = find_note('changed title') - expect(note).not_to be_nil expect(note.note).to eq 'changed title from **{-Old-} title** to **{+New+} title**' end it 'creates system note about discussion lock' do note = find_note('locked this issue') - expect(note).not_to be_nil expect(note.note).to eq 'locked this issue' end end @@ -221,20 +218,10 @@ describe Issues::UpdateService, :mailer do note = find_note('changed the description') - expect(note).not_to be_nil expect(note.note).to eq('changed the description') end end - it 'creates zoom_link_added system note when a zoom link is added to the description' do - update_issue(description: 'Changed description https://zoom.us/j/5873603787') - - note = find_note('added a Zoom call') - - expect(note).not_to be_nil - expect(note.note).to eq('added a Zoom call to this issue') - end - context 'when issue turns confidential' do let(:opts) do { @@ -252,7 +239,6 @@ describe Issues::UpdateService, :mailer do note = find_note('made the issue confidential') - expect(note).not_to be_nil expect(note.note).to eq 'made the issue confidential' end @@ -366,7 +352,7 @@ describe Issues::UpdateService, :mailer do it_behaves_like 'system notes for milestones' - it 'sends notifications for subscribers of changed milestone' do + it 'sends notifications for subscribers of changed milestone', :sidekiq_might_not_need_inline do issue.milestone = create(:milestone, project: project) issue.save @@ -398,7 +384,7 @@ describe Issues::UpdateService, :mailer do it_behaves_like 'system notes for milestones' - it 'sends notifications for subscribers of changed milestone' do + it 'sends notifications for subscribers of changed milestone', :sidekiq_might_not_need_inline do perform_enqueued_jobs do update_issue(milestone: create(:milestone, project: project)) end @@ -435,7 +421,7 @@ describe Issues::UpdateService, :mailer do end end - it 'sends notifications for subscribers of newly added labels' do + it 'sends notifications for subscribers of newly added labels', :sidekiq_might_not_need_inline do opts = { label_ids: [label.id] } perform_enqueued_jobs do @@ -620,6 +606,24 @@ describe Issues::UpdateService, :mailer do end end + context 'when same id is passed as add_label_ids and remove_label_ids' do + let(:params) { { add_label_ids: [label.id], remove_label_ids: [label.id] } } + + context 'for a label assigned to an issue' do + it 'removes the label' do + issue.update(labels: [label]) + + expect(result.label_ids).to be_empty + end + end + + context 'for a label not assigned to an issue' do + it 'does not add the label' do + expect(result.label_ids).to be_empty + end + end + end + context 'when duplicate label titles are given' do let(:params) do { labels: [label3.title, label3.title] } diff --git a/spec/services/issues/zoom_link_service_spec.rb b/spec/services/issues/zoom_link_service_spec.rb index ba3f007c9177535025112bcec589a877a8f5c8fd..ecca94679653ffee1d9a3a0fa1aa52a8898c1bbd 100644 --- a/spec/services/issues/zoom_link_service_spec.rb +++ b/spec/services/issues/zoom_link_service_spec.rb @@ -14,27 +14,16 @@ describe Issues::ZoomLinkService do project.add_reporter(user) end - shared_context 'with Zoom link' do + shared_context '"added" Zoom meeting' do before do - issue.update!(description: "Description\n\n#{zoom_link}") + create(:zoom_meeting, issue: issue) end end - shared_context 'with Zoom link not at the end' do + shared_context '"removed" zoom meetings' do before do - issue.update!(description: "Description with #{zoom_link} some where") - end - end - - shared_context 'without Zoom link' do - before do - issue.update!(description: "Description\n\nhttp://example.com") - end - end - - shared_context 'without issue description' do - before do - issue.update!(description: nil) + create(:zoom_meeting, issue: issue, issue_status: :removed) + create(:zoom_meeting, issue: issue, issue_status: :removed) end end @@ -45,11 +34,10 @@ describe Issues::ZoomLinkService do end describe '#add_link' do - shared_examples 'can add link' do - it 'appends the link to issue description' do + shared_examples 'can add meeting' do + it 'appends the new meeting to zoom_meetings' do expect(result).to be_success - expect(result.payload[:description]) - .to eq("#{issue.description}\n\n#{zoom_link}") + expect(ZoomMeeting.canonical_meeting_url(issue)).to eq(zoom_link) end it 'tracks the add event' do @@ -57,55 +45,63 @@ describe Issues::ZoomLinkService do .with('IncidentManagement::ZoomIntegration', 'add_zoom_meeting', label: 'Issue ID', value: issue.id) result end + + it 'creates a zoom_link_added notification' do + expect(SystemNoteService).to receive(:zoom_link_added).with(issue, project, user) + expect(SystemNoteService).not_to receive(:zoom_link_removed) + result + end end - shared_examples 'cannot add link' do - it 'cannot add the link' do + shared_examples 'cannot add meeting' do + it 'cannot add the meeting' do expect(result).to be_error expect(result.message).to eq('Failed to add a Zoom meeting') end + + it 'creates no notification' do + expect(SystemNoteService).not_to receive(:zoom_link_added) + expect(SystemNoteService).not_to receive(:zoom_link_removed) + result + end end subject(:result) { service.add_link(zoom_link) } - context 'without Zoom link in the issue description' do - include_context 'without Zoom link' - include_examples 'can add link' + context 'without existing Zoom meeting' do + include_examples 'can add meeting' - context 'with invalid Zoom link' do + context 'with invalid Zoom url' do let(:zoom_link) { 'https://not-zoom.link' } - include_examples 'cannot add link' + include_examples 'cannot add meeting' end context 'with insufficient permissions' do include_context 'insufficient permissions' - include_examples 'cannot add link' + include_examples 'cannot add meeting' end end - context 'with Zoom link in the issue description' do - include_context 'with Zoom link' - include_examples 'cannot add link' + context 'with "added" Zoom meeting' do + include_context '"added" Zoom meeting' + include_examples 'cannot add meeting' + end - context 'but not at the end' do - include_context 'with Zoom link not at the end' - include_examples 'can add link' + context 'with "added" Zoom meeting and race condition' do + include_context '"added" Zoom meeting' + before do + allow(service).to receive(:can_add_link?).and_return(true) end - end - context 'without issue description' do - include_context 'without issue description' - include_examples 'can add link' + include_examples 'cannot add meeting' end end describe '#can_add_link?' do subject { service.can_add_link? } - context 'without Zoom link in the issue description' do - include_context 'without Zoom link' - + context 'without "added" zoom meeting' do it { is_expected.to eq(true) } context 'with insufficient permissions' do @@ -115,81 +111,93 @@ describe Issues::ZoomLinkService do end end - context 'with Zoom link in the issue description' do - include_context 'with Zoom link' + context 'with Zoom meeting in the issue description' do + include_context '"added" Zoom meeting' it { is_expected.to eq(false) } end end describe '#remove_link' do - shared_examples 'cannot remove link' do - it 'cannot remove the link' do + shared_examples 'cannot remove meeting' do + it 'cannot remove the meeting' do expect(result).to be_error expect(result.message).to eq('Failed to remove a Zoom meeting') end - end - subject(:result) { service.remove_link } + it 'creates no notification' do + expect(SystemNoteService).not_to receive(:zoom_link_added) + expect(SystemNoteService).not_to receive(:zoom_link_removed) + result + end + end - context 'with Zoom link in the issue description' do - include_context 'with Zoom link' + shared_examples 'can remove meeting' do + it 'creates no notification' do + expect(SystemNoteService).not_to receive(:zoom_link_added).with(issue, project, user) + expect(SystemNoteService).to receive(:zoom_link_removed) + result + end - it 'removes the link from the issue description' do + it 'can remove the meeting' do expect(result).to be_success - expect(result.payload[:description]) - .to eq(issue.description.delete_suffix("\n\n#{zoom_link}")) + expect(ZoomMeeting.canonical_meeting_url(issue)).to eq(nil) end it 'tracks the remove event' do expect(Gitlab::Tracking).to receive(:event) - .with('IncidentManagement::ZoomIntegration', 'remove_zoom_meeting', label: 'Issue ID', value: issue.id) - + .with('IncidentManagement::ZoomIntegration', 'remove_zoom_meeting', label: 'Issue ID', value: issue.id) result end + end - context 'with insufficient permissions' do - include_context 'insufficient permissions' - include_examples 'cannot remove link' - end + subject(:result) { service.remove_link } - context 'but not at the end' do - include_context 'with Zoom link not at the end' - include_examples 'cannot remove link' + context 'with Zoom meeting' do + include_context '"added" Zoom meeting' + + context 'removes the link' do + include_examples 'can remove meeting' end - end - context 'without Zoom link in the issue description' do - include_context 'without Zoom link' - include_examples 'cannot remove link' + context 'with insufficient permissions' do + include_context 'insufficient permissions' + include_examples 'cannot remove meeting' + end end - context 'without issue description' do - include_context 'without issue description' - include_examples 'cannot remove link' + context 'without "added" Zoom meeting' do + include_context '"removed" zoom meetings' + include_examples 'cannot remove meeting' end end describe '#can_remove_link?' do subject { service.can_remove_link? } - context 'with Zoom link in the issue description' do - include_context 'with Zoom link' + context 'without Zoom meeting' do + it { is_expected.to eq(false) } + end + + context 'with only "removed" zoom meetings' do + include_context '"removed" zoom meetings' + it { is_expected.to eq(false) } + end + context 'with "added" Zoom meeting' do + include_context '"added" Zoom meeting' it { is_expected.to eq(true) } + context 'with "removed" zoom meetings' do + include_context '"removed" zoom meetings' + it { is_expected.to eq(true) } + end + context 'with insufficient permissions' do include_context 'insufficient permissions' - it { is_expected.to eq(false) } end end - - context 'without Zoom link in the issue description' do - include_context 'without Zoom link' - - it { is_expected.to eq(false) } - end end describe '#parse_link' do diff --git a/spec/services/members/destroy_service_spec.rb b/spec/services/members/destroy_service_spec.rb index effcaf535350deea95f595e620b969e7b38fba2f..73ac0bd7716926528207aa8817f92ac22667e30e 100644 --- a/spec/services/members/destroy_service_spec.rb +++ b/spec/services/members/destroy_service_spec.rb @@ -45,7 +45,7 @@ describe Members::DestroyService do shared_examples 'a service destroying a member with access' do it_behaves_like 'a service destroying a member' - it 'invalidates cached counts for assigned issues and merge requests', :aggregate_failures do + it 'invalidates cached counts for assigned issues and merge requests', :aggregate_failures, :sidekiq_might_not_need_inline do create(:issue, project: group_project, assignees: [member_user]) create(:merge_request, source_project: group_project, assignees: [member_user]) create(:todo, :pending, project: group_project, user: member_user) diff --git a/spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb b/spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb index f26b67f902d4813d770a9ce304464bf1e2ae6219..203048984a1f9baef484b5df4e8cab1488979a1d 100644 --- a/spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb +++ b/spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb @@ -10,9 +10,7 @@ describe MergeRequests::AddTodoWhenBuildFailsService do let(:ref) { merge_request.source_branch } let(:pipeline) do - create(:ci_pipeline_with_one_job, ref: ref, - project: project, - sha: sha) + create(:ci_pipeline, ref: ref, project: project, sha: sha) end let(:service) do diff --git a/spec/services/merge_requests/build_service_spec.rb b/spec/services/merge_requests/build_service_spec.rb index 68e53553043ee4b45f06d0d0f89b1647b446fcd9..9b358839c0647059d70c9ea03d8c87b521e22f3a 100644 --- a/spec/services/merge_requests/build_service_spec.rb +++ b/spec/services/merge_requests/build_service_spec.rb @@ -80,7 +80,7 @@ describe MergeRequests::BuildService do end it 'does not assign force_remove_source_branch' do - expect(merge_request.force_remove_source_branch?).to be_falsey + expect(merge_request.force_remove_source_branch?).to be_truthy end context 'with force_remove_source_branch parameter when the user is authorized' do @@ -91,6 +91,36 @@ describe MergeRequests::BuildService do it 'assigns force_remove_source_branch' do expect(merge_request.force_remove_source_branch?).to be_truthy end + + context 'with project setting remove_source_branch_after_merge false' do + before do + project.remove_source_branch_after_merge = false + end + + it 'assigns force_remove_source_branch' do + expect(merge_request.force_remove_source_branch?).to be_truthy + end + end + end + + context 'with project setting remove_source_branch_after_merge true' do + before do + project.remove_source_branch_after_merge = true + end + + it 'assigns force_remove_source_branch' do + expect(merge_request.force_remove_source_branch?).to be_truthy + end + + context 'with force_remove_source_branch parameter false' do + before do + params[:force_remove_source_branch] = '0' + end + + it 'does not assign force_remove_source_branch' do + expect(merge_request.force_remove_source_branch?).to be(false) + end + end end context 'missing source branch' do @@ -131,7 +161,7 @@ describe MergeRequests::BuildService do let!(:project) { fork_project(target_project, user, namespace: user.namespace, repository: true) } let(:source_project) { project } - it 'creates compare object with target branch as default branch' do + it 'creates compare object with target branch as default branch', :sidekiq_might_not_need_inline do expect(merge_request.compare).to be_present expect(merge_request.target_branch).to eq(project.default_branch) end diff --git a/spec/services/merge_requests/close_service_spec.rb b/spec/services/merge_requests/close_service_spec.rb index 29b7e0f17e2a81e44f4b012b27fa1ef8b04c7c3d..b037b73752e6cdc3fb8edd89aad3d231d535dadd 100644 --- a/spec/services/merge_requests/close_service_spec.rb +++ b/spec/services/merge_requests/close_service_spec.rb @@ -38,7 +38,7 @@ describe MergeRequests::CloseService do .with(@merge_request, 'close') end - it 'sends email to user2 about assign of new merge_request' do + it 'sends email to user2 about assign of new merge_request', :sidekiq_might_not_need_inline do email = ActionMailer::Base.deliveries.last expect(email.to.first).to eq(user2.email) expect(email.subject).to include(merge_request.title) 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 51a5c51f6c3fa1981f64b6b1754dc16d21034908..7145cfe7897b1110cb5b3c4dafa500195eece596 100644 --- a/spec/services/merge_requests/create_from_issue_service_spec.rb +++ b/spec/services/merge_requests/create_from_issue_service_spec.rb @@ -36,25 +36,25 @@ describe MergeRequests::CreateFromIssueService do expect(result[:message]).to eq('Invalid issue iid') end - it 'creates a branch based on issue title' do + it 'creates a branch based on issue title', :sidekiq_might_not_need_inline do service.execute expect(target_project.repository.branch_exists?(issue.to_branch_name)).to be_truthy end - it 'creates a branch using passed name' do + it 'creates a branch using passed name', :sidekiq_might_not_need_inline do service_with_custom_source_branch.execute expect(target_project.repository.branch_exists?(custom_source_branch)).to be_truthy end - it 'creates the new_merge_request system note' do + it 'creates the new_merge_request system note', :sidekiq_might_not_need_inline do expect(SystemNoteService).to receive(:new_merge_request).with(issue, project, user, instance_of(MergeRequest)) service.execute end - it 'creates the new_issue_branch system note when the branch could be created but the merge_request cannot be created' do + 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(SystemNoteService).to receive(:new_issue_branch).with(issue, project, user, issue.to_branch_name, branch_project: target_project) @@ -62,35 +62,35 @@ describe MergeRequests::CreateFromIssueService do service.execute end - it 'creates a merge request' do + it 'creates a merge request', :sidekiq_might_not_need_inline do expect { service.execute }.to change(target_project.merge_requests, :count).by(1) end - it 'sets the merge request author to current user' do + it 'sets the merge request author to current user', :sidekiq_might_not_need_inline do result = service.execute expect(result[:merge_request].author).to eq(user) end - it 'sets the merge request source branch to the new issue branch' do + it 'sets the merge request source branch to the new issue branch', :sidekiq_might_not_need_inline do result = service.execute expect(result[:merge_request].source_branch).to eq(issue.to_branch_name) end - it 'sets the merge request source branch to the passed branch name' do + it 'sets the merge request source branch to the passed branch name', :sidekiq_might_not_need_inline do result = service_with_custom_source_branch.execute expect(result[:merge_request].source_branch).to eq(custom_source_branch) end - it 'sets the merge request target branch to the project default branch' do + it 'sets the merge request target branch to the project default branch', :sidekiq_might_not_need_inline do result = service.execute expect(result[:merge_request].target_branch).to eq(target_project.default_branch) end - it 'executes quick actions if the build service sets them in the description' do + it 'executes quick actions if the build service sets them in the description', :sidekiq_might_not_need_inline do allow(service).to receive(:merge_request).and_wrap_original do |m, *args| m.call(*args).tap do |merge_request| merge_request.description = "/assign #{user.to_reference}" @@ -102,7 +102,7 @@ describe MergeRequests::CreateFromIssueService do expect(result[:merge_request].assignees).to eq([user]) end - context 'when ref branch is set' do + context 'when ref branch is set', :sidekiq_might_not_need_inline do subject { described_class.new(project, user, ref: 'feature', **service_params).execute } it 'sets the merge request source branch to the new issue branch' do @@ -193,7 +193,7 @@ describe MergeRequests::CreateFromIssueService do it_behaves_like 'a service that creates a merge request from an issue' - it 'sets the merge request title to: "WIP: $issue-branch-name' do + it 'sets the merge request title to: "WIP: $issue-branch-name', :sidekiq_might_not_need_inline do result = service.execute expect(result[:merge_request].title).to eq("WIP: #{issue.to_branch_name.titleize.humanize}") diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb index 699f2a98088ef7534d4ab959e0287a322621d423..3db1471bf3c3e18f1f987c2e3a364750839f0dc8 100644 --- a/spec/services/merge_requests/create_service_spec.rb +++ b/spec/services/merge_requests/create_service_spec.rb @@ -57,7 +57,7 @@ describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do expect(Todo.where(attributes).count).to be_zero end - it 'creates exactly 1 create MR event' do + it 'creates exactly 1 create MR event', :sidekiq_might_not_need_inline do attributes = { action: Event::CREATED, target_id: merge_request.id, @@ -216,7 +216,7 @@ describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do target_project.add_maintainer(user) end - it 'create legacy detached merge request pipeline for fork merge request' do + it 'create legacy detached merge request pipeline for fork merge request', :sidekiq_might_not_need_inline do expect(merge_request.actual_head_pipeline) .to be_legacy_detached_merge_request_pipeline end @@ -477,7 +477,7 @@ describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do project.add_developer(user) end - it 'creates the merge request' do + it 'creates the merge request', :sidekiq_might_not_need_inline do merge_request = described_class.new(project, user, opts).execute expect(merge_request).to be_persisted diff --git a/spec/services/merge_requests/ff_merge_service_spec.rb b/spec/services/merge_requests/ff_merge_service_spec.rb index 3b1096c51cbaeca7468844b9c9505fdf5620271c..87fcd70a2981635b1a1f750108b398f328822cb3 100644 --- a/spec/services/merge_requests/ff_merge_service_spec.rb +++ b/spec/services/merge_requests/ff_merge_service_spec.rb @@ -13,6 +13,7 @@ describe MergeRequests::FfMergeService do author: create(:user)) end let(:project) { merge_request.project } + let(:valid_merge_params) { { sha: merge_request.diff_head_sha } } before do project.add_maintainer(user) @@ -21,39 +22,69 @@ describe MergeRequests::FfMergeService do describe '#execute' do context 'valid params' do - let(:service) { described_class.new(project, user, {}) } - - before do - allow(service).to receive(:execute_hooks) + let(:service) { described_class.new(project, user, valid_merge_params) } + def execute_ff_merge perform_enqueued_jobs do service.execute(merge_request) end end + before do + allow(service).to receive(:execute_hooks) + end + it "does not create merge commit" do + execute_ff_merge + source_branch_sha = merge_request.source_project.repository.commit(merge_request.source_branch).sha target_branch_sha = merge_request.target_project.repository.commit(merge_request.target_branch).sha + expect(source_branch_sha).to eq(target_branch_sha) end - it { expect(merge_request).to be_valid } - it { expect(merge_request).to be_merged } + it 'keeps the merge request valid' do + expect { execute_ff_merge } + .not_to change { merge_request.valid? } + end + + it 'updates the merge request to merged' do + expect { execute_ff_merge } + .to change { merge_request.merged? } + .from(false) + .to(true) + end it 'sends email to user2 about merge of new merge_request' do + execute_ff_merge + email = ActionMailer::Base.deliveries.last expect(email.to.first).to eq(user2.email) expect(email.subject).to include(merge_request.title) end it 'creates system note about merge_request merge' do + execute_ff_merge + note = merge_request.notes.last expect(note.note).to include 'merged' end + + it 'does not update squash_commit_sha if it is not a squash' do + expect { execute_ff_merge }.not_to change { merge_request.squash_commit_sha } + end + + it 'updates squash_commit_sha if it is a squash' do + merge_request.update!(squash: true) + + expect { execute_ff_merge } + .to change { merge_request.squash_commit_sha } + .from(nil) + end end - context "error handling" do - let(:service) { described_class.new(project, user, commit_message: 'Awesome message') } + context 'error handling' do + let(:service) { described_class.new(project, user, valid_merge_params.merge(commit_message: 'Awesome message')) } before do allow(Rails.logger).to receive(:error) @@ -82,6 +113,16 @@ describe MergeRequests::FfMergeService do expect(merge_request.merge_error).to include(error_message) expect(Rails.logger).to have_received(:error).with(a_string_matching(error_message)) end + + it 'does not update squash_commit_sha if squash merge is not successful' do + merge_request.update!(squash: true) + + expect(project.repository.raw).to receive(:ff_merge) do + raise 'Merge error' + end + + expect { service.execute(merge_request) }.not_to change { merge_request.squash_commit_sha } + end end end end diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb index 22578436c1852794db1aa8c07aa5d8180b327be7..c938dd1cb0b715ed04389aae051afb8d3797ecbd 100644 --- a/spec/services/merge_requests/merge_service_spec.rb +++ b/spec/services/merge_requests/merge_service_spec.rb @@ -14,9 +14,12 @@ describe MergeRequests::MergeService do end describe '#execute' do - context 'valid params' do - let(:service) { described_class.new(project, user, commit_message: 'Awesome message') } + let(:service) { described_class.new(project, user, merge_params) } + let(:merge_params) do + { commit_message: 'Awesome message', sha: merge_request.diff_head_sha } + end + context 'valid params' do before do allow(service).to receive(:execute_hooks) @@ -38,11 +41,80 @@ describe MergeRequests::MergeService do note = merge_request.notes.last expect(note.note).to include 'merged' end + + context 'when squashing' do + let(:merge_params) do + { commit_message: 'Merge commit message', + squash_commit_message: 'Squash commit message', + sha: merge_request.diff_head_sha } + end + + let(:merge_request) do + # A merge reqeust with 5 commits + create(:merge_request, :simple, + author: user2, + assignees: [user2], + squash: true, + source_branch: 'improve/awesome', + target_branch: 'fix') + end + + it 'merges the merge request with squashed commits' do + expect(merge_request).to be_merged + + merge_commit = merge_request.merge_commit + squash_commit = merge_request.merge_commit.parents.last + + expect(merge_commit.message).to eq('Merge commit message') + expect(squash_commit.message).to eq("Squash commit message\n") + end + end end - context 'closes related issues' do - let(:service) { described_class.new(project, user, commit_message: 'Awesome message') } + context 'when an invalid sha is passed' do + let(:merge_request) do + create(:merge_request, :simple, + author: user2, + assignees: [user2], + squash: true, + source_branch: 'improve/awesome', + target_branch: 'fix') + end + + let(:merge_params) do + { sha: merge_request.commits.second.sha } + end + + it 'does not merge the MR' do + service.execute(merge_request) + + expect(merge_request).not_to be_merged + expect(merge_request.merge_error).to match(/Branch has been updated/) + end + end + + context 'when the `sha` param is missing' do + let(:merge_params) { {} } + + it 'returns the error' do + merge_error = 'Branch has been updated since the merge was requested. '\ + 'Please review the changes.' + + expect { service.execute(merge_request) } + .to change { merge_request.merge_error } + .from(nil).to(merge_error) + end + + it 'merges the MR when the feature is disabled' do + stub_feature_flags(validate_merge_sha: false) + service.execute(merge_request) + + expect(merge_request).to be_merged + end + end + + context 'closes related issues' do before do allow(project).to receive(:default_branch).and_return(merge_request.target_branch) end @@ -83,12 +155,12 @@ describe MergeRequests::MergeService do service.execute(merge_request) end - context "when jira_issue_transition_id is not present" do + context 'when jira_issue_transition_id is not present' do before do allow_any_instance_of(JIRA::Resource::Issue).to receive(:resolution).and_return(nil) end - it "does not close issue" do + it 'does not close issue' do allow(jira_tracker).to receive_messages(jira_issue_transition_id: nil) expect_any_instance_of(JiraService).not_to receive(:transition_issue) @@ -97,7 +169,7 @@ describe MergeRequests::MergeService do end end - context "wrong issue markdown" do + context 'wrong issue markdown' do it 'does not close issues on Jira issue tracker' do jira_issue = ExternalIssue.new('#JIRA-123', project) stub_jira_urls(jira_issue) @@ -115,7 +187,7 @@ describe MergeRequests::MergeService do context 'closes related todos' do let(:merge_request) { create(:merge_request, assignees: [user], author: user) } let(:project) { merge_request.project } - let(:service) { described_class.new(project, user, commit_message: 'Awesome message') } + let!(:todo) do create(:todo, :assigned, project: project, @@ -139,7 +211,7 @@ describe MergeRequests::MergeService do context 'source branch removal' do context 'when the source branch is protected' do let(:service) do - described_class.new(project, user, 'should_remove_source_branch' => true) + described_class.new(project, user, merge_params.merge('should_remove_source_branch' => true)) end before do @@ -154,7 +226,7 @@ describe MergeRequests::MergeService do context 'when the source branch is the default branch' do let(:service) do - described_class.new(project, user, 'should_remove_source_branch' => true) + described_class.new(project, user, merge_params.merge('should_remove_source_branch' => true)) end before do @@ -169,8 +241,6 @@ describe MergeRequests::MergeService do context 'when the source branch can be removed' do context 'when MR author set the source branch to be removed' do - let(:service) { described_class.new(project, user, commit_message: 'Awesome message') } - before do merge_request.update_attribute(:merge_params, { 'force_remove_source_branch' => '1' }) end @@ -183,7 +253,7 @@ describe MergeRequests::MergeService do end context 'when the merger set the source branch not to be removed' do - let(:service) { described_class.new(project, user, commit_message: 'Awesome message', 'should_remove_source_branch' => false) } + let(:service) { described_class.new(project, user, merge_params.merge('should_remove_source_branch' => false)) } it 'does not delete the source branch' do expect(DeleteBranchService).not_to receive(:new) @@ -194,7 +264,7 @@ describe MergeRequests::MergeService do context 'when MR merger set the source branch to be removed' do let(:service) do - described_class.new(project, user, commit_message: 'Awesome message', 'should_remove_source_branch' => true) + described_class.new(project, user, merge_params.merge('should_remove_source_branch' => true)) end it 'removes the source branch using the current user' do @@ -207,9 +277,7 @@ describe MergeRequests::MergeService do end end - context "error handling" do - let(:service) { described_class.new(project, user, commit_message: 'Awesome message') } - + context 'error handling' do before do allow(Rails.logger).to receive(:error) end @@ -230,7 +298,7 @@ describe MergeRequests::MergeService do it 'logs and saves error if there is an exception' do error_message = 'error message' - allow(service).to receive(:repository).and_raise("error message") + allow(service).to receive(:repository).and_raise('error message') allow(service).to receive(:execute_hooks) service.execute(merge_request) @@ -310,7 +378,7 @@ describe MergeRequests::MergeService do expect(Rails.logger).to have_received(:error).with(a_string_matching(error_message)) end - context "when fast-forward merge is not allowed" do + context 'when fast-forward merge is not allowed' do before do allow_any_instance_of(Repository).to receive(:ancestor?).and_return(nil) end 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 758679edc4595c63159cd8523589aedcb199c369..cccafddc450d5a5488a22717d3805968ccf04498 100644 --- a/spec/services/merge_requests/merge_to_ref_service_spec.rb +++ b/spec/services/merge_requests/merge_to_ref_service_spec.rb @@ -76,7 +76,7 @@ describe MergeRequests::MergeToRefService do described_class.new(project, user, **params) end - let(:params) { { commit_message: 'Awesome message', should_remove_source_branch: true } } + let(:params) { { commit_message: 'Awesome message', should_remove_source_branch: true, sha: merge_request.diff_head_sha } } def process_merge_to_ref perform_enqueued_jobs do @@ -103,7 +103,7 @@ describe MergeRequests::MergeToRefService do end let(:merge_service) do - MergeRequests::MergeService.new(project, user, {}) + MergeRequests::MergeService.new(project, user, { sha: merge_request.diff_head_sha }) end context 'when merge commit' do @@ -205,7 +205,7 @@ describe MergeRequests::MergeToRefService do end context 'when target ref is passed as a parameter' do - let(:params) { { commit_message: 'merge train', target_ref: target_ref } } + let(:params) { { commit_message: 'merge train', target_ref: target_ref, sha: merge_request.diff_head_sha } } it_behaves_like 'successfully merges to ref with merge method' do let(:first_parent_ref) { 'refs/heads/master' } @@ -215,7 +215,7 @@ describe MergeRequests::MergeToRefService do describe 'cascading merge refs' do set(:project) { create(:project, :repository) } - let(:params) { { commit_message: 'Cascading merge', first_parent_ref: first_parent_ref, target_ref: target_ref } } + 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 let(:merge_request) 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 ff4cdd3e7e280bbb4f7443b919f3ccddad40b322..75b9c2304a6663879b1bb27623951517392a0487 100644 --- a/spec/services/merge_requests/push_options_handler_service_spec.rb +++ b/spec/services/merge_requests/push_options_handler_service_spec.rb @@ -46,7 +46,7 @@ describe MergeRequests::PushOptionsHandlerService do expect(last_mr.assignees).to contain_exactly(user) end - context 'when project has been forked' do + context 'when project has been forked', :sidekiq_might_not_need_inline do let(:forked_project) { fork_project(project, user, repository: true) } let(:service) { described_class.new(forked_project, user, changes, push_options) } diff --git a/spec/services/merge_requests/rebase_service_spec.rb b/spec/services/merge_requests/rebase_service_spec.rb index 7b8c94c86fe9562b2721168c199d978417e7863c..9c535664c26894aa5c2aa654a19f4ad128eec687 100644 --- a/spec/services/merge_requests/rebase_service_spec.rb +++ b/spec/services/merge_requests/rebase_service_spec.rb @@ -174,7 +174,7 @@ describe MergeRequests::RebaseService do target_branch: 'master', target_project: project) end - it 'rebases source branch' do + it 'rebases source branch', :sidekiq_might_not_need_inline do parent_sha = forked_project.repository.commit(merge_request_from_fork.source_branch).parents.first.sha target_branch_sha = project.repository.commit(merge_request_from_fork.target_branch).sha expect(parent_sha).to eq(target_branch_sha) diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb index 2dc932c9f2c0936b41a301f5e4a9957bf0ec4d83..9d0ad60a624cc424bf39456164a7b22df1aa4431 100644 --- a/spec/services/merge_requests/refresh_service_spec.rb +++ b/spec/services/merge_requests/refresh_service_spec.rb @@ -137,7 +137,7 @@ describe MergeRequests::RefreshService do subject { service.new(@project, @user).execute(@oldrev, @newrev, 'refs/heads/master') } - it 'updates the head_pipeline_id for @merge_request' do + it 'updates the head_pipeline_id for @merge_request', :sidekiq_might_not_need_inline do expect { subject }.to change { @merge_request.reload.head_pipeline_id }.from(nil).to(pipeline.id) end @@ -200,7 +200,7 @@ describe MergeRequests::RefreshService do context 'when service runs on forked project' do let(:project) { @fork_project } - it 'creates legacy detached merge request pipeline for fork merge request' do + it 'creates legacy detached merge request pipeline for fork merge request', :sidekiq_might_not_need_inline do expect { subject } .to change { @fork_merge_request.pipelines_for_merge_request.count }.by(1) @@ -232,7 +232,7 @@ describe MergeRequests::RefreshService do subject end - it 'sets the latest detached merge request pipeline as a head pipeline' do + it 'sets the latest detached merge request pipeline as a head pipeline', :sidekiq_might_not_need_inline do @merge_request.reload expect(@merge_request.actual_head_pipeline).to be_merge_request_event end @@ -304,7 +304,7 @@ describe MergeRequests::RefreshService do end end - context 'push to origin repo target branch' do + context 'push to origin repo target branch', :sidekiq_might_not_need_inline do context 'when all MRs to the target branch had diffs' do before do service.new(@project, @user).execute(@oldrev, @newrev, 'refs/heads/feature') @@ -354,7 +354,7 @@ describe MergeRequests::RefreshService do end end - context 'manual merge of source branch' do + context 'manual merge of source branch', :sidekiq_might_not_need_inline do before do # Merge master -> feature branch @project.repository.merge(@user, @merge_request.diff_head_sha, @merge_request, 'Test message') @@ -374,7 +374,7 @@ describe MergeRequests::RefreshService do end end - context 'push to fork repo source branch' do + context 'push to fork repo source branch', :sidekiq_might_not_need_inline do let(:refresh_service) { service.new(@fork_project, @user) } def refresh @@ -431,7 +431,7 @@ describe MergeRequests::RefreshService do end end - context 'push to fork repo target branch' do + context 'push to fork repo target branch', :sidekiq_might_not_need_inline do describe 'changes to merge requests' do before do service.new(@fork_project, @user).execute(@oldrev, @newrev, 'refs/heads/feature') @@ -457,7 +457,7 @@ describe MergeRequests::RefreshService do end end - context 'forked projects with the same source branch name as target branch' do + context 'forked projects with the same source branch name as target branch', :sidekiq_might_not_need_inline do let!(:first_commit) do @fork_project.repository.create_file(@user, 'test1.txt', 'Test data', message: 'Test commit', @@ -537,7 +537,7 @@ describe MergeRequests::RefreshService do context 'push new branch that exists in a merge request' do let(:refresh_service) { service.new(@fork_project, @user) } - it 'refreshes the merge request' do + it 'refreshes the merge request', :sidekiq_might_not_need_inline do expect(refresh_service).to receive(:execute_hooks) .with(@fork_merge_request, 'update', old_rev: Gitlab::Git::BLANK_SHA) allow_any_instance_of(Repository).to receive(:merge_base).and_return(@oldrev) @@ -769,7 +769,7 @@ describe MergeRequests::RefreshService do fork_project(target_project, author, repository: true) end - let_it_be(:merge_request) do + let_it_be(:merge_request, refind: true) do create(:merge_request, author: author, source_project: source_project, @@ -795,88 +795,58 @@ describe MergeRequests::RefreshService do .parent_id end + let(:auto_merge_strategy) { AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS } let(:refresh_service) { service.new(project, user) } before do target_project.merge_method = merge_method target_project.save! + merge_request.auto_merge_strategy = auto_merge_strategy + merge_request.save! refresh_service.execute(oldrev, newrev, 'refs/heads/master') merge_request.reload end - let(:aborted_message) do - /aborted the automatic merge because target branch was updated/ - end - - shared_examples 'aborted MWPS' do - it 'aborts auto_merge' do - expect(merge_request.auto_merge_enabled?).to be_falsey - expect(merge_request.notes.last.note).to match(aborted_message) - end - - it 'removes merge_user' do - expect(merge_request.merge_user).to be_nil - end - - it 'does not add todos for merge user' do - expect(user.todos.for_target(merge_request)).to be_empty - end - - it 'adds todos for merge author' do - expect(author.todos.for_target(merge_request)).to be_present.and be_all(&:pending?) - end - end - context 'when Project#merge_method is set to FF' do let(:merge_method) { :ff } - it_behaves_like 'aborted MWPS' + it_behaves_like 'aborted merge requests for MWPS' context 'with forked project' do let(:source_project) { forked_project } - it_behaves_like 'aborted MWPS' + it_behaves_like 'aborted merge requests for MWPS' + end + + context 'with bogus auto merge strategy' do + let(:auto_merge_strategy) { 'bogus' } + + it_behaves_like 'maintained merge requests for MWPS' end end context 'when Project#merge_method is set to rebase_merge' do let(:merge_method) { :rebase_merge } - it_behaves_like 'aborted MWPS' + it_behaves_like 'aborted merge requests for MWPS' context 'with forked project' do let(:source_project) { forked_project } - it_behaves_like 'aborted MWPS' + it_behaves_like 'aborted merge requests for MWPS' end end context 'when Project#merge_method is set to merge' do let(:merge_method) { :merge } - shared_examples 'maintained MWPS' do - it 'does not cancel auto merge' do - expect(merge_request.auto_merge_enabled?).to be_truthy - expect(merge_request.notes).to be_empty - end - - it 'does not change merge_user' do - expect(merge_request.merge_user).to eq(user) - end - - it 'does not add todos' do - expect(author.todos.for_target(merge_request)).to be_empty - expect(user.todos.for_target(merge_request)).to be_empty - end - end - - it_behaves_like 'maintained MWPS' + it_behaves_like 'maintained merge requests for MWPS' context 'with forked project' do let(:source_project) { forked_project } - it_behaves_like 'maintained MWPS' + it_behaves_like 'maintained merge requests for MWPS' end end end diff --git a/spec/services/merge_requests/reopen_service_spec.rb b/spec/services/merge_requests/reopen_service_spec.rb index 7a98437f724834ebdb10e8c54604dc391e13d5c5..25ab79d70c3aa687e7e7b813e4997951784c5b94 100644 --- a/spec/services/merge_requests/reopen_service_spec.rb +++ b/spec/services/merge_requests/reopen_service_spec.rb @@ -37,7 +37,7 @@ describe MergeRequests::ReopenService do .with(merge_request, 'reopen') end - it 'sends email to user2 about reopen of merge_request' do + it 'sends email to user2 about reopen of merge_request', :sidekiq_might_not_need_inline do email = ActionMailer::Base.deliveries.last expect(email.to.first).to eq(user2.email) expect(email.subject).to include(merge_request.title) diff --git a/spec/services/merge_requests/resolved_discussion_notification_service_spec.rb b/spec/services/merge_requests/resolved_discussion_notification_service_spec.rb index 0a10a9ee13bae34c249e9883c7b8cfd360804556..dc2bd5bf3d0f624fee061fb8ad5e9c1f52f2ff07 100644 --- a/spec/services/merge_requests/resolved_discussion_notification_service_spec.rb +++ b/spec/services/merge_requests/resolved_discussion_notification_service_spec.rb @@ -38,7 +38,7 @@ describe MergeRequests::ResolvedDiscussionNotificationService do subject.execute(merge_request) end - it "sends a notification email" do + it "sends a notification email", :sidekiq_might_not_need_inline do expect_any_instance_of(NotificationService).to receive(:resolve_all_discussions).with(merge_request, user) subject.execute(merge_request) diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index d31f5dc0176b8085a371c3f62e389ddfbdf42818..baa0ecf27e39ba9f07675191a10c8ebba80e003e 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -98,7 +98,7 @@ describe MergeRequests::UpdateService, :mailer do ) end - it 'sends email to user2 about assign of new merge request and email to user3 about merge request unassignment' do + it 'sends email to user2 about assign of new merge request and email to user3 about merge request unassignment', :sidekiq_might_not_need_inline do deliveries = ActionMailer::Base.deliveries email = deliveries.last recipients = deliveries.last(2).flat_map(&:to) @@ -181,7 +181,7 @@ describe MergeRequests::UpdateService, :mailer do end end - it 'merges the MR' do + it 'merges the MR', :sidekiq_might_not_need_inline do expect(@merge_request).to be_valid expect(@merge_request.state).to eq('merged') expect(@merge_request.merge_error).to be_nil @@ -190,7 +190,7 @@ describe MergeRequests::UpdateService, :mailer do context 'with finished pipeline' do before do - create(:ci_pipeline_with_one_job, + create(:ci_pipeline, project: project, ref: merge_request.source_branch, sha: merge_request.diff_head_sha, @@ -202,7 +202,7 @@ describe MergeRequests::UpdateService, :mailer do end end - it 'merges the MR' do + it 'merges the MR', :sidekiq_might_not_need_inline do expect(@merge_request).to be_valid expect(@merge_request.state).to eq('merged') end @@ -212,14 +212,14 @@ describe MergeRequests::UpdateService, :mailer do before do service_mock = double create( - :ci_pipeline_with_one_job, + :ci_pipeline, project: project, ref: merge_request.source_branch, sha: merge_request.diff_head_sha, head_pipeline_of: merge_request ) - expect(AutoMerge::MergeWhenPipelineSucceedsService).to receive(:new).with(project, user, {}) + expect(AutoMerge::MergeWhenPipelineSucceedsService).to receive(:new).with(project, user, { sha: merge_request.diff_head_sha }) .and_return(service_mock) allow(service_mock).to receive(:available_for?) { true } expect(service_mock).to receive(:execute).with(merge_request) @@ -332,7 +332,7 @@ describe MergeRequests::UpdateService, :mailer do it_behaves_like 'system notes for milestones' - it 'sends notifications for subscribers of changed milestone' do + it 'sends notifications for subscribers of changed milestone', :sidekiq_might_not_need_inline do merge_request.milestone = create(:milestone, project: project) merge_request.save @@ -364,7 +364,7 @@ describe MergeRequests::UpdateService, :mailer do it_behaves_like 'system notes for milestones' - it 'sends notifications for subscribers of changed milestone' do + it 'sends notifications for subscribers of changed milestone', :sidekiq_might_not_need_inline do perform_enqueued_jobs do update_merge_request(milestone: create(:milestone, project: project)) end @@ -411,7 +411,7 @@ describe MergeRequests::UpdateService, :mailer do context 'when auto merge is enabled and target branch changed' do before do - AutoMergeService.new(project, user).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) + AutoMergeService.new(project, user, { sha: merge_request.diff_head_sha }).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) update_merge_request({ target_branch: 'target' }) end @@ -431,7 +431,7 @@ describe MergeRequests::UpdateService, :mailer do project.add_developer(subscriber) end - it 'sends notifications for subscribers of newly added labels' do + it 'sends notifications for subscribers of newly added labels', :sidekiq_might_not_need_inline do opts = { label_ids: [label.id] } perform_enqueued_jobs do diff --git a/spec/services/metrics/dashboard/grafana_metric_embed_service_spec.rb b/spec/services/metrics/dashboard/grafana_metric_embed_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f200c636aacf67c4443c854686da6e48fb2b77d1 --- /dev/null +++ b/spec/services/metrics/dashboard/grafana_metric_embed_service_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Metrics::Dashboard::GrafanaMetricEmbedService do + include MetricsDashboardHelpers + include ReactiveCachingHelpers + include GrafanaApiHelpers + + let_it_be(:project) { build(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:grafana_integration) { create(:grafana_integration, project: project) } + + let(:grafana_url) do + valid_grafana_dashboard_link(grafana_integration.grafana_url) + end + + before do + project.add_maintainer(user) + end + + describe '.valid_params?' do + let(:valid_params) { { embedded: true, grafana_url: grafana_url } } + + subject { described_class.valid_params?(params) } + + let(:params) { valid_params } + + it { is_expected.to be_truthy } + + context 'not embedded' do + let(:params) { valid_params.except(:embedded) } + + it { is_expected.to be_falsey } + end + + context 'undefined grafana_url' do + let(:params) { valid_params.except(:grafana_url) } + + it { is_expected.to be_falsey } + end + end + + describe '.from_cache' do + let(:params) { [project.id, user.id, grafana_url] } + + subject { described_class.from_cache(*params) } + + it 'initializes an instance of GrafanaMetricEmbedService' do + expect(subject).to be_an_instance_of(described_class) + expect(subject.project).to eq(project) + expect(subject.current_user).to eq(user) + expect(subject.params[:grafana_url]).to eq(grafana_url) + end + end + + describe '#get_dashboard', :use_clean_rails_memory_store_caching do + let(:service_params) do + [ + project, + user, + { + embedded: true, + grafana_url: grafana_url + } + ] + end + + let(:service) { described_class.new(*service_params) } + let(:service_call) { service.get_dashboard } + + context 'without caching' do + before do + synchronous_reactive_cache(service) + end + + it_behaves_like 'raises error for users with insufficient permissions' + + context 'without a grafana integration' do + before do + allow(project).to receive(:grafana_integration).and_return(nil) + end + + it_behaves_like 'misconfigured dashboard service response', :bad_request + end + + context 'when grafana cannot be reached' do + before do + allow(grafana_integration.client).to receive(:get_dashboard).and_raise(::Grafana::Client::Error) + end + + it_behaves_like 'misconfigured dashboard service response', :service_unavailable + end + + context 'when panelId is missing' do + let(:grafana_url) do + grafana_integration.grafana_url + + '/d/XDaNK6amz/gitlab-omnibus-redis' \ + '?from=1570397739557&to=1570484139557' + end + + before do + stub_dashboard_request(grafana_integration.grafana_url) + end + + it_behaves_like 'misconfigured dashboard service response', :unprocessable_entity + end + + context 'when uid is missing' do + let(:grafana_url) { grafana_integration.grafana_url + '/d/' } + + before do + stub_dashboard_request(grafana_integration.grafana_url) + end + + it_behaves_like 'misconfigured dashboard service response', :unprocessable_entity + end + + context 'when the dashboard response contains misconfigured json' do + before do + stub_dashboard_request(grafana_integration.grafana_url, body: '') + end + + it_behaves_like 'misconfigured dashboard service response', :unprocessable_entity + end + + context 'when the datasource response contains misconfigured json' do + before do + stub_dashboard_request(grafana_integration.grafana_url) + stub_datasource_request(grafana_integration.grafana_url, body: '') + end + + it_behaves_like 'misconfigured dashboard service response', :unprocessable_entity + end + + context 'when the embed was created successfully' do + before do + stub_dashboard_request(grafana_integration.grafana_url) + stub_datasource_request(grafana_integration.grafana_url) + end + + it_behaves_like 'valid embedded dashboard service response' + end + end + + context 'with caching', :use_clean_rails_memory_store_caching do + let(:cache_params) { [project.id, user.id, grafana_url] } + + context 'when value not present in cache' do + it 'returns nil' do + expect(ReactiveCachingWorker) + .to receive(:perform_async) + .with(service.class, service.id, *cache_params) + + expect(service_call).to eq(nil) + end + end + + context 'when value present in cache' do + let(:return_value) { { 'http_status' => :ok, 'dashboard' => '{}' } } + + before do + stub_reactive_cache(service, return_value, cache_params) + end + + it 'returns cached value' do + expect(ReactiveCachingWorker) + .not_to receive(:perform_async) + .with(service.class, service.id, *cache_params) + + expect(service_call[:http_status]).to eq(return_value[:http_status]) + expect(service_call[:dashboard]).to eq(return_value[:dashboard]) + end + end + end + end +end diff --git a/spec/services/metrics/dashboard/project_dashboard_service_spec.rb b/spec/services/metrics/dashboard/project_dashboard_service_spec.rb index e76db868425f20ca5dc94c9dead22fbc2efac323..ab7a7b978615614956dfd5af9f02fd79ab0f4642 100644 --- a/spec/services/metrics/dashboard/project_dashboard_service_spec.rb +++ b/spec/services/metrics/dashboard/project_dashboard_service_spec.rb @@ -80,7 +80,8 @@ describe Metrics::Dashboard::ProjectDashboardService, :use_clean_rails_memory_st [{ path: dashboard_path, display_name: 'test.yml', - default: false + default: false, + system_dashboard: false }] ) end diff --git a/spec/services/metrics/dashboard/system_dashboard_service_spec.rb b/spec/services/metrics/dashboard/system_dashboard_service_spec.rb index 8be3e7f6064bc78fcfa3ecd4187924087fc247aa..ec861465662a46dd0a2886d8897765e812e34491 100644 --- a/spec/services/metrics/dashboard/system_dashboard_service_spec.rb +++ b/spec/services/metrics/dashboard/system_dashboard_service_spec.rb @@ -44,7 +44,8 @@ describe Metrics::Dashboard::SystemDashboardService, :use_clean_rails_memory_sto [{ path: described_class::SYSTEM_DASHBOARD_PATH, display_name: described_class::SYSTEM_DASHBOARD_NAME, - default: true + default: true, + system_dashboard: true }] ) end diff --git a/spec/services/namespaces/statistics_refresher_service_spec.rb b/spec/services/namespaces/statistics_refresher_service_spec.rb index f4d9c96f7f4f2e6bca602d5f7131d68343cfd6dd..9d42e917efe00f11fade02bb07a73544c2dd1464 100644 --- a/spec/services/namespaces/statistics_refresher_service_spec.rb +++ b/spec/services/namespaces/statistics_refresher_service_spec.rb @@ -23,7 +23,7 @@ describe Namespaces::StatisticsRefresherService, '#execute' do end end - context 'with a root storage statistics relation' do + context 'with a root storage statistics relation', :sidekiq_might_not_need_inline do before do Namespace::AggregationSchedule.safe_find_or_create_by!(namespace_id: group.id) end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index aa67b87a6458585ee442f5560a4f53554ad4c8f0..25900043f11203dcebcffc3baf6382a0a19dc725 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -682,7 +682,7 @@ describe NotificationService, :mailer do context 'when recipients for a new release exist' do let(:release) { create(:release) } - it 'calls new_release_email for each relevant recipient' do + it 'calls new_release_email for each relevant recipient', :sidekiq_might_not_need_inline do user_1 = create(:user) user_2 = create(:user) user_3 = create(:user) @@ -869,6 +869,18 @@ describe NotificationService, :mailer do should_email(user_4) end + it 'adds "subscribed" reason to subscriber emails' do + user_1 = create(:user) + label = create(:label, project: project, issues: [issue]) + issue.reload + label.subscribe(user_1) + + notification.new_issue(issue, @u_disabled) + + email = find_email_for(user_1) + expect(email).to have_header('X-GitLab-NotificationReason', NotificationReason::SUBSCRIBED) + end + it_behaves_like 'project emails are disabled' do let(:notification_target) { issue } let(:notification_trigger) { notification.new_issue(issue, @u_disabled) } @@ -1272,6 +1284,17 @@ describe NotificationService, :mailer do let(:notification_target) { issue } let(:notification_trigger) { notification.close_issue(issue, @u_disabled) } end + + it 'adds "subscribed" reason to subscriber emails' do + user_1 = create(:user) + issue.subscribe(user_1) + issue.reload + + notification.close_issue(issue, @u_disabled) + + email = find_email_for(user_1) + expect(email).to have_header('X-GitLab-NotificationReason', NotificationReason::SUBSCRIBED) + end end describe '#reopen_issue' do diff --git a/spec/services/projects/after_rename_service_spec.rb b/spec/services/projects/after_rename_service_spec.rb index 8585d495ffbe4e522af2566aa83e13840d2425da..bf637b70aafd73f8da68a2d4913f146902d141c4 100644 --- a/spec/services/projects/after_rename_service_spec.rb +++ b/spec/services/projects/after_rename_service_spec.rb @@ -222,7 +222,7 @@ describe Projects::AfterRenameService do def expect_repository_exist(full_path_with_extension) expect( - gitlab_shell.exists?( + TestEnv.storage_dir_exists?( project.repository_storage, full_path_with_extension ) 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 f296ef3a7769e7836586c7bb4d56d0f36f391d0b..1cfe3582e5623c8105bd9535cbf35c6ad54623fb 100644 --- a/spec/services/projects/container_repository/delete_tags_service_spec.rb +++ b/spec/services/projects/container_repository/delete_tags_service_spec.rb @@ -57,21 +57,7 @@ describe Projects::ContainerRepository::DeleteTagsService do end end - context 'with dummy tags disabled' do - let(:tags) { %w[A Ba] } - - before do - stub_feature_flags(container_registry_smart_delete: false) - end - - it 'deletes tags one by one' do - expect_delete_tag('sha256:configA') - expect_delete_tag('sha256:configB') - is_expected.to include(status: :success) - end - end - - context 'with dummy tags enabled' do + context 'with tags to delete' do let(:tags) { %w[A Ba] } it 'deletes the tags using a dummy image' do @@ -102,6 +88,33 @@ describe Projects::ContainerRepository::DeleteTagsService do is_expected.to include(status: :success) end + + context 'with failures' do + context 'when the dummy manifest generation fails' do + before do + stub_upload("{\n \"config\": {\n }\n}", 'sha256:4435000728ee66e6a80e55637fc22725c256b61de344a2ecdeaac6bdb36e8bc3', success: false) + end + + it { is_expected.to include(status: :error) } + end + + context 'when updating the tags fails' do + before do + stub_upload("{\n \"config\": {\n }\n}", 'sha256:4435000728ee66e6a80e55637fc22725c256b61de344a2ecdeaac6bdb36e8bc3') + + stub_request(:put, "http://registry.gitlab/v2/#{repository.path}/manifests/A") + .to_return(status: 500, body: "", headers: { 'docker-content-digest' => 'sha256:dummy' }) + + stub_request(:put, "http://registry.gitlab/v2/#{repository.path}/manifests/Ba") + .to_return(status: 500, body: "", headers: { 'docker-content-digest' => 'sha256:dummy' }) + + stub_request(:delete, "http://registry.gitlab/v2/#{repository.path}/manifests/sha256:4435000728ee66e6a80e55637fc22725c256b61de344a2ecdeaac6bdb36e8bc3") + .to_return(status: 200, body: "", headers: {}) + end + + it { is_expected.to include(status: :error) } + end + end end end end @@ -121,10 +134,10 @@ describe Projects::ContainerRepository::DeleteTagsService do end end - def stub_upload(content, digest) + def stub_upload(content, digest, success: true) expect_any_instance_of(ContainerRegistry::Client) .to receive(:upload_blob) - .with(repository.path, content, digest) { double(success?: true ) } + .with(repository.path, content, digest) { double(success?: success ) } end def expect_delete_tag(digest) diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb index 2331281bd8e7725d7ad1c9070ef95525872960b4..642986bb176d6ae8ad15322be266b6d65ea1898f 100644 --- a/spec/services/projects/destroy_service_spec.rb +++ b/spec/services/projects/destroy_service_spec.rb @@ -81,7 +81,7 @@ describe Projects::DestroyService do end let!(:async) { true } - it 'destroys them' do + it 'destroys them', :sidekiq_might_not_need_inline do expect(RemoteMirror.count).to eq(0) end end @@ -102,7 +102,7 @@ describe Projects::DestroyService do end let!(:async) { true } - it 'destroys project and export' do + it 'destroys project and export', :sidekiq_might_not_need_inline do expect { destroy_project(project_with_export, user) }.to change(ImportExportUpload, :count).by(-1) expect(Project.all).not_to include(project_with_export) @@ -153,7 +153,7 @@ describe Projects::DestroyService do end end - context 'with async_execute' do + context 'with async_execute', :sidekiq_might_not_need_inline do let(:async) { true } context 'async delete of project with private issue visibility' do @@ -346,21 +346,21 @@ describe Projects::DestroyService do let(:path) { project.disk_path + '.git' } before do - expect(project.gitlab_shell.exists?(project.repository_storage, path)).to be_truthy - expect(project.gitlab_shell.exists?(project.repository_storage, remove_path)).to be_falsey + expect(TestEnv.storage_dir_exists?(project.repository_storage, path)).to be_truthy + expect(TestEnv.storage_dir_exists?(project.repository_storage, remove_path)).to be_falsey # Dont run sidekiq to check if renamed repository exists Sidekiq::Testing.fake! { destroy_project(project, user, {}) } - expect(project.gitlab_shell.exists?(project.repository_storage, path)).to be_falsey - expect(project.gitlab_shell.exists?(project.repository_storage, remove_path)).to be_truthy + expect(TestEnv.storage_dir_exists?(project.repository_storage, path)).to be_falsey + expect(TestEnv.storage_dir_exists?(project.repository_storage, remove_path)).to be_truthy end it 'restores the repositories' do Sidekiq::Testing.fake! { described_class.new(project, user).attempt_repositories_rollback } - expect(project.gitlab_shell.exists?(project.repository_storage, path)).to be_truthy - expect(project.gitlab_shell.exists?(project.repository_storage, remove_path)).to be_falsey + expect(TestEnv.storage_dir_exists?(project.repository_storage, path)).to be_truthy + expect(TestEnv.storage_dir_exists?(project.repository_storage, remove_path)).to be_falsey end end diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb index 7e7e80ca240adda5999f939774a95bfe426c10a8..5a3796fec3d39d494e1a10b3a456552c7d3c2a9b 100644 --- a/spec/services/projects/fork_service_spec.rb +++ b/spec/services/projects/fork_service_spec.rb @@ -79,7 +79,7 @@ describe Projects::ForkService do expect(fork_network.projects).to contain_exactly(@from_project, to_project) end - it 'imports the repository of the forked project' do + it 'imports the repository of the forked project', :sidekiq_might_not_need_inline do to_project = fork_project(@from_project, @to_user, repository: true) expect(to_project.empty_repo?).to be_falsy diff --git a/spec/services/projects/hashed_storage/base_attachment_service_spec.rb b/spec/services/projects/hashed_storage/base_attachment_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..34c37be67031ba1cd0c73ae6deb01ed39141f483 --- /dev/null +++ b/spec/services/projects/hashed_storage/base_attachment_service_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Projects::HashedStorage::BaseAttachmentService do + let(:project) { create(:project, :repository, storage_version: 0, skip_disk_validation: true) } + + subject(:service) { described_class.new(project: project, old_disk_path: project.full_path, logger: nil) } + + describe '#old_disk_path' do + it { is_expected.to respond_to :old_disk_path } + end + + describe '#new_disk_path' do + it { is_expected.to respond_to :new_disk_path } + end + + describe '#skipped?' do + it { is_expected.to respond_to :skipped? } + end + + describe '#target_path_discardable?' do + it 'returns false' do + expect(subject.target_path_discardable?('something/something')).to be_falsey + end + end + + describe '#discard_path!' do + it 'renames target path adding a timestamp at the end' do + target_path = Dir.mktmpdir + expect(Dir.exist?(target_path)).to be_truthy + + Timecop.freeze do + suffix = Time.now.utc.to_i + subject.send(:discard_path!, target_path) + + expected_renamed_path = "#{target_path}-#{suffix}" + + expect(Dir.exist?(target_path)).to be_falsey + expect(Dir.exist?(expected_renamed_path)).to be_truthy + end + end + end + + describe '#move_folder!' do + context 'when old_path is not a directory' do + it 'adds information to the logger and returns true' do + Tempfile.create do |old_path| + new_path = "#{old_path}-new" + + expect(subject.send(:move_folder!, old_path, new_path)).to be_truthy + end + end + end + end +end diff --git a/spec/services/projects/hashed_storage/migrate_attachments_service_spec.rb b/spec/services/projects/hashed_storage/migrate_attachments_service_spec.rb index 32ebec318f20b6935f99e778d3d3502862b55ee0..ab9d2bdba8f985d4b1528bf033dda2ac8d1b6761 100644 --- a/spec/services/projects/hashed_storage/migrate_attachments_service_spec.rb +++ b/spec/services/projects/hashed_storage/migrate_attachments_service_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Projects::HashedStorage::MigrateAttachmentsService do - subject(:service) { described_class.new(project, project.full_path, logger: nil) } + subject(:service) { described_class.new(project: project, old_disk_path: project.full_path, logger: nil) } let(:project) { create(:project, :repository, storage_version: 1, skip_disk_validation: true) } let(:legacy_storage) { Storage::LegacyProject.new(project) } @@ -72,7 +72,23 @@ describe Projects::HashedStorage::MigrateAttachmentsService do FileUtils.mkdir_p(base_path(hashed_storage)) end - it 'raises AttachmentCannotMoveError' do + it 'succeed when target is empty' do + expect { service.execute }.not_to raise_error + end + + it 'succeed when target include only discardable items' do + Projects::HashedStorage::MigrateAttachmentsService::DISCARDABLE_PATHS.each do |path_fragment| + discardable_path = File.join(base_path(hashed_storage), path_fragment) + FileUtils.mkdir_p(discardable_path) + end + + expect { service.execute }.not_to raise_error + end + + it 'raises AttachmentCannotMoveError when there are non discardable items on target path' do + not_discardable_path = File.join(base_path(hashed_storage), 'something') + FileUtils.mkdir_p(not_discardable_path) + expect(FileUtils).not_to receive(:mv).with(base_path(legacy_storage), base_path(hashed_storage)) expect { service.execute }.to raise_error(Projects::HashedStorage::AttachmentCannotMoveError) @@ -100,6 +116,18 @@ describe Projects::HashedStorage::MigrateAttachmentsService do end end + context '#target_path_discardable?' do + it 'returns true when it include only items on the discardable list' do + hashed_attachments_path = File.join(base_path(hashed_storage)) + Projects::HashedStorage::MigrateAttachmentsService::DISCARDABLE_PATHS.each do |path_fragment| + discardable_path = File.join(hashed_attachments_path, path_fragment) + FileUtils.mkdir_p(discardable_path) + end + + expect(service.target_path_discardable?(hashed_attachments_path)).to be_truthy + end + end + def base_path(storage) File.join(FileUploader.root, storage.disk_path) end diff --git a/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb b/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb index 70785c606a57da9853caca4e033da953eb8009bf..132b895fc354847d45f80c9f96a2f0c71388c349 100644 --- a/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb +++ b/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb @@ -10,7 +10,7 @@ describe Projects::HashedStorage::MigrateRepositoryService do let(:legacy_storage) { Storage::LegacyProject.new(project) } let(:hashed_storage) { Storage::HashedProject.new(project) } - subject(:service) { described_class.new(project, project.disk_path) } + subject(:service) { described_class.new(project: project, old_disk_path: project.disk_path) } describe '#execute' do let(:old_disk_path) { legacy_storage.disk_path } diff --git a/spec/services/projects/hashed_storage/migration_service_spec.rb b/spec/services/projects/hashed_storage/migration_service_spec.rb index e3191cd7ebcbe5b9a462e7b6481e686f7ed6c800..f3ac26e776139cc9ab9cc0d268562e39815a0848 100644 --- a/spec/services/projects/hashed_storage/migration_service_spec.rb +++ b/spec/services/projects/hashed_storage/migration_service_spec.rb @@ -10,13 +10,14 @@ describe Projects::HashedStorage::MigrationService do describe '#execute' do context 'repository migration' do - let(:repository_service) { Projects::HashedStorage::MigrateRepositoryService.new(project, project.full_path, logger: logger) } + let(:repository_service) do + Projects::HashedStorage::MigrateRepositoryService.new(project: project, + old_disk_path: project.full_path, + logger: logger) + end it 'delegates migration to Projects::HashedStorage::MigrateRepositoryService' do - expect(Projects::HashedStorage::MigrateRepositoryService) - .to receive(:new) - .with(project, project.full_path, logger: logger) - .and_return(repository_service) + expect(service).to receive(:migrate_repository_service).and_return(repository_service) expect(repository_service).to receive(:execute) service.execute @@ -31,13 +32,14 @@ describe Projects::HashedStorage::MigrationService do end context 'attachments migration' do - let(:attachments_service) { Projects::HashedStorage::MigrateAttachmentsService.new(project, project.full_path, logger: logger) } + let(:attachments_service) do + Projects::HashedStorage::MigrateAttachmentsService.new(project: project, + old_disk_path: project.full_path, + logger: logger) + end it 'delegates migration to Projects::HashedStorage::MigrateRepositoryService' do - expect(Projects::HashedStorage::MigrateAttachmentsService) - .to receive(:new) - .with(project, project.full_path, logger: logger) - .and_return(attachments_service) + expect(service).to receive(:migrate_attachments_service).and_return(attachments_service) expect(attachments_service).to receive(:execute) service.execute diff --git a/spec/services/projects/hashed_storage/rollback_attachments_service_spec.rb b/spec/services/projects/hashed_storage/rollback_attachments_service_spec.rb index 815c85e08665cd45a533fd773744829fd0e50fa3..c2ba9626f41477fd4ab3daa50389453451e0e3ae 100644 --- a/spec/services/projects/hashed_storage/rollback_attachments_service_spec.rb +++ b/spec/services/projects/hashed_storage/rollback_attachments_service_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Projects::HashedStorage::RollbackAttachmentsService do - subject(:service) { described_class.new(project, logger: nil) } + subject(:service) { described_class.new(project: project, old_disk_path: project.disk_path, logger: nil) } let(:project) { create(:project, :repository, skip_disk_validation: true) } let(:legacy_storage) { Storage::LegacyProject.new(project) } diff --git a/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb b/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb index 3ca9ee5bee5582ea3881097d427ed74429c4c1c1..97c7c0af946c7c9ff56ae1c50bf5b3d8475dc773 100644 --- a/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb +++ b/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb @@ -10,7 +10,7 @@ describe Projects::HashedStorage::RollbackRepositoryService, :clean_gitlab_redis let(:legacy_storage) { Storage::LegacyProject.new(project) } let(:hashed_storage) { Storage::HashedProject.new(project) } - subject(:service) { described_class.new(project, project.disk_path) } + subject(:service) { described_class.new(project: project, old_disk_path: project.disk_path) } describe '#execute' do let(:old_disk_path) { hashed_storage.disk_path } diff --git a/spec/services/projects/hashed_storage/rollback_service_spec.rb b/spec/services/projects/hashed_storage/rollback_service_spec.rb index 427d1535559623be1727eb88272e550a6f474024..48d4eac9eb7f053d8bc350b60ab2dfe7ea5f7404 100644 --- a/spec/services/projects/hashed_storage/rollback_service_spec.rb +++ b/spec/services/projects/hashed_storage/rollback_service_spec.rb @@ -6,17 +6,15 @@ describe Projects::HashedStorage::RollbackService do let(:project) { create(:project, :empty_repo, :wiki_repo) } let(:logger) { double } - subject(:service) { described_class.new(project, project.full_path, logger: logger) } + subject(:service) { described_class.new(project, project.disk_path, logger: logger) } describe '#execute' do context 'attachments rollback' do let(:attachments_service_class) { Projects::HashedStorage::RollbackAttachmentsService } - let(:attachments_service) { attachments_service_class.new(project, logger: logger) } + let(:attachments_service) { attachments_service_class.new(project: project, old_disk_path: project.disk_path, logger: logger) } it 'delegates rollback to Projects::HashedStorage::RollbackAttachmentsService' do - expect(attachments_service_class).to receive(:new) - .with(project, logger: logger) - .and_return(attachments_service) + expect(service).to receive(:rollback_attachments_service).and_return(attachments_service) expect(attachments_service).to receive(:execute) service.execute @@ -31,15 +29,12 @@ describe Projects::HashedStorage::RollbackService do end context 'repository rollback' do + let(:project) { create(:project, :empty_repo, :wiki_repo, storage_version: ::Project::HASHED_STORAGE_FEATURES[:repository]) } let(:repository_service_class) { Projects::HashedStorage::RollbackRepositoryService } - let(:repository_service) { repository_service_class.new(project, project.full_path, logger: logger) } + let(:repository_service) { repository_service_class.new(project: project, old_disk_path: project.disk_path, logger: logger) } it 'delegates rollback to RollbackRepositoryService' do - project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository] - - expect(repository_service_class).to receive(:new) - .with(project, project.full_path, logger: logger) - .and_return(repository_service) + expect(service).to receive(:rollback_repository_service).and_return(repository_service) expect(repository_service).to receive(:execute) service.execute diff --git a/spec/services/projects/import_export/export_service_spec.rb b/spec/services/projects/import_export/export_service_spec.rb index 146d656c909bbcc17985017658d00807cfad12ca..a557e61da787f87ec91071257de951e7f14acb09 100644 --- a/spec/services/projects/import_export/export_service_spec.rb +++ b/spec/services/projects/import_export/export_service_spec.rb @@ -66,7 +66,7 @@ describe Projects::ImportExport::ExportService do end it 'saves the project in the file system' do - expect(Gitlab::ImportExport::Saver).to receive(:save).with(project: project, shared: shared) + expect(Gitlab::ImportExport::Saver).to receive(:save).with(exportable: project, shared: shared) service.execute end diff --git a/spec/services/projects/lfs_pointers/lfs_link_service_spec.rb b/spec/services/projects/lfs_pointers/lfs_link_service_spec.rb index 66233787d3a922ff4513a54d23af89fecab2c601..aca59079b3c8cad853296c16ec077b0f4a1f323d 100644 --- a/spec/services/projects/lfs_pointers/lfs_link_service_spec.rb +++ b/spec/services/projects/lfs_pointers/lfs_link_service_spec.rb @@ -16,6 +16,13 @@ describe Projects::LfsPointers::LfsLinkService do end describe '#execute' do + it 'raises an error when trying to link too many objects at once' do + oids = Array.new(described_class::MAX_OIDS) { |i| "oid-#{i}" } + oids << 'the straw' + + expect { subject.execute(oids) }.to raise_error(described_class::TooManyOidsError) + end + it 'links existing lfs objects to the project' do expect(project.all_lfs_objects.count).to eq 2 @@ -28,7 +35,7 @@ describe Projects::LfsPointers::LfsLinkService do it 'returns linked oids' do linked = lfs_objects_project.map(&:lfs_object).map(&:oid) << new_lfs_object.oid - expect(subject.execute(new_oid_list.keys)).to eq linked + expect(subject.execute(new_oid_list.keys)).to contain_exactly(*linked) end it 'links in batches' do @@ -48,5 +55,26 @@ describe Projects::LfsPointers::LfsLinkService do expect(project.all_lfs_objects.count).to eq 9 expect(linked.size).to eq 7 end + + it 'only queries for the batch that will be processed', :aggregate_failures do + stub_const("#{described_class}::BATCH_SIZE", 1) + oids = %w(one two) + + expect(LfsObject).to receive(:where).with(oid: %w(one)).once.and_call_original + expect(LfsObject).to receive(:where).with(oid: %w(two)).once.and_call_original + + subject.execute(oids) + end + + it 'only queries 3 times' do + # make sure that we don't count the queries in the setup + new_oid_list + + # These are repeated for each batch of oids: maximum (MAX_OIDS / BATCH_SIZE) times + # 1. Load the batch of lfs object ids that we might know already + # 2. Load the objects that have not been linked to the project yet + # 3. Insert the lfs_objects_projects for that batch + expect { subject.execute(new_oid_list.keys) }.not_to exceed_query_limit(3) + end end end diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index 31bd0f0f8369d9e974bdf98cd391cec6192be036..c848a5397e1fd6446cda5ea7b739d7444ca9cf80 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -151,7 +151,7 @@ describe Projects::UpdateService do context 'when we update project but not enabling a wiki' do it 'does not try to create an empty wiki' do - Gitlab::Shell.new.rm_directory(project.repository_storage, project.wiki.path) + TestEnv.rm_storage_dir(project.repository_storage, project.wiki.path) result = update_project(project, user, { name: 'test1' }) @@ -172,7 +172,7 @@ describe Projects::UpdateService do context 'when enabling a wiki' do it 'creates a wiki' do project.project_feature.update(wiki_access_level: ProjectFeature::DISABLED) - Gitlab::Shell.new.rm_directory(project.repository_storage, project.wiki.path) + TestEnv.rm_storage_dir(project.repository_storage, project.wiki.path) result = update_project(project, user, project_feature_attributes: { wiki_access_level: ProjectFeature::ENABLED }) diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 83101add72499228bbacaed2b61a805be83ce6f9..e2ed7581ad4032a9a3858b0e7937577e690429ed 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -115,51 +115,36 @@ describe SystemNoteService do end describe '.merge_when_pipeline_succeeds' do - let(:pipeline) { build(:ci_pipeline_without_jobs )} - let(:noteable) do - create(:merge_request, source_project: project, target_project: project) - end - - subject { described_class.merge_when_pipeline_succeeds(noteable, project, author, pipeline.sha) } + it 'calls MergeRequestsService' do + sha = double - it_behaves_like 'a system note' do - let(:action) { 'merge' } - end + expect_next_instance_of(::SystemNotes::MergeRequestsService) do |service| + expect(service).to receive(:merge_when_pipeline_succeeds).with(sha) + end - it "posts the 'merge when pipeline succeeds' system note" do - expect(subject.note).to match(%r{enabled an automatic merge when the pipeline for (\w+/\w+@)?\h{40} succeeds}) + described_class.merge_when_pipeline_succeeds(noteable, project, author, sha) end end describe '.cancel_merge_when_pipeline_succeeds' do - let(:noteable) do - create(:merge_request, source_project: project, target_project: project) - end - - subject { described_class.cancel_merge_when_pipeline_succeeds(noteable, project, author) } - - it_behaves_like 'a system note' do - let(:action) { 'merge' } - end + it 'calls MergeRequestsService' do + expect_next_instance_of(::SystemNotes::MergeRequestsService) do |service| + expect(service).to receive(:cancel_merge_when_pipeline_succeeds) + end - it "posts the 'merge when pipeline succeeds' system note" do - expect(subject.note).to eq "canceled the automatic merge" + described_class.cancel_merge_when_pipeline_succeeds(noteable, project, author) end end describe '.abort_merge_when_pipeline_succeeds' do - let(:noteable) do - create(:merge_request, source_project: project, target_project: project) - end + it 'calls MergeRequestsService' do + reason = double - subject { described_class.abort_merge_when_pipeline_succeeds(noteable, project, author, 'merge request was closed') } - - it_behaves_like 'a system note' do - let(:action) { 'merge' } - end + expect_next_instance_of(::SystemNotes::MergeRequestsService) do |service| + expect(service).to receive(:abort_merge_when_pipeline_succeeds).with(reason) + end - it "posts the 'merge when pipeline succeeds' system note" do - expect(subject.note).to eq "aborted the automatic merge because merge request was closed" + described_class.abort_merge_when_pipeline_succeeds(noteable, project, author, reason) end end @@ -196,77 +181,55 @@ describe SystemNoteService do end describe '.change_branch' do - subject { described_class.change_branch(noteable, project, author, 'target', old_branch, new_branch) } - - let(:old_branch) { 'old_branch'} - let(:new_branch) { 'new_branch'} - - it_behaves_like 'a system note' do - let(:action) { 'branch' } - end + it 'calls MergeRequestsService' do + old_branch = double + new_branch = double + branch_type = double - context 'when target branch name changed' do - it 'sets the note text' do - expect(subject.note).to eq "changed target branch from `#{old_branch}` to `#{new_branch}`" + expect_next_instance_of(::SystemNotes::MergeRequestsService) do |service| + expect(service).to receive(:change_branch).with(branch_type, old_branch, new_branch) end + + described_class.change_branch(noteable, project, author, branch_type, old_branch, new_branch) end end describe '.change_branch_presence' do - subject { described_class.change_branch_presence(noteable, project, author, :source, 'feature', :delete) } - - it_behaves_like 'a system note' do - let(:action) { 'branch' } - end + it 'calls MergeRequestsService' do + presence = double + branch = double + branch_type = double - context 'when source branch deleted' do - it 'sets the note text' do - expect(subject.note).to eq "deleted source branch `feature`" + expect_next_instance_of(::SystemNotes::MergeRequestsService) do |service| + expect(service).to receive(:change_branch_presence).with(branch_type, branch, presence) end + + described_class.change_branch_presence(noteable, project, author, branch_type, branch, presence) end end describe '.new_issue_branch' do - let(:branch) { '1-mepmep' } + it 'calls MergeRequestsService' do + branch = double + branch_project = double - subject { described_class.new_issue_branch(noteable, project, author, branch, branch_project: branch_project) } - - shared_examples_for 'a system note for new issue branch' do - it_behaves_like 'a system note' do - let(:action) { 'branch' } - end - - context 'when a branch is created from the new branch button' do - it 'sets the note text' do - expect(subject.note).to start_with("created branch [`#{branch}`]") - end + expect_next_instance_of(::SystemNotes::MergeRequestsService) do |service| + expect(service).to receive(:new_issue_branch).with(branch, branch_project: branch_project) end - end - context 'branch_project is set' do - let(:branch_project) { create(:project, :repository) } - - it_behaves_like 'a system note for new issue branch' - end - - context 'branch_project is not set' do - let(:branch_project) { nil } - - it_behaves_like 'a system note for new issue branch' + described_class.new_issue_branch(noteable, project, author, branch, branch_project: branch_project) end end describe '.new_merge_request' do - subject { described_class.new_merge_request(noteable, project, author, merge_request) } - - let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + it 'calls MergeRequestsService' do + merge_request = double - it_behaves_like 'a system note' do - let(:action) { 'merge' } - end + expect_next_instance_of(::SystemNotes::MergeRequestsService) do |service| + expect(service).to receive(:new_merge_request).with(merge_request) + end - it 'sets the new merge request note text' do - expect(subject.note).to eq("created merge request #{merge_request.to_reference(project)} to address this issue") + described_class.new_merge_request(noteable, project, author, merge_request) end end @@ -642,57 +605,24 @@ describe SystemNoteService do end describe '.handle_merge_request_wip' do - context 'adding wip note' do - let(:noteable) { create(:merge_request, source_project: project, title: 'WIP Lorem ipsum') } - - subject { described_class.handle_merge_request_wip(noteable, project, author) } - - it_behaves_like 'a system note' do - let(:action) { 'title' } + it 'calls MergeRequestsService' do + expect_next_instance_of(::SystemNotes::MergeRequestsService) do |service| + expect(service).to receive(:handle_merge_request_wip) end - it 'sets the note text' do - expect(subject.note).to eq 'marked as a **Work In Progress**' - end - end - - context 'removing wip note' do - let(:noteable) { create(:merge_request, source_project: project, title: 'Lorem ipsum') } - - subject { described_class.handle_merge_request_wip(noteable, project, author) } - - it_behaves_like 'a system note' do - let(:action) { 'title' } - end - - it 'sets the note text' do - expect(subject.note).to eq 'unmarked as a **Work In Progress**' - end + described_class.handle_merge_request_wip(noteable, project, author) end end describe '.add_merge_request_wip_from_commit' do - let(:noteable) do - create(:merge_request, source_project: project, target_project: project) - end - - subject do - described_class.add_merge_request_wip_from_commit( - noteable, - project, - author, - noteable.diff_head_commit - ) - end + it 'calls MergeRequestsService' do + commit = double - it_behaves_like 'a system note' do - let(:action) { 'title' } - end + expect_next_instance_of(::SystemNotes::MergeRequestsService) do |service| + expect(service).to receive(:add_merge_request_wip_from_commit).with(commit) + end - it "posts the 'marked as a Work In Progress from commit' system note" do - expect(subject.note).to match( - /marked as a \*\*Work In Progress\*\* from #{Commit.reference_pattern}/ - ) + described_class.add_merge_request_wip_from_commit(noteable, project, author, commit) end end @@ -709,75 +639,25 @@ describe SystemNoteService do end describe '.resolve_all_discussions' do - let(:noteable) { create(:merge_request, source_project: project, target_project: project) } - - subject { described_class.resolve_all_discussions(noteable, project, author) } - - it_behaves_like 'a system note' do - let(:action) { 'discussion' } - end + it 'calls MergeRequestsService' do + expect_next_instance_of(::SystemNotes::MergeRequestsService) do |service| + expect(service).to receive(:resolve_all_discussions) + end - it 'sets the note text' do - expect(subject.note).to eq 'resolved all threads' + described_class.resolve_all_discussions(noteable, project, author) end end describe '.diff_discussion_outdated' do - let(:discussion) { create(:diff_note_on_merge_request, project: project).to_discussion } - let(:merge_request) { discussion.noteable } - let(:change_position) { discussion.position } + it 'calls MergeRequestsService' do + discussion = double + change_position = double - def reloaded_merge_request - MergeRequest.find(merge_request.id) - end - - subject { described_class.diff_discussion_outdated(discussion, project, author, change_position) } - - it_behaves_like 'a system note' do - let(:expected_noteable) { discussion.first_note.noteable } - let(:action) { 'outdated' } - end - - context 'when the change_position is valid for the discussion' do - it 'creates a new note in the discussion' do - # we need to completely rebuild the merge request object, or the `@discussions` on the merge request are not reloaded. - expect { subject }.to change { reloaded_merge_request.discussions.first.notes.size }.by(1) - end - - it 'links to the diff in the system note' do - diff_id = merge_request.merge_request_diff.id - line_code = change_position.line_code(project.repository) - link = diffs_project_merge_request_path(project, merge_request, diff_id: diff_id, anchor: line_code) - - expect(subject.note).to eq("changed this line in [version 1 of the diff](#{link})") + expect_next_instance_of(::SystemNotes::MergeRequestsService) do |service| + expect(service).to receive(:diff_discussion_outdated).with(discussion, change_position) end - context 'discussion is on an image' do - let(:discussion) { create(:image_diff_note_on_merge_request, project: project).to_discussion } - - it 'links to the diff in the system note' do - diff_id = merge_request.merge_request_diff.id - file_hash = change_position.file_hash - link = diffs_project_merge_request_path(project, merge_request, diff_id: diff_id, anchor: file_hash) - - expect(subject.note).to eq("changed this file in [version 1 of the diff](#{link})") - end - end - end - - context 'when the change_position does not point to a valid version' do - before do - allow(merge_request).to receive(:version_params_for).and_return(nil) - end - - it 'creates a new note in the discussion' do - # we need to completely rebuild the merge request object, or the `@discussions` on the merge request are not reloaded. - expect { subject }.to change { reloaded_merge_request.discussions.first.notes.size }.by(1) - end - - it 'does not create a link' do - expect(subject.note).to eq('changed this line in version 1 of the diff') - end + described_class.diff_discussion_outdated(discussion, project, author, change_position) end end diff --git a/spec/services/system_notes/issuables_service_spec.rb b/spec/services/system_notes/issuables_service_spec.rb index 5023abad4cd425c65291a9d24ea22b4c842d62c6..ba484d95c9cc57cbf9758313630573683586cb21 100644 --- a/spec/services/system_notes/issuables_service_spec.rb +++ b/spec/services/system_notes/issuables_service_spec.rb @@ -395,7 +395,7 @@ describe ::SystemNotes::IssuablesService do end end - context 'commit with cross-reference from fork' do + context 'commit with cross-reference from fork', :sidekiq_might_not_need_inline do let(:author2) { create(:project_member, :reporter, user: create(:user), project: project).user } let(:forked_project) { fork_project(project, author2, repository: true) } let(:commit2) { forked_project.commit } diff --git a/spec/services/system_notes/merge_requests_service_spec.rb b/spec/services/system_notes/merge_requests_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6d2473e8c03429a25a587ddb9fa30c0b347bd7de --- /dev/null +++ b/spec/services/system_notes/merge_requests_service_spec.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ::SystemNotes::MergeRequestsService do + include Gitlab::Routing + + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :repository, group: group) } + let_it_be(:author) { create(:user) } + + let(:noteable) { create(:merge_request, source_project: project, target_project: project) } + + let(:service) { described_class.new(noteable: noteable, project: project, author: author) } + + describe '.merge_when_pipeline_succeeds' do + let(:pipeline) { build(:ci_pipeline) } + + subject { service.merge_when_pipeline_succeeds(pipeline.sha) } + + it_behaves_like 'a system note' do + let(:action) { 'merge' } + end + + it "posts the 'merge when pipeline succeeds' system note" do + expect(subject.note).to match(%r{enabled an automatic merge when the pipeline for (\w+/\w+@)?\h{40} succeeds}) + end + end + + describe '.cancel_merge_when_pipeline_succeeds' do + subject { service.cancel_merge_when_pipeline_succeeds } + + it_behaves_like 'a system note' do + let(:action) { 'merge' } + end + + it "posts the 'merge when pipeline succeeds' system note" do + expect(subject.note).to eq "canceled the automatic merge" + end + end + + describe '.abort_merge_when_pipeline_succeeds' do + subject { service.abort_merge_when_pipeline_succeeds('merge request was closed') } + + it_behaves_like 'a system note' do + let(:action) { 'merge' } + end + + it "posts the 'merge when pipeline succeeds' system note" do + expect(subject.note).to eq "aborted the automatic merge because merge request was closed" + end + end + + describe '.handle_merge_request_wip' do + context 'adding wip note' do + let(:noteable) { create(:merge_request, source_project: project, title: 'WIP Lorem ipsum') } + + subject { service.handle_merge_request_wip } + + it_behaves_like 'a system note' do + let(:action) { 'title' } + end + + it 'sets the note text' do + expect(subject.note).to eq 'marked as a **Work In Progress**' + end + end + + context 'removing wip note' do + subject { service.handle_merge_request_wip } + + it_behaves_like 'a system note' do + let(:action) { 'title' } + end + + it 'sets the note text' do + expect(subject.note).to eq 'unmarked as a **Work In Progress**' + end + end + end + + describe '.add_merge_request_wip_from_commit' do + subject { service.add_merge_request_wip_from_commit(noteable.diff_head_commit) } + + it_behaves_like 'a system note' do + let(:action) { 'title' } + end + + it "posts the 'marked as a Work In Progress from commit' system note" do + expect(subject.note).to match( + /marked as a \*\*Work In Progress\*\* from #{Commit.reference_pattern}/ + ) + end + end + + describe '.resolve_all_discussions' do + subject { service.resolve_all_discussions } + + it_behaves_like 'a system note' do + let(:action) { 'discussion' } + end + + it 'sets the note text' do + expect(subject.note).to eq 'resolved all threads' + end + end + + describe '.diff_discussion_outdated' do + let(:discussion) { create(:diff_note_on_merge_request, project: project).to_discussion } + let(:merge_request) { discussion.noteable } + let(:change_position) { discussion.position } + + def reloaded_merge_request + MergeRequest.find(merge_request.id) + end + + let(:service) { described_class.new(project: project, author: author) } + + subject { service.diff_discussion_outdated(discussion, change_position) } + + it_behaves_like 'a system note' do + let(:expected_noteable) { discussion.first_note.noteable } + let(:action) { 'outdated' } + end + + context 'when the change_position is valid for the discussion' do + it 'creates a new note in the discussion' do + # we need to completely rebuild the merge request object, or the `@discussions` on the merge request are not reloaded. + expect { subject }.to change { reloaded_merge_request.discussions.first.notes.size }.by(1) + end + + it 'links to the diff in the system note' do + diff_id = merge_request.merge_request_diff.id + line_code = change_position.line_code(project.repository) + link = diffs_project_merge_request_path(project, merge_request, diff_id: diff_id, anchor: line_code) + + expect(subject.note).to eq("changed this line in [version 1 of the diff](#{link})") + end + + context 'discussion is on an image' do + let(:discussion) { create(:image_diff_note_on_merge_request, project: project).to_discussion } + + it 'links to the diff in the system note' do + diff_id = merge_request.merge_request_diff.id + file_hash = change_position.file_hash + link = diffs_project_merge_request_path(project, merge_request, diff_id: diff_id, anchor: file_hash) + + expect(subject.note).to eq("changed this file in [version 1 of the diff](#{link})") + end + end + end + + context 'when the change_position does not point to a valid version' do + before do + allow(merge_request).to receive(:version_params_for).and_return(nil) + end + + it 'creates a new note in the discussion' do + # we need to completely rebuild the merge request object, or the `@discussions` on the merge request are not reloaded. + expect { subject }.to change { reloaded_merge_request.discussions.first.notes.size }.by(1) + end + + it 'does not create a link' do + expect(subject.note).to eq('changed this line in version 1 of the diff') + end + end + end + + describe '.change_branch' do + subject { service.change_branch('target', old_branch, new_branch) } + + let(:old_branch) { 'old_branch'} + let(:new_branch) { 'new_branch'} + + it_behaves_like 'a system note' do + let(:action) { 'branch' } + end + + context 'when target branch name changed' do + it 'sets the note text' do + expect(subject.note).to eq "changed target branch from `#{old_branch}` to `#{new_branch}`" + end + end + end + + describe '.change_branch_presence' do + subject { service.change_branch_presence(:source, 'feature', :delete) } + + it_behaves_like 'a system note' do + let(:action) { 'branch' } + end + + context 'when source branch deleted' do + it 'sets the note text' do + expect(subject.note).to eq "deleted source branch `feature`" + end + end + end + + describe '.new_issue_branch' do + let(:branch) { '1-mepmep' } + + subject { service.new_issue_branch(branch, branch_project: branch_project) } + + shared_examples_for 'a system note for new issue branch' do + it_behaves_like 'a system note' do + let(:action) { 'branch' } + end + + context 'when a branch is created from the new branch button' do + it 'sets the note text' do + expect(subject.note).to start_with("created branch [`#{branch}`]") + end + end + end + + context 'branch_project is set' do + let(:branch_project) { create(:project, :repository) } + + it_behaves_like 'a system note for new issue branch' + end + + context 'branch_project is not set' do + let(:branch_project) { nil } + + it_behaves_like 'a system note for new issue branch' + end + end + + describe '.new_merge_request' do + subject { service.new_merge_request(merge_request) } + + let!(:merge_request) { create(:merge_request, source_project: project, source_branch: generate(:branch), target_project: project) } + + it_behaves_like 'a system note' do + let(:action) { 'merge' } + end + + it 'sets the new merge request note text' do + expect(subject.note).to eq("created merge request #{merge_request.to_reference(project)} to address this issue") + end + end +end diff --git a/spec/services/users/signup_service_spec.rb b/spec/services/users/signup_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7d3cd614142fb0652dee3b744099d2403251be35 --- /dev/null +++ b/spec/services/users/signup_service_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Users::SignupService do + let(:user) { create(:user, setup_for_company: true) } + + describe '#execute' do + context 'when updating name' do + it 'updates the name attribute' do + result = update_user(user, name: 'New Name') + + expect(result).to eq(status: :success) + expect(user.reload.name).to eq('New Name') + end + + it 'returns an error result when name is missing' do + result = update_user(user, name: '') + + expect(user.reload.name).not_to be_blank + expect(result[:status]).to eq(:error) + expect(result[:message]).to include("Name can't be blank") + end + end + + context 'when updating role' do + it 'updates the role attribute' do + result = update_user(user, role: 'development_team_lead') + + expect(result).to eq(status: :success) + expect(user.reload.role).to eq('development_team_lead') + end + + it 'returns an error result when role is missing' do + result = update_user(user, role: '') + + expect(user.reload.role).not_to be_blank + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq("Role can't be blank") + end + end + + context 'when updating setup_for_company' do + it 'updates the setup_for_company attribute' do + result = update_user(user, setup_for_company: 'false') + + expect(result).to eq(status: :success) + expect(user.reload.setup_for_company).to be(false) + end + + it 'returns an error result when setup_for_company is missing' do + result = update_user(user, setup_for_company: '') + + expect(user.reload.setup_for_company).not_to be_blank + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq("Setup for company can't be blank") + end + end + + def update_user(user, opts) + described_class.new(user, opts).execute + end + end +end diff --git a/spec/services/zoom_notes_service_spec.rb b/spec/services/zoom_notes_service_spec.rb deleted file mode 100644 index 419ecf3f374f0d7c33d979c3714dc5e2c10e2bf1..0000000000000000000000000000000000000000 --- a/spec/services/zoom_notes_service_spec.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe ZoomNotesService do - describe '#execute' do - let(:issue) { OpenStruct.new(description: description) } - let(:project) { Object.new } - let(:user) { Object.new } - let(:description) { 'an issue description' } - let(:old_description) { nil } - - subject { described_class.new(issue, project, user, old_description: old_description) } - - shared_examples 'no notifications' do - it "doesn't create notifications" do - expect(SystemNoteService).not_to receive(:zoom_link_added) - expect(SystemNoteService).not_to receive(:zoom_link_removed) - - subject.execute - end - end - - it_behaves_like 'no notifications' - - context 'when the zoom link exists in both description and old_description' do - let(:description) { 'a changed issue description https://zoom.us/j/123' } - let(:old_description) { 'an issue description https://zoom.us/j/123' } - - it_behaves_like 'no notifications' - end - - context "when the zoom link doesn't exist in both description and old_description" do - let(:description) { 'a changed issue description' } - let(:old_description) { 'an issue description' } - - it_behaves_like 'no notifications' - end - - context 'when description == old_description' do - let(:old_description) { 'an issue description' } - - it_behaves_like 'no notifications' - end - - context 'when the description contains a zoom link and old_description is nil' do - let(:description) { 'a changed issue description https://zoom.us/j/123' } - - it 'creates a zoom_link_added notification' do - expect(SystemNoteService).to receive(:zoom_link_added).with(issue, project, user) - expect(SystemNoteService).not_to receive(:zoom_link_removed) - - subject.execute - end - end - - context 'when the zoom link has been added to the description' do - let(:description) { 'a changed issue description https://zoom.us/j/123' } - let(:old_description) { 'an issue description' } - - it 'creates a zoom_link_added notification' do - expect(SystemNoteService).to receive(:zoom_link_added).with(issue, project, user) - expect(SystemNoteService).not_to receive(:zoom_link_removed) - - subject.execute - end - end - - context 'when the zoom link has been removed from the description' do - let(:description) { 'a changed issue description' } - let(:old_description) { 'an issue description https://zoom.us/j/123' } - - it 'creates a zoom_link_removed notification' do - expect(SystemNoteService).not_to receive(:zoom_link_added).with(issue, project, user) - expect(SystemNoteService).to receive(:zoom_link_removed) - - subject.execute - end - end - end -end diff --git a/spec/sidekiq/cron/job_gem_dependency_spec.rb b/spec/sidekiq/cron/job_gem_dependency_spec.rb index 2e7de75fd08534ea548a4b126172829d54abb875..20347b4d306c326fbdebb4caa1a5526e437e9228 100644 --- a/spec/sidekiq/cron/job_gem_dependency_spec.rb +++ b/spec/sidekiq/cron/job_gem_dependency_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Sidekiq::Cron::Job do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 7a5e570558ea414327ea751978627d1e07068d42..d7533f99683df2ed7eb43e3cf8c1097a42005bf0 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -66,6 +66,11 @@ RSpec.configure do |config| config.infer_spec_type_from_file_location! config.full_backtrace = !!ENV['CI'] + unless ENV['CI'] + # Re-run failures locally with `--only-failures` + config.example_status_persistence_file_path = './spec/examples.txt' + end + config.define_derived_metadata(file_path: %r{(ee)?/spec/.+_spec\.rb\z}) do |metadata| location = metadata[:location] diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index 7b3b966bd508976e485b96986209e88160c0ae2e..2bd4750dffab74cbd908e48ae82834de2fbae3c1 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -101,8 +101,12 @@ RSpec.configure do |config| config.after(:example, :js) do |example| # when a test fails, display any messages in the browser's console - if example.exception + # but fail don't add the message if the failure is a pending test that got + # fixed. If we raised the `JSException` the fixed test would be marked as + # failed again. + if example.exception && !example.exception.is_a?(RSpec::Core::Pending::PendingExampleFixedError) console = page.driver.browser.manage.logs.get(:browser)&.reject { |log| log.message =~ JS_CONSOLE_FILTER } + if console.present? message = "Unexpected browser console output:\n" + console.map(&:message).join("\n") raise JSConsoleError, message diff --git a/spec/support/controllers/ldap_omniauth_callbacks_controller_shared_context.rb b/spec/support/controllers/ldap_omniauth_callbacks_controller_shared_context.rb index d636c1cf6cd0370436d6f51c08c93f91ea5c08d4..8a8a2f714bc28c3a4659c75a9cfd467b19d2e77b 100644 --- a/spec/support/controllers/ldap_omniauth_callbacks_controller_shared_context.rb +++ b/spec/support/controllers/ldap_omniauth_callbacks_controller_shared_context.rb @@ -10,6 +10,8 @@ shared_context 'Ldap::OmniauthCallbacksController' do let(:provider) { 'ldapmain' } let(:valid_login?) { true } let(:user) { create(:omniauth_user, extern_uid: uid, provider: provider) } + let(:ldap_setting_defaults) { { enabled: true, servers: ldap_server_config } } + let(:ldap_settings) { ldap_setting_defaults } let(:ldap_server_config) do { main: ldap_config_defaults(:main) } end @@ -23,7 +25,7 @@ shared_context 'Ldap::OmniauthCallbacksController' do end before do - stub_ldap_setting(enabled: true, servers: ldap_server_config) + stub_ldap_setting(ldap_settings) described_class.define_providers! Rails.application.reload_routes! @@ -36,4 +38,8 @@ shared_context 'Ldap::OmniauthCallbacksController' do after do Rails.application.env_config['omniauth.auth'] = @original_env_config_omniauth_auth end + + after(:all) do + Rails.application.reload_routes! + end end diff --git a/spec/support/cycle_analytics_helpers/test_generation.rb b/spec/support/cycle_analytics_helpers/test_generation.rb index c57abbd96c6542c0a4cb43461a456a65a272e6dd..2096ec90c5bab9dc0e534a2d2abdf629f84bac65 100644 --- a/spec/support/cycle_analytics_helpers/test_generation.rb +++ b/spec/support/cycle_analytics_helpers/test_generation.rb @@ -29,7 +29,7 @@ module CycleAnalyticsHelpers scenarios.each do |start_time_conditions, end_time_conditions| context "start condition: #{start_time_conditions.map(&:first).to_sentence}" do context "end condition: #{end_time_conditions.map(&:first).to_sentence}" do - it "finds the median of available durations between the two conditions" do + it "finds the median of available durations between the two conditions", :sidekiq_might_not_need_inline do time_differences = Array.new(5) do |index| data = data_fn[self] start_time = (index * 10).days.from_now diff --git a/spec/support/generate-seed-repo-rb b/spec/support/generate-seed-repo-rb index bee9d41937619d9cc89e9945718dbf17305f2ae8..b63ff7147ec84f341ae6c869e0f1d49e3f3348ce 100755 --- a/spec/support/generate-seed-repo-rb +++ b/spec/support/generate-seed-repo-rb @@ -1,4 +1,5 @@ #!/usr/bin/env ruby +# frozen_string_literal: true # # # generate-seed-repo-rb # @@ -15,9 +16,9 @@ require 'erb' require 'tempfile' -SOURCE = File.expand_path('gitlab-git-test.git', __dir__).freeze -SCRIPT_NAME = 'generate-seed-repo-rb'.freeze -REPO_NAME = 'gitlab-git-test.git'.freeze +SOURCE = File.expand_path('gitlab-git-test.git', __dir__) +SCRIPT_NAME = 'generate-seed-repo-rb' +REPO_NAME = 'gitlab-git-test.git' def main Dir.mktmpdir do |dir| diff --git a/spec/support/helpers/access_matchers_helpers.rb b/spec/support/helpers/access_matchers_helpers.rb new file mode 100644 index 0000000000000000000000000000000000000000..9100f245d362d6921ccc90135105b2502f6f479e --- /dev/null +++ b/spec/support/helpers/access_matchers_helpers.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module AccessMatchersHelpers + USER_ACCESSOR_METHOD_NAME = 'user' + + def provide_user(role, membership = nil) + case role + when :admin + create(:admin) + when :auditor + create(:user, :auditor) + when :user + create(:user) + when :external + create(:user, :external) + when :visitor, :anonymous + nil + when User + role + when *Gitlab::Access.sym_options_with_owner.keys # owner, maintainer, developer, reporter, guest + raise ArgumentError, "cannot provide #{role} when membership reference is blank" unless membership + + provide_user_by_membership(role, membership) + else + raise ArgumentError, "cannot provide user of an unknown role #{role}" + end + end + + def provide_user_by_membership(role, membership) + if role == :owner && membership.owner + membership.owner + else + create(:user).tap do |user| + membership.public_send(:"add_#{role}", user) + end + end + end + + def raise_if_non_block_expectation!(actual) + raise ArgumentError, 'This matcher supports block expectations only.' unless actual.is_a?(Proc) + end + + def update_owner(objects, user) + return unless objects + + objects.each do |object| + if object.respond_to?(:owner) + object.update_attribute(:owner, user) + elsif object.respond_to?(:user) + object.update_attribute(:user, user) + else + raise ArgumentError, "cannot own this object #{object}" + end + end + end + + def patch_example_group(user) + return if user.nil? # for anonymous users + + # This call is evaluated in context of ExampleGroup instance in which the matcher is called. Overrides the `user` + # (or defined by `method_name`) method generated by `let` definition in example group before it's used by `subject`. + # This override is per concrete example only because the example group class gets re-created for each example. + instance_eval(<<~CODE, __FILE__, __LINE__ + 1) + if instance_variable_get(:@__#{USER_ACCESSOR_METHOD_NAME}_patched) + raise ArgumentError, 'An access matcher be_allowed_for/be_denied_for can be used only once per example (`it` block)' + end + instance_variable_set(:@__#{USER_ACCESSOR_METHOD_NAME}_patched, true) + + def #{USER_ACCESSOR_METHOD_NAME} + @#{USER_ACCESSOR_METHOD_NAME} ||= User.find(#{user.id}) + end + CODE + end + + def prepare_matcher_environment(role, membership, owned_objects) + user = provide_user(role, membership) + + if user + update_owner(owned_objects, user) + patch_example_group(user) + end + end + + def run_matcher(action, role, membership, owned_objects) + raise_if_non_block_expectation!(action) + + prepare_matcher_environment(role, membership, owned_objects) + + if block_given? + yield action + else + action.call + end + end +end diff --git a/spec/support/helpers/cycle_analytics_helpers.rb b/spec/support/helpers/cycle_analytics_helpers.rb index a604359942f46becca876bd80501fc74134fd018..d101b092e7dd568839d9ca0cc8c767aee2833c6e 100644 --- a/spec/support/helpers/cycle_analytics_helpers.rb +++ b/spec/support/helpers/cycle_analytics_helpers.rb @@ -77,7 +77,7 @@ module CycleAnalyticsHelpers .new(project, user) .closed_by_merge_requests(issue) - merge_requests.each { |merge_request| MergeRequests::MergeService.new(project, user).execute(merge_request) } + merge_requests.each { |merge_request| MergeRequests::MergeService.new(project, user, sha: merge_request.diff_head_sha).execute(merge_request) } end def deploy_master(user, project, environment: 'production') diff --git a/spec/support/helpers/filtered_search_helpers.rb b/spec/support/helpers/filtered_search_helpers.rb index 39c818b1763fbd38fdd96e377c9756cdea6462ea..5dc87c369316066499b83fc62b33eb3f144d2552 100644 --- a/spec/support/helpers/filtered_search_helpers.rb +++ b/spec/support/helpers/filtered_search_helpers.rb @@ -114,6 +114,10 @@ module FilteredSearchHelpers create_token('Milestone', milestone_name, symbol) end + def release_token(release_tag = nil) + create_token('Release', release_tag) + end + def label_token(label_name = nil, has_symbol = true) symbol = has_symbol ? '~' : nil create_token('Label', label_name, symbol) diff --git a/spec/support/helpers/grafana_api_helpers.rb b/spec/support/helpers/grafana_api_helpers.rb new file mode 100644 index 0000000000000000000000000000000000000000..e47b1a808f2ffbf1124dece4438b3a894d5f7a1c --- /dev/null +++ b/spec/support/helpers/grafana_api_helpers.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module GrafanaApiHelpers + def valid_grafana_dashboard_link(base_url) + base_url + + '/d/XDaNK6amz/gitlab-omnibus-redis' \ + '?from=1570397739557&to=1570484139557' \ + '&var-instance=localhost:9121&panelId=8' + end + + def stub_dashboard_request(base_url, path: '/api/dashboards/uid/XDaNK6amz', body: nil) + body ||= fixture_file('grafana/dashboard_response.json') + + stub_request(:get, "#{base_url}#{path}") + .to_return( + status: 200, + body: body, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_datasource_request(base_url, path: '/api/datasources/name/GitLab%20Omnibus', body: nil) + body ||= fixture_file('grafana/datasource_response.json') + + stub_request(:get, "#{base_url}#{path}") + .to_return( + status: 200, + body: body, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_all_grafana_proxy_requests(base_url) + stub_request(:any, /#{base_url}\/api\/datasources\/proxy/) + .to_return( + status: 200, + body: fixture_file('grafana/proxy_response.json'), + headers: { 'Content-Type' => 'application/json' } + ) + end +end diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index 6fb1d279456cd7ad95387a87cb42d9e88f8ea8dc..80a3f7df05f57cb0049954a000333b6ddcb2e9c0 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -37,9 +37,12 @@ module GraphqlHelpers # BatchLoader::GraphQL returns a wrapper, so we need to :sync in order # to get the actual values def batch_sync(max_queries: nil, &blk) - result = batch(max_queries: nil, &blk) + wrapper = proc do + lazy_vals = yield + lazy_vals.is_a?(Array) ? lazy_vals.map(&:sync) : lazy_vals&.sync + end - result.is_a?(Array) ? result.map(&:sync) : result&.sync + batch(max_queries: max_queries, &wrapper) end def graphql_query_for(name, attributes = {}, fields = nil) @@ -157,7 +160,13 @@ module GraphqlHelpers def attributes_to_graphql(attributes) attributes.map do |name, value| - "#{GraphqlHelpers.fieldnamerize(name.to_s)}: \"#{value}\"" + value_str = if value.is_a?(Array) + '["' + value.join('","') + '"]' + else + "\"#{value}\"" + end + + "#{GraphqlHelpers.fieldnamerize(name.to_s)}: #{value_str}" end.join(", ") end @@ -282,6 +291,12 @@ module GraphqlHelpers def allow_high_graphql_recursion allow_any_instance_of(Gitlab::Graphql::QueryAnalyzers::RecursionAnalyzer).to receive(:recursion_threshold).and_return 1000 end + + def node_array(data, extract_attribute = nil) + data.map do |item| + extract_attribute ? item['node'][extract_attribute] : item['node'] + end + 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 e74dbca4f935e323779d2a3c845bd40accf3dbf3..677aef57661e18c4915953b57f1833928ed30301 100644 --- a/spec/support/helpers/kubernetes_helpers.rb +++ b/spec/support/helpers/kubernetes_helpers.rb @@ -16,7 +16,7 @@ module KubernetesHelpers end def kube_logs_response - kube_response(kube_logs_body) + { body: kube_logs_body } end def kube_deployments_response @@ -319,10 +319,10 @@ module KubernetesHelpers } end - def kube_knative_services_body(legacy_knative: false, **options) + def kube_knative_services_body(**options) { "kind" => "List", - "items" => [legacy_knative ? knative_05_service(options) : kube_service(options)] + "items" => [knative_07_service(options)] } end @@ -398,77 +398,171 @@ module KubernetesHelpers } end - def kube_service(name: "kubetest", namespace: "default", domain: "example.com") - { - "metadata" => { - "creationTimestamp" => "2018-11-21T06:16:33Z", - "name" => name, - "namespace" => namespace, - "selfLink" => "/apis/serving.knative.dev/v1alpha1/namespaces/#{namespace}/services/#{name}" - }, + # noinspection RubyStringKeysInHashInspection + def knative_06_service(name: 'kubetest', namespace: 'default', domain: 'example.com', description: 'a knative service', environment: 'production') + { "apiVersion" => "serving.knative.dev/v1alpha1", + "kind" => "Service", + "metadata" => + { "annotations" => + { "serving.knative.dev/creator" => "system:serviceaccount:#{namespace}:#{namespace}-service-account", + "serving.knative.dev/lastModifier" => "system:serviceaccount:#{namespace}:#{namespace}-service-account" }, + "creationTimestamp" => "2019-10-22T21:19:20Z", + "generation" => 1, + "labels" => { "service" => name }, + "name" => name, + "namespace" => namespace, + "resourceVersion" => "6042", + "selfLink" => "/apis/serving.knative.dev/v1alpha1/namespaces/#{namespace}/services/#{name}", + "uid" => "9c7f63d0-f511-11e9-8815-42010a80002f" }, "spec" => { - "generation" => 2 + "runLatest" => { + "configuration" => { + "revisionTemplate" => { + "metadata" => { + "annotations" => { "Description" => description }, + "creationTimestamp" => "2019-10-22T21:19:20Z", + "labels" => { "service" => name } + }, + "spec" => { + "container" => { + "env" => [{ "name" => "timestamp", "value" => "2019-10-22 21:19:20" }], + "image" => "image_name", + "name" => "", + "resources" => {} + }, + "timeoutSeconds" => 300 + } + } + } + } }, "status" => { - "url" => "http://#{name}.#{namespace}.#{domain}", "address" => { - "url" => "#{name}.#{namespace}.svc.cluster.local" + "hostname" => "#{name}.#{namespace}.svc.cluster.local", + "url" => "http://#{name}.#{namespace}.svc.cluster.local" }, - "latestCreatedRevisionName" => "#{name}-00002", - "latestReadyRevisionName" => "#{name}-00002", - "observedGeneration" => 2 - } - } - end - - def knative_05_service(name: "kubetest", namespace: "default", domain: "example.com") - { - "metadata" => { - "creationTimestamp" => "2018-11-21T06:16:33Z", - "name" => name, - "namespace" => namespace, - "selfLink" => "/apis/serving.knative.dev/v1alpha1/namespaces/#{namespace}/services/#{name}" - }, - "spec" => { - "generation" => 2 - }, - "status" => { + "conditions" => + [{ "lastTransitionTime" => "2019-10-22T21:20:25Z", "status" => "True", "type" => "ConfigurationsReady" }, + { "lastTransitionTime" => "2019-10-22T21:20:25Z", "status" => "True", "type" => "Ready" }, + { "lastTransitionTime" => "2019-10-22T21:20:25Z", "status" => "True", "type" => "RoutesReady" }], "domain" => "#{name}.#{namespace}.#{domain}", "domainInternal" => "#{name}.#{namespace}.svc.cluster.local", - "latestCreatedRevisionName" => "#{name}-00002", - "latestReadyRevisionName" => "#{name}-00002", - "observedGeneration" => 2 - } - } - end - - def kube_service_full(name: "kubetest", namespace: "kube-ns", domain: "example.com") - { - "metadata" => { - "creationTimestamp" => "2018-11-21T06:16:33Z", - "name" => name, - "namespace" => namespace, - "selfLink" => "/apis/serving.knative.dev/v1alpha1/namespaces/#{namespace}/services/#{name}", - "annotation" => { - "description" => "This is a test description" - } + "latestCreatedRevisionName" => "#{name}-bskx6", + "latestReadyRevisionName" => "#{name}-bskx6", + "observedGeneration" => 1, + "traffic" => [{ "latestRevision" => true, "percent" => 100, "revisionName" => "#{name}-bskx6" }], + "url" => "http://#{name}.#{namespace}.#{domain}" }, + "environment_scope" => environment, + "cluster_id" => 9, + "podcount" => 0 } + end + + # noinspection RubyStringKeysInHashInspection + def knative_07_service(name: 'kubetest', namespace: 'default', domain: 'example.com', description: 'a knative service', environment: 'production') + { "apiVersion" => "serving.knative.dev/v1alpha1", + "kind" => "Service", + "metadata" => + { "annotations" => + { "serving.knative.dev/creator" => "system:serviceaccount:#{namespace}:#{namespace}-service-account", + "serving.knative.dev/lastModifier" => "system:serviceaccount:#{namespace}:#{namespace}-service-account" }, + "creationTimestamp" => "2019-10-22T21:19:13Z", + "generation" => 1, + "labels" => { "service" => name }, + "name" => name, + "namespace" => namespace, + "resourceVersion" => "289726", + "selfLink" => "/apis/serving.knative.dev/v1alpha1/namespaces/#{namespace}/services/#{name}", + "uid" => "988349fa-f511-11e9-9ea1-42010a80005e" }, "spec" => { - "generation" => 2, - "build" => { - "template" => "go-1.10.3" + "template" => { + "metadata" => { + "annotations" => { "Description" => description }, + "creationTimestamp" => "2019-10-22T21:19:12Z", + "labels" => { "service" => name } + }, + "spec" => { + "containers" => [{ + "env" => + [{ "name" => "timestamp", "value" => "2019-10-22 21:19:12" }], + "image" => "image_name", + "name" => "user-container", + "resources" => {} + }], + "timeoutSeconds" => 300 + } + }, + "traffic" => [{ "latestRevision" => true, "percent" => 100 }] + }, + "status" => + { "address" => { "url" => "http://#{name}.#{namespace}.svc.cluster.local" }, + "conditions" => + [{ "lastTransitionTime" => "2019-10-22T21:20:15Z", "status" => "True", "type" => "ConfigurationsReady" }, + { "lastTransitionTime" => "2019-10-22T21:20:15Z", "status" => "True", "type" => "Ready" }, + { "lastTransitionTime" => "2019-10-22T21:20:15Z", "status" => "True", "type" => "RoutesReady" }], + "latestCreatedRevisionName" => "#{name}-92tsj", + "latestReadyRevisionName" => "#{name}-92tsj", + "observedGeneration" => 1, + "traffic" => [{ "latestRevision" => true, "percent" => 100, "revisionName" => "#{name}-92tsj" }], + "url" => "http://#{name}.#{namespace}.#{domain}" }, + "environment_scope" => environment, + "cluster_id" => 5, + "podcount" => 0 } + end + + # noinspection RubyStringKeysInHashInspection + def knative_05_service(name: 'kubetest', namespace: 'default', domain: 'example.com', description: 'a knative service', environment: 'production') + { "apiVersion" => "serving.knative.dev/v1alpha1", + "kind" => "Service", + "metadata" => + { "annotations" => + { "serving.knative.dev/creator" => "system:serviceaccount:#{namespace}:#{namespace}-service-account", + "serving.knative.dev/lastModifier" => "system:serviceaccount:#{namespace}:#{namespace}-service-account" }, + "creationTimestamp" => "2019-10-22T21:19:19Z", + "generation" => 1, + "labels" => { "service" => name }, + "name" => name, + "namespace" => namespace, + "resourceVersion" => "330390", + "selfLink" => "/apis/serving.knative.dev/v1alpha1/namespaces/#{namespace}/services/#{name}", + "uid" => "9c710da6-f511-11e9-9ba0-42010a800161" }, + "spec" => { + "runLatest" => { + "configuration" => { + "revisionTemplate" => { + "metadata" => { + "annotations" => { "Description" => description }, + "creationTimestamp" => "2019-10-22T21:19:19Z", + "labels" => { "service" => name } + }, + "spec" => { + "container" => { + "env" => [{ "name" => "timestamp", "value" => "2019-10-22 21:19:19" }], + "image" => "image_name", + "name" => "", + "resources" => { "requests" => { "cpu" => "400m" } } + }, + "timeoutSeconds" => 300 + } + } + } } }, - "status" => { - "url" => "http://#{name}.#{namespace}.#{domain}", - "address" => { - "url" => "#{name}.#{namespace}.svc.cluster.local" - }, - "latestCreatedRevisionName" => "#{name}-00002", - "latestReadyRevisionName" => "#{name}-00002", - "observedGeneration" => 2 - } - } + "status" => + { "address" => { "hostname" => "#{name}.#{namespace}.svc.cluster.local" }, + "conditions" => + [{ "lastTransitionTime" => "2019-10-22T21:20:24Z", "status" => "True", "type" => "ConfigurationsReady" }, + { "lastTransitionTime" => "2019-10-22T21:20:24Z", "status" => "True", "type" => "Ready" }, + { "lastTransitionTime" => "2019-10-22T21:20:24Z", "status" => "True", "type" => "RoutesReady" }], + "domain" => "#{name}.#{namespace}.#{domain}", + "domainInternal" => "#{name}.#{namespace}.svc.cluster.local", + "latestCreatedRevisionName" => "#{name}-58qgr", + "latestReadyRevisionName" => "#{name}-58qgr", + "observedGeneration" => 1, + "traffic" => [{ "percent" => 100, "revisionName" => "#{name}-58qgr" }] }, + "environment_scope" => environment, + "cluster_id" => 8, + "podcount" => 0 } end def kube_terminals(service, pod) diff --git a/spec/support/helpers/login_helpers.rb b/spec/support/helpers/login_helpers.rb index 7d5896e4eebfd77636e1eafe2f60098cfd2d6b11..1d42f26ad3eb35a18efd5696a88ec4cd37489b62 100644 --- a/spec/support/helpers/login_helpers.rb +++ b/spec/support/helpers/login_helpers.rb @@ -53,7 +53,7 @@ module LoginHelpers fill_in 'password', with: user.password - click_button 'Enter admin mode' + click_button 'Enter Admin Mode' end def gitlab_sign_in_via(provider, user, uid, saml_response = nil) diff --git a/spec/support/helpers/smime_helper.rb b/spec/support/helpers/smime_helper.rb index 656b3e196ba77fe2ec9ab7c2f3af880f16777721..3ad19cd3da0df9267e95c2295aa61cca4d67cc8b 100644 --- a/spec/support/helpers/smime_helper.rb +++ b/spec/support/helpers/smime_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module SmimeHelper include OpenSSL diff --git a/spec/support/helpers/stub_experiments.rb b/spec/support/helpers/stub_experiments.rb index ed868e22c6edde4fb5157f8340b0557e2995343d..7a5a188ab4dc78dc1f72dea80d81777b316ec84e 100644 --- a/spec/support/helpers/stub_experiments.rb +++ b/spec/support/helpers/stub_experiments.rb @@ -9,7 +9,19 @@ module StubExperiments # - `stub_experiment(signup_flow: false)` ... Disable `signup_flow` experiment globally. def stub_experiment(experiments) experiments.each do |experiment_key, enabled| - allow(Gitlab::Experimentation).to receive(:enabled?).with(experiment_key, any_args) { enabled } + allow(Gitlab::Experimentation).to receive(:enabled?).with(experiment_key) { enabled } + end + end + + # Stub Experiment for user with `key: true/false` + # + # @param [Hash] experiment where key is feature name and value is boolean whether enabled or not. + # + # Examples + # - `stub_experiment_for_user(signup_flow: false)` ... Disable `signup_flow` experiment for user. + def stub_experiment_for_user(experiments) + experiments.each do |experiment_key, enabled| + allow(Gitlab::Experimentation).to receive(:enabled_for_user?).with(experiment_key, anything) { enabled } end end end diff --git a/spec/support/helpers/stub_gitlab_calls.rb b/spec/support/helpers/stub_gitlab_calls.rb index e3dde888277a7c43047139b4abf25d7300b8e853..fe343da783807de206bca096117f04fbbc4ca172 100644 --- a/spec/support/helpers/stub_gitlab_calls.rb +++ b/spec/support/helpers/stub_gitlab_calls.rb @@ -18,8 +18,13 @@ module StubGitlabCalls stub_ci_pipeline_yaml_file(gitlab_ci_yaml) end - def stub_ci_pipeline_yaml_file(ci_yaml) - allow_any_instance_of(Ci::Pipeline).to receive(:ci_yaml_file) { ci_yaml } + def stub_ci_pipeline_yaml_file(ci_yaml_content) + allow_any_instance_of(Repository).to receive(:gitlab_ci_yml_for).and_return(ci_yaml_content) + + # Ensure we don't hit auto-devops when config not found in repository + unless ci_yaml_content + allow_any_instance_of(Project).to receive(:auto_devops_enabled?).and_return(false) + end end def stub_pipeline_modified_paths(pipeline, modified_paths) diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb index a409dd2ef26ff56d2b63aa7b32033f09120984e2..6a23875f1031874d38cb46666528d53f63fd09d9 100644 --- a/spec/support/helpers/test_env.rb +++ b/spec/support/helpers/test_env.rb @@ -148,8 +148,6 @@ module TestEnv end def setup_gitaly - socket_path = Gitlab::GitalyClient.address('default').sub(/\Aunix:/, '') - gitaly_dir = File.dirname(socket_path) install_gitaly_args = [gitaly_dir, repos_path, gitaly_url].compact.join(',') component_timed_setup('Gitaly', @@ -162,8 +160,16 @@ module TestEnv end end + def gitaly_socket_path + Gitlab::GitalyClient.address('default').sub(/\Aunix:/, '') + end + + def gitaly_dir + File.dirname(gitaly_socket_path) + end + def start_gitaly(gitaly_dir) - if ENV['CI'].present? + if ci? # Gitaly has been spawned outside this process already return end @@ -172,8 +178,13 @@ module TestEnv spawn_script = Rails.root.join('scripts/gitaly-test-spawn').to_s Bundler.with_original_env do - raise "gitaly spawn failed" unless system(spawn_script) + unless system(spawn_script) + message = 'gitaly spawn failed' + message += " (try `rm -rf #{gitaly_dir}` ?)" unless ci? + raise message + end end + @gitaly_pid = Integer(File.read('tmp/tests/gitaly.pid')) Kernel.at_exit { stop_gitaly } @@ -243,6 +254,22 @@ module TestEnv FileUtils.chmod_R 0755, target_repo_path end + def rm_storage_dir(storage, dir) + Gitlab::GitalyClient::StorageSettings.allow_disk_access do + repos_path = Gitlab.config.repositories.storages[storage].legacy_disk_path + target_repo_refs_path = File.join(repos_path, dir) + FileUtils.remove_dir(target_repo_refs_path) + end + rescue Errno::ENOENT + end + + def storage_dir_exists?(storage, dir) + Gitlab::GitalyClient::StorageSettings.allow_disk_access do + repos_path = Gitlab.config.repositories.storages[storage].legacy_disk_path + File.exist?(File.join(repos_path, dir)) + end + end + def create_bare_repository(path) FileUtils.mkdir_p(path) @@ -370,7 +397,7 @@ module TestEnv ensure_component_dir_name_is_correct!(component, install_dir) # On CI, once installed, components never need update - return if File.exist?(install_dir) && ENV['CI'] + return if File.exist?(install_dir) && ci? if component_needs_update?(install_dir, version) # Cleanup the component entirely to ensure we start fresh @@ -391,6 +418,10 @@ module TestEnv puts " #{component} set up in #{Time.now - start} seconds...\n" end + def ci? + ENV['CI'].present? + end + def ensure_component_dir_name_is_correct!(component, path) actual_component_dir_name = File.basename(path) expected_component_dir_name = component.parameterize diff --git a/spec/support/import_export/common_util.rb b/spec/support/import_export/common_util.rb index ac6840dbcfcf4cdab97effdc05f065910bb23c04..4e149c9fa5464facbd4d71169e571288da85e47e 100644 --- a/spec/support/import_export/common_util.rb +++ b/spec/support/import_export/common_util.rb @@ -8,5 +8,12 @@ module ImportExport File.open("#{tmpdir}/test", 'w') { |file| file.write("test") } FileUtils.ln_s("#{tmpdir}/test", "#{tmpdir}/#{symlink_name}") end + + def setup_import_export_config(name, prefix = nil) + export_path = [prefix, 'spec', 'fixtures', 'lib', 'gitlab', 'import_export', name].compact + export_path = File.join(*export_path) + + allow_any_instance_of(Gitlab::ImportExport).to receive(:export_path) { export_path } + end end end diff --git a/spec/support/matchers/access_matchers_for_request.rb b/spec/support/matchers/access_matchers_for_request.rb new file mode 100644 index 0000000000000000000000000000000000000000..9b80bf8562c24ac2d4bc0a9fac2f1feeddd8f2b1 --- /dev/null +++ b/spec/support/matchers/access_matchers_for_request.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +# AccessMatchersForRequest +# +# Matchers to test the access permissions for requests specs (most useful for API tests). +module AccessMatchersForRequest + extend RSpec::Matchers::DSL + include AccessMatchersHelpers + + EXPECTED_STATUS_CODES_ALLOWED = [200, 201, 204, 302, 304].freeze + EXPECTED_STATUS_CODES_DENIED = [401, 403, 404].freeze + + def description_for(role, type, expected, result) + "be #{type} for #{role} role. Expected status code: any of #{expected.join(', ')} Got: #{result}" + end + + matcher :be_allowed_for do |role| + match do |action| + # methods called in this and negated block are being run in context of ExampleGroup + # (not matcher) instance so we have to pass data via local vars + + run_matcher(action, role, @membership, @owned_objects) + + EXPECTED_STATUS_CODES_ALLOWED.include?(response.status) + end + + match_when_negated do |action| + run_matcher(action, role, @membership, @owned_objects) + + EXPECTED_STATUS_CODES_DENIED.include?(response.status) + end + + chain :of do |membership| + @membership = membership + end + + chain :own do |*owned_objects| + @owned_objects = owned_objects + end + + failure_message do + "expected this action to #{description_for(role, 'allowed', EXPECTED_STATUS_CODES_ALLOWED, response.status)}" + end + + failure_message_when_negated do + "expected this action to #{description_for(role, 'denied', EXPECTED_STATUS_CODES_DENIED, response.status)}" + end + + supports_block_expectations + end + + RSpec::Matchers.define_negated_matcher :be_denied_for, :be_allowed_for +end diff --git a/spec/support/matchers/access_matchers_generic.rb b/spec/support/matchers/access_matchers_generic.rb new file mode 100644 index 0000000000000000000000000000000000000000..13955750f4f2fd078f391cc9825093792f9ba0e2 --- /dev/null +++ b/spec/support/matchers/access_matchers_generic.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# AccessMatchersGeneric +# +# Matchers to test the access permissions for service classes or other generic pieces of business logic. +module AccessMatchersGeneric + extend RSpec::Matchers::DSL + include AccessMatchersHelpers + + ERROR_CLASS = Gitlab::Access::AccessDeniedError + + def error_message(error) + str = error.class.name + str += ": #{error.message}" if error.message != error.class.name + str + end + + def error_expectation_message(allowed, error) + if allowed + "Expected to raise nothing but #{error_message(error)} was raised." + else + "Expected to raise #{ERROR_CLASS} but nothing was raised." + end + end + + def description_for(role, type, error) + allowed = type == 'allowed' + "be #{type} for #{role} role. #{error_expectation_message(allowed, error)}" + end + + matcher :be_allowed_for do |role| + match do |action| + # methods called in this and negated block are being run in context of ExampleGroup + # (not matcher) instance so we have to pass data via local vars + + run_matcher(action, role, @membership, @owned_objects) do |action| + action.call + rescue => e + @error = e + raise unless e.is_a?(ERROR_CLASS) + end + + @error.nil? + end + + chain :of do |membership| + @membership = membership + end + + chain :own do |*owned_objects| + @owned_objects = owned_objects + end + + failure_message do + "expected this action to #{description_for(role, 'allowed', @error)}" + end + + failure_message_when_negated do + "expected this action to #{description_for(role, 'denied', @error)}" + end + + supports_block_expectations + end + + RSpec::Matchers.define_negated_matcher :be_denied_for, :be_allowed_for +end diff --git a/spec/support/matchers/db_schema_matchers.rb b/spec/support/matchers/db_schema_matchers.rb new file mode 100644 index 0000000000000000000000000000000000000000..55843b7bb490c15e9e02d59eb4b3f2467ac613a6 --- /dev/null +++ b/spec/support/matchers/db_schema_matchers.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +EXPECTED_SMALLINT_LIMIT = 2 + +RSpec::Matchers.define :use_smallint_for_enums do |enums| + match do |actual| + @failing_enums = enums.select do |enum| + enum_type = actual.type_for_attribute(enum) + actual_limit = enum_type.send(:subtype).limit + actual_limit != EXPECTED_SMALLINT_LIMIT + end + @failing_enums.empty? + end + + failure_message do + <<~FAILURE_MESSAGE + Expected #{actual.name} enums: #{failing_enums.join(', ')} to use the smallint type. + + The smallint type is 2 bytes which is more than sufficient for an enum. + Using the smallint type would help us save space in the database. + To fix this, please add `limit: 2` in the migration file, for example: + + def change + add_column :ci_job_artifacts, :file_format, :integer, limit: 2 + end + FAILURE_MESSAGE + end + + def failing_enums + @failing_enums ||= [] + end +end diff --git a/spec/support/prepare-gitlab-git-test-for-commit b/spec/support/prepare-gitlab-git-test-for-commit index d08e3ba54811e49163bc63f110489e6f11ca0d8d..77c7f309312fa0b8294009af85ea4fb3de78daf4 100755 --- a/spec/support/prepare-gitlab-git-test-for-commit +++ b/spec/support/prepare-gitlab-git-test-for-commit @@ -1,4 +1,5 @@ #!/usr/bin/env ruby +# frozen_string_literal: true abort unless [ system('spec/support/generate-seed-repo-rb', out: 'spec/support/helpers/seed_repo.rb'), diff --git a/spec/support/shared_examples/ci/auto_merge_merge_requests_examples.rb b/spec/support/shared_examples/ci/auto_merge_merge_requests_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..c11448ffe0f99bfe88d902900f800ac6a610ac00 --- /dev/null +++ b/spec/support/shared_examples/ci/auto_merge_merge_requests_examples.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +shared_examples 'aborted merge requests for MWPS' do + let(:aborted_message) do + /aborted the automatic merge because target branch was updated/ + end + + it 'aborts auto_merge' do + expect(merge_request.auto_merge_enabled?).to be_falsey + expect(merge_request.notes.last.note).to match(aborted_message) + end + + it 'removes merge_user' do + expect(merge_request.merge_user).to be_nil + end + + it 'does not add todos for merge user' do + expect(user.todos.for_target(merge_request)).to be_empty + end + + it 'adds todos for merge author' do + expect(author.todos.for_target(merge_request)).to be_present.and be_all(&:pending?) + end +end + +shared_examples 'maintained merge requests for MWPS' do + it 'does not cancel auto merge' do + expect(merge_request.auto_merge_enabled?).to be_truthy + expect(merge_request.notes).to be_empty + end + + it 'does not change merge_user' do + expect(merge_request.merge_user).to eq(user) + end + + it 'does not add todos' do + expect(author.todos.for_target(merge_request)).to be_empty + expect(user.todos.for_target(merge_request)).to be_empty + end +end diff --git a/spec/support/shared_examples/container_repositories_shared_examples.rb b/spec/support/shared_examples/container_repositories_shared_examples.rb index 946b130fca2123a0aa41920e36a3fe6c1a52399f..b4f45ba9a00f171d00f88846182711dfb73be5fb 100644 --- a/spec/support/shared_examples/container_repositories_shared_examples.rb +++ b/spec/support/shared_examples/container_repositories_shared_examples.rb @@ -56,3 +56,11 @@ shared_examples 'returns repositories for allowed users' do |user_type, scope| end end end + +shared_examples 'a gitlab tracking event' do |category, action| + it "creates a gitlab tracking event #{action}" do + expect(Gitlab::Tracking).to receive(:event).with(category, action, {}) + + subject + end +end diff --git a/spec/support/shared_examples/cycle_analytics_event_shared_examples.rb b/spec/support/shared_examples/cycle_analytics_event_shared_examples.rb index dce1dbe1cd1c577d9666ea496749a793a9829ab1..028b8da94a6fe51242256eb19481d8db21f60b78 100644 --- a/spec/support/shared_examples/cycle_analytics_event_shared_examples.rb +++ b/spec/support/shared_examples/cycle_analytics_event_shared_examples.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true shared_examples_for 'cycle analytics event' do - let(:instance) { described_class.new({}) } + let(:params) { {} } + let(:instance) { described_class.new(params) } it { expect(described_class.name).to be_a_kind_of(String) } it { expect(described_class.identifier).to be_a_kind_of(Symbol) } diff --git a/spec/support/shared_examples/cycle_analytics_stage_shared_examples.rb b/spec/support/shared_examples/cycle_analytics_stage_shared_examples.rb index afa035d039a9812227e3d4e401299c22540c5fea..c781f72ff119f2cd20af0af70bd1907ad91a256b 100644 --- a/spec/support/shared_examples/cycle_analytics_stage_shared_examples.rb +++ b/spec/support/shared_examples/cycle_analytics_stage_shared_examples.rb @@ -10,6 +10,11 @@ shared_examples_for 'cycle analytics stage' do } end + describe 'associations' do + it { is_expected.to belong_to(:end_event_label) } + it { is_expected.to belong_to(:start_event_label) } + end + describe 'validation' do it 'is valid' do expect(described_class.new(valid_params)).to be_valid @@ -18,22 +23,22 @@ shared_examples_for 'cycle analytics stage' do it 'validates presence of parent' do stage = described_class.new(valid_params.except(:parent)) - expect(stage).not_to be_valid - expect(stage.errors.details[parent_name]).to eq([{ error: :blank }]) + expect(stage).to be_invalid + expect(stage.errors[parent_name]).to include("can't be blank") end it 'validates presence of start_event_identifier' do stage = described_class.new(valid_params.except(:start_event_identifier)) - expect(stage).not_to be_valid - expect(stage.errors.details[:start_event_identifier]).to eq([{ error: :blank }]) + expect(stage).to be_invalid + expect(stage.errors[:start_event_identifier]).to include("can't be blank") end it 'validates presence of end_event_identifier' do stage = described_class.new(valid_params.except(:end_event_identifier)) - expect(stage).not_to be_valid - expect(stage.errors.details[:end_event_identifier]).to eq([{ error: :blank }]) + expect(stage).to be_invalid + expect(stage.errors[:end_event_identifier]).to include("can't be blank") end it 'is invalid when end_event is not allowed for the given start_event' do @@ -43,8 +48,8 @@ shared_examples_for 'cycle analytics stage' do ) stage = described_class.new(invalid_params) - expect(stage).not_to be_valid - expect(stage.errors.details[:end_event]).to eq([{ error: :not_allowed_for_the_given_start_event }]) + expect(stage).to be_invalid + expect(stage.errors[:end_event]).to include(s_('CycleAnalytics|not allowed for the given start event')) end context 'disallows default stage names when creating custom stage' do @@ -105,3 +110,119 @@ shared_examples_for 'cycle analytics stage' do end end end + +shared_examples_for 'cycle analytics label based stage' do + context 'when creating label based event' do + context 'when the label id is not passed' do + it 'returns validation error when `start_event_label_id` is missing' do + stage = described_class.new({ + name: 'My Stage', + parent: parent, + start_event_identifier: :issue_label_added, + end_event_identifier: :issue_closed + }) + + expect(stage).to be_invalid + expect(stage.errors[:start_event_label]).to include("can't be blank") + end + + it 'returns validation error when `end_event_label_id` is missing' do + stage = described_class.new({ + name: 'My Stage', + parent: parent, + start_event_identifier: :issue_closed, + end_event_identifier: :issue_label_added + }) + + expect(stage).to be_invalid + expect(stage.errors[:end_event_label]).to include("can't be blank") + end + end + + context 'when group label is defined on the root group' do + it 'succeeds' do + stage = described_class.new({ + name: 'My Stage', + parent: parent, + start_event_identifier: :issue_label_added, + start_event_label: group_label, + end_event_identifier: :issue_closed + }) + + expect(stage).to be_valid + end + end + + context 'when subgroup is given' do + it 'succeeds' do + stage = described_class.new({ + name: 'My Stage', + parent: parent_in_subgroup, + start_event_identifier: :issue_label_added, + start_event_label: group_label, + end_event_identifier: :issue_closed + }) + + expect(stage).to be_valid + end + end + + context 'when label is defined for a different group' do + let(:error_message) { s_('CycleAnalyticsStage|is not available for the selected group') } + + it 'returns validation for `start_event_label`' do + stage = described_class.new({ + name: 'My Stage', + parent: parent_outside_of_group_label_scope, + start_event_identifier: :issue_label_added, + start_event_label: group_label, + end_event_identifier: :issue_closed + }) + + expect(stage).to be_invalid + expect(stage.errors[:start_event_label]).to include(error_message) + end + + it 'returns validation for `end_event_label`' do + stage = described_class.new({ + name: 'My Stage', + parent: parent_outside_of_group_label_scope, + start_event_identifier: :issue_closed, + end_event_identifier: :issue_label_added, + end_event_label: group_label + }) + + expect(stage).to be_invalid + expect(stage.errors[:end_event_label]).to include(error_message) + end + end + + context 'when `ProjectLabel is given' do + let_it_be(:label) { create(:label) } + + it 'raises error when `ProjectLabel` is given for `start_event_label`' do + params = { + name: 'My Stage', + parent: parent, + start_event_identifier: :issue_label_added, + start_event_label: label, + end_event_identifier: :issue_closed + } + + expect { described_class.new(params) }.to raise_error(ActiveRecord::AssociationTypeMismatch) + end + + it 'raises error when `ProjectLabel` is given for `end_event_label`' do + params = { + name: 'My Stage', + parent: parent, + start_event_identifier: :issue_closed, + end_event_identifier: :issue_label_added, + end_event_label: label + } + + expect { described_class.new(params) }.to raise_error(ActiveRecord::AssociationTypeMismatch) + end + end + end +end diff --git a/spec/support/shared_examples/features/archive_download_buttons_shared_examples.rb b/spec/support/shared_examples/features/archive_download_buttons_shared_examples.rb index 920fcbde4839f369b859329a9629652333cf3958..21c32c9c04adbbb0428c1fc46e148c2e639f62f8 100644 --- a/spec/support/shared_examples/features/archive_download_buttons_shared_examples.rb +++ b/spec/support/shared_examples/features/archive_download_buttons_shared_examples.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true shared_examples 'archive download buttons' do - let(:formats) { %w(zip tar.gz tar.bz2 tar) } let(:path_to_visit) { project_path(project) } let(:ref) { project.default_branch } @@ -13,7 +12,7 @@ shared_examples 'archive download buttons' do context 'private project' do it 'shows archive download buttons with external storage URL prepended and user token appended to their href' do - formats.each do |format| + Gitlab::Workhorse::ARCHIVE_FORMATS.each do |format| path = archive_path(project, ref, format) uri = URI('https://cdn.gitlab.com') uri.path = path @@ -28,7 +27,7 @@ shared_examples 'archive download buttons' do let(:project) { create(:project, :repository, :public) } it 'shows archive download buttons with external storage URL prepended to their href' do - formats.each do |format| + Gitlab::Workhorse::ARCHIVE_FORMATS.each do |format| path = archive_path(project, ref, format) uri = URI('https://cdn.gitlab.com') uri.path = path @@ -45,7 +44,7 @@ shared_examples 'archive download buttons' do end it 'shows default archive download buttons' do - formats.each do |format| + Gitlab::Workhorse::ARCHIVE_FORMATS.each do |format| path = archive_path(project, ref, format) expect(page).to have_link format, href: path diff --git a/spec/support/shared_examples/file_finder.rb b/spec/support/shared_examples/file_finder.rb index 984a06ccd1aec8ce0bf973f643f80adb5ea6888c..f4b28b94090e5bd98d7bda6d4bbd0fd1fea9ff30 100644 --- a/spec/support/shared_examples/file_finder.rb +++ b/spec/support/shared_examples/file_finder.rb @@ -4,19 +4,19 @@ shared_examples 'file finder' do let(:query) { 'files' } let(:search_results) { subject.find(query) } - it 'finds by name' do - blob = search_results.find { |blob| blob.filename == expected_file_by_name } + it 'finds by path' do + blob = search_results.find { |blob| blob.path == expected_file_by_path } - expect(blob.filename).to eq(expected_file_by_name) + expect(blob.path).to eq(expected_file_by_path) expect(blob).to be_a(Gitlab::Search::FoundBlob) expect(blob.ref).to eq(subject.ref) expect(blob.data).not_to be_empty end it 'finds by content' do - blob = search_results.find { |blob| blob.filename == expected_file_by_content } + blob = search_results.find { |blob| blob.path == expected_file_by_content } - expect(blob.filename).to eq(expected_file_by_content) + expect(blob.path).to eq(expected_file_by_content) expect(blob).to be_a(Gitlab::Search::FoundBlob) expect(blob.ref).to eq(subject.ref) expect(blob.data).not_to be_empty diff --git a/spec/support/shared_examples/graphql/connection_paged_nodes.rb b/spec/support/shared_examples/graphql/connection_paged_nodes.rb new file mode 100644 index 0000000000000000000000000000000000000000..830d2d2d4b113a8f861e38a30a0030721c4ec0be --- /dev/null +++ b/spec/support/shared_examples/graphql/connection_paged_nodes.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +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) + end + + it 'is a loaded memoized array' do + expect(paged_nodes).to be_an(Array) + expect(paged_nodes.object_id).to eq(paged_nodes.object_id) + end + + context 'when `first` is passed' do + let(:arguments) { { first: 2 } } + + it 'returns only the first elements' do + expect(paged_nodes).to contain_exactly(all_nodes.first, all_nodes.second) + end + end + + context 'when `last` is passed' do + let(:arguments) { { last: 2 } } + + it 'returns only the last elements' do + expect(paged_nodes).to contain_exactly(all_nodes[3], all_nodes[4]) + end + end +end diff --git a/spec/support/shared_examples/graphql/sort_enum_shared_examples.rb b/spec/support/shared_examples/graphql/sort_enum_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..becea9bcae12e48bad78e4bf6b89a4498e1151bd --- /dev/null +++ b/spec/support/shared_examples/graphql/sort_enum_shared_examples.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'common sort values' do + it 'exposes all the existing common sort values' do + expect(described_class.values.keys).to include(*%w[updated_desc updated_asc created_desc created_asc]) + end +end diff --git a/spec/support/shared_examples/lib/gitlab/ci/config/entry/key_validations_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/ci/config/entry/key_validations_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..b0b3e46332d5c56e022a9b1c2ada074f92945788 --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/ci/config/entry/key_validations_shared_examples.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'key entry validations' do |config_name| + shared_examples 'key with slash' do + it 'is invalid' do + expect(entry).not_to be_valid + end + + it 'reports errors with config value' do + expect(entry.errors).to include "#{config_name} config cannot contain the \"/\" character" + end + end + + shared_examples 'key with only dots' do + it 'is invalid' do + expect(entry).not_to be_valid + end + + it 'reports errors with config value' do + expect(entry.errors).to include "#{config_name} config cannot be \".\" or \"..\"" + end + end + + context 'when entry value contains slash' do + let(:config) { 'key/with/some/slashes' } + + it_behaves_like 'key with slash' + end + + context 'when entry value contains URI encoded slash (%2F)' do + let(:config) { 'key%2Fwith%2Fsome%2Fslashes' } + + it_behaves_like 'key with slash' + end + + context 'when entry value is a dot' do + let(:config) { '.' } + + it_behaves_like 'key with only dots' + end + + context 'when entry value is two dots' do + let(:config) { '..' } + + it_behaves_like 'key with only dots' + end + + context 'when entry value is a URI encoded dot (%2E)' do + let(:config) { '%2e' } + + it_behaves_like 'key with only dots' + end + + context 'when entry value is two URI encoded dots (%2E)' do + let(:config) { '%2E%2e' } + + it_behaves_like 'key with only dots' + end + + context 'when entry value is one dot and one URI encoded dot' do + let(:config) { '.%2e' } + + it_behaves_like 'key with only dots' + end + + context 'when key is a string' do + let(:config) { 'test' } + + describe '#value' do + it 'returns key value' do + expect(entry.value).to eq 'test' + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end +end diff --git a/spec/support/shared_examples/lib/gitlab/config/inheritable_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/config/inheritable_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..556d81133bccef8cc65ddd7991e68a9d83efdfb3 --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/config/inheritable_shared_examples.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'with inheritable CI config' do + using RSpec::Parameterized::TableSyntax + + let(:ignored_inheritable_columns) { [] } + + it 'does prepend an Inheritable mixin' do + expect(described_class).to include_module(Gitlab::Config::Entry::Inheritable) + end + + it 'all inheritable entries are covered' do + inheritable_entries = inheritable_class.nodes.keys + entries = described_class.nodes.keys + + expect(entries + ignored_inheritable_columns).to include( + *inheritable_entries) + end + + it 'all entries do have inherit flag' do + without_inherit_flag = described_class.nodes.map do |key, factory| + key if factory.inherit.nil? + end.compact + + expect(without_inherit_flag).to be_empty + end + + context 'for non-inheritable entries' do + where(:entry_key) do + described_class.nodes.map do |key, factory| + [key] unless factory.inherit + end.compact + end + + with_them do + it 'inheritable_class does not define entry' do + expect(inheritable_class.nodes).not_to include(entry_key) + end + end + end + + context 'for inheritable entries' do + where(:entry_key, :entry_class) do + described_class.nodes.map do |key, factory| + [key, factory.entry_class] if factory.inherit + end.compact + end + + with_them do + let(:specified) { double('deps_specified', 'specified?' => true, value: 'specified') } + let(:unspecified) { double('unspecified', 'specified?' => false) } + let(:inheritable) { double(inheritable_key, '[]' => unspecified) } + + let(:deps) do + if inheritable_key + double('deps', inheritable_key => inheritable, '[]' => unspecified) + else + inheritable + end + end + + it 'inheritable_class does define entry' do + expect(inheritable_class.nodes).to include(entry_key) + expect(inheritable_class.nodes[entry_key].entry_class).to eq(entry_class) + end + + context 'when is specified' do + it 'does inherit value' do + expect(inheritable).to receive('[]').with(entry_key).and_return(specified) + + entry.compose!(deps) + + expect(entry[entry_key]).to eq(specified) + end + + context 'when entry is specified' do + let(:entry_specified) do + double('entry_specified', 'specified?' => true, value: 'specified', errors: []) + end + + it 'does not inherit value' do + entry.send(:entries)[entry_key] = entry_specified + + allow(inheritable).to receive('[]').with(entry_key).and_return(specified) + + expect do + # we ignore exceptions as `#overwrite_entry` + # can raise exception on duplicates + entry.compose!(deps) rescue described_class::InheritError + end.not_to change { entry[entry_key] } + end + end + end + + context 'when inheritable does not specify' do + it 'does not inherit value' do + entry.compose!(deps) + + expect(entry[entry_key]).to be_a( + Gitlab::Config::Entry::Undefined) + end + end + end + 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 new file mode 100644 index 0000000000000000000000000000000000000000..80120629a32366d7ff30b161bf3f2e42037f8603 --- /dev/null +++ b/spec/support/shared_examples/merge_requests_rendering_a_single_diff_version.rb @@ -0,0 +1,17 @@ +# 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| + 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/models/cluster_application_helm_cert_examples.rb b/spec/support/shared_examples/models/cluster_application_helm_cert_examples.rb index 7ddb3b11c85ed2bac94076385f9321cab123d1b9..1c8c19acc7408b45367fa1b357725e6f1a414ad2 100644 --- a/spec/support/shared_examples/models/cluster_application_helm_cert_examples.rb +++ b/spec/support/shared_examples/models/cluster_application_helm_cert_examples.rb @@ -8,10 +8,6 @@ shared_examples 'cluster application helm specs' do |application_name| it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::DeleteCommand) } - it 'has the application name' do - expect(subject.name).to eq(application.name) - end - it 'has files' do expect(subject.files).to eq(application.files) end diff --git a/spec/support/shared_examples/models/concern/issuable_shared_examples.rb b/spec/support/shared_examples/models/concerns/issuable_shared_examples.rb similarity index 100% rename from spec/support/shared_examples/models/concern/issuable_shared_examples.rb rename to spec/support/shared_examples/models/concerns/issuable_shared_examples.rb diff --git a/spec/support/shared_examples/models/concerns/redactable_shared_examples.rb b/spec/support/shared_examples/models/concerns/redactable_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..c5c14901268fff1032a1c15d867798610a17bbde --- /dev/null +++ b/spec/support/shared_examples/models/concerns/redactable_shared_examples.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +shared_examples 'model with redactable field' do + it 'redacts unsubscribe token' do + model[field] = 'some text /sent_notifications/00000000000000000000000000000000/unsubscribe more text' + + model.save! + + expect(model[field]).to eq 'some text /sent_notifications/REDACTED/unsubscribe more text' + end + + it 'ignores not hexadecimal tokens' do + text = 'some text /sent_notifications/token/unsubscribe more text' + model[field] = text + + model.save! + + expect(model[field]).to eq text + end + + it 'ignores not matching texts' do + text = 'some text /sent_notifications/.*/unsubscribe more text' + model[field] = text + + model.save! + + expect(model[field]).to eq text + end + + it 'redacts the field when saving the model before creating markdown cache' do + model[field] = 'some text /sent_notifications/00000000000000000000000000000000/unsubscribe more text' + + model.save! + + expected = 'some text /sent_notifications/REDACTED/unsubscribe more text' + expect(model[field]).to eq expected + expect(model["#{field}_html"]).to eq "<p dir=\"auto\">#{expected}</p>" + end +end diff --git a/spec/support/shared_examples/models/with_uploads_shared_examples.rb b/spec/support/shared_examples/models/with_uploads_shared_examples.rb index 822836c771e16d529e66035633e6171a69090f50..3d622ba8195220407111d0e79d08d785ff55147b 100644 --- a/spec/support/shared_examples/models/with_uploads_shared_examples.rb +++ b/spec/support/shared_examples/models/with_uploads_shared_examples.rb @@ -18,7 +18,7 @@ shared_examples_for 'model with uploads' do |supports_fileuploads| end end - context 'with not mounted uploads', :sidekiq, skip: !supports_fileuploads do + context 'with not mounted uploads', :sidekiq_might_not_need_inline, skip: !supports_fileuploads do context 'with local files' do let!(:uploads) { create_list(:upload, 2, uploader: FileUploader, model: model_object) } diff --git a/spec/support/shared_examples/quick_actions/issue/zoom_quick_actions_shared_examples.rb b/spec/support/shared_examples/quick_actions/issue/zoom_quick_actions_shared_examples.rb index b4a8e3fca4d40058abc53393039edcf16cef9f75..92bbc4abe77ac129c873e6fd5460233d6e485b37 100644 --- a/spec/support/shared_examples/quick_actions/issue/zoom_quick_actions_shared_examples.rb +++ b/spec/support/shared_examples/quick_actions/issue/zoom_quick_actions_shared_examples.rb @@ -2,22 +2,19 @@ shared_examples 'zoom quick actions' do let(:zoom_link) { 'https://zoom.us/j/123456789' } + let(:existing_zoom_link) { 'https://zoom.us/j/123456780' } let(:invalid_zoom_link) { 'https://invalid-zoom' } - before do - issue.update!(description: description) - end - describe '/zoom' do shared_examples 'skip silently' do - it 'skip addition silently' do + it 'skips addition silently' do add_note("/zoom #{zoom_link}") wait_for_requests expect(page).not_to have_content('Zoom meeting added') expect(page).not_to have_content('Failed to add a Zoom meeting') - expect(issue.reload.description).to eq(description) + expect(ZoomMeeting.canonical_meeting_url(issue.reload)).not_to eq(zoom_link) end end @@ -28,13 +25,11 @@ shared_examples 'zoom quick actions' do wait_for_requests expect(page).to have_content('Zoom meeting added') - expect(issue.reload.description).to end_with(zoom_link) + expect(ZoomMeeting.canonical_meeting_url(issue.reload)).to eq(zoom_link) end end - context 'without issue description' do - let(:description) { nil } - + context 'without zoom_meetings' do include_examples 'success' it 'cannot add invalid zoom link' do @@ -47,14 +42,18 @@ shared_examples 'zoom quick actions' do end end - context 'with Zoom link not at the end of the issue description' do - let(:description) { "A link #{zoom_link} not at the end" } + context 'with "removed" zoom meeting' do + before do + create(:zoom_meeting, issue_status: :removed, url: existing_zoom_link, issue: issue) + end include_examples 'success' end - context 'with Zoom link at end of the issue description' do - let(:description) { "Text\n#{zoom_link}" } + context 'with "added" zoom meeting' do + before do + create(:zoom_meeting, issue_status: :added, url: existing_zoom_link, issue: issue) + end include_examples 'skip silently' end @@ -62,19 +61,19 @@ shared_examples 'zoom quick actions' do describe '/remove_zoom' do shared_examples 'skip silently' do - it 'skip removal silently' do + it 'skips removal silently' do add_note('/remove_zoom') wait_for_requests expect(page).not_to have_content('Zoom meeting removed') expect(page).not_to have_content('Failed to remove a Zoom meeting') - expect(issue.reload.description).to eq(description) + expect(ZoomMeeting.canonical_meeting_url(issue.reload)).to be_nil end end - context 'with Zoom link in the description' do - let(:description) { "Text with #{zoom_link}\n\n\n#{zoom_link}" } + context 'with added zoom meeting' do + let!(:added_zoom_meeting) { create(:zoom_meeting, url: zoom_link, issue: issue, issue_status: :added) } it 'removes last Zoom link' do add_note('/remove_zoom') @@ -82,14 +81,8 @@ shared_examples 'zoom quick actions' do wait_for_requests expect(page).to have_content('Zoom meeting removed') - expect(issue.reload.description).to eq("Text with #{zoom_link}") + expect(ZoomMeeting.canonical_meeting_url(issue.reload)).to be_nil end end - - context 'with a Zoom link not at the end of the description' do - let(:description) { "A link #{zoom_link} not at the end" } - - include_examples 'skip silently' - end end end diff --git a/spec/support/shared_examples/quick_actions/merge_request/merge_quick_action_shared_examples.rb b/spec/support/shared_examples/quick_actions/merge_request/merge_quick_action_shared_examples.rb index ac7c17915de20f27a349568ac9bca40c6f3a71a3..a77d729aa2cc06c77af5ffa9c7ef6d1fb38453d9 100644 --- a/spec/support/shared_examples/quick_actions/merge_request/merge_quick_action_shared_examples.rb +++ b/spec/support/shared_examples/quick_actions/merge_request/merge_quick_action_shared_examples.rb @@ -7,7 +7,7 @@ shared_examples 'merge quick action' do visit project_merge_request_path(project, merge_request) end - it 'merges the MR' do + it 'merges the MR', :sidekiq_might_not_need_inline do add_note("/merge") expect(page).to have_content 'Scheduled to merge this merge request when the pipeline succeeds.' diff --git a/spec/support/shared_examples/requests/api/discussions.rb b/spec/support/shared_examples/requests/api/discussions.rb index a36bc2dc9b57ab528945feb3b91c6602fa856659..2a5a48f305426336faa167e8824441aba4eeeaca 100644 --- a/spec/support/shared_examples/requests/api/discussions.rb +++ b/spec/support/shared_examples/requests/api/discussions.rb @@ -117,6 +117,29 @@ shared_examples 'discussions API' do |parent_type, noteable_type, id_name, can_r expect(response).to have_gitlab_http_status(401) end + it 'tracks a Notes::CreateService event' do + expect(Gitlab::Tracking).to receive(:event) do |category, action, data| + expect(category).to eq('Notes::CreateService') + expect(action).to eq('execute') + expect(data[:label]).to eq('note') + expect(data[:value]).to be_an(Integer) + end + + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", user), params: { body: 'hi!' } + end + + context 'with notes_create_service_tracking feature flag disabled' do + before do + stub_feature_flags(notes_create_service_tracking: false) + end + + it 'does not track any events' do + expect(Gitlab::Tracking).not_to receive(:event) + + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions"), params: { body: 'hi!' } + end + end + context 'when an admin or owner makes the request' do it 'accepts the creation date to be set' do creation_time = 2.weeks.ago diff --git a/spec/support/shared_examples/requests/api/notes.rb b/spec/support/shared_examples/requests/api/notes.rb index 354ae7288b17e30e18bf65826b5b24d4ad774da0..4ce78d885bcc3b3994fc587b0fc25555eab1dd25 100644 --- a/spec/support/shared_examples/requests/api/notes.rb +++ b/spec/support/shared_examples/requests/api/notes.rb @@ -139,7 +139,7 @@ shared_examples 'noteable API' do |parent_type, noteable_type, id_name| expect(response).to have_gitlab_http_status(401) end - it "creates an activity event when a note is created" do + it "creates an activity event when a note is created", :sidekiq_might_not_need_inline do expect(Event).to receive(:create!) post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), params: { body: 'hi!' } diff --git a/spec/support/shared_examples/requests/rack_attack_shared_examples.rb b/spec/support/shared_examples/requests/rack_attack_shared_examples.rb index a2e38cfc60b4ba1dab168a72c866c536b086d8ed..c078e982e87c95ab57fda848a3e33190c99e11b1 100644 --- a/spec/support/shared_examples/requests/rack_attack_shared_examples.rb +++ b/spec/support/shared_examples/requests/rack_attack_shared_examples.rb @@ -2,8 +2,9 @@ # # Requires let variables: # * throttle_setting_prefix: "throttle_authenticated_api", "throttle_authenticated_web", "throttle_protected_paths" -# * get_args -# * other_user_get_args +# * request_method +# * request_args +# * other_user_request_args # * requests_per_period # * period_in_seconds # * period @@ -31,66 +32,66 @@ shared_examples_for 'rate-limited token-authenticated requests' do it 'rejects requests over the rate limit' do # At first, allow requests under the rate limit. requests_per_period.times do - get(*get_args) - expect(response).to have_http_status 200 + make_request(request_args) + expect(response).not_to have_http_status 429 end # the last straw - expect_rejection { get(*get_args) } + expect_rejection { make_request(request_args) } end it 'allows requests after throttling and then waiting for the next period' do requests_per_period.times do - get(*get_args) - expect(response).to have_http_status 200 + make_request(request_args) + expect(response).not_to have_http_status 429 end - expect_rejection { get(*get_args) } + expect_rejection { make_request(request_args) } Timecop.travel(period.from_now) do requests_per_period.times do - get(*get_args) - expect(response).to have_http_status 200 + make_request(request_args) + expect(response).not_to have_http_status 429 end - expect_rejection { get(*get_args) } + expect_rejection { make_request(request_args) } end end it 'counts requests from different users separately, even from the same IP' do requests_per_period.times do - get(*get_args) - expect(response).to have_http_status 200 + make_request(request_args) + expect(response).not_to have_http_status 429 end # would be over the limit if this wasn't a different user - get(*other_user_get_args) - expect(response).to have_http_status 200 + make_request(other_user_request_args) + expect(response).not_to have_http_status 429 end it 'counts all requests from the same user, even via different IPs' do requests_per_period.times do - get(*get_args) - expect(response).to have_http_status 200 + make_request(request_args) + expect(response).not_to have_http_status 429 end - expect_any_instance_of(Rack::Attack::Request).to receive(:ip).and_return('1.2.3.4') + expect_any_instance_of(Rack::Attack::Request).to receive(:ip).at_least(:once).and_return('1.2.3.4') - expect_rejection { get(*get_args) } + expect_rejection { make_request(request_args) } end it 'logs RackAttack info into structured logs' do requests_per_period.times do - get(*get_args) - expect(response).to have_http_status 200 + make_request(request_args) + expect(response).not_to have_http_status 429 end arguments = { message: 'Rack_Attack', env: :throttle, remote_ip: '127.0.0.1', - request_method: 'GET', - path: get_args.first, + request_method: request_method, + path: request_args.first, user_id: user.id, username: user.username, throttle_type: throttle_types[throttle_setting_prefix] @@ -98,7 +99,7 @@ shared_examples_for 'rate-limited token-authenticated requests' do expect(Gitlab::AuthLogger).to receive(:error).with(arguments).once - expect_rejection { get(*get_args) } + expect_rejection { make_request(request_args) } end end @@ -110,17 +111,26 @@ shared_examples_for 'rate-limited token-authenticated requests' do it 'allows requests over the rate limit' do (1 + requests_per_period).times do - get(*get_args) - expect(response).to have_http_status 200 + make_request(request_args) + expect(response).not_to have_http_status 429 end end end + + def make_request(args) + if request_method == 'POST' + post(*args) + else + get(*args) + end + end end # Requires let variables: # * throttle_setting_prefix: "throttle_authenticated_web" or "throttle_protected_paths" # * user # * url_that_requires_authentication +# * request_method # * requests_per_period # * period_in_seconds # * period @@ -149,68 +159,68 @@ shared_examples_for 'rate-limited web authenticated requests' do it 'rejects requests over the rate limit' do # At first, allow requests under the rate limit. requests_per_period.times do - get url_that_requires_authentication - expect(response).to have_http_status 200 + request_authenticated_web_url + expect(response).not_to have_http_status 429 end # the last straw - expect_rejection { get url_that_requires_authentication } + expect_rejection { request_authenticated_web_url } end it 'allows requests after throttling and then waiting for the next period' do requests_per_period.times do - get url_that_requires_authentication - expect(response).to have_http_status 200 + request_authenticated_web_url + expect(response).not_to have_http_status 429 end - expect_rejection { get url_that_requires_authentication } + expect_rejection { request_authenticated_web_url } Timecop.travel(period.from_now) do requests_per_period.times do - get url_that_requires_authentication - expect(response).to have_http_status 200 + request_authenticated_web_url + expect(response).not_to have_http_status 429 end - expect_rejection { get url_that_requires_authentication } + expect_rejection { request_authenticated_web_url } end end it 'counts requests from different users separately, even from the same IP' do requests_per_period.times do - get url_that_requires_authentication - expect(response).to have_http_status 200 + request_authenticated_web_url + expect(response).not_to have_http_status 429 end # would be over the limit if this wasn't a different user login_as(create(:user)) - get url_that_requires_authentication - expect(response).to have_http_status 200 + request_authenticated_web_url + expect(response).not_to have_http_status 429 end it 'counts all requests from the same user, even via different IPs' do requests_per_period.times do - get url_that_requires_authentication - expect(response).to have_http_status 200 + request_authenticated_web_url + expect(response).not_to have_http_status 429 end - expect_any_instance_of(Rack::Attack::Request).to receive(:ip).and_return('1.2.3.4') + expect_any_instance_of(Rack::Attack::Request).to receive(:ip).at_least(:once).and_return('1.2.3.4') - expect_rejection { get url_that_requires_authentication } + expect_rejection { request_authenticated_web_url } end it 'logs RackAttack info into structured logs' do requests_per_period.times do - get url_that_requires_authentication - expect(response).to have_http_status 200 + request_authenticated_web_url + expect(response).not_to have_http_status 429 end arguments = { message: 'Rack_Attack', env: :throttle, remote_ip: '127.0.0.1', - request_method: 'GET', - path: '/dashboard/snippets', + request_method: request_method, + path: url_that_requires_authentication, user_id: user.id, username: user.username, throttle_type: throttle_types[throttle_setting_prefix] @@ -218,7 +228,7 @@ shared_examples_for 'rate-limited web authenticated requests' do expect(Gitlab::AuthLogger).to receive(:error).with(arguments).once - get url_that_requires_authentication + request_authenticated_web_url end end @@ -230,9 +240,17 @@ shared_examples_for 'rate-limited web authenticated requests' do it 'allows requests over the rate limit' do (1 + requests_per_period).times do - get url_that_requires_authentication - expect(response).to have_http_status 200 + request_authenticated_web_url + expect(response).not_to have_http_status 429 end end end + + def request_authenticated_web_url + if request_method == 'POST' + post url_that_requires_authentication + else + get url_that_requires_authentication + end + end end diff --git a/spec/support/shared_examples/serializers/diff_file_entity_examples.rb b/spec/support/shared_examples/serializers/diff_file_entity_examples.rb index 96cb71be7374944683b68afd6f3787e0ed00d202..d2c269c597cbfbf508ce83166c1d8e63cdb7288a 100644 --- a/spec/support/shared_examples/serializers/diff_file_entity_examples.rb +++ b/spec/support/shared_examples/serializers/diff_file_entity_examples.rb @@ -31,14 +31,43 @@ shared_examples 'diff file entity' do it 'exposes correct attributes' do expect(subject).to include(:added_lines, :removed_lines, - :context_lines_path, :highlighted_diff_lines, - :parallel_diff_lines) + :context_lines_path) end it 'includes viewer' do expect(subject[:viewer].with_indifferent_access) .to match_schema('entities/diff_viewer') end + + context 'diff files' do + context 'when diff_view is parallel' do + let(:options) { { diff_view: :parallel } } + + it 'contains only the parallel diff lines', :aggregate_failures do + expect(subject).to include(:parallel_diff_lines) + expect(subject).not_to include(:highlighted_diff_lines) + end + end + + context 'when diff_view is parallel' do + let(:options) { { diff_view: :inline } } + + it 'contains only the inline diff lines', :aggregate_failures do + expect(subject).not_to include(:parallel_diff_lines) + expect(subject).to include(:highlighted_diff_lines) + end + end + + context 'when the `single_mr_diff_view` feature is disabled' do + before do + stub_feature_flags(single_mr_diff_view: false) + end + + it 'contains both kinds of diffs' do + expect(subject).to include(:highlighted_diff_lines, :parallel_diff_lines) + end + end + end end shared_examples 'diff file discussion entity' do diff --git a/spec/support/shared_examples/services/error_tracking_service_shared_examples.rb b/spec/support/shared_examples/services/error_tracking_service_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..83c6d89e56089672325cfa6aed7811e78985dfa9 --- /dev/null +++ b/spec/support/shared_examples/services/error_tracking_service_shared_examples.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +shared_examples 'error tracking service data not ready' do |service_call| + context "when #{service_call} returns nil" do + before do + expect(error_tracking_setting) + .to receive(service_call).and_return(nil) + end + + it 'result is not ready' do + expect(result).to eq( + status: :error, http_status: :no_content, message: 'Not ready. Try again later') + end + end +end + +shared_examples 'error tracking service sentry error handling' do |service_call| + context "when #{service_call} returns error" do + before do + allow(error_tracking_setting) + .to receive(service_call) + .and_return( + error: 'Sentry response status code: 401', + error_type: ErrorTracking::ProjectErrorTrackingSetting::SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE + ) + end + + it 'returns the error' do + expect(result).to eq( + status: :error, + http_status: :bad_request, + message: 'Sentry response status code: 401' + ) + end + end +end + +shared_examples 'error tracking service http status handling' do |service_call| + context "when #{service_call} returns error with http_status" do + before do + allow(error_tracking_setting) + .to receive(service_call) + .and_return( + error: 'Sentry API response is missing keys. key not found: "id"', + error_type: ErrorTracking::ProjectErrorTrackingSetting::SENTRY_API_ERROR_TYPE_MISSING_KEYS + ) + end + + it 'returns the error with correct http_status' do + expect(result).to eq( + status: :error, + http_status: :internal_server_error, + message: 'Sentry API response is missing keys. key not found: "id"' + ) + end + end +end + +shared_examples 'error tracking service unauthorized user' do + context 'with unauthorized user' do + let(:unauthorized_user) { create(:user) } + + subject { described_class.new(project, unauthorized_user) } + + it 'returns error' do + result = subject.execute + + expect(result).to include( + status: :error, + message: 'Access denied', + http_status: :unauthorized + ) + end + end +end + +shared_examples 'error tracking service disabled' do + context 'with error tracking disabled' do + before do + error_tracking_setting.enabled = false + end + + it 'raises error' do + result = subject.execute + + expect(result).to include(status: :error, message: 'Error Tracking is not enabled') + end + end +end diff --git a/spec/support/shared_examples/updating_mentions_shared_examples.rb b/spec/support/shared_examples/updating_mentions_shared_examples.rb index 9a8f80127628b66ee95d84ae87ae86496255ace9..84f6c4d136a29ddd7f9ef2f33d3990d29e9838a5 100644 --- a/spec/support/shared_examples/updating_mentions_shared_examples.rb +++ b/spec/support/shared_examples/updating_mentions_shared_examples.rb @@ -27,7 +27,7 @@ RSpec.shared_examples 'updating mentions' do |service_class| update_mentionable(title: "For #{mentioned_user.to_reference}") end - it 'emails only the newly-mentioned user' do + it 'emails only the newly-mentioned user', :sidekiq_might_not_need_inline do should_only_email(mentioned_user) end end @@ -37,7 +37,7 @@ RSpec.shared_examples 'updating mentions' do |service_class| update_mentionable(description: "For #{mentioned_user.to_reference}") end - it 'emails only the newly-mentioned user' do + it 'emails only the newly-mentioned user', :sidekiq_might_not_need_inline do should_only_email(mentioned_user) end end @@ -51,16 +51,32 @@ RSpec.shared_examples 'updating mentions' do |service_class| ) end - it 'emails group members' do + it 'emails group members', :sidekiq_might_not_need_inline do should_email(mentioned_user) should_email(group_member1) should_email(group_member2) end end + shared_examples 'updating attribute with existing group mention' do |attribute| + before do + mentionable.update!({ attribute => "FYI: #{group.to_reference}" }) + end + + it 'creates todos for only newly mentioned users' do + expect do + update_mentionable( + { attribute => "For #{group.to_reference}, cc: #{mentioned_user.to_reference}" } + ) + end.to change { Todo.count }.by(1) + end + end + context 'when group is public' do it_behaves_like 'updating attribute with allowed mentions', :title it_behaves_like 'updating attribute with allowed mentions', :description + it_behaves_like 'updating attribute with existing group mention', :title + it_behaves_like 'updating attribute with existing group mention', :description end context 'when the group is private' do @@ -70,6 +86,8 @@ RSpec.shared_examples 'updating mentions' do |service_class| it_behaves_like 'updating attribute with allowed mentions', :title it_behaves_like 'updating attribute with allowed mentions', :description + it_behaves_like 'updating attribute with existing group mention', :title + it_behaves_like 'updating attribute with existing group mention', :description end end @@ -81,7 +99,7 @@ RSpec.shared_examples 'updating mentions' do |service_class| ) end - it 'emails mentioned user' do + it 'emails mentioned user', :sidekiq_might_not_need_inline do should_only_email(mentioned_user) end end diff --git a/spec/support/sidekiq.rb b/spec/support/sidekiq.rb index 585c458a64e7422527dbb95c5fb6f2cec78434b6..246efedc7e5dce70c89fb8d17457203faa6aa2db 100644 --- a/spec/support/sidekiq.rb +++ b/spec/support/sidekiq.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'sidekiq/testing/inline' +require 'sidekiq/testing' # If Sidekiq::Testing.inline! is used, SQL transactions done inside # Sidekiq worker are included in the SQL query limit (in a real @@ -27,7 +27,9 @@ Sidekiq::Testing.server_middleware do |chain| end RSpec.configure do |config| - config.after(:each, :sidekiq) do + config.around(:each, :sidekiq) do |example| + Sidekiq::Worker.clear_all + example.run Sidekiq::Worker.clear_all end @@ -36,4 +38,19 @@ RSpec.configure do |config| connection.redis.flushdb end end + + # As we'll review the examples with this tag, we should either: + # - fix the example to not require Sidekiq inline mode (and remove this tag) + # - explicitly keep the inline mode and change the tag for `:sidekiq_inline` instead + config.around(:example, :sidekiq_might_not_need_inline) do |example| + Sidekiq::Worker.clear_all + Sidekiq::Testing.inline! { example.run } + Sidekiq::Worker.clear_all + end + + config.around(:example, :sidekiq_inline) do |example| + Sidekiq::Worker.clear_all + Sidekiq::Testing.inline! { example.run } + Sidekiq::Worker.clear_all + end end diff --git a/spec/support/unpack-gitlab-git-test b/spec/support/unpack-gitlab-git-test index d5b4912457da90fa137ffa3d75a1fb8081d9a177..5d5f1b7d0829536de3822d3b888769f205a3e4b9 100755 --- a/spec/support/unpack-gitlab-git-test +++ b/spec/support/unpack-gitlab-git-test @@ -1,10 +1,12 @@ #!/usr/bin/env ruby +# frozen_string_literal: true + require 'fileutils' -REPO = 'spec/support/gitlab-git-test.git'.freeze +REPO = 'spec/support/gitlab-git-test.git' PACK_DIR = REPO + '/objects/pack' GIT = %W[git --git-dir=#{REPO}].freeze -BASE_PACK = 'pack-691247af2a6acb0b63b73ac0cb90540e93614043'.freeze +BASE_PACK = 'pack-691247af2a6acb0b63b73ac0cb90540e93614043' def main unpack diff --git a/spec/tasks/gitlab/shell_rake_spec.rb b/spec/tasks/gitlab/shell_rake_spec.rb index abad16be580a275c1eed99113c1b9617d3d31fbd..08b3fea0c8002d16c00984f9a3498f503e50dbd7 100644 --- a/spec/tasks/gitlab/shell_rake_spec.rb +++ b/spec/tasks/gitlab/shell_rake_spec.rb @@ -17,7 +17,7 @@ describe 'gitlab:shell rake tasks' do expect_any_instance_of(Gitlab::TaskHelpers).to receive(:checkout_or_clone_version) allow(Kernel).to receive(:system).with('bin/install', *storages).and_return(true) - allow(Kernel).to receive(:system).with('bin/compile').and_return(true) + allow(Kernel).to receive(:system).with('make', 'build').and_return(true) run_rake_task('gitlab:shell:install') end diff --git a/spec/tasks/gitlab/task_helpers_spec.rb b/spec/tasks/gitlab/task_helpers_spec.rb index 4b4f7d7c956e32afad4c34a375d54d67555864d1..4546d3bdfafaa3b7a4774b26f5601e037596dc11 100644 --- a/spec/tasks/gitlab/task_helpers_spec.rb +++ b/spec/tasks/gitlab/task_helpers_spec.rb @@ -20,22 +20,12 @@ describe Gitlab::TaskHelpers do end it 'checkout the version and reset to it' do + expect(subject).to receive(:get_version).with(version).and_call_original expect(subject).to receive(:checkout_version).with(tag, clone_path) subject.checkout_or_clone_version(version: version, repo: repo, target_dir: clone_path) end - context 'with a branch version' do - let(:version) { '=branch_name' } - let(:branch) { 'branch_name' } - - it 'checkout the version and reset to it with a branch name' do - expect(subject).to receive(:checkout_version).with(branch, clone_path) - - subject.checkout_or_clone_version(version: version, repo: repo, target_dir: clone_path) - end - end - context "target_dir doesn't exist" do it 'clones the repo' do expect(subject).to receive(:clone_repo).with(repo, clone_path) @@ -96,4 +86,19 @@ describe Gitlab::TaskHelpers do expect { subject.run_command!(['bash', '-c', 'exit 1']) }.to raise_error Gitlab::TaskFailedError end end + + describe '#get_version' do + using RSpec::Parameterized::TableSyntax + + where(:version, :result) do + '1.1.1' | 'v1.1.1' + 'master' | 'master' + '12.4.0-rc7' | 'v12.4.0-rc7' + '594c3ea3e0e5540e5915bd1c49713a0381459dd6' | '594c3ea3e0e5540e5915bd1c49713a0381459dd6' + end + + with_them do + it { expect(subject.get_version(version)).to eq(result) } + end + end end diff --git a/spec/uploaders/workers/object_storage/background_move_worker_spec.rb b/spec/uploaders/workers/object_storage/background_move_worker_spec.rb index cf4872d690432fcc12f177f999c2facfd61f4248..38b70d33993e641b241eb56d1537fab7a66e3a07 100644 --- a/spec/uploaders/workers/object_storage/background_move_worker_spec.rb +++ b/spec/uploaders/workers/object_storage/background_move_worker_spec.rb @@ -22,7 +22,7 @@ describe ObjectStorage::BackgroundMoveWorker do stub_lfs_object_storage(background_upload: true) end - it 'uploads object to storage' do + it 'uploads object to storage', :sidekiq_might_not_need_inline do expect { perform }.to change { lfs_object.reload.file_store }.from(local).to(remote) end @@ -65,7 +65,7 @@ describe ObjectStorage::BackgroundMoveWorker do stub_artifacts_object_storage(background_upload: true) end - it "migrates file to remote storage" do + it "migrates file to remote storage", :sidekiq_might_not_need_inline do perform expect(artifact.reload.file_store).to eq(remote) @@ -91,7 +91,7 @@ describe ObjectStorage::BackgroundMoveWorker do let(:subject_class) { project.class } let(:subject_id) { project.id } - it "migrates file to remote storage" do + it "migrates file to remote storage", :sidekiq_might_not_need_inline do perform project.reload BatchLoader::Executor.clear_current @@ -104,7 +104,7 @@ describe ObjectStorage::BackgroundMoveWorker do let(:subject_class) { Upload } let(:subject_id) { project.avatar.upload.id } - it "migrates file to remote storage" do + it "migrates file to remote storage", :sidekiq_might_not_need_inline do perform expect(project.reload.avatar).not_to be_file_storage diff --git a/spec/views/admin/application_settings/integrations.html.haml_spec.rb b/spec/views/admin/application_settings/integrations.html.haml_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..392d43ef2d44f38bd1d0c73f7549ae3f2ffea6ba --- /dev/null +++ b/spec/views/admin/application_settings/integrations.html.haml_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'admin/application_settings/integrations.html.haml' do + let(:app_settings) { build(:application_setting) } + + describe 'sourcegraph integration' do + let(:sourcegraph_flag) { true } + + before do + assign(:application_setting, app_settings) + allow(Gitlab::Sourcegraph).to receive(:feature_available?).and_return(sourcegraph_flag) + end + + context 'when sourcegraph feature is enabled' do + it 'show the form' do + render + + expect(rendered).to have_field('application_setting_sourcegraph_enabled') + end + end + + context 'when sourcegraph feature is disabled' do + let(:sourcegraph_flag) { false } + + it 'show the form' do + render + + expect(rendered).not_to have_field('application_setting_sourcegraph_enabled') + end + end + end +end diff --git a/spec/views/devise/sessions/new.html.haml_spec.rb b/spec/views/devise/sessions/new.html.haml_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..66afc2af7ce5ddaa4d4eb7bb0c0a0688ca728292 --- /dev/null +++ b/spec/views/devise/sessions/new.html.haml_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'devise/sessions/new' do + describe 'ldap' do + include LdapHelpers + + let(:server) { { provider_name: 'ldapmain', label: 'LDAP' }.with_indifferent_access } + + before do + enable_ldap + stub_devise + disable_captcha + disable_sign_up + disable_other_signin_methods + + allow(view).to receive(:experiment_enabled?).and_return(false) + end + + it 'is shown when enabled' do + render + + expect(rendered).to have_selector('.new-session-tabs') + expect(rendered).to have_selector('[data-qa-selector="ldap_tab"]') + expect(rendered).to have_field('LDAP Username') + end + + it 'is not shown when LDAP sign in is disabled' do + disable_ldap_sign_in + + render + + expect(rendered).to have_content('No authentication methods configured') + expect(rendered).not_to have_selector('[data-qa-selector="ldap_tab"]') + expect(rendered).not_to have_field('LDAP Username') + end + end + + def disable_other_signin_methods + allow(view).to receive(:password_authentication_enabled_for_web?).and_return(false) + allow(view).to receive(:omniauth_enabled?).and_return(false) + end + + def disable_sign_up + allow(view).to receive(:allow_signup?).and_return(false) + end + + def stub_devise + allow(view).to receive(:devise_mapping).and_return(Devise.mappings[:user]) + allow(view).to receive(:resource).and_return(spy) + allow(view).to receive(:resource_name).and_return(:user) + end + + def enable_ldap + stub_ldap_setting(enabled: true) + assign(:ldap_servers, [server]) + allow(view).to receive(:form_based_providers).and_return([:ldapmain]) + allow(view).to receive(:omniauth_callback_path).with(:user, 'ldapmain').and_return('/ldapmain') + end + + def disable_ldap_sign_in + allow(view).to receive(:ldap_sign_in_enabled?).and_return(false) + assign(:ldap_servers, []) + end + + def disable_captcha + allow(view).to receive(:captcha_enabled?).and_return(false) + allow(view).to receive(:captcha_on_login_required?).and_return(false) + end +end diff --git a/spec/views/layouts/_head.html.haml_spec.rb b/spec/views/layouts/_head.html.haml_spec.rb index e9b3334fffc012ab6bc96faf5f59bbc846e10293..f181e18e53d36694a5f06ad848e2655b5d3dd021 100644 --- a/spec/views/layouts/_head.html.haml_spec.rb +++ b/spec/views/layouts/_head.html.haml_spec.rb @@ -84,7 +84,7 @@ describe 'layouts/_head' do allow(Gitlab::CurrentSettings).to receive(:snowplow_collector_hostname).and_return('www.snow.plow') end - it 'add a snowplow script tag with asset host' do + it 'adds a snowplow script tag with asset host' do render expect(rendered).to match('http://test.host/assets/snowplow/') expect(rendered).to match('window.snowplow') diff --git a/spec/views/profiles/preferences/show.html.haml_spec.rb b/spec/views/profiles/preferences/show.html.haml_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..52933c42621e20229470857f76a1819c656cb460 --- /dev/null +++ b/spec/views/profiles/preferences/show.html.haml_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'profiles/preferences/show' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:user) { build(:user) } + + before do + assign(:user, user) + allow(controller).to receive(:current_user).and_return(user) + end + + context 'sourcegraph' do + def have_sourcegraph_field(*args) + have_field('user_sourcegraph_enabled', *args) + end + + def have_integrations_section + have_css('.profile-settings-sidebar', { text: 'Integrations' }) + end + + before do + # Can't use stub_feature_flags because we use Feature.get to check if conditinally applied + Feature.get(:sourcegraph).enable sourcegraph_feature + stub_application_setting(sourcegraph_enabled: sourcegraph_enabled) + end + + context 'when not fully enabled' do + where(:feature, :admin_enabled) do + false | false + false | true + true | false + end + + with_them do + let(:sourcegraph_feature) { feature } + let(:sourcegraph_enabled) { admin_enabled } + + before do + render + end + + it 'does not display sourcegraph field' do + expect(rendered).not_to have_sourcegraph_field + end + + it 'does not display integrations settings' do + expect(rendered).not_to have_integrations_section + end + end + end + + context 'when fully enabled' do + let(:sourcegraph_feature) { true } + let(:sourcegraph_enabled) { true } + + before do + render + end + + it 'displays the sourcegraph field' do + expect(rendered).to have_sourcegraph_field + end + + it 'displays the integrations section' do + expect(rendered).to have_integrations_section + end + end + end +end diff --git a/spec/views/profiles/show.html.haml_spec.rb b/spec/views/profiles/show.html.haml_spec.rb index 592b3a56ba3da6285a9b88d7cea41fd49f9f8637..14e6feed3ab57cc8d9c5ec3254c000584aa1cb79 100644 --- a/spec/views/profiles/show.html.haml_spec.rb +++ b/spec/views/profiles/show.html.haml_spec.rb @@ -8,6 +8,7 @@ describe 'profiles/show' do before do assign(:user, user) allow(controller).to receive(:current_user).and_return(user) + allow(view).to receive(:experiment_enabled?) end context 'when the profile page is opened' do diff --git a/spec/views/projects/clusters/clusters/gcp/_form.html.haml_spec.rb b/spec/views/projects/clusters/clusters/gcp/_form.html.haml_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1cb2f9a4301d2ec224968a5acfeff1b1792c8a47 --- /dev/null +++ b/spec/views/projects/clusters/clusters/gcp/_form.html.haml_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'clusters/clusters/gcp/_form' do + let(:admin) { create(:admin) } + let(:environment) { create(:environment) } + let(:gcp_cluster) { create(:cluster, :provided_by_gcp) } + let(:clusterable) { ClusterablePresenter.fabricate(environment.project, current_user: admin) } + + before do + assign(:environment, environment) + assign(:gcp_cluster, gcp_cluster) + allow(view).to receive(:clusterable).and_return(clusterable) + allow(view).to receive(:url_for).and_return('#') + allow(view).to receive(:token_in_session).and_return('') + end + + context 'with all feature flags enabled' do + it 'has a cloud run checkbox' do + render + + expect(rendered).to have_selector("input[id='cluster_provider_gcp_attributes_cloud_run']") + end + end + + context 'with cloud run feature flag disabled' do + before do + stub_feature_flags(create_cloud_run_clusters: false) + end + + it 'does not have a cloud run checkbox' do + render + + expect(rendered).not_to have_selector("input[id='cluster_provider_gcp_attributes_cloud_run']") + end + end +end diff --git a/spec/views/projects/deployments/_confirm_rollback_modal_spec.html_spec.rb b/spec/views/projects/deployments/_confirm_rollback_modal_spec.html_spec.rb index 54ec4f32856dc6aa551f397f43fa2431fe63aed4..9168bc8e833053a48a7a1f58faea70559bb05677 100644 --- a/spec/views/projects/deployments/_confirm_rollback_modal_spec.html_spec.rb +++ b/spec/views/projects/deployments/_confirm_rollback_modal_spec.html_spec.rb @@ -48,7 +48,7 @@ describe 'projects/deployments/_confirm_rollback_modal' do render expect(rendered).to have_selector('h4', text: "Rollback environment #{environment.name}?") - expect(rendered).to have_selector('p', text: "This action will run the job defined by staging for commit #{deployment.short_sha}, putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?") + expect(rendered).to have_selector('p', text: "This action will run the job defined by #{environment.name} for commit #{deployment.short_sha}, putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?") expect(rendered).to have_selector('a.btn-danger', text: 'Rollback') end diff --git a/spec/views/projects/merge_requests/_commits.html.haml_spec.rb b/spec/views/projects/merge_requests/_commits.html.haml_spec.rb index 71d74b06f85a336e8e8f625f37af8939510b8143..755a40a7e4c073c75dafae8d5eb6180420549ae2 100644 --- a/spec/views/projects/merge_requests/_commits.html.haml_spec.rb +++ b/spec/views/projects/merge_requests/_commits.html.haml_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe 'projects/merge_requests/_commits.html.haml' do +describe 'projects/merge_requests/_commits.html.haml', :sidekiq_might_not_need_inline do include Devise::Test::ControllerHelpers include ProjectForksHelper diff --git a/spec/views/projects/pages_domains/show.html.haml_spec.rb b/spec/views/projects/pages_domains/show.html.haml_spec.rb index ba0544a49b081d0c6e694ea703073dace991ff37..331bfe63f288f7b29672897c2d057b4202684b56 100644 --- a/spec/views/projects/pages_domains/show.html.haml_spec.rb +++ b/spec/views/projects/pages_domains/show.html.haml_spec.rb @@ -30,39 +30,5 @@ describe 'projects/pages_domains/show' do expect(rendered).to have_content("GitLab is obtaining a Let's Encrypt SSL certificate for this domain. This process can take some time. Please try again later.") end end - - context 'when certificate is present' do - let(:domain) { create(:pages_domain, :letsencrypt, project: project) } - - it 'shows certificate info' do - render - - # test just a random part of cert represenations(X509v3 Subject Key Identifier:) - expect(rendered).to have_content("C6:5F:56:4B:10:69:AC:1D:33:D2:26:C9:B3:7A:D7:12:4D:3E:F7:90") - end - end - end - - context 'when auto_ssl is disabled' do - context 'when certificate is present' do - let(:domain) { create(:pages_domain, project: project) } - - it 'shows certificate info' do - render - - # test just a random part of cert represenations(X509v3 Subject Key Identifier:) - expect(rendered).to have_content("C6:5F:56:4B:10:69:AC:1D:33:D2:26:C9:B3:7A:D7:12:4D:3E:F7:90") - end - end - - context 'when certificate is absent' do - let(:domain) { create(:pages_domain, :without_certificate, :without_key, project: project) } - - it 'shows missing certificate' do - render - - expect(rendered).to have_content("missing") - end - end end end diff --git a/spec/views/projects/show.html.haml_spec.rb b/spec/views/projects/show.html.haml_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4f5f0f0285c21758cccf2f59a70a336684c87547 --- /dev/null +++ b/spec/views/projects/show.html.haml_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'projects/show' do + include Devise::Test::ControllerHelpers + + let(:user) { create(:admin) } + let(:project) { create(:project, :repository) } + + before do + presented_project = project.present(current_user: user) + + allow(presented_project).to receive(:default_view).and_return('customize_workflow') + allow(controller).to receive(:current_user).and_return(user) + + assign(:project, presented_project) + end + + context 'commit signatures' do + context 'with vue tree view enabled' do + it 'are not rendered via js-signature-container' do + render + + expect(rendered).not_to have_css('.js-signature-container') + end + end + + context 'with vue tree view disabled' do + before do + stub_feature_flags(vue_file_list: false) + end + + it 'rendered via js-signature-container' do + render + + expect(rendered).to have_css('.js-signature-container') + end + end + end +end diff --git a/spec/views/projects/tree/_tree_header.html.haml_spec.rb b/spec/views/projects/tree/_tree_header.html.haml_spec.rb index 4b71ea9ffe3acf64ea277e96183524943b553345..caf8c4d1969ad12daf3eca6b0a0dd1a9283e14ef 100644 --- a/spec/views/projects/tree/_tree_header.html.haml_spec.rb +++ b/spec/views/projects/tree/_tree_header.html.haml_spec.rb @@ -8,6 +8,8 @@ describe 'projects/tree/_tree_header' do let(:repository) { project.repository } before do + stub_feature_flags(vue_file_list: false) + assign(:project, project) assign(:repository, repository) assign(:id, File.join('master', '')) diff --git a/spec/views/projects/tree/show.html.haml_spec.rb b/spec/views/projects/tree/show.html.haml_spec.rb index 960cf42a793b1215a63c6731afa59738ddb0a436..8c6b229247d95bac1d74cbb1434e03c95e30d62c 100644 --- a/spec/views/projects/tree/show.html.haml_spec.rb +++ b/spec/views/projects/tree/show.html.haml_spec.rb @@ -7,10 +7,12 @@ describe 'projects/tree/show' do let(:project) { create(:project, :repository) } let(:repository) { project.repository } + let(:ref) { 'master' } + let(:commit) { repository.commit(ref) } + let(:path) { '' } + let(:tree) { repository.tree(commit.id, path) } before do - stub_feature_flags(vue_file_list: false) - assign(:project, project) assign(:repository, repository) assign(:lfs_blob_ids, []) @@ -19,26 +21,44 @@ describe 'projects/tree/show' do allow(view).to receive(:can_collaborate_with_project?).and_return(true) allow(view).to receive_message_chain('user_access.can_push_to_branch?').and_return(true) allow(view).to receive(:current_application_settings).and_return(Gitlab::CurrentSettings.current_application_settings) + allow(view).to receive(:current_user).and_return(project.creator) + + assign(:id, File.join(ref, path)) + assign(:ref, ref) + assign(:path, path) + assign(:last_commit, commit) + assign(:tree, tree) end context 'for branch names ending on .json' do let(:ref) { 'ends-with.json' } - let(:commit) { repository.commit(ref) } - let(:path) { '' } - let(:tree) { repository.tree(commit.id, path) } - - before do - assign(:id, File.join(ref, path)) - assign(:ref, ref) - assign(:path, path) - assign(:last_commit, commit) - assign(:tree, tree) - end it 'displays correctly' do render + expect(rendered).to have_css('.js-project-refs-dropdown .dropdown-toggle-text', text: ref) - expect(rendered).to have_css('.readme-holder') + end + end + + context 'commit signatures' do + context 'with vue tree view disabled' do + before do + stub_feature_flags(vue_file_list: false) + end + + it 'rendered via js-signature-container' do + render + + expect(rendered).to have_css('.js-signature-container') + end + end + + context 'with vue tree view enabled' do + it 'are not rendered via js-signature-container' do + render + + expect(rendered).not_to have_css('.js-signature-container') + end end end end diff --git a/spec/workers/cluster_provision_worker_spec.rb b/spec/workers/cluster_provision_worker_spec.rb index 3f69962f25d73104d3ebe5879974da47b0801ee1..608639331fd4e7521634f6695da5ef5235a87ceb 100644 --- a/spec/workers/cluster_provision_worker_spec.rb +++ b/spec/workers/cluster_provision_worker_spec.rb @@ -9,7 +9,18 @@ describe ClusterProvisionWorker do let(:provider) { create(:cluster_provider_gcp, :scheduled) } it 'provision a cluster' do - expect_any_instance_of(Clusters::Gcp::ProvisionService).to receive(:execute) + expect_any_instance_of(Clusters::Gcp::ProvisionService).to receive(:execute).with(provider) + + described_class.new.perform(cluster.id) + end + end + + context 'when provider type is aws' do + let(:cluster) { create(:cluster, provider_type: :aws, provider_aws: provider) } + let(:provider) { create(:cluster_provider_aws, :scheduled) } + + it 'provision a cluster' do + expect_any_instance_of(Clusters::Aws::ProvisionService).to receive(:execute).with(provider) described_class.new.perform(cluster.id) end diff --git a/spec/workers/every_sidekiq_worker_spec.rb b/spec/workers/every_sidekiq_worker_spec.rb index b7ba4d617234bfd57f109065fff76e7fe487667c..5ceb54eb2d58c1a3761d81b6b16a69ff4f30ea96 100644 --- a/spec/workers/every_sidekiq_worker_spec.rb +++ b/spec/workers/every_sidekiq_worker_spec.rb @@ -21,8 +21,8 @@ describe 'Every Sidekiq worker' do missing_from_file = worker_queues - file_worker_queues expect(missing_from_file).to be_empty, "expected #{missing_from_file.to_a.inspect} to be in Gitlab::SidekiqConfig::QUEUE_CONFIG_PATHS" - unncessarily_in_file = file_worker_queues - worker_queues - expect(unncessarily_in_file).to be_empty, "expected #{unncessarily_in_file.to_a.inspect} not to be in Gitlab::SidekiqConfig::QUEUE_CONFIG_PATHS" + unnecessarily_in_file = file_worker_queues - worker_queues + expect(unnecessarily_in_file).to be_empty, "expected #{unnecessarily_in_file.to_a.inspect} not to be in Gitlab::SidekiqConfig::QUEUE_CONFIG_PATHS" end it 'has its queue or namespace in config/sidekiq_queues.yml', :aggregate_failures do @@ -42,7 +42,7 @@ describe 'Every Sidekiq worker' do end # All Sidekiq worker classes should declare a valid `feature_category` - # or explicitely be excluded with the `feature_category_not_owned!` annotation. + # or explicitly be excluded with the `feature_category_not_owned!` annotation. # Please see doc/development/sidekiq_style_guide.md#Feature-Categorization for more details. it 'has a feature_category or feature_category_not_owned! attribute', :aggregate_failures do Gitlab::SidekiqConfig.workers.each do |worker| @@ -62,5 +62,36 @@ describe 'Every Sidekiq worker' do expect(feature_categories).to include(worker.get_feature_category), "expected #{worker.inspect} to declare a valid feature_category, but got #{worker.get_feature_category}" end end + + # Memory-bound workers are very expensive to run, since they need to run on nodes with very low + # concurrency, so that each job can consume a large amounts of memory. For this reason, on + # GitLab.com, when a large number of memory-bound jobs arrive at once, we let them queue up + # rather than scaling the hardware to meet the SLO. For this reason, memory-bound, + # latency-sensitive jobs are explicitly discouraged and disabled. + it 'is (exclusively) memory-bound or latency-sentitive, not both', :aggregate_failures do + latency_sensitive_workers = Gitlab::SidekiqConfig.workers + .select(&:latency_sensitive_worker?) + + latency_sensitive_workers.each do |worker| + expect(worker.get_worker_resource_boundary).not_to eq(:memory), "#{worker.inspect} cannot be both memory-bound and latency sensitive" + end + end + + # In high traffic installations, such as GitLab.com, `latency_sensitive` workers run in a + # dedicated fleet. In order to ensure short queue times, `latency_sensitive` jobs have strict + # SLOs in order to ensure throughput. However, when a worker depends on an external service, + # such as a user's k8s cluster or a third-party internet service, we cannot guarantee latency, + # and therefore throughput. An outage to an 3rd party service could therefore impact throughput + # on other latency_sensitive jobs, leading to degradation through the GitLab application. + # Please see doc/development/sidekiq_style_guide.md#Jobs-with-External-Dependencies for more + # details. + it 'has (exclusively) external dependencies or is latency-sentitive, not both', :aggregate_failures do + latency_sensitive_workers = Gitlab::SidekiqConfig.workers + .select(&:latency_sensitive_worker?) + + latency_sensitive_workers.each do |worker| + expect(worker.worker_has_external_dependencies?).to be_falsey, "#{worker.inspect} cannot have both external dependencies and be latency sensitive" + end + end end end diff --git a/spec/workers/expire_build_artifacts_worker_spec.rb b/spec/workers/expire_build_artifacts_worker_spec.rb index 74d6b5605d13a4ab3b260cae0104c7b87385da80..0a0aea838d2c66d33a54ab8a7eb14e30cd5b5ff4 100644 --- a/spec/workers/expire_build_artifacts_worker_spec.rb +++ b/spec/workers/expire_build_artifacts_worker_spec.rb @@ -3,62 +3,11 @@ require 'spec_helper' describe ExpireBuildArtifactsWorker do - include RepoHelpers - let(:worker) { described_class.new } - before do - Sidekiq::Worker.clear_all - end - describe '#perform' do - before do - stub_feature_flags(ci_new_expire_job_artifacts_service: false) - build - end - - subject! do - Sidekiq::Testing.fake! { worker.perform } - end - - context 'with expired artifacts' do - let(:build) { create(:ci_build, :artifacts, artifacts_expire_at: Time.now - 7.days) } - - it 'enqueues that build' do - expect(jobs_enqueued.size).to eq(1) - expect(jobs_enqueued[0]["args"]).to eq([build.id]) - end - end - - context 'with not yet expired artifacts' do - let(:build) { create(:ci_build, :artifacts, artifacts_expire_at: Time.now + 7.days) } - - it 'does not enqueue that build' do - expect(jobs_enqueued.size).to eq(0) - end - end - - context 'without expire date' do - let(:build) { create(:ci_build, :artifacts) } - - it 'does not enqueue that build' do - expect(jobs_enqueued.size).to eq(0) - end - end - - def jobs_enqueued - Sidekiq::Queues.jobs_by_worker['ExpireBuildInstanceArtifactsWorker'] - end - end - - describe '#perform with ci_new_expire_job_artifacts_service feature flag' do - before do - stub_feature_flags(ci_new_expire_job_artifacts_service: true) - end - it 'executes a service' do expect_any_instance_of(Ci::DestroyExpiredJobArtifactsService).to receive(:execute) - expect(ExpireBuildInstanceArtifactsWorker).not_to receive(:bulk_perform_async) worker.perform end diff --git a/spec/workers/group_export_worker_spec.rb b/spec/workers/group_export_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4aa85d2b381a1cfe4999bdd1b66bc6e1b832be31 --- /dev/null +++ b/spec/workers/group_export_worker_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe GroupExportWorker do + let!(:user) { create(:user) } + let!(:group) { create(:group) } + + subject { described_class.new } + + describe '#perform' do + context 'when it succeeds' do + it 'calls the ExportService' do + expect_any_instance_of(::Groups::ImportExport::ExportService).to receive(:execute) + + subject.perform(user.id, group.id, {}) + end + end + + context 'when it fails' do + it 'raises an exception when params are invalid' do + expect_any_instance_of(::Groups::ImportExport::ExportService).not_to receive(:execute) + + expect { subject.perform(1234, group.id, {}) }.to raise_exception(ActiveRecord::RecordNotFound) + expect { subject.perform(user.id, 1234, {}) }.to raise_exception(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/spec/workers/hashed_storage/migrator_worker_spec.rb b/spec/workers/hashed_storage/migrator_worker_spec.rb index 12c1a26104ec51c99f48824cf2488e103b304c22..9180da870582280dfd8dd6741677f03d37834d55 100644 --- a/spec/workers/hashed_storage/migrator_worker_spec.rb +++ b/spec/workers/hashed_storage/migrator_worker_spec.rb @@ -15,7 +15,7 @@ describe HashedStorage::MigratorWorker do worker.perform(5, 10) end - it 'migrates projects in the specified range' do + it 'migrates projects in the specified range', :sidekiq_might_not_need_inline do perform_enqueued_jobs do worker.perform(ids.min, ids.max) end diff --git a/spec/workers/hashed_storage/rollbacker_worker_spec.rb b/spec/workers/hashed_storage/rollbacker_worker_spec.rb index 5fcb1adf9aeec47fe3fa2f3ba40122b617c0861c..3ca2601df0fa936c5b6f280a214fb7a66ea6818c 100644 --- a/spec/workers/hashed_storage/rollbacker_worker_spec.rb +++ b/spec/workers/hashed_storage/rollbacker_worker_spec.rb @@ -15,7 +15,7 @@ describe HashedStorage::RollbackerWorker do worker.perform(5, 10) end - it 'rollsback projects in the specified range' do + it 'rollsback projects in the specified range', :sidekiq_might_not_need_inline do perform_enqueued_jobs do worker.perform(ids.min, ids.max) end diff --git a/spec/workers/merge_worker_spec.rb b/spec/workers/merge_worker_spec.rb index 138a99abde67bda60a048b7fc6252f9041bd487b..dc98c9836fad4705902aafaded4a4f8ac2b0b9f3 100644 --- a/spec/workers/merge_worker_spec.rb +++ b/spec/workers/merge_worker_spec.rb @@ -20,6 +20,7 @@ describe MergeWorker do described_class.new.perform( merge_request.id, merge_request.author_id, commit_message: 'wow such merge', + sha: merge_request.diff_head_sha, should_remove_source_branch: true) merge_request.reload diff --git a/spec/workers/new_note_worker_spec.rb b/spec/workers/new_note_worker_spec.rb index 2966a201a62cb3e670577389bc526b51d77467c8..ae62237960a70de5ac86a07fca84889b124a98e0 100644 --- a/spec/workers/new_note_worker_spec.rb +++ b/spec/workers/new_note_worker_spec.rb @@ -7,16 +7,17 @@ describe NewNoteWorker do let(:note) { create(:note) } it "calls NotificationService#new_note" do - expect_any_instance_of(NotificationService).to receive(:new_note).with(note) + expect_next_instance_of(NotificationService) do |service| + expect(service).to receive(:new_note).with(note) + end described_class.new.perform(note.id) end it "calls Notes::PostProcessService#execute" do - notes_post_process_service = double(Notes::PostProcessService) - allow(Notes::PostProcessService).to receive(:new).with(note) { notes_post_process_service } - - expect(notes_post_process_service).to receive(:execute) + expect_next_instance_of(Notes::PostProcessService) do |service| + expect(service).to receive(:execute) + end described_class.new.perform(note.id) end @@ -36,14 +37,14 @@ describe NewNoteWorker do expect { described_class.new.perform(unexistent_note_id) }.not_to raise_error end - it "does not call NotificationService#new_note" do - expect_any_instance_of(NotificationService).not_to receive(:new_note) + it "does not call NotificationService" do + expect(NotificationService).not_to receive(:new) described_class.new.perform(unexistent_note_id) end - it "does not call Notes::PostProcessService#execute" do - expect_any_instance_of(Notes::PostProcessService).not_to receive(:execute) + it "does not call Notes::PostProcessService" do + expect(Notes::PostProcessService).not_to receive(:new) described_class.new.perform(unexistent_note_id) end diff --git a/spec/workers/pages_domain_ssl_renewal_cron_worker_spec.rb b/spec/workers/pages_domain_ssl_renewal_cron_worker_spec.rb index 08a3511f70b18c47ffd1913b362880ae139dac18..10c23cbb6d498a710b41d0db7160fdf1b0a35790 100644 --- a/spec/workers/pages_domain_ssl_renewal_cron_worker_spec.rb +++ b/spec/workers/pages_domain_ssl_renewal_cron_worker_spec.rb @@ -13,7 +13,7 @@ describe PagesDomainSslRenewalCronWorker do describe '#perform' do let(:project) { create :project } - let!(:domain) { create(:pages_domain, project: project) } + let!(:domain) { create(:pages_domain, project: project, auto_ssl_enabled: false) } let!(:domain_with_enabled_auto_ssl) { create(:pages_domain, project: project, auto_ssl_enabled: true) } let!(:domain_with_obtained_letsencrypt) do create(:pages_domain, :letsencrypt, project: project, auto_ssl_enabled: true) diff --git a/spec/workers/pipeline_schedule_worker_spec.rb b/spec/workers/pipeline_schedule_worker_spec.rb index 9326db34209b803291ce32967ca1be83ed0c8a45..4926c14a6ab50db88a1f5de5a7d5dbba2ca3bb52 100644 --- a/spec/workers/pipeline_schedule_worker_spec.rb +++ b/spec/workers/pipeline_schedule_worker_spec.rb @@ -28,7 +28,7 @@ describe PipelineScheduleWorker do context 'when there is a scheduled pipeline within next_run_at' do shared_examples 'successful scheduling' do - it 'creates a new pipeline' do + it 'creates a new pipeline', :sidekiq_might_not_need_inline do expect { subject }.to change { project.ci_pipelines.count }.by(1) expect(Ci::Pipeline.last).to be_schedule diff --git a/spec/workers/process_commit_worker_spec.rb b/spec/workers/process_commit_worker_spec.rb index eb1d3c364ac7d6abafbd8decbee76b03ec9c6325..9980013507546912131bf2d34778b1b6de79d21a 100644 --- a/spec/workers/process_commit_worker_spec.rb +++ b/spec/workers/process_commit_worker_spec.rb @@ -81,9 +81,10 @@ describe ProcessCommitWorker do let(:commit) do project.repository.create_branch('feature-merged', 'feature') + project.repository.after_create_branch MergeRequests::MergeService - .new(project, merge_request.author) + .new(project, merge_request.author, { sha: merge_request.diff_head_sha }) .execute(merge_request) merge_request.reload.merge_commit diff --git a/spec/workers/project_cache_worker_spec.rb b/spec/workers/project_cache_worker_spec.rb index 7f3c4881b89f3a2ef96386077462882156421f0d..fa02762d71668932fae0a584daefd275bf0bcb43 100644 --- a/spec/workers/project_cache_worker_spec.rb +++ b/spec/workers/project_cache_worker_spec.rb @@ -105,7 +105,7 @@ describe ProjectCacheWorker do end context 'when a lease could be obtained' do - it 'updates the project statistics twice' do + it 'updates the project statistics twice', :sidekiq_might_not_need_inline do stub_exclusive_lease(lease_key, timeout: lease_timeout) expect(Projects::UpdateStatisticsService).to receive(:new) diff --git a/spec/workers/remove_expired_group_links_worker_spec.rb b/spec/workers/remove_expired_group_links_worker_spec.rb index 10d9aa37deea5d00cb79e7d141b409989b384736..9557aa3086ca52133128b06654dbfb2f49986ee8 100644 --- a/spec/workers/remove_expired_group_links_worker_spec.rb +++ b/spec/workers/remove_expired_group_links_worker_spec.rb @@ -4,23 +4,54 @@ require 'spec_helper' describe RemoveExpiredGroupLinksWorker do describe '#perform' do - let!(:expired_project_group_link) { create(:project_group_link, expires_at: 1.hour.ago) } - let!(:project_group_link_expiring_in_future) { create(:project_group_link, expires_at: 10.days.from_now) } - let!(:non_expiring_project_group_link) { create(:project_group_link, expires_at: nil) } + context 'ProjectGroupLinks' do + let!(:expired_project_group_link) { create(:project_group_link, expires_at: 1.hour.ago) } + let!(:project_group_link_expiring_in_future) { create(:project_group_link, expires_at: 10.days.from_now) } + let!(:non_expiring_project_group_link) { create(:project_group_link, expires_at: nil) } - it 'removes expired group links' do - expect { subject.perform }.to change { ProjectGroupLink.count }.by(-1) - expect(ProjectGroupLink.find_by(id: expired_project_group_link.id)).to be_nil - end + it 'removes expired group links' do + expect { subject.perform }.to change { ProjectGroupLink.count }.by(-1) + expect(ProjectGroupLink.find_by(id: expired_project_group_link.id)).to be_nil + end + + it 'leaves group links that expire in the future' do + subject.perform + expect(project_group_link_expiring_in_future.reload).to be_present + end - it 'leaves group links that expire in the future' do - subject.perform - expect(project_group_link_expiring_in_future.reload).to be_present + it 'leaves group links that do not expire at all' do + subject.perform + expect(non_expiring_project_group_link.reload).to be_present + end end - it 'leaves group links that do not expire at all' do - subject.perform - expect(non_expiring_project_group_link.reload).to be_present + context 'GroupGroupLinks' do + let(:mock_destroy_service) { instance_double(Groups::GroupLinks::DestroyService) } + + before do + allow(Groups::GroupLinks::DestroyService).to( + receive(:new).and_return(mock_destroy_service)) + end + + context 'expired GroupGroupLink exists' do + before do + create(:group_group_link, expires_at: 1.hour.ago) + end + + it 'calls Groups::GroupLinks::DestroyService' do + expect(mock_destroy_service).to receive(:execute).once + + subject.perform + end + end + + context 'expired GroupGroupLink does not exist' do + it 'does not call Groups::GroupLinks::DestroyService' do + expect(mock_destroy_service).not_to receive(:execute) + + subject.perform + end + end end end end diff --git a/spec/workers/repository_check/single_repository_worker_spec.rb b/spec/workers/repository_check/single_repository_worker_spec.rb index 65e1c5e9d5d139bdfd72a126bf23da382085d300..6870e15424fdde1cd35b1aa20a4f4d6e95241263 100644 --- a/spec/workers/repository_check/single_repository_worker_spec.rb +++ b/spec/workers/repository_check/single_repository_worker_spec.rb @@ -68,7 +68,7 @@ describe RepositoryCheck::SingleRepositoryWorker do it 'creates missing wikis' do project = create(:project, :wiki_enabled) - Gitlab::Shell.new.rm_directory(project.repository_storage, project.wiki.path) + TestEnv.rm_storage_dir(project.repository_storage, project.wiki.path) subject.perform(project.id) @@ -77,12 +77,12 @@ describe RepositoryCheck::SingleRepositoryWorker do it 'does not create a wiki if the main repo does not exist at all' do project = create(:project, :repository) - Gitlab::Shell.new.rm_directory(project.repository_storage, project.path) - Gitlab::Shell.new.rm_directory(project.repository_storage, project.wiki.path) + TestEnv.rm_storage_dir(project.repository_storage, project.path) + TestEnv.rm_storage_dir(project.repository_storage, project.wiki.path) subject.perform(project.id) - expect(Gitlab::Shell.new.exists?(project.repository_storage, project.wiki.path)).to eq(false) + expect(TestEnv.storage_dir_exists?(project.repository_storage, project.wiki.path)).to eq(false) end def create_push_event(project) diff --git a/spec/workers/stuck_ci_jobs_worker_spec.rb b/spec/workers/stuck_ci_jobs_worker_spec.rb index c3d577e2dae5f7cf6f938e8555a4d4b7e1c175bb..59707409b5a5d384cc46a5c689e1a5fa60f16010 100644 --- a/spec/workers/stuck_ci_jobs_worker_spec.rb +++ b/spec/workers/stuck_ci_jobs_worker_spec.rb @@ -18,15 +18,30 @@ describe StuckCiJobsWorker do end shared_examples 'job is dropped' do - before do + it "changes status" do worker.perform job.reload - end - it "changes status" do expect(job).to be_failed expect(job).to be_stuck_or_timeout_failure end + + context 'when job have data integrity problem' do + it "does drop the job and logs the reason" do + job.update_columns(yaml_variables: '[{"key" => "value"}]') + + expect(Gitlab::Sentry).to receive(:track_acceptable_exception) + .with(anything, a_hash_including(extra: a_hash_including(build_id: job.id))) + .once + .and_call_original + + worker.perform + job.reload + + expect(job).to be_failed + expect(job).to be_data_integrity_failure + end + end end shared_examples 'job is unchanged' do diff --git a/spec/workers/stuck_merge_jobs_worker_spec.rb b/spec/workers/stuck_merge_jobs_worker_spec.rb index 09efed6d2cfeadcbedf6a348a721f167d575876a..8ceaf1fc5551dada469095de593e64260f46acc0 100644 --- a/spec/workers/stuck_merge_jobs_worker_spec.rb +++ b/spec/workers/stuck_merge_jobs_worker_spec.rb @@ -22,7 +22,7 @@ describe StuckMergeJobsWorker do expect(mr_without_sha.merge_jid).to be_nil end - it 'updates merge request to opened when locked but has not been merged' do + it 'updates merge request to opened when locked but has not been merged', :sidekiq_might_not_need_inline do allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return(%w(123)) merge_request = create(:merge_request, :locked, merge_jid: '123', state: :locked) pipeline = create(:ci_empty_pipeline, project: merge_request.project, ref: merge_request.source_branch, sha: merge_request.source_branch_sha) diff --git a/spec/workers/wait_for_cluster_creation_worker_spec.rb b/spec/workers/wait_for_cluster_creation_worker_spec.rb index 850eba263a74fe2b7977b870ec46322ac9ac73cc..b21a9b612af1632311710c47450fccfac0b341cc 100644 --- a/spec/workers/wait_for_cluster_creation_worker_spec.rb +++ b/spec/workers/wait_for_cluster_creation_worker_spec.rb @@ -8,8 +8,19 @@ describe WaitForClusterCreationWorker do let(:cluster) { create(:cluster, provider_type: :gcp, provider_gcp: provider) } let(:provider) { create(:cluster_provider_gcp, :creating) } - it 'provision a cluster' do - expect_any_instance_of(Clusters::Gcp::VerifyProvisionStatusService).to receive(:execute) + it 'provisions a cluster' do + expect_any_instance_of(Clusters::Gcp::VerifyProvisionStatusService).to receive(:execute).with(provider) + + described_class.new.perform(cluster.id) + end + end + + context 'when provider type is aws' do + let(:cluster) { create(:cluster, provider_type: :aws, provider_aws: provider) } + let(:provider) { create(:cluster_provider_aws, :creating) } + + it 'provisions a cluster' do + expect_any_instance_of(Clusters::Aws::VerifyProvisionStatusService).to receive(:execute).with(provider) described_class.new.perform(cluster.id) end diff --git a/vendor/aws/cloudformation/eks_cluster.yaml b/vendor/aws/cloudformation/eks_cluster.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c32f54d66dc98b86e6425fa79312f4c251832fb7 --- /dev/null +++ b/vendor/aws/cloudformation/eks_cluster.yaml @@ -0,0 +1,340 @@ +--- +AWSTemplateFormatVersion: "2010-09-09" +Description: GitLab EKS Cluster + +Parameters: + + KubernetesVersion: + Description: The Kubernetes version to install + Type: String + Default: 1.14 + AllowedValues: + - 1.12 + - 1.13 + - 1.14 + + KeyName: + Description: The EC2 Key Pair to allow SSH access to the node instances + Type: AWS::EC2::KeyPair::KeyName + + NodeImageIdSSMParam: + Type: "AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>" + Default: /aws/service/eks/optimized-ami/1.14/amazon-linux-2/recommended/image_id + Description: AWS Systems Manager Parameter Store parameter of the AMI ID for the worker node instances. + + NodeInstanceType: + Description: EC2 instance type for the node instances + Type: String + Default: t3.medium + ConstraintDescription: Must be a valid EC2 instance type + AllowedValues: + - t2.small + - t2.medium + - t2.large + - t2.xlarge + - t2.2xlarge + - t3.nano + - t3.micro + - t3.small + - t3.medium + - t3.large + - t3.xlarge + - t3.2xlarge + - m3.medium + - m3.large + - m3.xlarge + - m3.2xlarge + - m4.large + - m4.xlarge + - m4.2xlarge + - m4.4xlarge + - m4.10xlarge + - m5.large + - m5.xlarge + - m5.2xlarge + - m5.4xlarge + - m5.12xlarge + - m5.24xlarge + - c4.large + - c4.xlarge + - c4.2xlarge + - c4.4xlarge + - c4.8xlarge + - c5.large + - c5.xlarge + - c5.2xlarge + - c5.4xlarge + - c5.9xlarge + - c5.18xlarge + - i3.large + - i3.xlarge + - i3.2xlarge + - i3.4xlarge + - i3.8xlarge + - i3.16xlarge + - r3.xlarge + - r3.2xlarge + - r3.4xlarge + - r3.8xlarge + - r4.large + - r4.xlarge + - r4.2xlarge + - r4.4xlarge + - r4.8xlarge + - r4.16xlarge + - x1.16xlarge + - x1.32xlarge + - p2.xlarge + - p2.8xlarge + - p2.16xlarge + - p3.2xlarge + - p3.8xlarge + - p3.16xlarge + - p3dn.24xlarge + - r5.large + - r5.xlarge + - r5.2xlarge + - r5.4xlarge + - r5.12xlarge + - r5.24xlarge + - r5d.large + - r5d.xlarge + - r5d.2xlarge + - r5d.4xlarge + - r5d.12xlarge + - r5d.24xlarge + - z1d.large + - z1d.xlarge + - z1d.2xlarge + - z1d.3xlarge + - z1d.6xlarge + - z1d.12xlarge + + NodeAutoScalingGroupDesiredCapacity: + Description: Desired capacity of Node Group ASG. + Type: Number + Default: 3 + + NodeVolumeSize: + Description: Node volume size + Type: Number + Default: 20 + + ClusterName: + Description: Unique name for your Amazon EKS cluster. + Type: String + + ClusterRole: + Description: The IAM Role to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. + Type: String + + ClusterControlPlaneSecurityGroup: + Description: The security groups to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets. + Type: AWS::EC2::SecurityGroup::Id + + VpcId: + Description: The VPC to use for your EKS Cluster resources. + Type: AWS::EC2::VPC::Id + + Subnets: + Description: The subnets in your VPC where your worker nodes will run. + Type: List<AWS::EC2::Subnet::Id> + +Metadata: + + AWS::CloudFormation::Interface: + ParameterGroups: + - Label: + default: EKS Cluster + Parameters: + - ClusterName + - ClusterRole + - KubernetesVersion + - ClusterControlPlaneSecurityGroup + - Label: + default: Worker Node Configuration + Parameters: + - NodeAutoScalingGroupDesiredCapacity + - NodeInstanceType + - NodeImageIdSSMParam + - NodeVolumeSize + - KeyName + - Label: + default: Worker Network Configuration + Parameters: + - VpcId + - Subnets + +Resources: + + Cluster: + Type: AWS::EKS::Cluster + Properties: + Name: !Sub ${ClusterName} + Version: !Sub ${KubernetesVersion} + RoleArn: !Sub ${ClusterRole} + ResourcesVpcConfig: + SecurityGroupIds: + - !Ref ClusterControlPlaneSecurityGroup + SubnetIds: !Ref Subnets + + NodeInstanceProfile: + Type: AWS::IAM::InstanceProfile + Properties: + Path: "/" + Roles: + - !Ref NodeInstanceRole + + NodeInstanceRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: ec2.amazonaws.com + Action: sts:AssumeRole + Path: "/" + ManagedPolicyArns: + - arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy + - arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy + - arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly + + NodeSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Security group for all nodes in the cluster + VpcId: !Ref VpcId + Tags: + - Key: !Sub kubernetes.io/cluster/${ClusterName} + Value: owned + + NodeSecurityGroupIngress: + Type: AWS::EC2::SecurityGroupIngress + DependsOn: NodeSecurityGroup + Properties: + Description: Allow nodes to communicate with each other + GroupId: !Ref NodeSecurityGroup + SourceSecurityGroupId: !Ref NodeSecurityGroup + IpProtocol: -1 + FromPort: 0 + ToPort: 65535 + + NodeSecurityGroupFromControlPlaneIngress: + Type: AWS::EC2::SecurityGroupIngress + DependsOn: NodeSecurityGroup + Properties: + Description: Allow worker Kubelets and pods to receive communication from the cluster control plane + GroupId: !Ref NodeSecurityGroup + SourceSecurityGroupId: !Ref ClusterControlPlaneSecurityGroup + IpProtocol: tcp + FromPort: 1025 + ToPort: 65535 + + ControlPlaneEgressToNodeSecurityGroup: + Type: AWS::EC2::SecurityGroupEgress + DependsOn: NodeSecurityGroup + Properties: + Description: Allow the cluster control plane to communicate with worker Kubelet and pods + GroupId: !Ref ClusterControlPlaneSecurityGroup + DestinationSecurityGroupId: !Ref NodeSecurityGroup + IpProtocol: tcp + FromPort: 1025 + ToPort: 65535 + + NodeSecurityGroupFromControlPlaneOn443Ingress: + Type: AWS::EC2::SecurityGroupIngress + DependsOn: NodeSecurityGroup + Properties: + Description: Allow pods running extension API servers on port 443 to receive communication from cluster control plane + GroupId: !Ref NodeSecurityGroup + SourceSecurityGroupId: !Ref ClusterControlPlaneSecurityGroup + IpProtocol: tcp + FromPort: 443 + ToPort: 443 + + ControlPlaneEgressToNodeSecurityGroupOn443: + Type: AWS::EC2::SecurityGroupEgress + DependsOn: NodeSecurityGroup + Properties: + Description: Allow the cluster control plane to communicate with pods running extension API servers on port 443 + GroupId: !Ref ClusterControlPlaneSecurityGroup + DestinationSecurityGroupId: !Ref NodeSecurityGroup + IpProtocol: tcp + FromPort: 443 + ToPort: 443 + + ClusterControlPlaneSecurityGroupIngress: + Type: AWS::EC2::SecurityGroupIngress + DependsOn: NodeSecurityGroup + Properties: + Description: Allow pods to communicate with the cluster API Server + GroupId: !Ref ClusterControlPlaneSecurityGroup + SourceSecurityGroupId: !Ref NodeSecurityGroup + IpProtocol: tcp + ToPort: 443 + FromPort: 443 + + NodeGroup: + Type: AWS::AutoScaling::AutoScalingGroup + DependsOn: Cluster + Properties: + DesiredCapacity: !Ref NodeAutoScalingGroupDesiredCapacity + LaunchConfigurationName: !Ref NodeLaunchConfig + MinSize: !Ref NodeAutoScalingGroupDesiredCapacity + MaxSize: !Ref NodeAutoScalingGroupDesiredCapacity + VPCZoneIdentifier: !Ref Subnets + Tags: + - Key: Name + Value: !Sub ${ClusterName}-node + PropagateAtLaunch: true + - Key: !Sub kubernetes.io/cluster/${ClusterName} + Value: owned + PropagateAtLaunch: true + UpdatePolicy: + AutoScalingRollingUpdate: + MaxBatchSize: 1 + MinInstancesInService: !Ref NodeAutoScalingGroupDesiredCapacity + PauseTime: PT5M + + NodeLaunchConfig: + Type: AWS::AutoScaling::LaunchConfiguration + Properties: + AssociatePublicIpAddress: true + IamInstanceProfile: !Ref NodeInstanceProfile + ImageId: !Ref NodeImageIdSSMParam + InstanceType: !Ref NodeInstanceType + KeyName: !Ref KeyName + SecurityGroups: + - !Ref NodeSecurityGroup + BlockDeviceMappings: + - DeviceName: /dev/xvda + Ebs: + VolumeSize: !Ref NodeVolumeSize + VolumeType: gp2 + DeleteOnTermination: true + UserData: + Fn::Base64: + !Sub | + #!/bin/bash + set -o xtrace + /etc/eks/bootstrap.sh "${ClusterName}" + /opt/aws/bin/cfn-signal --exit-code $? \ + --stack ${AWS::StackName} \ + --resource NodeGroup \ + --region ${AWS::Region} + +Outputs: + + NodeInstanceRole: + Description: The node instance role + Value: !GetAtt NodeInstanceRole.Arn + + ClusterCertificate: + Description: The cluster certificate + Value: !GetAtt Cluster.CertificateAuthorityData + + ClusterEndpoint: + Description: The cluster endpoint + Value: !GetAtt Cluster.Endpoint diff --git a/vendor/crossplane/values.yaml b/vendor/crossplane/values.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/vendor/elastic_stack/values.yaml b/vendor/elastic_stack/values.yaml new file mode 100644 index 0000000000000000000000000000000000000000..9346c0e25e6c9a7029223d8bbf0f903089ba8788 --- /dev/null +++ b/vendor/elastic_stack/values.yaml @@ -0,0 +1,47 @@ +elasticsearch: + enabled: true + cluster: + env: + MINIMUM_MASTER_NODES: "1" + master: + replicas: 2 + client: + replicas: 1 + data: + 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" + +logstash: + enabled: false + +filebeat: + enabled: true + config: + output.file.enabled: false + output.elasticsearch: + enabled: true + hosts: ["http://elastic-stack-elasticsearch-client:9200"] + +fluentd: + enabled: false + +fluent-bit: + enabled: false + +nginx-ldapauth-proxy: + enabled: false + +elasticsearch-curator: + enabled: false + +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/ingress/modsecurity.conf b/vendor/ingress/modsecurity.conf new file mode 100644 index 0000000000000000000000000000000000000000..3a6b5cee2e5143b9ddc7e9d33a1621817eb476ca --- /dev/null +++ b/vendor/ingress/modsecurity.conf @@ -0,0 +1,274 @@ +# -- GitLab Customization ---------------------------------------------- +# Based on https://github.com/SpiderLabs/ModSecurity/blob/v3.0.3/modsecurity.conf-recommended +# Our base modsecurity.conf includes some minor customization: +# - `SecRuleEngine` is disabled, defaulting to `DetectionOnly`. Overridable at project-level +# - `SecAuditLogType` is disabled, defaulting to `Serial`. Overridable at project-level +# - `SecStatusEngine` is disabled, to disallow usage reporting +# +# ---------------------------------------------------------------------------- + +# -- Rule engine initialization ---------------------------------------------- + +# Enable ModSecurity, attaching it to every transaction. Use detection +# only to start with, because that minimises the chances of post-installation +# disruption. +# +# SecRuleEngine DetectionOnly + + +# -- Request body handling --------------------------------------------------- + +# Allow ModSecurity to access request bodies. If you don't, ModSecurity +# won't be able to see any POST parameters, which opens a large security +# hole for attackers to exploit. +# +SecRequestBodyAccess On + + +# Enable XML request body parser. +# Initiate XML Processor in case of xml content-type +# +SecRule REQUEST_HEADERS:Content-Type "(?:application(?:/soap\+|/)|text/)xml" \ + "id:'200000',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=XML" + +# Enable JSON request body parser. +# Initiate JSON Processor in case of JSON content-type; change accordingly +# if your application does not use 'application/json' +# +SecRule REQUEST_HEADERS:Content-Type "application/json" \ + "id:'200001',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=JSON" + +# Maximum request body size we will accept for buffering. If you support +# file uploads then the value given on the first line has to be as large +# as the largest file you are willing to accept. The second value refers +# to the size of data, with files excluded. You want to keep that value as +# low as practical. +# +SecRequestBodyLimit 13107200 +SecRequestBodyNoFilesLimit 131072 + +# What do do if the request body size is above our configured limit. +# Keep in mind that this setting will automatically be set to ProcessPartial +# when SecRuleEngine is set to DetectionOnly mode in order to minimize +# disruptions when initially deploying ModSecurity. +# +SecRequestBodyLimitAction Reject + +# Verify that we've correctly processed the request body. +# As a rule of thumb, when failing to process a request body +# you should reject the request (when deployed in blocking mode) +# or log a high-severity alert (when deployed in detection-only mode). +# +SecRule REQBODY_ERROR "!@eq 0" \ +"id:'200002', phase:2,t:none,log,deny,status:400,msg:'Failed to parse request body.',logdata:'%{reqbody_error_msg}',severity:2" + +# By default be strict with what we accept in the multipart/form-data +# request body. If the rule below proves to be too strict for your +# environment consider changing it to detection-only. You are encouraged +# _not_ to remove it altogether. +# +SecRule MULTIPART_STRICT_ERROR "!@eq 0" \ +"id:'200003',phase:2,t:none,log,deny,status:400, \ +msg:'Multipart request body failed strict validation: \ +PE %{REQBODY_PROCESSOR_ERROR}, \ +BQ %{MULTIPART_BOUNDARY_QUOTED}, \ +BW %{MULTIPART_BOUNDARY_WHITESPACE}, \ +DB %{MULTIPART_DATA_BEFORE}, \ +DA %{MULTIPART_DATA_AFTER}, \ +HF %{MULTIPART_HEADER_FOLDING}, \ +LF %{MULTIPART_LF_LINE}, \ +SM %{MULTIPART_MISSING_SEMICOLON}, \ +IQ %{MULTIPART_INVALID_QUOTING}, \ +IP %{MULTIPART_INVALID_PART}, \ +IH %{MULTIPART_INVALID_HEADER_FOLDING}, \ +FL %{MULTIPART_FILE_LIMIT_EXCEEDED}'" + +# Did we see anything that might be a boundary? +# +# Here is a short description about the ModSecurity Multipart parser: the +# parser returns with value 0, if all "boundary-like" line matches with +# the boundary string which given in MIME header. In any other cases it returns +# with different value, eg. 1 or 2. +# +# The RFC 1341 descript the multipart content-type and its syntax must contains +# only three mandatory lines (above the content): +# * Content-Type: multipart/mixed; boundary=BOUNDARY_STRING +# * --BOUNDARY_STRING +# * --BOUNDARY_STRING-- +# +# First line indicates, that this is a multipart content, second shows that +# here starts a part of the multipart content, third shows the end of content. +# +# If there are any other lines, which starts with "--", then it should be +# another boundary id - or not. +# +# After 3.0.3, there are two kinds of types of boundary errors: strict and permissive. +# +# If multipart content contains the three necessary lines with correct order, but +# there are one or more lines with "--", then parser returns with value 2 (non-zero). +# +# If some of the necessary lines (usually the start or end) misses, or the order +# is wrong, then parser returns with value 1 (also a non-zero). +# +# You can choose, which one is what you need. The example below contains the +# 'strict' mode, which means if there are any lines with start of "--", then +# ModSecurity blocked the content. But the next, commented example contains +# the 'permissive' mode, then you check only if the necessary lines exists in +# correct order. Whit this, you can enable to upload PEM files (eg "----BEGIN.."), +# or other text files, which contains eg. HTTP headers. +# +# The difference is only the operator - in strict mode (first) the content blocked +# in case of any non-zero value. In permissive mode (second, commented) the +# content blocked only if the value is explicit 1. If it 0 or 2, the content will +# allowed. +# + +# +# See #1747 and #1924 for further information on the possible values for +# MULTIPART_UNMATCHED_BOUNDARY. +# +SecRule MULTIPART_UNMATCHED_BOUNDARY "@eq 1" \ + "id:'200004',phase:2,t:none,log,deny,msg:'Multipart parser detected a possible unmatched boundary.'" + + +# PCRE Tuning +# We want to avoid a potential RegEx DoS condition +# +SecPcreMatchLimit 1000 +SecPcreMatchLimitRecursion 1000 + +# Some internal errors will set flags in TX and we will need to look for these. +# All of these are prefixed with "MSC_". The following flags currently exist: +# +# MSC_PCRE_LIMITS_EXCEEDED: PCRE match limits were exceeded. +# +SecRule TX:/^MSC_/ "!@streq 0" \ + "id:'200005',phase:2,t:none,deny,msg:'ModSecurity internal error flagged: %{MATCHED_VAR_NAME}'" + + +# -- Response body handling -------------------------------------------------- + +# Allow ModSecurity to access response bodies. +# You should have this directive enabled in order to identify errors +# and data leakage issues. +# +# Do keep in mind that enabling this directive does increases both +# memory consumption and response latency. +# +SecResponseBodyAccess On + +# Which response MIME types do you want to inspect? You should adjust the +# configuration below to catch documents but avoid static files +# (e.g., images and archives). +# +SecResponseBodyMimeType text/plain text/html text/xml + +# Buffer response bodies of up to 512 KB in length. +SecResponseBodyLimit 524288 + +# What happens when we encounter a response body larger than the configured +# limit? By default, we process what we have and let the rest through. +# That's somewhat less secure, but does not break any legitimate pages. +# +SecResponseBodyLimitAction ProcessPartial + + +# -- Filesystem configuration ------------------------------------------------ + +# The location where ModSecurity stores temporary files (for example, when +# it needs to handle a file upload that is larger than the configured limit). +# +# This default setting is chosen due to all systems have /tmp available however, +# this is less than ideal. It is recommended that you specify a location that's private. +# +SecTmpDir /tmp/ + +# The location where ModSecurity will keep its persistent data. This default setting +# is chosen due to all systems have /tmp available however, it +# too should be updated to a place that other users can't access. +# +SecDataDir /tmp/ + + +# -- File uploads handling configuration ------------------------------------- + +# The location where ModSecurity stores intercepted uploaded files. This +# location must be private to ModSecurity. You don't want other users on +# the server to access the files, do you? +# +#SecUploadDir /opt/modsecurity/var/upload/ + +# By default, only keep the files that were determined to be unusual +# in some way (by an external inspection script). For this to work you +# will also need at least one file inspection rule. +# +#SecUploadKeepFiles RelevantOnly + +# Uploaded files are by default created with permissions that do not allow +# any other user to access them. You may need to relax that if you want to +# interface ModSecurity to an external program (e.g., an anti-virus). +# +#SecUploadFileMode 0600 + + +# -- Debug log configuration ------------------------------------------------- + +# The default debug log configuration is to duplicate the error, warning +# and notice messages from the error log. +# +#SecDebugLog /opt/modsecurity/var/log/debug.log +#SecDebugLogLevel 3 + + +# -- Audit log configuration ------------------------------------------------- + +# Log the transactions that are marked by a rule, as well as those that +# trigger a server error (determined by a 5xx or 4xx, excluding 404, +# level response status codes). +# +SecAuditEngine RelevantOnly +SecAuditLogRelevantStatus "^(?:5|4(?!04))" + +# Log everything we know about a transaction. +SecAuditLogParts ABIJDEFHZ + +# Use a single file for logging. This is much easier to look at, but +# assumes that you will use the audit log only ocassionally. +# +# SecAuditLogType Serial +SecAuditLogFormat JSON +SecAuditLog /var/log/modsec/audit.log + +# Specify the path for concurrent audit logging. +#SecAuditLogStorageDir /opt/modsecurity/var/audit/ + + +# -- Miscellaneous ----------------------------------------------------------- + +# Use the most commonly used application/x-www-form-urlencoded parameter +# separator. There's probably only one application somewhere that uses +# something else so don't expect to change this value. +# +SecArgumentSeparator & + +# Settle on version 0 (zero) cookies, as that is what most applications +# use. Using an incorrect cookie version may open your installation to +# evasion attacks (against the rules that examine named cookies). +# +SecCookieFormat 0 + +# Specify your Unicode Code Point. +# This mapping is used by the t:urlDecodeUni transformation function +# to properly map encoded data to your language. Properly setting +# these directives helps to reduce false positives and negatives. +# +SecUnicodeMapFile unicode.mapping 20127 + +# Improve the quality of ModSecurity by sharing information about your +# current ModSecurity version and dependencies versions. +# The following information will be shared: ModSecurity version, +# Web Server version, APR version, PCRE version, Lua version, Libxml2 +# version, Anonymous unique id for host. +# SecStatusEngine On + + diff --git a/vendor/project_templates/serverless_framework.tar.gz b/vendor/project_templates/serverless_framework.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..b09de0ec3a2a6a408aa957282eb6a170a7ac9d50 Binary files /dev/null and b/vendor/project_templates/serverless_framework.tar.gz differ diff --git a/yarn.lock b/yarn.lock index 3abd18d111173ecc8b11227ad15727ac73f03fb2..3b359ee98e9b7dcf9daf23e2fb02bfa14d49831f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9,7 +9,7 @@ dependencies: "@babel/highlight" "^7.0.0" -"@babel/core@>=7.2.2", "@babel/core@^7.1.0", "@babel/core@^7.1.2", "@babel/core@^7.6.2": +"@babel/core@>=7.2.2", "@babel/core@^7.1.0", "@babel/core@^7.6.2": version "7.6.2" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.6.2.tgz#069a776e8d5e9eefff76236bc8845566bd31dd91" integrity sha512-l8zto/fuoZIbncm+01p8zPSDZu/VuuJhAfA7d/AbzM09WR7iVhavvfNDYCNpo1VvLk6E6xgAoP9P+/EMJHuRkQ== @@ -29,7 +29,7 @@ semver "^5.4.1" source-map "^0.5.0" -"@babel/generator@^7.1.3", "@babel/generator@^7.4.0", "@babel/generator@^7.6.2": +"@babel/generator@^7.4.0", "@babel/generator@^7.6.2": version "7.6.2" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.6.2.tgz#dac8a3c2df118334c2a29ff3446da1636a8f8c03" integrity sha512-j8iHaIW4gGPnViaIHI7e9t/Hl8qLjERI6DcV9kEpAIDJsAOrcnXqRS7t+QbhL76pwbtqP+QCQLL0z1CyVmtjjQ== @@ -54,14 +54,6 @@ "@babel/helper-explode-assignable-expression" "^7.1.0" "@babel/types" "^7.0.0" -"@babel/helper-builder-react-jsx@^7.3.0": - version "7.3.0" - resolved "https://registry.yarnpkg.com/@babel/helper-builder-react-jsx/-/helper-builder-react-jsx-7.3.0.tgz#a1ac95a5d2b3e88ae5e54846bf462eeb81b318a4" - integrity sha512-MjA9KgwCuPEkQd9ncSXvSyJ5y+j2sICHyrI0M3L+6fnS4wMSNDc1ARXsbTfbb2cXHn17VisSnU/sHFTCxVxSMw== - dependencies: - "@babel/types" "^7.3.0" - esutils "^2.0.0" - "@babel/helper-call-delegate@^7.4.4": version "7.4.4" resolved "https://registry.yarnpkg.com/@babel/helper-call-delegate/-/helper-call-delegate-7.4.4.tgz#87c1f8ca19ad552a736a7a27b1c1fcf8b1ff1f43" @@ -71,7 +63,7 @@ "@babel/traverse" "^7.4.4" "@babel/types" "^7.4.4" -"@babel/helper-create-class-features-plugin@^7.4.4", "@babel/helper-create-class-features-plugin@^7.5.5", "@babel/helper-create-class-features-plugin@^7.6.0": +"@babel/helper-create-class-features-plugin@^7.5.5", "@babel/helper-create-class-features-plugin@^7.6.0": version "7.6.0" resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.6.0.tgz#769711acca889be371e9bc2eb68641d55218021f" integrity sha512-O1QWBko4fzGju6VoVvrZg0RROCVifcLxiApnGP3OWfWzvxRZFCoBD81K5ur5e3bVY2Vf/5rIJm8cqPKn8HUJng== @@ -204,7 +196,7 @@ dependencies: "@babel/types" "^7.4.4" -"@babel/helper-wrap-function@^7.1.0", "@babel/helper-wrap-function@^7.2.0": +"@babel/helper-wrap-function@^7.1.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.2.0.tgz#c4e0012445769e2815b55296ead43a958549f6fa" integrity sha512-o9fP1BZLLSrYlxYEYyl2aS+Flun5gtjTIG8iln+XuEzQTs0PLagAGSXUcqruJwD5fM48jzIEggCKpIfWTcR7pQ== @@ -232,11 +224,6 @@ esutils "^2.0.2" js-tokens "^4.0.0" -"@babel/parser@7.1.3": - version "7.1.3" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.1.3.tgz#2c92469bac2b7fbff810b67fca07bd138b48af77" - integrity sha512-gqmspPZOMW3MIRb9HlrnbZHXI1/KHTOroBwN1NcLL6pWxzqzEKGvRTq0W/PxS45OtQGbaFikSQpkS5zbnsQm2w== - "@babel/parser@^7.0.0", "@babel/parser@^7.1.0", "@babel/parser@^7.4.3", "@babel/parser@^7.6.0", "@babel/parser@^7.6.2": version "7.6.2" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.6.2.tgz#205e9c95e16ba3b8b96090677a67c9d6075b70a1" @@ -251,7 +238,7 @@ "@babel/helper-remap-async-to-generator" "^7.1.0" "@babel/plugin-syntax-async-generators" "^7.2.0" -"@babel/plugin-proposal-class-properties@^7.1.0", "@babel/plugin-proposal-class-properties@^7.5.5": +"@babel/plugin-proposal-class-properties@^7.5.5": version "7.5.5" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.5.5.tgz#a974cfae1e37c3110e71f3c6a2e48b8e71958cd4" integrity sha512-AF79FsnWFxjlaosgdi421vmYG6/jg79bVD0dpD44QdgobzHKuLZ6S3vl8la9qIeSwGi8i1fS0O1mfuDAAdo1/A== @@ -259,23 +246,6 @@ "@babel/helper-create-class-features-plugin" "^7.5.5" "@babel/helper-plugin-utils" "^7.0.0" -"@babel/plugin-proposal-decorators@^7.1.2": - version "7.4.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.4.4.tgz#de9b2a1a8ab0196f378e2a82f10b6e2a36f21cc0" - integrity sha512-z7MpQz3XC/iQJWXH9y+MaWcLPNSMY9RQSthrLzak8R8hCj0fuyNk+Dzi9kfNe/JxxlWQ2g7wkABbgWjW36MTcw== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.4.4" - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-syntax-decorators" "^7.2.0" - -"@babel/plugin-proposal-do-expressions@^7.0.0": - version "7.5.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-do-expressions/-/plugin-proposal-do-expressions-7.5.0.tgz#ceb594d4a618545b00aa0b5cd61cad4aaaeb7a5a" - integrity sha512-xe0QQrhm+DGj6H23a6XtwkJNimy1fo71O/YVBfrfvfSl0fsq9T9dfoQBIY4QceEIdUo7u9s7OPEdsWEuizfGeg== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-syntax-do-expressions" "^7.2.0" - "@babel/plugin-proposal-dynamic-import@^7.5.0": version "7.5.0" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.5.0.tgz#e532202db4838723691b10a67b8ce509e397c506" @@ -284,40 +254,7 @@ "@babel/helper-plugin-utils" "^7.0.0" "@babel/plugin-syntax-dynamic-import" "^7.2.0" -"@babel/plugin-proposal-export-default-from@^7.0.0": - version "7.5.2" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.5.2.tgz#2c0ac2dcc36e3b2443fead2c3c5fc796fb1b5145" - integrity sha512-wr9Itk05L1/wyyZKVEmXWCdcsp/e185WUNl6AfYZeEKYaUPPvHXRDqO5K1VH7/UamYqGJowFRuCv30aDYZawsg== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-syntax-export-default-from" "^7.2.0" - -"@babel/plugin-proposal-export-namespace-from@^7.0.0": - version "7.5.2" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.5.2.tgz#ccd5ed05b06d700688ff1db01a9dd27155e0d2a0" - integrity sha512-TKUdOL07anjZEbR1iSxb5WFh810KyObdd29XLFLGo1IDsSuGrjH3ouWSbAxHNmrVKzr9X71UYl2dQ7oGGcRp0g== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-syntax-export-namespace-from" "^7.2.0" - -"@babel/plugin-proposal-function-bind@^7.0.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-function-bind/-/plugin-proposal-function-bind-7.2.0.tgz#94dc2cdc505cafc4e225c0014335a01648056bf7" - integrity sha512-qOFJ/eX1Is78sywwTxDcsntLOdb5ZlHVVqUz5xznq8ldAfOVIyZzp1JE2rzHnaksZIhrqMrwIpQL/qcEprnVbw== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-syntax-function-bind" "^7.2.0" - -"@babel/plugin-proposal-function-sent@^7.1.0": - version "7.5.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-function-sent/-/plugin-proposal-function-sent-7.5.0.tgz#39233aa801145e7d8072077cdb2d25f781c1ffd7" - integrity sha512-JXdfiQpKoC6UgQliZkp3NX7K3MVec1o1nfTWiCCIORE5ag/QZXhL0aSD8/Y2K+hIHonSTxuJF9rh9zsB6hBi2A== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/helper-wrap-function" "^7.2.0" - "@babel/plugin-syntax-function-sent" "^7.2.0" - -"@babel/plugin-proposal-json-strings@^7.0.0", "@babel/plugin-proposal-json-strings@^7.2.0": +"@babel/plugin-proposal-json-strings@^7.2.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.2.0.tgz#568ecc446c6148ae6b267f02551130891e29f317" integrity sha512-MAFV1CA/YVmYwZG0fBQyXhmj0BHCB5egZHCKWIFVv/XCxAeVGIHfos3SwDck4LvCllENIAg7xMKOG5kH0dzyUg== @@ -325,30 +262,6 @@ "@babel/helper-plugin-utils" "^7.0.0" "@babel/plugin-syntax-json-strings" "^7.2.0" -"@babel/plugin-proposal-logical-assignment-operators@^7.0.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.2.0.tgz#8a5cea6c42a7c87446959e02fff5fad012c56f57" - integrity sha512-0w797xwdPXKk0m3Js74hDi0mCTZplIu93MOSfb1ZLd/XFe3abWypx1QknVk0J+ohnsjYpvjH4Gwfo2i3RicB6Q== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-syntax-logical-assignment-operators" "^7.2.0" - -"@babel/plugin-proposal-nullish-coalescing-operator@^7.0.0": - version "7.4.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.4.4.tgz#41c360d59481d88e0ce3a3f837df10121a769b39" - integrity sha512-Amph7Epui1Dh/xxUxS2+K22/MUi6+6JVTvy3P58tja3B6yKTSjwwx0/d83rF7551D6PVSSoplQb8GCwqec7HRw== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-syntax-nullish-coalescing-operator" "^7.2.0" - -"@babel/plugin-proposal-numeric-separator@^7.0.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.2.0.tgz#646854daf4cd22fd6733f6076013a936310443ac" - integrity sha512-DohMOGDrZiMKS7LthjUZNNcWl8TAf5BZDwZAH4wpm55FuJTHgfqPGdibg7rZDmont/8Yg0zA03IgT6XLeP+4sg== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-syntax-numeric-separator" "^7.2.0" - "@babel/plugin-proposal-object-rest-spread@^7.6.2": version "7.6.2" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.6.2.tgz#8ffccc8f3a6545e9f78988b6bf4fe881b88e8096" @@ -365,22 +278,6 @@ "@babel/helper-plugin-utils" "^7.0.0" "@babel/plugin-syntax-optional-catch-binding" "^7.2.0" -"@babel/plugin-proposal-optional-chaining@^7.0.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.2.0.tgz#ae454f4c21c6c2ce8cb2397dc332ae8b420c5441" - integrity sha512-ea3Q6edZC/55wEBVZAEz42v528VulyO0eir+7uky/sT4XRcdkWJcFi1aPtitTlwUzGnECWJNExWww1SStt+yWw== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-syntax-optional-chaining" "^7.2.0" - -"@babel/plugin-proposal-pipeline-operator@^7.0.0": - version "7.5.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-pipeline-operator/-/plugin-proposal-pipeline-operator-7.5.0.tgz#4100ec55ef4f6a4c2490b5f5a4f2a22dfa272c06" - integrity sha512-HFYuu/yGnkn69ligXxU0ohOVvQDsMNOUJs/c4PYLUVS6ntCYOyGmRQQaSYJARJ9rvc7/ulZKIzxd4wk91hN63A== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-syntax-pipeline-operator" "^7.5.0" - "@babel/plugin-proposal-private-methods@^7.6.0": version "7.6.0" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.6.0.tgz#19ddc493c7b5d47afdd4291e740c609a83c9fae4" @@ -389,14 +286,6 @@ "@babel/helper-create-class-features-plugin" "^7.6.0" "@babel/helper-plugin-utils" "^7.0.0" -"@babel/plugin-proposal-throw-expressions@^7.0.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-throw-expressions/-/plugin-proposal-throw-expressions-7.2.0.tgz#2d9e452d370f139000e51db65d0a85dc60c64739" - integrity sha512-adsydM8DQF4i5DLNO4ySAU5VtHTPewOtNBV3u7F4lNMPADFF9bWQ+iDtUUe8+033cYCUz+bFlQdXQJmJOwoLpw== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-syntax-throw-expressions" "^7.2.0" - "@babel/plugin-proposal-unicode-property-regex@^7.6.2": version "7.6.2" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.6.2.tgz#05413762894f41bfe42b9a5e80919bd575dcc802" @@ -413,63 +302,14 @@ dependencies: "@babel/helper-plugin-utils" "^7.0.0" -"@babel/plugin-syntax-decorators@^7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.2.0.tgz#c50b1b957dcc69e4b1127b65e1c33eef61570c1b" - integrity sha512-38QdqVoXdHUQfTpZo3rQwqQdWtCn5tMv4uV6r2RMfTqNBuv4ZBhz79SfaQWKTVmxHjeFv/DnXVC/+agHCklYWA== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - -"@babel/plugin-syntax-do-expressions@^7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-do-expressions/-/plugin-syntax-do-expressions-7.2.0.tgz#f3d4b01be05ecde2892086d7cfd5f1fa1ead5a2a" - integrity sha512-/u4rJ+XEmZkIhspVuKRS+7WLvm7Dky9j9TvGK5IgId8B3FKir9MG+nQxDZ9xLn10QMBvW58dZ6ABe2juSmARjg== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - -"@babel/plugin-syntax-dynamic-import@^7.0.0", "@babel/plugin-syntax-dynamic-import@^7.2.0": +"@babel/plugin-syntax-dynamic-import@^7.2.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.2.0.tgz#69c159ffaf4998122161ad8ebc5e6d1f55df8612" integrity sha512-mVxuJ0YroI/h/tbFTPGZR8cv6ai+STMKNBq0f8hFxsxWjl94qqhsb+wXbpNMDPU3cfR1TIsVFzU3nXyZMqyK4w== dependencies: "@babel/helper-plugin-utils" "^7.0.0" -"@babel/plugin-syntax-export-default-from@^7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.2.0.tgz#edd83b7adc2e0d059e2467ca96c650ab6d2f3820" - integrity sha512-c7nqUnNST97BWPtoe+Ssi+fJukc9P9/JMZ71IOMNQWza2E+Psrd46N6AEvtw6pqK+gt7ChjXyrw4SPDO79f3Lw== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - -"@babel/plugin-syntax-export-namespace-from@^7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.2.0.tgz#8d257838c6b3b779db52c0224443459bd27fb039" - integrity sha512-1zGA3UNch6A+A11nIzBVEaE3DDJbjfB+eLIcf0GGOh/BJr/8NxL3546MGhV/r0RhH4xADFIEso39TKCfEMlsGA== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - -"@babel/plugin-syntax-flow@^7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.2.0.tgz#a765f061f803bc48f240c26f8747faf97c26bf7c" - integrity sha512-r6YMuZDWLtLlu0kqIim5o/3TNRAlWb073HwT3e2nKf9I8IIvOggPrnILYPsrrKilmn/mYEMCf/Z07w3yQJF6dg== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - -"@babel/plugin-syntax-function-bind@^7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-function-bind/-/plugin-syntax-function-bind-7.2.0.tgz#68fe85b0c0da67125f87bf239c68051b06c66309" - integrity sha512-/WzU1lLU2l0wDfB42Wkg6tahrmtBbiD8C4H6EGSX0M4GAjzN6JiOpq/Uh8G6GSoR6lPMvhjM0MNiV6znj6y/zg== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - -"@babel/plugin-syntax-function-sent@^7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-function-sent/-/plugin-syntax-function-sent-7.2.0.tgz#91474d4d400604e4c6cbd4d77cd6cb3b8565576c" - integrity sha512-2MOVuJ6IMAifp2cf0RFkHQaOvHpbBYyWCvgtF/WVqXhTd7Bgtov8iXVCadLXp2FN1BrI2EFl+JXuwXy0qr3KoQ== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - -"@babel/plugin-syntax-import-meta@^7.0.0", "@babel/plugin-syntax-import-meta@^7.2.0": +"@babel/plugin-syntax-import-meta@^7.2.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.2.0.tgz#2333ef4b875553a3bcd1e93f8ebc09f5b9213a40" integrity sha512-Hq6kFSZD7+PHkmBN8bCpHR6J8QEoCuEV/B38AIQscYjgMZkGlXB7cHNFzP5jR4RCh5545yP1ujHdmO7hAgKtBA== @@ -483,34 +323,6 @@ dependencies: "@babel/helper-plugin-utils" "^7.0.0" -"@babel/plugin-syntax-jsx@^7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.2.0.tgz#0b85a3b4bc7cdf4cc4b8bf236335b907ca22e7c7" - integrity sha512-VyN4QANJkRW6lDBmENzRszvZf3/4AXaj9YR7GwrWeeN9tEBPuXbmDYVU9bYBN0D70zCWVwUy0HWq2553VCb6Hw== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - -"@babel/plugin-syntax-logical-assignment-operators@^7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.2.0.tgz#fcab7388530e96c6f277ce494c55caa6c141fcfb" - integrity sha512-l/NKSlrnvd73/EL540t9hZhcSo4TULBrIPs9Palju8Oc/A8DXDO+xQf04whfeuZLpi8AuIvCAdpKmmubLN4EfQ== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - -"@babel/plugin-syntax-nullish-coalescing-operator@^7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.2.0.tgz#f75083dfd5ade73e783db729bbd87e7b9efb7624" - integrity sha512-lRCEaKE+LTxDQtgbYajI04ddt6WW0WJq57xqkAZ+s11h4YgfRHhVA/Y2VhfPzzFD4qeLHWg32DMp9HooY4Kqlg== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - -"@babel/plugin-syntax-numeric-separator@^7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.2.0.tgz#7470fe070c2944469a756752a69a6963135018be" - integrity sha512-DroeVNkO/BnGpL2R7+ZNZqW+E24aR/4YWxP3Qb15d6lPU8KDzF8HlIUIRCOJRn4X77/oyW4mJY+7FHfY82NLtQ== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-syntax-object-rest-spread@^7.0.0", "@babel/plugin-syntax-object-rest-spread@^7.2.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.2.0.tgz#3b7a3e733510c57e820b9142a6579ac8b0dfad2e" @@ -525,27 +337,6 @@ dependencies: "@babel/helper-plugin-utils" "^7.0.0" -"@babel/plugin-syntax-optional-chaining@^7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.2.0.tgz#a59d6ae8c167e7608eaa443fda9fa8fa6bf21dff" - integrity sha512-HtGCtvp5Uq/jH/WNUPkK6b7rufnCPLLlDAFN7cmACoIjaOOiXxUt3SswU5loHqrhtqTsa/WoLQ1OQ1AGuZqaWA== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - -"@babel/plugin-syntax-pipeline-operator@^7.5.0": - version "7.5.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-pipeline-operator/-/plugin-syntax-pipeline-operator-7.5.0.tgz#8ea7c2c22847c797748bf07752722a317079dc1e" - integrity sha512-5FVxPiMTMXWk4R7Kq9pt272nDu8VImJdaIzvXFSTcXFbgKWWaOdbic12TvUvl6cK+AE5EgnhwvxuWik4ZYYdzg== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - -"@babel/plugin-syntax-throw-expressions@^7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-throw-expressions/-/plugin-syntax-throw-expressions-7.2.0.tgz#79001ee2afe1b174b1733cdc2fc69c9a46a0f1f8" - integrity sha512-ngwynuqu1Rx0JUS9zxSDuPgW1K8TyVZCi2hHehrL4vyjqE7RGoNHWlZsS7KQT2vw9Yjk4YLa0+KldBXTRdPLRg== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-transform-arrow-functions@^7.2.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz#9aeafbe4d6ffc6563bf8f8372091628f00779550" @@ -629,14 +420,6 @@ "@babel/helper-builder-binary-assignment-operator-visitor" "^7.1.0" "@babel/helper-plugin-utils" "^7.0.0" -"@babel/plugin-transform-flow-strip-types@^7.0.0": - version "7.4.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.4.4.tgz#d267a081f49a8705fc9146de0768c6b58dccd8f7" - integrity sha512-WyVedfeEIILYEaWGAUWzVNyqG4sfsNooMhXWsu/YzOvVGcsnPb5PguysjJqI3t3qiaYj0BR8T2f5njdjTGe44Q== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-syntax-flow" "^7.2.0" - "@babel/plugin-transform-for-of@^7.4.4": version "7.4.4" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.4.4.tgz#0267fc735e24c808ba173866c6c4d1440fc3c556" @@ -740,38 +523,6 @@ dependencies: "@babel/helper-plugin-utils" "^7.0.0" -"@babel/plugin-transform-react-display-name@^7.0.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.2.0.tgz#ebfaed87834ce8dc4279609a4f0c324c156e3eb0" - integrity sha512-Htf/tPa5haZvRMiNSQSFifK12gtr/8vwfr+A9y69uF0QcU77AVu4K7MiHEkTxF7lQoHOL0F9ErqgfNEAKgXj7A== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - -"@babel/plugin-transform-react-jsx-self@^7.0.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.2.0.tgz#461e21ad9478f1031dd5e276108d027f1b5240ba" - integrity sha512-v6S5L/myicZEy+jr6ielB0OR8h+EH/1QFx/YJ7c7Ua+7lqsjj/vW6fD5FR9hB/6y7mGbfT4vAURn3xqBxsUcdg== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-syntax-jsx" "^7.2.0" - -"@babel/plugin-transform-react-jsx-source@^7.0.0": - version "7.5.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.5.0.tgz#583b10c49cf057e237085bcbd8cc960bd83bd96b" - integrity sha512-58Q+Jsy4IDCZx7kqEZuSDdam/1oW8OdDX8f+Loo6xyxdfg1yF0GE2XNJQSTZCaMol93+FBzpWiPEwtbMloAcPg== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-syntax-jsx" "^7.2.0" - -"@babel/plugin-transform-react-jsx@^7.0.0": - version "7.3.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.3.0.tgz#f2cab99026631c767e2745a5368b331cfe8f5290" - integrity sha512-a/+aRb7R06WcKvQLOu4/TpjKOdvVEKRLWFpKcNuHhiREPgGRB4TQJxq07+EZLS8LFVYpfq1a5lDUnuMdcCpBKg== - dependencies: - "@babel/helper-builder-react-jsx" "^7.3.0" - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-syntax-jsx" "^7.2.0" - "@babel/plugin-transform-regenerator@^7.4.5": version "7.4.5" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.4.5.tgz#629dc82512c55cee01341fb27bdfcb210354680f" @@ -832,7 +583,7 @@ "@babel/helper-regex" "^7.4.4" regexpu-core "^4.6.0" -"@babel/preset-env@^7.1.0", "@babel/preset-env@^7.6.2": +"@babel/preset-env@^7.6.2": version "7.6.2" resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.6.2.tgz#abbb3ed785c7fe4220d4c82a53621d71fc0c75d3" integrity sha512-Ru7+mfzy9M1/YTEtlDS8CD45jd22ngb9tXnn64DvQK3ooyqSw9K4K9DUWmYknTTVk4TqygL9dqCrZgm1HMea/Q== @@ -888,30 +639,6 @@ js-levenshtein "^1.1.3" semver "^5.5.0" -"@babel/preset-flow@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/@babel/preset-flow/-/preset-flow-7.0.0.tgz#afd764835d9535ec63d8c7d4caf1c06457263da2" - integrity sha512-bJOHrYOPqJZCkPVbG1Lot2r5OSsB+iUOaxiHdlOeB1yPWS6evswVHwvkDLZ54WTaTRIk89ds0iHmGZSnxlPejQ== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-transform-flow-strip-types" "^7.0.0" - -"@babel/preset-react@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/@babel/preset-react/-/preset-react-7.0.0.tgz#e86b4b3d99433c7b3e9e91747e2653958bc6b3c0" - integrity sha512-oayxyPS4Zj+hF6Et11BwuBkmpgT/zMxyuZgFrMeZID6Hdh3dGlk4sHCAhdBCpuCKW2ppBfl2uCCetlrUIJRY3w== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-transform-react-display-name" "^7.0.0" - "@babel/plugin-transform-react-jsx" "^7.0.0" - "@babel/plugin-transform-react-jsx-self" "^7.0.0" - "@babel/plugin-transform-react-jsx-source" "^7.0.0" - -"@babel/preset-stage-0@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/@babel/preset-stage-0/-/preset-stage-0-7.0.0.tgz#999aaec79ee8f0a763042c68c06539c97c6e0646" - integrity sha512-FBMd0IiARPtH5aaOFUVki6evHiJQiY0pFy7fizyRF7dtwc+el3nwpzvhb9qBNzceG1OIJModG1xpE0DDFjPXwA== - "@babel/standalone@^7.0.0": version "7.5.5" resolved "https://registry.yarnpkg.com/@babel/standalone/-/standalone-7.5.5.tgz#9d3143f6078ff408db694a4254bd6f03c5c33962" @@ -926,7 +653,7 @@ "@babel/parser" "^7.6.0" "@babel/types" "^7.6.0" -"@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.1.4", "@babel/traverse@^7.4.3", "@babel/traverse@^7.4.4", "@babel/traverse@^7.5.5", "@babel/traverse@^7.6.2": +"@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.4.3", "@babel/traverse@^7.4.4", "@babel/traverse@^7.5.5", "@babel/traverse@^7.6.2": version "7.6.2" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.6.2.tgz#b0e2bfd401d339ce0e6c05690206d1e11502ce2c" integrity sha512-8fRE76xNwNttVEF2TwxJDGBLWthUkHWSldmfuBzVRmEDWOtu4XdINTgN7TDWzuLg4bbeIMLvfMFD9we5YcWkRQ== @@ -941,7 +668,7 @@ globals "^11.1.0" lodash "^4.17.13" -"@babel/types@^7.0.0", "@babel/types@^7.1.3", "@babel/types@^7.2.0", "@babel/types@^7.3.0", "@babel/types@^7.4.0", "@babel/types@^7.4.4", "@babel/types@^7.5.5", "@babel/types@^7.6.0": +"@babel/types@^7.0.0", "@babel/types@^7.2.0", "@babel/types@^7.3.0", "@babel/types@^7.4.0", "@babel/types@^7.4.4", "@babel/types@^7.5.5", "@babel/types@^7.6.0": version "7.6.1" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.6.1.tgz#53abf3308add3ac2a2884d539151c57c4b3ac648" integrity sha512-X7gdiuaCmA0uRjCmRtYJNAVCc/q+5xSgsfKJHqMN4iNLILX39677fJE1O40arPMh0TTtS9ItH67yre6c7k6t0g== @@ -990,15 +717,15 @@ dependencies: vue-eslint-parser "^6.0.4" -"@gitlab/svgs@^1.78.0": - version "1.78.0" - resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.78.0.tgz#469493bd6cdd254eb5d1271edeab22bbbee2f4c4" - integrity sha512-dBgEB/Q4FRD0NapmNrD86DF1FsV0uSgTx0UOJloHnGE2DNR2P1HQrCmLW2fX+QgN4P9CDAzdi2buVHuholofWw== +"@gitlab/svgs@^1.82.0": + version "1.82.0" + resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.82.0.tgz#c059c460afc13ebfe9df370521ca8963fa5afb80" + integrity sha512-9L4Brys2LCk44lHvFsCFDKN768lYjoMVYDb4PD7FSjqUEruQQ1SRj0rvb1RWKLhiTCDKrtDOXkH6I1TTEms24w== -"@gitlab/ui@5.36.0": - version "5.36.0" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-5.36.0.tgz#3087b23c138ad1c222f6b047e533f253371bc618" - integrity sha512-XXWUYZbRItKh9N92Vxql04BJ05uW5HlOuTCkD+lMbUgneqYTgVoKGH8d9kD++Jy7q8l5+AfzjboUn2n9sbQMZA== +"@gitlab/ui@7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-7.11.0.tgz#b5c981f3b1edbf0ad75bcca8fa1cd81017676b3b" + integrity sha512-PxZkgdY2j/XdriTdp3jsnsif9cgcxd1wUF8PVOho2HIyJqU244E8ELewIXkDozQq3p3ZXzWnjR/GvYcNMZtGmA== dependencies: "@babel/standalone" "^7.0.0" "@gitlab/vue-toasted" "^1.2.1" @@ -1013,10 +740,10 @@ vue "^2.6.10" vue-loader "^15.4.2" -"@gitlab/visual-review-tools@1.0.3": - version "1.0.3" - resolved "https://registry.yarnpkg.com/@gitlab/visual-review-tools/-/visual-review-tools-1.0.3.tgz#b49c4a6fd8af3a1517d7e7d04096562f8bcb5d14" - integrity sha512-96j+0+Ivon5nYvT2doDCLQoBzU/GZYfQGLBmZZE3FZVMsIPAEsqDcSV/6+XCikUzU3B8VnH6er6l9OxE5x1RVw== +"@gitlab/visual-review-tools@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@gitlab/visual-review-tools/-/visual-review-tools-1.2.0.tgz#8d6757917193c1023012bb4a316dc1a97309a27a" + integrity sha512-GaV/lYLmOF0hWtv8K8MLWGaCZ7PL1LF4D0/gargXYf9HO0Cw4wtz4oWyaLS15wFposJIYdPIHSNfrLVk4Dk9sQ== "@gitlab/vue-toasted@^1.2.1": version "1.2.1" @@ -1191,6 +918,63 @@ consola "^2.3.0" node-fetch "^2.3.0" +"@sentry/browser@^5.7.1": + version "5.7.1" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-5.7.1.tgz#1f8435e2a325d7a09f830065ebce40a2b3c708a4" + integrity sha512-K0x1XhsHS8PPdtlVOLrKZyYvi5Vexs9WApdd214bO6KaGF296gJvH1mG8XOY0+7aA5i2A7T3ttcaJNDYS49lzw== + dependencies: + "@sentry/core" "5.7.1" + "@sentry/types" "5.7.1" + "@sentry/utils" "5.7.1" + tslib "^1.9.3" + +"@sentry/core@5.7.1": + version "5.7.1" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.7.1.tgz#3eb2b7662cac68245931ee939ec809bf7a639d0e" + integrity sha512-AOn3k3uVWh2VyajcHbV9Ta4ieDIeLckfo7UMLM+CTk2kt7C89SayDGayJMSsIrsZlL4qxBoLB9QY4W2FgAGJrg== + dependencies: + "@sentry/hub" "5.7.1" + "@sentry/minimal" "5.7.1" + "@sentry/types" "5.7.1" + "@sentry/utils" "5.7.1" + tslib "^1.9.3" + +"@sentry/hub@5.7.1": + version "5.7.1" + resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.7.1.tgz#a52acd9fead7f3779d96e9965c6978aecc8b9cad" + integrity sha512-evGh323WR073WSBCg/RkhlUmCQyzU0xzBzCZPscvcoy5hd4SsLE6t9Zin+WACHB9JFsRQIDwNDn+D+pj3yKsig== + dependencies: + "@sentry/types" "5.7.1" + "@sentry/utils" "5.7.1" + tslib "^1.9.3" + +"@sentry/minimal@5.7.1": + version "5.7.1" + resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-5.7.1.tgz#56afc537737586929e25349765e37a367958c1e1" + integrity sha512-nS/Dg+jWAZtcxQW8wKbkkw4dYvF6uyY/vDiz/jFCaux0LX0uhgXAC9gMOJmgJ/tYBLJ64l0ca5LzpZa7BMJQ0g== + dependencies: + "@sentry/hub" "5.7.1" + "@sentry/types" "5.7.1" + tslib "^1.9.3" + +"@sentry/types@5.7.1": + version "5.7.1" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.7.1.tgz#4c4c1d4d891b6b8c2c3c7b367d306a8b1350f090" + integrity sha512-tbUnTYlSliXvnou5D4C8Zr+7/wJrHLbpYX1YkLXuIJRU0NSi81bHMroAuHWILcQKWhVjaV/HZzr7Y/hhWtbXVQ== + +"@sentry/utils@5.7.1": + version "5.7.1" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.7.1.tgz#cf37ad55f78e317665cd8680f202d307fa77f1d0" + integrity sha512-nhirUKj/qFLsR1i9kJ5BRvNyzdx/E2vorIsukuDrbo8e3iZ11JMgCOVrmC8Eq9YkHBqgwX4UnrPumjFyvGMZ2Q== + dependencies: + "@sentry/types" "5.7.1" + tslib "^1.9.3" + +"@sourcegraph/code-host-integration@^0.0.13": + version "0.0.13" + resolved "https://registry.yarnpkg.com/@sourcegraph/code-host-integration/-/code-host-integration-0.0.13.tgz#4fd5fe1e0088c63b2a26be231c5a2a4ca79b1596" + integrity sha512-IjF9gb9e8dG8p12DKg5Z7UMOVQO/ClH3AyMCPfX/qH7DH/0b55WH6stYVqZu6y776quFonO4Z9gWYM8pQZjzKw== + "@types/anymatch@*": version "1.3.0" resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.0.tgz#d1d55958d1fccc5527d4aba29fc9c4b942f563ff" @@ -1273,7 +1057,7 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== -"@types/node@*", "@types/node@^10.11.7": +"@types/node@*", "@types/node@>=6", "@types/node@^10.11.7": 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== @@ -1305,7 +1089,7 @@ dependencies: source-map "^0.6.1" -"@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2": +"@types/unist@*", "@types/unist@^2.0.0": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e" integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ== @@ -1531,6 +1315,21 @@ "@webassemblyjs/wast-parser" "1.8.5" "@xtuc/long" "4.2.2" +"@wry/context@^0.4.0": + version "0.4.4" + resolved "https://registry.yarnpkg.com/@wry/context/-/context-0.4.4.tgz#e50f5fa1d6cfaabf2977d1fda5ae91717f8815f8" + integrity sha512-LrKVLove/zw6h2Md/KZyWxIkFM6AoyKp71OqpH9Hiip1csjPVoD3tPxlbQUNxEnHENks3UGgNpSBCAfq9KWuag== + dependencies: + "@types/node" ">=6" + tslib "^1.9.3" + +"@wry/equality@^0.1.2": + version "0.1.9" + resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.1.9.tgz#b13e18b7a8053c6858aa6c85b54911fb31e3a909" + integrity sha512-mB6ceGjpMGz1ZTza8HYnrPGos2mC6So4NhS1PtZ8s4Qt0K7fBiIGhpSxUbQmhwcSWE3no+bYxmI2OL6KuXYmoQ== + dependencies: + tslib "^1.9.3" + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" @@ -1546,14 +1345,6 @@ resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== -JSONStream@^1.0.3: - version "1.3.5" - resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" - integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ== - dependencies: - jsonparse "^1.2.0" - through ">=2.2.7 <3" - abab@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.0.tgz#aba0ab4c5eee2d4c79d3487d85450fb2376ebb0f" @@ -1590,7 +1381,7 @@ acorn-walk@^6.0.1, acorn-walk@^6.1.1: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-6.2.0.tgz#123cb8f3b84c2171f1f7fb252615b1c78a6b1a8c" integrity sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA== -acorn@^5.2.1, acorn@^5.5.3: +acorn@^5.5.3: version "5.7.3" resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279" integrity sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw== @@ -1647,7 +1438,7 @@ ansi-escapes@^3.0.0: resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== -ansi-html@0.0.7, ansi-html@^0.0.7: +ansi-html@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e" integrity sha1-gTWEAhliqenm/QOflA0S9WynhZ4= @@ -1695,37 +1486,36 @@ anymatch@^3.0.1: normalize-path "^3.0.0" picomatch "^2.0.4" -apollo-cache-inmemory@^1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.5.1.tgz#265d1ee67b0bf0aca9c37629d410bfae44e62953" - integrity sha512-D3bdpPmWfaKQkWy8lfwUg+K8OBITo3sx0BHLs1B/9vIdOIZ7JNCKq3EUcAgAfInomJUdN0QG1yOfi8M8hxkN1g== +apollo-cache-inmemory@^1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.6.3.tgz#826861d20baca4abc45f7ca7a874105905b8525d" + integrity sha512-S4B/zQNSuYc0M/1Wq8dJDTIO9yRgU0ZwDGnmlqxGGmFombOZb9mLjylewSfQKmjNpciZ7iUIBbJ0mHlPJTzdXg== dependencies: - apollo-cache "^1.2.1" - apollo-utilities "^1.2.1" - optimism "^0.6.9" - ts-invariant "^0.2.1" + apollo-cache "^1.3.2" + apollo-utilities "^1.3.2" + optimism "^0.10.0" + ts-invariant "^0.4.0" tslib "^1.9.3" -apollo-cache@1.2.1, apollo-cache@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.2.1.tgz#aae71eb4a11f1f7322adc343f84b1a39b0693644" - integrity sha512-nzFmep/oKlbzUuDyz6fS6aYhRmfpcHWqNkkA9Bbxwk18RD6LXC4eZkuE0gXRX0IibVBHNjYVK+Szi0Yied4SpQ== +apollo-cache@1.3.2, apollo-cache@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.3.2.tgz#df4dce56240d6c95c613510d7e409f7214e6d26a" + integrity sha512-+KA685AV5ETEJfjZuviRTEImGA11uNBp/MJGnaCvkgr+BYRrGLruVKBv6WvyFod27WEB2sp7SsG8cNBKANhGLg== dependencies: - apollo-utilities "^1.2.1" + apollo-utilities "^1.3.2" tslib "^1.9.3" -apollo-client@^2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.5.1.tgz#36126ed1d32edd79c3713c6684546a3bea80e6d1" - integrity sha512-MNcQKiqLHdGmNJ0rZ0NXaHrToXapJgS/5kPk0FygXt+/FmDCdzqcujI7OPxEC6e9Yw5S/8dIvOXcRNuOMElHkA== +apollo-client@^2.6.4: + version "2.6.4" + resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.6.4.tgz#872c32927263a0d34655c5ef8a8949fbb20b6140" + integrity sha512-oWOwEOxQ9neHHVZrQhHDbI6bIibp9SHgxaLRVPoGvOFy7OH5XUykZE7hBQAVxq99tQjBzgytaZffQkeWo1B4VQ== dependencies: "@types/zen-observable" "^0.8.0" - apollo-cache "1.2.1" + apollo-cache "1.3.2" apollo-link "^1.0.0" - apollo-link-dedup "^1.0.0" - apollo-utilities "1.2.1" + apollo-utilities "1.3.2" symbol-observable "^1.0.2" - ts-invariant "^0.2.1" + ts-invariant "^0.4.0" tslib "^1.9.3" zen-observable "^0.8.0" @@ -1747,13 +1537,6 @@ apollo-link-batch@^1.1.12: apollo-link "^1.2.11" tslib "^1.9.3" -apollo-link-dedup@^1.0.0: - version "1.0.10" - resolved "https://registry.yarnpkg.com/apollo-link-dedup/-/apollo-link-dedup-1.0.10.tgz#7b94589fe7f969777efd18a129043c78430800ae" - integrity sha512-tpUI9lMZsidxdNygSY1FxflXEkUZnvKRkMUsXXuQUNoSLeNtEvUX7QtKRAl4k9ubLl8JKKc9X3L3onAFeGTK8w== - dependencies: - apollo-link "^1.2.3" - apollo-link-http-common@^0.2.13, apollo-link-http-common@^0.2.8: version "0.2.13" resolved "https://registry.yarnpkg.com/apollo-link-http-common/-/apollo-link-http-common-0.2.13.tgz#c688f6baaffdc7b269b2db7ae89dae7c58b5b350" @@ -1763,7 +1546,7 @@ apollo-link-http-common@^0.2.13, apollo-link-http-common@^0.2.8: ts-invariant "^0.3.2" tslib "^1.9.3" -apollo-link@^1.0.0, apollo-link@^1.2.11, apollo-link@^1.2.3, apollo-link@^1.2.6: +apollo-link@^1.0.0, apollo-link@^1.2.11, apollo-link@^1.2.6: version "1.2.11" resolved "https://registry.yarnpkg.com/apollo-link/-/apollo-link-1.2.11.tgz#493293b747ad3237114ccd22e9f559e5e24a194d" integrity sha512-PQvRCg13VduLy3X/0L79M6uOpTh5iHdxnxYuo8yL7sJlWybKRJwsv4IcRBJpMFbChOOaHY7Og9wgPo6DLKDKDA== @@ -1782,22 +1565,16 @@ apollo-upload-client@^10.0.0: apollo-link-http-common "^0.2.8" extract-files "^5.0.0" -apollo-utilities@1.2.1, apollo-utilities@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.2.1.tgz#1c3a1ebf5607d7c8efe7636daaf58e7463b41b3c" - integrity sha512-Zv8Udp9XTSFiN8oyXOjf6PMHepD4yxxReLsl6dPUy5Ths7jti3nmlBzZUOxuTWRwZn0MoclqL7RQ5UEJN8MAxg== +apollo-utilities@1.3.2, apollo-utilities@^1.2.1, apollo-utilities@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.2.tgz#8cbdcf8b012f664cd6cb5767f6130f5aed9115c9" + integrity sha512-JWNHj8XChz7S4OZghV6yc9FNnzEXj285QYp/nLNh943iObycI5GTDO3NGR9Dth12LRrSFMeDOConPfPln+WGfg== dependencies: + "@wry/equality" "^0.1.2" fast-json-stable-stringify "^2.0.0" - ts-invariant "^0.2.1" + ts-invariant "^0.4.0" tslib "^1.9.3" -append-buffer@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/append-buffer/-/append-buffer-1.0.2.tgz#d8220cf466081525efea50614f3de6514dfa58f1" - integrity sha1-2CIM9GYIFSXv6lBhTz3mUU36WPE= - dependencies: - buffer-equal "^1.0.0" - append-transform@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-1.0.0.tgz#046a52ae582a228bd72f58acfbe2967c678759ab" @@ -2110,11 +1887,6 @@ babel-preset-jest@^24.6.0: "@babel/plugin-syntax-object-rest-spread" "^7.0.0" babel-plugin-jest-hoist "^24.6.0" -babelify@^10.0.0: - version "10.0.0" - resolved "https://registry.yarnpkg.com/babelify/-/babelify-10.0.0.tgz#fe73b1a22583f06680d8d072e25a1e0d1d1d7fb5" - integrity sha512-X40FaxyH7t3X+JFAKvb1H9wooWKLRCi8pg3m8poqtdZaIng+bjzp9RvKQCvRjF9isHiPkXspbbXT/zwXLtwgwg== - babylon@7.0.0-beta.19: version "7.0.0-beta.19" resolved "https://registry.yarnpkg.com/babylon/-/babylon-7.0.0-beta.19.tgz#e928c7e807e970e0536b078ab3e0c48f9e052503" @@ -2250,16 +2022,6 @@ body-parser@1.19.0, body-parser@^1.16.1: raw-body "2.4.0" type-is "~1.6.17" -body@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/body/-/body-5.1.0.tgz#e4ba0ce410a46936323367609ecb4e6553125069" - integrity sha1-5LoM5BCkaTYyM2dgnstOZVMSUGk= - dependencies: - continuable-cache "^0.3.1" - error "^7.0.0" - raw-body "~1.1.0" - safe-json-parse "~1.0.1" - bonjour@^3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/bonjour/-/bonjour-3.5.0.tgz#8e890a183d8ee9a2393b3844c691a42bcf7bc9f5" @@ -2342,7 +2104,7 @@ browser-process-hrtime@^0.1.2: resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-0.1.3.tgz#616f00faef1df7ec1b5bf9cfe2bdc3170f26c7b4" integrity sha512-bRFnI4NnjO6cnyLmOV/7PVoDEMJChlcfN0z4s1YMBY989/SvlfMI1lgCnkFUs53e9gQF+w7qu7XdllSTiSl8Aw== -browser-resolve@^1.11.3, browser-resolve@^1.7.0: +browser-resolve@^1.11.3: version "1.11.3" resolved "https://registry.yarnpkg.com/browser-resolve/-/browser-resolve-1.11.3.tgz#9b7cbb3d0f510e4cb86bdbd796124d28b5890af6" integrity sha512-exDi1BYWB/6raKHmDTCicQfTkqwN5fioMFV4j8BsfMU4R2DK/QfZfK7kOVkmWCNANf0snkBzqGqAJBao9gZMdQ== @@ -2430,11 +2192,6 @@ bser@^2.0.0: dependencies: node-int64 "^0.4.0" -buffer-equal@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-1.0.0.tgz#59616b498304d556abd466966b22eeda3eca5fbe" - integrity sha1-WWFrSYME1Var1GaWayLu2j7KX74= - buffer-from@1.x, buffer-from@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" @@ -2450,11 +2207,6 @@ buffer-json@^2.0.0: resolved "https://registry.yarnpkg.com/buffer-json/-/buffer-json-2.0.0.tgz#f73e13b1e42f196fe2fd67d001c7d7107edd7c23" integrity sha512-+jjPFVqyfF1esi9fvfUs3NqM0pH1ziZ36VP4hmA/y/Ssfo/5w5xHKfTw9BwQjoJ1w/oVtpLomqwUHKdefGyuHw== -buffer-shims@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/buffer-shims/-/buffer-shims-1.0.0.tgz#9978ce317388c649ad8793028c3477ef044a8b51" - integrity sha1-mXjOMXOIxkmth5MCjDR37wRKi1E= - buffer-xor@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" @@ -2474,11 +2226,6 @@ builtin-status-codes@^3.0.0: resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" integrity sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug= -bytes@1: - version "1.0.0" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-1.0.0.tgz#3569ede8ba34315fab99c3e92cb04c7220de1fa8" - integrity sha1-NWnt6Lo0MV+rmcPpLLBMciDeH6g= - bytes@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" @@ -2557,11 +2304,6 @@ cache-loader@^4.1.0: neo-async "^2.6.1" schema-utils "^2.0.0" -cached-path-relative@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.0.2.tgz#a13df4196d26776220cc3356eb147a52dba2c6db" - integrity sha512-5r2GqsoEb4qMTTN9J+WzXfjov+hjxT+j3u5K+kIVNIwAd99DLCJE9pBIMP1qVeybV6JiijL385Oz0DcYxfbOIg== - call-me-maybe@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b" @@ -2687,7 +2429,7 @@ ccount@^1.0.0: resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.0.3.tgz#f1cec43f332e2ea5a569fd46f9f5bde4e6102aff" integrity sha512-Jt9tIBkRc9POUof7QA/VwWd+58fKkEEfI+/t1/eOlxKM7ZhrczNzMFefge7Ai+39y1pR/pP6cI19guHy3FSLmw== -chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2: +chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -2909,11 +2651,6 @@ cliui@^5.0.0: strip-ansi "^5.2.0" wrap-ansi "^5.1.0" -clone-buffer@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58" - integrity sha1-4+JbIHrE5wGvch4staFnksrD3Fg= - clone-deep@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" @@ -2930,25 +2667,6 @@ clone-regexp@^2.1.0: dependencies: is-regexp "^2.0.0" -clone-stats@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680" - integrity sha1-s3gt/4u1R04Yuba/D9/ngvh3doA= - -clone@^2.1.1: - version "2.1.2" - resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" - integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18= - -cloneable-readable@^1.0.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/cloneable-readable/-/cloneable-readable-1.1.3.tgz#120a00cb053bfb63a222e709f9683ea2e11d8cec" - integrity sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ== - dependencies: - inherits "^2.0.1" - process-nextick-args "^2.0.0" - readable-stream "^2.3.5" - co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -2978,7 +2696,7 @@ codesandbox-import-utils@^1.2.3: istextorbinary "^2.2.1" lz-string "^1.4.4" -collapse-white-space@^1.0.0, collapse-white-space@^1.0.2: +collapse-white-space@^1.0.2: version "1.0.5" resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-1.0.5.tgz#c2495b699ab1ed380d29a1091e01063e75dbbe3a" integrity sha512-703bOOmytCYAX9cXYqoikYIx6twmFCXsnzRQheBcTG3nzKYBR4P/+wkYeH+Mvj7qUz8zZDtdyzbxfnEi/kYzRQ== @@ -2991,7 +2709,7 @@ collection-visit@^1.0.0: map-visit "^1.0.0" object-visit "^1.0.0" -color-convert@^0.5.3: +color-convert@^0.5.3, color-convert@~0.5.0: version "0.5.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-0.5.3.tgz#bdb6c69ce660fadffe0b0007cc447e1b9f7282bd" integrity sha1-vbbGnOZg+t/+CwAHzER+G59ygr0= @@ -3020,11 +2738,6 @@ combined-stream@^1.0.6, combined-stream@~1.0.6: dependencies: delayed-stream "~1.0.0" -comma-separated-tokens@^1.0.1: - version "1.0.7" - resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-1.0.7.tgz#419cd7fb3258b1ed838dc0953167a25e152f5b59" - integrity sha512-Jrx3xsP4pPv4AwJUDWY9wOXGtwPXARej6Xd99h4TUGotmf8APuquKMpK+dnD3UgyxK7OEWaisjZz+3b5jtL6xQ== - commander@2, commander@^2.10.0, commander@^2.16.0, commander@^2.18.0, commander@^2.19.0, commander@^2.20.0, commander@~2.20.0: version "2.20.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422" @@ -3099,7 +2812,7 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= -concat-stream@^1.5.0, concat-stream@^1.6.0: +concat-stream@^1.5.0: version "1.6.2" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== @@ -3109,15 +2822,6 @@ concat-stream@^1.5.0, concat-stream@^1.6.0: readable-stream "^2.2.2" typedarray "^0.0.6" -concat-stream@~1.5.0: - version "1.5.2" - resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.5.2.tgz#708978624d856af41a5a741defdd261da752c266" - integrity sha1-cIl4Yk2FavQaWnQd790mHadSwmY= - dependencies: - inherits "~2.0.1" - readable-stream "~2.0.0" - typedarray "~0.0.5" - config-chain@^1.1.12: version "1.1.12" resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.12.tgz#0fde8d091200eb5e808caf25fe618c02f48e4efa" @@ -3199,12 +2903,7 @@ content-type@~1.0.4: resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== -continuable-cache@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/continuable-cache/-/continuable-cache-0.3.1.tgz#bd727a7faed77e71ff3985ac93351a912733ad0f" - integrity sha1-vXJ6f67XfnH/OYWskzUakSczrQ8= - -convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.0: +convert-source-map@^1.1.0, convert-source-map@^1.4.0: version "1.6.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" integrity sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A== @@ -3406,6 +3105,13 @@ crypto-random-string@^1.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e" integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4= +crypto-random-string@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-3.0.1.tgz#29d7dc759d577a768afb3b7b2765dd9bd7ffe36a" + integrity sha512-dUL0cJ4PBLanJGJQBHQUkvZ3C4q13MXzl54oRqAIiJGiNkOZ4JDwkg/SBo7daGghzlJv16yW1p/4lIQukmbedA== + dependencies: + type-fest "^0.5.2" + css-b64-images@~0.2.5: version "0.2.5" resolved "https://registry.yarnpkg.com/css-b64-images/-/css-b64-images-0.2.5.tgz#42005d83204b2b4a5d93b6b1a5644133b5927a02" @@ -3468,6 +3174,11 @@ cssesc@^3.0.0: resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== +cssfontparser@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/cssfontparser/-/cssfontparser-1.2.1.tgz#f4022fc8f9700c68029d542084afbaf425a3f3e3" + integrity sha1-9AIvyPlwDGgCnVQghK+69CWj8+M= + cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0": version "0.3.4" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.4.tgz#8cd52e8a3acfd68d3aed38ee0a640177d2f9d797" @@ -3777,10 +3488,10 @@ d3@^4.13.0: d3-voronoi "1.1.2" d3-zoom "1.7.1" -d3@^5.7.0: - version "5.9.2" - resolved "https://registry.yarnpkg.com/d3/-/d3-5.9.2.tgz#64e8a7e9c3d96d9e6e4999d2c8a2c829767e67f5" - integrity sha512-ydrPot6Lm3nTWH+gJ/Cxf3FcwuvesYQ5uk+j/kXEH/xbuYWYWTMAHTJQkyeuG8Y5WM5RSEYB41EctUrXQQytRQ== +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== dependencies: d3-array "1" d3-axis "1" @@ -3814,21 +3525,22 @@ d3@^5.7.0: d3-voronoi "1" d3-zoom "1" -dagre-d3-renderer@^0.5.8: - version "0.5.8" - resolved "https://registry.yarnpkg.com/dagre-d3-renderer/-/dagre-d3-renderer-0.5.8.tgz#aa071bb71d3c4d67426925906f3f6ddead49c1a3" - integrity sha512-XH2a86isUHRxzIYbjQVEuZtJnWEufb64H5DuXIUmn8esuB40jgLEbUUclulWOW62/ZoXlj2ZDyL8SJ+YRxs+jQ== +dagre-d3@dagrejs/dagre-d3: + version "0.6.4-pre" + resolved "https://codeload.github.com/dagrejs/dagre-d3/tar.gz/e1a00e5cb518f5d2304a35647e024f31d178e55b" dependencies: - dagre-layout "^0.8.8" - lodash "^4.17.5" + d3 "^5.12" + dagre "^0.8.4" + graphlib "^2.1.7" + lodash "^4.17.15" -dagre-layout@^0.8.8: - version "0.8.8" - resolved "https://registry.yarnpkg.com/dagre-layout/-/dagre-layout-0.8.8.tgz#9b6792f24229f402441c14162c1049e3f261f6d9" - integrity sha512-ZNV15T9za7X+fV8Z07IZquUKugCxm5owoiPPxfEx6OJRD331nkiIaF3vSt0JEY5FkrY0KfRQxcpQ3SpXB7pLPQ== +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== dependencies: - graphlibrary "^2.2.0" - lodash "^4.17.5" + graphlib "^2.1.7" + lodash "^4.17.4" dashdash@^1.12.0: version "1.14.1" @@ -3907,10 +3619,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.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/deckar01-task_list/-/deckar01-task_list-2.2.0.tgz#5cc3ea06f01d3d786b1a667064a462eb5d069bd3" - integrity sha512-NUfu5ARoD9SC2k+fBT5cBer59iKfEdawPrmfqp5+zAahTECb8z9dsuS1Xnx7jzFAmCCLnEs3z/aYucYXzNrKkQ== +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== decode-uri-component@^0.2.0: version "0.2.0" @@ -3981,11 +3693,6 @@ define-property@^2.0.2: is-descriptor "^1.0.2" isobject "^3.0.1" -defined@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693" - integrity sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM= - del@^2.0.2: version "2.2.2" resolved "https://registry.yarnpkg.com/del/-/del-2.2.2.tgz#c12c981d067846c84bcaf862cff930d907ffd1a8" @@ -4050,13 +3757,6 @@ destroy@~1.0.4: resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= -detab@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/detab/-/detab-2.0.2.tgz#074970d1a807b045d0258a4235df5928dd683561" - integrity sha512-Q57yPrxScy816TTE1P/uLRXLDKjXhvYTbfxS/e6lPD+YrqghbsMlGB9nQzj/zVtSPaF0DFPSdO916EWO4sQUyQ== - dependencies: - repeat-string "^1.5.4" - detect-file@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7" @@ -4077,14 +3777,6 @@ detect-node@^2.0.4: resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c" integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw== -detective@^4.0.0: - version "4.7.1" - resolved "https://registry.yarnpkg.com/detective/-/detective-4.7.1.tgz#0eca7314338442febb6d65da54c10bb1c82b246e" - integrity sha512-H6PmeeUcZloWtdt4DAkFyzFL94arpHr3NOwwmVILFiy+9Qd4JTxxXrzfyGk/lmct2qVGBwTSwSXagqu2BxmWig== - dependencies: - acorn "^5.2.1" - defined "^1.0.0" - di@^0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c" @@ -4100,11 +3792,6 @@ diff@^3.2.0, diff@^3.4.0: resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== -diff@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.1.tgz#0c667cb467ebbb5cea7f14f135cc2dba7780a8ff" - integrity sha512-s2+XdvhPCOF01LRQBC8hf4vhbVmI2CGS5aZnxLJlT5FtdhPCDFq80q++zK2KlrVorVDdL5BOGZ/VfLrVtYNF+Q== - diffie-hellman@^5.0.0: version "5.0.2" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e" @@ -4146,13 +3833,6 @@ docdash@^1.0.2: resolved "https://registry.yarnpkg.com/docdash/-/docdash-1.0.2.tgz#0449a8f6bb247f563020b78a5485dea95ae2e094" integrity sha512-IEM57bWPLtVXpUeCKbiGvHsHtW9O9ZiiBPfeQDAZ7JdQiAF3aNWQoJ3e/+uJ63lHO009yn0tbJjtKpXJ2AURCQ== -doctrine-temporary-fork@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/doctrine-temporary-fork/-/doctrine-temporary-fork-2.1.0.tgz#36f2154f556ee4f1e60311d391cd23de5187ed57" - integrity sha512-nliqOv5NkE4zMON4UA6AMJE6As35afs8aYXATpU4pTUdIKiARZwrJVEP1boA3Rx1ZXHVkwxkhcq4VkqvsuRLsA== - dependencies: - esutils "^2.0.2" - doctrine@1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa" @@ -4175,77 +3855,6 @@ document-register-element@1.13.1: dependencies: lightercollective "^0.1.0" -documentation@^12.0.1: - version "12.0.3" - resolved "https://registry.yarnpkg.com/documentation/-/documentation-12.0.3.tgz#32f91da8e5cb4104f69db9fd32c87773a1ad6240" - integrity sha512-RoqkH+mQ4Vi/nFMxG0BaqPAnjKfsJ9lbLWB8KqoKVAZy+urSpk1K1zBzaFesdDkKeaR3aBgeR3RjtHp8Ut/1Wg== - dependencies: - "@babel/core" "^7.1.2" - "@babel/generator" "^7.1.3" - "@babel/parser" "7.1.3" - "@babel/plugin-proposal-class-properties" "^7.1.0" - "@babel/plugin-proposal-decorators" "^7.1.2" - "@babel/plugin-proposal-do-expressions" "^7.0.0" - "@babel/plugin-proposal-export-default-from" "^7.0.0" - "@babel/plugin-proposal-export-namespace-from" "^7.0.0" - "@babel/plugin-proposal-function-bind" "^7.0.0" - "@babel/plugin-proposal-function-sent" "^7.1.0" - "@babel/plugin-proposal-json-strings" "^7.0.0" - "@babel/plugin-proposal-logical-assignment-operators" "^7.0.0" - "@babel/plugin-proposal-nullish-coalescing-operator" "^7.0.0" - "@babel/plugin-proposal-numeric-separator" "^7.0.0" - "@babel/plugin-proposal-optional-chaining" "^7.0.0" - "@babel/plugin-proposal-pipeline-operator" "^7.0.0" - "@babel/plugin-proposal-throw-expressions" "^7.0.0" - "@babel/plugin-syntax-dynamic-import" "^7.0.0" - "@babel/plugin-syntax-import-meta" "^7.0.0" - "@babel/preset-env" "^7.1.0" - "@babel/preset-flow" "^7.0.0" - "@babel/preset-react" "^7.0.0" - "@babel/preset-stage-0" "^7.0.0" - "@babel/traverse" "^7.1.4" - "@babel/types" "^7.1.3" - ansi-html "^0.0.7" - babelify "^10.0.0" - chalk "^2.3.0" - chokidar "^2.0.4" - concat-stream "^1.6.0" - diff "^4.0.1" - doctrine-temporary-fork "2.1.0" - get-port "^4.0.0" - git-url-parse "^10.0.1" - github-slugger "1.2.0" - glob "^7.1.2" - globals-docs "^2.4.0" - highlight.js "^9.15.5" - js-yaml "^3.10.0" - lodash "^4.17.10" - mdast-util-inject "^1.1.0" - micromatch "^3.1.5" - mime "^2.2.0" - module-deps-sortable "5.0.0" - parse-filepath "^1.0.2" - pify "^4.0.0" - read-pkg-up "^4.0.0" - remark "^9.0.0" - remark-html "^8.0.0" - remark-reference-links "^4.0.1" - remark-toc "^5.0.0" - remote-origin-url "0.4.0" - resolve "^1.8.1" - stream-array "^1.1.2" - strip-json-comments "^2.0.1" - tiny-lr "^1.1.0" - unist-builder "^1.0.2" - unist-util-visit "^1.3.0" - vfile "^4.0.0" - vfile-reporter "^6.0.0" - vfile-sort "^2.1.0" - vinyl "^2.1.0" - vinyl-fs "^3.0.2" - vue-template-compiler "^2.5.16" - yargs "^12.0.2" - dom-serialize@^2.2.0: version "2.2.1" resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b" @@ -4313,13 +3922,6 @@ dropzone@^4.2.0: resolved "https://registry.yarnpkg.com/dropzone/-/dropzone-4.2.0.tgz#fbe7acbb9918e0706489072ef663effeef8a79f3" integrity sha1-++esu5kY4HBkiQcu9mPv/u+KefM= -duplexer2@^0.1.2, duplexer2@~0.1.0: - version "0.1.4" - resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" - integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME= - dependencies: - readable-stream "^2.0.2" - duplexer3@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" @@ -4400,11 +4002,6 @@ elliptic@^6.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.0" -"emoji-regex@>=6.0.0 <=6.1.1": - version "6.1.1" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.1.1.tgz#c6cd0ec1b0642e2a3c67a1137efc5e796da4f88e" - integrity sha1-xs0OwbBkLio8Z6ETfvxeeW2k+I4= - emoji-regex@^7.0.1, emoji-regex@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" @@ -4519,14 +4116,6 @@ error-ex@^1.2.0, error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" -error@^7.0.0: - version "7.0.2" - resolved "https://registry.yarnpkg.com/error/-/error-7.0.2.tgz#a5f75fff4d9926126ddac0ea5dc38e689153cb02" - integrity sha1-pfdf/02ZJhJt2sDqXcOOaJFTywI= - dependencies: - string-template "~0.2.1" - xtend "~4.0.0" - es-abstract@^1.5.1, es-abstract@^1.6.1: version "1.13.0" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.13.0.tgz#ac86145fdd5099d8dd49558ccba2eaf9b88e24e9" @@ -4814,7 +4403,7 @@ estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13" integrity sha1-De4/7TH81GlhjOc0IJn8GvoL2xM= -esutils@^2.0.0, esutils@^2.0.2: +esutils@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" integrity sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs= @@ -5077,7 +4666,7 @@ fault@^1.0.2: dependencies: format "^0.2.2" -faye-websocket@^0.10.0, faye-websocket@~0.10.0: +faye-websocket@^0.10.0: version "0.10.0" resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.10.0.tgz#4e492f8d04dfb6f89003507f6edbf2d501e7c6f4" integrity sha1-TkkvjQTftviQA1B/btvy1QHnxvQ= @@ -5276,7 +4865,7 @@ flatted@^2.0.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.0.tgz#55122b6536ea496b4b44893ee2608141d10d9916" integrity sha512-R+H8IZclI8AAkSBRQJLVOsxwAoHd6WC40b4QTNWIjzAa6BXOBfQcM587MXDTVPeYaopFNWHUFLx7eNmHDSxMWg== -flush-write-stream@^1.0.0, flush-write-stream@^1.0.2: +flush-write-stream@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8" integrity sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w== @@ -5361,14 +4950,6 @@ fs-minipass@^1.2.5: dependencies: minipass "^2.2.1" -fs-mkdirp-stream@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs-mkdirp-stream/-/fs-mkdirp-stream-1.0.0.tgz#0b7815fc3201c6a69e14db98ce098c16935259eb" - integrity sha1-C3gV/DIBxqaeFNuYzgmMFpNSWes= - dependencies: - graceful-fs "^4.1.11" - through2 "^2.0.3" - fs-write-stream-atomic@^1.0.8: version "1.0.10" resolved "https://registry.yarnpkg.com/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9" @@ -5453,11 +5034,6 @@ get-caller-file@^2.0.1: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== -get-port@^4.0.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/get-port/-/get-port-4.2.0.tgz#e37368b1e863b7629c43c5a323625f95cf24b119" - integrity sha512-/b3jarXkH8KJoOMQc3uVGHASwGLPq3gSFJ7tgJm2diza+bydJPTGOibin2steecKeOylE8oY2JERlVWkAJO6yw== - get-stdin@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe" @@ -5524,35 +5100,6 @@ gettext-extractor@^3.4.3: pofile "^1" typescript "2 - 3" -git-up@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/git-up/-/git-up-2.1.0.tgz#2f14cfe78327e7c4a2b92fcac7bfc674fdfad40c" - integrity sha512-MJgwfcSd9qxgDyEYpRU/CDxNpUadrK80JHuEQDG4Urn0m7tpSOgCBrtiSIa9S9KH8Tbuo/TN8SSQmJBvsw1HkA== - dependencies: - is-ssh "^1.3.0" - parse-url "^3.0.2" - -git-url-parse@^10.0.1: - version "10.1.0" - resolved "https://registry.yarnpkg.com/git-url-parse/-/git-url-parse-10.1.0.tgz#a27813218f8777e91d15f1c121b83bf14721b67e" - integrity sha512-goZOORAtFjU1iG+4zZgWq+N7It09PqS3Xsy43ZwhP5unDD0tTSmXTpqULHodMdJXGejm3COwXIhIRT6Z8DYVZQ== - dependencies: - git-up "^2.0.0" - -github-slugger@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-1.2.0.tgz#8ada3286fd046d8951c3c952a8d7854cfd90fd9a" - integrity sha512-wIaa75k1vZhyPm9yWrD08A5Xnx/V+RmzGrpjQuLemGKSb77Qukiaei58Bogrl/LZSADDfPzKJX8jhLs4CRTl7Q== - dependencies: - emoji-regex ">=6.0.0 <=6.1.1" - -github-slugger@^1.0.0, github-slugger@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-1.2.1.tgz#47e904e70bf2dccd0014748142d31126cfd49508" - integrity sha512-SsZUjg/P03KPzQBt7OxJPasGw6NRO5uOgiZ5RGXVud5iSIZ0eNZeNp5rTwCxtavrRUa/A77j8mePVc5lEvk0KQ== - dependencies: - emoji-regex ">=6.0.0 <=6.1.1" - glob-parent@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" @@ -5568,22 +5115,6 @@ glob-parent@^5.0.0: dependencies: is-glob "^4.0.1" -glob-stream@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/glob-stream/-/glob-stream-6.1.0.tgz#7045c99413b3eb94888d83ab46d0b404cc7bdde4" - integrity sha1-cEXJlBOz65SIjYOrRtC0BMx73eQ= - dependencies: - extend "^3.0.0" - glob "^7.1.1" - glob-parent "^3.1.0" - is-negated-glob "^1.0.0" - ordered-read-streams "^1.0.0" - pumpify "^1.3.5" - readable-stream "^2.1.5" - remove-trailing-separator "^1.0.1" - to-absolute-glob "^2.0.0" - unique-stream "^2.0.2" - glob-to-regexp@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab" @@ -5649,11 +5180,6 @@ global-prefix@^3.0.0: kind-of "^6.0.2" which "^1.3.1" -globals-docs@^2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/globals-docs/-/globals-docs-2.4.0.tgz#f2c647544eb6161c7c38452808e16e693c2dafbb" - integrity sha512-B69mWcqCmT3jNYmSxRxxOXWfzu3Go8NQXPfl2o0qPd1EEFhwW0dFUg9ztTu915zPQzqwIhWAlw6hmfIcCK4kkQ== - globals@^11.1.0, globals@^11.7.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" @@ -5753,7 +5279,7 @@ got@^6.7.1: unzip-response "^2.0.1" url-parse-lax "^1.0.0" -graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9: +graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9: version "4.2.0" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.0.tgz#8d8fdc73977cb04104721cb53666c1ca64cd328b" integrity sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg== @@ -5763,10 +5289,10 @@ graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2 resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" integrity sha1-TK+tdrxi8C+gObL5Tpo906ORpyU= -graphlibrary@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/graphlibrary/-/graphlibrary-2.2.0.tgz#017a14899775228dec4497a39babfdd6bf56eac6" - integrity sha512-XTcvT55L8u4MBZrM37zXoUxsgxs/7sow7YSygd9CIwfWTVO8RVu7AYXhhCiTuFEf+APKgx6Jk4SuQbYR0vYKmQ== +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== dependencies: lodash "^4.17.5" @@ -5924,50 +5450,12 @@ hash.js@^1.0.0, hash.js@^1.0.3: inherits "^2.0.3" minimalistic-assert "^1.0.0" -hast-util-is-element@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/hast-util-is-element/-/hast-util-is-element-1.0.3.tgz#423b4b26fe8bf1f25950fe052e9ce8f83fd5f6a4" - integrity sha512-C62CVn7jbjp89yOhhy7vrkSaB7Vk906Gtcw/Ihd+Iufnq+2pwOZjdPmpzpKLWJXPJBMDX3wXg4FqmdOayPcewA== - -hast-util-sanitize@^1.0.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/hast-util-sanitize/-/hast-util-sanitize-1.3.1.tgz#4e60d66336bd67e52354d581967467029a933f2e" - integrity sha512-AIeKHuHx0Wk45nSkGVa2/ujQYTksnDl8gmmKo/mwQi7ag7IBZ8cM3nJ2G86SajbjGP/HRpud6kMkPtcM2i0Tlw== - dependencies: - xtend "^4.0.1" - -hast-util-to-html@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-4.0.1.tgz#3666b05afb62bd69f8f5e6c94db04dea19438e2a" - integrity sha512-2emzwyf0xEsc4TBIPmDJmBttIw8R4SXAJiJZoiRR/s47ODYWgOqNoDbf2SJAbMbfNdFWMiCSOrI3OVnX6Qq2Mg== - dependencies: - ccount "^1.0.0" - comma-separated-tokens "^1.0.1" - hast-util-is-element "^1.0.0" - hast-util-whitespace "^1.0.0" - html-void-elements "^1.0.0" - property-information "^4.0.0" - space-separated-tokens "^1.0.0" - stringify-entities "^1.0.1" - unist-util-is "^2.0.0" - xtend "^4.0.1" - -hast-util-whitespace@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-1.0.3.tgz#6d161b307bd0693b5ec000c7c7e8b5445109ee34" - integrity sha512-AlkYiLTTwPOyxZ8axq2/bCwRUPjIPBfrHkXuCR92B38b3lSdU22R5F/Z4DL6a2kxWpekWq1w6Nj48tWat6GeRA== - he@^1.1.0, he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== -highlight.js@^9.13.1, highlight.js@^9.15.5: - version "9.15.8" - resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.15.8.tgz#f344fda123f36f1a65490e932cf90569e4999971" - integrity sha512-RrapkKQWwE+wKdF73VsOa2RQdIoO3mxwJ4P8mhbI6KYJUraUHRKM5w5zQQKXNk0xNL4UVRdulV9SBJcmzJNzVA== - -highlight.js@~9.13.0: +highlight.js@^9.13.1, highlight.js@~9.13.0: version "9.13.1" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.13.1.tgz#054586d53a6863311168488a0f58d6c505ce641e" integrity sha512-Sc28JNQNDzaH6PORtRLMvif9RSn1mYuOoX3omVjnb0+HbpPygU2ALBI0R/wsiqCb4/fcp07Gdo8g+fhtFrQl6A== @@ -6038,12 +5526,7 @@ html-tags@^3.0.0: resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.0.0.tgz#41f57708c9e6b7b46a00a22317d614c4a2bab166" integrity sha512-xiXEBjihaNI+VZ2mKEoI5ZPxqUsevTKM+aeeJ/W4KAg2deGE35minmCJMn51BvwJZmiHaeAxrb2LAS0yZJxuuA== -html-void-elements@^1.0.0: - version "1.0.4" - resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-1.0.4.tgz#95e8bb5ecd6b88766569c2645f2b5f1591db9ba5" - integrity sha512-yMk3naGPLrfvUV9TdDbuYXngh/TpHbA6TrOw3HL9kS8yhwx7i309BReNg7CbAJXGE+UMJ6je5OqJ7lC63o6YuQ== - -htmlparser2@^3.10.0, htmlparser2@^3.9.0: +htmlparser2@^3.10.0: version "3.10.0" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.0.tgz#5f5e422dcf6119c0d983ed36260ce9ded0bee464" integrity sha512-J1nEUGv+MkXS0weHNWVKJJ+UrLfePxRWpN3C9bEi9fLxL2+ggW94DQvgYVXsaT30PGwYRIZKNZXuyMhp3Di4bQ== @@ -6175,11 +5658,6 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= -immutable-tuple@^0.4.9: - version "0.4.9" - resolved "https://registry.yarnpkg.com/immutable-tuple/-/immutable-tuple-0.4.9.tgz#473ebdd6c169c461913a454bf87ef8f601a20ff0" - integrity sha512-LWbJPZnidF8eczu7XmcnLBsumuyRBkpwIRPCZxlojouhBo5jEBO4toj6n7hMy6IxHU/c+MqDSWkvaTpPlMQcyA== - import-fresh@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" @@ -6269,7 +5747,7 @@ inherits@2.0.1: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" integrity sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE= -ini@^1.3.3, ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: +ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: version "1.3.5" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== @@ -6343,14 +5821,6 @@ is-absolute-url@^3.0.2: resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-3.0.2.tgz#554f2933e7385cc46e94351977ca2081170a206e" integrity sha512-+5g/wLlcm1AcxSP7014m6GvbPHswDx980vD/3bZaap8aGV9Yfs7Q6y6tfaupgZ5O74Byzc8dGrSCJ+bFXx0KdA== -is-absolute@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-absolute/-/is-absolute-1.0.0.tgz#395e1ae84b11f26ad1795e73c17378e48a301576" - integrity sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA== - dependencies: - is-relative "^1.0.0" - is-windows "^1.0.1" - is-accessor-descriptor@^0.1.6: version "0.1.6" resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" @@ -6402,7 +5872,7 @@ is-binary-path@^2.1.0: dependencies: binary-extensions "^2.0.0" -is-buffer@^1.1.4, is-buffer@^1.1.5, is-buffer@~1.1.1: +is-buffer@^1.1.5, is-buffer@~1.1.1: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== @@ -6551,11 +6021,6 @@ is-installed-globally@^0.1.0: global-dirs "^0.1.0" is-path-inside "^1.0.0" -is-negated-glob@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-negated-glob/-/is-negated-glob-1.0.0.tgz#6910bca5da8c95e784b5751b976cf5a10fee36d2" - integrity sha1-aRC8pdqMleeEtXUbl2z1oQ/uNtI= - is-npm@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4" @@ -6616,7 +6081,7 @@ is-path-inside@^2.1.0: dependencies: path-is-inside "^1.0.2" -is-plain-obj@^1.0.0, is-plain-obj@^1.1.0: +is-plain-obj@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4= @@ -6655,13 +6120,6 @@ is-regexp@^2.0.0: resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-2.1.0.tgz#cd734a56864e23b956bf4e7c66c396a4c0b22c2d" integrity sha512-OZ4IlER3zmRIoB9AqNhEggVxqIH4ofDns5nRrPS6yQxXE1TPCUpFznBfRQmQa8uC+pXqjMnukiJBxCisIxiLGA== -is-relative@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-relative/-/is-relative-1.0.0.tgz#a1bb6935ce8c5dba1e8b9754b9b2dcc020e2260d" - integrity sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA== - dependencies: - is-unc-path "^1.0.0" - is-resolvable@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88" @@ -6672,13 +6130,6 @@ is-retry-allowed@^1.0.0: resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.1.0.tgz#11a060568b67339444033d0125a61a20d564fb34" integrity sha1-EaBgVotnM5REAz0BJaYaINVk+zQ= -is-ssh@^1.3.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/is-ssh/-/is-ssh-1.3.1.tgz#f349a8cadd24e65298037a522cf7520f2e81a0f3" - integrity sha512-0eRIASHZt1E68/ixClI8bp2YK2wmBPVWEismTs6M+M099jKgrzl/3E976zIbImSIob48N2/XGe9y7ZiYdImSlg== - dependencies: - protocols "^1.1.0" - is-stream@^1.0.0, is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" @@ -6696,23 +6147,11 @@ is-typedarray@~1.0.0: resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= -is-unc-path@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-unc-path/-/is-unc-path-1.0.0.tgz#d731e8898ed090a12c352ad2eaed5095ad322c9d" - integrity sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ== - dependencies: - unc-path-regex "^0.1.2" - -is-utf8@^0.2.0, is-utf8@^0.2.1: +is-utf8@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI= -is-valid-glob@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-valid-glob/-/is-valid-glob-1.0.0.tgz#29bf3eff701be2d4d315dbacc39bc39fe8f601aa" - integrity sha1-Kb8+/3Ab4tTTFdusw5vDn+j2Aao= - is-whitespace-character@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/is-whitespace-character/-/is-whitespace-character-1.0.2.tgz#ede53b4c6f6fb3874533751ec9280d01928d03ed" @@ -6877,6 +6316,14 @@ jed@^1.1.1: resolved "https://registry.yarnpkg.com/jed/-/jed-1.1.1.tgz#7a549bbd9ffe1585b0cd0a191e203055bee574b4" integrity sha1-elSbvZ/+FYWwzQoZHiAwVb7ldLQ= +jest-canvas-mock@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.1.2.tgz#0d16c9f91534f773fd132fc289f2e6b6db8faa28" + integrity sha512-1VI4PK4/X70yrSjYScYVkYJYbXYlZLKJkUrAlyHjQsfolv64aoFyIrmMDtqCjpYrpVvWYEcAGUaYv5DVJj00oQ== + dependencies: + cssfontparser "^1.2.1" + parse-color "^1.0.0" + jest-changed-files@^24.8.0: version "24.8.0" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-24.8.0.tgz#7e7eb21cf687587a85e50f3d249d1327e15b157b" @@ -7307,7 +6754,7 @@ js-tokens@^3.0.2: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= -js-yaml@^3.10.0, js-yaml@^3.12.0, js-yaml@^3.13.1: +js-yaml@^3.12.0, js-yaml@^3.13.1: version "3.13.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== @@ -7443,11 +6890,6 @@ jsonfile@^4.0.0: optionalDependencies: graceful-fs "^4.1.6" -jsonparse@^1.2.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" - integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA= - jsprim@^1.2.2: version "1.4.1" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" @@ -7623,13 +7065,6 @@ latest-version@^3.0.0: dependencies: package-json "^4.0.0" -lazystream@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.0.tgz#f6995fe0f820392f61396be89462407bb77168e4" - integrity sha1-9plf4PggOS9hOWvolGJAe7dxaOQ= - dependencies: - readable-stream "^2.0.5" - lcid@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" @@ -7644,13 +7079,6 @@ lcid@^2.0.0: dependencies: invert-kv "^2.0.0" -lead@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/lead/-/lead-1.0.0.tgz#6f14f99a37be3a9dd784f5495690e5903466ee42" - integrity sha1-bxT5mje+Op3XhPVJVpDlkDRm7kI= - dependencies: - flush-write-stream "^1.0.2" - left-pad@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e" @@ -7693,11 +7121,6 @@ linkify-it@^2.0.0: dependencies: uc.micro "^1.0.1" -livereload-js@^2.3.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/livereload-js/-/livereload-js-2.4.0.tgz#447c31cf1ea9ab52fc20db615c5ddf678f78009c" - integrity sha512-XPQH8Z2GDP/Hwz2PCDrh2mth4yFejwA1OZ/81Ti3LgKyhDcEjsSsqFWZojHG0va/duGd+WyosY7eXLDoOyqcPw== - load-json-file@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" @@ -7796,12 +7219,22 @@ lodash.isequal@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE= + lodash.kebabcase@4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" integrity sha1-hImxyw0p/4gZXM7KRI/21swpXDY= -lodash.mergewith@^4.6.0: +lodash.mergewith@^4.6.1: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55" integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ== @@ -7960,7 +7393,7 @@ map-age-cleaner@^0.1.1: dependencies: p-defer "^1.0.0" -map-cache@^0.2.0, map-cache@^0.2.2: +map-cache@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= @@ -8071,52 +7504,6 @@ mdast-util-compact@^1.0.0: dependencies: unist-util-visit "^1.1.0" -mdast-util-definitions@^1.2.0: - version "1.2.4" - resolved "https://registry.yarnpkg.com/mdast-util-definitions/-/mdast-util-definitions-1.2.4.tgz#2b54ad4eecaff9d9fcb6bf6f9f6b68b232d77ca7" - integrity sha512-HfUArPog1j4Z78Xlzy9Q4aHLnrF/7fb57cooTHypyGoe2XFNbcx/kWZDoOz+ra8CkUzvg3+VHV434yqEd1DRmA== - dependencies: - unist-util-visit "^1.0.0" - -mdast-util-inject@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/mdast-util-inject/-/mdast-util-inject-1.1.0.tgz#db06b8b585be959a2dcd2f87f472ba9b756f3675" - integrity sha1-2wa4tYW+lZotzS+H9HK6m3VvNnU= - dependencies: - mdast-util-to-string "^1.0.0" - -mdast-util-to-hast@^3.0.0: - version "3.0.4" - resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-3.0.4.tgz#132001b266031192348d3366a6b011f28e54dc40" - integrity sha512-/eIbly2YmyVgpJNo+bFLLMCI1XgolO/Ffowhf+pHDq3X4/V6FntC9sGQCDLM147eTS+uSXv5dRzJyFn+o0tazA== - dependencies: - collapse-white-space "^1.0.0" - detab "^2.0.0" - mdast-util-definitions "^1.2.0" - mdurl "^1.0.1" - trim "0.0.1" - trim-lines "^1.0.0" - unist-builder "^1.0.1" - unist-util-generated "^1.1.0" - unist-util-position "^3.0.0" - unist-util-visit "^1.1.0" - xtend "^4.0.1" - -mdast-util-to-string@^1.0.0, mdast-util-to-string@^1.0.5: - version "1.0.6" - resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-1.0.6.tgz#7d85421021343b33de1552fc71cb8e5b4ae7536d" - integrity sha512-868pp48gUPmZIhfKrLbaDneuzGiw3OTDjHc5M1kAepR2CWBJ+HpEsm252K4aXdiP5coVZaJPOqGtVU6Po8xnXg== - -mdast-util-toc@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/mdast-util-toc/-/mdast-util-toc-3.1.0.tgz#395eeb877f067f9d2165d990d77c7eea6f740934" - integrity sha512-Za0hqL1PqWrvxGtA/3NH9D5nhGAUS9grMM4obEAz5+zsk1RIw/vWUchkaoDLNdrwk05A0CSC5eEXng36/1qE5w== - dependencies: - github-slugger "^1.2.1" - mdast-util-to-string "^1.0.5" - unist-util-is "^2.1.2" - unist-util-visit "^1.1.0" - mdurl@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" @@ -8204,21 +7591,22 @@ merge2@^1.2.3: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.2.3.tgz#7ee99dbd69bb6481689253f018488a1b902b0ed5" integrity sha512-gdUU1Fwj5ep4kplwcmftruWofEFt6lfpkkr3h860CXbAB9c3hGb55EOL2ali0Td5oebvW0E1+3Sr+Ur7XfKpRA== -mermaid@^8.2.6: - version "8.2.6" - resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-8.2.6.tgz#e73f396461a435c39a998819171c2114f59e46e1" - integrity sha512-A8y4zW2aXPj8Yw+BkrCkV6fvzhsFWVESV1IkzRjqQ6T/+tzhkz946+bdebCmHqicEJGTncu/U6h8dgjo5pWo6Q== +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== dependencies: "@braintree/sanitize-url" "^3.1.0" + crypto-random-string "^3.0.1" d3 "^5.7.0" - dagre-d3-renderer "^0.5.8" - dagre-layout "^0.8.8" - documentation "^12.0.1" - graphlibrary "^2.2.0" + dagre "^0.8.4" + dagre-d3 dagrejs/dagre-d3 + 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: @@ -8226,7 +7614,7 @@ methods@~1.1.2: resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= -micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^3.1.5, micromatch@^3.1.6: +micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^3.1.6: version "3.1.10" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== @@ -8278,7 +7666,7 @@ mime@1.6.0: resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== -mime@^2.2.0, mime@^2.3.1, mime@^2.4.4: +mime@^2.3.1, mime@^2.4.4: version "2.4.4" resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5" integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA== @@ -8341,7 +7729,7 @@ minimist@1.1.x: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.1.3.tgz#3bedfd91a92d39016fcfaa1c681e8faa1a1efda8" integrity sha1-O+39kaktOQFvz6ocaB6Pqhoe/ag= -minimist@^1.1.0, minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0: +minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ= @@ -8392,26 +7780,6 @@ mkdirp@0.5.x, mkdirp@0.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp dependencies: minimist "0.0.8" -module-deps-sortable@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/module-deps-sortable/-/module-deps-sortable-5.0.0.tgz#99db5bb08f7eab55e4c31f6b7c722c6a2144ba74" - integrity sha512-bnGGeghQmz/t/6771/KC4FmxpVm126iR6AAzzq4N6hVZQVl4+ZZBv+VF3PJmDyxXtVtgcgTSSP7NL+jq1QAHrg== - dependencies: - JSONStream "^1.0.3" - browser-resolve "^1.7.0" - cached-path-relative "^1.0.0" - concat-stream "~1.5.0" - defined "^1.0.0" - detective "^4.0.0" - duplexer2 "^0.1.2" - inherits "^2.0.1" - readable-stream "^2.0.2" - resolve "^1.1.3" - stream-combiner2 "^1.1.1" - subarg "^1.0.0" - through2 "^2.0.0" - xtend "^4.0.0" - moment-mini@^2.22.1: version "2.22.1" resolved "https://registry.yarnpkg.com/moment-mini/-/moment-mini-2.22.1.tgz#bc32d73e43a4505070be6b53494b17623183420d" @@ -8736,23 +8104,6 @@ normalize-selector@^0.2.0: resolved "https://registry.yarnpkg.com/normalize-selector/-/normalize-selector-0.2.0.tgz#d0b145eb691189c63a78d201dc4fdb1293ef0c03" integrity sha1-0LFF62kRicY6eNIB3E/bEpPvDAM= -normalize-url@^1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-1.9.1.tgz#2cc0d66b31ea23036458436e3620d85954c66c3c" - integrity sha1-LMDWazHqIwNkWENuNiDYWVTGbDw= - dependencies: - object-assign "^4.0.1" - prepend-http "^1.0.0" - query-string "^4.1.0" - sort-keys "^1.0.0" - -now-and-later@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/now-and-later/-/now-and-later-2.0.1.tgz#8e579c8685764a7cc02cb680380e94f43ccb1f7c" - integrity sha512-KGvQ0cB70AQfg107Xvs/Fbu+dGmZoTRJp2TaPwcwQm3/7PteUyN2BCgk8KBMPGBUXZdVwyWS8fDCGFygBm19UQ== - dependencies: - once "^1.3.2" - npm-bundled@^1.0.1: version "1.0.6" resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.6.tgz#e7ba9aadcef962bb61248f91721cd932b3fe6bdd" @@ -8834,7 +8185,7 @@ object-visit@^1.0.0: dependencies: isobject "^3.0.0" -object.assign@^4.0.4, object.assign@^4.1.0: +object.assign@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da" integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w== @@ -8886,7 +8237,7 @@ on-headers@~1.0.2: resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== -once@^1.3.0, once@^1.3.1, once@^1.3.2, once@^1.4.0: +once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= @@ -8912,12 +8263,12 @@ opn@^5.5.0: dependencies: is-wsl "^1.1.0" -optimism@^0.6.9: - version "0.6.9" - resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.6.9.tgz#19258ff8b3be0cea29ac35f06bff818e026e30bb" - integrity sha512-xoQm2lvXbCA9Kd7SCx6y713Y7sZ6fUc5R6VYpoL5M6svKJbTuvtNopexK8sO8K4s0EOUYHuPN2+yAEsNyRggkQ== +optimism@^0.10.0: + version "0.10.3" + resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.10.3.tgz#163268fdc741dea2fb50f300bedda80356445fd7" + integrity sha512-9A5pqGoQk49H6Vhjb9kPgAeeECfUDF6aIICbMDL23kDLStBn1MWk3YvcZ4xWF9CsSf6XEgvRLkXy4xof/56vVw== dependencies: - immutable-tuple "^0.4.9" + "@wry/context" "^0.4.0" optimist@^0.6.1: version "0.6.1" @@ -8939,13 +8290,6 @@ optionator@^0.8.1, optionator@^0.8.2: type-check "~0.3.2" wordwrap "~1.0.0" -ordered-read-streams@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/ordered-read-streams/-/ordered-read-streams-1.0.1.tgz#77c0cb37c41525d64166d990ffad7ec6a0e1363e" - integrity sha1-d8DLN8QVJdZBZtmQ/61+xqDhNj4= - dependencies: - readable-stream "^2.0.1" - orderedmap@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-1.0.0.tgz#d90fc2ba1ed085190907d601dec6e6a53f8d41ba" @@ -9123,6 +8467,13 @@ parse-asn1@^5.0.0: evp_bytestokey "^1.0.0" pbkdf2 "^3.0.3" +parse-color@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/parse-color/-/parse-color-1.0.0.tgz#7b748b95a83f03f16a94f535e52d7f3d94658619" + integrity sha1-e3SLlag/A/FqlPU15S1/PZRlhhk= + dependencies: + color-convert "~0.5.0" + parse-entities@^1.0.2, parse-entities@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-1.2.0.tgz#9deac087661b2e36814153cb78d7e54a4c5fd6f4" @@ -9135,22 +8486,6 @@ parse-entities@^1.0.2, parse-entities@^1.1.0: is-decimal "^1.0.0" is-hexadecimal "^1.0.0" -parse-filepath@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/parse-filepath/-/parse-filepath-1.0.2.tgz#a632127f53aaf3d15876f5872f3ffac763d6c891" - integrity sha1-pjISf1Oq89FYdvWHLz/6x2PWyJE= - dependencies: - is-absolute "^1.0.0" - map-cache "^0.2.0" - path-root "^0.1.1" - -parse-git-config@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/parse-git-config/-/parse-git-config-0.2.0.tgz#272833fdd15fea146fb75d336d236b963b6ff706" - integrity sha1-Jygz/dFf6hRvt10zbSNrljtv9wY= - dependencies: - ini "^1.3.3" - parse-json@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9" @@ -9171,24 +8506,6 @@ parse-passwd@^1.0.0: resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY= -parse-path@^3.0.1: - version "3.0.4" - resolved "https://registry.yarnpkg.com/parse-path/-/parse-path-3.0.4.tgz#a48b7b529da41f34d9d1428602a39b29fc7180e4" - integrity sha512-wP70vtwv2DyrM2YoA7ZHVv4zIXa4P7dGgHlj+VwyXNDduLLVJ7NMY1zsFxjUUJ3DAwJLupGb1H5gMDDiNlJaxw== - dependencies: - is-ssh "^1.3.0" - protocols "^1.4.0" - -parse-url@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/parse-url/-/parse-url-3.0.2.tgz#602787a7063a795d72b8673197505e72f60610be" - integrity sha1-YCeHpwY6eV1yuGcxl1BecvYGEL4= - dependencies: - is-ssh "^1.3.0" - normalize-url "^1.9.1" - parse-path "^3.0.1" - protocols "^1.4.0" - parse5@4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608" @@ -9270,18 +8587,6 @@ path-parse@^1.0.6: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== -path-root-regex@^0.1.0: - version "0.1.2" - resolved "https://registry.yarnpkg.com/path-root-regex/-/path-root-regex-0.1.2.tgz#bfccdc8df5b12dc52c8b43ec38d18d72c04ba96d" - integrity sha1-v8zcjfWxLcUsi0PsONGNcsBLqW0= - -path-root@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/path-root/-/path-root-0.1.1.tgz#9a4a6814cac1c0cd73360a95f32083c8ea4745b7" - integrity sha1-mkpoFMrBwM1zNgqV8yCDyOpHRbc= - dependencies: - path-root-regex "^0.1.0" - path-to-regexp@0.1.7: version "0.1.7" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" @@ -9349,7 +8654,7 @@ pify@^3.0.0: resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY= -pify@^4.0.0, pify@^4.0.1: +pify@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== @@ -9594,7 +8899,7 @@ postcss-value-parser@^4.0.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.0.0.tgz#99a983d365f7b2ad8d0f9b8c3094926eab4b936d" integrity sha512-ESPktioptiSUchCKgggAkzdmkgzKfmp0EU8jXH+5kbIUB+unr0Y4CY9SRMvibuvYUBjNh1ACLbxqYNpdTQOteQ== -postcss@^6.0.1, postcss@^6.0.14, postcss@^6.0.23: +postcss@^6.0.1, postcss@^6.0.23: version "6.0.23" resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324" integrity sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag== @@ -9603,10 +8908,10 @@ postcss@^6.0.1, postcss@^6.0.14, postcss@^6.0.23: source-map "^0.6.1" supports-color "^5.4.0" -postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.17, postcss@^7.0.2, postcss@^7.0.7: - version "7.0.18" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.18.tgz#4b9cda95ae6c069c67a4d933029eddd4838ac233" - integrity sha512-/7g1QXXgegpF+9GJj4iN7ChGF40sYuGYJ8WZu8DZWnmhQ/G36hfdk3q9LBJmoK+lZ+yzZ5KYpOoxq7LF1BxE8g== +postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.17, postcss@^7.0.2, postcss@^7.0.5, postcss@^7.0.7: + version "7.0.21" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.21.tgz#06bb07824c19c2021c5d056d5b10c35b989f7e17" + integrity sha512-uIFtJElxJo29QC753JzhidoAhvp/e/Exezkdhfmt8AymWT6/5B7W1WmponYWkHk2eg6sONyTch0A3nkMPun3SQ== dependencies: chalk "^2.4.2" source-map "^0.6.1" @@ -9617,7 +8922,7 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= -prepend-http@^1.0.0, prepend-http@^1.0.1: +prepend-http@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= @@ -9627,7 +8932,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== @@ -9654,16 +8959,16 @@ private@^0.1.6: resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" integrity sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg== -process-nextick-args@^2.0.0, process-nextick-args@~2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" - integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== - process-nextick-args@~1.0.6: version "1.0.7" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3" integrity sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M= +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + process@^0.11.10: version "0.11.10" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" @@ -9687,13 +8992,6 @@ prompts@^2.0.1: kleur "^3.0.2" sisteransi "^1.0.0" -property-information@^4.0.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/property-information/-/property-information-4.2.0.tgz#f0e66e07cbd6fed31d96844d958d153ad3eb486e" - integrity sha512-TlgDPagHh+eBKOnH2VYvk8qbwsCG/TAJdmTL7f1PROUcSO8qt/KSmShEQ/OKvock8X9tFjtqjCScyOkkkvIKVQ== - dependencies: - xtend "^4.0.1" - prosemirror-commands@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.0.7.tgz#e5a2ba821e29ea7065c88277fe2c3d7f6b0b9d37" @@ -9815,11 +9113,6 @@ proto-list@~1.2.1: resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" integrity sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk= -protocols@^1.1.0, protocols@^1.4.0: - version "1.4.7" - resolved "https://registry.yarnpkg.com/protocols/-/protocols-1.4.7.tgz#95f788a4f0e979b291ffefcf5636ad113d037d32" - integrity sha512-Fx65lf9/YDn3hUX08XUc0J8rSux36rEsyiv21ZGUC1mOyeM3lTRpZLcrm8aAolzS4itwVfm7TAPyxC2E5zd6xg== - proxy-addr@~2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.5.tgz#34cbd64a2d81f4b1fd21e76f9f06c8a45299ee34" @@ -9875,7 +9168,7 @@ pump@^3.0.0: end-of-stream "^1.1.0" once "^1.3.1" -pumpify@^1.3.3, pumpify@^1.3.5: +pumpify@^1.3.3: version "1.5.1" resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce" integrity sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ== @@ -9904,7 +9197,7 @@ qjobs@^1.1.4: resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.2.0.tgz#c45e9c61800bd087ef88d7e256423bdd49e5d071" integrity sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg== -qs@6.7.0, qs@^6.4.0: +qs@6.7.0: version "6.7.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== @@ -9914,14 +9207,6 @@ qs@~6.5.2: resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== -query-string@^4.1.0: - version "4.3.4" - resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb" - integrity sha1-u7aTucqRXCMlFbIosaArYJBD2+s= - dependencies: - object-assign "^4.1.0" - strict-uri-encode "^1.0.0" - querystring-es3@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" @@ -9969,11 +9254,6 @@ raphael@^2.2.7: dependencies: eve-raphael "0.5.0" -raven-js@^3.22.1: - version "3.22.1" - resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.22.1.tgz#1117f00dfefaa427ef6e1a7d50bbb1fb998a24da" - integrity sha512-2Y8czUl5a9usbvXbpV8a+GPAiDXjxQjaHImZL0TyJWI5k5jV/6o+wceaBAg9g6RpO9OOJp0/w2mMs6pBoqOyDA== - raw-body@2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332" @@ -9984,14 +9264,6 @@ raw-body@2.4.0: iconv-lite "0.4.24" unpipe "1.0.0" -raw-body@~1.1.0: - version "1.1.7" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-1.1.7.tgz#1d027c2bfa116acc6623bca8f00016572a87d425" - integrity sha1-HQJ8K/oRasxmI7yo8AAWVyqH1CU= - dependencies: - bytes "1" - string_decoder "0.10" - raw-loader@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-3.1.0.tgz#5e9d399a5a222cc0de18f42c3bc5e49677532b3f" @@ -10074,7 +9346,7 @@ read-pkg@^3.0.0: normalize-package-data "^2.3.2" path-type "^3.0.0" -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.6" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw== @@ -10096,7 +9368,7 @@ readable-stream@^3.0.6: string_decoder "^1.1.1" util-deprecate "^1.0.1" -readable-stream@~2.0.0, readable-stream@~2.0.6: +readable-stream@~2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.0.6.tgz#8f90341e68a53ccc928788dacfcd11b36eb9b78e" integrity sha1-j5A0HmilPMySh4jaz80Rs265t44= @@ -10108,19 +9380,6 @@ readable-stream@~2.0.0, readable-stream@~2.0.6: string_decoder "~0.10.x" util-deprecate "~1.0.1" -readable-stream@~2.1.0: - version "2.1.5" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.1.5.tgz#66fa8b720e1438b364681f2ad1a63c618448c9d0" - integrity sha1-ZvqLcg4UOLNkaB8q0aY8YYRIydA= - dependencies: - buffer-shims "^1.0.0" - core-util-is "~1.0.0" - inherits "~2.0.1" - isarray "~1.0.0" - process-nextick-args "~1.0.6" - string_decoder "~0.10.x" - util-deprecate "~1.0.1" - readdir-enhanced@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/readdir-enhanced/-/readdir-enhanced-2.2.4.tgz#773fb8a8de5f645fb13d9403746d490d4facb3e6" @@ -10265,37 +9524,6 @@ relateurl@^0.2.7: resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk= -remark-html@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/remark-html/-/remark-html-8.0.0.tgz#9fcb859a6f3cb40f3ef15402950f1a62ec301b3a" - integrity sha512-3V2391GL3hxKhrkzYOyfPpxJ6taIKLCfuLVqumeWQOk3H9nTtSQ8St8kMYkBVIEAquXN1chT83qJ/2lAW+dpEg== - dependencies: - hast-util-sanitize "^1.0.0" - hast-util-to-html "^4.0.0" - mdast-util-to-hast "^3.0.0" - xtend "^4.0.1" - -remark-parse@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-5.0.0.tgz#4c077f9e499044d1d5c13f80d7a98cf7b9285d95" - integrity sha512-b3iXszZLH1TLoyUzrATcTQUZrwNl1rE70rVdSruJFlDaJ9z5aMkhrG43Pp68OgfHndL/ADz6V69Zow8cTQu+JA== - dependencies: - collapse-white-space "^1.0.2" - is-alphabetical "^1.0.0" - is-decimal "^1.0.0" - is-whitespace-character "^1.0.0" - is-word-character "^1.0.0" - markdown-escapes "^1.0.0" - parse-entities "^1.1.0" - repeat-string "^1.5.4" - state-toggle "^1.0.0" - trim "0.0.1" - trim-trailing-lines "^1.0.0" - unherit "^1.0.4" - unist-util-remove-position "^1.0.0" - vfile-location "^2.0.0" - xtend "^4.0.1" - remark-parse@^6.0.0: version "6.0.3" resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-6.0.3.tgz#c99131052809da482108413f87b0ee7f52180a3a" @@ -10317,42 +9545,6 @@ remark-parse@^6.0.0: vfile-location "^2.0.0" xtend "^4.0.1" -remark-reference-links@^4.0.1: - version "4.0.4" - resolved "https://registry.yarnpkg.com/remark-reference-links/-/remark-reference-links-4.0.4.tgz#190579a0d6b002859d6cdbdc5aeb8bbdae4e06ab" - integrity sha512-+2X8hwSQqxG4tvjYZNrTcEC+bXp8shQvwRGG6J/rnFTvBoU4G0BBviZoqKGZizLh/DG+0gSYhiDDWCqyxXW1iQ== - dependencies: - unist-util-visit "^1.0.0" - -remark-slug@^5.0.0: - version "5.1.2" - resolved "https://registry.yarnpkg.com/remark-slug/-/remark-slug-5.1.2.tgz#715ecdef8df1226786204b1887d31ab16aa24609" - integrity sha512-DWX+Kd9iKycqyD+/B+gEFO3jjnt7Yg1O05lygYSNTe5i5PIxxxPjp5qPBDxPIzp5wreF7+1ROCwRgjEcqmzr3A== - dependencies: - github-slugger "^1.0.0" - mdast-util-to-string "^1.0.0" - unist-util-visit "^1.0.0" - -remark-stringify@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-5.0.0.tgz#336d3a4d4a6a3390d933eeba62e8de4bd280afba" - integrity sha512-Ws5MdA69ftqQ/yhRF9XhVV29mhxbfGhbz0Rx5bQH+oJcNhhSM6nCu1EpLod+DjrFGrU0BMPs+czVmJZU7xiS7w== - dependencies: - ccount "^1.0.0" - is-alphanumeric "^1.0.0" - is-decimal "^1.0.0" - is-whitespace-character "^1.0.0" - longest-streak "^2.0.1" - markdown-escapes "^1.0.0" - markdown-table "^1.1.0" - mdast-util-compact "^1.0.0" - parse-entities "^1.0.2" - repeat-string "^1.5.4" - state-toggle "^1.0.0" - stringify-entities "^1.0.1" - unherit "^1.0.4" - xtend "^4.0.1" - remark-stringify@^6.0.0: version "6.0.4" resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-6.0.4.tgz#16ac229d4d1593249018663c7bddf28aafc4e088" @@ -10373,14 +9565,6 @@ remark-stringify@^6.0.0: unherit "^1.0.4" xtend "^4.0.1" -remark-toc@^5.0.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/remark-toc/-/remark-toc-5.1.1.tgz#8c229d6f834cdb43fde6685e2d43248d3fc82d78" - integrity sha512-vCPW4YOsm2CfyuScdktM9KDnJXVHJsd/ZeRtst+dnBU3B3KKvt8bc+bs5syJjyptAHfqo7H+5Uhz+2blWBfwow== - dependencies: - mdast-util-toc "^3.0.0" - remark-slug "^5.0.0" - remark@^10.0.1: version "10.0.1" resolved "https://registry.yarnpkg.com/remark/-/remark-10.0.1.tgz#3058076dc41781bf505d8978c291485fe47667df" @@ -10390,39 +9574,6 @@ remark@^10.0.1: remark-stringify "^6.0.0" unified "^7.0.0" -remark@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/remark/-/remark-9.0.0.tgz#c5cfa8ec535c73a67c4b0f12bfdbd3a67d8b2f60" - integrity sha512-amw8rGdD5lHbMEakiEsllmkdBP+/KpjW/PRK6NSGPZKCQowh0BT4IWXDAkRMyG3SB9dKPXWMviFjNusXzXNn3A== - dependencies: - remark-parse "^5.0.0" - remark-stringify "^5.0.0" - unified "^6.0.0" - -remote-origin-url@0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/remote-origin-url/-/remote-origin-url-0.4.0.tgz#4d3e2902f34e2d37d1c263d87710b77eb4086a30" - integrity sha1-TT4pAvNOLTfRwmPYdxC3frQIajA= - dependencies: - parse-git-config "^0.2.0" - -remove-bom-buffer@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/remove-bom-buffer/-/remove-bom-buffer-3.0.0.tgz#c2bf1e377520d324f623892e33c10cac2c252b53" - integrity sha512-8v2rWhaakv18qcvNeli2mZ/TMTL2nEyAKRvzo1WtnZBl15SHyEhrCu2/xKlJyUFKHiHgfXIyuY6g2dObJJycXQ== - dependencies: - is-buffer "^1.1.5" - is-utf8 "^0.2.1" - -remove-bom-stream@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/remove-bom-stream/-/remove-bom-stream-1.2.0.tgz#05f1a593f16e42e1fb90ebf59de8e569525f9523" - integrity sha1-BfGlk/FuQuH7kOv1nejlaVJflSM= - dependencies: - remove-bom-buffer "^3.0.0" - safe-buffer "^5.1.0" - through2 "^2.0.3" - remove-trailing-separator@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" @@ -10433,7 +9584,7 @@ repeat-element@^1.1.2: resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g== -repeat-string@^1.5.0, repeat-string@^1.5.4, repeat-string@^1.6.1: +repeat-string@^1.5.4, repeat-string@^1.6.1: version "1.6.1" resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= @@ -10445,7 +9596,7 @@ repeating@^2.0.0: dependencies: is-finite "^1.0.0" -replace-ext@1.0.0, replace-ext@^1.0.0: +replace-ext@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb" integrity sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs= @@ -10567,13 +9718,6 @@ resolve-from@^5.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== -resolve-options@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/resolve-options/-/resolve-options-1.1.0.tgz#32bb9e39c06d67338dc9378c0d6d6074566ad131" - integrity sha1-MrueOcBtZzONyTeMDW1gdFZq0TE= - dependencies: - value-or-function "^3.0.0" - resolve-url@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" @@ -10584,7 +9728,7 @@ resolve@1.1.7: resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs= -resolve@1.x, resolve@^1.1.3, resolve@^1.10.0, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.8.1, resolve@^1.9.0: +resolve@1.x, resolve@^1.10.0, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.9.0: version "1.11.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.11.1.tgz#ea10d8110376982fef578df8fc30b9ac30a07a3e" integrity sha512-vIpgF6wfuJOZI7KKKSP+HmiKggadPQAdsp5HiC1mvqnfp0gF1vdwgBWZIdrVft9pgqoMFQN+R7BSWZiBxx+BBw== @@ -10670,11 +9814,6 @@ safe-buffer@5.1.2, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, s resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-json-parse@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/safe-json-parse/-/safe-json-parse-1.0.1.tgz#3e76723e38dfdda13c9b1d29a1e07ffee4b30b57" - integrity sha1-PnZyPjjf3aE8mx0poeB//uSzC1c= - safe-regex@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" @@ -10702,18 +9841,21 @@ sane@^4.0.3: minimist "^1.1.1" walker "~1.0.5" -sanitize-html@^1.16.1: - version "1.16.3" - resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-1.16.3.tgz#96c1b44a36ff7312e1c22a14b05274370ac8bd56" - integrity sha512-XpAJGnkMfNM7AzXLRw225blBB/pE4dM4jzRn98g4r88cfxwN6g+5IsRmCAh/gbhYGm6u6i97zsatMOM7Lr8wyw== +sanitize-html@^1.20.0: + version "1.20.1" + resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-1.20.1.tgz#f6effdf55dd398807171215a62bfc21811bacf85" + integrity sha512-txnH8TQjaQvg2Q0HY06G6CDJLVYCpbnxrdO0WN8gjCKaU5J0KbyGYhZxx5QJg3WLZ1lB7XU9kDkfrCXUozqptA== dependencies: - htmlparser2 "^3.9.0" + chalk "^2.4.1" + htmlparser2 "^3.10.0" lodash.clonedeep "^4.5.0" lodash.escaperegexp "^4.1.2" - lodash.mergewith "^4.6.0" - postcss "^6.0.14" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.mergewith "^4.6.1" + postcss "^7.0.5" srcset "^1.0.0" - xtend "^4.0.0" + xtend "^4.0.1" sass-graph@^2.2.4: version "2.2.4" @@ -11097,13 +10239,6 @@ sockjs@0.3.19: faye-websocket "^0.10.0" uuid "^3.0.1" -sort-keys@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" - integrity sha1-RBttTTRnmPG05J6JIK37oOVD+a0= - dependencies: - is-plain-obj "^1.0.0" - sortablejs@^1.10.0, sortablejs@^1.9.0: version "1.10.0" resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.10.0.tgz#0ebc054acff2486569194a2f975b2b145dd5e7d6" @@ -11165,11 +10300,6 @@ source-map@^0.7.3: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== -space-separated-tokens@^1.0.0: - version "1.1.4" - resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-1.1.4.tgz#27910835ae00d0adfcdbd0ad7e611fb9544351fa" - integrity sha512-UyhMSmeIqZrQn2UdjYpxEkwY9JUrn8pP+7L4f91zRzOQuI8MF1FGLfYU9DKCYeLdo7LXMxwrX5zKFy7eeeVHuA== - spdx-correct@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-1.0.2.tgz#4b3073d933ff51f3912f03ac5519498a4150db40" @@ -11307,13 +10437,6 @@ stickyfilljs@^2.0.5: resolved "https://registry.yarnpkg.com/stickyfilljs/-/stickyfilljs-2.0.5.tgz#d229e372d2199ddf5d283bbe34ac1f7d2529c2fc" integrity sha512-KGKdqKbv1jXit54ltFPIWw/XVeuSrJmTUS8viT1Pmdpp1Jyv3SMpFmhvPBdddX9FHDlHbm9s8cPAhPviBaBVpA== -stream-array@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/stream-array/-/stream-array-1.1.2.tgz#9e5f7345f2137c30ee3b498b9114e80b52bb7eb5" - integrity sha1-nl9zRfITfDDuO0mLkRToC1K7frU= - dependencies: - readable-stream "~2.1.0" - stream-browserify@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db" @@ -11322,14 +10445,6 @@ stream-browserify@^2.0.1: inherits "~2.0.1" readable-stream "^2.0.2" -stream-combiner2@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/stream-combiner2/-/stream-combiner2-1.1.1.tgz#fb4d8a1420ea362764e21ad4780397bebcb41cbe" - integrity sha1-+02KFCDqNidk4hrUeAOXvry0HL4= - dependencies: - duplexer2 "~0.1.0" - readable-stream "^2.0.2" - stream-each@^1.1.0: version "1.2.2" resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.2.tgz#8e8c463f91da8991778765873fe4d960d8f616bd" @@ -11365,11 +10480,6 @@ streamroller@^1.0.6: fs-extra "^7.0.1" lodash "^4.17.14" -strict-uri-encode@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" - integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= - string-length@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed" @@ -11378,11 +10488,6 @@ string-length@^2.0.0: astral-regex "^1.0.0" strip-ansi "^4.0.0" -string-template@~0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" - integrity sha1-QpMuWYo1LQH8IuwzZ9nYTuxsmt0= - string-width@^1.0.1, string-width@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" @@ -11409,7 +10514,7 @@ string-width@^3.0.0, string-width@^3.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" -string-width@^4.0.0, string-width@^4.1.0: +string-width@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.1.0.tgz#ba846d1daa97c3c596155308063e075ed1c99aff" integrity sha512-NrX+1dVVh+6Y9dnQ19pR0pP4FiEIlUvdTGn8pw6CKTNq5sgib2nIhmUNT5TAmhWmvKr3WcxBcP3E8nWezuipuQ== @@ -11418,11 +10523,6 @@ string-width@^4.0.0, string-width@^4.1.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^5.2.0" -string_decoder@0.10, string_decoder@~0.10.x: - version "0.10.31" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" - integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ= - string_decoder@^1.0.0, string_decoder@^1.1.1, string_decoder@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" @@ -11430,6 +10530,11 @@ string_decoder@^1.0.0, string_decoder@^1.1.1, string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" +string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ= + stringify-entities@^1.0.1: version "1.3.2" resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-1.3.2.tgz#a98417e5471fd227b3e45d3db1861c11caf668f7" @@ -11585,13 +10690,6 @@ stylelint@^10.1.0: svg-tags "^1.0.0" table "^5.2.3" -subarg@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/subarg/-/subarg-1.0.0.tgz#f62cf17581e996b48fc965699f54c06ae268b8d2" - integrity sha1-9izxdYHplrSPyWVpn1TAauJouNI= - dependencies: - minimist "^1.1.0" - sugarss@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/sugarss/-/sugarss-2.0.0.tgz#ddd76e0124b297d40bf3cca31c8b22ecb43bc61d" @@ -11599,7 +10697,7 @@ sugarss@^2.0.0: dependencies: postcss "^7.0.2" -supports-color@6.1.0, supports-color@^6.0.0, supports-color@^6.1.0: +supports-color@6.1.0, supports-color@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== @@ -11761,15 +10859,7 @@ throttle-debounce@^2.0.0: resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.0.1.tgz#7307ddd6cd9acadb349132fbf6c18d78c88a5e62" integrity sha512-Sr6jZBlWShsAaSXKyNXyNicOrJW/KtkDqIEwHt4wYwWA2wa/q67Luhqoujg48V8hTk60wB56tYrJJn6jc2R7VA== -through2-filter@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-3.0.0.tgz#700e786df2367c2c88cd8aa5be4cf9c1e7831254" - integrity sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA== - dependencies: - through2 "~2.0.0" - xtend "~4.0.0" - -through2@^2.0.0, through2@^2.0.3, through2@~2.0.0: +through2@^2.0.0: version "2.0.5" resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== @@ -11777,7 +10867,7 @@ through2@^2.0.0, through2@^2.0.3, through2@~2.0.0: readable-stream "~2.3.6" xtend "~4.0.1" -"through@>=2.2.7 <3", through@^2.3.6: +through@^2.3.6: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= @@ -11816,18 +10906,6 @@ tiny-emitter@^2.0.0: resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.0.2.tgz#82d27468aca5ade8e5fd1e6d22b57dd43ebdfb7c" integrity sha512-2NM0auVBGft5tee/OxP4PI3d8WItkDM+fPnaRAVo6xTDI2knbz9eC5ArWGqtGlYqiH3RU5yMpdyTTO7MguC4ow== -tiny-lr@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/tiny-lr/-/tiny-lr-1.1.1.tgz#9fa547412f238fedb068ee295af8b682c98b2aab" - integrity sha512-44yhA3tsaRoMOjQQ+5v5mVdqef+kH6Qze9jTpqtVufgYjYt08zyZAwNwwVBj3i1rJMnR52IxOW0LK0vBzgAkuA== - dependencies: - body "^5.1.0" - debug "^3.1.0" - faye-websocket "~0.10.0" - livereload-js "^2.3.0" - object-assign "^4.1.0" - qs "^6.4.0" - tiptap-commands@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/tiptap-commands/-/tiptap-commands-1.4.0.tgz#0cfb3ac138ee3099de56114cb119abd841fbcbe7" @@ -11891,14 +10969,6 @@ tmpl@1.0.x: resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE= -to-absolute-glob@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz#1865f43d9e74b0822db9f145b78cff7d0f7c849b" - integrity sha1-GGX0PZ50sIItufFFt4z/fQ98hJs= - dependencies: - is-absolute "^1.0.0" - is-negated-glob "^1.0.0" - to-array@0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890" @@ -11946,13 +11016,6 @@ to-regex@^3.0.1, to-regex@^3.0.2: regex-not "^1.0.2" safe-regex "^1.1.0" -to-through@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/to-through/-/to-through-2.0.0.tgz#fc92adaba072647bc0b67d6b03664aa195093af6" - integrity sha1-/JKtq6ByZHvAtn1rA2ZKoZUJOvY= - dependencies: - through2 "^2.0.3" - toggle-selection@^1.0.3: version "1.0.6" resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" @@ -11985,11 +11048,6 @@ tr46@^1.0.1: dependencies: punycode "^2.1.0" -trim-lines@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-1.1.2.tgz#c8adbdbdae21bb5c2766240a661f693afe23e59b" - integrity sha512-3GOuyNeTqk3FAqc3jOJtw7FTjYl94XBR5aD9QnDbK/T4CA9sW/J0l9RoaRPE9wyPP7NF331qnHnvJFBJ+IDkmQ== - trim-newlines@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613" @@ -12037,13 +11095,6 @@ tryer@^1.0.0: resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.0.tgz#027b69fa823225e551cace3ef03b11f6ab37c1d7" integrity sha1-Antp+oIyJeVRys4+8DsR9qs3wdc= -ts-invariant@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.2.1.tgz#3d587f9d6e3bded97bf9ec17951dd9814d5a9d3f" - integrity sha512-Z/JSxzVmhTo50I+LKagEISFJW3pvPCqsMWLamCTX8Kr3N5aMrnGOqcflbe5hLUzwjvgPfnLzQtHZv0yWQ+FIHg== - dependencies: - tslib "^1.9.3" - ts-invariant@^0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.3.2.tgz#89a2ffeb70879b777258df1df1c59383c35209b0" @@ -12051,6 +11102,13 @@ ts-invariant@^0.3.2: dependencies: tslib "^1.9.3" +ts-invariant@^0.4.0: + version "0.4.4" + resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86" + integrity sha512-uEtWkFM/sdZvRNNDL3Ehu4WVpwaulhwQszV8mrtcdeE8nN00BV9mAmQ88RkrBhFgl9gMgvjJLAQcZbnPXI9mlA== + dependencies: + tslib "^1.9.3" + ts-jest@24.0.0, ts-jest@^23.10.5: version "24.0.0" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-24.0.0.tgz#3f26bf2ec1fa584863a5a9c29bd8717d549efbf6" @@ -12095,6 +11153,11 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" +type-fest@^0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.5.2.tgz#d6ef42a0356c6cd45f49485c3b6281fc148e48a2" + integrity sha512-DWkS49EQKVX//Tbupb9TFa19c7+MK1XmzkrZUR8TAktmE/DizXoaoJV6TZ/tSIPXipqNiRI6CyAe7x69Jb6RSw== + type-is@~1.6.17, type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" @@ -12103,7 +11166,7 @@ type-is@~1.6.17, type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" -typedarray@^0.0.6, typedarray@~0.0.5: +typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= @@ -12131,11 +11194,6 @@ ultron@~1.1.0: resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz#9fe1536a10a664a65266a1e3ccf85fd36302bc9c" integrity sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og== -unc-path-regex@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" - integrity sha1-5z3T17DXxe2G+6xrCufYxqadUPo= - undefsafe@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.2.tgz#225f6b9e0337663e0d8e7cfd686fc2836ccace76" @@ -12196,18 +11254,6 @@ unicode-property-aliases-ecmascript@^1.0.4: resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.0.4.tgz#5a533f31b4317ea76f17d807fa0d116546111dd0" integrity sha512-2WSLa6OdYd2ng8oqiGIWnJqyFArvhn+5vgx5GTxMbUYjCYKUcuKS62YLFF0R/BDGlB1yzXjQOLtPAfHsgirEpg== -unified@^6.0.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/unified/-/unified-6.2.0.tgz#7fbd630f719126d67d40c644b7e3f617035f6dba" - integrity sha512-1k+KPhlVtqmG99RaTbAv/usu85fcSRu3wY8X+vnsEhIxNP5VbVIDiXnLqyKIG+UMdyTg0ZX9EI6k2AfjJkHPtA== - dependencies: - bail "^1.0.0" - extend "^3.0.0" - is-plain-obj "^1.1.0" - trough "^1.0.0" - vfile "^2.0.0" - x-is-string "^0.1.0" - unified@^7.0.0: version "7.1.0" resolved "https://registry.yarnpkg.com/unified/-/unified-7.1.0.tgz#5032f1c1ee3364bd09da12e27fdd4a7553c7be13" @@ -12251,14 +11297,6 @@ unique-slug@^2.0.0: dependencies: imurmurhash "^0.1.4" -unique-stream@^2.0.2: - version "2.3.1" - resolved "https://registry.yarnpkg.com/unique-stream/-/unique-stream-2.3.1.tgz#c65d110e9a4adf9a6c5948b28053d9a8d04cbeac" - integrity sha512-2nY4TnBE70yoxHkDli7DMazpWiP7xMdCYqU2nBRO0UB+ZpEkGsSija7MvmvnZFUeC+mrgiUfcHSr3LmRFIg4+A== - dependencies: - json-stable-stringify-without-jsonify "^1.0.1" - through2-filter "^3.0.0" - unique-string@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-1.0.0.tgz#9e1057cca851abb93398f8b33ae187b99caec11a" @@ -12266,13 +11304,6 @@ unique-string@^1.0.0: dependencies: crypto-random-string "^1.0.0" -unist-builder@^1.0.1, unist-builder@^1.0.2: - version "1.0.4" - resolved "https://registry.yarnpkg.com/unist-builder/-/unist-builder-1.0.4.tgz#e1808aed30bd72adc3607f25afecebef4dd59e17" - integrity sha512-v6xbUPP7ILrT15fHGrNyHc1Xda8H3xVhP7/HAIotHOhVPjH5dCXA097C3Rry1Q2O+HbOLCao4hfPB+EYEjHgVg== - dependencies: - object-assign "^4.1.0" - unist-util-find-all-after@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/unist-util-find-all-after/-/unist-util-find-all-after-1.0.2.tgz#9be49cfbae5ca1566b27536670a92836bf2f8d6d" @@ -12280,21 +11311,11 @@ unist-util-find-all-after@^1.0.2: dependencies: unist-util-is "^2.0.0" -unist-util-generated@^1.1.0: - version "1.1.4" - resolved "https://registry.yarnpkg.com/unist-util-generated/-/unist-util-generated-1.1.4.tgz#2261c033d9fc23fae41872cdb7663746e972c1a7" - integrity sha512-SA7Sys3h3X4AlVnxHdvN/qYdr4R38HzihoEVY2Q2BZu8NHWDnw5OGcC/tXWjQfd4iG+M6qRFNIRGqJmp2ez4Ww== - unist-util-is@^2.0.0, unist-util-is@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-2.1.2.tgz#1193fa8f2bfbbb82150633f3a8d2eb9a1c1d55db" integrity sha512-YkXBK/H9raAmG7KXck+UUpnKiNmUdB+aBGrknfQ4EreE1banuzrKABx3jP6Z5Z3fMSPMQQmeXBlKpCbMwBkxVw== -unist-util-position@^3.0.0: - version "3.0.3" - resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-3.0.3.tgz#fff942b879538b242096c148153826664b1ca373" - integrity sha512-28EpCBYFvnMeq9y/4w6pbnFmCUfzlsc41NJui5c51hOFjBA1fejcwc+5W4z2+0ECVbScG3dURS3JTVqwenzqZw== - unist-util-remove-position@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/unist-util-remove-position/-/unist-util-remove-position-1.1.2.tgz#86b5dad104d0bbfbeb1db5f5c92f3570575c12cb" @@ -12307,13 +11328,6 @@ unist-util-stringify-position@^1.0.0, unist-util-stringify-position@^1.1.1: resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-1.1.2.tgz#3f37fcf351279dcbca7480ab5889bb8a832ee1c6" integrity sha512-pNCVrk64LZv1kElr0N1wPiHEUoXNVFERp+mlTg/s9R5Lwg87f9bM/3sQB99w+N9D/qnM9ar3+AKDBwo/gm/iQQ== -unist-util-stringify-position@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-2.0.1.tgz#de2a2bc8d3febfa606652673a91455b6a36fb9f3" - integrity sha512-Zqlf6+FRI39Bah8Q6ZnNGrEHUhwJOkHde2MHVk96lLyftfJJckaPslKgzhVcviXj8KcE9UJM9F+a4JEiBUTYgA== - dependencies: - "@types/unist" "^2.0.2" - unist-util-visit-parents@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-2.0.1.tgz#63fffc8929027bee04bfef7d2cce474f71cb6217" @@ -12321,7 +11335,7 @@ unist-util-visit-parents@^2.0.0: dependencies: unist-util-is "^2.1.2" -unist-util-visit@^1.0.0, unist-util-visit@^1.1.0, unist-util-visit@^1.3.0: +unist-util-visit@^1.1.0: version "1.4.1" resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-1.4.1.tgz#4724aaa8486e6ee6e26d7ff3c8685960d560b1e3" integrity sha512-AvGNk7Bb//EmJZyhtRUnNMEpId/AZ5Ph/KUpTI09WHQuDZHKovQ1oEv3mfmKpWKtoMzyMC4GLBm1Zy5k12fjIw== @@ -12497,11 +11511,6 @@ validate-npm-package-license@^3.0.1: spdx-correct "~1.0.0" spdx-expression-parse "~1.0.0" -value-or-function@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/value-or-function/-/value-or-function-3.0.0.tgz#1c243a50b595c1be54a754bfece8563b9ff8d813" - integrity sha1-HCQ6ULWVwb5Up1S/7OhWO5/42BM= - vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" @@ -12528,46 +11537,6 @@ vfile-message@^1.0.0: dependencies: unist-util-stringify-position "^1.1.1" -vfile-message@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-2.0.1.tgz#951881861c22fc1eb39f873c0b93e336a64e8f6d" - integrity sha512-KtasSV+uVU7RWhUn4Lw+wW1Zl/nW8JWx7JCPps10Y9JRRIDeDXf8wfBLoOSsJLyo27DqMyAi54C6Jf/d6Kr2Bw== - dependencies: - "@types/unist" "^2.0.2" - unist-util-stringify-position "^2.0.0" - -vfile-reporter@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/vfile-reporter/-/vfile-reporter-6.0.0.tgz#753119f51dec9289b7508b457afc0cddf5e07f2e" - integrity sha512-8Is0XxFxWJUhPJdOg3CyZTqd3ICCWg6r304PuBl818ZG91h4FMS3Q+lrOPS+cs5/DZK3H0+AkJdH0J8JEwKtDA== - dependencies: - repeat-string "^1.5.0" - string-width "^4.0.0" - supports-color "^6.0.0" - unist-util-stringify-position "^2.0.0" - vfile-sort "^2.1.2" - vfile-statistics "^1.1.0" - -vfile-sort@^2.1.0, vfile-sort@^2.1.2: - version "2.2.1" - resolved "https://registry.yarnpkg.com/vfile-sort/-/vfile-sort-2.2.1.tgz#74e714f9175618cdae96bcaedf1a3dc711d87567" - integrity sha512-5dt7xEhC44h0uRQKhbM2JAe0z/naHphIZlMOygtMBM9Nn0pZdaX5fshhwWit9wvsuP8t/wp43nTDRRErO1WK8g== - -vfile-statistics@^1.1.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/vfile-statistics/-/vfile-statistics-1.1.3.tgz#e9c87071997fbcb4243764d2c3805e0bb0820c60" - integrity sha512-CstaK/ebTz1W3Qp41Bt9Lj/2DmumFsCwC2sKahDNSPh0mPh7/UyMLCoU8ZBX34CRU0d61B4W41yIFsV0NKMZeA== - -vfile@^2.0.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/vfile/-/vfile-2.3.0.tgz#e62d8e72b20e83c324bc6c67278ee272488bf84a" - integrity sha512-ASt4mBUHcTpMKD/l5Q+WJXNtshlWxOogYyGYYrg4lt/vuRjC1EFQtlAofL5VmtVNIZJzWYFJjzGWZ0Gw8pzW1w== - dependencies: - is-buffer "^1.1.4" - replace-ext "1.0.0" - unist-util-stringify-position "^1.0.0" - vfile-message "^1.0.0" - vfile@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/vfile/-/vfile-3.0.1.tgz#47331d2abe3282424f4a4bb6acd20a44c4121803" @@ -12578,65 +11547,6 @@ vfile@^3.0.0: unist-util-stringify-position "^1.0.0" vfile-message "^1.0.0" -vfile@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/vfile/-/vfile-4.0.1.tgz#fc3d43a1c71916034216bf65926d5ee3c64ed60c" - integrity sha512-lRHFCuC4SQBFr7Uq91oJDJxlnftoTLQ7eKIpMdubhYcVMho4781a8MWXLy3qZrZ0/STD1kRiKc0cQOHm4OkPeA== - dependencies: - "@types/unist" "^2.0.0" - is-buffer "^2.0.0" - replace-ext "1.0.0" - unist-util-stringify-position "^2.0.0" - vfile-message "^2.0.0" - -vinyl-fs@^3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/vinyl-fs/-/vinyl-fs-3.0.3.tgz#c85849405f67428feabbbd5c5dbdd64f47d31bc7" - integrity sha512-vIu34EkyNyJxmP0jscNzWBSygh7VWhqun6RmqVfXePrOwi9lhvRs//dOaGOTRUQr4tx7/zd26Tk5WeSVZitgng== - dependencies: - fs-mkdirp-stream "^1.0.0" - glob-stream "^6.1.0" - graceful-fs "^4.0.0" - is-valid-glob "^1.0.0" - lazystream "^1.0.0" - lead "^1.0.0" - object.assign "^4.0.4" - pumpify "^1.3.5" - readable-stream "^2.3.3" - remove-bom-buffer "^3.0.0" - remove-bom-stream "^1.2.0" - resolve-options "^1.1.0" - through2 "^2.0.0" - to-through "^2.0.0" - value-or-function "^3.0.0" - vinyl "^2.0.0" - vinyl-sourcemap "^1.1.0" - -vinyl-sourcemap@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/vinyl-sourcemap/-/vinyl-sourcemap-1.1.0.tgz#92a800593a38703a8cdb11d8b300ad4be63b3e16" - integrity sha1-kqgAWTo4cDqM2xHYswCtS+Y7PhY= - dependencies: - append-buffer "^1.0.2" - convert-source-map "^1.5.0" - graceful-fs "^4.1.6" - normalize-path "^2.1.1" - now-and-later "^2.0.0" - remove-bom-buffer "^3.0.0" - vinyl "^2.0.0" - -vinyl@^2.0.0, vinyl@^2.1.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.2.0.tgz#d85b07da96e458d25b2ffe19fece9f2caa13ed86" - integrity sha512-MBH+yP0kC/GQ5GwBqrTPTzEfiiLjta7hTtvQtbxBgTeSXsmKQRQecjibMbxIXzVT3Y9KJK+drOz1/k+vsu8Nkg== - dependencies: - clone "^2.1.1" - clone-buffer "^1.0.0" - clone-stats "^1.0.0" - cloneable-readable "^1.0.0" - remove-trailing-separator "^1.0.1" - replace-ext "^1.0.0" - visibilityjs@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/visibilityjs/-/visibilityjs-1.2.4.tgz#bff8663da62c8c10ad4ee5ae6a1ae6fac4259d63" @@ -12730,7 +11640,7 @@ vue-style-loader@^4.1.0: hash-sum "^1.0.2" loader-utils "^1.0.2" -vue-template-compiler@^2.5.16, vue-template-compiler@^2.5.20, vue-template-compiler@^2.6.10: +vue-template-compiler@^2.5.20, vue-template-compiler@^2.6.10: version "2.6.10" resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.6.10.tgz#323b4f3495f04faa3503337a82f5d6507799c9cc" integrity sha512-jVZkw4/I/HT5ZMvRnhv78okGusqe0+qH2A0Em0Cp8aq78+NK9TII263CDVz2QXZsIT+yyV/gZc/j/vlwa+Epyg== @@ -13160,7 +12070,7 @@ xmlhttprequest@1: resolved "https://registry.yarnpkg.com/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz#67fe075c5c24fef39f9d65f5f7b7fe75171968fc" integrity sha1-Z/4HXFwk/vOfnWX197f+dRcZaPw= -xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.0, xtend@~4.0.1: +xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==