Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-07-15 06:09:57 +00:00
parent 08e3d71512
commit 4b41b57abf
39 changed files with 1031 additions and 215 deletions

View File

@ -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);

View File

@ -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>

View File

@ -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');

View File

@ -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),
},
}),
});
}
}

View File

@ -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>

View File

@ -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"

View File

@ -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>

View File

@ -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
}
}
}

View File

@ -5,5 +5,6 @@ mutation workItemUpdate($input: WorkItemUpdateInput!) {
workItem {
...WorkItem
}
errors
}
}

View File

@ -35,6 +35,13 @@ fragment WorkItem on WorkItem {
iid
title
}
children {
edges {
node {
id
}
}
}
}
}
}

View File

@ -506,8 +506,7 @@
max-width: unset;
}
.no-emoji-placeholder,
.clear-user-status {
.no-emoji-placeholder {
svg {
fill: var(--gray-500, $gray-500);
}

View File

@ -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

View File

@ -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')} }

View File

@ -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 } }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
```

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 ""

View File

@ -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

View File

@ -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>
`;

View File

@ -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);
});
});
});

View File

@ -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);
});
});

View File

@ -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();
});
});

View File

@ -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', () => {

View File

@ -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',
},
},
],

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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...)
}

View File

@ -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() {

View File

@ -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
}