Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
08e3d71512
commit
4b41b57abf
|
@ -23,6 +23,7 @@ import initNotesApp from '~/notes';
|
|||
import { store } from '~/notes/stores';
|
||||
import ZenMode from '~/zen_mode';
|
||||
import initAwardsApp from '~/emoji/awards_app';
|
||||
import initLinkedResources from '~/linked_resources';
|
||||
import FilteredSearchServiceDesk from './filtered_search_service_desk';
|
||||
|
||||
export function initFilteredSearchServiceDesk() {
|
||||
|
@ -59,6 +60,7 @@ export function initShow() {
|
|||
if (issueType === IssueType.Incident) {
|
||||
initIncidentApp({ ...issuableData, issuableId: el.dataset.issuableId });
|
||||
initHeaderActions(store, IssueType.Incident);
|
||||
initLinkedResources();
|
||||
initRelatedIssues(IssueType.Incident);
|
||||
} else {
|
||||
initIssueApp(issuableData, store);
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
<script>
|
||||
import { GlLink, GlIcon, GlButton } from '@gitlab/ui';
|
||||
import {
|
||||
LINKED_RESOURCES_HEADER_TEXT,
|
||||
LINKED_RESOURCES_HELP_TEXT,
|
||||
LINKED_RESOURCES_ADD_BUTTON_TEXT,
|
||||
} from '../constants';
|
||||
|
||||
export default {
|
||||
name: 'ResourceLinksBlock',
|
||||
components: {
|
||||
GlLink,
|
||||
GlButton,
|
||||
GlIcon,
|
||||
},
|
||||
props: {
|
||||
helpPath: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
canAddResourceLinks: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
helpLinkText() {
|
||||
return LINKED_RESOURCES_HELP_TEXT;
|
||||
},
|
||||
badgeLabel() {
|
||||
return 0;
|
||||
},
|
||||
resourceLinkAddButtonText() {
|
||||
return LINKED_RESOURCES_ADD_BUTTON_TEXT;
|
||||
},
|
||||
resourceLinkHeaderText() {
|
||||
return LINKED_RESOURCES_HEADER_TEXT;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="resource-links" class="gl-mt-5">
|
||||
<div class="card card-slim gl-overflow-hidden">
|
||||
<div
|
||||
:class="{ 'panel-empty-heading border-bottom-0': true }"
|
||||
class="card-header gl-display-flex gl-justify-content-space-between"
|
||||
>
|
||||
<h3
|
||||
class="card-title h5 position-relative gl-my-0 gl-display-flex gl-align-items-center gl-h-7"
|
||||
>
|
||||
<gl-link
|
||||
id="user-content-resource-links"
|
||||
class="anchor position-absolute gl-text-decoration-none"
|
||||
href="#resource-links"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<slot name="header-text">{{ resourceLinkHeaderText }}</slot>
|
||||
<gl-link
|
||||
:href="helpPath"
|
||||
target="_blank"
|
||||
class="gl-display-flex gl-align-items-center gl-ml-2 gl-text-gray-500"
|
||||
data-testid="help-link"
|
||||
:aria-label="helpLinkText"
|
||||
>
|
||||
<gl-icon name="question" :size="12" />
|
||||
</gl-link>
|
||||
|
||||
<div class="gl-display-inline-flex">
|
||||
<div class="gl-display-inline-flex gl-mx-5">
|
||||
<span class="gl-display-inline-flex gl-align-items-center">
|
||||
<gl-icon name="link" class="gl-mr-2 gl-text-gray-500" />
|
||||
{{ badgeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<gl-button
|
||||
v-if="canAddResourceLinks"
|
||||
icon="plus"
|
||||
:aria-label="resourceLinkAddButtonText"
|
||||
/>
|
||||
</div>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,5 @@
|
|||
import { __ } from '~/locale';
|
||||
|
||||
export const LINKED_RESOURCES_HEADER_TEXT = __('Linked resources');
|
||||
export const LINKED_RESOURCES_HELP_TEXT = __('Read more about linked resources');
|
||||
export const LINKED_RESOURCES_ADD_BUTTON_TEXT = __('Add a resource link');
|
|
@ -0,0 +1,28 @@
|
|||
import Vue from 'vue';
|
||||
import { parseBoolean } from '~/lib/utils/common_utils';
|
||||
import ResourceLinksBlock from './components/resource_links_block.vue';
|
||||
|
||||
export default function initLinkedResources() {
|
||||
const linkedResourcesRootElement = document.querySelector('.js-linked-resources-root');
|
||||
|
||||
if (linkedResourcesRootElement) {
|
||||
const { issuableId, canAddResourceLinks, helpPath } = linkedResourcesRootElement.dataset;
|
||||
|
||||
// eslint-disable-next-line no-new
|
||||
new Vue({
|
||||
el: linkedResourcesRootElement,
|
||||
name: 'LinkedResourcesRoot',
|
||||
components: {
|
||||
resourceLinksBlock: ResourceLinksBlock,
|
||||
},
|
||||
render: (createElement) =>
|
||||
createElement('resource-links-block', {
|
||||
props: {
|
||||
issuableId,
|
||||
helpPath,
|
||||
canAddResourceLinks: parseBoolean(canAddResourceLinks),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,10 +1,13 @@
|
|||
<script>
|
||||
import {
|
||||
GlButton,
|
||||
GlToast,
|
||||
GlModal,
|
||||
GlTooltipDirective,
|
||||
GlIcon,
|
||||
GlFormCheckbox,
|
||||
GlFormInput,
|
||||
GlFormInputGroup,
|
||||
GlDropdown,
|
||||
GlDropdownItem,
|
||||
GlSafeHtmlDirective,
|
||||
|
@ -38,9 +41,12 @@ const statusTimeRanges = [
|
|||
|
||||
export default {
|
||||
components: {
|
||||
GlButton,
|
||||
GlIcon,
|
||||
GlModal,
|
||||
GlFormCheckbox,
|
||||
GlFormInput,
|
||||
GlFormInputGroup,
|
||||
GlDropdown,
|
||||
GlDropdownItem,
|
||||
EmojiPicker: () => import('~/emoji/components/picker.vue'),
|
||||
|
@ -215,97 +221,80 @@ export default {
|
|||
@primary="setStatus"
|
||||
@secondary="removeStatus"
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
v-model="emoji"
|
||||
class="js-status-emoji-field"
|
||||
type="hidden"
|
||||
name="user[status][emoji]"
|
||||
<input v-model="emoji" class="js-status-emoji-field" type="hidden" name="user[status][emoji]" />
|
||||
<gl-form-input-group class="gl-mb-5">
|
||||
<gl-form-input
|
||||
ref="statusMessageField"
|
||||
v-model="message"
|
||||
:placeholder="s__(`SetStatusModal|What's your status?`)"
|
||||
class="js-status-message-field"
|
||||
name="user[status][message]"
|
||||
@keyup="setDefaultEmoji"
|
||||
@keyup.enter.prevent
|
||||
/>
|
||||
<div ref="userStatusForm" class="form-group position-relative m-0">
|
||||
<div class="input-group gl-mb-5">
|
||||
<span class="input-group-prepend">
|
||||
<emoji-picker
|
||||
dropdown-class="gl-h-full"
|
||||
toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
|
||||
boundary="viewport"
|
||||
:right="false"
|
||||
@click="setEmoji"
|
||||
<template #prepend>
|
||||
<emoji-picker
|
||||
dropdown-class="gl-h-full"
|
||||
toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
|
||||
boundary="viewport"
|
||||
:right="false"
|
||||
@click="setEmoji"
|
||||
>
|
||||
<template #button-content>
|
||||
<span v-safe-html:[$options.safeHtmlConfig]="emojiTag"></span>
|
||||
<span
|
||||
v-show="noEmoji"
|
||||
class="js-no-emoji-placeholder no-emoji-placeholder position-relative"
|
||||
>
|
||||
<template #button-content>
|
||||
<span v-safe-html:[$options.safeHtmlConfig]="emojiTag"></span>
|
||||
<span
|
||||
v-show="noEmoji"
|
||||
class="js-no-emoji-placeholder no-emoji-placeholder position-relative"
|
||||
>
|
||||
<gl-icon name="slight-smile" class="award-control-icon-neutral" />
|
||||
<gl-icon name="smiley" class="award-control-icon-positive" />
|
||||
<gl-icon name="smile" class="award-control-icon-super-positive" />
|
||||
</span>
|
||||
</template>
|
||||
</emoji-picker>
|
||||
</span>
|
||||
<input
|
||||
ref="statusMessageField"
|
||||
v-model="message"
|
||||
:placeholder="s__('SetStatusModal|What\'s your status?')"
|
||||
type="text"
|
||||
class="form-control form-control input-lg js-status-message-field"
|
||||
name="user[status][message]"
|
||||
@keyup="setDefaultEmoji"
|
||||
@keyup.enter.prevent
|
||||
/>
|
||||
<span v-show="isDirty" class="input-group-append">
|
||||
<button
|
||||
v-gl-tooltip.bottom
|
||||
:title="s__('SetStatusModal|Clear status')"
|
||||
:aria-label="s__('SetStatusModal|Clear status')"
|
||||
name="button"
|
||||
type="button"
|
||||
class="js-clear-user-status-button clear-user-status btn"
|
||||
@click="clearStatusInputs()"
|
||||
>
|
||||
<gl-icon name="close" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="gl-display-flex">
|
||||
<gl-form-checkbox
|
||||
v-model="availability"
|
||||
data-testid="user-availability-checkbox"
|
||||
class="gl-mb-0"
|
||||
>
|
||||
<span class="gl-font-weight-bold">{{ s__('SetStatusModal|Busy') }}</span>
|
||||
</gl-form-checkbox>
|
||||
</div>
|
||||
<div class="gl-display-flex">
|
||||
<span class="gl-text-gray-600 gl-ml-5">
|
||||
{{ s__('SetStatusModal|An indicator appears next to your name and avatar') }}
|
||||
<gl-icon name="slight-smile" class="award-control-icon-neutral" />
|
||||
<gl-icon name="smiley" class="award-control-icon-positive" />
|
||||
<gl-icon name="smile" class="award-control-icon-super-positive" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="gl-display-flex gl-align-items-baseline">
|
||||
<span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span>
|
||||
<gl-dropdown :text="clearStatusAfter" data-testid="clear-status-at-dropdown">
|
||||
<gl-dropdown-item
|
||||
v-for="after in $options.statusTimeRanges"
|
||||
:key="after.name"
|
||||
:data-testid="after.name"
|
||||
@click="setClearStatusAfter(after.label)"
|
||||
>{{ after.label }}</gl-dropdown-item
|
||||
>
|
||||
</gl-dropdown>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentClearStatusAfter.length"
|
||||
class="gl-mt-3 gl-text-gray-400 gl-font-sm"
|
||||
data-testid="clear-status-at-message"
|
||||
</template>
|
||||
</emoji-picker>
|
||||
</template>
|
||||
<template v-if="isDirty" #append>
|
||||
<gl-button
|
||||
v-gl-tooltip.bottom
|
||||
:title="s__('SetStatusModal|Clear status')"
|
||||
:aria-label="s__('SetStatusModal|Clear status')"
|
||||
icon="close"
|
||||
class="js-clear-user-status-button"
|
||||
@click="clearStatusInputs"
|
||||
/>
|
||||
</template>
|
||||
</gl-form-input-group>
|
||||
|
||||
<gl-form-checkbox
|
||||
v-model="availability"
|
||||
class="gl-mb-5"
|
||||
data-testid="user-availability-checkbox"
|
||||
>
|
||||
{{ s__('SetStatusModal|Busy') }}
|
||||
<template #help>
|
||||
{{ s__('SetStatusModal|An indicator appears next to your name and avatar') }}
|
||||
</template>
|
||||
</gl-form-checkbox>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="gl-display-flex gl-align-items-baseline">
|
||||
<span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span>
|
||||
<gl-dropdown :text="clearStatusAfter" data-testid="clear-status-at-dropdown">
|
||||
<gl-dropdown-item
|
||||
v-for="after in $options.statusTimeRanges"
|
||||
:key="after.name"
|
||||
:data-testid="after.name"
|
||||
@click="setClearStatusAfter(after.label)"
|
||||
>{{ after.label }}</gl-dropdown-item
|
||||
>
|
||||
{{ clearStatusAfterMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</gl-dropdown>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentClearStatusAfter.length"
|
||||
class="gl-mt-3 gl-text-gray-400 gl-font-sm"
|
||||
data-testid="clear-status-at-message"
|
||||
>
|
||||
{{ clearStatusAfterMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</gl-modal>
|
||||
|
|
|
@ -77,6 +77,9 @@ export default {
|
|||
isLoading() {
|
||||
return this.$apollo.queries.children.loading;
|
||||
},
|
||||
childrenIds() {
|
||||
return this.children.map((c) => c.id);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
badgeVariant(state) {
|
||||
|
@ -88,13 +91,16 @@ export default {
|
|||
toggleAddForm() {
|
||||
this.isShownAddForm = !this.isShownAddForm;
|
||||
},
|
||||
addChild(child) {
|
||||
this.children = [child, ...this.children];
|
||||
},
|
||||
},
|
||||
i18n: {
|
||||
title: s__('WorkItem|Child items'),
|
||||
emptyStateMessage: s__(
|
||||
'WorkItem|No child items are currently assigned. Use child items to prioritize tasks that your team should complete in order to accomplish your goals!',
|
||||
),
|
||||
addChildButtonLabel: s__('WorkItem|Add a child'),
|
||||
addChildButtonLabel: s__('WorkItem|Add a task'),
|
||||
},
|
||||
WIDGET_TYPE_TASK_ICON: WIDGET_ICONS.TASK,
|
||||
WORK_ITEM_STATUS_TEXT,
|
||||
|
@ -107,8 +113,16 @@ export default {
|
|||
class="gl-p-4 gl-display-flex gl-justify-content-space-between"
|
||||
:class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }"
|
||||
>
|
||||
<h5 class="gl-m-0 gl-line-height-32">{{ $options.i18n.title }}</h5>
|
||||
<div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-50 gl-pl-4">
|
||||
<h5 class="gl-m-0 gl-line-height-32 gl-flex-grow-1">{{ $options.i18n.title }}</h5>
|
||||
<gl-button
|
||||
v-if="!isShownAddForm"
|
||||
category="secondary"
|
||||
data-testid="toggle-add-form"
|
||||
@click="toggleAddForm"
|
||||
>
|
||||
{{ $options.i18n.addChildButtonLabel }}
|
||||
</gl-button>
|
||||
<div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-50 gl-pl-4 gl-ml-3">
|
||||
<gl-button
|
||||
category="tertiary"
|
||||
:icon="toggleIcon"
|
||||
|
@ -126,21 +140,19 @@ export default {
|
|||
<gl-loading-icon v-if="isLoading" color="dark" class="gl-my-3" />
|
||||
|
||||
<template v-else>
|
||||
<div v-if="isChildrenEmpty" class="gl-px-8" data-testid="links-empty">
|
||||
<p>
|
||||
<div v-if="isChildrenEmpty && !isShownAddForm" data-testid="links-empty">
|
||||
<p class="gl-my-3">
|
||||
{{ $options.i18n.emptyStateMessage }}
|
||||
</p>
|
||||
<gl-button
|
||||
v-if="!isShownAddForm"
|
||||
category="secondary"
|
||||
variant="confirm"
|
||||
data-testid="toggle-add-form"
|
||||
@click="toggleAddForm"
|
||||
>
|
||||
{{ $options.i18n.addChildButtonLabel }}
|
||||
</gl-button>
|
||||
<work-item-links-form v-else data-testid="add-links-form" @cancel="toggleAddForm" />
|
||||
</div>
|
||||
<work-item-links-form
|
||||
v-if="isShownAddForm"
|
||||
data-testid="add-links-form"
|
||||
:issuable-gid="issuableGid"
|
||||
:children-ids="childrenIds"
|
||||
@cancel="toggleAddForm"
|
||||
@addWorkItemChild="addChild"
|
||||
/>
|
||||
<div
|
||||
v-for="child in children"
|
||||
:key="child.id"
|
||||
|
|
|
@ -1,48 +1,107 @@
|
|||
<script>
|
||||
import { GlForm, GlFormCombobox, GlButton } from '@gitlab/ui';
|
||||
import { GlAlert, GlForm, GlFormCombobox, GlButton } from '@gitlab/ui';
|
||||
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
|
||||
import { __ } from '~/locale';
|
||||
import { __, s__ } from '~/locale';
|
||||
import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql';
|
||||
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlAlert,
|
||||
GlForm,
|
||||
GlFormCombobox,
|
||||
GlButton,
|
||||
},
|
||||
inject: ['projectPath'],
|
||||
props: {
|
||||
issuableGid: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
childrenIds: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
apollo: {
|
||||
availableWorkItems: {
|
||||
query: projectWorkItemsQuery,
|
||||
variables() {
|
||||
return {
|
||||
projectPath: this.projectPath,
|
||||
searchTerm: this.search,
|
||||
searchTerm: this.search?.title || this.search,
|
||||
types: ['TASK'],
|
||||
};
|
||||
},
|
||||
skip() {
|
||||
return this.search.length === 0;
|
||||
},
|
||||
update(data) {
|
||||
return data.workspace.workItems.edges.map((wi) => wi.node);
|
||||
return data.workspace.workItems.edges
|
||||
.filter((wi) => !this.childrenIds.includes(wi.node.id))
|
||||
.map((wi) => wi.node);
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
relatedWorkItem: '',
|
||||
availableWorkItems: [],
|
||||
search: '',
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
getIdFromGraphQLId,
|
||||
unsetError() {
|
||||
this.error = null;
|
||||
},
|
||||
addChild() {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: updateWorkItemMutation,
|
||||
variables: {
|
||||
input: {
|
||||
id: this.issuableGid,
|
||||
hierarchyWidget: {
|
||||
childrenIds: [this.search.id],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(({ data }) => {
|
||||
if (data.workItemUpdate?.errors?.length) {
|
||||
[this.error] = data.workItemUpdate.errors;
|
||||
} else {
|
||||
this.unsetError();
|
||||
this.$emit('addWorkItemChild', this.search);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.error = this.$options.i18n.errorMessage;
|
||||
})
|
||||
.finally(() => {
|
||||
this.search = '';
|
||||
});
|
||||
},
|
||||
},
|
||||
i18n: {
|
||||
inputLabel: __('Children'),
|
||||
errorMessage: s__(
|
||||
'WorkItem|Something went wrong when trying to add a child. Please try again.',
|
||||
),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<gl-form @submit.prevent>
|
||||
<gl-form
|
||||
class="gl-mb-3 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base"
|
||||
>
|
||||
<gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError">
|
||||
{{ error }}
|
||||
</gl-alert>
|
||||
<gl-form-combobox
|
||||
v-model="search"
|
||||
:token-list="availableWorkItems"
|
||||
|
@ -59,10 +118,10 @@ export default {
|
|||
</div>
|
||||
</template>
|
||||
</gl-form-combobox>
|
||||
<gl-button type="submit" category="secondary" variant="confirm">
|
||||
{{ s__('WorkItem|Add') }}
|
||||
<gl-button category="secondary" data-testid="add-child-button" @click="addChild">
|
||||
{{ s__('WorkItem|Add task') }}
|
||||
</gl-button>
|
||||
<gl-button category="tertiary" class="gl-float-right" @click="$emit('cancel')">
|
||||
<gl-button category="tertiary" @click="$emit('cancel')">
|
||||
{{ s__('WorkItem|Cancel') }}
|
||||
</gl-button>
|
||||
</gl-form>
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
query projectWorkItems($searchTerm: String, $projectPath: ID!) {
|
||||
query projectWorkItems($searchTerm: String, $projectPath: ID!, $types: [IssueType!]) {
|
||||
workspace: project(fullPath: $projectPath) {
|
||||
id
|
||||
workItems(search: $searchTerm) {
|
||||
workItems(search: $searchTerm, types: $types) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,5 +5,6 @@ mutation workItemUpdate($input: WorkItemUpdateInput!) {
|
|||
workItem {
|
||||
...WorkItem
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,6 +35,13 @@ fragment WorkItem on WorkItem {
|
|||
iid
|
||||
title
|
||||
}
|
||||
children {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -506,8 +506,7 @@
|
|||
max-width: unset;
|
||||
}
|
||||
|
||||
.no-emoji-placeholder,
|
||||
.clear-user-status {
|
||||
.no-emoji-placeholder {
|
||||
svg {
|
||||
fill: var(--gray-500, $gray-500);
|
||||
}
|
||||
|
|
|
@ -44,20 +44,18 @@
|
|||
%p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.")
|
||||
.col-lg-8
|
||||
= f.fields_for :status, @user.status do |status_form|
|
||||
- emoji_button = button_tag type: :button,
|
||||
class: 'js-toggle-emoji-menu emoji-menu-toggle-button btn gl-button btn-default has-tooltip',
|
||||
title: s_("Profiles|Add status emoji") do
|
||||
- emoji_button = render Pajamas::ButtonComponent.new(button_options: { title: s_("Profiles|Add status emoji"),
|
||||
class: 'js-toggle-emoji-menu emoji-menu-toggle-button has-tooltip' } ) do
|
||||
- if custom_emoji
|
||||
= emoji_icon(@user.status.emoji, class: 'gl-mr-0!')
|
||||
%span#js-no-emoji-placeholder.no-emoji-placeholder{ class: ('hidden' if custom_emoji) }
|
||||
= sprite_icon('slight-smile', css_class: 'award-control-icon-neutral')
|
||||
= sprite_icon('smiley', css_class: 'award-control-icon-positive')
|
||||
= sprite_icon('smile', css_class: 'award-control-icon-super-positive')
|
||||
- reset_message_button = button_tag type: :button,
|
||||
id: 'js-clear-user-status-button',
|
||||
class: 'clear-user-status btn gl-button btn-default has-tooltip',
|
||||
title: s_("Profiles|Clear status") do
|
||||
= sprite_icon("close")
|
||||
- reset_message_button = render Pajamas::ButtonComponent.new(icon: 'close',
|
||||
button_options: { id: 'js-clear-user-status-button',
|
||||
class: 'has-tooltip',
|
||||
title: s_("Profiles|Clear status") } )
|
||||
|
||||
= status_form.hidden_field :emoji, id: 'js-status-emoji-field'
|
||||
.form-group.gl-form-group
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
- if Feature.enabled?(:incident_resource_links_widget, @project) && can?(current_user, :read_issuable_resource_link, @issue)
|
||||
.js-linked-resources-root{ data: { issuable_id: @issue.id,
|
||||
can_add_resource_links: "#{can?(current_user, :admin_issuable_resource_link, @issue)}",
|
||||
help_path: help_page_path('user/project/issues/related_issues')} }
|
|
@ -18,6 +18,7 @@
|
|||
= render 'projects/issues/design_management'
|
||||
|
||||
= render_if_exists 'projects/issues/work_item_links'
|
||||
= render_if_exists 'projects/issues/linked_resources'
|
||||
= render_if_exists 'projects/issues/related_issues'
|
||||
|
||||
#js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: issuable.iid)), project_namespace: @project.namespace.path, project_path: @project.path } }
|
||||
|
|
|
@ -3,7 +3,7 @@ data_category: optional
|
|||
key_path: counts.releases
|
||||
description: Count of releases
|
||||
product_section: ops
|
||||
product_stage: releases
|
||||
product_stage: release
|
||||
product_group: release
|
||||
product_category: release_orchestration
|
||||
value_type: number
|
||||
|
|
|
@ -236,6 +236,11 @@ command. For example:
|
|||
/chatops run feature list --staging
|
||||
```
|
||||
|
||||
## Toggle a feature flag
|
||||
|
||||
See [rolling out changes](controls.md#rolling-out-changes) for more information about toggling
|
||||
feature flags.
|
||||
|
||||
## Delete a feature flag
|
||||
|
||||
See [cleaning up feature flags](controls.md#cleaning-up) for more information about
|
||||
|
|
|
@ -469,6 +469,11 @@ clone, and compares the hash with the value the **primary** site
|
|||
calculated. If there is a mismatch, Geo will mark this as a mismatch
|
||||
and the administrator can see this in the [Geo Admin Area](../user/admin_area/geo_sites.md).
|
||||
|
||||
## Geo proxying
|
||||
|
||||
Geo secondaries can proxy web requests to the primary.
|
||||
Read more on the [Geo proxying (development) page](geo/proxying.md).
|
||||
|
||||
## Glossary
|
||||
|
||||
### Primary site
|
||||
|
|
|
@ -0,0 +1,356 @@
|
|||
---
|
||||
stage: Systems
|
||||
group: Geo
|
||||
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
|
||||
---
|
||||
|
||||
# Geo proxying
|
||||
|
||||
With Geo proxying, secondaries now proxy web requests through Workhorse to the primary, so users navigating to the
|
||||
secondary see a read-write UI, and are able to do all operations that they can do on the primary.
|
||||
|
||||
## Request life cycle
|
||||
|
||||
### Top-level view
|
||||
|
||||
The proxying interaction can be explained at a high level through the following diagram:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor client
|
||||
participant secondary
|
||||
participant primary
|
||||
|
||||
client->>secondary: GET /explore
|
||||
secondary-->>primary: GET /explore (proxied)
|
||||
primary-->>secondary: HTTP/1.1 200 OK [..]
|
||||
secondary->>client: HTTP/1.1 200 OK [..]
|
||||
```
|
||||
|
||||
### Proxy detection mechanism
|
||||
|
||||
To know whether or not it should proxy requests to the primary, and the URL of the primary (as it is stored in
|
||||
the database), Workhorse polls the internal API when Geo is enabled. When proxying should be enabled, the internal
|
||||
API responds with the primary URL and JWT-signed data that is passed on to the primary for every request.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant W as Workhorse (secondary)
|
||||
participant API as Internal Rails API
|
||||
W->API: GET /api/v4/geo/proxy (internal)
|
||||
loop Poll every 10 seconds
|
||||
API-->W: {geo_proxy_primary_url, geo_proxy_extra_data}, update config
|
||||
end
|
||||
```
|
||||
|
||||
### In-depth request flow and local data acceleration compared with proxying
|
||||
|
||||
Detailing implementation, Workhorse on the secondary (requested) site decides whether to proxy the data or not. If it
|
||||
can "accelerate" the data type (that is, can serve locally to save a roundtrip request), it returns the data
|
||||
immediately. Otherwise, traffic is sent to the primary's internal URL, served by Workhorse on the primary exactly
|
||||
as a direct request would. The response is then be proxied back to the user through the secondary Workhorse in the
|
||||
same connection.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Client]--->W1["Workhorse (secondary)"]
|
||||
W1 --> W1C[Serve data locally?]
|
||||
W1C -- "Yes" ----> W1
|
||||
W1C -- "No (proxy)" ----> W2["Workhorse (primary)"]
|
||||
W2 --> W1 ----> A
|
||||
```
|
||||
|
||||
## Sign-in
|
||||
|
||||
### Requests proxied to the primary requiring authorization
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autoNumber
|
||||
participant Client
|
||||
participant Secondary
|
||||
participant Primary
|
||||
|
||||
Client->>Secondary: `/group/project` request
|
||||
Secondary->>Primary: proxy /group/project
|
||||
opt primary not signed in
|
||||
Primary-->>Secondary: 302 redirect
|
||||
Secondary-->>Client: proxy 302 redirect
|
||||
Client->>Secondary: /users/sign_in
|
||||
Secondary->>Primary: proxy /users/sign_in
|
||||
Note right of Primary: authentication happens, POST to same URL etc
|
||||
Primary-->>Secondary: 302 redirect
|
||||
Secondary-->>Client: proxy 302 redirect
|
||||
Client->>Secondary: /group/project
|
||||
Secondary->>Primary: proxy /group/project
|
||||
end
|
||||
Primary-->>Secondary: /group/project logged in response (session on primary created)
|
||||
Secondary-->>Client: proxy full response
|
||||
```
|
||||
|
||||
### Requests requiring a user session on the secondary
|
||||
|
||||
At the moment, this flow only applies to Project Replication Details and Design Replication Details in the Geo Admin
|
||||
Area. For more context, see
|
||||
[View replication data on the primary site](../../administration/geo/index.md#view-replication-data-on-the-primary-site).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autoNumber
|
||||
participant Client
|
||||
participant Secondary
|
||||
participant Primary
|
||||
|
||||
Client->>Secondary: `admin/geo/replication/projects` request
|
||||
opt secondary not signed in
|
||||
Secondary-->>Client: 302 redirect
|
||||
Client->>Secondary: /users/auth/geo/sign_in
|
||||
Secondary-->>Client: 302 redirect
|
||||
Client->>Secondary: /oauth/geo/auth/geo/sign_in
|
||||
Secondary-->>Client: 302 redirect
|
||||
Client->>Secondary: /oauth/authorize
|
||||
Secondary->>Primary: proxy /oauth/authorize
|
||||
opt primary not signed in
|
||||
Primary-->>Secondary: 302 redirect
|
||||
Secondary-->>Client: proxy 302 redirect
|
||||
Client->>Secondary: /users/sign_in
|
||||
Secondary->>Primary: proxy /users/sign_in
|
||||
Note right of Primary: authentication happens, POST to same URL etc
|
||||
end
|
||||
Primary-->>Secondary: 302 redirect
|
||||
Secondary-->>Client: proxy 302 redirect
|
||||
Client->>Secondary: /oauth/geo/callback
|
||||
Secondary-->>Client: 302 redirect
|
||||
Client->>Secondary: admin/geo/replication/projects
|
||||
end
|
||||
Secondary-->>Client: admin/geo/replication/projects logged in response (session on both primary and secondary)
|
||||
```
|
||||
|
||||
## Git pull
|
||||
|
||||
For historical reasons, the `push_from_secondary` path is used to forward a Git pull. There is [an issue proposing to
|
||||
rename this route](https://gitlab.com/gitlab-org/gitlab/-/issues/292690) to avoid confusion.
|
||||
|
||||
### Git pull over HTTP(s)
|
||||
|
||||
#### Accelerated repositories
|
||||
|
||||
When a repository exists on the secondary and we detect is up to date with the primary, we serve it directly instead of
|
||||
proxying.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Git client
|
||||
participant Wsec as "Workhorse (secondary)"
|
||||
participant Rsec as "Rails (secondary)"
|
||||
participant Gsec as "Gitaly (secondary)"
|
||||
C->>Wsec: GET /foo/bar.git/info/refs/?service=git-upload-pack
|
||||
Wsec->>Rsec: <internal API check>
|
||||
note over Rsec: decide that the repo is synced and up to date
|
||||
Rsec-->>Wsec: 401 Unauthorized
|
||||
Wsec-->>C: <response>
|
||||
C->>Wsec: GET /foo/bar.git/info/refs/?service=git-upload-pack
|
||||
Wsec->>Rsec: <internal API check>
|
||||
Rsec-->>Wsec: Render Workhorse OK
|
||||
Wsec-->>C: 200 OK
|
||||
C->>Wsec: POST /foo/bar.git/git-upload-pack
|
||||
Wsec->>Rsec: GitHttpController#git_receive_pack
|
||||
Rsec-->>Wsec: Render Workhorse OK
|
||||
Wsec->>Gsec: Workhorse gets the connection details from Rails, connects to Gitaly: SmartHTTP Service, UploadPack RPC (check the proto for details)
|
||||
Gsec-->>Wsec: Return a stream of Proto messages
|
||||
Wsec-->>C: Pipe messages to the Git client
|
||||
```
|
||||
|
||||
#### Proxied repositories
|
||||
|
||||
If a requested repository isn't synced, or we detect is not up to date, the request will be proxied to the primary, in
|
||||
order to get the latest version of the changes.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Git client
|
||||
participant Wsec as "Workhorse (secondary)"
|
||||
participant Rsec as "Rails (secondary)"
|
||||
participant W as "Workhorse (primary)"
|
||||
participant R as "Rails (primary)"
|
||||
participant G as "Gitaly (primary)"
|
||||
C->>Wsec: GET /foo/bar.git/info/refs/?service=git-upload-pack
|
||||
Wsec->>Rsec: <response>
|
||||
note over Rsec: decide that the repo is out of date
|
||||
Rsec-->>Wsec: 302 Redirect to /-/push_from_secondary/2/foo/bar.git/info/refs?service=git-upload-pack
|
||||
Wsec-->>C: <response>
|
||||
C->>Wsec: GET /-/push_from_secondary/2/foo/bar.git/info/refs/?service=git-upload-pack
|
||||
Wsec->>W: <proxied request>
|
||||
W->>R: <data>
|
||||
R-->>W: 401 Unauthorized
|
||||
W-->>Wsec: <proxied response>
|
||||
Wsec-->>C: <response>
|
||||
C->>Wsec: GET /-/push_from_secondary/2/foo/bar.git/info/refs/?service=git-upload-pack
|
||||
note over W: proxied
|
||||
Wsec->>W: <proxied request>
|
||||
W->>R: <data>
|
||||
R-->>W: Render Workhorse OK
|
||||
W-->>Wsec: <proxied response>
|
||||
Wsec-->>C: <response>
|
||||
C->>Wsec: POST /-/push_from_secondary/2/foo/bar.git/git-upload-pack
|
||||
Wsec->>W: <proxied request>
|
||||
W->>R: GitHttpController#git_receive_pack
|
||||
R-->>W: Render Workhorse OK
|
||||
W->>G: Workhorse gets the connection details from Rails, connects to Gitaly: SmartHTTP Service, UploadPack RPC (check the proto for details)
|
||||
G-->>W: Return a stream of Proto messages
|
||||
W-->>Wsec: Pipe messages to the Git client
|
||||
Wsec-->>C: Return piped messages from Git
|
||||
```
|
||||
|
||||
### Git pull over SSH
|
||||
|
||||
As SSH operations go through GitLab Shell instead of Workhorse, they are not proxied through the mechanism used for
|
||||
Workhorse requests. With SSH operations, they are proxied as Git HTTP requests to the primary site by the secondary
|
||||
Rails internal API.
|
||||
|
||||
#### Accelerated repositories
|
||||
|
||||
When a repository exists on the secondary and we detect is up to date with the primary, we serve it directly instead of
|
||||
proxying.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Git client
|
||||
participant S as GitLab Shell (secondary)
|
||||
participant I as Internal API (secondary Rails)
|
||||
participant G as Gitaly (secondary)
|
||||
C->>S: git pull
|
||||
S->>I: SSH key validation (api/v4/internal/authorized_keys?key=..)
|
||||
I-->>S: HTTP/1.1 200 OK
|
||||
S->>G: InfoRefs:UploadPack RPC
|
||||
G-->>S: stream Git response back
|
||||
S-->>C: stream Git response back
|
||||
C-->>S: stream Git data to push
|
||||
S->>G: UploadPack RPC
|
||||
G-->>S: stream Git response back
|
||||
S-->>C: stream Git response back
|
||||
```
|
||||
|
||||
#### Proxied repositories
|
||||
|
||||
If a requested repository isn't synced, or we detect is not up to date, the request will be proxied to the primary, in
|
||||
order to get the latest version of the changes.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Git client
|
||||
participant S as GitLab Shell (secondary)
|
||||
participant I as Internal API (secondary Rails)
|
||||
participant P as Primary API
|
||||
C->>S: git pull
|
||||
S->>I: SSH key validation (api/v4/internal/authorized_keys?key=..)
|
||||
I-->>S: HTTP/1.1 300 (custom action status) with {endpoint, msg, primary_repo}
|
||||
S->>I: POST /api/v4/geo/proxy_git_ssh/info_refs_upload_pack
|
||||
I->>P: POST $PRIMARY/foo/bar.git/info/refs/?service=git-upload-pack
|
||||
P-->>I: HTTP/1.1 200 OK
|
||||
I-->>S: <response>
|
||||
S-->>C: return Git response from primary
|
||||
C-->>S: stream Git data to push
|
||||
S->>I: POST /api/v4/geo/proxy_git_ssh/upload_pack
|
||||
I->>P: POST $PRIMARY/foo/bar.git/git-upload-pack
|
||||
P-->>I: HTTP/1.1 200 OK
|
||||
I-->>S: <response>
|
||||
S-->>C: return Git response from primary
|
||||
```
|
||||
|
||||
## Git push
|
||||
|
||||
### Unified URLs
|
||||
|
||||
With unified URLs, a push will redirect to a local path formatted as `/-/push_from_secondary/$SECONDARY_ID/*`. Further
|
||||
requests through this path will be proxied to the primary, which will handle the push.
|
||||
|
||||
#### Git push over SSH
|
||||
|
||||
As SSH operations go through GitLab Shell instead of Workhorse, they are not proxied through the mechanism used for
|
||||
Workhorse requests. With SSH operations, they are proxied as Git HTTP requests to the primary site by the secondary
|
||||
Rails internal API.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Git client
|
||||
participant S as GitLab Shell (secondary)
|
||||
participant I as Internal API (secondary Rails)
|
||||
participant P as Primary API
|
||||
C->>S: git push
|
||||
S->>I: SSH key validation (api/v4/internal/authorized_keys?key=..)
|
||||
I-->>S: HTTP/1.1 300 (custom action status) with {endpoint, msg, primary_repo}
|
||||
S->>I: POST /api/v4/geo/proxy_git_ssh/info_refs_receive_pack
|
||||
I->>P: POST $PRIMARY/foo/bar.git/info/refs/?service=git-receive-pack
|
||||
P-->>I: HTTP/1.1 200 OK
|
||||
I-->>S: <response>
|
||||
S-->>C: return Git response from primary
|
||||
C-->>S: stream Git data to push
|
||||
S->>I: POST /api/v4/geo/proxy_git_ssh/receive_pack
|
||||
I->>P: POST $PRIMARY/foo/bar.git/git-receive-pack
|
||||
P-->>I: HTTP/1.1 200 OK
|
||||
I-->>S: <response>
|
||||
S-->>C: return Git response from primary
|
||||
```
|
||||
|
||||
#### Git push over HTTP(s)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Git client
|
||||
participant Wsec as Workhorse (secondary)
|
||||
participant W as Workhorse (primary)
|
||||
participant R as Rails (primary)
|
||||
participant G as Gitaly (primary)
|
||||
C->>Wsec: GET /foo/bar.git/info/refs/?service=git-receive-pack
|
||||
Wsec->>C: 302 Redirect to /-/push_from_secondary/2/foo/bar.git/info/refs?service=git-receive-pack
|
||||
C->>Wsec: GET /-/push_from_secondary/2/foo/bar.git/info/refs/?service=git-receive-pack
|
||||
Wsec->>W: <proxied request>
|
||||
W->>R: <data>
|
||||
R-->>W: 401 Unauthorized
|
||||
W-->>Wsec: <proxied response>
|
||||
Wsec-->>C: <response>
|
||||
C->>Wsec: GET /-/push_from_secondary/2/foo/bar.git/info/refs/?service=git-receive-pack
|
||||
Wsec->>W: <proxied request>
|
||||
W->>R: <data>
|
||||
R-->>W: Render Workhorse OK
|
||||
W-->>Wsec: <proxied response>
|
||||
Wsec-->>C: <response>
|
||||
C->>Wsec: POST /-/push_from_secondary/2/foo/bar.git/git-receive-pack
|
||||
Wsec->>W: <proxied request>
|
||||
W->>R: GitHttpController:git_receive_pack
|
||||
R-->>W: Render Workhorse OK
|
||||
W->>G: Get connection details from Rails and connects to SmartHTTP Service, ReceivePack RPC
|
||||
G-->>W: Return a stream of Proto messages
|
||||
W-->>Wsec: Pipe messages to the Git client
|
||||
Wsec-->>C: Return piped messages from Git
|
||||
```
|
||||
|
||||
### Git push over HTTP with Separate URLs
|
||||
|
||||
With separate URLs, the secondary will redirect to a URL formatted like `$PRIMARY/-/push_from_secondary/$SECONDARY_ID/*`.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Wsec as Workhorse (secondary)
|
||||
participant C as Git client
|
||||
participant W as Workhorse (primary)
|
||||
participant R as Rails (primary)
|
||||
participant G as Gitaly (primary)
|
||||
C->>Wsec: GET $SECONDARY/foo/bar.git/info/refs/?service=git-receive-pack
|
||||
Wsec->>C: 302 Redirect to $PRIMARY/-/push_from_secondary/2/foo/bar.git/info/refs?service=git-receive-pack
|
||||
C->>W: GET $PRIMARY/-/push_from_secondary/2/foo/bar.git/info/refs/?service=git-receive-pack
|
||||
W->>R: <data>
|
||||
R-->>W: 401 Unauthorized
|
||||
W-->>C: <response>
|
||||
C->>W: GET /-/push_from_secondary/2/foo/bar.git/info/refs/?service=git-receive-pack
|
||||
W->>R: <data>
|
||||
R-->>W: Render Workhorse OK
|
||||
W-->>C: <response>
|
||||
C->>W: POST /-/push_from_secondary/2/foo/bar.git/git-receive-pack
|
||||
W->>R: GitHttpController:git_receive_pack
|
||||
R-->>W: Render Workhorse OK
|
||||
W->>G: Get connection details from Rails and connects to SmartHTTP Service, ReceivePack RPC
|
||||
G-->>W: Return a stream of Proto messages
|
||||
W-->>C: Pipe messages to the Git client
|
||||
```
|
|
@ -147,8 +147,8 @@ The default scanner images are build off a base Alpine image for size and mainta
|
|||
|
||||
> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/6479) in GitLab 14.10.
|
||||
|
||||
GitLab offers [Red Hat UBI](https://www.redhat.com/en/blog/introducing-red-hat-universal-base-image)
|
||||
versions of the images that are FIPS-enabled. To use the FIPS-enabled images, you can either:
|
||||
GitLab offers an image version, based on the [Red Hat UBI](https://www.redhat.com/en/blog/introducing-red-hat-universal-base-image) base image,
|
||||
that uses a FIPS 140-validated cryptographic module. To use the FIPS-enabled image, you can either:
|
||||
|
||||
- Set the `SAST_IMAGE_SUFFIX` to `-fips`.
|
||||
- Add the `-fips` extension to the default image name.
|
||||
|
@ -163,6 +163,10 @@ include:
|
|||
- template: Security/SAST.gitlab-ci.yml
|
||||
```
|
||||
|
||||
A FIPS-compliant image is only available for the Semgrep-based analyzer.
|
||||
|
||||
To use SAST in a FIPS-compliant manner, you must [exclude other analyzers from running](analyzers.md#customize-analyzers).
|
||||
|
||||
### Making SAST analyzers available to all GitLab tiers
|
||||
|
||||
All open source (OSS) analyzers have been moved to the GitLab Free tier as of GitLab 13.3.
|
||||
|
|
|
@ -18,6 +18,8 @@ module Gitlab
|
|||
UnknownRef = Class.new(BaseError)
|
||||
CommandTimedOut = Class.new(CommandError)
|
||||
InvalidPageToken = Class.new(BaseError)
|
||||
InvalidRefFormatError = Class.new(BaseError)
|
||||
ReferencesLockedError = Class.new(BaseError)
|
||||
|
||||
class << self
|
||||
include Gitlab::EncodingHelper
|
||||
|
|
|
@ -485,6 +485,22 @@ module Gitlab
|
|||
|
||||
stack_counter.select { |_, v| v == max }.keys
|
||||
end
|
||||
|
||||
def self.decode_detailed_error(err)
|
||||
# details could have more than one in theory, but we only have one to worry about for now.
|
||||
detailed_error = err.to_rpc_status&.details&.first
|
||||
|
||||
return unless detailed_error.present?
|
||||
|
||||
prefix = %r{type\.googleapis\.com\/gitaly\.(?<error_type>.+)}
|
||||
error_type = prefix.match(detailed_error.type_url)[:error_type]
|
||||
|
||||
Gitaly.const_get(error_type, false).decode(detailed_error.value)
|
||||
rescue NameError, NoMethodError
|
||||
# Error Class might not be known to ruby yet
|
||||
nil
|
||||
end
|
||||
|
||||
private_class_method :max_stacks
|
||||
end
|
||||
end
|
||||
|
|
|
@ -102,7 +102,7 @@ module Gitlab
|
|||
raise Gitlab::Git::PreReceiveError, pre_receive_error
|
||||
end
|
||||
rescue GRPC::BadStatus => e
|
||||
detailed_error = decode_detailed_error(e)
|
||||
detailed_error = GitalyClient.decode_detailed_error(e)
|
||||
|
||||
case detailed_error&.error
|
||||
when :custom_hook
|
||||
|
@ -166,7 +166,7 @@ module Gitlab
|
|||
Gitlab::Git::OperationService::BranchUpdate.from_gitaly(branch_update)
|
||||
|
||||
rescue GRPC::BadStatus => e
|
||||
detailed_error = decode_detailed_error(e)
|
||||
detailed_error = GitalyClient.decode_detailed_error(e)
|
||||
|
||||
case detailed_error&.error
|
||||
when :access_check
|
||||
|
@ -277,7 +277,7 @@ module Gitlab
|
|||
|
||||
rebase_sha
|
||||
rescue GRPC::BadStatus => e
|
||||
detailed_error = decode_detailed_error(e)
|
||||
detailed_error = GitalyClient.decode_detailed_error(e)
|
||||
|
||||
case detailed_error&.error
|
||||
when :access_check
|
||||
|
@ -314,7 +314,7 @@ module Gitlab
|
|||
|
||||
response.squash_sha
|
||||
rescue GRPC::BadStatus => e
|
||||
detailed_error = decode_detailed_error(e)
|
||||
detailed_error = GitalyClient.decode_detailed_error(e)
|
||||
|
||||
case detailed_error&.error
|
||||
when :resolve_revision, :rebase_conflict
|
||||
|
@ -474,7 +474,7 @@ module Gitlab
|
|||
|
||||
handle_cherry_pick_or_revert_response(response)
|
||||
rescue GRPC::BadStatus => e
|
||||
detailed_error = decode_detailed_error(e)
|
||||
detailed_error = GitalyClient.decode_detailed_error(e)
|
||||
|
||||
case detailed_error&.error
|
||||
when :access_check
|
||||
|
@ -538,21 +538,6 @@ module Gitlab
|
|||
raise ArgumentError, "Unknown action '#{action[:action]}'"
|
||||
end
|
||||
|
||||
def decode_detailed_error(err)
|
||||
# details could have more than one in theory, but we only have one to worry about for now.
|
||||
detailed_error = err.to_rpc_status&.details&.first
|
||||
|
||||
return unless detailed_error.present?
|
||||
|
||||
prefix = %r{type\.googleapis\.com\/gitaly\.(?<error_type>.+)}
|
||||
error_type = prefix.match(detailed_error.type_url)[:error_type]
|
||||
|
||||
Gitaly.const_get(error_type, false).decode(detailed_error.value)
|
||||
rescue NameError, NoMethodError
|
||||
# Error Class might not be known to ruby yet
|
||||
nil
|
||||
end
|
||||
|
||||
def custom_hook_error_message(custom_hook_error)
|
||||
# Custom hooks may return messages via either stdout or stderr which have a specific prefix. If
|
||||
# that prefix is present we'll want to print the hook's output, otherwise we'll want to print the
|
||||
|
|
|
@ -132,6 +132,17 @@ module Gitlab
|
|||
response = GitalyClient.call(@repository.storage, :ref_service, :delete_refs, request, timeout: GitalyClient.medium_timeout)
|
||||
|
||||
raise Gitlab::Git::Repository::GitError, response.git_error if response.git_error.present?
|
||||
rescue GRPC::BadStatus => e
|
||||
detailed_error = GitalyClient.decode_detailed_error(e)
|
||||
|
||||
case detailed_error&.error
|
||||
when :invalid_format
|
||||
raise Gitlab::Git::InvalidRefFormatError, "references have an invalid format: #{detailed_error.invalid_format.refs.join(",")}"
|
||||
when :references_locked
|
||||
raise Gitlab::Git::ReferencesLockedError
|
||||
else
|
||||
raise e
|
||||
end
|
||||
end
|
||||
|
||||
# Limit: 0 implies no limit, thus all tag names will be returned
|
||||
|
|
|
@ -2147,6 +2147,9 @@ msgstr ""
|
|||
msgid "Add a related issue"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add a resource link"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add a suffix to Service Desk email address. %{linkStart}Learn more.%{linkEnd}"
|
||||
msgstr ""
|
||||
|
||||
|
@ -23453,6 +23456,9 @@ msgstr ""
|
|||
msgid "Linked issues"
|
||||
msgstr ""
|
||||
|
||||
msgid "Linked resources"
|
||||
msgstr ""
|
||||
|
||||
msgid "LinkedIn"
|
||||
msgstr ""
|
||||
|
||||
|
@ -31753,6 +31759,9 @@ msgstr ""
|
|||
msgid "Read more about GitLab at %{link_to_promo}."
|
||||
msgstr ""
|
||||
|
||||
msgid "Read more about linked resources"
|
||||
msgstr ""
|
||||
|
||||
msgid "Read more about project permissions %{help_link_open}here%{help_link_close}"
|
||||
msgstr ""
|
||||
|
||||
|
@ -43828,10 +43837,7 @@ msgstr ""
|
|||
msgid "Work in progress Limit"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItem|Add"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItem|Add a child"
|
||||
msgid "WorkItem|Add a task"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItem|Add assignee"
|
||||
|
@ -43840,6 +43846,9 @@ msgstr ""
|
|||
msgid "WorkItem|Add assignees"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItem|Add task"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItem|Are you sure you want to cancel editing?"
|
||||
msgstr ""
|
||||
|
||||
|
@ -43896,6 +43905,9 @@ msgstr ""
|
|||
msgid "WorkItem|Something went wrong when fetching work item types. Please try again"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItem|Something went wrong when trying to add a child. Please try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItem|Something went wrong while updating the work item. Please try again."
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -73,7 +73,7 @@ class PipelineTestReportBuilder
|
|||
def test_report_for_build(pipeline, build_id)
|
||||
fetch("#{pipeline['web_url']}/tests/suite.json?build_ids[]=#{build_id}")
|
||||
rescue Net::HTTPServerException => e
|
||||
raise e unless e.response.code == 404
|
||||
raise e unless e.response.code.to_i == 404
|
||||
|
||||
puts "Artifacts not found. They may have expired. Skipping this build."
|
||||
end
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ResourceLinksBlock with defaults renders correct component 1`] = `
|
||||
<div
|
||||
class="gl-mt-5"
|
||||
id="resource-links"
|
||||
>
|
||||
<div
|
||||
class="card card-slim gl-overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="card-header gl-display-flex gl-justify-content-space-between panel-empty-heading border-bottom-0"
|
||||
>
|
||||
<h3
|
||||
class="card-title h5 position-relative gl-my-0 gl-display-flex gl-align-items-center gl-h-7"
|
||||
>
|
||||
<gl-link-stub
|
||||
aria-hidden="true"
|
||||
class="anchor position-absolute gl-text-decoration-none"
|
||||
href="#resource-links"
|
||||
id="user-content-resource-links"
|
||||
/>
|
||||
Linked resources
|
||||
<gl-link-stub
|
||||
aria-label="Read more about linked resources"
|
||||
class="gl-display-flex gl-align-items-center gl-ml-2 gl-text-gray-500"
|
||||
data-testid="help-link"
|
||||
href="/help/user/project/issues/linked_resources"
|
||||
target="_blank"
|
||||
>
|
||||
<gl-icon-stub
|
||||
name="question"
|
||||
size="12"
|
||||
/>
|
||||
</gl-link-stub>
|
||||
|
||||
<div
|
||||
class="gl-display-inline-flex"
|
||||
>
|
||||
<div
|
||||
class="gl-display-inline-flex gl-mx-5"
|
||||
>
|
||||
<span
|
||||
class="gl-display-inline-flex gl-align-items-center"
|
||||
>
|
||||
<gl-icon-stub
|
||||
class="gl-mr-2 gl-text-gray-500"
|
||||
name="link"
|
||||
size="16"
|
||||
/>
|
||||
|
||||
0
|
||||
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<gl-button-stub
|
||||
aria-label="Add a resource link"
|
||||
buttontextclasses=""
|
||||
category="primary"
|
||||
icon="plus"
|
||||
size="medium"
|
||||
variant="default"
|
||||
/>
|
||||
</div>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,35 @@
|
|||
import { GlButton } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import ResourceLinksBlock from '~/linked_resources/components/resource_links_block.vue';
|
||||
|
||||
describe('ResourceLinksBlock', () => {
|
||||
let wrapper;
|
||||
|
||||
const findResourceLinkAddButton = () => wrapper.find(GlButton);
|
||||
const helpPath = '/help/user/project/issues/linked_resources';
|
||||
|
||||
describe('with defaults', () => {
|
||||
it('renders correct component', () => {
|
||||
wrapper = shallowMount(ResourceLinksBlock, {
|
||||
propsData: {
|
||||
helpPath,
|
||||
canAddResourceLinks: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with canAddResourceLinks=false', () => {
|
||||
it('does not show the add button', () => {
|
||||
wrapper = shallowMount(ResourceLinksBlock, {
|
||||
propsData: {
|
||||
canAddResourceLinks: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(findResourceLinkAddButton().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,10 +1,11 @@
|
|||
import { GlModal, GlFormCheckbox } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import { initEmojiMock, clearEmojiMock } from 'helpers/emoji';
|
||||
import * as UserApi from '~/api/user_api';
|
||||
import EmojiPicker from '~/emoji/components/picker.vue';
|
||||
import createFlash from '~/flash';
|
||||
import stubChildren from 'helpers/stub_children';
|
||||
import SetStatusModalWrapper, {
|
||||
AVAILABILITY_STATUS,
|
||||
} from '~/set_status_modal/set_status_modal_wrapper.vue';
|
||||
|
@ -26,12 +27,23 @@ describe('SetStatusModalWrapper', () => {
|
|||
defaultEmoji,
|
||||
};
|
||||
|
||||
const EmojiPickerStub = {
|
||||
props: EmojiPicker.props,
|
||||
template: '<div></div>',
|
||||
};
|
||||
|
||||
const createComponent = (props = {}) => {
|
||||
return shallowMount(SetStatusModalWrapper, {
|
||||
return mount(SetStatusModalWrapper, {
|
||||
propsData: {
|
||||
...defaultProps,
|
||||
...props,
|
||||
},
|
||||
stubs: {
|
||||
...stubChildren(SetStatusModalWrapper),
|
||||
GlFormInput: false,
|
||||
GlFormInputGroup: false,
|
||||
EmojiPicker: EmojiPickerStub,
|
||||
},
|
||||
mocks: {
|
||||
$toast,
|
||||
},
|
||||
|
@ -43,7 +55,7 @@ describe('SetStatusModalWrapper', () => {
|
|||
const findClearStatusButton = () => wrapper.find('.js-clear-user-status-button');
|
||||
const findAvailabilityCheckbox = () => wrapper.find(GlFormCheckbox);
|
||||
const findClearStatusAtMessage = () => wrapper.find('[data-testid="clear-status-at-message"]');
|
||||
const getEmojiPicker = () => wrapper.findComponent(EmojiPicker);
|
||||
const getEmojiPicker = () => wrapper.findComponent(EmojiPickerStub);
|
||||
|
||||
const initModal = async ({ mockOnUpdateSuccess = true, mockOnUpdateFailure = true } = {}) => {
|
||||
const modal = findModal();
|
||||
|
@ -88,7 +100,7 @@ describe('SetStatusModalWrapper', () => {
|
|||
});
|
||||
|
||||
it('has a clear status button', () => {
|
||||
expect(findClearStatusButton().isVisible()).toBe(true);
|
||||
expect(findClearStatusButton().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('displays the clear status at dropdown', () => {
|
||||
|
@ -125,7 +137,7 @@ describe('SetStatusModalWrapper', () => {
|
|||
});
|
||||
|
||||
it('hides the clear status button', () => {
|
||||
expect(findClearStatusButton().isVisible()).toBe(false);
|
||||
expect(findClearStatusButton().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -6,19 +6,23 @@ import createMockApollo from 'helpers/mock_apollo_helper';
|
|||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import WorkItemLinksForm from '~/work_items/components/work_item_links/work_item_links_form.vue';
|
||||
import projectWorkItemsQuery from '~/work_items/graphql/project_work_items.query.graphql';
|
||||
import { availableWorkItemsResponse } from '../../mock_data';
|
||||
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
|
||||
import { availableWorkItemsResponse, updateWorkItemMutationResponse } from '../../mock_data';
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
describe('WorkItemLinksForm', () => {
|
||||
let wrapper;
|
||||
|
||||
const createComponent = async ({ response = availableWorkItemsResponse } = {}) => {
|
||||
const updateMutationResolver = jest.fn().mockResolvedValue(updateWorkItemMutationResponse);
|
||||
|
||||
const createComponent = async ({ listResponse = availableWorkItemsResponse } = {}) => {
|
||||
wrapper = shallowMountExtended(WorkItemLinksForm, {
|
||||
apolloProvider: createMockApollo([
|
||||
[projectWorkItemsQuery, jest.fn().mockResolvedValue(response)],
|
||||
[projectWorkItemsQuery, jest.fn().mockResolvedValue(listResponse)],
|
||||
[updateWorkItemMutation, updateMutationResolver],
|
||||
]),
|
||||
propsData: { issuableId: 1 },
|
||||
propsData: { issuableGid: 'gid://gitlab/WorkItem/1' },
|
||||
provide: {
|
||||
projectPath: 'project/path',
|
||||
},
|
||||
|
@ -29,6 +33,7 @@ describe('WorkItemLinksForm', () => {
|
|||
|
||||
const findForm = () => wrapper.findComponent(GlForm);
|
||||
const findCombobox = () => wrapper.findComponent(GlFormCombobox);
|
||||
const findAddChildButton = () => wrapper.findByTestId('add-child-button');
|
||||
|
||||
beforeEach(async () => {
|
||||
await createComponent();
|
||||
|
@ -43,7 +48,18 @@ describe('WorkItemLinksForm', () => {
|
|||
});
|
||||
|
||||
it('passes available work items as prop when typing in combobox', async () => {
|
||||
findCombobox().vm.$emit('input', 'Task');
|
||||
await waitForPromises();
|
||||
|
||||
expect(findCombobox().exists()).toBe(true);
|
||||
expect(findCombobox().props('tokenList').length).toBe(2);
|
||||
});
|
||||
|
||||
it('selects and add child', async () => {
|
||||
findCombobox().vm.$emit('input', availableWorkItemsResponse.data.workspace.workItems.edges[0]);
|
||||
|
||||
findAddChildButton().vm.$emit('click');
|
||||
await waitForPromises();
|
||||
expect(updateMutationResolver).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -51,6 +51,20 @@ describe('WorkItemLinks', () => {
|
|||
expect(findLinksBody().exists()).toBe(false);
|
||||
});
|
||||
|
||||
describe('add link form', () => {
|
||||
it('displays form on click add button and hides form on cancel', async () => {
|
||||
findToggleAddFormButton().vm.$emit('click');
|
||||
await nextTick();
|
||||
|
||||
expect(findAddLinksForm().exists()).toBe(true);
|
||||
|
||||
findAddLinksForm().vm.$emit('cancel');
|
||||
await nextTick();
|
||||
|
||||
expect(findAddLinksForm().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when no child links', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent({ response: workItemHierarchyEmptyResponse });
|
||||
|
@ -59,22 +73,6 @@ describe('WorkItemLinks', () => {
|
|||
it('displays empty state if there are no children', () => {
|
||||
expect(findEmptyState().exists()).toBe(true);
|
||||
});
|
||||
|
||||
describe('add link form', () => {
|
||||
it('displays form on click add button and hides form on cancel', async () => {
|
||||
expect(findEmptyState().exists()).toBe(true);
|
||||
|
||||
findToggleAddFormButton().vm.$emit('click');
|
||||
await nextTick();
|
||||
|
||||
expect(findAddLinksForm().exists()).toBe(true);
|
||||
|
||||
findAddLinksForm().vm.$emit('cancel');
|
||||
await nextTick();
|
||||
|
||||
expect(findAddLinksForm().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('renders all hierarchy widget children', () => {
|
||||
|
|
|
@ -58,6 +58,15 @@ export const workItemQueryResponse = {
|
|||
iid: '5',
|
||||
title: 'Parent title',
|
||||
},
|
||||
children: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
id: 'gid://gitlab/WorkItem/444',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -83,7 +92,17 @@ export const updateWorkItemMutationResponse = {
|
|||
deleteWorkItem: false,
|
||||
updateWorkItem: false,
|
||||
},
|
||||
widgets: [],
|
||||
widgets: [
|
||||
{
|
||||
children: {
|
||||
edges: [
|
||||
{
|
||||
node: 'gid://gitlab/WorkItem/444',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -136,6 +155,15 @@ export const workItemResponseFactory = ({
|
|||
iid: '5',
|
||||
title: 'Parent title',
|
||||
},
|
||||
children: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
id: 'gid://gitlab/WorkItem/444',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -378,12 +406,14 @@ export const availableWorkItemsResponse = {
|
|||
node: {
|
||||
id: 'gid://gitlab/WorkItem/458',
|
||||
title: 'Task 1',
|
||||
state: 'OPEN',
|
||||
},
|
||||
},
|
||||
{
|
||||
node: {
|
||||
id: 'gid://gitlab/WorkItem/459',
|
||||
title: 'Task 2',
|
||||
state: 'OPEN',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -2,9 +2,6 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
require 'google/rpc/status_pb'
|
||||
require 'google/protobuf/well_known_types'
|
||||
|
||||
RSpec.describe Gitlab::GitalyClient::OperationService do
|
||||
let_it_be(:user) { create(:user) }
|
||||
let_it_be(:project) { create(:project, :repository) }
|
||||
|
@ -816,14 +813,4 @@ RSpec.describe Gitlab::GitalyClient::OperationService do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
def new_detailed_error(error_code, error_message, details)
|
||||
status_error = Google::Rpc::Status.new(
|
||||
code: error_code,
|
||||
message: error_message,
|
||||
details: [Google::Protobuf::Any.pack(details)]
|
||||
)
|
||||
|
||||
GRPC::BadStatus.new(error_code, error_message, { "grpc-status-details-bin" => Google::Rpc::Status.encode(status_error) })
|
||||
end
|
||||
end
|
||||
|
|
|
@ -258,13 +258,54 @@ RSpec.describe Gitlab::GitalyClient::RefService do
|
|||
describe '#delete_refs' do
|
||||
let(:prefixes) { %w(refs/heads refs/keep-around) }
|
||||
|
||||
subject(:delete_refs) { client.delete_refs(except_with_prefixes: prefixes) }
|
||||
|
||||
it 'sends a delete_refs message' do
|
||||
expect_any_instance_of(Gitaly::RefService::Stub)
|
||||
.to receive(:delete_refs)
|
||||
.with(gitaly_request_with_params(except_with_prefix: prefixes), kind_of(Hash))
|
||||
.and_return(double('delete_refs_response', git_error: ""))
|
||||
|
||||
client.delete_refs(except_with_prefixes: prefixes)
|
||||
delete_refs
|
||||
end
|
||||
|
||||
context 'with a references locked error' do
|
||||
let(:references_locked_error) do
|
||||
new_detailed_error(
|
||||
GRPC::Core::StatusCodes::FAILED_PRECONDITION,
|
||||
"error message",
|
||||
Gitaly::DeleteRefsError.new(references_locked: Gitaly::ReferencesLockedError.new))
|
||||
end
|
||||
|
||||
it 'raises ReferencesLockedError' do
|
||||
expect_any_instance_of(Gitaly::RefService::Stub).to receive(:delete_refs)
|
||||
.with(gitaly_request_with_params(except_with_prefix: prefixes), kind_of(Hash))
|
||||
.and_raise(references_locked_error)
|
||||
|
||||
expect { delete_refs }.to raise_error(Gitlab::Git::ReferencesLockedError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a invalid format error' do
|
||||
let(:invalid_refs) {['\invali.\d/1', '\.invali/d/2']}
|
||||
let(:invalid_reference_format_error) do
|
||||
new_detailed_error(
|
||||
GRPC::Core::StatusCodes::INVALID_ARGUMENT,
|
||||
"error message",
|
||||
Gitaly::DeleteRefsError.new(invalid_format: Gitaly::InvalidRefFormatError.new(refs: invalid_refs)))
|
||||
end
|
||||
|
||||
it 'raises InvalidRefFormatError' do
|
||||
expect_any_instance_of(Gitaly::RefService::Stub)
|
||||
.to receive(:delete_refs)
|
||||
.with(gitaly_request_with_params(except_with_prefix: prefixes), kind_of(Hash))
|
||||
.and_raise(invalid_reference_format_error)
|
||||
|
||||
expect { delete_refs }.to raise_error do |error|
|
||||
expect(error).to be_a(Gitlab::Git::InvalidRefFormatError)
|
||||
expect(error.message).to eq("references have an invalid format: #{invalid_refs.join(",")}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -545,4 +545,44 @@ RSpec.describe Gitlab::GitalyClient do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.decode_detailed_error' do
|
||||
let(:detailed_error) do
|
||||
new_detailed_error(GRPC::Core::StatusCodes::INVALID_ARGUMENT,
|
||||
"error message",
|
||||
Gitaly::InvalidRefFormatError.new)
|
||||
end
|
||||
|
||||
let(:error_without_details) do
|
||||
error_code = GRPC::Core::StatusCodes::INVALID_ARGUMENT
|
||||
error_message = "error message"
|
||||
|
||||
status_error = Google::Rpc::Status.new(
|
||||
code: error_code,
|
||||
message: error_message,
|
||||
details: nil
|
||||
)
|
||||
|
||||
GRPC::BadStatus.new(
|
||||
error_code,
|
||||
error_message,
|
||||
{ "grpc-status-details-bin" => Google::Rpc::Status.encode(status_error) })
|
||||
end
|
||||
|
||||
context 'decodes a structured error' do
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
||||
where(:error, :result) do
|
||||
detailed_error | Gitaly::InvalidRefFormatError.new
|
||||
error_without_details | nil
|
||||
StandardError.new | nil
|
||||
end
|
||||
|
||||
with_them do
|
||||
it 'returns correct detailed error' do
|
||||
expect(described_class.decode_detailed_error(error)).to eq(result)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -204,6 +204,7 @@ RSpec.configure do |config|
|
|||
config.include SnowplowHelpers
|
||||
config.include RenderedHelpers
|
||||
config.include RSpec::Benchmark::Matchers, type: :benchmark
|
||||
config.include DetailedErrorHelpers
|
||||
|
||||
include StubFeatureFlags
|
||||
include StubSnowplow
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'google/rpc/status_pb'
|
||||
require 'google/protobuf/well_known_types'
|
||||
|
||||
module DetailedErrorHelpers
|
||||
def new_detailed_error(error_code, error_message, details)
|
||||
status_error = Google::Rpc::Status.new(
|
||||
code: error_code,
|
||||
message: error_message,
|
||||
details: [Google::Protobuf::Any.pack(details)]
|
||||
)
|
||||
|
||||
GRPC::BadStatus.new(
|
||||
error_code,
|
||||
error_message,
|
||||
{ "grpc-status-details-bin" => Google::Rpc::Status.encode(status_error) })
|
||||
end
|
||||
end
|
|
@ -67,14 +67,6 @@ func (b *Builder) WithError(err error) *Builder {
|
|||
return b
|
||||
}
|
||||
|
||||
func Debug(args ...interface{}) {
|
||||
NewBuilder().Debug(args...)
|
||||
}
|
||||
|
||||
func (b *Builder) Debug(args ...interface{}) {
|
||||
b.entry.Debug(args...)
|
||||
}
|
||||
|
||||
func Info(args ...interface{}) {
|
||||
NewBuilder().Info(args...)
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ import (
|
|||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
|
@ -15,7 +14,6 @@ func captureLogs(b *Builder, testFn func()) string {
|
|||
buf := &bytes.Buffer{}
|
||||
|
||||
logger := b.entry.Logger
|
||||
logger.SetLevel(logrus.DebugLevel)
|
||||
oldOut := logger.Out
|
||||
logger.Out = buf
|
||||
defer func() {
|
||||
|
@ -27,15 +25,6 @@ func captureLogs(b *Builder, testFn func()) string {
|
|||
return buf.String()
|
||||
}
|
||||
|
||||
func TestLogDebug(t *testing.T) {
|
||||
b := NewBuilder()
|
||||
logLine := captureLogs(b, func() {
|
||||
b.Debug("an observation")
|
||||
})
|
||||
|
||||
require.Regexp(t, `level=debug msg="an observation"`, logLine)
|
||||
}
|
||||
|
||||
func TestLogInfo(t *testing.T) {
|
||||
b := NewBuilder()
|
||||
logLine := captureLogs(b, func() {
|
||||
|
|
|
@ -23,7 +23,6 @@ import (
|
|||
apipkg "gitlab.com/gitlab-org/gitlab/workhorse/internal/api"
|
||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/config"
|
||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/helper"
|
||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/log"
|
||||
proxypkg "gitlab.com/gitlab-org/gitlab/workhorse/internal/proxy"
|
||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/rejectmethods"
|
||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/upload"
|
||||
|
@ -183,20 +182,16 @@ func (u *upstream) findGeoProxyRoute(cleanedPath string, r *http.Request) *route
|
|||
defer u.mu.RUnlock()
|
||||
|
||||
if u.geoProxyBackend.String() == "" {
|
||||
log.WithRequest(r).Debug("Geo Proxy: Not a Geo proxy")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Some routes are safe to serve from this GitLab instance
|
||||
for _, ro := range u.geoLocalRoutes {
|
||||
if ro.isMatch(cleanedPath, r) {
|
||||
log.WithRequest(r).Debug("Geo Proxy: Handle this request locally")
|
||||
return &ro
|
||||
}
|
||||
}
|
||||
|
||||
log.WithRequest(r).WithFields(log.Fields{"geoProxyBackend": u.geoProxyBackend}).Debug("Geo Proxy: Forward this request")
|
||||
|
||||
if cleanedPath == "/-/cable" {
|
||||
return &u.geoProxyCableRoute
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue