diff options
396 files changed, 6281 insertions, 2375 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4d30efccb5c..44beccd966a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.5.3-golang-1.11-git-2.21-chrome-71.0-node-10.x-yarn-1.12-postgresql-9.6-graphicsmagick-1.3.29" +image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.5.3-golang-1.11-git-2.21-chrome-73.0-node-10.x-yarn-1.12-postgresql-9.6-graphicsmagick-1.3.29" variables: MYSQL_ALLOW_EMPTY_PASSWORD: "1" @@ -28,6 +28,8 @@ stages: - prepare - merge - test + - review + - qa - post-test - pages - post-cleanup diff --git a/.gitlab/ci/cng.gitlab-ci.yml b/.gitlab/ci/cng.gitlab-ci.yml index e15f8ed91e0..c384bcdcdfc 100644 --- a/.gitlab/ci/cng.gitlab-ci.yml +++ b/.gitlab/ci/cng.gitlab-ci.yml @@ -9,7 +9,7 @@ cloud-native-image: cache: {} when: manual script: - - gem install gitlab --no-document + - install_gitlab_gem - CNG_PROJECT_PATH="gitlab-org/build/CNG" BUILD_TRIGGER_TOKEN=$CI_JOB_TOKEN ./scripts/trigger-build cng only: - tags@gitlab-org/gitlab-ce diff --git a/.gitlab/ci/frontend.gitlab-ci.yml b/.gitlab/ci/frontend.gitlab-ci.yml index fd179f77e26..fbf8925e30a 100644 --- a/.gitlab/ci/frontend.gitlab-ci.yml +++ b/.gitlab/ci/frontend.gitlab-ci.yml @@ -16,7 +16,7 @@ gitlab:assets:compile: <<: *assets-compile-cache extends: .dedicated-no-docs-pull-cache-job - image: dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.5.3-git-2.21-chrome-71.0-node-8.x-yarn-1.12-graphicsmagick-1.3.29-docker-18.06.1 + image: dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.5.3-git-2.21-chrome-73.0-node-8.x-yarn-1.12-graphicsmagick-1.3.29-docker-18.06.1 dependencies: - setup-test-env services: @@ -38,6 +38,10 @@ gitlab:assets:compile: - bundle exec rake gitlab:assets:compile - time scripts/build_assets_image - scripts/clean-old-cached-assets + # Play dependent manual jobs + - install_api_client_dependencies_with_apt + - play_job "review-build-cng" || true # this job might not exist so ignore the failure if it cannot be played + - play_job "schedule:review-build-cng" || true # this job might not exist so ignore the failure if it cannot be played artifacts: name: webpack-report expire_in: 31d @@ -103,7 +107,7 @@ gitlab:ui:visual: - $CI_COMMIT_MESSAGE =~ /\[skip visual\]/i artifacts: paths: - - tests/__image_snapshots__/ + - gitlab-ui/tests/__image_snapshots__/ when: always karma: diff --git a/.gitlab/ci/qa.gitlab-ci.yml b/.gitlab/ci/qa.gitlab-ci.yml index 07b38c9aa85..85c6409186e 100644 --- a/.gitlab/ci/qa.gitlab-ci.yml +++ b/.gitlab/ci/qa.gitlab-ci.yml @@ -1,20 +1,17 @@ package-and-qa: image: ruby:2.5-alpine - stage: test + stage: qa + when: manual before_script: [] dependencies: [] cache: {} variables: GIT_DEPTH: "1" - API_TOKEN: "${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}" retry: 0 script: - - apk add --update openssl curl jq - - gem install gitlab --no-document - - source ./scripts/review_apps/review-apps.sh - - wait_for_job_to_be_done "gitlab:assets:compile" + - source scripts/utils.sh + - install_gitlab_gem - ./scripts/trigger-build omnibus - when: manual only: - /.+/@gitlab-org/gitlab-ce - /.+/@gitlab-org/gitlab-ee diff --git a/.gitlab/ci/rails.gitlab-ci.yml b/.gitlab/ci/rails.gitlab-ci.yml index bdc6ce234b8..01e71a7faf1 100644 --- a/.gitlab/ci/rails.gitlab-ci.yml +++ b/.gitlab/ci/rails.gitlab-ci.yml @@ -86,7 +86,7 @@ .rspec-metadata-pg-10: &rspec-metadata-pg-10 <<: *rspec-metadata <<: *use-pg-10 - image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.5.3-golang-1.11-git-2.21-chrome-71.0-node-10.x-yarn-1.12-postgresql-10-graphicsmagick-1.3.29" + image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.5.3-golang-1.11-git-2.21-chrome-73.0-node-10.x-yarn-1.12-postgresql-10-graphicsmagick-1.3.29" .rspec-metadata-mysql: &rspec-metadata-mysql <<: *rspec-metadata diff --git a/.gitlab/ci/review.gitlab-ci.yml b/.gitlab/ci/review.gitlab-ci.yml index 9cfb50eeefc..f5b131cf6b2 100644 --- a/.gitlab/ci/review.gitlab-ci.yml +++ b/.gitlab/ci/review.gitlab-ci.yml @@ -26,12 +26,10 @@ extends: .dedicated-runner <<: *review-only image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-charts-build-base - stage: test cache: {} dependencies: [] - environment: &review-environment - name: review/${CI_COMMIT_REF_NAME} - url: https://gitlab-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN} + before_script: + - source scripts/utils.sh .review-docker: &review-docker <<: *review-base @@ -42,18 +40,13 @@ - gitlab-org - docker variables: &review-docker-variables - GIT_DEPTH: "1" DOCKER_DRIVER: overlay2 DOCKER_HOST: tcp://docker:2375 LATEST_QA_IMAGE: "gitlab/${CI_PROJECT_NAME}-qa:nightly" QA_IMAGE: "${CI_REGISTRY}/${CI_PROJECT_PATH}/gitlab/${CI_PROJECT_NAME}-qa:${CI_COMMIT_REF_SLUG}" - before_script: [] build-qa-image: <<: *review-docker - variables: - <<: *review-docker-variables - GIT_DEPTH: "20" stage: prepare script: - time docker build --cache-from ${LATEST_QA_IMAGE} --tag ${QA_IMAGE} ./qa/ @@ -63,16 +56,14 @@ build-qa-image: .review-build-cng-base: &review-build-cng-base image: ruby:2.5-alpine stage: test - before_script: [] + when: manual + before_script: + - source scripts/utils.sh + - install_api_client_dependencies_with_apk + - install_gitlab_gem dependencies: [] cache: {} - variables: - API_TOKEN: "${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}" script: - - apk add --update openssl curl jq - - gem install gitlab --no-document - - source ./scripts/review_apps/review-apps.sh - - wait_for_job_to_be_done "gitlab:assets:compile" - BUILD_TRIGGER_TOKEN=$REVIEW_APPS_BUILD_TRIGGER_TOKEN ./scripts/trigger-build cng review-build-cng: @@ -85,26 +76,32 @@ schedule:review-build-cng: .review-deploy-base: &review-deploy-base <<: *review-base + stage: review retry: 2 allow_failure: true variables: HOST_SUFFIX: "${CI_ENVIRONMENT_SLUG}" DOMAIN: "-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN}" GITLAB_HELM_CHART_REF: "master" - API_TOKEN: "${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}" - environment: - <<: *review-environment + environment: &review-environment + name: review/${CI_COMMIT_REF_NAME} + url: https://gitlab-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN} on_stop: review-stop before_script: - export GITLAB_SHELL_VERSION=$(<GITLAB_SHELL_VERSION) - export GITALY_VERSION=$(<GITALY_SERVER_VERSION) - export GITLAB_WORKHORSE_VERSION=$(<GITLAB_WORKHORSE_VERSION) - - apk update && apk add jq - - gem install gitlab --no-document - - source ./scripts/review_apps/review-apps.sh + - echo "${CI_ENVIRONMENT_URL}" > review_app_url.txt + - source scripts/utils.sh + - install_api_client_dependencies_with_apk + - source scripts/review_apps/review-apps.sh script: - - wait_for_job_to_be_done "review-build-cng" - perform_review_app_deployment + artifacts: + paths: + - review_app_url.txt + expire_in: 2 days + when: always review-deploy: <<: *review-deploy-base @@ -113,15 +110,29 @@ schedule:review-deploy: <<: *review-deploy-base <<: *review-schedules-only script: - - wait_for_job_to_be_done "schedule:review-build-cng" - perform_review_app_deployment +review-stop: + <<: *review-base + stage: review + when: manual + allow_failure: true + variables: + GIT_DEPTH: "1" + environment: + <<: *review-environment + action: stop + script: + - source scripts/review_apps/review-apps.sh + - delete + - cleanup + .review-qa-base: &review-qa-base <<: *review-docker + stage: qa allow_failure: true variables: <<: *review-docker-variables - API_TOKEN: "${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}" QA_ARTIFACTS_DIR: "${CI_PROJECT_DIR}/qa" QA_CAN_TEST_GIT_PROTOCOL_V2: "false" GITLAB_USERNAME: "root" @@ -131,40 +142,45 @@ schedule:review-deploy: GITHUB_ACCESS_TOKEN: "${REVIEW_APPS_QA_GITHUB_ACCESS_TOKEN}" EE_LICENSE: "${REVIEW_APPS_EE_LICENSE}" QA_DEBUG: "true" + dependencies: + - review-deploy artifacts: paths: - ./qa/gitlab-qa-run-* expire_in: 7 days when: always before_script: - - echo "${QA_IMAGE}" + - export CI_ENVIRONMENT_URL="$(cat review_app_url.txt)" - echo "${CI_ENVIRONMENT_URL}" - - apk update && apk add curl jq - - source ./scripts/review_apps/review-apps.sh + - echo "${QA_IMAGE}" + - source scripts/utils.sh + - install_api_client_dependencies_with_apk - gem install gitlab-qa --no-document ${GITLAB_QA_VERSION:+ --version ${GITLAB_QA_VERSION}} review-qa-smoke: <<: *review-qa-base retry: 2 script: - - wait_for_job_to_be_done "review-deploy" - gitlab-qa Test::Instance::Smoke "${QA_IMAGE}" "${CI_ENVIRONMENT_URL}" review-qa-all: <<: *review-qa-base + when: manual script: - - wait_for_job_to_be_done "review-deploy" - gitlab-qa Test::Instance::Any "${QA_IMAGE}" "${CI_ENVIRONMENT_URL}" - when: manual .review-performance-base: &review-performance-base <<: *review-qa-base - script: - - wait_for_job_to_be_done "review-deploy" + stage: qa + before_script: + - export CI_ENVIRONMENT_URL="$(cat review_app_url.txt)" + - echo "${CI_ENVIRONMENT_URL}" - mkdir -p gitlab-exporter - wget -O ./gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/master/index.js - - mkdir sitespeed-results - - docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:6.3.1 --plugins.add ./gitlab-exporter --outputFolder sitespeed-results "$CI_ENVIRONMENT_URL" + - mkdir -p sitespeed-results + script: + - docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:6.3.1 --plugins.add ./gitlab-exporter --outputFolder sitespeed-results "${CI_ENVIRONMENT_URL}" + after_script: - mv sitespeed-results/data/performance.json performance.json artifacts: paths: @@ -175,42 +191,26 @@ review-qa-all: review-performance: <<: *review-performance-base -review-stop: - <<: *review-base - extends: .single-script-job-dedicated-runner - image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-charts-build-base - allow_failure: true - variables: - SCRIPT_NAME: "review_apps/review-apps.sh" - when: manual - environment: - <<: *review-environment - action: stop - script: - - source $(basename "${SCRIPT_NAME}") - - delete - - cleanup +schedule:review-performance: + <<: *review-performance-base + <<: *review-schedules-only + dependencies: + - schedule:review-deploy schedule:review-cleanup: <<: *review-base <<: *review-schedules-only stage: build allow_failure: true - variables: - GIT_DEPTH: "1" environment: name: review/auto-cleanup + action: stop before_script: - - gem install gitlab --no-document + - source scripts/utils.sh + - install_gitlab_gem script: - ruby -rrubygems scripts/review_apps/automated_cleanup.rb -schedule:review-performance: - <<: *review-performance-base - <<: *review-schedules-only - script: - - wait_for_job_to_be_done "schedule:review-deploy" - danger-review: extends: .dedicated-pull-cache-job image: registry.gitlab.com/gitlab-org/gitlab-build-images:danger diff --git a/.stylelintrc b/.stylelintrc index 241d2c94a88..b0ace93e04f 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -2,7 +2,7 @@ "plugins":[ "./scripts/frontend/stylelint/stylelint-duplicate-selectors.js", "./scripts/frontend/stylelint/stylelint-utility-classes.js", - "stylelint-scss", + "stylelint-scss" ], "rules":{ "at-rule-blacklist":[ @@ -64,7 +64,7 @@ "number-leading-zero":"always", "number-no-trailing-zeros":true, "property-no-unknown":true, - "property-no-vendor-prefix":true, + "property-no-vendor-prefix": [true, { "ignoreProperties": ["user-select"] }], "rule-empty-line-before":[ "always-multi-line", { @@ -94,7 +94,7 @@ { "message":"Selector should be written in lowercase with hyphens (selector-class-pattern)", "severity": "warning" - }, + } ], "selector-list-comma-newline-after":"always", "selector-max-compound-selectors":[3, { "severity": "warning" }], @@ -104,8 +104,8 @@ "selector-pseudo-element-no-unknown":true, "shorthand-property-no-redundant-values":true, "string-quotes":"single", - "value-no-vendor-prefix":[true, { ignoreValues: ["sticky"] }], + "value-no-vendor-prefix":[true, { "ignoreValues": ["sticky"] }], "stylelint-gitlab/duplicate-selectors":[true,{ "severity": "warning" }], - "stylelint-gitlab/utility-classes":[true,{ "severity": "warning" }], + "stylelint-gitlab/utility-classes":[true,{ "severity": "warning" }] } } diff --git a/CHANGELOG.md b/CHANGELOG.md index bd4c0e479cc..8e1ffeaebd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 11.10.2 (2019-04-25) + +### Security (4 changes) + +- Loosen regex for exception sanitization. !3076 +- Resolve: moving an issue to private repo leaks namespace and project name. +- Escape path in new merge request mail. +- Stop sending emails to users who can't read commit. + + ## 11.10.1 (2019-04-23) ### Fixed (2 changes) @@ -253,6 +263,17 @@ entry. - Removes EE differences for environment_item.vue. +## 11.9.10 (2019-04-26) + +### Security (5 changes) + +- Loosen regex for exception sanitization. !3077 +- Resolve: moving an issue to private repo leaks namespace and project name. +- Escape path in new merge request mail. +- Stop sending emails to users who can't read commit. +- Upgrade Rails to 5.0.7.2. + + ## 11.9.9 (2019-04-23) ### Performance (1 change) diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 2b17ffd5042..39fc130ef85 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -1.34.0 +1.36.0 @@ -79,6 +79,7 @@ gem 'rack-cors', '~> 1.0.0', require: 'rack/cors' # GraphQL API gem 'graphql', '~> 1.8.0' gem 'graphiql-rails', '~> 1.4.10' +gem 'apollo_upload_server', '~> 2.0.0.beta3' # Disable strong_params so that Mash does not respond to :permitted? gem 'hashie-forbidden_attributes' @@ -284,7 +285,7 @@ gem 'gettext_i18n_rails', '~> 1.8.0' gem 'gettext_i18n_rails_js', '~> 1.3' gem 'gettext', '~> 3.2.2', require: false, group: :development -gem 'batch-loader', '~> 1.2.2' +gem 'batch-loader', '~> 1.4.0' # Perf bar gem 'peek', '~> 1.0.1' @@ -416,7 +417,7 @@ group :ed25519 do end # Gitaly GRPC client -gem 'gitaly-proto', '~> 1.19.0', require: 'gitaly' +gem 'gitaly-proto', '~> 1.22.0', require: 'gitaly' gem 'grpc', '~> 1.19.0' diff --git a/Gemfile.lock b/Gemfile.lock index 8626a11ad45..c5ad2357434 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -52,6 +52,9 @@ GEM public_suffix (>= 2.0.2, < 4.0) aes_key_wrap (1.0.1) akismet (2.0.0) + apollo_upload_server (2.0.0.beta.3) + graphql (>= 1.8) + rails (>= 4.2) arel (8.0.0) asana (0.8.1) faraday (~> 0.9) @@ -73,7 +76,7 @@ GEM thread_safe (~> 0.3, >= 0.3.1) babosa (1.0.2) base32 (0.3.2) - batch-loader (1.2.2) + batch-loader (1.4.0) bcrypt (3.1.12) bcrypt_pbkdf (1.0.0) benchmark-ips (2.3.0) @@ -280,7 +283,7 @@ GEM gettext_i18n_rails (>= 0.7.1) po_to_json (>= 1.0.0) rails (>= 3.2.0) - gitaly-proto (1.19.0) + gitaly-proto (1.22.0) grpc (~> 1.0) github-markup (1.7.0) gitlab-default_value_for (3.1.1) @@ -988,6 +991,7 @@ DEPENDENCIES acts-as-taggable-on (~> 6.0) addressable (~> 2.5.2) akismet (~> 2.0) + apollo_upload_server (~> 2.0.0.beta3) asana (~> 0.8.1) asciidoctor (~> 1.5.8) asciidoctor-plantuml (= 0.0.8) @@ -995,7 +999,7 @@ DEPENDENCIES awesome_print babosa (~> 1.0.2) base32 (~> 0.3.0) - batch-loader (~> 1.2.2) + batch-loader (~> 1.4.0) bcrypt_pbkdf (~> 1.0) benchmark-ips (~> 2.3.0) better_errors (~> 2.5.0) @@ -1052,7 +1056,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.3) - gitaly-proto (~> 1.19.0) + gitaly-proto (~> 1.22.0) github-markup (~> 1.7.0) gitlab-default_value_for (~> 3.1.1) gitlab-labkit (~> 0.1.2) diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 8754c253881..e583a8affd4 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import _ from 'underscore'; import axios from './lib/utils/axios_utils'; +import { joinPaths } from './lib/utils/url_utility'; const Api = { groupsPath: '/api/:version/groups.json', @@ -339,11 +340,7 @@ const Api = { }, buildUrl(url) { - let urlRoot = ''; - if (gon.relative_url_root != null) { - urlRoot = gon.relative_url_root; - } - return urlRoot + url.replace(':version', gon.api_version); + return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version)); }, }; diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index c9effa0639b..b8882203cc7 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -83,7 +83,7 @@ export default { }" :index="index" :data-issue-id="issue.id" - class="board-card position-relative p-3 rounded" + class="board-card p-3 rounded" @mousedown="mouseDown" @mousemove="mouseMove" @mouseup="showIssue($event)" diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 6aa689b4056..17de7b2cf1e 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -168,7 +168,7 @@ export default { </script> <template> <div> - <div class="d-flex board-card-header"> + <div class="d-flex board-card-header" dir="auto"> <h4 class="board-card-title append-bottom-0 prepend-top-0"> <icon v-if="issue.confidential" diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index df855261b3c..8461e01de7b 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -1,25 +1,20 @@ import Visibility from 'visibilityjs'; import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; import PersistentUserCallout from '../persistent_user_callout'; import { s__, sprintf } from '../locale'; import Flash from '../flash'; import Poll from '../lib/utils/poll'; import initSettingsPanels from '../settings_panels'; import eventHub from './event_hub'; -import { - APPLICATION_STATUS, - REQUEST_SUBMITTED, - REQUEST_FAILURE, - UPGRADE_REQUESTED, - UPGRADE_REQUEST_FAILURE, - INGRESS, - INGRESS_DOMAIN_SUFFIX, -} from './constants'; +import { APPLICATION_STATUS, INGRESS, INGRESS_DOMAIN_SUFFIX } 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'; +Vue.use(GlToast); + /** * Cluster page has 2 separate parts: * Toggle button and applications section @@ -134,7 +129,6 @@ export default class Clusters { if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken); eventHub.$on('installApplication', this.installApplication); eventHub.$on('upgradeApplication', data => this.upgradeApplication(data)); - eventHub.$on('upgradeFailed', appId => this.upgradeFailed(appId)); eventHub.$on('dismissUpgradeSuccess', appId => this.dismissUpgradeSuccess(appId)); eventHub.$on('saveKnativeDomain', data => this.saveKnativeDomain(data)); eventHub.$on('setKnativeHostname', data => this.setKnativeHostname(data)); @@ -144,7 +138,6 @@ export default class Clusters { if (this.showTokenButton) this.showTokenButton.removeEventListener('click', this.showToken); eventHub.$off('installApplication', this.installApplication); eventHub.$off('upgradeApplication', this.upgradeApplication); - eventHub.$off('upgradeFailed', this.upgradeFailed); eventHub.$off('dismissUpgradeSuccess', this.dismissUpgradeSuccess); eventHub.$off('saveKnativeDomain'); eventHub.$off('setKnativeHostname'); @@ -258,12 +251,13 @@ export default class Clusters { installApplication(data) { const appId = data.id; - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUBMITTED); this.store.updateAppProperty(appId, 'requestReason', null); this.store.updateAppProperty(appId, 'statusReason', null); + this.store.installApplication(appId); + return this.service.installApplication(appId, data.params).catch(() => { - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE); + this.store.notifyInstallFailure(appId); this.store.updateAppProperty( appId, 'requestReason', @@ -274,27 +268,21 @@ export default class Clusters { upgradeApplication(data) { const appId = data.id; - this.store.updateAppProperty(appId, 'requestStatus', UPGRADE_REQUESTED); - this.store.updateAppProperty(appId, 'status', APPLICATION_STATUS.UPDATING); - this.service.installApplication(appId, data.params).catch(() => this.upgradeFailed(appId)); - } - upgradeFailed(appId) { - this.store.updateAppProperty(appId, 'requestStatus', UPGRADE_REQUEST_FAILURE); + this.store.updateApplication(appId); + this.service.installApplication(appId, data.params).catch(() => { + this.store.notifyUpdateFailure(appId); + }); } dismissUpgradeSuccess(appId) { - this.store.updateAppProperty(appId, 'requestStatus', null); + this.store.acknowledgeSuccessfulUpdate(appId); } - toggleIngressDomainHelpText(ingressPreviousState, ingressNewState) { - const { externalIp, status } = ingressNewState; - const helpTextHidden = status !== APPLICATION_STATUS.INSTALLED || !externalIp; - const domainSnippetText = `${externalIp}${INGRESS_DOMAIN_SUFFIX}`; - - if (ingressPreviousState.status !== status) { - this.ingressDomainHelpText.classList.toggle('hide', helpTextHidden); - this.ingressDomainSnippet.textContent = domainSnippetText; + toggleIngressDomainHelpText({ externalIp }, { externalIp: newExternalIp }) { + if (externalIp !== newExternalIp) { + this.ingressDomainHelpText.classList.toggle('hide', !newExternalIp); + this.ingressDomainSnippet.textContent = `${newExternalIp}${INGRESS_DOMAIN_SUFFIX}`; } } diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index 937e4c3bfc3..a351916942e 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -8,12 +8,7 @@ import identicon from '../../vue_shared/components/identicon.vue'; import loadingButton from '../../vue_shared/components/loading_button.vue'; import UninstallApplicationButton from './uninstall_application_button.vue'; -import { - APPLICATION_STATUS, - REQUEST_SUBMITTED, - REQUEST_FAILURE, - UPGRADE_REQUESTED, -} from '../constants'; +import { APPLICATION_STATUS } from '../constants'; export default { components: { @@ -63,10 +58,6 @@ export default { type: String, required: false, }, - requestStatus: { - type: String, - required: false, - }, requestReason: { type: String, required: false, @@ -76,6 +67,11 @@ export default { required: false, default: false, }, + installFailed: { + type: Boolean, + required: false, + default: false, + }, version: { type: String, required: false, @@ -88,6 +84,21 @@ export default { type: Boolean, required: false, }, + updateSuccessful: { + type: Boolean, + required: false, + default: false, + }, + updateFailed: { + type: Boolean, + required: false, + default: false, + }, + updateAcknowledged: { + type: Boolean, + required: false, + default: true, + }, installApplicationRequestParams: { type: Object, required: false, @@ -102,21 +113,12 @@ export default { return Object.values(APPLICATION_STATUS).includes(this.status); }, isInstalling() { - return ( - this.status === APPLICATION_STATUS.SCHEDULED || - this.status === APPLICATION_STATUS.INSTALLING || - (this.requestStatus === REQUEST_SUBMITTED && !this.statusReason && !this.installed) - ); + return this.status === APPLICATION_STATUS.INSTALLING; }, canInstall() { - if (this.isInstalling) { - return false; - } - return ( this.status === APPLICATION_STATUS.NOT_INSTALLABLE || this.status === APPLICATION_STATUS.INSTALLABLE || - this.status === APPLICATION_STATUS.ERROR || this.isUnknownStatus ); }, @@ -137,7 +139,7 @@ export default { return !this.installed || !this.uninstallable; }, installButtonLoading() { - return !this.status || this.status === APPLICATION_STATUS.SCHEDULED || this.isInstalling; + return !this.status || this.isInstalling; }, installButtonDisabled() { // Avoid the potential for the real-time data to say APPLICATION_STATUS.INSTALLABLE but @@ -168,19 +170,13 @@ export default { manageButtonLabel() { return s__('ClusterIntegration|Manage'); }, - hasError() { - return ( - !this.isInstalling && - (this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE) - ); - }, generalErrorDescription() { return sprintf(s__('ClusterIntegration|Something went wrong while installing %{title}'), { title: this.title, }); }, versionLabel() { - if (this.upgradeFailed) { + if (this.updateFailed) { return s__('ClusterIntegration|Upgrade failed'); } else if (this.isUpgrading) { return s__('ClusterIntegration|Upgrading'); @@ -188,19 +184,6 @@ export default { return s__('ClusterIntegration|Upgraded'); }, - upgradeRequested() { - return this.requestStatus === UPGRADE_REQUESTED; - }, - upgradeSuccessful() { - return this.status === APPLICATION_STATUS.UPDATED; - }, - upgradeFailed() { - if (this.isUpgrading) { - return false; - } - - return this.status === APPLICATION_STATUS.UPDATE_ERRORED; - }, upgradeFailureDescription() { return s__('ClusterIntegration|Update failed. Please check the logs and try again.'); }, @@ -211,11 +194,11 @@ export default { }, upgradeButtonLabel() { let label; - if (this.upgradeAvailable && !this.upgradeFailed && !this.isUpgrading) { + if (this.upgradeAvailable && !this.updateFailed && !this.isUpgrading) { label = s__('ClusterIntegration|Upgrade'); } else if (this.isUpgrading) { label = s__('ClusterIntegration|Updating'); - } else if (this.upgradeFailed) { + } else if (this.updateFailed) { label = s__('ClusterIntegration|Retry update'); } @@ -223,24 +206,19 @@ export default { }, isUpgrading() { // Since upgrading is handled asynchronously on the backend we need this check to prevent any delay on the frontend - return ( - this.status === APPLICATION_STATUS.UPDATING || - (this.upgradeRequested && !this.upgradeSuccessful) - ); + return this.status === APPLICATION_STATUS.UPDATING; }, shouldShowUpgradeDetails() { // This method only returns true when; // Upgrade was successful OR Upgrade failed // AND new upgrade is unavailable AND version information is present. - return ( - (this.upgradeSuccessful || this.upgradeFailed) && !this.upgradeAvailable && this.version - ); + return (this.updateSuccessful || this.updateFailed) && !this.upgradeAvailable && this.version; }, }, watch: { - status() { - if (this.status === APPLICATION_STATUS.UPDATE_ERRORED) { - eventHub.$emit('upgradeFailed', this.id); + updateSuccessful() { + if (this.updateSuccessful) { + this.$toast.show(this.upgradeSuccessDescription); } }, }, @@ -257,9 +235,6 @@ export default { params: this.installApplicationRequestParams, }); }, - dismissUpgradeSuccess() { - eventHub.$emit('dismissUpgradeSuccess', this.id); - }, }, }; </script> @@ -297,7 +272,7 @@ export default { </strong> <slot name="description"></slot> <div - v-if="hasError || isUnknownStatus" + v-if="installFailed || isUnknownStatus" class="cluster-application-error text-danger prepend-top-10" > <p class="js-cluster-application-general-error-message append-bottom-0"> @@ -318,10 +293,10 @@ export default { class="form-text text-muted label p-0 js-cluster-application-upgrade-details" > {{ versionLabel }} - <span v-if="upgradeSuccessful">to</span> + <span v-if="updateSuccessful">to</span> <gl-link - v-if="upgradeSuccessful" + v-if="updateSuccessful" :href="chartRepo" target="_blank" class="js-cluster-application-upgrade-version" @@ -330,24 +305,13 @@ export default { </div> <div - v-if="upgradeFailed && !isUpgrading" + v-if="updateFailed && !isUpgrading" class="bs-callout bs-callout-danger cluster-application-banner mt-2 mb-0 js-cluster-application-upgrade-failure-message" > {{ upgradeFailureDescription }} </div> - - <div - v-if="upgradeRequested && upgradeSuccessful" - class="bs-callout bs-callout-success cluster-application-banner mt-2 mb-0 p-0 pl-3" - > - {{ upgradeSuccessDescription }} - <button class="close cluster-application-banner-close" @click="dismissUpgradeSuccess"> - × - </button> - </div> - <loading-button - v-if="upgradeAvailable || upgradeFailed || isUpgrading" + v-if="upgradeAvailable || updateFailed || isUpgrading" class="btn btn-primary js-cluster-application-upgrade-button mt-2" :loading="isUpgrading" :disabled="isUpgrading" @@ -361,9 +325,9 @@ export default { role="gridcell" > <div v-if="showManageButton" class="btn-group table-action-buttons"> - <a :href="manageLink" :class="{ disabled: disabled }" class="btn">{{ - manageButtonLabel - }}</a> + <a :href="manageLink" :class="{ disabled: disabled }" class="btn"> + {{ manageButtonLabel }} + </a> </div> <div class="btn-group table-action-buttons"> <loading-button diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index ae4fe11c6ae..dfc2069f131 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -224,9 +224,9 @@ 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> + <a :href="helpPath">{{ __('More information') }}</a> </p> <div class="cluster-application-list prepend-top-10"> @@ -239,15 +239,16 @@ export default { :request-status="applications.helm.requestStatus" :request-reason="applications.helm.requestReason" :installed="applications.helm.installed" + :install-failed="applications.helm.installFailed" class="rounded-top" title-link="https://docs.helm.sh/" > <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> @@ -255,7 +256,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 @@ -267,6 +268,7 @@ export default { :request-status="applications.ingress.requestStatus" :request-reason="applications.ingress.requestReason" :installed="applications.ingress.installed" + :install-failed="applications.ingress.installFailed" :disabled="!helmInstalled" title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/" > @@ -274,16 +276,14 @@ 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> <template v-if="ingressInstalled"> <div class="form-group"> - <label for="ingress-endpoint"> - {{ s__('ClusterIntegration|Ingress Endpoint') }} - </label> + <label for="ingress-endpoint">{{ s__('ClusterIntegration|Ingress Endpoint') }}</label> <div v-if="ingressExternalEndpoint" class="input-group"> <input id="ingress-endpoint" @@ -309,8 +309,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') }} @@ -321,10 +321,9 @@ 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') }} </a> @@ -344,6 +343,7 @@ export default { :request-status="applications.cert_manager.requestStatus" :request-reason="applications.cert_manager.requestReason" :installed="applications.cert_manager.installed" + :install-failed="applications.cert_manager.installFailed" :install-application-request-params="{ email: applications.cert_manager.email }" :disabled="!helmInstalled" title-link="https://cert-manager.readthedocs.io/en/latest/#" @@ -366,15 +366,14 @@ 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" target="_blank" rel="noopener noreferrer" + >{{ __('More information') }}</a > - {{ __('More information') }} - </a> </p> </div> </div> @@ -391,6 +390,7 @@ export default { :request-status="applications.prometheus.requestStatus" :request-reason="applications.prometheus.requestReason" :installed="applications.prometheus.installed" + :install-failed="applications.prometheus.installFailed" :disabled="!helmInstalled" title-link="https://prometheus.io/docs/introduction/overview/" > @@ -408,15 +408,18 @@ export default { :chart-repo="applications.runner.chartRepo" :upgrade-available="applications.runner.upgradeAvailable" :installed="applications.runner.installed" + :install-failed="applications.runner.installFailed" + :update-successful="applications.runner.updateSuccessful" + :update-failed="applications.runner.updateFailed" :disabled="!helmInstalled" title-link="https://docs.gitlab.com/runner/" > <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> @@ -430,6 +433,7 @@ export default { :request-status="applications.jupyter.requestStatus" :request-reason="applications.jupyter.requestReason" :installed="applications.jupyter.installed" + :install-failed="applications.jupyter.installFailed" :install-application-request-params="{ hostname: applications.jupyter.hostname }" :disabled="!helmInstalled" title-link="https://jupyterhub.readthedocs.io/en/stable/" @@ -438,18 +442,16 @@ 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> <template v-if="ingressExternalEndpoint"> <div class="form-group"> - <label for="jupyter-hostname"> - {{ s__('ClusterIntegration|Jupyter Hostname') }} - </label> + <label for="jupyter-hostname">{{ s__('ClusterIntegration|Jupyter Hostname') }}</label> <div class="input-group"> <input @@ -470,7 +472,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') }} @@ -490,8 +492,10 @@ export default { :request-status="applications.knative.requestStatus" :request-reason="applications.knative.requestReason" :installed="applications.knative.installed" + :install-failed="applications.knative.installFailed" :install-application-request-params="{ hostname: applications.knative.hostname }" :disabled="!helmInstalled" + v-bind="applications.knative" title-link="https://github.com/knative/docs" > <div slot="description"> @@ -499,7 +503,7 @@ export default { <p v-if="!rbac" class="rbac-notice bs-callout bs-callout-info append-bottom-0"> {{ s__(`ClusterIntegration|You must have an RBAC-enabled cluster - to install Knative.`) + to install Knative.`) }} <a :href="helpPath" target="_blank" rel="noopener noreferrer"> {{ __('More information') }} @@ -510,9 +514,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> @@ -523,9 +527,7 @@ export default { class="form-group col-sm-12 mb-0" > <label for="knative-domainname"> - <strong> - {{ s__('ClusterIntegration|Knative Domain Name:') }} - </strong> + <strong>{{ s__('ClusterIntegration|Knative Domain Name:') }}</strong> </label> <input id="knative-domainname" @@ -538,9 +540,7 @@ export default { <template v-if="knativeInstalled"> <div class="form-group col-sm-12 col-md-6 pl-md-0 mb-0 mt-3 mt-md-0"> <label for="knative-endpoint"> - <strong> - {{ s__('ClusterIntegration|Knative Endpoint:') }} - </strong> + <strong>{{ s__('ClusterIntegration|Knative Endpoint:') }}</strong> </label> <div v-if="knativeExternalEndpoint" class="input-group"> <input @@ -583,8 +583,8 @@ export default { > {{ 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.`) }} </p> diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index 17849497c87..48dbce9676e 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -7,6 +7,7 @@ export const CLUSTER_TYPE = { // These need to match what is returned from the server export const APPLICATION_STATUS = { + NO_STATUS: null, NOT_INSTALLABLE: 'not_installable', INSTALLABLE: 'installable', SCHEDULED: 'scheduled', @@ -27,17 +28,13 @@ export const APPLICATION_STATUS = { export const APPLICATION_INSTALLED_STATUSES = [ APPLICATION_STATUS.INSTALLED, APPLICATION_STATUS.UPDATING, - APPLICATION_STATUS.UPDATED, - APPLICATION_STATUS.UPDATE_ERRORED, - APPLICATION_STATUS.UNINSTALLING, - APPLICATION_STATUS.UNINSTALL_ERRORED, ]; // These are only used client-side -export const REQUEST_SUBMITTED = 'request-submitted'; -export const REQUEST_FAILURE = 'request-failure'; -export const UPGRADE_REQUESTED = 'upgrade-requested'; -export const UPGRADE_REQUEST_FAILURE = 'upgrade-request-failure'; + +export const UPDATE_EVENT = 'update'; +export const INSTALL_EVENT = 'install'; + export const INGRESS = 'ingress'; export const JUPYTER = 'jupyter'; export const KNATIVE = 'knative'; diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js new file mode 100644 index 00000000000..aafb2350ae4 --- /dev/null +++ b/app/assets/javascripts/clusters/services/application_state_machine.js @@ -0,0 +1,141 @@ +import { APPLICATION_STATUS, UPDATE_EVENT, INSTALL_EVENT } from '../constants'; + +const { + NO_STATUS, + SCHEDULED, + NOT_INSTALLABLE, + INSTALLABLE, + INSTALLING, + INSTALLED, + ERROR, + UPDATING, + UPDATED, + UPDATE_ERRORED, +} = APPLICATION_STATUS; + +const applicationStateMachine = { + /* When the application initially loads, it will have `NO_STATUS` + * It will transition from `NO_STATUS` once the async backend call is completed + */ + [NO_STATUS]: { + on: { + [SCHEDULED]: { + target: INSTALLING, + }, + [NOT_INSTALLABLE]: { + target: NOT_INSTALLABLE, + }, + [INSTALLABLE]: { + target: INSTALLABLE, + }, + [INSTALLING]: { + target: INSTALLING, + }, + [INSTALLED]: { + target: INSTALLED, + }, + [ERROR]: { + target: INSTALLABLE, + effects: { + installFailed: true, + }, + }, + [UPDATING]: { + target: UPDATING, + }, + [UPDATED]: { + target: INSTALLED, + }, + [UPDATE_ERRORED]: { + target: INSTALLED, + effects: { + updateFailed: true, + }, + }, + }, + }, + [NOT_INSTALLABLE]: { + on: { + [INSTALLABLE]: { + target: INSTALLABLE, + }, + }, + }, + [INSTALLABLE]: { + on: { + [INSTALL_EVENT]: { + target: INSTALLING, + effects: { + installFailed: false, + }, + }, + // This is possible in artificial environments for E2E testing + [INSTALLED]: { + target: INSTALLED, + }, + }, + }, + [INSTALLING]: { + on: { + [INSTALLED]: { + target: INSTALLED, + }, + [ERROR]: { + target: INSTALLABLE, + effects: { + installFailed: true, + }, + }, + }, + }, + [INSTALLED]: { + on: { + [UPDATE_EVENT]: { + target: UPDATING, + effects: { + updateFailed: false, + updateSuccessful: false, + }, + }, + }, + }, + [UPDATING]: { + on: { + [UPDATED]: { + target: INSTALLED, + effects: { + updateSuccessful: true, + updateAcknowledged: false, + }, + }, + [UPDATE_ERRORED]: { + target: INSTALLED, + effects: { + updateFailed: true, + }, + }, + }, + }, +}; + +/** + * Determines an application new state based on the application current state + * and an event. If the application current state cannot handle a given event, + * the current state is returned. + * + * @param {*} application + * @param {*} event + */ +const transitionApplicationState = (application, event) => { + const newState = applicationStateMachine[application.status].on[event]; + + return newState + ? { + ...application, + status: newState.target, + ...newState.effects, + } + : application; +}; + +export default transitionApplicationState; diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 38512ac28c2..c2e30960659 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -7,7 +7,11 @@ import { CERT_MANAGER, RUNNER, APPLICATION_INSTALLED_STATUSES, + APPLICATION_STATUS, + INSTALL_EVENT, + UPDATE_EVENT, } from '../constants'; +import transitionApplicationState from '../services/application_state_machine'; const isApplicationInstalled = appStatus => APPLICATION_INSTALLED_STATUSES.includes(appStatus); @@ -15,8 +19,8 @@ const applicationInitialState = { status: null, statusReason: null, requestReason: null, - requestStatus: null, installed: false, + installFailed: false, }; export default class ClusterStore { @@ -49,6 +53,9 @@ export default class ClusterStore { version: null, chartRepo: 'https://gitlab.com/charts/gitlab-runner', upgradeAvailable: null, + updateAcknowledged: true, + updateSuccessful: false, + updateFailed: false, }, prometheus: { ...applicationInitialState, @@ -93,6 +100,32 @@ export default class ClusterStore { this.state.statusReason = reason; } + installApplication(appId) { + this.handleApplicationEvent(appId, INSTALL_EVENT); + } + + notifyInstallFailure(appId) { + this.handleApplicationEvent(appId, APPLICATION_STATUS.ERROR); + } + + updateApplication(appId) { + this.handleApplicationEvent(appId, UPDATE_EVENT); + } + + notifyUpdateFailure(appId) { + this.handleApplicationEvent(appId, APPLICATION_STATUS.UPDATE_ERRORED); + } + + handleApplicationEvent(appId, event) { + const currentAppState = this.state.applications[appId]; + + this.state.applications[appId] = transitionApplicationState(currentAppState, event); + } + + acknowledgeSuccessfulUpdate(appId) { + this.state.applications[appId].updateAcknowledged = true; + } + updateAppProperty(appId, prop, value) { this.state.applications[appId][prop] = value; } @@ -109,12 +142,16 @@ export default class ClusterStore { version, update_available: upgradeAvailable, } = serverAppEntry; + const currentApplicationState = this.state.applications[appId] || {}; + const nextApplicationState = transitionApplicationState(currentApplicationState, status); this.state.applications[appId] = { - ...(this.state.applications[appId] || {}), - status, + ...currentApplicationState, + ...nextApplicationState, statusReason, - installed: isApplicationInstalled(status), + installed: isApplicationInstalled(nextApplicationState.status), + // Make sure uninstallable is always false until this feature is unflagged + uninstallable: false, }; if (appId === INGRESS) { diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index 4de425b48e7..3f0a9f2602c 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -12,6 +12,7 @@ import stageStagingComponent from './components/stage_staging_component.vue'; import stageTestComponent from './components/stage_test_component.vue'; import CycleAnalyticsService from './cycle_analytics_service'; import CycleAnalyticsStore from './cycle_analytics_store'; +import { __ } from '~/locale'; Vue.use(Translate); @@ -61,7 +62,7 @@ export default () => { methods: { handleError() { this.store.setErrorState(true); - return new Flash('There was an error while fetching cycle analytics data.'); + return new Flash(__('There was an error while fetching cycle analytics data.')); }, initDropdown() { const $dropdown = $('.js-ca-dropdown'); diff --git a/app/assets/javascripts/diffs/workers/tree_worker.js b/app/assets/javascripts/diffs/workers/tree_worker.js index 534d737c77e..415c463fd19 100644 --- a/app/assets/javascripts/diffs/workers/tree_worker.js +++ b/app/assets/javascripts/diffs/workers/tree_worker.js @@ -4,6 +4,11 @@ import { generateTreeList } from '../store/utils'; // eslint-disable-next-line no-restricted-globals self.addEventListener('message', e => { const { data } = e; + + if (data === undefined) { + return; + } + const { treeEntries, tree } = generateTreeList(data); // eslint-disable-next-line no-restricted-globals diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index f1e26cdfa21..f437954881c 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -477,6 +477,16 @@ class GfmAutoComplete { } return null; }, + highlighter(li, query) { + // override default behaviour to escape dot character + // see https://github.com/ichord/At.js/pull/576 + if (!query) { + return li; + } + const escapedQuery = query.replace(/[.+]/, '\\$&'); + const regexp = new RegExp(`>\\s*([^<]*?)(${escapedQuery})([^<]*)\\s*<`, 'ig'); + return li.replace(regexp, (str, $1, $2, $3) => `> ${$1}<strong>${$2}</strong>${$3} <`); + }, }; } diff --git a/app/assets/javascripts/groups/transfer_dropdown.js b/app/assets/javascripts/groups/transfer_dropdown.js index 26510fcdb2a..ce0c9256148 100644 --- a/app/assets/javascripts/groups/transfer_dropdown.js +++ b/app/assets/javascripts/groups/transfer_dropdown.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { __ } from '~/locale'; export default class TransferDropdown { constructor() { @@ -13,7 +14,7 @@ export default class TransferDropdown { } buildDropdown() { - const extraOptions = [{ id: '', text: 'No parent group' }, 'divider']; + const extraOptions = [{ id: '', text: __('No parent group') }, 'divider']; this.groupDropdown.glDropdown({ selectable: true, diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue index 00b2d236da3..6b0aa5b2b2b 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue @@ -108,6 +108,7 @@ export default { :placeholder="placeholder" :value="text" class="note-textarea ide-commit-message-textarea" + dir="auto" name="commit-message" @scroll="handleScroll" @input="onInput" diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index 229ef168926..518a9cf7a0f 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import VueRouter from 'vue-router'; -import { join as joinPath } from 'path'; +import { joinPaths } from '~/lib/utils/url_utility'; import flash from '~/flash'; import store from './stores'; @@ -34,7 +34,7 @@ const EmptyRouterComponent = { const router = new VueRouter({ mode: 'history', - base: `${gon.relative_url_root}/-/ide/`, + base: joinPaths(gon.relative_url_root || '', '/-/ide/'), routes: [ { path: '/project/:namespace+/:project', @@ -46,11 +46,11 @@ const router = new VueRouter({ }, { path: ':targetmode(edit|tree|blob)/:branchid+/', - redirect: to => joinPath(to.path, '/-/'), + redirect: to => joinPaths(to.path, '/-/'), }, { path: ':targetmode(edit|tree|blob)', - redirect: to => joinPath(to.path, '/master/-/'), + redirect: to => joinPaths(to.path, '/master/-/'), }, { path: 'merge_requests/:mrid', @@ -58,7 +58,7 @@ const router = new VueRouter({ }, { path: '', - redirect: to => joinPath(to.path, '/edit/master/-/'), + redirect: to => joinPaths(to.path, '/edit/master/-/'), }, ], }, diff --git a/app/assets/javascripts/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_projects/components/import_projects_table.vue index 777f8fa6691..00eb0afb3bf 100644 --- a/app/assets/javascripts/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_projects/components/import_projects_table.vue @@ -74,7 +74,7 @@ export default { <gl-loading-icon v-if="isLoadingRepos" class="js-loading-button-icon import-projects-loading-icon" - :size="4" + size="md" /> <div v-else-if="hasProviderRepos || hasImportedProjects" class="table-responsive"> <table class="table import-table"> diff --git a/app/assets/javascripts/import_projects/store/index.js b/app/assets/javascripts/import_projects/store/index.js index f666e2ebf33..ff1fd1e598e 100644 --- a/app/assets/javascripts/import_projects/store/index.js +++ b/app/assets/javascripts/import_projects/store/index.js @@ -7,6 +7,8 @@ import mutations from './mutations'; Vue.use(Vuex); +export { state, actions, getters, mutations }; + export default () => new Vuex.Store({ state: state(), diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index 385e9543973..f2462e50093 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -149,6 +149,7 @@ export default { v-model="descriptionText" :data-update-url="updateUrl" class="hidden js-task-list-field" + dir="auto" > </textarea> diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue index 299130e56ae..d27dd873125 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -53,6 +53,7 @@ export default { v-model="formState.description" class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea" + dir="auto" data-supports-quick-actions="false" aria-label="Description" placeholder="Write a comment or drag your files here…" diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issue_show/components/fields/title.vue index a3371cb9614..ce4baf17d09 100644 --- a/app/assets/javascripts/issue_show/components/fields/title.vue +++ b/app/assets/javascripts/issue_show/components/fields/title.vue @@ -20,6 +20,7 @@ export default { ref="input" v-model="formState.title" class="form-control qa-title-input" + dir="auto" type="text" placeholder="Title" aria-label="Title" diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue index 3b5c95ccded..d2f33dc31a7 100644 --- a/app/assets/javascripts/issue_show/components/title.vue +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -72,6 +72,7 @@ export default { 'issue-realtime-trigger-pulse': pulseAnimation, }" class="title" + dir="auto" v-html="titleHtml" ></h2> <button diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index ae02559415c..498c2348ca2 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -3,10 +3,17 @@ import { InMemoryCache } from 'apollo-cache-inmemory'; import { createUploadLink } from 'apollo-upload-client'; import csrf from '~/lib/utils/csrf'; -export default (resolvers = {}) => - new ApolloClient({ +export default (resolvers = {}, baseUrl = '') => { + let uri = `${gon.relative_url_root}/api/graphql`; + + if (baseUrl) { + // Prepend baseUrl and ensure that `///` are replaced with `/` + uri = `${baseUrl}${uri}`.replace(/\/{3,}/g, '/'); + } + + return new ApolloClient({ link: createUploadLink({ - uri: `${gon.relative_url_root}/api/graphql`, + uri, headers: { [csrf.headerKey]: csrf.token, }, @@ -14,3 +21,4 @@ export default (resolvers = {}) => cache: new InMemoryCache(), resolvers, }); +}; diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 4ba84589705..bdfd06fc250 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -120,3 +120,5 @@ export function webIDEUrl(route = undefined) { } return returnUrl; } + +export { join as joinPaths } from 'path'; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 1b722c0505a..a2ca4b07a66 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -135,6 +135,12 @@ function deferredInitialisation() { }); loadAwardsHandler(); + + // Toggle Canary Badge + if (Cookies.get('gitlab_canary') && Cookies.get('gitlab_canary') === 'true') { + document.querySelector('.js-canary-badge').classList.remove('hidden'); + document.querySelector('.js-canary-link').classList.add('hidden'); + } } document.addEventListener('DOMContentLoaded', () => { diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js index 547c078ec55..f7e80950803 100644 --- a/app/assets/javascripts/mirrors/ssh_mirror.js +++ b/app/assets/javascripts/mirrors/ssh_mirror.js @@ -290,7 +290,7 @@ export default class SSHMirror { this.setSSHPublicKey(data.import_data_attributes.ssh_public_key); }) .catch(() => { - Flash(_('Unable to regenerate public ssh key.')); + Flash(__('Unable to regenerate public ssh key.')); }); } diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index f2bd4150b6d..00547abd7bc 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -221,6 +221,7 @@ export default { <gl-dropdown-item v-for="environment in store.environmentsData" :key="environment.id" + :href="environment.metrics_path" :active="environment.name === currentEnvironmentName" active-class="is-active" >{{ environment.name }}</gl-dropdown-item diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index ed794779ff2..08dc57d545c 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; -import Dashboard from './components/dashboard.vue'; +import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue'; export default (props = {}) => { const el = document.getElementById('prometheus-graphs'); diff --git a/app/assets/javascripts/monitoring/services/monitoring_service.js b/app/assets/javascripts/monitoring/services/monitoring_service.js index 5fcc2c8cfac..1efa5189996 100644 --- a/app/assets/javascripts/monitoring/services/monitoring_service.js +++ b/app/assets/javascripts/monitoring/services/monitoring_service.js @@ -1,7 +1,7 @@ import axios from '../../lib/utils/axios_utils'; import statusCodes from '../../lib/utils/http_status'; import { backOff } from '../../lib/utils/common_utils'; -import { s__ } from '../../locale'; +import { s__, __ } from '../../locale'; const MAX_REQUESTS = 3; @@ -15,7 +15,7 @@ function backOffRequest(makeRequestCallback) { if (requestCounter < MAX_REQUESTS) { next(); } else { - stop(new Error('Failed to connect to the prometheus server')); + stop(new Error(__('Failed to connect to the prometheus server'))); } } else { stop(resp); diff --git a/app/assets/javascripts/mr_popover/constants.js b/app/assets/javascripts/mr_popover/constants.js index 433df844c80..c13c417cc18 100644 --- a/app/assets/javascripts/mr_popover/constants.js +++ b/app/assets/javascripts/mr_popover/constants.js @@ -1,10 +1,12 @@ +import { __ } from '~/locale'; + export const mrStates = { merged: 'merged', closed: 'closed', }; export const humanMRStates = { - merged: 'Merged', - closed: 'Closed', - open: 'Open', + merged: __('Merged'), + closed: __('Closed'), + open: __('Open'), }; diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index caf22f71bf9..688c06878ac 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -351,6 +351,7 @@ Please check your network connection and try again.`; ref="textarea" slot="textarea" v-model="note" + dir="auto" :disabled="isSubmitting" name="note[note]" class="note-textarea js-vue-comment-form js-note-text diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index fbf75ed0e41..8ddd5b8514a 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -122,6 +122,7 @@ export default { v-model="note.note" :data-update-url="note.path" class="hidden js-task-list-field" + dir="auto" ></textarea> <note-edited-text v-if="note.last_edited_at" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 471323bfc83..fb098095cf3 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -268,6 +268,7 @@ export default { :data-supports-quick-actions="!isEditing" name="note[note]" class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form js-vue-textarea qa-reply-input" + dir="auto" aria-label="Description" placeholder="Write a comment or drag your files here…" @keydown.meta.enter="handleKeySubmit()" diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 61204c37307..4a20753e7ae 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -65,18 +65,18 @@ export default class ActivityCalendar { this.daySize = 15; this.daySizeWithSpace = this.daySize + this.daySpace * 2; this.monthNames = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', + __('Jan'), + __('Feb'), + __('Mar'), + __('Apr'), + __('May'), + __('Jun'), + __('Jul'), + __('Aug'), + __('Sep'), + __('Oct'), + __('Nov'), + __('Dec'), ]; this.months = []; this.firstDayOfWeek = firstDayOfWeek; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 6660f8120f8..b8976f77bac 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -34,6 +34,7 @@ export default () => { props: { isLoading: this.mediator.state.isLoading, pipeline: this.mediator.store.state.pipeline, + mediator: this.mediator, }, on: { refreshPipelineGraph: this.requestRefreshPipelineGraph, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue index a38f25cce35..acd8037cfb2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue @@ -30,6 +30,7 @@ export default { :id="inputId" :value="value" class="form-control js-gfm-input append-bottom-default commit-message-edit" + dir="auto" required="required" rows="7" @input="$emit('input', $event.target.value)" diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index aa4ecb0aac3..705ee05e29f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -119,7 +119,8 @@ export default { }, showTargetBranchAdvancedError() { return Boolean( - this.mr.pipeline && + this.mr.isOpen && + this.mr.pipeline && this.mr.pipeline.target_sha && this.mr.pipeline.target_sha !== this.mr.targetBranchSha, ); diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js index f01a51da0b3..ba63683f5c0 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js +++ b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js @@ -1,10 +1,12 @@ +import { __ } from '~/locale'; + const viewers = { image: { id: 'image', }, markdown: { id: 'markdown', - previewTitle: 'Preview Markdown', + previewTitle: __('Preview Markdown'), }, }; diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue index 1f9670cf2fc..53e6efa6ea3 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue @@ -17,15 +17,13 @@ export default { required: true, }, }, - data() { - return { - milestoneDue: this.milestone.due_date ? parsePikadayDate(this.milestone.due_date) : null, - milestoneStart: this.milestone.start_date - ? parsePikadayDate(this.milestone.start_date) - : null, - }; - }, computed: { + milestoneDue() { + return this.milestone.due_date ? parsePikadayDate(this.milestone.due_date) : null; + }, + milestoneStart() { + return this.milestone.start_date ? parsePikadayDate(this.milestone.start_date) : null; + }, isMilestoneStarted() { if (!this.milestoneStart) { return false; diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue index ffde55bf083..b807a35b421 100644 --- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue +++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue @@ -1,4 +1,5 @@ <script> +import '~/commons/bootstrap'; import { GlTooltipDirective } from '@gitlab/ui'; import { sprintf } from '~/locale'; import IssueMilestone from '../../components/issue/issue_milestone.vue'; diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue index cafd3a515ea..c09bdfec250 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue @@ -17,10 +17,10 @@ export default { <template> <tr class="line_holder" :class="lineType"> - <td class="diff-line-num old_line" :class="lineType"> + <td class="diff-line-num old_line border-top-0 border-bottom-0" :class="lineType"> {{ line.old_line }} </td> - <td class="diff-line-num new_line" :class="lineType"> + <td class="diff-line-num new_line border-top-0 border-bottom-0" :class="lineType"> {{ line.new_line }} </td> <td class="line_content" :class="lineType"> 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 acc179b3834..3c86b7e4c61 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -22,6 +22,7 @@ import noteHeader from '~/notes/components/note_header.vue'; import Icon from '~/vue_shared/components/icon.vue'; import TimelineEntryItem from './timeline_entry_item.vue'; import { spriteIcon } from '../../../lib/utils/common_utils'; +import initMRPopovers from '~/mr_popover/'; const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; @@ -71,6 +72,9 @@ export default { ); }, }, + mounted() { + initMRPopovers(this.$el.querySelectorAll('.gfm-merge_request')); + }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/select2_select.vue b/app/assets/javascripts/vue_shared/components/select2_select.vue index 3074ea859cc..6d2612556ff 100644 --- a/app/assets/javascripts/vue_shared/components/select2_select.vue +++ b/app/assets/javascripts/vue_shared/components/select2_select.vue @@ -1,6 +1,6 @@ <script> import $ from 'jquery'; -import 'select2/select2'; +import 'select2'; export default { name: 'Select2Select', 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 f9773622001..4dbfd1ba6f4 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 @@ -1,11 +1,13 @@ <script> import { GlPopover, GlSkeletonLoading } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; import { glEmojiTag } from '../../../emoji'; export default { name: 'UserPopover', components: { + Icon, GlPopover, GlSkeletonLoading, UserAvatarImage, @@ -68,16 +70,27 @@ export default { <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" /> </div> <div class="text-secondary"> - <div v-if="user.bio" class="js-bio">{{ user.bio }}</div> - <div v-if="user.organization" class="js-organization">{{ user.organization }}</div> + <div v-if="user.bio" class="js-bio d-flex mb-1"> + <icon name="profile" css-classes="category-icon" /> + <span class="ml-1">{{ user.bio }}</span> + </div> + <div v-if="user.organization" class="js-organization d-flex mb-1"> + <icon v-show="!jobInfoIsLoading" name="work" css-classes="category-icon" /> + <span class="ml-1">{{ user.organization }}</span> + </div> <gl-skeleton-loading v-if="jobInfoIsLoading" :lines="1" class="animation-container-small mb-1" /> </div> - <div class="text-secondary"> - {{ user.location }} + <div class="js-location text-secondary d-flex"> + <icon + v-show="!locationIsLoading && user.location" + name="location" + css-classes="category-icon" + /> + <span class="ml-1">{{ user.location }}</span> <gl-skeleton-loading v-if="locationIsLoading" :lines="1" diff --git a/app/assets/stylesheets/components/dashboard_skeleton.scss b/app/assets/stylesheets/components/dashboard_skeleton.scss index 9775c329922..a104d035a9a 100644 --- a/app/assets/stylesheets/components/dashboard_skeleton.scss +++ b/app/assets/stylesheets/components/dashboard_skeleton.scss @@ -11,7 +11,7 @@ } &-body { - height: 120px; + min-height: 120px; &-warning { background-color: $orange-50; @@ -22,10 +22,8 @@ } } - &-time-ago { - &-icon { - color: $gray-500; - } + &-icon { + color: $gray-500; } &-footer { diff --git a/app/assets/stylesheets/components/popover.scss b/app/assets/stylesheets/components/popover.scss index 7d46b262a69..838bf5d343b 100644 --- a/app/assets/stylesheets/components/popover.scss +++ b/app/assets/stylesheets/components/popover.scss @@ -5,6 +5,10 @@ padding: $gl-padding-8; font-size: $gl-font-size-small; line-height: $gl-line-height; + + .category-icon { + color: $gray-600; + } } } diff --git a/app/assets/stylesheets/components/toast.scss b/app/assets/stylesheets/components/toast.scss new file mode 100644 index 00000000000..91d16c8e98d --- /dev/null +++ b/app/assets/stylesheets/components/toast.scss @@ -0,0 +1,3 @@ +.toast-close { + font-size: $default-icon-size !important; +} diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 1e025b3a67d..a6179e2a96e 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -447,30 +447,29 @@ } } +.title-container, .navbar-nav { - li { - .badge.badge-pill { - position: inherit; - font-weight: $gl-font-weight-normal; - margin-left: -6px; - font-size: 11px; - color: $white-light; - padding: 0 5px; - line-height: 12px; - border-radius: 7px; - box-shadow: 0 1px 0 rgba($gl-header-color, 0.2); - - &.issues-count { - background-color: $green-500; - } + .badge.badge-pill { + position: inherit; + font-weight: $gl-font-weight-normal; + margin-left: -6px; + font-size: 11px; + color: $white-light; + padding: 0 5px; + line-height: 12px; + border-radius: 7px; + box-shadow: 0 1px 0 rgba($gl-header-color, 0.2); + + &.green-badge { + background-color: $green-500; + } - &.merge-requests-count { - background-color: $orange-600; - } + &.merge-requests-count { + background-color: $orange-600; + } - &.todos-count { - background-color: $blue-500; - } + &.todos-count { + background-color: $blue-500; } } } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 61fab445793..09ff518bbdf 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -7,6 +7,9 @@ opacity: 1 !important; * { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; user-select: none; // !important to make sure no style can override this when dragging cursor: grabbing !important; @@ -207,6 +210,7 @@ border: 1px solid $gray-200; box-shadow: 0 1px 2px $issue-boards-card-shadow; line-height: $gl-padding; + list-style: none; &:not(:last-child) { margin-bottom: $gl-padding-8; @@ -256,6 +260,10 @@ } } +.board-card-header { + text-align: initial; +} + .board-card-assignee { margin-top: -$gl-padding-4; margin-bottom: -$gl-padding-4; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index f8620eec46d..04c66006027 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -60,6 +60,7 @@ overflow-wrap: break-word; min-width: 0; width: 100%; + text-align: initial; } .btn-edit { diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 60a840aac1b..13288d8bad1 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -456,7 +456,9 @@ // Don't hide the overflow in system messages .system-note-message, -.issuable-detail { +.issuable-detail, +.md-preview-holder, +.note-body { .scoped-label-wrapper { .badge { overflow: initial; diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 20bdc6596e9..37071a57bb3 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -75,6 +75,8 @@ input[type='checkbox']:hover { } .search-input-wrap { + width: 100%; + .search-icon, .clear-icon { position: absolute; @@ -84,6 +86,9 @@ input[type='checkbox']:hover { .search-icon { transition: color $default-transition-duration; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; user-select: none; } diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index b681949ab36..d445be0eb19 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -127,6 +127,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController [ *::ApplicationSettingsHelper.visible_attributes, *::ApplicationSettingsHelper.external_authorization_service_attributes, + *lets_encrypt_visible_attributes, :domain_blacklist_file, disabled_oauth_sign_in_sources: [], import_sources: [], @@ -134,4 +135,13 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController restricted_visibility_levels: [] ] end + + def lets_encrypt_visible_attributes + return [] unless Feature.enabled?(:pages_auto_ssl) + + [ + :lets_encrypt_notification_email, + :lets_encrypt_terms_of_service_accepted + ] + end end diff --git a/app/controllers/clusters/applications_controller.rb b/app/controllers/clusters/applications_controller.rb index 73c744efeba..16c2365f85d 100644 --- a/app/controllers/clusters/applications_controller.rb +++ b/app/controllers/clusters/applications_controller.rb @@ -4,6 +4,7 @@ class Clusters::ApplicationsController < Clusters::BaseController before_action :cluster before_action :authorize_create_cluster!, only: [:create] before_action :authorize_update_cluster!, only: [:update] + before_action :authorize_admin_cluster!, only: [:destroy] def create request_handler do @@ -21,6 +22,14 @@ class Clusters::ApplicationsController < Clusters::BaseController end end + def destroy + request_handler do + Clusters::Applications::DestroyService + .new(@cluster, current_user, cluster_application_destroy_params) + .execute(request) + end + end + private def request_handler @@ -40,4 +49,8 @@ class Clusters::ApplicationsController < Clusters::BaseController def cluster_application_params params.permit(:application, :hostname, :email) end + + def cluster_application_destroy_params + params.permit(:application) + end end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index e35f34be23c..4aa572ade73 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -10,8 +10,9 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics] before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :expire_etag_cache, only: [:index] - before_action only: [:metrics, :additional_metrics] do + before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do push_frontend_feature_flag(:metrics_time_window) + push_frontend_feature_flag(:environment_metrics_use_prometheus_endpoint) end def index @@ -134,13 +135,13 @@ class Projects::EnvironmentsController < Projects::ApplicationController end def metrics - # Currently, this acts as a hint to load the metrics details into the cache - # if they aren't there already - @metrics = environment.metrics || {} - respond_to do |format| format.html format.json do + # Currently, this acts as a hint to load the metrics details into the cache + # if they aren't there already + @metrics = environment.metrics || {} + render json: @metrics, status: @metrics.any? ? :ok : :no_content end end @@ -156,6 +157,20 @@ class Projects::EnvironmentsController < Projects::ApplicationController end end + def metrics_dashboard + return render_403 unless Feature.enabled?(:environment_metrics_use_prometheus_endpoint, @project) + + result = Gitlab::Metrics::Dashboard::Service.new(@project, @current_user, environment: environment).get_dashboard + + respond_to do |format| + if result[:status] == :success + format.json { render status: :ok, json: result } + else + format.json { render status: result[:http_status], json: result } + end + end + end + def search respond_to do |format| format.json do diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 2ef5c207d67..b4d89db20c5 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -132,18 +132,6 @@ class Projects::IssuesController < Projects::ApplicationController render_conflict_response end - def referenced_merge_requests - @merge_requests, @closed_by_merge_requests = ::Issues::ReferencedMergeRequestsService.new(project, current_user).execute(issue) - - respond_to do |format| - format.json do - render json: { - html: view_to_html_string('projects/issues/_merge_requests') - } - end - end - end - def related_branches @related_branches = Issues::RelatedBranchesService.new(project, current_user).execute(issue) diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb index 1fafc33e917..5cfb0ac307d 100644 --- a/app/controllers/projects/settings/operations_controller.rb +++ b/app/controllers/projects/settings/operations_controller.rb @@ -56,6 +56,8 @@ module Projects # overridden in EE def permitted_project_params { + metrics_setting_attributes: [:external_dashboard_url], + error_tracking_setting_attributes: [ :enabled, :api_host, diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index 88910c91763..fa5bdbc7d49 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -17,7 +17,7 @@ class Projects::WikisController < Projects::ApplicationController def pages @wiki_pages = Kaminari.paginate_array( - @project_wiki.pages(sort: params[:sort], direction: params[:direction]) + @project_wiki.list_pages(sort: params[:sort], direction: params[:direction]) ).page(params[:page]) @wiki_entries = WikiPage.group_by_directory(@wiki_pages) @@ -118,7 +118,7 @@ class Projects::WikisController < Projects::ApplicationController @sidebar_page = @project_wiki.find_sidebar(params[:version_id]) unless @sidebar_page # Fallback to default sidebar - @sidebar_wiki_entries = WikiPage.group_by_directory(@project_wiki.pages(limit: 15)) + @sidebar_wiki_entries = WikiPage.group_by_directory(@project_wiki.list_pages(limit: 15)) end rescue ProjectWiki::CouldNotCreateWikiError flash[:notice] = _("Could not create Wiki Repository at this time. Please try again later.") diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index b98d8bd1fff..54d32a688bf 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -44,6 +44,12 @@ module Resolvers alias_method :project, :object def resolve(**args) + # The project could have been loaded in batch by `BatchLoader`. + # At this point we need the `id` of the project to query for issues, so + # make sure it's loaded and not `nil` before continueing. + project.sync if project.respond_to?(:sync) + return Issue.none if project.nil? + # Will need to be be made group & namespace aware with # https://gitlab.com/gitlab-org/gitlab-ce/issues/54520 args[:project_id] = project.id diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb index 289cb44f1e8..495c29d3e24 100644 --- a/app/helpers/broadcast_messages_helper.rb +++ b/app/helpers/broadcast_messages_helper.rb @@ -4,7 +4,7 @@ module BroadcastMessagesHelper def broadcast_message(message) return unless message.present? - content_tag :div, class: 'broadcast-message', style: broadcast_message_style(message) do + content_tag :div, dir: 'auto', class: 'broadcast-message', style: broadcast_message_style(message) do icon('bullhorn') << ' ' << render_broadcast_message(message) end end diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index ad77f99fe44..dce4168ad7b 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -4,7 +4,7 @@ require 'nokogiri' module MarkupHelper include ActionView::Helpers::TagHelper - include ActionView::Context + include ::Gitlab::ActionViewOutput::Context def plain?(filename) Gitlab::MarkupHelper.plain?(filename) diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 9d71f250466..d1d01368972 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -17,6 +17,19 @@ class ApplicationRecord < ActiveRecord::Base where(nil).pluck(self.primary_key) end + def self.safe_ensure_unique(retries: 0) + transaction(requires_new: true) do + yield + end + rescue ActiveRecord::RecordNotUnique + if retries > 0 + retries -= 1 + retry + end + + false + end + def self.safe_find_or_create_by!(*args) safe_find_or_create_by(*args).tap do |record| record.validate! unless record.persisted? @@ -24,10 +37,8 @@ class ApplicationRecord < ActiveRecord::Base end def self.safe_find_or_create_by(*args) - transaction(requires_new: true) do + safe_ensure_unique(retries: 1) do find_or_create_by(*args) end - rescue ActiveRecord::RecordNotUnique - retry end end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 2f9b4c4eaa2..fb1e558e46c 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -229,6 +229,16 @@ class ApplicationSetting < ApplicationRecord presence: true, if: -> (setting) { setting.external_auth_client_cert.present? } + validates :lets_encrypt_notification_email, + devise_email: true, + format: { without: /@example\.(com|org|net)\z/, + message: N_("Let's Encrypt does not accept emails on example.com") }, + allow_blank: true + + validates :lets_encrypt_notification_email, + presence: true, + if: :lets_encrypt_terms_of_service_accepted? + validates_with X509CertificateCredentialsValidator, certificate: :external_auth_client_cert, pkey: :external_auth_client_key, diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 0d8d7d95791..644716ba8e7 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -4,6 +4,7 @@ module Ci class Bridge < CommitStatus include Ci::Processable include Ci::Contextable + include Ci::PipelineDelegator include Importable include AfterCommitQueue include HasRef @@ -13,8 +14,6 @@ module Ci belongs_to :trigger_request validates :ref, presence: true - delegate :merge_request_event?, to: :pipeline - def self.retry(bridge, current_user) raise NotImplementedError end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index e5236051118..5a2ead41578 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -6,6 +6,7 @@ module Ci include Ci::Processable include Ci::Metadatable include Ci::Contextable + include Ci::PipelineDelegator include TokenAuthenticatable include AfterCommitQueue include ObjectStorage::BackgroundMove @@ -49,8 +50,6 @@ module Ci delegate :terminal_specification, to: :runner_session, allow_nil: true delegate :gitlab_deploy_token, to: :project delegate :trigger_short_token, to: :trigger_request, allow_nil: true - delegate :merge_request_event?, :merge_request_ref?, - :legacy_detached_merge_request_pipeline?, to: :pipeline ## # Since Gitlab 11.5, deployments records started being created right after diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index bbd21eb0e78..2b7835d7fab 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -759,6 +759,18 @@ module Ci user == current_user end + def source_ref + if triggered_by_merge_request? + merge_request.source_branch + else + ref + end + end + + def source_ref_slug + Gitlab::Utils.slugify(source_ref.to_s) + end + private def ci_yaml_from_repo diff --git a/app/models/clusters/applications/cert_manager.rb b/app/models/clusters/applications/cert_manager.rb index ac0e7eb03bc..d6a7d1d2bdd 100644 --- a/app/models/clusters/applications/cert_manager.rb +++ b/app/models/clusters/applications/cert_manager.rb @@ -24,6 +24,12 @@ module Clusters 'stable/cert-manager' end + # We will implement this in future MRs. + # Need to reverse postinstall step + def allowed_to_uninstall? + false + end + def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: 'certmanager', diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb index 71aff00077d..a83d06c4b00 100644 --- a/app/models/clusters/applications/helm.rb +++ b/app/models/clusters/applications/helm.rb @@ -29,6 +29,13 @@ module Clusters self.status = 'installable' if cluster&.platform_kubernetes_active? end + # We will implement this in future MRs. + # Basically we need to check all other applications are not installed + # first. + def allowed_to_uninstall? + false + end + def install_command Gitlab::Kubernetes::Helm::InitCommand.new( name: name, diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index 376d54aab2c..a1023f44049 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -35,6 +35,13 @@ module Clusters 'stable/nginx-ingress' end + # We will implement this in future MRs. + # Basically we need to check all dependent applications are not installed + # first. + def allowed_to_uninstall? + false + end + def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb index f86ff3551a1..987c057ad6d 100644 --- a/app/models/clusters/applications/jupyter.rb +++ b/app/models/clusters/applications/jupyter.rb @@ -38,6 +38,12 @@ module Clusters content_values.to_yaml end + # Will be addressed in future MRs + # We need to investigate and document what will be permenantly deleted. + def allowed_to_uninstall? + false + end + def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb index 38cbc9ce8eb..9fbf5d8af04 100644 --- a/app/models/clusters/applications/knative.rb +++ b/app/models/clusters/applications/knative.rb @@ -51,6 +51,12 @@ module Clusters { "domain" => hostname }.to_yaml end + # Handled in a new issue: + # https://gitlab.com/gitlab-org/gitlab-ce/issues/59369 + def allowed_to_uninstall? + false + end + def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index 954c29da196..a6b7617b830 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -16,10 +16,12 @@ module Clusters default_value_for :version, VERSION + after_destroy :disable_prometheus_integration + state_machine :status do after_transition any => [:installed] do |application| application.cluster.projects.each do |project| - project.find_or_initialize_service('prometheus').update(active: true) + project.find_or_initialize_service('prometheus').update!(active: true) end end end @@ -47,6 +49,14 @@ module Clusters ) end + def uninstall_command + Gitlab::Kubernetes::Helm::DeleteCommand.new( + name: name, + rbac: cluster.platform_kubernetes_rbac?, + files: files + ) + end + def upgrade_command(values) ::Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, @@ -82,6 +92,12 @@ module Clusters private + def disable_prometheus_integration + cluster.projects.each do |project| + project.prometheus_service&.update!(active: false) + end + end + def kube_client cluster&.kubeclient&.core_client end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 06ab0855e40..af648db3708 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.4.0'.freeze + VERSION = '0.4.1'.freeze self.table_name = 'clusters_applications_runners' @@ -29,6 +29,13 @@ module Clusters content_values.to_yaml end + # Need to investigate if pipelines run by this runner will stop upon the + # executor pod stopping + # I.e.run a pipeline, and uninstall runner while pipeline is running + def allowed_to_uninstall? + false + end + def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb index ee964fb7c93..4514498b84b 100644 --- a/app/models/clusters/concerns/application_core.rb +++ b/app/models/clusters/concerns/application_core.rb @@ -18,6 +18,16 @@ module Clusters self.status = 'installable' if cluster&.application_helm_available? end + def can_uninstall? + allowed_to_uninstall? + end + + # All new applications should uninstall by default + # Override if there's dependencies that needs to be uninstalled first + def allowed_to_uninstall? + true + end + def self.application_name self.to_s.demodulize.underscore end diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb index 1273ed83abe..54a3dda6d75 100644 --- a/app/models/clusters/concerns/application_status.rb +++ b/app/models/clusters/concerns/application_status.rb @@ -25,9 +25,11 @@ module Clusters state :updating, value: 4 state :updated, value: 5 state :update_errored, value: 6 + state :uninstalling, value: 7 + state :uninstall_errored, value: 8 event :make_scheduled do - transition [:installable, :errored, :installed, :updated, :update_errored] => :scheduled + transition [:installable, :errored, :installed, :updated, :update_errored, :uninstall_errored] => :scheduled end event :make_installing do @@ -40,8 +42,9 @@ module Clusters end event :make_errored do - transition any - [:updating] => :errored + transition any - [:updating, :uninstalling] => :errored transition [:updating] => :update_errored + transition [:uninstalling] => :uninstall_errored end event :make_updating do @@ -52,6 +55,10 @@ module Clusters transition any => :update_errored end + event :make_uninstalling do + transition [:scheduled] => :uninstalling + end + before_transition any => [:scheduled] do |app_status, _| app_status.status_reason = nil end @@ -65,7 +72,7 @@ module Clusters app_status.status_reason = nil end - before_transition any => [:update_errored] do |app_status, transition| + before_transition any => [:update_errored, :uninstall_errored] do |app_status, transition| status_reason = transition.args.first app_status.status_reason = status_reason if status_reason end diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index 4687ec7d166..80278e07e65 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -91,7 +91,8 @@ module Avatarable private def retrieve_upload_from_batch(identifier) - BatchLoader.for(identifier: identifier, model: self).batch(key: self.class) do |upload_params, loader, args| + BatchLoader.for(identifier: identifier, model: self) + .batch(key: self.class, cache: true, replace_methods: false) do |upload_params, loader, args| model_class = args[:key] paths = upload_params.flat_map do |params| params[:model].upload_paths(params[:identifier]) diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb index 4986a42dbd2..e1d5ce7f7d4 100644 --- a/app/models/concerns/ci/contextable.rb +++ b/app/models/concerns/ci/contextable.rb @@ -70,8 +70,8 @@ module Ci variables.append(key: 'CI_COMMIT_SHA', value: sha) variables.append(key: 'CI_COMMIT_SHORT_SHA', value: short_sha) variables.append(key: 'CI_COMMIT_BEFORE_SHA', value: before_sha) - variables.append(key: 'CI_COMMIT_REF_NAME', value: ref) - variables.append(key: 'CI_COMMIT_REF_SLUG', value: ref_slug) + variables.append(key: 'CI_COMMIT_REF_NAME', value: source_ref) + variables.append(key: 'CI_COMMIT_REF_SLUG', value: source_ref_slug) variables.append(key: "CI_COMMIT_TAG", value: ref) if tag? variables.append(key: "CI_PIPELINE_TRIGGERED", value: 'true') if trigger_request variables.append(key: "CI_JOB_MANUAL", value: 'true') if action? @@ -85,8 +85,8 @@ module Ci Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'CI_BUILD_REF', value: sha) variables.append(key: 'CI_BUILD_BEFORE_SHA', value: before_sha) - variables.append(key: 'CI_BUILD_REF_NAME', value: ref) - variables.append(key: 'CI_BUILD_REF_SLUG', value: ref_slug) + variables.append(key: 'CI_BUILD_REF_NAME', value: source_ref) + variables.append(key: 'CI_BUILD_REF_SLUG', value: source_ref_slug) variables.append(key: 'CI_BUILD_NAME', value: name) variables.append(key: 'CI_BUILD_STAGE', value: stage) variables.append(key: "CI_BUILD_TAG", value: ref) if tag? diff --git a/app/models/concerns/ci/pipeline_delegator.rb b/app/models/concerns/ci/pipeline_delegator.rb new file mode 100644 index 00000000000..dbc5ed1bc9a --- /dev/null +++ b/app/models/concerns/ci/pipeline_delegator.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +## +# This module is mainly used by child associations of `Ci::Pipeline` that needs to look up +# single source of truth. For example, `Ci::Build` has `git_ref` method, which behaves +# slightly different from `Ci::Pipeline`'s `git_ref`. This is very confusing as +# the system could behave differently time to time. +# We should have a single interface in `Ci::Pipeline` and access the method always. +module Ci + module PipelineDelegator + extend ActiveSupport::Concern + + included do + delegate :merge_request_event?, + :merge_request_ref?, + :source_ref, + :source_ref_slug, + :legacy_detached_merge_request_pipeline?, to: :pipeline + end + end +end diff --git a/app/models/concerns/has_ref.rb b/app/models/concerns/has_ref.rb index 413cd36dcaa..fa0cf5ddfd2 100644 --- a/app/models/concerns/has_ref.rb +++ b/app/models/concerns/has_ref.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +## +# We will disable `ref` and `sha` attributes in `Ci::Build` in the future +# and remove this module in favor of Ci::PipelineDelegator. module HasRef extend ActiveSupport::Concern diff --git a/app/models/deployment.rb b/app/models/deployment.rb index d847a0a11e4..92c7311014a 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -47,6 +47,12 @@ class Deployment < ApplicationRecord Deployments::SuccessWorker.perform_async(id) end end + + after_transition any => [:success, :failed, :canceled] do |deployment| + deployment.run_after_commit do + Deployments::FinishedWorker.perform_async(id) + end + end end enum status: { @@ -79,7 +85,16 @@ class Deployment < ApplicationRecord end def cluster - project.deployment_platform(environment: environment.name)&.cluster + platform = project.deployment_platform(environment: environment.name) + + if platform.present? && platform.respond_to?(:cluster) + platform.cluster + end + end + + def execute_hooks + deployment_data = Gitlab::DataBuilder::Deployment.build(self) + project.execute_services(deployment_data, :deployment_hooks) end def last? diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb index 377ac3febb6..6889e0d776b 100644 --- a/app/models/notification_recipient.rb +++ b/app/models/notification_recipient.rb @@ -119,15 +119,19 @@ class NotificationRecipient return @read_ability if instance_variable_defined?(:@read_ability) @read_ability = - case @target - when Issuable - :"read_#{@target.to_ability_name}" - when Ci::Pipeline + if @target.is_a?(Ci::Pipeline) :read_build # We have build trace in pipeline emails - when ActiveRecord::Base - :"read_#{@target.class.model_name.name.underscore}" - else - nil + elsif default_ability_for_target + :"read_#{default_ability_for_target}" + end + end + + def default_ability_for_target + @default_ability_for_target ||= + if @target.respond_to?(:to_ability_name) + @target.to_ability_name + elsif @target.class.respond_to?(:model_name) + @target.class.model_name.name.underscore end end diff --git a/app/models/project.rb b/app/models/project.rb index 626ff9e1389..a29100405f9 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -188,6 +188,7 @@ class Project < ApplicationRecord has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_one :project_repository, inverse_of: :project has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting' + has_one :metrics_setting, inverse_of: :project, class_name: 'ProjectMetricsSetting' # Merge Requests for target project should be removed with it has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project @@ -297,6 +298,7 @@ class Project < ApplicationRecord reject_if: ->(attrs) { attrs[:id].blank? && attrs[:url].blank? } accepts_nested_attributes_for :error_tracking_setting, update_only: true + accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true delegate :name, to: :owner, allow_nil: true, prefix: true delegate :members, to: :team, prefix: true diff --git a/app/models/project_metrics_setting.rb b/app/models/project_metrics_setting.rb new file mode 100644 index 00000000000..a2a7dc571a4 --- /dev/null +++ b/app/models/project_metrics_setting.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ProjectMetricsSetting < ApplicationRecord + belongs_to :project + + validates :external_dashboard_url, + length: { maximum: 255 }, + addressable_url: { enforce_sanitization: true, ascii_only: true } +end diff --git a/app/models/project_services/chat_message/deployment_message.rb b/app/models/project_services/chat_message/deployment_message.rb new file mode 100644 index 00000000000..656a3e6ab4b --- /dev/null +++ b/app/models/project_services/chat_message/deployment_message.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module ChatMessage + class DeploymentMessage < BaseMessage + attr_reader :commit_url + attr_reader :deployable_id + attr_reader :deployable_url + attr_reader :environment + attr_reader :short_sha + attr_reader :status + + def initialize(data) + super + + @commit_url = data[:commit_url] + @deployable_id = data[:deployable_id] + @deployable_url = data[:deployable_url] + @environment = data[:environment] + @short_sha = data[:short_sha] + @status = data[:status] + end + + def attachments + [{ + text: "#{project_link}\n#{deployment_link}, SHA #{commit_link}, by #{user_combined_name}", + color: color + }] + end + + def activity + {} + end + + private + + def message + "Deploy to #{environment} #{humanized_status}" + end + + def color + case status + when 'success' + 'good' + when 'canceled' + 'warning' + when 'failed' + 'danger' + else + '#334455' + end + end + + def project_link + link(project_name, project_url) + end + + def deployment_link + link("Job ##{deployable_id}", deployable_url) + end + + def commit_link + link(short_sha, commit_url) + end + + def humanized_status + status == 'success' ? 'succeeded' : status + end + end +end diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index c10ee07ccf4..7c9ecc6b821 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -33,7 +33,7 @@ class ChatNotificationService < Service def self.supported_events %w[push issue confidential_issue merge_request note confidential_note tag_push - pipeline wiki_page] + pipeline wiki_page deployment] end def fields @@ -122,6 +122,8 @@ class ChatNotificationService < Service ChatMessage::PipelineMessage.new(data) if should_pipeline_be_notified?(data) when "wiki_page" ChatMessage::WikiPageMessage.new(data) + when "deployment" + ChatMessage::DeploymentMessage.new(data) end end diff --git a/app/models/project_services/discord_service.rb b/app/models/project_services/discord_service.rb index 405676792de..4385834ed0a 100644 --- a/app/models/project_services/discord_service.rb +++ b/app/models/project_services/discord_service.rb @@ -33,6 +33,11 @@ class DiscordService < ChatNotificationService # No-op. end + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + def default_fields [ { type: "text", name: "webhook", placeholder: "e.g. https://discordapp.com/api/webhooks/…" }, diff --git a/app/models/project_services/hangouts_chat_service.rb b/app/models/project_services/hangouts_chat_service.rb index 272cd0f4e47..699cf1659d1 100644 --- a/app/models/project_services/hangouts_chat_service.rb +++ b/app/models/project_services/hangouts_chat_service.rb @@ -35,6 +35,11 @@ class HangoutsChatService < ChatNotificationService 'https://chat.googleapis.com/v1/spaces…' end + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + def default_fields [ { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb index c34078f13c1..c22a6dc26f6 100644 --- a/app/models/project_services/microsoft_teams_service.rb +++ b/app/models/project_services/microsoft_teams_service.rb @@ -33,6 +33,11 @@ class MicrosoftTeamsService < ChatNotificationService def default_channel_placeholder end + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + def default_fields [ { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 23ddd708396..c91add6439f 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -82,17 +82,25 @@ class ProjectWiki end def empty? - pages(limit: 1).empty? + list_pages(limit: 1).empty? end + # Lists wiki pages of the repository. + # + # limit - max number of pages returned by the method. + # sort - criterion by which the pages are sorted. + # direction - order of the sorted pages. + # load_content - option, which specifies whether the content inside the page + # will be loaded. + # # Returns an Array of GitLab WikiPage instances or an # empty Array if this Wiki has no pages. - def pages(limit: 0, sort: nil, direction: DIRECTION_ASC) - sort ||= TITLE_ORDER - direction_desc = direction == DIRECTION_DESC - - wiki.pages( - limit: limit, sort: sort, direction_desc: direction_desc + def list_pages(limit: 0, sort: nil, direction: DIRECTION_ASC, load_content: false) + wiki.list_pages( + limit: limit, + sort: sort, + direction_desc: direction == DIRECTION_DESC, + load_content: load_content ).map do |page| WikiPage.new(self, page, true) end diff --git a/app/models/repository.rb b/app/models/repository.rb index 51ab2247a03..8b728c4f6b2 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -1065,6 +1065,19 @@ class Repository blob.data end + def create_if_not_exists + return if exists? + + raw.create_repository + after_create + end + + def blobs_metadata(paths, ref = 'HEAD') + references = Array.wrap(paths).map { |path| [ref, path] } + + Gitlab::Git::Blob.batch_metadata(raw, references).map { |raw_blob| Blob.decorate(raw_blob) } + end + private # TODO Generice finder, later split this on finders by Ref or Oid diff --git a/app/models/service.rb b/app/models/service.rb index de549becf71..9896aa12e90 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -50,6 +50,7 @@ class Service < ApplicationRecord scope :job_hooks, -> { where(job_events: true, active: true) } scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) } scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) } + scope :deployment_hooks, -> { where(deployment_events: true, active: true) } scope :external_issue_trackers, -> { issue_trackers.active.without_defaults } scope :deployment, -> { where(category: 'deployment') } @@ -335,6 +336,8 @@ class Service < ApplicationRecord "Event will be triggered when a wiki page is created/updated" when "commit", "commit_events" "Event will be triggered when a commit is created/updated" + when "deployment" + "Event will be triggered when a deployment finishes" end end diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index 1c1347c5a57..944895904fe 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -63,19 +63,11 @@ module Ci end def link_to_merge_request_source_branch - return unless merge_request_presenter - - link_to(merge_request_presenter.source_branch, - merge_request_presenter.source_branch_commits_path, - class: 'ref-name') + merge_request_presenter&.source_branch_link end def link_to_merge_request_target_branch - return unless merge_request_presenter - - link_to(merge_request_presenter.target_branch, - merge_request_presenter.target_branch_commits_path, - class: 'ref-name') + merge_request_presenter&.target_branch_link end private diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index 3f7b5bebb74..ba0711ca867 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -216,6 +216,22 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated help_page_path('ci/merge_request_pipelines/index.md') end + def source_branch_link + if source_branch_exists? + link_to(source_branch, source_branch_commits_path, class: 'ref-name') + else + content_tag(:span, source_branch, class: 'ref-name') + end + end + + def target_branch_link + if target_branch_exists? + link_to(target_branch, target_branch_commits_path, class: 'ref-name') + else + content_tag(:span, target_branch, class: 'ref-name') + end + end + private def cached_can_be_reverted? diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb index a4a2c015c4e..2a916b13f52 100644 --- a/app/serializers/cluster_application_entity.rb +++ b/app/serializers/cluster_application_entity.rb @@ -10,4 +10,5 @@ class ClusterApplicationEntity < Grape::Entity expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) } expose :email, if: -> (e, _) { e.respond_to?(:email) } 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/concerns/user_status_tooltip.rb b/app/serializers/concerns/user_status_tooltip.rb index 633b117d392..a81e377691e 100644 --- a/app/serializers/concerns/user_status_tooltip.rb +++ b/app/serializers/concerns/user_status_tooltip.rb @@ -3,7 +3,7 @@ module UserStatusTooltip extend ActiveSupport::Concern include ActionView::Helpers::TagHelper - include ActionView::Context + include ::Gitlab::ActionViewOutput::Context include EmojiHelper include UsersHelper diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb index c592d608b89..3c6803d24e6 100644 --- a/app/services/clusters/applications/check_installation_progress_service.rb +++ b/app/services/clusters/applications/check_installation_progress_service.rb @@ -37,7 +37,7 @@ module Clusters end def check_timeout - if timeouted? + if timed_out? begin app.make_errored!("Operation timed out. Check pod logs for #{pod_name} for more details.") end @@ -51,8 +51,8 @@ module Clusters install_command.pod_name end - def timeouted? - Time.now.utc - app.updated_at.to_time.utc > ClusterWaitForAppInstallationWorker::TIMEOUT + def timed_out? + Time.now.utc - app.updated_at.utc > ClusterWaitForAppInstallationWorker::TIMEOUT end def remove_installation_pod diff --git a/app/services/clusters/applications/check_uninstall_progress_service.rb b/app/services/clusters/applications/check_uninstall_progress_service.rb new file mode 100644 index 00000000000..8786d295d6a --- /dev/null +++ b/app/services/clusters/applications/check_uninstall_progress_service.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class CheckUninstallProgressService < BaseHelmService + def execute + return unless app.uninstalling? + + case installation_phase + when Gitlab::Kubernetes::Pod::SUCCEEDED + on_success + when Gitlab::Kubernetes::Pod::FAILED + on_failed + else + check_timeout + end + rescue Kubeclient::HttpError => e + log_error(e) + + app.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code }) + end + + private + + def on_success + app.destroy! + rescue StandardError => e + app.make_errored!(_('Application uninstalled but failed to destroy: %{error_message}') % { error_message: e.message }) + ensure + remove_installation_pod + end + + def on_failed + app.make_errored!(_('Operation failed. Check pod logs for %{pod_name} for more details.') % { pod_name: pod_name }) + end + + def check_timeout + if timed_out? + app.make_errored!(_('Operation timed out. Check pod logs for %{pod_name} for more details.') % { pod_name: pod_name }) + else + WaitForUninstallAppWorker.perform_in(WaitForUninstallAppWorker::INTERVAL, app.name, app.id) + end + end + + def pod_name + app.uninstall_command.pod_name + end + + def timed_out? + Time.now.utc - app.updated_at.utc > WaitForUninstallAppWorker::TIMEOUT + end + + def remove_installation_pod + helm_api.delete_pod!(pod_name) + end + + def installation_phase + helm_api.status(pod_name) + end + end + end +end diff --git a/app/services/clusters/applications/create_service.rb b/app/services/clusters/applications/create_service.rb index ae36da7b3dd..f723c42c049 100644 --- a/app/services/clusters/applications/create_service.rb +++ b/app/services/clusters/applications/create_service.rb @@ -10,8 +10,8 @@ module Clusters end def builder - cluster.method("application_#{application_name}").call || - cluster.method("build_application_#{application_name}").call + cluster.public_send(:"application_#{application_name}") || # rubocop:disable GitlabSecurity/PublicSend + cluster.public_send(:"build_application_#{application_name}") # rubocop:disable GitlabSecurity/PublicSend end end end diff --git a/app/services/clusters/applications/destroy_service.rb b/app/services/clusters/applications/destroy_service.rb new file mode 100644 index 00000000000..f3a4c4f754a --- /dev/null +++ b/app/services/clusters/applications/destroy_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class DestroyService < ::Clusters::Applications::BaseService + def execute(_request) + instantiate_application.tap do |application| + break unless application.can_uninstall? + + application.make_scheduled! + + Clusters::Applications::UninstallWorker.perform_async(application.name, application.id) + end + end + + private + + def builder + cluster.public_send(:"application_#{application_name}") # rubocop:disable GitlabSecurity/PublicSend + end + end + end +end diff --git a/app/services/clusters/applications/uninstall_service.rb b/app/services/clusters/applications/uninstall_service.rb new file mode 100644 index 00000000000..50c8d806c14 --- /dev/null +++ b/app/services/clusters/applications/uninstall_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class UninstallService < BaseHelmService + def execute + return unless app.scheduled? + + app.make_uninstalling! + uninstall + end + + private + + def uninstall + helm_api.uninstall(app.uninstall_command) + + Clusters::Applications::WaitForUninstallAppWorker.perform_in( + Clusters::Applications::WaitForUninstallAppWorker::INTERVAL, app.name, app.id) + rescue Kubeclient::HttpError => e + log_error(e) + app.make_errored!("Kubernetes error: #{e.error_code}") + rescue StandardError => e + log_error(e) + app.make_errored!('Failed to uninstall.') + end + end + end +end diff --git a/app/services/clusters/applications/update_service.rb b/app/services/clusters/applications/update_service.rb index 5071c31839c..0fa937da865 100644 --- a/app/services/clusters/applications/update_service.rb +++ b/app/services/clusters/applications/update_service.rb @@ -10,7 +10,7 @@ module Clusters end def builder - cluster.method("application_#{application_name}").call + cluster.public_send(:"application_#{application_name}") # rubocop:disable GitlabSecurity/PublicSend end end end diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb index a8478e3a904..9d371e234ee 100644 --- a/app/services/git/base_hooks_service.rb +++ b/app/services/git/base_hooks_service.rb @@ -73,13 +73,13 @@ module Git def push_data @push_data ||= Gitlab::DataBuilder::Push.build( - project, - current_user, - params[:oldrev], - params[:newrev], - params[:ref], - limited_commits, - event_message, + project: project, + user: current_user, + oldrev: params[:oldrev], + newrev: params[:newrev], + ref: params[:ref], + commits: limited_commits, + message: event_message, commits_count: commits_count, push_options: params[:push_options] || {} ) diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index 642465551c9..073c14040ce 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -94,16 +94,13 @@ module Projects return unless project.lfs_enabled? - lfs_objects_to_download = Projects::LfsPointers::LfsImportService.new(project).execute + result = Projects::LfsPointers::LfsImportService.new(project).execute - lfs_objects_to_download.each do |lfs_download_object| - Projects::LfsPointers::LfsDownloadService.new(project, lfs_download_object) - .execute + if result[:status] == :error + # To avoid aborting the importing process, we silently fail + # if any exception raises. + Gitlab::AppLogger.error("The Lfs import process failed. #{result[:message]}") end - rescue => e - # Right now, to avoid aborting the importing process, we silently fail - # if any exception raises. - Rails.logger.error("The Lfs import process failed. #{e.message}") end def import_data diff --git a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb index a9570176e81..05974948505 100644 --- a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb +++ b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb @@ -21,9 +21,9 @@ module Projects # This method accepts two parameters: # - oids: hash of oids to query. The structure is { lfs_file_oid => lfs_file_size } # - # Returns a hash with the structure { lfs_file_oids => download_link } + # Returns an array of LfsDownloadObject def execute(oids) - return {} unless project&.lfs_enabled? && remote_uri && oids.present? + return [] unless project&.lfs_enabled? && remote_uri && oids.present? get_download_links(oids) end diff --git a/app/services/projects/lfs_pointers/lfs_import_service.rb b/app/services/projects/lfs_pointers/lfs_import_service.rb index 9215fa0a7bf..2afcce7099b 100644 --- a/app/services/projects/lfs_pointers/lfs_import_service.rb +++ b/app/services/projects/lfs_pointers/lfs_import_service.rb @@ -1,95 +1,23 @@ # frozen_string_literal: true -# This service manages the whole worflow of discovering the Lfs files in a -# repository, linking them to the project and downloading (and linking) the non -# existent ones. +# This service is responsible of managing the retrieval of the lfs objects, +# and call the service LfsDownloadService, which performs the download +# for each of the retrieved lfs objects module Projects module LfsPointers class LfsImportService < BaseService - include Gitlab::Utils::StrongMemoize - - HEAD_REV = 'HEAD'.freeze - LFS_ENDPOINT_PATTERN = /^\t?url\s*=\s*(.+)$/.freeze - LFS_BATCH_API_ENDPOINT = '/info/lfs/objects/batch'.freeze - - LfsImportError = Class.new(StandardError) - def execute - return {} unless project&.lfs_enabled? + return success unless project&.lfs_enabled? - if external_lfs_endpoint? - # If the endpoint host is different from the import_url it means - # that the repo is using a third party service for storing the LFS files. - # In this case, we have to disable lfs in the project - disable_lfs! + lfs_objects_to_download = LfsObjectDownloadListService.new(project).execute - return {} + lfs_objects_to_download.each do |lfs_download_object| + LfsDownloadService.new(project, lfs_download_object).execute end - get_download_links - rescue LfsDownloadLinkListService::DownloadLinksError => e - raise LfsImportError, "The LFS objects download list couldn't be imported. Error: #{e.message}" - end - - private - - def external_lfs_endpoint? - lfsconfig_endpoint_uri && lfsconfig_endpoint_uri.host != import_uri.host - end - - def disable_lfs! - project.update(lfs_enabled: false) - end - - # rubocop: disable CodeReuse/ActiveRecord - def get_download_links - existent_lfs = LfsListService.new(project).execute - linked_oids = LfsLinkService.new(project).execute(existent_lfs.keys) - - # Retrieving those oids not linked and which we need to download - not_linked_lfs = existent_lfs.except(*linked_oids) - - LfsDownloadLinkListService.new(project, remote_uri: current_endpoint_uri).execute(not_linked_lfs) - end - # rubocop: enable CodeReuse/ActiveRecord - - def lfsconfig_endpoint_uri - strong_memoize(:lfsconfig_endpoint_uri) do - # Retrieveing the blob data from the .lfsconfig file - data = project.repository.lfsconfig_for(HEAD_REV) - # Parsing the data to retrieve the url - parsed_data = data&.match(LFS_ENDPOINT_PATTERN) - - if parsed_data - URI.parse(parsed_data[1]).tap do |endpoint| - endpoint.user ||= import_uri.user - endpoint.password ||= import_uri.password - end - end - end - rescue URI::InvalidURIError - raise LfsImportError, 'Invalid URL in .lfsconfig file' - end - - def import_uri - @import_uri ||= URI.parse(project.import_url) - rescue URI::InvalidURIError - raise LfsImportError, 'Invalid project import URL' - end - - def current_endpoint_uri - (lfsconfig_endpoint_uri || default_endpoint_uri) - end - - # The import url must end with '.git' here we ensure it is - def default_endpoint_uri - @default_endpoint_uri ||= begin - import_uri.dup.tap do |uri| - path = uri.path.gsub(%r(/$), '') - path += '.git' unless path.ends_with?('.git') - uri.path = path + LFS_BATCH_API_ENDPOINT - end - end + success + rescue => e + error(e.message) end end end diff --git a/app/services/projects/lfs_pointers/lfs_link_service.rb b/app/services/projects/lfs_pointers/lfs_link_service.rb index 8401f3d1d89..e3c956250f0 100644 --- a/app/services/projects/lfs_pointers/lfs_link_service.rb +++ b/app/services/projects/lfs_pointers/lfs_link_service.rb @@ -6,9 +6,9 @@ module Projects class LfsLinkService < BaseService # Accept an array of oids to link # - # Returns a hash with the same structure with oids linked + # Returns an array with the oid of the existent lfs objects def execute(oids) - return {} unless project&.lfs_enabled? + return [] unless project&.lfs_enabled? # Search and link existing LFS Object link_existing_lfs_objects(oids) diff --git a/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb b/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb new file mode 100644 index 00000000000..5ba0f50f2ff --- /dev/null +++ b/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +# This service manages the whole worflow of discovering the Lfs files in a +# repository, linking them to the project and downloading (and linking) the non +# existent ones. +module Projects + module LfsPointers + class LfsObjectDownloadListService < BaseService + include Gitlab::Utils::StrongMemoize + + HEAD_REV = 'HEAD'.freeze + LFS_ENDPOINT_PATTERN = /^\t?url\s*=\s*(.+)$/.freeze + LFS_BATCH_API_ENDPOINT = '/info/lfs/objects/batch'.freeze + + LfsObjectDownloadListError = Class.new(StandardError) + + def execute + return [] unless project&.lfs_enabled? + + if external_lfs_endpoint? + # If the endpoint host is different from the import_url it means + # that the repo is using a third party service for storing the LFS files. + # In this case, we have to disable lfs in the project + disable_lfs! + + return [] + end + + # Getting all Lfs pointers already in the database and linking them to the project + linked_oids = LfsLinkService.new(project).execute(lfs_pointers_in_repository.keys) + # Retrieving those oids not present in the database which we need to download + missing_oids = lfs_pointers_in_repository.except(*linked_oids) # rubocop: disable CodeReuse/ActiveRecord + # Downloading the required information and gathering it inside a LfsDownloadObject for each oid + LfsDownloadLinkListService.new(project, remote_uri: current_endpoint_uri).execute(missing_oids) + rescue LfsDownloadLinkListService::DownloadLinksError => e + raise LfsObjectDownloadListError, "The LFS objects download list couldn't be imported. Error: #{e.message}" + end + + private + + def external_lfs_endpoint? + lfsconfig_endpoint_uri && lfsconfig_endpoint_uri.host != import_uri.host + end + + def disable_lfs! + unless project.update(lfs_enabled: false) + raise LfsDownloadLinkListService::DownloadLinksError, "Invalid project state" + end + end + + # Retrieves all lfs pointers in the repository + def lfs_pointers_in_repository + @lfs_pointers_in_repository ||= LfsListService.new(project).execute + end + + def lfsconfig_endpoint_uri + strong_memoize(:lfsconfig_endpoint_uri) do + # Retrieveing the blob data from the .lfsconfig file + data = project.repository.lfsconfig_for(HEAD_REV) + # Parsing the data to retrieve the url + parsed_data = data&.match(LFS_ENDPOINT_PATTERN) + + if parsed_data + URI.parse(parsed_data[1]).tap do |endpoint| + endpoint.user ||= import_uri.user + endpoint.password ||= import_uri.password + end + end + end + rescue URI::InvalidURIError + raise LfsObjectDownloadListError, 'Invalid URL in .lfsconfig file' + end + + def import_uri + @import_uri ||= URI.parse(project.import_url) + rescue URI::InvalidURIError + raise LfsObjectDownloadListError, 'Invalid project import URL' + end + + def current_endpoint_uri + (lfsconfig_endpoint_uri || default_endpoint_uri) + end + + # The import url must end with '.git' here we ensure it is + def default_endpoint_uri + @default_endpoint_uri ||= begin + import_uri.dup.tap do |uri| + path = uri.path.gsub(%r(/$), '') + path += '.git' unless path.ends_with?('.git') + uri.path = path + LFS_BATCH_API_ENDPOINT + end + end + end + end + end +end diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb index aedf79c86d7..48eddb0e8d0 100644 --- a/app/services/projects/operations/update_service.rb +++ b/app/services/projects/operations/update_service.rb @@ -12,7 +12,16 @@ module Projects private def project_update_params - error_tracking_params + error_tracking_params.merge(metrics_setting_params) + end + + def metrics_setting_params + attribs = params[:metrics_setting_attributes] + return {} unless attribs + + destroy = attribs[:external_dashboard_url].blank? + + { metrics_setting_attributes: attribs.merge(_destroy: destroy) } end def error_tracking_params diff --git a/app/services/tags/destroy_service.rb b/app/services/tags/destroy_service.rb index cab507946b4..4f6ae07be7d 100644 --- a/app/services/tags/destroy_service.rb +++ b/app/services/tags/destroy_service.rb @@ -41,12 +41,11 @@ module Tags def build_push_data(tag) Gitlab::DataBuilder::Push.build( - project, - current_user, - tag.dereferenced_target.sha, - Gitlab::Git::BLANK_SHA, - "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}", - []) + project: project, + user: current_user, + oldrev: tag.dereferenced_target.sha, + newrev: Gitlab::Git::BLANK_SHA, + ref: "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}") end end end diff --git a/app/services/test_hooks/project_service.rb b/app/services/test_hooks/project_service.rb index 6607f5b2418..a71278e8b8b 100644 --- a/app/services/test_hooks/project_service.rb +++ b/app/services/test_hooks/project_service.rb @@ -56,7 +56,7 @@ module TestHooks end def wiki_page_events_data - page = project.wiki.pages.first + page = project.wiki.list_pages(limit: 1).first if !project.wiki_enabled? || page.blank? throw(:validation_error, s_('TestHooks|Ensure the wiki is enabled and has pages.')) end diff --git a/app/services/todos/destroy/entity_leave_service.rb b/app/services/todos/destroy/entity_leave_service.rb index ebfb20132d0..4743e9b02ce 100644 --- a/app/services/todos/destroy/entity_leave_service.rb +++ b/app/services/todos/destroy/entity_leave_service.rb @@ -37,8 +37,8 @@ module Todos private def enqueue_private_features_worker - project_ids.each do |project_id| - TodosDestroyer::PrivateFeaturesWorker.perform_async(project_id, user.id) + projects.each do |project| + TodosDestroyer::PrivateFeaturesWorker.perform_async(project.id, user.id) end end @@ -62,9 +62,8 @@ module Todos end # rubocop: enable CodeReuse/ActiveRecord - override :project_ids # rubocop: disable CodeReuse/ActiveRecord - def project_ids + def projects condition = case entity when Project { id: entity.id } @@ -72,13 +71,13 @@ module Todos { namespace_id: non_member_groups } end - Project.where(condition).select(:id) + Project.where(condition) end # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord def non_authorized_projects - project_ids.where('id NOT IN (?)', user.authorized_projects.select(:id)) + projects.where('id NOT IN (?)', user.authorized_projects.select(:id)) end # rubocop: enable CodeReuse/ActiveRecord @@ -110,7 +109,7 @@ module Todos authorized_reporter_projects = user .authorized_projects(Gitlab::Access::REPORTER).select(:id) - Issue.where(project_id: project_ids, confidential: true) + Issue.where(project_id: projects, confidential: true) .where('project_id NOT IN(?)', authorized_reporter_projects) .where('author_id != ?', user.id) .where('id NOT IN (?)', assigned_ids) diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml index ad5c8d4da22..64e01fa2d00 100644 --- a/app/views/admin/application_settings/_pages.html.haml +++ b/app/views/admin/application_settings/_pages.html.haml @@ -5,16 +5,33 @@ .form-group = f.label :max_pages_size, 'Maximum size of pages (MB)', class: 'label-bold' = f.number_field :max_pages_size, class: 'form-control' - .form-text.text-muted 0 for unlimited + .form-text.text-muted + = _("0 for unlimited") .form-group .form-check = f.check_box :pages_domain_verification_enabled, class: 'form-check-input' = f.label :pages_domain_verification_enabled, class: 'form-check-label' do - Require users to prove ownership of custom domains + = _("Require users to prove ownership of custom domains") .form-text.text-muted - Domain verification is an essential security measure for public GitLab - sites. Users are required to demonstrate they control a domain before - it is enabled + = _("Domain verification is an essential security measure for public GitLab sites. Users are required to demonstrate they control a domain before it is enabled") = link_to icon('question-circle'), help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') + - if Feature.enabled?(:pages_auto_ssl) + %h5 + = _("Configure Let's Encrypt") + %p + - lets_encrypt_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: "https://letsencrypt.org/" } + = _("%{lets_encrypt_link_start}Let's Encrypt%{lets_encrypt_link_end} is a free, automated, and open certificate authority (CA), that give digital certificates in order to enable HTTPS (SSL/TLS) for websites.").html_safe % { lets_encrypt_link_start: lets_encrypt_link_start, lets_encrypt_link_end: '</a>'.html_safe } + .form-group + = f.label :lets_encrypt_notification_email, _("Email"), class: 'label-bold' + = f.text_field :lets_encrypt_notification_email, class: 'form-control' + .form-text.text-muted + = _("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.") + .form-group + .form-check + = f.check_box :lets_encrypt_terms_of_service_accepted, class: 'form-check-input' + = f.label :lets_encrypt_terms_of_service_accepted, class: 'form-check-label' do + // Terms of Service should actually be a link, but the best way to get the url is using API + // So it will be done in later MR + = _("I have read and agree to the Let's Encrypt Terms of Service") - = f.submit 'Save changes', class: "btn btn-success" + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml index c465d9f51d6..46beca0465e 100644 --- a/app/views/admin/broadcast_messages/_form.html.haml +++ b/app/views/admin/broadcast_messages/_form.html.haml @@ -14,6 +14,7 @@ .col-sm-10 = f.text_area :message, class: "form-control js-autosize", required: true, + dir: 'auto', data: { preview_path: preview_admin_broadcast_messages_path } .form-group.row.js-toggle-colors-container .col-sm-10.offset-sm-2 diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index e38a16e7a1a..80d706ae3d3 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -4,7 +4,7 @@ - page_title _('Kubernetes Cluster') - manage_prometheus_path = edit_project_service_path(@cluster.project, 'prometheus') if @project -- expanded = Rails.env.test? +- expanded = expanded_by_default? - status_path = clusterable.cluster_status_cluster_path(@cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster) .edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path, diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index 96d6553a2ac..b02fdb4b638 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -11,7 +11,8 @@ = link_to [event.project.namespace.becomes(Namespace), event.project, event.target], class: 'has-tooltip event-target-link append-right-4', title: event.target_title do = event.target.reference_link_text - unless event.milestone? - %span.event-target-title.append-right-4= """.html_safe + event.target.title + """.html_safe + %span.event-target-title.append-right-4{ dir: "auto" } + = """.html_safe + event.target.title + """.html_safe - else %span.event-type.d-inline-block.append-right-4{ class: event.action_name } = event_action_name(event) diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml index 90ed8e41d32..7e2103287f7 100644 --- a/app/views/events/event/_note.html.haml +++ b/app/views/events/event/_note.html.haml @@ -7,7 +7,8 @@ %span.event-type.d-inline-block.append-right-4{ class: event.action_name } = event.action_name = event_note_title_html(event) - %span.event-target-title.append-right-4= """.html_safe + event.target.title + """.html_safe + %span.event-target-title.append-right-4{ dir: "auto" } + = """.html_safe + event.target.title + """.html_safe = render "events/event_scope", event: event diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 2f635757902..0c8f86c2822 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -1,6 +1,6 @@ - breadcrumb_title "General Settings" - @content_class = "limit-container-width" unless fluid_layout -- expanded = Rails.env.test? +- expanded = expanded_by_default? %section.settings.gs-general.no-animate#js-general-settings{ class: ('expanded') } diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index d0f5cd94002..d21496ee0aa 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -1,7 +1,7 @@ - breadcrumb_title "CI / CD Settings" - page_title "CI / CD" -- expanded = Rails.env.test? +- expanded = expanded_by_default? %section.settings#ci-variables.no-animate{ class: ('expanded' if expanded) } .settings-header diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml index 916f98a62d1..75e4dc46c9b 100644 --- a/app/views/help/index.html.haml +++ b/app/views/help/index.html.haml @@ -1,6 +1,7 @@ %div - if Gitlab::CurrentSettings.help_page_text.present? - = markdown_field(Gitlab::CurrentSettings.current_application_settings, :help_page_text) + .prepend-top-default.md + = markdown_field(Gitlab::CurrentSettings.current_application_settings, :help_page_text) %hr %h1 diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index a9b85889846..319d0307f78 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -17,6 +17,8 @@ - if logo_text.present? %span.logo-text.d-none.d-lg-block.prepend-left-8 = logo_text + %span.js-canary-badge.badge.badge-pill.green-badge.align-self-center + = _('Next') - if current_user = render "layouts/nav/dashboard" @@ -38,7 +40,7 @@ = link_to assigned_issues_dashboard_path, title: _('Issues'), class: 'dashboard-shortcuts-issues', aria: { label: _('Issues') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = sprite_icon('issues', size: 16) - issues_count = assigned_issuables_count(:issues) - %span.badge.badge-pill.issues-count{ class: ('hidden' if issues_count.zero?) } + %span.badge.badge-pill.issues-count.green-badge{ class: ('hidden' if issues_count.zero?) } = number_with_delimiter(issues_count) - if header_link?(:merge_requests) = nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter" }) do diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index cd9128c452b..c53bfd8a85d 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -7,3 +7,6 @@ = link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback" - if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile) = render 'shared/user_dropdown_contributing_link' + - if Gitlab.com? + %li.js-canary-link + = link_to _("Switch to GitLab Next"), "https://next.gitlab.com/" diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml index 77d2e65d285..9ab648e2a64 100644 --- a/app/views/notify/new_merge_request_email.html.haml +++ b/app/views/notify/new_merge_request_email.html.haml @@ -3,7 +3,7 @@ #{link_to @merge_request.author_name, user_url(@merge_request.author)} created a merge request: %p.details - != merge_path_description(@merge_request, '→') + = merge_path_description(@merge_request, '→') - if @merge_request.assignees.any? %p diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 50adc19f524..3ca4abddbb8 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -80,6 +80,8 @@ - deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)') = deleted_message % { project_name: fork_source_name(@project) } + = render_if_exists "projects/home_mirror" + - if @project.badges.present? .project-badges.mb-2 - @project.badges.each do |badge| diff --git a/app/views/projects/_zen.html.haml b/app/views/projects/_zen.html.haml index afc40ca4eab..c502b392384 100644 --- a/app/views/projects/_zen.html.haml +++ b/app/views/projects/_zen.html.haml @@ -8,6 +8,7 @@ = f.text_area attr, class: classes, placeholder: placeholder, + dir: 'auto', data: { supports_quick_actions: supports_quick_actions, supports_autocomplete: supports_autocomplete } - else diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml index 888be4ee282..ed3c9890efd 100644 --- a/app/views/projects/cleanup/_show.html.haml +++ b/app/views/projects/cleanup/_show.html.haml @@ -1,4 +1,4 @@ -- expanded = Rails.env.test? +- expanded = expanded_by_default? %section.settings.no-animate#cleanup{ class: ('expanded' if expanded) } .settings-header diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml index ff6a9d49a61..59efcde5825 100644 --- a/app/views/projects/default_branch/_show.html.haml +++ b/app/views/projects/default_branch/_show.html.haml @@ -1,4 +1,4 @@ -- expanded = Rails.env.test? +- expanded = expanded_by_default? %section.settings.no-animate#default-branch-settings{ class: ('expanded' if expanded) } .settings-header diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml index 24d665761cc..fcf27351a21 100644 --- a/app/views/projects/deploy_keys/_index.html.haml +++ b/app/views/projects/deploy_keys/_index.html.haml @@ -1,4 +1,4 @@ -- expanded = Rails.env.test? +- expanded = expanded_by_default? %section.qa-deploy-keys-settings.settings.no-animate#js-deploy-keys-settings{ class: ('expanded' if expanded) } .settings-header %h4 diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index c04530dc62c..c15b84d0aac 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -1,7 +1,7 @@ - breadcrumb_title _("General Settings") - page_title _("General") - @content_class = "limit-container-width" unless fluid_layout -- expanded = Rails.env.test? +- expanded = expanded_by_default? %section.settings.general-settings.no-animate.expanded#js-general-settings .settings-header diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 945d1b00b08..0d8d7123a01 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -6,7 +6,7 @@ .issuable-info-container .issuable-main-info .issue-title.title - %span.issue-title-text + %span.issue-title-text{ dir: "auto" } - if issue.confidential? %span.has-tooltip{ title: _('Confidential') } = confidential_icon(issue) diff --git a/app/views/projects/issues/_merge_requests_status.html.haml b/app/views/projects/issues/_merge_requests_status.html.haml deleted file mode 100644 index 90838a75214..00000000000 --- a/app/views/projects/issues/_merge_requests_status.html.haml +++ /dev/null @@ -1,25 +0,0 @@ -- time_format = '%b %e, %Y %l:%M%P %Z%z' - -- if merge_request.merged? - - mr_status_date = merge_request.merged_at - - mr_status_title = _('Merged') - - mr_status_icon = 'merge' - - mr_status_class = 'merged' -- elsif merge_request.closed? - - mr_status_date = merge_request.closed_event&.created_at - - mr_status_title = _('Closed') - - mr_status_icon = 'issue-close' - - mr_status_class = 'closed' -- else - - mr_status_date = merge_request.created_at - - mr_status_title = mr_status_date ? _('Opened') : _('Open') - - mr_status_icon = 'issue-open-m' - - mr_status_class = 'open' - -- if mr_status_date - - mr_status_tooltip = "<div><span class=\"bold\">#{mr_status_title}</span> #{time_ago_in_words(mr_status_date)} ago</div><span class=\"text-tertiary\">#{l(mr_status_date.to_time, format: time_format)}</span>" -- else - - mr_status_tooltip = "<div><span class=\"bold\">#{mr_status_title}</span></div>" - -%span.mr-status-wrapper.suggestion-help-hover{ class: css_class, data: { toggle: 'tooltip', placement: 'bottom', html: 'true', title: mr_status_tooltip } } - = sprite_icon(mr_status_icon, size: 16, css_class: "merge-request-status #{mr_status_class}") diff --git a/app/views/projects/issues/new.html.haml b/app/views/projects/issues/new.html.haml index c6ff0d50ef4..d1601d7fd10 100644 --- a/app/views/projects/issues/new.html.haml +++ b/app/views/projects/issues/new.html.haml @@ -2,8 +2,7 @@ - breadcrumb_title _("New") - page_title _("New Issue") -%h3.page-title - _("New Issue") +%h3.page-title= _("New Issue") %hr = render "form" diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 0bf664d5b66..715c36fa9aa 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -15,7 +15,7 @@ .issuable-status-box.status-box.status-box-issue-closed{ class: issue_button_visibility(@issue, false) } = sprite_icon('mobile-issue-close', size: 16, css_class: 'd-block d-sm-none') .d-none.d-sm-block - - if @issue.moved? + - if @issue.moved? && can?(current_user, :read_issue, @issue.moved_to) - moved_link_start = "<a href=\"#{issue_path(@issue.moved_to)}\" class=\"text-white text-underline\">".html_safe - moved_link_end = '</a>'.html_safe = s_('IssuableStatus|Closed (%{moved_link_start}moved%{moved_link_end})').html_safe % {moved_link_start: moved_link_start, diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 79c586eef73..05aeb5d972b 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -37,21 +37,21 @@ %ul.merge-request-tabs.nav-tabs.nav.nav-links.scrolling-tabs %li.notes-tab.qa-notes-tab = tab_link_for @merge_request, :show, force_link: @commit.present? do - Discussion + = _("Discussion") %span.badge.badge-pill= @merge_request.related_notes.user.count - if @merge_request.source_project %li.commits-tab = tab_link_for @merge_request, :commits do - Commits + = _("Commits") %span.badge.badge-pill= @commits_count - if @pipelines.any? %li.pipelines-tab = tab_link_for @merge_request, :pipelines do - Pipelines + = _("Pipelines") %span.badge.badge-pill.js-pipelines-mr-count= @pipelines.size %li.diffs-tab.qa-diffs-tab = tab_link_for @merge_request, :diffs do - Changes + = _("Changes") %span.badge.badge-pill= @merge_request.diff_size .d-inline-flex.flex-wrap #js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@merge_request), diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index c031815200b..0cd00d3e708 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -1,4 +1,4 @@ -- expanded = Rails.env.test? +- expanded = expanded_by_default? - protocols = Gitlab::UrlSanitizer::ALLOWED_SCHEMES.join('|') %section.settings.project-mirror-settings.js-mirror-settings.no-animate.qa-mirroring-repositories-settings#js-push-remote-settings{ class: ('expanded' if expanded) } @@ -11,7 +11,7 @@ = link_to _('Read more'), help_page_path('workflow/repository_mirroring'), target: '_blank' .settings-content - = form_for @project, url: project_mirror_path(@project), html: { class: 'gl-show-field-errors js-mirror-form', autocomplete: 'false', data: mirrors_form_data_attributes } do |f| + = form_for @project, url: project_mirror_path(@project), html: { class: 'gl-show-field-errors js-mirror-form', autocomplete: 'new-password', data: mirrors_form_data_attributes } do |f| .panel.panel-default .panel-heading %h3.panel-title= _('Mirror a repository') @@ -20,7 +20,7 @@ .form-group.has-feedback = label_tag :url, _('Git repository URL'), class: 'label-light' - = text_field_tag :url, nil, class: 'form-control js-mirror-url js-repo-url qa-mirror-repository-url-input', placeholder: _('Input your repository URL'), required: true, pattern: "(#{protocols}):\/\/.+" + = text_field_tag :url, nil, class: 'form-control js-mirror-url js-repo-url qa-mirror-repository-url-input', placeholder: _('Input your repository URL'), required: true, pattern: "(#{protocols}):\/\/.+", autocomplete: 'new-password' = render 'projects/mirrors/instructions' diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 95027634de2..d7e16dbd40c 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -16,6 +16,7 @@ = _('A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}.').html_safe % { among_other_things_link: among_other_things_link } %p = _('All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings.') + = render_if_exists 'projects/new_ci_cd_banner_external_repo' %p - pages_getting_started_guide = link_to _('Pages getting started guide'), help_page_path("user/project/pages/getting_started_part_two", anchor: "fork-a-project-to-get-started-from"), target: '_blank' = _('Information about additional Pages templates and how to install them can be found in our %{pages_getting_started_guide}.').html_safe % { pages_getting_started_guide: pages_getting_started_guide } @@ -42,6 +43,7 @@ %a.nav-link{ href: '#import-project-pane', id: 'import-project-tab', data: { toggle: 'tab', track_label: 'import_project', track_event: "click_tab" }, role: 'tab' } %span.d-none.d-sm-block Import project %span.d-block.d-sm-none Import + = render_if_exists 'projects/new_ci_cd_only_project_tab', active_tab: active_tab .tab-content.gitlab-tab-content .tab-pane{ id: 'blank-project-pane', class: active_when(active_tab == 'blank'), role: 'tabpanel' } @@ -68,6 +70,8 @@ %h4 No import options available %p Contact an administrator to enable options for importing your project. + = render_if_exists 'projects/new_ci_cd_only_project_pane', active_tab: active_tab + .save-project-loader.d-none .center %h2 diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/projects/protected_branches/shared/_index.html.haml index 539b184e5c2..63748d8d85f 100644 --- a/app/views/projects/protected_branches/shared/_index.html.haml +++ b/app/views/projects/protected_branches/shared/_index.html.haml @@ -1,4 +1,4 @@ -- expanded = Rails.env.test? +- expanded = expanded_by_default? %section.qa-protected-branches-settings.settings.no-animate#js-protected-branches-settings{ class: ('expanded' if expanded) } .settings-header diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml index 9a50a51e4be..b0c87ac8c17 100644 --- a/app/views/projects/protected_tags/shared/_index.html.haml +++ b/app/views/projects/protected_tags/shared/_index.html.haml @@ -1,4 +1,4 @@ -- expanded = Rails.env.test? +- expanded = expanded_by_default? %section.settings.no-animate#js-protected-tags-settings{ class: ('expanded' if expanded) } .settings-header diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 548b7c06867..5e3e1076c2c 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -2,7 +2,7 @@ - page_title _("CI / CD Settings") - page_title _("CI / CD") -- expanded = Rails.env.test? +- expanded = expanded_by_default? - general_expanded = @project.errors.empty? ? expanded : true %section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded) } diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index 5b25a67bc87..8ae2807729b 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -21,10 +21,9 @@ - if @scope == 'projects' .term = render 'shared/projects/list', projects: @search_objects, pipeline_status: false - - elsif %w[blobs wiki_blobs].include?(@scope) - = render partial: 'search/results/blob', collection: @search_objects, locals: { projects: blob_projects(@search_objects) } - else - = render partial: "search/results/#{@scope.singularize}", collection: @search_objects + - locals = { projects: blob_projects(@search_objects) } if %w[blobs wiki_blobs].include?(@scope) + = render partial: "search/results/#{@scope.singularize}", collection: @search_objects, locals: locals - if @scope != 'projects' = paginate_collection(@search_objects) diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml index b351ecd4edf..5847751b268 100644 --- a/app/views/search/results/_wiki_blob.html.haml +++ b/app/views/search/results/_wiki_blob.html.haml @@ -1,5 +1,5 @@ - project = find_project_for_result_blob(projects, wiki_blob) - wiki_blob = parse_search_result(wiki_blob) -- wiki_blob_link = project_wiki_path(project, wiki_blob.basename) +- wiki_blob_link = project_wiki_path(project, Pathname.new(wiki_blob.filename).sub_ext('')) = render partial: 'search/results/blob_data', locals: { blob: wiki_blob, project: project, file_name: wiki_blob.filename, blob_link: wiki_blob_link } diff --git a/app/views/shared/_sidebar_toggle_button.html.haml b/app/views/shared/_sidebar_toggle_button.html.haml index aa428f9fe73..d90a6d43761 100644 --- a/app/views/shared/_sidebar_toggle_button.html.haml +++ b/app/views/shared/_sidebar_toggle_button.html.haml @@ -1,8 +1,8 @@ %a.toggle-sidebar-button.js-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" } = sprite_icon('angle-double-left', css_class: 'icon-angle-double-left') = sprite_icon('angle-double-right', css_class: 'icon-angle-double-right') - %span.collapse-text _("Collapse sidebar") + %span.collapse-text= _("Collapse sidebar") = button_tag class: 'close-nav-button', type: 'button' do = sprite_icon('close', size: 16) - %span.collapse-text _("Close sidebar") + %span.collapse-text= _("Close sidebar") diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml index 56c4b021eab..75e9ab547ce 100644 --- a/app/views/shared/issuable/form/_title.html.haml +++ b/app/views/shared/issuable/form/_title.html.haml @@ -6,7 +6,7 @@ %div{ class: div_class } = form.text_field :title, required: true, maxlength: 255, autofocus: true, - autocomplete: 'off', class: 'form-control pad qa-issuable-form-title', placeholder: _('Title') + autocomplete: 'off', class: 'form-control pad qa-issuable-form-title', placeholder: _('Title'), dir: 'auto' - if issuable.respond_to?(:work_in_progress?) .form-text.text-muted diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index f9b2e698fc9..7cf2e7100d5 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -32,6 +32,8 @@ - gcp_cluster:cluster_wait_for_ingress_ip_address - gcp_cluster:cluster_configure - gcp_cluster:cluster_project_configure +- gcp_cluster:clusters_applications_wait_for_uninstall_app +- gcp_cluster:clusters_applications_uninstall - github_import_advance_stage - github_importer:github_import_import_diff_note @@ -83,6 +85,7 @@ - pipeline_processing:ci_build_schedule - deployment:deployments_success +- deployment:deployments_finished - repository_check:repository_check_clear - repository_check:repository_check_batch diff --git a/app/workers/clusters/applications/uninstall_worker.rb b/app/workers/clusters/applications/uninstall_worker.rb new file mode 100644 index 00000000000..85e8ecc4ad5 --- /dev/null +++ b/app/workers/clusters/applications/uninstall_worker.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class UninstallWorker + include ApplicationWorker + include ClusterQueue + include ClusterApplications + + def perform(app_name, app_id) + find_application(app_name, app_id) do |app| + Clusters::Applications::UninstallService.new(app).execute + end + end + end + end +end diff --git a/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb new file mode 100644 index 00000000000..163c99d3c3c --- /dev/null +++ b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class WaitForUninstallAppWorker + include ApplicationWorker + include ClusterQueue + include ClusterApplications + + INTERVAL = 10.seconds + TIMEOUT = 20.minutes + + def perform(app_name, app_id) + find_application(app_name, app_id) do |app| + Clusters::Applications::CheckUninstallProgressService.new(app).execute + end + end + end + end +end diff --git a/app/workers/deployments/finished_worker.rb b/app/workers/deployments/finished_worker.rb new file mode 100644 index 00000000000..c9d448d5d18 --- /dev/null +++ b/app/workers/deployments/finished_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Deployments + class FinishedWorker + include ApplicationWorker + + queue_namespace :deployment + + def perform(deployment_id) + Deployment.find_by_id(deployment_id).try(:execute_hooks) + end + end +end diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb index 02a69ea3b54..8a9ee7808e4 100644 --- a/app/workers/pipeline_schedule_worker.rb +++ b/app/workers/pipeline_schedule_worker.rb @@ -3,20 +3,26 @@ class PipelineScheduleWorker include ApplicationWorker include CronjobQueue + include ::Gitlab::ExclusiveLeaseHelpers + + EXCLUSIVE_LOCK_KEY = 'pipeline_schedules:run:lock' + LOCK_TIMEOUT = 50.minutes # rubocop: disable CodeReuse/ActiveRecord def perform - Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now) - .preload(:owner, :project).find_each do |schedule| - - Ci::CreatePipelineService.new(schedule.project, - schedule.owner, - ref: schedule.ref) - .execute!(:schedule, ignore_skip_ci: true, save_on_errors: true, schedule: schedule) - rescue => e - error(schedule, e) - ensure - schedule.schedule_next_run! + in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do + Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now) + .preload(:owner, :project).find_each do |schedule| + + schedule.schedule_next_run! + + Ci::CreatePipelineService.new(schedule.project, + schedule.owner, + ref: schedule.ref) + .execute!(:schedule, ignore_skip_ci: true, save_on_errors: true, schedule: schedule) + rescue => e + error(schedule, e) + end end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 337efa7919b..9a9c0c9d803 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -21,8 +21,10 @@ class PostReceive if repo_type.wiki? process_wiki_changes(post_received) - else + elsif repo_type.project? process_project_changes(post_received) + else + # Other repos don't have hooks for now end end diff --git a/changelogs/unreleased/10327-enable-reliable-fetcher-by-default.yml b/changelogs/unreleased/10327-enable-reliable-fetcher-by-default.yml new file mode 100644 index 00000000000..89d2fced6e1 --- /dev/null +++ b/changelogs/unreleased/10327-enable-reliable-fetcher-by-default.yml @@ -0,0 +1,5 @@ +--- +title: Enable Sidekiq Reliable Fetcher for background jobs by default +merge_request: 27530 +author: +type: added diff --git a/changelogs/unreleased/11254-overflow-ce.yml b/changelogs/unreleased/11254-overflow-ce.yml new file mode 100644 index 00000000000..dcac46000ac --- /dev/null +++ b/changelogs/unreleased/11254-overflow-ce.yml @@ -0,0 +1,5 @@ +--- +title: Hide ScopedBadge overflow notes +merge_request: 27651 +author: +type: fixed diff --git a/changelogs/unreleased/25604-add-dotnet-core-yaml-template.yml b/changelogs/unreleased/25604-add-dotnet-core-yaml-template.yml new file mode 100644 index 00000000000..ef9172aaf3b --- /dev/null +++ b/changelogs/unreleased/25604-add-dotnet-core-yaml-template.yml @@ -0,0 +1,5 @@ +--- +title: Add .NET Core YAML template +merge_request: 25604 +author: Piotr Wosiek +type: added diff --git a/changelogs/unreleased/46048-canary-next.yml b/changelogs/unreleased/46048-canary-next.yml new file mode 100644 index 00000000000..1a702cccff9 --- /dev/null +++ b/changelogs/unreleased/46048-canary-next.yml @@ -0,0 +1,5 @@ +--- +title: Adds badge for Canary environment and help link +merge_request: +author: +type: added diff --git a/changelogs/unreleased/48479-auto-direction-for-issue-title.yml b/changelogs/unreleased/48479-auto-direction-for-issue-title.yml new file mode 100644 index 00000000000..0571f58ab4d --- /dev/null +++ b/changelogs/unreleased/48479-auto-direction-for-issue-title.yml @@ -0,0 +1,5 @@ +--- +title: Add auto direction for issue title +merge_request: 27378 +author: Ahmad Haghighi +type: fixed diff --git a/changelogs/unreleased/55948-help-text-formatting-wiki.yml b/changelogs/unreleased/55948-help-text-formatting-wiki.yml new file mode 100644 index 00000000000..e1e0475a117 --- /dev/null +++ b/changelogs/unreleased/55948-help-text-formatting-wiki.yml @@ -0,0 +1,5 @@ +--- +title: Format extra help page text like wiki +merge_request: 26782 +author: Bastian Blank +type: fixed diff --git a/changelogs/unreleased/57017-add-toast-success-message.yml b/changelogs/unreleased/57017-add-toast-success-message.yml new file mode 100644 index 00000000000..931e7755591 --- /dev/null +++ b/changelogs/unreleased/57017-add-toast-success-message.yml @@ -0,0 +1,5 @@ +--- +title: Display a toast message when the Kubernetes runner has successfully upgraded. +merge_request: 27206 +author: +type: changed diff --git a/changelogs/unreleased/57171-add-dashboard-settings.yml b/changelogs/unreleased/57171-add-dashboard-settings.yml new file mode 100644 index 00000000000..f235872b35c --- /dev/null +++ b/changelogs/unreleased/57171-add-dashboard-settings.yml @@ -0,0 +1,5 @@ +--- +title: Add backend support for a External Dashboard URL setting +merge_request: 27550 +author: +type: added diff --git a/changelogs/unreleased/60387-use-icons-in-user-popovers.yml b/changelogs/unreleased/60387-use-icons-in-user-popovers.yml new file mode 100644 index 00000000000..100d33690b3 --- /dev/null +++ b/changelogs/unreleased/60387-use-icons-in-user-popovers.yml @@ -0,0 +1,5 @@ +--- +title: Show category icons in user popover +merge_request: +author: +type: added diff --git a/changelogs/unreleased/60552-period-dropdown.yml b/changelogs/unreleased/60552-period-dropdown.yml new file mode 100644 index 00000000000..e1b4a098ab0 --- /dev/null +++ b/changelogs/unreleased/60552-period-dropdown.yml @@ -0,0 +1,5 @@ +--- +title: Fix autocomplete dropdown for usernames starting with period +merge_request: 27533 +author: Jan Beckmann +type: fixed diff --git a/changelogs/unreleased/60687-enviro-dropdown.yml b/changelogs/unreleased/60687-enviro-dropdown.yml new file mode 100644 index 00000000000..1fc5a7dd6f5 --- /dev/null +++ b/changelogs/unreleased/60687-enviro-dropdown.yml @@ -0,0 +1,5 @@ +--- +title: Fix Metrics Environments dropdown +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/60808-only-show-target-branch-advanced-error-before-merge.yml b/changelogs/unreleased/60808-only-show-target-branch-advanced-error-before-merge.yml new file mode 100644 index 00000000000..b340f8408f3 --- /dev/null +++ b/changelogs/unreleased/60808-only-show-target-branch-advanced-error-before-merge.yml @@ -0,0 +1,6 @@ +--- +title: Only show the "target branch has advanced" message when the merge request is + open +merge_request: 27588 +author: +type: fixed diff --git a/changelogs/unreleased/60821-deployment-jobs-broken-as-of-11-10-0.yml b/changelogs/unreleased/60821-deployment-jobs-broken-as-of-11-10-0.yml new file mode 100644 index 00000000000..88584352d42 --- /dev/null +++ b/changelogs/unreleased/60821-deployment-jobs-broken-as-of-11-10-0.yml @@ -0,0 +1,5 @@ +--- +title: Fix Kubernetes service template deployment jobs broken as of 11.10.0 +merge_request: 27687 +author: +type: fixed diff --git a/changelogs/unreleased/60855-mr-popover-is-not-attached-in-system-notes.yml b/changelogs/unreleased/60855-mr-popover-is-not-attached-in-system-notes.yml new file mode 100644 index 00000000000..d8bc0fbb4d4 --- /dev/null +++ b/changelogs/unreleased/60855-mr-popover-is-not-attached-in-system-notes.yml @@ -0,0 +1,5 @@ +--- +title: Fix bug where system note MR has no popover +merge_request: 27747 +author: +type: fixed diff --git a/changelogs/unreleased/60874-fix-suggestion-misalignment.yml b/changelogs/unreleased/60874-fix-suggestion-misalignment.yml new file mode 100644 index 00000000000..f5717ac19fd --- /dev/null +++ b/changelogs/unreleased/60874-fix-suggestion-misalignment.yml @@ -0,0 +1,5 @@ +--- +title: Resolve Misalignment on suggested changes diff table +merge_request: 27612 +author: +type: fixed diff --git a/changelogs/unreleased/60906-fix-wiki-links.yml b/changelogs/unreleased/60906-fix-wiki-links.yml new file mode 100644 index 00000000000..cc65a1382bf --- /dev/null +++ b/changelogs/unreleased/60906-fix-wiki-links.yml @@ -0,0 +1,5 @@ +--- +title: Show proper wiki links in search results +merge_request: 27634 +author: +type: fixed diff --git a/changelogs/unreleased/61036-fix-ingress-base-domain-text.yml b/changelogs/unreleased/61036-fix-ingress-base-domain-text.yml new file mode 100644 index 00000000000..32f0e023923 --- /dev/null +++ b/changelogs/unreleased/61036-fix-ingress-base-domain-text.yml @@ -0,0 +1,5 @@ +--- +title: Fix base domain help text update +merge_request: 27746 +author: +type: fixed diff --git a/changelogs/unreleased/autodevops_remote_private_helm_repository.yml b/changelogs/unreleased/autodevops_remote_private_helm_repository.yml new file mode 100644 index 00000000000..5341abb1095 --- /dev/null +++ b/changelogs/unreleased/autodevops_remote_private_helm_repository.yml @@ -0,0 +1,6 @@ +--- +title: Allow linking to a private helm repository by providing credentials, and customisation + of repository name +merge_request: 27123 +author: Stuart Moore @stjm-cc +type: added diff --git a/changelogs/unreleased/fix-api-ide-relative-url-root.yml b/changelogs/unreleased/fix-api-ide-relative-url-root.yml new file mode 100644 index 00000000000..8c058645f3e --- /dev/null +++ b/changelogs/unreleased/fix-api-ide-relative-url-root.yml @@ -0,0 +1,5 @@ +--- +title: Fix FE API and IDE handling of '/' relative_url_root +merge_request: 27635 +author: +type: fixed diff --git a/changelogs/unreleased/fix-ci-commit-ref-name-and-slug.yml b/changelogs/unreleased/fix-ci-commit-ref-name-and-slug.yml new file mode 100644 index 00000000000..c34bc6d8b52 --- /dev/null +++ b/changelogs/unreleased/fix-ci-commit-ref-name-and-slug.yml @@ -0,0 +1,5 @@ +--- +title: Make `CI_COMMIT_REF_NAME` and `SLUG` variable idempotent +merge_request: 27663 +author: +type: fixed diff --git a/changelogs/unreleased/fix-lazy-blobs-requesting-all-previous-blobs.yml b/changelogs/unreleased/fix-lazy-blobs-requesting-all-previous-blobs.yml new file mode 100644 index 00000000000..58f5a9c943c --- /dev/null +++ b/changelogs/unreleased/fix-lazy-blobs-requesting-all-previous-blobs.yml @@ -0,0 +1,6 @@ +--- +title: Fix Blob.lazy always loading all previously-requested blobs when a new request + is made +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/fix-ref-text-of-mr-pipelines.yml b/changelogs/unreleased/fix-ref-text-of-mr-pipelines.yml new file mode 100644 index 00000000000..8803f9b52a4 --- /dev/null +++ b/changelogs/unreleased/fix-ref-text-of-mr-pipelines.yml @@ -0,0 +1,6 @@ +--- +title: Fix pipelines for merge requests does not show pipeline page when source branch + is removed +merge_request: 27803 +author: +type: fixed diff --git a/changelogs/unreleased/fj-53523-add-option-avoid-loading-wiki-page-content.yml b/changelogs/unreleased/fj-53523-add-option-avoid-loading-wiki-page-content.yml new file mode 100644 index 00000000000..49eaff52e5a --- /dev/null +++ b/changelogs/unreleased/fj-53523-add-option-avoid-loading-wiki-page-content.yml @@ -0,0 +1,5 @@ +--- +title: Added list_pages method to avoid loading all wiki pages content +merge_request: 22801 +author: +type: performance diff --git a/changelogs/unreleased/gitaly-version-v1.36.0.yml b/changelogs/unreleased/gitaly-version-v1.36.0.yml new file mode 100644 index 00000000000..22fdca8da80 --- /dev/null +++ b/changelogs/unreleased/gitaly-version-v1.36.0.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade to Gitaly v1.36.0 +merge_request: 27831 +author: +type: changed diff --git a/changelogs/unreleased/issue-42692-deployment-chat-notifications.yml b/changelogs/unreleased/issue-42692-deployment-chat-notifications.yml new file mode 100644 index 00000000000..3f0a96ad50e --- /dev/null +++ b/changelogs/unreleased/issue-42692-deployment-chat-notifications.yml @@ -0,0 +1,5 @@ +--- +title: Add deployment events to chat notification services +merge_request: 27338 +author: +type: added diff --git a/changelogs/unreleased/jc-client-gitaly-session-id.yml b/changelogs/unreleased/jc-client-gitaly-session-id.yml new file mode 100644 index 00000000000..ae5b7144b98 --- /dev/null +++ b/changelogs/unreleased/jc-client-gitaly-session-id.yml @@ -0,0 +1,5 @@ +--- +title: Add gitaly session id & catfile-cache feature flag +merge_request: 27472 +author: +type: performance diff --git a/changelogs/unreleased/jc-update-list-last-commits.yml b/changelogs/unreleased/jc-update-list-last-commits.yml new file mode 100644 index 00000000000..0e72c4255ae --- /dev/null +++ b/changelogs/unreleased/jc-update-list-last-commits.yml @@ -0,0 +1,5 @@ +--- +title: Client side changes for ListLastCommitsForTree response update +merge_request: 26880 +author: +type: fixed diff --git a/changelogs/unreleased/lock-pipeline-schedule-worker.yml b/changelogs/unreleased/lock-pipeline-schedule-worker.yml new file mode 100644 index 00000000000..1b889f01620 --- /dev/null +++ b/changelogs/unreleased/lock-pipeline-schedule-worker.yml @@ -0,0 +1,5 @@ +--- +title: Prevent concurrent execution of PipelineScheduleWorker +merge_request: 27781 +author: +type: performance diff --git a/changelogs/unreleased/pl-upgrade-letter_opener_web.yml b/changelogs/unreleased/pl-upgrade-letter_opener_web.yml new file mode 100644 index 00000000000..9891344215a --- /dev/null +++ b/changelogs/unreleased/pl-upgrade-letter_opener_web.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade letter_opener_web to support Rails 5.1 +merge_request: 27829 +author: +type: fixed diff --git a/changelogs/unreleased/refactor-58827-migrate-issue-spec-to-jest.yml b/changelogs/unreleased/refactor-58827-migrate-issue-spec-to-jest.yml new file mode 100644 index 00000000000..03d94c39c10 --- /dev/null +++ b/changelogs/unreleased/refactor-58827-migrate-issue-spec-to-jest.yml @@ -0,0 +1,5 @@ +--- +title: 'refactor(issue): Refactored issue tests from Karma to Jest' +merge_request: 27673 +author: Martin Hobert +type: other diff --git a/changelogs/unreleased/refactor-58829-migrate-notes-spec-to-jest.yml b/changelogs/unreleased/refactor-58829-migrate-notes-spec-to-jest.yml new file mode 100644 index 00000000000..9a1886797da --- /dev/null +++ b/changelogs/unreleased/refactor-58829-migrate-notes-spec-to-jest.yml @@ -0,0 +1,5 @@ +--- +title: 'Refactored notes tests from Karma to Jest' +merge_request: 27648 +author: Martin Hobert +type: other diff --git a/changelogs/unreleased/secure-disallow-read-user-scope-to-read-project-events.yml b/changelogs/unreleased/secure-disallow-read-user-scope-to-read-project-events.yml new file mode 100644 index 00000000000..4a91bfa8827 --- /dev/null +++ b/changelogs/unreleased/secure-disallow-read-user-scope-to-read-project-events.yml @@ -0,0 +1,5 @@ +--- +title: Allow to see project events only with api scope token +merge_request: +author: +type: security diff --git a/changelogs/unreleased/sh-disable-batch-load-replace-methods.yml b/changelogs/unreleased/sh-disable-batch-load-replace-methods.yml new file mode 100644 index 00000000000..00f897ac4b1 --- /dev/null +++ b/changelogs/unreleased/sh-disable-batch-load-replace-methods.yml @@ -0,0 +1,5 @@ +--- +title: Disable method replacement in avatar loading +merge_request: 27866 +author: +type: performance diff --git a/changelogs/unreleased/sh-fix-autocomplete-mirror-repo.yml b/changelogs/unreleased/sh-fix-autocomplete-mirror-repo.yml new file mode 100644 index 00000000000..e855684bab1 --- /dev/null +++ b/changelogs/unreleased/sh-fix-autocomplete-mirror-repo.yml @@ -0,0 +1,5 @@ +--- +title: Disable password autocomplete in mirror repository form +merge_request: 27542 +author: +type: fixed diff --git a/changelogs/unreleased/sh-fix-slow-partial-rendering.yml b/changelogs/unreleased/sh-fix-slow-partial-rendering.yml new file mode 100644 index 00000000000..0f65a6f8d69 --- /dev/null +++ b/changelogs/unreleased/sh-fix-slow-partial-rendering.yml @@ -0,0 +1,5 @@ +--- +title: Fix slow performance with compiling HAML templates +merge_request: 27782 +author: +type: performance diff --git a/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-4-1.yml b/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-4-1.yml new file mode 100644 index 00000000000..f36c1d0e77e --- /dev/null +++ b/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-4-1.yml @@ -0,0 +1,5 @@ +--- +title: Update GitLab Runner Helm Chart to 0.4.1 +merge_request: 27627 +author: +type: other diff --git a/changelogs/unreleased/wiki-search-results-fix.yml b/changelogs/unreleased/wiki-search-results-fix.yml new file mode 100644 index 00000000000..693867eb385 --- /dev/null +++ b/changelogs/unreleased/wiki-search-results-fix.yml @@ -0,0 +1,5 @@ +--- +title: fix wiki search result links in titles +merge_request: 27400 +author: khm +type: fixed diff --git a/changelogs/unreleased/winh-boards-drag-selection.yml b/changelogs/unreleased/winh-boards-drag-selection.yml new file mode 100644 index 00000000000..84b23ab664b --- /dev/null +++ b/changelogs/unreleased/winh-boards-drag-selection.yml @@ -0,0 +1,5 @@ +--- +title: Prevent text selection when dragging in issue boards +merge_request: 27724 +author: +type: fixed diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 2e4aa9c1053..7b69cf11288 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -1,5 +1,17 @@ require 'sidekiq/web' +def enable_reliable_fetch? + return true unless Feature::FlipperFeature.table_exists? + + Feature.enabled?(:gitlab_sidekiq_reliable_fetcher, default_enabled: true) +end + +def enable_semi_reliable_fetch_mode? + return true unless Feature::FlipperFeature.table_exists? + + Feature.enabled?(:gitlab_sidekiq_enable_semi_reliable_fetcher, default_enabled: true) +end + # Disable the Sidekiq Rack session since GitLab already has its own session store. # CSRF protection still works (https://github.com/mperham/sidekiq/commit/315504e766c4fd88a29b7772169060afc4c40329). Sidekiq::Web.set :sessions, false @@ -45,9 +57,8 @@ Sidekiq.configure_server do |config| ActiveRecord::Base.clear_all_connections! end - if Feature::FlipperFeature.table_exists? && Feature.enabled?(:gitlab_sidekiq_reliable_fetcher) - # By default we're going to use Semi Reliable Fetch - config.options[:semi_reliable_fetch] = Feature.enabled?(:gitlab_sidekiq_enable_semi_reliable_fetcher, default_enabled: true) + if enable_reliable_fetch? + config.options[:semi_reliable_fetch] = enable_semi_reliable_fetch_mode? Sidekiq::ReliableFetch.setup_reliable_fetch!(config) end diff --git a/config/locales/en.yml b/config/locales/en.yml index eb3b7771968..a3dceb2fb62 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -14,6 +14,8 @@ en: token: "Auth Token" project: "Project" api_url: "Sentry API URL" + project/metrics_setting: + external_dashboard_url: "External dashboard URL" errors: messages: label_already_exists_at_group_level: "already exists at group level for %{group}. Please choose another one." diff --git a/config/routes.rb b/config/routes.rb index bbf00208545..f5957f43655 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -103,6 +103,7 @@ Rails.application.routes.draw do scope :applications do post '/:application', to: 'clusters/applications#create', as: :install_applications patch '/:application', to: 'clusters/applications#update', as: :update_applications + delete '/:application', to: 'clusters/applications#destroy', as: :uninstall_applications end get :cluster_status, format: :json diff --git a/config/routes/project.rb b/config/routes/project.rb index 93d168fc595..61eb136f65b 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -218,6 +218,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do get :terminal get :metrics get :additional_metrics + get :metrics_dashboard get '/terminal.ws/authorize', to: 'environments#terminal_websocket_authorize', constraints: { format: nil } get '/prometheus/api/v1/*proxy_path', to: 'environments/prometheus_api#proxy' @@ -360,7 +361,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do post :toggle_subscription post :mark_as_spam post :move - get :referenced_merge_requests get :related_branches get :can_create_branch get :realtime_changes diff --git a/db/migrate/20190320174702_add_lets_encrypt_notification_email_to_application_settings.rb b/db/migrate/20190320174702_add_lets_encrypt_notification_email_to_application_settings.rb new file mode 100644 index 00000000000..e9cf2af84a5 --- /dev/null +++ b/db/migrate/20190320174702_add_lets_encrypt_notification_email_to_application_settings.rb @@ -0,0 +1,15 @@ +# 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 AddLetsEncryptNotificationEmailToApplicationSettings < ActiveRecord::Migration[5.0] + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + add_column :application_settings, :lets_encrypt_notification_email, :string + end +end diff --git a/db/migrate/20190329085614_add_lets_encrypt_terms_of_service_accepted_to_application_settings.rb b/db/migrate/20190329085614_add_lets_encrypt_terms_of_service_accepted_to_application_settings.rb new file mode 100644 index 00000000000..16de63f207f --- /dev/null +++ b/db/migrate/20190329085614_add_lets_encrypt_terms_of_service_accepted_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 AddLetsEncryptTermsOfServiceAcceptedToApplicationSettings < ActiveRecord::Migration[5.0] + 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(:application_settings, :lets_encrypt_terms_of_service_accepted, :boolean, default: false) + end + + def down + remove_column :application_settings, :lets_encrypt_terms_of_service_accepted + end +end diff --git a/db/migrate/20190422082247_create_project_metrics_settings.rb b/db/migrate/20190422082247_create_project_metrics_settings.rb new file mode 100644 index 00000000000..3e21dd0a934 --- /dev/null +++ b/db/migrate/20190422082247_create_project_metrics_settings.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateProjectMetricsSettings < ActiveRecord::Migration[5.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :project_metrics_settings, id: :int, primary_key: :project_id, default: nil do |t| + t.string :external_dashboard_url, null: false + t.foreign_key :projects, column: :project_id, on_delete: :cascade + end + end +end diff --git a/db/migrate/20190426180107_add_deployment_events_to_services.rb b/db/migrate/20190426180107_add_deployment_events_to_services.rb new file mode 100644 index 00000000000..1fb137fb5f9 --- /dev/null +++ b/db/migrate/20190426180107_add_deployment_events_to_services.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddDeploymentEventsToServices < ActiveRecord::Migration[5.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default(:services, :deployment_events, :boolean, default: false, allow_null: false) + end + + def down + remove_column(:services, :deployment_events) + end +end diff --git a/db/schema.rb b/db/schema.rb index 3a5d567ac57..5a486b369e3 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: 20190408163745) do +ActiveRecord::Schema.define(version: 20190426180107) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -187,6 +187,8 @@ ActiveRecord::Schema.define(version: 20190408163745) do t.string "encrypted_external_auth_client_key_iv" t.string "encrypted_external_auth_client_key_pass" t.string "encrypted_external_auth_client_key_pass_iv" + t.string "lets_encrypt_notification_email" + t.boolean "lets_encrypt_terms_of_service_accepted", default: false, null: false t.index ["usage_stats_set_by_user_id"], name: "index_application_settings_on_usage_stats_set_by_user_id", using: :btree end @@ -1681,6 +1683,10 @@ ActiveRecord::Schema.define(version: 20190408163745) do t.index ["project_id"], name: "index_project_import_data_on_project_id", using: :btree end + create_table "project_metrics_settings", primary_key: "project_id", id: :integer, default: nil, force: :cascade do |t| + t.string "external_dashboard_url", null: false + end + create_table "project_mirror_data", id: :serial, force: :cascade do |t| t.integer "project_id", null: false t.string "status" @@ -1995,6 +2001,7 @@ ActiveRecord::Schema.define(version: 20190408163745) do t.boolean "commit_events", default: true, null: false t.boolean "job_events", default: false, null: false t.boolean "confidential_note_events", default: true + t.boolean "deployment_events", default: false, null: false t.index ["project_id"], name: "index_services_on_project_id", using: :btree t.index ["template"], name: "index_services_on_template", using: :btree t.index ["type"], name: "index_services_on_type", using: :btree @@ -2529,6 +2536,7 @@ ActiveRecord::Schema.define(version: 20190408163745) do add_foreign_key "project_features", "projects", name: "fk_18513d9b92", on_delete: :cascade add_foreign_key "project_group_links", "projects", name: "fk_daa8cee94c", on_delete: :cascade add_foreign_key "project_import_data", "projects", name: "fk_ffb9ee3a10", on_delete: :cascade + add_foreign_key "project_metrics_settings", "projects", on_delete: :cascade add_foreign_key "project_mirror_data", "projects", on_delete: :cascade add_foreign_key "project_repositories", "projects", on_delete: :cascade add_foreign_key "project_repositories", "shards", on_delete: :restrict diff --git a/doc/administration/high_availability/nfs.md b/doc/administration/high_availability/nfs.md index 72341a5c777..d0e0e320019 100644 --- a/doc/administration/high_availability/nfs.md +++ b/doc/administration/high_availability/nfs.md @@ -39,7 +39,14 @@ options: ### Improving NFS performance with GitLab -NOTE: **Note:** This is only available with GitLab 11.9 and up. +NOTE: **Note:** +This is only available starting in certain versions of GitLab: + + * 11.5.11 + * 11.6.11 + * 11.7.12 + * 11.8.8 + * 11.9.0 and up (e.g. 11.10, 11.11, etc.) If you are using NFS to share Git data, we recommend that you enable a number of feature flags that will allow GitLab application processes to diff --git a/doc/api/users.md b/doc/api/users.md index 606003a75e2..d3e67d3d510 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -33,7 +33,7 @@ GET /users ] ``` -You can also search for users by email or username with: `/users?search=John` +You can also search for users by name or primary email using `?search=`. For example. `/users?search=John`. In addition, you can lookup users by username: diff --git a/doc/ci/README.md b/doc/ci/README.md index 123a5e50f14..440a79c7782 100644 --- a/doc/ci/README.md +++ b/doc/ci/README.md @@ -5,71 +5,113 @@ description: "Learn how to use GitLab CI/CD, the GitLab built-in Continuous Inte # GitLab Continuous Integration (GitLab CI/CD) -GitLab CI/CD is GitLab's built-in tool for software development using continuous methodology: +GitLab CI/CD is a tool built into GitLab for software development +through the [continuous methodologies](introduction/index.md#introduction-to-cicd-methodologies): -- Continuous integration (CI). -- Continuous delivery and deployment (CD). - -Within the [DevOps lifecycle](../README.md#the-entire-devops-lifecycle), GitLab CI/CD spans -the [Verify (CI)](../README.md#verify) and [Release (CD)](../README.md#release) stages. +- Continuous Integration (CI) +- Continuous Delivery (CD) +- Continuous Deployment (CD) ## Overview -CI/CD is a vast area, so GitLab provides documentation for all levels of expertise. Consult the following table to find the right documentation for you: - -| Level of expertise | Resource | -|:------------------------------------|:------------------------------------------------------------------------------------------------------------------------------------------| -| New to the concepts of CI and CD | For a high-level overview, read an [introduction to CI/CD with GitLab](introduction/index.md). | -| Familiar with GitLab CI/CD concepts | After getting familiar with GitLab CI/CD, let us walk you through a simple example in our [getting started guide](quick_start/README.md). | -| A GitLab CI/CD expert | Jump straight to our [`.gitlab.yml`](yaml/README.md) reference. | - -Familiarity with GitLab Runner is also useful because it is responsible for running the jobs in your -CI/CD pipeline. On GitLab.com, shared Runners are enabled by default so you won't need to set this up to get started. - -## CI/CD with Auto DevOps - -[Auto DevOps](../topics/autodevops/index.md) is the default minimum-configuration method for -implementing CI/CD. Auto DevOps: - -- Provides simplified setup and execution of CI/CD. -- Allows GitLab to automatically detect, build, test, deploy, and monitor your applications. - -## Manually configured CI/CD - -For complete control, you can manually configure GitLab CI/CD. - -### Configuration and Usage - -The following topics contain configuration and usage information for all features of GitLab CI/CD: - -| Topic | Description | -|:--------------------------------------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------| -| [Creating and using CI/CD pipelines](pipelines.md) | Understand, visualize, create, and use CI/CD pipelines. | -| [CI/CD Variables](variables/README.md) | Configuring and using environment variables in pipelines. | -| [Where variables can be used](variables/where_variables_can_be_used.md) | Where and how CI/CD variables can be used. | -| [User](../user/permissions.md#gitlab-cicd-permissions) and [job](../user/permissions.md#job-permissions) permissions | User access levels for performing certain CI actions. | -| [Configuring GitLab Runners](runners/README.md) | Configuring [GitLab Runner](https://docs.gitlab.com/runner/). | -| [Environments and deployments](environments.md) | Deploy the output of jobs into environments for reviewing, staging, and production. | -| [Review Apps](review_apps/index.md) | Configure GitLab CI/CD to preview code changes. | -| [Job artifacts](../user/project/pipelines/job_artifacts.md) | Using the output of jobs. | -| [Cache dependencies in GitLab CI/CD](caching/index.md) | Speed up pipelines using caching. | -| [Using Git submodules with GitLab CI](git_submodules.md) | How to run your CI jobs when using Git submodules. | -| [Using SSH keys with GitLab CI/CD](ssh_keys/README.md) | Use SSH keys in your build environment. | -| [Triggering pipelines through the API](triggers/README.md) | Use the GitLab API to trigger a pipeline. | -| [Connecting GitLab with a Kubernetes cluster](../user/project/clusters/index.md) | Integrate one or more Kubernetes clusters to your project. | -| [ChatOps](chatops/README.md) | Trigger CI jobs from chat, with results sent back to the channel. | -| [Interactive web terminals](interactive_web_terminal/index.md) | Open an interactive web terminal to debug the running jobs. | -| [Optimizing GitLab for large repositories](large_repositories/index.md) | Useful tips on how to optimize GitLab and GitLab Runner for big repositories. | -| [Deploy Boards](https://docs.gitlab.com/ee/user/project/deploy_boards.html) **[PREMIUM]** | Check the current health and status of each CI/CD environment running on Kubernetes. | -| [GitLab CI/CD for external repositories](https://docs.gitlab.com/ee/ci/ci_cd_for_external_repos/index.html) **[PREMIUM]** | Get the benefits of GitLab CI/CD combined with repositories in GitHub and BitBucket Cloud. | - -### GitLab Pages - -GitLab CI/CD can be used to build and host static websites. For more information, see the -documentation on [GitLab Pages](../user/project/pages/index.md), -or dive right into the [CI/CD step-by-step guide for Pages](../user/project/pages/getting_started_part_four.md). - -### Examples +Continuous Integration works by pushing small code chunks to your +application's code base hosted in a Git repository, and, to every +push, run a pipeline of scripts to build, test, and validate the +code changes before merging them into the main branch. + +Continuous Delivery and Deployment consist of a step further CI, +deploying your application to production at every +push to the default branch of the repository. + +These methodologies allow you to catch bugs and errors early in +the development cycle, ensuring that all the code deployed to +production complies with the code standards you established for +your app. + +For a complete overview of these methodologies and GitLab CI/CD, +read the [Introduction to CI/CD with GitLab](introduction/index.md). + +## Getting started + +GitLab CI/CD is configured by a file called `.gitlab-ci.yml` placed +at the repository's root. The scripts set in this file are executed +by the [GitLab Runner](https://docs.gitlab.com/runner/). + +To get started with GitLab CI/CD, we recommend you read through +the following documents: + +- [How GitLab CI/CD works](introduction/index.md#how-gitlab-cicd-works). +- [GitLab CI/CD basic workflow](introduction/index.md#basic-cicd-workflow). +- [Step-by-step guide for writing `.gitlab-ci.yml` for the first time](../user/project/pages/getting_started_part_four.md). + +You can also get started by using one of the +[`.gitlab-ci.yml` templates](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/lib/gitlab/ci/templates) +available through the UI. You can use them by creating a new file, +choosing a template that suits your application, and adjusting it +to your needs: + +![Use a .gitlab-ci.yml template](img/add_file_template_11_10.png) + +For a broader overview, see the [CI/CD getting started](quick_start/README.md) guide. + +Once you're familiar with how GitLab CI/CD works, see the +[`. gitlab-ci.yml` full reference](yaml/README.md) +for all the attributes you can set and use. + +NOTE: **Note:** +GitLab CI/CD and [shared runners](runners/README.md#shared-specific-and-group-runners) are enabled in GitLab.com and available for all users, limited only to the [user's pipelines quota](https://docs.gitlab.com/ee/user/admin_area/settings/continuous_integration.html#extra-shared-runners-pipeline-minutes-quota). + +## GitLab CI/CD configuration + +GitLab CI/CD supports numerous configuration options: + +| Configuration | Description | +|:--- |:--- | +| [Pipelines](pipelines.md) | Structure your CI/CD process through pipelines. | +| [Environment variables](variables/README.md) | Reuse values based on a variable/value key pair. | +| [Environments](environments.md) | Deploy your application to different environments (e.g., staging, production). | +| [Job artifacts](../user/project/pipelines/job_artifacts.md) | Output, use, and reuse job artifacts. | +| [Cache dependencies](caching/index.md) | Cache your dependencies for a faster execution. | +| [Schedule pipelines](../user/project/pipelines/schedules.md) | Schedule pipelines to run as often as you need. | +| [Custom path for `.gitlab-ci.yml`](../user/project/pipelines/settings.md#custom-ci-config-path) | Define a custom path for the CI/CD configuration file. | +| [Git submodules for CI/CD](git_submodules.md) | Configure jobs for using Git submodules. | +| [SSH keys for CI/CD](ssh_keys/README.md) | Using SSH keys in your CI pipelines. | +| [Pipelines triggers](triggers/README.md) | Trigger pipelines through the API. | +| [Integrate with Kubernetes clusters](../user/project/clusters/index.md) | Connect your project to Google Kubernetes Engine (GKE) or an existing Kubernetes cluster. | +| [GitLab Runner](https://docs.gitlab.com/runner/) | Configure your own GitLab Runners to execute your scripts. | +| [Optimize GitLab and Runner for large repositories](large_repositories/index.md) | Recommended strategies for handling large repos. | +| [`.gitlab-ci.yml` full reference](yaml/README.md) | All the attributes you can use with GitLab CI/CD. | + +Note that certain operations can only be performed according to the +[user](../user/permissions.md#gitlab-cicd-permissions) and [job](../user/permissions.md#job-permissions) permissions. + +## GitLab CI/CD feature set + +You can also use the vast GitLab CI/CD feature set to easily configure +it for specific purposes: + +| Feature | Description | +|:--- |:--- | +| [Auto Deploy](../topics/autodevops/index.md#auto-deploy) | Deploy your application to a production environment in a Kubernetes cluster. | +| [Auto DevOps](../topics/autodevops/index.md) | Set up your app's entire lifecycle. | +| [Building Docker images](docker/using_docker_build.md) | Maintain Docker-based projects using GitLab CI/CD. | +| [Canary Deployments](https://docs.gitlab.com/ee/user/project/canary_deployments.html) **[PREMIUM]** | Ship features to only a portion of your pods and let a percentage of your user base to visit the temporarily deployed feature. | +| [ChatOps](chatops/README.md) | Trigger CI jobs from chat, with results sent back to the channel. | +| [CI services](services/README.md)| Link Docker containers with your base image. | +| [Container Scanning](https://docs.gitlab.com/ee/ci/examples/container_scanning.html) **[ULTIMATE]**| Check your Docker containers for known vulnerabilities. | +| [Dependency Scanning](https://docs.gitlab.com/ee/ci/examples/dependency_scanning.html) **[ULTIMATE]**| Analyze your dependencies for known vulnerabilities. | +| [Deploy Boards](https://docs.gitlab.com/ee/user/project/deploy_boards.html) **[PREMIUM]** | Check the current health and status of each CI/CD environment running on Kubernetes. | +| [Feature Flags](https://docs.gitlab.com/ee/user/project/operations/feature_flags.html) **[PREMIUM]** | Deploy your features behind Feature Flags. | +| [GitLab CI/CD for external repositories](https://docs.gitlab.com/ee/ci/ci_cd_for_external_repos/) **[PREMIUM]** | Get the benefits of GitLab CI/CD combined with repositories in GitHub and BitBucket Cloud. | +| [GitLab Pages](../user/project/pages/index.md) | Deploy static websites. | +| [GitLab Releases](../user/project/releases/index.md) | Add release notes to Git tags. | +| [Interactive Web Terminals](interactive_web_terminal/index.md) **[CORE ONLY]** | Open an interactive web terminal to debug the running jobs. | +| [JUnit tests](junit_test_reports.md)| Identify script failures directly on merge requests. | +| [Review Apps](review_apps/index.md) | Configure GitLab CI/CD to preview code changes. | +| [Security Test reports](https://docs.gitlab.com/ee/user/project/merge_requests/#security-reports-ultimate) **[ULTIMATE]** | Check for app vulnerabilities. | +| [Using Docker images](docker/using_docker_images.md) | Use GitLab and GitLab Runner with Docker to build and test applications. | + +## GitLab CI/CD examples GitLab provides examples of configuring GitLab CI/CD in the form of: @@ -78,9 +120,10 @@ GitLab provides examples of configuring GitLab CI/CD in the form of: - [`multi-project-pipelines`](https://gitlab.com/gitlab-examples/multi-project-pipelines) for examples of implementing multi-project pipelines. - [`review-apps-nginx`](https://gitlab.com/gitlab-examples/review-apps-nginx/) provides an example of using Review Apps. -### Administration +## GitLab CI/CD administration **[CORE ONLY]** -As a GitLab administrator, you can change the default behavior of GitLab CI/CD for: +As a GitLab administrator, you can change the default behavior +of GitLab CI/CD for: - An [entire GitLab instance](../user/admin_area/settings/continuous_integration.md). - Specific projects, using [pipelines settings](../user/project/pipelines/settings.md). @@ -90,33 +133,22 @@ See also: - [How to enable or disable GitLab CI/CD](enable_or_disable_ci.md). - Other [CI administration settings](../administration/index.md#continuous-integration-settings). -### Using Docker - -Docker is commonly used with GitLab CI/CD. Learn more about how to to accomplish this with the following -documentation: - -| Topic | Description | -|:-------------------------------------------------------------------------|:-------------------------------------------------------------------------| -| [Using Docker images](docker/using_docker_images.md) | Use GitLab and GitLab Runner with Docker to build and test applications. | -| [Building Docker images with GitLab CI/CD](docker/using_docker_build.md) | Maintain Docker-based projects using GitLab CI/CD. | - -Related topics include: - -- [Docker integration](docker/README.md). -- [CI services (linked Docker containers)](services/README.md). +## References -## Why GitLab CI/CD? +### Why GitLab CI/CD? -The following articles explain reasons to use GitLab CI/CD for your CI/CD infrastructure: +The following articles explain reasons to use GitLab CI/CD +for your CI/CD infrastructure: - [Why we chose GitLab CI for our CI/CD solution](https://about.gitlab.com/2016/10/17/gitlab-ci-oohlala/) - [Building our web-app on GitLab CI](https://about.gitlab.com/2016/07/22/building-our-web-app-on-gitlab-ci/) See also the [Why CI/CD?](https://docs.google.com/presentation/d/1OGgk2Tcxbpl7DJaIOzCX4Vqg3dlwfELC3u2jEeCBbDk) presentation. -## Breaking changes +### Breaking changes -As GitLab CI/CD has evolved, certain breaking changes have been necessary. These are: +As GitLab CI/CD has evolved, certain breaking changes have +been necessary. These are: - [CI variables renaming for GitLab 9.0](variables/deprecated_variables.md#gitlab-90-renamed-variables). Read about the deprecated CI variables and what you should use for GitLab 9.0+. diff --git a/doc/ci/environments.md b/doc/ci/environments.md index 3e52cc786dd..6b4d4f1b9d4 100644 --- a/doc/ci/environments.md +++ b/doc/ci/environments.md @@ -16,11 +16,11 @@ For example: 1. Test your code. 1. Deploy your code into a testing or staging environment before you release it to the public. -This helps prevent bugs not only in your software, but in the deployment process as well. +This helps find bugs in your software, and also in the deployment process as well. GitLab CI/CD is capable of not only testing or building your projects, but also deploying them in your infrastructure, with the added benefit of giving you a -way to track your deployments. In other words, you can always know what is +way to track your deployments. In other words, you will always know what is currently being deployed or has been deployed on your servers. It's important to know that: @@ -31,12 +31,12 @@ It's important to know that: GitLab: -- Provides a full history of your deployments per every environment. +- Provides a full history of your deployments for each environment. - Keeps track of your deployments, so you always know what is currently being deployed on your servers. If you have a deployment service such as [Kubernetes](../user/project/clusters/index.md) -enabled for your project, you can use it to assist with your deployments, and +associated with your project, you can use it to assist with your deployments, and can even access a [web terminal](#web-terminals) for your environment from within GitLab! ## Configuring environments @@ -46,8 +46,8 @@ Configuring environments involves: 1. Understanding how [pipelines](pipelines.md) work. 1. Defining environments in your project's [`.gitlab-ci.yml`](yaml/README.md) file. -The rest of this section illustrates how to configure environments and deployments using an example. -It assumes you have already: +The rest of this section illustrates how to configure environments and deployments using +an example scenario. It assumes you have already: - Created a [project](../gitlab-basics/create-project.md) in GitLab. - Set up [a Runner](runners/README.md). @@ -94,9 +94,8 @@ We have defined 3 [stages](yaml/README.md#stages): - `build` - `deploy` -The jobs assigned to these stages will run in this order. If a job fails, then -the jobs that are assigned to the next stage won't run, rendering the pipeline -as failed. +The jobs assigned to these stages will run in this order. If any job fails, then +the pipeline fails and jobs that are assigned to the next stage won't run. In our case: @@ -104,15 +103,15 @@ In our case: - Then the `build` job. - Lastly the `deploy_staging` job. -With this configuration, we ensure that: +With this configuration, we: -- The tests pass. -- Our app is able to be built successfully. +- Check that the tests pass. +- Ensure that our app is able to be built successfully. - Lastly we deploy to the staging server. NOTE: **Note:** The `environment` keyword is just a hint for GitLab that this job actually -deploys to this environment's `name`. It can also have a `url` that is +deploys to the `name` environment. It can also have a `url` that is exposed in various places within GitLab. Each time a job that has an environment specified succeeds, a deployment is recorded, storing the Git SHA and environment name. @@ -134,14 +133,13 @@ In summary, with the above `.gitlab-ci.yml` we have achieved the following: > etc. > Starting with GitLab 9.3, the environment URL is exposed to the Runner via -> `$CI_ENVIRONMENT_URL`. The URL would be expanded from `.gitlab-ci.yml`, or if -> the URL was not defined there, the external URL from the environment would be -> used. +> `$CI_ENVIRONMENT_URL`. The URL is expanded from `.gitlab-ci.yml`, or if +> the URL was not defined there, the external URL from the environment is used. ### Configuring manual deployments -Converting automatically executed job into jobs requiring to a manual action involves -adding `when: manual` to the job's configuration. +Adding `when: manual` to an automatically executed job's configuration converts it to +a job requiring manual action. To expand on the [previous example](#defining-environments), the following includes another job that deploys our app to a production server and is @@ -187,7 +185,7 @@ deploy_prod: The `when: manual` action: -- Exposes a "play" button in GitLab's UI. +- Exposes a "play" button in GitLab's UI for that job. - Means the `deploy_prod` job will only be triggered when the "play" button is clicked. You can find the "play" button in the pipelines, environments, deployments, and jobs views. @@ -200,8 +198,8 @@ You can find the "play" button in the pipelines, environments, deployments, and | Deployments | ![Deployments manual action](img/environments_manual_action_deployments.png) | | Jobs | ![Builds manual action](img/environments_manual_action_jobs.png) | -Clicking on the play button in any view will trigger the `deploy_prod` job, and the deployment will be recorded under a new -environment named `production`. +Clicking on the play button in any view will trigger the `deploy_prod` job, and the +deployment will be recorded as a new environment named `production`. NOTE: **Note:** If your environment's name is `production` (all lowercase), @@ -209,14 +207,13 @@ it will get recorded in [Cycle Analytics](../user/project/cycle_analytics.md). ### Configuring dynamic environments -Other environments are good for deploying to stable environments like staging or production. +Regular environments are good when deploying to "stable" environments like staging or production. -However, what about environments for branches other than `master`? Dynamic environments can be used to achieve these. - -Dynamic environments make it possible to create environments on the fly by +However, for environments for branches other than `master`, dynamic environments +can be used. Dynamic environments make it possible to create environments on the fly by declaring their names dynamically in `.gitlab-ci.yml`. -Dynamic environments form the basis of [Review apps](review_apps/index.md). +Dynamic environments are a fundamental part of [Review apps](review_apps/index.md). #### Allowed variables @@ -237,10 +234,10 @@ For more information, see [Where variables can be used](variables/where_variable #### Example configuration -GitLab Runner exposes various [environment variables](variables/README.md) when a job runs and so +GitLab Runner exposes various [environment variables](variables/README.md) when a job runs, so you can use them as environment names. -In the following example, a job will deploy to all branches except `master`: +In the following example, the job will deploy to all branches except `master`: ```yaml deploy_review: @@ -261,28 +258,33 @@ In this example: - The job's name is `deploy_review` and it runs on the `deploy` stage. - We set the `environment` with the `environment:name` as `review/$CI_COMMIT_REF_NAME`. Since the [environment name](yaml/README.md#environmentname) can contain slashes (`/`), we can - use this pattern to distinguish between dynamic environments and the regular ones. -- We tell the job to run [`only`](yaml/README.md#onlyexcept-basic) on branches [`except`](yaml/README.md#onlyexcept-basic) `master`. + use this pattern to distinguish between dynamic and regular environments. +- We tell the job to run [`only`](yaml/README.md#onlyexcept-basic) on branches, + [`except`](yaml/README.md#onlyexcept-basic) `master`. For the value of: - `environment:name`, the first part is `review`, followed by a `/` and then `$CI_COMMIT_REF_NAME`, - which takes the value of the branch name. -- `environment:url`, since `$CI_COMMIT_REF_NAME` itself may also contain `/`, or other characters that - would be invalid in a domain name or URL, we use `$CI_ENVIRONMENT_SLUG` so that the environment can get a specific and distinct URL for each branch. + which receives the value of the branch name. +- `environment:url`, we want a specific and distinct URL for each branch. `$CI_COMMIT_REF_NAME` + may contain a `/` or other characters that would be invalid in a domain name or URL, + so we use `$CI_ENVIRONMENT_SLUG` to get a "clean" or "safe" URL. For example, given a `$CI_COMMIT_REF_NAME` of `100-Do-The-Thing`, the URL will be something like `https://100-do-the-4f99a2.example.com`. Again, the way you set up the web server to serve these requests is based on your setup. - You could also use `$CI_COMMIT_REF_SLUG` in `environment:url`. For example, `https://$CI_COMMIT_REF_SLUG.example.com`. - 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 are unlikely and you may prefer environment names to be more closely based on the branch name. The example - above would give you an URL like `https://100-do-the-thing.example.com`. + 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 + 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 + of `https://100-do-the-thing.example.com`. NOTE: **Note:** -You are not bound to use the same prefix or only slashes in the dynamic -environments' names (`/`). However, this will enable the [grouping similar environments](#grouping-similar-environments) feature. +You are not required to use the same prefix or only slashes (`/`) in the dynamic environments' +names. However, using this format will enable the [grouping similar environments](#grouping-similar-environments) +feature. ### Complete example @@ -292,7 +294,7 @@ The configuration in this section provides a full development workflow where you - Built. - Deployed as a Review App. - Deployed to a staging server once the merge request is merged. -- Finally, manually deployed to the production server. +- Finally, able to be manually deployed to the production server. The following combines the previous configuration examples, including: @@ -348,8 +350,8 @@ deploy_prod: - master ``` -A more realistic example would include copying files to a location where a -webserver (for example, NGINX) could then read and serve. +A more realistic example would also include copying files to a location where a +webserver (for example, NGINX) could then acess and serve them. The example below will copy the `public` directory to `/srv/nginx/$CI_COMMIT_REF_SLUG/public`: @@ -366,32 +368,33 @@ review_app: This example requires that NGINX and GitLab Runner are set up on the server this job will run on. NOTE: **Note:** -See the [limitations](#limitations) section for some edge cases regarding naming of your branches and Review Apps. +See the [limitations](#limitations) section for some edge cases regarding the naming of +your branches and Review Apps. -The complete example provides the following workflow for developers: +The complete example provides the following workflow to developers: - Create a branch locally. -- Make changes and commit them +- Make changes and commit them. - Push the branch to GitLab. - Create a merge request. -Behind the scenes, GitLab runner will: +Behind the scenes, GitLab Runner will: - Pick up the changes and start running the jobs. - Run the jobs sequentially as defined in `stages`: - First, run the tests. - If the tests succeed, build the app. - - If the build succeeds, the app will be is deployed to an environment with a name specific to the + - If the build succeeds, the app is deployed to an environment with a name specific to the branch. So now, every branch: - Gets its own environment. -- Is deployed to its own location, with the added benefit of: +- Is deployed to its own unique location, with the added benefit of: - Having a [history of deployments](#viewing-deployment-history). - Being able to [rollback changes](#retrying-and-rolling-back) if needed. -For more information on using the URL, see [Using the environment URL](#using-the-environment-url). +For more information, see [Using the environment URL](#using-the-environment-url). ### Protected environments @@ -401,11 +404,12 @@ For more information, see [Protected environments](environments/protected_enviro ## Working with environments -Having configured environments, GitLab provides many features to work with them. These are documented below. +Once environments are configured, GitLab provides many features for working with them, +as documented below. ### Viewing environments and deployments -A list of environments and deployment statuses is available on project's **Operations > Environments** page. +A list of environments and deployment statuses is available on each project's **Operations > Environments** page. For example: @@ -416,11 +420,11 @@ This example shows: - The environment's name with a link to its deployments. - The last deployment ID number and who performed it. - The job ID of the last deployment with its respective job name. -- The commit information of the last deployment such as who committed, to what +- The commit information of the last deployment, such as who committed it, to what branch, and the Git SHA of the commit. - The exact time the last deployment was performed. -- A button that takes you to the URL that you have defined under the - `environment` keyword in `.gitlab-ci.yml`. +- A button that takes you to the URL that you defined under the `environment` keyword + in `.gitlab-ci.yml`. - A button that re-deploys the latest deployment, meaning it runs the job defined by the environment name for that specific commit. @@ -432,8 +436,8 @@ deployments, but an environment can have multiple deployments. > - While you can create environments manually in the web interface, we recommend > that you define your environments in `.gitlab-ci.yml` first. They will > be automatically created for you after the first deploy. -> - The environments page can only be viewed by Reporters and above. For more -> information on the permissions, see the [permissions documentation](../user/permissions.md). +> - The environments page can only be viewed by users with [Reporter permission](../user/permissions.md#project-members-permissions) +> and above. For more information on permissions, see the [permissions documentation](../user/permissions.md). > - Only deploys that happen after your `.gitlab-ci.yml` is properly configured > will show up in the **Environment** and **Last deployment** lists. @@ -442,7 +446,7 @@ deployments, but an environment can have multiple deployments. GitLab keeps track of your deployments, so you: - Always know what is currently being deployed on your servers. -- Can have the full history of your deployments per every environment. +- Can have the full history of your deployments for every environment. Clicking on an environment shows the history of its deployments. Here's an example **Environments** page with multiple deployments: @@ -460,9 +464,9 @@ To retry or rollback a deployment: 1. Navigate to **Operations > Environments**. 1. Click on the environment. -1. On the page that lists the deployment history for the environment, click the: - - **Rollback** button against a previously successful deployment, to roll back to that deployment. - - **Retry** button against the last deployment, to retry that deployment. +1. In the deployment history list for the environment, click the: + - **Retry** button next to the last deployment, to retry that deployment. + - **Rollback** button next to a previously successful deployment, to roll back to that deployment. NOTE: **Note:** The defined deployment process in the job's `script` determines whether the rollback succeeds or not. @@ -470,9 +474,7 @@ The defined deployment process in the job's `script` determines whether the roll ### Using the environment URL The [environment URL](yaml/README.md#environmenturl) is exposed in a few -places within GitLab. - -These are: +places within GitLab: - In a merge request widget as a link: ![Environment URL in merge request](img/environments_mr_review_app.png) @@ -493,27 +495,28 @@ For example: #### Going from source files to public pages With GitLab's [Route Maps](review_apps/index.md#route-maps) you can go directly -from source files to public pages on the environment set for Review Apps. +from source files to public pages in the environment set for Review Apps. ### Stopping an environment Stopping an environment: -- Moves it from the list of **Available** environments to the list of **Stopped** environments on the [**Environments** page](#viewing-environments-and-deployments). +- Moves it from the list of **Available** environments to the list of **Stopped** + environments on the [**Environments** page](#viewing-environments-and-deployments). - Executes an [`on_stop` action](yaml/README.md#environmenton_stop), if defined. This is often used when multiple developers are working on a project at the same time, each of them pushing to their own branches, causing many dynamic environments to be created. NOTE: **Note:** -Starting with GitLab 8.14, dynamic environments will be stopped automatically +Starting with GitLab 8.14, dynamic environments are stopped automatically when their associated branch is deleted. #### Automatically stopping an environment Environments can be stopped automatically using special configuration. -Consider the following example where the `deploy_review` calls the `stop_review` +Consider the following example where the `deploy_review` job calls `stop_review` to clean up and stop the environment: ```yaml @@ -542,14 +545,14 @@ stop_review: action: stop ``` -Setting the [`GIT_STRATEGY`](yaml/README.md#git-strategy) to `none` is necessary on the -`stop_review` job so that the [GitLab Runner](https://docs.gitlab.com/runner/) won't try to check out the code -after the branch is deleted. +Setting the [`GIT_STRATEGY`](yaml/README.md#git-strategy) to `none` is necessary in the +`stop_review` job so that the [GitLab Runner](https://docs.gitlab.com/runner/) won't +try to check out the code after the branch is deleted. When you have an environment that has a stop action defined (typically when the environment describes a Review App), GitLab will automatically trigger a stop action when the associated branch is deleted. The `stop_review` job must -be in the same `stage` as the `deploy_review` one in order for the environment +be in the same `stage` as the `deploy_review` job in order for the environment to automatically stop. You can read more in the [`.gitlab-ci.yml` reference](yaml/README.md#environmenton_stop). @@ -562,8 +565,8 @@ As documented in [Configuring dynamic environments](#configuring-dynamic-environ prepend environment name with a word, followed by a `/`, and finally the branch name, which is automatically defined by the `CI_COMMIT_REF_NAME` variable. -In short, environments that are named like `type/foo` are presented under a -group named `type`. +In short, environments that are named like `type/foo` are all presented under the same +group, named `type`. In our [minimal example](#example-configuration), we named the environments `review/$CI_COMMIT_REF_NAME` where `$CI_COMMIT_REF_NAME` is the branch name. Here is a snippet of the example: @@ -588,13 +591,14 @@ exist, you should see something like: > > - For the monitoring dashboard to appear, you need to: > - Enable the [Prometheus integration](../user/project/integrations/prometheus.md). -> - Configure Prometheus to collect at least one [supported metric](../user/project/integrations/prometheus_library/index.md) +> - Configure Prometheus to collect at least one [supported metric](../user/project/integrations/prometheus_library/index.md). > - With GitLab 9.2, all deployments to an environment are shown directly on the monitoring dashboard. -If you have enabled [Prometheus for monitoring system and response metrics](../user/project/integrations/prometheus.md), you can monitor the performance behavior of your app running in each environment. +If you have enabled [Prometheus for monitoring system and response metrics](../user/project/integrations/prometheus.md), +you can monitor the behavior of your app running in each environment. -Once configured, GitLab will attempt to retrieve [supported performance metrics](../user/project/integrations/prometheus_library/index.md) for any -environment that has had a successful deployment. If monitoring data was +Once configured, GitLab will attempt to retrieve [supported performance metrics](../user/project/integrations/prometheus_library/index.md) +for any environment that has had a successful deployment. If monitoring data was successfully retrieved, a **Monitoring** button will appear for each environment. ![Environment Detail with Metrics](img/deployments_view.png) @@ -604,8 +608,8 @@ Clicking on the **Monitoring** button will display a new page showing up to the after initial deployment. All deployments to an environment are shown directly on the monitoring dashboard, -which allows easy correlation between any changes in performance and a new -version of the app, all without leaving GitLab. +which allows easy correlation between any changes in performance and new +versions of the app, all without leaving GitLab. ![Monitoring dashboard](img/environments_monitoring.png) @@ -617,8 +621,8 @@ If you deploy to your environments with the help of a deployment service (for ex the [Kubernetes integration](../user/project/clusters/index.md)), GitLab can open a terminal session to your environment. -This is a powerful feature that allows you to debug issues without leaving the comfort of your web browser. To -enable it, just follow the instructions given in the service integration +This is a powerful feature that allows you to debug issues without leaving the comfort +of your web browser. To enable it, just follow the instructions given in the service integration documentation. Once enabled, your environments will gain a "terminal" button: @@ -663,8 +667,9 @@ fetch = +refs/environments/*:refs/remotes/origin/environments/* ### Scoping environments with specs **[PREMIUM]** -Some GitLab [Enterprise Edition](https://about.gitlab.com/pricing/) features can behave differently for each -environment. For example, you can [create a secret variable to be injected only into a production environment](https://docs.gitlab.com/ee/ci/variables/#limiting-environment-scopes-of-environment-variables-premium). +Some GitLab [Enterprise Edition](https://about.gitlab.com/pricing/) features can +behave differently for each environment. For example, you can +[create a secret variable to be injected only into a production environment](https://docs.gitlab.com/ee/ci/variables/README.md#limiting-environment-scopes-of-environment-variables-premium). In most cases, these features use the _environment specs_ mechanism, which offers an efficient way to implement scoping within each environment group. @@ -696,9 +701,8 @@ In this case, `review/feature-1` spec takes precedence over `review/*` and `*` s ## Limitations -You are limited to use only the [CI predefined variables](variables/README.md) in the -`environment: name`. If you try to re-use variables defined inside `script` -as part of the environment name, it will not work. +In the `environment: name`, you are limited to only the [predefined environment variables](variables/predefined_variables.md). +Re-using variables defined inside `script` as part of the environment name will not work. ## Further reading @@ -707,3 +711,4 @@ Below are some links you may find interesting: - [The `.gitlab-ci.yml` definition of environments](yaml/README.md#environment) - [A blog post on Deployments & Environments](https://about.gitlab.com/2016/08/26/ci-deployment-and-environments/) - [Review Apps - Use dynamic environments to deploy your code for every branch](review_apps/index.md) +- [Deploy Boards for your applications running on Kubernetes](https://docs.gitlab.com/ee/user/project/deploy_boards.html) **[PREMIUM]** diff --git a/doc/ci/environments/protected_environments.md b/doc/ci/environments/protected_environments.md index 219af4ced9d..ab5c0e2dbad 100644 --- a/doc/ci/environments/protected_environments.md +++ b/doc/ci/environments/protected_environments.md @@ -9,8 +9,8 @@ - Some of them are just for testing. - Others are for production. -Because deploy jobs can be raised by different users with different roles, it is important that -specific environments are "protected" to avoid unauthorized people affecting them. +Since deploy jobs can be raised by different users with different roles, it is important that +specific environments are "protected" to prevent unauthorized people from affecting them. By default, a protected environment does one thing: it ensures that only people with the right privileges can deploy to it, thus keeping it safe. @@ -28,14 +28,14 @@ To protect an environment: 1. Navigate to your project's **Settings > CI/CD**. 1. Expand the **Protected Environments** section. 1. From the **Environment** dropdown menu, select the environment you want to protect. -1. In the **Allowed to Deploy** dropdown menu, select the role, users, or groups you want to have deploy access. - There are some considerations to have in mind: - - There are two roles to choose from: - - **Maintainers**: will allow access to all maintainers in the project. - - **Developers**: will allow access to all maintainers and all developers in the project. - - You can only select groups that are associated with the project. - - Only users that have at least Developer permission level will appear on - the **Allowed to Deploy** dropdown menu. +1. In the **Allowed to Deploy** dropdown menu, select the role, users, or groups you + want to give deploy access to. Keep in mind that: + - There are two roles to choose from: + - **Maintainers**: will allow access to all maintainers in the project. + - **Developers**: will allow access to all maintainers and all developers in the project. + - You can only select groups that are already associated with the project. + - Only users that have at least Developer permission level will appear in + the **Allowed to Deploy** dropdown menu. 1. Click the **Protect** button. The protected environment will now appear in the list of protected environments. @@ -44,5 +44,6 @@ The protected environment will now appear in the list of protected environments. Maintainers can: -- Update existing protected environments at any time by changing the access on **Allowed to deploy** dropdown menu. -- Unprotect a protected environment by clicking the **Unprotect** button of the environment to unprotect. +- Update existing protected environments at any time by changing the access in the + **Allowed to Deploy** dropdown menu. +- Unprotect a protected environment by clicking the **Unprotect** button for that environment. diff --git a/doc/ci/img/add_file_template_11_10.png b/doc/ci/img/add_file_template_11_10.png Binary files differnew file mode 100644 index 00000000000..ca04d72615b --- /dev/null +++ b/doc/ci/img/add_file_template_11_10.png diff --git a/doc/ci/img/deployments_view.png b/doc/ci/img/deployments_view.png Binary files differindex 45d882b536c..12090434bef 100644 --- a/doc/ci/img/deployments_view.png +++ b/doc/ci/img/deployments_view.png diff --git a/doc/ci/img/environments_available.png b/doc/ci/img/environments_available.png Binary files differindex 7ab92838ece..48fc6effc2d 100644 --- a/doc/ci/img/environments_available.png +++ b/doc/ci/img/environments_available.png diff --git a/doc/ci/img/environments_mr_review_app.png b/doc/ci/img/environments_mr_review_app.png Binary files differindex 61b7e9fe77c..6a7b7ce5679 100644 --- a/doc/ci/img/environments_mr_review_app.png +++ b/doc/ci/img/environments_mr_review_app.png diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 9983b015b31..6313ffc584d 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -143,14 +143,16 @@ This means that the value of the variable will be hidden in job logs, though it must match certain requirements to do so: - The value must be in a single line. -- The value must not have escape characters. +- The value must contain only letters, numbers, or underscores. +- The value must not have escape characters, such as `\"` - The value must not use variables. - The value must not have any whitespace. - The value must be at least 8 characters long. -If the value does not meet the requirements above, then the CI variable will fail to save. -In order to save, either alter the value to meet the masking requirements -or disable `Masked` for the variable. +The above rules are validated using the regex `/\A\w{8,}\z/`. If the value +does not meet the requirements above, then the CI variable will fail to save. +In order to save, either alter the value to meet the masking requirements or +disable `Masked` for the variable. ### Syntax of environment variables in job scripts diff --git a/doc/ci/variables/predefined_variables.md b/doc/ci/variables/predefined_variables.md index 40458137752..4e902c042e6 100644 --- a/doc/ci/variables/predefined_variables.md +++ b/doc/ci/variables/predefined_variables.md @@ -112,3 +112,7 @@ future GitLab releases.** | `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 | + +[gitlab-deploy-token]: ../../user/project/deploy_tokens/index.md#gitlab-deploy-token +[registry]: ../../user/project/container_registry.md +[dependent-repositories]: ../../user/project/new_ci_build_permissions_model.md#dependent-repositories diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 2e85e34f17b..03383d11c14 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -2061,12 +2061,12 @@ from another project: ```yaml include: - template: Bash.gitlab-ci.yml - - project: /group/my-project + - project: group/my-project file: /templates/docker-workflow.yml ``` -The `/templates/docker-workflow.yml` present in `/group/my-project` includes two local files -of the `/group/my-project`: +The `/templates/docker-workflow.yml` present in `group/my-project` includes two local files +of the `group/my-project`: ```yaml include: @@ -2074,14 +2074,14 @@ include: - local: : /templates/docker-testing.yml ``` -Our `/templates/docker-build.yml` present in `/group/my-project` adds a `docker-build` job: +Our `/templates/docker-build.yml` present in `group/my-project` adds a `docker-build` job: ```yaml docker-build: script: docker build -t my-image . ``` -Our second `/templates/docker-test.yml` present in `/group/my-project` adds a `docker-test` job: +Our second `/templates/docker-test.yml` present in `group/my-project` adds a `docker-test` job: ```yaml docker-test: @@ -2479,9 +2479,9 @@ This can only be used when `custom_build_dir` is enabled in the [Runner's configuration](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runnerscustom_build_dir-section). This is the default configuration for `docker` and `kubernetes` executor. -By default, GitLab Runner clones the repository in a unique subpath of the -`$CI_BUILDS_DIR` directory. However, your project might require the code in a -specific directory (Go projects, for example). In that case, you can specify +By default, GitLab Runner clones the repository in a unique subpath of the +`$CI_BUILDS_DIR` directory. However, your project might require the code in a +specific directory (Go projects, for example). In that case, you can specify the `GIT_CLONE_PATH` variable to tell the Runner in which directory to clone the repository: diff --git a/doc/development/documentation/styleguide.md b/doc/development/documentation/styleguide.md index fbca99fbfea..9d8d5afedad 100644 --- a/doc/development/documentation/styleguide.md +++ b/doc/development/documentation/styleguide.md @@ -362,16 +362,23 @@ For other punctuation rules, please refer to the E.g., instead of writing something like `Read more about GitLab Issue Boards [here](LINK)`, write `Read more about [GitLab Issue Boards](LINK)`. -### Links to confidential issues +### Links requiring permissions -Don't link directly to [confidential issues](../../user/project/issues/confidential_issues.md). These will fail for: +Don't link directly to: + +- [Confidential issues](../../user/project/issues/confidential_issues.md). +- Project features that require [special permissions](../../user/permissions.md) to view. + +These will fail for: - Those without sufficient permissions. - Automated link checkers. Instead: -- Mention in the text that the information is contained in a confidential issue. This will reduce confusion. +- To reduce confusion, mention in the text that the information is either: + - Contained in a confidential issue. + - Requires special permission to a project to view. - Provide a link in back ticks (`` ` ``) so that those with access to the issue can easily navigate to it. Example: diff --git a/doc/development/i18n/externalization.md b/doc/development/i18n/externalization.md index 223585ebb55..38d56322de8 100644 --- a/doc/development/i18n/externalization.md +++ b/doc/development/i18n/externalization.md @@ -300,7 +300,7 @@ file in. Once the changes are on master, they will be picked up by [Crowdin](http://translate.gitlab.com) and be presented for translation. If there are merge conflicts in the `gitlab.pot` file, you can delete the file -and regenerate it using the same command. Confirm that you are not deleting any strings accidentally by looking over the diff. +and regenerate it using the same command. ### Validating PO files diff --git a/doc/development/testing_guide/end_to_end_tests.md b/doc/development/testing_guide/end_to_end_tests.md index 51fe19c3d9e..fc7aaedca29 100644 --- a/doc/development/testing_guide/end_to_end_tests.md +++ b/doc/development/testing_guide/end_to_end_tests.md @@ -99,13 +99,13 @@ subgraph gitlab-qa pipeline 1. When packages are ready, and available in the registry, a final step in the [Omnibus GitLab][omnibus-gitlab] pipeline, triggers a new - [GitLab QA pipeline][gitlab-qa-pipelines]. It also waits for a resulting status. + GitLab QA pipeline (those with access can view them at `https://gitlab.com/gitlab-org/gitlab-qa/pipelines`). It also waits for a resulting status. 1. GitLab QA pulls images from the registry, spins-up containers and runs tests against a test environment that has been just orchestrated by the `gitlab-qa` tool. -1. The result of the [GitLab QA pipeline][gitlab-qa-pipelines] is being +1. The result of the GitLab QA pipeline is being propagated upstream, through Omnibus, back to the CE / EE merge request. #### Using the `review-qa-all` jobs @@ -146,7 +146,6 @@ you can find an issue you would like to work on in [omnibus-gitlab]: https://gitlab.com/gitlab-org/omnibus-gitlab [gitlab-qa]: https://gitlab.com/gitlab-org/gitlab-qa -[gitlab-qa-pipelines]: https://gitlab.com/gitlab-org/gitlab-qa/pipelines [gitlab-qa-readme]: https://gitlab.com/gitlab-org/gitlab-qa/tree/master/README.md [quality-nightly-pipelines]: https://gitlab.com/gitlab-org/quality/nightly/pipelines [quality-staging-pipelines]: https://gitlab.com/gitlab-org/quality/staging/pipelines diff --git a/doc/development/testing_guide/img/review_apps_cicd_architecture.png b/doc/development/testing_guide/img/review_apps_cicd_architecture.png Binary files differindex 87e472076f3..1ee28d3db91 100644 --- a/doc/development/testing_guide/img/review_apps_cicd_architecture.png +++ b/doc/development/testing_guide/img/review_apps_cicd_architecture.png diff --git a/doc/development/testing_guide/review_apps.md b/doc/development/testing_guide/review_apps.md index 55ca502f84a..7ad33f05f77 100644 --- a/doc/development/testing_guide/review_apps.md +++ b/doc/development/testing_guide/review_apps.md @@ -6,7 +6,7 @@ Review Apps are automatically deployed by each pipeline, both in ## How does it work? -### CD/CD architecture diagram +### CI/CD architecture diagram ![Review Apps CI/CD architecture](img/review_apps_cicd_architecture.png) @@ -14,23 +14,29 @@ Review Apps are automatically deployed by each pipeline, both in <summary>Show mermaid source</summary> <pre> graph TD - B1 -.->|2. once gitlab:assets:compile is done,<br />triggers a CNG-mirror pipeline and wait for it to be done| A2 - C1 -.->|2. once review-build-cng is done,<br />Helm deploys the Review App using the Cloud<br/>Native images built by the CNG-mirror pipeline| A3 - -subgraph gitlab-ce/ee `test` stage - A1[gitlab:assets:compile] - B1[review-build-cng] -->|1. wait for| A1 - C1[review-deploy] -->|1. wait for| B1 - D1[review-qa-smoke] -->|1. wait for| C1 - D1[review-qa-smoke] -.->|2. once review-deploy is done| E1>gitlab-qa runs the smoke<br/>suite against the Review App] + build-qa-image -.->|once the `prepare` stage is done| gitlab:assets:compile + review-build-cng -->|triggers a CNG-mirror pipeline and wait for it to be done| CNG-mirror + review-build-cng -.->|once the `test` stage is done| review-deploy + review-deploy -.->|once the `review` stage is done| review-qa-smoke + +subgraph 1. gitlab-ce/ee `prepare` stage + build-qa-image end -subgraph CNG-mirror pipeline - A2>Cloud Native images are built]; +subgraph 2. gitlab-ce/ee `test` stage + gitlab:assets:compile -->|plays dependent job once done| review-build-cng end -subgraph GCP `gitlab-review-apps` project - A3>"Cloud Native images are deployed to the<br />`review-apps-ce` or `review-apps-ee` Kubernetes (GKE) cluster"]; +subgraph 3. gitlab-ce/ee `review` stage + review-deploy["review-deploy<br /><br />Helm deploys the Review App using the Cloud<br/>Native images built by the CNG-mirror pipeline.<br /><br />Cloud Native images are deployed to the `review-apps-ce` or `review-apps-ee`<br />Kubernetes (GKE) cluster, in the GCP `gitlab-review-apps` project."] + end + +subgraph 4. gitlab-ce/ee `qa` stage + review-qa-smoke[review-qa-smoke<br /><br />gitlab-qa runs the smoke suite against the Review App.] + end + +subgraph CNG-mirror pipeline + CNG-mirror>Cloud Native images are built]; end </pre> </details> @@ -38,40 +44,37 @@ subgraph GCP `gitlab-review-apps` project ### Detailed explanation 1. On every [pipeline][gitlab-pipeline] during the `test` stage, the - [`review-build-cng`][review-build-cng] and - [`review-deploy`][review-deploy] jobs are automatically started. - - The [`review-deploy`][review-deploy] job waits for the - [`review-build-cng`][review-build-cng] job to finish. - - The [`review-build-cng`][review-build-cng] job waits for the - [`gitlab:assets:compile`][gitlab:assets:compile] job to finish since the - [`CNG-mirror`][cng-mirror] pipeline triggered in the following step depends on it. -1. Once the [`gitlab:assets:compile`][gitlab:assets:compile] job is done, - [`review-build-cng`][review-build-cng] [triggers a pipeline][cng-pipeline] - in the [`CNG-mirror`][cng-mirror] project. - - The [`CNG-mirror`][cng-pipeline] pipeline creates the Docker images of - each component (e.g. `gitlab-rails-ee`, `gitlab-shell`, `gitaly` etc.) - based on the commit from the [GitLab pipeline][gitlab-pipeline] and store - them in its [registry][cng-mirror-registry]. - - We use the [`CNG-mirror`][cng-mirror] project so that the `CNG`, (**C**loud - **N**ative **G**itLab), project's registry is not overloaded with a - lot of transient Docker images. -1. Once the [`review-build-cng`][review-build-cng] job is done, the - [`review-deploy`][review-deploy] job deploys the Review App using - [the official GitLab Helm chart][helm-chart] to the - [`review-apps-ce`][review-apps-ce] / [`review-apps-ee`][review-apps-ee] - Kubernetes cluster on GCP. - - The actual scripts used to deploy the Review App can be found at - [`scripts/review_apps/review-apps.sh`][review-apps.sh]. - - These scripts are basically - [our official Auto DevOps scripts][Auto-DevOps.gitlab-ci.yml] where the - default CNG images are overridden with the images built and stored in the - [`CNG-mirror` project's registry][cng-mirror-registry]. - - Since we're using [the official GitLab Helm chart][helm-chart], this means - you get a dedicated environment for your branch that's very close to what - it would look in production. + [`gitlab:assets:compile`][gitlab:assets:compile] job is automatically started. + - Once it's done, it starts the [`review-build-cng`][review-build-cng] + manual job since the [`CNG-mirror`][cng-mirror] pipeline triggered in the + following step depends on it. +1. The [`review-build-cng`][review-build-cng] job [triggers a pipeline][cng-mirror-pipeline] + in the [`CNG-mirror`][cng-mirror] project. + - The [`CNG-mirror`][cng-mirror-pipeline] pipeline creates the Docker images of + each component (e.g. `gitlab-rails-ee`, `gitlab-shell`, `gitaly` etc.) + based on the commit from the [GitLab pipeline][gitlab-pipeline] and stores + them in its [registry][cng-mirror-registry]. + - We use the [`CNG-mirror`][cng-mirror] project so that the `CNG`, (**C**loud + **N**ative **G**itLab), project's registry is not overloaded with a + lot of transient Docker images. + - Note that the official CNG images are built by the `cloud-native-image` + job, which runs only for tags, and triggers itself a [`CNG`][cng] pipeline. +1. Once the `test` stage is done, the [`review-deploy`][review-deploy] job + deploys the Review App using [the official GitLab Helm chart][helm-chart] to + the [`review-apps-ce`][review-apps-ce] / [`review-apps-ee`][review-apps-ee] + Kubernetes cluster on GCP. + - The actual scripts used to deploy the Review App can be found at + [`scripts/review_apps/review-apps.sh`][review-apps.sh]. + - These scripts are basically + [our official Auto DevOps scripts][Auto-DevOps.gitlab-ci.yml] where the + default CNG images are overridden with the images built and stored in the + [`CNG-mirror` project's registry][cng-mirror-registry]. + - Since we're using [the official GitLab Helm chart][helm-chart], this means + you get a dedicated environment for your branch that's very close to what + it would look in production. 1. Once the [`review-deploy`][review-deploy] job succeeds, you should be able to - use your Review App thanks to the direct link to it from the MR widget. To log - into the Review App, see "Log into my Review App?" below. + use your Review App thanks to the direct link to it from the MR widget. To log + into the Review App, see "Log into my Review App?" below. **Additional notes:** @@ -82,71 +85,69 @@ subgraph GCP `gitlab-review-apps` project - If the Review App deployment fails, you can simply retry it (there's no need to run the [`review-stop`][gitlab-ci-yml] job first). - The manual [`review-stop`][gitlab-ci-yml] in the `test` stage can be used to - stop a Review App manually, and is also started by GitLab once a branch is - deleted. -- Review Apps are cleaned up regularly using a pipeline schedule that runs + stop a Review App manually, and is also started by GitLab once a merge + request's branch is deleted after being merged. +- Review Apps are cleaned up regularly via a pipeline schedule that runs the [`schedule:review-cleanup`][gitlab-ci-yml] job. ## QA runs -On every [pipeline][gitlab-pipeline] during the `test` stage, the -`review-qa-smoke` job is automatically started: it runs the QA smoke suite. -You can also manually start the `review-qa-all`: it runs the QA full suite. +On every [pipeline][gitlab-pipeline] in the `qa` stage (which comes after the +`review` stage), the `review-qa-smoke` job is automatically started and it runs +the QA smoke suite. -Note that both jobs first wait for the `review-deploy` job to be finished. +You can also manually start the `review-qa-all`: it runs the full QA suite. ## Performance Metrics -On every [pipeline][gitlab-pipeline] during the `test` stage, the +On every [pipeline][gitlab-pipeline] in the `qa` stage, the `review-performance` job is automatically started: this job does basic -browser performance testing using [Sitespeed.io Container](https://docs.gitlab.com/ee/user/project/merge_requests/browser_performance_testing.html) . +browser performance testing using a +[Sitespeed.io Container](https://docs.gitlab.com/ee/user/project/merge_requests/browser_performance_testing.html). -This job waits for the `review-deploy` job to be finished. +## How to: -## How to? - -### Log into my Review App? +### Log into my Review App The default username is `root` and its password can be found in the 1Password secure note named **gitlab-{ce,ee} Review App's root password**. -### Enable a feature flag for my Review App? +### Enable a feature flag for my Review App 1. Open your Review App and log in as documented above. 1. Create a personal access token. 1. Enable the feature flag using the [Feature flag API](../../api/features.md). -### Find my Review App slug? +### Find my Review App slug 1. Open the `review-deploy` job. 1. Look for `Checking for previous deployment of review-*`. 1. For instance for `Checking for previous deployment of review-qa-raise-e-12chm0`, - your Review App slug would be `review-qa-raise-e-12chm0` in this case. + your Review App slug would be `review-qa-raise-e-12chm0` in this case. -### Run a Rails console? +### Run a Rails console 1. [Filter Workloads by your Review App slug](https://console.cloud.google.com/kubernetes/workload?project=gitlab-review-apps) - , e.g. `review-29951-issu-id2qax`. -1. Find and open the `task-runner` Deployment, e.g. `review-29951-issu-id2qax-task-runner`. -1. Click on the Pod in the "Managed pods" section, e.g. `review-29951-issu-id2qax-task-runner-d5455cc8-2lsvz`. + , e.g. `review-qa-raise-e-12chm0`. +1. Find and open the `task-runner` Deployment, e.g. `review-qa-raise-e-12chm0-task-runner`. +1. Click on the Pod in the "Managed pods" section, e.g. `review-qa-raise-e-12chm0-task-runner-d5455cc8-2lsvz`. 1. Click on the `KUBECTL` dropdown, then `Exec` -> `task-runner`. 1. Replace `-c task-runner -- ls` with `-it -- gitlab-rails console` from the - default command or - - Run `kubectl exec --namespace review-apps-ce review-29951-issu-id2qax-task-runner-d5455cc8-2lsvz -it -- gitlab-rails console` - and - - Replace `review-apps-ce` with `review-apps-ee` if the Review App - is running EE, and - - Replace `review-29951-issu-id2qax-task-runner-d5455cc8-2lsvz` - with your Pod's name. + default command or + - Run `kubectl exec --namespace review-apps-ce review-qa-raise-e-12chm0-task-runner-d5455cc8-2lsvz -it -- gitlab-rails console` and + - Replace `review-apps-ce` with `review-apps-ee` if the Review App + is running EE, and + - Replace `review-qa-raise-e-12chm0-task-runner-d5455cc8-2lsvz` + with your Pod's name. -### Dig into a Pod's logs? +### Dig into a Pod's logs -1. [Filter Workloads by your Review App slug](https://console.cloud.google.com/kubernetes/workload?project=gitlab-review-apps) - , e.g. `review-1979-1-mul-dnvlhv`. +1. [Filter Workloads by your Review App slug](https://console.cloud.google.com/kubernetes/workload?project=gitlab-review-apps), + e.g. `review-qa-raise-e-12chm0`. 1. Find and open the `migrations` Deployment, e.g. - `review-1979-1-mul-dnvlhv-migrations.1`. + `review-qa-raise-e-12chm0-migrations.1`. 1. Click on the Pod in the "Managed pods" section, e.g. - `review-1979-1-mul-dnvlhv-migrations.1-nqwtx`. + `review-qa-raise-e-12chm0-migrations.1-nqwtx`. 1. Click on the `Container logs` link. ## Frequently Asked Questions @@ -182,7 +183,8 @@ find a way to limit it to only us.** [review-build-cng]: https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/149511623 [review-deploy]: https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/149511624 [cng-mirror]: https://gitlab.com/gitlab-org/build/CNG-mirror -[cng-pipeline]: https://gitlab.com/gitlab-org/build/CNG-mirror/pipelines/44364657 +[cng]: https://gitlab.com/gitlab-org/build/CNG +[cng-mirror-pipeline]: https://gitlab.com/gitlab-org/build/CNG-mirror/pipelines/44364657 [cng-mirror-registry]: https://gitlab.com/gitlab-org/build/CNG-mirror/container_registry [helm-chart]: https://gitlab.com/charts/gitlab/ [review-apps-ce]: https://console.cloud.google.com/kubernetes/clusters/details/us-central1-a/review-apps-ce?project=gitlab-review-apps diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md index 0ab9406c681..69834f7ae47 100644 --- a/doc/topics/autodevops/index.md +++ b/doc/topics/autodevops/index.md @@ -737,6 +737,9 @@ also be customized, and you can easily use a [custom buildpack](#custom-buildpac | `AUTO_DEVOPS_DOMAIN` | The [Auto DevOps domain](#auto-devops-base-domain). By default, set automatically by the [Auto DevOps setting](#enablingdisabling-auto-devops). This variable is deprecated and [is scheduled to be removed](https://gitlab.com/gitlab-org/gitlab-ce/issues/56959). Use `KUBE_INGRESS_BASE_DOMAIN` instead. | | `AUTO_DEVOPS_CHART` | The Helm Chart used to deploy your apps; defaults to the one [provided by GitLab](https://gitlab.com/gitlab-org/charts/auto-deploy-app). | | `AUTO_DEVOPS_CHART_REPOSITORY` | The Helm Chart repository used to search for charts; defaults to `https://charts.gitlab.io`. | +| `AUTO_DEVOPS_CHART_REPOSITORY_NAME` | From Gitlab 11.11, this variable can be used to set the name of the helm repository; defaults to "gitlab" | +| `AUTO_DEVOPS_CHART_REPOSITORY_USERNAME` | From Gitlab 11.11, this variable can be 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, this variable can be used to set a password to connect to the helm repository. Defaults to no credentials. (Also set AUTO_DEVOPS_CHART_REPOSITORY_USERNAME) | | `REPLICAS` | The number of replicas to deploy; defaults to 1. | | `PRODUCTION_REPLICAS` | The number of replicas to deploy in the production environment. This takes precedence over `REPLICAS`; defaults to 1. | | `CANARY_REPLICAS` | The number of canary replicas to deploy for [Canary Deployments](https://docs.gitlab.com/ee/user/project/canary_deployments.html); defaults to 1 | @@ -767,6 +770,7 @@ also be customized, and you can easily use a [custom buildpack](#custom-buildpac | `PERFORMANCE_DISABLED` | From GitLab 11.0, this variable can be used to disable the `performance` job. If the variable is present, the job will not be created. | | `K8S_SECRET_*` | From GitLab 11.7, any variable prefixed with [`K8S_SECRET_`](#application-secret-variables) will be made available by Auto DevOps as environment variables to the deployed application. | | `KUBE_INGRESS_BASE_DOMAIN` | From GitLab 11.8, this variable can be used to set a domain per cluster. See [cluster domains](../../user/project/clusters/index.md#base-domain) for more information. | +| `ROLLOUT_RESOURCE_TYPE` | From GitLab 11.9, this variable allows specification of the resource type being deployed when using a custom helm chart. Default value is `deployment`. | | `HELM_UPGRADE_EXTRA_ARGS` | From GitLab 11.11, this variable allows extra arguments in `helm` commands when deploying the application. Note that using quotes will not prevent word splitting. | TIP: **Tip:** diff --git a/doc/user/admin_area/index.md b/doc/user/admin_area/index.md index 00cea22e4e1..5e67cb0ef16 100644 --- a/doc/user/admin_area/index.md +++ b/doc/user/admin_area/index.md @@ -16,7 +16,7 @@ The Admin Area is made up of the following sections: | Section | Description | |:------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------| -| Overview | View your GitLab Dashboard, and maintain projects, users, groups, jobs, runners, and Gitaly servers. | +| Overview | View your GitLab [Dashboard](#admin-dashboard), and maintain projects, users, groups, jobs, runners, and Gitaly servers. | | Monitoring | View GitLab system information, and information on background jobs, logs, [health checks](monitoring/health_check.md), request profiles, and audit logs. | | Messages | Send and manage [broadcast messages](broadcast_messages.md) for your users. | | System Hooks | Configure [system hooks](../../system_hooks/system_hooks.md) for many events. | @@ -27,3 +27,23 @@ The Admin Area is made up of the following sections: | Labels | Create and maintain [labels](labels.md) for your GitLab instance. | | Appearance | Customize [GitLab's appearance](../../customization/index.md). | | Settings | Modify the [settings](settings/index.md) for your GitLab instance. | + +## Admin Dashboard + +The Dashboard provides statistics and system information about the GitLab instance. + +To access the Dashboard, either: + +- Click the Admin Area icon (the wrench icon). +- Visit `/admin` on your self-managed instance. + +The Dashboard is the default view of the Admin Area, and is made up of the following sections: + +| Section | Description | +|------------|---------------| +| Projects | The total number of projects, up to 10 of the latest projects, and the option of creating a new project. | +| Users | The total number of users, up to 10 of the latest users, and the option of creating a new user. | +| Groups | The total number of groups, up to 10 of the latest groups, and the option of creating a new group. | +| Statistics | Totals of all elements of the GitLab instance. | +| Features | All features available on the GitLab instance. Enabled features are marked with a green circle icon, and disabled features are marked with a power icon. | +| Components | The major components of GitLab and the version number of each. A link to the Gitaly Servers is also included. |
\ No newline at end of file diff --git a/doc/user/group/index.md b/doc/user/group/index.md index 6054ab97dc1..2d887673fd6 100644 --- a/doc/user/group/index.md +++ b/doc/user/group/index.md @@ -68,7 +68,7 @@ together in a single list view. You can create a group in GitLab from: -1. The Groups page: expand the left menu, click **Groups**, and click the green button **New group**: +1. The Groups page: from the top menu, click **Groups**, and click the green button **New group**: ![new group from groups page](img/new_group_from_groups.png) @@ -80,14 +80,21 @@ Add the following information: ![new group info](img/create_new_group_info.png) -1. Set the **Group path** which will be the **namespace** under which your projects - will be hosted (path can contain only letters, digits, underscores, dashes - and dots; it cannot start with dashes or end in dot). -1. The **Group name** will populate with the path. Optionally, you can change - it. This is the name that will display in the group views. -1. Optionally, you can add a description so that others can briefly understand +1. The **Group name** will populate the URL automatically. Optionally, you can change it. + This is the name that is displayed in the group views. + The name can contain only: + - Alphanumeric characters. + - Underscores. + - Dashes and dots. + - Spaces. +1. The **Group URL**, which will be the namespace under which your projects will be hosted. + The URL can contain only: + - Alphanumeric characters. + - Underscores. + - Dashes and dots. It cannot start with dashes or end in dot. +1. Optionally, you can add a brief description to tell others what this group is about. -1. Optionally, choose an avatar for your project. +1. Optionally, choose an avatar for your group. 1. Choose the [visibility level](../../public_access/public_access.md). ## Add users to a group diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md index 0db95e5a64c..0677fe622f2 100644 --- a/doc/user/project/clusters/index.md +++ b/doc/user/project/clusters/index.md @@ -257,11 +257,11 @@ GitLab will create the necessary service accounts and privileges in order to ins NOTE: **Note:** Restricted service account for deployment was [introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/51716) in GitLab 11.5. -- When you install Helm Tiller into your cluster, the `tiller` service account +- When you install Helm into your cluster, the `tiller` service account will be 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 Tiller will also create additional service accounts and other resources for each + 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. @@ -315,25 +315,29 @@ install it manually. ## Installing applications GitLab provides **GitLab Managed Apps**, a one-click install for various applications which can -be added directly to your configured cluster. Those applications are +be added directly to your configured cluster. These applications are needed for [Review Apps](../../../ci/review_apps/index.md) and -[deployments](../../../ci/environments.md). You can install them after you +[deployments](../../../ci/environments.md) when using [Auto DevOps](../../../topics/autodevops/index.md). +You can install them after you [create a cluster](#adding-and-creating-a-new-gke-cluster-via-gitlab). +Applications managed by GitLab will be installed onto the `gitlab-managed-apps` namespace. This differrent +from the namespace used for project deployments. It is only created once and its name is not configurable. + To see a list of available applications to install: 1. Navigate to your project's **Operations > Kubernetes**. 1. Select your cluster. -Install Helm Tiller first because it's used to install other applications. +Install Helm first as it's used to install other applications. NOTE: **Note:** -As of GitLab 11.6, Helm Tiller will be upgraded to the latest version supported +As of GitLab 11.6, Helm will be upgraded to the latest version supported by GitLab before installing any of the applications. | Application | GitLab version | Description | Helm Chart | | ----------- | :------------: | ----------- | --------------- | -| [Helm Tiller](https://docs.helm.sh/) | 10.2+ | Helm is a package manager for Kubernetes and is required to install all the other applications. It is installed in its own pod inside the cluster which can run the `helm` CLI in a safe environment. | n/a | +| [Helm](https://docs.helm.sh/) | 10.2+ | Helm is a package manager for Kubernetes and is required to install all the other applications. It is installed in its own pod inside the cluster which can run the `helm` CLI in a safe environment. | n/a | | [Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) | 10.2+ | Ingress can provide load balancing, SSL termination, and name-based virtual hosting. It acts as a web proxy for your applications and is useful if you want to use [Auto DevOps] or deploy your own web apps. | [stable/nginx-ingress](https://github.com/helm/charts/tree/master/stable/nginx-ingress) | | [Cert-Manager](https://docs.cert-manager.io/en/latest/) | 11.6+ | Cert-Manager is a native Kubernetes certificate management controller that helps with issuing 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. | [stable/cert-manager](https://github.com/helm/charts/tree/master/stable/cert-manager) | | [Prometheus](https://prometheus.io/docs/introduction/overview/) | 10.4+ | Prometheus is an open-source monitoring and alerting system useful to supervise your deployed applications. | [stable/prometheus](https://github.com/helm/charts/tree/master/stable/prometheus) | @@ -345,9 +349,9 @@ With the exception of Knative, the applications will be installed in a dedicated namespace called `gitlab-managed-apps`. CAUTION: **Caution:** -If you have an existing Kubernetes cluster with Tiller already installed, +If you have an existing Kubernetes cluster with Helm already installed, you should be careful as GitLab cannot detect it. In this case, installing -Tiller via the applications will result in the cluster having it twice, which +Helm via the applications will result in the cluster having it twice, which can lead to confusion during deployments. ### Upgrading applications @@ -384,7 +388,7 @@ To avoid installation errors: - Before starting the installation of applications, make sure that time is synchronized between your GitLab server and your Kubernetes cluster. -- Ensure certificates are not out of sync. When installing applications, GitLab expects a new cluster with no previous installation of Tiller. +- Ensure certificates are not out of sync. When installing applications, GitLab expects a new cluster with no previous installation of Helm. You can confirm that the certificates match via `kubectl`: diff --git a/lib/api/api.rb b/lib/api/api.rb index bf8ddba6f0d..a572cca24e9 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -134,6 +134,7 @@ module API mount ::API::Pipelines mount ::API::PipelineSchedules mount ::API::ProjectClusters + mount ::API::ProjectEvents mount ::API::ProjectExport mount ::API::ProjectImport mount ::API::ProjectHooks diff --git a/lib/api/events.rb b/lib/api/events.rb index b98aa9f31e1..e4c017fab42 100644 --- a/lib/api/events.rb +++ b/lib/api/events.rb @@ -4,34 +4,11 @@ module API class Events < Grape::API include PaginationParams include APIGuard + helpers ::API::Helpers::EventsHelpers - helpers do - params :event_filter_params do - optional :action, type: String, values: Event.actions, desc: 'Event action to filter on' - optional :target_type, type: String, values: Event.target_types, desc: 'Event target type to filter on' - optional :before, type: Date, desc: 'Include only events created before this date' - optional :after, type: Date, desc: 'Include only events created after this date' - end - - params :sort_params do - optional :sort, type: String, values: %w[asc desc], default: 'desc', - desc: 'Return events sorted in ascending and descending order' - end - - def present_events(events) - events = paginate(events) - - present events, with: Entities::Event - end - - def find_events(source) - EventsFinder.new(params.merge(source: source, current_user: current_user, with_associations: true)).execute - end - end + allow_access_with_scope :read_user, if: -> (request) { request.get? } resource :events do - allow_access_with_scope :read_user, if: -> (request) { request.get? } - desc "List currently authenticated user's events" do detail 'This feature was introduced in GitLab 9.3.' success Entities::Event @@ -55,8 +32,6 @@ module API requires :id, type: String, desc: 'The ID or Username of the user' end resource :users do - allow_access_with_scope :read_user, if: -> (request) { request.get? } - desc 'Get the contribution events of a specified user' do detail 'This feature was introduced in GitLab 8.13.' success Entities::Event @@ -76,25 +51,5 @@ module API present_events(events) end end - - params do - requires :id, type: String, desc: 'The ID of a project' - end - resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do - desc "List a Project's visible events" do - success Entities::Event - end - params do - use :pagination - use :event_filter_params - use :sort_params - end - - get ":id/events" do - events = find_events(user_project) - - present_events(events) - end - end end end diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 8a21d44b4bf..7e4539d0419 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -67,10 +67,6 @@ module API initial_current_user != current_user end - def user_namespace - @user_namespace ||= find_namespace!(params[:id]) - end - def user_group @group ||= find_group!(params[:id]) end diff --git a/lib/api/helpers/events_helpers.rb b/lib/api/helpers/events_helpers.rb new file mode 100644 index 00000000000..bf3b76bb92d --- /dev/null +++ b/lib/api/helpers/events_helpers.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module API + module Helpers + module EventsHelpers + extend Grape::API::Helpers + + params :event_filter_params do + optional :action, type: String, values: Event.actions, desc: 'Event action to filter on' + optional :target_type, type: String, values: Event.target_types, desc: 'Event target type to filter on' + optional :before, type: Date, desc: 'Include only events created before this date' + optional :after, type: Date, desc: 'Include only events created after this date' + end + + params :sort_params do + optional :sort, type: String, values: %w[asc desc], default: 'desc', + desc: 'Return events sorted in ascending and descending order' + end + + def present_events(events) + events = paginate(events) + + present events, with: Entities::Event + end + + def find_events(source) + EventsFinder.new(params.merge(source: source, current_user: current_user, with_associations: true)).execute + end + end + end +end diff --git a/lib/api/namespaces.rb b/lib/api/namespaces.rb index 3cc09f6ac3f..77ecb3e7cde 100644 --- a/lib/api/namespaces.rb +++ b/lib/api/namespaces.rb @@ -44,6 +44,8 @@ module API requires :id, type: String, desc: "Namespace's ID or path" end get ':id', requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + user_namespace = find_namespace!(params[:id]) + present user_namespace, with: Entities::Namespace, current_user: current_user end end diff --git a/lib/api/project_events.rb b/lib/api/project_events.rb new file mode 100644 index 00000000000..734311e1142 --- /dev/null +++ b/lib/api/project_events.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module API + class ProjectEvents < Grape::API + include PaginationParams + include APIGuard + helpers ::API::Helpers::EventsHelpers + + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + desc "List a Project's visible events" do + success Entities::Event + end + params do + use :pagination + use :event_filter_params + use :sort_params + end + + get ":id/events" do + events = find_events(user_project) + + present_events(events) + end + end + end +end diff --git a/lib/api/wikis.rb b/lib/api/wikis.rb index 994074ddc67..5724adb2c40 100644 --- a/lib/api/wikis.rb +++ b/lib/api/wikis.rb @@ -33,7 +33,8 @@ module API authorize! :read_wiki, user_project entity = params[:with_content] ? Entities::WikiPage : Entities::WikiPageBasic - present user_project.wiki.pages, with: entity + + present user_project.wiki.list_pages(load_content: params[:with_content]), with: entity end desc 'Get a wiki page' do diff --git a/lib/gitlab/action_view_output/context.rb b/lib/gitlab/action_view_output/context.rb new file mode 100644 index 00000000000..9fbc9811636 --- /dev/null +++ b/lib/gitlab/action_view_output/context.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# This file was simplified from https://raw.githubusercontent.com/rails/rails/195f39804a7a4a0034f25e8704220e03d95a752a/actionview/lib/action_view/context.rb. +# +# It is only needed by modules that need to call ActionView helper +# methods (e.g. those in +# https://github.com/rails/rails/tree/c4d3e202e10ae627b3b9c34498afb45450652421/actionview/lib/action_view/helpers) +# to generate tags outside of a Rails controller (e.g. API, Sidekiq, +# etc.). +# +# In Rails 5, ActionView::Context automatically includes CompiledTemplates. +# This means that any module that includes ActionView::Context is now a descendant +# of CompiledTemplates. +# +# When a partial is rendered for the first time, it runs +# Module#module_eval, which will evaluate a string source that defines a +# new method. For example: +# +# def _app_views_profiles_show_html_haml___1285955918103175884_70307801785400(local_assigns, output_buffer) +# "hello world" +# end +# +# When a new method is defined, the Ruby interpreter clears the method +# cache for all descendants, and all methods for those modules will have +# to be redefined. This can lead to a significant performance penalty. +# +# Rails 6 fixes this behavior by moving out the `include +# CompiledTemplates` into ActionView::Base so that including `ActionView::Context` +# doesn't quietly affect other modules in this way. + +if Rails::VERSION::STRING.start_with?('6') + raise 'This module is no longer needed in Rails 6. Use ActionView::Context instead.' +end + +module Gitlab + module ActionViewOutput + module Context + attr_accessor :output_buffer, :view_flow + end + end +end diff --git a/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml index d36576fe39f..9d99d04d263 100644 --- a/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml @@ -490,7 +490,7 @@ rollout 100%: fi helm init --client-only - helm repo add gitlab ${AUTO_DEVOPS_CHART_REPOSITORY:-https://charts.gitlab.io} + helm repo add ${AUTO_DEVOPS_CHART_REPOSITORY_NAME:-gitlab} ${AUTO_DEVOPS_CHART_REPOSITORY:-https://charts.gitlab.io} ${AUTO_DEVOPS_CHART_REPOSITORY_USERNAME:+"--username" "$AUTO_DEVOPS_CHART_REPOSITORY_USERNAME"} ${AUTO_DEVOPS_CHART_REPOSITORY_PASSWORD:+"--password" "$AUTO_DEVOPS_CHART_REPOSITORY_PASSWORD"} if [[ ! -d "$auto_chart" ]]; then helm fetch ${auto_chart} --untar fi diff --git a/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml b/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml index 2a90cc9a06c..fd7fac5dcab 100644 --- a/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml @@ -30,6 +30,7 @@ dast: - | function dast_run() { docker run \ + --env DAST_FULL_SCAN_ENABLED \ --env DAST_TARGET_AVAILABILITY_TIMEOUT \ --volume "$PWD:/output" \ --volume /var/run/docker.sock:/var/run/docker.sock \ @@ -46,7 +47,8 @@ dast: --auth-username $DAST_USERNAME \ --auth-password $DAST_PASSWORD \ --auth-username-field $DAST_USERNAME_FIELD \ - --auth-password-field $DAST_PASSWORD_FIELD + --auth-password-field $DAST_PASSWORD_FIELD \ + --auth-exclude-urls $DAST_AUTH_EXCLUDE_URLS else dast_run fi diff --git a/lib/gitlab/ci/templates/dotNET-Core.yml b/lib/gitlab/ci/templates/dotNET-Core.yml new file mode 100644 index 00000000000..558ca3d22e1 --- /dev/null +++ b/lib/gitlab/ci/templates/dotNET-Core.yml @@ -0,0 +1,107 @@ +# This is a simple example illustrating how to build and test .NET Core project +# with GitLab Continuous Integration / Continuous Delivery. + +# ### Specify the Docker image +# +# Instead of installing .NET Core SDK manually, a docker image is used +# with already pre-installed .NET Core SDK. +# The 'latest' tag targets the latest available version of .NET Core SDK image. +# If preferred, you can explicitly specify version of .NET Core e.g. using '2.2-sdk' tag. +# +# See other available tags for .NET Core: https://hub.docker.com/r/microsoft/dotnet +# Learn more about Docker tags: https://docs.docker.com/glossary/?term=tag +# and the Docker itself: https://opensource.com/resources/what-docker +image: microsoft/dotnet:latest + +# ### Define variables +# +variables: + # 1) Name of directory where restore and build objects are stored. + OBJECTS_DIRECTORY: 'obj' + # 2) Name of directory used for keeping restored dependencies. + NUGET_PACKAGES_DIRECTORY: '.nuget' + # 3) A relative path to the source code from project repository root. + # NOTE: Please edit this path so it matches the structure of your project! + SOURCE_CODE_PATH: '*/*/' + +# ### Define stage list +# +# In this example there are only two stages. +# Initially, the project will be built and then tested. +stages: + - build + - test + +# ### Define global cache rule +# +# Before building the project, all dependencies (e.g. third-party NuGet packages) +# must be restored. Jobs on GitLab.com's Shared Runners are executed on autoscaled machines. +# Each machine is used only once (for security reasons) and after that it is removed. +# What that means is that before every job a dependency restore must be performed +# because restored dependencies are removed along with machines. Fortunately, +# GitLab provides cache mechanism with the aim of keeping restored dependencies +# for other jobs. This example shows how to configure cache to pass over restored +# dependencies for re-use. +# +# With global cache rule, cached dependencies will be downloaded before every job +# and then unpacked to the paths as specified below. +cache: + # Per-stage and per-branch caching. + key: "$CI_JOB_STAGE-$CI_COMMIT_REF_SLUG" + paths: + # Specify three paths that should be cached: + # + # 1) Main JSON file holding information about package dependency tree, packages versions, + # frameworks etc. It also holds information where to the dependencies were restored. + - '$SOURCE_CODE_PATH$OBJECTS_DIRECTORY/project.assets.json' + # 2) Other NuGet and MSBuild related files. Also needed. + - '$SOURCE_CODE_PATH$OBJECTS_DIRECTORY/*.csproj.nuget.*' + # 3) Path to the directory where restored dependencies are kept. + - '$NUGET_PACKAGES_DIRECTORY' + # 'pull-push' policy means that latest cache will be downloaded (if exists) + # before executing the job, and a newer version will be uploaded afterwards. + # Such setting saves time when there are no changes in referenced third-party + # packages. For example if you run a pipeline with changes in your code, + # but with no changes within third-party packages which your project is using, + # then project restore will happen in next to no time as all required dependencies + # will already be there — unzipped from cache. 'pull-push' policy is a default + # cache policy, you do not have to specify it explicitly. + policy: pull-push + +# ### Restore project dependencies +# +# NuGet packages by default are restored to '.nuget/packages' directory +# in the user's home directory. That directory is out of scope of GitLab caching. +# To get around this a custom path can be specified using '--packages <PATH>' option +# for 'dotnet restore' command. In this example a temporary directory is created +# in the root of project repository, so it's content can be cached. +# +# Learn more about GitLab cache: https://docs.gitlab.com/ee/ci/caching/index.html +before_script: + - 'dotnet restore --packages $NUGET_PACKAGES_DIRECTORY' + +build: + stage: build + # ### Build all projects discovered from solution file. + # + # Note: this will fail if you have any projects in your solution that are not + # .NET Core based projects e.g. WCF service, which is based on .NET Framework, + # not .NET Core. In such scenario you will need to build every .NET Core based + # project by explicitly specifying a relative path to the directory + # where it is located e.g. 'dotnet build ./src/ConsoleApp'. + # Only one project path can be passed as a parameter to 'dotnet build' command. + script: + - 'dotnet build --no-restore' + +tests: + stage: test + # ### Run the tests + # + # You can either run tests for all test projects that are defined in your solution + # with 'dotnet test' or run tests only for specific project by specifying + # a relative path to the directory where it is located e.g. 'dotnet test ./test/UnitTests'. + # + # You may want to define separate testing jobs for different types of testing + # e.g. integration tests, unit tests etc. + script: + - 'dotnet test --no-restore' diff --git a/lib/gitlab/ci/variables/collection/item.rb b/lib/gitlab/ci/variables/collection/item.rb index 833aa75adb5..aab10aef398 100644 --- a/lib/gitlab/ci/variables/collection/item.rb +++ b/lib/gitlab/ci/variables/collection/item.rb @@ -27,13 +27,9 @@ module Gitlab # don't expose `file` attribute at all (stems from what the runner # expects). # - # If the `variable_masking` feature is enabled we expose the `masked` - # attribute, otherwise it's not exposed. - # def to_runner_variable @variable.reject do |hash_key, hash_value| - (hash_key == :file && hash_value == false) || - (hash_key == :masked && !Feature.enabled?(:variable_masking)) + hash_key == :file && hash_value == false end end diff --git a/lib/gitlab/data_builder/deployment.rb b/lib/gitlab/data_builder/deployment.rb new file mode 100644 index 00000000000..26705dd1f6f --- /dev/null +++ b/lib/gitlab/data_builder/deployment.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Gitlab + module DataBuilder + module Deployment + extend self + + def build(deployment) + { + object_kind: 'deployment', + status: deployment.status, + deployable_id: deployment.deployable_id, + deployable_url: Gitlab::UrlBuilder.build(deployment.deployable), + environment: deployment.environment.name, + project: deployment.project.hook_attrs, + short_sha: deployment.short_sha, + user: deployment.user.hook_attrs, + commit_url: Gitlab::UrlBuilder.build(deployment.commit) + } + end + end + end +end diff --git a/lib/gitlab/data_builder/push.rb b/lib/gitlab/data_builder/push.rb index af385d7d4ca..40bda3410e1 100644 --- a/lib/gitlab/data_builder/push.rb +++ b/lib/gitlab/data_builder/push.rb @@ -58,7 +58,10 @@ module Gitlab # } # # rubocop:disable Metrics/ParameterLists - def build(project, user, oldrev, newrev, ref, commits = [], message = nil, commits_count: nil, push_options: {}) + def build( + project:, user:, ref:, oldrev: nil, newrev: nil, + commits: [], commits_count: nil, message: nil, push_options: {}) + commits = Array(commits) # Total commits count @@ -113,7 +116,12 @@ module Gitlab ref = "#{Gitlab::Git::BRANCH_REF_PREFIX}#{project.default_branch}" commits = project.repository.commits(project.default_branch.to_s, limit: 3) - build(project, user, commits.last&.id, commits.first&.id, ref, commits) + build(project: project, + user: user, + oldrev: commits.last&.id, + newrev: commits.first&.id, + ref: ref, + commits: commits) end def sample_data diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index c12cb6a6434..55bd77f6c4a 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -118,6 +118,12 @@ module Gitlab gitaly_repository_client.exists? end + def create_repository + wrapped_gitaly_errors do + gitaly_repository_client.create_repository + end + end + # Returns an Array of branch names # sorted by name ASC def branch_names diff --git a/lib/gitlab/git/wiki.rb b/lib/gitlab/git/wiki.rb index a0dd4a24363..c1bcd8e934a 100644 --- a/lib/gitlab/git/wiki.rb +++ b/lib/gitlab/git/wiki.rb @@ -86,9 +86,14 @@ module Gitlab end end - def pages(limit: 0, sort: nil, direction_desc: false) + def list_pages(limit: 0, sort: nil, direction_desc: false, load_content: false) wrapped_gitaly_errors do - gitaly_get_all_pages(limit: limit, sort: sort, direction_desc: direction_desc) + gitaly_list_pages( + limit: limit, + sort: sort, + direction_desc: direction_desc, + load_content: load_content + ) end end @@ -168,10 +173,17 @@ module Gitlab Gitlab::Git::WikiFile.new(wiki_file) end - def gitaly_get_all_pages(limit: 0, sort: nil, direction_desc: false) - gitaly_wiki_client.get_all_pages( - limit: limit, sort: sort, direction_desc: direction_desc - ).map do |wiki_page, version| + def gitaly_list_pages(limit: 0, sort: nil, direction_desc: false, load_content: false) + params = { limit: limit, sort: sort, direction_desc: direction_desc } + + gitaly_pages = + if load_content + gitaly_wiki_client.load_all_pages(params) + else + gitaly_wiki_client.list_all_pages(params) + end + + gitaly_pages.map do |wiki_page, version| Gitlab::Git::WikiPage.new(wiki_page, version) end end diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb index cb80ed64eff..4b626509008 100644 --- a/lib/gitlab/git_access.rb +++ b/lib/gitlab/git_access.rb @@ -85,7 +85,7 @@ module Gitlab check_push_access! end - ::Gitlab::GitAccessResult::Success.new(console_messages: check_for_console_messages(cmd)) + success_result(cmd) end def guest_can_download_code? @@ -365,6 +365,10 @@ module Gitlab protected + def success_result(cmd) + ::Gitlab::GitAccessResult::Success.new(console_messages: check_for_console_messages(cmd)) + end + def changes_list @changes_list ||= Gitlab::ChangesList.new(changes == ANY ? [] : changes) end diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb index c432317eb24..d34b50c5215 100644 --- a/lib/gitlab/gitaly_client.rb +++ b/lib/gitlab/gitaly_client.rb @@ -31,6 +31,9 @@ module Gitlab MAXIMUM_GITALY_CALLS = 30 CLIENT_NAME = (Sidekiq.server? ? 'gitlab-sidekiq' : 'gitlab-web').freeze + SERVER_FEATURE_CATFILE_CACHE = 'catfile-cache'.freeze + SERVER_FEATURE_FLAGS = [SERVER_FEATURE_CATFILE_CACHE].freeze + MUTEX = Mutex.new define_histogram :gitaly_controller_action_duration_seconds do @@ -219,6 +222,7 @@ module Gitlab metadata['call_site'] = feature.to_s if feature metadata['gitaly-servers'] = address_metadata(remote_storage) if remote_storage metadata['x-gitlab-correlation-id'] = Labkit::Correlation::CorrelationId.current_id if Labkit::Correlation::CorrelationId.current_id + metadata['gitaly-session-id'] = session_id if feature_enabled?(SERVER_FEATURE_CATFILE_CACHE) metadata.merge!(server_feature_flags) @@ -235,7 +239,9 @@ module Gitlab result end - SERVER_FEATURE_FLAGS = %w[].freeze + def self.session_id + Gitlab::SafeRequestStore[:gitaly_session_id] ||= SecureRandom.uuid + end def self.server_feature_flags SERVER_FEATURE_FLAGS.map do |f| diff --git a/lib/gitlab/gitaly_client/blob_service.rb b/lib/gitlab/gitaly_client/blob_service.rb index 6b8e58e6199..8ccefb00d20 100644 --- a/lib/gitlab/gitaly_client/blob_service.rb +++ b/lib/gitlab/gitaly_client/blob_service.rb @@ -55,13 +55,13 @@ module Gitlab def get_blobs(revision_paths, limit = -1) return [] if revision_paths.empty? - revision_paths.map! do |rev, path| + request_revision_paths = revision_paths.map do |rev, path| Gitaly::GetBlobsRequest::RevisionPath.new(revision: rev, path: encode_binary(path)) end request = Gitaly::GetBlobsRequest.new( repository: @gitaly_repo, - revision_paths: revision_paths, + revision_paths: request_revision_paths, limit: limit ) diff --git a/lib/gitlab/gitaly_client/commit_service.rb b/lib/gitlab/gitaly_client/commit_service.rb index 0d5debfcd01..2896b7e1ce0 100644 --- a/lib/gitlab/gitaly_client/commit_service.rb +++ b/lib/gitlab/gitaly_client/commit_service.rb @@ -174,7 +174,7 @@ module Gitlab response.each_with_object({}) do |gitaly_response, hsh| gitaly_response.commits.each do |commit_for_tree| - hsh[commit_for_tree.path] = Gitlab::Git::Commit.new(@repository, commit_for_tree.commit) + hsh[commit_for_tree.path_bytes] = Gitlab::Git::Commit.new(@repository, commit_for_tree.commit) end end end diff --git a/lib/gitlab/gitaly_client/wiki_service.rb b/lib/gitlab/gitaly_client/wiki_service.rb index e036cdcd800..ce9faad825c 100644 --- a/lib/gitlab/gitaly_client/wiki_service.rb +++ b/lib/gitlab/gitaly_client/wiki_service.rb @@ -87,7 +87,27 @@ module Gitlab wiki_page_from_iterator(response) end - def get_all_pages(limit: 0, sort: nil, direction_desc: false) + def list_all_pages(limit: 0, sort: nil, direction_desc: false) + sort_value = Gitaly::WikiListPagesRequest::SortBy.resolve(sort.to_s.upcase.to_sym) + + params = { repository: @gitaly_repo, limit: limit, direction_desc: direction_desc } + params[:sort] = sort_value if sort_value + + request = Gitaly::WikiListPagesRequest.new(params) + stream = GitalyClient.call(@repository.storage, :wiki_service, :wiki_list_pages, request, timeout: GitalyClient.medium_timeout) + stream.each_with_object([]) do |message, pages| + page = message.page + + next unless page + + wiki_page = GitalyClient::WikiPage.new(page.to_h) + version = new_wiki_page_version(page.version) + + pages << [wiki_page, version] + end + end + + def load_all_pages(limit: 0, sort: nil, direction_desc: false) sort_value = Gitaly::WikiGetAllPagesRequest::SortBy.resolve(sort.to_s.upcase.to_sym) params = { repository: @gitaly_repo, limit: limit, direction_desc: direction_desc } @@ -95,6 +115,7 @@ module Gitlab request = Gitaly::WikiGetAllPagesRequest.new(params) response = GitalyClient.call(@repository.storage, :wiki_service, :wiki_get_all_pages, request, timeout: GitalyClient.medium_timeout) + pages = [] loop do diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index ce268793128..c6d4fda4af5 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -75,6 +75,7 @@ project_tree: - :project_badges - :ci_cd_settings - :error_tracking_setting + - :metrics_setting # Only include the following attributes for the models specified. included_attributes: diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index 61a1aa6da5a..e1e70a008d9 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -25,7 +25,8 @@ module Gitlab metrics: 'MergeRequest::Metrics', ci_cd_settings: 'ProjectCiCdSetting', error_tracking_setting: 'ErrorTracking::ProjectErrorTrackingSetting', - links: 'Releases::Link' }.freeze + links: 'Releases::Link', + metrics_setting: 'ProjectMetricsSetting' }.freeze USER_REFERENCES = %w[author_id assignee_id updated_by_id merged_by_id latest_closed_by_id user_id created_by_id last_edited_by_id merge_user_id resolved_by_id closed_by_id].freeze diff --git a/lib/gitlab/kubernetes/helm/api.rb b/lib/gitlab/kubernetes/helm/api.rb index 7dfd9ed4f35..ff1dadf9247 100644 --- a/lib/gitlab/kubernetes/helm/api.rb +++ b/lib/gitlab/kubernetes/helm/api.rb @@ -22,6 +22,13 @@ module Gitlab alias_method :update, :install + def uninstall(command) + namespace.ensure_exists! + + delete_pod!(command.pod_name) + kubeclient.create_pod(command.pod_resource) + end + ## # Returns Pod phase # diff --git a/lib/gitlab/metrics/dashboard/processor.rb b/lib/gitlab/metrics/dashboard/processor.rb new file mode 100644 index 00000000000..cc34ac53051 --- /dev/null +++ b/lib/gitlab/metrics/dashboard/processor.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Gitlab + module Metrics + module Dashboard + # Responsible for processesing a dashboard hash, inserting + # relevant DB records & sorting for proper rendering in + # the UI. These includes shared metric info, custom metrics + # info, and alerts (only in EE). + class Processor + SEQUENCE = [ + Stages::CommonMetricsInserter, + Stages::ProjectMetricsInserter, + Stages::Sorter + ].freeze + + def initialize(project, environment, dashboard) + @project = project + @environment = environment + @dashboard = dashboard + end + + # Returns a new dashboard hash with the results of + # running transforms on the dashboard. + def process + @dashboard.deep_symbolize_keys.tap do |dashboard| + sequence.each do |stage| + stage.new(@project, @environment, dashboard).transform! + end + end + end + + private + + def sequence + SEQUENCE + end + end + end + end +end diff --git a/lib/gitlab/metrics/dashboard/service.rb b/lib/gitlab/metrics/dashboard/service.rb new file mode 100644 index 00000000000..79d563cce4f --- /dev/null +++ b/lib/gitlab/metrics/dashboard/service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# Fetches the metrics dashboard layout and supplemented the output with DB info. +module Gitlab + module Metrics + module Dashboard + class Service < ::BaseService + SYSTEM_DASHBOARD_NAME = 'common_metrics' + SYSTEM_DASHBOARD_PATH = Rails.root.join('config', 'prometheus', "#{SYSTEM_DASHBOARD_NAME}.yml") + + # Returns a DB-supplemented json representation of a dashboard config file. + def get_dashboard + dashboard_string = Rails.cache.fetch(cache_key) { system_dashboard } + + dashboard = process_dashboard(dashboard_string) + + success(dashboard: dashboard) + rescue Gitlab::Metrics::Dashboard::Stages::BaseStage::DashboardLayoutError => e + error(e.message, :unprocessable_entity) + end + + private + + # Returns the base metrics shipped with every GitLab service. + def system_dashboard + YAML.safe_load(File.read(SYSTEM_DASHBOARD_PATH)) + end + + def cache_key + "metrics_dashboard_#{SYSTEM_DASHBOARD_NAME}" + end + + # Returns a new dashboard Hash, supplemented with DB info + def process_dashboard(dashboard) + Gitlab::Metrics::Dashboard::Processor.new(project, params[:environment], dashboard).process + end + end + end + end +end diff --git a/lib/gitlab/metrics/dashboard/stages/base_stage.rb b/lib/gitlab/metrics/dashboard/stages/base_stage.rb new file mode 100644 index 00000000000..dd4aae6c115 --- /dev/null +++ b/lib/gitlab/metrics/dashboard/stages/base_stage.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Gitlab + module Metrics + module Dashboard + module Stages + class BaseStage + DashboardLayoutError = Class.new(StandardError) + + DEFAULT_PANEL_TYPE = 'area-chart' + + attr_reader :project, :environment, :dashboard + + def initialize(project, environment, dashboard) + @project = project + @environment = environment + @dashboard = dashboard + end + + # Entry-point to the stage + def transform! + raise NotImplementedError + end + + protected + + def missing_panel_groups! + raise DashboardLayoutError.new('Top-level key :panel_groups must be an array') + end + + def missing_panels! + raise DashboardLayoutError.new('Each "panel_group" must define an array :panels') + end + + def missing_metrics! + raise DashboardLayoutError.new('Each "panel" must define an array :metrics') + end + + def for_metrics(dashboard) + missing_panel_groups! unless dashboard[:panel_groups].is_a?(Array) + + dashboard[:panel_groups].each do |panel_group| + missing_panels! unless panel_group[:panels].is_a?(Array) + + panel_group[:panels].each do |panel| + missing_metrics! unless panel[:metrics].is_a?(Array) + + panel[:metrics].each do |metric| + yield metric + end + end + end + end + end + end + end + end +end diff --git a/lib/gitlab/metrics/dashboard/stages/common_metrics_inserter.rb b/lib/gitlab/metrics/dashboard/stages/common_metrics_inserter.rb new file mode 100644 index 00000000000..3406021bbea --- /dev/null +++ b/lib/gitlab/metrics/dashboard/stages/common_metrics_inserter.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Gitlab + module Metrics + module Dashboard + module Stages + class CommonMetricsInserter < BaseStage + # For each metric in the dashboard config, attempts to + # find a corresponding database record. If found, + # includes the record's id in the dashboard config. + def transform! + common_metrics = ::PrometheusMetric.common + + for_metrics(dashboard) do |metric| + metric_record = common_metrics.find { |m| m.identifier == metric[:id] } + metric[:metric_id] = metric_record.id if metric_record + end + 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 new file mode 100644 index 00000000000..221610a14d1 --- /dev/null +++ b/lib/gitlab/metrics/dashboard/stages/project_metrics_inserter.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Gitlab + module Metrics + module Dashboard + module Stages + class ProjectMetricsInserter < BaseStage + # Inserts project-specific metrics into the dashboard + # config. If there are no project-specific metrics, + # this will have no effect. + def transform! + project.prometheus_metrics.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) + end + end + + private + + # Looks for a panel_group corresponding to the + # provided metric object. If unavailable, inserts one. + # @param panel_groups [Array<Hash>] + # @param metric [PrometheusMetric] + def find_or_create_panel_group(panel_groups, metric) + panel_group = find_panel_group(panel_groups, metric) + return panel_group if panel_group + + panel_group = new_panel_group(metric) + panel_groups << panel_group + + panel_group + end + + # Looks for a panel corresponding to the provided + # metric object. If unavailable, inserts one. + # @param panels [Array<Hash>] + # @param metric [PrometheusMetric] + def find_or_create_panel(panels, metric) + panel = find_panel(panels, metric) + return panel if panel + + panel = new_panel(metric) + panels << panel + + panel + end + + # Looks for a metric corresponding to the provided + # metric object. If unavailable, inserts one. + # @param metrics [Array<Hash>] + # @param metric [PrometheusMetric] + def find_or_create_metric(metrics, metric) + target_metric = find_metric(metrics, metric) + return target_metric if target_metric + + target_metric = new_metric(metric) + metrics << target_metric + + target_metric + end + + def find_panel_group(panel_groups, metric) + return unless panel_groups + + panel_groups.find { |group| group[:group] == metric.group_title } + end + + def find_panel(panels, metric) + return unless panels + + panel_identifiers = [DEFAULT_PANEL_TYPE, metric.title, metric.y_label] + panels.find { |panel| panel.values_at(:type, :title, :y_label) == panel_identifiers } + end + + def find_metric(metrics, metric) + return unless metrics + + metrics.find { |m| m[:id] == metric.identifier } + end + + def new_panel_group(metric) + { + group: metric.group_title, + priority: metric.priority, + panels: [] + } + end + + def new_panel(metric) + { + type: DEFAULT_PANEL_TYPE, + title: metric.title, + y_label: metric.y_label, + metrics: [] + } + end + + def new_metric(metric) + metric.queries.first.merge(metric_id: metric.id) + end + end + end + end + end +end diff --git a/lib/gitlab/metrics/dashboard/stages/sorter.rb b/lib/gitlab/metrics/dashboard/stages/sorter.rb new file mode 100644 index 00000000000..ba5aa78059c --- /dev/null +++ b/lib/gitlab/metrics/dashboard/stages/sorter.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Gitlab + module Metrics + module Dashboard + module Stages + class Sorter < BaseStage + def transform! + missing_panel_groups! unless dashboard[:panel_groups].is_a? Array + + sort_groups! + sort_panels! + end + + private + + # Sorts the groups in the dashboard by the :priority key + def sort_groups! + dashboard[:panel_groups] = dashboard[:panel_groups].sort_by { |group| -group[:priority].to_i } + end + + # Sorts the panels in the dashboard by the :weight key + def sort_panels! + dashboard[:panel_groups].each do |group| + missing_panels! unless group[:panels].is_a? Array + + group[:panels] = group[:panels].sort_by { |panel| -panel[:weight].to_i } + end + end + end + end + end + end +end diff --git a/lib/gitlab/profiler.rb b/lib/gitlab/profiler.rb index 28ed587f5c7..890228e5e78 100644 --- a/lib/gitlab/profiler.rb +++ b/lib/gitlab/profiler.rb @@ -73,7 +73,7 @@ module Gitlab result = with_custom_logger(logger) do with_user(user) do - RubyProf.profile { app.public_send(verb, url, post_data, headers) } # rubocop:disable GitlabSecurity/PublicSend + RubyProf.profile { app.public_send(verb, url, params: post_data, headers: headers) } # rubocop:disable GitlabSecurity/PublicSend end end diff --git a/lib/gitlab/sidekiq_config.rb b/lib/gitlab/sidekiq_config.rb index fb303e3fb0c..c102fa14cfc 100644 --- a/lib/gitlab/sidekiq_config.rb +++ b/lib/gitlab/sidekiq_config.rb @@ -7,7 +7,7 @@ module Gitlab module SidekiqConfig QUEUE_CONFIG_PATHS = %w[app/workers/all_queues.yml ee/app/workers/all_queues.yml].freeze - # This method is called by `bin/sidekiq-cluster` in EE, which runs outside + # This method is called by `ee/bin/sidekiq-cluster` in EE, which runs outside # of bundler/Rails context, so we cannot use any gem or Rails methods. def self.worker_queues(rails_path = Rails.root.to_s) @worker_queues ||= {} @@ -19,7 +19,7 @@ module Gitlab end end - # This method is called by `bin/sidekiq-cluster` in EE, which runs outside + # This method is called by `ee/bin/sidekiq-cluster` in EE, which runs outside # of bundler/Rails context, so we cannot use any gem or Rails methods. def self.expand_queues(queues, all_queues = self.worker_queues) return [] if queues.empty? diff --git a/lib/gitlab/url_builder.rb b/lib/gitlab/url_builder.rb index f86d599e4cb..169ce8ab026 100644 --- a/lib/gitlab/url_builder.rb +++ b/lib/gitlab/url_builder.rb @@ -30,6 +30,8 @@ module Gitlab snippet_url(object) when Milestone milestone_url(object) + when ::Ci::Build + project_job_url(object.project, object) else raise NotImplementedError.new("No URL builder defined for #{object.class}") end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 06f2f848925..3d56efa9834 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -138,6 +138,9 @@ msgstr "" msgid "%{label_for_message} unavailable" msgstr "" +msgid "%{lets_encrypt_link_start}Let's Encrypt%{lets_encrypt_link_end} is a free, automated, and open certificate authority (CA), that give digital certificates in order to enable HTTPS (SSL/TLS) for websites." +msgstr "" + msgid "%{level_name} is not allowed in a %{group_level_name} group." msgstr "" @@ -245,6 +248,9 @@ msgstr "" msgid "- show less" msgstr "" +msgid "0 for unlimited" +msgstr "" + msgid "1 %{type} addition" msgid_plural "%{count} %{type} additions" msgstr[0] "" @@ -366,6 +372,9 @@ msgstr "" msgid "A Jekyll site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features." 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 default branch cannot be chosen for an empty project." msgstr "" @@ -933,6 +942,9 @@ msgstr "" msgid "Application settings saved successfully" msgstr "" +msgid "Application uninstalled but failed to destroy: %{error_message}" +msgstr "" + msgid "Application was successfully destroyed." msgstr "" @@ -1890,6 +1902,9 @@ msgstr "" msgid "Close milestone" msgstr "" +msgid "Close sidebar" +msgstr "" + msgid "Closed" msgstr "" @@ -2522,6 +2537,9 @@ msgstr "" msgid "Configure Gitaly timeouts." msgstr "" +msgid "Configure Let's Encrypt" +msgstr "" + msgid "Configure automatic git checks and housekeeping on repositories." msgstr "" @@ -3259,6 +3277,9 @@ msgstr "" msgid "Discuss a specific suggestion or question that needs to be resolved" msgstr "" +msgid "Discussion" +msgstr "" + msgid "Dismiss" msgstr "" @@ -3274,6 +3295,9 @@ msgstr "" msgid "Domain" msgstr "" +msgid "Domain verification is an essential security measure for public GitLab sites. Users are required to demonstrate they control a domain before it is enabled" +msgstr "" + msgid "Don't show again" msgstr "" @@ -3937,6 +3961,9 @@ msgstr "" msgid "Failed to check related branches." msgstr "" +msgid "Failed to connect to the prometheus server" +msgstr "" + msgid "Failed to create repository via gitlab-shell" msgstr "" @@ -4616,6 +4643,9 @@ msgstr "" msgid "I accept the|Terms of Service and Privacy Policy" msgstr "" +msgid "I have read and agree to the Let's Encrypt Terms of Service" +msgstr "" + msgid "ID" msgstr "" @@ -5305,6 +5335,9 @@ msgstr "" msgid "Leave the \"File type\" and \"Delivery method\" options on their default values." msgstr "" +msgid "Let's Encrypt does not accept emails on example.com" +msgstr "" + msgid "Limited to showing %d event at most" msgid_plural "Limited to showing %d events at most" msgstr[0] "" @@ -5936,6 +5969,9 @@ msgstr "" msgid "Newly registered users will by default be external" msgstr "" +msgid "Next" +msgstr "" + msgid "No" msgstr "" @@ -6008,6 +6044,9 @@ msgstr "" msgid "No other labels with such name or description" msgstr "" +msgid "No parent group" +msgstr "" + msgid "No preview for this file type" msgstr "" @@ -6238,6 +6277,12 @@ msgstr "" msgid "Opens in a new window" msgstr "" +msgid "Operation failed. Check pod logs for %{pod_name} for more details." +msgstr "" + +msgid "Operation timed out. Check pod logs for %{pod_name} for more details." +msgstr "" + msgid "Operations" msgstr "" @@ -6655,6 +6700,9 @@ msgstr "" msgid "Preview" msgstr "" +msgid "Preview Markdown" +msgstr "" + msgid "Preview changes" msgstr "" @@ -7617,6 +7665,9 @@ msgstr "" msgid "Require all users to accept Terms of Service and Privacy Policy when they access GitLab." msgstr "" +msgid "Require users to prove ownership of custom domains" +msgstr "" + msgid "Resend invite" msgstr "" @@ -8671,6 +8722,9 @@ msgstr "" msgid "Switch branch/tag" msgstr "" +msgid "Switch to GitLab Next" +msgstr "" + msgid "System Hooks" msgstr "" @@ -9117,6 +9171,9 @@ msgstr "" msgid "There was an error when unsubscribing from this label." msgstr "" +msgid "There was an error while fetching cycle analytics data." +msgstr "" + msgid "There was an error with the reCAPTCHA. Please solve the reCAPTCHA again." msgstr "" @@ -9760,6 +9817,9 @@ msgstr "" msgid "Unable to load the diff. %{button_try_again}" msgstr "" +msgid "Unable to regenerate public ssh key." +msgstr "" + msgid "Unable to schedule a pipeline to run immediately" msgstr "" diff --git a/package.json b/package.json index e04470109be..7981ec850a2 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@babel/preset-env": "^7.3.1", "@gitlab/csslab": "^1.9.0", "@gitlab/svgs": "^1.59.0", - "@gitlab/ui": "^3.4.0", + "@gitlab/ui": "^3.7.0", "apollo-cache-inmemory": "^1.5.1", "apollo-client": "^2.5.1", "apollo-upload-client": "^10.0.0", 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 de5c535c757..10bba98f704 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 @@ -2,7 +2,8 @@ module QA context 'Create' do - describe 'File templates' do + # Issue: https://gitlab.com/gitlab-org/quality/nightly/issues/97 + describe 'File templates', :quarantine do include Runtime::Fixtures def login 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 daeee665c93..c2c2b6da90a 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 @@ -1,7 +1,8 @@ # frozen_string_literal: true module QA - context 'Create' do + # Failure issue: https://gitlab.com/gitlab-org/quality/nightly/issues/62 + context 'Create', :quarantine do describe 'Create, list, and delete branches via web' do master_branch = 'master' second_branch = 'second-branch' diff --git a/qa/qa/specs/features/browser_ui/3_create/wiki/create_edit_clone_push_wiki_spec.rb b/qa/qa/specs/features/browser_ui/3_create/wiki/create_edit_clone_push_wiki_spec.rb index 309ae6cd986..e689ba4c69c 100644 --- a/qa/qa/specs/features/browser_ui/3_create/wiki/create_edit_clone_push_wiki_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/wiki/create_edit_clone_push_wiki_spec.rb @@ -3,11 +3,6 @@ module QA context 'Create' do describe 'Wiki management' do - def validate_content(content) - expect(page).to have_content('Wiki was successfully updated') - expect(page).to have_content(/#{content}/) - end - it 'user creates, edits, clones, and pushes to the wiki' do Runtime::Browser.visit(:gitlab, Page::Main::Login) Page::Main::Login.perform(&:sign_in_using_credentials) @@ -38,6 +33,11 @@ module QA expect(page).to have_content('My Third Wiki Content') end + + def validate_content(content) + expect(page).to have_content('Wiki was successfully updated') + expect(page).to have_content(/#{content}/) + end end end end diff --git a/qa/qa/specs/helpers/quarantine.rb b/qa/qa/specs/helpers/quarantine.rb index 52cb05fcd13..ca0ce32e74f 100644 --- a/qa/qa/specs/helpers/quarantine.rb +++ b/qa/qa/specs/helpers/quarantine.rb @@ -20,6 +20,14 @@ module QA::Specs::Helpers end end + # Skip the entire context if a context is quarantined. This avoids running + # before blocks unnecessarily. + def skip_or_run_quarantined_contexts(filters, example) + return unless example.metadata.key?(:quarantine) + + skip_or_run_quarantined_tests_or_contexts(filters, example) + end + # Skip tests in quarantine unless we explicitly focus on them. def skip_or_run_quarantined_tests_or_contexts(filters, example) if filters.key?(:quarantine) @@ -39,14 +47,6 @@ module QA::Specs::Helpers end end - # Skip the entire context if a context is quarantined. This avoids running - # before blocks unnecessarily. - def skip_or_run_quarantined_contexts(filters, example) - return unless example.metadata.key?(:quarantine) - - skip_or_run_quarantined_tests_or_contexts(filters, example) - end - def filters_other_than_quarantine(filter) filter.reject { |key, _| key == :quarantine } end diff --git a/qa/qa/vendor/github/page/login.rb b/qa/qa/vendor/github/page/login.rb index 6d8f9aa7c12..120ba6e6c06 100644 --- a/qa/qa/vendor/github/page/login.rb +++ b/qa/qa/vendor/github/page/login.rb @@ -12,9 +12,7 @@ module QA fill_in 'password', with: QA::Runtime::Env.github_password click_on 'Sign in' - unless has_no_text?("Authorize GitLab-OAuth") - click_on 'Authorize gitlab-qa' if has_button?('Authorize gitlab-qa') - end + click_on 'Authorize gitlab-qa' if has_button?('Authorize gitlab-qa') end end end diff --git a/rubocop/cop/include_action_view_context.rb b/rubocop/cop/include_action_view_context.rb new file mode 100644 index 00000000000..14662a33e95 --- /dev/null +++ b/rubocop/cop/include_action_view_context.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative '../spec_helpers' + +module RuboCop + module Cop + # Cop that makes sure workers include `::Gitlab::ActionViewOutput::Context`, not `ActionView::Context`. + class IncludeActionViewContext < RuboCop::Cop::Cop + include SpecHelpers + + MSG = 'Include `::Gitlab::ActionViewOutput::Context`, not `ActionView::Context`, for Rails 5.'.freeze + + def_node_matcher :includes_action_view_context?, <<~PATTERN + (send nil? :include (const (const nil? :ActionView) :Context)) + PATTERN + + def on_send(node) + return if in_spec?(node) + return unless includes_action_view_context?(node) + + add_offense(node.arguments.first, location: :expression) + end + + def autocorrect(node) + lambda do |corrector| + corrector.replace(node.source_range, '::Gitlab::ActionViewOutput::Context') + end + end + end + end +end diff --git a/rubocop/rubocop.rb b/rubocop/rubocop.rb index 50eab6f9270..ce6bdbf292c 100644 --- a/rubocop/rubocop.rb +++ b/rubocop/rubocop.rb @@ -4,6 +4,7 @@ require_relative 'cop/gitlab/predicate_memoization' require_relative 'cop/gitlab/httparty' require_relative 'cop/gitlab/finder_with_find_by' require_relative 'cop/gitlab/union' +require_relative 'cop/include_action_view_context' require_relative 'cop/include_sidekiq_worker' require_relative 'cop/safe_params' require_relative 'cop/active_record_association_reload' diff --git a/scripts/review_apps/review-apps.sh b/scripts/review_apps/review-apps.sh index b55ce1af55e..8be22dc0278 100755 --- a/scripts/review_apps/review-apps.sh +++ b/scripts/review_apps/review-apps.sh @@ -1,26 +1,6 @@ [[ "$TRACE" ]] && set -x export TILLER_NAMESPACE="$KUBE_NAMESPACE" -function echoerr() { - local header="${2}" - - if [ -n "${header}" ]; then - printf "\n\033[0;31m** %s **\n\033[0m" "${1}" >&2; - else - printf "\033[0;31m%s\n\033[0m" "${1}" >&2; - fi -} - -function echoinfo() { - local header="${2}" - - if [ -n "${header}" ]; then - printf "\n\033[0;33m** %s **\n\033[0m" "${1}" >&2; - else - printf "\033[0;33m%s\n\033[0m" "${1}" >&2; - fi -} - function deployExists() { local namespace="${1}" local deploy="${2}" @@ -328,80 +308,3 @@ function add_license() { puts "License added"; ' } - -function get_job_id() { - local job_name="${1}" - local query_string="${2:+&${2}}" - - local max_page=3 - local page=1 - - while true; do - local url="https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/pipelines/${CI_PIPELINE_ID}/jobs?per_page=100&page=${page}${query_string}" - echoinfo "GET ${url}" - - local job_id - job_id=$(curl --silent --show-error --header "PRIVATE-TOKEN: ${API_TOKEN}" "${url}" | jq "map(select(.name == \"${job_name}\")) | map(.id) | last") - [[ "${job_id}" == "null" && "${page}" -lt "$max_page" ]] || break - - let "page++" - done - - if [[ "${job_id}" == "" ]]; then - echoerr "The '${job_name}' job ID couldn't be retrieved!" - else - echoinfo "The '${job_name}' job ID is ${job_id}" - echo "${job_id}" - fi -} - -function play_job() { - local job_name="${1}" - local job_id - job_id=$(get_job_id "${job_name}" "scope=manual"); - if [ -z "${job_id}" ]; then return; fi - - local url="https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/jobs/${job_id}/play" - echoinfo "POST ${url}" - - local job_url - job_url=$(curl --silent --show-error --request POST --header "PRIVATE-TOKEN: ${API_TOKEN}" "${url}" | jq ".web_url") - echoinfo "Manual job '${job_name}' started at: ${job_url}" -} - -function wait_for_job_to_be_done() { - local job_name="${1}" - local query_string="${2}" - local job_id - job_id=$(get_job_id "${job_name}" "${query_string}") - if [ -z "${job_id}" ]; then return; fi - - echoinfo "Waiting for the '${job_name}' job to finish..." - - local url="https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/jobs/${job_id}" - echoinfo "GET ${url}" - - # In case the job hasn't finished yet. Keep trying until the job times out. - local interval=30 - local elapsed_seconds=0 - while true; do - local job_status - job_status=$(curl --silent --show-error --header "PRIVATE-TOKEN: ${API_TOKEN}" "${url}" | jq ".status" | sed -e s/\"//g) - [[ "${job_status}" == "pending" || "${job_status}" == "running" ]] || break - - printf "." - let "elapsed_seconds+=interval" - sleep ${interval} - done - - local elapsed_minutes=$((elapsed_seconds / 60)) - echoinfo "Waited '${job_name}' for ${elapsed_minutes} minutes." - - if [[ "${job_status}" == "failed" ]]; then - echoerr "The '${job_name}' failed." - elif [[ "${job_status}" == "manual" ]]; then - echoinfo "The '${job_name}' is manual." - else - echoinfo "The '${job_name}' passed." - fi -} diff --git a/scripts/utils.sh b/scripts/utils.sh index 2d2ba115563..4a6567b8a62 100644 --- a/scripts/utils.sh +++ b/scripts/utils.sh @@ -1,4 +1,4 @@ -retry() { +function retry() { if eval "$@"; then return 0 fi @@ -13,15 +13,15 @@ retry() { return 1 } -setup_db_user_only() { +function setup_db_user_only() { if [ "$GITLAB_DATABASE" = "postgresql" ]; then - . scripts/create_postgres_user.sh + source scripts/create_postgres_user.sh else - . scripts/create_mysql_user.sh + source scripts/create_mysql_user.sh fi } -setup_db() { +function setup_db() { setup_db_user_only bundle exec rake db:drop db:create db:schema:load db:migrate @@ -30,3 +30,129 @@ setup_db() { bundle exec rake add_limits_mysql fi } + +function install_api_client_dependencies_with_apk() { + apk add --update openssl curl jq +} + +function install_api_client_dependencies_with_apt() { + apt update && apt install jq -y +} + +function install_gitlab_gem() { + gem install gitlab --no-document +} + +function echoerr() { + local header="${2}" + + if [ -n "${header}" ]; then + printf "\n\033[0;31m** %s **\n\033[0m" "${1}" >&2; + else + printf "\033[0;31m%s\n\033[0m" "${1}" >&2; + fi +} + +function echoinfo() { + local header="${2}" + + if [ -n "${header}" ]; then + printf "\n\033[0;33m** %s **\n\033[0m" "${1}" >&2; + else + printf "\033[0;33m%s\n\033[0m" "${1}" >&2; + fi +} + +function get_job_id() { + local job_name="${1}" + local query_string="${2:+&${2}}" + local api_token="${API_TOKEN-${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}}" + if [ -z "${api_token}" ]; then + echoerr "Please provide an API token with \$API_TOKEN or \$GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN." + return + fi + + local max_page=3 + local page=1 + + while true; do + local url="https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/pipelines/${CI_PIPELINE_ID}/jobs?per_page=100&page=${page}${query_string}" + echoinfo "GET ${url}" + + local job_id + job_id=$(curl --silent --show-error --header "PRIVATE-TOKEN: ${api_token}" "${url}" | jq "map(select(.name == \"${job_name}\")) | map(.id) | last") + [[ "${job_id}" == "null" && "${page}" -lt "$max_page" ]] || break + + let "page++" + done + + if [[ "${job_id}" == "" ]]; then + echoerr "The '${job_name}' job ID couldn't be retrieved!" + else + echoinfo "The '${job_name}' job ID is ${job_id}" + echo "${job_id}" + fi +} + +function play_job() { + local job_name="${1}" + local job_id + job_id=$(get_job_id "${job_name}" "scope=manual"); + if [ -z "${job_id}" ]; then return; fi + + local api_token="${API_TOKEN-${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}}" + if [ -z "${api_token}" ]; then + echoerr "Please provide an API token with \$API_TOKEN or \$GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN." + return + fi + + local url="https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/jobs/${job_id}/play" + echoinfo "POST ${url}" + + local job_url + job_url=$(curl --silent --show-error --request POST --header "PRIVATE-TOKEN: ${api_token}" "${url}" | jq ".web_url") + echoinfo "Manual job '${job_name}' started at: ${job_url}" +} + +function wait_for_job_to_be_done() { + local job_name="${1}" + local query_string="${2}" + local job_id + job_id=$(get_job_id "${job_name}" "${query_string}") + if [ -z "${job_id}" ]; then return; fi + + local api_token="${API_TOKEN-${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}}" + if [ -z "${api_token}" ]; then + echoerr "Please provide an API token with \$API_TOKEN or \$GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN." + return + fi + + echoinfo "Waiting for the '${job_name}' job to finish..." + + local url="https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/jobs/${job_id}" + echoinfo "GET ${url}" + + # In case the job hasn't finished yet. Keep trying until the job times out. + local interval=30 + local elapsed_seconds=0 + while true; do + local job_status + job_status=$(curl --silent --show-error --header "PRIVATE-TOKEN: ${api_token}" "${url}" | jq ".status" | sed -e s/\"//g) + [[ "${job_status}" == "pending" || "${job_status}" == "running" ]] || break + + printf "." + let "elapsed_seconds+=interval" + sleep ${interval} + done + + local elapsed_minutes=$((elapsed_seconds / 60)) + echoinfo "Waited '${job_name}' for ${elapsed_minutes} minutes." + + if [[ "${job_status}" == "failed" ]]; then + echoerr "The '${job_name}' failed." + elif [[ "${job_status}" == "manual" ]]; then + echoinfo "The '${job_name}' is manual." + else + echoinfo "The '${job_name}' passed." + fi +} diff --git a/spec/controllers/projects/clusters/applications_controller_spec.rb b/spec/controllers/projects/clusters/applications_controller_spec.rb index cd1a01f8acc..70b34f071c8 100644 --- a/spec/controllers/projects/clusters/applications_controller_spec.rb +++ b/spec/controllers/projects/clusters/applications_controller_spec.rb @@ -145,4 +145,66 @@ describe Projects::Clusters::ApplicationsController do it_behaves_like 'a secure endpoint' end end + + describe 'DELETE destroy' do + subject do + delete :destroy, params: params.merge(namespace_id: project.namespace, project_id: project) + end + + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } + let!(:application) { create(:clusters_applications_prometheus, :installed, cluster: cluster) } + let(:application_name) { application.name } + let(:params) { { application: application_name, id: cluster.id } } + let(:worker_class) { Clusters::Applications::UninstallWorker } + + describe 'functionality' do + let(:user) { create(:user) } + + before do + project.add_maintainer(user) + sign_in(user) + end + + context "when cluster and app exists" do + it "schedules an application update" do + expect(worker_class).to receive(:perform_async).with(application.name, application.id).once + + is_expected.to have_http_status(:no_content) + + expect(cluster.application_prometheus).to be_scheduled + end + end + + context 'when cluster do not exists' do + before do + cluster.destroy! + end + + it { is_expected.to have_http_status(:not_found) } + end + + context 'when application is unknown' do + let(:application_name) { 'unkwnown-app' } + + it { is_expected.to have_http_status(:not_found) } + end + + context 'when application is already scheduled' do + before do + application.make_scheduled! + end + + it { is_expected.to have_http_status(:bad_request) } + end + end + + describe 'security' do + before do + allow(worker_class).to receive(:perform_async) + end + + it_behaves_like 'a secure endpoint' + end + end end diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index 75158f2e8e0..a62422d0229 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -342,11 +342,9 @@ describe Projects::EnvironmentsController do end context 'when environment has no metrics' do - before do - expect(environment).to receive(:metrics).and_return(nil) - end - it 'returns a metrics page' do + expect(environment).not_to receive(:metrics) + get :metrics, params: environment_params expect(response).to be_ok @@ -354,6 +352,8 @@ describe Projects::EnvironmentsController do context 'when requesting metrics as JSON' do it 'returns a metrics JSON document' do + expect(environment).to receive(:metrics).and_return(nil) + get :metrics, params: environment_params(format: :json) expect(response).to have_gitlab_http_status(204) @@ -461,6 +461,43 @@ describe Projects::EnvironmentsController do end end + describe 'metrics_dashboard' do + context 'when prometheus endpoint is disabled' do + before do + stub_feature_flags(environment_metrics_use_prometheus_endpoint: false) + end + + it 'responds with status code 403' do + get :metrics_dashboard, params: environment_params(format: :json) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'when prometheus endpoint is enabled' do + it 'returns a json representation of the environment dashboard' do + get :metrics_dashboard, params: environment_params(format: :json) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.keys).to contain_exactly('dashboard', 'status') + expect(json_response['dashboard']).to be_an_instance_of(Hash) + end + + context 'when the dashboard could not be provided' do + before do + allow(YAML).to receive(:safe_load).and_return({}) + end + + it 'returns an error response' do + get :metrics_dashboard, params: environment_params(format: :json) + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response.keys).to contain_exactly('message', 'status', 'http_status') + end + end + end + end + describe 'GET #search' do before do create(:environment, name: 'staging', project: project) diff --git a/spec/controllers/projects/settings/operations_controller_spec.rb b/spec/controllers/projects/settings/operations_controller_spec.rb index 02a392f23c2..aa9cd41ed19 100644 --- a/spec/controllers/projects/settings/operations_controller_spec.rb +++ b/spec/controllers/projects/settings/operations_controller_spec.rb @@ -11,15 +11,118 @@ describe Projects::Settings::OperationsController do project.add_maintainer(user) end - context 'error tracking' do - describe 'GET #show' do - it 'renders show template' do + shared_examples 'PATCHable' do + let(:operations_update_service) { instance_double(::Projects::Operations::UpdateService) } + let(:operations_url) { project_settings_operations_url(project) } + + let(:permitted_params) do + ActionController::Parameters.new(params).permit! + end + + context 'format json' do + context 'when update succeeds' do + it 'returns success status' do + stub_operations_update_service_returning(status: :success) + + patch :update, + params: project_params(project, params), + format: :json + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to eq('status' => 'success') + expect(flash[:notice]).to eq('Your changes have been saved') + end + end + + context 'when update fails' do + it 'returns error' do + stub_operations_update_service_returning( + status: :error, + message: 'error message' + ) + + patch :update, + params: project_params(project, params), + format: :json + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to eq('error message') + end + end + end + + private + + def stub_operations_update_service_returning(return_value = {}) + expect(::Projects::Operations::UpdateService) + .to receive(:new).with(project, user, permitted_params) + .and_return(operations_update_service) + expect(operations_update_service).to receive(:execute) + .and_return(return_value) + end + end + + describe 'GET #show' do + it 'renders show template' do + get :show, params: project_params(project) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template(:show) + end + + context 'with insufficient permissions' do + before do + project.add_reporter(user) + end + + it 'renders 404' do + get :show, params: project_params(project) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'as an anonymous user' do + before do + sign_out(user) + end + + it 'redirects to signup page' do get :show, params: project_params(project) - expect(response).to have_gitlab_http_status(:ok) - expect(response).to render_template(:show) + expect(response).to redirect_to(new_user_session_path) + end + end + end + + describe 'PATCH #update' do + context 'with insufficient permissions' do + before do + project.add_reporter(user) + end + + it 'renders 404' do + patch :update, params: project_params(project) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'as an anonymous user' do + before do + sign_out(user) + end + + it 'redirects to signup page' do + patch :update, params: project_params(project) + + expect(response).to redirect_to(new_user_session_path) end + end + end + context 'error tracking' do + describe 'GET #show' do context 'with existing setting' do let!(:error_tracking_setting) do create(:project_error_tracking_setting, project: project) @@ -40,37 +143,10 @@ describe Projects::Settings::OperationsController do expect(controller.helpers.error_tracking_setting).to be_new_record end end - - context 'with insufficient permissions' do - before do - project.add_reporter(user) - end - - it 'renders 404' do - get :show, params: project_params(project) - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'as an anonymous user' do - before do - sign_out(user) - end - - it 'redirects to signup page' do - get :show, params: project_params(project) - - expect(response).to redirect_to(new_user_session_path) - end - end end describe 'PATCH #update' do - let(:operations_update_service) { spy(:operations_update_service) } - let(:operations_url) { project_settings_operations_url(project) } - - let(:error_tracking_params) do + let(:params) do { error_tracking_setting_attributes: { enabled: '1', @@ -86,79 +162,21 @@ describe Projects::Settings::OperationsController do } end - let(:error_tracking_permitted) do - ActionController::Parameters.new(error_tracking_params).permit! - end - - context 'format json' do - context 'when update succeeds' do - before do - stub_operations_update_service_returning(status: :success) - end - - it 'returns success status' do - patch :update, - params: project_params(project, error_tracking_params), - format: :json - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response).to eq('status' => 'success') - expect(flash[:notice]).to eq('Your changes have been saved') - end - end - - context 'when update fails' do - before do - stub_operations_update_service_returning( - status: :error, - message: 'error message' - ) - end - - it 'returns error' do - patch :update, - params: project_params(project, error_tracking_params), - format: :json - - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['message']).not_to be_nil - end - end - end - - context 'with insufficient permissions' do - before do - project.add_reporter(user) - end - - it 'renders 404' do - patch :update, params: project_params(project) - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'as an anonymous user' do - before do - sign_out(user) - end - - it 'redirects to signup page' do - patch :update, params: project_params(project) - - expect(response).to redirect_to(new_user_session_path) - end - end + it_behaves_like 'PATCHable' end + end - private + context 'metrics dashboard setting' do + describe 'PATCH #update' do + let(:params) do + { + metrics_setting_attributes: { + external_dashboard_url: 'https://gitlab.com' + } + } + end - def stub_operations_update_service_returning(return_value = {}) - expect(::Projects::Operations::UpdateService) - .to receive(:new).with(project, user, error_tracking_permitted) - .and_return(operations_update_service) - expect(operations_update_service).to receive(:execute) - .and_return(return_value) + it_behaves_like 'PATCHable' end end diff --git a/spec/controllers/projects/wikis_controller_spec.rb b/spec/controllers/projects/wikis_controller_spec.rb index e0a6fc52ee9..f2e0b5e5c1d 100644 --- a/spec/controllers/projects/wikis_controller_spec.rb +++ b/spec/controllers/projects/wikis_controller_spec.rb @@ -19,6 +19,18 @@ describe Projects::WikisController do destroy_page(wiki_title) end + describe 'GET #pages' do + subject { get :pages, params: { namespace_id: project.namespace, project_id: project, id: wiki_title } } + + it 'does not load the pages content' do + expect(controller).to receive(:load_wiki).and_return(project_wiki) + + expect(project_wiki).to receive(:list_pages).twice.and_call_original + + subject + end + end + describe 'GET #show' do render_views @@ -28,9 +40,9 @@ describe Projects::WikisController do expect(controller).to receive(:load_wiki).and_return(project_wiki) # empty? call - expect(project_wiki).to receive(:pages).with(limit: 1).and_call_original + expect(project_wiki).to receive(:list_pages).with(limit: 1).and_call_original # Sidebar entries - expect(project_wiki).to receive(:pages).with(limit: 15).and_call_original + expect(project_wiki).to receive(:list_pages).with(limit: 15).and_call_original subject @@ -104,7 +116,7 @@ describe Projects::WikisController do subject - expect(response).to redirect_to(project_wiki_path(project, project_wiki.pages.first)) + expect(response).to redirect_to(project_wiki_path(project, project_wiki.list_pages.first)) end end @@ -138,7 +150,7 @@ describe Projects::WikisController do allow(controller).to receive(:valid_encoding?).and_return(false) subject - expect(response).to redirect_to(project_wiki_path(project, project_wiki.pages.first)) + expect(response).to redirect_to(project_wiki_path(project, project_wiki.list_pages.first)) end end @@ -148,7 +160,7 @@ describe Projects::WikisController do it 'updates the page' do subject - wiki_page = project_wiki.pages.first + wiki_page = project_wiki.list_pages(load_content: true).first expect(wiki_page.title).to eq new_title expect(wiki_page.content).to eq new_content diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb index 9c60f0fcd4d..4634d1d4bb3 100644 --- a/spec/controllers/search_controller_spec.rb +++ b/spec/controllers/search_controller_spec.rb @@ -11,6 +11,30 @@ describe SearchController do sign_in(user) end + context 'uses the right partials depending on scope' do + using RSpec::Parameterized::TableSyntax + render_views + + set(:project) { create(:project, :public, :repository, :wiki_repo) } + + subject { get(:show, params: { project_id: project.id, scope: scope, search: 'merge' }) } + + where(:partial, :scope) do + '_blob' | :blobs + '_wiki_blob' | :wiki_blobs + '_commit' | :commits + end + + with_them do + it do + project_wiki = create(:project_wiki, project: project, user: user) + create(:wiki_page, wiki: project_wiki, attrs: { title: 'merge', content: 'merge' }) + + expect(subject).to render_template("search/results/#{partial}") + end + end + end + it 'finds issue comments' do project = create(:project, :public) note = create(:note_on_issue, project: project) diff --git a/spec/factories/clusters/applications/helm.rb b/spec/factories/clusters/applications/helm.rb index fe56ac5b71d..d78f01828d7 100644 --- a/spec/factories/clusters/applications/helm.rb +++ b/spec/factories/clusters/applications/helm.rb @@ -6,6 +6,11 @@ FactoryBot.define do status(-2) end + trait :errored do + status(-1) + status_reason 'something went wrong' + end + trait :installable do status 0 end @@ -30,17 +35,21 @@ FactoryBot.define do status 5 end - trait :errored do - status(-1) + trait :update_errored do + status(6) status_reason 'something went wrong' end - trait :update_errored do - status(6) + trait :uninstalling do + status 7 + end + + trait :uninstall_errored do + status(8) status_reason 'something went wrong' end - trait :timeouted do + trait :timed_out do installing updated_at { ClusterWaitForAppInstallationWorker::TIMEOUT.ago } end diff --git a/spec/factories/deployments.rb b/spec/factories/deployments.rb index 011c98599a3..db438ad32d3 100644 --- a/spec/factories/deployments.rb +++ b/spec/factories/deployments.rb @@ -1,6 +1,6 @@ FactoryBot.define do factory :deployment, class: Deployment do - sha '97de212e80737a608d939f648d959671fb0a0142' + sha 'b83d6e391c22777fca1ed3012fce84f633d7fed0' ref 'master' tag false user nil diff --git a/spec/factories/project_metrics_settings.rb b/spec/factories/project_metrics_settings.rb new file mode 100644 index 00000000000..234753f9b87 --- /dev/null +++ b/spec/factories/project_metrics_settings.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :project_metrics_setting, class: ProjectMetricsSetting do + project + external_dashboard_url 'https://grafana.com' + end +end diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index 04f39b807d7..f9950b5b03f 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -230,6 +230,13 @@ describe 'Admin updates settings' do expect(find_field('Username').value).to eq 'test_user' expect(find('#service_push_channel').value).to eq '#test_channel' end + + it 'defaults Deployment events to false for chat notification template settings' do + first(:link, 'Service Templates').click + click_link 'Slack notifications' + + expect(find_field('Deployment')).not_to be_checked + end end context 'CI/CD page' do @@ -368,15 +375,50 @@ describe 'Admin updates settings' do expect(Gitlab::CurrentSettings.pages_domain_verification_enabled?).to be_truthy expect(page).to have_content "Application settings saved successfully" end + + context 'When pages_auto_ssl is enabled' do + before do + stub_feature_flags(pages_auto_ssl: true) + visit preferences_admin_application_settings_path + end + + it "Change Pages Let's Encrypt settings" do + page.within('.as-pages') do + fill_in 'Email', with: 'my@test.example.com' + check "I have read and agree to the Let's Encrypt Terms of Service" + click_button 'Save changes' + end + + expect(Gitlab::CurrentSettings.lets_encrypt_notification_email).to eq 'my@test.example.com' + expect(Gitlab::CurrentSettings.lets_encrypt_terms_of_service_accepted).to eq true + end + end + + context 'When pages_auto_ssl is disabled' do + before do + stub_feature_flags(pages_auto_ssl: false) + visit preferences_admin_application_settings_path + end + + it "Doesn't show Let's Encrypt options" do + page.within('.as-pages') do + expect(page).not_to have_content('Email') + end + end + end end def check_all_events page.check('Active') page.check('Push') - page.check('Tag push') - page.check('Note') page.check('Issue') + page.check('Confidential issue') page.check('Merge request') + page.check('Note') + page.check('Confidential note') + page.check('Tag push') page.check('Pipeline') + page.check('Wiki page') + page.check('Deployment') end end diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index b1c6f308bc6..29545779a34 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -34,14 +34,11 @@ describe "Admin::Users" do expect(page).to have_button('Delete user and contributions') end - describe "view extra user information", :js do - it 'does not have the user popover open' do + describe "view extra user information" do + it 'shows the user popover on hover', :js, :quarantine do expect(page).not_to have_selector('#__BV_popover_1__') - end - it 'shows the user popover on hover' do first_user_link = page.first('.js-user-link') - first_user_link.hover expect(page).to have_selector('#__BV_popover_1__') diff --git a/spec/features/issuables/markdown_references/jira_spec.rb b/spec/features/issuables/markdown_references/jira_spec.rb index 8eaccfc0949..cc04798248c 100644 --- a/spec/features/issuables/markdown_references/jira_spec.rb +++ b/spec/features/issuables/markdown_references/jira_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -describe "Jira", :js do +describe "Jira", :js, :quarantine do let(:user) { create(:user) } let(:actual_project) { create(:project, :public, :repository) } let(:merge_request) { create(:merge_request, target_project: actual_project, source_project: actual_project) } diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index cf334e1e4da..4ec44cb05b3 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -331,11 +331,9 @@ describe 'Pipeline', :js do merge_request.all_pipelines.last end - before do + it 'shows the pipeline information' do visit_pipeline - end - it 'shows the pipeline information' do within '.pipeline-info' do expect(page).to have_content("#{pipeline.statuses.count} jobs " \ "for !#{merge_request.iid} " \ @@ -347,6 +345,21 @@ describe 'Pipeline', :js do end end + context 'when source branch does not exist' do + before do + project.repository.rm_branch(user, merge_request.source_branch) + end + + it 'does not link to the source branch commit path' do + visit_pipeline + + within '.pipeline-info' do + expect(page).not_to have_link(merge_request.source_branch) + expect(page).to have_content(merge_request.source_branch) + end + end + end + context 'when source project is a forked project' do let(:source_project) { fork_project(project, user, repository: true) } @@ -386,11 +399,11 @@ describe 'Pipeline', :js do before do pipeline.update(user: user) - - visit_pipeline end it 'shows the pipeline information' do + visit_pipeline + within '.pipeline-info' do expect(page).to have_content("#{pipeline.statuses.count} jobs " \ "for !#{merge_request.iid} " \ @@ -405,6 +418,21 @@ describe 'Pipeline', :js do end end + context 'when target branch does not exist' do + before do + project.repository.rm_branch(user, merge_request.target_branch) + end + + it 'does not link to the target branch commit path' do + visit_pipeline + + within '.pipeline-info' do + expect(page).not_to have_link(merge_request.target_branch) + expect(page).to have_content(merge_request.target_branch) + end + end + end + context 'when source project is a forked project' do let(:source_project) { fork_project(project, user, repository: true) } diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb index 0aff916ec83..0dbff5a2701 100644 --- a/spec/features/protected_branches_spec.rb +++ b/spec/features/protected_branches_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe 'Protected Branches', :js do + include ProtectedBranchHelpers + let(:user) { create(:user) } let(:admin) { create(:admin) } let(:project) { create(:project, :repository) } @@ -150,27 +152,11 @@ describe 'Protected Branches', :js do end describe "access control" do - include_examples "protected branches > access control > CE" - end - end - - def set_protected_branch_name(branch_name) - find(".js-protected-branch-select").click - find(".dropdown-input-field").set(branch_name) - click_on("Create wildcard #{branch_name}") - end - - def set_defaults - find(".js-allowed-to-merge").click - within('.qa-allowed-to-merge-dropdown') do - expect(first("li")).to have_content("Roles") - find(:link, 'No one').click - end + before do + stub_licensed_features(protected_refs_for_users: false) + end - find(".js-allowed-to-push").click - within('.qa-allowed-to-push-dropdown') do - expect(first("li")).to have_content("Roles") - find(:link, 'No one').click + include_examples "protected branches > access control > CE" end end end diff --git a/spec/features/protected_tags_spec.rb b/spec/features/protected_tags_spec.rb index c8e92cd1c07..652542b1719 100644 --- a/spec/features/protected_tags_spec.rb +++ b/spec/features/protected_tags_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe 'Protected Tags', :js do + include ProtectedTagHelpers + let(:user) { create(:user, :admin) } let(:project) { create(:project, :repository) } @@ -8,13 +10,6 @@ describe 'Protected Tags', :js do sign_in(user) end - def set_protected_tag_name(tag_name) - find(".js-protected-tag-select").click - find(".dropdown-input-field").set(tag_name) - click_on("Create wildcard #{tag_name}") - find('.protected-tags-dropdown .dropdown-menu', visible: false) - end - describe "explicit protected tags" do it "allows creating explicit protected tags" do visit project_protected_tags_path(project) @@ -92,6 +87,10 @@ describe 'Protected Tags', :js do end describe "access control" do + before do + stub_licensed_features(protected_refs_for_users: false) + end + include_examples "protected tags > access control > CE" end end diff --git a/spec/features/search/user_searches_for_wiki_pages_spec.rb b/spec/features/search/user_searches_for_wiki_pages_spec.rb index 7225ca65492..6d4facd0649 100644 --- a/spec/features/search/user_searches_for_wiki_pages_spec.rb +++ b/spec/features/search/user_searches_for_wiki_pages_spec.rb @@ -14,22 +14,36 @@ describe 'User searches for wiki pages', :js do include_examples 'top right search form' - it 'finds a page' do - find('.js-search-project-dropdown').click + shared_examples 'search wiki blobs' do + it 'finds a page' do + find('.js-search-project-dropdown').click - page.within('.project-filter') do - click_link(project.full_name) - end + page.within('.project-filter') do + click_link(project.full_name) + end + + fill_in('dashboard_search', with: 'content') + find('.btn-search').click + + page.within('.search-filter') do + click_link('Wiki') + end - fill_in('dashboard_search', with: 'content') - find('.btn-search').click + page.within('.results') do + expect(find(:css, '.search-results')).to have_link(wiki_page.title, href: project_wiki_path(project, wiki_page.slug)) + end + end + end - page.within('.search-filter') do - click_link('Wiki') + context 'when searching by content' do + it_behaves_like 'search wiki blobs' do + let(:search_term) { 'content' } end + end - page.within('.results') do - expect(find(:css, '.search-results')).to have_link(wiki_page.title) + context 'when searching by title' do + it_behaves_like 'search wiki blobs' do + let(:search_term) { 'test_wiki' } end end end diff --git a/spec/fixtures/api/schemas/cluster_status.json b/spec/fixtures/api/schemas/cluster_status.json index 9da07a0b253..695175689b9 100644 --- a/spec/fixtures/api/schemas/cluster_status.json +++ b/spec/fixtures/api/schemas/cluster_status.json @@ -36,7 +36,8 @@ "external_hostname": { "type": ["string", "null"] }, "hostname": { "type": ["string", "null"] }, "email": { "type": ["string", "null"] }, - "update_available": { "type": ["boolean", "null"] } + "update_available": { "type": ["boolean", "null"] }, + "can_uninstall": { "type": "boolean" } }, "required" : [ "name", "status" ] } diff --git a/spec/fixtures/lib/gitlab/metrics/dashboard/sample_dashboard.yml b/spec/fixtures/lib/gitlab/metrics/dashboard/sample_dashboard.yml new file mode 100644 index 00000000000..c2d3d3d8aca --- /dev/null +++ b/spec/fixtures/lib/gitlab/metrics/dashboard/sample_dashboard.yml @@ -0,0 +1,36 @@ +dashboard: 'Test Dashboard' +priority: 1 +panel_groups: +- group: Group A + priority: 10 + panels: + - title: "Super Chart A1" + type: "area-chart" + y_label: "y_label" + weight: 1 + metrics: + - id: metric_a1 + query_range: 'query' + unit: unit + label: Legend Label + - title: "Super Chart A2" + type: "area-chart" + y_label: "y_label" + weight: 2 + metrics: + - id: metric_a2 + query_range: 'query' + label: Legend Label + unit: unit +- group: Group B + priority: 1 + panels: + - title: "Super Chart B" + type: "area-chart" + y_label: "y_label" + weight: 1 + metrics: + - id: metric_b + query_range: 'query' + unit: unit + label: Legend Label diff --git a/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/dashboard.json b/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/dashboard.json new file mode 100644 index 00000000000..1ee1205e29a --- /dev/null +++ b/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/dashboard.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "required": ["dashboard", "priority", "panel_groups"], + "properties": { + "dashboard": { "type": "string" }, + "priority": { "type": "number" }, + "panel_groups": { + "type": "array", + "items": { "$ref": "spec/fixtures/lib/gitlab/metrics/dashboard/schemas/panel_groups.json" } + } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/metrics.json b/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/metrics.json new file mode 100644 index 00000000000..2d0af57ec2c --- /dev/null +++ b/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/metrics.json @@ -0,0 +1,20 @@ +{ + "type": "object", + "required": [ + "unit", + "label" + ], + "oneOf": [ + { "required": ["query"] }, + { "required": ["query_range"] } + ], + "properties": { + "id": { "type": "string" }, + "query_range": { "type": "string" }, + "query": { "type": "string" }, + "unit": { "type": "string" }, + "label": { "type": "string" }, + "track": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/panel_groups.json b/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/panel_groups.json new file mode 100644 index 00000000000..d7a390adcdc --- /dev/null +++ b/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/panel_groups.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "required": [ + "group", + "priority", + "panels" + ], + "properties": { + "group": { "type": "string" }, + "priority": { "type": "number" }, + "panels": { + "type": "array", + "items": { "$ref": "panels.json" } + } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/panels.json b/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/panels.json new file mode 100644 index 00000000000..1548daacd64 --- /dev/null +++ b/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/panels.json @@ -0,0 +1,20 @@ +{ + "type": "object", + "required": [ + "title", + "y_label", + "weight", + "metrics" + ], + "properties": { + "title": { "type": "string" }, + "type": { "type": "string" }, + "y_label": { "type": "string" }, + "weight": { "type": "number" }, + "metrics": { + "type": "array", + "items": { "$ref": "metrics.json" } + } + }, + "additionalProperties": false +} diff --git a/spec/frontend/clusters/clusters_bundle_spec.js b/spec/frontend/clusters/clusters_bundle_spec.js index 33a35069004..a61103397eb 100644 --- a/spec/frontend/clusters/clusters_bundle_spec.js +++ b/spec/frontend/clusters/clusters_bundle_spec.js @@ -1,16 +1,13 @@ import Clusters from '~/clusters/clusters_bundle'; -import { - REQUEST_SUBMITTED, - REQUEST_FAILURE, - APPLICATION_STATUS, - INGRESS_DOMAIN_SUFFIX, -} from '~/clusters/constants'; +import { APPLICATION_STATUS, INGRESS_DOMAIN_SUFFIX } from '~/clusters/constants'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import { loadHTMLFixture } from 'helpers/fixtures'; import { setTestTimeout } from 'helpers/timeout'; import $ from 'jquery'; +const { INSTALLING, INSTALLABLE, INSTALLED } = APPLICATION_STATUS; + describe('Clusters', () => { setTestTimeout(1000); @@ -93,7 +90,7 @@ describe('Clusters', () => { it('does not show alert when things transition from initial null state to something', () => { cluster.checkForNewInstalls(INITIAL_APP_MAP, { ...INITIAL_APP_MAP, - helm: { status: APPLICATION_STATUS.INSTALLABLE, title: 'Helm Tiller' }, + helm: { status: INSTALLABLE, title: 'Helm Tiller' }, }); const flashMessage = document.querySelector('.js-cluster-application-notice .flash-text'); @@ -105,11 +102,11 @@ describe('Clusters', () => { cluster.checkForNewInstalls( { ...INITIAL_APP_MAP, - helm: { status: APPLICATION_STATUS.INSTALLING, title: 'Helm Tiller' }, + helm: { status: INSTALLING, title: 'Helm Tiller' }, }, { ...INITIAL_APP_MAP, - helm: { status: APPLICATION_STATUS.INSTALLED, title: 'Helm Tiller' }, + helm: { status: INSTALLED, title: 'Helm Tiller' }, }, ); @@ -125,13 +122,13 @@ describe('Clusters', () => { cluster.checkForNewInstalls( { ...INITIAL_APP_MAP, - helm: { status: APPLICATION_STATUS.INSTALLING, title: 'Helm Tiller' }, - ingress: { status: APPLICATION_STATUS.INSTALLABLE, title: 'Ingress' }, + helm: { status: INSTALLING, title: 'Helm Tiller' }, + ingress: { status: INSTALLABLE, title: 'Ingress' }, }, { ...INITIAL_APP_MAP, - helm: { status: APPLICATION_STATUS.INSTALLED, title: 'Helm Tiller' }, - ingress: { status: APPLICATION_STATUS.INSTALLED, title: 'Ingress' }, + helm: { status: INSTALLED, title: 'Helm Tiller' }, + ingress: { status: INSTALLED, title: 'Ingress' }, }, ); @@ -218,11 +215,11 @@ describe('Clusters', () => { it('tries to install helm', () => { jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce(); - expect(cluster.store.state.applications.helm.requestStatus).toEqual(null); + cluster.store.state.applications.helm.status = INSTALLABLE; cluster.installApplication({ id: 'helm' }); - expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUBMITTED); + expect(cluster.store.state.applications.helm.status).toEqual(INSTALLING); expect(cluster.store.state.applications.helm.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalledWith('helm', undefined); }); @@ -230,11 +227,11 @@ describe('Clusters', () => { it('tries to install ingress', () => { jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce(); - expect(cluster.store.state.applications.ingress.requestStatus).toEqual(null); + cluster.store.state.applications.ingress.status = INSTALLABLE; cluster.installApplication({ id: 'ingress' }); - expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_SUBMITTED); + expect(cluster.store.state.applications.ingress.status).toEqual(INSTALLING); expect(cluster.store.state.applications.ingress.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalledWith('ingress', undefined); }); @@ -242,11 +239,11 @@ describe('Clusters', () => { it('tries to install runner', () => { jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce(); - expect(cluster.store.state.applications.runner.requestStatus).toEqual(null); + cluster.store.state.applications.runner.status = INSTALLABLE; cluster.installApplication({ id: 'runner' }); - expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_SUBMITTED); + expect(cluster.store.state.applications.runner.status).toEqual(INSTALLING); expect(cluster.store.state.applications.runner.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalledWith('runner', undefined); }); @@ -254,13 +251,12 @@ describe('Clusters', () => { it('tries to install jupyter', () => { jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce(); - expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(null); cluster.installApplication({ id: 'jupyter', params: { hostname: cluster.store.state.applications.jupyter.hostname }, }); - expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(REQUEST_SUBMITTED); + cluster.store.state.applications.jupyter.status = INSTALLABLE; expect(cluster.store.state.applications.jupyter.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalledWith('jupyter', { hostname: cluster.store.state.applications.jupyter.hostname, @@ -272,16 +268,18 @@ describe('Clusters', () => { .spyOn(cluster.service, 'installApplication') .mockRejectedValueOnce(new Error('STUBBED ERROR')); - expect(cluster.store.state.applications.helm.requestStatus).toEqual(null); + cluster.store.state.applications.helm.status = INSTALLABLE; const promise = cluster.installApplication({ id: 'helm' }); - expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUBMITTED); + expect(cluster.store.state.applications.helm.status).toEqual(INSTALLING); expect(cluster.store.state.applications.helm.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalled(); return promise.then(() => { - expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_FAILURE); + expect(cluster.store.state.applications.helm.status).toEqual(INSTALLABLE); + expect(cluster.store.state.applications.helm.installFailed).toBe(true); + expect(cluster.store.state.applications.helm.requestReason).toBeDefined(); }); }); @@ -315,18 +313,16 @@ describe('Clusters', () => { }); describe('toggleIngressDomainHelpText', () => { - const { INSTALLED, INSTALLABLE, NOT_INSTALLABLE } = APPLICATION_STATUS; let ingressPreviousState; let ingressNewState; beforeEach(() => { - ingressPreviousState = { status: INSTALLABLE }; - ingressNewState = { status: INSTALLED, externalIp: '127.0.0.1' }; + ingressPreviousState = { externalIp: null }; + ingressNewState = { externalIp: '127.0.0.1' }; }); - describe(`when ingress application new status is ${INSTALLED}`, () => { + describe(`when ingress have an external ip assigned`, () => { beforeEach(() => { - ingressNewState.status = INSTALLED; cluster.toggleIngressDomainHelpText(ingressPreviousState, ingressNewState); }); @@ -341,31 +337,11 @@ describe('Clusters', () => { }); }); - describe(`when ingress application new status is different from ${INSTALLED}`, () => { + describe(`when ingress does not have an external ip assigned`, () => { it('hides custom domain help text', () => { - ingressNewState.status = NOT_INSTALLABLE; - cluster.ingressDomainHelpText.classList.remove('hide'); - - cluster.toggleIngressDomainHelpText(ingressPreviousState, ingressNewState); - - expect(cluster.ingressDomainHelpText.classList.contains('hide')).toEqual(true); - }); - }); - - describe('when ingress application new status and old status are the same', () => { - it('does not display custom domain help text', () => { - ingressPreviousState.status = INSTALLED; - ingressNewState.status = ingressPreviousState.status; - - cluster.toggleIngressDomainHelpText(ingressPreviousState, ingressNewState); - - expect(cluster.ingressDomainHelpText.classList.contains('hide')).toEqual(true); - }); - }); - - describe(`when ingress new status is ${INSTALLED} and there isn’t an ip assigned`, () => { - it('does not display custom domain help text', () => { + ingressPreviousState.externalIp = '127.0.0.1'; ingressNewState.externalIp = null; + cluster.ingressDomainHelpText.classList.remove('hide'); cluster.toggleIngressDomainHelpText(ingressPreviousState, ingressNewState); diff --git a/spec/frontend/clusters/components/application_row_spec.js b/spec/frontend/clusters/components/application_row_spec.js index 038d2be9e98..17273b7d5b1 100644 --- a/spec/frontend/clusters/components/application_row_spec.js +++ b/spec/frontend/clusters/components/application_row_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import eventHub from '~/clusters/event_hub'; -import { APPLICATION_STATUS, REQUEST_SUBMITTED, REQUEST_FAILURE } from '~/clusters/constants'; +import { APPLICATION_STATUS } from '~/clusters/constants'; import applicationRow from '~/clusters/components/application_row.vue'; import mountComponent from 'helpers/vue_mount_component_helper'; import { DEFAULT_APPLICATION_STATE } from '../services/mock_data'; @@ -80,17 +80,6 @@ describe('Application Row', () => { expect(vm.installButtonDisabled).toEqual(false); }); - it('has loading "Installing" when APPLICATION_STATUS.SCHEDULED', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.SCHEDULED, - }); - - expect(vm.installButtonLabel).toEqual('Installing'); - expect(vm.installButtonLoading).toEqual(true); - expect(vm.installButtonDisabled).toEqual(true); - }); - it('has loading "Installing" when APPLICATION_STATUS.INSTALLING', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, @@ -102,18 +91,6 @@ describe('Application Row', () => { expect(vm.installButtonDisabled).toEqual(true); }); - it('has loading "Installing" when REQUEST_SUBMITTED', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.INSTALLABLE, - requestStatus: REQUEST_SUBMITTED, - }); - - expect(vm.installButtonLabel).toEqual('Installing'); - expect(vm.installButtonLoading).toEqual(true); - expect(vm.installButtonDisabled).toEqual(true); - }); - it('has disabled "Installed" when application is installed and not uninstallable', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, @@ -139,10 +116,11 @@ describe('Application Row', () => { expect(installBtn).toBe(null); }); - it('has enabled "Install" when APPLICATION_STATUS.ERROR', () => { + it('has enabled "Install" when install fails', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.ERROR, + status: APPLICATION_STATUS.INSTALLABLE, + installFailed: true, }); expect(vm.installButtonLabel).toEqual('Install'); @@ -154,7 +132,6 @@ describe('Application Row', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, status: APPLICATION_STATUS.INSTALLABLE, - requestStatus: REQUEST_FAILURE, }); expect(vm.installButtonLabel).toEqual('Install'); @@ -246,15 +223,15 @@ describe('Application Row', () => { expect(upgradeBtn.innerHTML).toContain('Upgrade'); }); - it('has enabled "Retry update" when APPLICATION_STATUS.UPDATE_ERRORED', () => { + it('has enabled "Retry update" when update process fails', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.UPDATE_ERRORED, + status: APPLICATION_STATUS.INSTALLED, + updateFailed: true, }); const upgradeBtn = vm.$el.querySelector('.js-cluster-application-upgrade-button'); expect(upgradeBtn).not.toBe(null); - expect(vm.upgradeFailed).toBe(true); expect(upgradeBtn.innerHTML).toContain('Retry update'); }); @@ -274,7 +251,8 @@ describe('Application Row', () => { jest.spyOn(eventHub, '$emit'); vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.UPDATE_ERRORED, + status: APPLICATION_STATUS.INSTALLED, + upgradeAvailable: true, }); const upgradeBtn = vm.$el.querySelector('.js-cluster-application-upgrade-button'); @@ -303,7 +281,8 @@ describe('Application Row', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, title: 'GitLab Runner', - status: APPLICATION_STATUS.UPDATE_ERRORED, + status: APPLICATION_STATUS.INSTALLED, + updateFailed: true, }); const failureMessage = vm.$el.querySelector( '.js-cluster-application-upgrade-failure-message', @@ -314,6 +293,21 @@ describe('Application Row', () => { 'Update failed. Please check the logs and try again.', ); }); + + it('displays a success toast message if application upgrade was successful', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + title: 'GitLab Runner', + updateSuccessful: false, + }); + + vm.$toast = { show: jest.fn() }; + vm.updateSuccessful = true; + + vm.$nextTick(() => { + expect(vm.$toast.show).toHaveBeenCalledWith('GitLab Runner upgraded successfully.'); + }); + }); }); describe('Version', () => { @@ -321,7 +315,8 @@ describe('Application Row', () => { const version = '0.1.45'; vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.UPDATED, + status: APPLICATION_STATUS.INSTALLED, + updateSuccessful: true, version, }); const upgradeDetails = vm.$el.querySelector('.js-cluster-application-upgrade-details'); @@ -337,7 +332,8 @@ describe('Application Row', () => { const chartRepo = 'https://gitlab.com/charts/gitlab-runner'; vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.UPDATED, + status: APPLICATION_STATUS.INSTALLED, + updateSuccessful: true, chartRepo, version, }); @@ -351,7 +347,8 @@ describe('Application Row', () => { const version = '0.1.45'; vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.UPDATE_ERRORED, + status: APPLICATION_STATUS.INSTALLED, + updateFailed: true, version, }); const upgradeDetails = vm.$el.querySelector('.js-cluster-application-upgrade-details'); @@ -367,7 +364,6 @@ describe('Application Row', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, status: null, - requestStatus: null, }); const generalErrorMessage = vm.$el.querySelector( '.js-cluster-application-general-error-message', @@ -376,12 +372,13 @@ describe('Application Row', () => { expect(generalErrorMessage).toBeNull(); }); - it('shows status reason when APPLICATION_STATUS.ERROR', () => { + it('shows status reason when install fails', () => { const statusReason = 'We broke it 0.0'; vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, status: APPLICATION_STATUS.ERROR, statusReason, + installFailed: true, }); const generalErrorMessage = vm.$el.querySelector( '.js-cluster-application-general-error-message', @@ -402,7 +399,7 @@ describe('Application Row', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, status: APPLICATION_STATUS.INSTALLABLE, - requestStatus: REQUEST_FAILURE, + installFailed: true, requestReason, }); const generalErrorMessage = vm.$el.querySelector( diff --git a/spec/frontend/clusters/services/application_state_machine_spec.js b/spec/frontend/clusters/services/application_state_machine_spec.js new file mode 100644 index 00000000000..e74b7910572 --- /dev/null +++ b/spec/frontend/clusters/services/application_state_machine_spec.js @@ -0,0 +1,134 @@ +import transitionApplicationState from '~/clusters/services/application_state_machine'; +import { APPLICATION_STATUS, UPDATE_EVENT, INSTALL_EVENT } from '~/clusters/constants'; + +const { + NO_STATUS, + SCHEDULED, + NOT_INSTALLABLE, + INSTALLABLE, + INSTALLING, + INSTALLED, + ERROR, + UPDATING, + UPDATED, + UPDATE_ERRORED, +} = APPLICATION_STATUS; + +const NO_EFFECTS = 'no effects'; + +describe('applicationStateMachine', () => { + const noEffectsToEmptyObject = effects => (typeof effects === 'string' ? {} : effects); + + describe(`current state is ${NO_STATUS}`, () => { + it.each` + expectedState | event | effects + ${INSTALLING} | ${SCHEDULED} | ${NO_EFFECTS} + ${NOT_INSTALLABLE} | ${NOT_INSTALLABLE} | ${NO_EFFECTS} + ${INSTALLABLE} | ${INSTALLABLE} | ${NO_EFFECTS} + ${INSTALLING} | ${INSTALLING} | ${NO_EFFECTS} + ${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS} + ${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }} + ${UPDATING} | ${UPDATING} | ${NO_EFFECTS} + ${INSTALLED} | ${UPDATED} | ${NO_EFFECTS} + ${INSTALLED} | ${UPDATE_ERRORED} | ${{ updateFailed: true }} + `(`transitions to $expectedState on $event event and applies $effects`, data => { + const { expectedState, event, effects } = data; + const currentAppState = { + status: NO_STATUS, + }; + + expect(transitionApplicationState(currentAppState, event)).toEqual({ + status: expectedState, + ...noEffectsToEmptyObject(effects), + }); + }); + }); + + describe(`current state is ${NOT_INSTALLABLE}`, () => { + it.each` + expectedState | event | effects + ${INSTALLABLE} | ${INSTALLABLE} | ${NO_EFFECTS} + `(`transitions to $expectedState on $event event and applies $effects`, data => { + const { expectedState, event, effects } = data; + const currentAppState = { + status: NOT_INSTALLABLE, + }; + + expect(transitionApplicationState(currentAppState, event)).toEqual({ + status: expectedState, + ...noEffectsToEmptyObject(effects), + }); + }); + }); + + describe(`current state is ${INSTALLABLE}`, () => { + it.each` + expectedState | event | effects + ${INSTALLING} | ${INSTALL_EVENT} | ${{ installFailed: false }} + ${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS} + `(`transitions to $expectedState on $event event and applies $effects`, data => { + const { expectedState, event, effects } = data; + const currentAppState = { + status: INSTALLABLE, + }; + + expect(transitionApplicationState(currentAppState, event)).toEqual({ + status: expectedState, + ...noEffectsToEmptyObject(effects), + }); + }); + }); + + describe(`current state is ${INSTALLING}`, () => { + it.each` + expectedState | event | effects + ${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS} + ${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }} + `(`transitions to $expectedState on $event event and applies $effects`, data => { + const { expectedState, event, effects } = data; + const currentAppState = { + status: INSTALLING, + }; + + expect(transitionApplicationState(currentAppState, event)).toEqual({ + status: expectedState, + ...noEffectsToEmptyObject(effects), + }); + }); + }); + + describe(`current state is ${INSTALLED}`, () => { + it.each` + expectedState | event | effects + ${UPDATING} | ${UPDATE_EVENT} | ${{ updateFailed: false, updateSuccessful: false }} + `(`transitions to $expectedState on $event event and applies $effects`, data => { + const { expectedState, event, effects } = data; + const currentAppState = { + status: INSTALLED, + }; + + expect(transitionApplicationState(currentAppState, event)).toEqual({ + status: expectedState, + ...effects, + }); + }); + }); + + describe(`current state is ${UPDATING}`, () => { + it.each` + expectedState | event | effects + ${INSTALLED} | ${UPDATED} | ${{ updateSuccessful: true, updateAcknowledged: false }} + ${INSTALLED} | ${UPDATE_ERRORED} | ${{ updateFailed: true }} + `(`transitions to $expectedState on $event event and applies $effects`, data => { + const { expectedState, event, effects } = data; + const currentAppState = { + status: UPDATING, + }; + + expect(transitionApplicationState(currentAppState, event)).toEqual({ + status: expectedState, + ...effects, + }); + }); + }); +}); diff --git a/spec/frontend/clusters/services/mock_data.js b/spec/frontend/clusters/services/mock_data.js index b4d1bb710e0..1e896af1c7d 100644 --- a/spec/frontend/clusters/services/mock_data.js +++ b/spec/frontend/clusters/services/mock_data.js @@ -113,7 +113,6 @@ const DEFAULT_APPLICATION_STATE = { description: 'Some description about this interesting application!', status: null, statusReason: null, - requestStatus: null, requestReason: null, }; diff --git a/spec/frontend/clusters/stores/clusters_store_spec.js b/spec/frontend/clusters/stores/clusters_store_spec.js index c0e8b737ea2..a20e0439555 100644 --- a/spec/frontend/clusters/stores/clusters_store_spec.js +++ b/spec/frontend/clusters/stores/clusters_store_spec.js @@ -32,15 +32,6 @@ describe('Clusters Store', () => { }); describe('updateAppProperty', () => { - it('should store new request status', () => { - expect(store.state.applications.helm.requestStatus).toEqual(null); - - const newStatus = APPLICATION_STATUS.INSTALLING; - store.updateAppProperty('helm', 'requestStatus', newStatus); - - expect(store.state.applications.helm.requestStatus).toEqual(newStatus); - }); - it('should store new request reason', () => { expect(store.state.applications.helm.requestReason).toEqual(null); @@ -68,80 +59,90 @@ describe('Clusters Store', () => { title: 'Helm Tiller', status: mockResponseData.applications[0].status, statusReason: mockResponseData.applications[0].status_reason, - requestStatus: null, requestReason: null, installed: false, + installFailed: false, + uninstallable: false, }, ingress: { title: 'Ingress', - status: mockResponseData.applications[1].status, + status: APPLICATION_STATUS.INSTALLABLE, statusReason: mockResponseData.applications[1].status_reason, - requestStatus: null, requestReason: null, externalIp: null, externalHostname: null, installed: false, + installFailed: true, + uninstallable: false, }, runner: { title: 'GitLab Runner', status: mockResponseData.applications[2].status, statusReason: mockResponseData.applications[2].status_reason, - requestStatus: null, requestReason: null, version: mockResponseData.applications[2].version, upgradeAvailable: mockResponseData.applications[2].update_available, chartRepo: 'https://gitlab.com/charts/gitlab-runner', installed: false, + installFailed: false, + updateAcknowledged: true, + updateFailed: false, + updateSuccessful: false, + uninstallable: false, }, prometheus: { title: 'Prometheus', - status: mockResponseData.applications[3].status, + status: APPLICATION_STATUS.INSTALLABLE, statusReason: mockResponseData.applications[3].status_reason, - requestStatus: null, requestReason: null, installed: false, + installFailed: true, + uninstallable: false, }, jupyter: { title: 'JupyterHub', status: mockResponseData.applications[4].status, statusReason: mockResponseData.applications[4].status_reason, - requestStatus: null, requestReason: null, hostname: '', installed: false, + installFailed: false, + uninstallable: false, }, knative: { title: 'Knative', status: mockResponseData.applications[5].status, statusReason: mockResponseData.applications[5].status_reason, - requestStatus: null, requestReason: null, hostname: null, isEditingHostName: false, externalIp: null, externalHostname: null, installed: false, + installFailed: false, + uninstallable: false, }, cert_manager: { title: 'Cert-Manager', - status: mockResponseData.applications[6].status, + status: APPLICATION_STATUS.INSTALLABLE, + installFailed: true, statusReason: mockResponseData.applications[6].status_reason, - requestStatus: null, requestReason: null, email: mockResponseData.applications[6].email, installed: false, + uninstallable: false, }, }, }); }); - describe.each(APPLICATION_INSTALLED_STATUSES)('given the current app status is %s', () => { + describe.each(APPLICATION_INSTALLED_STATUSES)('given the current app status is %s', status => { it('marks application as installed', () => { const mockResponseData = CLUSTERS_MOCK_DATA.GET['/gitlab-org/gitlab-shell/clusters/2/status.json'].data; const runnerAppIndex = 2; - mockResponseData.applications[runnerAppIndex].status = APPLICATION_STATUS.INSTALLED; + mockResponseData.applications[runnerAppIndex].status = status; store.updateStateFromServer(mockResponseData); diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index fba7c41df94..3886853f3c1 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -209,6 +209,38 @@ describe('GfmAutoComplete', () => { }); }); + describe('DefaultOptions.highlighter', () => { + beforeEach(() => { + atwhoInstance = { setting: {} }; + }); + + it('should return li if no query is given', () => { + const liTag = '<li></li>'; + + const highlightedTag = gfmAutoCompleteCallbacks.highlighter.call(atwhoInstance, liTag); + + expect(highlightedTag).toEqual(liTag); + }); + + it('should highlight search query in li element', () => { + const liTag = '<li><img src="" />string</li>'; + const query = 's'; + + const highlightedTag = gfmAutoCompleteCallbacks.highlighter.call(atwhoInstance, liTag, query); + + expect(highlightedTag).toEqual('<li><img src="" /> <strong>s</strong>tring </li>'); + }); + + it('should highlight search query with special char in li element', () => { + const liTag = '<li><img src="" />te.st</li>'; + const query = '.'; + + const highlightedTag = gfmAutoCompleteCallbacks.highlighter.call(atwhoInstance, liTag, query); + + expect(highlightedTag).toEqual('<li><img src="" /> te<strong>.</strong>st </li>'); + }); + }); + describe('isLoading', () => { it('should be true with loading data object item', () => { expect(GfmAutoComplete.isLoading({ name: 'loading' })).toBe(true); diff --git a/spec/frontend/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_projects/components/import_projects_table_spec.js new file mode 100644 index 00000000000..17a998d0174 --- /dev/null +++ b/spec/frontend/import_projects/components/import_projects_table_spec.js @@ -0,0 +1,185 @@ +import Vuex from 'vuex'; +import { createLocalVue, mount } from '@vue/test-utils'; +import { state, actions, getters, mutations } from '~/import_projects/store'; +import importProjectsTable from '~/import_projects/components/import_projects_table.vue'; +import STATUS_MAP from '~/import_projects/constants'; + +describe('ImportProjectsTable', () => { + let vm; + const providerTitle = 'THE PROVIDER'; + const providerRepo = { id: 10, sanitizedName: 'sanitizedName', fullName: 'fullName' }; + const importedProject = { + id: 1, + fullPath: 'fullPath', + importStatus: 'started', + providerLink: 'providerLink', + importSource: 'importSource', + }; + + function initStore() { + const stubbedActions = Object.assign({}, actions, { + fetchJobs: jest.fn(), + fetchRepos: jest.fn(actions.requestRepos), + fetchImport: jest.fn(actions.requestImport), + }); + + const store = new Vuex.Store({ + state: state(), + actions: stubbedActions, + mutations, + getters, + }); + + return store; + } + + function mountComponent() { + const localVue = createLocalVue(); + localVue.use(Vuex); + + const store = initStore(); + + const component = mount(importProjectsTable, { + localVue, + store, + propsData: { + providerTitle, + }, + sync: false, + }); + + return component.vm; + } + + beforeEach(() => { + vm = mountComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders a loading icon whilst repos are loading', () => + vm.$nextTick().then(() => { + expect(vm.$el.querySelector('.js-loading-button-icon')).not.toBeNull(); + })); + + it('renders a table with imported projects and provider repos', () => { + vm.$store.dispatch('receiveReposSuccess', { + importedProjects: [importedProject], + providerRepos: [providerRepo], + namespaces: [{ path: 'path' }], + }); + + return vm.$nextTick().then(() => { + expect(vm.$el.querySelector('.js-loading-button-icon')).toBeNull(); + expect(vm.$el.querySelector('.table')).not.toBeNull(); + expect(vm.$el.querySelector('.import-jobs-from-col').innerText).toMatch( + `From ${providerTitle}`, + ); + + expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull(); + expect(vm.$el.querySelector('.js-provider-repo')).not.toBeNull(); + }); + }); + + it('renders an empty state if there are no imported projects or provider repos', () => { + vm.$store.dispatch('receiveReposSuccess', { + importedProjects: [], + providerRepos: [], + namespaces: [], + }); + + return vm.$nextTick().then(() => { + expect(vm.$el.querySelector('.js-loading-button-icon')).toBeNull(); + expect(vm.$el.querySelector('.table')).toBeNull(); + expect(vm.$el.innerText).toMatch(`No ${providerTitle} repositories available to import`); + }); + }); + + it('shows loading spinner when bulk import button is clicked', () => { + vm.$store.dispatch('receiveReposSuccess', { + importedProjects: [], + providerRepos: [providerRepo], + namespaces: [{ path: 'path' }], + }); + + return vm + .$nextTick() + .then(() => { + expect(vm.$el.querySelector('.js-imported-project')).toBeNull(); + expect(vm.$el.querySelector('.js-provider-repo')).not.toBeNull(); + + vm.$el.querySelector('.js-import-all').click(); + }) + .then(() => vm.$nextTick()) + .then(() => { + expect(vm.$el.querySelector('.js-import-all .js-loading-button-icon')).not.toBeNull(); + }); + }); + + it('imports provider repos if bulk import button is clicked', () => { + mountComponent(); + + vm.$store.dispatch('receiveReposSuccess', { + importedProjects: [], + providerRepos: [providerRepo], + namespaces: [{ path: 'path' }], + }); + + return vm + .$nextTick() + .then(() => { + expect(vm.$el.querySelector('.js-imported-project')).toBeNull(); + expect(vm.$el.querySelector('.js-provider-repo')).not.toBeNull(); + + vm.$store.dispatch('receiveImportSuccess', { importedProject, repoId: providerRepo.id }); + }) + .then(() => vm.$nextTick()) + .then(() => { + expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull(); + expect(vm.$el.querySelector('.js-provider-repo')).toBeNull(); + }); + }); + + it('polls to update the status of imported projects', () => { + const updatedProjects = [ + { + id: importedProject.id, + importStatus: 'finished', + }, + ]; + + vm.$store.dispatch('receiveReposSuccess', { + importedProjects: [importedProject], + providerRepos: [], + namespaces: [{ path: 'path' }], + }); + + return vm + .$nextTick() + .then(() => { + const statusObject = STATUS_MAP[importedProject.importStatus]; + + expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull(); + expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch( + statusObject.text, + ); + + expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull(); + + vm.$store.dispatch('receiveJobsSuccess', updatedProjects); + }) + .then(() => vm.$nextTick()) + .then(() => { + const statusObject = STATUS_MAP[updatedProjects[0].importStatus]; + + expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull(); + expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch( + statusObject.text, + ); + + expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull(); + }); + }); +}); diff --git a/spec/javascripts/import_projects/components/imported_project_table_row_spec.js b/spec/frontend/import_projects/components/imported_project_table_row_spec.js index 7dac7e9ccc1..f95acc1edd7 100644 --- a/spec/javascripts/import_projects/components/imported_project_table_row_spec.js +++ b/spec/frontend/import_projects/components/imported_project_table_row_spec.js @@ -1,5 +1,6 @@ -import Vue from 'vue'; +import Vuex from 'vuex'; import createStore from '~/import_projects/store'; +import { createLocalVue, mount } from '@vue/test-utils'; import importedProjectTableRow from '~/import_projects/components/imported_project_table_row.vue'; import STATUS_MAP from '~/import_projects/constants'; @@ -13,27 +14,33 @@ describe('ImportedProjectTableRow', () => { importSource: 'importSource', }; - function createComponent() { - const ImportedProjectTableRow = Vue.extend(importedProjectTableRow); + function mountComponent() { + const localVue = createLocalVue(); + localVue.use(Vuex); - const store = createStore(); - return new ImportedProjectTableRow({ - store, + const component = mount(importedProjectTableRow, { + localVue, + store: createStore(), propsData: { project: { ...project, }, }, - }).$mount(); + sync: false, + }); + + return component.vm; } + beforeEach(() => { + vm = mountComponent(); + }); + afterEach(() => { vm.$destroy(); }); it('renders an imported project table row', () => { - vm = createComponent(); - const providerLink = vm.$el.querySelector('.js-provider-link'); const statusObject = STATUS_MAP[project.importStatus]; diff --git a/spec/javascripts/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_projects/components/provider_repo_table_row_spec.js index 4d2bacd2ad0..02c786d8d0b 100644 --- a/spec/javascripts/import_projects/components/provider_repo_table_row_spec.js +++ b/spec/frontend/import_projects/components/provider_repo_table_row_spec.js @@ -1,14 +1,15 @@ -import Vue from 'vue'; -import MockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; -import createStore from '~/import_projects/store'; +import Vuex from 'vuex'; +import { createLocalVue, mount } from '@vue/test-utils'; +import { state, actions, getters, mutations } from '~/import_projects/store'; import providerRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue'; import STATUS_MAP, { STATUSES } from '~/import_projects/constants'; -import setTimeoutPromise from '../../helpers/set_timeout_promise_helper'; describe('ProviderRepoTableRow', () => { - let store; let vm; + const fetchImport = jest.fn((context, data) => actions.requestImport(context, data)); + const importPath = '/import-path'; + const defaultTargetNamespace = 'user'; + const ciCdOnly = true; const repo = { id: 10, sanitizedName: 'sanitizedName', @@ -16,21 +17,42 @@ describe('ProviderRepoTableRow', () => { providerLink: 'providerLink', }; - function createComponent() { - const ProviderRepoTableRow = Vue.extend(providerRepoTableRow); + function initStore() { + const stubbedActions = Object.assign({}, actions, { + fetchImport, + }); - return new ProviderRepoTableRow({ + const store = new Vuex.Store({ + state: state(), + actions: stubbedActions, + mutations, + getters, + }); + + return store; + } + + function mountComponent() { + const localVue = createLocalVue(); + localVue.use(Vuex); + + const store = initStore(); + store.dispatch('setInitialData', { importPath, defaultTargetNamespace, ciCdOnly }); + + const component = mount(providerRepoTableRow, { + localVue, store, propsData: { - repo: { - ...repo, - }, + repo, }, - }).$mount(); + sync: false, + }); + + return component.vm; } beforeEach(() => { - store = createStore(); + vm = mountComponent(); }); afterEach(() => { @@ -38,8 +60,6 @@ describe('ProviderRepoTableRow', () => { }); it('renders a provider repo table row', () => { - vm = createComponent(); - const providerLink = vm.$el.querySelector('.js-provider-link'); const statusObject = STATUS_MAP[STATUSES.NONE]; @@ -55,8 +75,6 @@ describe('ProviderRepoTableRow', () => { }); it('renders a select2 namespace select', () => { - vm = createComponent(); - const dropdownTrigger = vm.$el.querySelector('.js-namespace-select'); expect(dropdownTrigger).not.toBeNull(); @@ -67,30 +85,20 @@ describe('ProviderRepoTableRow', () => { expect(vm.$el.querySelector('.select2-drop')).not.toBeNull(); }); - it('imports repo when clicking import button', done => { - const importPath = '/import-path'; - const defaultTargetNamespace = 'user'; - const ciCdOnly = true; - const mock = new MockAdapter(axios); - - store.dispatch('setInitialData', { importPath, defaultTargetNamespace, ciCdOnly }); - mock.onPost(importPath).replyOnce(200); - spyOn(store, 'dispatch').and.returnValue(new Promise(() => {})); - - vm = createComponent(); - + it('imports repo when clicking import button', () => { vm.$el.querySelector('.js-import-button').click(); - setTimeoutPromise() - .then(() => { - expect(store.dispatch).toHaveBeenCalledWith('fetchImport', { - repo, - newName: repo.sanitizedName, - targetNamespace: defaultTargetNamespace, - }); - }) - .then(() => mock.restore()) - .then(done) - .catch(done.fail); + return vm.$nextTick().then(() => { + const { calls } = fetchImport.mock; + + // Not using .toBeCalledWith because it expects + // an unmatchable and undefined 3rd argument. + expect(calls.length).toBe(1); + expect(calls[0][1]).toEqual({ + repo, + newName: repo.sanitizedName, + targetNamespace: defaultTargetNamespace, + }); + }); }); }); diff --git a/spec/javascripts/import_projects/store/actions_spec.js b/spec/frontend/import_projects/store/actions_spec.js index 77850ee3283..6a7b90788dd 100644 --- a/spec/javascripts/import_projects/store/actions_spec.js +++ b/spec/frontend/import_projects/store/actions_spec.js @@ -27,8 +27,8 @@ import { stopJobsPolling, } from '~/import_projects/store/actions'; import state from '~/import_projects/store/state'; -import testAction from 'spec/helpers/vuex_action_helper'; -import { TEST_HOST } from 'spec/test_constants'; +import testAction from 'helpers/vuex_action_helper'; +import { TEST_HOST } from 'helpers/test_constants'; describe('import_projects store actions', () => { let localState; diff --git a/spec/javascripts/vue_shared/components/issue/issue_assignees_spec.js b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js index 9eac75fac96..d1de98f4a15 100644 --- a/spec/javascripts/vue_shared/components/issue/issue_assignees_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js @@ -2,8 +2,8 @@ import Vue from 'vue'; import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import { mockAssigneesList } from 'spec/boards/mock_data'; +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); diff --git a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js new file mode 100644 index 00000000000..2e93ec412b9 --- /dev/null +++ b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js @@ -0,0 +1,172 @@ +import Vue from 'vue'; +import { mount } from '@vue/test-utils'; + +import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue'; + +import { mockMilestone } from '../../../../javascripts/boards/mock_data'; + +const createComponent = (milestone = mockMilestone) => { + const Component = Vue.extend(IssueMilestone); + + return mount(Component, { + propsData: { + milestone, + }, + sync: false, + }); +}; + +describe('IssueMilestoneComponent', () => { + let wrapper; + let vm; + + beforeEach(done => { + wrapper = createComponent(); + + ({ vm } = wrapper); + + Vue.nextTick(done); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('computed', () => { + describe('isMilestoneStarted', () => { + it('should return `false` when milestoneStart prop is not defined', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + start_date: '', + }), + }); + + expect(wrapper.vm.isMilestoneStarted).toBe(false); + }); + + it('should return `true` when milestone start date is past current date', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + start_date: '1990-07-22', + }), + }); + + expect(wrapper.vm.isMilestoneStarted).toBe(true); + }); + }); + + describe('isMilestonePastDue', () => { + it('should return `false` when milestoneDue prop is not defined', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + due_date: '', + }), + }); + + expect(wrapper.vm.isMilestonePastDue).toBe(false); + }); + + it('should return `true` when milestone due is past current date', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + due_date: '1990-07-22', + }), + }); + + expect(wrapper.vm.isMilestonePastDue).toBe(true); + }); + }); + + describe('milestoneDatesAbsolute', () => { + it('returns string containing absolute milestone due date', () => { + expect(vm.milestoneDatesAbsolute).toBe('(December 31, 2019)'); + }); + + it('returns string containing absolute milestone start date when due date is not present', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + due_date: '', + }), + }); + + expect(wrapper.vm.milestoneDatesAbsolute).toBe('(January 1, 2018)'); + }); + + it('returns empty string when both milestone start and due dates are not present', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + start_date: '', + due_date: '', + }), + }); + + expect(wrapper.vm.milestoneDatesAbsolute).toBe(''); + }); + }); + + describe('milestoneDatesHuman', () => { + it('returns string containing milestone due date when date is yet to be due', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + due_date: `${new Date().getFullYear() + 10}-01-01`, + }), + }); + + expect(wrapper.vm.milestoneDatesHuman).toContain('years remaining'); + }); + + it('returns string containing milestone start date when date has already started and due date is not present', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + start_date: '1990-07-22', + due_date: '', + }), + }); + + expect(wrapper.vm.milestoneDatesHuman).toContain('Started'); + }); + + it('returns string containing milestone start date when date is yet to start and due date is not present', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + start_date: `${new Date().getFullYear() + 10}-01-01`, + due_date: '', + }), + }); + + expect(wrapper.vm.milestoneDatesHuman).toContain('Starts'); + }); + + it('returns empty string when milestone start and due dates are not present', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + start_date: '', + due_date: '', + }), + }); + + expect(wrapper.vm.milestoneDatesHuman).toBe(''); + }); + }); + }); + + describe('template', () => { + it('renders component root element with class `issue-milestone-details`', () => { + expect(vm.$el.classList.contains('issue-milestone-details')).toBe(true); + }); + + it('renders milestone icon', () => { + expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('clock'); + }); + + it('renders milestone title', () => { + expect(vm.$el.querySelector('.milestone-title').innerText.trim()).toBe(mockMilestone.title); + }); + + it('renders milestone tooltip', () => { + expect(vm.$el.querySelector('.js-item-milestone').innerText.trim()).toContain( + mockMilestone.title, + ); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/issue/issue_warning_spec.js b/spec/frontend/vue_shared/components/issue/issue_warning_spec.js index aa7d6ea2e34..4a8de5fc4f1 100644 --- a/spec/javascripts/vue_shared/components/issue/issue_warning_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_warning_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import issueWarning from '~/vue_shared/components/issue/issue_warning.vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import mountComponent from 'helpers/vue_mount_component_helper'; const IssueWarning = Vue.extend(issueWarning); @@ -19,7 +19,9 @@ describe('Issue Warning Component', () => { isLocked: true, }); - expect(vm.$el.querySelector('.icon use').href.baseVal).toMatch(/lock$/); + expect( + vm.$el.querySelector('.icon use').getAttributeNS('http://www.w3.org/1999/xlink', 'href'), + ).toMatch(/lock$/); expect(formatWarning(vm.$el.querySelector('span').textContent)).toEqual( 'This issue is locked. Only project members can comment.', ); @@ -32,7 +34,9 @@ describe('Issue Warning Component', () => { isConfidential: true, }); - expect(vm.$el.querySelector('.icon use').href.baseVal).toMatch(/eye-slash$/); + expect( + vm.$el.querySelector('.icon use').getAttributeNS('http://www.w3.org/1999/xlink', 'href'), + ).toMatch(/eye-slash$/); expect(formatWarning(vm.$el.querySelector('span').textContent)).toEqual( 'This is a confidential issue. Your comment will not be visible to the public.', ); diff --git a/spec/javascripts/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js index 42198e92eea..e43d5301a50 100644 --- a/spec/javascripts/vue_shared/components/issue/related_issuable_item_spec.js +++ b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js @@ -1,7 +1,11 @@ import Vue from 'vue'; +import { formatDate } from '~/lib/utils/datetime_utility'; import { mount, createLocalVue } from '@vue/test-utils'; import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; -import { defaultMilestone, defaultAssignees } from './related_issuable_mock_data'; +import { + defaultAssignees, + defaultMilestone, +} from '../../../../javascripts/vue_shared/components/issue/related_issuable_mock_data'; describe('RelatedIssuableItem', () => { let wrapper; @@ -85,11 +89,11 @@ describe('RelatedIssuableItem', () => { it('renders state title', () => { const stateTitle = tokenState.attributes('data-original-title'); + const formatedCreateDate = formatDate(props.createdAt); expect(stateTitle).toContain('<span class="bold">Opened</span>'); - expect(stateTitle).toContain( - '<span class="text-tertiary">Dec 1, 2018 12:00am GMT+0000</span>', - ); + + expect(stateTitle).toContain(`<span class="text-tertiary">${formatedCreateDate}</span>`); }); it('renders aria label', () => { diff --git a/spec/javascripts/vue_shared/components/notes/placeholder_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js index 45f131194ca..eafff7f681e 100644 --- a/spec/javascripts/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 '../../../notes/mock_data'; +import { userDataMock } from '../../../../javascripts/notes/mock_data'; describe('issue placeholder system note component', () => { let store; diff --git a/spec/javascripts/vue_shared/components/notes/placeholder_system_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_system_note_spec.js index 6013e85811a..976e38c15ee 100644 --- a/spec/javascripts/vue_shared/components/notes/placeholder_system_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/placeholder_system_note_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import placeholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import mountComponent from 'helpers/vue_mount_component_helper'; describe('placeholder system note component', () => { let PlaceholderSystemNote; diff --git a/spec/javascripts/vue_shared/components/notes/system_note_spec.js b/spec/frontend/vue_shared/components/notes/system_note_spec.js index adcb1c858aa..dc66150ab8d 100644 --- a/spec/javascripts/vue_shared/components/notes/system_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/system_note_spec.js @@ -1,6 +1,9 @@ import Vue from 'vue'; import issueSystemNote from '~/vue_shared/components/notes/system_note.vue'; import createStore from '~/notes/stores'; +import initMRPopovers from '~/mr_popover/index'; + +jest.mock('~/mr_popover/index', () => jest.fn()); describe('system note component', () => { let vm; @@ -56,4 +59,8 @@ describe('system note component', () => { it('removes wrapping paragraph from note HTML', () => { expect(vm.$el.querySelector('.system-note-message').innerHTML).toEqual('<span>closed</span>'); }); + + it('should initMRPopovers onMount', () => { + expect(initMRPopovers).toHaveBeenCalled(); + }); }); diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb index 5f9c180cbb7..399a33dae75 100644 --- a/spec/graphql/resolvers/issues_resolver_spec.rb +++ b/spec/graphql/resolvers/issues_resolver_spec.rb @@ -4,104 +4,119 @@ describe Resolvers::IssuesResolver do include GraphqlHelpers let(:current_user) { create(:user) } - set(:project) { create(:project) } - set(:issue1) { create(:issue, project: project, state: :opened, created_at: 3.hours.ago, updated_at: 3.hours.ago) } - set(:issue2) { create(:issue, project: project, state: :closed, title: 'foo', created_at: 1.hour.ago, updated_at: 1.hour.ago, closed_at: 1.hour.ago) } - set(:label1) { create(:label, project: project) } - set(:label2) { create(:label, project: project) } - - before do - project.add_developer(current_user) - create(:label_link, label: label1, target: issue1) - create(:label_link, label: label1, target: issue2) - create(:label_link, label: label2, target: issue2) - end - - describe '#resolve' do - it 'finds all issues' do - expect(resolve_issues).to contain_exactly(issue1, issue2) - end - it 'filters by state' do - expect(resolve_issues(state: 'opened')).to contain_exactly(issue1) - expect(resolve_issues(state: 'closed')).to contain_exactly(issue2) + context "with a project" do + set(:project) { create(:project) } + set(:issue1) { create(:issue, project: project, state: :opened, created_at: 3.hours.ago, updated_at: 3.hours.ago) } + set(:issue2) { create(:issue, project: project, state: :closed, title: 'foo', created_at: 1.hour.ago, updated_at: 1.hour.ago, closed_at: 1.hour.ago) } + set(:label1) { create(:label, project: project) } + set(:label2) { create(:label, project: project) } + + before do + project.add_developer(current_user) + create(:label_link, label: label1, target: issue1) + create(:label_link, label: label1, target: issue2) + create(:label_link, label: label2, target: issue2) end - it 'filters by labels' do - expect(resolve_issues(label_name: [label1.title])).to contain_exactly(issue1, issue2) - expect(resolve_issues(label_name: [label1.title, label2.title])).to contain_exactly(issue2) - end + describe '#resolve' do + it 'finds all issues' do + expect(resolve_issues).to contain_exactly(issue1, issue2) + end - describe 'filters by created_at' do - it 'filters by created_before' do - expect(resolve_issues(created_before: 2.hours.ago)).to contain_exactly(issue1) + it 'filters by state' do + expect(resolve_issues(state: 'opened')).to contain_exactly(issue1) + expect(resolve_issues(state: 'closed')).to contain_exactly(issue2) end - it 'filters by created_after' do - expect(resolve_issues(created_after: 2.hours.ago)).to contain_exactly(issue2) + it 'filters by labels' do + expect(resolve_issues(label_name: [label1.title])).to contain_exactly(issue1, issue2) + expect(resolve_issues(label_name: [label1.title, label2.title])).to contain_exactly(issue2) end - end - describe 'filters by updated_at' do - it 'filters by updated_before' do - expect(resolve_issues(updated_before: 2.hours.ago)).to contain_exactly(issue1) + describe 'filters by created_at' do + it 'filters by created_before' do + expect(resolve_issues(created_before: 2.hours.ago)).to contain_exactly(issue1) + end + + it 'filters by created_after' do + expect(resolve_issues(created_after: 2.hours.ago)).to contain_exactly(issue2) + end end - it 'filters by updated_after' do - expect(resolve_issues(updated_after: 2.hours.ago)).to contain_exactly(issue2) + describe 'filters by updated_at' do + it 'filters by updated_before' do + expect(resolve_issues(updated_before: 2.hours.ago)).to contain_exactly(issue1) + end + + it 'filters by updated_after' do + expect(resolve_issues(updated_after: 2.hours.ago)).to contain_exactly(issue2) + end end - end - describe 'filters by closed_at' do - let!(:issue3) { create(:issue, project: project, state: :closed, closed_at: 3.hours.ago) } + describe 'filters by closed_at' do + let!(:issue3) { create(:issue, project: project, state: :closed, closed_at: 3.hours.ago) } - it 'filters by closed_before' do - expect(resolve_issues(closed_before: 2.hours.ago)).to contain_exactly(issue3) + it 'filters by closed_before' do + expect(resolve_issues(closed_before: 2.hours.ago)).to contain_exactly(issue3) + end + + it 'filters by closed_after' do + expect(resolve_issues(closed_after: 2.hours.ago)).to contain_exactly(issue2) + end end - it 'filters by closed_after' do - expect(resolve_issues(closed_after: 2.hours.ago)).to contain_exactly(issue2) + it 'searches issues' do + expect(resolve_issues(search: 'foo')).to contain_exactly(issue2) end - end - it 'searches issues' 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] + end - it 'sort issues' do - expect(resolve_issues(sort: 'created_desc')).to eq [issue2, issue1] - end + it 'returns issues user can see' do + project.add_guest(current_user) - it 'returns issues user can see' do - project.add_guest(current_user) + create(:issue, confidential: true) - create(:issue, confidential: true) + expect(resolve_issues).to contain_exactly(issue1, issue2) + end - expect(resolve_issues).to contain_exactly(issue1, issue2) - end + it 'finds a specific issue with iid' do + expect(resolve_issues(iid: issue1.iid)).to contain_exactly(issue1) + end - it 'finds a specific issue with iid' do - expect(resolve_issues(iid: issue1.iid)).to contain_exactly(issue1) - end + it 'finds a specific issue with iids' do + expect(resolve_issues(iids: issue1.iid)).to contain_exactly(issue1) + end - it 'finds a specific issue with iids' do - expect(resolve_issues(iids: issue1.iid)).to contain_exactly(issue1) - end + it 'finds multiple issues with iids' do + expect(resolve_issues(iids: [issue1.iid, issue2.iid])) + .to contain_exactly(issue1, issue2) + end - it 'finds multiple issues with iids' do - expect(resolve_issues(iids: [issue1.iid, issue2.iid])) - .to contain_exactly(issue1, issue2) - end + it 'finds only the issues within the project we are looking at' do + another_project = create(:project) + iids = [issue1, issue2].map(&:iid) + + iids.each do |iid| + create(:issue, project: another_project, iid: iid) + end - it 'finds only the issues within the project we are looking at' do - another_project = create(:project) - iids = [issue1, issue2].map(&:iid) + expect(resolve_issues(iids: iids)).to contain_exactly(issue1, issue2) + end + end + end - iids.each do |iid| - create(:issue, project: another_project, iid: iid) + context "when passing a non existent, batch loaded project" do + let(:project) do + BatchLoader.for("non-existent-path").batch do |_fake_paths, loader, _| + loader.call("non-existent-path", nil) end + end - expect(resolve_issues(iids: iids)).to contain_exactly(issue1, issue2) + it "returns nil without breaking" do + expect(resolve_issues(iids: ["don't", "break"])).to be_empty end end diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js index e537e0e8afc..494b3b934a8 100644 --- a/spec/javascripts/api_spec.js +++ b/spec/javascripts/api_spec.js @@ -4,7 +4,7 @@ import Api from '~/api'; describe('Api', () => { const dummyApiVersion = 'v3000'; - const dummyUrlRoot = 'http://host.invalid'; + const dummyUrlRoot = '/gitlab'; const dummyGon = { api_version: dummyApiVersion, relative_url_root: dummyUrlRoot, @@ -32,6 +32,18 @@ describe('Api', () => { expect(builtUrl).toEqual(expectedOutput); }); + + [null, '', '/'].forEach(root => { + it(`works when relative_url_root is ${root}`, () => { + window.gon.relative_url_root = root; + const input = '/api/:version/foo/bar'; + const expectedOutput = `/api/${dummyApiVersion}/foo/bar`; + + const builtUrl = Api.buildUrl(input); + + expect(builtUrl).toEqual(expectedOutput); + }); + }); }); describe('group', () => { diff --git a/spec/javascripts/fixtures/images/green_box.png b/spec/javascripts/fixtures/static/images/green_box.png Binary files differindex cd1ff9f9ade..cd1ff9f9ade 100644 --- a/spec/javascripts/fixtures/images/green_box.png +++ b/spec/javascripts/fixtures/static/images/green_box.png diff --git a/spec/javascripts/fixtures/one_white_pixel.png b/spec/javascripts/fixtures/static/images/one_white_pixel.png Binary files differindex 073fcf40a18..073fcf40a18 100644 --- a/spec/javascripts/fixtures/one_white_pixel.png +++ b/spec/javascripts/fixtures/static/images/one_white_pixel.png diff --git a/spec/javascripts/fixtures/images/red_box.png b/spec/javascripts/fixtures/static/images/red_box.png Binary files differindex 73b2927da0f..73b2927da0f 100644 --- a/spec/javascripts/fixtures/images/red_box.png +++ b/spec/javascripts/fixtures/static/images/red_box.png diff --git a/spec/javascripts/fixtures/projects.json b/spec/javascripts/fixtures/static/projects.json index 68a150f602a..68a150f602a 100644 --- a/spec/javascripts/fixtures/projects.json +++ b/spec/javascripts/fixtures/static/projects.json diff --git a/spec/javascripts/gl_dropdown_spec.js b/spec/javascripts/gl_dropdown_spec.js index 57e31d933ca..8c7820ddb52 100644 --- a/spec/javascripts/gl_dropdown_spec.js +++ b/spec/javascripts/gl_dropdown_spec.js @@ -6,7 +6,7 @@ import '~/lib/utils/common_utils'; describe('glDropdown', function describeDropdown() { preloadFixtures('static/gl_dropdown.html'); - loadJSONFixtures('projects.json'); + loadJSONFixtures('static/projects.json'); const NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-item'; @@ -67,7 +67,7 @@ describe('glDropdown', function describeDropdown() { loadFixtures('static/gl_dropdown.html'); this.dropdownContainerElement = $('.dropdown.inline'); this.$dropdownMenuElement = $('.dropdown-menu', this.dropdownContainerElement); - this.projectsData = getJSONFixture('projects.json'); + this.projectsData = getJSONFixture('static/projects.json'); }); afterEach(() => { diff --git a/spec/javascripts/import_projects/components/import_projects_table_spec.js b/spec/javascripts/import_projects/components/import_projects_table_spec.js deleted file mode 100644 index ab8642bf0dd..00000000000 --- a/spec/javascripts/import_projects/components/import_projects_table_spec.js +++ /dev/null @@ -1,188 +0,0 @@ -import Vue from 'vue'; -import MockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; -import createStore from '~/import_projects/store'; -import importProjectsTable from '~/import_projects/components/import_projects_table.vue'; -import STATUS_MAP from '~/import_projects/constants'; -import setTimeoutPromise from '../../helpers/set_timeout_promise_helper'; - -describe('ImportProjectsTable', () => { - let vm; - let mock; - let store; - const reposPath = '/repos-path'; - const jobsPath = '/jobs-path'; - const providerTitle = 'THE PROVIDER'; - const providerRepo = { id: 10, sanitizedName: 'sanitizedName', fullName: 'fullName' }; - const importedProject = { - id: 1, - fullPath: 'fullPath', - importStatus: 'started', - providerLink: 'providerLink', - importSource: 'importSource', - }; - - function createComponent() { - const ImportProjectsTable = Vue.extend(importProjectsTable); - - const component = new ImportProjectsTable({ - store, - propsData: { - providerTitle, - }, - }).$mount(); - - store.dispatch('stopJobsPolling'); - - return component; - } - - beforeEach(() => { - store = createStore(); - store.dispatch('setInitialData', { reposPath }); - mock = new MockAdapter(axios); - }); - - afterEach(() => { - vm.$destroy(); - mock.restore(); - }); - - it('renders a loading icon whilst repos are loading', done => { - mock.restore(); // Stop the mock adapter from responding to the request, keeping the spinner up - - vm = createComponent(); - - setTimeoutPromise() - .then(() => { - expect(vm.$el.querySelector('.js-loading-button-icon')).not.toBeNull(); - }) - .then(() => done()) - .catch(() => done.fail()); - }); - - it('renders a table with imported projects and provider repos', done => { - const response = { - importedProjects: [importedProject], - providerRepos: [providerRepo], - namespaces: [{ path: 'path' }], - }; - mock.onGet(reposPath).reply(200, response); - - vm = createComponent(); - - setTimeoutPromise() - .then(() => { - expect(vm.$el.querySelector('.js-loading-button-icon')).toBeNull(); - expect(vm.$el.querySelector('.table')).not.toBeNull(); - expect(vm.$el.querySelector('.import-jobs-from-col').innerText).toMatch( - `From ${providerTitle}`, - ); - - expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull(); - expect(vm.$el.querySelector('.js-provider-repo')).not.toBeNull(); - }) - .then(() => done()) - .catch(() => done.fail()); - }); - - it('renders an empty state if there are no imported projects or provider repos', done => { - const response = { - importedProjects: [], - providerRepos: [], - namespaces: [], - }; - mock.onGet(reposPath).reply(200, response); - - vm = createComponent(); - - setTimeoutPromise() - .then(() => { - expect(vm.$el.querySelector('.js-loading-button-icon')).toBeNull(); - expect(vm.$el.querySelector('.table')).toBeNull(); - expect(vm.$el.innerText).toMatch(`No ${providerTitle} repositories available to import`); - }) - .then(() => done()) - .catch(() => done.fail()); - }); - - it('imports provider repos if bulk import button is clicked', done => { - const importPath = '/import-path'; - const response = { - importedProjects: [], - providerRepos: [providerRepo], - namespaces: [{ path: 'path' }], - }; - - mock.onGet(reposPath).replyOnce(200, response); - mock.onPost(importPath).replyOnce(200, importedProject); - - store.dispatch('setInitialData', { importPath }); - - vm = createComponent(); - - setTimeoutPromise() - .then(() => { - expect(vm.$el.querySelector('.js-imported-project')).toBeNull(); - expect(vm.$el.querySelector('.js-provider-repo')).not.toBeNull(); - - vm.$el.querySelector('.js-import-all').click(); - }) - .then(() => setTimeoutPromise()) - .then(() => { - expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull(); - expect(vm.$el.querySelector('.js-provider-repo')).toBeNull(); - }) - .then(() => done()) - .catch(() => done.fail()); - }); - - it('polls to update the status of imported projects', done => { - const importPath = '/import-path'; - const response = { - importedProjects: [importedProject], - providerRepos: [], - namespaces: [{ path: 'path' }], - }; - const updatedProjects = [ - { - id: importedProject.id, - importStatus: 'finished', - }, - ]; - - mock.onGet(reposPath).replyOnce(200, response); - - store.dispatch('setInitialData', { importPath, jobsPath }); - - vm = createComponent(); - - setTimeoutPromise() - .then(() => { - const statusObject = STATUS_MAP[importedProject.importStatus]; - - expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull(); - expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch( - statusObject.text, - ); - - expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull(); - - mock.onGet(jobsPath).replyOnce(200, updatedProjects); - return store.dispatch('restartJobsPolling'); - }) - .then(() => setTimeoutPromise()) - .then(() => { - const statusObject = STATUS_MAP[updatedProjects[0].importStatus]; - - expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull(); - expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch( - statusObject.text, - ); - - expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull(); - }) - .then(() => done()) - .catch(() => done.fail()); - }); -}); diff --git a/spec/javascripts/issue_spec.js b/spec/javascripts/issue_spec.js index 11ab6c38a55..966aee72abb 100644 --- a/spec/javascripts/issue_spec.js +++ b/spec/javascripts/issue_spec.js @@ -113,7 +113,6 @@ describe('Issue', function() { mock = new MockAdapter(axios); mock.onGet(/(.*)\/related_branches$/).reply(200, {}); - mock.onGet(/(.*)\/referenced_merge_requests$/).reply(200, {}); findElements(isIssueInitiallyOpen); this.issue = new Issue(); diff --git a/spec/javascripts/monitoring/dashboard_spec.js b/spec/javascripts/monitoring/dashboard_spec.js index ce2c6c43c0f..16dc0084a10 100644 --- a/spec/javascripts/monitoring/dashboard_spec.js +++ b/spec/javascripts/monitoring/dashboard_spec.js @@ -175,7 +175,7 @@ describe('Dashboard', () => { setTimeout(() => { const dropdownItems = component.$el.querySelectorAll( - '.js-environments-dropdown .dropdown-item[active="true"]', + '.js-environments-dropdown .dropdown-item.is-active', ); expect(dropdownItems.length).toEqual(1); diff --git a/spec/javascripts/test_constants.js b/spec/javascripts/test_constants.js index a820dd2d09c..24b5512b053 100644 --- a/spec/javascripts/test_constants.js +++ b/spec/javascripts/test_constants.js @@ -1,7 +1,7 @@ export const FIXTURES_PATH = '/base/spec/javascripts/fixtures'; export const TEST_HOST = 'http://test.host'; -export const DUMMY_IMAGE_URL = `${FIXTURES_PATH}/one_white_pixel.png`; +export const DUMMY_IMAGE_URL = `${FIXTURES_PATH}/static/images/one_white_pixel.png`; -export const GREEN_BOX_IMAGE_URL = `${FIXTURES_PATH}/images/green_box.png`; -export const RED_BOX_IMAGE_URL = `${FIXTURES_PATH}/images/red_box.png`; +export const GREEN_BOX_IMAGE_URL = `${FIXTURES_PATH}/static/images/green_box.png`; +export const RED_BOX_IMAGE_URL = `${FIXTURES_PATH}/static/images/red_box.png`; diff --git a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js index 690fcd3e224..a0628fdcebe 100644 --- a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js @@ -228,6 +228,7 @@ describe('mrWidgetOptions', () => { describe('showTargetBranchAdvancedError', () => { describe(`when the pipeline's target_sha property doesn't exist`, () => { beforeEach(done => { + Vue.set(vm.mr, 'isOpen', true); Vue.set(vm.mr.pipeline, 'target_sha', undefined); Vue.set(vm.mr, 'targetBranchSha', 'abcd'); vm.$nextTick(done); @@ -240,6 +241,7 @@ describe('mrWidgetOptions', () => { describe(`when the pipeline's target_sha matches the target branch's sha`, () => { beforeEach(done => { + Vue.set(vm.mr, 'isOpen', true); Vue.set(vm.mr.pipeline, 'target_sha', 'abcd'); Vue.set(vm.mr, 'targetBranchSha', 'abcd'); vm.$nextTick(done); @@ -250,8 +252,22 @@ describe('mrWidgetOptions', () => { }); }); + describe(`when the merge request is not open`, () => { + beforeEach(done => { + Vue.set(vm.mr, 'isOpen', false); + Vue.set(vm.mr.pipeline, 'target_sha', 'abcd'); + Vue.set(vm.mr, 'targetBranchSha', 'bcde'); + vm.$nextTick(done); + }); + + it('should be false', () => { + expect(vm.showTargetBranchAdvancedError).toEqual(false); + }); + }); + describe(`when the pipeline's target_sha does not match the target branch's sha`, () => { beforeEach(done => { + Vue.set(vm.mr, 'isOpen', true); Vue.set(vm.mr.pipeline, 'target_sha', 'abcd'); Vue.set(vm.mr, 'targetBranchSha', 'bcde'); vm.$nextTick(done); diff --git a/spec/javascripts/vue_shared/components/issue/issue_milestone_spec.js b/spec/javascripts/vue_shared/components/issue/issue_milestone_spec.js deleted file mode 100644 index 8fca2637326..00000000000 --- a/spec/javascripts/vue_shared/components/issue/issue_milestone_spec.js +++ /dev/null @@ -1,234 +0,0 @@ -import Vue from 'vue'; - -import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue'; - -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import { mockMilestone } from 'spec/boards/mock_data'; - -const createComponent = (milestone = mockMilestone) => { - const Component = Vue.extend(IssueMilestone); - - return mountComponent(Component, { - milestone, - }); -}; - -describe('IssueMilestoneComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('computed', () => { - describe('isMilestoneStarted', () => { - it('should return `false` when milestoneStart prop is not defined', done => { - const vmStartUndefined = createComponent( - Object.assign({}, mockMilestone, { - start_date: '', - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmStartUndefined.isMilestoneStarted).toBe(false); - }) - .then(done) - .catch(done.fail); - - vmStartUndefined.$destroy(); - }); - - it('should return `true` when milestone start date is past current date', done => { - const vmStarted = createComponent( - Object.assign({}, mockMilestone, { - start_date: '1990-07-22', - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmStarted.isMilestoneStarted).toBe(true); - }) - .then(done) - .catch(done.fail); - - vmStarted.$destroy(); - }); - }); - - describe('isMilestonePastDue', () => { - it('should return `false` when milestoneDue prop is not defined', done => { - const vmDueUndefined = createComponent( - Object.assign({}, mockMilestone, { - due_date: '', - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmDueUndefined.isMilestonePastDue).toBe(false); - }) - .then(done) - .catch(done.fail); - - vmDueUndefined.$destroy(); - }); - - it('should return `true` when milestone due is past current date', done => { - const vmPastDue = createComponent( - Object.assign({}, mockMilestone, { - due_date: '1990-07-22', - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmPastDue.isMilestonePastDue).toBe(true); - }) - .then(done) - .catch(done.fail); - - vmPastDue.$destroy(); - }); - }); - - describe('milestoneDatesAbsolute', () => { - it('returns string containing absolute milestone due date', () => { - expect(vm.milestoneDatesAbsolute).toBe('(December 31, 2019)'); - }); - - it('returns string containing absolute milestone start date when due date is not present', done => { - const vmDueUndefined = createComponent( - Object.assign({}, mockMilestone, { - due_date: '', - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmDueUndefined.milestoneDatesAbsolute).toBe('(January 1, 2018)'); - }) - .then(done) - .catch(done.fail); - - vmDueUndefined.$destroy(); - }); - - it('returns empty string when both milestone start and due dates are not present', done => { - const vmDatesUndefined = createComponent( - Object.assign({}, mockMilestone, { - start_date: '', - due_date: '', - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmDatesUndefined.milestoneDatesAbsolute).toBe(''); - }) - .then(done) - .catch(done.fail); - - vmDatesUndefined.$destroy(); - }); - }); - - describe('milestoneDatesHuman', () => { - it('returns string containing milestone due date when date is yet to be due', done => { - const vmFuture = createComponent( - Object.assign({}, mockMilestone, { - due_date: `${new Date().getFullYear() + 10}-01-01`, - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmFuture.milestoneDatesHuman).toContain('years remaining'); - }) - .then(done) - .catch(done.fail); - - vmFuture.$destroy(); - }); - - it('returns string containing milestone start date when date has already started and due date is not present', done => { - const vmStarted = createComponent( - Object.assign({}, mockMilestone, { - start_date: '1990-07-22', - due_date: '', - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmStarted.milestoneDatesHuman).toContain('Started'); - }) - .then(done) - .catch(done.fail); - - vmStarted.$destroy(); - }); - - it('returns string containing milestone start date when date is yet to start and due date is not present', done => { - const vmStarts = createComponent( - Object.assign({}, mockMilestone, { - start_date: `${new Date().getFullYear() + 10}-01-01`, - due_date: '', - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmStarts.milestoneDatesHuman).toContain('Starts'); - }) - .then(done) - .catch(done.fail); - - vmStarts.$destroy(); - }); - - it('returns empty string when milestone start and due dates are not present', done => { - const vmDatesUndefined = createComponent( - Object.assign({}, mockMilestone, { - start_date: '', - due_date: '', - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmDatesUndefined.milestoneDatesHuman).toBe(''); - }) - .then(done) - .catch(done.fail); - - vmDatesUndefined.$destroy(); - }); - }); - }); - - describe('template', () => { - it('renders component root element with class `issue-milestone-details`', () => { - expect(vm.$el.classList.contains('issue-milestone-details')).toBe(true); - }); - - it('renders milestone icon', () => { - expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('clock'); - }); - - it('renders milestone title', () => { - expect(vm.$el.querySelector('.milestone-title').innerText.trim()).toBe(mockMilestone.title); - }); - - it('renders milestone tooltip', () => { - expect(vm.$el.querySelector('.js-item-milestone').innerText.trim()).toContain( - mockMilestone.title, - ); - }); - }); -}); diff --git a/spec/javascripts/vue_shared/components/project_selector/project_list_item_spec.js b/spec/javascripts/vue_shared/components/project_selector/project_list_item_spec.js index b95183747bb..268ced38f40 100644 --- a/spec/javascripts/vue_shared/components/project_selector/project_list_item_spec.js +++ b/spec/javascripts/vue_shared/components/project_selector/project_list_item_spec.js @@ -9,8 +9,8 @@ describe('ProjectListItem component', () => { let wrapper; let vm; let options; - loadJSONFixtures('projects.json'); - const project = getJSONFixture('projects.json')[0]; + loadJSONFixtures('static/projects.json'); + const project = getJSONFixture('static/projects.json')[0]; beforeEach(() => { options = { 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 ba9ec8f2f19..34c0cd435cd 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 @@ -8,8 +8,8 @@ import { trimText } from 'spec/helpers/vue_component_helper'; describe('ProjectSelector component', () => { let wrapper; let vm; - loadJSONFixtures('projects.json'); - const allProjects = getJSONFixture('projects.json'); + loadJSONFixtures('static/projects.json'); + const allProjects = getJSONFixture('static/projects.json'); const searchResults = allProjects.slice(0, 5); let selected = []; selected = selected.concat(allProjects.slice(0, 3)).concat(allProjects.slice(5, 8)); 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 index 852558a83bc..c7e0d806d80 100644 --- a/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js @@ -61,6 +61,12 @@ describe('User Popover Component', () => { 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', () => { @@ -117,6 +123,18 @@ describe('User Popover Component', () => { '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', () => { diff --git a/spec/lib/api/helpers_spec.rb b/spec/lib/api/helpers_spec.rb index 08165f147bb..00916f80784 100644 --- a/spec/lib/api/helpers_spec.rb +++ b/spec/lib/api/helpers_spec.rb @@ -137,18 +137,6 @@ describe API::Helpers do it_behaves_like 'user namespace finder' end - describe '#user_namespace' do - let(:namespace_finder) do - subject.user_namespace - end - - before do - allow(subject).to receive(:params).and_return({ id: namespace.id }) - end - - it_behaves_like 'user namespace finder' - end - describe '#send_git_blob' do let(:repository) { double } let(:blob) { double(name: 'foobar') } diff --git a/spec/lib/gitlab/ci/variables/collection/item_spec.rb b/spec/lib/gitlab/ci/variables/collection/item_spec.rb index 3ff2fe18c15..613814df23f 100644 --- a/spec/lib/gitlab/ci/variables/collection/item_spec.rb +++ b/spec/lib/gitlab/ci/variables/collection/item_spec.rb @@ -137,19 +137,5 @@ describe Gitlab::Ci::Variables::Collection::Item do .to eq(key: 'VAR', value: 'value', public: true, file: true, masked: false) end end - - context 'when variable masking is disabled' do - before do - stub_feature_flags(variable_masking: false) - end - - it 'does not expose the masked field to the runner' do - runner_variable = described_class - .new(key: 'VAR', value: 'value', masked: true) - .to_runner_variable - - expect(runner_variable).to eq(key: 'VAR', value: 'value', public: true) - end - end end end diff --git a/spec/lib/gitlab/data_builder/deployment_spec.rb b/spec/lib/gitlab/data_builder/deployment_spec.rb new file mode 100644 index 00000000000..b89a44e178b --- /dev/null +++ b/spec/lib/gitlab/data_builder/deployment_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::DataBuilder::Deployment do + describe '.build' do + it 'returns the object kind for a deployment' do + deployment = build(:deployment) + + data = described_class.build(deployment) + + expect(data[:object_kind]).to eq('deployment') + end + + it 'returns data for the given build' do + environment = create(:environment, name: "somewhere") + project = create(:project, :repository, name: 'myproj') + commit = project.commit('HEAD') + deployment = create(:deployment, status: :failed, environment: environment, sha: commit.sha, project: project) + deployable = deployment.deployable + expected_deployable_url = Gitlab::Routing.url_helpers.project_job_url(deployable.project, deployable) + expected_commit_url = Gitlab::UrlBuilder.build(commit) + + data = described_class.build(deployment) + + expect(data[:status]).to eq('failed') + expect(data[:deployable_id]).to eq(deployable.id) + expect(data[:deployable_url]).to eq(expected_deployable_url) + expect(data[:environment]).to eq("somewhere") + expect(data[:project]).to eq(project.hook_attrs) + expect(data[:short_sha]).to eq(deployment.short_sha) + expect(data[:user]).to eq(deployment.user.hook_attrs) + expect(data[:commit_url]).to eq(expected_commit_url) + end + end +end diff --git a/spec/lib/gitlab/data_builder/push_spec.rb b/spec/lib/gitlab/data_builder/push_spec.rb index 0c4decc6518..46ad674a1eb 100644 --- a/spec/lib/gitlab/data_builder/push_spec.rb +++ b/spec/lib/gitlab/data_builder/push_spec.rb @@ -23,9 +23,12 @@ describe Gitlab::DataBuilder::Push do describe '.build' do let(:data) do - described_class.build(project, user, Gitlab::Git::BLANK_SHA, - '8a2a6eb295bb170b34c24c76c49ed0e9b2eaf34b', - 'refs/tags/v1.1.0') + described_class.build( + project: project, + user: user, + oldrev: Gitlab::Git::BLANK_SHA, + newrev: '8a2a6eb295bb170b34c24c76c49ed0e9b2eaf34b', + ref: 'refs/tags/v1.1.0') end it { expect(data).to be_a(Hash) } @@ -47,7 +50,7 @@ describe Gitlab::DataBuilder::Push do include_examples 'deprecated repository hook data' it 'does not raise an error when given nil commits' do - expect { described_class.build(spy, spy, spy, spy, 'refs/tags/v1.1.0', nil) } + expect { described_class.build(project: spy, user: spy, ref: 'refs/tags/v1.1.0', commits: nil) } .not_to raise_error end end diff --git a/spec/lib/gitlab/git/blob_spec.rb b/spec/lib/gitlab/git/blob_spec.rb index 10bc82e24d1..1c24244c3a6 100644 --- a/spec/lib/gitlab/git/blob_spec.rb +++ b/spec/lib/gitlab/git/blob_spec.rb @@ -341,7 +341,7 @@ describe Gitlab::Git::Blob, :seed_helper do it { expect(blob.mode).to eq("100755") } end - context 'file with Chinese text' do + context 'file with Japanese text' do let(:blob) { Gitlab::Git::Blob.find(repository, SeedRepo::Commit::ID, "encoding/テスト.txt") } it { expect(blob.name).to eq("テスト.txt") } diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 45fe5d72937..5f8a2848944 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -95,6 +95,12 @@ describe Gitlab::Git::Repository, :seed_helper do end end + describe '#create_repository' do + it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::RepositoryService, :create_repository do + subject { repository.create_repository } + end + end + describe '#branch_names' do subject { repository.branch_names } diff --git a/spec/lib/gitlab/git/wiki_spec.rb b/spec/lib/gitlab/git/wiki_spec.rb index ded5d7576df..1e577392949 100644 --- a/spec/lib/gitlab/git/wiki_spec.rb +++ b/spec/lib/gitlab/git/wiki_spec.rb @@ -21,13 +21,13 @@ describe Gitlab::Git::Wiki do end it 'returns all the pages' do - expect(subject.pages.count).to eq(2) - expect(subject.pages.first.title).to eq 'page1' - expect(subject.pages.last.title).to eq 'page2' + expect(subject.list_pages.count).to eq(2) + expect(subject.list_pages.first.title).to eq 'page1' + expect(subject.list_pages.last.title).to eq 'page2' end it 'returns only one page' do - pages = subject.pages(limit: 1) + pages = subject.list_pages(limit: 1) expect(pages.count).to eq(1) expect(pages.first.title).to eq 'page1' @@ -62,8 +62,8 @@ describe Gitlab::Git::Wiki do subject.delete_page('*', commit_details('whatever')) - expect(subject.pages.count).to eq 1 - expect(subject.pages.first.title).to eq 'page1' + expect(subject.list_pages.count).to eq 1 + expect(subject.list_pages.first.title).to eq 'page1' end end @@ -87,7 +87,7 @@ describe Gitlab::Git::Wiki do with_them do subject { wiki.preview_slug(title, format) } - let(:gitaly_slug) { wiki.pages.first } + let(:gitaly_slug) { wiki.list_pages.first } it { is_expected.to eq(expected_slug) } @@ -96,7 +96,7 @@ describe Gitlab::Git::Wiki do create_page(title, 'content', format: format) - gitaly_slug = wiki.pages.first.url_path + gitaly_slug = wiki.list_pages.first.url_path is_expected.to eq(gitaly_slug) end diff --git a/spec/lib/gitlab/gitaly_client/wiki_service_spec.rb b/spec/lib/gitlab/gitaly_client/wiki_service_spec.rb index d82c9c28da0..4fa8e97aca0 100644 --- a/spec/lib/gitlab/gitaly_client/wiki_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/wiki_service_spec.rb @@ -46,7 +46,7 @@ describe Gitlab::GitalyClient::WikiService do end end - describe '#get_all_pages' do + describe '#load_all_pages' do let(:page_2_info) { { title: 'My Page 2', raw_data: 'c', version: page_version } } let(:response) do [ @@ -63,7 +63,7 @@ describe Gitlab::GitalyClient::WikiService do let(:wiki_page_2) { subject[1].first } let(:wiki_page_2_version) { subject[1].last } - subject { client.get_all_pages } + subject { client.load_all_pages } it 'sends a wiki_get_all_pages message' do expect_any_instance_of(Gitaly::WikiService::Stub) @@ -99,7 +99,7 @@ describe Gitlab::GitalyClient::WikiService do end context 'with limits' do - subject { client.get_all_pages(limit: 1) } + subject { client.load_all_pages(limit: 1) } it 'sends a request with the limit' do expect_any_instance_of(Gitaly::WikiService::Stub) diff --git a/spec/lib/gitlab/gitaly_client_spec.rb b/spec/lib/gitlab/gitaly_client_spec.rb index f1acb1d9bc4..da1eb0c2618 100644 --- a/spec/lib/gitlab/gitaly_client_spec.rb +++ b/spec/lib/gitlab/gitaly_client_spec.rb @@ -142,6 +142,48 @@ describe Gitlab::GitalyClient do end end + describe '.request_kwargs' do + context 'when catfile-cache feature is enabled' do + before do + stub_feature_flags('gitaly_catfile-cache': true) + end + + it 'sets the gitaly-session-id in the metadata' do + results = described_class.request_kwargs('default', nil) + expect(results[:metadata]).to include('gitaly-session-id') + end + + context 'when RequestStore is not enabled' do + it 'sets a different gitaly-session-id per request' do + gitaly_session_id = described_class.request_kwargs('default', nil)[:metadata]['gitaly-session-id'] + + expect(described_class.request_kwargs('default', nil)[:metadata]['gitaly-session-id']).not_to eq(gitaly_session_id) + end + end + + context 'when RequestStore is enabled', :request_store do + it 'sets the same gitaly-session-id on every outgoing request metadata' do + gitaly_session_id = described_class.request_kwargs('default', nil)[:metadata]['gitaly-session-id'] + + 3.times do + expect(described_class.request_kwargs('default', nil)[:metadata]['gitaly-session-id']).to eq(gitaly_session_id) + end + end + end + end + + context 'when catfile-cache feature is disabled' do + before do + stub_feature_flags({ 'gitaly_catfile-cache': false }) + end + + it 'does not set the gitaly-session-id in the metadata' do + results = described_class.request_kwargs('default', nil) + expect(results[:metadata]).not_to include('gitaly-session-id') + end + end + end + describe 'enforce_gitaly_request_limits?' do def call_gitaly(count = 1) (1..count).each do diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 54369ff75f4..482e9c05da8 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -322,6 +322,7 @@ project: - pool_repository - kubernetes_namespaces - error_tracking_setting +- metrics_setting award_emoji: - awardable - user @@ -360,3 +361,5 @@ error_tracking_setting: - project suggestions: - note +metrics_setting: +- project diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index ebb62124cb1..9093d21647a 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -423,6 +423,7 @@ Service: - wiki_page_events - confidential_issues_events - confidential_note_events +- deployment_events ProjectHook: - id - url @@ -606,7 +607,6 @@ ResourceLabelEvent: - user_id - created_at ErrorTracking::ProjectErrorTrackingSetting: -- id - api_url - project_id - project_name @@ -626,3 +626,8 @@ MergeRequestAssignee: - id - user_id - merge_request_id +ProjectMetricsSetting: +- project_id +- external_dashboard_url +- created_at +- updated_at diff --git a/spec/lib/gitlab/kubernetes/helm/api_spec.rb b/spec/lib/gitlab/kubernetes/helm/api_spec.rb index 8433d40b2ea..24ce397ec3d 100644 --- a/spec/lib/gitlab/kubernetes/helm/api_spec.rb +++ b/spec/lib/gitlab/kubernetes/helm/api_spec.rb @@ -33,6 +33,28 @@ describe Gitlab::Kubernetes::Helm::Api do end end + describe '#uninstall' do + before do + allow(client).to receive(:create_pod).and_return(nil) + allow(client).to receive(:delete_pod).and_return(nil) + allow(namespace).to receive(:ensure_exists!).once + end + + it 'ensures the namespace exists before creating the POD' do + expect(namespace).to receive(:ensure_exists!).once.ordered + expect(client).to receive(:create_pod).once.ordered + + subject.uninstall(command) + end + + it 'removes an existing pod before installing' do + expect(client).to receive(:delete_pod).with('install-app-name', 'gitlab-managed-apps').once.ordered + expect(client).to receive(:create_pod).once.ordered + + subject.uninstall(command) + end + end + describe '#install' do before do allow(client).to receive(:create_pod).and_return(nil) diff --git a/spec/lib/gitlab/metrics/dashboard/processor_spec.rb b/spec/lib/gitlab/metrics/dashboard/processor_spec.rb new file mode 100644 index 00000000000..ee7c93fce8d --- /dev/null +++ b/spec/lib/gitlab/metrics/dashboard/processor_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Metrics::Dashboard::Processor do + let(:project) { build(:project) } + let(:environment) { build(:environment) } + let(:dashboard_yml) { YAML.load_file('spec/fixtures/lib/gitlab/metrics/dashboard/sample_dashboard.yml') } + + describe 'process' do + let(:process_params) { [project, environment, dashboard_yml] } + let(:dashboard) { described_class.new(*process_params).process } + + context 'when dashboard config corresponds to common metrics' do + let!(:common_metric) { create(:prometheus_metric, :common, identifier: 'metric_a1') } + + it 'inserts metric ids into the config' do + target_metric = all_metrics.find { |metric| metric[:id] == 'metric_a1' } + + expect(target_metric).to include(:metric_id) + expect(target_metric[:metric_id]).to eq(common_metric.id) + end + end + + context 'when the project has associated metrics' do + let!(:project_response_metric) { create(:prometheus_metric, project: project, group: :response) } + let!(:project_system_metric) { create(:prometheus_metric, project: project, group: :system) } + let!(:project_business_metric) { create(:prometheus_metric, project: project, group: :business) } + + it 'includes project-specific metrics' do + expect(all_metrics).to include get_metric_details(project_system_metric) + expect(all_metrics).to include get_metric_details(project_response_metric) + expect(all_metrics).to include get_metric_details(project_business_metric) + end + + it 'orders groups by priority and panels by weight' do + expected_metrics_order = [ + 'metric_a2', # group priority 10, panel weight 2 + 'metric_a1', # group priority 10, panel weight 1 + 'metric_b', # group priority 1, panel weight 1 + project_business_metric.id, # group priority 0, panel weight nil (0) + project_response_metric.id, # group priority -5, panel weight nil (0) + project_system_metric.id, # group priority -10, panel weight nil (0) + ] + actual_metrics_order = all_metrics.map { |m| m[:id] || m[:metric_id] } + + expect(actual_metrics_order).to eq expected_metrics_order + end + end + + shared_examples_for 'errors with message' do |expected_message| + it 'raises a DashboardLayoutError' do + error_class = Gitlab::Metrics::Dashboard::Stages::BaseStage::DashboardLayoutError + + expect { dashboard }.to raise_error(error_class, expected_message) + end + end + + context 'when the dashboard is missing panel_groups' do + let(:dashboard_yml) { {} } + + it_behaves_like 'errors with message', 'Top-level key :panel_groups must be an array' + end + + context 'when the dashboard contains a panel_group which is missing panels' do + let(:dashboard_yml) { { panel_groups: [{}] } } + + it_behaves_like 'errors with message', 'Each "panel_group" must define an array :panels' + end + + context 'when the dashboard contains a panel which is missing metrics' do + let(:dashboard_yml) { { panel_groups: [{ panels: [{}] }] } } + + it_behaves_like 'errors with message', 'Each "panel" must define an array :metrics' + end + end + + private + + def all_metrics + dashboard[:panel_groups].map do |group| + group[:panels].map { |panel| panel[:metrics] } + end.flatten + end + + def get_metric_details(metric) + { + query_range: metric.query, + unit: metric.unit, + label: metric.legend, + metric_id: metric.id + } + end +end diff --git a/spec/lib/gitlab/metrics/dashboard/service_spec.rb b/spec/lib/gitlab/metrics/dashboard/service_spec.rb new file mode 100644 index 00000000000..e66c356bf49 --- /dev/null +++ b/spec/lib/gitlab/metrics/dashboard/service_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Metrics::Dashboard::Service, :use_clean_rails_memory_store_caching do + let(:project) { build(:project) } + let(:environment) { build(:environment) } + + describe 'get_dashboard' do + let(:dashboard_schema) { JSON.parse(fixture_file('lib/gitlab/metrics/dashboard/schemas/dashboard.json')) } + + it 'returns a json representation of the environment dashboard' do + result = described_class.new(project, environment).get_dashboard + + expect(result.keys).to contain_exactly(:dashboard, :status) + expect(result[:status]).to eq(:success) + + expect(JSON::Validator.fully_validate(dashboard_schema, result[:dashboard])).to be_empty + end + + it 'caches the dashboard for subsequent calls' do + expect(YAML).to receive(:safe_load).once.and_call_original + + described_class.new(project, environment).get_dashboard + described_class.new(project, environment).get_dashboard + end + + context 'when the dashboard is configured incorrectly' do + before do + allow(YAML).to receive(:safe_load).and_return({}) + end + + it 'returns an appropriate message and status code' do + result = described_class.new(project, environment).get_dashboard + + expect(result.keys).to contain_exactly(:message, :http_status, :status) + expect(result[:status]).to eq(:error) + expect(result[:http_status]).to eq(:unprocessable_entity) + end + end + end +end diff --git a/spec/lib/gitlab/profiler_spec.rb b/spec/lib/gitlab/profiler_spec.rb index 9f2214f7ce7..5af52db7a1f 100644 --- a/spec/lib/gitlab/profiler_spec.rb +++ b/spec/lib/gitlab/profiler_spec.rb @@ -27,13 +27,13 @@ describe Gitlab::Profiler do it 'sends a POST request when data is passed' do post_data = '{"a":1}' - expect(app).to receive(:post).with(anything, post_data, anything) + expect(app).to receive(:post).with(anything, params: post_data, headers: anything) described_class.profile('/', post_data: post_data) end it 'uses the private_token for auth if given' do - expect(app).to receive(:get).with('/', nil, 'Private-Token' => private_token) + expect(app).to receive(:get).with('/', params: nil, headers: { 'Private-Token' => private_token }) expect(app).to receive(:get).with('/api/v4/users') described_class.profile('/', private_token: private_token) @@ -51,7 +51,7 @@ describe Gitlab::Profiler do user = double(:user) expect(described_class).to receive(:with_user).with(nil).and_call_original - expect(app).to receive(:get).with('/', nil, 'Private-Token' => private_token) + expect(app).to receive(:get).with('/', params: nil, headers: { 'Private-Token' => private_token }) expect(app).to receive(:get).with('/api/v4/users') described_class.profile('/', user: user, private_token: private_token) diff --git a/spec/models/application_record_spec.rb b/spec/models/application_record_spec.rb index fd25132ed3a..cc90a998d3f 100644 --- a/spec/models/application_record_spec.rb +++ b/spec/models/application_record_spec.rb @@ -11,6 +11,25 @@ describe ApplicationRecord do end end + describe '.safe_ensure_unique' do + let(:model) { build(:suggestion) } + let(:klass) { model.class } + + before do + allow(model).to receive(:save).and_raise(ActiveRecord::RecordNotUnique) + end + + it 'returns false when ActiveRecord::RecordNotUnique is raised' do + expect(model).to receive(:save).once + expect(klass.safe_ensure_unique { model.save }).to be_falsey + end + + it 'retries based on retry count specified' do + expect(model).to receive(:save).exactly(3).times + expect(klass.safe_ensure_unique(retries: 2) { model.save }).to be_falsey + end + end + describe '.safe_find_or_create_by' do it 'creates the user avoiding race conditions' do expect(Suggestion).to receive(:find_or_create_by).and_raise(ActiveRecord::RecordNotUnique) diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index c7d7dbac736..f8dc1541dd3 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -31,6 +31,20 @@ describe ApplicationSetting do it { is_expected.to allow_value("dev.gitlab.com").for(:commit_email_hostname) } it { is_expected.not_to allow_value("@dev.gitlab").for(:commit_email_hostname) } + it { is_expected.to allow_value("myemail@gitlab.com").for(:lets_encrypt_notification_email) } + it { is_expected.to allow_value(nil).for(:lets_encrypt_notification_email) } + it { is_expected.not_to allow_value("notanemail").for(:lets_encrypt_notification_email) } + it { is_expected.not_to allow_value("myemail@example.com").for(:lets_encrypt_notification_email) } + it { is_expected.to allow_value("myemail@test.example.com").for(:lets_encrypt_notification_email) } + + context "when user accepted let's encrypt terms of service" do + before do + setting.update(lets_encrypt_terms_of_service_accepted: true) + end + + it { is_expected.not_to allow_value(nil).for(:lets_encrypt_notification_email) } + end + describe 'default_artifacts_expire_in' do it 'sets an error if it cannot parse' do setting.update(default_artifacts_expire_in: 'a') diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index d0e1688cce3..8364293b908 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -43,6 +43,21 @@ describe Blob do changelog.id contributing.id end + + it 'does not include blobs from previous requests in later requests' do + changelog = described_class.lazy(project, commit_id, 'CHANGELOG') + contributing = described_class.lazy(same_project, commit_id, 'CONTRIBUTING.md') + + # Access property so the values are loaded + changelog.id + contributing.id + + readme = described_class.lazy(project, commit_id, 'README.md') + + expect(project.repository).to receive(:blobs_at).with([[commit_id, 'README.md']]).once.and_call_original + + readme.id + end end describe '#data' do diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb index 44b5af5e5aa..eb32198265b 100644 --- a/spec/models/ci/bridge_spec.rb +++ b/spec/models/ci/bridge_spec.rb @@ -10,6 +10,8 @@ describe Ci::Bridge do create(:ci_bridge, pipeline: pipeline) end + it { is_expected.to include_module(Ci::PipelineDelegator) } + describe '#tags' do it 'only has a bridge tag' do expect(bridge.tags).to eq [:bridge] diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 3a7d20a58c8..59ec7310391 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -28,6 +28,7 @@ describe Ci::Build do it { is_expected.to delegate_method(:merge_request_event?).to(:pipeline) } it { is_expected.to delegate_method(:merge_request_ref?).to(:pipeline) } it { is_expected.to delegate_method(:legacy_detached_merge_request_pipeline?).to(:pipeline) } + it { is_expected.to include_module(Ci::PipelineDelegator) } it { is_expected.to be_a(ArtifactMigratable) } @@ -856,6 +857,10 @@ describe Ci::Build do let(:deployment) { build.deployment } let(:environment) { deployment.environment } + before do + allow(Deployments::FinishedWorker).to receive(:perform_async) + end + it 'has deployments record with created status' do expect(deployment).to be_created expect(environment.name).to eq('review/master') @@ -2269,6 +2274,19 @@ describe Ci::Build do it { user_variables.each { |v| is_expected.to include(v) } } end + context 'when build belongs to a pipeline for merge request' do + let(:merge_request) { create(:merge_request, :with_detached_merge_request_pipeline, source_branch: 'improve/awesome') } + let(:pipeline) { merge_request.all_pipelines.first } + let(:build) { create(:ci_build, ref: pipeline.ref, pipeline: pipeline) } + + it 'returns values based on source ref' do + is_expected.to include( + { key: 'CI_COMMIT_REF_NAME', value: 'improve/awesome', public: true, masked: false }, + { key: 'CI_COMMIT_REF_SLUG', value: 'improve-awesome', public: true, masked: false } + ) + end + end + context 'when build has an environment' do let(:environment_variables) do [ @@ -2660,6 +2678,8 @@ describe Ci::Build do ) end + let(:pipeline) { create(:ci_pipeline, project: project, ref: 'feature') } + it 'returns static predefined variables' do expect(build.variables.size).to be >= 28 expect(build.variables) @@ -2709,6 +2729,8 @@ describe Ci::Build do ) end + let(:pipeline) { create(:ci_pipeline, project: project, ref: 'feature') } + it 'does not persist the build' do expect(build).to be_valid expect(build).not_to be_persisted diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 3c823b78be7..9d0cd654f13 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -382,6 +382,54 @@ describe Ci::Pipeline, :mailer do end end + describe '#source_ref' do + subject { pipeline.source_ref } + + let(:pipeline) { create(:ci_pipeline, ref: 'feature') } + + it 'returns source ref' do + is_expected.to eq('feature') + end + + context 'when the pipeline is a detached merge request pipeline' do + let(:merge_request) { create(:merge_request) } + + let(:pipeline) do + create(:ci_pipeline, source: :merge_request_event, merge_request: merge_request, ref: merge_request.ref_path) + end + + it 'returns source ref' do + is_expected.to eq(merge_request.source_branch) + end + end + end + + describe '#source_ref_slug' do + subject { pipeline.source_ref_slug } + + let(:pipeline) { create(:ci_pipeline, ref: 'feature') } + + it 'slugifies with the source ref' do + expect(Gitlab::Utils).to receive(:slugify).with('feature') + + subject + end + + context 'when the pipeline is a detached merge request pipeline' do + let(:merge_request) { create(:merge_request) } + + let(:pipeline) do + create(:ci_pipeline, source: :merge_request_event, merge_request: merge_request, ref: merge_request.ref_path) + end + + it 'slugifies with the source ref of the merge request' do + expect(Gitlab::Utils).to receive(:slugify).with(merge_request.source_branch) + + subject + end + end + end + describe '.triggered_for_branch' do subject { described_class.triggered_for_branch(ref) } diff --git a/spec/models/clusters/applications/cert_manager_spec.rb b/spec/models/clusters/applications/cert_manager_spec.rb index 5cd80edb3a1..8d853a04e33 100644 --- a/spec/models/clusters/applications/cert_manager_spec.rb +++ b/spec/models/clusters/applications/cert_manager_spec.rb @@ -10,6 +10,12 @@ describe Clusters::Applications::CertManager do include_examples 'cluster application version specs', :clusters_applications_cert_managers include_examples 'cluster application initial status specs' + describe '#can_uninstall?' do + subject { cert_manager.can_uninstall? } + + it { is_expected.to be_falsey } + end + describe '#install_command' do let(:cert_email) { 'admin@example.com' } diff --git a/spec/models/clusters/applications/helm_spec.rb b/spec/models/clusters/applications/helm_spec.rb index f177d493a2e..6ea6c110d62 100644 --- a/spec/models/clusters/applications/helm_spec.rb +++ b/spec/models/clusters/applications/helm_spec.rb @@ -18,6 +18,14 @@ describe Clusters::Applications::Helm do it { is_expected.to contain_exactly(installed_cluster, updated_cluster) } end + describe '#can_uninstall?' do + let(:helm) { create(:clusters_applications_helm) } + + subject { helm.can_uninstall? } + + it { is_expected.to be_falsey } + end + describe '#issue_client_cert' do let(:application) { create(:clusters_applications_helm) } subject { application.issue_client_cert } diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb index 113d29b5551..292ddabd2d8 100644 --- a/spec/models/clusters/applications/ingress_spec.rb +++ b/spec/models/clusters/applications/ingress_spec.rb @@ -18,6 +18,12 @@ describe Clusters::Applications::Ingress do allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_async) end + describe '#can_uninstall?' do + subject { ingress.can_uninstall? } + + it { is_expected.to be_falsey } + end + describe '#make_installed!' do before do application.make_installed! diff --git a/spec/models/clusters/applications/jupyter_spec.rb b/spec/models/clusters/applications/jupyter_spec.rb index 1a7363b64f9..fc9ebed863e 100644 --- a/spec/models/clusters/applications/jupyter_spec.rb +++ b/spec/models/clusters/applications/jupyter_spec.rb @@ -10,6 +10,15 @@ describe Clusters::Applications::Jupyter do it { is_expected.to belong_to(:oauth_application) } + describe '#can_uninstall?' do + let(:ingress) { create(:clusters_applications_ingress, :installed, external_hostname: 'localhost.localdomain') } + let(:jupyter) { create(:clusters_applications_jupyter, cluster: ingress.cluster) } + + subject { jupyter.can_uninstall? } + + it { is_expected.to be_falsey } + end + describe '#set_initial_status' do before do jupyter.set_initial_status diff --git a/spec/models/clusters/applications/knative_spec.rb b/spec/models/clusters/applications/knative_spec.rb index 405b5ad691c..d5974f47190 100644 --- a/spec/models/clusters/applications/knative_spec.rb +++ b/spec/models/clusters/applications/knative_spec.rb @@ -39,6 +39,12 @@ describe Clusters::Applications::Knative do end end + describe '#can_uninstall?' do + subject { knative.can_uninstall? } + + it { is_expected.to be_falsey } + end + describe '#schedule_status_update with external_ip' do let(:application) { create(:clusters_applications_knative, :installed) } diff --git a/spec/models/clusters/applications/prometheus_spec.rb b/spec/models/clusters/applications/prometheus_spec.rb index e8ba9737c23..26267c64112 100644 --- a/spec/models/clusters/applications/prometheus_spec.rb +++ b/spec/models/clusters/applications/prometheus_spec.rb @@ -11,6 +11,21 @@ describe Clusters::Applications::Prometheus do include_examples 'cluster application helm specs', :clusters_applications_prometheus include_examples 'cluster application initial status specs' + describe 'after_destroy' do + let(:project) { create(:project) } + let(:cluster) { create(:cluster, :with_installed_helm, projects: [project]) } + let!(:application) { create(:clusters_applications_prometheus, :installed, cluster: cluster) } + let!(:prometheus_service) { project.create_prometheus_service(active: true) } + + it 'deactivates prometheus_service after destroy' do + expect do + application.destroy! + + prometheus_service.reload + end.to change(prometheus_service, :active).from(true).to(false) + end + end + describe 'transition to installed' do let(:project) { create(:project) } let(:cluster) { create(:cluster, :with_installed_helm, projects: [project]) } @@ -23,12 +38,20 @@ describe Clusters::Applications::Prometheus do end it 'ensures Prometheus service is activated' do - expect(prometheus_service).to receive(:update).with(active: true) + expect(prometheus_service).to receive(:update!).with(active: true) subject.make_installed end end + describe '#can_uninstall?' do + let(:prometheus) { create(:clusters_applications_prometheus) } + + subject { prometheus.can_uninstall? } + + it { is_expected.to be_truthy } + end + describe '#prometheus_client' do context 'cluster is nil' do it 'returns nil' do @@ -134,6 +157,34 @@ describe Clusters::Applications::Prometheus do end end + describe '#uninstall_command' do + let(:prometheus) { create(:clusters_applications_prometheus) } + + subject { prometheus.uninstall_command } + + it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::DeleteCommand) } + + it 'has the application name' do + expect(subject.name).to eq('prometheus') + end + + it 'has files' do + expect(subject.files).to eq(prometheus.files) + end + + it 'is rbac' do + expect(subject).to be_rbac + end + + context 'on a non rbac enabled cluster' do + before do + prometheus.cluster.platform_kubernetes.abac! + end + + it { is_expected.not_to be_rbac } + end + end + describe '#upgrade_command' do let(:prometheus) { build(:clusters_applications_prometheus) } let(:values) { prometheus.values } diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb index de406211a5b..bdc0cb8ed86 100644 --- a/spec/models/clusters/applications/runner_spec.rb +++ b/spec/models/clusters/applications/runner_spec.rb @@ -13,6 +13,14 @@ describe Clusters::Applications::Runner do it { is_expected.to belong_to(:runner) } + describe '#can_uninstall?' do + let(:gitlab_runner) { create(:clusters_applications_runner, runner: ci_runner) } + + subject { gitlab_runner.can_uninstall? } + + it { is_expected.to be_falsey } + end + describe '#install_command' do let(:kubeclient) { double('kubernetes client') } let(:gitlab_runner) { create(:clusters_applications_runner, runner: ci_runner) } @@ -24,7 +32,7 @@ describe Clusters::Applications::Runner do it 'is initialized with 4 arguments' do expect(subject.name).to eq('runner') expect(subject.chart).to eq('runner/gitlab-runner') - expect(subject.version).to eq('0.4.0') + expect(subject.version).to eq(Clusters::Applications::Runner::VERSION) expect(subject).to be_rbac expect(subject.repository).to eq('https://charts.gitlab.io') expect(subject.files).to eq(gitlab_runner.files) @@ -42,7 +50,7 @@ describe Clusters::Applications::Runner do let(:gitlab_runner) { create(:clusters_applications_runner, :errored, runner: ci_runner, version: '0.1.13') } it 'is initialized with the locked version' do - expect(subject.version).to eq('0.4.0') + expect(subject.version).to eq(Clusters::Applications::Runner::VERSION) end end end diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb index d9170d5fa07..f51322e1404 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -102,6 +102,13 @@ describe Deployment do deployment.succeed! end + + it 'executes Deployments::FinishedWorker asynchronously' do + expect(Deployments::FinishedWorker) + .to receive(:perform_async).with(deployment.id) + + deployment.succeed! + end end context 'when deployment failed' do @@ -115,6 +122,13 @@ describe Deployment do expect(deployment.finished_at).to be_like_time(Time.now) end end + + it 'executes Deployments::FinishedWorker asynchronously' do + expect(Deployments::FinishedWorker) + .to receive(:perform_async).with(deployment.id) + + deployment.drop! + end end context 'when deployment was canceled' do @@ -128,6 +142,13 @@ describe Deployment do expect(deployment.finished_at).to be_like_time(Time.now) end end + + it 'executes Deployments::FinishedWorker asynchronously' do + expect(Deployments::FinishedWorker) + .to receive(:perform_async).with(deployment.id) + + deployment.cancel! + end end end @@ -379,6 +400,12 @@ describe Deployment do it { is_expected.to be_nil } end + context 'project uses the kubernetes service for deployments' do + let!(:service) { create(:kubernetes_service, project: project) } + + it { is_expected.to be_nil } + end + context 'project has a deployment platform' do let!(:cluster) { create(:cluster, projects: [project]) } let!(:platform) { create(:cluster_platform_kubernetes, cluster: cluster) } diff --git a/spec/models/notification_recipient_spec.rb b/spec/models/notification_recipient_spec.rb index 3710f2be287..1b1ede6b14c 100644 --- a/spec/models/notification_recipient_spec.rb +++ b/spec/models/notification_recipient_spec.rb @@ -9,11 +9,43 @@ describe NotificationRecipient do subject(:recipient) { described_class.new(user, :watch, target: target, project: project) } - it 'denies access to a target when cross project access is denied' do - allow(Ability).to receive(:allowed?).and_call_original - expect(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(false) + describe '#has_access?' do + before do + allow(user).to receive(:can?).and_call_original + end + + context 'user cannot read cross project' do + it 'returns false' do + expect(user).to receive(:can?).with(:read_cross_project).and_return(false) + expect(recipient.has_access?).to eq false + end + end + + context 'user cannot read build' do + let(:target) { build(:ci_pipeline) } + + it 'returns false' do + expect(user).to receive(:can?).with(:read_build, target).and_return(false) + expect(recipient.has_access?).to eq false + end + end - expect(recipient.has_access?).to be_falsy + context 'user cannot read commit' do + let(:target) { build(:commit) } + + it 'returns false' do + expect(user).to receive(:can?).with(:read_commit, target).and_return(false) + expect(recipient.has_access?).to eq false + end + end + + context 'target has no policy' do + let(:target) { double.as_null_object } + + it 'returns true' do + expect(recipient.has_access?).to eq true + end + end end context '#notification_setting' do diff --git a/spec/models/project_metrics_setting_spec.rb b/spec/models/project_metrics_setting_spec.rb new file mode 100644 index 00000000000..7df01625ba1 --- /dev/null +++ b/spec/models/project_metrics_setting_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ProjectMetricsSetting do + describe 'Associations' do + it { is_expected.to belong_to(:project) } + end + + describe 'Validations' do + context 'when external_dashboard_url is over 255 chars' do + before do + subject.external_dashboard_url = 'https://' + 'a' * 250 + end + + it 'fails validation' do + expect(subject).not_to be_valid + expect(subject.errors.messages[:external_dashboard_url]) + .to include('is too long (maximum is 255 characters)') + end + end + + context 'with unsafe url' do + before do + subject.external_dashboard_url = %{https://replaceme.com/'><script>alert(document.cookie)</script>} + end + + it { is_expected.to be_invalid } + end + + context 'non ascii chars in external_dashboard_url' do + before do + subject.external_dashboard_url = 'http://gitlab.com/api/0/projects/project1/something€' + end + + it { is_expected.to be_invalid } + end + + context 'internal url in external_dashboard_url' do + before do + subject.external_dashboard_url = 'http://192.168.1.1' + end + + it { is_expected.to be_valid } + end + + context 'external_dashboard_url is blank' do + before do + subject.external_dashboard_url = '' + end + + it { is_expected.to be_invalid } + end + end +end diff --git a/spec/models/project_services/chat_message/deployment_message_spec.rb b/spec/models/project_services/chat_message/deployment_message_spec.rb new file mode 100644 index 00000000000..86565ce8b01 --- /dev/null +++ b/spec/models/project_services/chat_message/deployment_message_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ChatMessage::DeploymentMessage do + describe '#pretext' do + it 'returns a message with the data returned by the deployment data builder' do + environment = create(:environment, name: "myenvironment") + project = create(:project, :repository) + commit = project.commit('HEAD') + deployment = create(:deployment, status: :success, environment: environment, project: project, sha: commit.sha) + data = Gitlab::DataBuilder::Deployment.build(deployment) + + message = described_class.new(data) + + expect(message.pretext).to eq("Deploy to myenvironment succeeded") + end + + it 'returns a message for a successful deployment' do + data = { + status: 'success', + environment: 'production' + } + + message = described_class.new(data) + + expect(message.pretext).to eq('Deploy to production succeeded') + end + + it 'returns a message for a failed deployment' do + data = { + status: 'failed', + environment: 'production' + } + + message = described_class.new(data) + + expect(message.pretext).to eq('Deploy to production failed') + end + + it 'returns a message for a canceled deployment' do + data = { + status: 'canceled', + environment: 'production' + } + + message = described_class.new(data) + + expect(message.pretext).to eq('Deploy to production canceled') + end + + it 'returns a message for a deployment to another environment' do + data = { + status: 'success', + environment: 'staging' + } + + message = described_class.new(data) + + expect(message.pretext).to eq('Deploy to staging succeeded') + end + + it 'returns a message for a deployment with any other status' do + data = { + status: 'unknown', + environment: 'staging' + } + + message = described_class.new(data) + + expect(message.pretext).to eq('Deploy to staging unknown') + end + end + + describe '#attachments' do + def deployment_data(params) + { + object_kind: "deployment", + status: "success", + deployable_id: 3, + deployable_url: "deployable_url", + environment: "sandbox", + project: { + name: "greatproject", + web_url: "project_web_url", + path_with_namespace: "project_path_with_namespace" + }, + user: { + name: "Jane Person", + username: "jane" + }, + short_sha: "12345678", + commit_url: "commit_url" + }.merge(params) + end + + it 'returns attachments with the data returned by the deployment data builder' do + user = create(:user, name: "John Smith", username: "smith") + namespace = create(:namespace, name: "myspace") + project = create(:project, :repository, namespace: namespace, name: "myproject") + commit = project.commit('HEAD') + environment = create(:environment, name: "myenvironment", project: project) + ci_build = create(:ci_build, project: project) + deployment = create(:deployment, :success, deployable: ci_build, environment: environment, project: project, user: user, sha: commit.sha) + job_url = Gitlab::Routing.url_helpers.project_job_url(project, ci_build) + commit_url = Gitlab::UrlBuilder.build(deployment.commit) + data = Gitlab::DataBuilder::Deployment.build(deployment) + + message = described_class.new(data) + + expect(message.attachments).to eq([{ + text: "[myspace/myproject](#{project.web_url})\n[Job ##{ci_build.id}](#{job_url}), SHA [#{deployment.short_sha}](#{commit_url}), by John Smith (smith)", + color: "good" + }]) + end + + it 'returns attachments for a failed deployment' do + data = deployment_data(status: 'failed') + + message = described_class.new(data) + + expect(message.attachments).to eq([{ + text: "[project_path_with_namespace](project_web_url)\n[Job #3](deployable_url), SHA [12345678](commit_url), by Jane Person (jane)", + color: "danger" + }]) + end + + it 'returns attachments for a canceled deployment' do + data = deployment_data(status: 'canceled') + + message = described_class.new(data) + + expect(message.attachments).to eq([{ + text: "[project_path_with_namespace](project_web_url)\n[Job #3](deployable_url), SHA [12345678](commit_url), by Jane Person (jane)", + color: "warning" + }]) + end + + it 'uses a neutral color for a deployment with any other status' do + data = deployment_data(status: 'some-new-status-we-make-in-the-future') + + message = described_class.new(data) + + expect(message.attachments).to eq([{ + text: "[project_path_with_namespace](project_web_url)\n[Job #3](deployable_url), SHA [12345678](commit_url), by Jane Person (jane)", + color: "#334455" + }]) + end + end +end diff --git a/spec/models/project_services/hipchat_service_spec.rb b/spec/models/project_services/hipchat_service_spec.rb index fd9e33c1781..a04b984c1f6 100644 --- a/spec/models/project_services/hipchat_service_spec.rb +++ b/spec/models/project_services/hipchat_service_spec.rb @@ -98,12 +98,11 @@ describe HipchatService do context 'tag_push events' do let(:push_sample_data) do Gitlab::DataBuilder::Push.build( - project, - user, - Gitlab::Git::BLANK_SHA, - '1' * 40, - 'refs/tags/test', - []) + project: project, + user: user, + oldrev: Gitlab::Git::BLANK_SHA, + newrev: '1' * 40, + ref: 'refs/tags/test') end it "calls Hipchat API for tag push events" do diff --git a/spec/models/project_services/microsoft_teams_service_spec.rb b/spec/models/project_services/microsoft_teams_service_spec.rb index 521d5265753..c025d7c882e 100644 --- a/spec/models/project_services/microsoft_teams_service_spec.rb +++ b/spec/models/project_services/microsoft_teams_service_spec.rb @@ -30,6 +30,12 @@ describe MicrosoftTeamsService do end end + describe '.supported_events' do + it 'does not support deployment_events' do + expect(described_class.supported_events).not_to include('deployment') + end + end + describe "#execute" do let(:user) { create(:user) } set(:project) { create(:project, :repository, :wiki_repo) } diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb index 2525a6aebe0..d12dd97bb9e 100644 --- a/spec/models/project_wiki_spec.rb +++ b/spec/models/project_wiki_spec.rb @@ -109,8 +109,7 @@ describe ProjectWiki do subject { super().empty? } it { is_expected.to be_falsey } - # Re-enable this when https://gitlab.com/gitlab-org/gitaly/issues/1204 is fixed - xit 'only instantiates a Wiki page once' do + it 'only instantiates a Wiki page once' do expect(WikiPage).to receive(:new).once.and_call_original subject @@ -119,22 +118,65 @@ describe ProjectWiki do end end - describe "#pages" do + describe "#list_pages" do + let(:wiki_pages) { subject.list_pages } + before do - create_page("index", "This is an awesome new Gollum Wiki") - @pages = subject.pages + create_page("index", "This is an index") + create_page("index2", "This is an index2") + create_page("an index3", "This is an index3") end after do - destroy_page(@pages.first.page) + wiki_pages.each do |wiki_page| + destroy_page(wiki_page.page) + end end it "returns an array of WikiPage instances" do - expect(@pages.first).to be_a WikiPage + expect(wiki_pages.first).to be_a WikiPage + end + + it 'does not load WikiPage content by default' do + wiki_pages.each do |page| + expect(page.content).to be_empty + end + end + + it 'returns all pages by default' do + expect(wiki_pages.count).to eq(3) + end + + context "with limit option" do + it 'returns limited set of pages' do + expect(subject.list_pages(limit: 1).count).to eq(1) + end end - it "returns the correct number of pages" do - expect(@pages.count).to eq(1) + context "with sorting options" do + it 'returns pages sorted by title by default' do + pages = ['an index3', 'index', 'index2'] + + expect(subject.list_pages.map(&:title)).to eq(pages) + expect(subject.list_pages(direction: "desc").map(&:title)).to eq(pages.reverse) + end + + it 'returns pages sorted by created_at' do + pages = ['index', 'index2', 'an index3'] + + expect(subject.list_pages(sort: 'created_at').map(&:title)).to eq(pages) + expect(subject.list_pages(sort: 'created_at', direction: "desc").map(&:title)).to eq(pages.reverse) + end + end + + context "with load_content option" do + let(:pages) { subject.list_pages(load_content: true) } + + it 'loads WikiPage content' do + expect(pages.first.content).to eq("This is an index3") + expect(pages.second.content).to eq("This is an index") + expect(pages.third.content).to eq("This is an index2") + end end end @@ -144,7 +186,7 @@ describe ProjectWiki do end after do - subject.pages.each { |page| destroy_page(page.page) } + subject.list_pages.each { |page| destroy_page(page.page) } end it "returns the latest version of the page if it exists" do @@ -195,7 +237,7 @@ describe ProjectWiki do end after do - subject.pages.each { |page| destroy_page(page.page) } + subject.list_pages.each { |page| destroy_page(page.page) } end it 'finds the page defined as _sidebar' do @@ -242,12 +284,12 @@ describe ProjectWiki do describe "#create_page" do after do - destroy_page(subject.pages.first.page) + destroy_page(subject.list_pages.first.page) end it "creates a new wiki page" do expect(subject.create_page("test page", "this is content")).not_to eq(false) - expect(subject.pages.count).to eq(1) + expect(subject.list_pages.count).to eq(1) end it "returns false when a duplicate page exists" do @@ -262,7 +304,7 @@ describe ProjectWiki do it "sets the correct commit message" do subject.create_page("test page", "some content", :markdown, "commit message") - expect(subject.pages.first.page.version.message).to eq("commit message") + expect(subject.list_pages.first.page.version.message).to eq("commit message") end it 'sets the correct commit email' do @@ -293,7 +335,7 @@ describe ProjectWiki do format: :markdown, message: "updated page" ) - @page = subject.pages.first.page + @page = subject.list_pages(load_content: true).first.page end after do @@ -337,7 +379,7 @@ describe ProjectWiki do it "deletes the page" do subject.delete_page(@page) - expect(subject.pages.count).to eq(0) + expect(subject.list_pages.count).to eq(0) end it 'sets the correct commit email' do diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 3f5d285bc2c..43ec1125087 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -217,6 +217,25 @@ describe Repository do expect(result.size).to eq(0) end + + context 'with a commit with invalid UTF-8 path' do + def create_commit_with_invalid_utf8_path + rugged = rugged_repo(repository) + blob_id = Rugged::Blob.from_buffer(rugged, "some contents") + tree_builder = Rugged::Tree::Builder.new(rugged) + tree_builder.insert({ oid: blob_id, name: "hello\x80world", filemode: 0100644 }) + tree_id = tree_builder.write + user = { email: "jcai@gitlab.com", time: Time.now, name: "John Cai" } + + Rugged::Commit.create(rugged, message: 'some commit message', parents: [rugged.head.target.oid], tree: tree_id, committer: user, author: user) + end + + it 'does not raise an error' do + commit = create_commit_with_invalid_utf8_path + + expect { repository.list_last_commits_for_tree(commit, '.', offset: 0) }.not_to raise_error + end + end end describe '#last_commit_for_path' do @@ -2468,4 +2487,69 @@ describe Repository do repository.merge_base('master', 'fix') end end + + describe '#create_if_not_exists' do + let(:project) { create(:project) } + let(:repository) { project.repository } + + it 'creates the repository if it did not exist' do + expect { repository.create_if_not_exists }.to change { repository.exists? }.from(false).to(true) + end + + it 'calls out to the repository client to create a repo' do + expect(repository.raw.gitaly_repository_client).to receive(:create_repository) + + repository.create_if_not_exists + end + + context 'it does nothing if the repository already existed' do + let(:project) { create(:project, :repository) } + + it 'does nothing if the repository already existed' do + expect(repository.raw.gitaly_repository_client).not_to receive(:create_repository) + + repository.create_if_not_exists + end + end + + context 'when the repository exists but the cache is not up to date' do + let(:project) { create(:project, :repository) } + + it 'does not raise errors' do + allow(repository).to receive(:exists?).and_return(false) + expect(repository.raw).to receive(:create_repository).and_call_original + + expect { repository.create_if_not_exists }.not_to raise_error + end + end + end + + describe "#blobs_metadata" do + set(:project) { create(:project, :repository) } + let(:repository) { project.repository } + + def expect_metadata_blob(thing) + expect(thing).to be_a(Blob) + expect(thing.data).to be_empty + end + + it "returns blob metadata in batch for HEAD" do + result = repository.blobs_metadata(["bar/branch-test.txt", "README.md", "does/not/exist"]) + + expect_metadata_blob(result.first) + expect_metadata_blob(result.second) + expect(result.size).to eq(2) + end + + it "returns blob metadata for a specified ref" do + result = repository.blobs_metadata(["files/ruby/feature.rb"], "feature") + + expect_metadata_blob(result.first) + end + + it "performs a single gitaly call", :request_store do + expect { repository.blobs_metadata(["bar/branch-test.txt", "readme.txt", "does/not/exist"]) } + .to change { Gitlab::GitalyClient.get_request_count }.by(1) + end + end end diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb index d5c85c11195..520a06e138e 100644 --- a/spec/models/wiki_page_spec.rb +++ b/spec/models/wiki_page_spec.rb @@ -44,47 +44,49 @@ describe WikiPage do WikiDirectory.new('dir_2', pages) end - context 'sort by title' do - let(:grouped_entries) { described_class.group_by_directory(wiki.pages) } - let(:expected_grouped_entries) { [dir_1_1, dir_1, page_dir_2, dir_2, page_1, page_6] } - - it 'returns an array with pages and directories' do - grouped_entries.each_with_index do |page_or_dir, i| - expected_page_or_dir = expected_grouped_entries[i] - expected_slugs = get_slugs(expected_page_or_dir) - slugs = get_slugs(page_or_dir) - - expect(slugs).to match_array(expected_slugs) + context "#list_pages" do + context 'sort by title' do + let(:grouped_entries) { described_class.group_by_directory(wiki.list_pages) } + let(:expected_grouped_entries) { [dir_1_1, dir_1, page_dir_2, dir_2, page_1, page_6] } + + it 'returns an array with pages and directories' do + grouped_entries.each_with_index do |page_or_dir, i| + expected_page_or_dir = expected_grouped_entries[i] + expected_slugs = get_slugs(expected_page_or_dir) + slugs = get_slugs(page_or_dir) + + expect(slugs).to match_array(expected_slugs) + end end end - end - context 'sort by created_at' do - let(:grouped_entries) { described_class.group_by_directory(wiki.pages(sort: 'created_at')) } - let(:expected_grouped_entries) { [dir_1_1, page_1, dir_1, page_dir_2, dir_2, page_6] } + context 'sort by created_at' do + let(:grouped_entries) { described_class.group_by_directory(wiki.list_pages(sort: 'created_at')) } + let(:expected_grouped_entries) { [dir_1_1, page_1, dir_1, page_dir_2, dir_2, page_6] } - it 'returns an array with pages and directories' do - grouped_entries.each_with_index do |page_or_dir, i| - expected_page_or_dir = expected_grouped_entries[i] - expected_slugs = get_slugs(expected_page_or_dir) - slugs = get_slugs(page_or_dir) + it 'returns an array with pages and directories' do + grouped_entries.each_with_index do |page_or_dir, i| + expected_page_or_dir = expected_grouped_entries[i] + expected_slugs = get_slugs(expected_page_or_dir) + slugs = get_slugs(page_or_dir) - expect(slugs).to match_array(expected_slugs) + expect(slugs).to match_array(expected_slugs) + end end end - end - it 'returns an array with retained order with directories at the top' do - expected_order = ['dir_1/dir_1_1/page_3', 'dir_1/page_2', 'dir_2', 'dir_2/page_4', 'dir_2/page_5', 'page_1', 'page_6'] + it 'returns an array with retained order with directories at the top' do + expected_order = ['dir_1/dir_1_1/page_3', 'dir_1/page_2', 'dir_2', 'dir_2/page_4', 'dir_2/page_5', 'page_1', 'page_6'] - grouped_entries = described_class.group_by_directory(wiki.pages) + grouped_entries = described_class.group_by_directory(wiki.list_pages) - actual_order = - grouped_entries.map do |page_or_dir| - get_slugs(page_or_dir) - end - .flatten - expect(actual_order).to eq(expected_order) + actual_order = + grouped_entries.map do |page_or_dir| + get_slugs(page_or_dir) + end + .flatten + expect(actual_order).to eq(expected_order) + end end end end @@ -386,7 +388,7 @@ describe WikiPage do it "deletes the page" do @page.delete - expect(wiki.pages).to be_empty + expect(wiki.list_pages).to be_empty end it "returns true" do diff --git a/spec/presenters/merge_request_presenter_spec.rb b/spec/presenters/merge_request_presenter_spec.rb index e5f08aeb1fa..451dc88880c 100644 --- a/spec/presenters/merge_request_presenter_spec.rb +++ b/spec/presenters/merge_request_presenter_spec.rb @@ -439,6 +439,52 @@ describe MergeRequestPresenter do end end + describe '#source_branch_link' do + subject { presenter.source_branch_link } + + let(:presenter) { described_class.new(resource, current_user: user) } + + context 'when source branch exists' do + it 'returns link' do + allow(resource).to receive(:source_branch_exists?) { true } + + is_expected + .to eq("<a class=\"ref-name\" href=\"#{presenter.source_branch_commits_path}\">#{presenter.source_branch}</a>") + end + end + + context 'when source branch does not exist' do + it 'returns text' do + allow(resource).to receive(:source_branch_exists?) { false } + + is_expected.to eq("<span class=\"ref-name\">#{presenter.source_branch}</span>") + end + end + end + + describe '#target_branch_link' do + subject { presenter.target_branch_link } + + let(:presenter) { described_class.new(resource, current_user: user) } + + context 'when target branch exists' do + it 'returns link' do + allow(resource).to receive(:target_branch_exists?) { true } + + is_expected + .to eq("<a class=\"ref-name\" href=\"#{presenter.target_branch_commits_path}\">#{presenter.target_branch}</a>") + end + end + + context 'when target branch does not exist' do + it 'returns text' do + allow(resource).to receive(:target_branch_exists?) { false } + + is_expected.to eq("<span class=\"ref-name\">#{presenter.target_branch}</span>") + end + end + end + describe '#source_branch_with_namespace_link' do subject do described_class.new(resource, current_user: user).source_branch_with_namespace_link diff --git a/spec/requests/api/events_spec.rb b/spec/requests/api/events_spec.rb index 065b16c6221..018691e8099 100644 --- a/spec/requests/api/events_spec.rb +++ b/spec/requests/api/events_spec.rb @@ -164,139 +164,4 @@ describe API::Events do expect(json_response['message']).to eq('404 User Not Found') end end - - describe 'GET /projects/:id/events' do - context 'when unauthenticated ' do - it 'returns 404 for private project' do - get api("/projects/#{private_project.id}/events") - - expect(response).to have_gitlab_http_status(404) - end - - it 'returns 200 status for a public project' do - public_project = create(:project, :public) - - get api("/projects/#{public_project.id}/events") - - expect(response).to have_gitlab_http_status(200) - end - end - - context 'with inaccessible events' do - let(:public_project) { create(:project, :public, creator_id: user.id, namespace: user.namespace) } - let(:confidential_issue) { create(:closed_issue, confidential: true, project: public_project, author: user) } - let!(:confidential_event) { create(:event, project: public_project, author: user, target: confidential_issue, action: Event::CLOSED) } - let(:public_issue) { create(:closed_issue, project: public_project, author: user) } - let!(:public_event) { create(:event, project: public_project, author: user, target: public_issue, action: Event::CLOSED) } - - it 'returns only accessible events' do - get api("/projects/#{public_project.id}/events", non_member) - - expect(response).to have_gitlab_http_status(200) - expect(json_response.size).to eq(1) - end - - it 'returns all events when the user has access' do - get api("/projects/#{public_project.id}/events", user) - - expect(response).to have_gitlab_http_status(200) - expect(json_response.size).to eq(2) - end - end - - context 'pagination' do - let(:public_project) { create(:project, :public) } - - before do - create(:event, - project: public_project, - target: create(:issue, project: public_project, title: 'Issue 1'), - action: Event::CLOSED, - created_at: Date.parse('2018-12-10')) - create(:event, - project: public_project, - target: create(:issue, confidential: true, project: public_project, title: 'Confidential event'), - action: Event::CLOSED, - created_at: Date.parse('2018-12-11')) - create(:event, - project: public_project, - target: create(:issue, project: public_project, title: 'Issue 2'), - action: Event::CLOSED, - created_at: Date.parse('2018-12-12')) - end - - it 'correctly returns the second page without inaccessible events' do - get api("/projects/#{public_project.id}/events", user), params: { per_page: 2, page: 2 } - - titles = json_response.map { |event| event['target_title'] } - - expect(titles.first).to eq('Issue 1') - expect(titles).not_to include('Confidential event') - end - - it 'correctly returns the first page without inaccessible events' do - get api("/projects/#{public_project.id}/events", user), params: { per_page: 2, page: 1 } - - titles = json_response.map { |event| event['target_title'] } - - expect(titles.first).to eq('Issue 2') - expect(titles).not_to include('Confidential event') - end - end - - context 'when not permitted to read' do - it 'returns 404' do - get api("/projects/#{private_project.id}/events", non_member) - - expect(response).to have_gitlab_http_status(404) - end - end - - context 'when authenticated' do - it 'returns project events' do - get api("/projects/#{private_project.id}/events?action=closed&target_type=issue&after=2016-12-1&before=2016-12-31", user) - - expect(response).to have_gitlab_http_status(200) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - expect(json_response.size).to eq(1) - end - - it 'returns 404 if project does not exist' do - get api("/projects/1234/events", user) - - expect(response).to have_gitlab_http_status(404) - end - end - - context 'when exists some events' do - let(:merge_request1) { create(:merge_request, :closed, author: user, assignees: [user], source_project: private_project, title: 'Test') } - let(:merge_request2) { create(:merge_request, :closed, author: user, assignees: [user], source_project: private_project, title: 'Test') } - - before do - create_event(merge_request1) - end - - it 'avoids N+1 queries' do - control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do - get api("/projects/#{private_project.id}/events", user), params: { target_type: :merge_request } - end.count - - create_event(merge_request2) - - expect do - get api("/projects/#{private_project.id}/events", user), params: { target_type: :merge_request } - end.not_to exceed_all_query_limit(control_count) - - expect(response).to have_gitlab_http_status(200) - expect(response).to include_pagination_headers - expect(json_response.size).to eq(2) - expect(json_response.map { |r| r['target_id'] }).to match_array([merge_request1.id, merge_request2.id]) - end - - def create_event(target) - create(:event, project: private_project, author: user, target: target) - end - end - end end diff --git a/spec/requests/api/project_events_spec.rb b/spec/requests/api/project_events_spec.rb new file mode 100644 index 00000000000..43df9993eb9 --- /dev/null +++ b/spec/requests/api/project_events_spec.rb @@ -0,0 +1,156 @@ +require 'spec_helper' + +describe API::ProjectEvents do + include ApiHelpers + + let(:user) { create(:user) } + let(:non_member) { create(:user) } + let(:private_project) { create(:project, :private, creator_id: user.id, namespace: user.namespace) } + let(:closed_issue) { create(:closed_issue, project: private_project, author: user) } + let!(:closed_issue_event) { create(:event, project: private_project, author: user, target: closed_issue, action: Event::CLOSED, created_at: Date.new(2016, 12, 30)) } + + describe 'GET /projects/:id/events' do + context 'when unauthenticated ' do + it 'returns 404 for private project' do + get api("/projects/#{private_project.id}/events") + + expect(response).to have_gitlab_http_status(404) + end + + it 'returns 200 status for a public project' do + public_project = create(:project, :public) + + get api("/projects/#{public_project.id}/events") + + expect(response).to have_gitlab_http_status(200) + end + end + + context 'with inaccessible events' do + let(:public_project) { create(:project, :public, creator_id: user.id, namespace: user.namespace) } + let(:confidential_issue) { create(:closed_issue, confidential: true, project: public_project, author: user) } + let!(:confidential_event) { create(:event, project: public_project, author: user, target: confidential_issue, action: Event::CLOSED) } + let(:public_issue) { create(:closed_issue, project: public_project, author: user) } + let!(:public_event) { create(:event, project: public_project, author: user, target: public_issue, action: Event::CLOSED) } + + it 'returns only accessible events' do + get api("/projects/#{public_project.id}/events", non_member) + + expect(response).to have_gitlab_http_status(200) + expect(json_response.size).to eq(1) + end + + it 'returns all events when the user has access' do + get api("/projects/#{public_project.id}/events", user) + + expect(response).to have_gitlab_http_status(200) + expect(json_response.size).to eq(2) + end + end + + context 'pagination' do + let(:public_project) { create(:project, :public) } + + before do + create(:event, + project: public_project, + target: create(:issue, project: public_project, title: 'Issue 1'), + action: Event::CLOSED, + created_at: Date.parse('2018-12-10')) + create(:event, + project: public_project, + target: create(:issue, confidential: true, project: public_project, title: 'Confidential event'), + action: Event::CLOSED, + created_at: Date.parse('2018-12-11')) + create(:event, + project: public_project, + target: create(:issue, project: public_project, title: 'Issue 2'), + action: Event::CLOSED, + created_at: Date.parse('2018-12-12')) + end + + it 'correctly returns the second page without inaccessible events' do + get api("/projects/#{public_project.id}/events", user), params: { per_page: 2, page: 2 } + + titles = json_response.map { |event| event['target_title'] } + + expect(titles.first).to eq('Issue 1') + expect(titles).not_to include('Confidential event') + end + + it 'correctly returns the first page without inaccessible events' do + get api("/projects/#{public_project.id}/events", user), params: { per_page: 2, page: 1 } + + titles = json_response.map { |event| event['target_title'] } + + expect(titles.first).to eq('Issue 2') + expect(titles).not_to include('Confidential event') + end + end + + context 'when not permitted to read' do + it 'returns 404' do + get api("/projects/#{private_project.id}/events", non_member) + + expect(response).to have_gitlab_http_status(404) + end + end + + context 'when authenticated' do + it 'returns project events' do + get api("/projects/#{private_project.id}/events?action=closed&target_type=issue&after=2016-12-1&before=2016-12-31", user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + end + + it 'returns 404 if project does not exist' do + get api("/projects/1234/events", user) + + expect(response).to have_gitlab_http_status(404) + end + + context 'when the requesting token does not have "api" scope' do + let(:token) { create(:personal_access_token, scopes: ['read_user'], user: user) } + + it 'returns a "403" response' do + get api("/projects/#{private_project.id}/events", personal_access_token: token) + + expect(response).to have_gitlab_http_status(403) + end + end + end + + context 'when exists some events' do + let(:merge_request1) { create(:merge_request, :closed, author: user, assignees: [user], source_project: private_project, title: 'Test') } + let(:merge_request2) { create(:merge_request, :closed, author: user, assignees: [user], source_project: private_project, title: 'Test') } + + before do + create_event(merge_request1) + end + + it 'avoids N+1 queries' do + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do + get api("/projects/#{private_project.id}/events", user), params: { target_type: :merge_request } + end.count + + create_event(merge_request2) + + expect do + get api("/projects/#{private_project.id}/events", user), params: { target_type: :merge_request } + end.not_to exceed_all_query_limit(control_count) + + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response.size).to eq(2) + expect(json_response.map { |r| r['target_id'] }).to match_array([merge_request1.id, merge_request2.id]) + end + + def create_event(target) + create(:event, project: private_project, author: user, target: target) + end + end + end +end diff --git a/spec/rubocop/cop/include_action_view_context_spec.rb b/spec/rubocop/cop/include_action_view_context_spec.rb new file mode 100644 index 00000000000..c888555b54f --- /dev/null +++ b/spec/rubocop/cop/include_action_view_context_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require 'rubocop' +require 'rubocop/rspec/support' + +require_relative '../../../rubocop/cop/include_action_view_context' + +describe RuboCop::Cop::IncludeActionViewContext do + include CopHelper + + subject(:cop) { described_class.new } + + context 'when `ActionView::Context` is included' do + let(:source) { 'include ActionView::Context' } + let(:correct_source) { 'include ::Gitlab::ActionViewOutput::Context' } + + it 'registers an offense' do + inspect_source(source) + + aggregate_failures do + expect(cop.offenses.size).to eq(1) + expect(cop.offenses.map(&:line)).to eq([1]) + expect(cop.highlights).to eq(['ActionView::Context']) + end + end + + it 'autocorrects to the right version' do + autocorrected = autocorrect_source(source) + + expect(autocorrected).to eq(correct_source) + end + end + + context 'when `ActionView::Context` is not included' do + it 'registers no offense' do + inspect_source('include Context') + + aggregate_failures do + expect(cop.offenses.size).to eq(0) + end + end + end +end diff --git a/spec/serializers/cluster_application_entity_spec.rb b/spec/serializers/cluster_application_entity_spec.rb index 7e151c3744e..f38a18fcf59 100644 --- a/spec/serializers/cluster_application_entity_spec.rb +++ b/spec/serializers/cluster_application_entity_spec.rb @@ -21,6 +21,10 @@ describe ClusterApplicationEntity do expect(subject[:status_reason]).to be_nil end + it 'has can_uninstall' do + expect(subject[:can_uninstall]).to be_falsey + end + context 'non-helm application' do let(:application) { build(:clusters_applications_runner, version: '0.0.0') } diff --git a/spec/services/clusters/applications/check_installation_progress_service_spec.rb b/spec/services/clusters/applications/check_installation_progress_service_spec.rb index 8ad90aaf720..a54bd85a11a 100644 --- a/spec/services/clusters/applications/check_installation_progress_service_spec.rb +++ b/spec/services/clusters/applications/check_installation_progress_service_spec.rb @@ -18,7 +18,7 @@ describe Clusters::Applications::CheckInstallationProgressService, '#execute' do end context "when phase is #{a_phase}" do - context 'when not timeouted' do + context 'when not timed_out' do it 'reschedule a new check' do expect(ClusterWaitForAppInstallationWorker).to receive(:perform_in).once expect(service).not_to receive(:remove_installation_pod) @@ -113,7 +113,7 @@ describe Clusters::Applications::CheckInstallationProgressService, '#execute' do end context 'when timed out' do - let(:application) { create(:clusters_applications_helm, :timeouted, :updating) } + let(:application) { create(:clusters_applications_helm, :timed_out, :updating) } before do expect(service).to receive(:installation_phase).once.and_return(phase) @@ -174,7 +174,7 @@ describe Clusters::Applications::CheckInstallationProgressService, '#execute' do end context 'when timed out' do - let(:application) { create(:clusters_applications_helm, :timeouted) } + let(:application) { create(:clusters_applications_helm, :timed_out) } before do expect(service).to receive(:installation_phase).once.and_return(phase) diff --git a/spec/services/clusters/applications/check_uninstall_progress_service_spec.rb b/spec/services/clusters/applications/check_uninstall_progress_service_spec.rb new file mode 100644 index 00000000000..9ab83d913f5 --- /dev/null +++ b/spec/services/clusters/applications/check_uninstall_progress_service_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::Applications::CheckUninstallProgressService do + RESCHEDULE_PHASES = Gitlab::Kubernetes::Pod::PHASES - [Gitlab::Kubernetes::Pod::SUCCEEDED, Gitlab::Kubernetes::Pod::FAILED].freeze + + let(:application) { create(:clusters_applications_prometheus, :uninstalling) } + let(:service) { described_class.new(application) } + let(:phase) { Gitlab::Kubernetes::Pod::UNKNOWN } + let(:errors) { nil } + let(:worker_class) { Clusters::Applications::WaitForUninstallAppWorker } + + before do + allow(service).to receive(:installation_errors).and_return(errors) + allow(service).to receive(:remove_installation_pod) + end + + shared_examples 'a not yet terminated installation' do |a_phase| + let(:phase) { a_phase } + + before do + expect(service).to receive(:installation_phase).once.and_return(phase) + end + + context "when phase is #{a_phase}" do + context 'when not timed_out' do + it 'reschedule a new check' do + expect(worker_class).to receive(:perform_in).once + expect(service).not_to receive(:remove_installation_pod) + + expect do + service.execute + + application.reload + end.not_to change(application, :status) + + expect(application.status_reason).to be_nil + end + end + end + end + + context 'when application is installing' do + RESCHEDULE_PHASES.each { |phase| it_behaves_like 'a not yet terminated installation', phase } + + context 'when installation POD succeeded' do + let(:phase) { Gitlab::Kubernetes::Pod::SUCCEEDED } + before do + expect(service).to receive(:installation_phase).once.and_return(phase) + end + + it 'removes the installation POD' do + expect(service).to receive(:remove_installation_pod).once + + service.execute + end + + it 'destroys the application' do + expect(worker_class).not_to receive(:perform_in) + + service.execute + + expect(application).to be_destroyed + end + + context 'an error occurs while destroying' do + before do + expect(application).to receive(:destroy!).once.and_raise("destroy failed") + end + + it 'still removes the installation POD' do + expect(service).to receive(:remove_installation_pod).once + + service.execute + end + + it 'makes the application uninstall_errored' do + service.execute + + expect(application).to be_uninstall_errored + expect(application.status_reason).to eq('Application uninstalled but failed to destroy: destroy failed') + end + end + end + + context 'when installation POD failed' do + let(:phase) { Gitlab::Kubernetes::Pod::FAILED } + let(:errors) { 'test installation failed' } + + before do + expect(service).to receive(:installation_phase).once.and_return(phase) + end + + it 'make the application errored' do + service.execute + + expect(application).to be_uninstall_errored + expect(application.status_reason).to eq('Operation failed. Check pod logs for uninstall-prometheus for more details.') + end + end + + context 'when timed out' do + let(:application) { create(:clusters_applications_prometheus, :timed_out, :uninstalling) } + + before do + expect(service).to receive(:installation_phase).once.and_return(phase) + end + + it 'make the application errored' do + expect(worker_class).not_to receive(:perform_in) + + service.execute + + expect(application).to be_uninstall_errored + expect(application.status_reason).to eq('Operation timed out. Check pod logs for uninstall-prometheus for more details.') + end + end + + context 'when installation raises a Kubeclient::HttpError' do + let(:cluster) { create(:cluster, :provided_by_user, :project) } + let(:logger) { service.send(:logger) } + let(:error) { Kubeclient::HttpError.new(401, 'Unauthorized', nil) } + + before do + application.update!(cluster: cluster) + + expect(service).to receive(:installation_phase).and_raise(error) + end + + include_examples 'logs kubernetes errors' do + let(:error_name) { 'Kubeclient::HttpError' } + let(:error_message) { 'Unauthorized' } + let(:error_code) { 401 } + end + + it 'shows the response code from the error' do + service.execute + + expect(application).to be_uninstall_errored + expect(application.status_reason).to eq('Kubernetes error: 401') + end + end + end +end diff --git a/spec/services/clusters/applications/destroy_service_spec.rb b/spec/services/clusters/applications/destroy_service_spec.rb new file mode 100644 index 00000000000..8d9dc6a0f11 --- /dev/null +++ b/spec/services/clusters/applications/destroy_service_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::Applications::DestroyService, '#execute' do + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:user) { create(:user) } + let(:params) { { application: 'prometheus' } } + let(:service) { described_class.new(cluster, user, params) } + let(:test_request) { double } + let(:worker_class) { Clusters::Applications::UninstallWorker } + + subject { service.execute(test_request) } + + before do + allow(worker_class).to receive(:perform_async) + end + + context 'application is not installed' do + it 'raises Clusters::Applications::BaseService::InvalidApplicationError' do + expect(worker_class).not_to receive(:perform_async) + + expect { subject } + .to raise_exception { Clusters::Applications::BaseService::InvalidApplicationError } + .and not_change { Clusters::Applications::Prometheus.count } + .and not_change { Clusters::Applications::Prometheus.with_status(:scheduled).count } + end + end + + context 'application is installed' do + context 'application is schedulable' do + let!(:application) do + create(:clusters_applications_prometheus, :installed, cluster: cluster) + end + + it 'makes application scheduled!' do + subject + + expect(application.reload).to be_scheduled + end + + it 'schedules UninstallWorker' do + expect(worker_class).to receive(:perform_async).with(application.name, application.id) + + subject + end + end + + context 'application is not schedulable' do + let!(:application) do + create(:clusters_applications_prometheus, :updating, cluster: cluster) + end + + it 'raises StateMachines::InvalidTransition' do + expect(worker_class).not_to receive(:perform_async) + + expect { subject } + .to raise_exception { StateMachines::InvalidTransition } + .and not_change { Clusters::Applications::Prometheus.with_status(:scheduled).count } + end + end + end +end diff --git a/spec/services/clusters/applications/uninstall_service_spec.rb b/spec/services/clusters/applications/uninstall_service_spec.rb new file mode 100644 index 00000000000..16497d752b2 --- /dev/null +++ b/spec/services/clusters/applications/uninstall_service_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::Applications::UninstallService, '#execute' do + let(:application) { create(:clusters_applications_prometheus, :scheduled) } + let(:service) { described_class.new(application) } + let(:helm_client) { instance_double(Gitlab::Kubernetes::Helm::Api) } + let(:worker_class) { Clusters::Applications::WaitForUninstallAppWorker } + + before do + allow(service).to receive(:helm_api).and_return(helm_client) + end + + context 'when there are no errors' do + before do + expect(helm_client).to receive(:uninstall).with(kind_of(Gitlab::Kubernetes::Helm::DeleteCommand)) + allow(worker_class).to receive(:perform_in).and_return(nil) + end + + it 'make the application to be uninstalling' do + expect(application.cluster).not_to be_nil + service.execute + + expect(application).to be_uninstalling + end + + it 'schedule async installation status check' do + expect(worker_class).to receive(:perform_in).once + + service.execute + end + end + + context 'when k8s cluster communication fails' do + let(:error) { Kubeclient::HttpError.new(500, 'system failure', nil) } + + before do + expect(helm_client).to receive(:uninstall).with(kind_of(Gitlab::Kubernetes::Helm::DeleteCommand)).and_raise(error) + end + + include_examples 'logs kubernetes errors' do + let(:error_name) { 'Kubeclient::HttpError' } + let(:error_message) { 'system failure' } + let(:error_code) { 500 } + end + + it 'make the application errored' do + service.execute + + expect(application).to be_uninstall_errored + expect(application.status_reason).to match('Kubernetes error: 500') + end + end + + context 'a non kubernetes error happens' do + let(:application) { create(:clusters_applications_prometheus, :scheduled) } + let(:error) { StandardError.new('something bad happened') } + + before do + expect(helm_client).to receive(:uninstall).with(kind_of(Gitlab::Kubernetes::Helm::DeleteCommand)).and_raise(error) + end + + include_examples 'logs kubernetes errors' do + let(:error_name) { 'StandardError' } + let(:error_message) { 'something bad happened' } + let(:error_code) { nil } + end + + it 'make the application errored' do + service.execute + + expect(application).to be_uninstall_errored + expect(application.status_reason).to eq('Failed to uninstall.') + end + end +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 24d09c1fd00..0ac23050caf 100644 --- a/spec/services/merge_requests/merge_to_ref_service_spec.rb +++ b/spec/services/merge_requests/merge_to_ref_service_spec.rb @@ -104,7 +104,7 @@ describe MergeRequests::MergeToRefService do it_behaves_like 'MergeService for target ref' end - context 'when merge commit with squash' do + context 'when merge commit with squash', :quarantine do before do merge_request.update!(squash: true, source_branch: 'master', target_branch: 'feature') end diff --git a/spec/services/projects/import_service_spec.rb b/spec/services/projects/import_service_spec.rb index 7f233a52f50..d9f9ede8ecd 100644 --- a/spec/services/projects/import_service_spec.rb +++ b/spec/services/projects/import_service_spec.rb @@ -5,15 +5,11 @@ require 'spec_helper' describe Projects::ImportService do let!(:project) { create(:project) } let(:user) { project.creator } - let(:import_url) { 'http://www.gitlab.com/demo/repo.git' } - let(:oid_download_links) { { 'oid1' => "#{import_url}/gitlab-lfs/objects/oid1", 'oid2' => "#{import_url}/gitlab-lfs/objects/oid2" } } subject { described_class.new(project, user) } before do allow(project).to receive(:lfs_enabled?).and_return(true) - allow_any_instance_of(Projects::LfsPointers::LfsDownloadService).to receive(:execute) - allow_any_instance_of(Projects::LfsPointers::LfsImportService).to receive(:execute).and_return(oid_download_links) end describe '#async?' do @@ -77,7 +73,6 @@ describe Projects::ImportService do context 'when repository creation succeeds' do it 'does not download lfs files' do expect_any_instance_of(Projects::LfsPointers::LfsImportService).not_to receive(:execute) - expect_any_instance_of(Projects::LfsPointers::LfsDownloadService).not_to receive(:execute) subject.execute end @@ -114,7 +109,6 @@ describe Projects::ImportService do context 'when repository import scheduled' do it 'does not download lfs objects' do expect_any_instance_of(Projects::LfsPointers::LfsImportService).not_to receive(:execute) - expect_any_instance_of(Projects::LfsPointers::LfsDownloadService).not_to receive(:execute) subject.execute end @@ -130,7 +124,7 @@ describe Projects::ImportService do it 'succeeds if repository import is successful' do expect_any_instance_of(Gitlab::Shell).to receive(:import_repository).and_return(true) expect_any_instance_of(Gitlab::BitbucketImport::Importer).to receive(:execute).and_return(true) - expect_any_instance_of(Projects::LfsPointers::LfsImportService).to receive(:execute).and_return({}) + expect_any_instance_of(Projects::LfsPointers::LfsImportService).to receive(:execute).and_return(status: :success) result = subject.execute @@ -146,6 +140,19 @@ describe Projects::ImportService do expect(result[:message]).to eq "Error importing repository #{project.safe_import_url} into #{project.full_path} - Failed to import the repository [FILTERED]" end + context 'when lfs import fails' do + it 'logs the error' do + error_message = 'error message' + + expect_any_instance_of(Gitlab::Shell).to receive(:import_repository).and_return(true) + expect_any_instance_of(Gitlab::BitbucketImport::Importer).to receive(:execute).and_return(true) + expect_any_instance_of(Projects::LfsPointers::LfsImportService).to receive(:execute).and_return(status: :error, message: error_message) + expect(Gitlab::AppLogger).to receive(:error).with("The Lfs import process failed. #{error_message}") + + subject.execute + end + end + context 'when repository import scheduled' do before do allow_any_instance_of(Gitlab::Shell).to receive(:import_repository).and_return(true) @@ -155,10 +162,7 @@ describe Projects::ImportService do it 'downloads lfs objects if lfs_enabled is enabled for project' do allow(project).to receive(:lfs_enabled?).and_return(true) - service = double - expect_any_instance_of(Projects::LfsPointers::LfsImportService).to receive(:execute).and_return(oid_download_links) - expect(Projects::LfsPointers::LfsDownloadService).to receive(:new).and_return(service).twice - expect(service).to receive(:execute).twice + expect_any_instance_of(Projects::LfsPointers::LfsImportService).to receive(:execute) subject.execute end @@ -166,7 +170,6 @@ describe Projects::ImportService do it 'does not download lfs objects if lfs_enabled is not enabled for project' do allow(project).to receive(:lfs_enabled?).and_return(false) expect_any_instance_of(Projects::LfsPointers::LfsImportService).not_to receive(:execute) - expect_any_instance_of(Projects::LfsPointers::LfsDownloadService).not_to receive(:execute) subject.execute end @@ -208,7 +211,6 @@ describe Projects::ImportService do allow(Gitlab::GithubImport::ParallelImporter).to receive(:imports_repository?).and_return(true) expect_any_instance_of(Projects::LfsPointers::LfsImportService).not_to receive(:execute) - expect_any_instance_of(Projects::LfsPointers::LfsDownloadService).not_to receive(:execute) subject.execute end @@ -216,13 +218,22 @@ describe Projects::ImportService do it 'does not have a custom repository importer downloads lfs objects' do allow(Gitlab::GithubImport::ParallelImporter).to receive(:imports_repository?).and_return(false) - service = double - expect_any_instance_of(Projects::LfsPointers::LfsImportService).to receive(:execute).and_return(oid_download_links) - expect(Projects::LfsPointers::LfsDownloadService).to receive(:new).and_return(service).twice - expect(service).to receive(:execute).twice + expect_any_instance_of(Projects::LfsPointers::LfsImportService).to receive(:execute) subject.execute end + + context 'when lfs import fails' do + it 'logs the error' do + error_message = 'error message' + + allow(Gitlab::GithubImport::ParallelImporter).to receive(:imports_repository?).and_return(false) + expect_any_instance_of(Projects::LfsPointers::LfsImportService).to receive(:execute).and_return(status: :error, message: error_message) + expect(Gitlab::AppLogger).to receive(:error).with("The Lfs import process failed. #{error_message}") + + subject.execute + end + end end end diff --git a/spec/services/projects/lfs_pointers/lfs_download_link_list_service_spec.rb b/spec/services/projects/lfs_pointers/lfs_download_link_list_service_spec.rb index f1c0f5b9576..d8427d0bf78 100644 --- a/spec/services/projects/lfs_pointers/lfs_download_link_list_service_spec.rb +++ b/spec/services/projects/lfs_pointers/lfs_download_link_list_service_spec.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true - require 'spec_helper' describe Projects::LfsPointers::LfsDownloadLinkListService do @@ -85,7 +84,7 @@ describe Projects::LfsPointers::LfsDownloadLinkListService do end describe '#get_download_links' do - it 'raise errorif request fails' do + it 'raise error if request fails' do allow(Gitlab::HTTP).to receive(:post).and_return(Struct.new(:success?, :message).new(false, 'Failed request')) expect { subject.send(:get_download_links, new_oids) }.to raise_error(described_class::DownloadLinksError) diff --git a/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb b/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb index cde3f2d6155..f4470b50753 100644 --- a/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb +++ b/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true - require 'spec_helper' describe Projects::LfsPointers::LfsDownloadService do diff --git a/spec/services/projects/lfs_pointers/lfs_import_service_spec.rb b/spec/services/projects/lfs_pointers/lfs_import_service_spec.rb index 5c9ca99df7c..7ca20a6d751 100644 --- a/spec/services/projects/lfs_pointers/lfs_import_service_spec.rb +++ b/spec/services/projects/lfs_pointers/lfs_import_service_spec.rb @@ -1,148 +1,63 @@ # frozen_string_literal: true - require 'spec_helper' describe Projects::LfsPointers::LfsImportService do + let(:project) { create(:project) } + let(:user) { project.creator } let(:import_url) { 'http://www.gitlab.com/demo/repo.git' } - let(:default_endpoint) { "#{import_url}/info/lfs/objects/batch"} - let(:group) { create(:group, lfs_enabled: true)} - let!(:project) { create(:project, namespace: group, import_url: import_url, lfs_enabled: true) } - let!(:lfs_objects_project) { create_list(:lfs_objects_project, 2, project: project) } - let!(:existing_lfs_objects) { LfsObject.pluck(:oid, :size).to_h } - let(:oids) { { 'oid1' => 123, 'oid2' => 125 } } let(:oid_download_links) { { 'oid1' => "#{import_url}/gitlab-lfs/objects/oid1", 'oid2' => "#{import_url}/gitlab-lfs/objects/oid2" } } - let(:all_oids) { existing_lfs_objects.merge(oids) } - let(:remote_uri) { URI.parse(lfs_endpoint) } - - subject { described_class.new(project) } - - before do - allow(project.repository).to receive(:lfsconfig_for).and_return(nil) - allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) - allow_any_instance_of(Projects::LfsPointers::LfsListService).to receive(:execute).and_return(all_oids) - end - - describe '#execute' do - context 'when no lfs pointer is linked' do - before do - allow_any_instance_of(Projects::LfsPointers::LfsLinkService).to receive(:execute).and_return([]) - allow_any_instance_of(Projects::LfsPointers::LfsDownloadLinkListService).to receive(:execute).and_return(oid_download_links) - expect(Projects::LfsPointers::LfsDownloadLinkListService).to receive(:new).with(project, remote_uri: URI.parse(default_endpoint)).and_call_original - end - - it 'retrieves all lfs pointers in the project repository' do - expect_any_instance_of(Projects::LfsPointers::LfsListService).to receive(:execute) - - subject.execute - end - - it 'links existent lfs objects to the project' do - expect_any_instance_of(Projects::LfsPointers::LfsLinkService).to receive(:execute) - - subject.execute - end - it 'retrieves the download links of non existent objects' do - expect_any_instance_of(Projects::LfsPointers::LfsDownloadLinkListService).to receive(:execute).with(all_oids) + subject { described_class.new(project, user) } - subject.execute - end + context 'when lfs is enabled for the project' do + before do + allow(project).to receive(:lfs_enabled?).and_return(true) end - context 'when some lfs objects are linked' do - before do - allow_any_instance_of(Projects::LfsPointers::LfsLinkService).to receive(:execute).and_return(existing_lfs_objects.keys) - allow_any_instance_of(Projects::LfsPointers::LfsDownloadLinkListService).to receive(:execute).and_return(oid_download_links) - end + it 'downloads lfs objects' do + service = double + expect_any_instance_of(Projects::LfsPointers::LfsObjectDownloadListService).to receive(:execute).and_return(oid_download_links) + expect(Projects::LfsPointers::LfsDownloadService).to receive(:new).and_return(service).twice + expect(service).to receive(:execute).twice - it 'retrieves the download links of non existent objects' do - expect_any_instance_of(Projects::LfsPointers::LfsDownloadLinkListService).to receive(:execute).with(oids) + result = subject.execute - subject.execute - end + expect(result[:status]).to eq :success end - context 'when all lfs objects are linked' do - before do - allow_any_instance_of(Projects::LfsPointers::LfsLinkService).to receive(:execute).and_return(all_oids.keys) - allow_any_instance_of(Projects::LfsPointers::LfsDownloadLinkListService).to receive(:execute) - end + context 'when no downloadable lfs object links' do + it 'does not call LfsDownloadService' do + expect_any_instance_of(Projects::LfsPointers::LfsObjectDownloadListService).to receive(:execute).and_return({}) + expect(Projects::LfsPointers::LfsDownloadService).not_to receive(:new) - it 'retrieves no download links' do - expect_any_instance_of(Projects::LfsPointers::LfsDownloadLinkListService).to receive(:execute).with({}).and_call_original + result = subject.execute - expect(subject.execute).to be_empty + expect(result[:status]).to eq :success end end - context 'when lfsconfig file exists' do - before do - allow(project.repository).to receive(:lfsconfig_for).and_return("[lfs]\n\turl = #{lfs_endpoint}\n") - end - - context 'when url points to the same import url host' do - let(:lfs_endpoint) { "#{import_url}/different_endpoint" } - let(:service) { double } - - before do - allow(service).to receive(:execute) - end - it 'downloads lfs object using the new endpoint' do - expect(Projects::LfsPointers::LfsDownloadLinkListService).to receive(:new).with(project, remote_uri: remote_uri).and_return(service) - - subject.execute - end - - context 'when import url has credentials' do - let(:import_url) { 'http://user:password@www.gitlab.com/demo/repo.git'} - - it 'adds the credentials to the new endpoint' do - expect(Projects::LfsPointers::LfsDownloadLinkListService) - .to receive(:new).with(project, remote_uri: URI.parse("http://user:password@www.gitlab.com/demo/repo.git/different_endpoint")) - .and_return(service) - - subject.execute - end - - context 'when url has its own credentials' do - let(:lfs_endpoint) { "http://user1:password1@www.gitlab.com/demo/repo.git/different_endpoint" } + context 'when an exception is raised' do + it 'returns error' do + error_message = "error message" + expect_any_instance_of(Projects::LfsPointers::LfsObjectDownloadListService).to receive(:execute).and_raise(StandardError, error_message) - it 'does not add the import url credentials' do - expect(Projects::LfsPointers::LfsDownloadLinkListService) - .to receive(:new).with(project, remote_uri: remote_uri) - .and_return(service) + result = subject.execute - subject.execute - end - end - end - end - - context 'when url points to a third party service' do - let(:lfs_endpoint) { 'http://third_party_service.com/info/lfs/objects/' } - - it 'disables lfs from the project' do - expect(project.lfs_enabled?).to be_truthy - - subject.execute - - expect(project.lfs_enabled?).to be_falsey - end - - it 'does not download anything' do - expect_any_instance_of(Projects::LfsPointers::LfsListService).not_to receive(:execute) - - subject.execute - end + expect(result[:status]).to eq :error + expect(result[:message]).to eq error_message end end end - describe '#default_endpoint_uri' do - let(:import_url) { 'http://www.gitlab.com/demo/repo' } + context 'when lfs is not enabled for the project' do + it 'does not download lfs objects' do + allow(project).to receive(:lfs_enabled?).and_return(false) + expect(Projects::LfsPointers::LfsObjectDownloadListService).not_to receive(:new) + expect(Projects::LfsPointers::LfsDownloadService).not_to receive(:new) + + result = subject.execute - it 'adds suffix .git if the url does not have it' do - expect(subject.send(:default_endpoint_uri).path).to match(/repo.git/) + expect(result[:status]).to eq :success end end 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 5caa9de732e..849601c4a63 100644 --- a/spec/services/projects/lfs_pointers/lfs_link_service_spec.rb +++ b/spec/services/projects/lfs_pointers/lfs_link_service_spec.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true - require 'spec_helper' describe Projects::LfsPointers::LfsLinkService do diff --git a/spec/services/projects/lfs_pointers/lfs_object_download_list_service_spec.rb b/spec/services/projects/lfs_pointers/lfs_object_download_list_service_spec.rb new file mode 100644 index 00000000000..9dac29765a2 --- /dev/null +++ b/spec/services/projects/lfs_pointers/lfs_object_download_list_service_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe Projects::LfsPointers::LfsObjectDownloadListService do + let(:import_url) { 'http://www.gitlab.com/demo/repo.git' } + let(:default_endpoint) { "#{import_url}/info/lfs/objects/batch"} + let(:group) { create(:group, lfs_enabled: true)} + let!(:project) { create(:project, namespace: group, import_url: import_url, lfs_enabled: true) } + let!(:lfs_objects_project) { create_list(:lfs_objects_project, 2, project: project) } + let!(:existing_lfs_objects) { LfsObject.pluck(:oid, :size).to_h } + let(:oids) { { 'oid1' => 123, 'oid2' => 125 } } + let(:oid_download_links) { { 'oid1' => "#{import_url}/gitlab-lfs/objects/oid1", 'oid2' => "#{import_url}/gitlab-lfs/objects/oid2" } } + let(:all_oids) { existing_lfs_objects.merge(oids) } + let(:remote_uri) { URI.parse(lfs_endpoint) } + + subject { described_class.new(project) } + + before do + allow(project.repository).to receive(:lfsconfig_for).and_return(nil) + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + allow_any_instance_of(Projects::LfsPointers::LfsListService).to receive(:execute).and_return(all_oids) + end + + describe '#execute' do + context 'when no lfs pointer is linked' do + before do + allow_any_instance_of(Projects::LfsPointers::LfsLinkService).to receive(:execute).and_return([]) + allow_any_instance_of(Projects::LfsPointers::LfsDownloadLinkListService).to receive(:execute).and_return(oid_download_links) + expect(Projects::LfsPointers::LfsDownloadLinkListService).to receive(:new).with(project, remote_uri: URI.parse(default_endpoint)).and_call_original + end + + it 'retrieves all lfs pointers in the project repository' do + expect_any_instance_of(Projects::LfsPointers::LfsListService).to receive(:execute) + + subject.execute + end + + it 'links existent lfs objects to the project' do + expect_any_instance_of(Projects::LfsPointers::LfsLinkService).to receive(:execute) + + subject.execute + end + + it 'retrieves the download links of non existent objects' do + expect_any_instance_of(Projects::LfsPointers::LfsDownloadLinkListService).to receive(:execute).with(all_oids) + + subject.execute + end + end + + context 'when some lfs objects are linked' do + before do + allow_any_instance_of(Projects::LfsPointers::LfsLinkService).to receive(:execute).and_return(existing_lfs_objects.keys) + allow_any_instance_of(Projects::LfsPointers::LfsDownloadLinkListService).to receive(:execute).and_return(oid_download_links) + end + + it 'retrieves the download links of non existent objects' do + expect_any_instance_of(Projects::LfsPointers::LfsDownloadLinkListService).to receive(:execute).with(oids) + + subject.execute + end + end + + context 'when all lfs objects are linked' do + before do + allow_any_instance_of(Projects::LfsPointers::LfsLinkService).to receive(:execute).and_return(all_oids.keys) + allow_any_instance_of(Projects::LfsPointers::LfsDownloadLinkListService).to receive(:execute) + end + + it 'retrieves no download links' do + expect_any_instance_of(Projects::LfsPointers::LfsDownloadLinkListService).to receive(:execute).with({}).and_call_original + + expect(subject.execute).to be_empty + end + end + + context 'when lfsconfig file exists' do + before do + allow(project.repository).to receive(:lfsconfig_for).and_return("[lfs]\n\turl = #{lfs_endpoint}\n") + end + + context 'when url points to the same import url host' do + let(:lfs_endpoint) { "#{import_url}/different_endpoint" } + let(:service) { double } + + before do + allow(service).to receive(:execute) + end + + it 'downloads lfs object using the new endpoint' do + expect(Projects::LfsPointers::LfsDownloadLinkListService).to receive(:new).with(project, remote_uri: remote_uri).and_return(service) + + subject.execute + end + + context 'when import url has credentials' do + let(:import_url) { 'http://user:password@www.gitlab.com/demo/repo.git'} + + it 'adds the credentials to the new endpoint' do + expect(Projects::LfsPointers::LfsDownloadLinkListService) + .to receive(:new).with(project, remote_uri: URI.parse("http://user:password@www.gitlab.com/demo/repo.git/different_endpoint")) + .and_return(service) + + subject.execute + end + + context 'when url has its own credentials' do + let(:lfs_endpoint) { "http://user1:password1@www.gitlab.com/demo/repo.git/different_endpoint" } + + it 'does not add the import url credentials' do + expect(Projects::LfsPointers::LfsDownloadLinkListService) + .to receive(:new).with(project, remote_uri: remote_uri) + .and_return(service) + + subject.execute + end + end + end + end + + context 'when url points to a third party service' do + let(:lfs_endpoint) { 'http://third_party_service.com/info/lfs/objects/' } + + it 'disables lfs from the project' do + expect(project.lfs_enabled?).to be_truthy + + subject.execute + + expect(project.lfs_enabled?).to be_falsey + end + + it 'does not download anything' do + expect_any_instance_of(Projects::LfsPointers::LfsListService).not_to receive(:execute) + + subject.execute + end + end + end + end + + describe '#default_endpoint_uri' do + let(:import_url) { 'http://www.gitlab.com/demo/repo' } + + it 'adds suffix .git if the url does not have it' do + expect(subject.send(:default_endpoint_uri).path).to match(/repo.git/) + end + end +end diff --git a/spec/services/projects/operations/update_service_spec.rb b/spec/services/projects/operations/update_service_spec.rb index 86b1ec83f50..7e765659b9d 100644 --- a/spec/services/projects/operations/update_service_spec.rb +++ b/spec/services/projects/operations/update_service_spec.rb @@ -11,6 +11,56 @@ describe Projects::Operations::UpdateService do subject { described_class.new(project, user, params) } describe '#execute' do + context 'metrics dashboard setting' do + let(:params) do + { + metrics_setting_attributes: { + external_dashboard_url: 'http://gitlab.com' + } + } + end + + context 'without existing metrics dashboard setting' do + it 'creates a setting' do + expect(result[:status]).to eq(:success) + + expect(project.reload.metrics_setting.external_dashboard_url).to eq( + 'http://gitlab.com' + ) + end + end + + context 'with existing metrics dashboard setting' do + before do + create(:project_metrics_setting, project: project) + end + + it 'updates the settings' do + expect(result[:status]).to eq(:success) + + expect(project.reload.metrics_setting.external_dashboard_url).to eq( + 'http://gitlab.com' + ) + end + + context 'with blank external_dashboard_url in params' do + let(:params) do + { + metrics_setting_attributes: { + external_dashboard_url: '' + } + } + end + + it 'destroys the metrics_setting entry in DB' do + expect(result[:status]).to eq(:success) + + expect(project.reload.metrics_setting).to be_nil + end + end + end + end + context 'error tracking' do context 'with existing error tracking setting' do let(:params) do diff --git a/spec/services/todos/destroy/entity_leave_service_spec.rb b/spec/services/todos/destroy/entity_leave_service_spec.rb index 1447b9d4126..2a553e18807 100644 --- a/spec/services/todos/destroy/entity_leave_service_spec.rb +++ b/spec/services/todos/destroy/entity_leave_service_spec.rb @@ -75,6 +75,13 @@ describe Todos::Destroy::EntityLeaveService do project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL) end + it 'enqueues the PrivateFeaturesWorker' do + expect(TodosDestroyer::PrivateFeaturesWorker) + .to receive(:perform_async).with(project.id, user.id) + + subject + end + context 'confidential issues' do context 'when a user is not an author of confidential issue' do it 'removes only confidential issues todos' do @@ -246,6 +253,13 @@ describe Todos::Destroy::EntityLeaveService do project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL) end + it 'enqueues the PrivateFeaturesWorker' do + expect(TodosDestroyer::PrivateFeaturesWorker) + .to receive(:perform_async).with(project.id, user.id) + + subject + end + context 'when user is not member' do it 'removes only confidential issues todos' do expect { subject }.to change { Todo.count }.from(5).to(4) diff --git a/spec/services/update_deployment_service_spec.rb b/spec/services/update_deployment_service_spec.rb index c664bac39fc..7dc52f6816a 100644 --- a/spec/services/update_deployment_service_spec.rb +++ b/spec/services/update_deployment_service_spec.rb @@ -22,6 +22,7 @@ describe UpdateDeploymentService do subject(:service) { described_class.new(deployment) } before do + allow(Deployments::FinishedWorker).to receive(:perform_async) job.success! # Create/Succeed deployment end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 8ca4c172707..fbc5fcea7b9 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -53,6 +53,7 @@ RSpec.configure do |config| config.display_try_failure_messages = true config.infer_spec_type_from_file_location! + config.full_backtrace = true config.define_derived_metadata(file_path: %r{/spec/}) do |metadata| location = metadata[:location] diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index 2f4e6e4c934..b49d743fb9a 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -61,7 +61,14 @@ module GraphqlHelpers def variables_for_mutation(name, input) graphql_input = input.map { |name, value| [GraphqlHelpers.fieldnamerize(name), value] }.to_h - { input_variable_name_for_mutation(name) => graphql_input }.to_json + result = { input_variable_name_for_mutation(name) => graphql_input } + + # Avoid trying to serialize multipart data into JSON + if graphql_input.values.none? { |value| io_value?(value) } + result.to_json + else + result + end end def input_variable_name_for_mutation(mutation_name) @@ -162,6 +169,10 @@ module GraphqlHelpers field.arguments.values.any? { |argument| argument.type.non_null? } end + def io_value?(value) + Array.wrap(value).any? { |v| v.respond_to?(:to_io) } + end + def field_type(field) field_type = field.type diff --git a/spec/support/protected_branch_helpers.rb b/spec/support/protected_branch_helpers.rb new file mode 100644 index 00000000000..ede16d1c1e2 --- /dev/null +++ b/spec/support/protected_branch_helpers.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module ProtectedBranchHelpers + def set_allowed_to(operation, option = 'Maintainers', form: '.js-new-protected-branch') + within form do + select_elem = find(".js-allowed-to-#{operation}") + select_elem.click + + wait_for_requests + + within('.dropdown-content') do + Array(option).each { |opt| click_on(opt) } + end + + # Enhanced select is used in EE, therefore an extra click is needed. + select_elem.click if select_elem['aria-expanded'] == 'true' + end + end + + def set_protected_branch_name(branch_name) + find('.js-protected-branch-select').click + find('.dropdown-input-field').set(branch_name) + click_on("Create wildcard #{branch_name}") + end + + def set_defaults + set_allowed_to('merge') + set_allowed_to('push') + end +end diff --git a/spec/support/protected_tag_helpers.rb b/spec/support/protected_tag_helpers.rb new file mode 100644 index 00000000000..fe9be856286 --- /dev/null +++ b/spec/support/protected_tag_helpers.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require_relative 'protected_branch_helpers' + +module ProtectedTagHelpers + include ::ProtectedBranchHelpers + + def set_allowed_to(operation, option = 'Maintainers', form: '.new-protected-tag') + super + end + + def set_protected_tag_name(tag_name) + find('.js-protected-tag-select').click + find('.dropdown-input-field').set(tag_name) + click_on("Create wildcard #{tag_name}") + find('.protected-tags-dropdown .dropdown-menu', visible: false) + end +end diff --git a/spec/support/shared_examples/models/chat_service_spec.rb b/spec/support/shared_examples/models/chat_service_spec.rb index cf1d52a9616..0a302e7d030 100644 --- a/spec/support/shared_examples/models/chat_service_spec.rb +++ b/spec/support/shared_examples/models/chat_service_spec.rb @@ -25,6 +25,12 @@ shared_examples_for "chat service" do |service_name| end end + describe '.supported_events' do + it 'does not support deployment_events' do + expect(described_class.supported_events).not_to include('deployment') + end + end + describe "#execute" do let(:user) { create(:user) } let(:project) { create(:project, :repository) } @@ -64,7 +70,7 @@ shared_examples_for "chat service" do |service_name| context "with not default branch" do let(:sample_data) do - Gitlab::DataBuilder::Push.build(project, user, nil, nil, "not-the-default-branch") + Gitlab::DataBuilder::Push.build(project: project, user: user, ref: "not-the-default-branch") end context "when notify_only_default_branch enabled" do diff --git a/spec/support/shared_examples/models/cluster_application_core_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_core_shared_examples.rb index 1f76b981292..d6490a808ce 100644 --- a/spec/support/shared_examples/models/cluster_application_core_shared_examples.rb +++ b/spec/support/shared_examples/models/cluster_application_core_shared_examples.rb @@ -2,6 +2,14 @@ shared_examples 'cluster application core specs' do |application_name| it { is_expected.to belong_to(:cluster) } it { is_expected.to validate_presence_of(:cluster) } + describe '#can_uninstall?' do + it 'calls allowed_to_uninstall?' do + expect(subject).to receive(:allowed_to_uninstall?).and_return(true) + + expect(subject.can_uninstall?).to be_truthy + end + end + describe '#name' do it 'is .application_name' do expect(subject.name).to eq(described_class.application_name) diff --git a/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb index b8c19cab0c4..4525c03837f 100644 --- a/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb +++ b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb @@ -114,6 +114,17 @@ shared_examples 'cluster application status specs' do |application_name| expect(subject.status_reason).to eq(reason) end end + + context 'application is uninstalling' do + subject { create(application_name, :uninstalling) } + + it 'is uninstall_errored' do + subject.make_errored(reason) + + expect(subject).to be_uninstall_errored + expect(subject.status_reason).to eq(reason) + end + end end describe '#make_scheduled' do @@ -125,6 +136,16 @@ shared_examples 'cluster application status specs' do |application_name| expect(subject).to be_scheduled end + describe 'when installed' do + subject { create(application_name, :installed) } + + it 'is scheduled' do + subject.make_scheduled + + expect(subject).to be_scheduled + end + end + describe 'when was errored' do subject { create(application_name, :errored) } @@ -148,6 +169,28 @@ shared_examples 'cluster application status specs' do |application_name| expect(subject.status_reason).to be_nil end end + + describe 'when was uninstall_errored' do + subject { create(application_name, :uninstall_errored) } + + it 'clears #status_reason' do + expect(subject.status_reason).not_to be_nil + + subject.make_scheduled! + + expect(subject.status_reason).to be_nil + end + end + end + + describe '#make_uninstalling' do + subject { create(application_name, :scheduled) } + + it 'is uninstalling' do + subject.make_uninstalling! + + expect(subject).to be_uninstalling + end end end @@ -155,16 +198,18 @@ shared_examples 'cluster application status specs' do |application_name| using RSpec::Parameterized::TableSyntax where(:trait, :available) do - :not_installable | false - :installable | false - :scheduled | false - :installing | false - :installed | true - :updating | false - :updated | true - :errored | false - :update_errored | false - :timeouted | false + :not_installable | false + :installable | false + :scheduled | false + :installing | false + :installed | true + :updating | false + :updated | true + :errored | false + :update_errored | false + :uninstalling | false + :uninstall_errored | false + :timed_out | false end with_them do diff --git a/spec/support/shared_examples/slack_mattermost_notifications_shared_examples.rb b/spec/support/shared_examples/slack_mattermost_notifications_shared_examples.rb index 940c24c8d67..36c486dbdd6 100644 --- a/spec/support/shared_examples/slack_mattermost_notifications_shared_examples.rb +++ b/spec/support/shared_examples/slack_mattermost_notifications_shared_examples.rb @@ -106,6 +106,14 @@ RSpec.shared_examples 'slack or mattermost notifications' do expect(WebMock).to have_requested(:post, webhook_url).once end + it "calls Slack/Mattermost API for deployment events" do + deployment_event_data = { object_kind: 'deployment' } + + chat_service.execute(deployment_event_data) + + expect(WebMock).to have_requested(:post, webhook_url).once + end + it 'uses the username as an option for slack when configured' do allow(chat_service).to receive(:username).and_return(username) @@ -267,7 +275,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do it 'does not notify push events if they are not for the default branch' do ref = "#{Gitlab::Git::BRANCH_REF_PREFIX}test" - push_sample_data = Gitlab::DataBuilder::Push.build(project, user, nil, nil, ref, []) + push_sample_data = Gitlab::DataBuilder::Push.build(project: project, user: user, ref: ref) chat_service.execute(push_sample_data) @@ -284,7 +292,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do it 'still notifies about pushed tags' do ref = "#{Gitlab::Git::TAG_REF_PREFIX}test" - push_sample_data = Gitlab::DataBuilder::Push.build(project, user, nil, nil, ref, []) + push_sample_data = Gitlab::DataBuilder::Push.build(project: project, user: user, ref: ref) chat_service.execute(push_sample_data) @@ -299,7 +307,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do it 'notifies about all push events' do ref = "#{Gitlab::Git::BRANCH_REF_PREFIX}test" - push_sample_data = Gitlab::DataBuilder::Push.build(project, user, nil, nil, ref, []) + push_sample_data = Gitlab::DataBuilder::Push.build(project: project, user: user, ref: ref) chat_service.execute(push_sample_data) diff --git a/spec/uploaders/object_storage_spec.rb b/spec/uploaders/object_storage_spec.rb index 9ce9a353913..a62830c35f1 100644 --- a/spec/uploaders/object_storage_spec.rb +++ b/spec/uploaders/object_storage_spec.rb @@ -771,6 +771,14 @@ describe ObjectStorage do expect { avatars }.not_to exceed_query_limit(1) end + it 'does not attempt to replace methods' do + models.each do |model| + expect(model.avatar.upload).to receive(:method_missing).and_call_original + + model.avatar.upload.path + end + end + it 'fetches a unique upload for each model' do expect(avatars.map(&:url).uniq).to eq(avatars.map(&:url)) expect(avatars.map(&:upload).uniq).to eq(avatars.map(&:upload)) diff --git a/spec/views/projects/issues/_merge_requests_status.html.haml_spec.rb b/spec/views/projects/issues/_merge_requests_status.html.haml_spec.rb deleted file mode 100644 index 9424795749d..00000000000 --- a/spec/views/projects/issues/_merge_requests_status.html.haml_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true -require 'spec_helper' - -describe 'projects/issues/_merge_requests_status.html.haml' do - around do |ex| - Timecop.freeze(Date.new(2018, 7, 22)) do - ex.run - end - end - - it 'shows date of status change in tooltip' do - merge_request = create(:merge_request, created_at: 1.month.ago) - - render partial: 'projects/issues/merge_requests_status', - locals: { merge_request: merge_request, css_class: '' } - - expect(rendered).to match("Opened.*about 1 month ago") - end - - it 'shows only status in tooltip if date is not set' do - merge_request = create(:merge_request, state: :closed) - - render partial: 'projects/issues/merge_requests_status', - locals: { merge_request: merge_request, css_class: '' } - - expect(rendered).to match("Closed") - end -end diff --git a/spec/views/projects/issues/show.html.haml_spec.rb b/spec/views/projects/issues/show.html.haml_spec.rb index 1d9c6d36ad7..1ca9eaf8fdb 100644 --- a/spec/views/projects/issues/show.html.haml_spec.rb +++ b/spec/views/projects/issues/show.html.haml_spec.rb @@ -19,6 +19,7 @@ describe 'projects/issues/show' do context 'when the issue is closed' do before do allow(issue).to receive(:closed?).and_return(true) + allow(view).to receive(:current_user).and_return(user) end context 'when the issue was moved' do @@ -28,16 +29,30 @@ describe 'projects/issues/show' do issue.moved_to = new_issue end - it 'shows "Closed (moved)" if an issue has been moved' do - render + context 'when user can see the moved issue' do + before do + project.add_developer(user) + end - expect(rendered).to have_selector('.status-box-issue-closed:not(.hidden)', text: 'Closed (moved)') + it 'shows "Closed (moved)" if an issue has been moved' do + render + + expect(rendered).to have_selector('.status-box-issue-closed:not(.hidden)', text: 'Closed (moved)') + end + + it 'links "moved" to the new issue the original issue was moved to' do + render + + expect(rendered).to have_selector("a[href=\"#{issue_path(new_issue)}\"]", text: 'moved') + end end - it 'links "moved" to the new issue the original issue was moved to' do - render + context 'when user cannot see moved issue' do + it 'does not show moved issue link' do + render - expect(rendered).to have_selector("a[href=\"#{issue_path(new_issue)}\"]", text: 'moved') + expect(rendered).not_to have_selector("a[href=\"#{issue_path(new_issue)}\"]", text: 'moved') + end end end diff --git a/spec/workers/build_success_worker_spec.rb b/spec/workers/build_success_worker_spec.rb index 065aeaf2b65..ffe8796ded9 100644 --- a/spec/workers/build_success_worker_spec.rb +++ b/spec/workers/build_success_worker_spec.rb @@ -15,6 +15,7 @@ describe BuildSuccessWorker do let!(:build) { create(:ci_build, :deploy_to_production) } before do + allow(Deployments::FinishedWorker).to receive(:perform_async) Deployment.delete_all build.reload end diff --git a/spec/workers/clusters/applications/wait_for_uninstall_app_worker_spec.rb b/spec/workers/clusters/applications/wait_for_uninstall_app_worker_spec.rb new file mode 100644 index 00000000000..aaf5c9defc4 --- /dev/null +++ b/spec/workers/clusters/applications/wait_for_uninstall_app_worker_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::Applications::WaitForUninstallAppWorker, '#perform' do + let(:app) { create(:clusters_applications_helm) } + let(:app_name) { app.name } + let(:app_id) { app.id } + + subject { described_class.new.perform(app_name, app_id) } + + context 'app exists' do + let(:service) { instance_double(Clusters::Applications::CheckUninstallProgressService) } + + it 'calls the check service' do + expect(Clusters::Applications::CheckUninstallProgressService).to receive(:new).with(app).and_return(service) + expect(service).to receive(:execute).once + + subject + end + end + + context 'app does not exist' do + let(:app_id) { 0 } + + it 'does not call the check service' do + expect(Clusters::Applications::CheckUninstallProgressService).not_to receive(:new) + + expect { subject }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end diff --git a/spec/workers/deployments/finished_worker_spec.rb b/spec/workers/deployments/finished_worker_spec.rb new file mode 100644 index 00000000000..df62821e2cd --- /dev/null +++ b/spec/workers/deployments/finished_worker_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Deployments::FinishedWorker do + let(:worker) { described_class.new } + + describe '#perform' do + before do + allow(ProjectServiceWorker).to receive(:perform_async) + end + + it 'executes project services for deployment_hooks' do + deployment = create(:deployment) + project = deployment.project + service = create(:service, type: 'SlackService', project: project, deployment_events: true, active: true) + + worker.perform(deployment.id) + + expect(ProjectServiceWorker).to have_received(:perform_async).with(service.id, an_instance_of(Hash)) + end + + it 'does not execute an inactive service' do + deployment = create(:deployment) + project = deployment.project + create(:service, type: 'SlackService', project: project, deployment_events: true, active: false) + + worker.perform(deployment.id) + + expect(ProjectServiceWorker).not_to have_received(:perform_async) + end + + it 'does nothing if a deployment with the given id does not exist' do + worker.perform(0) + + expect(ProjectServiceWorker).not_to have_received(:perform_async) + end + end +end diff --git a/spec/workers/pipeline_schedule_worker_spec.rb b/spec/workers/pipeline_schedule_worker_spec.rb index f23910d23be..8c604b13297 100644 --- a/spec/workers/pipeline_schedule_worker_spec.rb +++ b/spec/workers/pipeline_schedule_worker_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe PipelineScheduleWorker do + include ExclusiveLeaseHelpers + subject { described_class.new.perform } set(:project) { create(:project, :repository) } @@ -39,6 +41,16 @@ describe PipelineScheduleWorker do it_behaves_like 'successful scheduling' + context 'when exclusive lease has already been taken by the other instance' do + before do + stub_exclusive_lease_taken(described_class::EXCLUSIVE_LOCK_KEY, timeout: described_class::LOCK_TIMEOUT) + end + + it 'raises an error and does not start creating pipelines' do + expect { subject }.to raise_error(Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError) + end + end + context 'when the latest commit contains [ci skip]' do before do allow_any_instance_of(Ci::Pipeline) diff --git a/yarn.lock b/yarn.lock index 8a23aabba20..d97659ab5fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -663,12 +663,13 @@ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.59.0.tgz#affcf9596d736836d37469bb4aea2226ac03e087" integrity sha512-dokGyyLRRsoBKO70KP1g+ZsDGyTK/RIHWDmvWI6Bx5AxQ3UqAzVXn2OIb3owjJAexyRG1uBmJrriiVVyHznQ4g== -"@gitlab/ui@^3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-3.4.0.tgz#174681f210eb16c3d101a36968d5e4d163c0d014" - integrity sha512-joXNz80IHMQxEGrqcNUTEKofjfZtkOKUe34HAFI71NEeYT6H0r/lYmJ5Gcz+MmwM1CvZOVbB3DnKzxQPDbN/hQ== +"@gitlab/ui@^3.7.0": + version "3.7.0" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-3.7.0.tgz#8d0892ae54ddcb3c309bd970c57a433af6098edf" + integrity sha512-DEIPfem9P5j0DyzZp0M62SbLQu1D4feiNO0oAYN8bJrgiMC8H3VEJwiyplNItSwFYa985O1xOr3B81eTiZEWDQ== dependencies: "@babel/standalone" "^7.0.0" + "@gitlab/vue-toasted" "^1.2.1" bootstrap-vue "^2.0.0-rc.11" copy-to-clipboard "^3.0.8" echarts "^4.2.0-rc.2" @@ -679,6 +680,11 @@ vue "^2.5.21" vue-loader "^15.4.2" +"@gitlab/vue-toasted@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@gitlab/vue-toasted/-/vue-toasted-1.2.1.tgz#f407b5aa710863e5b7f021f4a1f66160331ab263" + integrity sha512-ve2PLxKqrwNpsd+4bV5zGJT5+H5N/VJBZoFS2Vp1mH5cUDBYIHTzDmbS6AbBGUDh0F3TxmFMiqfXfpO/1VjBNQ== + "@mrmlnc/readdir-enhanced@^2.2.1": version "2.2.1" resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde" |