Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
6110935892
commit
fe29f106cd
92 changed files with 3399 additions and 793 deletions
|
@ -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 }}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 [];
|
||||
|
|
|
@ -4,4 +4,5 @@ export const FILTERED_SEARCH = {
|
|||
MERGE_REQUESTS: 'merge_requests',
|
||||
ISSUES: 'issues',
|
||||
ADMIN_RUNNERS: 'admin/runners',
|
||||
GROUP_RUNNERS_ANCHOR: 'runners-settings',
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -26,21 +26,6 @@ fragment SnippetBase on Snippet {
|
|||
...BlobViewer
|
||||
}
|
||||
}
|
||||
blob {
|
||||
binary
|
||||
name
|
||||
path
|
||||
rawPath
|
||||
size
|
||||
externalStorage
|
||||
renderedAsText
|
||||
simpleViewer {
|
||||
...BlobViewer
|
||||
}
|
||||
richViewer {
|
||||
...BlobViewer
|
||||
}
|
||||
}
|
||||
userPermissions {
|
||||
adminSnippet
|
||||
updateSnippet
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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; }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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' }
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -26,6 +26,6 @@
|
|||
|
||||
= 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'
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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') }
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -20,6 +20,6 @@
|
|||
= link_to_label(label, type: :merge_request) { _('Merge requests') }
|
||||
- if force_priority
|
||||
·
|
||||
%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
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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' }
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
title: Add include_parent_milestones param to milestones API
|
||||
merge_request: 36944
|
||||
author:
|
||||
type: added
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Use native Gitaly pagination for Branch list API
|
||||
merge_request: 35819
|
||||
author:
|
||||
type: changed
|
5
changelogs/unreleased/220785-snippet-editing-multi.yml
Normal file
5
changelogs/unreleased/220785-snippet-editing-multi.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Support multiple files when editing snippets
|
||||
merge_request: 37079
|
||||
author:
|
||||
type: changed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Fix showing MLC form on replies
|
||||
merge_request: 37139
|
||||
author:
|
||||
type: fixed
|
|
@ -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
|
||||
|
|
|
@ -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 Gitaly’s 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.
|
||||
|
|
|
@ -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
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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).
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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!
|
||||
|
||||
|
|
|
@ -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']) }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) }
|
||||
|
|
Loading…
Reference in a new issue