Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-08-09 15:11:41 +00:00
parent 1faea1c6a0
commit d56569ff3e
47 changed files with 1239 additions and 384 deletions

View File

@ -31,6 +31,10 @@
- assets_compile_script
- echo -n "${GITLAB_ASSETS_HASH}" > "cached-assets-hash.txt"
.update-cache-base:
after_script:
- yarn patch-package --reverse # To avoid caching patched modules
compile-production-assets:
extends:
- .compile-assets-base
@ -47,8 +51,6 @@ compile-production-assets:
- public/assets/
- "${WEBPACK_COMPILE_LOG_PATH}"
when: always
after_script:
- rm -f /etc/apt/sources.list.d/google*.list # We don't need to update Chrome here
compile-production-assets as-if-foss:
extends:
@ -77,6 +79,7 @@ compile-test-assets as-if-foss:
update-assets-compile-production-cache:
extends:
- compile-production-assets
- .update-cache-base
- .assets-compile-cache-push
- .shared:rules:update-cache
stage: prepare
@ -85,6 +88,7 @@ update-assets-compile-production-cache:
update-assets-compile-test-cache:
extends:
- compile-test-assets
- .update-cache-base
- .assets-compile-cache-push
- .shared:rules:update-cache
stage: prepare
@ -94,6 +98,7 @@ update-storybook-yarn-cache:
extends:
- .default-retry
- .default-utils-before_script
- .update-cache-base
- .storybook-yarn-cache-push
- .shared:rules:update-cache
stage: prepare

View File

@ -1 +1 @@
0ec311c007a78701fa4ee2ed8d58ca686378fcf0
38aa3f1b70406c72e9ab26389cbdecbdd3218a0a

View File

@ -0,0 +1,90 @@
<script>
import * as Sentry from '@sentry/browser';
import { uniqueId } from 'lodash';
import { GlButton, GlTooltipDirective, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
import { createAlert } from '~/alert';
import { __ } from '~/locale';
import deleteCustomEmojiMutation from '../queries/delete_custom_emoji.mutation.graphql';
export default {
name: 'DeleteItem',
components: {
GlButton,
GlModal,
GlSprintf,
},
directives: {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
props: {
emoji: {
type: Object,
required: true,
},
},
data() {
return {
isDeleting: false,
modalId: uniqueId('delete-custom-emoji-'),
};
},
methods: {
showModal() {
this.$refs['delete-modal'].show();
},
async onDelete() {
this.isDeleting = true;
try {
await this.$apollo.mutate({
mutation: deleteCustomEmojiMutation,
variables: {
id: this.emoji.id,
},
update: (cache) => {
const cacheId = cache.identify(this.emoji);
cache.evict({ id: cacheId });
},
});
} catch (e) {
createAlert(__('Failed to delete custom emoji. Please try again.'));
Sentry.captureException(e);
}
},
},
actionPrimary: { text: __('Delete'), attributes: { variant: 'danger' } },
actionSecondary: { text: __('Cancel'), attributes: { variant: 'default' } },
};
</script>
<template>
<div>
<gl-button
v-gl-tooltip
icon="remove"
:aria-label="__('Delete custom emoji')"
:title="__('Delete custom emoji')"
:loading="isDeleting"
data-testid="delete-button"
@click="showModal"
/>
<gl-modal
ref="delete-modal"
:title="__('Delete custom emoji')"
:action-primary="$options.actionPrimary"
:action-secondary="$options.actionSecondary"
:modal-id="modalId"
size="sm"
@primary="onDelete"
>
<gl-sprintf
:message="__('Are you sure you want to delete %{name}? This action cannot be undone.')"
>
<template #name
><strong>{{ emoji.name }}</strong></template
>
</gl-sprintf>
</gl-modal>
</div>
</template>

View File

@ -3,6 +3,7 @@
import { GlLoadingIcon, GlTableLite, GlTabs, GlTab, GlBadge, GlKeysetPagination } from '@gitlab/ui';
import { __ } from '~/locale';
import { formatDate } from '~/lib/utils/datetime/date_format_utility';
import DeleteItem from './delete_item.vue';
export default {
components: {
@ -12,6 +13,7 @@ export default {
GlTab,
GlBadge,
GlKeysetPagination,
DeleteItem,
},
props: {
loading: {
@ -124,7 +126,13 @@ export default {
data-unicode-version="custom"
/>
</template>
<template #cell(action)> </template>
<template #cell(action)="data">
<delete-item
v-if="data.item.userPermissions.deleteCustomEmoji"
:key="data.item.name"
:emoji="data.item"
/>
</template>
<template #cell(created_at)="data">
{{ formatDate(data.item.createdAt) }}
</template>

View File

@ -16,6 +16,9 @@ query getCustomEmojis($groupPath: ID!, $after: String = "", $before: String = ""
name
url
createdAt
userPermissions {
deleteCustomEmoji
}
}
}
}

View File

@ -0,0 +1,7 @@
mutation deleteCustomEmoji($id: CustomEmojiID!) {
destroyCustomEmoji(input: { id: $id }) {
customEmoji {
id
}
}
}

View File

@ -26,7 +26,7 @@ export default {
<template>
<a
v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.homepage"
v-gl-tooltip:super-sidebar.hover.noninteractive.bottom.ds500="$options.i18n.homepage"
class="brand-logo"
:href="rootPath"
:title="$options.i18n.homepage"

View File

@ -95,6 +95,7 @@ export default {
:target="`#${$options.toggleId}`"
placement="bottom"
container="#super-sidebar"
noninteractive
>
{{ $options.i18n.createNew }}
</gl-tooltip>

View File

@ -168,7 +168,7 @@ export default {
</gl-badge>
<gl-button
v-if="isPinnable && !isPinned"
v-gl-tooltip.right.viewport="$options.i18n.pinItem"
v-gl-tooltip.noninteractive.ds500.right.viewport="$options.i18n.pinItem"
size="small"
category="tertiary"
icon="thumbtack"
@ -177,7 +177,7 @@ export default {
/>
<gl-button
v-else-if="isPinnable && isPinned"
v-gl-tooltip.right.viewport="$options.i18n.unpinItem"
v-gl-tooltip.noninteractive.ds500.right.viewport="$options.i18n.unpinItem"
size="small"
category="tertiary"
:aria-label="$options.i18n.unpinItem"

View File

@ -74,7 +74,7 @@ export default {
<template>
<gl-button
v-gl-tooltip.hover="tooltip"
v-gl-tooltip.hover.noninteractive.ds500="tooltip"
aria-controls="super-sidebar"
:aria-expanded="ariaExpanded"
:aria-label="$options.i18n.navigationSidebar"

View File

@ -130,7 +130,7 @@ export default {
<gl-button
id="super-sidebar-search"
v-gl-tooltip.bottom.hover.html="searchTooltip"
v-gl-tooltip.bottom.hover.noninteractive.ds500.html="searchTooltip"
v-gl-modal="$options.SEARCH_MODAL_ID"
data-testid="super-sidebar-search-button"
icon="search"
@ -143,7 +143,7 @@ export default {
<gl-button
v-if="isImpersonating"
v-gl-tooltip
v-gl-tooltip.noninteractive.ds500.bottom
:href="sidebarData.stop_impersonation_path"
:title="$options.i18n.stopImpersonating"
:aria-label="$options.i18n.stopImpersonating"
@ -159,7 +159,7 @@ export default {
class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2"
>
<counter
v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.issues"
v-gl-tooltip:super-sidebar.hover.noninteractive.ds500.bottom="$options.i18n.issues"
class="gl-flex-basis-third dashboard-shortcuts-issues"
icon="issues"
:count="userCounts.assigned_issues"
@ -177,7 +177,9 @@ export default {
@hidden="mrMenuShown = false"
>
<counter
v-gl-tooltip:super-sidebar.hover.bottom="mrMenuShown ? '' : $options.i18n.mergeRequests"
v-gl-tooltip:super-sidebar.hover.noninteractive.ds500.bottom="
mrMenuShown ? '' : $options.i18n.mergeRequests
"
class="gl-w-full"
icon="merge-request-open"
:count="mergeRequestTotalCount"
@ -189,7 +191,7 @@ export default {
/>
</merge-request-menu>
<counter
v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.todoList"
v-gl-tooltip:super-sidebar.hover.noninteractive.ds500.bottom="$options.i18n.todoList"
class="gl-flex-basis-third shortcuts-todos js-todos-count"
icon="todo-done"
:count="userCounts.todos"

View File

@ -0,0 +1,196 @@
<script>
import { GlLabel, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import { isScopedLabel } from '~/lib/utils/common_utils';
import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue';
import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/shared/work_item_link_child_metadata.vue';
import {
STATE_OPEN,
TASK_TYPE_NAME,
WIDGET_TYPE_PROGRESS,
WIDGET_TYPE_HIERARCHY,
WIDGET_TYPE_HEALTH_STATUS,
WIDGET_TYPE_MILESTONE,
WIDGET_TYPE_ASSIGNEES,
WIDGET_TYPE_LABELS,
WORK_ITEM_NAME_TO_ICON_MAP,
} from '../../constants';
import WorkItemLinksMenu from './work_item_links_menu.vue';
export default {
i18n: {
confidential: __('Confidential'),
created: __('Created'),
closed: __('Closed'),
},
components: {
GlLabel,
GlLink,
GlIcon,
RichTimestampTooltip,
WorkItemLinkChildMetadata,
WorkItemLinksMenu,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
childItem: {
type: Object,
required: true,
},
canUpdate: {
type: Boolean,
required: true,
},
parentWorkItemId: {
type: String,
required: true,
},
workItemType: {
type: String,
required: false,
default: '',
},
childPath: {
type: String,
required: true,
},
},
computed: {
labels() {
return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || [];
},
metadataWidgets() {
return this.childItem.widgets?.reduce((metadataWidgets, widget) => {
// Skip Hierarchy widget as it is not part of metadata.
if (widget.type && widget.type !== WIDGET_TYPE_HIERARCHY) {
// eslint-disable-next-line no-param-reassign
metadataWidgets[widget.type] = widget;
}
return metadataWidgets;
}, {});
},
allowsScopedLabels() {
return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels;
},
isChildItemOpen() {
return this.childItem.state === STATE_OPEN;
},
iconName() {
if (this.childItemType === TASK_TYPE_NAME) {
return this.isChildItemOpen ? 'issue-open-m' : 'issue-close';
}
return WORK_ITEM_NAME_TO_ICON_MAP[this.childItemType];
},
childItemType() {
return this.childItem.workItemType.name;
},
iconClass() {
if (this.childItemType === TASK_TYPE_NAME) {
return this.isChildItemOpen ? 'gl-text-green-500' : 'gl-text-blue-500';
}
return '';
},
stateTimestamp() {
return this.isChildItemOpen ? this.childItem.createdAt : this.childItem.closedAt;
},
stateTimestampTypeText() {
return this.isChildItemOpen ? this.$options.i18n.created : this.$options.i18n.closed;
},
hasMetadata() {
if (this.metadataWidgets) {
return (
Number.isInteger(this.metadataWidgets[WIDGET_TYPE_PROGRESS]?.progress) ||
Boolean(this.metadataWidgets[WIDGET_TYPE_HEALTH_STATUS]?.healthStatus) ||
Boolean(this.metadataWidgets[WIDGET_TYPE_MILESTONE]?.milestone) ||
this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes.length > 0 ||
this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes.length > 0
);
}
return false;
},
},
methods: {
showScopedLabel(label) {
return isScopedLabel(label) && this.allowsScopedLabels;
},
},
};
</script>
<template>
<div
class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-mx-n2 gl-rounded-base"
data-testid="links-child"
>
<div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0">
<div
class="gl-display-flex gl-flex-grow-1 gl-flex-wrap flex-xl-nowrap gl-align-items-center gl-justify-content-space-between gl-gap-3 gl-min-w-0"
>
<div class="item-title gl-display-flex gl-gap-3 gl-min-w-0">
<span
:id="`stateIcon-${childItem.id}`"
class="gl-cursor-help"
data-testid="item-status-icon"
>
<gl-icon
class="gl-text-secondary"
:class="iconClass"
:name="iconName"
:aria-label="stateTimestampTypeText"
/>
</span>
<rich-timestamp-tooltip
:target="`stateIcon-${childItem.id}`"
:raw-timestamp="stateTimestamp"
:timestamp-type-text="stateTimestampTypeText"
/>
<span v-if="childItem.confidential">
<gl-icon
v-gl-tooltip.top
name="eye-slash"
class="gl-text-orange-500"
data-testid="confidential-icon"
:aria-label="$options.i18n.confidential"
:title="$options.i18n.confidential"
/>
</span>
<gl-link
:href="childPath"
class="gl-text-truncate gl-text-black-normal! gl-font-weight-semibold"
data-testid="item-title"
@click="$emit('click', $event)"
@mouseover="$emit('mouseover')"
@mouseout="$emit('mouseout')"
>
{{ childItem.title }}
</gl-link>
</div>
<work-item-link-child-metadata
v-if="hasMetadata"
:metadata-widgets="metadataWidgets"
class="gl-ml-6 ml-xl-0"
/>
</div>
<div v-if="labels.length" class="gl-display-flex gl-flex-wrap gl-flex-basis-full gl-ml-6">
<gl-label
v-for="label in labels"
:key="label.id"
:title="label.title"
:background-color="label.color"
:description="label.description"
:scoped="showScopedLabel(label)"
class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm"
tooltip-placement="top"
/>
</div>
</div>
<div v-if="canUpdate" class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex">
<work-item-links-menu
data-testid="links-menu"
@removeChild="$emit('removeChild', childItem)"
/>
</div>
</div>
</template>

View File

@ -1,39 +1,27 @@
<script>
import { GlButton, GlLabel, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import * as Sentry from '@sentry/browser';
import { __, s__ } from '~/locale';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { createAlert } from '~/alert';
import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue';
import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/work_item_links/work_item_link_child_metadata.vue';
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
import {
STATE_OPEN,
TASK_TYPE_NAME,
WORK_ITEM_TYPE_VALUE_OBJECTIVE,
WIDGET_TYPE_PROGRESS,
WIDGET_TYPE_HEALTH_STATUS,
WIDGET_TYPE_MILESTONE,
WIDGET_TYPE_HIERARCHY,
WIDGET_TYPE_ASSIGNEES,
WIDGET_TYPE_LABELS,
WORK_ITEM_NAME_TO_ICON_MAP,
} from '../../constants';
import getWorkItemTreeQuery from '../../graphql/work_item_tree.query.graphql';
import WorkItemLinksMenu from './work_item_links_menu.vue';
import WorkItemLinkChildContents from '../shared/work_item_link_child_contents.vue';
import WorkItemTreeChildren from './work_item_tree_children.vue';
export default {
components: {
GlLabel,
GlLink,
GlButton,
GlIcon,
RichTimestampTooltip,
WorkItemLinkChildMetadata,
WorkItemLinksMenu,
WorkItemTreeChildren,
WorkItemLinkChildContents,
},
directives: {
GlTooltip: GlTooltipDirective,
@ -74,25 +62,9 @@ export default {
};
},
computed: {
labels() {
return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || [];
},
allowsScopedLabels() {
return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels;
},
canHaveChildren() {
return this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE;
},
metadataWidgets() {
return this.childItem.widgets?.reduce((metadataWidgets, widget) => {
// Skip Hierarchy widget as it is not part of metadata.
if (widget.type && widget.type !== WIDGET_TYPE_HIERARCHY) {
// eslint-disable-next-line no-param-reassign
metadataWidgets[widget.type] = widget;
}
return metadataWidgets;
}, {});
},
isItemOpen() {
return this.childItem.state === STATE_OPEN;
},
@ -126,18 +98,6 @@ export default {
chevronTooltip() {
return this.isExpanded ? __('Collapse') : __('Expand');
},
hasMetadata() {
if (this.metadataWidgets) {
return (
Number.isInteger(this.metadataWidgets[WIDGET_TYPE_PROGRESS]?.progress) ||
Boolean(this.metadataWidgets[WIDGET_TYPE_HEALTH_STATUS]?.healthStatus) ||
Boolean(this.metadataWidgets[WIDGET_TYPE_MILESTONE]?.milestone) ||
this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes.length > 0 ||
this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes.length > 0
);
}
return false;
},
},
watch: {
childItem: {
@ -270,81 +230,15 @@ export default {
data-testid="expand-child"
@click="toggleItem"
/>
<div
class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-mx-n2 gl-rounded-base"
data-testid="links-child"
>
<div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0">
<div
class="gl-display-flex gl-flex-grow-1 gl-flex-wrap flex-xl-nowrap gl-align-items-center gl-justify-content-space-between gl-gap-3 gl-min-w-0"
>
<div class="item-title gl-display-flex gl-gap-3 gl-min-w-0">
<span
:id="`stateIcon-${childItem.id}`"
class="gl-cursor-help"
data-testid="item-status-icon"
>
<gl-icon
class="gl-text-secondary"
:class="iconClass"
:name="iconName"
:aria-label="stateTimestampTypeText"
/>
</span>
<rich-timestamp-tooltip
:target="`stateIcon-${childItem.id}`"
:raw-timestamp="stateTimestamp"
:timestamp-type-text="stateTimestampTypeText"
/>
<span v-if="childItem.confidential">
<gl-icon
v-gl-tooltip.top
name="eye-slash"
class="gl-text-orange-500"
data-testid="confidential-icon"
:aria-label="__('Confidential')"
:title="__('Confidential')"
/>
</span>
<gl-link
:href="childPath"
class="gl-text-truncate gl-text-black-normal! gl-font-weight-semibold"
data-testid="item-title"
@click="$emit('click', $event)"
@mouseover="$emit('mouseover')"
@mouseout="$emit('mouseout')"
>
{{ childItem.title }}
</gl-link>
</div>
<work-item-link-child-metadata
v-if="hasMetadata"
:metadata-widgets="metadataWidgets"
class="gl-ml-6 ml-xl-0"
/>
</div>
<div v-if="labels.length" class="gl-display-flex gl-flex-wrap gl-flex-basis-full gl-ml-6">
<gl-label
v-for="label in labels"
:key="label.id"
:title="label.title"
:background-color="label.color"
:description="label.description"
:scoped="showScopedLabel(label)"
class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm"
tooltip-placement="top"
/>
</div>
</div>
<div v-if="canUpdate" class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex">
<work-item-links-menu
:work-item-id="childItem.id"
:parent-work-item-id="issuableGid"
data-testid="links-menu"
@removeChild="$emit('removeChild', childItem)"
/>
</div>
</div>
<work-item-link-child-contents
:child-item="childItem"
:can-update="canUpdate"
:parent-work-item-id="issuableGid"
:work-item-type="workItemType"
:child-path="childPath"
@click="$emit('click', $event)"
@removeChild="$emit('removeChild', childItem)"
/>
</div>
<work-item-tree-children
v-if="isExpanded"

View File

@ -1,20 +1,20 @@
# frozen_string_literal: true
module TimeHelper
def time_interval_in_words(interval_in_seconds)
interval_in_seconds = interval_in_seconds.to_i
minutes = interval_in_seconds / 60
seconds = interval_in_seconds - minutes * 60
TIME_UNIT_TRANSLATION = {
seconds: ->(seconds) { n_('%d second', '%d seconds', seconds) % seconds },
minutes: ->(minutes) { n_('%d minute', '%d minutes', minutes) % minutes },
hours: ->(hours) { n_('%d hour', '%d hours', hours) % hours },
days: ->(days) { n_('%d day', '%d days', days) % days },
weeks: ->(weeks) { n_('%d week', '%d weeks', weeks) % weeks },
months: ->(months) { n_('%d month', '%d months', months) % months },
years: ->(years) { n_('%d year', '%d years', years) % years }
}.freeze
if minutes >= 1
if seconds % 60 == 0
n_('%d minute', '%d minutes', minutes) % minutes
else
[n_('%d minute', '%d minutes', minutes) % minutes, n_('%d second', '%d seconds', seconds) % seconds].to_sentence
end
else
n_('%d second', '%d seconds', seconds) % seconds
end
def time_interval_in_words(interval_in_seconds)
time_parts = ActiveSupport::Duration.build(interval_in_seconds.to_i).parts
time_parts.map { |unit, value| TIME_UNIT_TRANSLATION[unit].call(value) }.to_sentence
end
def duration_in_numbers(duration_in_seconds)

View File

@ -0,0 +1,130 @@
# frozen_string_literal: true
module MergeRequests
# CreateRefService creates or overwrites a ref under "refs/merge-requests/"
# with a commit for the merged result.
class CreateRefService
include Gitlab::Utils::StrongMemoize
CreateRefError = Class.new(StandardError)
def initialize(
current_user:, merge_request:, target_ref:, first_parent_ref:,
source_sha: nil, merge_commit_message: nil)
@current_user = current_user
@merge_request = merge_request
@initial_source_sha = source_sha
@target_ref = target_ref
@merge_commit_message = merge_commit_message
@first_parent_sha = target_project.commit(first_parent_ref)&.sha
end
def execute
commit_sha = initial_source_sha # the SHA to be at HEAD of target_ref
source_sha = initial_source_sha # the SHA to be the merged result of the source (minus the merge commit)
expected_old_oid = "" # the SHA we expect target_ref to be at prior to an update (an optimistic lock)
# TODO: Update this message with the removal of FF merge_trains_create_ref_service and update tests
# This is for compatibility with MergeToRefService during the rollout.
return ServiceResponse.error(message: '3:Invalid merge source') unless first_parent_sha.present?
commit_sha, source_sha, expected_old_oid = maybe_squash!(commit_sha, source_sha, expected_old_oid)
commit_sha, source_sha, expected_old_oid = maybe_rebase!(commit_sha, source_sha, expected_old_oid)
commit_sha, source_sha = maybe_merge!(commit_sha, source_sha, expected_old_oid)
ServiceResponse.success(
payload: {
commit_sha: commit_sha,
target_sha: first_parent_sha,
source_sha: source_sha
}
)
rescue CreateRefError => error
ServiceResponse.error(message: error.message)
end
private
attr_reader :current_user, :merge_request, :target_ref, :first_parent_sha, :initial_source_sha
delegate :target_project, to: :merge_request
delegate :repository, to: :target_project
def maybe_squash!(commit_sha, source_sha, expected_old_oid)
if merge_request.squash_on_merge?
squash_result = MergeRequests::SquashService.new(
merge_request: merge_request,
current_user: current_user,
commit_message: squash_commit_message
).execute
raise CreateRefError, squash_result[:message] if squash_result[:status] == :error
commit_sha = squash_result[:squash_sha]
source_sha = commit_sha
end
# squash does not overwrite target_ref, so expected_old_oid remains the same
[commit_sha, source_sha, expected_old_oid]
end
def maybe_rebase!(commit_sha, source_sha, expected_old_oid)
if target_project.ff_merge_must_be_possible?
commit_sha = safe_gitaly_operation do
repository.rebase_to_ref(
current_user,
source_sha: source_sha,
target_ref: target_ref,
first_parent_ref: first_parent_sha
)
end
source_sha = commit_sha
expected_old_oid = commit_sha
end
[commit_sha, source_sha, expected_old_oid]
end
def maybe_merge!(commit_sha, source_sha, expected_old_oid)
unless target_project.merge_requests_ff_only_enabled
source_sha = safe_gitaly_operation do
repository.merge_to_ref(
current_user,
source_sha: source_sha,
target_ref: target_ref,
message: merge_commit_message,
first_parent_ref: first_parent_sha,
branch: nil,
expected_old_oid: expected_old_oid
)
end
commit = target_project.commit(commit_sha)
_, source_sha = commit.parent_ids
end
[commit_sha, source_sha]
end
def safe_gitaly_operation
yield
rescue Gitlab::Git::PreReceiveError, Gitlab::Git::CommandError, ArgumentError => error
raise CreateRefError, error.message
end
def squash_commit_message
merge_request.merge_params['squash_commit_message'].presence ||
merge_request.default_squash_commit_message(user: current_user)
end
strong_memoize_attr :squash_commit_message
def merge_commit_message
return @merge_commit_message if @merge_commit_message.present?
@merge_commit_message = (
merge_request.merge_params['commit_message'].presence ||
merge_request.default_merge_commit_message(user: current_user)
)
end
end
end

View File

@ -0,0 +1,8 @@
---
name: merge_trains_create_ref_service
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/127531
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/420161
milestone: '16.3'
type: development
group: group::pipeline execution
default_enabled: false

View File

@ -143,6 +143,8 @@
- 1
- - compliance_management_merge_requests_compliance_violations
- 1
- - compliance_management_standards_gitlab_at_least_two_approvals
- 1
- - compliance_management_standards_gitlab_base
- 1
- - compliance_management_standards_gitlab_group_base

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class AddIndexCustomEmailVerificationsOnTriggeredAtAndStateStarted < Gitlab::Database::Migration[2.1]
disable_ddl_transaction!
INDEX_NAME = 'i_custom_email_verifications_on_triggered_at_and_state_started'
def up
add_concurrent_index :service_desk_custom_email_verifications, :triggered_at,
where: 'state = 0',
name: INDEX_NAME
end
def down
remove_concurrent_index_by_name :service_desk_custom_email_verifications, INDEX_NAME
end
end

View File

@ -0,0 +1 @@
e63bf851a7a66f8aa0823e5c8f41eba3829494081dda54e7e39b265b0676d4da

View File

@ -30201,6 +30201,8 @@ CREATE INDEX i_compliance_violations_on_project_id_severity_and_id ON merge_requ
CREATE INDEX i_compliance_violations_on_project_id_title_and_id ON merge_requests_compliance_violations USING btree (target_project_id, title, id);
CREATE INDEX i_custom_email_verifications_on_triggered_at_and_state_started ON service_desk_custom_email_verifications USING btree (triggered_at) WHERE (state = 0);
CREATE INDEX i_dast_pre_scan_verification_steps_on_pre_scan_verification_id ON dast_pre_scan_verification_steps USING btree (dast_pre_scan_verification_id);
CREATE INDEX i_dast_profiles_tags_on_scanner_profiles_id ON dast_profiles_tags USING btree (dast_profile_id);

View File

@ -33,24 +33,23 @@ System: Ubuntu 20.04
Proxy: no
Current User: git
Using RVM: no
Ruby Version: 2.6.6p146
Gem Version: 2.7.10
Bundler Version:1.17.3
Rake Version: 12.3.3
Redis Version: 5.0.9
Git Version: 2.27.0
Sidekiq Version:5.2.9
Ruby Version: 2.7.6p219
Gem Version: 3.1.6
Bundler Version:2.3.15
Rake Version: 13.0.6
Redis Version: 6.2.7
Sidekiq Version:6.4.2
Go Version: unknown
GitLab information
Version: 13.2.2-ee
Revision: 618883a1f9d
Version: 15.5.5-ee
Revision: 5f5109f142d
Directory: /opt/gitlab/embedded/service/gitlab-rails
DB Adapter: PostgreSQL
DB Version: 11.7
URL: http://gitlab.example.com
HTTP Clone URL: http://gitlab.example.com/some-group/some-project.git
SSH Clone URL: git@gitlab.example.com:some-group/some-project.git
DB Version: 13.8
URL: https://app.gitaly.gcp.gitlabsandbox.net
HTTP Clone URL: https://app.gitaly.gcp.gitlabsandbox.net/some-group/some-project.git
SSH Clone URL: git@app.gitaly.gcp.gitlabsandbox.net:some-group/some-project.git
Elasticsearch: no
Geo: no
Using LDAP: no
@ -58,10 +57,20 @@ Using Omniauth: yes
Omniauth Providers:
GitLab Shell
Version: 13.3.0
Version: 14.12.0
Repository storage paths:
- default: /var/opt/gitlab/git-data/repositories
GitLab Shell path: /opt/gitlab/embedded/service/gitlab-shell
- default: /var/opt/gitlab/git-data/repositories
- gitaly: /var/opt/gitlab/git-data/repositories
GitLab Shell path: /opt/gitlab/embedded/service/gitlab-shell
Gitaly
- default Address: unix:/var/opt/gitlab/gitaly/gitaly.socket
- default Version: 15.5.5
- default Git Version: 2.37.1.gl1
- gitaly Address: tcp://10.128.20.6:2305
- gitaly Version: 15.5.5
- gitaly Git Version: 2.37.1.gl1
```
## Show GitLab license information **(PREMIUM SELF)**

View File

@ -25336,7 +25336,10 @@ Represents a progress widget.
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="workitemwidgetprogresscurrentvalue"></a>`currentValue` | [`Int`](#int) | Current value of the work item. |
| <a id="workitemwidgetprogressendvalue"></a>`endValue` | [`Int`](#int) | End value of the work item. |
| <a id="workitemwidgetprogressprogress"></a>`progress` | [`Int`](#int) | Progress of the work item. |
| <a id="workitemwidgetprogressstartvalue"></a>`startValue` | [`Int`](#int) | Start value of the work item. |
| <a id="workitemwidgetprogresstype"></a>`type` | [`WorkItemWidgetType`](#workitemwidgettype) | Widget type. |
| <a id="workitemwidgetprogressupdatedat"></a>`updatedAt` | [`Time`](#time) | Timestamp of last progress update. |
@ -25906,6 +25909,7 @@ Name of the check for the compliance standard.
| Value | Description |
| ----- | ----------- |
| <a id="compliancestandardsadherencechecknameat_least_two_approvals"></a>`AT_LEAST_TWO_APPROVALS` | At least two approvals. |
| <a id="compliancestandardsadherencechecknameprevent_approval_by_merge_request_author"></a>`PREVENT_APPROVAL_BY_MERGE_REQUEST_AUTHOR` | Prevent approval by merge request author. |
| <a id="compliancestandardsadherencechecknameprevent_approval_by_merge_request_committers"></a>`PREVENT_APPROVAL_BY_MERGE_REQUEST_COMMITTERS` | Prevent approval by merge request committers. |
@ -30308,6 +30312,8 @@ A time-frame defined as a closed inclusive range of two dates.
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="workitemwidgetprogressinputcurrentvalue"></a>`currentValue` | [`Int!`](#int) | Current progress value of the work item. |
| <a id="workitemwidgetprogressinputendvalue"></a>`endValue` | [`Int`](#int) | End value of the work item. |
| <a id="workitemwidgetprogressinputstartvalue"></a>`startValue` | [`Int`](#int) | Start value of the work item. |
### `WorkItemWidgetStartAndDueDateUpdateInput`

View File

@ -294,6 +294,7 @@ Example responses: **(PREMIUM SELF)**
> - Fields `housekeeping_full_repack_period`, `housekeeping_gc_period`, and `housekeeping_incremental_repack_period` [deprecated](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106963) in GitLab 15.8. Use `housekeeping_optimize_repository_period` instead.
> - Parameters `sign_in_text` and `help_text` were [deprecated](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/124461) in GitLab 16.2. Use `description` parameter in the [Appearance API](../api/appearance.md) instead.
> - Parameter `allow_account_deletion` [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/412411) in GitLab 16.1.
In general, all settings are optional. Certain settings though, if enabled,
require other settings to be set to function properly. These requirements are
@ -311,9 +312,10 @@ listed in the descriptions of the relevant settings.
| `after_sign_up_text` | string | no | Text shown to the user after signing up. |
| `akismet_api_key` | string | required by: `akismet_enabled` | API key for Akismet spam protection. |
| `akismet_enabled` | boolean | no | (**If enabled, requires:** `akismet_api_key`) Enable or disable Akismet spam protection. |
| `allow_group_owners_to_manage_ldap` **(PREMIUM)** | boolean | no | Set to `true` to allow group owners to manage LDAP. |
| `allow_local_requests_from_hooks_and_services` | boolean | no | (Deprecated: Use `allow_local_requests_from_web_hooks_and_services` instead) Allow requests to the local network from webhooks and integrations. |
| `allow_local_requests_from_system_hooks` | boolean | no | Allow requests to the local network from system hooks. |
| `allow_account_deletion` **(PREMIUM)** | boolean | no | Set to `true` to allow users to delete their accounts. |
| `allow_group_owners_to_manage_ldap` **(PREMIUM)** | boolean | no | Set to `true` to allow group owners to manage LDAP. |
| `allow_local_requests_from_hooks_and_services` | boolean | no | (Deprecated: Use `allow_local_requests_from_web_hooks_and_services` instead) Allow requests to the local network from webhooks and integrations. |
| `allow_local_requests_from_system_hooks` | boolean | no | Allow requests to the local network from system hooks. |
| `allow_local_requests_from_web_hooks_and_services` | boolean | no | Allow requests to the local network from webhooks and integrations. |
| `allow_runner_registration_token` | boolean | no | Allow using a registration token to create a runner. Defaults to `true`. |
| `archive_builds_in_human_readable` | string | no | Set the duration for which the jobs are considered as old and expired. After that time passes, the jobs are archived and no longer able to be retried. Make it empty to never expire jobs. It has to be no less than 1 day, for example: <code>15 days</code>, <code>1 month</code>, <code>2 years</code>. |

View File

@ -102,7 +102,7 @@ The following table lists project permissions available for each role:
| [Issues](project/issues/index.md):<br>Create [confidential issues](project/issues/confidential_issues.md) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [Issues](project/issues/index.md):<br>View [Design Management](project/issues/design_management.md) pages | ✓ | ✓ | ✓ | ✓ | ✓ |
| [Issues](project/issues/index.md):<br>View [related issues](project/issues/related_issues.md) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [Issues](project/issues/index.md):<br>Set [weight](project/issues/issue_weight.md) | ✓ (15) | ✓ | ✓ | ✓ | ✓ |
| [Issues](project/issues/index.md):<br>Set [weight](project/issues/issue_weight.md) | | ✓ | ✓ | ✓ | ✓ |
| [Issues](project/issues/index.md):<br>Set metadata such as labels, milestones, or assignees when creating an issue | ✓ (15) | ✓ | ✓ | ✓ | ✓ |
| [Issues](project/issues/index.md):<br>Edit metadata such labels, milestones, or assignees for an existing issue | (15) | ✓ | ✓ | ✓ | ✓ |
| [Issues](project/issues/index.md):<br>Set [parent epic](group/epics/manage_epics.md#add-an-existing-issue-to-an-epic) | | ✓ | ✓ | ✓ | ✓ |

View File

@ -10,21 +10,31 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> - [Enabled on GitLab.com and self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/391543) in GitLab 16.0.
FLAG:
On self-managed GitLab, by default this feature is available. To hide the feature, an administrator can [disable the feature flag](../../administration/feature_flags.md) named `remote_development_feature_flag`. On GitLab.com, this feature is available. The feature is not ready for production use.
On self-managed GitLab, by default this feature is available.
To hide the feature, an administrator can [disable the feature flag](../../administration/feature_flags.md) named `remote_development_feature_flag`.
On GitLab.com, this feature is available.
The feature is not ready for production use.
WARNING:
This feature is in [Beta](../../policy/experiment-beta-support.md#beta) and subject to change without notice. To leave feedback, see the [feedback issue](https://gitlab.com/gitlab-org/gitlab/-/issues/410031).
This feature is in [Beta](../../policy/experiment-beta-support.md#beta) and subject to change without notice.
To leave feedback, see the [feedback issue](https://gitlab.com/gitlab-org/gitlab/-/issues/410031).
You can use [workspaces](index.md) to create and manage isolated development environments for your GitLab projects. Each workspace includes its own set of dependencies, libraries, and tools, which you can customize to meet the specific needs of each project.
You can use [workspaces](index.md) to create and manage isolated development environments for your GitLab projects.
Each workspace includes its own set of dependencies, libraries, and tools,
which you can customize to meet the specific needs of each project.
## Set up a workspace
### Prerequisites
- Set up a Kubernetes cluster that the GitLab agent for Kubernetes supports. See the [supported Kubernetes versions](../clusters/agent/index.md#supported-kubernetes-versions-for-gitlab-features).
- Set up a Kubernetes cluster that the GitLab agent for Kubernetes supports.
See the [supported Kubernetes versions](../clusters/agent/index.md#supported-kubernetes-versions-for-gitlab-features).
- Ensure autoscaling for the Kubernetes cluster is enabled.
- In the Kubernetes cluster, verify that a [default storage class](https://kubernetes.io/docs/concepts/storage/storage-classes/) is defined so that volumes can be dynamically provisioned for each workspace.
- In the Kubernetes cluster, install an Ingress controller of your choice (for example, `ingress-nginx`) and make that controller accessible over a domain. For example, point `*.workspaces.example.dev` and `workspaces.example.dev` to the load balancer exposed by the Ingress controller.
- In the Kubernetes cluster, verify that a [default storage class](https://kubernetes.io/docs/concepts/storage/storage-classes/)
is defined so that volumes can be dynamically provisioned for each workspace.
- In the Kubernetes cluster, install an Ingress controller of your choice (for example, `ingress-nginx`)
and make that controller accessible over a domain. For example, point `*.workspaces.example.dev` and
`workspaces.example.dev` to the load balancer exposed by the Ingress controller.
- In the Kubernetes cluster, [install `gitlab-workspaces-proxy`](https://gitlab.com/gitlab-org/remote-development/gitlab-workspaces-proxy#installation-instructions).
- In the Kubernetes cluster, [install the GitLab agent for Kubernetes](../clusters/agent/install/index.md).
- Configure remote development settings for the GitLab agent with this snippet and update `dns_zone` as needed:
@ -35,11 +45,13 @@ You can use [workspaces](index.md) to create and manage isolated development env
dns_zone: "workspaces.example.dev"
```
You can use any agent defined under the root group of your project, provided that remote development is properly configured for that agent.
You can use any agent defined under the root group of your project,
provided that remote development is properly configured for that agent.
- You must have at least the Developer role in the root group.
- In each public project you want to use this feature for, create a [devfile](index.md#devfile):
1. On the left sidebar, at the top, select **Search GitLab** (**{search}**) to find your project
1. In the root directory of your project, create a file named `.devfile.yaml`. You can use one of the [example configurations](index.md#example-configurations).
1. On the left sidebar, at the top, select **Search GitLab** (**{search}**) to find your project.
1. In the root directory of your project, create a file named `.devfile.yaml`.
You can use one of the [example configurations](index.md#example-configurations).
- Ensure the container images used in the devfile support [arbitrary user IDs](index.md#arbitrary-user-ids).
### Create a workspace
@ -50,12 +62,15 @@ To create a workspace:
1. Select **Your work**.
1. Select **Workspaces**.
1. Select **New workspace**.
1. From the **Select project** dropdown list, [select a project with a `.devfile.yaml` file](#prerequisites). You can only create workspaces for public projects.
1. From the **Select project** dropdown list, [select a project with a `.devfile.yaml` file](#prerequisites).
You can only create workspaces for public projects.
1. From the **Select cluster agent** dropdown list, select a cluster agent owned by the group the project belongs to.
1. In **Time before automatic termination**, enter the number of hours until the workspace automatically terminates. This timeout is a safety measure to prevent a workspace from consuming excessive resources or running indefinitely.
1. In **Time before automatic termination**, enter the number of hours until the workspace automatically terminates.
This timeout is a safety measure to prevent a workspace from consuming excessive resources or running indefinitely.
1. Select **Create workspace**.
The workspace might take a few minutes to start. To open the workspace, under **Preview**, select the workspace.
The workspace might take a few minutes to start.
To open the workspace, under **Preview**, select the workspace.
You also have access to the terminal and can install any necessary dependencies.
## Connect to a workspace with SSH
@ -75,7 +90,8 @@ To connect to a workspace with an SSH client:
1. For the password, enter your personal access token with at least the `read_api` scope.
When you connect to `gitlab-workspaces-proxy` through the TCP load balancer, `gitlab-workspaces-proxy` examines the username (workspace name) and interacts with GitLab to verify:
When you connect to `gitlab-workspaces-proxy` through the TCP load balancer,
`gitlab-workspaces-proxy` examines the username (workspace name) and interacts with GitLab to verify:
- The personal access token
- User access to the workspace
@ -86,7 +102,8 @@ Prerequisite:
- You must have an SSH host key for client verification.
SSH is now enabled by default in [`gitlab-workspaces-proxy`](https://gitlab.com/gitlab-org/remote-development/gitlab-workspaces-proxy). To set up `gitlab-workspaces-proxy` with the GitLab Helm chart:
SSH is now enabled by default in [`gitlab-workspaces-proxy`](https://gitlab.com/gitlab-org/remote-development/gitlab-workspaces-proxy).
To set up `gitlab-workspaces-proxy` with the GitLab Helm chart:
1. Run this command:
@ -159,7 +176,8 @@ USER gitlab-workspaces
## Disable remote development in the GitLab agent for Kubernetes
You can stop the `remote_development` module of the GitLab agent for Kubernetes from communicating with GitLab. To disable remote development in the GitLab agent configuration, set this property:
You can stop the `remote_development` module of the GitLab agent for Kubernetes from communicating with GitLab.
To disable remote development in the GitLab agent configuration, set this property:
```yaml
remote_development:
@ -167,3 +185,23 @@ remote_development:
```
If you already have running workspaces, an administrator must manually delete these workspaces in Kubernetes.
## Related topics
- [Quickstart guide for GitLab remote development workspaces](https://go.gitlab.com/AVKFvy)
- [Set up your infrastructure for on-demand, cloud-based development environments in GitLab](https://go.gitlab.com/dp75xo)
## Troubleshooting
### `Failed to renew lease` when creating a workspace
You might not be able to create a workspace due to a known issue in the GitLab agent for Kubernetes.
The following error message might appear in the agent's log:
```plaintext
{"level":"info","time":"2023-01-01T00:00:00.000Z","msg":"failed to renew lease gitlab-agent-remote-dev-dev/agent-123XX-lock: timed out waiting for the condition\n","agent_id":XXXX}
```
This issue occurs when an agent instance cannot renew its leadership lease, which results
in the shutdown of leader-only modules including the `remote_development` module.
To resolve this issue, restart the agent instance.

View File

@ -10,21 +10,30 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> - [Enabled on GitLab.com and self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/391543) in GitLab 16.0.
FLAG:
On self-managed GitLab, by default this feature is available. To hide the feature, an administrator can [disable the feature flag](../../administration/feature_flags.md) named `remote_development_feature_flag`. On GitLab.com, this feature is available. The feature is not ready for production use.
On self-managed GitLab, by default this feature is available.
To hide the feature, an administrator can [disable the feature flag](../../administration/feature_flags.md) named `remote_development_feature_flag`.
On GitLab.com, this feature is available.
The feature is not ready for production use.
WARNING:
This feature is in [Beta](../../policy/experiment-beta-support.md#beta) and subject to change without notice. To leave feedback, see the [feedback issue](https://gitlab.com/gitlab-org/gitlab/-/issues/410031).
This feature is in [Beta](../../policy/experiment-beta-support.md#beta) and subject to change without notice.
To leave feedback, see the [feedback issue](https://gitlab.com/gitlab-org/gitlab/-/issues/410031).
A workspace is a virtual sandbox environment for your code in GitLab. You can use workspaces to create and manage isolated development environments for your GitLab projects. These environments ensure that different projects don't interfere with each other.
A workspace is a virtual sandbox environment for your code in GitLab.
You can use workspaces to create and manage isolated development environments for your GitLab projects.
These environments ensure that different projects don't interfere with each other.
Each workspace includes its own set of dependencies, libraries, and tools, which you can customize to meet the specific needs of each project. Workspaces use the AMD64 architecture.
Each workspace includes its own set of dependencies, libraries, and tools,
which you can customize to meet the specific needs of each project.
Workspaces use the AMD64 architecture.
## Workspaces and projects
Workspaces are scoped to a project. When you create a workspace, you must:
Workspaces are scoped to a project.
When you [create a workspace](configuration.md#create-a-workspace), you must:
- Assign the workspace to a specific project.
- Select a project with a `.devfile.yaml` file.
- Select a project with a [`.devfile.yaml`](#devfile) file.
The workspace can then interact with the GitLab API based on the permissions granted to the current user.
@ -56,11 +65,15 @@ To clean up orphaned resources, an administrator must manually delete the worksp
## Devfile
A devfile is a file that defines a development environment by specifying the necessary tools, languages, runtimes, and other components for a GitLab project.
A devfile is a file that defines a development environment by specifying the necessary
tools, languages, runtimes, and other components for a GitLab project.
Workspaces have built-in support for devfiles. You can specify a devfile for your project in the GitLab configuration file. The devfile is used to automatically configure the development environment with the defined specifications.
Workspaces have built-in support for devfiles.
You can specify a devfile for your project in the GitLab configuration file.
The devfile is used to automatically configure the development environment with the defined specifications.
This way, you can create consistent and reproducible development environments regardless of the machine or platform you use.
This way, you can create consistent and reproducible development environments
regardless of the machine or platform you use.
### Relevant schema properties
@ -104,61 +117,63 @@ components:
For more information, see the [devfile documentation](https://devfile.io/docs/2.2.0/devfile-schema).
For other examples, see the [`examples` projects](https://gitlab.com/gitlab-org/remote-development/examples).
This container image is for demonstration purposes only. To use your own container image, see [Arbitrary user IDs](#arbitrary-user-ids).
This container image is for demonstration purposes only.
To use your own container image, see [Arbitrary user IDs](#arbitrary-user-ids).
## Web IDE
Workspaces are bundled with the Web IDE by default. The Web IDE is the only code editor available for workspaces.
Workspaces are bundled with the Web IDE by default.
The Web IDE is the only code editor available for workspaces.
The Web IDE is powered by the [GitLab VS Code fork](https://gitlab.com/gitlab-org/gitlab-web-ide-vscode-fork). For more information, see [Web IDE](../project/web_ide/index.md).
The Web IDE is powered by the [GitLab VS Code fork](https://gitlab.com/gitlab-org/gitlab-web-ide-vscode-fork).
For more information, see [Web IDE](../project/web_ide/index.md).
## Private repositories
You cannot create a workspace for a private repository because GitLab does not inject any credentials into the workspace. You can only create a workspace for public repositories that have a devfile.
You cannot [create a workspace](configuration.md#create-a-workspace) for a private repository
because GitLab does not inject any credentials into the workspace.
You can only create a workspace for public repositories that have a devfile.
From a workspace, you can clone any repository manually.
## Pod interaction in a cluster
Workspaces run as pods in a Kubernetes cluster. GitLab does not impose any restrictions on the manner in which pods interact with each other.
Workspaces run as pods in a Kubernetes cluster.
GitLab does not impose any restrictions on the manner in which pods interact with each other.
Because of this requirement, you might want to isolate this feature from other containers in your cluster.
## Network access and workspace authorization
It's the client's responsibility to restrict network access to the Kubernetes control plane as GitLab does not have control over the API.
It's the client's responsibility to restrict network access to the Kubernetes control plane
because GitLab does not have control over the API.
Only the workspace creator can access the workspace and any endpoints exposed in that workspace. The workspace creator is only authorized to access the workspace after user authentication with OAuth.
Only the workspace creator can access the workspace and any endpoints exposed in that workspace.
The workspace creator is only authorized to access the workspace after user authentication with OAuth.
## Compute resources and volume storage
When you stop a workspace, the compute resources for that workspace are scaled down to zero. However, the volume provisioned for the workspace still exists.
When you stop a workspace, the compute resources for that workspace are scaled down to zero.
However, the volume provisioned for the workspace still exists.
To delete the provisioned volume, you must terminate the workspace.
## Arbitrary user IDs
You can provide your own container image, which can run as any Linux user ID. It's not possible for GitLab to predict the Linux user ID for a container image.
GitLab uses the Linux root group ID permission to create, update, or delete files in a container. The container runtime used by the Kubernetes cluster must ensure all containers have a default Linux group ID of `0`.
You can provide your own container image, which can run as any Linux user ID.
If you have a container image that does not support arbitrary user IDs, you cannot create, update, or delete files in a workspace. To create a container image that supports arbitrary user IDs, see [Create a custom workspace image that supports arbitrary user IDs](../workspace/create_image.md).
It's not possible for GitLab to predict the Linux user ID for a container image.
GitLab uses the Linux root group ID permission to create, update, or delete files in a container.
The container runtime used by the Kubernetes cluster must ensure all containers have a default Linux group ID of `0`.
For more information, see the [OpenShift documentation](https://docs.openshift.com/container-platform/4.12/openshift_images/create-images.html#use-uid_create-images).
If you have a container image that does not support arbitrary user IDs,
you cannot create, update, or delete files in a workspace.
To create a container image that supports arbitrary user IDs,
see [Create a custom workspace image that supports arbitrary user IDs](../workspace/create_image.md).
For more information, see the
[OpenShift documentation](https://docs.openshift.com/container-platform/4.12/openshift_images/create-images.html#use-uid_create-images).
## Related topics
- [Quickstart guide for GitLab remote development workspaces](https://go.gitlab.com/AVKFvy)
- [Set up your infrastructure for on-demand, cloud-based development environments in GitLab](https://go.gitlab.com/dp75xo)
- [GitLab workspaces demo](https://go.gitlab.com/qtu66q)
## Troubleshooting
### `Failed to renew lease` when creating a workspace
You might not be able to create a workspace due to a known issue in the GitLab agent for Kubernetes. The following error message might appear in the agent's log:
```plaintext
{"level":"info","time":"2023-01-01T00:00:00.000Z","msg":"failed to renew lease gitlab-agent-remote-dev-dev/agent-123XX-lock: timed out waiting for the condition\n","agent_id":XXXX}
```
This issue occurs when an agent instance cannot renew its leadership lease, which results in the shutdown of leader-only modules including the `remote_development` module. To resolve this issue, restart the agent instance.

View File

@ -222,11 +222,11 @@ module Gitlab
return unless valid_scoped_token?(token, all_available_scopes)
if project && token.user.project_bot?
if project && (token.user.project_bot? || token.user.service_account?)
return unless can_read_project?(token.user, project)
end
if token.user.can_log_in_with_non_expired_password? || token.user.project_bot?
if token.user.can_log_in_with_non_expired_password? || (token.user.project_bot? || token.user.service_account?)
::PersonalAccessTokens::LastUsedService.new(token).execute
Gitlab::Auth::Result.new(token.user, nil, :personal_access_token, abilities_for_scopes(token.scopes))
@ -238,7 +238,7 @@ module Gitlab
end
def bot_user_can_read_project?(user, project)
(user.project_bot? || user.security_policy_bot?) && can_read_project?(user, project)
(user.project_bot? || user.service_account? || user.security_policy_bot?) && can_read_project?(user, project)
end
def valid_oauth_token?(token)

View File

@ -20,13 +20,13 @@ module Gitlab
current_group = nil
i = first_line - 1
blame.each do |commit, line, previous_path|
blame.each do |commit, line, previous_path, span|
commit = Commit.new(commit, project)
commit.lazy_author # preload author
if prev_sha != commit.sha
groups << current_group if current_group
current_group = { commit: commit, lines: [], previous_path: previous_path }
current_group = { commit: commit, lines: [], previous_path: previous_path, span: span, lineno: i + 1 }
end
current_group[:lines] << (highlight ? highlighted_lines[i].html_safe : line)

View File

@ -18,7 +18,7 @@ module Gitlab
def each
@blames.each do |blame|
yield(blame.commit, blame.line, blame.previous_path)
yield(blame.commit, blame.line, blame.previous_path, blame.span)
end
end
@ -49,12 +49,12 @@ module Gitlab
output.split("\n").each do |line|
if line[0, 1] == "\t"
lines << line[1, line.size]
elsif m = /^(\w{40}) (\d+) (\d+)/.match(line)
elsif m = /^(\w{40}) (\d+) (\d+)\s?(\d+)?/.match(line)
# Removed these instantiations for performance but keeping them for reference:
# commit_id, old_lineno, lineno = m[1], m[2].to_i, m[3].to_i
# commit_id, old_lineno, lineno, span = m[1], m[2].to_i, m[3].to_i, m[4].to_i
commit_id = m[1]
commits[commit_id] = nil unless commits.key?(commit_id)
info[m[3].to_i] = [commit_id, m[2].to_i]
info[m[3].to_i] = [commit_id, m[2].to_i, m[4].to_i]
# Assumption: the first line returned by git blame is lowest-numbered
# This is true unless we start passing it `--incremental`.
@ -72,13 +72,14 @@ module Gitlab
end
# get it together
info.sort.each do |lineno, (commit_id, old_lineno)|
info.sort.each do |lineno, (commit_id, old_lineno, span)|
final << BlameLine.new(
lineno,
old_lineno,
commits[commit_id],
lines[lineno - start_line],
previous_paths[commit_id]
previous_paths[commit_id],
span
)
end
@ -87,14 +88,15 @@ module Gitlab
end
class BlameLine
attr_accessor :lineno, :oldlineno, :commit, :line, :previous_path
attr_accessor :lineno, :oldlineno, :commit, :line, :previous_path, :span
def initialize(lineno, oldlineno, commit, line, previous_path)
def initialize(lineno, oldlineno, commit, line, previous_path, span)
@lineno = lineno
@oldlineno = oldlineno
@commit = commit
@line = line
@previous_path = previous_path
@span = span
end
end
end

View File

@ -978,6 +978,18 @@ module Gitlab
end
end
def rebase_to_ref(user, source_sha:, target_ref:, first_parent_ref:, expected_old_oid: "")
wrapped_gitaly_errors do
gitaly_operation_client.user_rebase_to_ref(
user,
source_sha: source_sha,
target_ref: target_ref,
first_parent_ref: first_parent_ref,
expected_old_oid: expected_old_oid
)
end
end
def squash(user, start_sha:, end_sha:, author:, message:)
wrapped_gitaly_errors do
gitaly_operation_client.user_squash(user, start_sha, end_sha, author, message)

View File

@ -135,7 +135,7 @@ module Gitlab
end
end
def user_merge_to_ref(user, source_sha:, branch:, target_ref:, message:, first_parent_ref:)
def user_merge_to_ref(user, source_sha:, branch:, target_ref:, message:, first_parent_ref:, expected_old_oid: "")
request = Gitaly::UserMergeToRefRequest.new(
repository: @gitaly_repo,
source_sha: source_sha,
@ -144,6 +144,7 @@ module Gitlab
user: Gitlab::Git::User.from_gitlab(user).to_gitaly,
message: encode_binary(message),
first_parent_ref: encode_binary(first_parent_ref),
expected_old_oid: expected_old_oid,
timestamp: Google::Protobuf::Timestamp.new(seconds: Time.now.utc.to_i)
)
@ -344,6 +345,23 @@ module Gitlab
request_enum.close
end
def user_rebase_to_ref(user, source_sha:, target_ref:, first_parent_ref:, expected_old_oid: "")
request = Gitaly::UserRebaseToRefRequest.new(
user: Gitlab::Git::User.from_gitlab(user).to_gitaly,
repository: @gitaly_repo,
source_sha: source_sha,
target_ref: encode_binary(target_ref),
first_parent_ref: encode_binary(first_parent_ref),
expected_old_oid: expected_old_oid,
timestamp: Google::Protobuf::Timestamp.new(seconds: Time.now.utc.to_i)
)
response = gitaly_client_call(@repository.storage, :operation_service,
:user_rebase_to_ref, request, timeout: GitalyClient.long_timeout)
response.commit_id
end
def user_squash(user, start_sha, end_sha, author, message, time = Time.now.utc)
request = Gitaly::UserSquashRequest.new(
repository: @gitaly_repo,

View File

@ -90,6 +90,19 @@ namespace :gitlab do
puts "- #{name}: \t#{repository_storage.gitaly_address}"
end
puts "GitLab Shell path:\t\t#{Gitlab.config.gitlab_shell.path}"
# check Gitaly version
puts ""
puts "Gitaly".color(:yellow)
Gitlab.config.repositories.storages.each do |storage_name, storage|
gitaly_server_service = Gitlab::GitalyClient::ServerService.new(storage_name)
gitaly_server_info = gitaly_server_service.info
puts "- #{storage_name} Address: \t#{storage.gitaly_address}"
puts "- #{storage_name} Version: \t#{gitaly_server_info.server_version}"
puts "- #{storage_name} Git Version: \t#{gitaly_server_info.git_version}"
rescue GRPC::DeadlineExceeded
puts "Unable to reach storage #{storage_name}".color(red)
end
end
end
end

View File

@ -15491,6 +15491,9 @@ msgstr ""
msgid "Delete corpus"
msgstr ""
msgid "Delete custom emoji"
msgstr ""
msgid "Delete deploy key"
msgstr ""
@ -19421,6 +19424,9 @@ msgstr ""
msgid "Failed to create wiki"
msgstr ""
msgid "Failed to delete custom emoji. Please try again."
msgstr ""
msgid "Failed to deploy to"
msgstr ""
@ -39095,9 +39101,6 @@ msgstr ""
msgid "Remove user from project"
msgstr ""
msgid "Remove..."
msgstr ""
msgid "Removed"
msgstr ""
@ -45154,7 +45157,7 @@ msgstr ""
msgid "StatusCheck|Apply this status check to all branches or a specific protected branch."
msgstr ""
msgid "StatusCheck|Check for a status response in merge requests. %{link_start}Learn more%{link_end}."
msgid "StatusCheck|Check for a status response in merge requests. %{linkStart}Learn more%{linkEnd}."
msgstr ""
msgid "StatusCheck|Examples: QA, Security."

View File

@ -0,0 +1,89 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import * as Sentry from '@sentry/browser';
import { GlModal } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/alert';
import DeleteItem from '~/custom_emoji/components/delete_item.vue';
import deleteCustomEmojiMutation from '~/custom_emoji/queries/delete_custom_emoji.mutation.graphql';
import { CUSTOM_EMOJI } from '../mock_data';
jest.mock('~/alert');
jest.mock('@sentry/browser');
let wrapper;
let deleteMutationSpy;
Vue.use(VueApollo);
function createSuccessSpy() {
deleteMutationSpy = jest.fn().mockResolvedValue({
data: { destroyCustomEmoji: { customEmoji: { id: CUSTOM_EMOJI[0].id } } },
});
}
function createErrorSpy() {
deleteMutationSpy = jest.fn().mockRejectedValue();
}
function createMockApolloProvider() {
const requestHandlers = [[deleteCustomEmojiMutation, deleteMutationSpy]];
return createMockApollo(requestHandlers);
}
function createComponent() {
const apolloProvider = createMockApolloProvider();
wrapper = mountExtended(DeleteItem, {
apolloProvider,
propsData: {
emoji: CUSTOM_EMOJI[0],
},
});
}
const findDeleteButton = () => wrapper.findByTestId('delete-button');
const findModal = () => wrapper.findComponent(GlModal);
describe('Custom emoji delete item component', () => {
it('opens modal when clicking button', async () => {
createSuccessSpy();
createComponent();
await findDeleteButton().trigger('click');
expect(document.querySelector('.gl-modal')).not.toBe(null);
});
it('calls GraphQL mutation on modals primary action', () => {
createSuccessSpy();
createComponent();
findModal().vm.$emit('primary');
expect(deleteMutationSpy).toHaveBeenCalledWith({ id: CUSTOM_EMOJI[0].id });
});
it('creates alert when mutation fails', async () => {
createErrorSpy();
createComponent();
findModal().vm.$emit('primary');
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith('Failed to delete custom emoji. Please try again.');
});
it('calls sentry when mutation fails', async () => {
createErrorSpy();
createComponent();
findModal().vm.$emit('primary');
await waitForPromises();
expect(Sentry.captureException).toHaveBeenCalled();
});
});

View File

@ -1,6 +1,7 @@
import Vue from 'vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import List from '~/custom_emoji/components/list.vue';
import DeleteItem from '~/custom_emoji/components/delete_item.vue';
import { CUSTOM_EMOJI } from '../mock_data';
jest.mock('~/lib/utils/datetime/date_format_utility', () => ({
@ -58,4 +59,21 @@ describe('Custom emoji settings list component', () => {
expect(wrapper.emitted('input')[0]).toEqual([emits]);
});
});
describe('delete button', () => {
it.each`
deleteCustomEmoji | rendersText | renders
${true} | ${'renders'} | ${true}
${false} | ${'does not render'} | ${false}
`(
'$rendersText delete button when deleteCustomEmoji is $deleteCustomEmoji',
({ deleteCustomEmoji, renders }) => {
createComponent({
customEmojis: [{ ...CUSTOM_EMOJI[0], userPermissions: { deleteCustomEmoji } }],
});
expect(wrapper.findComponent(DeleteItem).exists()).toBe(renders);
},
);
});
});

View File

@ -4,6 +4,9 @@ export const CUSTOM_EMOJI = [
name: 'confused_husky',
url: 'https://gitlab.com/custom_emoji/custom_emoji/-/raw/main/img/confused_husky.gif',
createdAt: 'created-at',
userPermissions: {
deleteCustomEmoji: false,
},
},
];

View File

@ -0,0 +1,179 @@
import { GlLabel, GlIcon } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/shared/work_item_link_child_metadata.vue';
import { createAlert } from '~/alert';
import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue';
import WorkItemLinkChildContents from '~/work_items/components/shared/work_item_link_child_contents.vue';
import WorkItemLinksMenu from '~/work_items/components/shared/work_item_links_menu.vue';
import { TASK_TYPE_NAME, WORK_ITEM_TYPE_VALUE_OBJECTIVE } from '~/work_items/constants';
import {
workItemTask,
workItemObjectiveWithChild,
workItemObjectiveNoMetadata,
confidentialWorkItemTask,
closedWorkItemTask,
workItemObjectiveMetadataWidgets,
} from '../../mock_data';
jest.mock('~/alert');
describe('WorkItemLinkChildContents', () => {
Vue.use(VueApollo);
const WORK_ITEM_ID = 'gid://gitlab/WorkItem/2';
let wrapper;
const { LABELS } = workItemObjectiveMetadataWidgets;
const mockLabels = LABELS.labels.nodes;
const mockFullPath = 'gitlab-org/gitlab-test';
const findStatusIconComponent = () =>
wrapper.findByTestId('item-status-icon').findComponent(GlIcon);
const findConfidentialIconComponent = () => wrapper.findByTestId('confidential-icon');
const findTitleEl = () => wrapper.findByTestId('item-title');
const findStatusTooltipComponent = () => wrapper.findComponent(RichTimestampTooltip);
const findMetadataComponent = () => wrapper.findComponent(WorkItemLinkChildMetadata);
const findAllLabels = () => wrapper.findAllComponents(GlLabel);
const findRegularLabel = () => findAllLabels().at(0);
const findScopedLabel = () => findAllLabels().at(1);
const findLinksMenuComponent = () => wrapper.findComponent(WorkItemLinksMenu);
const createComponent = ({
canUpdate = true,
parentWorkItemId = WORK_ITEM_ID,
childItem = workItemTask,
workItemType = TASK_TYPE_NAME,
} = {}) => {
wrapper = shallowMountExtended(WorkItemLinkChildContents, {
propsData: {
canUpdate,
parentWorkItemId,
childItem,
workItemType,
fullPath: mockFullPath,
childPath: '/gitlab-org/gitlab-test/-/work_items/4',
},
});
};
beforeEach(() => {
createAlert.mockClear();
});
it.each`
status | childItem | statusIconName | statusIconColorClass | rawTimestamp | tooltipContents
${'open'} | ${workItemTask} | ${'issue-open-m'} | ${'gl-text-green-500'} | ${workItemTask.createdAt} | ${'Created'}
${'closed'} | ${closedWorkItemTask} | ${'issue-close'} | ${'gl-text-blue-500'} | ${closedWorkItemTask.closedAt} | ${'Closed'}
`(
'renders item status icon and tooltip when item status is `$status`',
({ childItem, statusIconName, statusIconColorClass, rawTimestamp, tooltipContents }) => {
createComponent({ childItem });
expect(findStatusIconComponent().props('name')).toBe(statusIconName);
expect(findStatusIconComponent().classes()).toContain(statusIconColorClass);
expect(findStatusTooltipComponent().props('rawTimestamp')).toBe(rawTimestamp);
expect(findStatusTooltipComponent().props('timestampTypeText')).toContain(tooltipContents);
},
);
it('renders confidential icon when item is confidential', () => {
createComponent({ childItem: confidentialWorkItemTask });
expect(findConfidentialIconComponent().props('name')).toBe('eye-slash');
expect(findConfidentialIconComponent().attributes('title')).toBe('Confidential');
});
describe('item title', () => {
beforeEach(() => {
createComponent();
});
it('renders item title', () => {
expect(findTitleEl().attributes('href')).toBe('/gitlab-org/gitlab-test/-/work_items/4');
expect(findTitleEl().text()).toBe(workItemTask.title);
});
it.each`
action | event | emittedEvent
${'on mouseover'} | ${'mouseover'} | ${'mouseover'}
${'on mouseout'} | ${'mouseout'} | ${'mouseout'}
`('$action item title emit `$emittedEvent` event', ({ event, emittedEvent }) => {
findTitleEl().vm.$emit(event);
expect(wrapper.emitted(emittedEvent)).toEqual([[]]);
});
it('emits click event with correct parameters on clicking title', () => {
const eventObj = {
preventDefault: jest.fn(),
};
findTitleEl().vm.$emit('click', eventObj);
expect(wrapper.emitted('click')).toEqual([[eventObj]]);
});
});
describe('item metadata', () => {
beforeEach(() => {
createComponent({
childItem: workItemObjectiveWithChild,
workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE,
});
});
it('renders item metadata component when item has metadata present', () => {
expect(findMetadataComponent().props()).toMatchObject({
metadataWidgets: workItemObjectiveMetadataWidgets,
});
});
it('does not render item metadata component when item has no metadata present', () => {
createComponent({
childItem: workItemObjectiveNoMetadata,
workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE,
});
expect(findMetadataComponent().exists()).toBe(false);
});
it('renders labels', () => {
const mockLabel = mockLabels[0];
expect(findAllLabels()).toHaveLength(mockLabels.length);
expect(findRegularLabel().props()).toMatchObject({
title: mockLabel.title,
backgroundColor: mockLabel.color,
description: mockLabel.description,
scoped: false,
});
expect(findScopedLabel().props('scoped')).toBe(true); // Second label is scoped
});
});
describe('item menu', () => {
beforeEach(() => {
createComponent();
});
it('renders work-item-links-menu', () => {
expect(findLinksMenuComponent().exists()).toBe(true);
});
it('does not render work-item-links-menu when canUpdate is false', () => {
createComponent({ canUpdate: false });
expect(findLinksMenuComponent().exists()).toBe(false);
});
it('removeChild event on menu triggers `click-remove-child` event', () => {
findLinksMenuComponent().vm.$emit('removeChild');
expect(wrapper.emitted('removeChild')).toEqual([[workItemTask]]);
});
});
});

View File

@ -3,7 +3,7 @@ import { GlAvatarsInline } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ItemMilestone from '~/issuable/components/issue_milestone.vue';
import WorkItemLinkChildMetadata from '~/work_items/components/work_item_links/work_item_link_child_metadata.vue';
import WorkItemLinkChildMetadata from '~/work_items/components/shared/work_item_link_child_metadata.vue';
import { workItemObjectiveMetadataWidgets } from '../../mock_data';

View File

@ -1,7 +1,7 @@
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import WorkItemLinksMenu from '~/work_items/components/work_item_links/work_item_links_menu.vue';
import WorkItemLinksMenu from '~/work_items/components/shared/work_item_links_menu.vue';
describe('WorkItemLinksMenu', () => {
let wrapper;

View File

@ -1,20 +1,16 @@
import { GlLabel, GlIcon } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/work_item_links/work_item_link_child_metadata.vue';
import { createAlert } from '~/alert';
import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue';
import getWorkItemTreeQuery from '~/work_items/graphql/work_item_tree.query.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue';
import WorkItemLinksMenu from '~/work_items/components/work_item_links/work_item_links_menu.vue';
import WorkItemTreeChildren from '~/work_items/components/work_item_links/work_item_tree_children.vue';
import WorkItemLinkChildContents from '~/work_items/components/shared/work_item_link_child_contents.vue';
import {
WIDGET_TYPE_HIERARCHY,
TASK_TYPE_NAME,
@ -24,12 +20,8 @@ import {
import {
workItemTask,
workItemObjectiveWithChild,
workItemObjectiveNoMetadata,
confidentialWorkItemTask,
closedWorkItemTask,
workItemHierarchyTreeResponse,
workItemHierarchyTreeFailureResponse,
workItemObjectiveMetadataWidgets,
changeIndirectWorkItemParentMutationResponse,
workItemUpdateFailureResponse,
} from '../../mock_data';
@ -41,8 +33,6 @@ describe('WorkItemLinkChild', () => {
let wrapper;
let getWorkItemTreeQueryHandler;
let mutationChangeParentHandler;
const { LABELS } = workItemObjectiveMetadataWidgets;
const mockLabels = LABELS.labels.nodes;
const $toast = {
show: jest.fn(),
@ -51,6 +41,8 @@ describe('WorkItemLinkChild', () => {
Vue.use(VueApollo);
const findWorkItemLinkChildContents = () => wrapper.findComponent(WorkItemLinkChildContents);
const createComponent = ({
canUpdate = true,
issuableGid = WORK_ITEM_ID,
@ -89,87 +81,7 @@ describe('WorkItemLinkChild', () => {
createAlert.mockClear();
});
it.each`
status | childItem | statusIconName | statusIconColorClass | rawTimestamp | tooltipContents
${'open'} | ${workItemTask} | ${'issue-open-m'} | ${'gl-text-green-500'} | ${workItemTask.createdAt} | ${'Created'}
${'closed'} | ${closedWorkItemTask} | ${'issue-close'} | ${'gl-text-blue-500'} | ${closedWorkItemTask.closedAt} | ${'Closed'}
`(
'renders item status icon and tooltip when item status is `$status`',
({ childItem, statusIconName, statusIconColorClass, rawTimestamp, tooltipContents }) => {
createComponent({ childItem });
const statusIcon = wrapper.findByTestId('item-status-icon').findComponent(GlIcon);
const statusTooltip = wrapper.findComponent(RichTimestampTooltip);
expect(statusIcon.props('name')).toBe(statusIconName);
expect(statusIcon.classes()).toContain(statusIconColorClass);
expect(statusTooltip.props('rawTimestamp')).toBe(rawTimestamp);
expect(statusTooltip.props('timestampTypeText')).toContain(tooltipContents);
},
);
it('renders confidential icon when item is confidential', () => {
createComponent({ childItem: confidentialWorkItemTask });
const confidentialIcon = wrapper.findByTestId('confidential-icon');
expect(confidentialIcon.props('name')).toBe('eye-slash');
expect(confidentialIcon.attributes('title')).toBe('Confidential');
});
describe('item title', () => {
let titleEl;
beforeEach(() => {
createComponent();
titleEl = wrapper.findByTestId('item-title');
});
it('renders item title', () => {
expect(titleEl.attributes('href')).toBe('/gitlab-org/gitlab-test/-/work_items/4');
expect(titleEl.text()).toBe(workItemTask.title);
});
describe('renders item title correctly for relative instance', () => {
beforeEach(() => {
window.gon = { relative_url_root: '/test' };
createComponent();
titleEl = wrapper.findByTestId('item-title');
});
it('renders item title with correct href', () => {
expect(titleEl.attributes('href')).toBe('/test/gitlab-org/gitlab-test/-/work_items/4');
});
it('renders item title with correct text', () => {
expect(titleEl.text()).toBe(workItemTask.title);
});
});
it.each`
action | event | emittedEvent
${'doing mouseover on'} | ${'mouseover'} | ${'mouseover'}
${'doing mouseout on'} | ${'mouseout'} | ${'mouseout'}
`('$action item title emit `$emittedEvent` event', ({ event, emittedEvent }) => {
titleEl.vm.$emit(event);
expect(wrapper.emitted(emittedEvent)).toEqual([[]]);
});
it('emits click event with correct parameters on clicking title', () => {
const eventObj = {
preventDefault: jest.fn(),
};
titleEl.vm.$emit('click', eventObj);
expect(wrapper.emitted('click')).toEqual([[eventObj]]);
});
});
describe('item metadata', () => {
const findMetadataComponent = () => wrapper.findComponent(WorkItemLinkChildMetadata);
describe('renders WorkItemLinkChildContents', () => {
beforeEach(() => {
createComponent({
childItem: workItemObjectiveWithChild,
@ -177,66 +89,30 @@ describe('WorkItemLinkChild', () => {
});
});
it('renders item metadata component when item has metadata present', () => {
const metadataEl = findMetadataComponent();
expect(metadataEl.exists()).toBe(true);
expect(metadataEl.props()).toMatchObject({
metadataWidgets: workItemObjectiveMetadataWidgets,
it('with default props', () => {
expect(findWorkItemLinkChildContents().props()).toEqual({
childItem: workItemObjectiveWithChild,
canUpdate: true,
parentWorkItemId: 'gid://gitlab/WorkItem/2',
workItemType: 'Objective',
childPath: '/gitlab-org/gitlab-test/-/work_items/12',
});
});
it('does not render item metadata component when item has no metadata present', () => {
createComponent({
childItem: workItemObjectiveNoMetadata,
workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE,
describe('with relative instance', () => {
beforeEach(() => {
window.gon = { relative_url_root: '/test' };
createComponent({
childItem: workItemObjectiveWithChild,
workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE,
});
});
expect(findMetadataComponent().exists()).toBe(false);
});
it('renders labels', () => {
const labels = wrapper.findAllComponents(GlLabel);
const mockLabel = mockLabels[0];
expect(labels).toHaveLength(mockLabels.length);
expect(labels.at(0).props()).toMatchObject({
title: mockLabel.title,
backgroundColor: mockLabel.color,
description: mockLabel.description,
scoped: false,
it('adds the relative url to child path value', () => {
expect(findWorkItemLinkChildContents().props('childPath')).toBe(
'/test/gitlab-org/gitlab-test/-/work_items/12',
);
});
expect(labels.at(1).props('scoped')).toBe(true); // Second label is scoped
});
});
describe('item menu', () => {
let itemMenuEl;
beforeEach(() => {
createComponent();
itemMenuEl = wrapper.findComponent(WorkItemLinksMenu);
});
it('renders work-item-links-menu', () => {
expect(itemMenuEl.exists()).toBe(true);
expect(itemMenuEl.attributes()).toMatchObject({
'work-item-id': workItemTask.id,
'parent-work-item-id': WORK_ITEM_ID,
});
});
it('does not render work-item-links-menu when canUpdate is false', () => {
createComponent({ canUpdate: false });
expect(wrapper.findComponent(WorkItemLinksMenu).exists()).toBe(false);
});
it('removeChild event on menu triggers `click-remove-child` event', () => {
itemMenuEl.vm.$emit('removeChild');
expect(wrapper.emitted('removeChild')).toEqual([[workItemTask]]);
});
});
@ -252,7 +128,6 @@ describe('WorkItemLinkChild', () => {
const findFirstItem = () => getChildrenNodes()[0];
beforeEach(() => {
getWorkItemTreeQueryHandler.mockClear();
createComponent({
childItem: workItemObjectiveWithChild,
workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE,

View File

@ -11,7 +11,7 @@ RSpec.describe TimeHelper do
100.32 => "1 minute and 40 seconds",
120 => "2 minutes",
121 => "2 minutes and 1 second",
3721 => "62 minutes and 1 second",
3721 => "1 hour, 2 minutes, and 1 second",
0 => "0 seconds"
}

View File

@ -33,12 +33,18 @@ RSpec.describe Gitlab::Blame do
expect(subject.count).to eq(18)
expect(subject[0][:commit].sha).to eq('913c66a37b4a45b9769037c55c2d238bd0942d2e')
expect(subject[0][:lines]).to eq(["require 'fileutils'", "require 'open3'", ""])
expect(subject[0][:span]).to eq(3)
expect(subject[0][:lineno]).to eq(1)
expect(subject[1][:commit].sha).to eq('874797c3a73b60d2187ed6e2fcabd289ff75171e')
expect(subject[1][:lines]).to eq(["module Popen", " extend self"])
expect(subject[1][:span]).to eq(2)
expect(subject[1][:lineno]).to eq(4)
expect(subject[-1][:commit].sha).to eq('913c66a37b4a45b9769037c55c2d238bd0942d2e')
expect(subject[-1][:lines]).to eq([" end", "end"])
expect(subject[-1][:span]).to eq(2)
expect(subject[-1][:lineno]).to eq(36)
end
context 'with a range 1..5' do

View File

@ -13,13 +13,17 @@ RSpec.describe Gitlab::Git::Blame do
let(:result) do
[].tap do |data|
blame.each do |commit, line, previous_path|
data << { commit: commit, line: line, previous_path: previous_path }
blame.each do |commit, line, previous_path, span|
data << { commit: commit, line: line, previous_path: previous_path, span: span }
end
end
end
describe 'blaming a file' do
it 'has the right commit span' do
expect(result.first[:span]).to eq(95)
end
it 'has the right number of lines' do
expect(result.size).to eq(95)
expect(result.first[:commit]).to be_kind_of(Gitlab::Git::Commit)

View File

@ -731,6 +731,39 @@ RSpec.describe Gitlab::GitalyClient::OperationService, feature_category: :source
end
end
describe '#user_rebase_to_ref' do
let(:first_parent_ref) { 'refs/heads/my-branch' }
let(:source_sha) { 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660' }
let(:target_ref) { 'refs/merge-requests/x/merge' }
let(:response) { Gitaly::UserRebaseToRefResponse.new(commit_id: 'new-commit-id') }
let(:payload) do
{ source_sha: source_sha, target_ref: target_ref, first_parent_ref: first_parent_ref }
end
it 'sends a user_rebase_to_ref message' do
freeze_time do
expect_any_instance_of(Gitaly::OperationService::Stub).to receive(:user_rebase_to_ref) do |_, request, options|
expect(options).to be_kind_of(Hash)
expect(request.to_h).to(
eq(
payload.merge(
{
expected_old_oid: "",
repository: repository.gitaly_repository.to_h,
user: Gitlab::Git::User.from_gitlab(user).to_gitaly.to_h,
timestamp: { nanos: 0, seconds: Time.current.to_i }
}
)
)
)
end.and_return(response)
client.user_rebase_to_ref(user, **payload)
end
end
end
describe '#user_squash' do
let(:start_sha) { 'b83d6e391c22777fca1ed3012fce84f633d7fed0' }
let(:end_sha) { '54cec5282aa9f21856362fe321c800c236a61615' }

View File

@ -0,0 +1,164 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe MergeRequests::CreateRefService, feature_category: :merge_trains do
using RSpec::Parameterized::TableSyntax
describe '#execute' do
let_it_be(:project) { create(:project, :empty_repo) }
let_it_be(:user) { project.creator }
let_it_be(:first_parent_ref) { project.default_branch_or_main }
let_it_be(:source_branch) { 'branch' }
let(:target_ref) { "refs/merge-requests/#{merge_request.iid}/train" }
let(:source_sha) { project.commit(source_branch).sha }
let(:squash) { false }
let(:merge_request) do
create(
:merge_request,
title: 'Merge request ref test',
author: user,
source_project: project,
target_project: project,
source_branch: source_branch,
target_branch: first_parent_ref,
squash: squash
)
end
subject(:result) do
described_class.new(
current_user: user,
merge_request: merge_request,
target_ref: target_ref,
source_sha: source_sha,
first_parent_ref: first_parent_ref
).execute
end
context 'when there is a user-caused gitaly error' do
let(:source_sha) { '123' }
it 'returns an error response' do
expect(result[:status]).to eq :error
end
end
context 'with valid inputs' do
before_all do
# ensure first_parent_ref is created before source_sha
project.repository.create_file(
user,
'README.md',
'',
message: 'Base parent commit 1',
branch_name: first_parent_ref
)
project.repository.create_branch(source_branch, first_parent_ref)
# create two commits source_branch to test squashing
project.repository.create_file(
user,
'.gitlab-ci.yml',
'',
message: 'Feature branch commit 1',
branch_name: source_branch
)
project.repository.create_file(
user,
'.gitignore',
'',
message: 'Feature branch commit 2',
branch_name: source_branch
)
# create an extra commit not present on source_branch
project.repository.create_file(
user,
'EXTRA',
'',
message: 'Base parent commit 2',
branch_name: first_parent_ref
)
end
it 'writes the merged result into target_ref', :aggregate_failures do
expect(result[:status]).to eq :success
expect(project.repository.commits(target_ref, limit: 10, order: 'topo').map(&:message)).to(
match(
[
a_string_matching(/Merge branch '#{source_branch}' into '#{first_parent_ref}'/),
'Feature branch commit 2',
'Feature branch commit 1',
'Base parent commit 2',
'Base parent commit 1'
]
)
)
end
context 'when squash is requested' do
let(:squash) { true }
it 'writes the squashed result', :aggregate_failures do
expect(result[:status]).to eq :success
expect(project.repository.commits(target_ref, limit: 10, order: 'topo').map(&:message)).to(
match(
[
a_string_matching(/Merge branch '#{source_branch}' into '#{first_parent_ref}'/),
"#{merge_request.title}\n",
'Base parent commit 2',
'Base parent commit 1'
]
)
)
end
end
context 'when semi-linear merges are enabled' do
before do
project.merge_method = :rebase_merge
project.save!
end
it 'writes the semi-linear merged result', :aggregate_failures do
expect(result[:status]).to eq :success
expect(project.repository.commits(target_ref, limit: 10, order: 'topo').map(&:message)).to(
match(
[
a_string_matching(/Merge branch '#{source_branch}' into '#{first_parent_ref}'/),
'Feature branch commit 2',
'Feature branch commit 1',
'Base parent commit 2',
'Base parent commit 1'
]
)
)
end
end
context 'when fast-forward merges are enabled' do
before do
project.merge_method = :ff
project.save!
end
it 'writes the rebased merged result', :aggregate_failures do
expect(result[:status]).to eq :success
expect(project.repository.commits(target_ref, limit: 10, order: 'topo').map(&:message)).to(
eq(
[
'Feature branch commit 2',
'Feature branch commit 1',
'Base parent commit 2',
'Base parent commit 1'
]
)
)
end
end
end
end
end