Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
77ded523f1
commit
acee9d6fb5
|
|
@ -566,7 +566,6 @@ lib/gitlab/checks/**
|
|||
/doc/administration/settings/slack_app.md @eread @ashrafkhamis
|
||||
/doc/administration/settings/terraform_limits.md @phillipwells
|
||||
/doc/administration/settings/third_party_offers.md @lciutacu
|
||||
/doc/administration/settings/usage_statistics.md @lciutacu
|
||||
/doc/administration/settings/visibility_and_access_controls.md @msedlakjakubowski
|
||||
/doc/administration/sidekiq/ @axil
|
||||
/doc/administration/sidekiq/sidekiq_memory_killer.md @jglassman1
|
||||
|
|
@ -733,7 +732,6 @@ lib/gitlab/checks/**
|
|||
/doc/api/templates/licenses.md @rdickenson
|
||||
/doc/api/todos.md @msedlakjakubowski
|
||||
/doc/api/topics.md @lciutacu
|
||||
/doc/api/usage_data.md @lciutacu
|
||||
/doc/api/users.md @jglassman1
|
||||
/doc/api/version.md @phillipwells
|
||||
/doc/api/visual_review_discussions.md @marcel.amirault
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ import {
|
|||
getInitialPageParams,
|
||||
getSortKey,
|
||||
getSortOptions,
|
||||
groupMultiSelectFilterTokens,
|
||||
isSortKey,
|
||||
mapWorkItemWidgetsToIssueFields,
|
||||
updateUpvotesCount,
|
||||
|
|
@ -384,6 +385,7 @@ export default {
|
|||
isProject: this.isProject,
|
||||
recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-author`,
|
||||
preloadedUsers,
|
||||
multiSelect: this.glFeatures.groupMultiSelectTokens,
|
||||
},
|
||||
{
|
||||
type: TOKEN_TYPE_ASSIGNEE,
|
||||
|
|
@ -396,6 +398,7 @@ export default {
|
|||
isProject: this.isProject,
|
||||
recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`,
|
||||
preloadedUsers,
|
||||
multiSelect: this.glFeatures.groupMultiSelectTokens,
|
||||
},
|
||||
{
|
||||
type: TOKEN_TYPE_MILESTONE,
|
||||
|
|
@ -803,7 +806,12 @@ export default {
|
|||
sortKey = defaultSortKey;
|
||||
}
|
||||
|
||||
this.filterTokens = getFilterTokens(window.location.search);
|
||||
const tokens = getFilterTokens(window.location.search);
|
||||
if (this.glFeatures.groupMultiSelectTokens) {
|
||||
this.filterTokens = groupMultiSelectFilterTokens(tokens, this.searchTokens);
|
||||
} else {
|
||||
this.filterTokens = tokens;
|
||||
}
|
||||
|
||||
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
|
||||
this.pageParams = getInitialPageParams(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
OPERATOR_NOT,
|
||||
OPERATOR_OR,
|
||||
OPERATOR_AFTER,
|
||||
OPERATORS_TO_GROUP,
|
||||
TOKEN_TYPE_ASSIGNEE,
|
||||
TOKEN_TYPE_AUTHOR,
|
||||
TOKEN_TYPE_CONFIDENTIAL,
|
||||
|
|
@ -233,6 +234,41 @@ export const getFilterTokens = (locationSearch) =>
|
|||
};
|
||||
});
|
||||
|
||||
export function groupMultiSelectFilterTokens(filterTokensToGroup, tokenDefs) {
|
||||
const groupedTokens = [];
|
||||
|
||||
const multiSelectTokenTypes = tokenDefs.filter((t) => t.multiSelect).map((t) => t.type);
|
||||
|
||||
filterTokensToGroup.forEach((token) => {
|
||||
const shouldGroup =
|
||||
OPERATORS_TO_GROUP.includes(token.value.operator) &&
|
||||
multiSelectTokenTypes.includes(token.type);
|
||||
|
||||
if (!shouldGroup) {
|
||||
groupedTokens.push(token);
|
||||
return;
|
||||
}
|
||||
|
||||
const sameTypeAndOperator = (t) =>
|
||||
t.type === token.type && t.value.operator === token.value.operator;
|
||||
const existingToken = groupedTokens.find(sameTypeAndOperator);
|
||||
|
||||
if (!existingToken) {
|
||||
groupedTokens.push({
|
||||
...token,
|
||||
value: {
|
||||
...token.value,
|
||||
data: [token.value.data],
|
||||
},
|
||||
});
|
||||
} else if (!existingToken.value.data.includes(token.value.data)) {
|
||||
existingToken.value.data.push(token.value.data);
|
||||
}
|
||||
});
|
||||
|
||||
return groupedTokens;
|
||||
}
|
||||
|
||||
export const isNotEmptySearchToken = (token) =>
|
||||
!(token.type === FILTERED_SEARCH_TERM && !token.value.data);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
// Keys for the memoized Intl dateTime formatters
|
||||
export const DATE_WITH_TIME_FORMAT = 'DATE_WITH_TIME_FORMAT';
|
||||
export const DATE_ONLY_FORMAT = 'DATE_ONLY_FORMAT';
|
||||
|
||||
export const DEFAULT_DATE_TIME_FORMAT = DATE_WITH_TIME_FORMAT;
|
||||
|
||||
export const DATE_TIME_FORMATS = [DATE_WITH_TIME_FORMAT, DATE_ONLY_FORMAT];
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import { createDateTimeFormat } from '~/locale';
|
||||
|
||||
/**
|
||||
* Format a Date with the help of {@link DateTimeFormat.asDateTime}
|
||||
*
|
||||
* Note: In case you can use localDateFormat.asDateTime directly, please do that.
|
||||
*
|
||||
* @example
|
||||
* localDateFormat[DATE_WITH_TIME_FORMAT].format(date) // returns 'Jul 6, 2020, 2:43 PM'
|
||||
* localDateFormat[DATE_WITH_TIME_FORMAT].formatRange(date, date) // returns 'Jul 6, 2020, 2:45PM – 8:43 PM'
|
||||
*/
|
||||
export const DATE_WITH_TIME_FORMAT = 'asDateTime';
|
||||
/**
|
||||
* Format a Date with the help of {@link DateTimeFormat.asDate}
|
||||
*
|
||||
* Note: In case you can use localDateFormat.asDate directly, please do that.
|
||||
*
|
||||
* @example
|
||||
* localDateFormat[DATE_ONLY_FORMAT].format(date) // returns 'Jul 05, 2023'
|
||||
* localDateFormat[DATE_ONLY_FORMAT].formatRange(date, date) // returns 'Jul 05 - Jul 07, 2023'
|
||||
*/
|
||||
export const DATE_ONLY_FORMAT = 'asDate';
|
||||
export const DEFAULT_DATE_TIME_FORMAT = DATE_WITH_TIME_FORMAT;
|
||||
export const DATE_TIME_FORMATS = [DATE_WITH_TIME_FORMAT, DATE_ONLY_FORMAT];
|
||||
|
||||
/**
|
||||
* The DateTimeFormat utilities support formatting a number of types,
|
||||
* essentially anything you might use in the `Date` constructor.
|
||||
*
|
||||
* The reason for this is mostly backwards compatibility, as dateformat did the same
|
||||
* https://github.com/felixge/node-dateformat/blob/c53e475891130a1fecd3b0d9bc5ebf3820b31b44/src/dateformat.js#L37-L41
|
||||
*
|
||||
* @typedef {Date|number|string|null} Dateish
|
||||
*
|
||||
*/
|
||||
/**
|
||||
* @typedef {Object} DateTimeFormatter
|
||||
* @property {function(Dateish): string} format
|
||||
* Formats a single {@link Dateish}
|
||||
* with {@link Intl.DateTimeFormat.format}
|
||||
* @property {function(Dateish, Dateish): string} formatRange
|
||||
* Formats two {@link Dateish} as a range
|
||||
* with {@link Intl.DateTimeFormat.formatRange}
|
||||
*/
|
||||
|
||||
class DateTimeFormat {
|
||||
#formatters = {};
|
||||
|
||||
/**
|
||||
* Locale aware formatter to display date _and_ time.
|
||||
*
|
||||
* Use this formatter when in doubt.
|
||||
*
|
||||
* @example
|
||||
* // en-US: returns something like Jul 6, 2020, 2:43 PM
|
||||
* // en-GB: returns something like 6 Jul 2020, 14:43
|
||||
* localDateFormat.asDateTime.format(date)
|
||||
*
|
||||
* @returns {DateTimeFormatter}
|
||||
*/
|
||||
get asDateTime() {
|
||||
return (
|
||||
this.#formatters[DATE_WITH_TIME_FORMAT] ||
|
||||
this.#createFormatter(DATE_WITH_TIME_FORMAT, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
hourCycle: DateTimeFormat.#hourCycle,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale aware formatter to display a only the date.
|
||||
*
|
||||
* Use {@link DateTimeFormat.asDateTime} if you also need to display the time.
|
||||
*
|
||||
* @example
|
||||
* // en-US: returns something like Jul 6, 2020
|
||||
* // en-GB: returns something like 6 Jul 2020
|
||||
* localDateFormat.asDate.format(date)
|
||||
*
|
||||
* @example
|
||||
* // en-US: returns something like Jul 6 – 7, 2020
|
||||
* // en-GB: returns something like 6-7 Jul 2020
|
||||
* localDateFormat.asDate.formatRange(date, date2)
|
||||
*
|
||||
* @returns {DateTimeFormatter}
|
||||
*/
|
||||
get asDate() {
|
||||
return (
|
||||
this.#formatters[DATE_ONLY_FORMAT] ||
|
||||
this.#createFormatter(DATE_ONLY_FORMAT, {
|
||||
dateStyle: 'medium',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the memoized formatters
|
||||
*
|
||||
* While this method only seems to be useful for testing right now,
|
||||
* it could also be used in the future to live-preview the formatting
|
||||
* to the user on their settings page.
|
||||
*/
|
||||
reset() {
|
||||
this.#formatters = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* This helper function creates formatters in a memoized fashion.
|
||||
*
|
||||
* The first time a getter is called, it will use this helper
|
||||
* to create an {@link Intl.DateTimeFormat} which is used internally.
|
||||
*
|
||||
* We memoize the creation of the formatter, because using one of them
|
||||
* is about 300 faster than creating them.
|
||||
*
|
||||
* @param {string} name (one of {@link DATE_TIME_FORMATS})
|
||||
* @param {Intl.DateTimeFormatOptions} format
|
||||
* @returns {DateTimeFormatter}
|
||||
*/
|
||||
#createFormatter(name, format) {
|
||||
const intlFormatter = createDateTimeFormat(format);
|
||||
|
||||
this.#formatters[name] = {
|
||||
format: (date) => intlFormatter.format(DateTimeFormat.castToDate(date)),
|
||||
formatRange: (date1, date2) => {
|
||||
return intlFormatter.formatRange(
|
||||
DateTimeFormat.castToDate(date1),
|
||||
DateTimeFormat.castToDate(date2),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return this.#formatters[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Casts a Dateish to a Date.
|
||||
* @param dateish {Dateish}
|
||||
* @returns {Date}
|
||||
*/
|
||||
static castToDate(dateish) {
|
||||
const date = dateish instanceof Date ? dateish : new Date(dateish);
|
||||
if (Number.isNaN(date)) {
|
||||
// eslint-disable-next-line @gitlab/require-i18n-strings
|
||||
throw new Error('Invalid date provided');
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to determine the {@link Intl.Locale.hourCycle} a user prefers.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/hourCycle
|
||||
* @returns {undefined|'h12'|'h23'}
|
||||
*/
|
||||
static get #hourCycle() {
|
||||
switch (window.gon?.time_display_format) {
|
||||
case 1:
|
||||
return 'h12';
|
||||
case 2:
|
||||
return 'h23';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A singleton instance of {@link DateTimeFormat}.
|
||||
* This formatting helper respects the user preferences (locale and 12h/24h preference)
|
||||
* and gives an efficient way to format dates and times.
|
||||
*
|
||||
* Each of the supported formatters has support to format a simple date, but also a range.
|
||||
*
|
||||
*
|
||||
* DateTime (showing both date and times):
|
||||
* - {@link DateTimeFormat.asDateTime localeDateFormat.asDateTime} - the default format for date times
|
||||
*
|
||||
* Date (showing date only):
|
||||
* - {@link DateTimeFormat.asDate localeDateFormat.asDate} - the default format for a date
|
||||
*/
|
||||
export const localeDateFormat = new DateTimeFormat();
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import * as timeago from 'timeago.js';
|
||||
import { languageCode, s__, createDateTimeFormat } from '~/locale';
|
||||
import { languageCode, s__ } from '~/locale';
|
||||
import { DEFAULT_DATE_TIME_FORMAT, localeDateFormat } from '~/lib/utils/datetime/locale_dateformat';
|
||||
import { formatDate } from './date_format_utility';
|
||||
import { DATE_WITH_TIME_FORMAT, DATE_ONLY_FORMAT, DEFAULT_DATE_TIME_FORMAT } from './constants';
|
||||
|
||||
/**
|
||||
* Timeago uses underscores instead of dashes to separate language from country code.
|
||||
|
|
@ -107,51 +107,10 @@ timeago.register(timeagoLanguageCode, memoizedLocale());
|
|||
timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining());
|
||||
timeago.register(`${timeagoLanguageCode}-duration`, memoizedLocaleDuration());
|
||||
|
||||
const setupAbsoluteFormatters = () => {
|
||||
let cache = {};
|
||||
|
||||
// Intl.DateTimeFormat options (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat#using_options)
|
||||
// For hourCycle please check https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/hourCycle
|
||||
const hourCycle = [undefined, 'h12', 'h23'];
|
||||
const formats = {
|
||||
[DATE_WITH_TIME_FORMAT]: () => ({
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
hourCycle: hourCycle[window.gon?.time_display_format || 0],
|
||||
}),
|
||||
[DATE_ONLY_FORMAT]: () => ({ dateStyle: 'medium' }),
|
||||
};
|
||||
|
||||
return (formatName = DEFAULT_DATE_TIME_FORMAT) => {
|
||||
if (cache.time_display_format !== window.gon?.time_display_format) {
|
||||
cache = {
|
||||
time_display_format: window.gon?.time_display_format,
|
||||
};
|
||||
}
|
||||
|
||||
if (cache[formatName]) {
|
||||
return cache[formatName];
|
||||
}
|
||||
|
||||
let format = formats[formatName] && formats[formatName]();
|
||||
if (!format) {
|
||||
format = formats[DEFAULT_DATE_TIME_FORMAT]();
|
||||
}
|
||||
|
||||
const formatter = createDateTimeFormat(format);
|
||||
|
||||
cache[formatName] = {
|
||||
format(date) {
|
||||
return formatter.format(date instanceof Date ? date : new Date(date));
|
||||
},
|
||||
};
|
||||
return cache[formatName];
|
||||
};
|
||||
};
|
||||
const memoizedFormatters = setupAbsoluteFormatters();
|
||||
|
||||
export const getTimeago = (formatName) =>
|
||||
window.gon?.time_display_relative === false ? memoizedFormatters(formatName) : timeago;
|
||||
window.gon?.time_display_relative === false
|
||||
? localeDateFormat[formatName] ?? localeDateFormat[DEFAULT_DATE_TIME_FORMAT]
|
||||
: timeago;
|
||||
|
||||
/**
|
||||
* For the given elements, sets a tooltip with a formatted date.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export * from './datetime/constants';
|
||||
export * from './datetime/timeago_utility';
|
||||
export * from './datetime/date_format_utility';
|
||||
export * from './datetime/date_calculation_utility';
|
||||
export * from './datetime/pikaday_utility';
|
||||
export * from './datetime/time_spent_utility';
|
||||
export * from './datetime/locale_dateformat';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
import initDatePickers from '~/behaviors/date_picker';
|
||||
|
||||
initDatePickers();
|
||||
|
|
@ -128,6 +128,10 @@ export const I18N_ERROR_INCORRECT_TOKEN_LABEL = s__('ServiceDesk|Incorrect verif
|
|||
export const I18N_ERROR_INCORRECT_TOKEN_DESC = s__(
|
||||
"ServiceDesk|The received email didn't contain the verification token that was sent to your email address.",
|
||||
);
|
||||
export const I18N_ERROR_READ_TIMEOUT_LABEL = s__('ServiceDesk|Read timeout');
|
||||
export const I18N_ERROR_READ_TIMEOUT_DESC = s__(
|
||||
'ServiceDesk|The SMTP server did not respond in time.',
|
||||
);
|
||||
|
||||
export const I18N_VERIFICATION_ERRORS = {
|
||||
smtp_host_issue: {
|
||||
|
|
@ -150,4 +154,8 @@ export const I18N_VERIFICATION_ERRORS = {
|
|||
label: I18N_ERROR_INCORRECT_TOKEN_LABEL,
|
||||
description: I18N_ERROR_INCORRECT_TOKEN_DESC,
|
||||
},
|
||||
read_timeout: {
|
||||
label: I18N_ERROR_READ_TIMEOUT_LABEL,
|
||||
description: I18N_ERROR_READ_TIMEOUT_DESC,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ export const OPERATORS_IS_NOT = [...OPERATORS_IS, ...OPERATORS_NOT];
|
|||
export const OPERATORS_IS_NOT_OR = [...OPERATORS_IS, ...OPERATORS_NOT, ...OPERATORS_OR];
|
||||
export const OPERATORS_AFTER_BEFORE = [...OPERATORS_AFTER, ...OPERATORS_BEFORE];
|
||||
|
||||
export const OPERATORS_TO_GROUP = [OPERATOR_OR, OPERATOR_NOT];
|
||||
|
||||
export const OPTION_NONE = { value: FILTER_NONE, text: __('None'), title: __('None') };
|
||||
export const OPTION_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') };
|
||||
export const OPTION_CURRENT = { value: FILTER_CURRENT, text: __('Current') };
|
||||
|
|
|
|||
|
|
@ -11,7 +11,13 @@ import { debounce, last } from 'lodash';
|
|||
|
||||
import { stripQuotes } from '~/lib/utils/text_utility';
|
||||
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import { DEBOUNCE_DELAY, FILTERS_NONE_ANY, OPERATOR_NOT, OPERATOR_OR } from '../constants';
|
||||
import {
|
||||
DEBOUNCE_DELAY,
|
||||
FILTERS_NONE_ANY,
|
||||
OPERATOR_NOT,
|
||||
OPERATOR_OR,
|
||||
OPERATORS_TO_GROUP,
|
||||
} from '../constants';
|
||||
import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed } from '../filtered_search_utils';
|
||||
|
||||
export default {
|
||||
|
|
@ -102,7 +108,7 @@ export default {
|
|||
},
|
||||
activeTokenValue() {
|
||||
const data =
|
||||
this.glFeatures.groupMultiSelectTokens && Array.isArray(this.value.data)
|
||||
this.multiSelectEnabled && Array.isArray(this.value.data)
|
||||
? last(this.value.data)
|
||||
: this.value.data;
|
||||
return this.getActiveTokenValue(this.suggestions, data);
|
||||
|
|
@ -153,6 +159,22 @@ export default {
|
|||
? this.activeTokenValue[this.searchBy]
|
||||
: undefined;
|
||||
},
|
||||
multiSelectEnabled() {
|
||||
return (
|
||||
this.config.multiSelect &&
|
||||
this.glFeatures.groupMultiSelectTokens &&
|
||||
OPERATORS_TO_GROUP.includes(this.value.operator)
|
||||
);
|
||||
},
|
||||
validatedConfig() {
|
||||
if (this.config.multiSelect && !this.multiSelectEnabled) {
|
||||
return {
|
||||
...this.config,
|
||||
multiSelect: false,
|
||||
};
|
||||
}
|
||||
return this.config;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
active: {
|
||||
|
|
@ -199,7 +221,7 @@ export default {
|
|||
}
|
||||
}, DEBOUNCE_DELAY),
|
||||
handleTokenValueSelected(selectedValue) {
|
||||
if (this.glFeatures.groupMultiSelectTokens) {
|
||||
if (this.multiSelectEnabled) {
|
||||
this.$emit('token-selected', selectedValue);
|
||||
}
|
||||
|
||||
|
|
@ -228,7 +250,7 @@ export default {
|
|||
|
||||
<template>
|
||||
<gl-filtered-search-token
|
||||
:config="config"
|
||||
:config="validatedConfig"
|
||||
:value="value"
|
||||
:active="active"
|
||||
:multi-select-values="multiSelectValues"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { __ } from '~/locale';
|
|||
import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
|
||||
import usersAutocompleteQuery from '~/graphql_shared/queries/users_autocomplete.query.graphql';
|
||||
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import { OPTIONS_NONE_ANY } from '../constants';
|
||||
import { OPERATORS_TO_GROUP, OPTIONS_NONE_ANY } from '../constants';
|
||||
|
||||
import BaseToken from './base_token.vue';
|
||||
|
||||
|
|
@ -57,7 +57,11 @@ export default {
|
|||
return this.config.fetchUsers ? this.config.fetchUsers : this.fetchUsersBySearchTerm;
|
||||
},
|
||||
multiSelectEnabled() {
|
||||
return this.config.multiSelect && this.glFeatures.groupMultiSelectTokens;
|
||||
return (
|
||||
this.config.multiSelect &&
|
||||
this.glFeatures.groupMultiSelectTokens &&
|
||||
OPERATORS_TO_GROUP.includes(this.value.operator)
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
|
|
@ -94,7 +98,7 @@ export default {
|
|||
return user?.avatarUrl || user?.avatar_url;
|
||||
},
|
||||
displayNameFor(username) {
|
||||
return this.getActiveUser(this.allUsers, username)?.name || `@${username}`;
|
||||
return this.getActiveUser(this.allUsers, username)?.name || username;
|
||||
},
|
||||
avatarFor(username) {
|
||||
const user = this.getActiveUser(this.allUsers, username);
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@
|
|||
a,
|
||||
.wiki-list-expand-button,
|
||||
.wiki-list-collapse-button {
|
||||
color: $black;
|
||||
color: var(--gl-text-color, $gl-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +149,7 @@
|
|||
}
|
||||
|
||||
ul.wiki-pages ul,
|
||||
ul.wiki-pages li:not(.wiki-directory){
|
||||
ul.wiki-pages li:not(.wiki-directory){
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
|
|
@ -162,16 +162,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.right-sidebar.wiki-sidebar {
|
||||
.active > .wiki-list {
|
||||
a,
|
||||
.wiki-list-expand-button,
|
||||
.wiki-list-collapse-button {
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ul.wiki-pages-list.content-list {
|
||||
a {
|
||||
color: var(--blue-600, $blue-600);
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class Admin::DeployKeysController < Admin::ApplicationController
|
|||
end
|
||||
|
||||
def create_params
|
||||
params.require(:deploy_key).permit(:key, :title)
|
||||
params.require(:deploy_key).permit(:key, :title, :expires_at)
|
||||
end
|
||||
|
||||
def update_params
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ module Explore
|
|||
class CatalogController < Explore::ApplicationController
|
||||
feature_category :pipeline_composition
|
||||
before_action :check_feature_flag
|
||||
before_action :check_resource_access, only: :show
|
||||
|
||||
def show; end
|
||||
|
||||
|
|
@ -16,5 +17,13 @@ module Explore
|
|||
def check_feature_flag
|
||||
render_404 unless Feature.enabled?(:global_ci_catalog, current_user)
|
||||
end
|
||||
|
||||
def check_resource_access
|
||||
render_404 unless catalog_resource.present?
|
||||
end
|
||||
|
||||
def catalog_resource
|
||||
::Ci::Catalog::Listing.new(current_user).find_resource(id: params[:id])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -272,6 +272,10 @@ class NotifyPreview < ActionMailer::Preview
|
|||
end
|
||||
end
|
||||
|
||||
def service_desk_verification_result_email_for_read_timeout_error
|
||||
service_desk_verification_result_email_for_error_state(error: :read_timeout)
|
||||
end
|
||||
|
||||
def service_desk_verification_result_email_for_incorrect_token_error
|
||||
service_desk_verification_result_email_for_error_state(error: :incorrect_token)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -29,6 +29,16 @@ module Ci
|
|||
end
|
||||
end
|
||||
|
||||
def find_resource(id:)
|
||||
resource = Ci::Catalog::Resource.find_by_id(id)
|
||||
|
||||
return unless resource.present?
|
||||
return unless resource.published?
|
||||
return unless current_user.can?(:read_code, resource.project)
|
||||
|
||||
resource
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :current_user
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ module Ci
|
|||
# dependency on the Project model and its need to join with that table
|
||||
# in order to generate the CI/CD catalog.
|
||||
class Resource < ::ApplicationRecord
|
||||
include Gitlab::SQL::Pattern
|
||||
include PgFullTextSearchable
|
||||
|
||||
self.table_name = 'catalog_resources'
|
||||
|
||||
|
|
@ -19,7 +19,10 @@ module Ci
|
|||
inverse_of: :catalog_resource
|
||||
|
||||
scope :for_projects, ->(project_ids) { where(project_id: project_ids) }
|
||||
scope :search, ->(query) { fuzzy_search(query, [:name, :description], use_minimum_char_limit: false) }
|
||||
|
||||
# The `search_vector` column contains a tsvector that has a greater weight on `name` than `description`.
|
||||
# The vector is automatically generated by the database when `name` or `description` is updated.
|
||||
scope :search, ->(query) { pg_full_text_search_in_model(query) }
|
||||
|
||||
scope :order_by_created_at_desc, -> { reorder(created_at: :desc) }
|
||||
scope :order_by_created_at_asc, -> { reorder(created_at: :asc) }
|
||||
|
|
|
|||
|
|
@ -49,8 +49,6 @@ module Noteable
|
|||
end
|
||||
|
||||
def supports_resolvable_notes?
|
||||
return false if is_a?(Issue) && Feature.disabled?(:resolvable_issue_threads, project)
|
||||
|
||||
self.class.resolvable_types.include?(base_class_name)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@
|
|||
# This also adds a `pg_full_text_search` scope so you can do:
|
||||
#
|
||||
# Model.pg_full_text_search("some search term")
|
||||
#
|
||||
# For situations where the `search_vector` column exists within the model table and not
|
||||
# in a `search_data` association, you may instead use `pg_full_text_search_in_model`.
|
||||
|
||||
module PgFullTextSearchable
|
||||
extend ActiveSupport::Concern
|
||||
|
|
@ -106,20 +109,27 @@ module PgFullTextSearchable
|
|||
def pg_full_text_search(query, matched_columns: [])
|
||||
search_data_table = reflect_on_association(:search_data).klass.arel_table
|
||||
|
||||
joins(:search_data).where(
|
||||
Arel::Nodes::InfixOperation.new(
|
||||
'@@',
|
||||
search_data_table[:search_vector],
|
||||
Arel::Nodes::NamedFunction.new(
|
||||
'to_tsquery',
|
||||
[Arel::Nodes.build_quoted(TEXT_SEARCH_DICTIONARY), build_tsquery(query, matched_columns)]
|
||||
)
|
||||
)
|
||||
)
|
||||
joins(:search_data)
|
||||
.where(pg_full_text_search_query(query, search_data_table, matched_columns: matched_columns))
|
||||
end
|
||||
|
||||
def pg_full_text_search_in_model(query, matched_columns: [])
|
||||
where(pg_full_text_search_query(query, arel_table, matched_columns: matched_columns))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def pg_full_text_search_query(query, search_table, matched_columns: [])
|
||||
Arel::Nodes::InfixOperation.new(
|
||||
'@@',
|
||||
search_table[:search_vector],
|
||||
Arel::Nodes::NamedFunction.new(
|
||||
'to_tsquery',
|
||||
[Arel::Nodes.build_quoted(TEXT_SEARCH_DICTIONARY), build_tsquery(query, matched_columns)]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def build_tsquery(query, matched_columns)
|
||||
# URLs get broken up into separate words when : is removed below, so we just remove the whole scheme.
|
||||
query = remove_url_scheme(query)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ module Projects
|
|||
|
||||
belongs_to :container, class_name: 'Project', inverse_of: :repository_storage_moves, foreign_key: :project_id
|
||||
alias_attribute :project, :container
|
||||
alias_attribute :container_id, :project_id
|
||||
scope :with_projects, -> { includes(container: :route) }
|
||||
|
||||
override :schedule_repository_storage_update_worker
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ module ServiceDesk
|
|||
incorrect_from: 1,
|
||||
mail_not_received_within_timeframe: 2,
|
||||
invalid_credentials: 3,
|
||||
smtp_host_issue: 4
|
||||
smtp_host_issue: 4,
|
||||
read_timeout: 5
|
||||
}
|
||||
|
||||
attr_encrypted :token,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ module Snippets
|
|||
|
||||
belongs_to :container, class_name: 'Snippet', inverse_of: :repository_storage_moves, foreign_key: :snippet_id
|
||||
alias_attribute :snippet, :container
|
||||
alias_attribute :container_id, :snippet_id
|
||||
|
||||
override :schedule_repository_storage_update_worker
|
||||
def schedule_repository_storage_update_worker
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ module ServiceDesk
|
|||
rescue Net::SMTPAuthenticationError
|
||||
# incorrect username or password
|
||||
@ramp_up_error = :invalid_credentials
|
||||
rescue Net::ReadTimeout
|
||||
# Server is slow to respond
|
||||
@ramp_up_error = :read_timeout
|
||||
end
|
||||
|
||||
def handle_error_case
|
||||
|
|
|
|||
|
|
@ -54,5 +54,10 @@
|
|||
%b
|
||||
= s_('Notify|Incorrect verification token:')
|
||||
= s_('Notify|We could not verify that we received the email we sent to your email inbox.')
|
||||
- if @verification.read_timeout?
|
||||
%p
|
||||
%b
|
||||
= s_('Notify|Read timeout:')
|
||||
= s_('Notify|The SMTP server did not respond in time.')
|
||||
%p
|
||||
= html_escape(s_('Notify|To restart the verification process, go to your %{settings_link_start}project\'s Service Desk settings page%{settings_link_end}.')) % { settings_link_start: settings_link_start, settings_link_end: settings_link_end }
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@
|
|||
<% elsif @verification.incorrect_token? %>
|
||||
<%= s_('Notify|Incorrect verification token:') %>
|
||||
<%= s_('Notify|We could not verify that we received the email we sent to your email inbox.') %>
|
||||
<% elsif @verification.read_timeout? %>
|
||||
<%= s_('Notify|Read timeout:') %>
|
||||
<%= s_('Notify|The SMTP server did not respond in time.') %>
|
||||
<% end %>
|
||||
|
||||
<%= s_('Notify|To restart the verification process, go to your %{settings_link_start}project\'s Service Desk settings page%{settings_link_end}.') % { settings_link_start: '', settings_link_end: '' } %>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@
|
|||
- link_end = '</a>'
|
||||
= _('Paste a public key here. %{link_start}How do I generate it?%{link_end}').html_safe % { link_start: link_start, link_end: link_end.html_safe }
|
||||
= form.text_area :key, class: 'form-control gl-form-input thin_area', rows: 5, data: { testid: 'deploy-key-field' }
|
||||
|
||||
.form-group
|
||||
= form.label :expires_at, _('Expiration date (optional)'), class: 'label-bold'
|
||||
= form.gitlab_ui_datepicker :expires_at
|
||||
- else
|
||||
- if deploy_key.fingerprint_sha256.present?
|
||||
.form-group
|
||||
|
|
@ -26,10 +30,10 @@
|
|||
.form-group
|
||||
= form.label :fingerprint, _('Fingerprint (MD5)')
|
||||
= form.text_field :fingerprint, class: 'form-control gl-form-input', readonly: 'readonly'
|
||||
|
||||
.form-group
|
||||
= form.label :expires_at, _('Expiration date (optional)'), class: 'label-bold'
|
||||
= form.text_field :expires_at, class: 'form-control gl-form-input', readonly: 'readonly'
|
||||
- if deploy_key.expires_at.present?
|
||||
.form-group
|
||||
= form.label :expires_at, _('Expiration date'), class: 'label-bold'
|
||||
= form.gitlab_ui_datepicker :expires_at, class: 'form-control gl-form-input', readonly: 'readonly'
|
||||
|
||||
- if deploy_keys_project.present?
|
||||
= form.fields_for :deploy_keys_projects, deploy_keys_project do |deploy_keys_project_form|
|
||||
|
|
|
|||
|
|
@ -13,7 +13,17 @@ module UpdateRepositoryStorageWorker
|
|||
|
||||
LEASE_TIMEOUT = 30.minutes.to_i
|
||||
|
||||
def perform(container_id, new_repository_storage_key, repository_storage_move_id = nil)
|
||||
# `container_id` and `new_repository_storage_key` arguments have been deprecated.
|
||||
# `repository_storage_move_id` is now a mandatory argument.
|
||||
# We are using *args for backwards compatability. Previously defined as:
|
||||
# perform(container_id, new_repository_storage_key, repository_storage_move_id = nil)
|
||||
def perform(*args)
|
||||
if args.length == 1
|
||||
repository_storage_move_id = args[0]
|
||||
else
|
||||
container_id, new_repository_storage_key, repository_storage_move_id = *args
|
||||
end
|
||||
|
||||
repository_storage_move =
|
||||
if repository_storage_move_id
|
||||
find_repository_storage_move(repository_storage_move_id)
|
||||
|
|
@ -26,6 +36,8 @@ module UpdateRepositoryStorageWorker
|
|||
)
|
||||
end
|
||||
|
||||
container_id ||= repository_storage_move.container_id
|
||||
|
||||
if Feature.enabled?(:use_lock_for_update_repository_storage)
|
||||
# Use exclusive lock to prevent multiple storage migrations at the same time
|
||||
#
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
---
|
||||
name: resolvable_issue_threads
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/127243
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/419893
|
||||
milestone: '16.3'
|
||||
type: development
|
||||
group: group::project management
|
||||
default_enabled: true
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddSearchVectorToCatalogResources < Gitlab::Database::Migration[2.2]
|
||||
milestone '16.7'
|
||||
|
||||
def up
|
||||
# This is required to implement PostgreSQL Full Text Search functionality in Ci::Catalog::Resource.
|
||||
# Indices on `search_vector` will be added in a later step. COALESCE is used here to avoid NULL results.
|
||||
# See https://gitlab.com/gitlab-org/gitlab/-/issues/430889 for details.
|
||||
execute <<~SQL
|
||||
ALTER TABLE catalog_resources
|
||||
ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS
|
||||
(setweight(to_tsvector('english', COALESCE(name, '')), 'A') ||
|
||||
setweight(to_tsvector('english', COALESCE(description, '')), 'B')) STORED;
|
||||
SQL
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :catalog_resources, :search_vector
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class RemoveNameDescriptionTrigramIndexesFromCatalogResources < Gitlab::Database::Migration[2.2]
|
||||
milestone '16.7'
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
NAME_TRIGRAM_INDEX = 'index_catalog_resources_on_name_trigram'
|
||||
DESCRIPTION_TRIGRAM_INDEX = 'index_catalog_resources_on_description_trigram'
|
||||
|
||||
def up
|
||||
remove_concurrent_index_by_name :catalog_resources, NAME_TRIGRAM_INDEX
|
||||
remove_concurrent_index_by_name :catalog_resources, DESCRIPTION_TRIGRAM_INDEX
|
||||
end
|
||||
|
||||
def down
|
||||
add_concurrent_index :catalog_resources, :name, name: NAME_TRIGRAM_INDEX,
|
||||
using: :gin, opclass: { name: :gin_trgm_ops }
|
||||
|
||||
add_concurrent_index :catalog_resources, :description, name: DESCRIPTION_TRIGRAM_INDEX,
|
||||
using: :gin, opclass: { description: :gin_trgm_ops }
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1 @@
|
|||
226d69a2d83bff6b26e9a7262877cbb1ee3f54189beff3929dabbf15e5574b84
|
||||
|
|
@ -0,0 +1 @@
|
|||
c73543be16d10357a095f5214dc9a9499b1b82e81741c3131b0822de4c6d67fe
|
||||
|
|
@ -13551,7 +13551,8 @@ CREATE TABLE catalog_resources (
|
|||
latest_released_at timestamp with time zone,
|
||||
name character varying,
|
||||
description text,
|
||||
visibility_level integer DEFAULT 0 NOT NULL
|
||||
visibility_level integer DEFAULT 0 NOT NULL,
|
||||
search_vector tsvector GENERATED ALWAYS AS ((setweight(to_tsvector('english'::regconfig, (COALESCE(name, ''::character varying))::text), 'A'::"char") || setweight(to_tsvector('english'::regconfig, COALESCE(description, ''::text)), 'B'::"char"))) STORED
|
||||
);
|
||||
|
||||
CREATE SEQUENCE catalog_resources_id_seq
|
||||
|
|
@ -31792,10 +31793,6 @@ CREATE INDEX index_catalog_resource_versions_on_project_id ON catalog_resource_v
|
|||
|
||||
CREATE UNIQUE INDEX index_catalog_resource_versions_on_release_id ON catalog_resource_versions USING btree (release_id);
|
||||
|
||||
CREATE INDEX index_catalog_resources_on_description_trigram ON catalog_resources USING gin (description gin_trgm_ops);
|
||||
|
||||
CREATE INDEX index_catalog_resources_on_name_trigram ON catalog_resources USING gin (name gin_trgm_ops);
|
||||
|
||||
CREATE UNIQUE INDEX index_catalog_resources_on_project_id ON catalog_resources USING btree (project_id);
|
||||
|
||||
CREATE INDEX index_catalog_resources_on_state ON catalog_resources USING btree (state);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ creation-date: "2022-09-07"
|
|||
authors: [ "@ayufan", "@fzimmer", "@DylanGriffith", "@lohrc", "@tkuah" ]
|
||||
coach: "@ayufan"
|
||||
approvers: [ "@lohrc" ]
|
||||
owning-stage: "~devops::enablement"
|
||||
owning-stage: "~devops::data stores"
|
||||
participating-stages: []
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ creation-date: "2023-02-02"
|
|||
authors: [ "@nhxnguyen" ]
|
||||
coach: "@grzesiek"
|
||||
approvers: [ "@dorrino", "@nhxnguyen" ]
|
||||
owning-stage: "~devops::data_stores"
|
||||
owning-stage: "~devops::data stores"
|
||||
participating-stages: ["~section::ops", "~section::dev"]
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ authors: [ "@alexpooley", "@ifarkas" ]
|
|||
coach: "@grzesiek"
|
||||
approvers: [ "@m_gill", "@mushakov" ]
|
||||
author-stage: "~devops::plan"
|
||||
owning-stage: "~devops::data_stores"
|
||||
owning-stage: "~devops::data stores"
|
||||
participating-stages: []
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ creation-date: "2023-02-08"
|
|||
authors: [ "@mattkasa", "@jon_jenkins" ]
|
||||
coach: "@DylanGriffith"
|
||||
approvers: [ "@rogerwoo", "@alexives" ]
|
||||
owning-stage: "~devops::data_stores"
|
||||
owning-stage: "~devops::data stores"
|
||||
participating-stages: []
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ creation-date: "2021-02-08"
|
|||
authors: [ "@abrandl" ]
|
||||
coach: "@glopezfernandez"
|
||||
approvers: [ "@fabian", "@craig-gomes" ]
|
||||
owning-stage: "~devops::data_stores"
|
||||
owning-stage: "~devops::data stores"
|
||||
participating-stages: []
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ creation-date: "2021-11-18"
|
|||
authors: [ "@nolith" ]
|
||||
coach: "@glopezfernandez"
|
||||
approvers: [ "@marin" ]
|
||||
owning-stage: "~devops::data_stores"
|
||||
owning-stage: "~devops::data stores"
|
||||
participating-stages: []
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -16,4 +16,4 @@ issues, epics, and more.
|
|||
| [Run an agile iteration](agile_sprint/index.md) | Use group, projects, and iterations to run an agile development iteration. |
|
||||
| [Set up a single project for issue triage](issue_triage/index.md) | Use labels to set up a project for issue triage. | **{star}** |
|
||||
| [Set up issue boards for team hand-off](boards_for_teams/index.md) | Use issue boards and scoped labels to set up collaboration across many teams. | **{star}** |
|
||||
| <i class="fa fa-youtube-play youtube" aria-hidden="true"></i> [Epics and Issue Boards](https://www.youtube.com/watch?v=I1bFIAQBHB8) | Find out how to use epics and issue boards for project management. | |
|
||||
| <i class="fa fa-youtube-play youtube" aria-hidden="true"></i> [Epics and Issue Boards](https://www.youtube.com/watch?v=eQUnHwbKEkY) | Find out how to use epics and issue boards for project management. | |
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ DORA includes four key metrics, divided into two core areas of DevOps:
|
|||
For software leaders, tracking velocity alongside quality metrics ensures they're not sacrificing quality for speed.
|
||||
|
||||
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
|
||||
For a video explanation, see [DORA metrics: User analytics](https://www.youtube.com/watch?v=lM_FbVYuN8s) and [GitLab speed run: DORA metrics](https://www.youtube.com/watch?v=1BrcMV6rCDw).
|
||||
For a video explanation, see [DORA metrics: User analytics](https://www.youtube.com/watch?v=jYQSH4EY6_U) and [GitLab speed run: DORA metrics](https://www.youtube.com/watch?v=1BrcMV6rCDw).
|
||||
|
||||
## DORA metrics in Value Stream Analytics
|
||||
|
||||
|
|
|
|||
|
|
@ -280,12 +280,7 @@ A threaded comment is created.
|
|||
|
||||
> - Resolvable threads for issues [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/31114) in GitLab 16.3 [with a flag](../../administration/feature_flags.md) named `resolvable_issue_threads`. Disabled by default.
|
||||
> - Resolvable threads for issues [enabled on GitLab.com and self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/31114) in GitLab 16.4.
|
||||
|
||||
FLAG:
|
||||
On self-managed GitLab, resolvable threads _for issues_ are available by default.
|
||||
To hide the feature, an administrator can
|
||||
[disable the feature flag](../../administration/feature_flags.md) named `resolvable_issue_threads`.
|
||||
On GitLab.com, this feature is available.
|
||||
> - Resolvable threads for issues [generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/31114) in GitLab 16.7. Feature flag `resolvable_issue_threads` removed.
|
||||
|
||||
You can resolve a thread when you want to finish a conversation.
|
||||
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ labels.
|
|||
On the top of each list, you can see the number of epics in the list (**{epic}**) and the total weight of all its epics (**{weight}**).
|
||||
|
||||
<div class="video-fallback">
|
||||
See the video: <a href="https://www.youtube.com/watch?v=I1bFIAQBHB8">Epics and Issue Boards - Project Management</a>.
|
||||
See the video: <a href="https://www.youtube.com/watch?v=eQUnHwbKEkY">Epics and Issue Boards - Project Management</a>.
|
||||
</div>
|
||||
<figure class="video-container">
|
||||
<iframe src="https://www.youtube-nocookie.com/embed/I1bFIAQBHB8" frameborder="0" allowfullscreen> </iframe>
|
||||
<iframe src="https://www.youtube-nocookie.com/embed/eQUnHwbKEkY" frameborder="0" allowfullscreen> </iframe>
|
||||
</figure>
|
||||
|
||||
To view an epic board:
|
||||
|
|
|
|||
|
|
@ -20,10 +20,10 @@ Use epics:
|
|||
- To discuss and collaborate on feature ideas and scope at a high level.
|
||||
|
||||
<div class="video-fallback">
|
||||
See the video: <a href="https://www.youtube.com/watch?v=kdE-yb6Puuo">GitLab Epics - Setting up your Organization with GitLab</a>.
|
||||
See the video: <a href="https://www.youtube.com/watch?v=c0EwYYUZppw">GitLab Epics - Setting up your Organization with GitLab</a>.
|
||||
</div>
|
||||
<figure class="video-container">
|
||||
<iframe src="https://www.youtube-nocookie.com/embed/kdE-yb6Puuo" frameborder="0" allowfullscreen> </iframe>
|
||||
<iframe src="https://www.youtube-nocookie.com/embed/c0EwYYUZppw" frameborder="0" allowfullscreen> </iframe>
|
||||
</figure>
|
||||
|
||||
## Relationships between epics and issues
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ Issues are always associated with a specific project. If you have multiple
|
|||
projects in a group, you can view all of the projects' issues at once.
|
||||
|
||||
<div class="video-fallback">
|
||||
See the video: <a href="https://www.youtube.com/watch?v=tTE6omrBBZI">Issues - Setting up your Organization with GitLab</a>.
|
||||
See the video: <a href="https://www.youtube.com/watch?v=Mt1EzlKToig">Issues - Setting up your Organization with GitLab</a>.
|
||||
</div>
|
||||
<figure class="video-container">
|
||||
<iframe src="https://www.youtube-nocookie.com/embed/tTE6omrBBZI" frameborder="0" allowfullscreen> </iframe>
|
||||
<iframe src="https://www.youtube-nocookie.com/embed/Mt1EzlKToig" frameborder="0" allowfullscreen> </iframe>
|
||||
</figure>
|
||||
|
||||
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
|
||||
|
|
|
|||
|
|
@ -4,16 +4,11 @@ group: IDE
|
|||
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments
|
||||
---
|
||||
|
||||
# Workspace configuration **(PREMIUM ALL BETA)**
|
||||
# Workspace configuration **(PREMIUM ALL)**
|
||||
|
||||
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/112397) in GitLab 15.11 [with a flag](../../administration/feature_flags.md) named `remote_development_feature_flag`. Disabled by default.
|
||||
> - [Enabled on GitLab.com and self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/391543) in GitLab 16.0.
|
||||
|
||||
FLAG:
|
||||
On self-managed GitLab, by default this feature is available.
|
||||
To hide the feature, an administrator can [disable the feature flag](../../administration/feature_flags.md) named `remote_development_feature_flag`.
|
||||
On GitLab.com, this feature is available.
|
||||
The feature is not ready for production use.
|
||||
> - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/136744) in GitLab 16.7. Feature flag `remote_development_feature_flag` removed.
|
||||
|
||||
WARNING:
|
||||
This feature is in [Beta](../../policy/experiment-beta-support.md#beta) and subject to change without notice.
|
||||
|
|
|
|||
|
|
@ -4,13 +4,11 @@ group: IDE
|
|||
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments
|
||||
---
|
||||
|
||||
# Tutorial: Create a custom workspace image that supports arbitrary user IDs **(PREMIUM ALL BETA)**
|
||||
# Tutorial: Create a custom workspace image that supports arbitrary user IDs **(PREMIUM ALL)**
|
||||
|
||||
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/112397) in GitLab 15.11 [with a flag](../../administration/feature_flags.md) named `remote_development_feature_flag`. Disabled by default.
|
||||
> - [Enabled on GitLab.com and self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/391543) in GitLab 16.0.
|
||||
|
||||
FLAG:
|
||||
On self-managed GitLab, by default this feature is available. To hide the feature, an administrator can [disable the feature flag](../../administration/feature_flags.md) named `remote_development_feature_flag`. On GitLab.com, this feature is available. The feature is not ready for production use.
|
||||
> - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/136744) in GitLab 16.7. Feature flag `remote_development_feature_flag` removed.
|
||||
|
||||
WARNING:
|
||||
This feature is in [Beta](../../policy/experiment-beta-support.md#beta) and subject to change without notice. To leave feedback, see the [feedback issue](https://gitlab.com/gitlab-org/gitlab/-/issues/410031).
|
||||
|
|
|
|||
|
|
@ -4,16 +4,11 @@ group: IDE
|
|||
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments
|
||||
---
|
||||
|
||||
# Workspaces **(PREMIUM ALL BETA)**
|
||||
# Workspaces **(PREMIUM ALL)**
|
||||
|
||||
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/112397) in GitLab 15.11 [with a flag](../../administration/feature_flags.md) named `remote_development_feature_flag`. Disabled by default.
|
||||
> - [Enabled on GitLab.com and self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/391543) in GitLab 16.0.
|
||||
|
||||
FLAG:
|
||||
On self-managed GitLab, by default this feature is available.
|
||||
To hide the feature, an administrator can [disable the feature flag](../../administration/feature_flags.md) named `remote_development_feature_flag`.
|
||||
On GitLab.com, this feature is available.
|
||||
The feature is not ready for production use.
|
||||
> - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/136744) in GitLab 16.7. Feature flag `remote_development_feature_flag` removed.
|
||||
|
||||
WARNING:
|
||||
This feature is in [Beta](../../policy/experiment-beta-support.md#beta) and subject to change without notice.
|
||||
|
|
|
|||
10
lefthook.yml
10
lefthook.yml
|
|
@ -110,6 +110,11 @@ pre-push:
|
|||
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
|
||||
glob: '*.{rb,rake}'
|
||||
run: REVEAL_RUBOCOP_TODO=0 bundle exec rubocop --parallel --force-exclusion --no-server {files}
|
||||
verify-tff-mapping:
|
||||
tags: backend style
|
||||
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
|
||||
glob: 'tests.yml'
|
||||
run: scripts/verify-tff-mapping
|
||||
|
||||
scripts:
|
||||
"merge_conflicts":
|
||||
|
|
@ -127,6 +132,11 @@ pre-commit:
|
|||
files: git diff --name-only --diff-filter=d --staged
|
||||
glob: '*.{rb,rake}'
|
||||
run: REVEAL_RUBOCOP_TODO=0 bundle exec rubocop --parallel --force-exclusion --no-server {files}
|
||||
verify-tff-mapping:
|
||||
tags: backend style
|
||||
files: git diff --name-only --diff-filter=d --staged
|
||||
glob: 'tests.yml'
|
||||
run: scripts/verify-tff-mapping
|
||||
secrets-detection:
|
||||
tags: secrets
|
||||
files: git diff --name-only --diff-filter=d --staged
|
||||
|
|
|
|||
|
|
@ -6,16 +6,20 @@ module Gitlab
|
|||
class PullRequestsImporter
|
||||
include ParallelScheduling
|
||||
|
||||
# Reduce fetch limit (from 100) to avoid Gitlab::Git::ResourceExhaustedError
|
||||
PULL_REQUESTS_BATCH_SIZE = 50
|
||||
|
||||
def execute
|
||||
page = 1
|
||||
|
||||
loop do
|
||||
log_info(
|
||||
import_stage: 'import_pull_requests', message: "importing page #{page} using batch-size #{BATCH_SIZE}"
|
||||
import_stage: 'import_pull_requests',
|
||||
message: "importing page #{page} using batch-size #{PULL_REQUESTS_BATCH_SIZE}"
|
||||
)
|
||||
|
||||
pull_requests = client.pull_requests(
|
||||
project_key, repository_slug, page_offset: page, limit: BATCH_SIZE
|
||||
project_key, repository_slug, page_offset: page, limit: PULL_REQUESTS_BATCH_SIZE
|
||||
).to_a
|
||||
|
||||
break if pull_requests.empty?
|
||||
|
|
|
|||
|
|
@ -78,7 +78,11 @@ module Gitlab
|
|||
strong_memoize_attr :component_name
|
||||
|
||||
def latest_version_sha
|
||||
project.releases.latest&.sha
|
||||
if project.catalog_resource
|
||||
project.catalog_resource.versions.latest&.sha
|
||||
else
|
||||
project.releases.latest&.sha
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ namespace :tw do
|
|||
# CodeOwnerRule.new('Acquisition', ''),
|
||||
CodeOwnerRule.new('AI Framework', '@sselhorn'),
|
||||
CodeOwnerRule.new('AI Model Validation', '@sselhorn'),
|
||||
CodeOwnerRule.new('Analytics Instrumentation', '@lciutacu'),
|
||||
# CodeOwnerRule.new('Analytics Instrumentation', ''),
|
||||
CodeOwnerRule.new('Anti-Abuse', '@phillipwells'),
|
||||
CodeOwnerRule.new('Cloud Connector', '@jglassman1'),
|
||||
CodeOwnerRule.new('Authentication', '@jglassman1'),
|
||||
|
|
|
|||
|
|
@ -32602,6 +32602,9 @@ msgstr ""
|
|||
msgid "Notify|Project %{project_name} was exported successfully."
|
||||
msgstr ""
|
||||
|
||||
msgid "Notify|Read timeout:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Notify|Remote mirror"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -32617,6 +32620,9 @@ msgstr ""
|
|||
msgid "Notify|The Auto DevOps pipeline failed for pipeline %{pipeline_link} and has been disabled for %{project_link}. In order to use the Auto DevOps pipeline with your project, please review the %{supported_langs_link}, adjust your project accordingly, and turn on the Auto DevOps pipeline within your %{settings_link}."
|
||||
msgstr ""
|
||||
|
||||
msgid "Notify|The SMTP server did not respond in time."
|
||||
msgstr ""
|
||||
|
||||
msgid "Notify|The diff for this file was not included because it is too large."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -42737,6 +42743,36 @@ msgstr ""
|
|||
msgid "Secrets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Secrets|Add a new secret to the group by following the instructions in the form below."
|
||||
msgstr ""
|
||||
|
||||
msgid "Secrets|Add secret"
|
||||
msgstr ""
|
||||
|
||||
msgid "Secrets|Audit log"
|
||||
msgstr ""
|
||||
|
||||
msgid "Secrets|Edit %{key}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Secrets|New secret"
|
||||
msgstr ""
|
||||
|
||||
msgid "Secrets|Secret details"
|
||||
msgstr ""
|
||||
|
||||
msgid "Secrets|Secret name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Secrets|Secrets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Secrets|Secrets represent sensitive information your CI job needs to complete work. This sensitive information can be items like API tokens, database credentials, or private keys. Unlike CI/CD variables, which are always presented to a job, secrets must be explicitly required by a job. %{linkStart}Learn more.%{linkEnd}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Secrets|Stored secrets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Secure Code Warrior"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -43738,6 +43774,9 @@ msgstr ""
|
|||
msgid "SecurityReports|Dismissed as..."
|
||||
msgstr ""
|
||||
|
||||
msgid "SecurityReports|Does not have a solution"
|
||||
msgstr ""
|
||||
|
||||
msgid "SecurityReports|Does not have issue"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -43789,6 +43828,9 @@ msgstr ""
|
|||
msgid "SecurityReports|Group your vulnerabilities by one of the provided categories. Leave feedback or suggestions in %{feedbackIssueStart}this issue%{feedbackIssueEnd}."
|
||||
msgstr ""
|
||||
|
||||
msgid "SecurityReports|Has a solution"
|
||||
msgstr ""
|
||||
|
||||
msgid "SecurityReports|Has issue"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -43804,12 +43846,6 @@ msgstr ""
|
|||
msgid "SecurityReports|Investigate this vulnerability by creating an issue"
|
||||
msgstr ""
|
||||
|
||||
msgid "SecurityReports|Is available"
|
||||
msgstr ""
|
||||
|
||||
msgid "SecurityReports|Is not available"
|
||||
msgstr ""
|
||||
|
||||
msgid "SecurityReports|Issue"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -44514,6 +44550,9 @@ msgstr ""
|
|||
msgid "ServiceDesk|Please try again. Check email forwarding settings and credentials, and then restart verification."
|
||||
msgstr ""
|
||||
|
||||
msgid "ServiceDesk|Read timeout"
|
||||
msgstr ""
|
||||
|
||||
msgid "ServiceDesk|Reset custom email"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -44574,6 +44613,9 @@ msgstr ""
|
|||
msgid "ServiceDesk|Service Desk setting or verification object missing"
|
||||
msgstr ""
|
||||
|
||||
msgid "ServiceDesk|The SMTP server did not respond in time."
|
||||
msgstr ""
|
||||
|
||||
msgid "ServiceDesk|The given credentials (username and password) were rejected by the SMTP server."
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@
|
|||
"@gitlab/cluster-client": "^2.1.0",
|
||||
"@gitlab/favicon-overlay": "2.0.0",
|
||||
"@gitlab/fonts": "^1.3.0",
|
||||
"@gitlab/svgs": "3.69.0",
|
||||
"@gitlab/svgs": "3.71.0",
|
||||
"@gitlab/ui": "^68.5.0",
|
||||
"@gitlab/visual-review-tools": "1.7.3",
|
||||
"@gitlab/web-ide": "0.0.1-dev-20231004090414",
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ function install_gitlab_gem() {
|
|||
}
|
||||
|
||||
function install_tff_gem() {
|
||||
run_timed_command "gem install test_file_finder --no-document --version 0.1.4"
|
||||
run_timed_command "gem install test_file_finder --no-document --version 0.2.1"
|
||||
}
|
||||
|
||||
function install_activesupport_gem() {
|
||||
|
|
|
|||
|
|
@ -166,12 +166,6 @@ tests = [
|
|||
expected: ['spec/workers/every_sidekiq_worker_spec.rb']
|
||||
},
|
||||
|
||||
{
|
||||
explanation: 'Known events',
|
||||
source: 'lib/gitlab/usage_data_counters/known_events/common.yml',
|
||||
expected: ['spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb', 'spec/lib/gitlab/usage_data_spec.rb']
|
||||
},
|
||||
|
||||
{
|
||||
explanation: 'FOSS mailer previews',
|
||||
source: 'app/mailers/previews/foo.rb',
|
||||
|
|
@ -204,11 +198,6 @@ tests = [
|
|||
expected: ['ee/spec/config/metrics/every_metric_definition_spec.rb']
|
||||
},
|
||||
|
||||
{
|
||||
explanation: 'https://gitlab.com/gitlab-org/quality/engineering-productivity/master-broken-incidents/-/issues/287#note_1192008962',
|
||||
source: 'ee/lib/ee/gitlab/usage_data_counters/known_events/common.yml',
|
||||
expected: ['ee/spec/config/metrics/every_metric_definition_spec.rb']
|
||||
},
|
||||
{
|
||||
explanation: 'https://gitlab.com/gitlab-org/quality/engineering-productivity/team/-/issues/146',
|
||||
source: 'config/feature_categories.yml',
|
||||
|
|
|
|||
|
|
@ -162,19 +162,6 @@ RSpec.describe Projects::DiscussionsController, feature_category: :team_planning
|
|||
expect(note.reload.resolved_at).not_to be_nil
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
end
|
||||
|
||||
context 'when resolvable_issue_threads is disabled' do
|
||||
before do
|
||||
stub_feature_flags(resolvable_issue_threads: false)
|
||||
end
|
||||
|
||||
it 'does not resolve the discussion and returns status 404' do
|
||||
post :resolve, params: request_params
|
||||
|
||||
expect(note.reload.resolved_at).to be_nil
|
||||
expect(response).to have_gitlab_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -261,19 +248,6 @@ RSpec.describe Projects::DiscussionsController, feature_category: :team_planning
|
|||
expect(note.reload.resolved_at).to be_nil
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
end
|
||||
|
||||
context 'when resolvable_issue_threads is disabled' do
|
||||
before do
|
||||
stub_feature_flags(resolvable_issue_threads: false)
|
||||
end
|
||||
|
||||
it 'does not unresolve the discussion and returns status 404' do
|
||||
delete :unresolve, params: request_params
|
||||
|
||||
expect(note.reload.resolved_at).not_to be_nil
|
||||
expect(response).to have_gitlab_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1797,18 +1797,6 @@ RSpec.describe Projects::IssuesController, :request_store, feature_category: :te
|
|||
expect(json_response.first.keys).to match_array(%w[id reply_id expanded notes diff_discussion discussion_path individual_note resolvable commit_id for_commit project_id confidential resolve_path resolved resolved_at resolved_by resolved_by_push])
|
||||
end
|
||||
|
||||
context 'when resolvable_issue_threads is disabled' do
|
||||
before do
|
||||
stub_feature_flags(resolvable_issue_threads: false)
|
||||
end
|
||||
|
||||
it 'returns discussion json without resolved fields' do
|
||||
get :discussions, params: { namespace_id: project.namespace, project_id: project, id: issue.iid }
|
||||
|
||||
expect(json_response.first.keys).to match_array(%w[id reply_id expanded notes diff_discussion discussion_path individual_note resolvable commit_id for_commit project_id confidential])
|
||||
end
|
||||
end
|
||||
|
||||
it 'renders the author status html if there is a status' do
|
||||
create(:user_status, user: discussion.author)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ RSpec.describe 'Global Catalog', :js, feature_category: :pipeline_composition do
|
|||
|
||||
describe 'GET explore/catalog' do
|
||||
let_it_be(:project) { create(:project, :repository, namespace: namespace) }
|
||||
|
||||
let_it_be(:ci_resource_projects) do
|
||||
create_list(
|
||||
:project,
|
||||
|
|
@ -26,11 +27,13 @@ RSpec.describe 'Global Catalog', :js, feature_category: :pipeline_composition do
|
|||
)
|
||||
end
|
||||
|
||||
before do
|
||||
ci_resource_projects.each do |current_project|
|
||||
let_it_be(:ci_catalog_resources) do
|
||||
ci_resource_projects.map do |current_project|
|
||||
create(:ci_catalog_resource, project: current_project)
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
visit explore_catalog_index_path
|
||||
wait_for_requests
|
||||
end
|
||||
|
|
@ -107,14 +110,26 @@ RSpec.describe 'Global Catalog', :js, feature_category: :pipeline_composition do
|
|||
|
||||
describe 'GET explore/catalog/:id' do
|
||||
let_it_be(:project) { create(:project, :repository, namespace: namespace) }
|
||||
let_it_be(:new_ci_resource) { create(:ci_catalog_resource, project: project) }
|
||||
|
||||
before do
|
||||
visit explore_catalog_path(id: new_ci_resource["id"])
|
||||
end
|
||||
|
||||
it 'navigates to the details page' do
|
||||
expect(page).to have_content('Go to the project')
|
||||
context 'when the resource is published' do
|
||||
let_it_be(:new_ci_resource) { create(:ci_catalog_resource, project: project, state: :published) }
|
||||
|
||||
it 'navigates to the details page' do
|
||||
expect(page).to have_content('Go to the project')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the resource is not published' do
|
||||
let_it_be(:new_ci_resource) { create(:ci_catalog_resource, project: project, state: :draft) }
|
||||
|
||||
it 'returns a 404' do
|
||||
expect(page).to have_title('Not Found')
|
||||
expect(page).to have_content('Page Not Found')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ exports[`Design management list item component with notes renders item with mult
|
|||
Updated
|
||||
<timeago-stub
|
||||
cssclass=""
|
||||
datetimeformat="DATE_WITH_TIME_FORMAT"
|
||||
datetimeformat="asDateTime"
|
||||
time="01-01-2019"
|
||||
tooltipplacement="bottom"
|
||||
/>
|
||||
|
|
@ -113,7 +113,7 @@ exports[`Design management list item component with notes renders item with sing
|
|||
Updated
|
||||
<timeago-stub
|
||||
cssclass=""
|
||||
datetimeformat="DATE_WITH_TIME_FORMAT"
|
||||
datetimeformat="asDateTime"
|
||||
time="01-01-2019"
|
||||
tooltipplacement="bottom"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
getIssuesCountsQueryResponse,
|
||||
getIssuesQueryEmptyResponse,
|
||||
getIssuesQueryResponse,
|
||||
groupedFilteredTokens,
|
||||
locationSearch,
|
||||
setSortPreferenceMutationResponse,
|
||||
setSortPreferenceMutationResponseWithErrors,
|
||||
|
|
@ -507,6 +508,13 @@ describe('CE IssuesListApp component', () => {
|
|||
});
|
||||
|
||||
describe('filter tokens', () => {
|
||||
it('groups url params of assignee and author', () => {
|
||||
setWindowLocation(locationSearch);
|
||||
wrapper = mountComponent({ provide: { glFeatures: { groupMultiSelectTokens: true } } });
|
||||
|
||||
expect(findIssuableList().props('initialFilterValue')).toEqual(groupedFilteredTokens);
|
||||
});
|
||||
|
||||
it('is set from the url params', () => {
|
||||
setWindowLocation(locationSearch);
|
||||
wrapper = mountComponent();
|
||||
|
|
|
|||
|
|
@ -231,19 +231,33 @@ export const locationSearchWithSpecialValues = [
|
|||
'health_status=None',
|
||||
].join('&');
|
||||
|
||||
export const filteredTokens = [
|
||||
const makeFilteredTokens = ({ grouped }) => [
|
||||
{ type: FILTERED_SEARCH_TERM, value: { data: 'find issues', operator: 'undefined' } },
|
||||
{ type: TOKEN_TYPE_AUTHOR, value: { data: 'homer', operator: OPERATOR_IS } },
|
||||
{ type: TOKEN_TYPE_AUTHOR, value: { data: 'marge', operator: OPERATOR_NOT } },
|
||||
{ type: TOKEN_TYPE_AUTHOR, value: { data: 'burns', operator: OPERATOR_OR } },
|
||||
{ type: TOKEN_TYPE_AUTHOR, value: { data: 'smithers', operator: OPERATOR_OR } },
|
||||
...(grouped
|
||||
? [
|
||||
{ type: TOKEN_TYPE_AUTHOR, value: { data: ['marge'], operator: OPERATOR_NOT } },
|
||||
{ type: TOKEN_TYPE_AUTHOR, value: { data: ['burns', 'smithers'], operator: OPERATOR_OR } },
|
||||
]
|
||||
: [
|
||||
{ type: TOKEN_TYPE_AUTHOR, value: { data: 'marge', operator: OPERATOR_NOT } },
|
||||
{ type: TOKEN_TYPE_AUTHOR, value: { data: 'burns', operator: OPERATOR_OR } },
|
||||
{ type: TOKEN_TYPE_AUTHOR, value: { data: 'smithers', operator: OPERATOR_OR } },
|
||||
]),
|
||||
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: 'bart', operator: OPERATOR_IS } },
|
||||
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: 'lisa', operator: OPERATOR_IS } },
|
||||
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: '5', operator: OPERATOR_IS } },
|
||||
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: 'patty', operator: OPERATOR_NOT } },
|
||||
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: 'selma', operator: OPERATOR_NOT } },
|
||||
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: 'carl', operator: OPERATOR_OR } },
|
||||
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: 'lenny', operator: OPERATOR_OR } },
|
||||
...(grouped
|
||||
? [
|
||||
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: ['patty', 'selma'], operator: OPERATOR_NOT } },
|
||||
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: ['carl', 'lenny'], operator: OPERATOR_OR } },
|
||||
]
|
||||
: [
|
||||
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: 'patty', operator: OPERATOR_NOT } },
|
||||
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: 'selma', operator: OPERATOR_NOT } },
|
||||
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: 'carl', operator: OPERATOR_OR } },
|
||||
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: 'lenny', operator: OPERATOR_OR } },
|
||||
]),
|
||||
{ type: TOKEN_TYPE_MILESTONE, value: { data: 'season 3', operator: OPERATOR_IS } },
|
||||
{ type: TOKEN_TYPE_MILESTONE, value: { data: 'season 4', operator: OPERATOR_IS } },
|
||||
{ type: TOKEN_TYPE_MILESTONE, value: { data: 'season 20', operator: OPERATOR_NOT } },
|
||||
|
|
@ -279,6 +293,9 @@ export const filteredTokens = [
|
|||
{ type: TOKEN_TYPE_HEALTH, value: { data: 'onTrack', operator: OPERATOR_NOT } },
|
||||
];
|
||||
|
||||
export const filteredTokens = makeFilteredTokens({ grouped: false });
|
||||
export const groupedFilteredTokens = makeFilteredTokens({ grouped: true });
|
||||
|
||||
export const filteredTokensWithSpecialValues = [
|
||||
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: '123', operator: OPERATOR_IS } },
|
||||
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: 'bart', operator: OPERATOR_IS } },
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
apiParamsWithSpecialValues,
|
||||
filteredTokens,
|
||||
filteredTokensWithSpecialValues,
|
||||
groupedFilteredTokens,
|
||||
locationSearch,
|
||||
locationSearchWithSpecialValues,
|
||||
urlParams,
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
getInitialPageParams,
|
||||
getSortKey,
|
||||
getSortOptions,
|
||||
groupMultiSelectFilterTokens,
|
||||
isSortKey,
|
||||
} from '~/issues/list/utils';
|
||||
import { DEFAULT_PAGE_SIZE } from '~/vue_shared/issuable/list/constants';
|
||||
|
|
@ -163,3 +165,14 @@ describe('convertToSearchQuery', () => {
|
|||
expect(convertToSearchQuery(filteredTokens)).toBe('find issues');
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupMultiSelectFilterTokens', () => {
|
||||
it('groups multiSelect filter tokens with || and != operators', () => {
|
||||
expect(
|
||||
groupMultiSelectFilterTokens(filteredTokens, [
|
||||
{ type: 'assignee', multiSelect: true },
|
||||
{ type: 'author', multiSelect: true },
|
||||
]),
|
||||
).toEqual(groupedFilteredTokens);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
import { DATE_TIME_FORMATS, localeDateFormat } from '~/lib/utils/datetime/locale_dateformat';
|
||||
import { setLanguage } from 'jest/__helpers__/locale_helper';
|
||||
import * as localeFns from '~/locale';
|
||||
|
||||
describe('localeDateFormat (en-US)', () => {
|
||||
const date = new Date('1983-07-09T14:15:23.123Z');
|
||||
const sameDay = new Date('1983-07-09T18:27:09.198Z');
|
||||
const sameMonth = new Date('1983-07-12T12:36:02.654Z');
|
||||
const nextYear = new Date('1984-01-10T07:47:54.947Z');
|
||||
|
||||
beforeEach(() => {
|
||||
setLanguage('en-US');
|
||||
localeDateFormat.reset();
|
||||
});
|
||||
|
||||
/*
|
||||
Depending on the ICU/Intl version, formatted strings might contain
|
||||
characters which aren't a normal space, e.g. U+2009 THIN SPACE in formatRange or
|
||||
U+202F NARROW NO-BREAK SPACE between time and AM/PM.
|
||||
|
||||
In order for the specs to be more portable and easier to read, as git/gitlab aren't
|
||||
great at rendering these other spaces, we replace them U+0020 SPACE
|
||||
*/
|
||||
function expectDateString(str) {
|
||||
// eslint-disable-next-line jest/valid-expect
|
||||
return expect(str.replace(/[\s\u2009]+/g, ' '));
|
||||
}
|
||||
|
||||
describe('#asDateTime', () => {
|
||||
it('exposes a working date formatter', () => {
|
||||
expectDateString(localeDateFormat.asDateTime.format(date)).toBe('Jul 9, 1983, 2:15 PM');
|
||||
expectDateString(localeDateFormat.asDateTime.format(nextYear)).toBe('Jan 10, 1984, 7:47 AM');
|
||||
});
|
||||
|
||||
it('exposes a working date range formatter', () => {
|
||||
expectDateString(localeDateFormat.asDateTime.formatRange(date, nextYear)).toBe(
|
||||
'Jul 9, 1983, 2:15 PM – Jan 10, 1984, 7:47 AM',
|
||||
);
|
||||
expectDateString(localeDateFormat.asDateTime.formatRange(date, sameMonth)).toBe(
|
||||
'Jul 9, 1983, 2:15 PM – Jul 12, 1983, 12:36 PM',
|
||||
);
|
||||
expectDateString(localeDateFormat.asDateTime.formatRange(date, sameDay)).toBe(
|
||||
'Jul 9, 1983, 2:15 – 6:27 PM',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['automatic', 0, '2:15 PM'],
|
||||
['h12 preference', 1, '2:15 PM'],
|
||||
['h24 preference', 2, '14:15'],
|
||||
])("respects user's hourCycle preference: %s", (_, timeDisplayFormat, result) => {
|
||||
window.gon.time_display_format = timeDisplayFormat;
|
||||
expectDateString(localeDateFormat.asDateTime.format(date)).toContain(result);
|
||||
expectDateString(localeDateFormat.asDateTime.formatRange(date, nextYear)).toContain(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#asDate', () => {
|
||||
it('exposes a working date formatter', () => {
|
||||
expectDateString(localeDateFormat.asDate.format(date)).toBe('Jul 9, 1983');
|
||||
expectDateString(localeDateFormat.asDate.format(nextYear)).toBe('Jan 10, 1984');
|
||||
});
|
||||
|
||||
it('exposes a working date range formatter', () => {
|
||||
expectDateString(localeDateFormat.asDate.formatRange(date, nextYear)).toBe(
|
||||
'Jul 9, 1983 – Jan 10, 1984',
|
||||
);
|
||||
expectDateString(localeDateFormat.asDate.formatRange(date, sameMonth)).toBe(
|
||||
'Jul 9 – 12, 1983',
|
||||
);
|
||||
expectDateString(localeDateFormat.asDate.formatRange(date, sameDay)).toBe('Jul 9, 1983');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#reset', () => {
|
||||
it('removes the cached formatters', () => {
|
||||
const spy = jest.spyOn(localeFns, 'createDateTimeFormat');
|
||||
|
||||
localeDateFormat.asDate.format(date);
|
||||
localeDateFormat.asDate.format(date);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
|
||||
localeDateFormat.reset();
|
||||
|
||||
localeDateFormat.asDate.format(date);
|
||||
localeDateFormat.asDate.format(date);
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(DATE_TIME_FORMATS)('formatter for %p', (format) => {
|
||||
it('is defined', () => {
|
||||
expect(localeDateFormat[format]).toBeDefined();
|
||||
expect(localeDateFormat[format].format(date)).toBeDefined();
|
||||
expect(localeDateFormat[format].formatRange(date, nextYear)).toBeDefined();
|
||||
});
|
||||
|
||||
it('getting the formatter multiple times, just calls the Intl API once', () => {
|
||||
const spy = jest.spyOn(localeFns, 'createDateTimeFormat');
|
||||
|
||||
localeDateFormat[format].format(date);
|
||||
localeDateFormat[format].format(date);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('getting the formatter memoized the correct formatter', () => {
|
||||
const spy = jest.spyOn(localeFns, 'createDateTimeFormat');
|
||||
|
||||
expect(localeDateFormat[format].format(date)).toBe(localeDateFormat[format].format(date));
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { DATE_ONLY_FORMAT } from '~/lib/utils/datetime/constants';
|
||||
import { getTimeago, localTimeAgo, timeFor, duration } from '~/lib/utils/datetime/timeago_utility';
|
||||
import { DATE_ONLY_FORMAT, localeDateFormat } from '~/lib/utils/datetime/locale_dateformat';
|
||||
|
||||
import { s__ } from '~/locale';
|
||||
import '~/commons/bootstrap';
|
||||
|
||||
|
|
@ -168,6 +169,7 @@ describe('TimeAgo utils', () => {
|
|||
${1} | ${'12-hour'} | ${'Feb 18, 2020, 10:22 PM'}
|
||||
${2} | ${'24-hour'} | ${'Feb 18, 2020, 22:22'}
|
||||
`(`'$display' renders as '$text'`, ({ timeDisplayFormat, text }) => {
|
||||
localeDateFormat.reset();
|
||||
gon.time_display_relative = false;
|
||||
gon.time_display_format = timeDisplayFormat;
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ describe('CustomEmail', () => {
|
|||
'mail_not_received_within_timeframe',
|
||||
'incorrect_from',
|
||||
'incorrect_token',
|
||||
'read_timeout',
|
||||
])('displays %s label and description', (error) => {
|
||||
createWrapper({ verificationError: error });
|
||||
const text = wrapper.text();
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ exports[`Repository table row component renders a symlink table row 1`] = `
|
|||
<gl-intersection-observer-stub>
|
||||
<timeago-tooltip-stub
|
||||
cssclass=""
|
||||
datetimeformat="DATE_WITH_TIME_FORMAT"
|
||||
datetimeformat="asDateTime"
|
||||
time="2019-01-01"
|
||||
tooltipplacement="top"
|
||||
/>
|
||||
|
|
@ -103,7 +103,7 @@ exports[`Repository table row component renders table row 1`] = `
|
|||
<gl-intersection-observer-stub>
|
||||
<timeago-tooltip-stub
|
||||
cssclass=""
|
||||
datetimeformat="DATE_WITH_TIME_FORMAT"
|
||||
datetimeformat="asDateTime"
|
||||
time="2019-01-01"
|
||||
tooltipplacement="top"
|
||||
/>
|
||||
|
|
@ -159,7 +159,7 @@ exports[`Repository table row component renders table row for path with special
|
|||
<gl-intersection-observer-stub>
|
||||
<timeago-tooltip-stub
|
||||
cssclass=""
|
||||
datetimeformat="DATE_WITH_TIME_FORMAT"
|
||||
datetimeformat="asDateTime"
|
||||
time="2019-01-01"
|
||||
tooltipplacement="top"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -156,9 +156,12 @@ describe('BaseToken', () => {
|
|||
it('uses last item in list when value is an array', () => {
|
||||
const mockGetActiveTokenValue = jest.fn();
|
||||
|
||||
const config = { ...mockConfig, multiSelect: true };
|
||||
|
||||
wrapper = createComponent({
|
||||
props: {
|
||||
value: { data: mockLabels.map((l) => l.title) },
|
||||
config,
|
||||
value: { data: mockLabels.map((l) => l.title), operator: '||' },
|
||||
suggestions: mockLabels,
|
||||
getActiveTokenValue: mockGetActiveTokenValue,
|
||||
},
|
||||
|
|
@ -409,8 +412,9 @@ describe('BaseToken', () => {
|
|||
});
|
||||
|
||||
it('emits token-selected event when groupMultiSelectTokens: true', () => {
|
||||
const config = { ...mockConfig, multiSelect: true };
|
||||
wrapper = createComponent({
|
||||
props: { suggestions: mockLabels },
|
||||
props: { suggestions: mockLabels, config, value: { operator: '||' } },
|
||||
groupMultiSelectTokens: true,
|
||||
});
|
||||
|
||||
|
|
@ -419,9 +423,10 @@ describe('BaseToken', () => {
|
|||
expect(wrapper.emitted('token-selected')).toEqual([[mockTokenValue.title]]);
|
||||
});
|
||||
|
||||
it('does not emit token-selected event when groupMultiSelectTokens: true', () => {
|
||||
it('does not emit token-selected event when groupMultiSelectTokens: false', () => {
|
||||
const config = { ...mockConfig, multiSelect: true };
|
||||
wrapper = createComponent({
|
||||
props: { suggestions: mockLabels },
|
||||
props: { suggestions: mockLabels, config, value: { operator: '||' } },
|
||||
groupMultiSelectTokens: false,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -313,11 +313,11 @@ describe('UserToken', () => {
|
|||
describe('multiSelect', () => {
|
||||
it('renders check icons in suggestions when multiSelect is true', async () => {
|
||||
wrapper = createComponent({
|
||||
value: { data: [mockUsers[0].username, mockUsers[1].username], operator: '=' },
|
||||
value: { data: [mockUsers[0].username, mockUsers[1].username], operator: '||' },
|
||||
data: {
|
||||
users: mockUsers,
|
||||
},
|
||||
config: { ...mockAuthorToken, multiSelect: true, initialUsers: mockUsers },
|
||||
config: { ...mockAuthorToken, multiSelect: true },
|
||||
active: true,
|
||||
stubs: { Portal: true },
|
||||
groupMultiSelectTokens: true,
|
||||
|
|
@ -327,18 +327,17 @@ describe('UserToken', () => {
|
|||
|
||||
const suggestions = wrapper.findAllComponents(GlFilteredSearchSuggestion);
|
||||
|
||||
expect(findIconAtSuggestion(1).exists()).toBe(false);
|
||||
expect(findIconAtSuggestion(2).props('name')).toBe('check');
|
||||
expect(findIconAtSuggestion(3).props('name')).toBe('check');
|
||||
expect(findIconAtSuggestion(0).props('name')).toBe('check');
|
||||
expect(findIconAtSuggestion(1).props('name')).toBe('check');
|
||||
expect(findIconAtSuggestion(2).exists()).toBe(false);
|
||||
|
||||
// test for left padding on unchecked items (so alignment is correct)
|
||||
expect(findIconAtSuggestion(4).exists()).toBe(false);
|
||||
expect(suggestions.at(4).find('.gl-pl-6').exists()).toBe(true);
|
||||
expect(suggestions.at(2).find('.gl-pl-6').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders multiple users when multiSelect is true', async () => {
|
||||
wrapper = createComponent({
|
||||
value: { data: [mockUsers[0].username, mockUsers[1].username], operator: '=' },
|
||||
value: { data: [mockUsers[0].username, mockUsers[1].username], operator: '||' },
|
||||
data: {
|
||||
users: mockUsers,
|
||||
},
|
||||
|
|
@ -363,7 +362,7 @@ describe('UserToken', () => {
|
|||
|
||||
it('adds new user to multi-select-values', () => {
|
||||
wrapper = createComponent({
|
||||
value: { data: [mockUsers[0].username], operator: '=' },
|
||||
value: { data: [mockUsers[0].username], operator: '||' },
|
||||
data: {
|
||||
users: mockUsers,
|
||||
},
|
||||
|
|
@ -383,7 +382,7 @@ describe('UserToken', () => {
|
|||
it('removes existing user from array', () => {
|
||||
const initialUsers = [mockUsers[0].username, mockUsers[1].username];
|
||||
wrapper = createComponent({
|
||||
value: { data: initialUsers, operator: '=' },
|
||||
value: { data: initialUsers, operator: '||' },
|
||||
data: {
|
||||
users: mockUsers,
|
||||
},
|
||||
|
|
@ -399,7 +398,7 @@ describe('UserToken', () => {
|
|||
|
||||
it('clears input field after token selected', () => {
|
||||
wrapper = createComponent({
|
||||
value: { data: [mockUsers[0].username, mockUsers[1].username], operator: '=' },
|
||||
value: { data: [mockUsers[0].username, mockUsers[1].username], operator: '||' },
|
||||
data: {
|
||||
users: mockUsers,
|
||||
},
|
||||
|
|
@ -410,7 +409,7 @@ describe('UserToken', () => {
|
|||
|
||||
findBaseToken().vm.$emit('token-selected', 'test');
|
||||
|
||||
expect(wrapper.emitted('input')).toEqual([[{ operator: '=', data: '' }]]);
|
||||
expect(wrapper.emitted('input')).toEqual([[{ operator: '||', data: '' }]]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { GlTruncate } from '@gitlab/ui';
|
|||
|
||||
import timezoneMock from 'timezone-mock';
|
||||
import { formatDate, getTimeago } from '~/lib/utils/datetime_utility';
|
||||
import { DATE_ONLY_FORMAT } from '~/lib/utils/datetime/constants';
|
||||
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
import { DATE_ONLY_FORMAT } from '~/lib/utils/datetime/locale_dateformat';
|
||||
|
||||
describe('Time ago with tooltip component', () => {
|
||||
let vm;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ RSpec.describe Gitlab::BitbucketServerImport::Importers::PullRequestsImporter, f
|
|||
|
||||
before do
|
||||
allow_next_instance_of(BitbucketServer::Client) do |client|
|
||||
allow(client).to receive(:pull_requests).and_return(
|
||||
allow(client).to receive(:pull_requests).with('key', 'slug', a_hash_including(limit: 50)).and_return(
|
||||
[
|
||||
BitbucketServer::Representation::PullRequest.new(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ RSpec.describe Gitlab::Ci::Components::InstancePath, feature_category: :pipeline
|
|||
released_at: Time.zone.now)
|
||||
end
|
||||
|
||||
it 'fetches the component content', :aggregate_failures do
|
||||
it 'returns the component content of the latest project release', :aggregate_failures do
|
||||
result = path.fetch_content!(current_user: user)
|
||||
expect(result.content).to eq('image: alpine_2')
|
||||
expect(result.path).to eq('templates/secret-detection.yml')
|
||||
|
|
@ -135,6 +135,25 @@ RSpec.describe Gitlab::Ci::Components::InstancePath, feature_category: :pipeline
|
|||
expect(path.project).to eq(project)
|
||||
expect(path.sha).to eq(latest_sha)
|
||||
end
|
||||
|
||||
context 'when the project is a catalog resource' do
|
||||
let_it_be(:resource) { create(:ci_catalog_resource, project: project) }
|
||||
|
||||
before do
|
||||
project.releases.each do |release|
|
||||
create(:ci_catalog_resource_version, catalog_resource: resource, release: release)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns the component content of the latest catalog resource version', :aggregate_failures do
|
||||
result = path.fetch_content!(current_user: user)
|
||||
expect(result.content).to eq('image: alpine_2')
|
||||
expect(result.path).to eq('templates/secret-detection.yml')
|
||||
expect(path.host).to eq(current_host)
|
||||
expect(path.project).to eq(project)
|
||||
expect(path.sha).to eq(latest_sha)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when version does not exist' do
|
||||
|
|
|
|||
|
|
@ -146,16 +146,14 @@ RSpec.describe Emails::ServiceDesk, feature_category: :service_desk do
|
|||
end
|
||||
end
|
||||
|
||||
shared_examples 'a custom email verification process result email with error' do |error_identifier, expected_text|
|
||||
context "when having #{error_identifier} error" do
|
||||
before do
|
||||
service_desk_setting.custom_email_verification.error = error_identifier
|
||||
end
|
||||
shared_examples 'a custom email verification process result email with error' do
|
||||
before do
|
||||
service_desk_setting.custom_email_verification.error = error_identifier
|
||||
end
|
||||
|
||||
it 'contains correct error message headline in text part' do
|
||||
# look for text part because we can ignore HTML tags then
|
||||
expect(subject.text_part.body).to match(expected_text)
|
||||
end
|
||||
it 'contains correct error message headline in text part' do
|
||||
# look for text part because we can ignore HTML tags then
|
||||
expect(subject.text_part.body).to match(expected_text)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -597,10 +595,35 @@ RSpec.describe Emails::ServiceDesk, feature_category: :service_desk do
|
|||
it_behaves_like 'an email sent from GitLab'
|
||||
it_behaves_like 'a custom email verification process email'
|
||||
it_behaves_like 'a custom email verification process notification email'
|
||||
it_behaves_like 'a custom email verification process result email with error', 'smtp_host_issue', 'SMTP host issue'
|
||||
it_behaves_like 'a custom email verification process result email with error', 'invalid_credentials', 'Invalid credentials'
|
||||
it_behaves_like 'a custom email verification process result email with error', 'mail_not_received_within_timeframe', 'Verification email not received within timeframe'
|
||||
it_behaves_like 'a custom email verification process result email with error', 'incorrect_from', 'Incorrect From header'
|
||||
it_behaves_like 'a custom email verification process result email with error', 'incorrect_token', 'Incorrect verification token'
|
||||
|
||||
it_behaves_like 'a custom email verification process result email with error' do
|
||||
let(:error_identifier) { 'smtp_host_issue' }
|
||||
let(:expected_text) { 'SMTP host issue' }
|
||||
end
|
||||
|
||||
it_behaves_like 'a custom email verification process result email with error' do
|
||||
let(:error_identifier) { 'invalid_credentials' }
|
||||
let(:expected_text) { 'Invalid credentials' }
|
||||
end
|
||||
|
||||
it_behaves_like 'a custom email verification process result email with error' do
|
||||
let(:error_identifier) { 'mail_not_received_within_timeframe' }
|
||||
let(:expected_text) { 'Verification email not received within timeframe' }
|
||||
end
|
||||
|
||||
it_behaves_like 'a custom email verification process result email with error' do
|
||||
let(:error_identifier) { 'incorrect_from' }
|
||||
let(:expected_text) { 'Incorrect From header' }
|
||||
end
|
||||
|
||||
it_behaves_like 'a custom email verification process result email with error' do
|
||||
let(:error_identifier) { 'incorrect_token' }
|
||||
let(:expected_text) { 'Incorrect verification token' }
|
||||
end
|
||||
|
||||
it_behaves_like 'a custom email verification process result email with error' do
|
||||
let(:error_identifier) { 'read_timeout' }
|
||||
let(:expected_text) { 'Read timeout' }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -179,4 +179,46 @@ RSpec.describe Ci::Catalog::Listing, feature_category: :pipeline_composition do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#find_resource' do
|
||||
subject { list.find_resource(id: id) }
|
||||
|
||||
context 'when the resource is published and visible to the user' do
|
||||
let_it_be(:accessible_resource) { create(:ci_catalog_resource, project: project_a, state: :published) }
|
||||
|
||||
let(:id) { accessible_resource.id }
|
||||
|
||||
it 'fetches the resource' do
|
||||
is_expected.to eq(accessible_resource)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the resource is not found' do
|
||||
let(:id) { 'not-an-id' }
|
||||
|
||||
it 'returns nil' do
|
||||
is_expected.to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the resource is not published' do
|
||||
let_it_be(:draft_resource) { create(:ci_catalog_resource, project: project_a, state: :draft) }
|
||||
|
||||
let(:id) { draft_resource.id }
|
||||
|
||||
it 'returns nil' do
|
||||
is_expected.to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context "when the current user cannot read code on the resource's project" do
|
||||
let_it_be(:inaccessible_resource) { create(:ci_catalog_resource, project: project_noaccess, state: :published) }
|
||||
|
||||
let(:id) { inaccessible_resource.id }
|
||||
|
||||
it 'returns nil' do
|
||||
is_expected.to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -87,6 +87,12 @@ RSpec.describe PgFullTextSearchable, feature_category: :global_search do
|
|||
[english, with_accent, japanese].each(&:update_search_data!)
|
||||
end
|
||||
|
||||
it 'builds a search query using `search_vector` from the search_data table' do
|
||||
sql = model_class.pg_full_text_search('test').to_sql
|
||||
|
||||
expect(sql).to include('"issue_search_data"."search_vector" @@ to_tsquery')
|
||||
end
|
||||
|
||||
it 'searches across all fields' do
|
||||
expect(model_class.pg_full_text_search('title english')).to contain_exactly(english, japanese)
|
||||
end
|
||||
|
|
@ -148,6 +154,14 @@ RSpec.describe PgFullTextSearchable, feature_category: :global_search do
|
|||
end
|
||||
end
|
||||
|
||||
describe '.pg_full_text_search_in_model' do
|
||||
it 'builds a search query using `search_vector` from the model table' do
|
||||
sql = model_class.pg_full_text_search_in_model('test').to_sql
|
||||
|
||||
expect(sql).to include('"issues"."search_vector" @@ to_tsquery')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#update_search_data!' do
|
||||
let(:model) { model_class.create!(project: project, namespace: project.project_namespace, title: 'title', description: 'description') }
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,13 @@
|
|||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Explore::CatalogController, feature_category: :pipeline_composition do
|
||||
let_it_be(:catalog_resource) { create(:ci_catalog_resource, state: :published) }
|
||||
let_it_be(:user) { create(:user) }
|
||||
|
||||
before_all do
|
||||
catalog_resource.project.add_reporter(user)
|
||||
end
|
||||
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
|
|
@ -14,7 +19,7 @@ RSpec.describe Explore::CatalogController, feature_category: :pipeline_compositi
|
|||
if action == :index
|
||||
explore_catalog_index_path
|
||||
else
|
||||
explore_catalog_path(id: 1)
|
||||
explore_catalog_path(id: catalog_resource.id)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -45,6 +50,16 @@ RSpec.describe Explore::CatalogController, feature_category: :pipeline_compositi
|
|||
|
||||
describe 'GET #show' do
|
||||
it_behaves_like 'basic get requests', :show
|
||||
|
||||
context 'when rendering a draft catalog resource' do
|
||||
it 'responds with 404' do
|
||||
catalog_resource = create(:ci_catalog_resource, state: :draft)
|
||||
|
||||
get explore_catalog_path(id: catalog_resource.id)
|
||||
|
||||
expect(response).to have_gitlab_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET #index' do
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ RSpec.describe ServiceDesk::CustomEmailVerifications::CreateService, feature_cat
|
|||
end
|
||||
end
|
||||
|
||||
shared_examples 'a verification process with ramp up error' do |error, error_identifier|
|
||||
shared_examples 'a verification process with ramp up error' do
|
||||
it 'aborts verification process', :aggregate_failures do
|
||||
allow(message).to receive(:deliver).and_raise(error)
|
||||
|
||||
|
|
@ -115,7 +115,7 @@ RSpec.describe ServiceDesk::CustomEmailVerifications::CreateService, feature_cat
|
|||
end
|
||||
|
||||
context 'when user has maintainer role in project' do
|
||||
before do
|
||||
before_all do
|
||||
project.add_maintainer(user)
|
||||
end
|
||||
|
||||
|
|
@ -151,10 +151,25 @@ RSpec.describe ServiceDesk::CustomEmailVerifications::CreateService, feature_cat
|
|||
allow(Notify).to receive(:service_desk_verification_result_email).and_return(message_delivery)
|
||||
end
|
||||
|
||||
it_behaves_like 'a verification process with ramp up error', SocketError, 'smtp_host_issue'
|
||||
it_behaves_like 'a verification process with ramp up error', OpenSSL::SSL::SSLError, 'smtp_host_issue'
|
||||
it_behaves_like 'a verification process with ramp up error',
|
||||
Net::SMTPAuthenticationError.new('Invalid username or password'), 'invalid_credentials'
|
||||
it_behaves_like 'a verification process with ramp up error' do
|
||||
let(:error) { SocketError }
|
||||
let(:error_identifier) { 'smtp_host_issue' }
|
||||
end
|
||||
|
||||
it_behaves_like 'a verification process with ramp up error' do
|
||||
let(:error) { OpenSSL::SSL::SSLError }
|
||||
let(:error_identifier) { 'smtp_host_issue' }
|
||||
end
|
||||
|
||||
it_behaves_like 'a verification process with ramp up error' do
|
||||
let(:error) { Net::SMTPAuthenticationError.new('Invalid username or password') }
|
||||
let(:error_identifier) { 'invalid_credentials' }
|
||||
end
|
||||
|
||||
it_behaves_like 'a verification process with ramp up error' do
|
||||
let(:error) { Net::ReadTimeout }
|
||||
let(:error_identifier) { 'read_timeout' }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -83,16 +83,16 @@ RSpec.shared_context 'ProjectPolicyTable context' do
|
|||
:public | :anonymous | nil | 1
|
||||
|
||||
:internal | :admin | true | 1
|
||||
:internal | :admin | false | 0
|
||||
:internal | :reporter | nil | 0
|
||||
:internal | :guest | nil | 0
|
||||
:internal | :non_member | nil | 0
|
||||
:internal | :admin | false | 1
|
||||
:internal | :reporter | nil | 1
|
||||
:internal | :guest | nil | 1
|
||||
:internal | :non_member | nil | 1
|
||||
:internal | :anonymous | nil | 0
|
||||
|
||||
:private | :admin | true | 1
|
||||
:private | :admin | false | 0
|
||||
:private | :reporter | nil | 0
|
||||
:private | :guest | nil | 0
|
||||
:private | :reporter | nil | 1
|
||||
:private | :guest | nil | 1
|
||||
:private | :non_member | nil | 0
|
||||
:private | :anonymous | nil | 0
|
||||
end
|
||||
|
|
|
|||
|
|
@ -8,35 +8,113 @@ RSpec.shared_examples 'an update storage move worker' do
|
|||
end
|
||||
|
||||
describe '#perform', :clean_gitlab_redis_shared_state do
|
||||
subject { worker.perform(container.id, 'test_second_storage', repository_storage_move_id) }
|
||||
|
||||
let(:service) { double(:update_repository_storage_service) }
|
||||
|
||||
before do
|
||||
allow(Gitlab.config.repositories.storages).to receive(:keys).and_return(%w[default test_second_storage])
|
||||
end
|
||||
|
||||
context 'without repository storage move' do
|
||||
let(:repository_storage_move_id) { nil }
|
||||
describe 'deprecated method signature' do
|
||||
# perform(container_id, new_repository_storage_key, repository_storage_move_id = nil)
|
||||
subject { worker.perform(container.id, 'test_second_storage', repository_storage_move_id) }
|
||||
|
||||
it 'calls the update repository storage service' do
|
||||
expect(service_klass).to receive(:new).and_return(service)
|
||||
expect(service).to receive(:execute)
|
||||
context 'without repository storage move' do
|
||||
let(:repository_storage_move_id) { nil }
|
||||
|
||||
expect do
|
||||
worker.perform(container.id, 'test_second_storage')
|
||||
end.to change { repository_storage_move_klass.count }.by(1)
|
||||
it 'calls the update repository storage service' do
|
||||
expect(service_klass).to receive(:new).and_return(service)
|
||||
expect(service).to receive(:execute)
|
||||
|
||||
storage_move = container.repository_storage_moves.last
|
||||
expect(storage_move).to have_attributes(
|
||||
source_storage_name: 'default',
|
||||
destination_storage_name: 'test_second_storage'
|
||||
)
|
||||
expect do
|
||||
worker.perform(container.id, 'test_second_storage')
|
||||
end.to change { repository_storage_move_klass.count }.by(1)
|
||||
|
||||
storage_move = container.repository_storage_moves.last
|
||||
expect(storage_move).to have_attributes(
|
||||
source_storage_name: 'default',
|
||||
destination_storage_name: 'test_second_storage'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with repository storage move' do
|
||||
let(:repository_storage_move_id) { repository_storage_move.id }
|
||||
|
||||
before do
|
||||
allow(service_klass).to receive(:new).and_return(service)
|
||||
end
|
||||
|
||||
it 'calls the update repository storage service' do
|
||||
expect(service).to receive(:execute)
|
||||
|
||||
expect do
|
||||
subject
|
||||
end.not_to change { repository_storage_move_klass.count }
|
||||
end
|
||||
|
||||
context 'when repository storage move raises an exception' do
|
||||
let(:exception) { RuntimeError.new('boom') }
|
||||
|
||||
it 'releases the exclusive lock' do
|
||||
expect(service).to receive(:execute).and_raise(exception)
|
||||
|
||||
allow_next_instance_of(Gitlab::ExclusiveLease) do |lease|
|
||||
expect(lease).to receive(:cancel)
|
||||
end
|
||||
|
||||
expect { subject }.to raise_error(exception)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when exclusive lease already set' do
|
||||
let(:lease_key) { [described_class.name.underscore, container.id].join(':') }
|
||||
let(:exclusive_lease) { Gitlab::ExclusiveLease.new(lease_key, uuid: uuid, timeout: 1.minute) }
|
||||
let(:uuid) { 'other_worker_jid' }
|
||||
|
||||
it 'does not call the update repository storage service' do
|
||||
expect(exclusive_lease.try_obtain).to eq(uuid)
|
||||
expect(service).not_to receive(:execute)
|
||||
|
||||
subject
|
||||
|
||||
expect(repository_storage_move.reload).to be_failed
|
||||
end
|
||||
|
||||
context 'when exclusive lease was taken by the current worker' do
|
||||
let(:uuid) { 'existing_worker_jid' }
|
||||
|
||||
before do
|
||||
allow(worker).to receive(:jid).and_return(uuid)
|
||||
end
|
||||
|
||||
it 'marks storage migration as failed' do
|
||||
expect(exclusive_lease.try_obtain).to eq(worker.jid)
|
||||
expect(service).not_to receive(:execute)
|
||||
|
||||
subject
|
||||
|
||||
expect(repository_storage_move.reload).to be_failed
|
||||
end
|
||||
end
|
||||
|
||||
context 'when feature flag "use_lock_for_update_repository_storage" is disabled' do
|
||||
before do
|
||||
stub_feature_flags(use_lock_for_update_repository_storage: false)
|
||||
end
|
||||
|
||||
it 'ignores lock and calls the update repository storage service' do
|
||||
expect(service).to receive(:execute)
|
||||
|
||||
subject
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with repository storage move' do
|
||||
let(:repository_storage_move_id) { repository_storage_move.id }
|
||||
describe 'new method signature' do
|
||||
# perform(repository_storage_move_id)
|
||||
subject { worker.perform(repository_storage_move.id) }
|
||||
|
||||
before do
|
||||
allow(service_klass).to receive(:new).and_return(service)
|
||||
|
|
@ -65,7 +143,7 @@ RSpec.shared_examples 'an update storage move worker' do
|
|||
end
|
||||
|
||||
context 'when exclusive lease already set' do
|
||||
let(:lease_key) { [described_class.name.underscore, container.id].join(':') }
|
||||
let(:lease_key) { [described_class.name.underscore, repository_storage_move.container_id].join(':') }
|
||||
let(:exclusive_lease) { Gitlab::ExclusiveLease.new(lease_key, uuid: uuid, timeout: 1.minute) }
|
||||
let(:uuid) { 'other_worker_jid' }
|
||||
|
||||
|
|
|
|||
30
tests.yml
30
tests.yml
|
|
@ -5,12 +5,11 @@ mapping:
|
|||
|
||||
# EE extension should also map to its FOSS class spec
|
||||
- source: 'ee/app/(.*/)ee/(.+)\.rb'
|
||||
test: 'spec/%s%s_spec.rb'
|
||||
|
||||
# Some EE extensions also map to its EE class spec, but this is not recommended:
|
||||
# https://docs.gitlab.com/ee/development/ee_features.html#testing-ee-features-based-on-ce-features
|
||||
- source: 'ee/app/(.*/)ee/(.+)\.rb'
|
||||
test: 'ee/spec/%s%s_spec.rb'
|
||||
test:
|
||||
- 'spec/%s%s_spec.rb'
|
||||
# Some EE extensions also map to its EE class spec, but this is not recommended:
|
||||
# https://docs.gitlab.com/ee/development/ee_features.html#testing-ee-features-based-on-ce-features
|
||||
- 'ee/spec/%s%s_spec.rb'
|
||||
|
||||
# EE/FOSS lib should map to respective spec
|
||||
- source: '(ee/)?lib/(.+)\.rb'
|
||||
|
|
@ -79,20 +78,15 @@ mapping:
|
|||
- source: '(ee/)?app/workers/.+\.rb'
|
||||
test: 'spec/workers/every_sidekiq_worker_spec.rb'
|
||||
|
||||
- source: 'lib/gitlab/usage_data_counters/known_events/.+\.yml'
|
||||
test: 'spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb'
|
||||
- source: 'lib/gitlab/usage_data_counters/known_events/.+\.yml'
|
||||
test: 'spec/lib/gitlab/usage_data_spec.rb'
|
||||
|
||||
# Mailer previews
|
||||
- source: '(ee/)?app/mailers/(ee/)?previews/.+\.rb'
|
||||
test: 'spec/mailers/previews_spec.rb'
|
||||
|
||||
## GLFM spec and config files for CE and EE should map to respective markdown snapshot specs
|
||||
- source: 'glfm_specification/.+'
|
||||
test: 'spec/requests/api/markdown_snapshot_spec.rb'
|
||||
- source: 'glfm_specification/.+'
|
||||
test: 'ee/spec/requests/api/markdown_snapshot_spec.rb'
|
||||
test:
|
||||
- 'spec/requests/api/markdown_snapshot_spec.rb'
|
||||
- 'ee/spec/requests/api/markdown_snapshot_spec.rb'
|
||||
|
||||
# Any change to metrics definition should trigger the specs in the ee/spec/config/metrics/ folder.
|
||||
#
|
||||
|
|
@ -101,14 +95,12 @@ mapping:
|
|||
# See https://gitlab.com/gitlab-org/quality/engineering-productivity/master-broken-incidents/-/issues/287#note_1192008962
|
||||
- source: 'ee/config/metrics/.*.yml'
|
||||
test: 'ee/spec/config/metrics/every_metric_definition_spec.rb'
|
||||
- source: 'ee/lib/ee/gitlab/usage_data_counters/known_events/.*.yml'
|
||||
test: 'ee/spec/config/metrics/every_metric_definition_spec.rb'
|
||||
|
||||
# See https://gitlab.com/gitlab-org/quality/engineering-productivity/team/-/issues/146
|
||||
- source: 'config/feature_categories.yml'
|
||||
test: 'spec/db/docs_spec.rb'
|
||||
- source: 'config/feature_categories.yml'
|
||||
test: 'ee/spec/lib/ee/gitlab/database/docs/docs_spec.rb'
|
||||
test:
|
||||
- 'spec/db/docs_spec.rb'
|
||||
- 'ee/spec/lib/ee/gitlab/database/docs/docs_spec.rb'
|
||||
|
||||
# See https://gitlab.com/gitlab-org/quality/engineering-productivity/master-broken-incidents/-/issues/1360
|
||||
- source: 'vendor/project_templates/.*'
|
||||
|
|
|
|||
|
|
@ -1269,10 +1269,10 @@
|
|||
stylelint-declaration-strict-value "1.9.2"
|
||||
stylelint-scss "5.1.0"
|
||||
|
||||
"@gitlab/svgs@3.69.0":
|
||||
version "3.69.0"
|
||||
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-3.69.0.tgz#bf76b8ffbe72a783807761a38abe8aaedcfe8c12"
|
||||
integrity sha512-Zu8Fcjhi3Bk26jZOptcD5F4SHWC7/KuAe00NULViCeswKdoda1k19B+9oCSbsbxY7vMoFuD20kiCJdBCpxb3HA==
|
||||
"@gitlab/svgs@3.71.0":
|
||||
version "3.71.0"
|
||||
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-3.71.0.tgz#ff4a3cf22cd12b3c861ef2065583cc49923cf5f8"
|
||||
integrity sha512-aYjC9uef5Q3CDg4Zu9fh0mce4jO2LANaEgRLutoAYRXG4ymWwRmgP8SZmZyQY0B4hcZjBfUsyVykIhVnlNcRLw==
|
||||
|
||||
"@gitlab/ui@^68.5.0":
|
||||
version "68.5.0"
|
||||
|
|
|
|||
Loading…
Reference in New Issue