Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-07-17 09:09:43 +00:00
parent 6110935892
commit fe29f106cd
92 changed files with 3399 additions and 793 deletions

View file

@ -72,7 +72,7 @@ export default {
<button
ref="selectAllBtn"
type="button"
class="btn btn-success btn-inverted prepend-left-10"
class="btn btn-success btn-inverted gl-ml-3"
@click="toggleAll"
>
{{ selectAllText }}

View file

@ -352,7 +352,7 @@ export default () => {
template: `
<div class="board-extra-actions">
<button
class="btn btn-success prepend-left-10"
class="btn btn-success gl-ml-3"
type="button"
data-placement="bottom"
ref="addIssuesButton"

View file

@ -25,7 +25,7 @@ export default (ModalStore, boardsStore) => {
<div class="board-extra-actions">
<a
href="#"
class="btn btn-default has-tooltip prepend-left-10 js-focus-mode-btn"
class="btn btn-default has-tooltip gl-ml-3 js-focus-mode-btn"
data-qa-selector="focus_mode_button"
role="button"
aria-label="Toggle focus mode"

View file

@ -32,6 +32,7 @@ export default class FilteredSearchManager {
filteredSearchTokenKeys = IssuableFilteredSearchTokenKeys,
stateFiltersSelector = '.issues-state-filters',
placeholder = __('Search or filter results...'),
anchor = null,
}) {
this.isGroup = isGroup;
this.isGroupAncestor = isGroupAncestor;
@ -47,6 +48,7 @@ export default class FilteredSearchManager {
this.filteredSearchTokenKeys = filteredSearchTokenKeys;
this.stateFiltersSelector = stateFiltersSelector;
this.placeholder = placeholder;
this.anchor = anchor;
const { multipleAssignees } = this.filteredSearchInput.dataset;
if (multipleAssignees && this.filteredSearchTokenKeys.enableMultipleAssignees) {
@ -779,7 +781,11 @@ export default class FilteredSearchManager {
paths.push(`search=${sanitized}`);
}
const parameterizedUrl = `?scope=all&utf8=%E2%9C%93&${paths.join('&')}`;
let parameterizedUrl = `?scope=all&utf8=%E2%9C%93&${paths.join('&')}`;
if (this.anchor) {
parameterizedUrl += `#${this.anchor}`;
}
if (this.updateObject) {
this.updateObject(parameterizedUrl);

View file

@ -83,7 +83,7 @@ export default {
<ul class="nav-links">
<li>
{{ __('Commit Message') }}
<span v-popover="$options.popoverOptions" class="form-text text-muted prepend-left-10">
<span v-popover="$options.popoverOptions" class="form-text text-muted gl-ml-3">
<icon name="question" />
</span>
</li>

View file

@ -44,7 +44,7 @@ export default {
data-qa-selector="start_new_mr_checkbox"
@change="toggleShouldCreateMR"
/>
<span class="prepend-left-10 ide-option-label">
<span class="gl-ml-3 ide-option-label">
{{ __('Start a new merge request') }}
</span>
</label>

View file

@ -66,7 +66,7 @@ export default {
name="commit-action"
@change="updateCommitAction($event.target.value)"
/>
<span class="prepend-left-10">
<span class="gl-ml-3">
<span v-if="label" class="ide-option-label"> {{ label }} </span> <slot v-else></slot>
</span>
</label>

View file

@ -147,10 +147,10 @@ export default {
return getEndLineNumber(this.lineRange);
},
showMultiLineComment() {
if (!this.glFeatures.multilineComments) return false;
if (!this.glFeatures.multilineComments || !this.discussionRoot) return false;
if (this.isEditing) return true;
return this.line && this.discussionRoot && this.startLineNumber !== this.endLineNumber;
return this.line && this.startLineNumber !== this.endLineNumber;
},
commentLineOptions() {
if (!this.diffFile || !this.line) return [];

View file

@ -4,4 +4,5 @@ export const FILTERED_SEARCH = {
MERGE_REQUESTS: 'merge_requests',
ISSUES: 'issues',
ADMIN_RUNNERS: 'admin/runners',
GROUP_RUNNERS_ANCHOR: 'runners-settings',
};

View file

@ -1,11 +1,20 @@
import initSettingsPanels from '~/settings_panels';
import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
import initVariableList from '~/ci_variable_list';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import AdminRunnersFilteredSearchTokenKeys from '~/filtered_search/admin_runners_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => {
// Initialize expandable settings panels
initSettingsPanels();
initFilteredSearch({
page: FILTERED_SEARCH.ADMIN_RUNNERS,
filteredSearchTokenKeys: AdminRunnersFilteredSearchTokenKeys,
anchor: FILTERED_SEARCH.GROUP_RUNNERS_ANCHOR,
});
if (gon.features.newVariablesUi) {
initVariableList();
} else {

View file

@ -7,6 +7,7 @@ export default ({
isGroupAncestor,
isGroupDecendent,
stateFiltersSelector,
anchor,
}) => {
const filteredSearchEnabled = FilteredSearchManager && document.querySelector('.filtered-search');
if (filteredSearchEnabled) {
@ -17,6 +18,7 @@ export default ({
isGroupDecendent,
filteredSearchTokenKeys,
stateFiltersSelector,
anchor,
});
filteredSearchManager.setup();
}

View file

@ -3,9 +3,8 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import Flash from '~/flash';
import { __, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import TitleField from '~/vue_shared/components/form/title.vue';
import { getBaseURL, joinPaths, redirectTo } from '~/lib/utils/url_utility';
import { redirectTo } from '~/lib/utils/url_utility';
import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue';
import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql';
@ -15,6 +14,9 @@ import {
SNIPPET_VISIBILITY_PRIVATE,
SNIPPET_CREATE_MUTATION_ERROR,
SNIPPET_UPDATE_MUTATION_ERROR,
SNIPPET_BLOB_ACTION_CREATE,
SNIPPET_BLOB_ACTION_UPDATE,
SNIPPET_BLOB_ACTION_MOVE,
} from '../constants';
import SnippetBlobEdit from './snippet_blob_edit.vue';
import SnippetVisibilityEdit from './snippet_visibility_edit.vue';
@ -53,18 +55,25 @@ export default {
},
data() {
return {
blob: {},
fileName: '',
content: '',
originalContent: '',
isContentLoading: true,
blobsActions: {},
isUpdating: false,
newSnippet: false,
};
},
computed: {
getActionsEntries() {
return Object.values(this.blobsActions);
},
allBlobsHaveContent() {
const entries = this.getActionsEntries;
return entries.length > 0 && !entries.find(action => !action.content);
},
allBlobChangesRegistered() {
const entries = this.getActionsEntries;
return entries.length > 0 && !entries.find(action => action.action === '');
},
updatePrevented() {
return this.snippet.title === '' || this.content === '' || this.isUpdating;
return this.snippet.title === '' || !this.allBlobsHaveContent || this.isUpdating;
},
isProjectSnippet() {
return Boolean(this.projectPath);
@ -75,8 +84,7 @@ export default {
title: this.snippet.title,
description: this.snippet.description,
visibilityLevel: this.snippet.visibilityLevel,
fileName: this.fileName,
content: this.content,
files: this.getActionsEntries.filter(entry => entry.action !== ''),
};
},
saveButtonLabel() {
@ -108,16 +116,47 @@ export default {
onBeforeUnload(e = {}) {
const returnValue = __('Are you sure you want to lose unsaved changes?');
if (!this.hasChanges()) return undefined;
if (!this.allBlobChangesRegistered) return undefined;
Object.assign(e, { returnValue });
return returnValue;
},
hasChanges() {
return this.content !== this.originalContent;
},
updateFileName(newName) {
this.fileName = newName;
updateBlobActions(args = {}) {
// `_constants` is the internal prop that
// should not be sent to the mutation. Hence we filter it out from
// the argsToUpdateAction that is the data-basis for the mutation.
const { _constants: blobConstants, ...argsToUpdateAction } = args;
const { previousPath, filePath, content } = argsToUpdateAction;
let actionEntry = this.blobsActions[blobConstants.id] || {};
let tunedActions = {
action: '',
previousPath,
};
if (this.newSnippet) {
// new snippet, hence new blob
tunedActions = {
action: SNIPPET_BLOB_ACTION_CREATE,
previousPath: '',
};
} else if (previousPath && filePath) {
// renaming of a blob + renaming & content update
const renamedToOriginal = filePath === blobConstants.originalPath;
tunedActions = {
action: renamedToOriginal ? SNIPPET_BLOB_ACTION_UPDATE : SNIPPET_BLOB_ACTION_MOVE,
previousPath: !renamedToOriginal ? blobConstants.originalPath : '',
};
} else if (content !== blobConstants.originalContent) {
// content update only
tunedActions = {
action: SNIPPET_BLOB_ACTION_UPDATE,
previousPath: '',
};
}
actionEntry = { ...actionEntry, ...argsToUpdateAction, ...tunedActions };
this.$set(this.blobsActions, blobConstants.id, actionEntry);
},
flashAPIFailure(err) {
const defaultErrorMsg = this.newSnippet
@ -129,26 +168,9 @@ export default {
onNewSnippetFetched() {
this.newSnippet = true;
this.snippet = this.$options.newSnippetSchema;
this.blob = this.snippet.blob;
this.isContentLoading = false;
},
onExistingSnippetFetched() {
this.newSnippet = false;
const { blob } = this.snippet;
this.blob = blob;
this.fileName = blob.name;
const baseUrl = getBaseURL();
const url = joinPaths(baseUrl, blob.rawPath);
axios
.get(url)
.then(res => {
this.originalContent = res.data;
this.content = res.data;
this.isContentLoading = false;
})
.catch(e => this.flashAPIFailure(e));
},
onSnippetFetch(snippetRes) {
if (snippetRes.data.snippets.edges.length === 0) {
@ -205,7 +227,6 @@ export default {
title: '',
description: '',
visibilityLevel: SNIPPET_VISIBILITY_PRIVATE,
blob: {},
},
};
</script>
@ -236,12 +257,16 @@ export default {
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
/>
<snippet-blob-edit
v-model="content"
:file-name="fileName"
:is-loading="isContentLoading"
@name-change="updateFileName"
/>
<template v-if="blobs.length">
<snippet-blob-edit
v-for="blob in blobs"
:key="blob.name"
:blob="blob"
@blob-updated="updateBlobActions"
/>
</template>
<snippet-blob-edit v-else @blob-updated="updateBlobActions" />
<snippet-visibility-edit
v-model="snippet.visibilityLevel"
:help-link="visibilityHelpLink"

View file

@ -2,6 +2,17 @@
import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue';
import BlobContentEdit from '~/blob/components/blob_edit_content.vue';
import { GlLoadingIcon } from '@gitlab/ui';
import { getBaseURL, joinPaths } from '~/lib/utils/url_utility';
import axios from '~/lib/utils/axios_utils';
import { SNIPPET_BLOB_CONTENT_FETCH_ERROR } from '~/snippets/constants';
import Flash from '~/flash';
import { sprintf } from '~/locale';
function localId() {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
export default {
components: {
@ -11,20 +22,70 @@ export default {
},
inheritAttrs: false,
props: {
value: {
type: String,
blob: {
type: Object,
required: false,
default: '',
default: null,
validator: ({ rawPath }) => Boolean(rawPath),
},
fileName: {
type: String,
required: false,
default: '',
},
data() {
return {
id: localId(),
filePath: this.blob?.path || '',
previousPath: '',
originalPath: this.blob?.path || '',
content: this.blob?.content || '',
originalContent: '',
isContentLoading: this.blob,
};
},
watch: {
filePath(filePath, previousPath) {
this.previousPath = previousPath;
this.notifyAboutUpdates({ previousPath });
},
isLoading: {
type: Boolean,
required: false,
default: true,
content() {
this.notifyAboutUpdates();
},
},
mounted() {
if (this.blob) {
this.fetchBlobContent();
}
},
methods: {
notifyAboutUpdates(args = {}) {
const { filePath, previousPath } = args;
this.$emit('blob-updated', {
filePath: filePath || this.filePath,
previousPath: previousPath || this.previousPath,
content: this.content,
_constants: {
originalPath: this.originalPath,
originalContent: this.originalContent,
id: this.id,
},
});
},
fetchBlobContent() {
const baseUrl = getBaseURL();
const url = joinPaths(baseUrl, this.blob.rawPath);
axios
.get(url)
.then(res => {
this.originalContent = res.data;
this.content = res.data;
})
.catch(e => this.flashAPIFailure(e))
.finally(() => {
this.isContentLoading = false;
});
},
flashAPIFailure(err) {
Flash(sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err }));
this.isContentLoading = false;
},
},
};
@ -33,23 +94,14 @@ export default {
<div class="form-group file-editor">
<label>{{ s__('Snippets|File') }}</label>
<div class="file-holder snippet">
<blob-header-edit
:value="fileName"
data-qa-selector="file_name_field"
@input="$emit('name-change', $event)"
/>
<blob-header-edit v-model="filePath" data-qa-selector="file_name_field" />
<gl-loading-icon
v-if="isLoading"
v-if="isContentLoading"
:label="__('Loading snippet')"
size="lg"
class="loading-animation prepend-top-20 append-bottom-20"
/>
<blob-content-edit
v-else
:value="value"
:file-name="fileName"
@input="$emit('input', $event)"
/>
<blob-content-edit v-else v-model="content" :file-name="filePath" />
</div>
</div>
</template>

View file

@ -25,3 +25,8 @@ export const SNIPPET_VISIBILITY = {
export const SNIPPET_CREATE_MUTATION_ERROR = __("Can't create snippet: %{err}");
export const SNIPPET_UPDATE_MUTATION_ERROR = __("Can't update snippet: %{err}");
export const SNIPPET_BLOB_CONTENT_FETCH_ERROR = __("Can't fetch content for the blob: %{err}");
export const SNIPPET_BLOB_ACTION_CREATE = 'create';
export const SNIPPET_BLOB_ACTION_UPDATE = 'update';
export const SNIPPET_BLOB_ACTION_MOVE = 'move';

View file

@ -26,21 +26,6 @@ fragment SnippetBase on Snippet {
...BlobViewer
}
}
blob {
binary
name
path
rawPath
size
externalStorage
renderedAsText
simpleViewer {
...BlobViewer
}
richViewer {
...BlobViewer
}
}
userPermissions {
adminSnippet
updateSnippet

View file

@ -1,5 +1,7 @@
import GetSnippetQuery from '../queries/snippet.query.graphql';
const blobsDefault = [];
export const getSnippetMixin = {
apollo: {
snippet: {
@ -11,7 +13,7 @@ export const getSnippetMixin = {
},
update: data => data.snippets.edges[0]?.node,
result(res) {
this.blobs = res.data.snippets.edges[0].node.blobs;
this.blobs = res.data.snippets.edges[0]?.node?.blobs || blobsDefault;
if (this.onSnippetFetch) {
this.onSnippetFetch(res);
}
@ -28,7 +30,7 @@ export const getSnippetMixin = {
return {
snippet: {},
newSnippet: false,
blobs: [],
blobs: blobsDefault,
};
},
computed: {

View file

@ -8,11 +8,7 @@ import { truncateNamespace } from '~/lib/utils/text_utility';
export default {
name: 'ProjectListItem',
components: {
Icon,
ProjectAvatar,
GlDeprecatedButton,
},
components: { Icon, ProjectAvatar, GlDeprecatedButton },
props: {
project: {
type: Object,
@ -22,15 +18,8 @@ export default {
isString(p.name) &&
(isString(p.name_with_namespace) || isString(p.nameWithNamespace)),
},
selected: {
type: Boolean,
required: true,
},
matcher: {
type: String,
required: false,
default: '',
},
selected: { type: Boolean, required: true },
matcher: { type: String, required: false, default: '' },
},
computed: {
projectNameWithNamespace() {
@ -56,7 +45,7 @@ export default {
@click="onClick"
>
<icon
class="prepend-left-10 gl-mr-3 flex-shrink-0 position-top-0 js-selected-icon"
class="gl-ml-3 gl-mr-3 flex-shrink-0 position-top-0 js-selected-icon"
:class="{ 'js-selected visible': selected, 'js-unselected invisible': !selected }"
name="mobile-issue-close"
/>

View file

@ -399,8 +399,6 @@ img.emoji {
.prepend-top-10 { margin-top: 10px; }
.prepend-top-15 { margin-top: 15px; }
.prepend-top-20 { margin-top: 20px; }
.prepend-left-5 { margin-left: 5px; }
.prepend-left-10 { margin-left: 10px; }
.prepend-left-15 { margin-left: 15px; }
.prepend-left-20 { margin-left: 20px; }
.prepend-left-64 { margin-left: 64px; }

View file

@ -23,9 +23,13 @@ class Groups::RunnersController < Groups::ApplicationController
end
def destroy
@runner.destroy
if @runner.belongs_to_more_than_one_project?
redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: :found, alert: _('Runner was not deleted because it is assigned to multiple projects.')
else
@runner.destroy
redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: :found
redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: :found
end
end
def resume
@ -47,7 +51,9 @@ class Groups::RunnersController < Groups::ApplicationController
private
def runner
@runner ||= @group.runners.find(params[:id])
@runner ||= Ci::RunnersFinder.new(current_user: current_user, group: @group, params: {}).execute
.except(:limit, :offset)
.find(params[:id])
end
def runner_params

View file

@ -11,7 +11,15 @@ module Groups
end
before_action :define_variables, only: [:show]
NUMBER_OF_RUNNERS_PER_PAGE = 4
def show
runners_finder = Ci::RunnersFinder.new(current_user: current_user, group: @group, params: params)
# We need all runners for count
@all_group_runners = runners_finder.execute.except(:limit, :offset)
@group_runners = runners_finder.execute.page(params[:page]).per(NUMBER_OF_RUNNERS_PER_PAGE)
@sort = runners_finder.sort_key
end
def update

View file

@ -5,11 +5,15 @@ class BranchesFinder < GitRefsFinder
super(repository, params)
end
def execute
branches = repository.branches_sorted_by(sort)
branches = by_search(branches)
branches = by_names(branches)
branches
def execute(gitaly_pagination: false)
if gitaly_pagination && names.blank? && search.blank?
repository.branches_sorted_by(sort, pagination_params)
else
branches = repository.branches_sorted_by(sort)
branches = by_search(branches)
branches = by_names(branches)
branches
end
end
private
@ -18,6 +22,18 @@ class BranchesFinder < GitRefsFinder
@params[:names].presence
end
def per_page
@params[:per_page].presence
end
def page_token
"#{Gitlab::Git::BRANCH_REF_PREFIX}#{@params[:page_token]}" if @params[:page_token]
end
def pagination_params
{ limit: per_page, page_token: page_token }
end
def by_names(branches)
return branches unless names

View file

@ -239,6 +239,10 @@ module Ci
runner_projects.count == 1
end
def belongs_to_more_than_one_project?
self.projects.limit(2).count(:all) > 1
end
def assigned_to_group?
runner_namespaces.any?
end

View file

@ -713,8 +713,8 @@ class Repository
"#{name}-#{highest_branch_id + 1}"
end
def branches_sorted_by(value)
raw_repository.local_branches(sort_by: value)
def branches_sorted_by(sort_by, pagination_params = nil)
raw_repository.local_branches(sort_by: sort_by, pagination_params: pagination_params)
end
def tags_sorted_by(value)

View file

@ -46,4 +46,4 @@
.form-actions
= link_to 'Edit', edit_admin_application_path(@application), class: 'btn btn-primary wide float-left'
= render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger prepend-left-10'
= render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger gl-ml-3'

View file

@ -4,6 +4,6 @@
%hr
= link_to 'Resend Request', retry_admin_hook_hook_log_path(@hook, @hook_log), method: :post, class: "btn btn-default float-right prepend-left-10"
= link_to 'Resend Request', retry_admin_hook_hook_log_path(@hook, @hook_log), method: :post, class: "btn btn-default float-right gl-ml-3"
= render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log }

View file

@ -1,6 +1,6 @@
- sorted_by = sort_options_hash[@sort]
.dropdown.inline.prepend-left-10
.dropdown.inline.gl-ml-3
%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } }
= sorted_by
= icon('chevron-down')

View file

@ -16,7 +16,7 @@
.float-right
%span.light.vertical-align-middle= group_member.human_access
- unless group_member.owner?
= link_to group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member) }, method: :delete, remote: true, class: "btn-sm btn btn-remove prepend-left-10", title: 'Remove user from group' do
= link_to group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member) }, method: :delete, remote: true, class: "btn-sm btn btn-remove gl-ml-3", title: 'Remove user from group' do
%i.fa.fa-times.fa-inverse
.row
@ -46,5 +46,5 @@
%span.light.vertical-align-middle= member.human_access
- if member.respond_to? :project
= link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn-sm btn btn-remove prepend-left-10", title: 'Remove user from project' do
= link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn-sm btn btn-remove gl-ml-3", title: 'Remove user from project' do
%i.fa.fa-times

View file

@ -36,7 +36,7 @@
%span.hide.js-ci-variables-save-loading-icon
.spinner.spinner-light.mr-1
= _('Save variables')
%button.btn.btn-info.btn-inverted.prepend-left-10.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@variables.size == 0}" } }
%button.btn.btn-info.btn-inverted.gl-ml-3.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@variables.size == 0}" } }
- if @variables.size == 0
= n_('Hide value', 'Hide values', @variables.size)
- else

View file

@ -44,4 +44,4 @@
.form-actions
= link_to _('Edit'), edit_oauth_application_path(@application), class: 'btn btn-primary wide float-left'
= render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger prepend-left-10'
= render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger gl-ml-3'

View file

@ -46,4 +46,4 @@
= hidden_field_tag :response_type, @pre_auth.response_type
= hidden_field_tag :scope, @pre_auth.scope
= hidden_field_tag :nonce, @pre_auth.nonce
= submit_tag _("Authorize"), class: "btn btn-success prepend-left-10", data: { qa_selector: 'authorization_button' }
= submit_tag _("Authorize"), class: "btn btn-success gl-ml-3", data: { qa_selector: 'authorization_button' }

View file

@ -27,5 +27,5 @@
= render 'shared/empty_states/labels'
%template#js-badge-item-template
%li.label-link-item.js-priority-badge.inline.prepend-left-10
%li.label-link-item.js-priority-badge.inline.gl-ml-3
.label-badge.label-badge-blue= _('Prioritized label')

View file

@ -18,13 +18,3 @@
locals: { registration_token: @group.runners_token,
type: 'group',
reset_token_url: reset_registration_token_group_settings_ci_cd_path }
- if @group.runners.empty?
%h4.underlined-title
= _('This group does not provide any group Runners yet.')
- else
%h4.underlined-title
= _('Available group Runners: %{runners}').html_safe % { runners: @group.runners.count }
%ul.bordered-list
= render partial: 'groups/runners/runner', collection: @group.runners, as: :runner

View file

@ -7,3 +7,97 @@
.row
.col-sm-6
= render 'groups/runners/group_runners'
%h4.underlined-title
= _('Available Runners: %{runners}').html_safe % { runners: limited_counter_with_delimiter(@all_group_runners) }
-# haml-lint:disable NoPlainNodes
.row
.col-sm-9
= form_tag group_settings_ci_cd_path, id: 'runners-search', method: :get, class: 'filter-form js-filter-form' do
.filtered-search-wrapper.d-flex
.filtered-search-box
= dropdown_tag(_('Recent searches'),
options: { wrapper_class: 'filtered-search-history-dropdown-wrapper',
toggle_class: 'btn filtered-search-history-dropdown-toggle-button',
dropdown_class: 'filtered-search-history-dropdown',
content_class: 'filtered-search-history-dropdown-content' }) do
.js-filtered-search-history-dropdown{ data: { full_path: group_settings_ci_cd_path } }
.filtered-search-box-input-container.droplab-dropdown
.scroll-container
%ul.tokens-container.list-unstyled
%li.input-token
%input.form-control.filtered-search{ search_filter_input_options('runners') }
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
= button_tag class: 'btn btn-link' do
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
%use{ 'xlink:href': "#{'{{icon}}'}" }
%span.js-filter-hint
{{formattedKey}}
#js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
%li.filter-dropdown-item{ data: { value: "{{ title }}" } }
= button_tag class: 'btn btn-link' do
{{ title }}
%span.btn-helptext
{{ help }}
#js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_STATUSES.each do |status|
%li.filter-dropdown-item{ data: { value: status } }
= button_tag class: 'btn btn-link' do
= status.titleize
#js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_TYPES.each do |runner_type|
- next if runner_type == 'instance_type'
%li.filter-dropdown-item{ data: { value: runner_type } }
= button_tag class: 'btn btn-link' do
= runner_type.titleize
#js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
= button_tag class: 'btn btn-link' do
= _('No Tag')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
= button_tag class: 'btn btn-link js-data-value' do
%span.dropdown-light-content
{{name}}
= button_tag class: 'clear-search hidden' do
= icon('times')
.filter-dropdown-container
= render 'admin/runners/sort_dropdown'
.col-sm-3.text-right-lg
= _('Runners currently online: %{active_runners_count}') % { active_runners_count: limited_counter_with_delimiter(@all_group_runners.online) }
- if @group_runners.any?
.runners-content.content-list
.table-holder
.gl-responsive-table-row.table-row-header{ role: 'row' }
.table-section.section-10{ role: 'rowheader' }= _('Type/State')
.table-section.section-10{ role: 'rowheader' }= _('Runner token')
.table-section.section-20{ role: 'rowheader' }= _('Description')
.table-section.section-10{ role: 'rowheader' }= _('Version')
.table-section.section-10{ role: 'rowheader' }= _('IP Address')
.table-section.section-5{ role: 'rowheader' }= _('Projects')
.table-section.section-5{ role: 'rowheader' }= _('Jobs')
.table-section.section-10{ role: 'rowheader' }= _('Tags')
.table-section.section-10{ role: 'rowheader' }= _('Last contact')
.table-section.section-10{ role: 'rowheader' }
- @group_runners.each do |runner|
= render 'groups/runners/runner', runner: runner
= paginate @group_runners, theme: 'gitlab', :params => { :anchor => 'runners-settings' }
- else
.nothing-here-block= _('No runners found')

View file

@ -1,27 +1,86 @@
%li.runner{ id: dom_id(runner) }
%h4
= runner_status_icon(runner)
= link_to runner.short_sha, group_runner_path(@group, runner), class: 'commit-sha'
%small.edit-runner
= link_to edit_group_runner_path(@group, runner) do
= icon('edit')
.float-right
- if runner.active?
= link_to _('Pause'), pause_group_runner_path(@group, runner), method: :post, class: 'btn btn-sm btn-danger', data: { confirm: _("Are you sure?") }
.gl-responsive-table-row{ id: dom_id(runner) }
.table-section.section-10.section-wrap
.table-mobile-header{ role: 'rowheader' }= _('Type')
.table-mobile-content
- if runner.group_type?
%span.badge.badge-success
= _('group')
- else
= link_to _('Resume'), resume_group_runner_path(@group, runner), method: :post, class: 'btn btn-success btn-sm'
= link_to _('Remove Runner'), group_runner_path(@group, runner), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn btn-danger btn-sm'
.float-right
%small.light
\##{runner.id}
- if runner.description.present?
%p.runner-description
%span.badge.badge-info
= _('specific')
- if runner.locked?
%span.badge.badge-warning
= _('locked')
- unless runner.active?
%span.badge.badge-danger
= _('paused')
.table-section.section-10
.table-mobile-header{ role: 'rowheader' }= _('Runner token')
.table-mobile-content
= link_to runner.short_sha, group_runner_path(@group, runner)
.table-section.section-20
.table-mobile-header{ role: 'rowheader' }= _('Description')
.table-mobile-content.str-truncated.has-tooltip{ title: runner.description }
= runner.description
- if runner.tag_list.present?
%p
- runner.tag_list.sort.each do |tag|
%span.label.label-primary
.table-section.section-10
.table-mobile-header{ role: 'rowheader' }= _('Version')
.table-mobile-content.str-truncated.has-tooltip{ title: runner.version }
= runner.version
.table-section.section-10
.table-mobile-header{ role: 'rowheader' }= _('IP Address')
.table-mobile-content.str-truncated.has-tooltip{ title: runner.ip_address }
= runner.ip_address
.table-section.section-5
.table-mobile-header{ role: 'rowheader' }= _('Projects')
.table-mobile-content
- if runner.group_type?
= _('n/a')
- else
= runner.projects.count(:all)
.table-section.section-5
.table-mobile-header{ role: 'rowheader' }= _('Jobs')
.table-mobile-content
= limited_counter_with_delimiter(runner.builds)
.table-section.section-10.section-wrap
.table-mobile-header{ role: 'rowheader' }= _('Tags')
.table-mobile-content
- runner.tags.map(&:name).sort.each do |tag|
%span.badge.badge-primary.str-truncated.has-tooltip{ title: tag }
= tag
.table-section.section-10
.table-mobile-header{ role: 'rowheader' }= _('Last contact')
.table-mobile-content
- contacted_at = runner_contacted_at(runner)
- if contacted_at
= time_ago_with_tooltip contacted_at
- else
= _('Never')
.table-section.table-button-footer.section-10
.btn-group.table-action-buttons
.btn-group
= link_to edit_group_runner_path(@group, runner), class: 'btn btn-default has-tooltip', title: _('Edit'), ref: 'tooltip', aria: { label: _('Edit') }, data: { placement: 'top', container: 'body'} do
= icon('pencil')
.btn-group
- if runner.active?
= link_to pause_group_runner_path(@group, runner), method: :post, class: 'btn btn-default has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
= icon('pause')
- else
= link_to resume_group_runner_path(@group, runner), method: :post, class: 'btn btn-default has-tooltip', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do
= icon('play')
- if runner.belongs_to_more_than_one_project?
.btn-group
.btn.btn-danger.has-tooltip{ 'aria-label' => 'Remove', 'data-container' => 'body', 'data-original-title' => _('Multi-project Runners cannot be removed'), 'data-placement' => 'top', disabled: 'disabled' }
= icon('remove')
- else
.btn-group
= link_to group_runner_path(@group, runner), method: :delete, class: 'btn btn-danger has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
= icon('remove')

View file

@ -35,4 +35,4 @@
- unless 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 _("Decline"), decline_invite_url(@token), method: :post, class: "btn btn-danger gl-ml-3"

View file

@ -27,6 +27,6 @@
- unless is_current_session
.float-right
= link_to profile_active_session_path(active_session.public_id), data: { confirm: _('Are you sure? The device will be signed out of GitLab.') }, method: :delete, class: "btn btn-danger prepend-left-10" do
= link_to profile_active_session_path(active_session.public_id), data: { confirm: _('Are you sure? The device will be signed out of GitLab.') }, method: :delete, class: "btn btn-danger gl-ml-3" do
%span.sr-only= _('Revoke')
= _('Revoke')

View file

@ -12,4 +12,4 @@
= submit_tag "Authorize", class: "btn btn-success wide float-left"
= form_tag deny_profile_chat_names_path, method: :delete do
= hidden_field_tag :token, @chat_name_token.token
= submit_tag "Deny", class: "btn btn-danger prepend-left-10"
= submit_tag "Deny", class: "btn btn-danger gl-ml-3"

View file

@ -56,8 +56,8 @@
%span.badge.badge-info= s_('Profiles|Notification email')
- unless email.confirmed?
- confirm_title = "#{email.confirmation_sent_at ? _('Resend confirmation email') : _('Send confirmation email')}"
= link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'btn btn-sm btn-warning prepend-left-10'
= link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'btn btn-sm btn-warning gl-ml-3'
= link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'btn btn-sm btn-danger prepend-left-10' do
= link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'btn btn-sm btn-danger gl-ml-3' do
%span.sr-only= _('Remove')
= icon('trash')

View file

@ -19,9 +19,9 @@
.float-right
%span.key-created-at
= s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago:time_ago_with_tooltip(key.created_at)}
= link_to profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: "btn btn-danger prepend-left-10" do
= link_to profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: "btn btn-danger gl-ml-3" do
%span.sr-only= _('Remove')
= icon('trash')
= link_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.') }, method: :put, class: "btn btn-danger prepend-left-10" do
= link_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.') }, method: :put, class: "btn btn-danger gl-ml-3" do
%span.sr-only= _('Revoke')
= _('Revoke')

View file

@ -26,6 +26,6 @@
%span.key-created-at
= s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago:time_ago_with_tooltip(key.created_at)}
- if key.can_delete?
= link_to path_to_key(key, is_admin), data: { confirm: _('Are you sure?')}, method: :delete, class: "btn btn-transparent prepend-left-10 align-baseline" do
= link_to path_to_key(key, is_admin), data: { confirm: _('Are you sure?')}, method: :delete, class: "btn btn-transparent gl-ml-3 align-baseline" do
%span.sr-only= _('Remove')
= sprite_icon('remove', size: 16)

View file

@ -41,7 +41,7 @@
- if branch.name != @repository.root_ref
= link_to project_compare_index_path(@project, from: @repository.root_ref, to: branch.name),
class: "btn btn-default js-onboarding-compare-branches #{'prepend-left-10' unless merge_project}",
class: "btn btn-default js-onboarding-compare-branches #{'gl-ml-3' unless merge_project}",
method: :post,
title: s_('Branches|Compare') do
= s_('Branches|Compare')

View file

@ -26,6 +26,6 @@
&nbsp;
= button_tag s_("CompareBranches|Compare"), class: "btn btn-success commits-compare-btn"
- if @merge_request.present?
= link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'prepend-left-10 btn'
= link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'gl-ml-3 btn'
- elsif create_mr_button?
= link_to _("Create merge request"), create_mr_path, class: 'prepend-left-10 btn'
= link_to _("Create merge request"), create_mr_path, class: 'gl-ml-3 btn'

View file

@ -7,6 +7,6 @@
%h4.gl-mt-0
Request details
.col-lg-9
= link_to 'Resend Request', @hook_log.present.retry_path, method: :post, class: "btn btn-default float-right prepend-left-10"
= link_to 'Resend Request', @hook_log.present.retry_path, method: :post, class: "btn btn-default float-right gl-ml-3"
= render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log }

View file

@ -12,7 +12,7 @@
.modal-body
.modal-subheader
= icon('check', { class: 'checkmark' })
%strong.prepend-left-10
%strong.gl-ml-3
- issues_count = issuables_count_for_state(:issues, params[:state])
= n_('%d issue selected', '%d issues selected', issues_count) % issues_count
.modal-text

View file

@ -52,5 +52,5 @@
= render 'shared/empty_states/labels'
%template#js-badge-item-template
%li.label-link-item.js-priority-badge.inline.prepend-left-10
%li.label-link-item.js-priority-badge.inline.gl-ml-3
.label-badge.label-badge-blue= _('Prioritized label')

View file

@ -15,7 +15,7 @@
= link_to project_repository_path(@project), method: :post, class: 'btn btn-primary' do
#{ _('Create empty repository') }
%strong.prepend-left-10.gl-mr-3 or
%strong.gl-ml-3.gl-mr-3 or
= link_to new_project_import_path(@project), class: 'btn' do
#{ _('Import repository') }

View file

@ -38,5 +38,5 @@
- if can?(current_user, :admin_tag, @project)
= link_to edit_project_tag_release_path(@project, tag.name), class: 'btn btn-edit has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do
= icon("pencil")
= link_to project_tag_path(@project, tag.name), class: "btn btn-remove remove-row has-tooltip prepend-left-10 #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag.name }, container: 'body' }, remote: true do
= link_to project_tag_path(@project, tag.name), class: "btn btn-remove remove-row has-tooltip gl-ml-3 #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag.name }, container: 'body' }, remote: true do
= icon("trash-o")

View file

@ -10,7 +10,7 @@
- if @path.present?
%tr.tree-item
%td.tree-item-file-name
= link_to "..", project_tree_path(@project, up_dir_path), class: 'prepend-left-10'
= link_to "..", project_tree_path(@project, up_dir_path), class: 'gl-ml-3'
%td
%td.d-none.d-sm-table-cell

View file

@ -20,6 +20,6 @@
= link_to_label(label, type: :merge_request) { _('Merge requests') }
- if force_priority
&middot;
%li.label-link-item.priority-badge.js-priority-badge.inline.prepend-left-10
%li.label-link-item.priority-badge.js-priority-badge.inline.gl-ml-3
.label-badge.label-badge-blue= _('Prioritized label')
= render_if_exists 'shared/label_row_epics_link', label: label

View file

@ -1,4 +1,4 @@
.dropdown.inline.prepend-left-10
.dropdown.inline.gl-ml-3
%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } }
%span.light
- if @sort.present?

View file

@ -1,4 +1,4 @@
.dropdown.prepend-left-10#js-add-list
.dropdown.gl-ml-3#js-add-list
%button.btn.btn-success.btn-inverted.js-new-board-list{ type: "button", data: board_list_data }
Add list
.dropdown-menu.dropdown-extended-height.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels

View file

@ -8,7 +8,7 @@
- if defined? warn_before_close
- add_blocked_class = !issuable.closed? && warn_before_close
.float-left.btn-group.prepend-left-10.issuable-close-dropdown.droplab-dropdown.js-issuable-close-dropdown
.float-left.btn-group.gl-ml-3.issuable-close-dropdown.droplab-dropdown.js-issuable-close-dropdown
%button{ class: "#{button_class} btn-#{button_action} #{(add_blocked_class ? 'btn-issue-blocked' : '')}", data: { qa_selector: 'close_issue_button', endpoint: close_reopen_issuable_path(issuable) } }
#{display_button_action} #{display_issuable_type}

View file

@ -135,7 +135,7 @@
%li.filter-dropdown-item
%button.btn.btn-link{ type: 'button' }
%gl-emoji
%span.js-data-value.prepend-left-10
%span.js-data-value.gl-ml-3
{{name}}
#js-dropdown-wip.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true } }
@ -172,7 +172,7 @@
- if user_can_admin_list
= render 'shared/issuable/board_create_list_dropdown', board: board
- if @project
#js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
#js-add-issues-btn.gl-ml-3{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
- if Feature.enabled?(:boards_with_swimlanes, @group)
#js-board-epics-swimlanes-toggle
#js-toggle-focus-btn

View file

@ -2,7 +2,7 @@
- sort_title = issuable_sort_option_title(sort_value)
- viewing_issues = controller.controller_name == 'issues' || controller.action_name == 'issues'
.dropdown.inline.prepend-left-10.issue-sort-dropdown
.dropdown.inline.gl-ml-3.issue-sort-dropdown
.btn-group{ role: 'group' }
.btn-group{ role: 'group' }
%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' }

View file

@ -1,6 +1,6 @@
.banner-callout.compact.milestone-deprecation-message.js-milestone-deprecation-message.prepend-top-20
.banner-graphic= image_tag 'illustrations/milestone_removing-page.svg'
.banner-body.prepend-left-10.gl-mr-3
.banner-body.gl-ml-3.gl-mr-3
%h5.banner-title.gl-mt-0= _('This page will be removed in a future release.')
%p.milestone-banner-text= _('Use group milestones to manage issues from multiple projects in the same milestone.')
= button_tag _('Promote these project milestones into a group milestone.'), class: 'btn btn-link js-popover-link text-align-left milestone-banner-link'

View file

@ -1,5 +1,5 @@
- unless can?(current_user, :push_code, @project)
.inline.prepend-left-10
.inline.gl-ml-3
- if @project.branch_allows_collaboration?(current_user, selected_branch)
= commit_in_single_accessible_branch
- else

View file

@ -10,7 +10,6 @@ module ApplicationWorker
include Sidekiq::Worker # rubocop:disable Cop/IncludeSidekiqWorker
include WorkerAttributes
include WorkerContext
include Gitlab::SidekiqVersioning::Worker
LOGGING_EXTRA_KEY = 'extra'

View file

@ -1,5 +0,0 @@
---
title: Add include_parent_milestones param to milestones API
merge_request: 36944
author:
type: added

View file

@ -0,0 +1,5 @@
---
title: Use native Gitaly pagination for Branch list API
merge_request: 35819
author:
type: changed

View file

@ -0,0 +1,5 @@
---
title: Support multiple files when editing snippets
merge_request: 37079
author:
type: changed

View file

@ -0,0 +1,5 @@
---
title: Fix showing MLC form on replies
merge_request: 37139
author:
type: fixed

View file

@ -38,10 +38,10 @@ In this setup we will share the home directory on the host with the client. Edit
```plaintext
#/etc/exports for one client
/home <client-ip-address>(rw,sync,no_root_squash,no_subtree_check)
/home <client_ip_address>(rw,sync,no_root_squash,no_subtree_check)
#/etc/exports for three clients
/home <client-ip-address>(rw,sync,no_root_squash,no_subtree_check) <client-2-ip-address>(rw,sync,no_root_squash,no_subtree_check) <client-3-ip-address>(rw,sync,no_root_squash,no_subtree_check)
/home <client_ip_address>(rw,sync,no_root_squash,no_subtree_check) <client_2_ip_address>(rw,sync,no_root_squash,no_subtree_check) <client_3_ip_address>(rw,sync,no_root_squash,no_subtree_check)
```
Restart the NFS server after making changes to the `exports` file for the changes
@ -54,7 +54,7 @@ systemctl restart nfs-kernel-server
NOTE: **Note:**
You may need to update your server's firewall. See the [firewall section](#nfs-in-a-firewalled-environment) at the end of this guide.
## Client/ GitLab application node Setup
## Client / GitLab application node Setup
> Follow the instructions below to connect any GitLab Rails application node running
inside your HA environment to the NFS server configured above.
@ -90,7 +90,7 @@ df -h
### Step 3 - Set up Automatic Mounts on Boot
Edit `/etc/fstab` on client as below to mount the remote shares automatically at boot.
Edit `/etc/fstab` on the client as below to mount the remote shares automatically at boot.
Note that GitLab requires advisory file locking, which is only supported natively in
NFS version 4. NFSv3 also supports locking as long as Linux Kernel 2.6.5+ is used.
We recommend using version 4 and do not specifically test NFSv3.
@ -98,14 +98,19 @@ See [NFS documentation](nfs.md#nfs-client-mount-options) for guidance on mount o
```plaintext
#/etc/fstab
10.0.0.1:/nfs/home /nfs/home nfs4 defaults,hard,vers=4.1,rsize=1048576,wsize=1048576,noatime,nofail,lookupcache=positive 0 2
<host_ip_address>:/home /nfs/home nfs4 defaults,hard,vers=4.1,rsize=1048576,wsize=1048576,noatime,nofail,lookupcache=positive 0 2
```
Reboot the client and confirm that the mount point is mounted automatically.
NOTE: **Note:**
If you followed our guide to [GitLab Pages on a separate server](../pages/index.md#running-gitlab-pages-on-a-separate-server)
here, please continue there with the pages-specific NFS mounts.
The step below is for broader use-cases than only sharing pages data.
### Step 4 - Set up GitLab to Use NFS mounts
When using the default Omnibus configuration you will need to share 5 data locations
When using the default Omnibus configuration you will need to share 4 data locations
between all GitLab cluster nodes. No other locations should be shared. Changing the
default file locations in `gitlab.rb` on the client allows you to have one main mount
point and have all the required locations as subdirectories to use the NFS mount for
@ -136,7 +141,7 @@ the command: `sudo ufw status`. If it's being blocked, then you can allow traffi
client with the command below.
```shell
sudo ufw allow from <client-ip-address> to any port nfs
sudo ufw allow from <client_ip_address> to any port nfs
```
<!-- ## Troubleshooting

View file

@ -8,6 +8,99 @@ GitLab, like most large applications, enforces limits within certain features to
minimum quality of performance. Allowing some features to be limitless could affect security,
performance, data, or could even exhaust the allocated resources for the application.
## Rate limits
Rate limits can be used to improve the security and durability of GitLab.
For example, a simple script can make thousands of web requests per second. Whether malicious, apathetic, or just a bug, your application and infrastructure may not be able to cope with the load. Rate limits can help mitigate these types of attacks.
Read more about [configuring rate limits](../security/rate_limits.md) in the Security documentation.
### Issue creation
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/28129) in GitLab 12.10.
This setting limits the request rate to the issue creation endpoint.
Read more on [issue creation rate limits](../user/admin_area/settings/rate_limit_on_issues_creation.md).
- **Default rate limit** - Disabled by default
### By User or IP
This setting limits the request rate per user or IP.
Read more on [User and IP rate limits](../user/admin_area/settings/user_and_ip_rate_limits.md).
- **Default rate limit** - Disabled by default
### By raw endpoint
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/30829) in GitLab 12.2.
This setting limits the request rate per endpoint.
Read more on [raw endpoint rate limits](../user/admin_area/settings/rate_limits_on_raw_endpoints.md).
- **Default rate limit** - 300 requests per project, per commit and per file path
### By protected path
This setting limits the request rate on specific paths.
GitLab rate limits the following paths by default:
```plaintext
'/users/password',
'/users/sign_in',
'/api/#{API::API.version}/session.json',
'/api/#{API::API.version}/session',
'/users',
'/users/confirmation',
'/unsubscribes/',
'/import/github/personal_access_token',
'/admin/session'
```
Read more on [protected path rate limits](../user/admin_area/settings/protected_paths.md).
- **Default rate limit** - After 10 requests, the client must wait 60 seconds before trying again
### Import/Export
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35728) in GitLab 13.2.
This setting limits the import/export actions for groups and projects.
| Limit | Default (per minute per user) |
| ----- | ----------------------------- |
| Project Import | 6 |
| Project Export | 6 |
| Project Export Download | 1 |
| Group Import | 6 |
| Group Export | 6 |
| Group Export | Download | 1 |
Read more on [import/export rate limits](../user/admin_area/settings/import_export_rate_limits.md).
### Rack attack
This method of rate limiting is cumbersome, but has some advantages. It allows
throttling of specific paths, and is also integrated into Git and container
registry requests.
Read more on the [Rack Attack initializer](../security/rack_attack.md) method of setting rate limits.
- **Default rate limit** - Disabled
## Gitaly concurrency limit
Clone traffic can put a large strain on your Gitaly service. To prevent such workloads from overwhelming your Gitaly server, you can set concurrency limits in Gitalys configuration file.
Read more on [Gitaly concurrency limits](gitaly/index.md#limit-rpc-concurrency).
- **Default rate limit** - Disabled
## Number of comments per issue, merge request or commit
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/22388) in GitLab 12.4.

View file

@ -511,10 +511,17 @@ The following procedure includes steps to back up and edit the
`gitlab-secrets.json` file. This file contains secrets that control
database encryption. Proceed with caution.
1. Create a backup of the secrets file on the **GitLab server**:
```shell
cp /etc/gitlab/gitlab-secrets.json /etc/gitlab/gitlab-secrets.json.bak
```
1. On the **GitLab server**, to enable Pages, add the following to `/etc/gitlab/gitlab.rb`:
```ruby
gitlab_pages['enable'] = true
pages_external_url "http://<pages_server_URL>"
```
1. Optionally, to enable [access control](#access-control), add the following to `/etc/gitlab/gitlab.rb`:
@ -527,26 +534,25 @@ database encryption. Proceed with caution.
changes to take effect. The `gitlab-secrets.json` file is now updated with the
new configuration.
1. Create a backup of the secrets file on the **GitLab server**:
```shell
cp /etc/gitlab/gitlab-secrets.json /etc/gitlab/gitlab-secrets.json.bak
```
1. Set up a new server. This will become the **Pages server**.
1. Create an [NFS share](../high_availability/nfs_host_client_setup.md) on the new server and configure this share to
allow access from your main **GitLab server**. For this example, we use the
1. Create an [NFS share](../high_availability/nfs_host_client_setup.md)
on the **Pages server** and configure this share to
allow access from your main **GitLab server**.
Note that the example there is more general and
shares several sub-directories from `/home` to several `/nfs/home` mountpoints.
For our Pages-specific example here, we instead share only the
default GitLab Pages folder `/var/opt/gitlab/gitlab-rails/shared/pages`
as the shared folder on the new server and we will mount it to `/mnt/pages`
from the **Pages server** and we mount it to `/mnt/pages`
on the **GitLab server**.
Therefore, omit "Step 4" there.
1. On the **Pages server**, install Omnibus GitLab and modify `/etc/gitlab/gitlab.rb`
to include:
```ruby
external_url 'http://<ip-address-of-the-server>'
pages_external_url "http://<your-pages-server-URL>"
external_url 'http://<gitlab_server_IP_or_URL>'
pages_external_url "http://<pages_server_URL>"
postgresql['enable'] = false
redis['enable'] = false
prometheus['enable'] = false
@ -566,7 +572,15 @@ database encryption. Proceed with caution.
```
1. Copy the `/etc/gitlab/gitlab-secrets.json` file from the **GitLab server**
to the **Pages server**.
to the **Pages server**, for example via the NFS share.
```shell
# On the GitLab server
cp /etc/gitlab/gitlab-secrets.json /mnt/pages/gitlab-secrets.json
# On the Pages server
mv /var/opt/gitlab/gitlab-rails/shared/pages/gitlab-secrets.json /etc/gitlab/gitlab-secrets.json
```
1. [Reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
@ -574,13 +588,13 @@ database encryption. Proceed with caution.
```ruby
gitlab_pages['enable'] = false
pages_external_url "http://<your-pages-server-URL>"
pages_external_url "http://<pages_server_URL>"
gitlab_rails['pages_path'] = "/mnt/pages"
```
1. [Reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
It is possible to run GitLab Pages on multiple servers if you wish to distribute
It's possible to run GitLab Pages on multiple servers if you wish to distribute
the load. You can do this through standard load balancing practices such as
configuring your DNS server to return multiple IPs for your Pages server,
configuring a load balancer to work at the IP level, and so on. If you wish to

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
# Troubleshooting a reference architecture set up
# Troubleshooting a reference architecture setup
This page serves as the troubleshooting documentation if you followed one of
the [reference architectures](index.md#reference-architectures).
@ -86,8 +86,117 @@ a workaround, in the mean time, to
## Troubleshooting Redis
If the application node cannot connect to the Redis node, check your firewall rules and
make sure Redis can accept TCP connections under port `6379`.
There are a lot of moving parts that needs to be taken care carefully
in order for the HA setup to work as expected.
Before proceeding with the troubleshooting below, check your firewall rules:
- Redis machines
- Accept TCP connection in `6379`
- Connect to the other Redis machines via TCP in `6379`
- Sentinel machines
- Accept TCP connection in `26379`
- Connect to other Sentinel machines via TCP in `26379`
- Connect to the Redis machines via TCP in `6379`
### Troubleshooting Redis replication
You can check if everything is correct by connecting to each server using
`redis-cli` application, and sending the `info replication` command as below.
```shell
/opt/gitlab/embedded/bin/redis-cli -h <redis-host-or-ip> -a '<redis-password>' info replication
```
When connected to a `Primary` Redis, you will see the number of connected
`replicas`, and a list of each with connection details:
```plaintext
# Replication
role:master
connected_replicas:1
replica0:ip=10.133.5.21,port=6379,state=online,offset=208037514,lag=1
master_repl_offset:208037658
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:206989083
repl_backlog_histlen:1048576
```
When it's a `replica`, you will see details of the primary connection and if
its `up` or `down`:
```plaintext
# Replication
role:replica
master_host:10.133.1.58
master_port:6379
master_link_status:up
master_last_io_seconds_ago:1
master_sync_in_progress:0
replica_repl_offset:208096498
replica_priority:100
replica_read_only:1
connected_replicas:0
master_repl_offset:0
repl_backlog_active:0
repl_backlog_size:1048576
repl_backlog_first_byte_offset:0
repl_backlog_histlen:0
```
### Troubleshooting Sentinel
If you get an error like: `Redis::CannotConnectError: No sentinels available.`,
there may be something wrong with your configuration files or it can be related
to [this issue](https://github.com/redis/redis-rb/issues/531).
You must make sure you are defining the same value in `redis['master_name']`
and `redis['master_pasword']` as you defined for your sentinel node.
The way the Redis connector `redis-rb` works with sentinel is a bit
non-intuitive. We try to hide the complexity in omnibus, but it still requires
a few extra configurations.
---
To make sure your configuration is correct:
1. SSH into your GitLab application server
1. Enter the Rails console:
```shell
# For Omnibus installations
sudo gitlab-rails console
# For source installations
sudo -u git rails console -e production
```
1. Run in the console:
```ruby
redis = Redis.new(Gitlab::Redis::SharedState.params)
redis.info
```
Keep this screen open and try to simulate a failover below.
1. To simulate a failover on primary Redis, SSH into the Redis server and run:
```shell
# port must match your primary redis port, and the sleep time must be a few seconds bigger than defined one
redis-cli -h localhost -p 6379 DEBUG sleep 20
```
1. Then back in the Rails console from the first step, run:
```ruby
redis.info
```
You should see a different port after a few seconds delay
(the failover/reconnect time).
## Troubleshooting Gitaly
@ -327,3 +436,135 @@ or
```shell
curl http[s]://localhost:<EXPORTER LISTENING PORT>/-/metric
```
## Troubleshooting PgBouncer
In case you are experiencing any issues connecting through PgBouncer, the first place to check is always the logs:
```shell
sudo gitlab-ctl tail pgbouncer
```
Additionally, you can check the output from `show databases` in the [administrative console](#pgbouncer-administrative-console). In the output, you would expect to see values in the `host` field for the `gitlabhq_production` database. Additionally, `current_connections` should be greater than 1.
### PgBouncer administrative console
As part of Omnibus GitLab, the `gitlab-ctl pgb-console` command is provided to automatically connect to the PgBouncer administrative console. See the [PgBouncer documentation](https://www.pgbouncer.org/usage.html#admin-console) for detailed instructions on how to interact with the console.
To start a session:
```shell
sudo gitlab-ctl pgb-console
```
The password you will be prompted for is the `pgbouncer_user_password`
To get some basic information about the instance, run
```shell
pgbouncer=# show databases; show clients; show servers;
name | host | port | database | force_user | pool_size | reserve_pool | pool_mode | max_connections | current_connections
---------------------+-----------+------+---------------------+------------+-----------+--------------+-----------+-----------------+---------------------
gitlabhq_production | 127.0.0.1 | 5432 | gitlabhq_production | | 100 | 5 | | 0 | 1
pgbouncer | | 6432 | pgbouncer | pgbouncer | 2 | 0 | statement | 0 | 0
(2 rows)
type | user | database | state | addr | port | local_addr | local_port | connect_time | request_time | ptr | link
| remote_pid | tls
------+-----------+---------------------+--------+-----------+-------+------------+------------+---------------------+---------------------+-----------+------
+------------+-----
C | gitlab | gitlabhq_production | active | 127.0.0.1 | 44590 | 127.0.0.1 | 6432 | 2018-04-24 22:13:10 | 2018-04-24 22:17:10 | 0x12444c0 |
| 0 |
C | gitlab | gitlabhq_production | active | 127.0.0.1 | 44592 | 127.0.0.1 | 6432 | 2018-04-24 22:13:10 | 2018-04-24 22:17:10 | 0x12447c0 |
| 0 |
C | gitlab | gitlabhq_production | active | 127.0.0.1 | 44594 | 127.0.0.1 | 6432 | 2018-04-24 22:13:10 | 2018-04-24 22:17:10 | 0x1244940 |
| 0 |
C | gitlab | gitlabhq_production | active | 127.0.0.1 | 44706 | 127.0.0.1 | 6432 | 2018-04-24 22:14:22 | 2018-04-24 22:16:31 | 0x1244ac0 |
| 0 |
C | gitlab | gitlabhq_production | active | 127.0.0.1 | 44708 | 127.0.0.1 | 6432 | 2018-04-24 22:14:22 | 2018-04-24 22:15:15 | 0x1244c40 |
| 0 |
C | gitlab | gitlabhq_production | active | 127.0.0.1 | 44794 | 127.0.0.1 | 6432 | 2018-04-24 22:15:15 | 2018-04-24 22:15:15 | 0x1244dc0 |
| 0 |
C | gitlab | gitlabhq_production | active | 127.0.0.1 | 44798 | 127.0.0.1 | 6432 | 2018-04-24 22:15:15 | 2018-04-24 22:16:31 | 0x1244f40 |
| 0 |
C | pgbouncer | pgbouncer | active | 127.0.0.1 | 44660 | 127.0.0.1 | 6432 | 2018-04-24 22:13:51 | 2018-04-24 22:17:12 | 0x1244640 |
| 0 |
(8 rows)
type | user | database | state | addr | port | local_addr | local_port | connect_time | request_time | ptr | link | rem
ote_pid | tls
------+--------+---------------------+-------+-----------+------+------------+------------+---------------------+---------------------+-----------+------+----
--------+-----
S | gitlab | gitlabhq_production | idle | 127.0.0.1 | 5432 | 127.0.0.1 | 35646 | 2018-04-24 22:15:15 | 2018-04-24 22:17:10 | 0x124dca0 | |
19980 |
(1 row)
```
### Message: `LOG: invalid CIDR mask in address`
See the suggested fix [in Geo documentation](../geo/replication/troubleshooting.md#message-log--invalid-cidr-mask-in-address).
### Message: `LOG: invalid IP mask "md5": Name or service not known`
See the suggested fix [in Geo documentation](../geo/replication/troubleshooting.md#message-log--invalid-ip-mask-md5-name-or-service-not-known).
## Troubleshooting PostgreSQL
In case you are experiencing any issues connecting through PgBouncer, the first place to check is always the logs:
```shell
sudo gitlab-ctl tail postgresql
```
### Consul and PostgreSQL changes not taking effect
Due to the potential impacts, `gitlab-ctl reconfigure` only reloads Consul and PostgreSQL, it will not restart the services. However, not all changes can be activated by reloading.
To restart either service, run `gitlab-ctl restart SERVICE`
For PostgreSQL, it is usually safe to restart the master node by default. Automatic failover defaults to a 1 minute timeout. Provided the database returns before then, nothing else needs to be done. To be safe, you can stop `repmgrd` on the standby nodes first with `gitlab-ctl stop repmgrd`, then start afterwards with `gitlab-ctl start repmgrd`.
On the Consul server nodes, it is important to restart the Consul service in a controlled fashion. Read our [Consul documentation](../high_availability/consul.md#restarting-the-server-cluster) for instructions on how to restart the service.
### `gitlab-ctl repmgr-check-master` command produces errors
If this command displays errors about database permissions it is likely that something failed during
install, resulting in the `gitlab-consul` database user getting incorrect permissions. Follow these
steps to fix the problem:
1. On the master database node, connect to the database prompt - `gitlab-psql -d template1`
1. Delete the `gitlab-consul` user - `DROP USER "gitlab-consul";`
1. Exit the database prompt - `\q`
1. [Reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) and the user will be re-added with the proper permissions.
1. Change to the `gitlab-consul` user - `su - gitlab-consul`
1. Try the check command again - `gitlab-ctl repmgr-check-master`.
Now there should not be errors. If errors still occur then there is another problem.
### PgBouncer error `ERROR: pgbouncer cannot connect to server`
You may get this error when running `gitlab-rake gitlab:db:configure` or you
may see the error in the PgBouncer log file.
```plaintext
PG::ConnectionBad: ERROR: pgbouncer cannot connect to server
```
The problem may be that your PgBouncer node's IP address is not included in the
`trust_auth_cidr_addresses` setting in `/etc/gitlab/gitlab.rb` on the database nodes.
You can confirm that this is the issue by checking the PostgreSQL log on the master
database node. If you see the following error then `trust_auth_cidr_addresses`
is the problem.
```plaintext
2018-03-29_13:59:12.11776 FATAL: no pg_hba.conf entry for host "123.123.123.123", user "pgbouncer", database "gitlabhq_production", SSL off
```
To fix the problem, add the IP address to `/etc/gitlab/gitlab.rb`.
```ruby
postgresql['trust_auth_cidr_addresses'] = %w(123.123.123.123/32 <other_cidrs>)
```
[Reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.

View file

@ -25,14 +25,13 @@ GET /projects/:id/milestones?search=version
Parameters:
| Attribute | Type | Required | Description |
| ---------------------------- | ------ | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `iids[]` | integer array | optional | Return only the milestones having the given `iid`. Will be ignored if `include_parent_milestones` is set to `true` |
| `state` | string | optional | Return only `active` or `closed` milestones |
| `title` | string | optional | Return only the milestones having the given `title` |
| `search` | string | optional | Return only milestones with a title or description matching the provided string |
| `include_parent_milestones` | boolean | optional | Include milestones from parent group and ancestors. Introduced in [GitLab 13.2](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36944) |
| Attribute | Type | Required | Description |
| --------- | ------ | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `iids[]` | integer array | optional | Return only the milestones having the given `iid` |
| `state` | string | optional | Return only `active` or `closed` milestones |
| `title` | string | optional | Return only the milestones having the given `title` |
| `search` | string | optional | Return only milestones with a title or description matching the provided string |
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/milestones"

View file

@ -3412,6 +3412,10 @@ This keyword allows the creation of two different types of downstream pipelines:
- [Multi-project pipelines](../multi_project_pipelines.md#creating-multi-project-pipelines-from-gitlab-ciyml)
- [Child pipelines](../parent_child_pipelines.md)
[Since GitLab 13.2](https://gitlab.com/gitlab-org/gitlab/-/issues/197140/), you can
see which job triggered a downstream pipeline by hovering your mouse cursor over
the downstream pipeline job in the [pipeline graph](../pipelines/index.md#visualize-pipelines).
NOTE: **Note:**
Using a `trigger` with `when:manual` together results in the error `jobs:#{job-name}
when should be on_success, on_failure or always`, because `when:manual` prevents

View file

@ -316,7 +316,7 @@ We automatically add the ~"Accepting merge requests" label to issues
that match the [triage policy](https://about.gitlab.com/handbook/engineering/quality/triage-operations/#accepting-merge-requests).
We recommend people that have never contributed to any open source project to
look for issues labeled `~"Accepting merge requests"` with a [weight of 1](https://gitlab.com/groups/gitlab-org/-/issues?state=opened&label_name[]=Accepting+merge+requests&assignee_id=None&sort=weight&weight=1).
look for issues labeled `~"Accepting merge requests"` with a [weight of 1](https://gitlab.com/groups/gitlab-org/-/issues?state=opened&label_name[]=Accepting+merge+requests&assignee_id=None&sort=weight&weight=1) or the `~"Good for 1st time contributors"` [label](https://gitlab.com/gitlab-org/gitlab/-/issues?scope=all&utf8=%E2%9C%93&state=opened&label_name[]=Good%20for%201st%20time%20contributors&assignee_id=None) attached to it.
More experienced contributors are very welcome to tackle
[any of them](https://gitlab.com/groups/gitlab-org/-/issues?state=opened&label_name[]=Accepting+merge+requests&assignee_id=None).

View file

@ -64,36 +64,6 @@ the extra jobs will take resources away from jobs from workers that were already
there, if the resources available to the Sidekiq process handling the namespace
are not adjusted appropriately.
## Versioning
Version can be specified on each Sidekiq worker class.
This is then sent along when the job is created.
```ruby
class FooWorker
include ApplicationWorker
version 2
def perform(*args)
if job_version == 2
foo = args.first['foo']
else
foo = args.first
end
end
end
```
Under this schema, any worker is expected to be able to handle any job that was
enqueued by an older version of that worker. This means that when changing the
arguments a worker takes, you must increment the `version` (or set `version 1`
if this is the first time a worker's arguments are changing), but also make sure
that the worker is still able to handle jobs that were queued with any earlier
version of the arguments. From the worker's `perform` method, you can read
`self.job_version` if you want to specifically branch on job version, or you
can read the number or type of provided arguments.
## Idempotent Jobs
It's known that a job can fail for multiple reasons. For example, network outages or bugs.

View file

@ -32,14 +32,21 @@ module API
params do
use :pagination
use :filter_params
optional :page_token, type: String, desc: 'Name of branch to start the paginaition from'
end
get ':id/repository/branches' do
user_project.preload_protected_branches
repository = user_project.repository
branches = BranchesFinder.new(repository, declared_params(include_missing: false)).execute
branches = paginate(::Kaminari.paginate_array(branches))
if Feature.enabled?(:branch_list_keyset_pagination, user_project)
branches = BranchesFinder.new(repository, declared_params(include_missing: false)).execute(gitaly_pagination: true)
else
branches = BranchesFinder.new(repository, declared_params(include_missing: false)).execute
branches = paginate(::Kaminari.paginate_array(branches))
end
merged_branch_names = repository.merged_branch_names(branches.map(&:name))
present(

View file

@ -31,14 +31,12 @@ module API
end
def list_milestones_for(parent)
finder_params = params.merge(milestones_finder_params(parent))
milestones = MilestonesFinder.new(finder_params).execute
milestones = parent.milestones.order_id_desc
milestones = Milestone.filter_by_state(milestones, params[:state])
milestones = filter_by_iid(milestones, params[:iids]) if params[:iids].present?
milestones = filter_by_title(milestones, params[:title]) if params[:title]
milestones = filter_by_search(milestones, params[:search]) if params[:search]
if params[:iids].present? && !params[:include_parent_milestones]
milestones = filter_by_iid(milestones, params[:iids])
end
present paginate(milestones), with: Entities::Milestone
end
@ -98,25 +96,6 @@ module API
[MergeRequestsFinder, Entities::MergeRequestBasic]
end
end
def milestones_finder_params(parent)
if parent.is_a?(Group)
{ group_ids: parent.id }
else
{
project_ids: parent.id,
group_ids: parent_group_ids(parent)
}
end
end
def parent_group_ids(parent)
return unless params[:include_parent_milestones].present?
parent.group.self_and_ancestors
.public_or_visible_to_user(current_user)
.select(:id)
end
end
end
end

View file

@ -16,8 +16,6 @@ module API
end
params do
use :list_params
optional :include_parent_milestones, type: Boolean, default: false,
desc: 'Include milestones from parent group and ancestors'
end
get ":id/milestones" do
authorize! :read_milestone, user_project

View file

@ -127,9 +127,9 @@ module Gitlab
end
end
def local_branches(sort_by: nil)
def local_branches(sort_by: nil, pagination_params: nil)
wrapped_gitaly_errors do
gitaly_ref_client.local_branches(sort_by: sort_by)
gitaly_ref_client.local_branches(sort_by: sort_by, pagination_params: pagination_params)
end
end

View file

@ -110,8 +110,8 @@ module Gitlab
branch_names.count
end
def local_branches(sort_by: nil)
request = Gitaly::FindLocalBranchesRequest.new(repository: @gitaly_repo)
def local_branches(sort_by: nil, pagination_params: nil)
request = Gitaly::FindLocalBranchesRequest.new(repository: @gitaly_repo, pagination_params: pagination_params)
request.sort_by = sort_by_param(sort_by) if sort_by
response = GitalyClient.call(@storage, :ref_service, :find_local_branches, request, timeout: GitalyClient.fast_timeout)
consume_find_local_branches_response(response)

View file

@ -5,10 +5,6 @@ module Gitlab
def self.install!
Sidekiq::Manager.prepend SidekiqVersioning::Manager
Sidekiq.server_middleware do |chain|
chain.add SidekiqVersioning::Middleware
end
# The Sidekiq client API always adds the queue to the Sidekiq queue
# list, but mail_room and gitlab-shell do not. This is only necessary
# for monitoring.

View file

@ -1,13 +0,0 @@
# frozen_string_literal: true
module Gitlab
module SidekiqVersioning
class Middleware
def call(worker, job, queue)
worker.job_version = job['version']
yield
end
end
end
end

View file

@ -1,31 +0,0 @@
# frozen_string_literal: true
module Gitlab
module SidekiqVersioning
module Worker
extend ActiveSupport::Concern
included do
version 0
attr_writer :job_version
end
class_methods do
def version(new_version = nil)
if new_version
sidekiq_options version: new_version.to_i
else
get_sidekiq_options['version']
end
end
end
# Version is not set if `new.perform` is called directly,
# and in that case we fallback to latest version
def job_version
@job_version ||= self.class.version
end
end
end
end

View file

@ -3507,6 +3507,9 @@ msgstr ""
msgid "Available"
msgstr ""
msgid "Available Runners: %{runners}"
msgstr ""
msgid "Available for dependency and container scanning"
msgstr ""
@ -4137,6 +4140,9 @@ msgstr ""
msgid "Can't edit as source branch was deleted"
msgstr ""
msgid "Can't fetch content for the blob: %{err}"
msgstr ""
msgid "Can't find HEAD commit for this branch"
msgstr ""
@ -15333,6 +15339,9 @@ msgstr ""
msgid "Multi-project"
msgstr ""
msgid "Multi-project Runners cannot be removed"
msgstr ""
msgid "Multiple IP address ranges are supported."
msgstr ""
@ -20278,6 +20287,9 @@ msgstr ""
msgid "Runner tokens"
msgstr ""
msgid "Runner was not deleted because it is assigned to multiple projects."
msgstr ""
msgid "Runner was not updated."
msgstr ""
@ -28167,6 +28179,9 @@ msgstr ""
msgid "loading"
msgstr ""
msgid "locked"
msgstr ""
msgid "locked by %{path_lock_user_name} %{created_at}"
msgstr ""
@ -28587,6 +28602,9 @@ msgstr[1] ""
msgid "password"
msgstr ""
msgid "paused"
msgstr ""
msgid "pending comment"
msgstr ""
@ -28758,6 +28776,9 @@ msgstr ""
msgid "source diff"
msgstr ""
msgid "specific"
msgstr ""
msgid "specified top is not part of the tree"
msgstr ""

View file

@ -6,6 +6,9 @@ RSpec.describe Groups::RunnersController do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:runner) { create(:ci_runner, :group, groups: [group]) }
let(:project) { create(:project, group: group) }
let(:runner_project) { create(:ci_runner, :project, projects: [project]) }
let(:params_runner_project) { { group_id: group, id: runner_project } }
let(:params) { { group_id: group, id: runner } }
before do
@ -24,6 +27,13 @@ RSpec.describe Groups::RunnersController do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:show)
end
it 'renders show with 200 status code project runner' do
get :show, params: { group_id: group, id: runner_project }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:show)
end
end
context 'when user is not owner' do
@ -36,6 +46,12 @@ RSpec.describe Groups::RunnersController do
expect(response).to have_gitlab_http_status(:not_found)
end
it 'renders a 404 project runner' do
get :show, params: { group_id: group, id: runner_project }
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
@ -51,6 +67,13 @@ RSpec.describe Groups::RunnersController do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:edit)
end
it 'renders show with 200 status code project runner' do
get :edit, params: { group_id: group, id: runner_project }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:edit)
end
end
context 'when user is not owner' do
@ -63,6 +86,12 @@ RSpec.describe Groups::RunnersController do
expect(response).to have_gitlab_http_status(:not_found)
end
it 'renders a 404 project runner' do
get :edit, params: { group_id: group, id: runner_project }
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
@ -82,6 +111,17 @@ RSpec.describe Groups::RunnersController do
expect(response).to have_gitlab_http_status(:found)
expect(runner.reload.description).to eq(new_desc)
end
it 'updates the project runner, ticks the queue, and redirects project runner' do
new_desc = runner_project.description.swapcase
expect do
post :update, params: params_runner_project.merge(runner: { description: new_desc } )
end.to change { runner_project.ensure_runner_queue_value }
expect(response).to have_gitlab_http_status(:found)
expect(runner_project.reload.description).to eq(new_desc)
end
end
context 'when user is not an owner' do
@ -99,6 +139,17 @@ RSpec.describe Groups::RunnersController do
expect(response).to have_gitlab_http_status(:not_found)
expect(runner.reload.description).to eq(old_desc)
end
it 'rejects the update and responds 404 project runner' do
old_desc = runner_project.description
expect do
post :update, params: params_runner_project.merge(runner: { description: old_desc.swapcase } )
end.not_to change { runner_project.ensure_runner_queue_value }
expect(response).to have_gitlab_http_status(:not_found)
expect(runner_project.reload.description).to eq(old_desc)
end
end
end
@ -114,6 +165,31 @@ RSpec.describe Groups::RunnersController do
expect(response).to have_gitlab_http_status(:found)
expect(Ci::Runner.find_by(id: runner.id)).to be_nil
end
it 'destroys the project runner and redirects' do
delete :destroy, params: params_runner_project
expect(response).to have_gitlab_http_status(:found)
expect(Ci::Runner.find_by(id: runner_project.id)).to be_nil
end
end
context 'when user is an owner and runner in multiple projects' do
let(:project_2) { create(:project, group: group) }
let(:runner_project_2) { create(:ci_runner, :project, projects: [project, project_2]) }
let(:params_runner_project_2) { { group_id: group, id: runner_project_2 } }
before do
group.add_owner(user)
end
it 'does not destroy the project runner' do
delete :destroy, params: params_runner_project_2
expect(response).to have_gitlab_http_status(:found)
expect(flash[:alert]).to eq('Runner was not deleted because it is assigned to multiple projects.')
expect(Ci::Runner.find_by(id: runner_project_2.id)).to be_present
end
end
context 'when user is not an owner' do
@ -127,6 +203,13 @@ RSpec.describe Groups::RunnersController do
expect(response).to have_gitlab_http_status(:not_found)
expect(Ci::Runner.find_by(id: runner.id)).to be_present
end
it 'responds 404 and does not destroy the project runner' do
delete :destroy, params: params_runner_project
expect(response).to have_gitlab_http_status(:not_found)
expect(Ci::Runner.find_by(id: runner_project.id)).to be_present
end
end
end
@ -146,6 +229,17 @@ RSpec.describe Groups::RunnersController do
expect(response).to have_gitlab_http_status(:found)
expect(runner.reload.active).to eq(true)
end
it 'marks the project runner as active, ticks the queue, and redirects' do
runner_project.update(active: false)
expect do
post :resume, params: params_runner_project
end.to change { runner_project.ensure_runner_queue_value }
expect(response).to have_gitlab_http_status(:found)
expect(runner_project.reload.active).to eq(true)
end
end
context 'when user is not an owner' do
@ -163,6 +257,17 @@ RSpec.describe Groups::RunnersController do
expect(response).to have_gitlab_http_status(:not_found)
expect(runner.reload.active).to eq(false)
end
it 'responds 404 and does not activate the project runner' do
runner_project.update(active: false)
expect do
post :resume, params: params_runner_project
end.not_to change { runner_project.ensure_runner_queue_value }
expect(response).to have_gitlab_http_status(:not_found)
expect(runner_project.reload.active).to eq(false)
end
end
end
@ -182,6 +287,17 @@ RSpec.describe Groups::RunnersController do
expect(response).to have_gitlab_http_status(:found)
expect(runner.reload.active).to eq(false)
end
it 'marks the project runner as inactive, ticks the queue, and redirects' do
runner_project.update(active: true)
expect do
post :pause, params: params_runner_project
end.to change { runner_project.ensure_runner_queue_value }
expect(response).to have_gitlab_http_status(:found)
expect(runner_project.reload.active).to eq(false)
end
end
context 'when user is not an owner' do
@ -199,6 +315,17 @@ RSpec.describe Groups::RunnersController do
expect(response).to have_gitlab_http_status(:not_found)
expect(runner.reload.active).to eq(true)
end
it 'responds 404 and does not update the project runner or queue' do
runner_project.update(active: true)
expect do
post :pause, params: params
end.not_to change { runner_project.ensure_runner_queue_value }
expect(response).to have_gitlab_http_status(:not_found)
expect(runner_project.reload.active).to eq(true)
end
end
end
end

View file

@ -5,8 +5,15 @@ require 'spec_helper'
RSpec.describe Groups::Settings::CiCdController do
include ExternalAuthorizationServiceHelpers
let(:group) { create(:group) }
let(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:sub_group) { create(:group, parent: group) }
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:project_2) { create(:project, group: sub_group) }
let_it_be(:runner_group) { create(:ci_runner, :group, groups: [group]) }
let_it_be(:runner_project_1) { create(:ci_runner, :project, projects: [project])}
let_it_be(:runner_project_2) { create(:ci_runner, :project, projects: [project_2])}
let_it_be(:runner_project_3) { create(:ci_runner, :project, projects: [project, project_2])}
before do
sign_in(user)
@ -18,11 +25,12 @@ RSpec.describe Groups::Settings::CiCdController do
group.add_owner(user)
end
it 'renders show with 200 status code' do
it 'renders show with 200 status code and correct runners' do
get :show, params: { group_id: group }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:show)
expect(assigns(:group_runners)).to match_array([runner_group, runner_project_1, runner_project_2, runner_project_3])
end
end
@ -35,6 +43,7 @@ RSpec.describe Groups::Settings::CiCdController do
get :show, params: { group_id: group }
expect(response).to have_gitlab_http_status(:not_found)
expect(assigns(:group_runners)).to be_nil
end
end

View file

@ -270,7 +270,7 @@ RSpec.describe 'Runners' do
it 'there are no runners displayed' do
visit group_settings_ci_cd_path(group)
expect(page).to have_content 'This group does not provide any group Runners yet'
expect(page).to have_content 'No runners found'
end
it 'user can see a link to install runners on kubernetes clusters' do
@ -286,26 +286,26 @@ RSpec.describe 'Runners' do
it 'the runner is visible' do
visit group_settings_ci_cd_path(group)
expect(page).not_to have_content 'This group does not provide any group Runners yet'
expect(page).to have_content 'Available group Runners: 1'
expect(page).not_to have_content 'No runners found'
expect(page).to have_content 'Available Runners: 1'
expect(page).to have_content 'group-runner'
end
it 'user can pause and resume the group runner' do
visit group_settings_ci_cd_path(group)
expect(page).to have_content('Pause')
expect(page).not_to have_content('Resume')
expect(page).to have_link href: pause_group_runner_path(group, runner)
expect(page).not_to have_link href: resume_group_runner_path(group, runner)
click_on 'Pause'
click_link href: pause_group_runner_path(group, runner)
expect(page).not_to have_content('Pause')
expect(page).to have_content('Resume')
expect(page).not_to have_link href: pause_group_runner_path(group, runner)
expect(page).to have_link href: resume_group_runner_path(group, runner)
click_on 'Resume'
click_link href: resume_group_runner_path(group, runner)
expect(page).to have_content('Pause')
expect(page).not_to have_content('Resume')
expect(page).to have_link href: pause_group_runner_path(group, runner)
expect(page).not_to have_link href: resume_group_runner_path(group, runner)
end
it 'user can view runner details' do
@ -321,7 +321,7 @@ RSpec.describe 'Runners' do
it 'user can remove a group runner' do
visit group_settings_ci_cd_path(group)
click_on 'Remove Runner'
all(:link, href: group_runner_path(group, runner))[1].click
expect(page).not_to have_content(runner.display_name)
end
@ -329,7 +329,7 @@ RSpec.describe 'Runners' do
it 'user edits the runner to be protected' do
visit group_settings_ci_cd_path(group)
first('.edit-runner > a').click
click_link href: edit_group_runner_path(group, runner)
expect(page.find_field('runner[access_level]')).not_to be_checked
@ -347,7 +347,7 @@ RSpec.describe 'Runners' do
it 'user edits runner not to run untagged jobs' do
visit group_settings_ci_cd_path(group)
first('.edit-runner > a').click
click_link href: edit_group_runner_path(group, runner)
expect(page.find_field('runner[run_untagged]')).to be_checked
@ -358,5 +358,97 @@ RSpec.describe 'Runners' do
end
end
end
context 'group with a project runner' do
let(:project) { create(:project, group: group) }
let!(:runner) { create(:ci_runner, :project, projects: [project], description: 'project-runner') }
it 'the runner is visible' do
visit group_settings_ci_cd_path(group)
expect(page).not_to have_content 'No runners found'
expect(page).to have_content 'Available Runners: 1'
expect(page).to have_content 'project-runner'
end
it 'user can pause and resume the project runner' do
visit group_settings_ci_cd_path(group)
expect(page).to have_link href: pause_group_runner_path(group, runner)
expect(page).not_to have_link href: resume_group_runner_path(group, runner)
click_link href: pause_group_runner_path(group, runner)
expect(page).not_to have_link href: pause_group_runner_path(group, runner)
expect(page).to have_link href: resume_group_runner_path(group, runner)
click_link href: resume_group_runner_path(group, runner)
expect(page).to have_link href: pause_group_runner_path(group, runner)
expect(page).not_to have_link href: resume_group_runner_path(group, runner)
end
it 'user can view runner details' do
visit group_settings_ci_cd_path(group)
expect(page).to have_content(runner.display_name)
click_on runner.short_sha
expect(page).to have_content(runner.platform)
end
it 'user can remove a project runner' do
visit group_settings_ci_cd_path(group)
all(:link, href: group_runner_path(group, runner))[1].click
expect(page).not_to have_content(runner.display_name)
end
it 'user edits the runner to be protected' do
visit group_settings_ci_cd_path(group)
click_link href: edit_group_runner_path(group, runner)
expect(page.find_field('runner[access_level]')).not_to be_checked
check 'runner_access_level'
click_button 'Save changes'
expect(page).to have_content 'Protected Yes'
end
context 'when a runner has a tag' do
before do
runner.update(tag_list: ['tag'])
end
it 'user edits runner not to run untagged jobs' do
visit group_settings_ci_cd_path(group)
click_link href: edit_group_runner_path(group, runner)
expect(page.find_field('runner[run_untagged]')).to be_checked
uncheck 'runner_run_untagged'
click_button 'Save changes'
expect(page).to have_content 'Can run untagged jobs No'
end
end
end
context 'group with a multi-project runner' do
let(:project) { create(:project, group: group) }
let(:project_2) { create(:project, group: group) }
let!(:runner) { create(:ci_runner, :project, projects: [project, project_2], description: 'group-runner') }
it 'user cannot remove the project runner' do
visit group_settings_ci_cd_path(group)
expect(all(:link, href: group_runner_path(group, runner)).length).to eq(1)
end
end
end
end

View file

@ -7,142 +7,255 @@ RSpec.describe BranchesFinder do
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
let(:branch_finder) { described_class.new(repository, params) }
let(:params) { {} }
describe '#execute' do
subject { branch_finder.execute }
context 'sort only' do
it 'sorts by name' do
branches_finder = described_class.new(repository, {})
context 'by name' do
let(:params) { {} }
result = branches_finder.execute
it 'sorts' do
result = subject
expect(result.first.name).to eq("'test'")
end
it 'sorts by recently_updated' do
branches_finder = described_class.new(repository, { sort: 'updated_desc' })
result = branches_finder.execute
recently_updated_branch = repository.branches.max do |a, b|
repository.commit(a.dereferenced_target).committed_date <=> repository.commit(b.dereferenced_target).committed_date
expect(result.first.name).to eq("'test'")
end
expect(result.first.name).to eq(recently_updated_branch.name)
end
it 'sorts by last_updated' do
branches_finder = described_class.new(repository, { sort: 'updated_asc' })
context 'by recently_updated' do
let(:params) { { sort: 'updated_desc' } }
result = branches_finder.execute
it 'sorts' do
result = subject
expect(result.first.name).to eq('feature')
recently_updated_branch = repository.branches.max do |a, b|
repository.commit(a.dereferenced_target).committed_date <=> repository.commit(b.dereferenced_target).committed_date
end
expect(result.first.name).to eq(recently_updated_branch.name)
end
end
context 'by last_updated' do
let(:params) { { sort: 'updated_asc' } }
it 'sorts' do
result = subject
expect(result.first.name).to eq('feature')
end
end
end
context 'filter only' do
it 'filters branches by name' do
branches_finder = described_class.new(repository, { search: 'fix' })
context 'by name' do
let(:params) { { search: 'fix' } }
result = branches_finder.execute
it 'filters branches' do
result = subject
expect(result.first.name).to eq('fix')
expect(result.count).to eq(1)
expect(result.first.name).to eq('fix')
expect(result.count).to eq(1)
end
end
it 'filters branches by name ignoring letter case' do
branches_finder = described_class.new(repository, { search: 'FiX' })
context 'by name ignoring letter case' do
let(:params) { { search: 'FiX' } }
result = branches_finder.execute
it 'filters branches' do
result = subject
expect(result.first.name).to eq('fix')
expect(result.count).to eq(1)
expect(result.first.name).to eq('fix')
expect(result.count).to eq(1)
end
end
it 'does not find any branch with that name' do
branches_finder = described_class.new(repository, { search: 'random' })
context 'with an unknown name' do
let(:params) { { search: 'random' } }
result = branches_finder.execute
it 'does not find any branch' do
result = subject
expect(result.count).to eq(0)
expect(result.count).to eq(0)
end
end
it 'filters branches by provided names' do
branches_finder = described_class.new(repository, { names: %w[fix csv lfs does-not-exist] })
context 'by provided names' do
let(:params) { { names: %w[fix csv lfs does-not-exist] } }
result = branches_finder.execute
it 'filters branches' do
result = subject
expect(result.count).to eq(3)
expect(result.map(&:name)).to eq(%w{csv fix lfs})
expect(result.count).to eq(3)
expect(result.map(&:name)).to eq(%w{csv fix lfs})
end
end
it 'filters branches by name that begins with' do
params = { search: '^feature_' }
branches_finder = described_class.new(repository, params)
context 'by name that begins with' do
let(:params) { { search: '^feature_' } }
result = branches_finder.execute
it 'filters branches' do
result = subject
expect(result.first.name).to eq('feature_conflict')
expect(result.count).to eq(1)
expect(result.first.name).to eq('feature_conflict')
expect(result.count).to eq(1)
end
end
it 'filters branches by name that ends with' do
params = { search: 'feature$' }
branches_finder = described_class.new(repository, params)
context 'by name that ends with' do
let(:params) { { search: 'feature$' } }
result = branches_finder.execute
it 'filters branches' do
result = subject
expect(result.first.name).to eq('feature')
expect(result.count).to eq(1)
expect(result.first.name).to eq('feature')
expect(result.count).to eq(1)
end
end
it 'filters branches by nonexistent name that begins with' do
params = { search: '^nope' }
branches_finder = described_class.new(repository, params)
context 'by nonexistent name that begins with' do
let(:params) { { search: '^nope' } }
result = branches_finder.execute
it 'filters branches' do
result = subject
expect(result.count).to eq(0)
expect(result.count).to eq(0)
end
end
it 'filters branches by nonexistent name that ends with' do
params = { search: 'nope$' }
branches_finder = described_class.new(repository, params)
context 'by nonexistent name that ends with' do
let(:params) { { search: 'nope$' } }
result = branches_finder.execute
it 'filters branches' do
result = subject
expect(result.count).to eq(0)
expect(result.count).to eq(0)
end
end
end
context 'filter and sort' do
it 'filters branches by name and sorts by recently_updated' do
params = { sort: 'updated_desc', search: 'feat' }
branches_finder = described_class.new(repository, params)
context 'by name and sorts by recently_updated' do
let(:params) { { sort: 'updated_desc', search: 'feat' } }
result = branches_finder.execute
it 'filters branches' do
result = subject
expect(result.first.name).to eq('feature_conflict')
expect(result.count).to eq(2)
expect(result.first.name).to eq('feature_conflict')
expect(result.count).to eq(2)
end
end
it 'filters branches by name and sorts by recently_updated, with exact matches first' do
params = { sort: 'updated_desc', search: 'feature' }
branches_finder = described_class.new(repository, params)
context 'by name and sorts by recently_updated, with exact matches first' do
let(:params) { { sort: 'updated_desc', search: 'feature' } }
result = branches_finder.execute
it 'filters branches' do
result = subject
expect(result.first.name).to eq('feature')
expect(result.second.name).to eq('feature_conflict')
expect(result.count).to eq(2)
expect(result.first.name).to eq('feature')
expect(result.second.name).to eq('feature_conflict')
expect(result.count).to eq(2)
end
end
it 'filters branches by name and sorts by last_updated' do
params = { sort: 'updated_asc', search: 'feature' }
branches_finder = described_class.new(repository, params)
context 'by name and sorts by last_updated' do
let(:params) { { sort: 'updated_asc', search: 'feature' } }
result = branches_finder.execute
it 'filters branches' do
result = subject
expect(result.first.name).to eq('feature')
expect(result.count).to eq(2)
expect(result.first.name).to eq('feature')
expect(result.count).to eq(2)
end
end
end
context 'with gitaly pagination' do
subject { branch_finder.execute(gitaly_pagination: true) }
context 'by page_token and per_page' do
let(:params) { { page_token: 'feature', per_page: 2 } }
it 'filters branches' do
result = subject
expect(result.map(&:name)).to eq(%w(feature_conflict fix))
end
end
context 'by next page_token and per_page' do
let(:params) { { page_token: 'fix', per_page: 2 } }
it 'filters branches' do
result = subject
expect(result.map(&:name)).to eq(%w(flatten-dir gitattributes))
end
end
context 'by per_page only' do
let(:params) { { per_page: 2 } }
it 'filters branches' do
result = subject
expect(result.map(&:name)).to eq(["'test'", '2-mb-file'])
end
end
context 'by page_token only' do
let(:params) { { page_token: 'feature' } }
it 'returns nothing' do
result = subject
expect(result.count).to eq(0)
end
end
context 'pagination and sort' do
context 'by per_page' do
let(:params) { { sort: 'updated_asc', per_page: 5 } }
it 'filters branches' do
result = subject
expect(result.map(&:name)).to eq(%w(feature improve/awesome merge-test markdown feature_conflict))
end
end
context 'by page_token and per_page' do
let(:params) { { sort: 'updated_asc', page_token: 'improve/awesome', per_page: 2 } }
it 'filters branches' do
result = subject
expect(result.map(&:name)).to eq(%w(merge-test markdown))
end
end
end
context 'pagination and names' do
let(:params) { { page_token: 'fix', per_page: 2, names: %w[fix csv lfs does-not-exist] } }
it 'falls back to default execute and ignore paginations' do
result = subject
expect(result.count).to eq(3)
expect(result.map(&:name)).to eq(%w{csv fix lfs})
end
end
context 'pagination and search' do
let(:params) { { page_token: 'feature', per_page: 2, search: '^f' } }
it 'falls back to default execute and ignore paginations' do
result = subject
expect(result.map(&:name)).to eq(%w(feature feature_conflict fix flatten-dir))
end
end
end
end

View file

@ -34,7 +34,13 @@ describe('issue_note', () => {
note,
},
localVue,
stubs: ['note-header', 'user-avatar-link', 'note-actions', 'note-body'],
stubs: [
'note-header',
'user-avatar-link',
'note-actions',
'note-body',
'multiline-comment-form',
],
});
});
@ -77,6 +83,24 @@ describe('issue_note', () => {
});
});
it('should render multiline comment if editing discussion root', () => {
wrapper.setProps({ discussionRoot: true });
wrapper.vm.isEditing = true;
return wrapper.vm.$nextTick().then(() => {
expect(findMultilineComment().exists()).toBe(true);
});
});
it('should not render multiline comment form unless it is the discussion root', () => {
wrapper.setProps({ discussionRoot: false });
wrapper.vm.isEditing = true;
return wrapper.vm.$nextTick().then(() => {
expect(findMultilineComment().exists()).toBe(false);
});
});
it('should not render if has single line comment', () => {
const position = {
line_range: {

View file

@ -1,9 +1,8 @@
import { shallowMount } from '@vue/test-utils';
import axios from '~/lib/utils/axios_utils';
import Flash from '~/flash';
import { GlLoadingIcon } from '@gitlab/ui';
import { joinPaths, redirectTo } from '~/lib/utils/url_utility';
import { redirectTo } from '~/lib/utils/url_utility';
import SnippetEditApp from '~/snippets/components/edit.vue';
import SnippetDescriptionEdit from '~/snippets/components/snippet_description_edit.vue';
@ -16,25 +15,17 @@ import { SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR } from '~/
import UpdateSnippetMutation from '~/snippets/mutations/updateSnippet.mutation.graphql';
import CreateSnippetMutation from '~/snippets/mutations/createSnippet.mutation.graphql';
import AxiosMockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import { ApolloMutation } from 'vue-apollo';
jest.mock('~/lib/utils/url_utility', () => ({
getBaseURL: jest.fn().mockReturnValue('foo/'),
redirectTo: jest.fn().mockName('redirectTo'),
joinPaths: jest
.fn()
.mockName('joinPaths')
.mockReturnValue('contentApiURL'),
}));
jest.mock('~/flash');
let flashSpy;
const contentMock = 'Foo Bar';
const rawPathMock = '/foo/bar';
const rawProjectPathMock = '/project/path';
const newlyEditedSnippetUrl = 'http://foo.bar';
const apiError = { message: 'Ufff' };
@ -43,15 +34,27 @@ const mutationError = 'Bummer';
const attachedFilePath1 = 'foo/bar';
const attachedFilePath2 = 'alpha/beta';
const actionWithContent = {
content: 'Foo Bar',
};
const actionWithoutContent = {
content: '',
};
const defaultProps = {
snippetGid: 'gid://gitlab/PersonalSnippet/42',
markdownPreviewPath: 'http://preview.foo.bar',
markdownDocsPath: 'http://docs.foo.bar',
};
const defaultData = {
blobsActions: {
...actionWithContent,
action: '',
},
};
describe('Snippet Edit app', () => {
let wrapper;
let axiosMock;
const resolveMutate = jest.fn().mockResolvedValue({
data: {
@ -156,18 +159,21 @@ describe('Snippet Edit app', () => {
});
it.each`
title | content | expectation
${''} | ${''} | ${true}
${'foo'} | ${''} | ${true}
${''} | ${'foo'} | ${true}
${'foo'} | ${'bar'} | ${false}
title | blobsActions | expectation
${''} | ${{}} | ${true}
${''} | ${{ actionWithContent }} | ${true}
${''} | ${{ actionWithoutContent }} | ${true}
${'foo'} | ${{}} | ${true}
${'foo'} | ${{ actionWithoutContent }} | ${true}
${'foo'} | ${{ actionWithoutContent, actionWithContent }} | ${true}
${'foo'} | ${{ actionWithContent }} | ${false}
`(
'disables submit button unless both title and content are present',
({ title, content, expectation }) => {
'disables submit button unless both title and content for all blobs are present',
({ title, blobsActions, expectation }) => {
createComponent({
data: {
snippet: { title },
content,
blobsActions,
},
});
const isBtnDisabled = Boolean(findSubmitButton().attributes('disabled'));
@ -192,83 +198,31 @@ describe('Snippet Edit app', () => {
});
describe('functionality', () => {
describe('handling of the data from GraphQL response', () => {
const snippet = {
blob: {
rawPath: rawPathMock,
},
};
const getResSchema = newSnippet => {
return {
data: {
snippets: {
edges: newSnippet ? [] : [snippet],
},
},
describe('form submission handling', () => {
it('does not submit unchanged blobs', () => {
const foo = {
action: '',
};
const bar = {
action: 'update',
};
};
const bootstrapForExistingSnippet = resp => {
createComponent({
data: {
snippet,
blobsActions: {
foo,
bar,
},
},
});
if (resp === 500) {
axiosMock.onGet('contentApiURL').reply(500);
} else {
axiosMock.onGet('contentApiURL').reply(200, contentMock);
}
wrapper.vm.onSnippetFetch(getResSchema());
};
const bootstrapForNewSnippet = () => {
createComponent();
wrapper.vm.onSnippetFetch(getResSchema(true));
};
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
});
afterEach(() => {
axiosMock.restore();
});
it('fetches blob content with the additional query', () => {
bootstrapForExistingSnippet();
clickSubmitBtn();
return waitForPromises().then(() => {
expect(joinPaths).toHaveBeenCalledWith('foo/', rawPathMock);
expect(wrapper.vm.newSnippet).toBe(false);
expect(wrapper.vm.content).toBe(contentMock);
expect(resolveMutate).toHaveBeenCalledWith(
expect.objectContaining({ variables: { input: { files: [bar] } } }),
);
});
});
it('flashes the error message if fetching content fails', () => {
bootstrapForExistingSnippet(500);
return waitForPromises().then(() => {
expect(flashSpy).toHaveBeenCalled();
expect(wrapper.vm.content).toBe('');
});
});
it('does not fetch content for new snippet', () => {
bootstrapForNewSnippet();
return waitForPromises().then(() => {
// we keep using waitForPromises to make sure we do not run failed test
expect(wrapper.vm.newSnippet).toBe(true);
expect(wrapper.vm.content).toBe('');
expect(joinPaths).not.toHaveBeenCalled();
expect(wrapper.vm.snippet).toEqual(wrapper.vm.$options.newSnippetSchema);
});
});
});
describe('form submission handling', () => {
it.each`
newSnippet | projectPath | mutation | mutationName
${true} | ${rawProjectPathMock} | ${CreateSnippetMutation} | ${'CreateSnippetMutation with projectPath'}
@ -279,6 +233,7 @@ describe('Snippet Edit app', () => {
createComponent({
data: {
newSnippet,
...defaultData,
},
props: {
...defaultProps,
@ -307,16 +262,6 @@ describe('Snippet Edit app', () => {
});
});
it('makes sure there are no unsaved changes in the snippet', () => {
createComponent();
clickSubmitBtn();
return waitForPromises().then(() => {
expect(wrapper.vm.originalContent).toBe(wrapper.vm.content);
expect(wrapper.vm.hasChanges()).toBe(false);
});
});
it.each`
newSnippet | projectPath | mutationName
${true} | ${rawProjectPathMock} | ${'CreateSnippetMutation with projectPath'}
@ -434,21 +379,45 @@ describe('Snippet Edit app', () => {
let event;
let returnValueSetter;
beforeEach(() => {
createComponent();
const bootstrap = data => {
createComponent({
data,
});
event = new Event('beforeunload');
returnValueSetter = jest.spyOn(event, 'returnValue', 'set');
};
it('does not prevent page navigation if there are no blobs', () => {
bootstrap();
window.dispatchEvent(event);
expect(returnValueSetter).not.toHaveBeenCalled();
});
it('does not prevent page navigation if there are no changes to the snippet content', () => {
it('does not prevent page navigation if there are no changes to the blobs content', () => {
bootstrap({
blobsActions: {
foo: {
...actionWithContent,
action: '',
},
},
});
window.dispatchEvent(event);
expect(returnValueSetter).not.toHaveBeenCalled();
});
it('prevents page navigation if there are some changes in the snippet content', () => {
wrapper.setData({ content: 'new content' });
bootstrap({
blobsActions: {
foo: {
...actionWithContent,
action: 'update',
},
},
});
window.dispatchEvent(event);

View file

@ -4,78 +4,161 @@ import BlobContentEdit from '~/blob/components/blob_edit_content.vue';
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import AxiosMockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import waitForPromises from 'helpers/wait_for_promises';
jest.mock('~/blob/utils', () => jest.fn());
jest.mock('~/lib/utils/url_utility', () => ({
getBaseURL: jest.fn().mockReturnValue('foo/'),
joinPaths: jest
.fn()
.mockName('joinPaths')
.mockReturnValue('contentApiURL'),
}));
jest.mock('~/flash');
let flashSpy;
describe('Snippet Blob Edit component', () => {
let wrapper;
const value = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
const fileName = 'lorem.txt';
const findHeader = () => wrapper.find(BlobHeaderEdit);
const findContent = () => wrapper.find(BlobContentEdit);
let axiosMock;
const contentMock = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
const pathMock = 'lorem.txt';
const rawPathMock = 'foo/bar';
const blob = {
path: pathMock,
content: contentMock,
rawPath: rawPathMock,
};
const findComponent = component => wrapper.find(component);
function createComponent(props = {}) {
function createComponent(props = {}, data = { isContentLoading: false }) {
wrapper = shallowMount(SnippetBlobEdit, {
propsData: {
value,
fileName,
isLoading: false,
...props,
},
data() {
return {
...data,
};
},
});
flashSpy = jest.spyOn(wrapper.vm, 'flashAPIFailure');
}
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
createComponent();
});
afterEach(() => {
axiosMock.restore();
wrapper.destroy();
});
describe('rendering', () => {
it('matches the snapshot', () => {
createComponent({ blob });
expect(wrapper.element).toMatchSnapshot();
});
it('renders required components', () => {
expect(findHeader().exists()).toBe(true);
expect(findContent().exists()).toBe(true);
expect(findComponent(BlobHeaderEdit).exists()).toBe(true);
expect(findComponent(BlobContentEdit).exists()).toBe(true);
});
it('renders loader if isLoading equals true', () => {
createComponent({ isLoading: true });
it('renders loader if existing blob is supplied but no content is fetched yet', () => {
createComponent({ blob }, { isContentLoading: true });
expect(wrapper.contains(GlLoadingIcon)).toBe(true);
expect(findContent().exists()).toBe(false);
expect(findComponent(BlobContentEdit).exists()).toBe(false);
});
it('does not render loader if when blob is not supplied', () => {
createComponent();
expect(wrapper.contains(GlLoadingIcon)).toBe(false);
expect(findComponent(BlobContentEdit).exists()).toBe(true);
});
});
describe('functionality', () => {
it('does not fail without content', () => {
it('does not fail without blob', () => {
const spy = jest.spyOn(global.console, 'error');
createComponent({ value: undefined });
createComponent({ blob: undefined });
expect(spy).not.toHaveBeenCalled();
expect(findContent().exists()).toBe(true);
expect(findComponent(BlobContentEdit).exists()).toBe(true);
});
it('emits "name-change" event when the file name gets changed', () => {
expect(wrapper.emitted('name-change')).toBeUndefined();
const newFilename = 'foo.bar';
findHeader().vm.$emit('input', newFilename);
it.each`
emitter | prop
${BlobHeaderEdit} | ${'filePath'}
${BlobContentEdit} | ${'content'}
`('emits "blob-updated" event when the $prop gets changed', ({ emitter, prop }) => {
expect(wrapper.emitted('blob-updated')).toBeUndefined();
const newValue = 'foo.bar';
findComponent(emitter).vm.$emit('input', newValue);
return nextTick().then(() => {
expect(wrapper.emitted('name-change')[0]).toEqual([newFilename]);
expect(wrapper.emitted('blob-updated')[0]).toEqual([
expect.objectContaining({
[prop]: newValue,
}),
]);
});
});
it('emits "input" event when the file content gets changed', () => {
expect(wrapper.emitted('input')).toBeUndefined();
const newValue = 'foo.bar';
findContent().vm.$emit('input', newValue);
describe('fetching blob content', () => {
const bootstrapForExistingSnippet = resp => {
createComponent({
blob: {
...blob,
content: '',
},
});
return nextTick().then(() => {
expect(wrapper.emitted('input')[0]).toEqual([newValue]);
if (resp === 500) {
axiosMock.onGet('contentApiURL').reply(500);
} else {
axiosMock.onGet('contentApiURL').reply(200, contentMock);
}
};
const bootstrapForNewSnippet = () => {
createComponent();
};
it('fetches blob content with the additional query', () => {
bootstrapForExistingSnippet();
return waitForPromises().then(() => {
expect(joinPaths).toHaveBeenCalledWith('foo/', rawPathMock);
expect(findComponent(BlobHeaderEdit).props('value')).toBe(pathMock);
expect(findComponent(BlobContentEdit).props('value')).toBe(contentMock);
});
});
it('flashes the error message if fetching content fails', () => {
bootstrapForExistingSnippet(500);
return waitForPromises().then(() => {
expect(flashSpy).toHaveBeenCalled();
expect(findComponent(BlobContentEdit).props('value')).toBe('');
});
});
it('does not fetch content for new snippet', () => {
bootstrapForNewSnippet();
return waitForPromises().then(() => {
// we keep using waitForPromises to make sure we do not run failed test
expect(findComponent(BlobHeaderEdit).props('value')).toBe('');
expect(findComponent(BlobContentEdit).props('value')).toBe('');
expect(joinPaths).not.toHaveBeenCalled();
});
});
});
});

View file

@ -1,38 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::SidekiqVersioning::Middleware do
let(:worker_class) do
Class.new do
def self.name
'DummyWorker'
end
include ApplicationWorker
version 2
end
end
describe '#call' do
let(:worker) { worker_class.new }
let(:job) { { 'version' => 3, 'queue' => queue } }
let(:queue) { worker_class.queue }
def call!(&block)
block ||= -> {}
subject.call(worker, job, queue, &block)
end
it 'sets worker.job_version' do
call!
expect(worker.job_version).to eq(job['version'])
end
it 'yields' do
expect { |b| call!(&b) }.to yield_control
end
end
end

View file

@ -1,54 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::SidekiqVersioning::Worker do
let(:worker) do
Class.new do
def self.name
'DummyWorker'
end
# ApplicationWorker includes Gitlab::SidekiqVersioning::Worker
include ApplicationWorker
version 2
end
end
describe '.version' do
context 'when called with an argument' do
it 'sets the version option' do
worker.version 3
expect(worker.get_sidekiq_options['version']).to eq(3)
end
end
context 'when called without an argument' do
it 'returns the version option' do
worker.sidekiq_options version: 3
expect(worker.version).to eq(3)
end
end
end
describe '#job_version' do
let(:job) { worker.new }
context 'when job_version is not set' do
it 'returns latest version' do
expect(job.job_version).to eq(2)
end
end
context 'when job_version is set' do
it 'returns the set version' do
job.job_version = 0
expect(job.job_version).to eq(0)
end
end
end
end

View file

@ -35,12 +35,6 @@ RSpec.describe Gitlab::SidekiqVersioning, :redis do
expect(Sidekiq::Manager).to include(Gitlab::SidekiqVersioning::Manager)
end
it 'adds the SidekiqVersioning::Middleware Sidekiq server middleware' do
described_class.install!
expect(Sidekiq.server_middleware.entries.map(&:klass)).to include(Gitlab::SidekiqVersioning::Middleware)
end
it 'registers all versionless and versioned queues with Redis' do
described_class.install!

View file

@ -713,6 +713,46 @@ RSpec.describe Ci::Runner do
end
end
describe '#belongs_to_more_than_one_project?' do
context 'project runner' do
let(:project1) { create(:project) }
let(:project2) { create(:project) }
context 'two projects assigned to runner' do
let(:runner) { create(:ci_runner, :project, projects: [project1, project2]) }
it 'returns true' do
expect(runner.belongs_to_more_than_one_project?).to be_truthy
end
end
context 'one project assigned to runner' do
let(:runner) { create(:ci_runner, :project, projects: [project1]) }
it 'returns false' do
expect(runner.belongs_to_more_than_one_project?).to be_falsey
end
end
end
context 'group runner' do
let(:group) { create(:group) }
let(:runner) { create(:ci_runner, :group, groups: [group]) }
it 'returns false' do
expect(runner.belongs_to_more_than_one_project?).to be_falsey
end
end
context 'shared runner' do
let(:runner) { create(:ci_runner, :instance) }
it 'returns false' do
expect(runner.belongs_to_more_than_one_project?).to be_falsey
end
end
end
describe '#has_tags?' do
context 'when runner has tags' do
subject { create(:ci_runner, tag_list: ['tag']) }

View file

@ -17,6 +17,7 @@ RSpec.describe API::Branches do
before do
project.add_maintainer(user)
project.repository.add_branch(user, 'ends-with.txt', branch_sha)
stub_feature_flags(branch_list_keyset_pagination: false)
end
describe "GET /projects/:id/repository/branches" do
@ -29,16 +30,6 @@ RSpec.describe API::Branches do
end
end
it 'returns the repository branches' do
get api(route, current_user), params: { per_page: 100 }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/branches')
expect(response).to include_pagination_headers
branch_names = json_response.map { |x| x['name'] }
expect(branch_names).to match_array(project.repository.branch_names)
end
def check_merge_status(json_response)
merged, unmerged = json_response.partition { |branch| branch['merged'] }
merged_branches = merged.map { |branch| branch['name'] }
@ -47,22 +38,107 @@ RSpec.describe API::Branches do
expect(project.repository.merged_branch_names(unmerged_branches)).to be_empty
end
it 'determines only a limited number of merged branch names' do
expect(API::Entities::Branch).to receive(:represent).with(anything, has_up_to_merged_branch_names_count(2)).and_call_original
context 'with branch_list_keyset_pagination feature off' do
context 'with legacy pagination params' do
it 'returns the repository branches' do
get api(route, current_user), params: { per_page: 100 }
get api(route, current_user), params: { per_page: 2 }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/branches')
expect(response).to include_pagination_headers
branch_names = json_response.map { |x| x['name'] }
expect(branch_names).to match_array(project.repository.branch_names)
end
expect(response).to have_gitlab_http_status(:ok)
it 'determines only a limited number of merged branch names' do
expect(API::Entities::Branch).to receive(:represent).with(anything, has_up_to_merged_branch_names_count(2)).and_call_original
check_merge_status(json_response)
get api(route, current_user), params: { per_page: 2 }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.count).to eq 2
check_merge_status(json_response)
end
it 'merge status matches reality on paginated input' do
expected_first_branch_name = project.repository.branches_sorted_by('name')[20].name
get api(route, current_user), params: { per_page: 20, page: 2 }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.count).to eq 20
expect(json_response.first['name']).to eq(expected_first_branch_name)
check_merge_status(json_response)
end
end
context 'with gitaly pagination params ' do
it 'merge status matches reality on paginated input' do
expected_first_branch_name = project.repository.branches_sorted_by('name').first.name
get api(route, current_user), params: { per_page: 20, page_token: 'feature' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.count).to eq 20
expect(json_response.first['name']).to eq(expected_first_branch_name)
check_merge_status(json_response)
end
end
end
it 'merge status matches reality on paginated input' do
get api(route, current_user), params: { per_page: 20, page: 2 }
context 'with branch_list_keyset_pagination feature on' do
before do
stub_feature_flags(branch_list_keyset_pagination: true)
end
expect(response).to have_gitlab_http_status(:ok)
context 'with gitaly pagination params ' do
it 'returns the repository branches' do
get api(route, current_user), params: { per_page: 100 }
check_merge_status(json_response)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/branches')
branch_names = json_response.map { |x| x['name'] }
expect(branch_names).to match_array(project.repository.branch_names)
end
it 'determines only a limited number of merged branch names' do
expect(API::Entities::Branch).to receive(:represent).with(anything, has_up_to_merged_branch_names_count(2)).and_call_original
get api(route, current_user), params: { per_page: 2 }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.count).to eq 2
check_merge_status(json_response)
end
it 'merge status matches reality on paginated input' do
expected_first_branch_name = project.repository.branches_sorted_by('name').drop_while { |b| b.name <= 'feature' }.first.name
get api(route, current_user), params: { per_page: 20, page_token: 'feature' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.count).to eq 20
expect(json_response.first['name']).to eq(expected_first_branch_name)
check_merge_status(json_response)
end
end
context 'with legacy pagination params' do
it 'ignores legacy pagination params' do
expected_first_branch_name = project.repository.branches_sorted_by('name').first.name
get api(route, current_user), params: { per_page: 20, page: 2 }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.first['name']).to eq(expected_first_branch_name)
check_merge_status(json_response)
end
end
end
context 'when repository is disabled' do

View file

@ -3,10 +3,10 @@
require 'spec_helper'
RSpec.describe API::ProjectMilestones do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, namespace: user.namespace ) }
let_it_be(:closed_milestone) { create(:closed_milestone, project: project, title: 'version1', description: 'closed milestone') }
let_it_be(:milestone) { create(:milestone, project: project, title: 'version2', description: 'open milestone') }
let(:user) { create(:user) }
let!(:project) { create(:project, namespace: user.namespace ) }
let!(:closed_milestone) { create(:closed_milestone, project: project, title: 'version1', description: 'closed milestone') }
let!(:milestone) { create(:milestone, project: project, title: 'version2', description: 'open milestone') }
before do
project.add_developer(user)
@ -16,65 +16,6 @@ RSpec.describe API::ProjectMilestones do
let(:route) { "/projects/#{project.id}/milestones" }
end
describe 'GET /projects/:id/milestones' do
context 'when include_parent_milestones is true' do
let_it_be(:group) { create(:group, :public) }
let_it_be(:child_group) { create(:group, :public, parent: group) }
let_it_be(:child_project) { create(:project, group: child_group) }
let_it_be(:project_milestone) { create(:milestone, project: child_project) }
let_it_be(:group_milestone) { create(:milestone, group: group) }
let_it_be(:child_group_milestone) { create(:milestone, group: child_group) }
before do
child_project.add_developer(user)
end
it 'includes parent groups milestones' do
milestones = [child_group_milestone, group_milestone, project_milestone]
get api("/projects/#{child_project.id}/milestones", user),
params: { include_parent_milestones: true }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.size).to eq(3)
expect(json_response.map { |entry| entry["id"] }).to eq(milestones.map(&:id))
end
context 'when user has no access to an ancestor group' do
before do
[child_group, group].each do |group|
group.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
end
end
it 'does not show ancestor group milestones' do
milestones = [child_group_milestone, project_milestone]
get api("/projects/#{child_project.id}/milestones", user),
params: { include_parent_milestones: true }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.size).to eq(2)
expect(json_response.map { |entry| entry["id"] }).to eq(milestones.map(&:id))
end
end
context 'when filtering by iids' do
it 'does not filter by iids' do
milestones = [child_group_milestone, group_milestone, project_milestone]
get api("/projects/#{child_project.id}/milestones", user),
params: { include_parent_milestones: true, iids: [group_milestone.iid] }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.size).to eq(3)
expect(json_response.map { |entry| entry["id"] }).to eq(milestones.map(&:id))
end
end
end
end
describe 'DELETE /projects/:id/milestones/:milestone_id' do
let(:guest) { create(:user) }
let(:reporter) { create(:user) }