Commit 8e45d25f authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 00c78fb8
VERSION merge=ours
Dangerfile gitlab-language=ruby
db/schema.rb merge=merge_db_schema
......@@ -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
......@@ -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:
......
......@@ -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'
......@@ -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/
......
......@@ -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"]
......@@ -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:
......
......@@ -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}
......
......@@ -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);
......
import ZenMode from '~/zen_mode';
import initEditRelease from '~/releases/detail';
document.addEventListener('DOMContentLoaded', () => {
new ZenMode(); // eslint-disable-line no-new
initEditRelease();
});
<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>
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');
},
});
};
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);
};
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,
});
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';
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;
},
};
export default () => ({
projectId: null,
tagName: null,
releasesPagePath: null,
markdownDocsPath: null,
markdownPreviewPath: null,
release: null,
isFetchingRelease: false,
fetchError: null,
isUpdatingRelease: false,
updateError: null,
});
......@@ -123,7 +123,7 @@ ul.content-list {
font-weight: $gl-font-weight-bold;
}
a:not(.default-link-color) {
a {
color: $gl-text-color;
}
......
.tag-release-link {
color: $blue-600 !important;
}
......@@ -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?
......
......@@ -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])
......
......@@ -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
......
......@@ -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
......
......@@ -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
......@@ -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
......@@ -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
......
......@@ -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,
......
......@@ -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
......
......@@ -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)
......
......@@ -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
......
......@@ -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
......@@ -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
......
# 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')
# 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
# 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
......@@ -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)
......
......@@ -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
......
# 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')
......@@ -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"
- 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 } }
......
.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
- 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?
......
......@@ -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
......
......@@ -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')
- page_title _('Edit Release')
#js-edit-release-page{ data: data_for_edit_release_page }
......@@ -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)
......
= 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')
......@@ -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
......
---
title: Fix showing diff when it has legacy diff notes
merge_request: 18510
author:
type: fixed
---
title: Don't execute webhooks/services when above limit
merge_request: 17874
author:
type: performance
---
title: Use correct icons for issue actions
merge_request:
author:
type: other
---
title: Introduce new Ansi2json parser to convert job logs to JSON
merge_request: 18133
author:
type: added
---
title: Add individual inherited member lookup API
merge_request: 17744
author:
type: added
---
title: Add API for manually creating and updating deployments
merge_request: 17620
author:
type: added
---
title: Add "Edit Release" page
merge_request: 18033
author:
type: added
---
title: Fix button link foreground color
merge_request: 18669
author:
type: fixed
......@@ -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;
......@@ -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),
}),
......
# 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
......@@ -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"
......
......@@ -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.
......@@ -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
}
```
......@@ -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 |
......
......@@ -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.
......
......@@ -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. |
......
......@@ -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
......
......@@ -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:
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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>
......
......@@ -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
......
......@@ -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**:
```
......
......@@ -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
......@@ -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
......
......@@ -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'
......
......@@ -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
......
# frozen_string_literal: true
# Convert terminal stream to JSON
module Gitlab
module HealthChecks
module Probes
class Liveness
def execute
Probes::Status.new(200, status: 'ok')
end
module Ci
module Ansi2json
def self.convert(ansi, state = nil)
Converter.new.convert(ansi, state)
end
end
end
......
# 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 << '&lt;'
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
# 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
# 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
# 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
# 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
......@@ -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
......
......@@ -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
......@@ -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)
......
......@@ -20,6 +20,3 @@
- type[:fields].each do |field|
= "| `#{field[:name]}` | #{render_field_type(field[:type][:info])} | #{field[:description]} |"
\
# 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
......@@ -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
......
......@@ -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)
......
......@@ -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
......
......@@ -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
......
......@@ -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 ""
......
......@@ -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
......
......@@ -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
......
......@@ -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)
......
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);
});
});
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);
});
});
});
/* 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);
});
});
});
# 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
......@@ -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)$/));
}
......
# 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
# 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
# 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
# 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('<', '&lt;')}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&lt;script>alert('XSS Hack!');&lt;/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
......@@ -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
......
# 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
# 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
# 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
......@@ -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,7 +154,8 @@ describe Gitlab do
described_class.instance_variable_set(:@is_ee, nil)
end
it 'returns true when using Enterprise Edition' do
context 'for EE' do
before do
root = Pathname.new('dummy')
license_path = double(:path, exist?: true)
......@@ -166,11 +167,37 @@ describe Gitlab do
.to receive(:join)
.with('ee/app/models/license.rb')
.and_return(license_path)
end
context 'when using FOSS_ONLY=1' do
before do
stub_env('FOSS_ONLY', '1')
end
it 'returns not to be EE' do
expect(described_class).not_to be_ee
end
end
context 'when using FOSS_ONLY=0' do
before do
stub_env('FOSS_ONLY', '0')
end
expect(described_class.ee?).to eq(true)
it 'returns to be EE' do
expect(described_class).to be_ee
end
end
context 'when using default FOSS_ONLY' do
it 'returns to be EE' do
expect(described_class).to be_ee
end
end
end
it 'returns false when using Community Edition' do
context 'for CE' do
before do
root = double(:path)
license_path = double(:path, exists?: false)
......@@ -182,14 +209,11 @@ describe Gitlab do
.to receive(:join)
.with('ee/app/models/license.rb')
.and_return(license_path)
expect(described_class.ee?).to eq(false)
end
it 'returns true when the IS_GITLAB_EE variable is not empty' do
stub_env('IS_GITLAB_EE', '1')
expect(described_class.ee?).to eq(true)
it 'returns not to be EE' do
expect(described_class).not_to be_ee
end
end
end
......
......@@ -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)
......
......@@ -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
......@@ -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
......@@ -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
......
# 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
......@@ -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 }
[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
......
......@@ -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
......
......@@ -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' } }
......
# 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
# 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
......@@ -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
......@@ -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
......
......@@ -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
......@@ -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
......
......@@ -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
......
......@@ -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])
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment