Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-07-10 15:25:43 +00:00
parent fb5de8adc2
commit b7b4bb751c
93 changed files with 1554 additions and 920 deletions

View File

@ -121,7 +121,6 @@ Layout/LineEndStringConcatenationIndentation:
- 'danger/experiments/Dangerfile'
- 'danger/feature_flag/Dangerfile'
- 'db/post_migrate/20231124133015_drop_idx_projects_id_created_at_disable_overriding_approvers_false_for_gitlab_com.rb'
- 'ee/app/components/namespaces/block_seat_overages/alert_component.rb'
- 'ee/app/components/namespaces/combined_storage_users/base_alert_component.rb'
- 'ee/app/components/namespaces/combined_storage_users/non_owner_alert_component.rb'
- 'ee/app/components/namespaces/combined_storage_users/owner_alert_component.rb'
@ -277,7 +276,6 @@ Layout/LineEndStringConcatenationIndentation:
- 'ee/lib/system_check/geo/ssh_port_check.rb'
- 'ee/lib/tasks/gitlab/custom_roles/check_docs_task.rb'
- 'ee/lib/tasks/gitlab/geo.rake'
- 'ee/spec/components/namespaces/block_seat_overages/alert_component_spec.rb'
- 'ee/spec/components/namespaces/combined_storage_users/non_owner_alert_component_spec.rb'
- 'ee/spec/components/namespaces/combined_storage_users/owner_alert_component_spec.rb'
- 'ee/spec/components/namespaces/free_user_cap/enforcement_alert_component_spec.rb'

View File

@ -1,8 +1,9 @@
<script>
import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
import {
I18N_JOB_STATUS_RUNNING,
I18N_JOB_STATUS_ACTIVE,
I18N_JOB_STATUS_IDLE,
JOB_STATUS_ACTIVE,
JOB_STATUS_RUNNING,
JOB_STATUS_IDLE,
} from '../constants';
@ -24,10 +25,11 @@ export default {
computed: {
badge() {
switch (this.jobStatus) {
case JOB_STATUS_ACTIVE:
case JOB_STATUS_RUNNING:
return {
classes: 'gl-text-blue-600! gl-shadow-inner-1-gray-400 gl-border-blue-600!',
label: I18N_JOB_STATUS_RUNNING,
label: I18N_JOB_STATUS_ACTIVE,
};
case JOB_STATUS_IDLE:
return {

View File

@ -36,7 +36,7 @@ export const I18N_STATUS_OFFLINE = s__('Runners|Offline');
export const I18N_STATUS_STALE = s__('Runners|Stale');
// Executor Status
export const I18N_JOB_STATUS_RUNNING = s__('Runners|Running');
export const I18N_JOB_STATUS_ACTIVE = s__('Runners|Active');
export const I18N_JOB_STATUS_IDLE = s__('Runners|Idle');
// Status tooltips
@ -163,6 +163,7 @@ export const STATUS_STALE = 'STALE';
// CiRunnerJobExecutionStatus
export const JOB_STATUS_ACTIVE = 'ACTIVE';
export const JOB_STATUS_RUNNING = 'RUNNING';
export const JOB_STATUS_IDLE = 'IDLE';

View File

@ -7,7 +7,7 @@ const chartData = generateRawData(30, 4);
export default {
component: ContributorAreaChart,
title: 'ce/contributors/contributor_area_chart',
title: 'contributors/contributor_area_chart',
decorators: [withVuexStore],
args: {
data: getMasterChartData(parsedData({ chartData })),

View File

@ -5,7 +5,7 @@ import Contributors from './contributors.vue';
export default {
component: Contributors,
title: 'ce/contributors/contributors',
title: 'contributors/contributors',
decorators: [withVuexStore],
args: {
commitsPath: '/gitlab-org/gitlab/-/commits/master?ref_type=heads',

View File

@ -1,5 +1,6 @@
<script>
import { GlLoadingIcon, GlModal, GlEmptyState } from '@gitlab/ui';
import emptySearchIllustration from '@gitlab/svgs/dist/illustrations/empty-state/empty-search-md.svg?url';
import { createAlert } from '~/alert';
import { HTTP_STATUS_FORBIDDEN } from '~/lib/utils/http_status';
import { mergeUrlParams, getParameterByName } from '~/lib/utils/url_utility';
@ -16,13 +17,13 @@ export default {
description: __('Edit your search and try again'),
},
},
emptySearchIllustration,
components: {
GroupsComponent,
GlModal,
GlLoadingIcon,
GlEmptyState,
},
inject: ['emptySearchIllustration'],
props: {
action: {
type: String,
@ -246,7 +247,7 @@ export default {
<groups-component v-if="hasGroups" :groups="groups" :page-info="pageInfo" :action="action" />
<gl-empty-state
v-else-if="fromSearch"
:svg-path="emptySearchIllustration"
:svg-path="$options.emptySearchIllustration"
:title="$options.i18n.searchEmptyState.title"
:description="$options.i18n.searchEmptyState.description"
data-testid="search-empty-state"

View File

@ -1,11 +1,12 @@
<script>
import { GlEmptyState } from '@gitlab/ui';
import groupsEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/empty-state/empty-groups-md.svg?url';
import { s__ } from '~/locale';
export default {
components: { GlEmptyState },
inject: ['groupsEmptyStateIllustration'],
groupsEmptyStateIllustration,
i18n: {
title: s__('GroupsEmptyState|A group is a collection of several projects'),
description: s__(
@ -19,7 +20,7 @@ export default {
<gl-empty-state
:title="$options.i18n.title"
:description="$options.i18n.description"
:svg-path="groupsEmptyStateIllustration"
:svg-path="$options.groupsEmptyStateIllustration"
:svg-height="null"
/>
</template>

View File

@ -1,11 +1,12 @@
<script>
import { GlEmptyState } from '@gitlab/ui';
import groupsEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/empty-state/empty-groups-md.svg?url';
import { s__ } from '~/locale';
export default {
components: { GlEmptyState },
inject: ['groupsEmptyStateIllustration'],
groupsEmptyStateIllustration,
computed: {
title() {
return s__('GroupsEmptyState|No public or internal groups');
@ -15,5 +16,9 @@ export default {
</script>
<template>
<gl-empty-state :title="title" :svg-path="groupsEmptyStateIllustration" :svg-height="null" />
<gl-empty-state
:title="title"
:svg-path="$options.groupsEmptyStateIllustration"
:svg-height="null"
/>
</template>

View File

@ -3,30 +3,34 @@ import { isEqual } from 'lodash';
import FilteredSearchAndSort from '~/groups_projects/components/filtered_search_and_sort.vue';
import { RECENT_SEARCHES_STORAGE_KEY_GROUPS } from '~/filtered_search/recent_searches_storage_keys';
import {
EXPLORE_SORTING_ITEMS,
GROUPS_LIST_SORTING_ITEMS,
SORTING_ITEM_NAME,
EXPLORE_FILTERED_SEARCH_TERM_KEY,
EXPLORE_FILTERED_SEARCH_NAMESPACE,
GROUPS_LIST_FILTERED_SEARCH_TERM_KEY,
} from '../constants';
import GroupsService from '../service/groups_service';
import GroupsStore from '../store/groups_store';
import eventHub from '../event_hub';
import GroupsApp from './app.vue';
import EmptyState from './empty_states/groups_explore_empty_state.vue';
export default {
filteredSearch: {
tokens: [],
namespace: EXPLORE_FILTERED_SEARCH_NAMESPACE,
recentSearchesStorageKey: RECENT_SEARCHES_STORAGE_KEY_GROUPS,
searchTermKey: EXPLORE_FILTERED_SEARCH_TERM_KEY,
},
components: {
FilteredSearchAndSort,
GroupsApp,
EmptyState,
},
inject: ['endpoint', 'initialSort'],
props: {
filteredSearchNamespace: {
type: String,
required: true,
},
initialSort: {
type: String,
required: true,
},
endpoint: {
type: String,
required: true,
},
},
data() {
return {
service: new GroupsService(this.endpoint),
@ -34,6 +38,14 @@ export default {
};
},
computed: {
filteredSearch() {
return {
tokens: [],
namespace: this.filteredSearchNamespace,
recentSearchesStorageKey: RECENT_SEARCHES_STORAGE_KEY_GROUPS,
searchTermKey: GROUPS_LIST_FILTERED_SEARCH_TERM_KEY,
};
},
sortByQuery() {
return this.$route.query?.sort;
},
@ -45,7 +57,7 @@ export default {
},
activeSortItem() {
return (
EXPLORE_SORTING_ITEMS.find(
GROUPS_LIST_SORTING_ITEMS.find(
(sortItem) => sortItem.asc === this.sortBy || sortItem.desc === this.sortBy,
) || SORTING_ITEM_NAME
);
@ -64,7 +76,7 @@ export default {
return this.activeSortItem.asc === this.sortBy;
},
sortOptions() {
return EXPLORE_SORTING_ITEMS.map((sortItem) => {
return GROUPS_LIST_SORTING_ITEMS.map((sortItem) => {
return {
value: this.isAscending ? sortItem.asc : sortItem.desc,
text: sortItem.label,
@ -104,14 +116,14 @@ export default {
this.pushQuery({ ...this.routeQueryWithoutPagination, sort: sortBy });
},
onFilter(filtersQuery) {
const search = filtersQuery[EXPLORE_FILTERED_SEARCH_TERM_KEY];
const search = filtersQuery[GROUPS_LIST_FILTERED_SEARCH_TERM_KEY];
eventHub.$emit('fetchFilteredAndSortedGroups', {
filterGroupsBy: search,
sortBy: this.sortBy,
});
this.pushQuery({
...(search ? { [EXPLORE_FILTERED_SEARCH_TERM_KEY]: search } : {}),
...(search ? { [GROUPS_LIST_FILTERED_SEARCH_TERM_KEY]: search } : {}),
sort: this.sortByQuery,
});
},
@ -122,12 +134,10 @@ export default {
<template>
<div>
<filtered-search-and-sort
:filtered-search-namespace="$options.filteredSearch.namespace"
:filtered-search-tokens="$options.filteredSearch.tokens"
:filtered-search-term-key="$options.filteredSearch.searchTermKey"
:filtered-search-recent-searches-storage-key="
$options.filteredSearch.recentSearchesStorageKey
"
:filtered-search-namespace="filteredSearch.namespace"
:filtered-search-tokens="filteredSearch.tokens"
:filtered-search-term-key="filteredSearch.searchTermKey"
:filtered-search-recent-searches-storage-key="filteredSearch.recentSearchesStorageKey"
:filtered-search-query="$route.query"
:is-ascending="isAscending"
:sort-options="sortOptions"
@ -138,7 +148,7 @@ export default {
/>
<groups-app :service="service" :store="store">
<template #empty-state>
<empty-state />
<slot name="empty-state"></slot>
</template>
</groups-app>
</div>

View File

@ -6,9 +6,6 @@ export const ACTIVE_TAB_SUBGROUPS_AND_PROJECTS = 'subgroups_and_projects';
export const ACTIVE_TAB_SHARED = 'shared';
export const ACTIVE_TAB_INACTIVE = 'inactive';
export const GROUPS_LIST_HOLDER_CLASS = '.js-groups-list-holder';
export const CONTENT_LIST_CLASS = '.groups-list';
export const COMMON_STR = {
FAILURE: __('An error occurred. Please try again.'),
LEAVE_FORBIDDEN: s__(
@ -49,14 +46,16 @@ export const SORTING_ITEM_STARS = {
desc: 'stars_desc',
};
export const EXPLORE_FILTERED_SEARCH_TERM_KEY = 'filter';
export const EXPLORE_FILTERED_SEARCH_NAMESPACE = 'explore';
export const EXPLORE_SORTING_ITEMS = [
export const GROUPS_LIST_FILTERED_SEARCH_TERM_KEY = 'filter';
export const GROUPS_LIST_SORTING_ITEMS = [
SORTING_ITEM_NAME,
SORTING_ITEM_CREATED,
SORTING_ITEM_UPDATED,
];
export const EXPLORE_FILTERED_SEARCH_NAMESPACE = 'explore';
export const DASHBOARD_FILTERED_SEARCH_NAMESPACE = 'dashboard';
export const OVERVIEW_TABS_FILTERED_SEARCH_TERM_KEY = 'filter';
export const OVERVIEW_TABS_FILTERED_SEARCH_NAMESPACE = 'overview';

View File

@ -3,7 +3,7 @@ import VueRouter from 'vue-router';
import GroupItem from 'jh_else_ce/groups/components/group_item.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import GroupFolder from './components/group_folder.vue';
import GroupsExploreApp from './components/groups_explore_app.vue';
import GroupsListWithFilteredSearchApp from './components/groups_list_with_filtered_search_app.vue';
export const createRouter = () => {
const routes = [{ path: '/', name: 'root' }];
@ -17,8 +17,8 @@ export const createRouter = () => {
return router;
};
export const initGroupsExplore = () => {
const el = document.getElementById('js-groups-explore');
export const initGroupsListWithFilteredSearch = ({ filteredSearchNamespace, EmptyState }) => {
const el = document.getElementById('js-groups-list-with-filtered-search');
if (!el) return false;
@ -30,8 +30,7 @@ export const initGroupsExplore = () => {
const {
dataset: { appData },
} = el;
const { groupsEmptyStateIllustration, emptySearchIllustration, endpoint, initialSort } =
convertObjectPropsToCamelCase(JSON.parse(appData));
const { endpoint, initialSort } = convertObjectPropsToCamelCase(JSON.parse(appData));
Vue.use(VueRouter);
const router = createRouter();
@ -39,10 +38,18 @@ export const initGroupsExplore = () => {
return new Vue({
el,
name: 'GroupsExploreRoot',
provide: { groupsEmptyStateIllustration, emptySearchIllustration, endpoint, initialSort },
router,
render(createElement) {
return createElement(GroupsExploreApp);
return createElement(GroupsListWithFilteredSearchApp, {
props: {
filteredSearchNamespace,
endpoint,
initialSort,
},
scopedSlots: {
'empty-state': () => createElement(EmptyState),
},
});
},
});
};

View File

@ -26,9 +26,10 @@ import { renderGFM } from '~/behaviors/markdown/render_gfm';
import eventHub from '../event_hub';
import animateMixin from '../mixins/animate';
import {
deleteTaskListItem,
convertDescriptionWithNewSort,
deleteTaskListItem,
extractTaskTitleAndDescription,
insertNextToTaskListItemText,
} from '../utils';
import TaskListItemActions from './task_list_item_actions.vue';
@ -265,12 +266,15 @@ export default {
createTaskListItemActions() {
const app = new Vue({
el: document.createElement('div'),
provide: { issuableType: this.issuableType },
provide: { id: this.issueId, issuableType: this.issuableType },
render: (createElement) => createElement(TaskListItemActions),
});
return app.$el;
},
convertTaskListItem(sourcepos) {
convertTaskListItem({ id, sourcepos }) {
if (this.issueId !== id) {
return;
}
const oldDescription = this.descriptionText;
const { newDescription, taskDescription, taskTitle } = deleteTaskListItem(
oldDescription,
@ -279,7 +283,10 @@ export default {
this.$emit('saveDescription', newDescription);
this.createTask({ taskTitle, taskDescription, oldDescription });
},
deleteTaskListItem(sourcepos) {
deleteTaskListItem({ id, sourcepos }) {
if (this.issueId !== id) {
return;
}
const { newDescription } = deleteTaskListItem(this.descriptionText, sourcepos);
this.$emit('saveDescription', newDescription);
},
@ -290,27 +297,11 @@ export default {
taskListItems?.forEach((item) => {
const dropdown = this.createTaskListItemActions();
this.insertNextToTaskListItemText(dropdown, item);
insertNextToTaskListItemText(dropdown, item);
this.addPointerEventListeners(item, '.task-list-item-actions');
this.hasTaskListItemActions = true;
});
},
insertNextToTaskListItemText(element, listItem) {
const children = Array.from(listItem.children);
const paragraph = children.find((el) => el.tagName === 'P');
const list = children.find((el) => el.classList.contains('task-list'));
if (paragraph) {
// If there's a `p` element, then it's a multi-paragraph task item
// and the task text exists within the `p` element as the last child
paragraph.append(element);
} else if (list) {
// Otherwise, the task item can have a child list which exists directly after the task text
list.insertAdjacentElement('beforebegin', element);
} else {
// Otherwise, the task item is a simple one where the task text exists as the last child
listItem.append(element);
}
},
stripClientState(description) {
return description.replaceAll('<details open="true">', '<details>');
},

View File

@ -8,7 +8,7 @@ export default {
GlDisclosureDropdown,
GlDisclosureDropdownItem,
},
inject: ['issuableType'],
inject: ['id', 'issuableType'],
computed: {
showConvertToTaskItem() {
return [TYPE_INCIDENT, TYPE_ISSUE].includes(this.issuableType);
@ -16,10 +16,16 @@ export default {
},
methods: {
convertToTask() {
eventHub.$emit('convert-task-list-item', this.$el.closest('li').dataset.sourcepos);
eventHub.$emit('convert-task-list-item', this.eventPayload());
},
deleteTaskListItem() {
eventHub.$emit('delete-task-list-item', this.$el.closest('li').dataset.sourcepos);
eventHub.$emit('delete-task-list-item', this.eventPayload());
},
eventPayload() {
return {
id: this.id,
sourcepos: this.$el.closest('li').dataset.sourcepos,
};
},
},
};

View File

@ -228,3 +228,28 @@ export const extractTaskTitleAndDescription = (taskTitle, taskDescription) => {
description: taskDescription,
};
};
/**
* Insert an element, such as a dropdown, next to a checkbox
* in an issue/work item description rendered from markdown.
*
* @param element Element to insert
* @param listItem The list item containing the checkbox
*/
export const insertNextToTaskListItemText = (element, listItem) => {
const children = Array.from(listItem.children);
const paragraph = children.find((el) => el.tagName === 'P');
const list = children.find((el) => el.classList.contains('task-list'));
if (paragraph) {
// If there's a `p` element, then it's a multi-paragraph task item
// and the task text exists within the `p` element as the last child
paragraph.append(element);
} else if (list) {
// Otherwise, the task item can have a child list which exists directly after the task text
list.insertAdjacentElement('beforebegin', element);
} else {
// Otherwise, the task item is a simple one where the task text exists as the last child
listItem.append(element);
}
};

View File

@ -1,4 +1,8 @@
import EmptyState from '~/groups/components/empty_states/groups_dashboard_empty_state.vue';
import initGroupsList from '~/groups';
import { initGroupsListWithFilteredSearch } from '~/groups/init_groups_list_with_filtered_search';
import { DASHBOARD_FILTERED_SEARCH_NAMESPACE } from '~/groups/constants';
initGroupsList(EmptyState);
initGroupsListWithFilteredSearch({
filteredSearchNamespace: DASHBOARD_FILTERED_SEARCH_NAMESPACE,
EmptyState,
});

View File

@ -1,3 +1,8 @@
import { initGroupsExplore } from '~/groups/init_groups_explore';
import EmptyState from '~/groups/components/empty_states/groups_explore_empty_state.vue';
import { initGroupsListWithFilteredSearch } from '~/groups/init_groups_list_with_filtered_search';
import { EXPLORE_FILTERED_SEARCH_NAMESPACE } from '~/groups/constants';
initGroupsExplore();
initGroupsListWithFilteredSearch({
filteredSearchNamespace: EXPLORE_FILTERED_SEARCH_NAMESPACE,
EmptyState,
});

View File

@ -353,8 +353,11 @@ export default {
<work-item-description-rendered
v-else
:work-item-description="workItemDescription"
:work-item-id="workItemId"
:work-item-type="workItemType"
:can-edit="canEdit"
:disable-truncation="disableTruncation"
:is-updating="isSubmitting"
@startEditing="startEditing"
@descriptionUpdated="handleDescriptionTextUpdated"
/>

View File

@ -1,9 +1,15 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import Vue from 'vue';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import TaskListItemActions from '~/issues/show/components/task_list_item_actions.vue';
import eventHub from '~/issues/show/event_hub';
import { deleteTaskListItem, insertNextToTaskListItemText } from '~/issues/show/utils';
import { isDragging } from '~/sortable/utils';
import SafeHtml from '~/vue_shared/directives/safe_html';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const FULL_OPACITY = 'gl-opacity-10';
const isCheckbox = (target) => target?.classList.contains('task-list-item-checkbox');
export default {
@ -21,10 +27,25 @@ export default {
required: false,
default: false,
},
isUpdating: {
type: Boolean,
required: false,
default: false,
},
workItemDescription: {
type: Object,
required: true,
},
workItemId: {
type: String,
required: false,
default: '',
},
workItemType: {
type: String,
required: false,
default: '',
},
canEdit: {
type: Boolean,
required: true,
@ -32,6 +53,7 @@ export default {
},
data() {
return {
hasTaskListItemActions: false,
truncated: false,
checkboxes: [],
};
@ -58,6 +80,13 @@ export default {
immediate: true,
},
},
mounted() {
eventHub.$on('delete-task-list-item', this.deleteTaskListItem);
},
beforeDestroy() {
eventHub.$off('delete-task-list-item', this.deleteTaskListItem);
this.removeAllPointerEventListeners();
},
methods: {
async renderGFM() {
await this.$nextTick();
@ -66,16 +95,88 @@ export default {
gl?.lazyLoader?.searchLazyImages();
if (this.canEdit) {
this.checkboxes = this.$el.querySelectorAll('.task-list-item-checkbox');
// enable boxes, disabled by default in markdown
this.checkboxes.forEach((checkbox) => {
// eslint-disable-next-line no-param-reassign
checkbox.disabled = false;
});
this.initCheckboxes();
this.removeAllPointerEventListeners();
this.renderTaskListItemActions();
}
this.truncateLongDescription();
},
initCheckboxes() {
this.checkboxes = this.$el.querySelectorAll('.task-list-item-checkbox');
// enable boxes, disabled by default in markdown
this.checkboxes.forEach((checkbox) => {
// eslint-disable-next-line no-param-reassign
checkbox.disabled = false;
});
},
renderTaskListItemActions() {
const taskListItems = this.$el.querySelectorAll?.(
'.task-list-item:not(.inapplicable, table .task-list-item)',
);
taskListItems?.forEach((listItem) => {
const dropdown = this.createTaskListItemActions();
insertNextToTaskListItemText(dropdown, listItem);
this.addPointerEventListeners(listItem, '.task-list-item-actions');
this.hasTaskListItemActions = true;
});
},
createTaskListItemActions() {
const app = new Vue({
el: document.createElement('div'),
provide: { id: this.workItemId, issuableType: this.workItemType },
render: (createElement) => createElement(TaskListItemActions),
});
return app.$el;
},
addPointerEventListeners(listItem, elementSelector) {
const pointeroverListener = (event) => {
const element = event.target.closest('li').querySelector(elementSelector);
if (!element || isDragging() || this.isUpdating) {
return;
}
element.classList.add(FULL_OPACITY);
};
const pointeroutListener = (event) => {
const element = event.target.closest('li').querySelector(elementSelector);
if (!element) {
return;
}
element.classList.remove(FULL_OPACITY);
};
// We use pointerover/pointerout instead of CSS so that when we hover over a
// list item with children, the grip icons of its children do not become visible.
listItem.addEventListener('pointerover', pointeroverListener);
listItem.addEventListener('pointerout', pointeroutListener);
this.pointerEventListeners = this.pointerEventListeners || new Map();
const events = [
{ type: 'pointerover', listener: pointeroverListener },
{ type: 'pointerout', listener: pointeroutListener },
];
if (this.pointerEventListeners.has(listItem)) {
const concatenatedEvents = this.pointerEventListeners.get(listItem).concat(events);
this.pointerEventListeners.set(listItem, concatenatedEvents);
} else {
this.pointerEventListeners.set(listItem, events);
}
},
removeAllPointerEventListeners() {
this.pointerEventListeners?.forEach((events, listItem) => {
events.forEach((event) => listItem.removeEventListener(event.type, event.listener));
this.pointerEventListeners.delete(listItem);
});
},
deleteTaskListItem({ id, sourcepos }) {
if (this.workItemId !== id) {
return;
}
const { newDescription } = deleteTaskListItem(this.descriptionText, sourcepos);
this.$emit('descriptionUpdated', newDescription);
},
toggleCheckboxes(event) {
const { target } = event;
@ -128,12 +229,16 @@ export default {
<template>
<div class="gl-my-5">
<div v-if="isDescriptionEmpty" class="gl-text-secondary">{{ __('No description') }}</div>
<div v-else ref="description" class="work-item-description md gl-clearfix gl-relative">
<div
v-else
ref="description"
class="work-item-description description md gl-clearfix gl-relative"
>
<div
ref="gfm-content"
v-safe-html="descriptionHtml"
data-testid="work-item-description"
:class="{ truncated: isTruncated }"
:class="{ truncated: isTruncated, 'has-task-list-item-actions': hasTaskListItemActions }"
@change="toggleCheckboxes"
></div>
<div

View File

@ -21,44 +21,3 @@
}
}
}
.group-nav-container {
.nav-controls {
.group-filter-form {
flex: 1 1 auto;
margin-right: $gl-padding-8;
}
.dropdown-menu-right {
margin-top: 0;
}
@include media-breakpoint-down(sm) {
.dropdown,
.dropdown .dropdown-toggle,
.btn-success {
display: block;
}
.group-filter-form,
.dropdown {
margin-bottom: 10px;
margin-right: 0;
}
&,
.group-filter-form,
.group-filter-form-field,
.dropdown,
.dropdown .dropdown-toggle,
.btn-success {
width: 100%;
}
}
}
// Remove this selector once https://gitlab.com/gitlab-org/gitlab/-/issues/370050 is addressed.
.scrolling-tabs-container {
width: 100%;
}
}

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
module Projects
class MlController < ::Projects::ApplicationController
feature_category :mlops
include PreviewMarkdown
before_action :authorize_read_model_registry!
private
def authorize_read_model_registry!
render_404 unless can?(current_user, :read_model_registry, @project)
end
end
end

View File

@ -52,7 +52,7 @@ module Types
def job_execution_status
BatchLoader::GraphQL.for(runner_manager.id).batch(key: :running_builds_exist) do |runner_manager_ids, loader|
statuses = ::Ci::RunnerManager.id_in(runner_manager_ids).with_running_builds.index_by(&:id)
statuses = ::Ci::RunnerManager.id_in(runner_manager_ids).with_executing_builds.index_by(&:id)
runner_manager_ids.each do |runner_manager_id|
loader.call(runner_manager_id, statuses[runner_manager_id] ? :running : :idle)

View File

@ -155,7 +155,7 @@ module Types
def job_execution_status
BatchLoader::GraphQL.for(runner.id).batch(key: :running_builds_exist) do |runner_ids, loader|
statuses = ::Ci::Runner.id_in(runner_ids).with_running_builds.index_by(&:id)
statuses = ::Ci::Runner.id_in(runner_ids).with_executing_builds.index_by(&:id)
runner_ids.each do |runner_id|
loader.call(runner_id, statuses[runner_id] ? :running : :idle)

View File

@ -11,6 +11,8 @@ module Types
present_using ::Ml::ModelPresenter
markdown_field :description_html, null: true
field :id, ::Types::GlobalIDType[::Ml::Model], null: false, description: 'ID of the model.'
field :name, ::GraphQL::Types::String, null: false, description: 'Name of the model.'
@ -42,6 +44,10 @@ module Types
field :version, ::Types::Ml::ModelVersionType, null: true,
description: 'Version of the model.',
resolver: ::Resolvers::Ml::FindModelVersionResolver
def description_html_resolver
::MarkupHelper.markdown(object.description, context.to_h.dup)
end
end
# rubocop: enable Graphql/AuthorizeTypes
end

View File

@ -293,11 +293,9 @@ module GroupsHelper
dropdown_data
end
def groups_explore_app_data
def groups_list_with_filtered_search_app_data(endpoint)
{
endpoint: explore_groups_path(format: :json),
empty_search_illustration: image_path('illustrations/empty-state/empty-search-md.svg'),
groups_empty_state_illustration: image_path('illustrations/empty-state/empty-groups-md.svg'),
endpoint: endpoint,
initial_sort: project_list_sort_by
}.to_json
end

View File

@ -11,7 +11,8 @@ module Projects
create_model_path: new_project_ml_model_path(project),
can_write_model_registry: can_write_model_registry?(user, project),
mlflow_tracking_url: mlflow_tracking_url(project),
max_allowed_file_size: max_allowed_file_size(project)
max_allowed_file_size: max_allowed_file_size(project),
markdown_preview_path: ::Gitlab::Routing.url_helpers.project_ml_preview_markdown_url(project)
}
to_json(data)

View File

@ -38,7 +38,7 @@ module Emails
setup_issue_mail(issue_id, recipient_id)
previous_assignees = []
previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any?
previous_assignees = User.where(id: previous_assignee_ids).order(:id) if previous_assignee_ids.any?
@added_assignees = @issue.assignees.map(&:name) - previous_assignees.map(&:name)
@removed_assignees = previous_assignees.map(&:name) - @issue.assignees.map(&:name)

View File

@ -14,7 +14,7 @@ module Emails
setup_note_mail(note_id, recipient_id)
@issue = @note.noteable
@target_url = project_issue_url(*note_target_url_options)
@target_url = Gitlab::UrlBuilder.build(@issue, **note_target_url_query_params)
mail_answer_note_thread(
@issue,
@note,
@ -85,6 +85,7 @@ module Emails
@note = note_id.is_a?(Note) ? note_id : Note.find(note_id)
@project = @note.project
@group = @note.noteable.try(:group)
@group ||= @note.noteable.resource_parent if @note.noteable.try(:resource_parent).is_a?(Group)
@recipient = User.find(recipient_id)
if (@project || @group) && @note.persisted?

View File

@ -115,9 +115,9 @@ module Ci
scope :ordered, -> { order(id: :desc) }
scope :with_recent_runner_queue, -> { where(arel_table[:contacted_at].gt(recent_queue_deadline)) }
scope :with_running_builds, -> do
where('EXISTS(?)',
::Ci::Build.running.select(1)
scope :with_executing_builds, -> do
where_exists(
::Ci::Build.executing
.where("#{::Ci::Build.quoted_table_name}.runner_id = #{quoted_table_name}.id")
)
end

View File

@ -84,14 +84,13 @@ module Ci
where(system_xid: system_xid)
end
scope :with_running_builds, -> do
where('EXISTS(?)',
Ci::Build.select(1)
scope :with_executing_builds, -> do
where_exists(
Ci::Build
.joins(:runner_manager_build)
.running
.executing
.where("#{::Ci::Build.quoted_table_name}.runner_id = #{quoted_table_name}.runner_id")
.where("#{::Ci::RunnerManagerBuild.quoted_table_name}.runner_machine_id = #{quoted_table_name}.id")
.limit(1)
)
end

View File

@ -15,6 +15,7 @@ module Ci
ORDERED_STATUSES = %w[failed preparing pending running waiting_for_callback waiting_for_resource manual scheduled canceling canceled success skipped created].freeze
PASSED_WITH_WARNINGS_STATUSES = %w[failed canceled].to_set.freeze
IGNORED_STATUSES = %w[manual].to_set.freeze
EXECUTING_STATUSES = %w[running canceling].freeze
ALIVE_STATUSES = ORDERED_STATUSES - COMPLETED_STATUSES - BLOCKED_STATUS
CANCELABLE_STATUSES = (ALIVE_STATUSES + ['scheduled'] - ['canceling']).freeze
STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3,
@ -93,6 +94,7 @@ module Ci
scope :alive, -> { with_status(*ALIVE_STATUSES) }
scope :created_or_pending, -> { with_status(:created, :pending) }
scope :running_or_pending, -> { with_status(:running, :pending) }
scope :executing, -> { with_status(*EXECUTING_STATUSES) }
scope :finished, -> { with_status(:success, :failed, :canceled) }
scope :failed_or_canceled, -> { with_status(:failed, :canceled, :canceling) }
scope :complete, -> { with_status(completed_statuses) }

View File

@ -4,8 +4,3 @@
- if current_user.can_create_group?
= render Pajamas::ButtonComponent.new(href: new_group_path, variant: :confirm, button_options: { data: { testid: "new-group-button" } }) do
= _("New group")
.gl-flex.gl-py-3.gl-gap-3
.gl-w-full
= render 'shared/groups/search_form'
= render 'shared/groups/dropdown'

View File

@ -1,2 +1 @@
.js-groups-list-holder
#js-groups-tree{ data: { endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap', groups_empty_state_illustration: image_path('illustrations/empty-state/empty-groups-md.svg'), empty_search_illustration: image_path('illustrations/empty-state/empty-search-md.svg') } }
#js-groups-list-with-filtered-search{ data: { app_data: groups_list_with_filtered_search_app_data(dashboard_groups_path(format: :json)) } }

View File

@ -1 +1 @@
#js-groups-explore{ data: { app_data: groups_explore_app_data } }
#js-groups-list-with-filtered-search{ data: { app_data: groups_list_with_filtered_search_app_data(explore_groups_path(format: :json)) } }

View File

@ -1,4 +0,0 @@
.top-area.gl-p-3.gl-justify-content-end
.nav-controls
= render 'shared/groups/search_form'
= render 'shared/groups/dropdown'

View File

@ -1,6 +1,7 @@
- if @work_item.present?
= render 'shared/work_item_metadata', work_item: @work_item
- add_work_item_show_breadcrumb(@group, @work_item.iid)
- add_page_specific_style 'page_bundles/issues_show'
- add_page_specific_style 'page_bundles/work_items'
- add_page_specific_style 'page_bundles/design_management'
- @gfm_form = true

View File

@ -23,7 +23,6 @@
= dispensable_render_if_exists "shared/web_hooks/group_web_hook_disabled_alert"
= dispensable_render_if_exists "shared/free_user_cap_alert", source: @group
= dispensable_render_if_exists "shared/seat_overage_alert", resource: @group
= dispensable_render_if_exists "shared/unlimited_members_during_trial_alert", resource: @group
= render template: base_layout || "layouts/application"

View File

@ -26,7 +26,6 @@
= dispensable_render_if_exists "projects/importing_alert", project: @project
= dispensable_render_if_exists "shared/web_hooks/web_hook_disabled_alert"
= dispensable_render_if_exists "projects/free_user_cap_alert", project: @project
= dispensable_render_if_exists "shared/seat_overage_alert", resource: @project
= dispensable_render_if_exists 'shared/unlimited_members_during_trial_alert', resource: @project
= render template: "layouts/application"

View File

@ -1,6 +1,7 @@
- page_title _('Issues')
- add_page_specific_style 'page_bundles/issuable_list'
- add_page_specific_style 'page_bundles/issues_list'
- add_page_specific_style 'page_bundles/issues_show'
- add_page_specific_style 'page_bundles/work_items'
- add_page_specific_style 'page_bundles/design_management'
= content_for :meta_tags do

View File

@ -1,6 +1,7 @@
- if @work_item.present?
= render 'shared/work_item_metadata', work_item: @work_item
- add_work_item_show_breadcrumb(@project, @work_item.iid)
- add_page_specific_style 'page_bundles/issues_show'
- add_page_specific_style 'page_bundles/work_items'
- add_page_specific_style 'page_bundles/design_management'
- @gfm_form = true

View File

@ -1,2 +0,0 @@
= form_tag request.path, method: :get, class: "group-filter-form js-group-filter-form", id: 'group-filter-form' do |f|
= search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Search by name'), class: 'group-filter-form-field form-control js-groups-list-filter', data: { testid: 'groups-filter-field' }, spellcheck: false, id: 'group-filter-form-field'

View File

@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/454583
milestone: '16.11'
type: beta
group: group::secret detection
default_enabled: false
default_enabled: true

View File

@ -1,9 +0,0 @@
---
name: sidekiq_ip_address
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/452532
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/155817
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/465177
milestone: '17.1'
group: group::duo chat
type: beta
default_enabled: true

View File

@ -483,6 +483,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resources :models, only: [:index, :show, :destroy, :new], controller: 'models', param: :model_id do
resources :versions, only: [:show], controller: 'model_versions', param: :model_version_id
end
post :preview_markdown
end
namespace :service_desk do

View File

@ -94,7 +94,7 @@ The following API resources are available in the project context:
| [Protected branches](protected_branches.md) | `/projects/:id/protected_branches` |
| [Protected container registry](project_container_registry_protection_rules.md) | `/projects/:id/registry/protection/rules` |
| [Protected environments](protected_environments.md) | `/projects/:id/protected_environments` |
| [Protected packages](project_packages_protection_rules.md) | `/projects/:id/protection/rules` |
| [Protected packages](project_packages_protection_rules.md) | `/projects/:id/packages/protection/rules` |
| [Protected tags](protected_tags.md) | `/projects/:id/protected_tags` |
| [PyPI packages](packages/pypi.md) | `/projects/:id/packages/pypi` (also available for groups) |
| [Release links](releases/links.md) | `/projects/:id/releases/.../assets/links` |

View File

@ -26356,6 +26356,7 @@ Machine learning model in the model registry.
| <a id="mlmodelcandidates"></a>`candidates` | [`MlCandidateConnection`](#mlcandidateconnection) | Version candidates of the model. (see [Connections](#connections)) |
| <a id="mlmodelcreatedat"></a>`createdAt` | [`Time!`](#time) | Date of creation. |
| <a id="mlmodeldescription"></a>`description` | [`String`](#string) | Description of the model. |
| <a id="mlmodeldescriptionhtml"></a>`descriptionHtml` | [`String`](#string) | GitLab Flavored Markdown rendering of `description`. |
| <a id="mlmodelid"></a>`id` | [`MlModelID!`](#mlmodelid) | ID of the model. |
| <a id="mlmodellatestversion"></a>`latestVersion` | [`MlModelVersion`](#mlmodelversion) | Latest version of the model. |
| <a id="mlmodelname"></a>`name` | [`String!`](#string) | Name of the model. |

View File

@ -114,7 +114,7 @@ curl --request POST \
}'
```
### Update a package protection rule
## Update a package protection rule
Update a package protection rule for a project.

View File

@ -328,18 +328,18 @@ insight into what went wrong.
The following table gives an overview of how the API functions generally behave.
| Request type | Description |
|:--------------|:--------------------------------------------------------------------------------------------------------------------------------|
| `GET` | Access one or more resources and return the result as JSON. |
| `POST` | Return `201 Created` if the resource is successfully created and return the newly created resource as JSON. |
| `GET` / `PUT` | Return `200 OK` if the resource is accessed or modified successfully. The (modified) result is returned as JSON. |
| `DELETE` | Returns `204 No Content` if the resource was deleted successfully or `202 Accepted` if the resource is scheduled to be deleted. |
| Request type | Description |
|:------------------------|:--------------------------------------------------------------------------------------------------------------------------------|
| `GET` | Access one or more resources and return the result as JSON. |
| `POST` | Return `201 Created` if the resource is successfully created and return the newly created resource as JSON. |
| `GET` / `PUT` / `PATCH` | Return `200 OK` if the resource is accessed or modified successfully. The (modified) result is returned as JSON. |
| `DELETE` | Returns `204 No Content` if the resource was deleted successfully or `202 Accepted` if the resource is scheduled to be deleted. |
The following table shows the possible return codes for API requests.
| Return values | Description |
|:--------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------|
| `200 OK` | The `GET`, `PUT` or `DELETE` request was successful, and the resource itself is returned as JSON. |
| `200 OK` | The `GET`, `PUT`, `PATCH` or `DELETE` request was successful, and the resource itself is returned as JSON. |
| `201 Created` | The `POST` request was successful, and the resource is returned as JSON. |
| `202 Accepted` | The `GET`, `PUT` or `DELETE` request was successful, and the resource is scheduled for processing. |
| `204 No Content` | The server has successfully fulfilled the request, and there is no additional content to send in the response payload body. |

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@ -405,7 +405,7 @@ When we have a blueprint merged ideally the confidence should move to 👍 becau
| Routing | group::tenant scale | [Blueprint](../http_routing_service.md) | 👍 |
| Cell Control Plane | group::Delivery/team::Foundations | To-Do | 👎 |
| Cell Sizing | team::Scalability-Observability | [To-Do](https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/2838) | 👎 |
| CI Runners | team::Scalability-Practices | To-Do | 👎 |
| CI Runners | team::Scalability-Practices | [Blueprint](runner.md) | 👎 |
| Databases | team::Database Reliability | [Blueprint](postgresql.md) | 👍 |
| Deployments | group::Delivery | [Blueprint](deployments.md) | 👍 |
| Observability | team::Scalability-Observability | [Blueprint](observability.md) | 👎 |

View File

@ -0,0 +1,105 @@
---
owning-stage: "~devops::verify"
group: Runner
description: 'Cells: Runner'
creation-date: "2024-07-10"
authors: [ "@rehab", "" ]
coach:
approvers: ["@josephburnett", "@tmaczukin", "@amknight", "@skarbek"]
---
# Cells: Runner
This blueprint describes the architecture and roadmap of the Runner service in a cellular architecture.
## Introduction
[GitLab Runner](https://docs.gitlab.com/runner/) is a service that is relatively easy to provision and update compared to other GitLab.com services.
Currently having two exemplary setups, [SaaS Runners](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/ci/runners/index.md) and [Dedicated Runners](https://gitlab.com/gitlab-com/gl-infra/gitlab-dedicated/team/-/blob/main/architecture/blueprints/dedicated-runners-beta.md?ref_type=heads), with the latter having a superior setup enhanced by implementing [GRIT](https://gitlab.com/gitlab-org/ci-cd/runner-tools/grit).
Following [Cell's philosophy](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/architecture/blueprints/cells/infrastructure/index.md#philosophy), the Runner service is a perfect candidate for implementing a cell-local service, with almost no need for any change to its [codebase](https://gitlab.com/gitlab-org/gitlab-runner/)(_TBC_).
In [Cells 1.0](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/architecture/blueprints/cells/iterations/cells-1.0.md), new customers are expected to enroll in the first Cell, in this iteration, we'll utilize the existing work made as part of Dedicated [Beta release](https://gitlab.com/gitlab-com/gl-infra/gitlab-dedicated/team/-/blob/main/architecture/blueprints/dedicated-runners-beta.md?ref_type=heads#dedicated-runners-beta) to provision the Runners.
### Architecture
<img src="../diagrams/term-cell-runner.drawio.png" alt="Cell Runner diagram">
- There's a 1:n relationship between a Cell and a Runner, i.e, one Cell can have many Runners, while a Runner is only registered to one Cell.
- Primary Cell (GitLab.com) is using Runners that are managed via [config-mgmt](https://gitlab.com/gitlab-com/gl-infra/config-mgmt) and [chef-repo](https://gitlab.com/gitlab-com/gl-infra/chef-repo), while the Secondary Cells are using [Transistor](https://gitlab.com/gitlab-com/gl-infra/gitlab-dedicated/transistor/)-like setup, which vendors in GRIT.
Note, there's currently no Runner Reference Architectures, however, some links the reader can reference:
- [GitLab-runner/docs/fleet_scaling](https://gitlab.com/gitlab-org/gitlab-runner/-/blob/main/docs/fleet_scaling/index.md).
- [Create a test framework for testing runner fleet autoscaling configurations](https://gitlab.com/gitlab-org/gitlab/-/issues/458311).
- [Document jump start configurations for an autoscaled runner fleet on Google Compute](https://gitlab.com/gitlab-org/gitlab/-/issues/458313).
- [Runner Fleet Scaling and Configuration Best Practices Center](https://gitlab.com/groups/gitlab-org/-/epics/8952).
### Provision
Cells are expected to follow provisioning design and tooling similar to that of Dedicated; Runners in this case would utilize a tool similar to [Transistor](https://gitlab.com/gitlab-com/gl-infra/gitlab-dedicated/transistor/), which in a nutshell, is a collection of Terraform modules and bash scripts.
Transistor's [development guidelines](https://gitlab.com/gitlab-com/gl-infra/gitlab-dedicated/transistor/-/blob/main/DEVELOPMENT.md) have more elaborate info, but a simplified version of the provisioning steps of a Runner could look like the following:
1. Generate a runner model.
1. Provision the runner via one of the following steps:
- Execute ./bin/prepare, ./bin/onboard/ then ./bin/provision.
- Alternatively, create an [MR in Switchboard](https://gitlab.com/gitlab-com/gl-infra/gitlab-dedicated/sandbox/switchboard_runners/#triggering-a-deployment-via-merge-request), which will execute the scripts above in order.
The deployment pipeline for a Runner could be triggered as a downstream pipeline following Instrumentor's deployment of a GitLab instance, this would require Instrumentor (or a higher-level job, e.g. in switchboard) to generate a runner model before the downstream pipeline is triggered.
#### Cells vs Dedicated
The Dedicated team has restricted access to the GitLab instances in which Runners are registered, this however, is not the case with Cells.
The SRE team fully owns and maintains a Cell, which means that registration token management will not require any external coordination, and can be a step that is automated using the provisioning scripts.
### Deployment
Ideally, Cells:Runner should be managed similarly to the rest of the services in a Cell.
At this moment, GitLab.com SaaS Runners have a separate ad-hoc [deployer tool](https://gitlab.com/gitlab-com/gl-infra/ci-runners/deployer/), not to be confused with the [GitLab.com grand Deployer](https://ops.gitlab.net/gitlab-com/gl-infra/deployer), which is run and maintained by the Runner group.
In Cells, the Runner service will utilize Dedicated-like tooling, which is similar to the rest of the services, this will ensure visibility, observability and maintainability. Runners Dedicated are highly dependent on GRIT, which promises to introduce a [blue-green mechanism](https://gitlab.com/groups/gitlab-org/ci-cd/runner-tools/-/epics/1) which will be eventually utilized by both Dedicated and Cells.
### Observability
Observability for Runners on Dedicated is currently a WIP, Runners should be treated as the rest of the services, and should utilize the recording rules and dashboards used currently in GitLab.com.
## Roadmap
To keep [Centralize tooling](index.md#philosophy), 2 open questions:
1. How do we feel about eventually combining [Transistor](https://gitlab.com/gitlab-com/gl-infra/gitlab-dedicated/transistor/) back into [Instrumentor](https://gitlab.com/gitlab-com/gl-infra/gitlab-dedicated/instrumentor/)?
Combining Transistor and Instrumentor could "make changes to runners INCREDIBLY costly and slow to have to go through the whole instrumentor deployment process." (@amknight , 2024), additionally, keeping both projects modular would enable a more efficient and safer release process as the QA testing process is separate (@skarbek, 2024).
1. How do we feel about incorporating Runner deployments with the rest of GitLab service deployments in a Cell?
As mentioned under [Provision](runner.md#provision), we could possible use downstream pipelines to achieve this.
### Iterations [WIP]
#### Cells 1.0
Linux Runners of type `small` will be available.
#### Cells 1.5
#### Cells 2.0
## FAQ [WIP]
**Q1**
A1
**Q2**
A2
## References
- @amknight (2024): [!146768 (comment 1823581090)](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/146768#note_1823581090).
- @skarbek (2024):[!146768 (comment 1819418987)](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/146768#note_1819418987).

View File

@ -10,14 +10,9 @@ DETAILS:
**Tier:** Free, Premium, Ultimate
**Offering:** GitLab.com, Self-managed, GitLab Dedicated
When a new pipeline starts, GitLab checks the pipeline configuration to determine
which jobs should run in that pipeline. You can configure jobs to run depending on
factors like the status of variables, or the pipeline type.
To configure a job to be included or excluded from certain pipelines, use [`rules`](job_rules.md).
Use [`needs`](../yaml/index.md#needs) to configure a job to run as soon as the
earlier jobs it depends on finish running.
Before a new pipeline starts, GitLab checks the pipeline configuration to determine
which jobs can run in that pipeline. You can configure jobs to run depending on
conditions like the value of variables or the pipeline type with [`rules`](job_rules.md).
## Create a job that must be run manually
@ -39,8 +34,7 @@ Manual jobs can be either optional or blocking.
In optional manual jobs:
- [`allow_failure`](../yaml/index.md#allow_failure) is `true`, which is the default
setting for jobs that have `when: manual` and no [`rules`](../yaml/index.md#rules),
or `when: manual` defined outside of `rules`.
setting for jobs that have `when: manual` defined outside of `rules`.
- The status does not contribute to the overall pipeline status. A pipeline can
succeed even if all of its manual jobs fail.
@ -69,7 +63,9 @@ You can also [add custom CI/CD variables when running a manual job](index.md#spe
### Add a confirmation dialog for manual jobs
Use [`manual_confirmation`](../yaml/index.md#manual_confirmation) with `when: manual` to add a confirmation dialog for manual jobs. The confirmation dialog helps to prevent accidental deployments or deletions, especially for sensitive jobs like those that deploy to production.
Use [`manual_confirmation`](../yaml/index.md#manual_confirmation) with `when: manual` to add a confirmation dialog for manual jobs.
The confirmation dialog helps to prevent accidental deployments or deletions,
especially for sensitive jobs like those that deploy to production.
Users are prompted to confirm the action before the manual job runs, which provides an additional layer of safety and control.
@ -185,7 +181,8 @@ Test Boosters reports usage statistics to the author.
### Run a one-dimensional matrix of parallel jobs
You can create a one-dimensional matrix of parallel jobs:
To run a job multiple times in parallel in a single pipeline, but with different variable values for each instance of the job,
use the [`parallel:matrix`](../yaml/index.md#parallelmatrix) keyword:
```yaml
deploystacks:
@ -198,8 +195,6 @@ deploystacks:
environment: production/$PROVIDER
```
You can also [create a multi-dimensional matrix](../yaml/index.md#parallelmatrix).
### Run a matrix of parallel trigger jobs
You can run a [trigger](../yaml/index.md#trigger) job multiple times in parallel in a single pipeline,
@ -240,6 +235,8 @@ keyword for dynamic runner selection:
```yaml
deploystacks:
stage: deploy
script:
- bin/deploy
parallel:
matrix:
- PROVIDER: aws
@ -251,7 +248,7 @@ deploystacks:
environment: $PROVIDER/$STACK
```
#### Fetch artifacts from a `parallel:matrix` job
### Fetch artifacts from a `parallel:matrix` job
You can fetch artifacts from a job created with [`parallel:matrix`](../yaml/index.md#parallelmatrix)
by using the [`dependencies`](../yaml/index.md#dependencies) keyword. Use the job name

View File

@ -613,7 +613,7 @@ flow of how we construct a Chat prompt:
which calls `ai_client.stream`
([code](https://gitlab.com/gitlab-org/gitlab/-/blob/e88256b1acc0d70ffc643efab99cad9190529312/ee/lib/gitlab/llm/chain/requests/ai_gateway.rb#L20-27))
1. `ai_client.stream` routes to `Gitlab::Llm::AiGateway::Client#stream`, which
makes an API request to the AI Gateway `/v1/chat/completion` endpoint
makes an API request to the AI Gateway `/v1/chat/agent` endpoint
([code](https://gitlab.com/gitlab-org/gitlab/-/blob/e88256b1acc0d70ffc643efab99cad9190529312/ee/lib/gitlab/llm/ai_gateway/client.rb#L64-82))
1. We've now made our first request to the AI Gateway. If the LLM says that the
answer to the first request is a final answer, we

View File

@ -51,9 +51,10 @@ If the highest number stable branch is unclear, check the [GitLab blog](https://
|:------------------------|:----------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [Ruby](#2-ruby) | `3.1.x` | From GitLab 16.7, Ruby 3.1 is required. You must use the standard MRI implementation of Ruby. We love [JRuby](https://www.jruby.org/) and [Rubinius](https://github.com/rubinius/rubinius#the-rubinius-language-platform), but GitLab needs several Gems that have native extensions. |
| [RubyGems](#3-rubygems) | `3.5.x` | A specific RubyGems version is not required, but you should update to benefit from some known performance improvements. |
| [Go](#4-go) | `1.20.x` | From GitLab 16.4, Go 1.20 or later is required. |
| [Go](#4-go) | `1.22.x` | From GitLab 17.1, Go 1.22 or later is required. |
| [Git](#git) | `2.44.x` | From GitLab 17.1, Git 2.44.x and later is required. You should use the [Git version provided by Gitaly](#git). |
| [Node.js](#5-node) | `20.13.x` | From GitLab 17.0, Node.js 20.13 or later is required. |
| [PostgreSQL](#7-database) | `14.x` | From GitLab 17.0, PostgreSQL 14 or later is required. |
## GitLab directory structure
@ -253,11 +254,11 @@ Linux. You can find downloads for other platforms at the
# Remove former Go installation folder
sudo rm -rf /usr/local/go
curl --remote-name --location --progress-bar "https://go.dev/dl/go1.20.8.linux-amd64.tar.gz"
echo 'cc97c28d9c252fbf28f91950d830201aa403836cbed702a05932e63f7f0c7bc4 go1.20.8.linux-amd64.tar.gz' | shasum -a256 -c - && \
sudo tar -C /usr/local -xzf go1.20.8.linux-amd64.tar.gz
curl --remote-name --location --progress-bar "https://go.dev/dl/go1.22.5.linux-amd64.tar.gz"
echo '904b924d435eaea086515bc63235b192ea441bd8c9b198c507e85009e6e4c7f0 go1.22.5.linux-amd64.tar.gz' | shasum -a256 -c - && \
sudo tar -C /usr/local -xzf go1.22.5.linux-amd64.tar.gz
sudo ln -sf /usr/local/go/bin/{go,gofmt} /usr/local/bin/
rm go1.20.8.linux-amd64.tar.gz
rm go1.22.5.linux-amd64.tar.gz
```
## 5. Node
@ -296,7 +297,7 @@ sudo adduser --disabled-login --gecos 'GitLab' git
NOTE:
Only PostgreSQL is supported.
In GitLab 16.0 and later, we [require PostgreSQL 13+](requirements.md#postgresql-requirements).
In GitLab 17.0 and later, we [require PostgreSQL 14+](requirements.md#postgresql-requirements).
1. Install the database packages.

View File

@ -357,6 +357,42 @@ Experiment locally before you push to the remote repository.
git rebase -i commit-id
```
### Redact text
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/450701) in GitLab 17.1 [with a flag](../../administration/feature_flags.md) named `rewrite_history_ui`. Disabled by default.
> - [Enabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/462999) in GitLab 17.2.
FLAG:
The availability of this feature is controlled by a feature flag.
For more information, see the history.
This feature is available for testing, but not ready for production use.
Permanently delete sensitive or confidential information that was accidentally committed, ensuring
it's no longer accessible in your repository's history.
Replaces a list of strings with `***REMOVED***`.
Alternatively, to completely delete specific files from a repository, see
[Remove blobs](../../user/project/repository/reducing_the_repo_size_using_git.md#remove-blobs).
Prerequisites:
- You must have the Owner role for the instance.
To redact text from your repository:
1. On the left sidebar, select **Search or go to** and find your project.
1. Select **Settings > Repository**.
1. Expand **Repository maintenance**.
1. Select **Redact text**.
1. On the drawer, enter the text to redact.
You can use regex and glob patterns.
1. Select **Redact matching strings**.
1. On the confirmation dialog, enter your project path.
1. Select **Yes, redact matching strings**.
1. On the left sidebar, select **Settings > General**.
1. Expand **Advanced**.
1. Select **Run housekeeping**.
### Delete sensitive information from commits
You can use Git to delete sensitive information from your past commits. However,

View File

@ -99,11 +99,11 @@ Download and install Go (for Linux, 64-bit):
# Remove former Go installation folder
sudo rm -rf /usr/local/go
curl --remote-name --location --progress-bar "https://go.dev/dl/go1.20.8.linux-amd64.tar.gz"
echo 'cc97c28d9c252fbf28f91950d830201aa403836cbed702a05932e63f7f0c7bc4 go1.20.8.linux-amd64.tar.gz' | shasum -a256 -c - && \
sudo tar -C /usr/local -xzf go1.20.8.linux-amd64.tar.gz
curl --remote-name --location --progress-bar "https://go.dev/dl/go1.22.5.linux-amd64.tar.gz"
echo '904b924d435eaea086515bc63235b192ea441bd8c9b198c507e85009e6e4c7f0 go1.22.5.linux-amd64.tar.gz' | shasum -a256 -c - && \
sudo tar -C /usr/local -xzf go1.22.5.linux-amd64.tar.gz
sudo ln -sf /usr/local/go/bin/{go,gofmt} /usr/local/bin/
rm go1.20.8.linux-amd64.tar.gz
rm go1.22.5.linux-amd64.tar.gz
```
### 6. Update Git
@ -136,7 +136,7 @@ Remember to set `git -> bin_path` to `/usr/local/bin/git` in `config/gitlab.yml`
### 7. Update PostgreSQL
WARNING:
GitLab 16.0 requires at least PostgreSQL 13.
GitLab 17.0 requires at least PostgreSQL 14.
The latest version of GitLab might depend on a more recent PostgreSQL version
than what you are running. You may also have to enable some

View File

@ -531,7 +531,7 @@ You can interact with the results of the security scanning tools in several loca
- [Scan information in merge requests](#merge-request)
- [Project Security Dashboard](security_dashboard/index.md#project-security-dashboard)
- [Security pipeline tab](security_dashboard/index.md)
- [Security pipeline tab](vulnerability_report/pipeline.md)
- [Group Security Dashboard](security_dashboard/index.md#group-security-dashboard)
- [Security Center](security_dashboard/index.md#security-center)
- [Vulnerability Report](vulnerability_report/index.md)

View File

@ -8,20 +8,21 @@ info: To determine the technical writer assigned to the Stage/Group associated w
DETAILS:
**Tier:** Ultimate
**Offering:** GitLab.com, GitLab Dedicated
**Offering:** GitLab.com, Self-managed, GitLab Dedicated
**Status:** Beta
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/11439) in GitLab 16.7 as an [experiment](../../../../policy/experiment-beta-support.md) for GitLab Dedicated customers.
> - [Changed](https://gitlab.com/groups/gitlab-org/-/epics/12729) to Beta and made available on GitLab.com in GitLab 17.1.
> - [Enabled on self-managed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/156907) in GitLab 17.2.
FLAG:
On self-managed GitLab, by default this feature is available. To hide the feature, ask an administrator to [disable the feature flags](../../../../administration/feature_flags.md) named `pre_receive_secret_detection_beta_release` and `pre_receive_secret_detection_push_check`.
Secret push protection blocks secrets such as keys and API tokens from being pushed to GitLab.
The content of each commit is checked for secrets when pushed to GitLab. If any secrets are
detected, the push is blocked.
Secret push protection is available on GitLab.com and GitLab Dedicated. To scan for secrets
in your GitLab self-managed instance, use [pipeline secret detection](../index.md)
instead. Pipeline secret detection can be used together with secret push protection to
further secure your GitLab.com or Dedicated instance.
Use [pipeline secret detection](../index.md) together with secret push protection to further strengthen your security.
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
For an overview, see the playlist [Get Started with Secret Push Protection](https://www.youtube.com/playlist?list=PL05JrBw4t0KoADm-g2vxfyR0m6QLphTv-).
@ -50,7 +51,9 @@ If secret push protection does not detect any secrets in your commits, no messag
## Enable secret push protection
On GitLab Dedicated and GitLab.com, secret push protection must be enabled at the instance level and then you must enable it per project.
On GitLab Dedicated and Self-managed instances, secret push protection must be enabled at the instance level and then you must enable it per project.
On GitLab.com, this setting has been enabled at the instance level. You must enable it per project.
### Allow the use of secret push protection in your GitLab instance

View File

@ -50,7 +50,7 @@ Because this is an experimental feature,
| Flag | Description | Actor | Status |
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ----- | ------ |
| `work_item_epics` | Consolidated flag that contains all the changes needed to get epic work items to work for a given group. | Group | **Required** |
| `sync_work_item_to_epic` | Synchronizes data from a legacy epic to a corresponding work item. | Group | **Required** |
| `synced_epic_work_item_editable` | Allows editing epic work items when they have a legacy epic. | Group | **Required** |
| `work_items_rolledup_dates` | Calculates the start and due dates in a hierarchy for work items. | Group | **Required** |
| `epic_and_work_item_labels_unification` | Delegates labels between epics and epic work item. | Group | **Required** |
| `epic_and_work_item_associations_unification` | Delegates other epic and work item associations. | Group | **Required** |

View File

@ -239,6 +239,7 @@ When using repository cleanup, note:
## Remove blobs
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/450701) in GitLab 17.1 [with a flag](../../../administration/feature_flags.md) named `rewrite_history_ui`. Disabled by default.
> - [Enabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/462999) in GitLab 17.2.
FLAG:
The availability of this feature is controlled by a feature flag.
@ -248,6 +249,9 @@ This feature is available for testing, but not ready for production use.
Permanently delete sensitive or confidential information that was accidentally committed, ensuring
it's no longer accessible in your repository's history.
Alternatively, to replace strings with `***REMOVED***`, see
[Redact text](../../../topics/git/undo.md#redact-text).
Prerequisites:
- You must have the Owner role for the instance.

View File

@ -20,6 +20,8 @@ module.exports = (path, options = {}) => {
roots: extRoots = [],
rootsEE: extRootsEE = [],
rootsJH: extRootsJH = [],
isEE = IS_EE,
isJH = IS_JH,
} = options;
const reporters = ['default'];
@ -80,11 +82,11 @@ module.exports = (path, options = {}) => {
const glob = `${path}/**/*@([._])spec.js`;
let testMatch = [`<rootDir>/${glob}`];
if (IS_EE) {
if (isEE) {
testMatch.push(`<rootDir>/ee/${glob}`);
}
if (IS_JH) {
if (isJH) {
testMatch.push(`<rootDir>/jh/${glob}`);
}
// workaround for eslint-import-resolver-jest only resolving in test files
@ -130,7 +132,7 @@ module.exports = (path, options = {}) => {
const collectCoverageFrom = ['<rootDir>/app/assets/javascripts/**/*.{js,vue}'];
if (IS_EE) {
if (isEE) {
const rootDirEE = '<rootDir>/ee/app/assets/javascripts$1';
const specDirEE = '<rootDir>/ee/spec/frontend/$1';
Object.assign(moduleNameMapper, {
@ -148,7 +150,7 @@ module.exports = (path, options = {}) => {
collectCoverageFrom.push(rootDirEE.replace('$1', '/**/*.{js,vue}'));
}
if (IS_JH) {
if (isJH) {
// DO NOT add additional path to Jihu side, it might break things.
const rootDirJH = '<rootDir>/jh/app/assets/javascripts$1';
const specDirJH = '<rootDir>/jh/spec/frontend/$1';
@ -266,8 +268,8 @@ module.exports = (path, options = {}) => {
},
testEnvironment: '<rootDir>/spec/frontend/environment.js',
testEnvironmentOptions: {
IS_EE,
IS_JH,
IS_EE: isEE,
IS_JH: isJH,
url: TEST_HOST,
},
testRunner: 'jest-jasmine2',
@ -278,8 +280,8 @@ module.exports = (path, options = {}) => {
roots: [
'<rootDir>/app/assets/javascripts/',
...extRoots,
...(IS_EE ? ['<rootDir>/ee/app/assets/javascripts/', ...extRootsEE] : []),
...(IS_JH ? ['<rootDir>/jh/app/assets/javascripts/', ...extRootsJH] : []),
...(isEE ? ['<rootDir>/ee/app/assets/javascripts/', ...extRootsEE] : []),
...(isJH ? ['<rootDir>/jh/app/assets/javascripts/', ...extRootsJH] : []),
],
/*
Reduce the amount of max workers in development mode.

View File

@ -4,7 +4,6 @@ module Gitlab
module SidekiqMiddleware
class SetIpAddress
def call(_worker_class, job, _queue)
return yield if Feature.disabled?(:sidekiq_ip_address) # rubocop: disable Gitlab/FeatureFlagWithoutActor -- not applicable
return yield unless job.key?('ip_address_state')
::Gitlab::IpAddressState.with(job['ip_address_state']) do # rubocop: disable CodeReuse/ActiveRecord -- Non-AR

View File

@ -8711,18 +8711,6 @@ msgstr ""
msgid "Block user"
msgstr ""
msgid "BlockSeatOverages|%{root_namespace_name} has exceeded the number of seats in its subscription and is now %{link_start}read-only%{link_end}. To remove the read-only state, reduce the number of users in your top-level group to make seats available, or purchase more seats for the subscription."
msgstr ""
msgid "BlockSeatOverages|The top-level group %{root_namespace_name} is now read-only."
msgstr ""
msgid "BlockSeatOverages|To remove the %{link_start}read-only%{link_end} state, ask a user with the Owner role for %{root_namespace_name} to reduce the number of users in the group, or to purchase more seats for the subscription."
msgstr ""
msgid "BlockSeatOverages|Your top-level group %{root_namespace_name} is now read-only."
msgstr ""
msgid "BlockSeatOverages|Your top-level group is over the number of seats in its subscription and has been placed in a read-only state."
msgstr ""
@ -25871,9 +25859,6 @@ msgstr ""
msgid "GroupsTree|Options"
msgstr ""
msgid "GroupsTree|Search by name"
msgstr ""
msgid "Groups|An error occurred updating this group. Please try again."
msgstr ""
@ -45247,6 +45232,9 @@ msgstr ""
msgid "Runners|A runner configuration is where you configure runners based on your requirements. Each runner in this table represents a %{linkStart}single runner entry%{linkEnd} in the %{codeStart}config.toml%{codeEnd}. Multiple runners can be linked to the same configuration. When there are multiple runners in a configuration, the one that contacted GitLab most recently displays here."
msgstr ""
msgid "Runners|Active"
msgstr ""
msgid "Runners|Active runners"
msgstr ""
@ -45913,9 +45901,6 @@ msgstr ""
msgid "Runners|Runners performance"
msgstr ""
msgid "Runners|Running"
msgstr ""
msgid "Runners|Running Jobs"
msgstr ""
@ -51664,6 +51649,9 @@ msgstr ""
msgid "SubscriptionBanner|Upload new license"
msgstr ""
msgid "SubscriptionGroupsNew|Group URL %{error}"
msgstr ""
msgid "SubscriptionGroupsNew|Group name %{error}"
msgstr ""

View File

@ -126,12 +126,12 @@ module QA
run_git("git rev-parse --abbrev-ref HEAD").to_s
end
def push_changes(branch = @default_branch, push_options: nil, max_attempts: 3)
def push_changes(branch = @default_branch, push_options: nil, max_attempts: 3, raise_on_failure: true)
cmd = ['git push']
cmd << push_options_hash_to_string(push_options)
cmd << uri
cmd << branch
run_git(cmd.compact.join(' '), max_attempts: max_attempts).to_s
run_git(cmd.compact.join(' '), raise_on_failure: raise_on_failure, max_attempts: max_attempts).to_s
end
def push_all_branches
@ -333,9 +333,10 @@ module QA
read_netrc_content.grep(/^#{Regexp.escape(netrc_content)}$/).any?
end
def run_git(command_str, env: env_vars, max_attempts: 1)
def run_git(command_str, raise_on_failure: true, env: env_vars, max_attempts: 1)
run(
command_str,
raise_on_failure: raise_on_failure,
env: env,
max_attempts: max_attempts,
sleep_internal: command_retry_sleep_interval,

View File

@ -9,10 +9,6 @@ module QA
def self.included(base)
super
base.view 'app/views/shared/groups/_search_form.html.haml' do
element 'groups-filter-field'
end
base.view 'app/assets/javascripts/groups/components/groups.vue' do
element 'groups-list-tree-container'
end
@ -33,7 +29,10 @@ module QA
# @param name [String] group name
# @return [Boolean] whether the filter returned any group
def filter_group(name)
fill_element('groups-filter-field', name).send_keys(:return)
filter_input = find_element('filtered-search-term-input')
filter_input.click
filter_input.set(name)
click_element 'search-button'
# Loading starts a moment after `return` is sent. We mustn't jump ahead
wait_for_requests if spinner_exists?
has_element?('groups-list-tree-container', wait: 1)

View File

@ -22,14 +22,7 @@ module QA
end
def has_subgroup?(name)
filter_input = find_element('filtered-search-term-input')
filter_input.click
filter_input.set(name)
click_element 'search-button'
wait_for_requests
page.has_link?(name, wait: 0) # element containing link to group
has_filtered_group?(name)
end
def go_to_new_subgroup

View File

@ -21,7 +21,7 @@ module QA
end
end
def run(command_str, env: [], max_attempts: 1, sleep_internal: 0, log_prefix: '')
def run(command_str, raise_on_failure: true, env: [], max_attempts: 1, sleep_internal: 0, log_prefix: '')
command = [*env, command_str, '2>&1'].compact.join(' ')
result = nil
@ -36,8 +36,11 @@ module QA
result.success?
end
unless result.success?
raise CommandError, "The command #{result.command} failed (#{result.exitstatus}) with the following output:\n#{result.response}"
raise_error = raise_on_failure && !result.success?
if raise_error
raise CommandError,
"The command #{result.command} failed (#{result.exitstatus}) with the following output:\n#{result.response}"
end
result

View File

@ -33,9 +33,8 @@ module QA
logger.info("Number of mapping files found: #{mapping_files.size}")
mapping_data = mapping_files.flat_map { |file| JSON.parse(File.read(file)) }.reduce(:merge!)
file = "#{ENV['CI_COMMIT_REF_SLUG']}/#{ENV['QA_RUN_TYPE']}/test-
code-paths-mapping-merged-pipeline-#{ENV['CI_PIPELINE_ID'] || 'local'}.json"
File.write(file, mapping_data.to_json) && logger.debug("Saved test code paths mapping to #{file}")
file = "#{ENV['CI_COMMIT_REF_SLUG']}/#{ENV['QA_RUN_TYPE']}/test-code-paths-mapping-merged-pipeline-#{\
ENV['CI_PIPELINE_ID'] || 'local'}.json"
upload_to_gcs(file, mapping_data)
end

View File

@ -9,7 +9,7 @@ RSpec.describe QA::Git::Repository do
let(:repo_uri_with_credentials) { 'http://root@foo/bar.git' }
let(:env_vars) { [%q(HOME="temp")] }
let(:extra_env_vars) { [] }
let(:run_params) { { env: env_vars + extra_env_vars, sleep_internal: 0, log_prefix: "Git: " } }
let(:run_params) { { raise_on_failure: true, env: env_vars + extra_env_vars, sleep_internal: 0, log_prefix: "Git: " } }
let(:repository) do
described_class.new(command_retry_sleep_interval: 0).tap do |r|
r.uri = repo_uri

View File

@ -24,4 +24,10 @@ RSpec.describe QA::Support::Run do
expect { class_instance.run(command) }.to raise_error(QA::Support::Run::CommandError, /The command .* failed \(1\) with the following output:\nFAILURE/)
end
it 'returns the error message in a non-zero response when raise_on_failure is false' do
allow(Open3).to receive(:capture2e).and_return([+'FAILURE', double(exitstatus: 1)])
expect(class_instance.run(command, raise_on_failure: false).response).to eql('FAILURE')
end
end

View File

@ -18,6 +18,10 @@ RSpec.describe QA::Tools::Ci::ExportCodePathsMapping do
}
end
let(:commit_ref) { 'master' }
let(:run_type) { 'e2e-test-on-gdk' }
let(:file_path) { "#{commit_ref}/#{run_type}/test-code-paths-mapping-merged-pipeline-1.json" }
let(:pretty_generated_mapping_json) do
JSON.pretty_generate(code_path_mappings_data)
end
@ -31,15 +35,15 @@ RSpec.describe QA::Tools::Ci::ExportCodePathsMapping do
allow(Dir).to receive(:glob).with(glob) { file_paths }
allow(::File).to receive(:read).with(anything).and_return(code_path_mappings_data.to_json)
stub_env('QA_CODE_PATH_MAPPINGS_GCS_CREDENTIALS', gcs_credentials)
stub_env('QA_RUN_TYPE', 'package-and-test')
stub_env('QA_RUN_TYPE', run_type)
stub_env('CI_COMMIT_REF_SLUG', commit_ref)
stub_env('CI_PIPELINE_ID', 1)
end
context "with mapping files present" do
it "exports mapping json to GCS and writes it as job artifact", :aggregate_failures do
expect(logger).to receive(:info).with("Number of mapping files found: #{file_paths.size}")
expect(::File).to receive(:write).with(String, mapping_json).once
expect(gcs_client).to receive(:put_object).with(gcs_bucket_name,
String, pretty_generated_mapping_json)
expect(gcs_client).to receive(:put_object).with(gcs_bucket_name, file_path, pretty_generated_mapping_json)
described_class.export(glob)
end
end

View File

@ -0,0 +1,110 @@
#!/usr/bin/env node
import { relative, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import Runtime from 'jest-runtime';
import { readConfig } from 'jest-config';
import createJestConfig from '../../jest.config.base.js';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../');
function resolveDependenciesRecursively(context, target, seen) {
const dependencies = context.hasteFS.getDependencies(target);
seen.add(target);
if (!dependencies) {
return [target];
}
return [
target,
...dependencies.flatMap((file) => {
const fileNormalized = file.replace(/\/+/g, '/');
const resolved = relative(
context.config.rootDir,
context.resolver.resolveModule(target, fileNormalized),
);
if (resolved.startsWith('node_modules/')) {
return [];
}
if (seen.has(resolved)) {
return [];
}
return resolveDependenciesRecursively(context, resolved, seen);
}),
];
}
async function getJestConfig(opts = {}) {
return (
await readConfig(
{
config: JSON.stringify(
createJestConfig('spec/frontend', {
roots: ['<rootDir>/spec/frontend/'],
rootsEE: ['<rootDir>/ee/spec/frontend/'],
rootsJH: ['<rootDir>/jh/spec/frontend/'],
...opts,
}),
),
},
ROOT,
)
).projectConfig;
}
async function resolveDependenciesWithJest(target, opts = {}) {
const seen = new Set();
const { isEE = false, isJH = false } = opts;
const config = await getJestConfig({ isEE, isJH });
const context = await Runtime.default.createContext(config, {
maxWorkers: 1,
console,
watch: false,
});
return resolveDependenciesRecursively(context, target, seen);
}
/**
* This script takes a frontend files as its only argument and walks the tree to find all files it
* depends on, then prints all their paths to stdin, including the provided file.
*
* Usage:
* scripts/frontend/find_frontend_files.mjs path/to/file.js
*
* This can be useful when migrating Tailwind utils in a given file and we'd like to migrate all
* dependents at the same time. Eg:
* scripts/frontend/find_frontend_files.mjs path/to/file.js | ./node_modules/@gitlab/ui/bin/migrate_custom_utils_to_tw.bundled.mjs --from-stdin
*/
async function main() {
if (process.argv.length !== 3) {
console.warn('Please use with one argument exactly');
process.exitCode = 1;
return;
}
const target = process.argv[2];
const resolvedWithJest = new Set([
...(await resolveDependenciesWithJest(target, { isEE: true })),
...(await resolveDependenciesWithJest(target)),
]);
console.log(
Array.from(resolvedWithJest)
.sort()
.filter((x) => {
return x.endsWith('.vue') || x.endsWith('.js');
})
.filter((x) => {
return x.includes('app/assets/');
})
.join('\n'),
);
}
main();

View File

@ -38,6 +38,10 @@ FactoryBot.define do
status { 'failed' }
end
trait :canceling do
status { 'canceling' }
end
trait :canceled do
status { 'canceled' }
end

View File

@ -228,7 +228,10 @@ FactoryBot.define do
discussion = discussion.to_discussion if discussion.is_a?(Note)
next unless discussion
note.assign_attributes(discussion.reply_attributes.merge(project: discussion.project))
parent_attributes = { project: discussion.project, namespace: discussion.namespace }.compact
note.assign_attributes(
discussion.reply_attributes.merge(parent_attributes)
)
end
end
end

View File

@ -91,14 +91,14 @@ RSpec.describe "Admin Runners", :freeze_time, feature_category: :fleet_visibilit
end
end
it 'shows a running status badge that links to jobs tab' do
it 'shows an Active status badge that links to jobs tab' do
runner = create(:ci_runner, :project, projects: [project])
job = create(:ci_build, :running, runner: runner)
visit admin_runners_path
within_runner_row(runner.id) do
click_on(s_('Runners|Running'))
click_on(s_('Runners|Active'))
end
expect(current_url).to match(admin_runner_path(runner))

View File

@ -2,18 +2,18 @@
require 'spec_helper'
RSpec.describe 'Dashboard Group', feature_category: :groups_and_projects do
RSpec.describe 'Dashboard Group', :js, feature_category: :groups_and_projects do
before do
sign_in(create(:user))
end
it 'defaults sort dropdown to last created' do
it 'defaults sort dropdown to Created date' do
visit dashboard_groups_path
expect(page).to have_button('Last created')
expect(page).to have_button('Created date')
end
it 'creates new group', :js do
it 'creates new group' do
visit dashboard_groups_path
find_by_testid('new-group-button').click
click_link 'Create group'

View File

@ -60,7 +60,7 @@ RSpec.describe 'Dashboard Groups page', :js, feature_category: :groups_and_proje
end
it 'expands when filtering groups' do
fill_in 'filter', with: nested_group.name
search(nested_group.name)
wait_for_requests
expect(page).not_to have_content(group.name)
@ -70,20 +70,21 @@ RSpec.describe 'Dashboard Groups page', :js, feature_category: :groups_and_proje
end
it 'resets search when user cleans the input' do
fill_in 'filter', with: group.name
search(group.name)
wait_for_requests
expect(page).to have_content(group.name)
expect(page).not_to have_content(nested_group.parent.name)
fill_in 'filter', with: ''
page.find('[name="filter"]').send_keys(:enter)
find_by_testid('filtered-search-clear-button').click
wait_for_requests
expect(page).to have_content(group.name)
expect(page).to have_content(nested_group.parent.name)
expect(page).not_to have_content(another_group.name)
expect(page.all('.js-groups-list-holder .groups-list li').length).to eq 2
within find_by_testid('groups-list-tree-container') do
expect(find_all('li').length).to eq 2
end
end
end
@ -248,4 +249,11 @@ RSpec.describe 'Dashboard Groups page', :js, feature_category: :groups_and_proje
expect(page).to have_content(s_('GroupsEmptyState|A group is a collection of several projects'))
end
end
def search(term)
filter_input = find_by_testid('filtered-search-term-input')
filter_input.click
filter_input.set(term)
click_button 'Search'
end
end

View File

@ -2,8 +2,9 @@ import { GlBadge } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import RunnerJobStatusBadge from '~/ci/runner/components/runner_job_status_badge.vue';
import {
I18N_JOB_STATUS_RUNNING,
I18N_JOB_STATUS_ACTIVE,
I18N_JOB_STATUS_IDLE,
JOB_STATUS_ACTIVE,
JOB_STATUS_RUNNING,
JOB_STATUS_IDLE,
} from '~/ci/runner/constants';
@ -24,7 +25,8 @@ describe('RunnerTypeBadge', () => {
it.each`
jobStatus | classes | text
${JOB_STATUS_RUNNING} | ${['gl-text-blue-600!', 'gl-border-blue-600!']} | ${I18N_JOB_STATUS_RUNNING}
${JOB_STATUS_ACTIVE} | ${['gl-text-blue-600!', 'gl-border-blue-600!']} | ${I18N_JOB_STATUS_ACTIVE}
${JOB_STATUS_RUNNING} | ${['gl-text-blue-600!', 'gl-border-blue-600!']} | ${I18N_JOB_STATUS_ACTIVE}
${JOB_STATUS_IDLE} | ${['gl-text-gray-700!', 'gl-border-gray-500!']} | ${I18N_JOB_STATUS_IDLE}
`(
'renders $jobStatus job status with "$text" text and styles',
@ -52,7 +54,7 @@ describe('RunnerTypeBadge', () => {
});
it('adds arbitrary attributes', () => {
createComponent({ props: { jobStatus: JOB_STATUS_RUNNING }, attrs: { href: '/url' } });
createComponent({ props: { jobStatus: JOB_STATUS_ACTIVE }, attrs: { href: '/url' } });
expect(findBadge().attributes('href')).toBe('/url');
});

View File

@ -3,16 +3,15 @@ import { GlEmptyState } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import GroupsDashboardEmptyState from '~/groups/components/empty_states/groups_dashboard_empty_state.vue';
jest.mock(
'@gitlab/svgs/dist/illustrations/empty-state/empty-groups-md.svg?url',
() => 'empty-groups-md.svg',
);
let wrapper;
const defaultProvide = {
groupsEmptyStateIllustration: '/assets/illustrations/empty-state/empty-groups-md.svg',
};
const createComponent = () => {
wrapper = shallowMountExtended(GroupsDashboardEmptyState, {
provide: defaultProvide,
});
wrapper = shallowMountExtended(GroupsDashboardEmptyState);
};
describe('GroupsDashboardEmptyState', () => {
@ -23,7 +22,7 @@ describe('GroupsDashboardEmptyState', () => {
title: 'A group is a collection of several projects',
description:
"If you organize your projects under a group, it works like a folder. You can manage your group member's permissions and access to each project in the group.",
svgPath: defaultProvide.groupsEmptyStateIllustration,
svgPath: 'empty-groups-md.svg',
});
});
});

View File

@ -3,16 +3,15 @@ import { GlEmptyState } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import GroupsExploreEmptyState from '~/groups/components/empty_states/groups_explore_empty_state.vue';
jest.mock(
'@gitlab/svgs/dist/illustrations/empty-state/empty-groups-md.svg?url',
() => 'empty-groups-md.svg',
);
let wrapper;
const defaultProvide = {
groupsEmptyStateIllustration: '/assets/illustrations/empty-state/empty-groups-md.svg',
};
const createComponent = () => {
wrapper = shallowMountExtended(GroupsExploreEmptyState, {
provide: defaultProvide,
});
wrapper = shallowMountExtended(GroupsExploreEmptyState);
};
afterEach(() => {
@ -25,7 +24,7 @@ describe('GroupsExploreEmptyState', () => {
expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({
title: 'No public or internal groups',
svgPath: defaultProvide.groupsEmptyStateIllustration,
svgPath: 'empty-groups-md.svg',
});
});
});

View File

@ -1,22 +1,21 @@
import App from '~/groups/components/groups_explore_app.vue';
import { createRouter } from '~/groups/init_groups_explore';
import GroupsListWithFilteredSearchApp from '~/groups/components/groups_list_with_filtered_search_app.vue';
import { createRouter } from '~/groups/init_groups_list_with_filtered_search';
import {
SORTING_ITEM_CREATED,
SORTING_ITEM_UPDATED,
EXPLORE_SORTING_ITEMS,
EXPLORE_FILTERED_SEARCH_TERM_KEY,
GROUPS_LIST_SORTING_ITEMS,
GROUPS_LIST_FILTERED_SEARCH_TERM_KEY,
EXPLORE_FILTERED_SEARCH_NAMESPACE,
} from '~/groups/constants';
import { RECENT_SEARCHES_STORAGE_KEY_GROUPS } from '~/filtered_search/recent_searches_storage_keys';
import FilteredSearchAndSort from '~/groups_projects/components/filtered_search_and_sort.vue';
import GroupsApp from '~/groups/components/app.vue';
import EmptyState from '~/groups/components/empty_states/groups_explore_empty_state.vue';
import GroupsService from '~/groups/service/groups_service';
import GroupsStore from '~/groups/store/groups_store';
import eventHub from '~/groups/event_hub';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
describe('GroupsExploreApp', () => {
describe('GroupsListWithFilteredSearch', () => {
const router = createRouter();
const routerMock = {
push: jest.fn(),
@ -24,16 +23,22 @@ describe('GroupsExploreApp', () => {
let wrapper;
const defaultProvide = {
const defaultPropsData = {
filteredSearchNamespace: EXPLORE_FILTERED_SEARCH_NAMESPACE,
endpoint: '/explore/groups.json',
initialSort: SORTING_ITEM_UPDATED.asc,
};
const createComponent = ({ routeQuery = { [EXPLORE_FILTERED_SEARCH_TERM_KEY]: 'foo' } } = {}) => {
wrapper = shallowMountExtended(App, {
const createComponent = ({
routeQuery = { [GROUPS_LIST_FILTERED_SEARCH_TERM_KEY]: 'foo' },
} = {}) => {
wrapper = shallowMountExtended(GroupsListWithFilteredSearchApp, {
router,
mocks: { $route: { path: '/', query: routeQuery }, $router: routerMock },
provide: defaultProvide,
propsData: defaultPropsData,
scopedSlots: {
'empty-state': '<div data-testid="empty-state"></div>',
},
});
};
@ -44,11 +49,11 @@ describe('GroupsExploreApp', () => {
expect(findFilteredSearchAndSort().props()).toMatchObject({
filteredSearchTokens: [],
filteredSearchQuery: { [EXPLORE_FILTERED_SEARCH_TERM_KEY]: 'foo' },
filteredSearchTermKey: EXPLORE_FILTERED_SEARCH_TERM_KEY,
filteredSearchQuery: { [GROUPS_LIST_FILTERED_SEARCH_TERM_KEY]: 'foo' },
filteredSearchTermKey: GROUPS_LIST_FILTERED_SEARCH_TERM_KEY,
filteredSearchNamespace: EXPLORE_FILTERED_SEARCH_NAMESPACE,
filteredSearchRecentSearchesStorageKey: RECENT_SEARCHES_STORAGE_KEY_GROUPS,
sortOptions: EXPLORE_SORTING_ITEMS.map((sortItem) => ({
sortOptions: GROUPS_LIST_SORTING_ITEMS.map((sortItem) => ({
value: sortItem.asc,
text: sortItem.label,
})),
@ -63,14 +68,14 @@ describe('GroupsExploreApp', () => {
it('renders `GroupsApp` and empty state', () => {
createComponent();
const service = new GroupsService(defaultProvide.endpoint);
const service = new GroupsService(defaultPropsData.endpoint);
const store = new GroupsStore({ hideProjects: true });
expect(wrapper.findComponent(GroupsApp).props()).toMatchObject({
service,
store,
});
expect(wrapper.findComponent(EmptyState).exists()).toBe(true);
expect(wrapper.findByTestId('empty-state').exists()).toBe(true);
});
describe('when filtered search bar is submitted', () => {
@ -81,20 +86,20 @@ describe('GroupsExploreApp', () => {
createComponent();
findFilteredSearchAndSort().vm.$emit('filter', {
[EXPLORE_FILTERED_SEARCH_TERM_KEY]: searchTerm,
[GROUPS_LIST_FILTERED_SEARCH_TERM_KEY]: searchTerm,
});
});
it(`updates \`${EXPLORE_FILTERED_SEARCH_TERM_KEY}\` query string`, () => {
it(`updates \`${GROUPS_LIST_FILTERED_SEARCH_TERM_KEY}\` query string`, () => {
expect(routerMock.push).toHaveBeenCalledWith({
query: { [EXPLORE_FILTERED_SEARCH_TERM_KEY]: searchTerm },
query: { [GROUPS_LIST_FILTERED_SEARCH_TERM_KEY]: searchTerm },
});
});
it('emits `fetchFilteredAndSortedGroups` with correct arguments', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('fetchFilteredAndSortedGroups', {
filterGroupsBy: searchTerm,
sortBy: defaultProvide.initialSort,
sortBy: defaultPropsData.initialSort,
});
});
});
@ -104,11 +109,11 @@ describe('GroupsExploreApp', () => {
createComponent();
findFilteredSearchAndSort().vm.$emit('filter', {
[EXPLORE_FILTERED_SEARCH_TERM_KEY]: '',
[GROUPS_LIST_FILTERED_SEARCH_TERM_KEY]: '',
});
});
it(`removes \`${EXPLORE_FILTERED_SEARCH_TERM_KEY}\` query string`, () => {
it(`removes \`${GROUPS_LIST_FILTERED_SEARCH_TERM_KEY}\` query string`, () => {
expect(routerMock.push).toHaveBeenCalledWith({
query: {},
});
@ -119,7 +124,7 @@ describe('GroupsExploreApp', () => {
beforeEach(() => {
createComponent({
routeQuery: {
[EXPLORE_FILTERED_SEARCH_TERM_KEY]: 'foo',
[GROUPS_LIST_FILTERED_SEARCH_TERM_KEY]: 'foo',
},
});
@ -130,7 +135,7 @@ describe('GroupsExploreApp', () => {
expect(routerMock.push).toHaveBeenCalledWith({
query: {
sort: SORTING_ITEM_CREATED.asc,
[EXPLORE_FILTERED_SEARCH_TERM_KEY]: 'foo',
[GROUPS_LIST_FILTERED_SEARCH_TERM_KEY]: 'foo',
},
});
});
@ -140,7 +145,7 @@ describe('GroupsExploreApp', () => {
beforeEach(() => {
createComponent({
routeQuery: {
[EXPLORE_FILTERED_SEARCH_TERM_KEY]: 'foo',
[GROUPS_LIST_FILTERED_SEARCH_TERM_KEY]: 'foo',
},
});
@ -151,7 +156,7 @@ describe('GroupsExploreApp', () => {
expect(routerMock.push).toHaveBeenCalledWith({
query: {
sort: SORTING_ITEM_UPDATED.desc,
[EXPLORE_FILTERED_SEARCH_TERM_KEY]: 'foo',
[GROUPS_LIST_FILTERED_SEARCH_TERM_KEY]: 'foo',
},
});
});

View File

@ -269,7 +269,7 @@ describe('Description component', () => {
});
await waitForPromises();
eventHub.$emit('convert-task-list-item', '4:4-8:19');
eventHub.$emit('convert-task-list-item', { id: '1', sourcepos: '4:4-8:19' });
await waitForPromises();
});
@ -324,7 +324,7 @@ describe('Description component', () => {
});
await waitForPromises();
eventHub.$emit('convert-task-list-item', '1:1-1:11');
eventHub.$emit('convert-task-list-item', { id: '1', sourcepos: '1:1-1:11' });
await waitForPromises();
});
@ -355,7 +355,7 @@ describe('Description component', () => {
props: { descriptionText },
});
eventHub.$emit('delete-task-list-item', '4:4-5:19');
eventHub.$emit('delete-task-list-item', { id: '1', sourcepos: '4:4-5:19' });
expect(wrapper.emitted('saveDescription')).toEqual([[newDescriptionText]]);
});

View File

@ -1,4 +1,5 @@
import { GlDisclosureDropdown } from '@gitlab/ui';
import { setHTMLFixture } from 'helpers/fixtures';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { TYPE_EPIC, TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants';
import TaskListItemActions from '~/issues/show/components/task_list_item_actions.vue';
@ -14,14 +15,18 @@ describe('TaskListItemActions component', () => {
const findDeleteItem = () => wrapper.findByTestId('delete');
const mountComponent = ({ issuableType = TYPE_ISSUE } = {}) => {
const li = document.createElement('li');
li.dataset.sourcepos = '3:1-3:10';
li.appendChild(document.createElement('div'));
document.body.appendChild(li);
setHTMLFixture(`
<li data-sourcepos="3:1-3:10">
<div></div>
</li>
`);
wrapper = shallowMountExtended(TaskListItemActions, {
provide: { issuableType },
attachTo: document.querySelector('div'),
provide: {
id: 'gid://gitlab/WorkItem/818',
issuableType,
},
attachTo: 'div',
});
};
@ -60,13 +65,19 @@ describe('TaskListItemActions component', () => {
it('emits event when `Convert to task` dropdown item is clicked', () => {
findConvertToTaskItem().vm.$emit('action');
expect(eventHub.$emit).toHaveBeenCalledWith('convert-task-list-item', '3:1-3:10');
expect(eventHub.$emit).toHaveBeenCalledWith('convert-task-list-item', {
id: 'gid://gitlab/WorkItem/818',
sourcepos: '3:1-3:10',
});
});
it('emits event when `Delete` dropdown item is clicked', () => {
findDeleteItem().vm.$emit('action');
expect(eventHub.$emit).toHaveBeenCalledWith('delete-task-list-item', '3:1-3:10');
expect(eventHub.$emit).toHaveBeenCalledWith('delete-task-list-item', {
id: 'gid://gitlab/WorkItem/818',
sourcepos: '3:1-3:10',
});
});
});
});

View File

@ -1,7 +1,9 @@
import { setHTMLFixture } from 'helpers/fixtures';
import {
deleteTaskListItem,
convertDescriptionWithNewSort,
deleteTaskListItem,
extractTaskTitleAndDescription,
insertNextToTaskListItemText,
} from '~/issues/show/utils';
describe('app/assets/javascripts/issues/show/utils.js', () => {
@ -408,3 +410,86 @@ description`;
});
});
});
describe('insertNextToTaskListItemText', () => {
const dropdown = document.createElement('div');
dropdown.classList.add('dropdown');
describe('when simple checkbox with text', () => {
it('inserts element as sibling to checkbox, last child of ul element', () => {
setHTMLFixture(`
<div class="description">
<div class="md">
<ul data-sourcepos="1:1-1:9" class="task-list">
<li data-sourcepos="1:1-1:9" class="task-list-item">
<input type="checkbox" class="task-list-item-checkbox">
one
</li>
</ul>
</div>
</div>
`);
const listItem = document.querySelector('.task-list-item');
insertNextToTaskListItemText(dropdown, listItem);
expect(listItem.lastChild).toBe(dropdown);
});
});
describe('when checkbox with nested checkbox', () => {
it('inserts element as sibling to checkbox, before the nested checkbox', () => {
setHTMLFixture(`
<div class="description">
<div class="md">
<ul data-sourcepos="1:1-1:9" class="task-list">
<li data-sourcepos="1:1-3:14" class="task-list-item">
<input type="checkbox" class="task-list-item-checkbox">
one
<ul data-sourcepos="2:3-3:14" class="task-list">
<li data-sourcepos="2:3-2:14" class="task-list-item">
<input type="checkbox" class="task-list-item-checkbox">
two
</li>
<li data-sourcepos="3:3-3:14" class="task-list-item enabled">
<input type="checkbox" class="task-list-item-checkbox">
three
</li>
</ul>
</li>
</ul>
</div>
</div>
`);
const listItem = document.querySelector('.task-list-item');
insertNextToTaskListItemText(dropdown, listItem);
expect(listItem.lastElementChild.previousSibling).toBe(dropdown);
});
});
describe('when multi-paragraph checkbox', () => {
it('inserts element as sibling to checkbox, inside p element', () => {
setHTMLFixture(`
<div class="description">
<div class="md">
<ul data-sourcepos="1:1-1:9" class="task-list">
<li data-sourcepos="1:1-3:11" class="task-list-item">
<p data-sourcepos="1:3-1:9">
<input type="checkbox" class="task-list-item-checkbox">
one
</p>
<p data-sourcepos="3:3-3:11">
paragraph
</p>
</li>
</ul>
</div>
</div>
`);
const listItem = document.querySelector('.task-list-item');
insertNextToTaskListItemText(dropdown, listItem);
expect(listItem.firstElementChild.lastElementChild).toBe(dropdown);
});
});
});

View File

@ -1,12 +1,13 @@
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import WorkItemDescriptionRendered from '~/work_items/components/work_item_description_rendered.vue';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import { descriptionTextWithCheckboxes, descriptionHtmlWithCheckboxes } from '../mock_data';
import eventHub from '~/issues/show/event_hub';
import WorkItemDescriptionRendered from '~/work_items/components/work_item_description_rendered.vue';
import { descriptionHtmlWithCheckboxes, descriptionTextWithCheckboxes } from '../mock_data';
jest.mock('~/behaviors/markdown/render_gfm');
describe('WorkItemDescription', () => {
describe('WorkItemDescriptionRendered', () => {
let wrapper;
const findCheckboxAtIndex = (index) => wrapper.findAll('input[type="checkbox"]').at(index);
@ -24,6 +25,7 @@ describe('WorkItemDescription', () => {
} = {}) => {
wrapper = shallowMount(WorkItemDescriptionRendered, {
propsData: {
workItemId: 'gid://gitlab/WorkItem/818',
workItemDescription,
canEdit,
},
@ -124,4 +126,30 @@ describe('WorkItemDescription', () => {
expect(wrapper.find('[data-test-id="description-read-more"]').exists()).toBe(false);
});
});
describe('task list item actions', () => {
describe('deleting the task list item', () => {
it('emits an event to update the description with the deleted task list item', () => {
const description = `Tasks
1. [ ] item 1
1. [ ] item 2
1. [ ] item 3
1. [ ] item 4;`;
const newDescription = `Tasks
1. [ ] item 1
1. [ ] item 3
1. [ ] item 4;`;
createComponent({ workItemDescription: { description } });
eventHub.$emit('delete-task-list-item', {
id: 'gid://gitlab/WorkItem/818',
sourcepos: '4:4-5:19',
});
expect(wrapper.emitted('descriptionUpdated')).toEqual([[newDescription]]);
});
});
});
});

View File

@ -4,18 +4,29 @@ require 'spec_helper'
RSpec.describe GitlabSchema.types['MlModel'], feature_category: :mlops do
let_it_be(:model) { create(:ml_models, :with_latest_version_and_package, description: 'A description') }
let_it_be(:model_markdown) { create(:ml_models, :with_latest_version_and_package, description: 'A **description**') }
let_it_be(:project) { model.project }
let_it_be(:project_markdown) { model_markdown.project }
let_it_be(:candidates) { Array.new(2) { create(:ml_candidates, experiment: model.default_experiment) } }
let_it_be(:candidates_markdown) do
Array.new(2) do
create(:ml_candidates, experiment: model_markdown.default_experiment)
end
end
let_it_be(:model_id) { GitlabSchema.id_from_object(model).to_s }
let_it_be(:model_version_id) { GitlabSchema.id_from_object(model.latest_version).to_s }
let_it_be(:model_id_markdown) { GitlabSchema.id_from_object(model_markdown).to_s }
let_it_be(:model_version_id_markdown) { GitlabSchema.id_from_object(model_markdown.latest_version).to_s }
let(:query) do
%(
query {
mlModel(id: "#{model_id}") {
id
description
descriptionHtml
name
versionCount
candidateCount
@ -33,13 +44,39 @@ RSpec.describe GitlabSchema.types['MlModel'], feature_category: :mlops do
)
end
let(:query_markdown) do
%(
query {
mlModel(id: "#{model_id_markdown}") {
id
description
descriptionHtml
name
versionCount
candidateCount
latestVersion {
id
}
version(modelVersionId: "#{model_version_id_markdown}") {
id
}
_links {
showPath
}
}
}
)
end
let(:data) { GitlabSchema.execute(query, context: { current_user: project.owner }).as_json }
specify { expect(described_class.description).to eq('Machine learning model in the model registry') }
subject(:data) { GitlabSchema.execute(query, context: { current_user: project.owner }).as_json }
subject(:data_markdown) { GitlabSchema.execute(query_markdown, context: { current_user: project.owner }).as_json }
it 'includes all the fields' do
expected_fields = %w[id name versions candidates version_count _links created_at latest_version description
candidate_count description version]
candidate_count description version description_html]
expect(described_class).to include_graphql_fields(*expected_fields)
end
@ -51,6 +88,8 @@ RSpec.describe GitlabSchema.types['MlModel'], feature_category: :mlops do
'id' => model_id,
'name' => model.name,
'description' => 'A description',
'descriptionHtml' =>
'<p data-sourcepos="1:1-1:13" dir="auto">A description</p>',
'latestVersion' => {
'id' => model_version_id
},
@ -64,4 +103,27 @@ RSpec.describe GitlabSchema.types['MlModel'], feature_category: :mlops do
}
})
end
it 'computes the correct properties with markdown' do
model_data = data_markdown.dig('data', 'mlModel')
expect(model_data).to eq({
'id' => model_id_markdown,
'name' => model_markdown.name,
'description' => model_markdown.description,
'descriptionHtml' =>
'<p data-sourcepos="1:1-1:17" dir="auto">A <strong data-sourcepos="1:3-1:17">description</strong></p>',
'latestVersion' => {
'id' => model_version_id_markdown
},
'version' => {
'id' => model_version_id_markdown
},
'versionCount' => 1,
'candidateCount' => 2,
'_links' => {
'showPath' => "/#{project_markdown.full_path}/-/ml/models/#{model_markdown.id}"
}
})
end
end

View File

@ -18,7 +18,8 @@ RSpec.describe Projects::Ml::ModelRegistryHelper, feature_category: :mlops do
'createModelPath' => "/#{project.full_path}/-/ml/models/new",
'canWriteModelRegistry' => true,
'maxAllowedFileSize' => 10737418240,
'mlflowTrackingUrl' => "http://localhost/api/v4/projects/#{project.id}/ml/mlflow/"
'mlflowTrackingUrl' => "http://localhost/api/v4/projects/#{project.id}/ml/mlflow/",
'markdownPreviewPath' => "http://localhost/#{project.full_path}/-/ml/preview_markdown"
})
end

View File

@ -47,22 +47,4 @@ RSpec.describe Gitlab::SidekiqMiddleware::SetIpAddress, feature_category: :syste
end
end
end
describe '#call with sidekiq_ip_address disabled' do
before do
stub_feature_flags(sidekiq_ip_address: false)
end
context 'when the IP address is present' do
it 'does not set the IP address' do
expect(::Gitlab::IpAddressState).not_to receive(:with).with(ip_address)
described_class.new.call(worker, job, queue) do
expect(::Gitlab::IpAddressState.current).to eq(nil)
end
expect(::Gitlab::IpAddressState.current).to eq(nil)
end
end
end
end

File diff suppressed because it is too large Load Diff

View File

@ -199,21 +199,33 @@ RSpec.describe Ci::RunnerManager, feature_category: :fleet_visibility, type: :mo
end
end
describe '.with_running_builds' do
subject(:scope) { described_class.with_running_builds }
describe '.with_executing_builds' do
subject(:scope) { described_class.with_executing_builds }
let_it_be(:runner) { create(:ci_runner) }
let_it_be(:runner_manager1) { create(:ci_runner_machine, runner: runner) }
let_it_be(:runner_manager2) { create(:ci_runner_machine, runner: runner) }
before_all do
create(:ci_runner_machine_build, runner_manager: runner_manager1,
build: create(:ci_build, :success, runner: runner))
create(:ci_runner_machine_build, runner_manager: runner_manager2,
build: create(:ci_build, :running, runner: runner))
let_it_be(:runner_managers_by_status) do
Ci::HasStatus::AVAILABLE_STATUSES.index_with { |_status| create(:ci_runner_machine, runner: runner) }
end
it { is_expected.to contain_exactly runner_manager2 }
let_it_be(:busy_runner_managers) do
Ci::HasStatus::EXECUTING_STATUSES.map { |status| runner_managers_by_status[status] }
end
context 'with no builds running' do
it { is_expected.to be_empty }
end
context 'with builds' do
before_all do
Ci::HasStatus::AVAILABLE_STATUSES.each do |status|
runner_manager = runner_managers_by_status[status]
build = create(:ci_build, status, runner: runner)
create(:ci_runner_machine_build, runner_manager: runner_manager, build: build)
end
end
it { is_expected.to match_array(busy_runner_managers) }
end
end
describe '.order_id_desc' do

View File

@ -650,27 +650,31 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
end
end
describe '.with_running_builds' do
subject { described_class.with_running_builds }
describe '.with_executing_builds' do
subject(:scope) { described_class.with_executing_builds }
let_it_be(:runner1) { create(:ci_runner) }
let_it_be(:runners_by_status) do
Ci::HasStatus::AVAILABLE_STATUSES.index_with { |_status| create(:ci_runner) }
end
let_it_be(:busy_runners) do
Ci::HasStatus::EXECUTING_STATUSES.map { |status| runners_by_status[status] }
end
context 'with no builds running' do
it { is_expected.to be_empty }
end
context 'with single build running on runner2' do
let(:runner2) { create(:ci_runner) }
let(:runner3) { create(:ci_runner) }
context 'with builds' do
before_all do
pipeline = create(:ci_pipeline, :running)
before do
project = create(:project, :repository)
pipeline = create(:ci_pipeline, project: project)
create(:ci_build, :running, runner: runner2, pipeline: pipeline)
create(:ci_build, :running, runner: runner3, pipeline: pipeline)
Ci::HasStatus::AVAILABLE_STATUSES.each do |status|
create(:ci_build, status, runner: runners_by_status[status], pipeline: pipeline)
end
end
it { is_expected.to contain_exactly(runner2, runner3) }
it { is_expected.to match_array(busy_runners) }
end
end

View File

@ -284,14 +284,26 @@ RSpec.describe Ci::HasStatus, feature_category: :continuous_integration do
end
end
describe '.alive' do
subject { CommitStatus.alive }
describe '.executing' do
subject { CommitStatus.executing }
%i[running pending waiting_for_callback waiting_for_resource preparing created].each do |status|
%i[running canceling].each do |status|
it_behaves_like 'containing the job', status
end
%i[failed success].each do |status|
%i[created failed success canceled].each do |status|
it_behaves_like 'not containing the job', status
end
end
describe '.alive' do
subject { CommitStatus.alive }
%i[running pending waiting_for_callback waiting_for_resource preparing created canceling].each do |status|
it_behaves_like 'containing the job', status
end
%i[canceled failed success].each do |status|
it_behaves_like 'not containing the job', status
end
end

View File

@ -83,7 +83,7 @@ RSpec.describe 'Query.runner(id)', :freeze_time, feature_category: :fleet_visibi
active: runner.active,
paused: !runner.active,
status: runner.status.to_s.upcase,
job_execution_status: runner.builds.running.any? ? 'RUNNING' : 'IDLE',
job_execution_status: runner.builds.executing.any? ? 'RUNNING' : 'IDLE',
maximum_timeout: runner.maximum_timeout,
access_level: runner.access_level.to_s.upcase,
run_untagged: runner.run_untagged,
@ -122,7 +122,7 @@ RSpec.describe 'Query.runner(id)', :freeze_time, feature_category: :fleet_visibi
architecture_name: runner_manager.architecture,
platform_name: runner_manager.platform,
status: runner_manager.status.to_s.upcase,
job_execution_status: runner_manager.builds.running.any? ? 'RUNNING' : 'IDLE'
job_execution_status: runner_manager.builds.executing.any? ? 'RUNNING' : 'IDLE'
)
end,
"pageInfo" => anything

View File

@ -0,0 +1,44 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::MlController, feature_category: :mlops do
let_it_be(:project) { create(:project) }
let_it_be(:user) { project.first_owner }
let_it_be(:model1) { create(:ml_models, :with_versions, project: project) }
let(:read_model_registry) { true }
let(:write_model_registry) { true }
let(:params) { {} }
before do
allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?)
.with(user, :read_model_registry, project)
.and_return(read_model_registry)
allow(Ability).to receive(:allowed?)
.with(user, :write_model_registry, project)
.and_return(write_model_registry)
sign_in(user)
end
describe 'POST #preview_markdown' do
it 'renders json in a correct format' do
preview_markdown
expect(response).to have_gitlab_http_status(:ok)
expect(response.content_type).to eq('application/json; charset=utf-8')
expect(json_response.keys).to match_array(%w[body references])
expect(json_response['body']).to eq('<p data-sourcepos="1:1-1:4" dir="auto">test</p>')
expect(json_response['references']).to eq({ "commands" => "", "suggestions" => [], "users" => [] })
end
end
private
def preview_markdown
post project_ml_preview_markdown_path(project, params: { text: 'test' })
end
end