Commit f7f4112c authored by Phil Hughes's avatar Phil Hughes

Merge branch 'ce-to-ee-2018-12-01' into 'master'

CE upstream - 2018-12-01 06:21 UTC

Closes gitlab-ce#51210

See merge request gitlab-org/gitlab-ee!8664
parents e9cefd1e 377044b4
...@@ -94,7 +94,7 @@ gem 'gitlab_omniauth-ldap', '~> 2.0.4', require: 'omniauth-ldap' ...@@ -94,7 +94,7 @@ gem 'gitlab_omniauth-ldap', '~> 2.0.4', require: 'omniauth-ldap'
gem 'net-ldap' gem 'net-ldap'
# API # API
gem 'grape', '~> 1.1' gem 'grape', '~> 1.1.0'
gem 'grape-entity', '~> 0.7.1' gem 'grape-entity', '~> 0.7.1'
gem 'rack-cors', '~> 1.0.0', require: 'rack/cors' gem 'rack-cors', '~> 1.0.0', require: 'rack/cors'
......
...@@ -1052,7 +1052,7 @@ DEPENDENCIES ...@@ -1052,7 +1052,7 @@ DEPENDENCIES
google-api-client (~> 0.23) google-api-client (~> 0.23)
google-protobuf (~> 3.6) google-protobuf (~> 3.6)
gpgme gpgme
grape (~> 1.1) grape (~> 1.1.0)
grape-entity (~> 0.7.1) grape-entity (~> 0.7.1)
grape-path-helpers (~> 1.0) grape-path-helpers (~> 1.0)
grape_logging (~> 1.7) grape_logging (~> 1.7)
......
...@@ -1043,7 +1043,7 @@ DEPENDENCIES ...@@ -1043,7 +1043,7 @@ DEPENDENCIES
google-api-client (~> 0.23) google-api-client (~> 0.23)
google-protobuf (~> 3.6) google-protobuf (~> 3.6)
gpgme gpgme
grape (~> 1.1) grape (~> 1.1.0)
grape-entity (~> 0.7.1) grape-entity (~> 0.7.1)
grape-path-helpers (~> 1.0) grape-path-helpers (~> 1.0)
grape_logging (~> 1.7) grape_logging (~> 1.7)
......
...@@ -5,6 +5,7 @@ import axios from './lib/utils/axios_utils'; ...@@ -5,6 +5,7 @@ import axios from './lib/utils/axios_utils';
const Api = { const Api = {
groupsPath: '/api/:version/groups.json', groupsPath: '/api/:version/groups.json',
groupPath: '/api/:version/groups/:id', groupPath: '/api/:version/groups/:id',
subgroupsPath: '/api/:version/groups/:id/subgroups',
namespacesPath: '/api/:version/namespaces.json', namespacesPath: '/api/:version/namespaces.json',
groupProjectsPath: '/api/:version/groups/:id/projects.json', groupProjectsPath: '/api/:version/groups/:id/projects.json',
projectsPath: '/api/:version/projects.json', projectsPath: '/api/:version/projects.json',
......
...@@ -102,6 +102,12 @@ export default { ...@@ -102,6 +102,12 @@ export default {
if (this.shouldShow) { if (this.shouldShow) {
this.fetchData(); this.fetchData();
} }
const id = window && window.location && window.location.hash;
if (id) {
this.setHighlightedRow(id.slice(1));
}
}, },
created() { created() {
this.adjustView(); this.adjustView();
...@@ -114,6 +120,7 @@ export default { ...@@ -114,6 +120,7 @@ export default {
'fetchDiffFiles', 'fetchDiffFiles',
'startRenderDiffsQueue', 'startRenderDiffsQueue',
'assignDiscussionsToDiff', 'assignDiscussionsToDiff',
'setHighlightedRow',
]), ]),
fetchData() { fetchData() {
this.fetchDiffFiles() this.fetchDiffFiles()
......
...@@ -72,6 +72,13 @@ export default { ...@@ -72,6 +72,13 @@ export default {
diffFiles: state => state.diffs.diffFiles, diffFiles: state => state.diffs.diffFiles,
}), }),
...mapGetters(['isLoggedIn']), ...mapGetters(['isLoggedIn']),
lineCode() {
return (
this.line.line_code ||
(this.line.left && this.line.line.left.line_code) ||
(this.line.right && this.line.right.line_code)
);
},
lineHref() { lineHref() {
return `#${this.line.line_code || ''}`; return `#${this.line.line_code || ''}`;
}, },
...@@ -97,7 +104,7 @@ export default { ...@@ -97,7 +104,7 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions('diffs', ['loadMoreLines', 'showCommentForm']), ...mapActions('diffs', ['loadMoreLines', 'showCommentForm', 'setHighlightedRow']),
handleCommentButton() { handleCommentButton() {
this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash }); this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash });
}, },
...@@ -168,7 +175,13 @@ export default { ...@@ -168,7 +175,13 @@ export default {
> >
<icon :size="12" name="comment" /> <icon :size="12" name="comment" />
</button> </button>
<a v-if="lineNumber" :data-linenumber="lineNumber" :href="lineHref"> </a> <a
v-if="lineNumber"
:data-linenumber="lineNumber"
:href="lineHref"
@click="setHighlightedRow(lineCode);"
>
</a>
<diff-gutter-avatars v-if="shouldShowAvatarsOnGutter" :discussions="line.discussions" /> <diff-gutter-avatars v-if="shouldShowAvatarsOnGutter" :discussions="line.discussions" />
</template> </template>
</div> </div>
......
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import DiffLineGutterContent from './diff_line_gutter_content.vue'; import DiffLineGutterContent from './diff_line_gutter_content.vue';
import { import {
MATCH_LINE_TYPE, MATCH_LINE_TYPE,
...@@ -30,6 +30,11 @@ export default { ...@@ -30,6 +30,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
isHighlighted: {
type: Boolean,
required: true,
default: false,
},
diffViewType: { diffViewType: {
type: String, type: String,
required: false, required: false,
...@@ -85,6 +90,7 @@ export default { ...@@ -85,6 +90,7 @@ export default {
const { type } = this.line; const { type } = this.line;
return { return {
hll: this.isHighlighted,
[type]: type, [type]: type,
[LINE_UNFOLD_CLASS_NAME]: this.isMatchLine, [LINE_UNFOLD_CLASS_NAME]: this.isMatchLine,
[LINE_HOVER_CLASS_NAME]: [LINE_HOVER_CLASS_NAME]:
...@@ -99,6 +105,7 @@ export default { ...@@ -99,6 +105,7 @@ export default {
return this.lineType === OLD_LINE_TYPE ? this.line.old_line : this.line.new_line; return this.lineType === OLD_LINE_TYPE ? this.line.old_line : this.line.new_line;
}, },
}, },
methods: mapActions('diffs', ['setHighlightedRow']),
}; };
</script> </script>
......
<script> <script>
import { mapGetters, mapActions } from 'vuex'; import { mapGetters, mapActions, mapState } from 'vuex';
import DiffTableCell from './diff_table_cell.vue'; import DiffTableCell from './diff_table_cell.vue';
import { import {
NEW_LINE_TYPE, NEW_LINE_TYPE,
...@@ -40,6 +40,11 @@ export default { ...@@ -40,6 +40,11 @@ export default {
}; };
}, },
computed: { computed: {
...mapState({
isHighlighted(state) {
return this.line.line_code !== null && this.line.line_code === state.diffs.highlightedRow;
},
}),
...mapGetters('diffs', ['isInlineView']), ...mapGetters('diffs', ['isInlineView']),
isContextLine() { isContextLine() {
return this.line.type === CONTEXT_LINE_TYPE; return this.line.type === CONTEXT_LINE_TYPE;
...@@ -91,6 +96,7 @@ export default { ...@@ -91,6 +96,7 @@ export default {
:is-bottom="isBottom" :is-bottom="isBottom"
:is-hover="isHover" :is-hover="isHover"
:show-comment-button="true" :show-comment-button="true"
:is-highlighted="isHighlighted"
class="diff-line-num old_line" class="diff-line-num old_line"
/> />
<diff-table-cell <diff-table-cell
...@@ -100,8 +106,18 @@ export default { ...@@ -100,8 +106,18 @@ export default {
:line-type="newLineType" :line-type="newLineType"
:is-bottom="isBottom" :is-bottom="isBottom"
:is-hover="isHover" :is-hover="isHover"
:is-highlighted="isHighlighted"
class="diff-line-num new_line qa-new-diff-line" class="diff-line-num new_line qa-new-diff-line"
/> />
<td :class="line.type" class="line_content" v-html="line.rich_text"></td> <td
:class="[
line.type,
{
hll: isHighlighted,
},
]"
class="line_content"
v-html="line.rich_text"
></td>
</tr> </tr>
</template> </template>
<script> <script>
import { mapActions } from 'vuex'; import { mapActions, mapState } from 'vuex';
import $ from 'jquery'; import $ from 'jquery';
import DiffTableCell from './diff_table_cell.vue'; import DiffTableCell from './diff_table_cell.vue';
import { import {
...@@ -43,6 +43,15 @@ export default { ...@@ -43,6 +43,15 @@ export default {
}; };
}, },
computed: { computed: {
...mapState({
isHighlighted(state) {
const lineCode =
(this.line.left && this.line.left.line_code) ||
(this.line.right && this.line.right.line_code);
return lineCode ? lineCode === state.diffs.highlightedRow : false;
},
}),
isContextLine() { isContextLine() {
return this.line.left && this.line.left.type === CONTEXT_LINE_TYPE; return this.line.left && this.line.left.type === CONTEXT_LINE_TYPE;
}, },
...@@ -57,7 +66,14 @@ export default { ...@@ -57,7 +66,14 @@ export default {
return OLD_NO_NEW_LINE_TYPE; return OLD_NO_NEW_LINE_TYPE;
} }
return this.line.left ? this.line.left.type : EMPTY_CELL_TYPE; const lineTypeClass = this.line.left ? this.line.left.type : EMPTY_CELL_TYPE;
return [
lineTypeClass,
{
hll: this.isHighlighted,
},
];
}, },
}, },
created() { created() {
...@@ -114,6 +130,7 @@ export default { ...@@ -114,6 +130,7 @@ export default {
:line-type="oldLineType" :line-type="oldLineType"
:is-bottom="isBottom" :is-bottom="isBottom"
:is-hover="isLeftHover" :is-hover="isLeftHover"
:is-highlighted="isHighlighted"
:show-comment-button="true" :show-comment-button="true"
:diff-view-type="parallelDiffViewType" :diff-view-type="parallelDiffViewType"
line-position="left" line-position="left"
...@@ -139,6 +156,7 @@ export default { ...@@ -139,6 +156,7 @@ export default {
:line-type="newLineType" :line-type="newLineType"
:is-bottom="isBottom" :is-bottom="isBottom"
:is-hover="isRightHover" :is-hover="isRightHover"
:is-highlighted="isHighlighted"
:show-comment-button="true" :show-comment-button="true"
:diff-view-type="parallelDiffViewType" :diff-view-type="parallelDiffViewType"
line-position="right" line-position="right"
...@@ -146,7 +164,12 @@ export default { ...@@ -146,7 +164,12 @@ export default {
/> />
<td <td
:id="line.right.line_code" :id="line.right.line_code"
:class="line.right.type" :class="[
line.right.type,
{
hll: isHighlighted,
},
]"
class="line_content parallel right-side" class="line_content parallel right-side"
@mousedown.native="handleParallelLineMouseDown" @mousedown.native="handleParallelLineMouseDown"
v-html="line.right.rich_text" v-html="line.right.rich_text"
......
...@@ -33,6 +33,10 @@ export const fetchDiffFiles = ({ state, commit }) => { ...@@ -33,6 +33,10 @@ export const fetchDiffFiles = ({ state, commit }) => {
.then(handleLocationHash); .then(handleLocationHash);
}; };
export const setHighlightedRow = ({ commit }, lineCode) => {
commit(types.SET_HIGHLIGHTED_ROW, lineCode);
};
// This is adding line discussions to the actual lines in the diff tree // This is adding line discussions to the actual lines in the diff tree
// once for parallel and once for inline mode // once for parallel and once for inline mode
export const assignDiscussionsToDiff = ( export const assignDiscussionsToDiff = (
...@@ -127,7 +131,7 @@ export const loadMoreLines = ({ commit }, options) => { ...@@ -127,7 +131,7 @@ export const loadMoreLines = ({ commit }, options) => {
export const scrollToLineIfNeededInline = (_, line) => { export const scrollToLineIfNeededInline = (_, line) => {
const hash = getLocationHash(); const hash = getLocationHash();
if (hash && line.lineCode === hash) { if (hash && line.line_code === hash) {
handleLocationHash(); handleLocationHash();
} }
}; };
...@@ -137,7 +141,7 @@ export const scrollToLineIfNeededParallel = (_, line) => { ...@@ -137,7 +141,7 @@ export const scrollToLineIfNeededParallel = (_, line) => {
if ( if (
hash && hash &&
((line.left && line.left.lineCode === hash) || (line.right && line.right.lineCode === hash)) ((line.left && line.left.line_code === hash) || (line.right && line.right.line_code === hash))
) { ) {
handleLocationHash(); handleLocationHash();
} }
......
...@@ -26,4 +26,5 @@ export default () => ({ ...@@ -26,4 +26,5 @@ export default () => ({
currentDiffFileId: '', currentDiffFileId: '',
projectPath: '', projectPath: '',
commentForms: [], commentForms: [],
highlightedRow: null,
}); });
...@@ -17,3 +17,4 @@ export const UPDATE_CURRENT_DIFF_FILE_ID = 'UPDATE_CURRENT_DIFF_FILE_ID'; ...@@ -17,3 +17,4 @@ export const UPDATE_CURRENT_DIFF_FILE_ID = 'UPDATE_CURRENT_DIFF_FILE_ID';
export const OPEN_DIFF_FILE_COMMENT_FORM = 'OPEN_DIFF_FILE_COMMENT_FORM'; export const OPEN_DIFF_FILE_COMMENT_FORM = 'OPEN_DIFF_FILE_COMMENT_FORM';
export const UPDATE_DIFF_FILE_COMMENT_FORM = 'UPDATE_DIFF_FILE_COMMENT_FORM'; export const UPDATE_DIFF_FILE_COMMENT_FORM = 'UPDATE_DIFF_FILE_COMMENT_FORM';
export const CLOSE_DIFF_FILE_COMMENT_FORM = 'CLOSE_DIFF_FILE_COMMENT_FORM'; export const CLOSE_DIFF_FILE_COMMENT_FORM = 'CLOSE_DIFF_FILE_COMMENT_FORM';
export const SET_HIGHLIGHTED_ROW = 'SET_HIGHLIGHTED_ROW';
...@@ -241,4 +241,7 @@ export default { ...@@ -241,4 +241,7 @@ export default {
[types.CLOSE_DIFF_FILE_COMMENT_FORM](state, fileHash) { [types.CLOSE_DIFF_FILE_COMMENT_FORM](state, fileHash) {
state.commentForms = state.commentForms.filter(form => form.fileHash !== fileHash); state.commentForms = state.commentForms.filter(form => form.fileHash !== fileHash);
}, },
[types.SET_HIGHLIGHTED_ROW](state, lineCode) {
state.highlightedRow = lineCode;
},
}; };
...@@ -10,13 +10,18 @@ export default function groupsSelect() { ...@@ -10,13 +10,18 @@ export default function groupsSelect() {
const $select = $(this); const $select = $(this);
const allAvailable = $select.data('allAvailable'); const allAvailable = $select.data('allAvailable');
const skipGroups = $select.data('skipGroups') || []; const skipGroups = $select.data('skipGroups') || [];
const parentGroupID = $select.data('parentId');
const groupsPath = parentGroupID
? Api.subgroupsPath.replace(':id', parentGroupID)
: Api.groupsPath;
$select.select2({ $select.select2({
placeholder: 'Search for a group', placeholder: 'Search for a group',
allowClear: $select.hasClass('allowClear'), allowClear: $select.hasClass('allowClear'),
multiple: $select.hasClass('multiselect'), multiple: $select.hasClass('multiselect'),
minimumInputLength: 0, minimumInputLength: 0,
ajax: { ajax: {
url: Api.buildUrl(Api.groupsPath), url: Api.buildUrl(groupsPath),
dataType: 'json', dataType: 'json',
quietMillis: 250, quietMillis: 250,
transport(params) { transport(params) {
......
...@@ -17,27 +17,29 @@ export function getParameterValues(sParam) { ...@@ -17,27 +17,29 @@ export function getParameterValues(sParam) {
// @param {Object} params - url keys and value to merge // @param {Object} params - url keys and value to merge
// @param {String} url // @param {String} url
export function mergeUrlParams(params, url) { export function mergeUrlParams(params, url) {
let newUrl = Object.keys(params).reduce((acc, paramName) => { const re = /^([^?#]*)(\?[^#]*)?(.*)/;
const paramValue = encodeURIComponent(params[paramName]); const merged = {};
const pattern = new RegExp(`\\b(${paramName}=).*?(&|$)`); const urlparts = url.match(re);
if (paramValue === null) { if (urlparts[2]) {
return acc.replace(pattern, ''); urlparts[2]
} else if (url.search(pattern) !== -1) { .substr(1)
return acc.replace(pattern, `$1${paramValue}$2`); .split('&')
.forEach(part => {
if (part.length) {
const kv = part.split('=');
merged[decodeURIComponent(kv[0])] = decodeURIComponent(kv.slice(1).join('='));
}
});
} }
return `${acc}${acc.indexOf('?') > 0 ? '&' : '?'}${paramName}=${paramValue}`; Object.assign(merged, params);
}, decodeURIComponent(url));
// Remove a trailing ampersand
const lastChar = newUrl[newUrl.length - 1];
if (lastChar === '&') { const query = Object.keys(merged)
newUrl = newUrl.slice(0, -1); .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(merged[key])}`)
} .join('&');
return newUrl; return `${urlparts[1]}?${query}${urlparts[3]}`;
} }
export function removeParamQueryString(url, param) { export function removeParamQueryString(url, param) {
......
...@@ -366,7 +366,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" ...@@ -366,7 +366,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
> >
<button <button
:disabled="isSubmitButtonDisabled" :disabled="isSubmitButtonDisabled"
class="btn btn-success comment-btn js-comment-button js-comment-submit-button class="btn btn-create comment-btn js-comment-button js-comment-submit-button
qa-comment-button" qa-comment-button"
type="submit" type="submit"
@click.prevent="handleSave();" @click.prevent="handleSave();"
......
...@@ -8,6 +8,7 @@ import systemNote from '~/vue_shared/components/notes/system_note.vue'; ...@@ -8,6 +8,7 @@ import systemNote from '~/vue_shared/components/notes/system_note.vue';
import icon from '~/vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
import batchCommentsDiffLineNoteFormMixin from 'ee/batch_comments/mixins/diff_line_note_form'; import batchCommentsDiffLineNoteFormMixin from 'ee/batch_comments/mixins/diff_line_note_form';
import DraftNote from 'ee/batch_comments/components/draft_note.vue'; import DraftNote from 'ee/batch_comments/components/draft_note.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import Flash from '../../flash'; import Flash from '../../flash';
import { SYSTEM_NOTE } from '../constants'; import { SYSTEM_NOTE } from '../constants';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
...@@ -40,6 +41,7 @@ export default { ...@@ -40,6 +41,7 @@ export default {
placeholderSystemNote, placeholderSystemNote,
systemNote, systemNote,
DraftNote, DraftNote,
TimelineEntryItem,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -311,8 +313,7 @@ Please check your network connection and try again.`; ...@@ -311,8 +313,7 @@ Please check your network connection and try again.`;
</script> </script>
<template> <template>
<li class="note note-discussion timeline-entry" :class="componentClassName"> <timeline-entry-item class="note note-discussion" :class="componentClassName">
<div class="timeline-entry-inner">
<div class="timeline-content"> <div class="timeline-content">
<div :data-discussion-id="discussion.id" class="discussion js-discussion-container"> <div :data-discussion-id="discussion.id" class="discussion js-discussion-container">
<div v-if="shouldRenderDiffs" class="discussion-header note-wrapper"> <div v-if="shouldRenderDiffs" class="discussion-header note-wrapper">
...@@ -420,11 +421,7 @@ Please check your network connection and try again.`; ...@@ -420,11 +421,7 @@ Please check your network connection and try again.`;
class="btn btn-default mr-sm-2" class="btn btn-default mr-sm-2"
@click="resolveHandler();" @click="resolveHandler();"
> >
<i <i v-if="isResolving" aria-hidden="true" class="fa fa-spinner fa-spin"></i>
v-if="isResolving"
aria-hidden="true"
class="fa fa-spinner fa-spin"
></i>
{{ resolveButtonTitle }} {{ resolveButtonTitle }}
</button> </button>
</div> </div>
...@@ -473,6 +470,5 @@ Please check your network connection and try again.`; ...@@ -473,6 +470,5 @@ Please check your network connection and try again.`;
</div> </div>
</div> </div>
</div> </div>
</div> </timeline-entry-item>
</li>
</template> </template>
...@@ -5,6 +5,7 @@ import initSettingsPanels from '~/settings_panels'; ...@@ -5,6 +5,7 @@ import initSettingsPanels from '~/settings_panels';
import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
import { GROUP_BADGE } from '~/badges/constants'; import { GROUP_BADGE } from '~/badges/constants';
import groupsSelect from '~/groups_select';
import projectSelect from '~/project_select'; import projectSelect from '~/project_select';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
...@@ -17,5 +18,8 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -17,5 +18,8 @@ document.addEventListener('DOMContentLoaded', () => {
); );
mountBadgeSettings(GROUP_BADGE); mountBadgeSettings(GROUP_BADGE);
// Initialize Subgroups selector
groupsSelect();
projectSelect(); projectSelect();
}); });
...@@ -33,7 +33,11 @@ ...@@ -33,7 +33,11 @@
.bs-callout-warning { .bs-callout-warning {
background-color: $orange-100; background-color: $orange-100;
border-color: $orange-200; border-color: $orange-200;
color: $orange-700; color: $orange-900;
a {
color: $orange-900;
}
} }
.bs-callout-info { .bs-callout-info {
......
...@@ -31,16 +31,6 @@ ...@@ -31,16 +31,6 @@
.timeline-entry-inner { .timeline-entry-inner {
position: relative; position: relative;
@include notes-media('max', map-get($grid-breakpoints, sm)) {
.timeline-icon {
display: none;
}
.timeline-content {
margin-left: 0;
}
}
} }
&:target, &:target,
......
...@@ -589,12 +589,6 @@ $note-form-margin-left: 72px; ...@@ -589,12 +589,6 @@ $note-form-margin-left: 72px;
padding-bottom: 0; padding-bottom: 0;
} }
.note-header-author-name {
@include notes-media('max', map-get($grid-breakpoints, sm) - 1) {
display: none;
}
}
.note-headline-light { .note-headline-light {
display: inline; display: inline;
......
...@@ -4,6 +4,8 @@ module Ci ...@@ -4,6 +4,8 @@ module Ci
class CreatePipelineService < BaseService class CreatePipelineService < BaseService
attr_reader :pipeline attr_reader :pipeline
CreateError = Class.new(StandardError)
SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build, SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build,
EE::Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs, EE::Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs,
Gitlab::Ci::Pipeline::Chain::Validate::Abilities, Gitlab::Ci::Pipeline::Chain::Validate::Abilities,
...@@ -55,6 +57,14 @@ module Ci ...@@ -55,6 +57,14 @@ module Ci
pipeline pipeline
end end
def execute!(*args, &block)
execute(*args, &block).tap do |pipeline|
unless pipeline.persisted?
raise CreateError, pipeline.errors.full_messages.join(',')
end
end
end
private private
def commit def commit
......
...@@ -11,7 +11,7 @@ module Projects ...@@ -11,7 +11,7 @@ module Projects
end end
def execute def execute
if @params[:template_name]&.present? if @params[:template_name].present?
return ::Projects::CreateFromTemplateService.new(current_user, params).execute return ::Projects::CreateFromTemplateService.new(current_user, params).execute
end end
......
...@@ -37,6 +37,7 @@ ...@@ -37,6 +37,7 @@
.settings-content .settings-content
= render 'shared/badges/badge_settings' = render 'shared/badges/badge_settings'
= render_if_exists 'groups/custom_project_templates_setting'
= render_if_exists 'groups/templates_setting', expanded: expanded = render_if_exists 'groups/templates_setting', expanded: expanded
%section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded) } %section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded) }
......
- page_title "Invitation" - page_title _("Invitation")
%h3.page-title Invitation %h3.page-title= _("Invitation")
%p %p
You have been invited You have been invited
...@@ -24,14 +24,17 @@ ...@@ -24,14 +24,17 @@
- if is_member - if is_member
%p %p
However, you are already a member of this #{@member.source.is_a?(Group) ? "group" : "project"}. - member_source = @member.source.is_a?(Group) ? _("group") : _("project")
Sign in using a different account to accept the invitation. = _("However, you are already a member of this %{member_source}. Sign in using a different account to accept the invitation.") % { member_source: member_source }
- if @member.invite_email != current_user.email - if @member.invite_email != current_user.email
%p %p
Note that this invitation was sent to #{mail_to @member.invite_email}, but you are signed in as #{link_to current_user.to_reference, user_url(current_user)} with email #{mail_to current_user.email}. - mail_to_invite_email = mail_to(@member.invite_email)
- mail_to_current_user = mail_to(current_user.email)
- link_to_current_user = link_to(current_user.to_reference, user_url(current_user))
= _("Note that this invitation was sent to %{mail_to_invite_email}, but you are signed in as %{link_to_current_user} with email %{mail_to_current_user}.").html_safe % { mail_to_invite_email: mail_to_invite_email, mail_to_current_user: mail_to_current_user, link_to_current_user: link_to_current_user }
- unless is_member - unless is_member
.actions .actions
= link_to "Accept invitation", accept_invite_url(@token), method: :post, class: "btn btn-success" = link_to _("Accept invitation"), accept_invite_url(@token), method: :post, class: "btn btn-success"
= link_to "Decline", decline_invite_url(@token), method: :post, class: "btn btn-danger prepend-left-10" = link_to _("Decline"), decline_invite_url(@token), method: :post, class: "btn btn-danger prepend-left-10"
...@@ -58,7 +58,7 @@ ...@@ -58,7 +58,7 @@
.project-template .project-template
.form-group .form-group
%div %div
= render 'project_templates', f: f = render 'project_templates', f: f, project: @project
.tab-pane.import-project-pane.js-toggle-container{ id: 'import-project-pane', class: active_when(active_tab == 'import'), role: 'tabpanel' } .tab-pane.import-project-pane.js-toggle-container{ id: 'import-project-pane', class: active_when(active_tab == 'import'), role: 'tabpanel' }
- if import_sources_enabled? - if import_sources_enabled?
......
...@@ -9,9 +9,9 @@ ...@@ -9,9 +9,9 @@
.text-muted .text-muted
= template.description = template.description
.controls.d-flex.align-items-center .controls.d-flex.align-items-center
%label.btn.btn-success.template-button.choose-template.append-right-10.append-bottom-0{ for: template.name } %a.btn.btn-default.append-right-10{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "create_from_template", track_property: "template_preview", track_event: "click_button", track_value: template.name } }
= _("Preview")
%label.btn.btn-success.template-button.choose-template.append-bottom-0{ for: template.name }
%input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "create_from_template", track_property: "template_use", track_event: "click_button" } } %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "create_from_template", track_property: "template_use", track_event: "click_button" } }
%span %span
= _("Use template") = _("Use template")
%a.btn.btn-default{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "create_from_template", track_property: "template_preview", track_event: "click_button", track_value: template.name } }
= _("Preview")
...@@ -9,18 +9,36 @@ class PipelineScheduleWorker ...@@ -9,18 +9,36 @@ class PipelineScheduleWorker
Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now) Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now)
.preload(:owner, :project).find_each do |schedule| .preload(:owner, :project).find_each do |schedule|
begin begin
pipeline = Ci::CreatePipelineService.new(schedule.project, Ci::CreatePipelineService.new(schedule.project,
schedule.owner, schedule.owner,
ref: schedule.ref) ref: schedule.ref)
.execute(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule) .execute!(:schedule, ignore_skip_ci: true, save_on_errors: true, schedule: schedule)
schedule.deactivate! unless pipeline.persisted?
rescue => e rescue => e
Rails.logger.error "#{schedule.id}: Failed to create a scheduled pipeline: #{e.message}" error(schedule, e)
ensure ensure
schedule.schedule_next_run! schedule.schedule_next_run!
end end
end end
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
private
def error(schedule, error)
failed_creation_counter.increment
Rails.logger.error "Failed to create a scheduled pipeline. " \
"schedule_id: #{schedule.id} message: #{error.message}"
Gitlab::Sentry
.track_exception(error,
issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/41231',
extra: { schedule_id: schedule.id })
end
def failed_creation_counter
@failed_creation_counter ||=
Gitlab::Metrics.counter(:pipeline_schedule_creation_failed_total,
"Counter of failed attempts of pipeline schedule creation")
end
end end
---
title: When user clicks linenumber in MR changes, highlight that line
merge_request:
author:
type: fixed
---
title: Resolve status emoji being replaced by avatar on mobile
merge_request: 23408
author:
type: other
---
title: "Fix mergeUrlParams with fragment URL"
merge_request: 54218
author: Thomas Holder
type: fixed
---
title: Externalize strings from `/app/views/invites`
merge_request: 23205
author: Tao Wang
type: other
---
title: Remove auto deactivation when failed to create a pipeline via pipeline schedules
merge_request: 22243
author:
type: changed
---
title: Upgrade GitLab Workhorse to v7.3.0
merge_request: 23489
author:
type: other
...@@ -237,3 +237,14 @@ gitaly_enabled=false ...@@ -237,3 +237,14 @@ gitaly_enabled=false
When you run `service gitlab restart` Gitaly will be disabled on this When you run `service gitlab restart` Gitaly will be disabled on this
particular machine. particular machine.
## Troubleshooting Gitaly in production
Since GitLab 11.6, Gitaly comes with a command-line tool called
`gitaly-debug` that can be run on a Gitaly server to aid in
troubleshooting. In GitLab 11.6 its only sub-command is
`simulate-http-clone` which allows you to measure the maximum possible
Git clone speed for a specific repository on the server.
For an up to date list of sub-commands see [the gitaly-debug
README](https://gitlab.com/gitlab-org/gitaly/blob/master/cmd/gitaly-debug/README.md).
# Request Profiling # Request Profiling
## Procedure ## Procedure
1. Grab the profiling token from `Monitoring > Requests Profiles` admin page 1. Grab the profiling token from `Monitoring > Requests Profiles` admin page
(highlighted in a blue in the image below). (highlighted in a blue in the image below).
![Profile token](img/request_profiling_token.png) ![Profile token](img/request_profiling_token.png)
1. Pass the header `X-Profile-Token: <token>` to the request you want to profile. You can use any of these tools 1. Pass the header `X-Profile-Token: <token>` to the request you want to profile. You can use:
* [ModHeader](https://chrome.google.com/webstore/detail/modheader/idgpnmonknjnojddfkpgkljpfnnfcklj) Chrome extension - Browser extensions. For example, [ModHeader](https://chrome.google.com/webstore/detail/modheader/idgpnmonknjnojddfkpgkljpfnnfcklj) Chrome extension.
* [Modify Headers](https://addons.mozilla.org/en-US/firefox/addon/modify-headers/) Firefox extension - `curl`. For example, `curl --header 'X-Profile-Token: <token>' https://gitlab.example.com/group/project`.
* `curl --header 'X-Profile-Token: <token>' https://gitlab.example.com/group/project`
1. Once request is finished (which will take a little longer than usual), you can 1. Once request is finished (which will take a little longer than usual), you can
view the profiling output from `Monitoring > Requests Profiles` admin page. view the profiling output from `Monitoring > Requests Profiles` admin page.
![Profiling output](img/request_profile_result.png) ![Profiling output](img/request_profile_result.png)
## Cleaning up ## Cleaning up
Profiling output will be cleared out every day via a Sidekiq worker. Profiling output will be cleared out every day via a Sidekiq worker.
...@@ -237,8 +237,7 @@ Impersonation tokens are used exactly like regular personal access tokens, and c ...@@ -237,8 +237,7 @@ Impersonation tokens are used exactly like regular personal access tokens, and c
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/40385) in GitLab > [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/40385) in GitLab
11.6. 11.6.
By default, impersonation is enabled. To disable impersonation, GitLab must be By default, impersonation is enabled. To disable impersonation:
reconfigured:
**For Omnibus installations** **For Omnibus installations**
...@@ -584,7 +583,7 @@ When you try to access an API URL that does not exist you will receive 404 Not F ...@@ -584,7 +583,7 @@ When you try to access an API URL that does not exist you will receive 404 Not F
``` ```
HTTP/1.1 404 Not Found HTTP/1.1 404 Not Found
Content-Type: application/json Content-Type: application/json
{ f {
"error": "404 Not Found" "error": "404 Not Found"
} }
``` ```
......
...@@ -53,7 +53,7 @@ from teams other than your own. ...@@ -53,7 +53,7 @@ from teams other than your own.
#### Security requirements #### Security requirements
1. If your merge request is processing, storing, or transferring any kind of [RED or ORANGE data][https://docs.google.com/document/d/15eNKGA3zyZazsJMldqTBFbYMnVUSQSpU14lo22JMZQY/edit] (this is a confidential document), it must be 1. If your merge request is processing, storing, or transferring any kind of [RED or ORANGE data](https://docs.google.com/document/d/15eNKGA3zyZazsJMldqTBFbYMnVUSQSpU14lo22JMZQY/edit) (this is a confidential document), it must be
**approved by a [Security Engineer][team]**. **approved by a [Security Engineer][team]**.
1. If your merge request involves implementing, utilizing, or is otherwise related to any type of authentication, authorization, or session handling mechanism, it must be 1. If your merge request involves implementing, utilizing, or is otherwise related to any type of authentication, authorization, or session handling mechanism, it must be
**approved by a [Security Engineer][team]**. **approved by a [Security Engineer][team]**.
......
# Custom project templates **[PREMIUM ONLY]** # Custom instance-level project templates **[PREMIUM ONLY]**
> **Notes:** > [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/6860) in [GitLab Premium](https://about.gitlab.com/pricing) 11.2.
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/6860) in [GitLab Premium](https://about.gitlab.com/pricing) 11.2
When you create a new project, creating it based on custom project templates is When you create a new project, creating it based on custom project templates is
a convenient option to bootstrap from an existing project boilerplate. a convenient option to bootstrap from an existing project boilerplate.
...@@ -10,7 +9,7 @@ source can be found under **Admin > Settings > Custom project templates**. ...@@ -10,7 +9,7 @@ source can be found under **Admin > Settings > Custom project templates**.
Within this section, you can configure the group where all the custom project Within this section, you can configure the group where all the custom project
templates are sourced. Every project directly under the group namespace will be templates are sourced. Every project directly under the group namespace will be
available to the user if they have access to them. For example: Every public available to the user if they have access to them. For example, every public
project in the group will be available to every logged user. However, project in the group will be available to every logged user. However,
private projects will be available only if the user has view [permissions](../permissions.md) private projects will be available only if the user has view [permissions](../permissions.md)
in the project: in the project:
...@@ -21,4 +20,6 @@ in the project: ...@@ -21,4 +20,6 @@ in the project:
Projects below subgroups of the template group are **not** supported. Projects below subgroups of the template group are **not** supported.
Repository and database information that are copied over to each new project are Repository and database information that are copied over to each new project are
identical to the data exported with [GitLab's Project Import/Export](../project/settings/import_export.md). identical to the data exported with [GitLab's Project Import/Export](../project/settings/import_export.md).
If you would like to set project templates at a group level, please see [Custom group-level project templates](../group/custom_project_templates.md).
# Custom group-level project templates **[PREMIUM ONLY]**
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/6861) in [GitLab Premium](https://about.gitlab.com/pricing) 11.6.
When you create a new project, creating it based on custom project templates is
a convenient option to bootstrap from an existing project boilerplate.
The group-level setting to configure a GitLab group that serves as template
source can be found under **Group > Settings > General > Custom project templates**.
Within this section, you can configure the group where all the custom project
templates are sourced. Every project directly under the group namespace will be
available to the user if they have access to them. For example, every public
project in the group will be available to every logged in user. However,
private projects will be available only if the user has view [permissions](../permissions.md)
in the project. That is, users with Owner, Maintainer, Developer, Reporter or Guest roles for projects,
or for groups to which the project belongs.
Projects of nested subgroups of a selected template source cannot be used.
Repository and database information that are copied over to each new project are
identical to the data exported with [GitLab's Project Import/Export](../project/settings/import_export.md).
If you would like to set project templates at an instance level, please see [Custom instance-level project templates](../admin_area/custom_project_templates.md).
\ No newline at end of file
...@@ -324,6 +324,11 @@ To enable this feature, navigate to the group settings page, expand the ...@@ -324,6 +324,11 @@ To enable this feature, navigate to the group settings page, expand the
![Group file template settings](img/group_file_template_settings.png) ![Group file template settings](img/group_file_template_settings.png)
#### Group-level project templates **[PREMIUM]**
Define project templates at a group-level by setting a group as a template source.
[Learn more about group-level project templates](custom_project_templates.md).
### Advanced settings ### Advanced settings
- **Projects**: view all projects within that group, add members to each project, - **Projects**: view all projects within that group, add members to each project,
......
...@@ -83,12 +83,12 @@ The next time a pipeline is scheduled, your credentials will be used. ...@@ -83,12 +83,12 @@ The next time a pipeline is scheduled, your credentials will be used.
![Schedules list](img/pipeline_schedules_ownership.png) ![Schedules list](img/pipeline_schedules_ownership.png)
> **Note:** NOTE: **Note:**
When the owner of the schedule doesn't have the ability to create pipelines If the owner of a pipeline schedule doesn't have the ability to create pipelines
anymore, due to e.g., being blocked or removed from the project, or lacking on the target branch, the schedule will stop creating new pipelines. This can
the permission to run on protected branches or tags. When this happened, the happen if, for example, the owner is blocked or removed from the project, or
schedule is deactivated. Another user can take ownership and activate it, so the target branch or tag is protected. In this case, someone with sufficient
the schedule can be run again. privileges must take ownership of the schedule.
## Advanced admin configuration ## Advanced admin configuration
......
...@@ -15,7 +15,7 @@ module Gitlab ...@@ -15,7 +15,7 @@ module Gitlab
@global = Entry::Global.new(@config) @global = Entry::Global.new(@config)
@global.compose! @global.compose!
rescue Loader::FormatError, rescue Gitlab::Config::Loader::FormatError,
Extendable::ExtensionError, Extendable::ExtensionError,
External::Processor::IncludeError => e External::Processor::IncludeError => e
raise Config::ConfigError, e.message raise Config::ConfigError, e.message
...@@ -71,7 +71,7 @@ module Gitlab ...@@ -71,7 +71,7 @@ module Gitlab
private private
def build_config(config, opts = {}) def build_config(config, opts = {})
initial_config = Loader.new(config).load! initial_config = Gitlab::Config::Loader::Yaml.new(config).load!
project = opts.fetch(:project, nil) project = opts.fetch(:project, nil)
if project if project
......
...@@ -7,10 +7,10 @@ module Gitlab ...@@ -7,10 +7,10 @@ module Gitlab
## ##
# Entry that represents a configuration of job artifacts. # Entry that represents a configuration of job artifacts.
# #
class Artifacts < Node class Artifacts < ::Gitlab::Config::Entry::Node
include Configurable include ::Gitlab::Config::Entry::Configurable
include Validatable include ::Gitlab::Config::Entry::Validatable
include Attributable include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[name untracked paths reports when expire_in].freeze ALLOWED_KEYS = %i[name untracked paths reports when expire_in].freeze
......
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
module Attributable
extend ActiveSupport::Concern
class_methods do
def attributes(*attributes)
attributes.flatten.each do |attribute|
if method_defined?(attribute)
raise ArgumentError, 'Method already defined!'
end
define_method(attribute) do
return unless config.is_a?(Hash)
config[attribute]
end
end
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# Entry that represents a boolean value.
#
class Boolean < Node
include Validatable
validations do
validates :config, boolean: true
end
end
end
end
end
end
...@@ -7,9 +7,9 @@ module Gitlab ...@@ -7,9 +7,9 @@ module Gitlab
## ##
# Entry that represents a cache configuration # Entry that represents a cache configuration
# #
class Cache < Node class Cache < ::Gitlab::Config::Entry::Node
include Configurable include ::Gitlab::Config::Entry::Configurable
include Attributable include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[key untracked paths policy].freeze ALLOWED_KEYS = %i[key untracked paths policy].freeze
DEFAULT_POLICY = 'pull-push'.freeze DEFAULT_POLICY = 'pull-push'.freeze
...@@ -22,7 +22,7 @@ module Gitlab ...@@ -22,7 +22,7 @@ module Gitlab
entry :key, Entry::Key, entry :key, Entry::Key,
description: 'Cache key used to define a cache affinity.' description: 'Cache key used to define a cache affinity.'
entry :untracked, Entry::Boolean, entry :untracked, ::Gitlab::Config::Entry::Boolean,
description: 'Cache all untracked files.' description: 'Cache all untracked files.'
entry :paths, Entry::Paths, entry :paths, Entry::Paths,
......
...@@ -7,8 +7,8 @@ module Gitlab ...@@ -7,8 +7,8 @@ module Gitlab
## ##
# Entry that represents a job script. # Entry that represents a job script.
# #
class Commands < Node class Commands < ::Gitlab::Config::Entry::Node
include Validatable include ::Gitlab::Config::Entry::Validatable
validations do validations do
validates :config, array_of_strings_or_string: true validates :config, array_of_strings_or_string: true
......
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# This mixin is responsible for adding DSL, which purpose is to
# simplifly process of adding child nodes.
#
# This can be used only if parent node is a configuration entry that
# holds a hash as a configuration value, for example:
#
# job:
# script: ...
# artifacts: ...
#
module Configurable
extend ActiveSupport::Concern
included do
include Validatable
validations do
validates :config, type: Hash
end
end
# rubocop: disable CodeReuse/ActiveRecord
def compose!(deps = nil)
return unless valid?
self.class.nodes.each do |key, factory|
factory
.value(config[key])
.with(key: key, parent: self)
entries[key] = factory.create!
end
yield if block_given?
entries.each_value do |entry|
entry.compose!(deps)
end
end
# rubocop: enable CodeReuse/ActiveRecord
class_methods do
def nodes
Hash[(@nodes || {}).map { |key, factory| [key, factory.dup] }]
end
private
# rubocop: disable CodeReuse/ActiveRecord
def entry(key, entry, metadata)
factory = Entry::Factory.new(entry)
.with(description: metadata[:description])
(@nodes ||= {}).merge!(key.to_sym => factory)
end
# rubocop: enable CodeReuse/ActiveRecord
def helpers(*nodes)
nodes.each do |symbol|
define_method("#{symbol}_defined?") do
entries[symbol]&.specified?
end
define_method("#{symbol}_value") do
return unless entries[symbol] && entries[symbol].valid?
entries[symbol].value
end
end
end
end
end
end
end
end
end
...@@ -7,8 +7,8 @@ module Gitlab ...@@ -7,8 +7,8 @@ module Gitlab
## ##
# Entry that represents Coverage settings. # Entry that represents Coverage settings.
# #
class Coverage < Node class Coverage < ::Gitlab::Config::Entry::Node
include Validatable include ::Gitlab::Config::Entry::Validatable
validations do validations do
validates :config, regexp: true validates :config, regexp: true
......
...@@ -7,8 +7,8 @@ module Gitlab ...@@ -7,8 +7,8 @@ module Gitlab
## ##
# Entry that represents an environment. # Entry that represents an environment.
# #
class Environment < Node class Environment < ::Gitlab::Config::Entry::Node
include Validatable include ::Gitlab::Config::Entry::Validatable
ALLOWED_KEYS = %i[name url action on_stop].freeze ALLOWED_KEYS = %i[name url action on_stop].freeze
......
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# Factory class responsible for fabricating entry objects.
#
class Factory
InvalidFactory = Class.new(StandardError)
def initialize(entry)
@entry = entry
@metadata = {}
@attributes = {}
end
def value(value)
@value = value
self
end
def metadata(metadata)
@metadata.merge!(metadata)
self
end
def with(attributes)
@attributes.merge!(attributes)
self
end
def create!
raise InvalidFactory unless defined?(@value)
##
# We assume that unspecified entry is undefined.
# See issue #18775.
#
if @value.nil?
Entry::Unspecified.new(
fabricate_unspecified
)
else
fabricate(@entry, @value)
end
end
private
def fabricate_unspecified
##
# If entry has a default value we fabricate concrete node
# with default value.
#
if @entry.default.nil?
fabricate(Entry::Undefined)
else
fabricate(@entry, @entry.default)
end
end
def fabricate(entry, value = nil)
entry.new(value, @metadata).tap do |node|
node.key = @attributes[:key]
node.parent = @attributes[:parent]
node.description = @attributes[:description]
end
end
end
end
end
end
end
...@@ -8,8 +8,8 @@ module Gitlab ...@@ -8,8 +8,8 @@ module Gitlab
# This class represents a global entry - root Entry for entire # This class represents a global entry - root Entry for entire
# GitLab CI Configuration file. # GitLab CI Configuration file.
# #
class Global < Node class Global < ::Gitlab::Config::Entry::Node
include Configurable include ::Gitlab::Config::Entry::Configurable
entry :before_script, Entry::Script, entry :before_script, Entry::Script,
description: 'Script that will be executed before each job.' description: 'Script that will be executed before each job.'
...@@ -49,7 +49,7 @@ module Gitlab ...@@ -49,7 +49,7 @@ module Gitlab
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def compose_jobs! def compose_jobs!
factory = Entry::Factory.new(Entry::Jobs) factory = ::Gitlab::Config::Entry::Factory.new(Entry::Jobs)
.value(@config.except(*self.class.nodes.keys)) .value(@config.except(*self.class.nodes.keys))
.with(key: :jobs, parent: self, .with(key: :jobs, parent: self,
description: 'Jobs definition for this pipeline') description: 'Jobs definition for this pipeline')
......
...@@ -7,8 +7,8 @@ module Gitlab ...@@ -7,8 +7,8 @@ module Gitlab
## ##
# Entry that represents a hidden CI/CD key. # Entry that represents a hidden CI/CD key.
# #
class Hidden < Node class Hidden < ::Gitlab::Config::Entry::Node
include Validatable include ::Gitlab::Config::Entry::Validatable
validations do validations do
validates :config, presence: true validates :config, presence: true
......
...@@ -7,8 +7,8 @@ module Gitlab ...@@ -7,8 +7,8 @@ module Gitlab
## ##
# Entry that represents a Docker image. # Entry that represents a Docker image.
# #
class Image < Node class Image < ::Gitlab::Config::Entry::Node
include Validatable include ::Gitlab::Config::Entry::Validatable
ALLOWED_KEYS = %i[name entrypoint].freeze ALLOWED_KEYS = %i[name entrypoint].freeze
......
...@@ -7,9 +7,9 @@ module Gitlab ...@@ -7,9 +7,9 @@ module Gitlab
## ##
# Entry that represents a concrete CI/CD job. # Entry that represents a concrete CI/CD job.
# #
class Job < Node class Job < ::Gitlab::Config::Entry::Node
include Configurable include ::Gitlab::Config::Entry::Configurable
include Attributable include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[tags script only except type image services ALLOWED_KEYS = %i[tags script only except type image services
allow_failure type stage when start_in artifacts cache allow_failure type stage when start_in artifacts cache
......
...@@ -7,8 +7,8 @@ module Gitlab ...@@ -7,8 +7,8 @@ module Gitlab
## ##
# Entry that represents a set of jobs. # Entry that represents a set of jobs.
# #
class Jobs < Node class Jobs < ::Gitlab::Config::Entry::Node
include Validatable include ::Gitlab::Config::Entry::Validatable
validations do validations do
validates :config, type: Hash validates :config, type: Hash
...@@ -34,7 +34,7 @@ module Gitlab ...@@ -34,7 +34,7 @@ module Gitlab
@config.each do |name, config| @config.each do |name, config|
node = hidden?(name) ? Entry::Hidden : Entry::Job node = hidden?(name) ? Entry::Hidden : Entry::Job
factory = Entry::Factory.new(node) factory = ::Gitlab::Config::Entry::Factory.new(node)
.value(config || {}) .value(config || {})
.metadata(name: name) .metadata(name: name)
.with(key: name, parent: self, .with(key: name, parent: self,
......
...@@ -7,8 +7,8 @@ module Gitlab ...@@ -7,8 +7,8 @@ module Gitlab
## ##
# Entry that represents a key. # Entry that represents a key.
# #
class Key < Node class Key < ::Gitlab::Config::Entry::Node
include Validatable include ::Gitlab::Config::Entry::Validatable
validations do validations do
validates :config, key: true validates :config, key: true
......
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
module LegacyValidationHelpers
private
def validate_duration(value)
value.is_a?(String) && ChronicDuration.parse(value)
rescue ChronicDuration::DurationParseError
false
end
def validate_duration_limit(value, limit)
return false unless value.is_a?(String)
ChronicDuration.parse(value).second.from_now <
ChronicDuration.parse(limit).second.from_now
rescue ChronicDuration::DurationParseError
false
end
def validate_array_of_strings(values)
values.is_a?(Array) && values.all? { |value| validate_string(value) }
end
def validate_array_of_strings_or_regexps(values)
values.is_a?(Array) && values.all? { |value| validate_string_or_regexp(value) }
end
def validate_variables(variables)
variables.is_a?(Hash) &&
variables.flatten.all? do |value|
validate_string(value) || validate_integer(value)
end
end
def validate_integer(value)
value.is_a?(Integer)
end
def validate_string(value)
value.is_a?(String) || value.is_a?(Symbol)
end
def validate_regexp(value)
!value.nil? && Regexp.new(value.to_s) && true
rescue RegexpError, TypeError
false
end
def validate_string_or_regexp(value)
return true if value.is_a?(Symbol)
return false unless value.is_a?(String)
if value.first == '/' && value.last == '/'
validate_regexp(value[1...-1])
else
true
end
end
def validate_boolean(value)
value.in?([true, false])
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# Base abstract class for each configuration entry node.
#
class Node
InvalidError = Class.new(StandardError)
attr_reader :config, :metadata
attr_accessor :key, :parent, :description
def initialize(config, **metadata)
@config = config
@metadata = metadata
@entries = {}
self.class.aspects.to_a.each do |aspect|
instance_exec(&aspect)
end
end
def [](key)
@entries[key] || Entry::Undefined.new
end
def compose!(deps = nil)
return unless valid?
yield if block_given?
end
def leaf?
@entries.none?
end
def descendants
@entries.values
end
def ancestors
@parent ? @parent.ancestors + [@parent] : []
end
def valid?
errors.none?
end
def errors
[]
end
def value
if leaf?
@config
else
meaningful = @entries.select do |_key, value|
value.specified? && value.relevant?
end
Hash[meaningful.map { |key, entry| [key, entry.value] }]
end
end
def specified?
true
end
def relevant?
true
end
def location
name = @key.presence || self.class.name.to_s.demodulize
.underscore.humanize.downcase
ancestors.map(&:key).append(name).compact.join(':')
end
def inspect
val = leaf? ? config : descendants
unspecified = specified? ? '' : '(unspecified) '
"#<#{self.class.name} #{unspecified}{#{key}: #{val.inspect}}>"
end
def self.default
end
def self.aspects
@aspects ||= []
end
private
attr_reader :entries
end
end
end
end
end
...@@ -7,8 +7,8 @@ module Gitlab ...@@ -7,8 +7,8 @@ module Gitlab
## ##
# Entry that represents an array of paths. # Entry that represents an array of paths.
# #
class Paths < Node class Paths < ::Gitlab::Config::Entry::Node
include Validatable include ::Gitlab::Config::Entry::Validatable
validations do validations do
validates :config, array_of_strings: true validates :config, array_of_strings: true
......
...@@ -7,12 +7,12 @@ module Gitlab ...@@ -7,12 +7,12 @@ module Gitlab
## ##
# Entry that represents an only/except trigger policy for the job. # Entry that represents an only/except trigger policy for the job.
# #
class Policy < Simplifiable class Policy < ::Gitlab::Config::Entry::Simplifiable
strategy :RefsPolicy, if: -> (config) { config.is_a?(Array) } strategy :RefsPolicy, if: -> (config) { config.is_a?(Array) }
strategy :ComplexPolicy, if: -> (config) { config.is_a?(Hash) } strategy :ComplexPolicy, if: -> (config) { config.is_a?(Hash) }
class RefsPolicy < Entry::Node class RefsPolicy < ::Gitlab::Config::Entry::Node
include Entry::Validatable include ::Gitlab::Config::Entry::Validatable
validations do validations do
validates :config, array_of_strings_or_regexps: true validates :config, array_of_strings_or_regexps: true
...@@ -23,9 +23,9 @@ module Gitlab ...@@ -23,9 +23,9 @@ module Gitlab
end end
end end
class ComplexPolicy < Entry::Node class ComplexPolicy < ::Gitlab::Config::Entry::Node
include Entry::Validatable include ::Gitlab::Config::Entry::Validatable
include Entry::Attributable include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[refs kubernetes variables changes].freeze ALLOWED_KEYS = %i[refs kubernetes variables changes].freeze
attributes :refs, :kubernetes, :variables, :changes attributes :refs, :kubernetes, :variables, :changes
...@@ -58,7 +58,7 @@ module Gitlab ...@@ -58,7 +58,7 @@ module Gitlab
end end
end end
class UnknownStrategy < Entry::Node class UnknownStrategy < ::Gitlab::Config::Entry::Node
def errors def errors
["#{location} has to be either an array of conditions or a hash"] ["#{location} has to be either an array of conditions or a hash"]
end end
......
...@@ -7,9 +7,9 @@ module Gitlab ...@@ -7,9 +7,9 @@ module Gitlab
## ##
# Entry that represents a configuration of job artifacts. # Entry that represents a configuration of job artifacts.
# #
class Reports < Node class Reports < ::Gitlab::Config::Entry::Node
include Validatable include ::Gitlab::Config::Entry::Validatable
include Attributable include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[junit codequality sast dependency_scanning container_scanning dast performance license_management].freeze ALLOWED_KEYS = %i[junit codequality sast dependency_scanning container_scanning dast performance license_management].freeze
......
...@@ -7,12 +7,12 @@ module Gitlab ...@@ -7,12 +7,12 @@ module Gitlab
## ##
# Entry that represents a retry config for a job. # Entry that represents a retry config for a job.
# #
class Retry < Simplifiable class Retry < ::Gitlab::Config::Entry::Simplifiable
strategy :SimpleRetry, if: -> (config) { config.is_a?(Integer) } strategy :SimpleRetry, if: -> (config) { config.is_a?(Integer) }
strategy :FullRetry, if: -> (config) { config.is_a?(Hash) } strategy :FullRetry, if: -> (config) { config.is_a?(Hash) }
class SimpleRetry < Entry::Node class SimpleRetry < ::Gitlab::Config::Entry::Node
include Entry::Validatable include ::Gitlab::Config::Entry::Validatable
validations do validations do
validates :config, numericality: { only_integer: true, validates :config, numericality: { only_integer: true,
...@@ -31,9 +31,9 @@ module Gitlab ...@@ -31,9 +31,9 @@ module Gitlab
end end
end end
class FullRetry < Entry::Node class FullRetry < ::Gitlab::Config::Entry::Node
include Entry::Validatable include ::Gitlab::Config::Entry::Validatable
include Entry::Attributable include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[max when].freeze ALLOWED_KEYS = %i[max when].freeze
attributes :max, :when attributes :max, :when
...@@ -73,7 +73,7 @@ module Gitlab ...@@ -73,7 +73,7 @@ module Gitlab
end end
end end
class UnknownStrategy < Entry::Node class UnknownStrategy < ::Gitlab::Config::Entry::Node
def errors def errors
["#{location} has to be either an integer or a hash"] ["#{location} has to be either an integer or a hash"]
end end
......
...@@ -7,8 +7,8 @@ module Gitlab ...@@ -7,8 +7,8 @@ module Gitlab
## ##
# Entry that represents a script. # Entry that represents a script.
# #
class Script < Node class Script < ::Gitlab::Config::Entry::Node
include Validatable include ::Gitlab::Config::Entry::Validatable
validations do validations do
validates :config, array_of_strings: true validates :config, array_of_strings: true
......
...@@ -8,7 +8,7 @@ module Gitlab ...@@ -8,7 +8,7 @@ module Gitlab
# Entry that represents a configuration of Docker service. # Entry that represents a configuration of Docker service.
# #
class Service < Image class Service < Image
include Validatable include ::Gitlab::Config::Entry::Validatable
ALLOWED_KEYS = %i[name entrypoint command alias].freeze ALLOWED_KEYS = %i[name entrypoint command alias].freeze
......
...@@ -7,8 +7,8 @@ module Gitlab ...@@ -7,8 +7,8 @@ module Gitlab
## ##
# Entry that represents a configuration of Docker services. # Entry that represents a configuration of Docker services.
# #
class Services < Node class Services < ::Gitlab::Config::Entry::Node
include Validatable include ::Gitlab::Config::Entry::Validatable
validations do validations do
validates :config, type: Array validates :config, type: Array
...@@ -18,7 +18,7 @@ module Gitlab ...@@ -18,7 +18,7 @@ module Gitlab
super do super do
@entries = [] @entries = []
@config.each do |config| @config.each do |config|
@entries << Entry::Factory.new(Entry::Service) @entries << ::Gitlab::Config::Entry::Factory.new(Entry::Service)
.value(config || {}) .value(config || {})
.create! .create!
end end
......
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
class Simplifiable < SimpleDelegator
EntryStrategy = Struct.new(:name, :condition)
def initialize(config, **metadata)
unless self.class.const_defined?(:UnknownStrategy)
raise ArgumentError, 'UndefinedStrategy not available!'
end
strategy = self.class.strategies.find do |variant|
variant.condition.call(config)
end
entry = self.class.entry_class(strategy)
super(entry.new(config, metadata))
end
def self.strategy(name, **opts)
EntryStrategy.new(name, opts.fetch(:if)).tap do |strategy|
strategies.append(strategy)
end
end
def self.strategies
@strategies ||= []
end
def self.entry_class(strategy)
if strategy.present?
self.const_get(strategy.name)
else
self::UnknownStrategy
end
end
end
end
end
end
end
...@@ -7,8 +7,8 @@ module Gitlab ...@@ -7,8 +7,8 @@ module Gitlab
## ##
# Entry that represents a stage for a job. # Entry that represents a stage for a job.
# #
class Stage < Node class Stage < ::Gitlab::Config::Entry::Node
include Validatable include ::Gitlab::Config::Entry::Validatable
validations do validations do
validates :config, type: String validates :config, type: String
......
...@@ -7,8 +7,8 @@ module Gitlab ...@@ -7,8 +7,8 @@ module Gitlab
## ##
# Entry that represents a configuration for pipeline stages. # Entry that represents a configuration for pipeline stages.
# #
class Stages < Node class Stages < ::Gitlab::Config::Entry::Node
include Validatable include ::Gitlab::Config::Entry::Validatable
validations do validations do
validates :config, array_of_strings: true validates :config, array_of_strings: true
......
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# This class represents an undefined entry.
#
class Undefined < Node
def initialize(*)
super(nil)
end
def value
nil
end
def valid?
true
end
def errors
[]
end
def specified?
false
end
def relevant?
false
end
def inspect
"#<#{self.class.name}>"
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# This class represents an unspecified entry.
#
# It decorates original entry adding method that indicates it is
# unspecified.
#
class Unspecified < SimpleDelegator
def specified?
false
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
module Validatable
extend ActiveSupport::Concern
def self.included(node)
node.aspects.append -> do
@validator = self.class.validator.new(self)
@validator.validate(:new)
end
end
def errors
@validator.messages + descendants.flat_map(&:errors) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
class_methods do
def validator
@validator ||= Class.new(Entry::Validator).tap do |validator|
if defined?(@validations)
@validations.each { |rules| validator.class_eval(&rules) }
end
end
end
private
def validations(&block)
(@validations ||= []).append(block)
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
class Validator < SimpleDelegator
include ActiveModel::Validations
include Entry::Validators
def initialize(entry)
super(entry)
end
def messages
errors.full_messages.map do |error|
"#{location} #{error}".downcase
end
end
def self.name
'Validator'
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
module Validators
class AllowedKeysValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unknown_keys = value.try(:keys).to_a - options[:in]
if unknown_keys.any?
record.errors.add(attribute, "contains unknown keys: " +
unknown_keys.join(', '))
end
end
end
class AllowedValuesValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless options[:in].include?(value.to_s)
record.errors.add(attribute, "unknown value: #{value}")
end
end
end
class AllowedArrayValuesValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unkown_values = value - options[:in]
unless unkown_values.empty?
record.errors.add(attribute, "contains unknown values: " +
unkown_values.join(', '))
end
end
end
class ArrayOfStringsValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_array_of_strings(value)
record.errors.add(attribute, 'should be an array of strings')
end
end
end
class BooleanValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_boolean(value)
record.errors.add(attribute, 'should be a boolean value')
end
end
end
class DurationValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_duration(value)
record.errors.add(attribute, 'should be a duration')
end
if options[:limit]
unless validate_duration_limit(value, options[:limit])
record.errors.add(attribute, 'should not exceed the limit')
end
end
end
end
class HashOrStringValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless value.is_a?(Hash) || value.is_a?(String)
record.errors.add(attribute, 'should be a hash or a string')
end
end
end
class HashOrIntegerValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless value.is_a?(Hash) || value.is_a?(Integer)
record.errors.add(attribute, 'should be a hash or an integer')
end
end
end
class KeyValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
if validate_string(value)
validate_path(record, attribute, value)
else
record.errors.add(attribute, 'should be a string or symbol')
end
end
private
def validate_path(record, attribute, value)
path = CGI.unescape(value.to_s)
if path.include?('/')
record.errors.add(attribute, 'cannot contain the "/" character')
elsif path == '.' || path == '..'
record.errors.add(attribute, 'cannot be "." or ".."')
end
end
end
class RegexpValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_regexp(value)
record.errors.add(attribute, 'must be a regular expression')
end
end
private
def look_like_regexp?(value)
value.is_a?(String) && value.start_with?('/') &&
value.end_with?('/')
end
def validate_regexp(value)
look_like_regexp?(value) &&
Regexp.new(value.to_s[1...-1]) &&
true
rescue RegexpError
false
end
end
class ArrayOfStringsOrRegexpsValidator < RegexpValidator
def validate_each(record, attribute, value)
unless validate_array_of_strings_or_regexps(value)
record.errors.add(attribute, 'should be an array of strings or regexps')
end
end
private
def validate_array_of_strings_or_regexps(values)
values.is_a?(Array) && values.all?(&method(:validate_string_or_regexp))
end
def validate_string_or_regexp(value)
return false unless value.is_a?(String)
return validate_regexp(value) if look_like_regexp?(value)
true
end
end
class ArrayOfStringsOrStringValidator < RegexpValidator
def validate_each(record, attribute, value)
unless validate_array_of_strings_or_string(value)
record.errors.add(attribute, 'should be an array of strings or a string')
end
end
private
def validate_array_of_strings_or_string(values)
validate_array_of_strings(values) || validate_string(values)
end
end
class TypeValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
type = options[:with]
raise unless type.is_a?(Class)
unless value.is_a?(type)
message = options[:message] || "should be a #{type.name}"
record.errors.add(attribute, message)
end
end
end
class VariablesValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_variables(value)
record.errors.add(attribute, 'should be a hash of key value pairs')
end
end
end
end
end
end
end
end
...@@ -7,8 +7,8 @@ module Gitlab ...@@ -7,8 +7,8 @@ module Gitlab
## ##
# Entry that represents environment variables. # Entry that represents environment variables.
# #
class Variables < Node class Variables < ::Gitlab::Config::Entry::Node
include Validatable include ::Gitlab::Config::Entry::Validatable
validations do validations do
validates :config, variables: true validates :config, variables: true
......
...@@ -37,8 +37,8 @@ module Gitlab ...@@ -37,8 +37,8 @@ module Gitlab
end end
def to_hash def to_hash
@hash ||= Ci::Config::Loader.new(content).load! @hash ||= Gitlab::Config::Loader::Yaml.new(content).load!
rescue Ci::Config::Loader::FormatError rescue Gitlab::Config::Loader::FormatError
nil nil
end end
......
...@@ -5,7 +5,7 @@ module Gitlab ...@@ -5,7 +5,7 @@ module Gitlab
class YamlProcessor class YamlProcessor
ValidationError = Class.new(StandardError) ValidationError = Class.new(StandardError)
include Gitlab::Ci::Config::Entry::LegacyValidationHelpers include Gitlab::Config::Entry::LegacyValidationHelpers
attr_reader :cache, :stages, :jobs attr_reader :cache, :stages, :jobs
......
# frozen_string_literal: true
module Gitlab
module Config
module Entry
module Attributable
extend ActiveSupport::Concern
class_methods do
def attributes(*attributes)
attributes.flatten.each do |attribute|
if method_defined?(attribute)
raise ArgumentError, 'Method already defined!'
end
define_method(attribute) do
return unless config.is_a?(Hash)
config[attribute]
end
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
##
# Entry that represents a boolean value.
#
class Boolean < Node
include Validatable
validations do
validates :config, boolean: true
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
##
# This mixin is responsible for adding DSL, which purpose is to
# simplifly process of adding child nodes.
#
# This can be used only if parent node is a configuration entry that
# holds a hash as a configuration value, for example:
#
# job:
# script: ...
# artifacts: ...
#
module Configurable
extend ActiveSupport::Concern
included do
include Validatable
validations do
validates :config, type: Hash
end
end
# rubocop: disable CodeReuse/ActiveRecord
def compose!(deps = nil)
return unless valid?
self.class.nodes.each do |key, factory|
factory
.value(config[key])
.with(key: key, parent: self)
entries[key] = factory.create!
end
yield if block_given?
entries.each_value do |entry|
entry.compose!(deps)
end
end
# rubocop: enable CodeReuse/ActiveRecord
class_methods do
def nodes
Hash[(@nodes || {}).map { |key, factory| [key, factory.dup] }]
end
private
# rubocop: disable CodeReuse/ActiveRecord
def entry(key, entry, metadata)
factory = ::Gitlab::Config::Entry::Factory.new(entry)
.with(description: metadata[:description])
(@nodes ||= {}).merge!(key.to_sym => factory)
end
# rubocop: enable CodeReuse/ActiveRecord
def helpers(*nodes)
nodes.each do |symbol|
define_method("#{symbol}_defined?") do
entries[symbol]&.specified?
end
define_method("#{symbol}_value") do
return unless entries[symbol] && entries[symbol].valid?
entries[symbol].value
end
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
##
# Factory class responsible for fabricating entry objects.
#
class Factory
InvalidFactory = Class.new(StandardError)
def initialize(entry)
@entry = entry
@metadata = {}
@attributes = {}
end
def value(value)
@value = value
self
end
def metadata(metadata)
@metadata.merge!(metadata)
self
end
def with(attributes)
@attributes.merge!(attributes)
self
end
def create!
raise InvalidFactory unless defined?(@value)
##
# We assume that unspecified entry is undefined.
# See issue #18775.
#
if @value.nil?
Entry::Unspecified.new(
fabricate_unspecified
)
else
fabricate(@entry, @value)
end
end
private
def fabricate_unspecified
##
# If entry has a default value we fabricate concrete node
# with default value.
#
if @entry.default.nil?
fabricate(Entry::Undefined)
else
fabricate(@entry, @entry.default)
end
end
def fabricate(entry, value = nil)
entry.new(value, @metadata).tap do |node|
node.key = @attributes[:key]
node.parent = @attributes[:parent]
node.description = @attributes[:description]
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
module LegacyValidationHelpers
private
def validate_duration(value)
value.is_a?(String) && ChronicDuration.parse(value)
rescue ChronicDuration::DurationParseError
false
end
def validate_duration_limit(value, limit)
return false unless value.is_a?(String)
ChronicDuration.parse(value).second.from_now <
ChronicDuration.parse(limit).second.from_now
rescue ChronicDuration::DurationParseError
false
end
def validate_array_of_strings(values)
values.is_a?(Array) && values.all? { |value| validate_string(value) }
end
def validate_array_of_strings_or_regexps(values)
values.is_a?(Array) && values.all? { |value| validate_string_or_regexp(value) }
end
def validate_variables(variables)
variables.is_a?(Hash) &&
variables.flatten.all? do |value|
validate_string(value) || validate_integer(value)
end
end
def validate_integer(value)
value.is_a?(Integer)
end
def validate_string(value)
value.is_a?(String) || value.is_a?(Symbol)
end
def validate_regexp(value)
!value.nil? && Regexp.new(value.to_s) && true
rescue RegexpError, TypeError
false
end
def validate_string_or_regexp(value)
return true if value.is_a?(Symbol)
return false unless value.is_a?(String)
if value.first == '/' && value.last == '/'
validate_regexp(value[1...-1])
else
true
end
end
def validate_boolean(value)
value.in?([true, false])
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
##
# Base abstract class for each configuration entry node.
#
class Node
InvalidError = Class.new(StandardError)
attr_reader :config, :metadata
attr_accessor :key, :parent, :description
def initialize(config, **metadata)
@config = config
@metadata = metadata
@entries = {}
self.class.aspects.to_a.each do |aspect|
instance_exec(&aspect)
end
end
def [](key)
@entries[key] || Entry::Undefined.new
end
def compose!(deps = nil)
return unless valid?
yield if block_given?
end
def leaf?
@entries.none?
end
def descendants
@entries.values
end
def ancestors
@parent ? @parent.ancestors + [@parent] : []
end
def valid?
errors.none?
end
def errors
[]
end
def value
if leaf?
@config
else
meaningful = @entries.select do |_key, value|
value.specified? && value.relevant?
end
Hash[meaningful.map { |key, entry| [key, entry.value] }]
end
end
def specified?
true
end
def relevant?
true
end
def location
name = @key.presence || self.class.name.to_s.demodulize
.underscore.humanize.downcase
ancestors.map(&:key).append(name).compact.join(':')
end
def inspect
val = leaf? ? config : descendants
unspecified = specified? ? '' : '(unspecified) '
"#<#{self.class.name} #{unspecified}{#{key}: #{val.inspect}}>"
end
def self.default
end
def self.aspects
@aspects ||= []
end
private
attr_reader :entries
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
class Simplifiable < SimpleDelegator
EntryStrategy = Struct.new(:name, :condition)
def initialize(config, **metadata)
unless self.class.const_defined?(:UnknownStrategy)
raise ArgumentError, 'UndefinedStrategy not available!'
end
strategy = self.class.strategies.find do |variant|
variant.condition.call(config)
end
entry = self.class.entry_class(strategy)
super(entry.new(config, metadata))
end
def self.strategy(name, **opts)
EntryStrategy.new(name, opts.fetch(:if)).tap do |strategy|
strategies.append(strategy)
end
end
def self.strategies
@strategies ||= []
end
def self.entry_class(strategy)
if strategy.present?
self.const_get(strategy.name)
else
self::UnknownStrategy
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
##
# This class represents an undefined entry.
#
class Undefined < Node
def initialize(*)
super(nil)
end
def value
nil
end
def valid?
true
end
def errors
[]
end
def specified?
false
end
def relevant?
false
end
def inspect
"#<#{self.class.name}>"
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
##
# This class represents an unspecified entry.
#
# It decorates original entry adding method that indicates it is
# unspecified.
#
class Unspecified < SimpleDelegator
def specified?
false
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
module Validatable
extend ActiveSupport::Concern
def self.included(node)
node.aspects.append -> do
@validator = self.class.validator.new(self)
@validator.validate(:new)
end
end
def errors
@validator.messages + descendants.flat_map(&:errors) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
class_methods do
def validator
@validator ||= Class.new(Entry::Validator).tap do |validator|
if defined?(@validations)
@validations.each { |rules| validator.class_eval(&rules) }
end
end
end
private
def validations(&block)
(@validations ||= []).append(block)
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
class Validator < SimpleDelegator
include ActiveModel::Validations
include Entry::Validators
def initialize(entry)
super(entry)
end
def messages
errors.full_messages.map do |error|
"#{location} #{error}".downcase
end
end
def self.name
'Validator'
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
module Validators
class AllowedKeysValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unknown_keys = value.try(:keys).to_a - options[:in]
if unknown_keys.any?
record.errors.add(attribute, "contains unknown keys: " +
unknown_keys.join(', '))
end
end
end
class AllowedValuesValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless options[:in].include?(value.to_s)
record.errors.add(attribute, "unknown value: #{value}")
end
end
end
class AllowedArrayValuesValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unkown_values = value - options[:in]
unless unkown_values.empty?
record.errors.add(attribute, "contains unknown values: " +
unkown_values.join(', '))
end
end
end
class ArrayOfStringsValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_array_of_strings(value)
record.errors.add(attribute, 'should be an array of strings')
end
end
end
class BooleanValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_boolean(value)
record.errors.add(attribute, 'should be a boolean value')
end
end
end
class DurationValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_duration(value)
record.errors.add(attribute, 'should be a duration')
end
if options[:limit]
unless validate_duration_limit(value, options[:limit])
record.errors.add(attribute, 'should not exceed the limit')
end
end
end
end
class HashOrStringValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless value.is_a?(Hash) || value.is_a?(String)
record.errors.add(attribute, 'should be a hash or a string')
end
end
end
class HashOrIntegerValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless value.is_a?(Hash) || value.is_a?(Integer)
record.errors.add(attribute, 'should be a hash or an integer')
end
end
end
class KeyValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
if validate_string(value)
validate_path(record, attribute, value)
else
record.errors.add(attribute, 'should be a string or symbol')
end
end
private
def validate_path(record, attribute, value)
path = CGI.unescape(value.to_s)
if path.include?('/')
record.errors.add(attribute, 'cannot contain the "/" character')
elsif path == '.' || path == '..'
record.errors.add(attribute, 'cannot be "." or ".."')
end
end
end
class RegexpValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_regexp(value)
record.errors.add(attribute, 'must be a regular expression')
end
end
private
def look_like_regexp?(value)
value.is_a?(String) && value.start_with?('/') &&
value.end_with?('/')
end
def validate_regexp(value)
look_like_regexp?(value) &&
Regexp.new(value.to_s[1...-1]) &&
true
rescue RegexpError
false
end
end
class ArrayOfStringsOrRegexpsValidator < RegexpValidator
def validate_each(record, attribute, value)
unless validate_array_of_strings_or_regexps(value)
record.errors.add(attribute, 'should be an array of strings or regexps')
end
end
private
def validate_array_of_strings_or_regexps(values)
values.is_a?(Array) && values.all?(&method(:validate_string_or_regexp))
end
def validate_string_or_regexp(value)
return false unless value.is_a?(String)
return validate_regexp(value) if look_like_regexp?(value)
true
end
end
class ArrayOfStringsOrStringValidator < RegexpValidator
def validate_each(record, attribute, value)
unless validate_array_of_strings_or_string(value)
record.errors.add(attribute, 'should be an array of strings or a string')
end
end
private
def validate_array_of_strings_or_string(values)
validate_array_of_strings(values) || validate_string(values)
end
end
class TypeValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
type = options[:with]
raise unless type.is_a?(Class)
unless value.is_a?(type)
message = options[:message] || "should be a #{type.name}"
record.errors.add(attribute, message)
end
end
end
class VariablesValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_variables(value)
record.errors.add(attribute, 'should be a hash of key value pairs')
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Loader
FormatError = Class.new(StandardError)
end
end
end
# frozen_string_literal: true # frozen_string_literal: true
module Gitlab module Gitlab
module Ci module Config
class Config module Loader
class Loader class Yaml
FormatError = Class.new(StandardError)
def initialize(config) def initialize(config)
@config = YAML.safe_load(config, [Symbol], [], true) @config = YAML.safe_load(config, [Symbol], [], true)
rescue Psych::Exception => e rescue Psych::Exception => e
raise FormatError, e.message raise Loader::FormatError, e.message
end end
def valid? def valid?
...@@ -18,7 +16,7 @@ module Gitlab ...@@ -18,7 +16,7 @@ module Gitlab
def load! def load!
unless valid? unless valid?
raise FormatError, 'Invalid configuration format' raise Loader::FormatError, 'Invalid configuration format'
end end
@config.deep_symbolize_keys @config.deep_symbolize_keys
......
...@@ -82,7 +82,7 @@ namespace :gettext do ...@@ -82,7 +82,7 @@ namespace :gettext do
# `gettext:find` writes touches to temp files to `stderr` which would cause # `gettext:find` writes touches to temp files to `stderr` which would cause
# `static-analysis` to report failures. We can ignore these. # `static-analysis` to report failures. We can ignore these.
silence_sdterr do silence_stderr do
Rake::Task['gettext:find'].invoke Rake::Task['gettext:find'].invoke
end end
...@@ -119,7 +119,7 @@ namespace :gettext do ...@@ -119,7 +119,7 @@ namespace :gettext do
end end
end end
def silence_sdterr(&block) def silence_stderr(&block)
old_stderr = $stderr.dup old_stderr = $stderr.dup
$stderr.reopen(File::NULL) $stderr.reopen(File::NULL)
$stderr.sync = true $stderr.sync = true
......
...@@ -360,6 +360,9 @@ msgstr "" ...@@ -360,6 +360,9 @@ msgstr ""
msgid "Abuse reports" msgid "Abuse reports"
msgstr "" msgstr ""
msgid "Accept invitation"
msgstr ""
msgid "Accept terms" msgid "Accept terms"
msgstr "" msgstr ""
...@@ -2675,6 +2678,9 @@ msgstr "" ...@@ -2675,6 +2678,9 @@ msgstr ""
msgid "December" msgid "December"
msgstr "" msgstr ""
msgid "Decline"
msgstr ""
msgid "Decline and sign out" msgid "Decline and sign out"
msgstr "" msgstr ""
...@@ -4404,6 +4410,9 @@ msgstr "" ...@@ -4404,6 +4410,9 @@ msgstr ""
msgid "Housekeeping successfully started" msgid "Housekeeping successfully started"
msgstr "" msgstr ""
msgid "However, you are already a member of this %{member_source}. Sign in using a different account to accept the invitation."
msgstr ""
msgid "I accept the %{terms_link}" msgid "I accept the %{terms_link}"
msgstr "" msgstr ""
...@@ -4634,6 +4643,9 @@ msgstr "" ...@@ -4634,6 +4643,9 @@ msgstr ""
msgid "Introducing Cycle Analytics" msgid "Introducing Cycle Analytics"
msgstr "" msgstr ""
msgid "Invitation"
msgstr ""
msgid "Invite" msgid "Invite"
msgstr "" msgstr ""
...@@ -5753,6 +5765,9 @@ msgstr "" ...@@ -5753,6 +5765,9 @@ msgstr ""
msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}" msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
msgstr "" msgstr ""
msgid "Note that this invitation was sent to %{mail_to_invite_email}, but you are signed in as %{link_to_current_user} with email %{mail_to_current_user}."
msgstr ""
msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token." msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
msgstr "" msgstr ""
...@@ -9978,6 +9993,9 @@ msgstr "" ...@@ -9978,6 +9993,9 @@ msgstr ""
msgid "from" msgid "from"
msgstr "" msgstr ""
msgid "group"
msgstr ""
msgid "help" msgid "help"
msgstr "" msgstr ""
......
...@@ -13,6 +13,8 @@ describe('diffs/components/app', () => { ...@@ -13,6 +13,8 @@ describe('diffs/components/app', () => {
beforeEach(() => { beforeEach(() => {
// setup globals (needed for component to mount :/) // setup globals (needed for component to mount :/)
window.mrTabs = jasmine.createSpyObj('mrTabs', ['resetViewContainer']); window.mrTabs = jasmine.createSpyObj('mrTabs', ['resetViewContainer']);
window.mrTabs.expandViewContainer = jasmine.createSpy();
window.location.hash = 'ABC_123';
// setup component // setup component
const store = createDiffsStore(); const store = createDiffsStore();
...@@ -39,4 +41,15 @@ describe('diffs/components/app', () => { ...@@ -39,4 +41,15 @@ describe('diffs/components/app', () => {
it('does not show commit info', () => { it('does not show commit info', () => {
expect(vm.$el).not.toContainElement('.blob-commit-info'); expect(vm.$el).not.toContainElement('.blob-commit-info');
}); });
it('sets highlighted row if hash exists in location object', done => {
vm.$props.shouldShow = true;
vm.$nextTick()
.then(() => {
expect(vm.$store.state.diffs.highlightedRow).toBe('ABC_123');
})
.then(done)
.catch(done.fail);
});
}); });
import Vue from 'vue';
import store from '~/mr_notes/stores';
import DiffTableCell from '~/diffs/components/diff_table_cell.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import diffFileMockData from '../mock_data/diff_file';
describe('DiffTableCell', () => {
const createComponent = options =>
createComponentWithStore(Vue.extend(DiffTableCell), store, {
line: diffFileMockData.highlighted_diff_lines[0],
fileHash: diffFileMockData.file_hash,
contextLinesPath: 'contextLinesPath',
...options,
}).$mount();
it('does not highlight row when isHighlighted prop is false', done => {
const vm = createComponent({ isHighlighted: false });
vm.$nextTick()
.then(() => {
expect(vm.$el.classList).not.toContain('hll');
})
.then(done)
.catch(done.fail);
});
it('highlights row when isHighlighted prop is true', done => {
const vm = createComponent({ isHighlighted: true });
vm.$nextTick()
.then(() => {
expect(vm.$el.classList).toContain('hll');
})
.then(done)
.catch(done.fail);
});
});
import Vue from 'vue';
import store from '~/mr_notes/stores';
import InlineDiffTableRow from '~/diffs/components/inline_diff_table_row.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import diffFileMockData from '../mock_data/diff_file';
describe('InlineDiffTableRow', () => {
let vm;
const thisLine = diffFileMockData.highlighted_diff_lines[0];
beforeEach(() => {
vm = createComponentWithStore(Vue.extend(InlineDiffTableRow), store, {
line: thisLine,
fileHash: diffFileMockData.file_hash,
contextLinesPath: 'contextLinesPath',
isHighlighted: false,
}).$mount();
});
it('does not add hll class to line content when line does not match highlighted row', done => {
vm.$nextTick()
.then(() => {
expect(vm.$el.querySelector('.line_content').classList).not.toContain('hll');
})
.then(done)
.catch(done.fail);
});
it('adds hll class to lineContent when line is the highlighted row', done => {
vm.$nextTick()
.then(() => {
vm.$store.state.diffs.highlightedRow = thisLine.line_code;
return vm.$nextTick();
})
.then(() => {
expect(vm.$el.querySelector('.line_content').classList).toContain('hll');
})
.then(done)
.catch(done.fail);
});
});
This diff is collapsed.
This diff is collapsed.
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