diff options
135 files changed, 4051 insertions, 407 deletions
diff --git a/.gitattributes b/.gitattributes index 0b87a97df9c..55c422f0f8c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,2 @@ VERSION merge=ours Dangerfile gitlab-language=ruby -db/schema.rb merge=merge_db_schema diff --git a/.gitlab/ci/docs.gitlab-ci.yml b/.gitlab/ci/docs.gitlab-ci.yml index 008151f889f..cc0999b50c6 100644 --- a/.gitlab/ci/docs.gitlab-ci.yml +++ b/.gitlab/ci/docs.gitlab-ci.yml @@ -67,3 +67,18 @@ docs lint: - bundle exec nanoc check internal_links # Check the internal anchor links - bundle exec nanoc check internal_anchors + +graphql-docs-verify: + extends: + - .default-tags + - .default-retry + - .default-cache + - .default-only + - .default-before_script + - .only-graphql-changes + variables: + SETUP_DB: "false" + stage: test + needs: ["setup-test-env"] + script: + - bundle exec rake gitlab:graphql:check_docs diff --git a/.gitlab/ci/frontend.gitlab-ci.yml b/.gitlab/ci/frontend.gitlab-ci.yml index a3a2ab0691f..2f457bc0ee2 100644 --- a/.gitlab/ci/frontend.gitlab-ci.yml +++ b/.gitlab/ci/frontend.gitlab-ci.yml @@ -53,7 +53,7 @@ - gitlab-org - docker -gitlab:assets:compile: +gitlab:assets:compile pull-push-cache: extends: .gitlab:assets:compile-metadata only: refs: @@ -63,9 +63,6 @@ gitlab:assets:compile: gitlab:assets:compile pull-cache: extends: .gitlab:assets:compile-metadata - except: - refs: - - master cache: policy: pull @@ -89,14 +86,14 @@ gitlab:assets:compile pull-cache: # we override the max_old_space_size to prevent OOM errors NODE_OPTIONS: --max_old_space_size=3584 cache: - key: "assets-compile:test:vendor_ruby:.yarn-cache:tmp_cache_assets_sprockets:v6" + key: "assets-compile:v7" artifacts: expire_in: 7d paths: - node_modules - public/assets -compile-assets: +compile-assets pull-push-cache: extends: .compile-assets-metadata only: refs: @@ -104,13 +101,25 @@ compile-assets: cache: policy: pull-push -compile-assets pull-cache: - extends: .compile-assets-metadata - except: +compile-assets pull-push-cache foss: + extends: [".compile-assets-metadata", ".only-ee-as-if-foss"] + only: refs: - master cache: + policy: pull-push + key: "assets-compile:v7:foss" + +compile-assets pull-cache: + extends: .compile-assets-metadata + cache: + policy: pull + +compile-assets pull-cache foss: + extends: [".compile-assets-metadata", ".only-ee-as-if-foss"] + cache: policy: pull + key: "assets-compile:v7:foss" .only-code-frontend-job-base: extends: @@ -121,7 +130,9 @@ compile-assets pull-cache: - .default-before_script - .only-code-changes - .use-pg9 - dependencies: ["compile-assets", "compile-assets pull-cache", "setup-test-env"] + stage: test + needs: ["setup-test-env", "compile-assets pull-cache"] + dependencies: ["setup-test-env", "compile-assets pull-cache"] .karma-base: extends: .only-code-frontend-job-base @@ -195,6 +206,7 @@ jest-foss: - .default-cache - .default-only - .only-code-changes + stage: test dependencies: [] cache: key: "$CI_JOB_NAME" @@ -227,7 +239,9 @@ webpack-dev-server: - .default-cache - .default-only - .only-code-changes - dependencies: ["setup-test-env", "compile-assets", "compile-assets pull-cache"] + stage: test + needs: ["setup-test-env", "compile-assets pull-cache"] + dependencies: ["setup-test-env", "compile-assets pull-cache"] variables: WEBPACK_MEMORY_TEST: "true" script: diff --git a/.gitlab/ci/global.gitlab-ci.yml b/.gitlab/ci/global.gitlab-ci.yml index af7c7a0d152..fc9b00b5d3c 100644 --- a/.gitlab/ci/global.gitlab-ci.yml +++ b/.gitlab/ci/global.gitlab-ci.yml @@ -71,6 +71,12 @@ - "doc/**/*" - ".markdownlint.json" +.only-graphql-changes: + only: + changes: + - "{,ee/}app/graphql/**/*" + - "{,ee/}lib/gitlab/graphql/**/*" + .only-code-qa-changes: only: changes: @@ -153,4 +159,4 @@ .only-ee-as-if-foss: extends: .only-ee variables: - IS_GITLAB_EE: '0' + FOSS_ONLY: '1' diff --git a/.gitlab/ci/pages.gitlab-ci.yml b/.gitlab/ci/pages.gitlab-ci.yml index a59b84fe1cf..a30772d5664 100644 --- a/.gitlab/ci/pages.gitlab-ci.yml +++ b/.gitlab/ci/pages.gitlab-ci.yml @@ -11,7 +11,7 @@ pages: variables: - $CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE == "gitlab-org" stage: pages - dependencies: ["coverage", "karma", "gitlab:assets:compile"] + dependencies: ["coverage", "karma", "gitlab:assets:compile pull-cache"] script: - mv public/ .public/ - mkdir public/ diff --git a/.gitlab/ci/qa.gitlab-ci.yml b/.gitlab/ci/qa.gitlab-ci.yml index a73edd3f65f..1194948a76f 100644 --- a/.gitlab/ci/qa.gitlab-ci.yml +++ b/.gitlab/ci/qa.gitlab-ci.yml @@ -71,4 +71,4 @@ schedule:package-and-qa: - .package-and-qa-base - .only-code-qa-changes - .only-canonical-schedules - needs: ["build-qa-image", "gitlab:assets:compile"] + needs: ["build-qa-image", "gitlab:assets:compile pull-cache"] diff --git a/.gitlab/ci/rails.gitlab-ci.yml b/.gitlab/ci/rails.gitlab-ci.yml index 73b649b4d14..bf478b68765 100644 --- a/.gitlab/ci/rails.gitlab-ci.yml +++ b/.gitlab/ci/rails.gitlab-ci.yml @@ -53,6 +53,8 @@ setup-test-env: .rspec-base: extends: .only-code-rails-job-base stage: test + needs: ["setup-test-env", "retrieve-tests-metadata", "compile-assets pull-cache"] + dependencies: ["setup-test-env", "retrieve-tests-metadata", "compile-assets pull-cache"] script: - source scripts/rspec_helpers.sh - rspec_paralellized_job "--tag ~quarantine --tag ~geo" @@ -69,6 +71,11 @@ setup-test-env: reports: junit: junit_rspec.xml +.rspec-base-foss: + extends: [".rspec-base", ".only-ee-as-if-foss"] + needs: ["setup-test-env", "retrieve-tests-metadata", "compile-assets pull-cache foss"] + dependencies: ["setup-test-env", "retrieve-tests-metadata", "compile-assets pull-cache foss"] + .rspec-base-pg9: extends: - .rspec-base @@ -76,9 +83,8 @@ setup-test-env: .rspec-base-pg9-foss: extends: - - .rspec-base + - .rspec-base-foss - .use-pg9 - - .only-ee-as-if-foss .rspec-base-pg10: extends: @@ -106,10 +112,9 @@ rspec system pg9: extends: .rspec-base-pg9 parallel: 24 -# TODO: This requires FOSS assets -# rspec system pg9-foss: -# extends: .rspec-base-pg9-foss -# parallel: 24 +rspec system pg9-foss: + extends: .rspec-base-pg9-foss + parallel: 24 rspec unit pg10: extends: .rspec-base-pg10 @@ -229,7 +234,9 @@ rspec fast_spec_helper: static-analysis: extends: .only-code-qa-rails-job-base - dependencies: ["setup-test-env", "compile-assets", "compile-assets pull-cache"] + stage: test + needs: ["setup-test-env", "compile-assets pull-cache"] + dependencies: ["setup-test-env", "compile-assets pull-cache"] variables: SETUP_DB: "false" script: @@ -252,16 +259,16 @@ downtime_check: variables: - $CI_COMMIT_REF_NAME =~ /^[\d-]+-stable(-ee)?$/ stage: test - dependencies: ["setup-test-env"] needs: ["setup-test-env"] + dependencies: ["setup-test-env"] .db-job-base: extends: - .only-code-rails-job-base - .use-pg9 stage: test - dependencies: ["setup-test-env"] needs: ["setup-test-env"] + dependencies: ["setup-test-env"] # DB migration, rollback, and seed jobs db:migrate:reset: diff --git a/.gitlab/ci/review.gitlab-ci.yml b/.gitlab/ci/review.gitlab-ci.yml index 7c4ba3878f1..ad516aba7c3 100644 --- a/.gitlab/ci/review.gitlab-ci.yml +++ b/.gitlab/ci/review.gitlab-ci.yml @@ -81,7 +81,7 @@ schedule:review-build-cng: extends: - .review-build-cng-base - .only-review-schedules - needs: ["gitlab:assets:compile"] + needs: ["gitlab:assets:compile pull-cache"] .review-deploy-base: extends: @@ -97,7 +97,7 @@ schedule:review-build-cng: variables: HOST_SUFFIX: "${CI_ENVIRONMENT_SLUG}" DOMAIN: "-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN}" - GITLAB_HELM_CHART_REF: "master" + GITLAB_HELM_CHART_REF: "v2.3.7" GITLAB_EDITION: "ce" environment: name: review/${CI_COMMIT_REF_NAME} diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index d57be10f472..908dc730aa4 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -36,6 +36,7 @@ const Api = { branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch', createBranchPath: '/api/:version/projects/:id/repository/branches', releasesPath: '/api/:version/projects/:id/releases', + releasePath: '/api/:version/projects/:id/releases/:tag_name', mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines', adminStatisticsPath: 'api/:version/application/statistics', @@ -391,6 +392,22 @@ const Api = { return axios.get(url); }, + release(projectPath, tagName) { + const url = Api.buildUrl(this.releasePath) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':tag_name', encodeURIComponent(tagName)); + + return axios.get(url); + }, + + updateRelease(projectPath, tagName, release) { + const url = Api.buildUrl(this.releasePath) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':tag_name', encodeURIComponent(tagName)); + + return axios.put(url, release); + }, + adminStatistics() { const url = Api.buildUrl(this.adminStatisticsPath); return axios.get(url); diff --git a/app/assets/javascripts/pages/projects/releases/edit/index.js b/app/assets/javascripts/pages/projects/releases/edit/index.js new file mode 100644 index 00000000000..98ec196fc37 --- /dev/null +++ b/app/assets/javascripts/pages/projects/releases/edit/index.js @@ -0,0 +1,7 @@ +import ZenMode from '~/zen_mode'; +import initEditRelease from '~/releases/detail'; + +document.addEventListener('DOMContentLoaded', () => { + new ZenMode(); // eslint-disable-line no-new + initEditRelease(); +}); diff --git a/app/assets/javascripts/releases/detail/components/app.vue b/app/assets/javascripts/releases/detail/components/app.vue new file mode 100644 index 00000000000..54a441de886 --- /dev/null +++ b/app/assets/javascripts/releases/detail/components/app.vue @@ -0,0 +1,156 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; + +export default { + name: 'ReleaseDetailApp', + components: { + GlFormInput, + GlFormGroup, + GlButton, + MarkdownField, + }, + directives: { + autofocusonshow, + }, + computed: { + ...mapState([ + 'isFetchingRelease', + 'fetchError', + 'markdownDocsPath', + 'markdownPreviewPath', + 'releasesPagePath', + ]), + showForm() { + return !this.isFetchingRelease && !this.fetchError; + }, + subtitleText() { + return sprintf( + __( + 'Releases are based on Git tags. We recommend naming tags that fit within semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}.', + ), + { + codeStart: '<code>', + codeEnd: '</code>', + }, + false, + ); + }, + tagName() { + return this.$store.state.release.tagName; + }, + releaseTitle: { + get() { + return this.$store.state.release.name; + }, + set(title) { + this.updateReleaseTitle(title); + }, + }, + releaseNotes: { + get() { + return this.$store.state.release.description; + }, + set(notes) { + this.updateReleaseNotes(notes); + }, + }, + }, + created() { + this.fetchRelease(); + }, + methods: { + ...mapActions([ + 'fetchRelease', + 'updateRelease', + 'updateReleaseTitle', + 'updateReleaseNotes', + 'navigateToReleasesPage', + ]), + }, +}; +</script> +<template> + <div class="d-flex flex-column"> + <p class="pt-3 js-subtitle-text" v-html="subtitleText"></p> + <form v-if="showForm" @submit.prevent="updateRelease()"> + <div class="row"> + <gl-form-group class="col-md-6 col-lg-5 col-xl-4"> + <label for="git-ref">{{ __('Tag name') }}</label> + <gl-form-input + id="git-ref" + v-model="tagName" + type="text" + class="form-control" + aria-describedby="tag-name-help" + disabled + /> + <div id="tag-name-help" class="form-text text-muted"> + {{ __('Choose an existing tag, or create a new one') }} + </div> + </gl-form-group> + </div> + <gl-form-group> + <label for="release-title">{{ __('Release title') }}</label> + <gl-form-input + id="release-title" + ref="releaseTitleInput" + v-model="releaseTitle" + v-autofocusonshow + autofocus + type="text" + class="form-control" + /> + </gl-form-group> + <gl-form-group> + <label for="release-notes">{{ __('Release notes') }}</label> + <div class="bordered-box pr-3 pl-3"> + <markdown-field + :can-attach-file="true" + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + :add-spacing-classes="false" + class="prepend-top-10 append-bottom-10" + > + <textarea + id="release-notes" + slot="textarea" + v-model="releaseNotes" + class="note-textarea js-gfm-input js-autosize markdown-area" + dir="auto" + data-supports-quick-actions="false" + :aria-label="__('Release notes')" + :placeholder="__('Write your release notes or drag your files here…')" + @keydown.meta.enter="updateRelease()" + @keydown.ctrl.enter="updateRelease()" + > + </textarea> + </markdown-field> + </div> + </gl-form-group> + + <div class="d-flex pt-3"> + <gl-button + class="mr-auto js-submit-button" + variant="success" + type="submit" + :aria-label="__('Save changes')" + > + {{ __('Save changes') }} + </gl-button> + <gl-button + class="js-cancel-button" + variant="default" + type="button" + :aria-label="__('Cancel')" + @click="navigateToReleasesPage()" + > + {{ __('Cancel') }} + </gl-button> + </div> + </form> + </div> +</template> diff --git a/app/assets/javascripts/releases/detail/index.js b/app/assets/javascripts/releases/detail/index.js new file mode 100644 index 00000000000..3da971e6d90 --- /dev/null +++ b/app/assets/javascripts/releases/detail/index.js @@ -0,0 +1,19 @@ +import Vue from 'vue'; +import ReleaseDetailApp from './components/app.vue'; +import createStore from './store'; + +export default () => { + const el = document.getElementById('js-edit-release-page'); + + const store = createStore(el.dataset); + store.dispatch('setInitialState', el.dataset); + + return new Vue({ + el, + store, + components: { ReleaseDetailApp }, + render(createElement) { + return createElement('release-detail-app'); + }, + }); +}; diff --git a/app/assets/javascripts/releases/detail/store/actions.js b/app/assets/javascripts/releases/detail/store/actions.js new file mode 100644 index 00000000000..c9749582f5c --- /dev/null +++ b/app/assets/javascripts/releases/detail/store/actions.js @@ -0,0 +1,62 @@ +import * as types from './mutation_types'; +import api from '~/api'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +export const setInitialState = ({ commit }, initialState) => + commit(types.SET_INITIAL_STATE, initialState); + +export const requestRelease = ({ commit }) => commit(types.REQUEST_RELEASE); +export const receiveReleaseSuccess = ({ commit }, data) => + commit(types.RECEIVE_RELEASE_SUCCESS, data); +export const receiveReleaseError = ({ commit }, error) => { + commit(types.RECEIVE_RELEASE_ERROR, error); + createFlash(s__('Release|Something went wrong while getting the release details')); +}; + +export const fetchRelease = ({ dispatch, state }) => { + dispatch('requestRelease'); + + return api + .release(state.projectId, state.tagName) + .then(({ data: release }) => { + const camelCasedRelease = convertObjectPropsToCamelCase(release, { deep: true }); + dispatch('receiveReleaseSuccess', camelCasedRelease); + }) + .catch(error => { + dispatch('receiveReleaseError', error); + }); +}; + +export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title); +export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes); + +export const requestUpdateRelease = ({ commit }) => commit(types.REQUEST_UPDATE_RELEASE); +export const receiveUpdateReleaseSuccess = ({ commit, dispatch }) => { + commit(types.RECEIVE_UPDATE_RELEASE_SUCCESS); + dispatch('navigateToReleasesPage'); +}; +export const receiveUpdateReleaseError = ({ commit }, error) => { + commit(types.RECEIVE_UPDATE_RELEASE_ERROR, error); + createFlash(s__('Release|Something went wrong while saving the release details')); +}; + +export const updateRelease = ({ dispatch, state }) => { + dispatch('requestUpdateRelease'); + + return api + .updateRelease(state.projectId, state.tagName, { + name: state.release.name, + description: state.release.description, + }) + .then(() => dispatch('receiveUpdateReleaseSuccess')) + .catch(error => { + dispatch('receiveUpdateReleaseError', error); + }); +}; + +export const navigateToReleasesPage = ({ state }) => { + redirectTo(state.releasesPagePath); +}; diff --git a/app/assets/javascripts/releases/detail/store/index.js b/app/assets/javascripts/releases/detail/store/index.js new file mode 100644 index 00000000000..e8623a49356 --- /dev/null +++ b/app/assets/javascripts/releases/detail/store/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + actions, + mutations, + state, + }); diff --git a/app/assets/javascripts/releases/detail/store/mutation_types.js b/app/assets/javascripts/releases/detail/store/mutation_types.js new file mode 100644 index 00000000000..75e1d78a645 --- /dev/null +++ b/app/assets/javascripts/releases/detail/store/mutation_types.js @@ -0,0 +1,12 @@ +export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; + +export const REQUEST_RELEASE = 'REQUEST_RELEASE'; +export const RECEIVE_RELEASE_SUCCESS = 'RECEIVE_RELEASE_SUCCESS'; +export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR'; + +export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE'; +export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES'; + +export const REQUEST_UPDATE_RELEASE = 'REQUEST_UPDATE_RELEASE'; +export const RECEIVE_UPDATE_RELEASE_SUCCESS = 'RECEIVE_UPDATE_RELEASE_SUCCESS'; +export const RECEIVE_UPDATE_RELEASE_ERROR = 'RECEIVE_UPDATE_RELEASE_ERROR'; diff --git a/app/assets/javascripts/releases/detail/store/mutations.js b/app/assets/javascripts/releases/detail/store/mutations.js new file mode 100644 index 00000000000..d739978d755 --- /dev/null +++ b/app/assets/javascripts/releases/detail/store/mutations.js @@ -0,0 +1,42 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_INITIAL_STATE](state, initialState) { + Object.keys(state).forEach(key => { + state[key] = initialState[key]; + }); + }, + + [types.REQUEST_RELEASE](state) { + state.isFetchingRelease = true; + }, + [types.RECEIVE_RELEASE_SUCCESS](state, data) { + state.fetchError = undefined; + state.isFetchingRelease = false; + state.release = data; + }, + [types.RECEIVE_RELEASE_ERROR](state, error) { + state.fetchError = error; + state.isFetchingRelease = false; + state.release = undefined; + }, + + [types.UPDATE_RELEASE_TITLE](state, title) { + state.release.name = title; + }, + [types.UPDATE_RELEASE_NOTES](state, notes) { + state.release.description = notes; + }, + + [types.REQUEST_UPDATE_RELEASE](state) { + state.isUpdatingRelease = true; + }, + [types.RECEIVE_UPDATE_RELEASE_SUCCESS](state) { + state.updateError = undefined; + state.isUpdatingRelease = false; + }, + [types.RECEIVE_UPDATE_RELEASE_ERROR](state, error) { + state.updateError = error; + state.isUpdatingRelease = false; + }, +}; diff --git a/app/assets/javascripts/releases/detail/store/state.js b/app/assets/javascripts/releases/detail/store/state.js new file mode 100644 index 00000000000..ff98e2bed78 --- /dev/null +++ b/app/assets/javascripts/releases/detail/store/state.js @@ -0,0 +1,15 @@ +export default () => ({ + projectId: null, + tagName: null, + releasesPagePath: null, + markdownDocsPath: null, + markdownPreviewPath: null, + + release: null, + + isFetchingRelease: false, + fetchError: null, + + isUpdatingRelease: false, + updateError: null, +}); diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index c73db2668ec..ecd32dcd0ce 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -123,7 +123,7 @@ ul.content-list { font-weight: $gl-font-weight-bold; } - a:not(.default-link-color) { + a { color: $gl-text-color; } diff --git a/app/assets/stylesheets/pages/tags.scss b/app/assets/stylesheets/pages/tags.scss new file mode 100644 index 00000000000..a6d30522ff7 --- /dev/null +++ b/app/assets/stylesheets/pages/tags.scss @@ -0,0 +1,3 @@ +.tag-release-link { + color: $blue-600 !important; +} diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index d88ec06a18b..efd5f0fc607 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -4,18 +4,31 @@ class HealthController < ActionController::Base protect_from_forgery with: :exception, prepend: true include RequiresWhitelistedMonitoringClient + CHECKS = [ + Gitlab::HealthChecks::DbCheck, + Gitlab::HealthChecks::Redis::RedisCheck, + Gitlab::HealthChecks::Redis::CacheCheck, + Gitlab::HealthChecks::Redis::QueuesCheck, + Gitlab::HealthChecks::Redis::SharedStateCheck, + Gitlab::HealthChecks::GitalyCheck + ].freeze + def readiness - render_probe(::Gitlab::HealthChecks::Probes::Readiness) + # readiness check is a collection with all above application-level checks + render_checks(*CHECKS) end def liveness - render_probe(::Gitlab::HealthChecks::Probes::Liveness) + # liveness check is a collection without additional checks + render_checks end private - def render_probe(probe_class) - result = probe_class.new.execute + def render_checks(*checks) + result = Gitlab::HealthChecks::Probes::Collection + .new(*checks) + .execute # disable static error pages at the gitlab-workhorse level, we want to see this error response even in production headers["X-GitLab-Custom-Error"] = 1 unless result.success? diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb index 32111b07a0b..766e2f86ea2 100644 --- a/app/controllers/projects/deployments_controller.rb +++ b/app/controllers/projects/deployments_controller.rb @@ -47,11 +47,9 @@ class Projects::DeploymentsController < Projects::ApplicationController @deployment_metrics ||= DeploymentMetrics.new(deployment.project, deployment) end - # rubocop: disable CodeReuse/ActiveRecord def deployment - @deployment ||= environment.deployments.find_by(iid: params[:id]) + @deployment ||= environment.deployments.find_successful_deployment!(params[:id]) end - # rubocop: enable CodeReuse/ActiveRecord def environment @environment ||= project.environments.find(params[:environment_id]) diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 1913d7cd580..4a37dfe5c19 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -51,9 +51,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic def render_diffs @environment = @merge_request.environments_for(current_user).last - note_positions = renderable_notes.map(&:position).compact - @diffs.unfold_diff_files(note_positions) - + @diffs.unfold_diff_files(note_positions.unfoldable) @diffs.write_cache request = { @@ -140,6 +138,10 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes), @merge_request) end + def note_positions + @note_positions ||= Gitlab::Diff::PositionCollection.new(renderable_notes.map(&:position)) + end + def renderable_notes define_diff_comment_vars unless @notes diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 42fe42398f1..3c70ff3b59f 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -289,7 +289,8 @@ module ApplicationSettingsHelper :snowplow_collector_hostname, :snowplow_cookie_domain, :snowplow_enabled, - :snowplow_site_id + :snowplow_site_id, + :push_event_hooks_limit ] end diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb index 2b7320817ed..52f189b122f 100644 --- a/app/helpers/environment_helper.rb +++ b/app/helpers/environment_helper.rb @@ -18,12 +18,16 @@ module EnvironmentHelper end end + def deployment_path(deployment) + [deployment.project.namespace.becomes(Namespace), deployment.project, deployment.deployable] + end + def deployment_link(deployment, text: nil) return unless deployment link_label = text ? text : "##{deployment.iid}" - link_to link_label, [deployment.project.namespace.becomes(Namespace), deployment.project, deployment.deployable] + link_to link_label, deployment_path(deployment) end def last_deployment_link_for_environment_build(project, build) @@ -32,4 +36,31 @@ module EnvironmentHelper deployment_link(environment.last_deployment) end + + def render_deployment_status(deployment) + status = deployment.status + + status_text = + case status + when 'created' + s_('Deployment|created') + when 'running' + s_('Deployment|running') + when 'success' + s_('Deployment|success') + when 'failed' + s_('Deployment|failed') + when 'canceled' + s_('Deployment|canceled') + end + + klass = "ci-status ci-#{status.dasherize}" + text = "#{ci_icon_for_status(status)} #{status_text}".html_safe + + if deployment.deployable + link_to(text, deployment_path(deployment), class: klass) + else + content_tag(:span, text, class: klass) + end + end end diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb index 3186bbd9322..68a19152d8f 100644 --- a/app/helpers/releases_helper.rb +++ b/app/helpers/releases_helper.rb @@ -19,4 +19,14 @@ module ReleasesHelper documentation_path: help_page } end + + def data_for_edit_release_page + { + project_id: @project.id, + tag_name: @release.tag, + markdown_preview_path: preview_markdown_path(@project), + markdown_docs_path: help_page_path('user/markdown'), + releases_page_path: project_releases_path(@project, anchor: @release.tag) + } + end end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 02f214341fb..0724ee8f39d 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -214,6 +214,9 @@ class ApplicationSetting < ApplicationRecord length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') }, allow_nil: false + validates :push_event_hooks_limit, + numericality: { greater_than_or_equal_to: 0 } + SUPPORTED_KEY_TYPES.each do |type| validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } end diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index e2579316fdd..e9aab4a3d05 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -82,6 +82,7 @@ module ApplicationSettingImplementation polling_interval_multiplier: 1, project_export_enabled: true, protected_ci_variables: false, + push_event_hooks_limit: 3, raw_blob_request_limit: 300, recaptcha_enabled: false, login_recaptcha_protection_enabled: false, diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 30694313f7a..7ccd5e98360 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -9,7 +9,7 @@ class Deployment < ApplicationRecord belongs_to :environment, required: true belongs_to :cluster, class_name: 'Clusters::Cluster', optional: true belongs_to :user - belongs_to :deployable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations + belongs_to :deployable, polymorphic: true, optional: true # rubocop:disable Cop/PolymorphicAssociations has_internal_id :iid, scope: :project, init: ->(s) do Deployment.where(project: s.project).maximum(:iid) if s&.project @@ -22,6 +22,8 @@ class Deployment < ApplicationRecord scope :for_environment, -> (environment) { where(environment_id: environment) } + scope :visible, -> { where(status: %i[running success failed canceled]) } + state_machine :status, initial: :created do event :run do transition created: :running @@ -73,6 +75,10 @@ class Deployment < ApplicationRecord find(ids) end + def self.find_successful_deployment!(iid) + success.find_by!(iid: iid) + end + def commit project.commit(sha) end diff --git a/app/models/environment.rb b/app/models/environment.rb index fe438b142b2..af0c219d9a0 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -6,7 +6,8 @@ class Environment < ApplicationRecord belongs_to :project, required: true - has_many :deployments, -> { success }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :deployments, -> { visible }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :successful_deployments, -> { success }, class_name: 'Deployment' has_one :last_deployment, -> { success.order('deployments.id DESC') }, class_name: 'Deployment' @@ -81,6 +82,10 @@ class Environment < ApplicationRecord pluck(:name) end + def self.find_or_create_by_name(name) + find_or_create_by(name: name) + end + def predefined_variables Gitlab::Ci::Variables::Collection.new .append(key: 'CI_ENVIRONMENT_NAME', value: name) diff --git a/app/models/project.rb b/app/models/project.rb index 4d518862146..f1e232e95f8 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -281,7 +281,7 @@ class Project < ApplicationRecord has_many :variables, class_name: 'Ci::Variable' has_many :triggers, class_name: 'Ci::Trigger' has_many :environments - has_many :deployments, -> { success } + has_many :deployments has_many :pipeline_schedules, class_name: 'Ci::PipelineSchedule' has_many :project_deploy_tokens has_many :deploy_tokens, through: :project_deploy_tokens diff --git a/app/policies/deployment_policy.rb b/app/policies/deployment_policy.rb index d4f2f3c52b1..1a92b735e36 100644 --- a/app/policies/deployment_policy.rb +++ b/app/policies/deployment_policy.rb @@ -7,8 +7,20 @@ class DeploymentPolicy < BasePolicy can?(:update_build, @subject.deployable) end - rule { ~can_retry_deployable }.policy do + condition(:has_deployable) do + @subject.deployable.present? + end + + condition(:can_update_deployment) do + can?(:update_deployment, @subject.environment) + end + + rule { has_deployable & ~can_retry_deployable }.policy do prevent :create_deployment prevent :update_deployment end + + rule { ~can_update_deployment }.policy do + prevent :update_deployment + end end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index a3540f31077..ea2be37d7e6 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -262,6 +262,7 @@ class ProjectPolicy < BasePolicy enable :destroy_container_image enable :create_environment enable :create_deployment + enable :update_deployment enable :create_release enable :update_release end diff --git a/app/services/deployments/after_create_service.rb b/app/services/deployments/after_create_service.rb new file mode 100644 index 00000000000..2572802e6a1 --- /dev/null +++ b/app/services/deployments/after_create_service.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Deployments + class AfterCreateService + attr_reader :deployment + attr_reader :deployable + + delegate :environment, to: :deployment + delegate :variables, to: :deployable + delegate :options, to: :deployable, allow_nil: true + + def initialize(deployment) + @deployment = deployment + @deployable = deployment.deployable + end + + def execute + deployment.create_ref + deployment.invalidate_cache + + update_environment(deployment) + + deployment + end + + def update_environment(deployment) + ActiveRecord::Base.transaction do + if (url = expanded_environment_url) + environment.external_url = url + end + + environment.fire_state_event(action) + + if environment.save && !environment.stopped? + deployment.update_merge_request_metrics! + end + end + end + + private + + def environment_options + options&.dig(:environment) || {} + end + + def expanded_environment_url + ExpandVariables.expand(environment_url, -> { variables }) if environment_url + end + + def environment_url + environment_options[:url] + end + + def action + environment_options[:action] || 'start' + end + end +end + +Deployments::AfterCreateService.prepend_if_ee('EE::Deployments::AfterCreateService') diff --git a/app/services/deployments/create_service.rb b/app/services/deployments/create_service.rb new file mode 100644 index 00000000000..89e3f7c8b83 --- /dev/null +++ b/app/services/deployments/create_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Deployments + class CreateService + attr_reader :environment, :current_user, :params + + def initialize(environment, current_user, params) + @environment = environment + @current_user = current_user + @params = params + end + + def execute + create_deployment.tap do |deployment| + AfterCreateService.new(deployment).execute if deployment.persisted? + end + end + + def create_deployment + environment.deployments.create(deployment_attributes) + end + + def deployment_attributes + # We use explicit parameters here so we never by accident allow parameters + # to be set that one should not be able to set (e.g. the row ID). + { + cluster_id: environment.deployment_platform&.cluster_id, + project_id: environment.project_id, + environment_id: environment.id, + ref: params[:ref], + tag: params[:tag], + sha: params[:sha], + user: current_user, + on_stop: params[:on_stop], + status: params[:status] + } + end + end +end diff --git a/app/services/deployments/update_service.rb b/app/services/deployments/update_service.rb new file mode 100644 index 00000000000..7c8215d28f2 --- /dev/null +++ b/app/services/deployments/update_service.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Deployments + class UpdateService + attr_reader :deployment, :params + + def initialize(deployment, params) + @deployment = deployment + @params = params + end + + def execute + deployment.update(status: params[:status]) + end + end +end diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb index 97047d96de1..b1faef58e33 100644 --- a/app/services/git/base_hooks_service.rb +++ b/app/services/git/base_hooks_service.rb @@ -62,6 +62,8 @@ module Git end def execute_project_hooks + return unless params.fetch(:execute_project_hooks, true) + # Creating push_data invokes one CommitDelta RPC per commit. Only # build this data if we actually need it. project.execute_hooks(push_data, hook_name) if project.has_active_hooks?(hook_name) diff --git a/app/services/git/process_ref_changes_service.rb b/app/services/git/process_ref_changes_service.rb index 33925147750..62159d4e7e5 100644 --- a/app/services/git/process_ref_changes_service.rb +++ b/app/services/git/process_ref_changes_service.rb @@ -17,7 +17,7 @@ module Git changes_by_action = group_changes_by_action(changes) changes_by_action.each do |_, changes| - process_changes(ref_type, changes) if changes.any? + process_changes(ref_type, changes, execute_project_hooks: execute_project_hooks?(changes)) if changes.any? end end @@ -34,7 +34,11 @@ module Git :pushed end - def process_changes(ref_type, changes) + def execute_project_hooks?(changes) + (changes.size <= Gitlab::CurrentSettings.push_event_hooks_limit) || Feature.enabled?(:git_push_execute_all_project_hooks, project) + end + + def process_changes(ref_type, changes, execute_project_hooks:) push_service_class = push_service_class_for(ref_type) changes.each do |change| @@ -43,7 +47,8 @@ module Git current_user, change: change, push_options: params[:push_options], - create_pipelines: change[:index] < PIPELINE_PROCESS_LIMIT || Feature.enabled?(:git_push_create_all_pipelines, project) + create_pipelines: change[:index] < PIPELINE_PROCESS_LIMIT || Feature.enabled?(:git_push_create_all_pipelines, project), + execute_project_hooks: execute_project_hooks ).execute end end diff --git a/app/services/update_deployment_service.rb b/app/services/update_deployment_service.rb deleted file mode 100644 index 730210c611a..00000000000 --- a/app/services/update_deployment_service.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -class UpdateDeploymentService - attr_reader :deployment - attr_reader :deployable - - delegate :environment, to: :deployment - delegate :variables, to: :deployable - - def initialize(deployment) - @deployment = deployment - @deployable = deployment.deployable - end - - def execute - deployment.create_ref - deployment.invalidate_cache - - ActiveRecord::Base.transaction do - environment.external_url = expanded_environment_url if - expanded_environment_url - - environment.fire_state_event(action) - - break unless environment.save - break if environment.stopped? - - deployment.tap(&:update_merge_request_metrics!) - end - - deployment - end - - private - - def environment_options - @environment_options ||= deployable.options&.dig(:environment) || {} - end - - def expanded_environment_url - return @expanded_environment_url if defined?(@expanded_environment_url) - return unless environment_url - - @expanded_environment_url = - ExpandVariables.expand(environment_url, -> { variables }) - end - - def environment_url - environment_options[:url] - end - - def action - environment_options[:action] || 'start' - end -end - -UpdateDeploymentService.prepend_if_ee('EE::UpdateDeploymentService') diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml index b52171afc69..22458223b93 100644 --- a/app/views/admin/application_settings/_performance.html.haml +++ b/app/views/admin/application_settings/_performance.html.haml @@ -20,5 +20,10 @@ = f.number_field :raw_blob_request_limit, class: 'form-control' .form-text.text-muted = _('Highest number of requests per minute for each raw path, default to 300. To disable throttling set to 0.') + .form-group + = f.label :push_event_hooks_limit, class: 'label-bold' + = f.number_field :push_event_hooks_limit, class: 'form-control' + .form-text.text-muted + = _("Number of changes (branches or tags) in a single push to determine whether webhooks and services will be fired or not. Webhooks and services won't be submitted if it surpasses that value.") = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index 688b8f001c3..7c73bbc7479 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -1,6 +1,6 @@ - breadcrumb_title "Repository" - page_title @blob.path, @ref -- signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit) +- signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit, limit: 1) .js-signature-container{ data: { 'signatures-path': signatures_path } } diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml index ef2ab4c698e..8270477ed3f 100644 --- a/app/views/projects/deployments/_deployment.html.haml +++ b/app/views/projects/deployments/_deployment.html.haml @@ -1,31 +1,49 @@ .gl-responsive-table-row.deployment{ role: 'row' } + .table-section.section-15{ role: 'gridcell' } + .table-mobile-header{ role: 'rowheader' }= _("Status") + .table-mobile-content + = render_deployment_status(deployment) + .table-section.section-10{ role: 'gridcell' } .table-mobile-header{ role: 'rowheader' }= _("ID") %strong.table-mobile-content ##{deployment.iid} - .table-section.section-30{ role: 'gridcell' } + .table-section.section-10{ role: 'gridcell' } + .table-mobile-header{ role: 'rowheader' }= _("Triggerer") + .table-mobile-content + - if deployment.deployed_by + = user_avatar(user: deployment.deployed_by, size: 26, css_class: "mr-0 float-none") + + .table-section.section-25{ role: 'gridcell' } .table-mobile-header{ role: 'rowheader' }= _("Commit") = render 'projects/deployments/commit', deployment: deployment - .table-section.section-25.build-column{ role: 'gridcell' } + .table-section.section-10.build-column{ role: 'gridcell' } .table-mobile-header{ role: 'rowheader' }= _("Job") - if deployment.deployable .table-mobile-content .flex-truncate-parent .flex-truncate-child - = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable], class: 'build-link' do + = link_to deployment_path(deployment), class: 'build-link' do #{deployment.deployable.name} (##{deployment.deployable.id}) - - if deployment.deployed_by - %div - by - = user_avatar(user: deployment.deployed_by, size: 20, css_class: "mr-0 float-none") + - else + .badge.badge-info.suggestion-help-hover{ title: s_('Deployment|This deployment was created using the API') } + = s_('Deployment|API') - .table-section.section-15{ role: 'gridcell' } + .table-section.section-10{ role: 'gridcell' } .table-mobile-header{ role: 'rowheader' }= _("Created") + %span.table-mobile-content.flex-truncate-parent + %span.flex-truncate-child + = time_ago_with_tooltip(deployment.created_at) + + .table-section.section-10{ role: 'gridcell' } + .table-mobile-header{ role: 'rowheader' }= _("Deployed") - if deployment.deployed_at - %span.table-mobile-content= time_ago_with_tooltip(deployment.deployed_at) + %span.table-mobile-content.flex-truncate-parent + %span.flex-truncate-child + = time_ago_with_tooltip(deployment.deployed_at) - .table-section.section-20.table-button-footer{ role: 'gridcell' } + .table-section.section-10.table-button-footer{ role: 'gridcell' } .btn-group.table-action-buttons = render 'projects/deployments/actions', deployment: deployment = render 'projects/deployments/rollback', deployment: deployment diff --git a/app/views/projects/deployments/_rollback.haml b/app/views/projects/deployments/_rollback.haml index d6bf8d564de..dffa5e4ba40 100644 --- a/app/views/projects/deployments/_rollback.haml +++ b/app/views/projects/deployments/_rollback.haml @@ -1,4 +1,4 @@ -- if can?(current_user, :create_deployment, deployment) +- if deployment.deployable && can?(current_user, :create_deployment, deployment) - tooltip = deployment.last? ? s_('Environments|Re-deploy to environment') : s_('Environments|Rollback environment') = button_tag class: 'btn btn-default btn-build has-tooltip', type: 'button', data: { toggle: 'modal', target: "#confirm-rollback-modal-#{deployment.id}" }, title: tooltip do - if deployment.last? diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index 75da151f329..c4c39c227c6 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -60,10 +60,13 @@ .table-holder .ci-table.environments{ role: 'grid' } .gl-responsive-table-row.table-row-header{ role: 'row' } + .table-section.section-15{ role: 'columnheader' }= _('Status') .table-section.section-10{ role: 'columnheader' }= _('ID') - .table-section.section-30{ role: 'columnheader' }= _('Commit') - .table-section.section-25{ role: 'columnheader' }= _('Job') - .table-section.section-15{ role: 'columnheader' }= _('Created') + .table-section.section-10{ role: 'columnheader' }= _('Triggerer') + .table-section.section-25{ role: 'columnheader' }= _('Commit') + .table-section.section-10{ role: 'columnheader' }= _('Job') + .table-section.section-10{ role: 'columnheader' }= _('Created') + .table-section.section-10{ role: 'columnheader' }= _('Deployed') = render @deployments diff --git a/app/views/projects/issues/import_csv/_button.html.haml b/app/views/projects/issues/import_csv/_button.html.haml index acc2c50294f..fe89d2fb748 100644 --- a/app/views/projects/issues/import_csv/_button.html.haml +++ b/app/views/projects/issues/import_csv/_button.html.haml @@ -3,7 +3,7 @@ %button.csv-import-button.btn{ title: _('Import CSV'), class: ('has-tooltip' if type == :icon), data: { toggle: 'modal', target: '.issues-import-modal' } } - if type == :icon - = sprite_icon('upload') + = sprite_icon('import') - else = _('Import CSV') diff --git a/app/views/projects/releases/edit.html.haml b/app/views/projects/releases/edit.html.haml new file mode 100644 index 00000000000..88ca64f2af0 --- /dev/null +++ b/app/views/projects/releases/edit.html.haml @@ -0,0 +1,3 @@ +- page_title _('Edit Release') + +#js-edit-release-page{ data: data_for_edit_release_page } diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index 3f6cd628d64..c7bd0262c54 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -24,7 +24,7 @@ .text-secondary = icon('rocket') = _("Release") - = link_to release.name, project_releases_path(@project, anchor: release.tag), class: 'default-link-color' + = link_to release.name, project_releases_path(@project, anchor: release.tag), class: 'tag-release-link' - if release.description.present? .description.md.prepend-top-default = markdown_field(release, :description) diff --git a/app/views/shared/issuable/_feed_buttons.html.haml b/app/views/shared/issuable/_feed_buttons.html.haml index 83f60fa6fe2..4fed95e2607 100644 --- a/app/views/shared/issuable/_feed_buttons.html.haml +++ b/app/views/shared/issuable/_feed_buttons.html.haml @@ -1,4 +1,4 @@ -= link_to safe_params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: _('Subscribe to RSS feed') do - = icon('rss') += link_to safe_params.merge(rss_url_options), class: 'btn has-tooltip js-rss-button', data: { container: 'body' }, title: _('Subscribe to RSS feed') do + = sprite_icon('rss') = link_to safe_params.merge(calendar_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: _('Subscribe to calendar') do - = custom_icon('icon_calendar') + = sprite_icon('calendar') diff --git a/app/workers/deployments/success_worker.rb b/app/workers/deployments/success_worker.rb index da517f3fb26..3c7e384365a 100644 --- a/app/workers/deployments/success_worker.rb +++ b/app/workers/deployments/success_worker.rb @@ -10,7 +10,7 @@ module Deployments Deployment.find_by_id(deployment_id).try do |deployment| break unless deployment.success? - UpdateDeploymentService.new(deployment).execute + Deployments::AfterCreateService.new(deployment).execute end end end diff --git a/changelogs/unreleased/27715-fix-unrenderable-notes.yml b/changelogs/unreleased/27715-fix-unrenderable-notes.yml new file mode 100644 index 00000000000..329f9cbb30c --- /dev/null +++ b/changelogs/unreleased/27715-fix-unrenderable-notes.yml @@ -0,0 +1,5 @@ +--- +title: Fix showing diff when it has legacy diff notes +merge_request: 18510 +author: +type: fixed diff --git a/changelogs/unreleased/31009-limit-project-hooks-services.yml b/changelogs/unreleased/31009-limit-project-hooks-services.yml new file mode 100644 index 00000000000..dc1e0461567 --- /dev/null +++ b/changelogs/unreleased/31009-limit-project-hooks-services.yml @@ -0,0 +1,5 @@ +--- +title: Don't execute webhooks/services when above limit +merge_request: 17874 +author: +type: performance diff --git a/changelogs/unreleased/32380-update-issue-list-icons.yml b/changelogs/unreleased/32380-update-issue-list-icons.yml new file mode 100644 index 00000000000..42ad9b1eb99 --- /dev/null +++ b/changelogs/unreleased/32380-update-issue-list-icons.yml @@ -0,0 +1,5 @@ +--- +title: Use correct icons for issue actions +merge_request: +author: +type: other diff --git a/changelogs/unreleased/add-ansi2json-log-parser.yml b/changelogs/unreleased/add-ansi2json-log-parser.yml new file mode 100644 index 00000000000..1aec5d36fbe --- /dev/null +++ b/changelogs/unreleased/add-ansi2json-log-parser.yml @@ -0,0 +1,5 @@ +--- +title: Introduce new Ansi2json parser to convert job logs to JSON +merge_request: 18133 +author: +type: added diff --git a/changelogs/unreleased/allow-api-lookup-of-inherited-member-by-id.yml b/changelogs/unreleased/allow-api-lookup-of-inherited-member-by-id.yml new file mode 100644 index 00000000000..f266d197c6c --- /dev/null +++ b/changelogs/unreleased/allow-api-lookup-of-inherited-member-by-id.yml @@ -0,0 +1,5 @@ +--- +title: Add individual inherited member lookup API +merge_request: 17744 +author: +type: added diff --git a/changelogs/unreleased/deployments-api.yml b/changelogs/unreleased/deployments-api.yml new file mode 100644 index 00000000000..dce1763bdf1 --- /dev/null +++ b/changelogs/unreleased/deployments-api.yml @@ -0,0 +1,5 @@ +--- +title: Add API for manually creating and updating deployments +merge_request: 17620 +author: +type: added diff --git a/changelogs/unreleased/nfriend-add-edit-release-page.yml b/changelogs/unreleased/nfriend-add-edit-release-page.yml new file mode 100644 index 00000000000..5369ab6b19c --- /dev/null +++ b/changelogs/unreleased/nfriend-add-edit-release-page.yml @@ -0,0 +1,5 @@ +--- +title: Add "Edit Release" page +merge_request: 18033 +author: +type: added diff --git a/changelogs/unreleased/nfriend-fix-lin.yml b/changelogs/unreleased/nfriend-fix-lin.yml new file mode 100644 index 00000000000..0b16eb9c1f4 --- /dev/null +++ b/changelogs/unreleased/nfriend-fix-lin.yml @@ -0,0 +1,5 @@ +--- +title: Fix button link foreground color +merge_request: 18669 +author: +type: fixed diff --git a/config/helpers/is_ee_env.js b/config/helpers/is_ee_env.js index 801cf6abc81..78f0bd65528 100644 --- a/config/helpers/is_ee_env.js +++ b/config/helpers/is_ee_env.js @@ -3,12 +3,12 @@ const path = require('path'); const ROOT_PATH = path.resolve(__dirname, '../..'); -// The `IS_GITLAB_EE` is always `string` or `nil` +// The `FOSS_ONLY` is always `string` or `nil` // Thus the nil or empty string will result -// in using default value: true +// in using default value: false // // The behavior needs to be synchronised with // lib/gitlab.rb: Gitlab.ee? +const isFossOnly = JSON.parse(process.env.FOSS_ONLY || 'false'); module.exports = - fs.existsSync(path.join(ROOT_PATH, 'ee', 'app', 'models', 'license.rb')) && - (!process.env.IS_GITLAB_EE || JSON.parse(process.env.IS_GITLAB_EE)); + fs.existsSync(path.join(ROOT_PATH, 'ee', 'app', 'models', 'license.rb')) && !isFossOnly; diff --git a/config/webpack.config.js b/config/webpack.config.js index b5656040da2..25fb6cc5f5a 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -380,7 +380,7 @@ module.exports = { new webpack.DefinePlugin({ // This one is used to define window.gon.ee and other things properly in tests: - 'process.env.IS_GITLAB_EE': JSON.stringify(IS_EE), + 'process.env.IS_EE': JSON.stringify(IS_EE), // This one is used to check against "EE" properly in application code IS_EE: IS_EE ? 'window.gon && window.gon.ee' : JSON.stringify(false), }), diff --git a/db/migrate/20191003060227_add_push_event_hooks_limit_to_application_settings.rb b/db/migrate/20191003060227_add_push_event_hooks_limit_to_application_settings.rb new file mode 100644 index 00000000000..f107181bbde --- /dev/null +++ b/db/migrate/20191003060227_add_push_event_hooks_limit_to_application_settings.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddPushEventHooksLimitToApplicationSettings < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default(:application_settings, :push_event_hooks_limit, :integer, default: 3) + end + + def down + remove_column(:application_settings, :push_event_hooks_limit) + end +end diff --git a/db/schema.rb b/db/schema.rb index f3ec5658f33..ab01ba21ee0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -338,6 +338,7 @@ ActiveRecord::Schema.define(version: 2019_10_16_072826) do t.boolean "throttle_incident_management_notification_enabled", default: false, null: false t.integer "throttle_incident_management_notification_period_in_seconds", default: 3600 t.integer "throttle_incident_management_notification_per_period", default: 3600 + t.integer "push_event_hooks_limit", default: 3, null: false t.index ["custom_project_templates_group_id"], name: "index_application_settings_on_custom_project_templates_group_id" t.index ["file_template_project_id"], name: "index_application_settings_on_file_template_project_id" t.index ["instance_administration_project_id"], name: "index_applicationsettings_on_instance_administration_project_id" diff --git a/doc/administration/high_availability/README.md b/doc/administration/high_availability/README.md index fc2986380f3..de8c3336335 100644 --- a/doc/administration/high_availability/README.md +++ b/doc/administration/high_availability/README.md @@ -217,14 +217,19 @@ workload. Your workload is influenced by factors such as - but not limited to - how active your users are, how much automation you use, mirroring, and repo/change size. -- 3 PostgreSQL - 4 CPU, 16GiB memory per node -- 1 PgBouncer - 2 CPU, 4GiB memory -- 2 Redis - 2 CPU, 8GiB memory per node -- 3 Consul/Sentinel - 2 CPU, 2GiB memory per node -- 4 Sidekiq - 4 CPU, 16GiB memory per node -- 5 GitLab application nodes - 16 CPU, 64GiB memory per node -- 1 Gitaly - 16 CPU, 64GiB memory -- 1 Monitoring node - 2 CPU, 8GiB memory, 100GiB local storage +| Service | Configuration | GCP type | +| ------------------------------|-------------------------|----------------| +| 3 GitLab Rails <br> - Puma workers on each node set to 90% of available CPUs with 16 threads | 32 vCPU, 28.8GB Memory | n1-highcpu-32 | +| 3 PostgreSQL | 4 vCPU, 15GB Memory | n1-standard-4 | +| 1 PgBouncer | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | +| X Gitaly[^1] <br> - Gitaly Ruby workers on each node set to 90% of available CPUs with 16 threads | 16 vCPU, 60GB Memory | n1-standard-16 | +| 3 Redis Cache + Sentinel <br> - Cache maxmemory set to 90% of available memory | 4 vCPU, 15GB Memory | n1-standard-4 | +| 3 Redis Persistent + Sentinel | 4 vCPU, 15GB Memory | n1-standard-4 | +| 4 Sidekiq | 4 vCPU, 15GB Memory | n1-standard-4 | +| 3 Consul | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | +| 1 NFS Server | 16 vCPU, 14.4GB Memory | n1-highcpu-16 | +| 1 Monitoring node | 4 CPU, 3.6GB Memory | n1-highcpu-4 | +| 1 Load Balancing node[^2] . | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | ### 25,000 User Configuration @@ -249,7 +254,7 @@ adjusted prior to certification based on performance testing. | 3 Redis Persistent + Sentinel | 4 vCPU, 15GB Memory | n1-standard-4 | | 4 Sidekiq | 4 vCPU, 15GB Memory | n1-standard-4 | | 3 Consul | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | -| 1 NFS Server | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | +| 1 NFS Server | 16 vCPU, 14.4GB Memory | n1-highcpu-16 | | 1 Monitoring node | 4 CPU, 3.6GB Memory | n1-highcpu-4 | | 1 Load Balancing node[^2] . | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | @@ -277,15 +282,15 @@ testing. | 3 Redis Persistent + Sentinel | 4 vCPU, 15GB Memory | n1-standard-4 | | 4 Sidekiq | 4 vCPU, 15GB Memory | n1-standard-4 | | 3 Consul | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | -| 1 NFS Server | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | +| 1 NFS Server | 16 vCPU, 14.4GB Memory | n1-highcpu-16 | | 1 Monitoring node | 4 CPU, 3.6GB Memory | n1-highcpu-4 | | 1 Load Balancing node[^2] . | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | [^1]: Gitaly node requirements are dependent on customer data. We recommend 2 - nodes as an absolute minimum for performance at the 25,000 user scale and - 4 nodes as an absolute minimum at the 50,000 user scale, but additional - nodes should be considered in conjunction with a review of project counts - and sizes. + nodes as an absolute minimum for performance at the 10,000 and 25,000 user + scale and 4 nodes as an absolute minimum at the 50,000 user scale, but + additional nodes should be considered in conjunction with a review of + project counts and sizes. [^2]: HAProxy is the only tested and recommended load balancer. Additional options may be supported in the future. diff --git a/doc/api/deployments.md b/doc/api/deployments.md index df3a98b1dc8..27254c42e3a 100644 --- a/doc/api/deployments.md +++ b/doc/api/deployments.md @@ -223,3 +223,100 @@ Example of response } } ``` + +## Create a deployment + +``` +POST /projects/:id/deployments +``` + +| Attribute | Type | Required | Description | +|------------------|----------------|----------|---------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `environment` | string | yes | The name of the environment to create the deployment for | +| `sha` | string | yes | The SHA of the commit that is deployed | +| `ref` | string | yes | The name of the branch or tag that is deployed | +| `tag` | boolean | yes | A boolean that indicates if the deployed ref is a tag (true) or not (false) | +| `status` | string | yes | The status of the deployment | + +The status can be one of the following values: + +- created +- running +- success +- failed +- canceled + +```bash +curl --data "environment=production&sha=a91957a858320c0e17f3a0eca7cfacbff50ea29a&ref=master&tag=false&status=success" --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/deployments" +``` + +Example of a response: + +```json +{ + "id": 42, + "iid": 2, + "ref": "master", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "created_at": "2016-08-11T11:32:35.444Z", + "status": "success", + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "environment": { + "id": 9, + "name": "production", + "external_url": "https://about.gitlab.com" + }, + "deployable": null +} +``` + +## Updating a deployment + +``` +PUT /projects/:id/deployments/:deployment_id +``` + +| Attribute | Type | Required | Description | +|------------------|----------------|----------|---------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `deployment_id` | integer | yes | The ID of the deployment to update | +| `status` | string | yes | The new status of the deployment | + +```bash +curl --request PUT --data "status=success" --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/deployments/42" +``` + +Example of a response: + +```json +{ + "id": 42, + "iid": 2, + "ref": "master", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "created_at": "2016-08-11T11:32:35.444Z", + "status": "success", + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "environment": { + "id": 9, + "name": "production", + "external_url": "https://about.gitlab.com" + }, + "deployable": null +} +``` diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 1efda2f07eb..b21fc9bfb18 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -54,9 +54,87 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `message` | String | | | `authoredDate` | Time | | | `webUrl` | String! | | +| `signatureHtml` | String | Rendered html for the commit signature | | `author` | User | | | `latestPipeline` | Pipeline | Latest pipeline for this commit | +### CreateDiffNotePayload + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `note` | Note | The note after mutation | + +### CreateImageDiffNotePayload + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `note` | Note | The note after mutation | + +### CreateNotePayload + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `note` | Note | The note after mutation | + +### Design + +| Name | Type | Description | +| --- | ---- | ---------- | +| `id` | ID! | | +| `project` | Project! | | +| `issue` | Issue! | | +| `notesCount` | Int! | The total count of user-created notes for this design | +| `filename` | String! | | +| `fullPath` | String! | | +| `event` | DesignVersionEvent! | The change that happened to the design at this version | +| `image` | String! | | +| `diffRefs` | DiffRefs! | | + +### DesignCollection + +| Name | Type | Description | +| --- | ---- | ---------- | +| `project` | Project! | | +| `issue` | Issue! | | + +### DesignManagementDeletePayload + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `version` | DesignVersion | The new version in which the designs are deleted | + +### DesignManagementUploadPayload + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `designs` | Design! => Array | The designs that were uploaded by the mutation | +| `skippedDesigns` | Design! => Array | Any designs that were skipped from the upload due to there being no change to their content since their last version | + +### DesignVersion + +| Name | Type | Description | +| --- | ---- | ---------- | +| `id` | ID! | | +| `sha` | ID! | | + +### DestroyNotePayload + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `note` | Note | The note after mutation | + ### DetailedStatus | Name | Type | Description | @@ -74,9 +152,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | Name | Type | Description | | --- | ---- | ---------- | -| `headSha` | String! | The sha of the head at the time the comment was made | -| `baseSha` | String | The merge base of the branch the comment was made on | -| `startSha` | String! | The sha of the branch being compared against | +| `diffRefs` | DiffRefs! | | | `filePath` | String! | The path of the file that was changed | | `oldPath` | String | The path of the file on the start sha. | | `newPath` | String | The path of the file on the head sha. | @@ -88,13 +164,146 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `width` | Int | The total width of the image | | `height` | Int | The total height of the image | +### DiffRefs + +| Name | Type | Description | +| --- | ---- | ---------- | +| `headSha` | String! | The sha of the head at the time the comment was made | +| `baseSha` | String! | The merge base of the branch the comment was made on | +| `startSha` | String! | The sha of the branch being compared against | + ### Discussion | Name | Type | Description | | --- | ---- | ---------- | | `id` | ID! | | +| `replyId` | ID! | The ID used to reply to this discussion | | `createdAt` | Time! | | +### Epic + +| Name | Type | Description | +| --- | ---- | ---------- | +| `userPermissions` | EpicPermissions! | Permissions for the current user on the resource | +| `id` | ID! | | +| `iid` | ID! | | +| `title` | String | | +| `description` | String | | +| `state` | EpicState! | | +| `group` | Group! | | +| `parent` | Epic | | +| `author` | User! | | +| `startDate` | Time | | +| `startDateIsFixed` | Boolean | | +| `startDateFixed` | Time | | +| `startDateFromMilestones` | Time | | +| `dueDate` | Time | | +| `dueDateIsFixed` | Boolean | | +| `dueDateFixed` | Time | | +| `dueDateFromMilestones` | Time | | +| `closedAt` | Time | | +| `createdAt` | Time | | +| `updatedAt` | Time | | +| `hasChildren` | Boolean! | | +| `hasIssues` | Boolean! | | +| `webPath` | String! | | +| `webUrl` | String! | | +| `relativePosition` | Int | The relative position of the epic in the Epic tree | +| `relationPath` | String | | +| `reference` | String! | | + +### EpicIssue + +| Name | Type | Description | +| --- | ---- | ---------- | +| `userPermissions` | IssuePermissions! | Permissions for the current user on the resource | +| `iid` | ID! | | +| `title` | String! | | +| `titleHtml` | String | The GitLab Flavored Markdown rendering of `title` | +| `description` | String | | +| `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` | +| `state` | IssueState! | | +| `reference` | String! | | +| `author` | User! | | +| `milestone` | Milestone | | +| `dueDate` | Time | | +| `confidential` | Boolean! | | +| `discussionLocked` | Boolean! | | +| `upvotes` | Int! | | +| `downvotes` | Int! | | +| `userNotesCount` | Int! | | +| `webPath` | String! | | +| `webUrl` | String! | | +| `relativePosition` | Int | | +| `timeEstimate` | Int! | The time estimate on the issue | +| `totalTimeSpent` | Int! | Total time reported as spent on the issue | +| `closedAt` | Time | | +| `createdAt` | Time! | | +| `updatedAt` | Time! | | +| `taskCompletionStatus` | TaskCompletionStatus! | | +| `epic` | Epic | The epic to which issue belongs | +| `weight` | Int | | +| `designs` | DesignCollection | | +| `designCollection` | DesignCollection | | +| `epicIssueId` | ID! | | +| `relationPath` | String | | +| `id` | ID | The global id of the epic-issue relation | + +### EpicPermissions + +| Name | Type | Description | +| --- | ---- | ---------- | +| `readEpic` | Boolean! | Whether or not a user can perform `read_epic` on this resource | +| `readEpicIid` | Boolean! | Whether or not a user can perform `read_epic_iid` on this resource | +| `updateEpic` | Boolean! | Whether or not a user can perform `update_epic` on this resource | +| `destroyEpic` | Boolean! | Whether or not a user can perform `destroy_epic` on this resource | +| `adminEpic` | Boolean! | Whether or not a user can perform `admin_epic` on this resource | +| `createEpic` | Boolean! | Whether or not a user can perform `create_epic` on this resource | +| `createNote` | Boolean! | Whether or not a user can perform `create_note` on this resource | +| `awardEmoji` | Boolean! | Whether or not a user can perform `award_emoji` on this resource | + +### EpicTreeReorderPayload + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | + +### ExtendedIssue + +| Name | Type | Description | +| --- | ---- | ---------- | +| `userPermissions` | IssuePermissions! | Permissions for the current user on the resource | +| `iid` | ID! | | +| `title` | String! | | +| `titleHtml` | String | The GitLab Flavored Markdown rendering of `title` | +| `description` | String | | +| `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` | +| `state` | IssueState! | | +| `reference` | String! | | +| `author` | User! | | +| `milestone` | Milestone | | +| `dueDate` | Time | | +| `confidential` | Boolean! | | +| `discussionLocked` | Boolean! | | +| `upvotes` | Int! | | +| `downvotes` | Int! | | +| `userNotesCount` | Int! | | +| `webPath` | String! | | +| `webUrl` | String! | | +| `relativePosition` | Int | | +| `timeEstimate` | Int! | The time estimate on the issue | +| `totalTimeSpent` | Int! | Total time reported as spent on the issue | +| `closedAt` | Time | | +| `createdAt` | Time! | | +| `updatedAt` | Time! | | +| `taskCompletionStatus` | TaskCompletionStatus! | | +| `epic` | Epic | The epic to which issue belongs | +| `weight` | Int | | +| `designs` | DesignCollection | | +| `designCollection` | DesignCollection | | +| `subscribed` | Boolean! | Boolean flag for whether the currently logged in user is subscribed to this issue | + ### Group | Name | Type | Description | @@ -109,11 +318,13 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `visibility` | String | | | `lfsEnabled` | Boolean | | | `requestAccessEnabled` | Boolean | | -| `rootStorageStatistics` | RootStorageStatistics | The aggregated storage statistics. Only available if the namespace has no parent | +| `rootStorageStatistics` | RootStorageStatistics | The aggregated storage statistics. Only available for root namespaces | | `userPermissions` | GroupPermissions! | Permissions for the current user on the resource | | `webUrl` | String! | | | `avatarUrl` | String | | | `parent` | Group | | +| `epicsEnabled` | Boolean | | +| `epic` | Epic | | ### GroupPermissions @@ -144,10 +355,16 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `webPath` | String! | | | `webUrl` | String! | | | `relativePosition` | Int | | +| `timeEstimate` | Int! | The time estimate on the issue | +| `totalTimeSpent` | Int! | Total time reported as spent on the issue | | `closedAt` | Time | | | `createdAt` | Time! | | | `updatedAt` | Time! | | | `taskCompletionStatus` | TaskCompletionStatus! | | +| `epic` | Epic | The epic to which issue belongs | +| `weight` | Int | | +| `designs` | DesignCollection | | +| `designCollection` | DesignCollection | | ### IssuePermissions @@ -158,6 +375,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `updateIssue` | Boolean! | Whether or not a user can perform `update_issue` on this resource | | `createNote` | Boolean! | Whether or not a user can perform `create_note` on this resource | | `reopenIssue` | Boolean! | Whether or not a user can perform `reopen_issue` on this resource | +| `readDesign` | Boolean! | Whether or not a user can perform `read_design` on this resource | +| `createDesign` | Boolean! | Whether or not a user can perform `create_design` on this resource | +| `destroyDesign` | Boolean! | Whether or not a user can perform `destroy_design` on this resource | ### Label @@ -185,6 +405,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `updatedAt` | Time! | | | `sourceProject` | Project | | | `targetProject` | Project! | | +| `diffRefs` | DiffRefs | | | `project` | Project! | | | `projectId` | Int! | | | `sourceProjectId` | Int | | @@ -271,6 +492,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `visibility` | String | | | `lfsEnabled` | Boolean | | | `requestAccessEnabled` | Boolean | | +| `rootStorageStatistics` | RootStorageStatistics | The aggregated storage statistics. Only available for root namespaces | ### Note @@ -381,7 +603,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `statistics` | ProjectStatistics | | | `repository` | Repository | | | `mergeRequest` | MergeRequest | | -| `issue` | Issue | | +| `issue` | ExtendedIssue | | ### ProjectPermissions @@ -424,6 +646,10 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `createPages` | Boolean! | Whether or not a user can perform `create_pages` on this resource | | `destroyPages` | Boolean! | Whether or not a user can perform `destroy_pages` on this resource | | `readPagesContent` | Boolean! | Whether or not a user can perform `read_pages_content` on this resource | +| `adminOperations` | Boolean! | Whether or not a user can perform `admin_operations` on this resource | +| `readDesign` | Boolean! | Whether or not a user can perform `read_design` on this resource | +| `createDesign` | Boolean! | Whether or not a user can perform `create_design` on this resource | +| `destroyDesign` | Boolean! | Whether or not a user can perform `destroy_design` on this resource | ### ProjectStatistics @@ -458,12 +684,12 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | Name | Type | Description | | --- | ---- | ---------- | -| `storageSize` | Int! | The total storage in Bytes | -| `repositorySize` | Int! | The Git repository size in Bytes | -| `lfsObjectsSize` | Int! | The LFS objects size in Bytes | -| `buildArtifactsSize` | Int! | The CI artifacts size in Bytes | -| `packagesSize` | Int! | The packages size in Bytes | -| `wikiSize` | Int! | The wiki size in Bytes | +| `storageSize` | Int! | The total storage in bytes | +| `repositorySize` | Int! | The git repository size in bytes | +| `lfsObjectsSize` | Int! | The LFS objects size in bytes | +| `buildArtifactsSize` | Int! | The CI artifacts size in bytes | +| `packagesSize` | Int! | The packages size in bytes | +| `wikiSize` | Int! | The wiki size in bytes | ### Submodule @@ -474,6 +700,8 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `type` | EntryType! | | | `path` | String! | | | `flatPath` | String! | | +| `webUrl` | String | | +| `treeUrl` | String | | ### TaskCompletionStatus @@ -495,7 +723,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | Name | Type | Description | | --- | ---- | ---------- | -| `lastCommit` | Commit | | +| `lastCommit` | Commit | Last commit for the tree | ### TreeEntry @@ -508,6 +736,14 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `flatPath` | String! | | | `webUrl` | String | | +### UpdateNotePayload + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `note` | Note | The note after mutation | + ### User | Name | Type | Description | diff --git a/doc/api/members.md b/doc/api/members.md index da62dc53659..50dcf86c972 100644 --- a/doc/api/members.md +++ b/doc/api/members.md @@ -26,6 +26,7 @@ GET /projects/:id/members | --------- | ---- | -------- | ----------- | | `id` | integer/string | yes | The ID or [URL-encoded path of the project or group](README.md#namespaced-path-encoding) owned by the authenticated user | | `query` | string | no | A query string to search for members | +| `user_ids` | array of integers | no | Filter the results on the given user IDs | ```bash curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/:id/members @@ -62,9 +63,8 @@ Example response: ## List all members of a group or project including inherited members Gets a list of group or project members viewable by the authenticated user, including inherited members through ancestor groups. -When a user is a member of the project/group and of one or more ancestor groups the user is returned only once with the project access_level (if exists) -or the access_level for the user in the first group which he belongs to in the project groups ancestors chain. -**Note:** We plan to [change](https://gitlab.com/gitlab-org/gitlab-foss/issues/62284) this behavior to return highest access_level instead. +When a user is a member of the project/group and of one or more ancestor groups the user is returned only once with the project `access_level` (if exists) +or the `access_level` for the user in the first group which he belongs to in the project groups ancestors chain. ``` GET /groups/:id/members/all @@ -75,6 +75,7 @@ GET /projects/:id/members/all | --------- | ---- | -------- | ----------- | | `id` | integer/string | yes | The ID or [URL-encoded path of the project or group](README.md#namespaced-path-encoding) owned by the authenticated user | | `query` | string | no | A query string to search for members | +| `user_ids` | array of integers | no | Filter the results on the given user IDs | ```bash curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/:id/members/all @@ -120,7 +121,7 @@ Example response: ## Get a member of a group or project -Gets a member of a group or project. +Gets a member of a group or project. Returns only direct members and not inherited members through ancestor groups. ``` GET /groups/:id/members/:user_id @@ -152,6 +153,42 @@ Example response: } ``` +## Get a member of a group or project, including inherited members + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/17744) in GitLab 12.4. + +Gets a member of a group or project, including members inherited through ancestor groups. See the corresponding [endpoint to list all inherited members](#list-all-members-of-a-group-or-project-including-inherited-members) for details. + +``` +GET /groups/:id/members/all/:user_id +GET /projects/:id/members/all/:user_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project or group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `user_id` | integer | yes | The user ID of the member | + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/:id/members/all/:user_id +curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/:id/members/all/:user_id +``` + +Example response: + +```json +{ + "id": 1, + "username": "raymond_smith", + "name": "Raymond Smith", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/c2525a7f58ae3776070e44c106c48e15?s=80&d=identicon", + "web_url": "http://192.168.1.8:3000/root", + "access_level": 30, + "expires_at": null +} +``` + ## Add a member to a group or project Adds a member to a group or project. diff --git a/doc/api/settings.md b/doc/api/settings.md index efb6809794f..24e6f90e844 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -289,6 +289,7 @@ are listed in the descriptions of the relevant settings. | `prometheus_metrics_enabled` | boolean | no | Enable Prometheus metrics. | | `protected_ci_variables` | boolean | no | Environment variables are protected by default. | | `pseudonymizer_enabled` | boolean | no | **(PREMIUM)** When enabled, GitLab will run a background job that will produce pseudonymized CSVs of the GitLab database that will be uploaded to your configured object storage directory. +| `push_event_hooks_limit` | integer | no | Number of changes (branches or tags) in a single push to determine whether webhooks and services will be fired or not. Webhooks and services won't be submitted if it surpasses that value. | | `recaptcha_enabled` | boolean | no | (**If enabled, requires:** `recaptcha_private_key` and `recaptcha_site_key`) Enable reCAPTCHA. | | `recaptcha_private_key` | string | required by: `recaptcha_enabled` | Private key for reCAPTCHA. | | `recaptcha_site_key` | string | required by: `recaptcha_enabled` | Site key for reCAPTCHA. | diff --git a/doc/development/chatops_on_gitlabcom.md b/doc/development/chatops_on_gitlabcom.md index a1c07ee2a1e..8a313a120f1 100644 --- a/doc/development/chatops_on_gitlabcom.md +++ b/doc/development/chatops_on_gitlabcom.md @@ -14,7 +14,7 @@ tasks such as: To request access to Chatops on GitLab.com: 1. Log into <https://ops.gitlab.net/users/sign_in> **using the same username** as for GitLab.com (you may have to rename it). -1. Ask [anyone in the `chatops` project](https://gitlab.com/gitlab-com/chatops/-/project_members) to add you by running `/chatops run member add <username> gitlab-com/chatops --ops`. +1. Ask [an owner/maintainer in the `chatops` project](https://gitlab.com/gitlab-com/chatops/-/project_members?search=&sort=access_level_desc) to add you by running `/chatops run member add <username> gitlab-com/chatops --ops`. ## See also diff --git a/doc/development/documentation/styleguide.md b/doc/development/documentation/styleguide.md index 988f82118cb..efff477eff6 100644 --- a/doc/development/documentation/styleguide.md +++ b/doc/development/documentation/styleguide.md @@ -492,19 +492,50 @@ For other punctuation rules, please refer to the - Use inline link markdown markup `[Text](https://example.com)`. It's easier to read, review, and maintain. **Do not** use `[Text][identifier]`. -- To link to internal documentation, use relative links, not full URLs. Use `../` to - navigate to high-level directories, and always add the file name `file.md` at the - end of the link with the `.md` extension, not `.html`. - Example: instead of `[text](../../merge_requests/)`, use - `[text](../../merge_requests/index.md)` or, `[text](../../ci/README.md)`, or, - for anchor links, `[text](../../ci/README.md#examples)`. - Using the markdown extension is necessary for the [`/help`](index.md#gitlab-help) - section of GitLab. -- To link from CE to EE-only documentation, use the EE-only doc full URL. + - Use [meaningful anchor texts](https://www.futurehosting.com/blog/links-should-have-meaningful-anchor-text-heres-why/). 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 internal documentation + +- To link to internal documentation, use relative links, not full URLs. + Use `../` to navigate to high-level directories. Links should not refer to root. + + Don't: + + ```md + [Geo Troubleshooting](https://docs.gitlab.com/ee/administration/geo/replication/troubleshooting.html) + [Geo Troubleshooting](/ee/administration/geo/replication/troubleshooting.md) + ``` + + Do: + + ```md + [Geo Troubleshooting](../../geo/replication/troubleshooting.md) + ``` + +- Always add the file name `file.md` at the end of the link with the `.md` extension, not `.html`. + + Don't: + + ```md + [merge requests](../../merge_requests/) + [issues](../../issues/tags.html) + [issue tags](../../issues/tags.html#stages) + ``` + + Do: + + ```md + [merge requests](../../merge_requests/index.md) + [issues](../../issues/tags.md) + [issue tags](../../issues/tags.md#stages) + ``` + +- Using the markdown extension is necessary for the [`/help`](index.md#gitlab-help) + section of GitLab. + ### Links requiring permissions Don't link directly to: diff --git a/doc/development/ee_features.md b/doc/development/ee_features.md index f89371f38ce..cc9df479492 100644 --- a/doc/development/ee_features.md +++ b/doc/development/ee_features.md @@ -20,9 +20,9 @@ should be added for EE. Licensed features can be stubbed using the spec helper `stub_licensed_features` in `EE::LicenseHelpers`. You can force GitLab to act as CE by either deleting the `ee/` directory or by -setting the [`IS_GITLAB_EE` environment variable](https://gitlab.com/gitlab-org/gitlab/blob/master/config/helpers/is_ee_env.js) -to something that evaluates as `false`. The same works for running tests -(for example `IS_GITLAB_EE=0 yarn jest`). +setting the [`FOSS_ONLY` environment variable](https://gitlab.com/gitlab-org/gitlab/blob/master/config/helpers/is_ee_env.js) +to something that evaluates as `true`. The same works for running tests +(for example `FOSS_ONLY=1 yarn jest`). [ee-as-ce]: https://gitlab.com/gitlab-org/gitlab/issues/2500 diff --git a/doc/development/pipelines.md b/doc/development/pipelines.md index 6520c7dbbcf..5954de03db4 100644 --- a/doc/development/pipelines.md +++ b/doc/development/pipelines.md @@ -102,7 +102,7 @@ These common definitions are: `docker.elastic.co/elasticsearch/elasticsearch:5.6.12` services. - `.only-ee`: Only creates a job for the `gitlab` project. - `.only-ee-as-if-foss`: Same as `.only-ee` but simulate the FOSS project by - setting the `IS_GITLAB_EE='0'` environment variable. + setting the `FOSS_ONLY='1'` environment variable. ## Changes detection @@ -115,6 +115,7 @@ from a commit or MR by extending from the following CI definitions: - `.only-qa-changes`: Allows a job to only be created upon QA-related changes. - `.only-docs-changes`: Allows a job to only be created upon docs-related changes. - `.only-code-qa-changes`: Allows a job to only be created upon code-related or QA-related changes. +- `.only-graphql-changes`: Allows a job to only be created upon graphql-related changes. **See <https://gitlab.com/gitlab-org/gitlab/blob/master/.gitlab/ci/global.gitlab-ci.yml> for the list of exact patterns.** @@ -127,7 +128,7 @@ execute jobs out of order for the following jobs: ```mermaid graph RL; A[setup-test-env]; - B["gitlab:assets:compile<br/>(master only)"]; + B["gitlab:assets:compile pull-push-cache<br/>(master only)"]; C[gitlab:assets:compile pull-cache]; D["cache gems<br/>(master and tags only)"]; E[review-build-cng]; @@ -136,7 +137,7 @@ graph RL; G2["schedule:review-deploy<br/>(master only)"]; H[karma]; I[jest]; - J["compile-assets<br/>(master only)"]; + J["compile-assets pull-push-cache<br/>(master only)"]; K[compile-assets pull-cache]; L[webpack-dev-server]; M[coverage]; @@ -145,39 +146,42 @@ graph RL; P["schedule:package-and-qa<br/>(master schedule only)"]; Q[package-and-qa]; R[package-and-qa-manual]; + S["RSpec<br/>(e.g. rspec unit pg9)"] + T[retrieve-tests-metadata]; subgraph "`prepare` stage" A F - J K + J + T end subgraph "`test` stage" B --> |needs| A; C --> |needs| A; D --> |needs| A; - H -.-> |depends on| A; - H -.-> |depends on| J; - H -.-> |depends on| K; - I -.-> |depends on| A; - I -.-> |depends on| J; - I -.-> |depends on| K; - L -.-> |depends on| A; - L -.-> |depends on| J; - L -.-> |depends on| K; + H -.-> |needs and depends on| A; + H -.-> |needs and depends on| K; + I -.-> |needs and depends on| A; + I -.-> |needs and depends on| K; + L -.-> |needs and depends on| A; + L -.-> |needs and depends on| K; + O -.-> |needs and depends on| A; + O -.-> |needs and depends on| K; + S -.-> |needs and depends on| A; + S -.-> |needs and depends on| K; + S -.-> |needs and depends on| T; downtime_check --> |needs and depends on| A; db:* --> |needs| A; gitlab:setup --> |needs| A; - O -.-> |depends on| A; - O -.-> |depends on| B; - O -.-> |depends on| C; downtime_check --> |needs and depends on| A; + graphql-docs-verify --> |needs| A; end subgraph "`review-prepare` stage" E --> |needs| C; - X["schedule:review-build-cng<br/>(master schedule only)"] --> |needs| B; + X["schedule:review-build-cng<br/>(master schedule only)"] --> |needs| C; end subgraph "`review` stage" @@ -190,7 +194,7 @@ subgraph "`qa` stage" Q --> |needs| F; R --> |needs| C; R --> |needs| F; - P --> |needs| B; + P --> |needs| C; P --> |needs| F; review-qa-smoke -.-> |needs and depends on| G; review-qa-all -.-> |needs and depends on| G; @@ -209,7 +213,7 @@ subgraph "`post-test` stage" end subgraph "`pages` stage" - N -.-> |depends on| B; + N -.-> |depends on| C; N -.-> |depends on| H; N -.-> |depends on| M; end diff --git a/doc/user/group/epics/img/child_epics_roadmap.png b/doc/user/group/epics/img/child_epics_roadmap.png Binary files differdeleted file mode 100644 index 819fed58989..00000000000 --- a/doc/user/group/epics/img/child_epics_roadmap.png +++ /dev/null diff --git a/doc/user/group/epics/img/epic_view.png b/doc/user/group/epics/img/epic_view.png Binary files differdeleted file mode 100644 index c55d302ec29..00000000000 --- a/doc/user/group/epics/img/epic_view.png +++ /dev/null diff --git a/doc/user/group/epics/img/epic_view_roadmap_v12.3.png b/doc/user/group/epics/img/epic_view_roadmap_v12.3.png Binary files differnew file mode 100755 index 00000000000..a17c56c618b --- /dev/null +++ b/doc/user/group/epics/img/epic_view_roadmap_v12.3.png diff --git a/doc/user/group/epics/img/epic_view_v12.3.png b/doc/user/group/epics/img/epic_view_v12.3.png Binary files differnew file mode 100755 index 00000000000..79758cf3d52 --- /dev/null +++ b/doc/user/group/epics/img/epic_view_v12.3.png diff --git a/doc/user/group/epics/img/epics_list_view.png b/doc/user/group/epics/img/epics_list_view.png Binary files differdeleted file mode 100644 index b30608d9d31..00000000000 --- a/doc/user/group/epics/img/epics_list_view.png +++ /dev/null diff --git a/doc/user/group/epics/img/epics_list_view_v12.3.png b/doc/user/group/epics/img/epics_list_view_v12.3.png Binary files differnew file mode 100755 index 00000000000..c6817a503e7 --- /dev/null +++ b/doc/user/group/epics/img/epics_list_view_v12.3.png diff --git a/doc/user/group/epics/index.md b/doc/user/group/epics/index.md index 51e779cce6a..f9690d4edfe 100644 --- a/doc/user/group/epics/index.md +++ b/doc/user/group/epics/index.md @@ -10,13 +10,13 @@ Epics let you manage your portfolio of projects more efficiently and with less effort by tracking groups of issues that share a theme, across projects and milestones. -![epics list view](img/epics_list_view.png) +![epics list view](img/epics_list_view_v12.3.png) ## Use cases - Suppose your team is working on a large feature that involves multiple discussions throughout different issues created in distinct projects within a [Group](../index.md). With Epics, you can track all the related activities that together contribute to that single feature. - Track when the work for the group of issues is targeted to begin, and when it is targeted to end. -- Discuss and collaborate on feature ideas and scope at a high-level. +- Discuss and collaborate on feature ideas and scope at a high level. ## Creating an epic @@ -24,78 +24,114 @@ A paginated list of epics is available in each group from where you can create a new epic. The list of epics includes also epics from all subgroups of the selected group. From your group page: -1. Go to **Epics** -1. Click the **New epic** button at the top right -1. Enter a descriptive title and hit **Create epic** +1. Go to **Epics**. +1. Click **New epic**. +1. Enter a descriptive title and click **Create epic**. -Once created, you will be taken to the view for that newly-created epic where -you can change its title, description, start date, and due date. +You will be taken to the new epic where can edit the following details: -![epic view](img/epic_view.png) +- Title +- Description +- Start date +- Due date +- Labels + +An epic's page contains the following tabs: + +- **Epics and Issues**: epics and issues added to this epic. Child epics, and their issues, are shown in a tree view. + - Click on the <kbd>></kbd> beside a parent epic to reveal the child epics and issues. +- **Roadmap**: a roadmap view of child epics which have start and due dates. + +![epic view](img/epic_view_v12.3.png) ## Adding an issue to an epic +Any issue that belongs to a project in the epic's group, or any of the epic's +subgroups, are eligible to be added. New issues appear at the top of the list of issues in the **Epics and Issues** tab. + An epic contains a list of issues and an issue can be associated with at most -one epic. When on an epic, you can add its associated issues: +one epic. When you add an issue to an epic that is already associated with another epic, +the issue is automatically removed from the previous epic. + +To add an issue to an epic: -1. Click the plus icon (<kbd>+</kbd>) under the epic description. -1. Paste the link of the issue (you can hit <kbd>Spacebar</kbd> to add more than - one issues at a time). +1. Click **Add an issue**. +1. Paste the link of the issue. + - Press <kbd>Spacebar</kbd> and repeat this step if there are multiple issues. 1. Click **Add**. -Any issue belonging to a project in the epic's group or any of the epic's -subgroups are eligible to be added. To remove an issue from an epic, click -on the <kbd>x</kbd> button in the epic's issue list. +To remove an issue from an epic: -NOTE: **Note:** -When you add an issue or an epic to an epic that's already associated with another epic, -the issue or the epic is automatically removed from the previous epic. +1. Click on the <kbd>x</kbd> button in the epic's issue list. +1. Click **Remove** in the **Remove issue** warning message. ## Multi-level child epics > [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/8333) in GitLab Ultimate 11.7. -Much like adding issues to an epic, an epic can have multiple child epics with -the maximum depth being 5. To add a child epic: +Any epic that belongs to a group, or subgroup of the parent epic's group, is +eligible to be added. New child epics appear at the top of the list of epics in the **Epics and Issues** tab. + +When you add a child epic that is already associated with another epic, +that epic is automatically removed from the previous epic. -1. Click the plus icon (<kbd>+</kbd>) under the epic description. +An epic can have multiple child epics with +the maximum depth being 5. + +To add a child epic: + +1. Click **Add an epic**. 1. Paste the link of the epic. + - Press <kbd>Spacebar</kbd> and repeat this step if there are multiple issues. 1. Click **Add**. -Any epic that belongs to a group or subgroup of the parent epic's group is -eligible to be added. To remove a child epic from a parent epic, -click on the <kbd>x</kbd> button in the parent epic's epic list. +To remove a child epic from a parent epic: + +1. Click on the <kbd>x</kbd> button in the parent epic's list of epics. +1. Click **Remove** in the **Remove epic** warning message. ## Start date and due date -For each of the dates in the sidebar of an epic, you can choose to either: +To set a **Start date** and **Due date** for an epic, you can choose either of the following: -- Enter a fixed value. -- Inherit a dynamic value called "From milestones". +- **Fixed**: Enter a fixed value. +- **From milestones:** Inherit a dynamic value from the issues added to the epic. -If you select "From milestones" for the start date, GitLab will automatically set the +If you select **From milestones** for the start date, GitLab will automatically set the date to be earliest start date across all milestones that are currently assigned -to the issues that are attached to the epic. Similarly, if you select "From milestones" +to the issues that are added to the epic. Similarly, if you select "From milestones" for the due date, GitLab will set it to be the latest due date across all milestones that are currently assigned to those issues. -These are dynamic dates in that if milestones are re-assigned to the issues, if the -milestone dates change, or if issues are added or removed from the epic, then -the re-calculation will happen immediately to set a new dynamic date. +These are dynamic dates which are recalculated immediately if any of the following occur: + +- Milestones are re-assigned to the issues. +- Milestone dates change. +- Issues are added or removed from the epic. -## Roadmap in epics +## Roadmap > [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/7327) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.10. If your epic contains one or more [child epics](#multi-level-child-epics) which -have a [start or due date](#start-date-and-due-date), then you can see a -[roadmap](../roadmap/index.md) view of the child epics under the parent epic itself. +have a [start or due date](#start-date-and-due-date), a +[roadmap](../roadmap/index.md) view of the child epics is listed under the parent epic. -![Child epics roadmap](img/child_epics_roadmap.png) +![Child epics roadmap](img/epic_view_roadmap_v12.3.png) ## Reordering issues and child epics -Drag and drop to reorder issues and child epics. New issues and child epics added to an epic appear at the top of the list. +New issues and child epics are added to the top of their respective lists in the **Epics and Issues** tab. You can reorder the list of issues and the list of child epics. Issues and child epics cannot be intermingled. + +To reorder issues assigned to an epic: + +1. Go to the **Epics and Issues** tab. +1. Drag and drop issues into the desired order. + +To reorder child epics assigned to an epic: + +1. Go to the **Epics and Issues** tab. +1. Drag and drop epics into the desired order. ## Updating epics diff --git a/doc/user/group/roadmap/index.md b/doc/user/group/roadmap/index.md index a72cd990706..bcd79bd04bf 100644 --- a/doc/user/group/roadmap/index.md +++ b/doc/user/group/roadmap/index.md @@ -26,7 +26,7 @@ Epics in the view can be sorted by: Each option contains a button that toggles the sort order between **ascending** and **descending**. The sort option and order will be persisted when browsing Epics, including the [epics list view](../epics/index.md). -Roadmaps can also be [visualized inside an epic](../epics/index.md#roadmap-in-epics). +Roadmaps can also be [visualized inside an epic](../epics/index.md#roadmap). ## Timeline duration diff --git a/doc/user/packages/maven_repository/index.md b/doc/user/packages/maven_repository/index.md index 0c0b44b3cd8..8ed10c09891 100644 --- a/doc/user/packages/maven_repository/index.md +++ b/doc/user/packages/maven_repository/index.md @@ -170,7 +170,7 @@ the `distributionManagement` section: <repositories> <repository> <id>gitlab-maven</id> - <url>https://gitlab.com/api/v4/groups/my-group/-/packages/maven</url> + <url>https://gitlab.com/api/v4/groups/GROUP_ID/-/packages/maven</url> </repository> </repositories> <distributionManagement> diff --git a/doc/user/project/integrations/project_services.md b/doc/user/project/integrations/project_services.md index 168ec1b15ea..e385ee53636 100644 --- a/doc/user/project/integrations/project_services.md +++ b/doc/user/project/integrations/project_services.md @@ -56,6 +56,16 @@ Click on the service links to see further configuration instructions and details | [Redmine](redmine.md) | Redmine issue tracker | | [YouTrack](youtrack.md) | YouTrack issue tracker | +## Push hooks limit + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/31009) in GitLab 12.4. + +If a single push includes changes to more than three branches or tags, services +supported by `push_hooks` and `tag_push_hooks` events won't be executed. + +The number of branches or tags supported can be changed via +[`push_event_hooks_limit` application setting](../../../api/settings.md#list-of-settings-that-can-be-accessed-via-api-calls). + ## Services templates Services templates is a way to set some predefined values in the Service of diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md index 3daf94702e7..d0f538a4b52 100644 --- a/doc/user/project/integrations/webhooks.md +++ b/doc/user/project/integrations/webhooks.md @@ -107,6 +107,9 @@ detailed commit data is expensive. Note that despite only 20 commits being present in the `commits` attribute, the `total_commits_count` attribute will contain the actual total. +Also, if a single push includes changes for more than three (by default, depending on +[`push_event_hooks_limit` setting](../../../api/settings.md#list-of-settings-that-can-be-accessed-via-api-calls)) branches, this hook won't be executed. + **Request header**: ``` @@ -190,6 +193,10 @@ X-Gitlab-Event: Push Hook Triggered when you create (or delete) tags to the repository. +NOTE: **Note:** +If a single push includes changes for more than three (by default, depending on +[`push_event_hooks_limit` setting](../../../api/settings.md#list-of-settings-that-can-be-accessed-via-api-calls)) tags, this hook won't be executed. + **Request header**: ``` diff --git a/lib/api/deployments.rb b/lib/api/deployments.rb index eb45df31ff9..da882547071 100644 --- a/lib/api/deployments.rb +++ b/lib/api/deployments.rb @@ -42,6 +42,88 @@ module API present deployment, with: Entities::Deployment end + + desc 'Creates a new deployment' do + detail 'This feature was introduced in GitLab 12.4' + success Entities::Deployment + end + params do + requires :environment, + type: String, + desc: 'The name of the environment to deploy to' + + requires :sha, + type: String, + desc: 'The SHA of the commit that was deployed' + + requires :ref, + type: String, + desc: 'The name of the branch or tag that was deployed' + + requires :tag, + type: Boolean, + desc: 'A boolean indicating if the deployment ran for a tag' + + requires :status, + type: String, + desc: 'The status of the deployment', + values: %w[running success failed canceled] + end + post ':id/deployments' do + authorize!(:create_deployment, user_project) + authorize!(:create_environment, user_project) + + environment = user_project + .environments + .find_or_create_by_name(params[:environment]) + + unless environment.persisted? + render_validation_error!(deployment) + end + + authorize!(:create_deployment, environment) + + service = ::Deployments::CreateService + .new(environment, current_user, declared_params) + + deployment = service.execute + + if deployment.persisted? + present(deployment, with: Entities::Deployment, current_user: current_user) + else + render_validation_error!(deployment) + end + end + + desc 'Updates an existing deployment' do + detail 'This feature was introduced in GitLab 12.4' + success Entities::Deployment + end + params do + requires :status, + type: String, + desc: 'The new status of the deployment', + values: %w[running success failed canceled] + end + put ':id/deployments/:deployment_id' do + authorize!(:read_deployment, user_project) + + deployment = user_project.deployments.find(params[:deployment_id]) + + authorize!(:update_deployment, deployment) + + if deployment.deployable + forbidden!('Deployments created using GitLab CI can not be updated using the API') + end + + service = ::Deployments::UpdateService.new(deployment, declared_params) + + if service.execute + present(deployment, with: Entities::Deployment, current_user: current_user) + else + render_validation_error!(deployment) + end + end end end end diff --git a/lib/api/members.rb b/lib/api/members.rb index 461ffe71a62..1d4616fed52 100644 --- a/lib/api/members.rb +++ b/lib/api/members.rb @@ -18,6 +18,7 @@ module API end params do optional :query, type: String, desc: 'A query string to search for members' + optional :user_ids, type: Array[Integer], desc: 'Array of user ids to look up for membership' use :pagination end # rubocop: disable CodeReuse/ActiveRecord @@ -26,6 +27,7 @@ module API members = source.members.where.not(user_id: nil).includes(:user) members = members.joins(:user).merge(User.search(params[:query])) if params[:query].present? + members = members.where(user_id: params[:user_ids]) if params[:user_ids].present? members = paginate(members) present members, with: Entities::Member @@ -37,6 +39,7 @@ module API end params do optional :query, type: String, desc: 'A query string to search for members' + optional :user_ids, type: Array[Integer], desc: 'Array of user ids to look up for membership' use :pagination end # rubocop: disable CodeReuse/ActiveRecord @@ -45,6 +48,7 @@ module API members = find_all_members(source_type, source) members = members.includes(:user).references(:user).merge(User.search(params[:query])) if params[:query].present? + members = members.where(user_id: params[:user_ids]) if params[:user_ids].present? members = paginate(members) present members, with: Entities::Member @@ -68,6 +72,23 @@ module API end # rubocop: enable CodeReuse/ActiveRecord + desc 'Gets a member of a group or project, including those who gained membership through ancestor group' do + success Entities::Member + end + params do + requires :user_id, type: Integer, desc: 'The user ID of the member' + end + # rubocop: disable CodeReuse/ActiveRecord + get ":id/members/all/:user_id" do + source = find_source(source_type, params[:id]) + + members = find_all_members(source_type, source) + member = members.find_by!(user_id: params[:user_id]) + + present member, with: Entities::Member + end + # rubocop: enable CodeReuse/ActiveRecord + desc 'Adds a member to a group or project.' do success Entities::Member end diff --git a/lib/api/settings.rb b/lib/api/settings.rb index e4ef507228b..b7a471f14fe 100644 --- a/lib/api/settings.rb +++ b/lib/api/settings.rb @@ -101,6 +101,7 @@ module API optional :polling_interval_multiplier, type: BigDecimal, desc: 'Interval multiplier used by endpoints that perform polling. Set to 0 to disable polling.' optional :project_export_enabled, type: Boolean, desc: 'Enable project export' optional :prometheus_metrics_enabled, type: Boolean, desc: 'Enable Prometheus metrics' + optional :push_event_hooks_limit, type: Integer, desc: "Number of changes (branches or tags) in a single push to determine whether webhooks and services will be fired or not. Webhooks and services won't be submitted if it surpasses that value." optional :recaptcha_enabled, type: Boolean, desc: 'Helps prevent bots from creating accounts' given recaptcha_enabled: ->(val) { val } do requires :recaptcha_site_key, type: String, desc: 'Generate site key at http://www.google.com/recaptcha' diff --git a/lib/gitlab.rb b/lib/gitlab.rb index 0cc9a6a5fb1..ad8e693ccbc 100644 --- a/lib/gitlab.rb +++ b/lib/gitlab.rb @@ -69,14 +69,14 @@ module Gitlab # means that checking the presence of the License class could result in # this method returning `false`, even for an EE installation. # - # The `IS_GITLAB_EE` is always `string` or `nil` + # The `FOSS_ONLY` is always `string` or `nil` # Thus the nil or empty string will result - # in using default value: true + # in using default value: false # # The behavior needs to be synchronised with # config/helpers/is_ee_env.js root.join('ee/app/models/license.rb').exist? && - (ENV['IS_GITLAB_EE'].to_s.empty? || Gitlab::Utils.to_boolean(ENV['IS_GITLAB_EE'])) + !%w[true 1].include?(ENV['FOSS_ONLY'].to_s) end def self.ee diff --git a/lib/gitlab/ci/ansi2json.rb b/lib/gitlab/ci/ansi2json.rb new file mode 100644 index 00000000000..79114d35916 --- /dev/null +++ b/lib/gitlab/ci/ansi2json.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Convert terminal stream to JSON +module Gitlab + module Ci + module Ansi2json + def self.convert(ansi, state = nil) + Converter.new.convert(ansi, state) + end + end + end +end diff --git a/lib/gitlab/ci/ansi2json/converter.rb b/lib/gitlab/ci/ansi2json/converter.rb new file mode 100644 index 00000000000..53adaf38b87 --- /dev/null +++ b/lib/gitlab/ci/ansi2json/converter.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Ansi2json + class Converter + def convert(stream, new_state) + @lines = [] + @state = State.new(new_state, stream.size) + + append = false + truncated = false + + cur_offset = stream.tell + if cur_offset > @state.offset + @state.offset = cur_offset + truncated = true + else + stream.seek(@state.offset) + append = @state.offset > 0 + end + + start_offset = @state.offset + + @state.set_current_line!(style: Style.new(@state.inherited_style)) + + stream.each_line do |line| + s = StringScanner.new(line) + convert_line(s) + end + + # This must be assigned before flushing the current line + # or the @current_line.offset will advance to the very end + # of the trace. Instead we want @last_line_offset to always + # point to the beginning of last line. + @state.set_last_line_offset + + flush_current_line + + OpenStruct.new( + lines: @lines, + state: @state.encode, + append: append, + truncated: truncated, + offset: start_offset, + size: stream.tell - start_offset, + total: stream.size + ) + end + + private + + def convert_line(scanner) + until scanner.eos? + + if scanner.scan(Gitlab::Regex.build_trace_section_regex) + handle_section(scanner) + elsif scanner.scan(/\e([@-_])(.*?)([@-~])/) + handle_sequence(scanner) + elsif scanner.scan(/\e(([@-_])(.*?)?)?$/) + break + elsif scanner.scan(/</) + @state.current_line << '<' + elsif scanner.scan(/\r?\n/) + # we advance the offset of the next current line + # so it does not start from \n + flush_current_line(advance_offset: scanner.matched_size) + else + @state.current_line << scanner.scan(/./m) + end + + @state.offset += scanner.matched_size + end + end + + def handle_sequence(scanner) + indicator = scanner[1] + commands = scanner[2].split ';' + terminator = scanner[3] + + # We are only interested in color and text style changes - triggered by + # sequences starting with '\e[' and ending with 'm'. Any other control + # sequence gets stripped (including stuff like "delete last line") + return unless indicator == '[' && terminator == 'm' + + @state.update_style(commands) + end + + def handle_section(scanner) + action = scanner[1] + timestamp = scanner[2] + section = scanner[3] + + section_name = sanitize_section_name(section) + + if action == "start" + handle_section_start(section_name, timestamp) + elsif action == "end" + handle_section_end(section_name, timestamp) + end + end + + def handle_section_start(section, timestamp) + flush_current_line unless @state.current_line.empty? + @state.open_section(section, timestamp) + end + + def handle_section_end(section, timestamp) + return unless @state.section_open?(section) + + flush_current_line unless @state.current_line.empty? + @state.close_section(section, timestamp) + + # ensure that section end is detached from the last + # line in the section + flush_current_line + end + + def flush_current_line(advance_offset: 0) + @lines << @state.current_line.to_h + + @state.set_current_line!(advance_offset: advance_offset) + end + + def sanitize_section_name(section) + section.to_s.downcase.gsub(/[^a-z0-9]/, '-') + end + end + end + end +end diff --git a/lib/gitlab/ci/ansi2json/line.rb b/lib/gitlab/ci/ansi2json/line.rb new file mode 100644 index 00000000000..173fb1df88e --- /dev/null +++ b/lib/gitlab/ci/ansi2json/line.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Ansi2json + # Line class is responsible for keeping the internal state of + # a log line and to finally serialize it as Hash. + class Line + # Line::Segment is a portion of a line that has its own style + # and text. Multiple segments make the line content. + class Segment + attr_accessor :text, :style + + def initialize(style:) + @text = +'' + @style = style + end + + def empty? + text.empty? + end + + def to_h + # Without force encoding to UTF-8 we could get an error + # when serializing the Hash to JSON. + # Encoding::UndefinedConversionError: + # "\xE2" from ASCII-8BIT to UTF-8 + { text: text.force_encoding('UTF-8') }.tap do |result| + result[:style] = style.to_s if style.set? + end + end + end + + attr_reader :offset, :sections, :segments, :current_segment, + :section_header, :section_duration + + def initialize(offset:, style:, sections: []) + @offset = offset + @segments = [] + @sections = sections + @section_header = false + @duration = nil + @current_segment = Segment.new(style: style) + end + + def <<(data) + @current_segment.text << data + end + + def style + @current_segment.style + end + + def empty? + @segments.empty? && @current_segment.empty? + end + + def update_style(ansi_commands) + @current_segment.style.update(ansi_commands) + end + + def add_section(section) + @sections << section + end + + def set_as_section_header + @section_header = true + end + + def set_section_duration(duration) + @section_duration = Time.at(duration.to_i).strftime('%M:%S') + end + + def flush_current_segment! + return if @current_segment.empty? + + @segments << @current_segment.to_h + @current_segment = Segment.new(style: @current_segment.style) + end + + def to_h + flush_current_segment! + + { offset: offset, content: @segments }.tap do |result| + result[:section] = sections.last if sections.any? + result[:section_header] = true if @section_header + result[:section_duration] = @section_duration if @section_duration + end + end + end + end + end +end diff --git a/lib/gitlab/ci/ansi2json/parser.rb b/lib/gitlab/ci/ansi2json/parser.rb new file mode 100644 index 00000000000..d428680fb2a --- /dev/null +++ b/lib/gitlab/ci/ansi2json/parser.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +# This Parser translates ANSI escape codes into human readable format. +# It considers color and format changes. +# Inspired by http://en.wikipedia.org/wiki/ANSI_escape_code +module Gitlab + module Ci + module Ansi2json + class Parser + # keys represent the trailing digit in color changing command (30-37, 40-47, 90-97. 100-107) + COLOR = { + 0 => 'black', # not that this is gray in the intense color table + 1 => 'red', + 2 => 'green', + 3 => 'yellow', + 4 => 'blue', + 5 => 'magenta', + 6 => 'cyan', + 7 => 'white' # not that this is gray in the dark (aka default) color table + }.freeze + + STYLE_SWITCHES = { + bold: 0x01, + italic: 0x02, + underline: 0x04, + conceal: 0x08, + cross: 0x10 + }.freeze + + def self.bold?(mask) + mask & STYLE_SWITCHES[:bold] != 0 + end + + def self.matching_formats(mask) + formats = [] + STYLE_SWITCHES.each do |text_format, flag| + formats << "term-#{text_format}" if mask & flag != 0 + end + + formats + end + + def initialize(command, ansi_stack = nil) + @command = command + @ansi_stack = ansi_stack + end + + def changes + if self.respond_to?("on_#{@command}") + send("on_#{@command}", @ansi_stack) # rubocop:disable GitlabSecurity/PublicSend + end + end + + # rubocop:disable Style/SingleLineMethods + def on_0(_) { reset: true } end + + def on_1(_) { enable: STYLE_SWITCHES[:bold] } end + + def on_3(_) { enable: STYLE_SWITCHES[:italic] } end + + def on_4(_) { enable: STYLE_SWITCHES[:underline] } end + + def on_8(_) { enable: STYLE_SWITCHES[:conceal] } end + + def on_9(_) { enable: STYLE_SWITCHES[:cross] } end + + def on_21(_) { disable: STYLE_SWITCHES[:bold] } end + + def on_22(_) { disable: STYLE_SWITCHES[:bold] } end + + def on_23(_) { disable: STYLE_SWITCHES[:italic] } end + + def on_24(_) { disable: STYLE_SWITCHES[:underline] } end + + def on_28(_) { disable: STYLE_SWITCHES[:conceal] } end + + def on_29(_) { disable: STYLE_SWITCHES[:cross] } end + + def on_30(_) { fg: fg_color(0) } end + + def on_31(_) { fg: fg_color(1) } end + + def on_32(_) { fg: fg_color(2) } end + + def on_33(_) { fg: fg_color(3) } end + + def on_34(_) { fg: fg_color(4) } end + + def on_35(_) { fg: fg_color(5) } end + + def on_36(_) { fg: fg_color(6) } end + + def on_37(_) { fg: fg_color(7) } end + + def on_38(stack) { fg: fg_color_256(stack) } end + + def on_39(_) { fg: fg_color(9) } end + + def on_40(_) { bg: bg_color(0) } end + + def on_41(_) { bg: bg_color(1) } end + + def on_42(_) { bg: bg_color(2) } end + + def on_43(_) { bg: bg_color(3) } end + + def on_44(_) { bg: bg_color(4) } end + + def on_45(_) { bg: bg_color(5) } end + + def on_46(_) { bg: bg_color(6) } end + + def on_47(_) { bg: bg_color(7) } end + + def on_48(stack) { bg: bg_color_256(stack) } end + + # TODO: all the x9 never get called? + def on_49(_) { fg: fg_color(9) } end + + def on_90(_) { fg: fg_color(0, 'l') } end + + def on_91(_) { fg: fg_color(1, 'l') } end + + def on_92(_) { fg: fg_color(2, 'l') } end + + def on_93(_) { fg: fg_color(3, 'l') } end + + def on_94(_) { fg: fg_color(4, 'l') } end + + def on_95(_) { fg: fg_color(5, 'l') } end + + def on_96(_) { fg: fg_color(6, 'l') } end + + def on_97(_) { fg: fg_color(7, 'l') } end + + def on_99(_) { fg: fg_color(9, 'l') } end + + def on_100(_) { fg: bg_color(0, 'l') } end + + def on_101(_) { fg: bg_color(1, 'l') } end + + def on_102(_) { fg: bg_color(2, 'l') } end + + def on_103(_) { fg: bg_color(3, 'l') } end + + def on_104(_) { fg: bg_color(4, 'l') } end + + def on_105(_) { fg: bg_color(5, 'l') } end + + def on_106(_) { fg: bg_color(6, 'l') } end + + def on_107(_) { fg: bg_color(7, 'l') } end + + def on_109(_) { fg: bg_color(9, 'l') } end + # rubocop:enable Style/SingleLineMethods + + def fg_color(color_index, prefix = nil) + term_color_class(color_index, ['fg', prefix]) + end + + def fg_color_256(command_stack) + xterm_color_class(command_stack, 'fg') + end + + def bg_color(color_index, prefix = nil) + term_color_class(color_index, ['bg', prefix]) + end + + def bg_color_256(command_stack) + xterm_color_class(command_stack, 'bg') + end + + def term_color_class(color_index, prefix) + color_name = COLOR[color_index] + return if color_name.nil? + + color_class(['term', prefix, color_name]) + end + + def xterm_color_class(command_stack, prefix) + # the 38 and 48 commands have to be followed by "5" and the color index + return unless command_stack.length >= 2 + return unless command_stack[0] == "5" + + command_stack.shift # ignore the "5" command + color_index = command_stack.shift.to_i + + return unless color_index >= 0 + return unless color_index <= 255 + + color_class(["xterm", prefix, color_index]) + end + + def color_class(segments) + [segments].flatten.compact.join('-') + end + end + end + end +end diff --git a/lib/gitlab/ci/ansi2json/state.rb b/lib/gitlab/ci/ansi2json/state.rb new file mode 100644 index 00000000000..db7a9035b8b --- /dev/null +++ b/lib/gitlab/ci/ansi2json/state.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +# In this class we keep track of the state changes that the +# Converter makes as it scans through the log stream. +module Gitlab + module Ci + module Ansi2json + class State + attr_accessor :offset, :current_line, :inherited_style, :open_sections, :last_line_offset + + def initialize(new_state, stream_size) + @offset = 0 + @inherited_style = {} + @open_sections = {} + @stream_size = stream_size + + restore_state!(new_state) + end + + def encode + state = { + offset: @last_line_offset, + style: @current_line.style.to_h, + open_sections: @open_sections + } + Base64.urlsafe_encode64(state.to_json) + end + + def open_section(section, timestamp) + @open_sections[section] = timestamp + + @current_line.add_section(section) + @current_line.set_as_section_header + end + + def close_section(section, timestamp) + return unless section_open?(section) + + duration = timestamp.to_i - @open_sections[section].to_i + @current_line.set_section_duration(duration) + + @open_sections.delete(section) + end + + def section_open?(section) + @open_sections.key?(section) + end + + def set_current_line!(style: nil, advance_offset: 0) + new_line = Line.new( + offset: @offset + advance_offset, + style: style || @current_line.style, + sections: @open_sections.keys + ) + @current_line = new_line + end + + def set_last_line_offset + @last_line_offset = @current_line.offset + end + + def update_style(commands) + @current_line.flush_current_segment! + @current_line.update_style(commands) + end + + private + + def restore_state!(encoded_state) + state = decode_state(encoded_state) + + return unless state + return if state['offset'].to_i > @stream_size + + @offset = state['offset'].to_i if state['offset'] + @open_sections = state['open_sections'] if state['open_sections'] + + if state['style'] + @inherited_style = { + fg: state.dig('style', 'fg'), + bg: state.dig('style', 'bg'), + mask: state.dig('style', 'mask') + } + end + end + + def decode_state(state) + return unless state.present? + + decoded_state = Base64.urlsafe_decode64(state) + return unless decoded_state.present? + + JSON.parse(decoded_state) + end + end + end + end +end diff --git a/lib/gitlab/ci/ansi2json/style.rb b/lib/gitlab/ci/ansi2json/style.rb new file mode 100644 index 00000000000..2739ffdfa5d --- /dev/null +++ b/lib/gitlab/ci/ansi2json/style.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Ansi2json + class Style + attr_reader :fg, :bg, :mask + + def initialize(fg: nil, bg: nil, mask: 0) + @fg = fg + @bg = bg + @mask = mask + + update_formats + end + + def update(ansi_commands) + command = ansi_commands.shift + return unless command + + if changes = Gitlab::Ci::Ansi2json::Parser.new(command, ansi_commands).changes + apply_changes(changes) + end + + update(ansi_commands) + end + + def set? + @fg || @bg || @formats.any? + end + + def reset! + @fg = nil + @bg = nil + @mask = 0 + @formats = [] + end + + def ==(other) + self.to_h == other.to_h + end + + def to_s + [@fg, @bg, @formats].flatten.compact.join(' ') + end + + def to_h + { fg: @fg, bg: @bg, mask: @mask } + end + + private + + def apply_changes(changes) + case + when changes[:reset] + reset! + when changes[:fg] + @fg = changes[:fg] + when changes[:bg] + @bg = changes[:bg] + when changes[:enable] + @mask |= changes[:enable] + when changes[:disable] + @mask &= ~changes[:disable] + else + return + end + + update_formats + end + + def update_formats + # Most terminals show bold colored text in the light color variant + # Let's mimic that here + if @fg.present? && Gitlab::Ci::Ansi2json::Parser.bold?(@mask) + @fg = @fg.sub(/fg-([a-z]{2,}+)/, 'fg-l-\1') + end + + @formats = Gitlab::Ci::Ansi2json::Parser.matching_formats(@mask) + end + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/summary/deploy.rb b/lib/gitlab/cycle_analytics/summary/deploy.rb index 0691f3cd131..5ff8d881143 100644 --- a/lib/gitlab/cycle_analytics/summary/deploy.rb +++ b/lib/gitlab/cycle_analytics/summary/deploy.rb @@ -12,7 +12,7 @@ module Gitlab def value strong_memoize(:value) do - query = @project.deployments.where("created_at >= ?", @from) + query = @project.deployments.success.where("created_at >= ?", @from) query = query.where("created_at <= ?", @to) if @to query.count end diff --git a/lib/gitlab/diff/position_collection.rb b/lib/gitlab/diff/position_collection.rb index 59c60f77aaa..2112d347678 100644 --- a/lib/gitlab/diff/position_collection.rb +++ b/lib/gitlab/diff/position_collection.rb @@ -6,13 +6,13 @@ module Gitlab include Enumerable # collection - An array of Gitlab::Diff::Position - def initialize(collection, diff_head_sha) + def initialize(collection, diff_head_sha = nil) @collection = collection @diff_head_sha = diff_head_sha end def each(&block) - @collection.each(&block) + filtered_positions.each(&block) end def concat(positions) @@ -23,9 +23,21 @@ module Gitlab # positions (https://gitlab.com/gitlab-org/gitlab/issues/33271). def unfoldable select do |position| - position.unfoldable? && position.head_sha == @diff_head_sha + position.unfoldable? && valid_head_sha?(position) end end + + private + + def filtered_positions + @collection.select { |item| item.is_a?(Position) } + end + + def valid_head_sha?(position) + return true unless @diff_head_sha + + position.head_sha == @diff_head_sha + end end end end diff --git a/lib/gitlab/graphql/docs/renderer.rb b/lib/gitlab/graphql/docs/renderer.rb index f47a372aa19..41aef64f683 100644 --- a/lib/gitlab/graphql/docs/renderer.rb +++ b/lib/gitlab/graphql/docs/renderer.rb @@ -23,15 +23,12 @@ module Gitlab @parsed_schema = GraphQLDocs::Parser.new(schema, {}).parse end - def render - contents = @layout.render(self) - - write_file(contents) + def contents + # Render and remove an extra trailing new line + @contents ||= @layout.render(self).sub!(/\n(?=\Z)/, '') end - private - - def write_file(contents) + def write filename = File.join(@output_dir, 'index.md') FileUtils.mkdir_p(@output_dir) diff --git a/lib/gitlab/graphql/docs/templates/default.md.haml b/lib/gitlab/graphql/docs/templates/default.md.haml index cc22d43ab4f..33acff38ef4 100644 --- a/lib/gitlab/graphql/docs/templates/default.md.haml +++ b/lib/gitlab/graphql/docs/templates/default.md.haml @@ -20,6 +20,3 @@ - type[:fields].each do |field| = "| `#{field[:name]}` | #{render_field_type(field[:type][:info])} | #{field[:description]} |" \ - - - diff --git a/lib/gitlab/health_checks/checks.rb b/lib/gitlab/health_checks/checks.rb deleted file mode 100644 index c4016c5fffd..00000000000 --- a/lib/gitlab/health_checks/checks.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module HealthChecks - CHECKS = [ - Gitlab::HealthChecks::DbCheck, - Gitlab::HealthChecks::Redis::RedisCheck, - Gitlab::HealthChecks::Redis::CacheCheck, - Gitlab::HealthChecks::Redis::QueuesCheck, - Gitlab::HealthChecks::Redis::SharedStateCheck, - Gitlab::HealthChecks::GitalyCheck - ].freeze - end -end diff --git a/lib/gitlab/health_checks/probes/readiness.rb b/lib/gitlab/health_checks/probes/collection.rb index 28abf490ffc..db3ef4834c2 100644 --- a/lib/gitlab/health_checks/probes/readiness.rb +++ b/lib/gitlab/health_checks/probes/collection.rb @@ -3,14 +3,13 @@ module Gitlab module HealthChecks module Probes - class Readiness + class Collection attr_reader :checks # This accepts an array of objects implementing `:readiness` # that returns `::Gitlab::HealthChecks::Result` - def initialize(*additional_checks) - @checks = ::Gitlab::HealthChecks::CHECKS - @checks += additional_checks + def initialize(*checks) + @checks = checks end def execute diff --git a/lib/gitlab/health_checks/probes/liveness.rb b/lib/gitlab/health_checks/probes/liveness.rb deleted file mode 100644 index b4d346e945e..00000000000 --- a/lib/gitlab/health_checks/probes/liveness.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module HealthChecks - module Probes - class Liveness - def execute - Probes::Status.new(200, status: 'ok') - end - end - end - end -end diff --git a/lib/gitlab/metrics/exporter/base_exporter.rb b/lib/gitlab/metrics/exporter/base_exporter.rb index 01d1ec9305e..7111835c85a 100644 --- a/lib/gitlab/metrics/exporter/base_exporter.rb +++ b/lib/gitlab/metrics/exporter/base_exporter.rb @@ -6,7 +6,7 @@ module Gitlab class BaseExporter < Daemon attr_reader :server - attr_accessor :additional_checks + attr_accessor :readiness_checks def enabled? settings.enabled @@ -73,11 +73,11 @@ module Gitlab end def readiness_probe - ::Gitlab::HealthChecks::Probes::Readiness.new(*additional_checks) + ::Gitlab::HealthChecks::Probes::Collection.new(*readiness_checks) end def liveness_probe - ::Gitlab::HealthChecks::Probes::Liveness.new + ::Gitlab::HealthChecks::Probes::Collection.new end def render_probe(probe, req, res) diff --git a/lib/gitlab/metrics/exporter/web_exporter.rb b/lib/gitlab/metrics/exporter/web_exporter.rb index 597ac289193..3940f6fa155 100644 --- a/lib/gitlab/metrics/exporter/web_exporter.rb +++ b/lib/gitlab/metrics/exporter/web_exporter.rb @@ -20,7 +20,7 @@ module Gitlab def initialize super - self.additional_checks = [ + self.readiness_checks = [ WebExporter::ExporterCheck.new(self), Gitlab::HealthChecks::PumaCheck, Gitlab::HealthChecks::UnicornCheck diff --git a/lib/tasks/gitlab/graphql.rake b/lib/tasks/gitlab/graphql.rake index fd8df015903..902f22684ee 100644 --- a/lib/tasks/gitlab/graphql.rake +++ b/lib/tasks/gitlab/graphql.rake @@ -11,10 +11,28 @@ namespace :gitlab do task compile_docs: :environment do renderer = Gitlab::Graphql::Docs::Renderer.new(GitlabSchema.graphql_definition, render_options) - renderer.render + renderer.write puts "Documentation compiled." end + + desc 'GitLab | Check if GraphQL docs are up to date' + task check_docs: :environment do + renderer = Gitlab::Graphql::Docs::Renderer.new(GitlabSchema.graphql_definition, render_options) + + doc = File.read(Rails.root.join(OUTPUT_DIR, 'index.md')) + + if doc == renderer.contents + puts "GraphQL documentation is up to date" + else + puts '#' * 10 + puts '#' + puts '# GraphQL documentation is outdated! Please update it by running `bundle exec rake gitlab:graphql:compile_docs`.' + puts '#' + puts '#' * 10 + abort + end + end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 40fb2194a14..01b42eddb28 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3066,6 +3066,9 @@ msgstr "" msgid "Choose a type..." msgstr "" +msgid "Choose an existing tag, or create a new one" +msgstr "" + msgid "Choose any color." msgstr "" @@ -5409,6 +5412,27 @@ msgstr "" msgid "Deploying to" msgstr "" +msgid "Deployment|API" +msgstr "" + +msgid "Deployment|This deployment was created using the API" +msgstr "" + +msgid "Deployment|canceled" +msgstr "" + +msgid "Deployment|created" +msgstr "" + +msgid "Deployment|failed" +msgstr "" + +msgid "Deployment|running" +msgstr "" + +msgid "Deployment|success" +msgstr "" + msgid "Deprioritize label" msgstr "" @@ -5766,6 +5790,9 @@ msgstr "" msgid "Edit Pipeline Schedule %{id}" msgstr "" +msgid "Edit Release" +msgstr "" + msgid "Edit Snippet" msgstr "" @@ -11172,6 +11199,9 @@ msgstr "" msgid "Number of LOCs per commit" msgstr "" +msgid "Number of changes (branches or tags) in a single push to determine whether webhooks and services will be fired or not. Webhooks and services won't be submitted if it surpasses that value." +msgstr "" + msgid "Number of commits per MR" msgstr "" @@ -13456,12 +13486,27 @@ msgstr "" msgid "Release" msgstr "" +msgid "Release notes" +msgstr "" + +msgid "Release title" +msgstr "" + msgid "Releases" msgstr "" +msgid "Releases are based on Git tags. We recommend naming tags that fit within semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}." +msgstr "" + msgid "Releases mark specific points in a project's development history, communicate information about the type of change, and deliver on prepared, often compiled, versions of the software to be reused elsewhere. Currently, releases can only be created through the API." msgstr "" +msgid "Release|Something went wrong while getting the release details" +msgstr "" + +msgid "Release|Something went wrong while saving the release details" +msgstr "" + msgid "Remember me" msgstr "" @@ -15943,6 +15988,9 @@ msgstr "" msgid "Tag list:" msgstr "" +msgid "Tag name" +msgstr "" + msgid "Tag this commit." msgstr "" @@ -18683,6 +18731,9 @@ msgstr "" msgid "Write milestone description..." msgstr "" +msgid "Write your release notes or drag your files here…" +msgstr "" + msgid "Wrong extern UID provided. Make sure Auth0 is configured correctly." msgstr "" diff --git a/spec/controllers/projects/deployments_controller_spec.rb b/spec/controllers/projects/deployments_controller_spec.rb index b9ee69a617b..66112c95742 100644 --- a/spec/controllers/projects/deployments_controller_spec.rb +++ b/spec/controllers/projects/deployments_controller_spec.rb @@ -75,15 +75,13 @@ describe Projects::DeploymentsController do } end - before do + it 'returns a metrics JSON document' do expect_next_instance_of(DeploymentMetrics) do |deployment_metrics| allow(deployment_metrics).to receive(:has_metrics?).and_return(true) expect(deployment_metrics).to receive(:metrics).and_return(empty_metrics) end - end - it 'returns a metrics JSON document' do get :metrics, params: deployment_params(id: deployment.to_param) expect(response).to be_ok @@ -91,6 +89,19 @@ describe Projects::DeploymentsController do expect(json_response['metrics']).to eq({}) expect(json_response['last_update']).to eq(42) end + + it 'returns a 404 if the deployment failed' do + failed_deployment = create( + :deployment, + :failed, + project: project, + environment: environment + ) + + get :metrics, params: deployment_params(id: failed_deployment.to_param) + + expect(response).to have_gitlab_http_status(404) + end end end end diff --git a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb index e677e836145..5c02e8d6461 100644 --- a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb +++ b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb @@ -82,9 +82,9 @@ describe Projects::MergeRequests::DiffsController do end end - context 'when note has no position' do + context 'when note is a legacy diff note' do before do - create(:legacy_diff_note_on_merge_request, project: project, noteable: merge_request, position: nil) + create(:legacy_diff_note_on_merge_request, project: project, noteable: merge_request) end it 'serializes merge request diff collection' do diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb index 25823b75d18..dd690699ff6 100644 --- a/spec/features/projects/environments/environment_spec.rb +++ b/spec/features/projects/environments/environment_spec.rb @@ -66,8 +66,8 @@ describe 'Environment' do create(:deployment, :running, environment: environment, deployable: build) end - it 'does not show deployments' do - expect(page).to have_content('You don\'t have any deployments right now.') + it 'does show deployments' do + expect(page).to have_link("#{build.name} (##{build.id})") end end @@ -79,8 +79,8 @@ describe 'Environment' do create(:deployment, :failed, environment: environment, deployable: build) end - it 'does not show deployments' do - expect(page).to have_content('You don\'t have any deployments right now.') + it 'does show deployments' do + expect(page).to have_link("#{build.name} (##{build.id})") end end @@ -175,7 +175,7 @@ describe 'Environment' do # # In EE we have to stub EE::Environment since it overwrites # the "terminals" method. - allow_any_instance_of(defined?(EE) ? EE::Environment : Environment) + allow_any_instance_of(Gitlab.ee? ? EE::Environment : Environment) .to receive(:terminals) { nil } visit terminal_project_environment_path(project, environment) diff --git a/spec/fixtures/api/schemas/deployment.json b/spec/fixtures/api/schemas/deployment.json index b1e3c000ddf..0cfeadfe548 100644 --- a/spec/fixtures/api/schemas/deployment.json +++ b/spec/fixtures/api/schemas/deployment.json @@ -61,7 +61,7 @@ "type": "array", "items": { "$ref": "job/job.json" } }, - "status": { "type": "string" } + "status": { "type": "string" } }, "additionalProperties": false } diff --git a/spec/frontend/releases/detail/components/app_spec.js b/spec/frontend/releases/detail/components/app_spec.js new file mode 100644 index 00000000000..f8eb33a69a8 --- /dev/null +++ b/spec/frontend/releases/detail/components/app_spec.js @@ -0,0 +1,70 @@ +import Vuex from 'vuex'; +import { mount } from '@vue/test-utils'; +import ReleaseDetailApp from '~/releases/detail/components/app'; +import { release } from '../../mock_data'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +describe('Release detail component', () => { + let wrapper; + let releaseClone; + let actions; + + beforeEach(() => { + gon.api_version = 'v4'; + + releaseClone = JSON.parse(JSON.stringify(convertObjectPropsToCamelCase(release))); + + const state = { + release: releaseClone, + markdownDocsPath: 'path/to/markdown/docs', + }; + + actions = { + fetchRelease: jest.fn(), + updateRelease: jest.fn(), + navigateToReleasesPage: jest.fn(), + }; + + const store = new Vuex.Store({ actions, state }); + + wrapper = mount(ReleaseDetailApp, { store }); + + return wrapper.vm.$nextTick(); + }); + + it('calls fetchRelease when the component is created', () => { + expect(actions.fetchRelease).toHaveBeenCalledTimes(1); + }); + + it('renders the description text at the top of the page', () => { + expect(wrapper.find('.js-subtitle-text').text()).toBe( + 'Releases are based on Git tags. We recommend naming tags that fit within semantic versioning, for example v1.0, v2.0-pre.', + ); + }); + + it('renders the correct tag name in the "Tag name" field', () => { + expect(wrapper.find('#git-ref').element.value).toBe(releaseClone.tagName); + }); + + it('renders the correct release title in the "Release title" field', () => { + expect(wrapper.find('#release-title').element.value).toBe(releaseClone.name); + }); + + it('renders the release notes in the "Release notes" textarea', () => { + expect(wrapper.find('#release-notes').element.value).toBe(releaseClone.description); + }); + + it('renders the "Save changes" button as type="submit"', () => { + expect(wrapper.find('.js-submit-button').attributes('type')).toBe('submit'); + }); + + it('calls updateRelease when the form is submitted', () => { + wrapper.find('form').trigger('submit'); + expect(actions.updateRelease).toHaveBeenCalledTimes(1); + }); + + it('calls navigateToReleasesPage when the "Cancel" button is clicked', () => { + wrapper.find('.js-cancel-button').vm.$emit('click'); + expect(actions.navigateToReleasesPage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/spec/frontend/releases/detail/store/actions_spec.js b/spec/frontend/releases/detail/store/actions_spec.js new file mode 100644 index 00000000000..f1c7f3c1048 --- /dev/null +++ b/spec/frontend/releases/detail/store/actions_spec.js @@ -0,0 +1,217 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import * as actions from '~/releases/detail/store/actions'; +import testAction from 'helpers/vuex_action_helper'; +import * as types from '~/releases/detail/store/mutation_types'; +import { release } from '../../mock_data'; +import state from '~/releases/detail/store/state'; +import createFlash from '~/flash'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +jest.mock('~/flash', () => jest.fn()); + +jest.mock('~/lib/utils/url_utility', () => ({ + redirectTo: jest.fn(), + joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths, +})); + +describe('Release detail actions', () => { + let stateClone; + let releaseClone; + let mock; + let error; + + beforeEach(() => { + stateClone = state(); + releaseClone = JSON.parse(JSON.stringify(release)); + mock = new MockAdapter(axios); + gon.api_version = 'v4'; + error = { message: 'An error occurred' }; + createFlash.mockClear(); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('setInitialState', () => { + it(`commits ${types.SET_INITIAL_STATE} with the provided object`, () => { + const initialState = {}; + + return testAction(actions.setInitialState, initialState, stateClone, [ + { type: types.SET_INITIAL_STATE, payload: initialState }, + ]); + }); + }); + + describe('requestRelease', () => { + it(`commits ${types.REQUEST_RELEASE}`, () => + testAction(actions.requestRelease, undefined, stateClone, [{ type: types.REQUEST_RELEASE }])); + }); + + describe('receiveReleaseSuccess', () => { + it(`commits ${types.RECEIVE_RELEASE_SUCCESS}`, () => + testAction(actions.receiveReleaseSuccess, releaseClone, stateClone, [ + { type: types.RECEIVE_RELEASE_SUCCESS, payload: releaseClone }, + ])); + }); + + describe('receiveReleaseError', () => { + it(`commits ${types.RECEIVE_RELEASE_ERROR}`, () => + testAction(actions.receiveReleaseError, error, stateClone, [ + { type: types.RECEIVE_RELEASE_ERROR, payload: error }, + ])); + + it('shows a flash with an error message', () => { + actions.receiveReleaseError({ commit: jest.fn() }, error); + + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith( + 'Something went wrong while getting the release details', + ); + }); + }); + + describe('fetchRelease', () => { + let getReleaseUrl; + + beforeEach(() => { + stateClone.projectId = '18'; + stateClone.tagName = 'v1.3'; + getReleaseUrl = `/api/v4/projects/${stateClone.projectId}/releases/${stateClone.tagName}`; + }); + + it(`dispatches requestRelease and receiveReleaseSuccess with the camel-case'd release object`, () => { + mock.onGet(getReleaseUrl).replyOnce(200, releaseClone); + + return testAction( + actions.fetchRelease, + undefined, + stateClone, + [], + [ + { type: 'requestRelease' }, + { + type: 'receiveReleaseSuccess', + payload: convertObjectPropsToCamelCase(releaseClone, { deep: true }), + }, + ], + ); + }); + + it(`dispatches requestRelease and receiveReleaseError with an error object`, () => { + mock.onGet(getReleaseUrl).replyOnce(500); + + return testAction( + actions.fetchRelease, + undefined, + stateClone, + [], + [{ type: 'requestRelease' }, { type: 'receiveReleaseError', payload: expect.anything() }], + ); + }); + }); + + describe('updateReleaseTitle', () => { + it(`commits ${types.UPDATE_RELEASE_TITLE} with the updated release title`, () => { + const newTitle = 'The new release title'; + return testAction(actions.updateReleaseTitle, newTitle, stateClone, [ + { type: types.UPDATE_RELEASE_TITLE, payload: newTitle }, + ]); + }); + }); + + describe('updateReleaseNotes', () => { + it(`commits ${types.UPDATE_RELEASE_NOTES} with the updated release notes`, () => { + const newReleaseNotes = 'The new release notes'; + return testAction(actions.updateReleaseNotes, newReleaseNotes, stateClone, [ + { type: types.UPDATE_RELEASE_NOTES, payload: newReleaseNotes }, + ]); + }); + }); + + describe('requestUpdateRelease', () => { + it(`commits ${types.REQUEST_UPDATE_RELEASE}`, () => + testAction(actions.requestUpdateRelease, undefined, stateClone, [ + { type: types.REQUEST_UPDATE_RELEASE }, + ])); + }); + + describe('receiveUpdateReleaseSuccess', () => { + it(`commits ${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () => + testAction( + actions.receiveUpdateReleaseSuccess, + undefined, + stateClone, + [{ type: types.RECEIVE_UPDATE_RELEASE_SUCCESS }], + [{ type: 'navigateToReleasesPage' }], + )); + }); + + describe('receiveUpdateReleaseError', () => { + it(`commits ${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () => + testAction(actions.receiveUpdateReleaseError, error, stateClone, [ + { type: types.RECEIVE_UPDATE_RELEASE_ERROR, payload: error }, + ])); + + it('shows a flash with an error message', () => { + actions.receiveUpdateReleaseError({ commit: jest.fn() }, error); + + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith( + 'Something went wrong while saving the release details', + ); + }); + }); + + describe('updateRelease', () => { + let getReleaseUrl; + + beforeEach(() => { + stateClone.release = releaseClone; + stateClone.projectId = '18'; + stateClone.tagName = 'v1.3'; + getReleaseUrl = `/api/v4/projects/${stateClone.projectId}/releases/${stateClone.tagName}`; + }); + + it(`dispatches requestUpdateRelease and receiveUpdateReleaseSuccess`, () => { + mock.onPut(getReleaseUrl).replyOnce(200); + + return testAction( + actions.updateRelease, + undefined, + stateClone, + [], + [{ type: 'requestUpdateRelease' }, { type: 'receiveUpdateReleaseSuccess' }], + ); + }); + + it(`dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object`, () => { + mock.onPut(getReleaseUrl).replyOnce(500); + + return testAction( + actions.updateRelease, + undefined, + stateClone, + [], + [ + { type: 'requestUpdateRelease' }, + { type: 'receiveUpdateReleaseError', payload: expect.anything() }, + ], + ); + }); + }); + + describe('navigateToReleasesPage', () => { + it(`calls redirectTo() with the URL to the releases page`, () => { + const releasesPagePath = 'path/to/releases/page'; + stateClone.releasesPagePath = releasesPagePath; + + actions.navigateToReleasesPage({ state: stateClone }); + + expect(redirectTo).toHaveBeenCalledTimes(1); + expect(redirectTo).toHaveBeenCalledWith(releasesPagePath); + }); + }); +}); diff --git a/spec/frontend/releases/detail/store/mutations_spec.js b/spec/frontend/releases/detail/store/mutations_spec.js new file mode 100644 index 00000000000..106a40c812e --- /dev/null +++ b/spec/frontend/releases/detail/store/mutations_spec.js @@ -0,0 +1,119 @@ +/* eslint-disable jest/valid-describe */ +/* + * ESLint disable directive ↑ can be removed once + * https://github.com/jest-community/eslint-plugin-jest/issues/203 + * is resolved + */ + +import state from '~/releases/detail/store/state'; +import mutations from '~/releases/detail/store/mutations'; +import * as types from '~/releases/detail/store/mutation_types'; +import { release } from '../../mock_data'; + +describe('Release detail mutations', () => { + let stateClone; + let releaseClone; + + beforeEach(() => { + stateClone = state(); + releaseClone = JSON.parse(JSON.stringify(release)); + }); + + describe(types.SET_INITIAL_STATE, () => { + it('populates the state with initial values', () => { + const initialState = { + projectId: '18', + tagName: 'v1.3', + releasesPagePath: 'path/to/releases/page', + markdownDocsPath: 'path/to/markdown/docs', + markdownPreviewPath: 'path/to/markdown/preview', + }; + + mutations[types.SET_INITIAL_STATE](stateClone, initialState); + + expect(stateClone).toEqual(expect.objectContaining(initialState)); + }); + }); + + describe(types.REQUEST_RELEASE, () => { + it('set state.isFetchingRelease to true', () => { + mutations[types.REQUEST_RELEASE](stateClone); + + expect(stateClone.isFetchingRelease).toEqual(true); + }); + }); + + describe(types.RECEIVE_RELEASE_SUCCESS, () => { + it('handles a successful response from the server', () => { + mutations[types.RECEIVE_RELEASE_SUCCESS](stateClone, releaseClone); + + expect(stateClone.fetchError).toEqual(undefined); + + expect(stateClone.isFetchingRelease).toEqual(false); + + expect(stateClone.release).toEqual(releaseClone); + }); + }); + + describe(types.RECEIVE_RELEASE_ERROR, () => { + it('handles an unsuccessful response from the server', () => { + const error = { message: 'An error occurred!' }; + mutations[types.RECEIVE_RELEASE_ERROR](stateClone, error); + + expect(stateClone.isFetchingRelease).toEqual(false); + + expect(stateClone.release).toBeUndefined(); + + expect(stateClone.fetchError).toEqual(error); + }); + }); + + describe(types.UPDATE_RELEASE_TITLE, () => { + it("updates the release's title", () => { + stateClone.release = releaseClone; + const newTitle = 'The new release title'; + mutations[types.UPDATE_RELEASE_TITLE](stateClone, newTitle); + + expect(stateClone.release.name).toEqual(newTitle); + }); + }); + + describe(types.UPDATE_RELEASE_NOTES, () => { + it("updates the release's notes", () => { + stateClone.release = releaseClone; + const newNotes = 'The new release notes'; + mutations[types.UPDATE_RELEASE_NOTES](stateClone, newNotes); + + expect(stateClone.release.description).toEqual(newNotes); + }); + }); + + describe(types.REQUEST_UPDATE_RELEASE, () => { + it('set state.isUpdatingRelease to true', () => { + mutations[types.REQUEST_UPDATE_RELEASE](stateClone); + + expect(stateClone.isUpdatingRelease).toEqual(true); + }); + }); + + describe(types.RECEIVE_UPDATE_RELEASE_SUCCESS, () => { + it('handles a successful response from the server', () => { + mutations[types.RECEIVE_UPDATE_RELEASE_SUCCESS](stateClone, releaseClone); + + expect(stateClone.updateError).toEqual(undefined); + + expect(stateClone.isUpdatingRelease).toEqual(false); + }); + }); + + describe(types.RECEIVE_UPDATE_RELEASE_ERROR, () => { + it('handles an unsuccessful response from the server', () => { + const error = { message: 'An error occurred!' }; + mutations[types.RECEIVE_UPDATE_RELEASE_ERROR](stateClone, error); + + expect(stateClone.isUpdatingRelease).toEqual(false); + + expect(stateClone.updateError).toEqual(error); + }); + }); +}); diff --git a/spec/helpers/environment_helper_spec.rb b/spec/helpers/environment_helper_spec.rb new file mode 100644 index 00000000000..53953d72b06 --- /dev/null +++ b/spec/helpers/environment_helper_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe EnvironmentHelper do + describe '#render_deployment_status' do + context 'when using a manual deployment' do + it 'renders a span tag' do + deploy = build(:deployment, deployable: nil, status: :success) + html = helper.render_deployment_status(deploy) + + expect(html).to have_css('span.ci-status.ci-success') + end + end + + context 'when using a deployment from a build' do + it 'renders a link tag' do + deploy = build(:deployment, status: :success) + html = helper.render_deployment_status(deploy) + + expect(html).to have_css('a.ci-status.ci-success') + end + end + end +end diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js index 191df3cc709..cb6b158f01c 100644 --- a/spec/javascripts/test_bundle.js +++ b/spec/javascripts/test_bundle.js @@ -70,7 +70,7 @@ window.gl = window.gl || {}; window.gl.TEST_HOST = TEST_HOST; window.gon = window.gon || {}; window.gon.test_env = true; -window.gon.ee = process.env.IS_GITLAB_EE; +window.gon.ee = process.env.IS_EE; gon.relative_url_root = ''; let hasUnhandledPromiseRejections = false; @@ -118,7 +118,7 @@ const axiosDefaultAdapter = getDefaultAdapter(); // render all of our tests const testContexts = [require.context('spec', true, /_spec$/)]; -if (process.env.IS_GITLAB_EE) { +if (process.env.IS_EE) { testContexts.push(require.context('ee_spec', true, /_spec$/)); } @@ -207,7 +207,7 @@ if (process.env.BABEL_ENV === 'coverage') { describe('Uncovered files', function() { const sourceFilesContexts = [require.context('~', true, /\.(js|vue)$/)]; - if (process.env.IS_GITLAB_EE) { + if (process.env.IS_EE) { sourceFilesContexts.push(require.context('ee', true, /\.(js|vue)$/)); } diff --git a/spec/lib/gitlab/ci/ansi2json/line_spec.rb b/spec/lib/gitlab/ci/ansi2json/line_spec.rb new file mode 100644 index 00000000000..4b5c3f9489e --- /dev/null +++ b/spec/lib/gitlab/ci/ansi2json/line_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Ansi2json::Line do + let(:offset) { 0 } + let(:style) { Gitlab::Ci::Ansi2json::Style.new } + + subject { described_class.new(offset: offset, style: style) } + + describe '#<<' do + it 'appends new data to the current segment' do + expect { subject << 'test 1' }.to change { subject.current_segment.text } + expect(subject.current_segment.text).to eq('test 1') + + expect { subject << ', test 2' }.to change { subject.current_segment.text } + expect(subject.current_segment.text).to eq('test 1, test 2') + end + end + + describe '#style' do + context 'when style is passed to the initializer' do + let(:style) { double } + + it 'returns the same style' do + expect(subject.style).to eq(style) + end + end + + context 'when style is not passed to the initializer' do + it 'returns the default style' do + expect(subject.style.set?).to be_falsey + end + end + end + + describe '#update_style' do + let(:expected_style) do + Gitlab::Ci::Ansi2json::Style.new( + fg: 'term-fg-l-yellow', + bg: 'term-bg-blue', + mask: 1) + end + + it 'sets the style' do + subject.update_style(%w[1 33 44]) + + expect(subject.style).to eq(expected_style) + end + end + + describe '#add_section' do + it 'appends a new section to the list' do + subject.add_section('section_1') + subject.add_section('section_2') + + expect(subject.sections).to eq(%w[section_1 section_2]) + end + end + + describe '#set_as_section_header' do + it 'change the section_header to true' do + expect { subject.set_as_section_header } + .to change { subject.section_header } + .to be_truthy + end + end + + describe '#set_section_duration' do + it 'sets and formats the section_duration' do + subject.set_section_duration(75) + + expect(subject.section_duration).to eq('01:15') + end + end + + describe '#flush_current_segment!' do + context 'when current segment is not empty' do + before do + subject << 'some data' + end + + it 'adds the segment to the list' do + expect { subject.flush_current_segment! }.to change { subject.segments.count }.by(1) + + expect(subject.segments.map { |s| s[:text] }).to eq(['some data']) + end + + it 'updates the current segment pointer propagating the style' do + previous_segment = subject.current_segment + + subject.flush_current_segment! + + expect(subject.current_segment).not_to eq(previous_segment) + expect(subject.current_segment.style).to eq(previous_segment.style) + end + end + + context 'when current segment is empty' do + it 'does not add any segments to the list' do + expect { subject.flush_current_segment! }.not_to change { subject.segments.count } + end + + it 'does not change the current segment' do + expect { subject.flush_current_segment! }.not_to change { subject.current_segment } + end + end + end + + describe '#to_h' do + before do + subject << 'some data' + subject.update_style(['1']) + end + + context 'when sections are present' do + before do + subject.add_section('section_1') + subject.add_section('section_2') + end + + context 'when section header is set' do + before do + subject.set_as_section_header + end + + it 'serializes the attributes set' do + result = { + offset: 0, + content: [{ text: 'some data', style: 'term-bold' }], + section: 'section_2', + section_header: true + } + + expect(subject.to_h).to eq(result) + end + end + + context 'when section duration is set' do + before do + subject.set_section_duration(75) + end + + it 'serializes the attributes set' do + result = { + offset: 0, + content: [{ text: 'some data', style: 'term-bold' }], + section: 'section_2', + section_duration: '01:15' + } + + expect(subject.to_h).to eq(result) + end + end + end + + context 'when there are no sections' do + it 'serializes the attributes set' do + result = { + offset: 0, + content: [{ text: 'some data', style: 'term-bold' }] + } + + expect(subject.to_h).to eq(result) + end + end + end +end diff --git a/spec/lib/gitlab/ci/ansi2json/parser_spec.rb b/spec/lib/gitlab/ci/ansi2json/parser_spec.rb new file mode 100644 index 00000000000..e161e74c1ff --- /dev/null +++ b/spec/lib/gitlab/ci/ansi2json/parser_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# The rest of the specs for this class are covered in style_spec.rb +describe Gitlab::Ci::Ansi2json::Parser do + subject { described_class } + + describe 'bold?' do + it 'returns true if style mask matches bold format' do + expect(subject.bold?(0x01)).to be_truthy + end + + it 'returns false if style mask does not match bold format' do + expect(subject.bold?(0x02)).to be_falsey + end + end + + describe 'matching_formats' do + it 'returns matching formats given a style mask' do + expect(subject.matching_formats(0x01)).to eq(%w[term-bold]) + expect(subject.matching_formats(0x03)).to eq(%w[term-bold term-italic]) + expect(subject.matching_formats(0x07)).to eq(%w[term-bold term-italic term-underline]) + end + + it 'returns an empty array if no formats match the style mask' do + expect(subject.matching_formats(0)).to eq([]) + end + end +end diff --git a/spec/lib/gitlab/ci/ansi2json/style_spec.rb b/spec/lib/gitlab/ci/ansi2json/style_spec.rb new file mode 100644 index 00000000000..88a0ca35859 --- /dev/null +++ b/spec/lib/gitlab/ci/ansi2json/style_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Ansi2json::Style do + describe '#set?' do + subject { described_class.new(params).set? } + + context 'when fg color is set' do + let(:params) { { fg: 'term-fg-black' } } + + it { is_expected.to be_truthy } + end + + context 'when bg color is set' do + let(:params) { { bg: 'term-bg-black' } } + + it { is_expected.to be_truthy } + end + + context 'when mask is set' do + let(:params) { { mask: 0x01 } } + + it { is_expected.to be_truthy } + end + + context 'nothing is set' do + let(:params) { {} } + + it { is_expected.to be_falsey } + end + end + + describe '#reset!' do + let(:style) { described_class.new(fg: 'term-fg-black', bg: 'term-bg-yellow', mask: 0x01) } + + it 'set the style params to default' do + style.reset! + + expect(style.fg).to be_nil + expect(style.bg).to be_nil + expect(style.mask).to be_zero + end + end + + describe 'update formats to mimic terminals' do + subject { described_class.new(params) } + + context 'when fg color present' do + let(:params) { { fg: 'term-fg-black', mask: mask } } + + context 'when mask is set to bold' do + let(:mask) { 0x01 } + + it 'changes the fg color to a lighter version' do + expect(subject.fg).to eq('term-fg-l-black') + end + end + + context 'when mask set to another format' do + let(:mask) { 0x02 } + + it 'does not change the fg color' do + expect(subject.fg).to eq('term-fg-black') + end + end + + context 'when mask is not set' do + let(:mask) { 0 } + + it 'does not change the fg color' do + expect(subject.fg).to eq('term-fg-black') + end + end + end + end + + describe '#update' do + where(:initial_state, :ansi_commands, :result, :description) do + [ + # add format + [[], %w[0], '', 'does not set any style'], + [[], %w[1], 'term-bold', 'enables format bold'], + [[], %w[3], 'term-italic', 'enables format italic'], + [[], %w[4], 'term-underline', 'enables format underline'], + [[], %w[8], 'term-conceal', 'enables format conceal'], + [[], %w[9], 'term-cross', 'enables format cross'], + # remove format + [%w[1], %w[21], '', 'disables format bold'], + [%w[1 3], %w[21], 'term-italic', 'disables format bold and leaves italic'], + [%w[1], %w[22], '', 'disables format bold using command 22'], + [%w[1 3], %w[22], 'term-italic', 'disables format bold and leaves italic using command 22'], + [%w[3], %w[23], '', 'disables format italic'], + [%w[1 3], %w[23], 'term-bold', 'disables format italic and leaves bold'], + [%w[4], %w[24], '', 'disables format underline'], + [%w[1 4], %w[24], 'term-bold', 'disables format underline and leaves bold'], + [%w[8], %w[28], '', 'disables format conceal'], + [%w[1 8], %w[28], 'term-bold', 'disables format conceal and leaves bold'], + [%w[9], %w[29], '', 'disables format cross'], + [%w[1 9], %w[29], 'term-bold', 'disables format cross and leaves bold'], + # set fg color + [[], %w[30], 'term-fg-black', 'sets fg color black'], + [[], %w[31], 'term-fg-red', 'sets fg color red'], + [[], %w[32], 'term-fg-green', 'sets fg color green'], + [[], %w[33], 'term-fg-yellow', 'sets fg color yellow'], + [[], %w[34], 'term-fg-blue', 'sets fg color blue'], + [[], %w[35], 'term-fg-magenta', 'sets fg color magenta'], + [[], %w[36], 'term-fg-cyan', 'sets fg color cyan'], + [[], %w[37], 'term-fg-white', 'sets fg color white'], + # sets xterm fg color + [[], %w[38 5 1], 'xterm-fg-1', 'sets xterm fg color 1'], + [[], %w[38 5 2], 'xterm-fg-2', 'sets xterm fg color 2'], + [[], %w[38 1], 'term-bold', 'ignores 38 command if not followed by 5 and sets format bold'], + # set bg color + [[], %w[40], 'term-bg-black', 'sets bg color black'], + [[], %w[41], 'term-bg-red', 'sets bg color red'], + [[], %w[42], 'term-bg-green', 'sets bg color green'], + [[], %w[43], 'term-bg-yellow', 'sets bg color yellow'], + [[], %w[44], 'term-bg-blue', 'sets bg color blue'], + [[], %w[45], 'term-bg-magenta', 'sets bg color magenta'], + [[], %w[46], 'term-bg-cyan', 'sets bg color cyan'], + [[], %w[47], 'term-bg-white', 'sets bg color white'], + # set xterm bg color + [[], %w[48 5 1], 'xterm-bg-1', 'sets xterm bg color 1'], + [[], %w[48 5 2], 'xterm-bg-2', 'sets xterm bg color 2'], + [[], %w[48 1], 'term-bold', 'ignores 48 command if not followed by 5 and sets format bold'], + # set light fg color + [[], %w[90], 'term-fg-l-black', 'sets fg color light black'], + [[], %w[91], 'term-fg-l-red', 'sets fg color light red'], + [[], %w[92], 'term-fg-l-green', 'sets fg color light green'], + [[], %w[93], 'term-fg-l-yellow', 'sets fg color light yellow'], + [[], %w[94], 'term-fg-l-blue', 'sets fg color light blue'], + [[], %w[95], 'term-fg-l-magenta', 'sets fg color light magenta'], + [[], %w[96], 'term-fg-l-cyan', 'sets fg color light cyan'], + [[], %w[97], 'term-fg-l-white', 'sets fg color light white'], + # set light bg color + [[], %w[100], 'term-bg-l-black', 'sets bg color light black'], + [[], %w[101], 'term-bg-l-red', 'sets bg color light red'], + [[], %w[102], 'term-bg-l-green', 'sets bg color light green'], + [[], %w[103], 'term-bg-l-yellow', 'sets bg color light yellow'], + [[], %w[104], 'term-bg-l-blue', 'sets bg color light blue'], + [[], %w[105], 'term-bg-l-magenta', 'sets bg color light magenta'], + [[], %w[106], 'term-bg-l-cyan', 'sets bg color light cyan'], + [[], %w[107], 'term-bg-l-white', 'sets bg color light white'], + # reset + [%w[1], %w[0], '', 'resets style from format bold'], + [%w[1 3], %w[0], '', 'resets style from format bold and italic'], + [%w[1 3 term-fg-l-red term-bg-yellow], %w[0], '', 'resets all formats and colors'], + # misc + [[], %w[1 30 42 3], 'term-fg-l-black term-bg-green term-bold term-italic', 'adds fg color, bg color and formats from no style'], + [%w[3 31], %w[23 1 43], 'term-fg-l-red term-bg-yellow term-bold', 'replaces format italic with bold and adds a yellow background'] + ] + end + + with_them do + it 'change the style' do + style = described_class.new + style.update(initial_state) + + style.update(ansi_commands) + + expect(style.to_s).to eq(result) + end + end + end +end diff --git a/spec/lib/gitlab/ci/ansi2json_spec.rb b/spec/lib/gitlab/ci/ansi2json_spec.rb new file mode 100644 index 00000000000..3c6bc46436b --- /dev/null +++ b/spec/lib/gitlab/ci/ansi2json_spec.rb @@ -0,0 +1,544 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Ansi2json do + subject { described_class } + + describe 'lines' do + it 'prints non-ansi as-is' do + expect(convert_json('Hello')).to eq([ + { offset: 0, content: [{ text: 'Hello' }] } + ]) + end + + it 'adds new line in a separate element' do + expect(convert_json("Hello\nworld")).to eq([ + { offset: 0, content: [{ text: 'Hello' }] }, + { offset: 6, content: [{ text: 'world' }] } + ]) + end + + it 'recognizes color changing ANSI sequences' do + expect(convert_json("\e[31mHello\e[0m")).to eq([ + { offset: 0, content: [{ text: 'Hello', style: 'term-fg-red' }] } + ]) + end + + it 'recognizes color changing ANSI sequences across multiple lines' do + expect(convert_json("\e[31mHello\nWorld\e[0m")).to eq([ + { offset: 0, content: [{ text: 'Hello', style: 'term-fg-red' }] }, + { offset: 11, content: [{ text: 'World', style: 'term-fg-red' }] } + ]) + end + + it 'recognizes background and foreground colors' do + expect(convert_json("\e[31;44mHello")).to eq([ + { offset: 0, content: [{ text: 'Hello', style: 'term-fg-red term-bg-blue' }] } + ]) + end + + it 'recognizes style changes within the same line' do + expect(convert_json("\e[31;44mHello\e[0m world")).to eq([ + { offset: 0, content: [ + { text: 'Hello', style: 'term-fg-red term-bg-blue' }, + { text: ' world' } + ] } + ]) + end + + context 'with section markers' do + let(:section_name) { 'prepare-script' } + let(:section_duration) { 63.seconds } + let(:section_start_time) { Time.new(2019, 9, 17).utc } + let(:section_end_time) { section_start_time + section_duration } + let(:section_start) { "section_start:#{section_start_time.to_i}:#{section_name}\r\033[0K"} + let(:section_end) { "section_end:#{section_end_time.to_i}:#{section_name}\r\033[0K"} + + it 'marks the first line of the section as header' do + expect(convert_json("Hello#{section_start}world!")).to eq([ + { + offset: 0, + content: [{ text: 'Hello' }] + }, + { + offset: 5, + content: [{ text: 'world!' }], + section: 'prepare-script', + section_header: true + } + ]) + end + + it 'does not marks the other lines of the section as header' do + expect(convert_json("outside section#{section_start}Hello\nworld!")).to eq([ + { + offset: 0, + content: [{ text: 'outside section' }] + }, + { + offset: 15, + content: [{ text: 'Hello' }], + section: 'prepare-script', + section_header: true + }, + { + offset: 65, + content: [{ text: 'world!' }], + section: 'prepare-script' + } + ]) + end + + it 'marks the last line of the section as footer' do + expect(convert_json("#{section_start}Good\nmorning\nworld!#{section_end}")).to eq([ + { + offset: 0, + content: [{ text: 'Good' }], + section: 'prepare-script', + section_header: true + }, + { + offset: 49, + content: [{ text: 'morning' }], + section: 'prepare-script' + }, + { + offset: 57, + content: [{ text: 'world!' }], + section: 'prepare-script' + }, + { + offset: 63, + content: [], + section_duration: '01:03', + section: 'prepare-script' + }, + { + offset: 63, + content: [] + } + ]) + end + + it 'marks the first line as header and footer if is the only line in the section' do + expect(convert_json("#{section_start}Hello world!#{section_end}")).to eq([ + { + offset: 0, + content: [{ text: 'Hello world!' }], + section: 'prepare-script', + section_header: true + }, + { + offset: 56, + content: [], + section: 'prepare-script', + section_duration: '01:03' + }, + { + offset: 56, + content: [] + } + ]) + end + + it 'does not add sections attribute to lines after the section is closed' do + expect(convert_json("#{section_start}Hello#{section_end}world")).to eq([ + { + offset: 0, + content: [{ text: 'Hello' }], + section: 'prepare-script', + section_header: true + }, + { + offset: 49, + content: [], + section: 'prepare-script', + section_duration: '01:03' + }, + { + offset: 49, + content: [{ text: 'world' }] + } + ]) + end + + it 'ignores section_end marker if no section_start exists' do + expect(convert_json("Hello #{section_end}world")).to eq([ + { + offset: 0, + content: [{ text: 'Hello world' }] + } + ]) + end + + context 'when section name contains .-_ and capital letters' do + let(:section_name) { 'a.Legit-SeCtIoN_namE' } + + it 'sanitizes the section name' do + expect(convert_json("Hello#{section_start}world!")).to eq([ + { + offset: 0, + content: [{ text: 'Hello' }] + }, + { + offset: 5, + content: [{ text: 'world!' }], + section: 'a-legit-section-name', + section_header: true + } + ]) + end + end + + context 'when section name includes $' do + let(:section_name) { 'my_$ection' } + + it 'ignores the section' do + expect(convert_json("#{section_start}hello")).to eq([ + { + offset: 0, + content: [{ text: "#{section_start.gsub("\033[0K", '')}hello" }] + } + ]) + end + end + + context 'when section name includes <' do + let(:section_name) { '<a_tag>' } + + it 'ignores the section' do + expect(convert_json("#{section_start}hello")).to eq([ + { + offset: 0, + content: [{ text: "#{section_start.gsub("\033[0K", '').gsub('<', '<')}hello" }] + } + ]) + end + end + + it 'prevents XSS injection' do + trace = "#{section_start}section_end:1:2<script>alert('XSS Hack!');</script>#{section_end}" + expect(convert_json(trace)).to eq([ + { + offset: 0, + content: [{ text: "section_end:1:2<script>alert('XSS Hack!');</script>" }], + section: 'prepare-script', + section_header: true + }, + { + offset: 95, + content: [], + section: 'prepare-script', + section_duration: '01:03' + }, + { + offset: 95, + content: [] + } + ]) + end + + context 'with nested section' do + let(:nested_section_name) { 'prepare-script-nested' } + let(:nested_section_duration) { 2.seconds } + let(:nested_section_start_time) { Time.new(2019, 9, 17).utc } + let(:nested_section_end_time) { nested_section_start_time + nested_section_duration } + let(:nested_section_start) { "section_start:#{nested_section_start_time.to_i}:#{nested_section_name}\r\033[0K"} + let(:nested_section_end) { "section_end:#{nested_section_end_time.to_i}:#{nested_section_name}\r\033[0K"} + + it 'adds multiple sections to the lines inside the nested section' do + trace = "Hello#{section_start}foo#{nested_section_start}bar#{nested_section_end}baz#{section_end}world" + + expect(convert_json(trace)).to eq([ + { + offset: 0, + content: [{ text: 'Hello' }] + }, + { + offset: 5, + content: [{ text: 'foo' }], + section: 'prepare-script', + section_header: true + }, + { + offset: 52, + content: [{ text: 'bar' }], + section: 'prepare-script-nested', + section_header: true + }, + { + offset: 106, + content: [], + section: 'prepare-script-nested', + section_duration: '00:02' + }, + { + offset: 106, + content: [{ text: 'baz' }], + section: 'prepare-script' + }, + { + offset: 158, + content: [], + section: 'prepare-script', + section_duration: '01:03' + }, + { + offset: 158, + content: [{ text: 'world' }] + } + ]) + end + + it 'adds multiple sections to the lines inside the nested section and closes all sections together' do + trace = "Hello#{section_start}\e[91mfoo\e[0m#{nested_section_start}bar#{nested_section_end}#{section_end}" + + expect(convert_json(trace)).to eq([ + { + offset: 0, + content: [{ text: 'Hello' }] + }, + { + offset: 5, + content: [{ text: 'foo', style: 'term-fg-l-red' }], + section: 'prepare-script', + section_header: true + }, + { + offset: 61, + content: [{ text: 'bar' }], + section: 'prepare-script-nested', + section_header: true + }, + { + offset: 115, + content: [], + section: 'prepare-script-nested', + section_duration: '00:02' + }, + { + offset: 115, + content: [], + section: 'prepare-script', + section_duration: '01:03' + }, + { + offset: 164, + content: [] + } + ]) + end + end + end + + describe 'incremental updates' do + let(:pass1_stream) { StringIO.new(pre_text) } + let(:pass2_stream) { StringIO.new(pre_text + text) } + let(:pass1) { subject.convert(pass1_stream) } + let(:pass2) { subject.convert(pass2_stream, pass1.state) } + + context 'with split word' do + let(:pre_text) { "\e[1mHello " } + let(:text) { "World" } + + let(:lines) do + [ + { offset: 0, content: [{ text: 'Hello World', style: 'term-bold' }] } + ] + end + + it 'returns the full line' do + expect(pass2.lines).to eq(lines) + expect(pass2.append).to be_falsey + end + end + + context 'with split word on second line' do + let(:pre_text) { "Good\nmorning " } + let(:text) { "World" } + + let(:lines) do + [ + { offset: 5, content: [{ text: 'morning World' }] } + ] + end + + it 'returns all lines since last partially processed line' do + expect(pass2.lines).to eq(lines) + expect(pass2.append).to be_truthy + end + end + + context 'with split sequence across multiple lines' do + let(:pre_text) { "\e[1mgood\nmorning\n" } + let(:text) { "\e[3mworld" } + + let(:lines) do + [ + { offset: 17, content: [{ text: 'world', style: 'term-bold term-italic' }] } + ] + end + + it 'returns the full line' do + expect(pass2.lines).to eq(lines) + expect(pass2.append).to be_truthy + end + end + + context 'with split partial sequence' do + let(:pre_text) { "hello\e" } + let(:text) { "[1m world" } + + let(:lines) do + [ + { offset: 0, content: [ + { text: 'hello' }, + { text: ' world', style: 'term-bold' } + ] } + ] + end + + it 'returns the full line' do + expect(pass2.lines).to eq(lines) + expect(pass2.append).to be_falsey + end + end + + context 'with split new line' do + let(:pre_text) { "hello\r" } + let(:text) { "\nworld" } + + let(:lines) do + [ + { offset: 0, content: [{ text: 'hello' }] }, + { offset: 7, content: [{ text: 'world' }] } + ] + end + + it 'returns the full line' do + expect(pass2.lines).to eq(lines) + expect(pass2.append).to be_falsey + end + end + + context 'with split section' do + let(:section_name) { 'prepare-script' } + let(:section_duration) { 63.seconds } + let(:section_start_time) { Time.new(2019, 9, 17).utc } + let(:section_end_time) { section_start_time + section_duration } + let(:section_start) { "section_start:#{section_start_time.to_i}:#{section_name}\r\033[0K"} + let(:section_end) { "section_end:#{section_end_time.to_i}:#{section_name}\r\033[0K"} + + context 'with split section body' do + let(:pre_text) { "#{section_start}this is a header\nand " } + let(:text) { "this\n is a body" } + + let(:lines) do + [ + { + offset: 61, + content: [{ text: 'and this' }], + section: 'prepare-script' + }, + { + offset: 70, + content: [{ text: ' is a body' }], + section: 'prepare-script' + } + ] + end + + it 'returns the full line' do + expect(pass2.lines).to eq(lines) + expect(pass2.append).to be_truthy + end + end + + context 'with split section where header is also split' do + let(:pre_text) { "#{section_start}this is " } + let(:text) { "a header\nand body" } + + let(:lines) do + [ + { + offset: 0, + content: [{ text: 'this is a header' }], + section: 'prepare-script', + section_header: true + }, + { + offset: 61, + content: [{ text: 'and body' }], + section: 'prepare-script' + } + ] + end + + it 'returns the full line' do + expect(pass2.lines).to eq(lines) + expect(pass2.append).to be_falsey + end + end + + context 'with split section end' do + let(:pre_text) { "#{section_start}this is a header\nthe" } + let(:text) { " body\nthe end#{section_end}" } + + let(:lines) do + [ + { + offset: 61, + content: [{ text: 'the body' }], + section: 'prepare-script' + }, + { + offset: 70, + content: [{ text: 'the end' }], + section: 'prepare-script' + }, + { + offset: 77, + content: [], + section: 'prepare-script', + section_duration: '01:03' + }, + { + offset: 77, + content: [] + } + ] + end + + it 'returns the full line' do + expect(pass2.lines).to eq(lines) + expect(pass2.append).to be_truthy + end + end + end + end + + describe 'trucates' do + let(:text) { "Hello World" } + let(:stream) { StringIO.new(text) } + let(:subject) { described_class.convert(stream) } + + before do + stream.seek(3, IO::SEEK_SET) + end + + it "returns truncated output" do + expect(subject.truncated).to be_truthy + end + + it "does not append output" do + expect(subject.append).to be_falsey + end + end + + def convert_json(data) + stream = StringIO.new(data) + subject.convert(stream).lines + end + end +end diff --git a/spec/lib/gitlab/diff/position_collection_spec.rb b/spec/lib/gitlab/diff/position_collection_spec.rb index de0e631ab03..f2a8312587c 100644 --- a/spec/lib/gitlab/diff/position_collection_spec.rb +++ b/spec/lib/gitlab/diff/position_collection_spec.rb @@ -35,14 +35,15 @@ describe Gitlab::Diff::PositionCollection do let(:text_position) { build_text_position } let(:folded_text_position) { build_text_position(old_line: 1, new_line: 1) } let(:image_position) { build_image_position } + let(:invalid_position) { 'a position' } let(:head_sha) { merge_request.diff_head_sha } let(:collection) do - described_class.new([text_position, folded_text_position, image_position], head_sha) + described_class.new([text_position, folded_text_position, image_position, invalid_position], head_sha) end describe '#to_a' do - it 'returns all positions' do + it 'returns all positions that are Gitlab::Diff::Position' do expect(collection.to_a).to eq([text_position, folded_text_position, image_position]) end end @@ -59,6 +60,14 @@ describe Gitlab::Diff::PositionCollection do expect(collection.unfoldable).to be_empty end end + + context 'when given head_sha is nil' do + let(:head_sha) { nil } + + it 'returns unfoldable diff positions unfiltered by head_sha' do + expect(collection.unfoldable).to eq([folded_text_position]) + end + end end describe '#concat' do diff --git a/spec/lib/gitlab/health_checks/probes/collection_spec.rb b/spec/lib/gitlab/health_checks/probes/collection_spec.rb new file mode 100644 index 00000000000..33efc640257 --- /dev/null +++ b/spec/lib/gitlab/health_checks/probes/collection_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::HealthChecks::Probes::Collection do + let(:readiness) { described_class.new(*checks) } + + describe '#call' do + subject { readiness.execute } + + context 'with all checks' do + let(:checks) do + [ + Gitlab::HealthChecks::DbCheck, + Gitlab::HealthChecks::Redis::RedisCheck, + Gitlab::HealthChecks::Redis::CacheCheck, + Gitlab::HealthChecks::Redis::QueuesCheck, + Gitlab::HealthChecks::Redis::SharedStateCheck, + Gitlab::HealthChecks::GitalyCheck + ] + end + + it 'responds with readiness checks data' do + expect(subject.http_status).to eq(200) + + expect(subject.json[:status]).to eq('ok') + expect(subject.json['db_check']).to contain_exactly(status: 'ok') + expect(subject.json['cache_check']).to contain_exactly(status: 'ok') + expect(subject.json['queues_check']).to contain_exactly(status: 'ok') + expect(subject.json['shared_state_check']).to contain_exactly(status: 'ok') + expect(subject.json['gitaly_check']).to contain_exactly( + status: 'ok', labels: { shard: 'default' }) + end + + context 'when Redis fails' do + before do + allow(Gitlab::HealthChecks::Redis::RedisCheck).to receive(:readiness).and_return( + Gitlab::HealthChecks::Result.new('redis_check', false, "check error")) + end + + it 'responds with failure' do + expect(subject.http_status).to eq(503) + + expect(subject.json[:status]).to eq('failed') + expect(subject.json['cache_check']).to contain_exactly(status: 'ok') + expect(subject.json['redis_check']).to contain_exactly( + status: 'failed', message: 'check error') + end + end + end + + context 'without checks' do + let(:checks) { [] } + + it 'responds with success' do + expect(subject.http_status).to eq(200) + + expect(subject.json).to eq(status: 'ok') + end + end + end +end diff --git a/spec/lib/gitlab/health_checks/probes/liveness_spec.rb b/spec/lib/gitlab/health_checks/probes/liveness_spec.rb deleted file mode 100644 index 91066cb8ba0..00000000000 --- a/spec/lib/gitlab/health_checks/probes/liveness_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe Gitlab::HealthChecks::Probes::Liveness do - let(:liveness) { described_class.new } - - describe '#call' do - subject { liveness.execute } - - it 'responds with liveness checks data' do - expect(subject.http_status).to eq(200) - - expect(subject.json[:status]).to eq('ok') - end - end -end diff --git a/spec/lib/gitlab/health_checks/probes/readiness_spec.rb b/spec/lib/gitlab/health_checks/probes/readiness_spec.rb deleted file mode 100644 index d88ffd984c2..00000000000 --- a/spec/lib/gitlab/health_checks/probes/readiness_spec.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe Gitlab::HealthChecks::Probes::Readiness do - let(:readiness) { described_class.new } - - describe '#call' do - subject { readiness.execute } - - it 'responds with readiness checks data' do - expect(subject.http_status).to eq(200) - - expect(subject.json[:status]).to eq('ok') - expect(subject.json['db_check']).to contain_exactly(status: 'ok') - expect(subject.json['cache_check']).to contain_exactly(status: 'ok') - expect(subject.json['queues_check']).to contain_exactly(status: 'ok') - expect(subject.json['shared_state_check']).to contain_exactly(status: 'ok') - expect(subject.json['gitaly_check']).to contain_exactly( - status: 'ok', labels: { shard: 'default' }) - end - - context 'when Redis fails' do - before do - allow(Gitlab::HealthChecks::Redis::RedisCheck).to receive(:readiness).and_return( - Gitlab::HealthChecks::Result.new('redis_check', false, "check error")) - end - - it 'responds with failure' do - expect(subject.http_status).to eq(503) - - expect(subject.json[:status]).to eq('failed') - expect(subject.json['cache_check']).to contain_exactly(status: 'ok') - expect(subject.json['redis_check']).to contain_exactly( - status: 'failed', message: 'check error') - end - end - end -end diff --git a/spec/lib/gitlab_spec.rb b/spec/lib/gitlab_spec.rb index c1d171815ba..6bf837f1d3f 100644 --- a/spec/lib/gitlab_spec.rb +++ b/spec/lib/gitlab_spec.rb @@ -146,7 +146,7 @@ describe Gitlab do describe '.ee?' do before do - stub_env('IS_GITLAB_EE', nil) # Make sure the ENV is clean + stub_env('FOSS_ONLY', nil) # Make sure the ENV is clean described_class.instance_variable_set(:@is_ee, nil) end @@ -154,42 +154,66 @@ describe Gitlab do described_class.instance_variable_set(:@is_ee, nil) end - it 'returns true when using Enterprise Edition' do - root = Pathname.new('dummy') - license_path = double(:path, exist?: true) + context 'for EE' do + before do + root = Pathname.new('dummy') + license_path = double(:path, exist?: true) - allow(described_class) - .to receive(:root) - .and_return(root) + allow(described_class) + .to receive(:root) + .and_return(root) - allow(root) - .to receive(:join) - .with('ee/app/models/license.rb') - .and_return(license_path) + allow(root) + .to receive(:join) + .with('ee/app/models/license.rb') + .and_return(license_path) + end - expect(described_class.ee?).to eq(true) - end + context 'when using FOSS_ONLY=1' do + before do + stub_env('FOSS_ONLY', '1') + end - it 'returns false when using Community Edition' do - root = double(:path) - license_path = double(:path, exists?: false) + it 'returns not to be EE' do + expect(described_class).not_to be_ee + end + end - allow(described_class) - .to receive(:root) - .and_return(Pathname.new('dummy')) + context 'when using FOSS_ONLY=0' do + before do + stub_env('FOSS_ONLY', '0') + end - allow(root) - .to receive(:join) - .with('ee/app/models/license.rb') - .and_return(license_path) + it 'returns to be EE' do + expect(described_class).to be_ee + end + end - expect(described_class.ee?).to eq(false) + context 'when using default FOSS_ONLY' do + it 'returns to be EE' do + expect(described_class).to be_ee + end + end end - it 'returns true when the IS_GITLAB_EE variable is not empty' do - stub_env('IS_GITLAB_EE', '1') + context 'for CE' do + before do + root = double(:path) + license_path = double(:path, exists?: false) - expect(described_class.ee?).to eq(true) + allow(described_class) + .to receive(:root) + .and_return(Pathname.new('dummy')) + + allow(root) + .to receive(:join) + .with('ee/app/models/license.rb') + .and_return(license_path) + end + + it 'returns not to be EE' do + expect(described_class).not_to be_ee + end end end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 84c25b93fc6..702a6fab0e6 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -56,6 +56,10 @@ describe ApplicationSetting do it { is_expected.not_to allow_value(nil).for(:protected_paths) } it { is_expected.to allow_value([]).for(:protected_paths) } + it { is_expected.to allow_value(3).for(:push_event_hooks_limit) } + it { is_expected.not_to allow_value('three').for(:push_event_hooks_limit) } + it { is_expected.not_to allow_value(nil).for(:push_event_hooks_limit) } + context "when user accepted let's encrypt terms of service" do before do setting.update(lets_encrypt_terms_of_service_accepted: true) diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb index 51ed8e9421b..3a0b3c46ad0 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -348,4 +348,17 @@ describe Deployment do expect(deployment.deployed_by).to eq(build_user) end end + + describe '.find_successful_deployment!' do + it 'returns a successful deployment' do + deploy = create(:deployment, :success) + + expect(described_class.find_successful_deployment!(deploy.iid)).to eq(deploy) + end + + it 'raises when no deployment is found' do + expect { described_class.find_successful_deployment!(-1) } + .to raise_error(ActiveRecord::RecordNotFound) + end + end end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 521c4704c87..786f3b832c4 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -882,4 +882,19 @@ describe Environment, :use_clean_rails_memory_store_caching do end end end + + describe '.find_or_create_by_name' do + it 'finds an existing environment if it exists' do + env = create(:environment) + + expect(described_class.find_or_create_by_name(env.name)).to eq(env) + end + + it 'creates an environment if it does not exist' do + env = project.environments.find_or_create_by_name('kittens') + + expect(env).to be_an_instance_of(described_class) + expect(env).to be_persisted + end + end end diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index 6093464c949..e61a064e82c 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -40,14 +40,14 @@ describe ProjectPolicy do update_commit_status create_build update_build create_pipeline update_pipeline create_merge_request_from create_wiki push_code resolve_note create_container_image update_container_image destroy_container_image - create_environment create_deployment create_release update_release + create_environment create_deployment update_deployment create_release update_release ] end let(:base_maintainer_permissions) do %i[ push_to_delete_protected_branch update_project_snippet update_environment - update_deployment admin_project_snippet admin_project_member admin_note admin_wiki admin_project + admin_project_snippet admin_project_member admin_note admin_wiki admin_project admin_commit_status admin_build admin_container_image admin_pipeline admin_environment admin_deployment destroy_release add_cluster daily_statistics diff --git a/spec/requests/api/deployments_spec.rb b/spec/requests/api/deployments_spec.rb index 3dac7225b7a..ad7be531979 100644 --- a/spec/requests/api/deployments_spec.rb +++ b/spec/requests/api/deployments_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe API::Deployments do @@ -96,4 +98,164 @@ describe API::Deployments do end end end + + describe 'POST /projects/:id/deployments' do + let!(:project) { create(:project, :repository) } + let(:sha) { 'b83d6e391c22777fca1ed3012fce84f633d7fed0' } + + context 'as a maintainer' do + it 'creates a new deployment' do + post( + api("/projects/#{project.id}/deployments", user), + params: { + environment: 'production', + sha: sha, + ref: 'master', + tag: false, + status: 'success' + } + ) + + expect(response).to have_gitlab_http_status(201) + + expect(json_response['sha']).to eq(sha) + expect(json_response['ref']).to eq('master') + expect(json_response['environment']['name']).to eq('production') + end + + it 'errors when creating a deployment with an invalid name' do + post( + api("/projects/#{project.id}/deployments", user), + params: { + environment: 'a' * 300, + sha: sha, + ref: 'master', + tag: false, + status: 'success' + } + ) + + expect(response).to have_gitlab_http_status(500) + end + end + + context 'as a developer' do + it 'creates a new deployment' do + developer = create(:user) + + project.add_developer(developer) + + post( + api("/projects/#{project.id}/deployments", developer), + params: { + environment: 'production', + sha: sha, + ref: 'master', + tag: false, + status: 'success' + } + ) + + expect(response).to have_gitlab_http_status(201) + + expect(json_response['sha']).to eq(sha) + expect(json_response['ref']).to eq('master') + end + end + + context 'as non member' do + it 'returns a 404 status code' do + post( + api( "/projects/#{project.id}/deployments", non_member), + params: { + environment: 'production', + sha: '123', + ref: 'master', + tag: false, + status: 'success' + } + ) + + expect(response).to have_gitlab_http_status(404) + end + end + end + + describe 'PUT /projects/:id/deployments/:deployment_id' do + let(:project) { create(:project) } + let(:build) { create(:ci_build, :failed, project: project) } + let(:environment) { create(:environment, project: project) } + let(:deploy) do + create( + :deployment, + :failed, + project: project, + environment: environment, + deployable: nil + ) + end + + context 'as a maintainer' do + it 'returns a 403 when updating a deployment with a build' do + deploy.update(deployable: build) + + put( + api("/projects/#{project.id}/deployments/#{deploy.id}", user), + params: { status: 'success' } + ) + + expect(response).to have_gitlab_http_status(403) + end + + it 'updates a deployment without an associated build' do + put( + api("/projects/#{project.id}/deployments/#{deploy.id}", user), + params: { status: 'success' } + ) + + expect(response).to have_gitlab_http_status(200) + expect(json_response['status']).to eq('success') + end + end + + context 'as a developer' do + let(:developer) { create(:user) } + + before do + project.add_developer(developer) + end + + it 'returns a 403 when updating a deployment with a build' do + deploy.update(deployable: build) + + put( + api("/projects/#{project.id}/deployments/#{deploy.id}", developer), + params: { status: 'success' } + ) + + expect(response).to have_gitlab_http_status(403) + end + + it 'updates a deployment without an associated build' do + put( + api("/projects/#{project.id}/deployments/#{deploy.id}", developer), + params: { status: 'success' } + ) + + expect(response).to have_gitlab_http_status(200) + expect(json_response['status']).to eq('success') + end + end + + context 'as non member' do + it 'returns a 404 status code' do + put( + api("/projects/#{project.id}/deployments/#{deploy.id}", non_member), + params: { status: 'success' } + ) + + expect(response).to have_gitlab_http_status(404) + end + end + end end diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb index 7e67ee28bef..eb55d747179 100644 --- a/spec/requests/api/members_spec.rb +++ b/spec/requests/api/members_spec.rb @@ -87,6 +87,15 @@ describe API::Members do expect(json_response.first['username']).to eq(maintainer.username) end + it 'finds members with the given user_ids' do + get api(members_url, developer), params: { user_ids: [maintainer.id, developer.id, stranger.id] } + + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.map { |u| u['id'] }).to contain_exactly(maintainer.id, developer.id) + end + it 'finds all members with no query specified' do get api(members_url, developer), params: { query: '' } @@ -155,10 +164,10 @@ describe API::Members do end end - shared_examples 'GET /:source_type/:id/members/:user_id' do |source_type| - context "with :source_type == #{source_type.pluralize}" do + shared_examples 'GET /:source_type/:id/members/(all/):user_id' do |source_type, all| + context "with :source_type == #{source_type.pluralize} and all == #{all}" do it_behaves_like 'a 404 response when source is private' do - let(:route) { get api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", stranger) } + let(:route) { get api("/#{source_type.pluralize}/#{source.id}/members/#{all ? 'all/' : ''}#{developer.id}", stranger) } end context 'when authenticated as a non-member' do @@ -166,7 +175,7 @@ describe API::Members do context "as a #{type}" do it 'returns 200' do user = public_send(type) - get api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", user) + get api("/#{source_type.pluralize}/#{source.id}/members/#{all ? 'all/' : ''}#{developer.id}", user) expect(response).to have_gitlab_http_status(200) # User attributes @@ -434,12 +443,14 @@ describe API::Members do end end - it_behaves_like 'GET /:source_type/:id/members/:user_id', 'project' do - let(:source) { project } - end + [false, true].each do |all| + it_behaves_like 'GET /:source_type/:id/members/(all/):user_id', 'project', all do + let(:source) { all ? create(:project, :public, group: group) : project } + end - it_behaves_like 'GET /:source_type/:id/members/:user_id', 'group' do - let(:source) { group } + it_behaves_like 'GET /:source_type/:id/members/(all/):user_id', 'group', all do + let(:source) { all ? create(:group, parent: group) : group } + end end it_behaves_like 'POST /:source_type/:id/members', 'project' do diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb index d98b9be726a..af1cf80e9d3 100644 --- a/spec/requests/api/settings_spec.rb +++ b/spec/requests/api/settings_spec.rb @@ -72,7 +72,8 @@ describe API::Settings, 'Settings' do default_branch_protection: ::Gitlab::Access::PROTECTION_DEV_CAN_MERGE, local_markdown_version: 3, allow_local_requests_from_web_hooks_and_services: true, - allow_local_requests_from_system_hooks: false + allow_local_requests_from_system_hooks: false, + push_event_hooks_limit: 2 } expect(response).to have_gitlab_http_status(200) @@ -102,6 +103,7 @@ describe API::Settings, 'Settings' do expect(json_response['local_markdown_version']).to eq(3) expect(json_response['allow_local_requests_from_web_hooks_and_services']).to eq(true) expect(json_response['allow_local_requests_from_system_hooks']).to eq(false) + expect(json_response['push_event_hooks_limit']).to eq(2) end end diff --git a/spec/services/update_deployment_service_spec.rb b/spec/services/deployments/after_create_service_spec.rb index 343dab8a974..b34483ea85b 100644 --- a/spec/services/update_deployment_service_spec.rb +++ b/spec/services/deployments/after_create_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe UpdateDeploymentService do +describe Deployments::AfterCreateService do let(:user) { create(:user) } let(:project) { create(:project, :repository) } let(:options) { { name: 'production' } } diff --git a/spec/services/deployments/create_service_spec.rb b/spec/services/deployments/create_service_spec.rb new file mode 100644 index 00000000000..e41c8259ea9 --- /dev/null +++ b/spec/services/deployments/create_service_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Deployments::CreateService do + let(:environment) do + double( + :environment, + deployment_platform: double(:platform, cluster_id: 1), + project_id: 2, + id: 3 + ) + end + + let(:user) { double(:user) } + + describe '#execute' do + let(:service) { described_class.new(environment, user, {}) } + + it 'does not run the AfterCreateService service if the deployment is not persisted' do + deploy = double(:deployment, persisted?: false) + + expect(service) + .to receive(:create_deployment) + .and_return(deploy) + + expect(Deployments::AfterCreateService) + .not_to receive(:new) + + expect(service.execute).to eq(deploy) + end + + it 'runs the AfterCreateService service if the deployment is persisted' do + deploy = double(:deployment, persisted?: true) + after_service = double(:after_create_service) + + expect(service) + .to receive(:create_deployment) + .and_return(deploy) + + expect(Deployments::AfterCreateService) + .to receive(:new) + .with(deploy) + .and_return(after_service) + + expect(after_service) + .to receive(:execute) + + expect(service.execute).to eq(deploy) + end + end + + describe '#create_deployment' do + it 'creates a deployment' do + environment = build(:environment) + service = described_class.new(environment, user, {}) + + expect(environment.deployments) + .to receive(:create) + .with(an_instance_of(Hash)) + + service.create_deployment + end + end + + describe '#deployment_attributes' do + it 'only includes attributes that we want to persist' do + service = described_class.new( + environment, + user, + ref: 'master', + tag: true, + sha: '123', + foo: 'bar', + on_stop: 'stop', + status: 'running' + ) + + expect(service.deployment_attributes).to eq( + cluster_id: 1, + project_id: 2, + environment_id: 3, + ref: 'master', + tag: true, + sha: '123', + user: user, + on_stop: 'stop', + status: 'running' + ) + end + end +end diff --git a/spec/services/deployments/update_service_spec.rb b/spec/services/deployments/update_service_spec.rb new file mode 100644 index 00000000000..a923099b82c --- /dev/null +++ b/spec/services/deployments/update_service_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Deployments::UpdateService do + let(:deploy) { create(:deployment, :running) } + let(:service) { described_class.new(deploy, status: 'success') } + + describe '#execute' do + it 'updates the status of a deployment' do + expect(service.execute).to eq(true) + expect(deploy.status).to eq('success') + end + end +end diff --git a/spec/services/git/base_hooks_service_spec.rb b/spec/services/git/base_hooks_service_spec.rb index e71900e3c0d..90b3eb38469 100644 --- a/spec/services/git/base_hooks_service_spec.rb +++ b/spec/services/git/base_hooks_service_spec.rb @@ -8,7 +8,6 @@ describe Git::BaseHooksService do let(:user) { create(:user) } let(:project) { create(:project, :repository) } - let(:oldrev) { Gitlab::Git::BLANK_SHA } let(:newrev) { "8a2a6eb295bb170b34c24c76c49ed0e9b2eaf34b" } # gitlab-test: git rev-parse refs/tags/v1.1.0 let(:ref) { 'refs/tags/v1.1.0' } @@ -26,7 +25,17 @@ describe Git::BaseHooksService do let(:project) { create(:project, :repository) } - subject { TestService.new(project, user, change: { oldrev: oldrev, newrev: newrev, ref: ref }) } + let(:params) do + { + change: { + oldrev: oldrev, + newrev: newrev, + ref: ref + } + } + end + + subject { TestService.new(project, user, params) } context '#execute_hooks' do before do @@ -83,5 +92,21 @@ describe Git::BaseHooksService do end end end + + context 'execute_project_hooks param set to false' do + before do + params[:execute_project_hooks] = false + + allow(project).to receive(:has_active_hooks?).and_return(true) + allow(project).to receive(:has_active_services?).and_return(true) + end + + it 'does not execute hooks and services' do + expect(project).not_to receive(:execute_hooks) + expect(project).not_to receive(:execute_services) + + subject.execute + end + end end end diff --git a/spec/services/git/process_ref_changes_service_spec.rb b/spec/services/git/process_ref_changes_service_spec.rb index 4d394a29867..eeb395f6c7b 100644 --- a/spec/services/git/process_ref_changes_service_spec.rb +++ b/spec/services/git/process_ref_changes_service_spec.rb @@ -28,12 +28,66 @@ describe Git::ProcessRefChangesService do it "calls #{push_service_class}" do expect(push_service_class) .to receive(:new) + .with(project, project.owner, hash_including(execute_project_hooks: true)) .exactly(changes.count).times .and_return(service) subject.execute end + context 'changes exceed push_event_hooks_limit' do + def multiple_changes(change, count) + Array.new(count).map.with_index do |n, index| + { index: index, oldrev: change[:oldrev], newrev: change[:newrev], ref: "#{change[:ref]}#{n}" } + end + end + + let(:push_event_hooks_limit) { 3 } + + let(:changes) do + multiple_changes( + { oldrev: '123456', newrev: '789012', ref: "#{ref_prefix}/test" }, + push_event_hooks_limit + 1 + ) + end + + before do + stub_application_setting(push_event_hooks_limit: push_event_hooks_limit) + end + + context 'git_push_execute_all_project_hooks is disabled' do + before do + stub_feature_flags(git_push_execute_all_project_hooks: false) + end + + it "calls #{push_service_class} with execute_project_hooks set to false" do + expect(push_service_class) + .to receive(:new) + .with(project, project.owner, hash_including(execute_project_hooks: false)) + .exactly(changes.count).times + .and_return(service) + + subject.execute + end + end + + context 'git_push_execute_all_project_hooks is enabled' do + before do + stub_feature_flags(git_push_execute_all_project_hooks: true) + end + + it "calls #{push_service_class} with execute_project_hooks set to true" do + expect(push_service_class) + .to receive(:new) + .with(project, project.owner, hash_including(execute_project_hooks: true)) + .exactly(changes.count).times + .and_return(service) + + subject.execute + end + end + end + context 'pipeline creation' do context 'with valid .gitlab-ci.yml' do before do diff --git a/spec/support/features/rss_shared_examples.rb b/spec/support/features/rss_shared_examples.rb index c97eeba87db..bbe793a81bc 100644 --- a/spec/support/features/rss_shared_examples.rb +++ b/spec/support/features/rss_shared_examples.rb @@ -8,7 +8,9 @@ end shared_examples "it has an RSS button with current_user's feed token" do it "shows the RSS button with current_user's feed token" do - expect(page).to have_css("a:has(.fa-rss)[href*='feed_token=#{user.feed_token}']") + expect(page) + .to have_css("a:has(.fa-rss)[href*='feed_token=#{user.feed_token}']") + .or have_css("a.js-rss-button[href*='feed_token=#{user.feed_token}']") end end @@ -20,6 +22,8 @@ end shared_examples "it has an RSS button without a feed token" do it "shows the RSS button without a feed token" do - expect(page).to have_css("a:has(.fa-rss):not([href*='feed_token'])") + expect(page) + .to have_css("a:has(.fa-rss):not([href*='feed_token'])") + .or have_css("a.js-rss-button:not([href*='feed_token'])") end end diff --git a/spec/support/shared_contexts/policies/project_policy_shared_context.rb b/spec/support/shared_contexts/policies/project_policy_shared_context.rb index 1aa40dcde3d..65398c13d90 100644 --- a/spec/support/shared_contexts/policies/project_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/project_policy_shared_context.rb @@ -38,14 +38,14 @@ RSpec.shared_context 'ProjectPolicy context' do update_commit_status create_build update_build create_pipeline update_pipeline create_merge_request_from create_wiki push_code resolve_note create_container_image update_container_image - create_environment create_deployment create_release update_release + create_environment create_deployment update_deployment create_release update_release ] end let(:base_maintainer_permissions) do %i[ push_to_delete_protected_branch update_project_snippet update_environment - update_deployment admin_project_snippet admin_project_member admin_note admin_wiki admin_project + admin_project_snippet admin_project_member admin_note admin_wiki admin_project admin_commit_status admin_build admin_container_image admin_pipeline admin_environment admin_deployment destroy_release add_cluster daily_statistics diff --git a/spec/workers/deployments/success_worker_spec.rb b/spec/workers/deployments/success_worker_spec.rb index 1c68922b03d..7f2816d7535 100644 --- a/spec/workers/deployments/success_worker_spec.rb +++ b/spec/workers/deployments/success_worker_spec.rb @@ -8,8 +8,8 @@ describe Deployments::SuccessWorker do context 'when successful deployment' do let(:deployment) { create(:deployment, :success) } - it 'executes UpdateDeploymentService' do - expect(UpdateDeploymentService) + it 'executes Deployments::AfterCreateService' do + expect(Deployments::AfterCreateService) .to receive(:new).with(deployment).and_call_original subject @@ -19,8 +19,8 @@ describe Deployments::SuccessWorker do context 'when canceled deployment' do let(:deployment) { create(:deployment, :canceled) } - it 'does not execute UpdateDeploymentService' do - expect(UpdateDeploymentService).not_to receive(:new) + it 'does not execute Deployments::AfterCreateService' do + expect(Deployments::AfterCreateService).not_to receive(:new) subject end @@ -29,8 +29,8 @@ describe Deployments::SuccessWorker do context 'when deploy record does not exist' do let(:deployment) { nil } - it 'does not execute UpdateDeploymentService' do - expect(UpdateDeploymentService).not_to receive(:new) + it 'does not execute Deployments::AfterCreateService' do + expect(Deployments::AfterCreateService).not_to receive(:new) subject end diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb index 6983fea021c..34aaa9bb1e9 100644 --- a/spec/workers/post_receive_spec.rb +++ b/spec/workers/post_receive_spec.rb @@ -93,6 +93,8 @@ describe PostReceive do end context 'with changes' do + let(:push_service) { double(execute: true) } + before do allow_any_instance_of(Gitlab::GitPostReceive).to receive(:identify).and_return(project.owner) allow(Gitlab::GlRepository).to receive(:parse).and_return([project, Gitlab::GlRepository::PROJECT]) |