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'
gem 'net-ldap'
# API
gem 'grape', '~> 1.1'
gem 'grape', '~> 1.1.0'
gem 'grape-entity', '~> 0.7.1'
gem 'rack-cors', '~> 1.0.0', require: 'rack/cors'
......
......@@ -1052,7 +1052,7 @@ DEPENDENCIES
google-api-client (~> 0.23)
google-protobuf (~> 3.6)
gpgme
grape (~> 1.1)
grape (~> 1.1.0)
grape-entity (~> 0.7.1)
grape-path-helpers (~> 1.0)
grape_logging (~> 1.7)
......
......@@ -1043,7 +1043,7 @@ DEPENDENCIES
google-api-client (~> 0.23)
google-protobuf (~> 3.6)
gpgme
grape (~> 1.1)
grape (~> 1.1.0)
grape-entity (~> 0.7.1)
grape-path-helpers (~> 1.0)
grape_logging (~> 1.7)
......
......@@ -5,6 +5,7 @@ import axios from './lib/utils/axios_utils';
const Api = {
groupsPath: '/api/:version/groups.json',
groupPath: '/api/:version/groups/:id',
subgroupsPath: '/api/:version/groups/:id/subgroups',
namespacesPath: '/api/:version/namespaces.json',
groupProjectsPath: '/api/:version/groups/:id/projects.json',
projectsPath: '/api/:version/projects.json',
......
......@@ -102,6 +102,12 @@ export default {
if (this.shouldShow) {
this.fetchData();
}
const id = window && window.location && window.location.hash;
if (id) {
this.setHighlightedRow(id.slice(1));
}
},
created() {
this.adjustView();
......@@ -114,6 +120,7 @@ export default {
'fetchDiffFiles',
'startRenderDiffsQueue',
'assignDiscussionsToDiff',
'setHighlightedRow',
]),
fetchData() {
this.fetchDiffFiles()
......
......@@ -72,6 +72,13 @@ export default {
diffFiles: state => state.diffs.diffFiles,
}),
...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() {
return `#${this.line.line_code || ''}`;
},
......@@ -97,7 +104,7 @@ export default {
},
},
methods: {
...mapActions('diffs', ['loadMoreLines', 'showCommentForm']),
...mapActions('diffs', ['loadMoreLines', 'showCommentForm', 'setHighlightedRow']),
handleCommentButton() {
this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash });
},
......@@ -168,7 +175,13 @@ export default {
>
<icon :size="12" name="comment" />
</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" />
</template>
</div>
......
<script>
import { mapGetters } from 'vuex';
import { mapGetters, mapActions } from 'vuex';
import DiffLineGutterContent from './diff_line_gutter_content.vue';
import {
MATCH_LINE_TYPE,
......@@ -30,6 +30,11 @@ export default {
type: String,
required: true,
},
isHighlighted: {
type: Boolean,
required: true,
default: false,
},
diffViewType: {
type: String,
required: false,
......@@ -85,6 +90,7 @@ export default {
const { type } = this.line;
return {
hll: this.isHighlighted,
[type]: type,
[LINE_UNFOLD_CLASS_NAME]: this.isMatchLine,
[LINE_HOVER_CLASS_NAME]:
......@@ -99,6 +105,7 @@ export default {
return this.lineType === OLD_LINE_TYPE ? this.line.old_line : this.line.new_line;
},
},
methods: mapActions('diffs', ['setHighlightedRow']),
};
</script>
......
<script>
import { mapGetters, mapActions } from 'vuex';
import { mapGetters, mapActions, mapState } from 'vuex';
import DiffTableCell from './diff_table_cell.vue';
import {
NEW_LINE_TYPE,
......@@ -40,6 +40,11 @@ export default {
};
},
computed: {
...mapState({
isHighlighted(state) {
return this.line.line_code !== null && this.line.line_code === state.diffs.highlightedRow;
},
}),
...mapGetters('diffs', ['isInlineView']),
isContextLine() {
return this.line.type === CONTEXT_LINE_TYPE;
......@@ -91,6 +96,7 @@ export default {
:is-bottom="isBottom"
:is-hover="isHover"
:show-comment-button="true"
:is-highlighted="isHighlighted"
class="diff-line-num old_line"
/>
<diff-table-cell
......@@ -100,8 +106,18 @@ export default {
:line-type="newLineType"
:is-bottom="isBottom"
:is-hover="isHover"
:is-highlighted="isHighlighted"
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>
</template>
<script>
import { mapActions } from 'vuex';
import { mapActions, mapState } from 'vuex';
import $ from 'jquery';
import DiffTableCell from './diff_table_cell.vue';
import {
......@@ -43,6 +43,15 @@ export default {
};
},
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() {
return this.line.left && this.line.left.type === CONTEXT_LINE_TYPE;
},
......@@ -57,7 +66,14 @@ export default {
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() {
......@@ -114,6 +130,7 @@ export default {
:line-type="oldLineType"
:is-bottom="isBottom"
:is-hover="isLeftHover"
:is-highlighted="isHighlighted"
:show-comment-button="true"
:diff-view-type="parallelDiffViewType"
line-position="left"
......@@ -139,6 +156,7 @@ export default {
:line-type="newLineType"
:is-bottom="isBottom"
:is-hover="isRightHover"
:is-highlighted="isHighlighted"
:show-comment-button="true"
:diff-view-type="parallelDiffViewType"
line-position="right"
......@@ -146,7 +164,12 @@ export default {
/>
<td
:id="line.right.line_code"
:class="line.right.type"
:class="[
line.right.type,
{
hll: isHighlighted,
},
]"
class="line_content parallel right-side"
@mousedown.native="handleParallelLineMouseDown"
v-html="line.right.rich_text"
......
......@@ -33,6 +33,10 @@ export const fetchDiffFiles = ({ state, commit }) => {
.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
// once for parallel and once for inline mode
export const assignDiscussionsToDiff = (
......@@ -127,7 +131,7 @@ export const loadMoreLines = ({ commit }, options) => {
export const scrollToLineIfNeededInline = (_, line) => {
const hash = getLocationHash();
if (hash && line.lineCode === hash) {
if (hash && line.line_code === hash) {
handleLocationHash();
}
};
......@@ -137,7 +141,7 @@ export const scrollToLineIfNeededParallel = (_, line) => {
if (
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();
}
......
......@@ -26,4 +26,5 @@ export default () => ({
currentDiffFileId: '',
projectPath: '',
commentForms: [],
highlightedRow: null,
});
......@@ -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 UPDATE_DIFF_FILE_COMMENT_FORM = 'UPDATE_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 {
[types.CLOSE_DIFF_FILE_COMMENT_FORM](state, 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() {
const $select = $(this);
const allAvailable = $select.data('allAvailable');
const skipGroups = $select.data('skipGroups') || [];
const parentGroupID = $select.data('parentId');
const groupsPath = parentGroupID
? Api.subgroupsPath.replace(':id', parentGroupID)
: Api.groupsPath;
$select.select2({
placeholder: 'Search for a group',
allowClear: $select.hasClass('allowClear'),
multiple: $select.hasClass('multiselect'),
minimumInputLength: 0,
ajax: {
url: Api.buildUrl(Api.groupsPath),
url: Api.buildUrl(groupsPath),
dataType: 'json',
quietMillis: 250,
transport(params) {
......
......@@ -17,27 +17,29 @@ export function getParameterValues(sParam) {
// @param {Object} params - url keys and value to merge
// @param {String} url
export function mergeUrlParams(params, url) {
let newUrl = Object.keys(params).reduce((acc, paramName) => {
const paramValue = encodeURIComponent(params[paramName]);
const pattern = new RegExp(`\\b(${paramName}=).*?(&|$)`);
if (paramValue === null) {
return acc.replace(pattern, '');
} else if (url.search(pattern) !== -1) {
return acc.replace(pattern, `$1${paramValue}$2`);
}
return `${acc}${acc.indexOf('?') > 0 ? '&' : '?'}${paramName}=${paramValue}`;
}, decodeURIComponent(url));
const re = /^([^?#]*)(\?[^#]*)?(.*)/;
const merged = {};
const urlparts = url.match(re);
if (urlparts[2]) {
urlparts[2]
.substr(1)
.split('&')
.forEach(part => {
if (part.length) {
const kv = part.split('=');
merged[decodeURIComponent(kv[0])] = decodeURIComponent(kv.slice(1).join('='));
}
});
}
// Remove a trailing ampersand
const lastChar = newUrl[newUrl.length - 1];
Object.assign(merged, params);
if (lastChar === '&') {
newUrl = newUrl.slice(0, -1);
}
const query = Object.keys(merged)
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(merged[key])}`)
.join('&');
return newUrl;
return `${urlparts[1]}?${query}${urlparts[3]}`;
}
export function removeParamQueryString(url, param) {
......
......@@ -366,8 +366,8 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
>
<button
:disabled="isSubmitButtonDisabled"
class="btn btn-success comment-btn js-comment-button js-comment-submit-button
qa-comment-button"
class="btn btn-create comment-btn js-comment-button js-comment-submit-button
qa-comment-button"
type="submit"
@click.prevent="handleSave();"
>
......
......@@ -5,6 +5,7 @@ import initSettingsPanels from '~/settings_panels';
import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
import { GROUP_BADGE } from '~/badges/constants';
import groupsSelect from '~/groups_select';
import projectSelect from '~/project_select';
document.addEventListener('DOMContentLoaded', () => {
......@@ -17,5 +18,8 @@ document.addEventListener('DOMContentLoaded', () => {
);
mountBadgeSettings(GROUP_BADGE);
// Initialize Subgroups selector
groupsSelect();
projectSelect();
});
......@@ -33,7 +33,11 @@
.bs-callout-warning {
background-color: $orange-100;
border-color: $orange-200;
color: $orange-700;
color: $orange-900;
a {
color: $orange-900;
}
}
.bs-callout-info {
......
......@@ -31,16 +31,6 @@
.timeline-entry-inner {
position: relative;
@include notes-media('max', map-get($grid-breakpoints, sm)) {
.timeline-icon {
display: none;
}
.timeline-content {
margin-left: 0;
}
}
}
&:target,
......
......@@ -589,12 +589,6 @@ $note-form-margin-left: 72px;
padding-bottom: 0;
}
.note-header-author-name {
@include notes-media('max', map-get($grid-breakpoints, sm) - 1) {
display: none;
}
}
.note-headline-light {
display: inline;
......
......@@ -4,6 +4,8 @@ module Ci
class CreatePipelineService < BaseService
attr_reader :pipeline
CreateError = Class.new(StandardError)
SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build,
EE::Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs,
Gitlab::Ci::Pipeline::Chain::Validate::Abilities,
......@@ -55,6 +57,14 @@ module Ci
pipeline
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
def commit
......
......@@ -11,7 +11,7 @@ module Projects
end
def execute
if @params[:template_name]&.present?
if @params[:template_name].present?
return ::Projects::CreateFromTemplateService.new(current_user, params).execute
end
......
......@@ -37,6 +37,7 @@
.settings-content
= render 'shared/badges/badge_settings'
= render_if_exists 'groups/custom_project_templates_setting'
= render_if_exists 'groups/templates_setting', expanded: expanded
%section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded) }
......
- page_title "Invitation"
%h3.page-title Invitation
- page_title _("Invitation")
%h3.page-title= _("Invitation")
%p
You have been invited
......@@ -24,14 +24,17 @@
- if is_member
%p
However, you are already a member of this #{@member.source.is_a?(Group) ? "group" : "project"}.
Sign in using a different account to accept the invitation.
- member_source = @member.source.is_a?(Group) ? _("group") : _("project")
= _("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
%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
.actions
= 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 _("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"
......@@ -58,7 +58,7 @@
.project-template
.form-group
%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' }
- if import_sources_enabled?
......
......@@ -9,9 +9,9 @@
.text-muted
= template.description
.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" } }
%span
= _("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
Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now)
.preload(:owner, :project).find_each do |schedule|
begin
pipeline = Ci::CreatePipelineService.new(schedule.project,
schedule.owner,
ref: schedule.ref)
.execute(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule)
schedule.deactivate! unless pipeline.persisted?
Ci::CreatePipelineService.new(schedule.project,
schedule.owner,
ref: schedule.ref)
.execute!(:schedule, ignore_skip_ci: true, save_on_errors: true, schedule: schedule)
rescue => e
Rails.logger.error "#{schedule.id}: Failed to create a scheduled pipeline: #{e.message}"
error(schedule, e)
ensure
schedule.schedule_next_run!
end
end
end
# 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
---
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
When you run `service gitlab restart` Gitaly will be disabled on this
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
## Procedure
1. Grab the profiling token from `Monitoring > Requests Profiles` admin page
(highlighted in a blue in the image below).
![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
* [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 --header 'X-Profile-Token: <token>' https://gitlab.example.com/group/project`
(highlighted in a blue in the image below).
![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:
- Browser extensions. For example, [ModHeader](https://chrome.google.com/webstore/detail/modheader/idgpnmonknjnojddfkpgkljpfnnfcklj) Chrome extension.
- `curl`. For example, `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
view the profiling output from `Monitoring > Requests Profiles` admin page.
![Profiling output](img/request_profile_result.png)
view the profiling output from `Monitoring > Requests Profiles` admin page.
![Profiling output](img/request_profile_result.png)
## Cleaning up
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
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/40385) in GitLab
11.6.
By default, impersonation is enabled. To disable impersonation, GitLab must be
reconfigured:
By default, impersonation is enabled. To disable impersonation:
**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
```
HTTP/1.1 404 Not Found
Content-Type: application/json
{ f
{
"error": "404 Not Found"
}
```
......
......@@ -53,7 +53,7 @@ from teams other than your own.
#### 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]**.
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]**.
......
# 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
a convenient option to bootstrap from an existing project boilerplate.
......@@ -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
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,
private projects will be available only if the user has view [permissions](../permissions.md)
in the project:
......@@ -21,4 +20,6 @@ in the project:
Projects below subgroups of the template group are **not** supported.
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
![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
- **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.
![Schedules list](img/pipeline_schedules_ownership.png)
> **Note:**
When the owner of the schedule doesn't have the ability to create pipelines
anymore, due to e.g., being blocked or removed from the project, or lacking
the permission to run on protected branches or tags. When this happened, the
schedule is deactivated. Another user can take ownership and activate it, so
the schedule can be run again.
NOTE: **Note:**
If the owner of a pipeline schedule doesn't have the ability to create pipelines
on the target branch, the schedule will stop creating new pipelines. This can
happen if, for example, the owner is blocked or removed from the project, or
the target branch or tag is protected. In this case, someone with sufficient
privileges must take ownership of the schedule.
## Advanced admin configuration
......
......@@ -15,7 +15,7 @@ module Gitlab
@global = Entry::Global.new(@config)
@global.compose!
rescue Loader::FormatError,
rescue Gitlab::Config::Loader::FormatError,
Extendable::ExtensionError,
External::Processor::IncludeError => e
raise Config::ConfigError, e.message
......@@ -71,7 +71,7 @@ module Gitlab
private
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)
if project
......
......@@ -7,10 +7,10 @@ module Gitlab
##
# Entry that represents a configuration of job artifacts.
#
class Artifacts < Node
include Configurable
include Validatable
include Attributable
class Artifacts < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Configurable
include ::Gitlab::Config::Entry::Validatable
include ::Gitlab::Config::Entry::Attributable
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
##
# Entry that represents a cache configuration
#
class Cache < Node
include Configurable
include Attributable
class Cache < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Configurable
include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[key untracked paths policy].freeze
DEFAULT_POLICY = 'pull-push'.freeze
......@@ -22,7 +22,7 @@ module Gitlab
entry :key, Entry::Key,
description: 'Cache key used to define a cache affinity.'
entry :untracked, Entry::Boolean,
entry :untracked, ::Gitlab::Config::Entry::Boolean,
description: 'Cache all untracked files.'
entry :paths, Entry::Paths,
......
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents a job script.
#
class Commands < Node
include Validatable
class Commands < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
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
##
# Entry that represents Coverage settings.
#
class Coverage < Node
include Validatable
class Coverage < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, regexp: true
......
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents an environment.
#
class Environment < Node
include Validatable
class Environment < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
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
# This class represents a global entry - root Entry for entire
# GitLab CI Configuration file.
#
class Global < Node
include Configurable
class Global < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Configurable
entry :before_script, Entry::Script,
description: 'Script that will be executed before each job.'
......@@ -49,7 +49,7 @@ module Gitlab
# rubocop: disable CodeReuse/ActiveRecord
def compose_jobs!
factory = Entry::Factory.new(Entry::Jobs)
factory = ::Gitlab::Config::Entry::Factory.new(Entry::Jobs)
.value(@config.except(*self.class.nodes.keys))
.with(key: :jobs, parent: self,
description: 'Jobs definition for this pipeline')
......
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents a hidden CI/CD key.
#
class Hidden < Node
include Validatable
class Hidden < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, presence: true
......
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents a Docker image.
#
class Image < Node
include Validatable
class Image < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
ALLOWED_KEYS = %i[name entrypoint].freeze
......
......@@ -7,9 +7,9 @@ module Gitlab
##
# Entry that represents a concrete CI/CD job.
#
class Job < Node
include Configurable
include Attributable
class Job < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Configurable
include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[tags script only except type image services
allow_failure type stage when start_in artifacts cache
......
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents a set of jobs.
#
class Jobs < Node
include Validatable
class Jobs < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, type: Hash
......@@ -34,7 +34,7 @@ module Gitlab
@config.each do |name, config|
node = hidden?(name) ? Entry::Hidden : Entry::Job
factory = Entry::Factory.new(node)
factory = ::Gitlab::Config::Entry::Factory.new(node)
.value(config || {})
.metadata(name: name)
.with(key: name, parent: self,
......
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents a key.
#
class Key < Node
include Validatable
class Key < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
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
##
# Entry that represents an array of paths.
#
class Paths < Node
include Validatable
class Paths < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, array_of_strings: true
......
......@@ -7,12 +7,12 @@ module Gitlab
##
# 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 :ComplexPolicy, if: -> (config) { config.is_a?(Hash) }
class RefsPolicy < Entry::Node
include Entry::Validatable
class RefsPolicy < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, array_of_strings_or_regexps: true
......@@ -23,9 +23,9 @@ module Gitlab
end
end
class ComplexPolicy < Entry::Node
include Entry::Validatable
include Entry::Attributable
class ComplexPolicy < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[refs kubernetes variables changes].freeze
attributes :refs, :kubernetes, :variables, :changes
......@@ -58,7 +58,7 @@ module Gitlab
end
end
class UnknownStrategy < Entry::Node
class UnknownStrategy < ::Gitlab::Config::Entry::Node
def errors
["#{location} has to be either an array of conditions or a hash"]
end
......
......@@ -7,9 +7,9 @@ module Gitlab
##
# Entry that represents a configuration of job artifacts.
#
class Reports < Node
include Validatable
include Attributable
class Reports < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[junit codequality sast dependency_scanning container_scanning dast performance license_management].freeze
......
......@@ -7,12 +7,12 @@ module Gitlab
##
# 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 :FullRetry, if: -> (config) { config.is_a?(Hash) }
class SimpleRetry < Entry::Node
include Entry::Validatable
class SimpleRetry < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, numericality: { only_integer: true,
......@@ -31,9 +31,9 @@ module Gitlab
end
end
class FullRetry < Entry::Node
include Entry::Validatable
include Entry::Attributable
class FullRetry < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[max when].freeze
attributes :max, :when
......@@ -73,7 +73,7 @@ module Gitlab
end
end
class UnknownStrategy < Entry::Node
class UnknownStrategy < ::Gitlab::Config::Entry::Node
def errors
["#{location} has to be either an integer or a hash"]
end
......
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents a script.
#
class Script < Node
include Validatable
class Script < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, array_of_strings: true
......
......@@ -8,7 +8,7 @@ module Gitlab
# Entry that represents a configuration of Docker service.
#
class Service < Image
include Validatable
include ::Gitlab::Config::Entry::Validatable
ALLOWED_KEYS = %i[name entrypoint command alias].freeze
......
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents a configuration of Docker services.
#
class Services < Node
include Validatable
class Services < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, type: Array
......@@ -18,7 +18,7 @@ module Gitlab
super do
@entries = []
@config.each do |config|
@entries << Entry::Factory.new(Entry::Service)
@entries << ::Gitlab::Config::Entry::Factory.new(Entry::Service)
.value(config || {})
.create!
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
##
# Entry that represents a stage for a job.
#
class Stage < Node
include Validatable
class Stage < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, type: String
......
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents a configuration for pipeline stages.
#
class Stages < Node
include Validatable
class Stages < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
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
##
# Entry that represents environment variables.
#
class Variables < Node
include Validatable
class Variables < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, variables: true
......
......@@ -37,8 +37,8 @@ module Gitlab
end
def to_hash
@hash ||= Ci::Config::Loader.new(content).load!
rescue Ci::Config::Loader::FormatError
@hash ||= Gitlab::Config::Loader::Yaml.new(content).load!
rescue Gitlab::Config::Loader::FormatError
nil
end
......
......@@ -5,7 +5,7 @@ module Gitlab
class YamlProcessor
ValidationError = Class.new(StandardError)
include Gitlab::Ci::Config::Entry::LegacyValidationHelpers
include Gitlab::Config::Entry::LegacyValidationHelpers
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
module Gitlab
module Ci
class Config
class Loader
FormatError = Class.new(StandardError)
module Config
module Loader
class Yaml
def initialize(config)
@config = YAML.safe_load(config, [Symbol], [], true)
rescue Psych::Exception => e
raise FormatError, e.message
raise Loader::FormatError, e.message
end
def valid?
......@@ -18,7 +16,7 @@ module Gitlab
def load!
unless valid?
raise FormatError, 'Invalid configuration format'
raise Loader::FormatError, 'Invalid configuration format'
end
@config.deep_symbolize_keys
......
......@@ -82,7 +82,7 @@ namespace :gettext do
# `gettext:find` writes touches to temp files to `stderr` which would cause
# `static-analysis` to report failures. We can ignore these.
silence_sdterr do
silence_stderr do
Rake::Task['gettext:find'].invoke
end
......@@ -119,7 +119,7 @@ namespace :gettext do
end
end
def silence_sdterr(&block)
def silence_stderr(&block)
old_stderr = $stderr.dup
$stderr.reopen(File::NULL)
$stderr.sync = true
......
......@@ -360,6 +360,9 @@ msgstr ""
msgid "Abuse reports"
msgstr ""
msgid "Accept invitation"
msgstr ""
msgid "Accept terms"
msgstr ""
......@@ -2675,6 +2678,9 @@ msgstr ""
msgid "December"
msgstr ""
msgid "Decline"
msgstr ""
msgid "Decline and sign out"
msgstr ""
......@@ -4404,6 +4410,9 @@ msgstr ""
msgid "Housekeeping successfully started"
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}"
msgstr ""
......@@ -4634,6 +4643,9 @@ msgstr ""
msgid "Introducing Cycle Analytics"
msgstr ""
msgid "Invitation"
msgstr ""
msgid "Invite"
msgstr ""
......@@ -5753,6 +5765,9 @@ msgstr ""
msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
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."
msgstr ""
......@@ -9978,6 +9993,9 @@ msgstr ""
msgid "from"
msgstr ""
msgid "group"
msgstr ""
msgid "help"
msgstr ""
......
......@@ -13,6 +13,8 @@ describe('diffs/components/app', () => {
beforeEach(() => {
// setup globals (needed for component to mount :/)
window.mrTabs = jasmine.createSpyObj('mrTabs', ['resetViewContainer']);
window.mrTabs.expandViewContainer = jasmine.createSpy();
window.location.hash = 'ABC_123';
// setup component
const store = createDiffsStore();
......@@ -39,4 +41,15 @@ describe('diffs/components/app', () => {
it('does not show 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