Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-06-14 12:08:53 +00:00
parent 067b3d0457
commit 7a124e225e
112 changed files with 2151 additions and 380 deletions

View File

@ -84,9 +84,9 @@ linters:
RuboCop:
enabled: true
# These cops are incredibly noisy when it comes to HAML templates, so we
# ignore them.
ignored_cops:
# These cops are incredibly noisy when it comes to HAML templates, so we
# ignore them.
- Layout/BlockAlignment
- Layout/EndAlignment
- Layout/LineLength
@ -103,6 +103,7 @@ linters:
- Style/Next
- Style/TrailingWhitespace
- Style/WhileUntilModifier
- Cop/StaticTranslationDefinition
# These cops should eventually get enabled
- Cop/LineBreakAfterGuardClauses

View File

@ -0,0 +1,37 @@
---
Cop/StaticTranslationDefinition:
Exclude:
- 'app/models/application_setting.rb'
- 'app/models/diff_viewer/image.rb'
- 'app/models/diff_viewer/rich.rb'
- 'app/models/diff_viewer/simple.rb'
- 'app/models/group_group_link.rb'
- 'app/models/integrations/bamboo.rb'
- 'app/models/integrations/buildkite.rb'
- 'app/models/integrations/drone_ci.rb'
- 'app/models/integrations/jenkins.rb'
- 'app/models/integrations/jira.rb'
- 'app/models/integrations/mock_ci.rb'
- 'app/models/integrations/teamcity.rb'
- 'app/models/jira_import_state.rb'
- 'app/models/member.rb'
- 'app/models/project.rb'
- 'app/models/project_group_link.rb'
- 'app/models/user.rb'
- 'app/models/users/banned_user.rb'
- 'ee/app/models/allowed_email_domain.rb'
- 'ee/app/models/dast/site_profile_secret_variable.rb'
- 'ee/app/models/group_merge_request_approval_setting.rb'
- 'ee/app/models/incident_management/escalation_policy.rb'
- 'ee/app/models/incident_management/escalation_rule.rb'
- 'ee/app/models/vulnerabilities/read.rb'
- 'ee/lib/ee/gitlab/quick_actions/epic_actions.rb'
- 'ee/lib/ee/gitlab/quick_actions/issue_actions.rb'
- 'ee/lib/ee/gitlab/quick_actions/issue_and_merge_request_actions.rb'
- 'ee/lib/ee/gitlab/quick_actions/merge_request_actions.rb'
- 'lib/gitlab/quick_actions/commit_actions.rb'
- 'lib/gitlab/quick_actions/issuable_actions.rb'
- 'lib/gitlab/quick_actions/issue_actions.rb'
- 'lib/gitlab/quick_actions/issue_and_merge_request_actions.rb'
- 'lib/gitlab/quick_actions/merge_request_actions.rb'
- 'lib/gitlab/quick_actions/relate_actions.rb'

View File

@ -1101,7 +1101,6 @@ Gitlab/NamespacedClass:
- 'ee/lib/gitlab/items_collection.rb'
- 'ee/lib/gitlab/manual_banner.rb'
- 'ee/lib/gitlab/manual_quarterly_co_term_banner.rb'
- 'ee/lib/gitlab/manual_renewal_banner.rb'
- 'ee/lib/gitlab/pagination_delegate.rb'
- 'ee/lib/gitlab/path_locks_finder.rb'
- 'ee/lib/gitlab/proxy.rb'

View File

@ -1052,7 +1052,6 @@ RSpec/ContextWording:
- 'ee/spec/services/vulnerability_exports/export_service_spec.rb'
- 'ee/spec/services/vulnerability_external_issue_links/create_service_spec.rb'
- 'ee/spec/support/features/manual_quarterly_co_term_banner_examples.rb'
- 'ee/spec/support/features/manual_renewal_banner_examples.rb'
- 'ee/spec/support/protected_tags/access_control_shared_examples.rb'
- 'ee/spec/support/shared_contexts/audit_event_not_licensed_shared_context.rb'
- 'ee/spec/support/shared_contexts/audit_event_queue_shared_context.rb'

View File

@ -263,7 +263,6 @@ Style/FormatString:
- 'ee/lib/gitlab/expiring_subscription_message.rb'
- 'ee/lib/gitlab/geo.rb'
- 'ee/lib/gitlab/manual_quarterly_co_term_banner.rb'
- 'ee/lib/gitlab/manual_renewal_banner.rb'
- 'ee/lib/gitlab/vulnerabilities/container_scanning_vulnerability.rb'
- 'ee/lib/tasks/gitlab/elastic.rake'
- 'ee/spec/controllers/admin/licenses_controller_spec.rb'

View File

@ -1 +1 @@
1.58.0
1.59.0

View File

@ -479,7 +479,7 @@ gem 'sys-filesystem', '~> 1.4.3'
gem 'net-ntp'
# SSH keys support
gem 'ssh_data', '~> 1.2'
gem 'ssh_data', '~> 1.3'
# Spamcheck GRPC protocol definitions
gem 'spamcheck', '~> 0.1.0'
@ -547,3 +547,5 @@ gem 'ipaddress', '~> 0.8.3'
gem 'parslet', '~> 1.8'
gem 'ipynbdiff', '0.4.7'
gem 'ed25519', '~> 1.3.0'

View File

@ -313,6 +313,7 @@ GEM
e2mmap (0.1.0)
ecma-re-validator (0.3.0)
regexp_parser (~> 2.0)
ed25519 (1.3.0)
elasticsearch (7.13.3)
elasticsearch-api (= 7.13.3)
elasticsearch-transport (= 7.13.3)
@ -1285,7 +1286,7 @@ GEM
activesupport (>= 5.2)
sprockets (>= 3.0.0)
sqlite3 (1.4.2)
ssh_data (1.2.0)
ssh_data (1.3.0)
ssrf_filter (1.0.7)
stackprof (0.2.15)
state_machines (0.5.0)
@ -1501,6 +1502,7 @@ DEPENDENCIES
discordrb-webhooks (~> 3.4)
doorkeeper (~> 5.5.0.rc2)
doorkeeper-openid_connect (~> 1.7.5)
ed25519 (~> 1.3.0)
elasticsearch-api (= 7.13.3)
elasticsearch-model (~> 7.2)
elasticsearch-rails (~> 7.2)
@ -1704,7 +1706,7 @@ DEPENDENCIES
spring-commands-rspec (~> 1.0.4)
sprite-factory (~> 1.7)
sprockets (~> 3.7.0)
ssh_data (~> 1.2)
ssh_data (~> 1.3)
stackprof (~> 0.2.15)
state_machines-activerecord (~> 0.8.0)
sys-filesystem (~> 1.4.3)

View File

@ -1,5 +1,6 @@
<script>
import { mapActions, mapGetters } from 'vuex';
import { mapGetters } from 'vuex';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import BoardContent from '~/boards/components/board_content.vue';
import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue';
import BoardTopBar from '~/boards/components/board_top_bar.vue';
@ -14,11 +15,11 @@ export default {
computed: {
...mapGetters(['isSidebarOpen']),
},
mounted() {
this.performSearch();
created() {
window.addEventListener('popstate', refreshCurrentPage);
},
methods: {
...mapActions(['performSearch']),
destroyed() {
window.removeEventListener('popstate', refreshCurrentPage);
},
};
</script>

View File

@ -14,6 +14,8 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import BoardForm from 'ee_else_ce/boards/components/board_form.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isMetaKey } from '~/lib/utils/common_utils';
import { updateHistory } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import eventHub from '../eventhub';
@ -21,6 +23,7 @@ import groupBoardsQuery from '../graphql/group_boards.query.graphql';
import projectBoardsQuery from '../graphql/project_boards.query.graphql';
import groupRecentBoardsQuery from '../graphql/group_recent_boards.query.graphql';
import projectRecentBoardsQuery from '../graphql/project_recent_boards.query.graphql';
import { fullBoardId } from '../boards_util';
const MIN_BOARDS_TO_VIEW_RECENT = 10;
@ -112,6 +115,9 @@ export default {
this.scrollFadeInitialized = false;
this.$nextTick(this.setScrollFade);
},
board(newBoard) {
document.title = newBoard.name;
},
},
created() {
eventHub.$on('showBoardModal', this.showPage);
@ -120,7 +126,7 @@ export default {
eventHub.$off('showBoardModal', this.showPage);
},
methods: {
...mapActions(['setError', 'setBoardConfig']),
...mapActions(['setError', 'fetchBoard', 'unsetActiveId']),
showPage(page) {
this.currentPage = page;
},
@ -196,6 +202,22 @@ export default {
this.hasScrollFade = this.isScrolledUp();
},
fetchCurrentBoard(boardId) {
this.fetchBoard({
fullPath: this.fullPath,
fullBoardId: fullBoardId(boardId),
boardType: this.boardType,
});
},
async switchBoard(boardId, e) {
if (isMetaKey(e)) {
window.open(`${this.boardBaseUrl}/${boardId}`, '_blank');
} else {
this.unsetActiveId();
this.fetchCurrentBoard(boardId);
updateHistory({ url: `${this.boardBaseUrl}/${boardId}` });
}
},
},
i18n: {
errorFetchingBoard: s__('Board|An error occurred while fetching the board, please try again.'),
@ -242,8 +264,8 @@ export default {
<gl-dropdown-item
v-for="recentBoard in recentBoards"
:key="`recent-${recentBoard.id}`"
:href="`${boardBaseUrl}/${recentBoard.id}`"
data-testid="dropdown-item"
@click.prevent="switchBoard(recentBoard.id, $event)"
>
{{ recentBoard.name }}
</gl-dropdown-item>
@ -258,8 +280,8 @@ export default {
<gl-dropdown-item
v-for="otherBoard in filteredBoards"
:key="otherBoard.id"
:href="`${boardBaseUrl}/${otherBoard.id}`"
data-testid="dropdown-item"
@click.prevent="switchBoard(otherBoard.id, $event)"
>
{{ otherBoard.name }}
</gl-dropdown-item>

View File

@ -68,8 +68,7 @@ export default {
commit(types.RECEIVE_BOARD_FAILURE);
} else {
const board = data.workspace?.board;
commit(types.RECEIVE_BOARD_SUCCESS, board);
dispatch('setBoardConfig', board);
dispatch('setBoard', board);
}
})
.catch(() => commit(types.RECEIVE_BOARD_FAILURE));
@ -420,9 +419,6 @@ export default {
fetchItemsForList: ({ state, commit }, { listId, fetchNext = false }) => {
if (!listId) return null;
if (!fetchNext) {
commit(types.RESET_ITEMS_FOR_LIST, listId);
}
commit(types.REQUEST_ITEMS_FOR_LIST, { listId, fetchNext });
const { fullPath, fullBoardId, boardType, filterParams } = state;
@ -444,6 +440,7 @@ export default {
isSingleRequest: true,
},
variables,
...(!fetchNext ? { fetchPolicy: fetchPolicies.NO_CACHE } : {}),
})
.then(({ data }) => {
const { lists } = data[boardType].board;

View File

@ -18,7 +18,6 @@ export const MOVE_LISTS = 'MOVE_LISTS';
export const TOGGLE_LIST_COLLAPSED = 'TOGGLE_LIST_COLLAPSED';
export const REMOVE_LIST = 'REMOVE_LIST';
export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE';
export const RESET_ITEMS_FOR_LIST = 'RESET_ITEMS_FOR_LIST';
export const REQUEST_ITEMS_FOR_LIST = 'REQUEST_ITEMS_FOR_LIST';
export const RECEIVE_ITEMS_FOR_LIST_FAILURE = 'RECEIVE_ITEMS_FOR_LIST_FAILURE';
export const RECEIVE_ITEMS_FOR_LIST_SUCCESS = 'RECEIVE_ITEMS_FOR_LIST_SUCCESS';

View File

@ -145,11 +145,6 @@ export default {
state.boardLists = listsBackup;
},
[mutationTypes.RESET_ITEMS_FOR_LIST]: (state, listId) => {
Vue.set(state, 'backupItemsList', state.boardItemsByListId[listId]);
Vue.set(state.boardItemsByListId, listId, []);
},
[mutationTypes.REQUEST_ITEMS_FOR_LIST]: (state, { listId, fetchNext }) => {
Vue.set(state.listsFlags, listId, { [fetchNext ? 'isLoadingMore' : 'isLoading']: true });
},
@ -185,7 +180,6 @@ export default {
'Boards|An error occurred while fetching the board issues. Please reload the page.',
);
Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false });
Vue.set(state.boardItemsByListId, listId, state.backupItemsList);
},
[mutationTypes.RESET_ISSUES]: (state) => {

View File

@ -13,7 +13,6 @@ export default () => ({
boardLists: {},
listsFlags: {},
boardItemsByListId: {},
backupItemsList: [],
isSettingAssignees: false,
pageInfoByListId: {},
boardItems: {},

View File

@ -19,6 +19,7 @@ const PRODUCT_INFO = {
variant: 'SaaS',
},
};
const EMPTY_NAMESPACE_ID_VALUE = 'not available';
const generateProductInfo = (sku, quantity) => {
const product = PRODUCT_INFO[sku];
@ -200,6 +201,10 @@ export const trackCheckout = (selectedPlan, quantity) => {
pushEnhancedEcommerceEvent('EECCheckout', eventData);
};
export const getNamespaceId = () => {
return window.gl.snowplowStandardContext?.data?.namespace_id || EMPTY_NAMESPACE_ID_VALUE;
};
export const trackTransaction = (transactionDetails) => {
if (!isSupported()) {
return;
@ -208,6 +213,7 @@ export const trackTransaction = (transactionDetails) => {
const transactionId = uuidv4();
const { paymentOption, revenue, tax, selectedPlan, quantity } = transactionDetails;
const product = generateProductInfo(selectedPlan, quantity);
const namespaceId = getNamespaceId();
if (Object.keys(product).length === 0) {
return;
@ -224,7 +230,7 @@ export const trackTransaction = (transactionDetails) => {
revenue: revenue.toString(),
tax: tax.toString(),
},
products: [product],
products: [{ ...product, dimension36: namespaceId }],
},
},
};

View File

@ -16,7 +16,7 @@ export default {
if (isEndingStatus(this.session.status)) {
return {
action: () => this.restartSession(),
variant: 'info',
variant: 'confirm',
category: 'primary',
text: __('Restart Terminal'),
};

View File

@ -11,6 +11,7 @@ import {
GlModal,
GlModalDirective,
} from '@gitlab/ui';
import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { I18N_PAGERDUTY_SETTINGS_FORM, CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK } from '../constants';
@ -42,6 +43,21 @@ export default {
};
},
i18n: I18N_PAGERDUTY_SETTINGS_FORM,
modal: {
id: 'resetWebhookModal',
actionPrimary: {
text: I18N_PAGERDUTY_SETTINGS_FORM.webhookUrl.resetWebhookUrl,
attributes: {
variant: 'danger',
},
},
actionCancel: {
text: __('Cancel'),
attributes: {
variant: 'default',
},
},
},
CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK,
computed: {
formData() {
@ -152,11 +168,11 @@ export default {
{{ $options.i18n.webhookUrl.resetWebhookUrl }}
</gl-button>
<gl-modal
modal-id="resetWebhookModal"
:modal-id="$options.modal.id"
:title="$options.i18n.webhookUrl.resetWebhookUrl"
:ok-title="$options.i18n.webhookUrl.resetWebhookUrl"
ok-variant="danger"
@ok="resetWebhookUrl"
:action-primary="$options.modal.actionPrimary"
:action-cancel="$options.modal.actionCancel"
@primary="resetWebhookUrl"
>
{{ $options.i18n.webhookUrl.restKeyInfo }}
</gl-modal>

View File

@ -57,7 +57,7 @@ export function initShow() {
const { issueType, ...issuableData } = parseIssuableData(el);
if (issueType === IssueType.Incident) {
initIncidentApp(issuableData);
initIncidentApp({ ...issuableData, issuableId: el.dataset.issuableId });
initHeaderActions(store, IssueType.Incident);
initRelatedIssues(IssueType.Incident);
} else {

View File

@ -0,0 +1,21 @@
query GetTimelineEvents($fullPath: ID!, $incidentId: IssueID!) {
project(fullPath: $fullPath) {
id
incidentManagementTimelineEvents(incidentId: $incidentId) {
nodes {
id
author {
id
name
username
}
note
noteHtml
action
occurredAt
createdAt
updatedAt
}
}
}
}

View File

@ -0,0 +1,73 @@
<script>
import { formatDate } from '~/lib/utils/datetime_utility';
import IncidentTimelineEventListItem from './timeline_events_list_item.vue';
export default {
name: 'IncidentTimelineEventList',
components: {
IncidentTimelineEventListItem,
},
props: {
timelineEventLoading: {
type: Boolean,
required: false,
default: true,
},
timelineEvents: {
type: Array,
required: true,
default: () => [],
},
},
computed: {
dateGroupedEvents() {
const groupedEvents = new Map();
this.timelineEvents.forEach((event) => {
const date = formatDate(event.occurredAt, 'isoDate', true);
if (groupedEvents.has(date)) {
groupedEvents.get(date).push(event);
} else {
groupedEvents.set(date, [event]);
}
});
return groupedEvents;
},
},
methods: {
isLastItem(groups, groupIndex, events, eventIndex) {
if (groupIndex < groups.size - 1) {
return false;
}
return eventIndex === events.length - 1;
},
},
};
</script>
<template>
<div class="issuable-discussion incident-timeline-events">
<div
v-for="([eventDate, events], groupIndex) in dateGroupedEvents"
:key="eventDate"
data-testid="timeline-group"
>
<div class="gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid">
<strong class="gl-font-size-h2" data-testid="event-date">{{ eventDate }}</strong>
</div>
<ul class="notes main-notes-list gl-pl-n3">
<incident-timeline-event-list-item
v-for="(event, eventIndex) in events"
:key="event.id"
:action="event.action"
:occurred-at="event.occurredAt"
:note-html="event.noteHtml"
:is-last-item="isLastItem(dateGroupedEvents, groupIndex, events, eventIndex)"
data-testid="timeline-event"
/>
</ul>
</div>
</div>
</template>

View File

@ -0,0 +1,71 @@
<script>
import { GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui';
import { formatDate } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
import { getEventIcon } from './utils';
export default {
name: 'IncidentTimelineEventListItem',
i18n: {
timeUTC: __('%{time} UTC'),
},
components: {
GlIcon,
GlSprintf,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
},
props: {
isLastItem: {
type: Boolean,
required: true,
},
occurredAt: {
type: String,
required: true,
},
action: {
type: String,
required: true,
},
noteHtml: {
type: String,
required: true,
},
},
computed: {
time() {
return formatDate(this.occurredAt, 'HH:MM', true);
},
},
methods: {
getEventIcon,
},
};
</script>
<template>
<li
class="timeline-entry timeline-entry-vertical-line note system-note note-wrapper gl-my-2! gl-pr-0!"
>
<div class="gl-display-flex gl-align-items-center">
<div
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-n2 gl-mr-3 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1"
>
<gl-icon :name="getEventIcon(action)" class="note-icon" />
</div>
<div
class="timeline-event-note gl-w-full"
:class="{ 'gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid': !isLastItem }"
data-testid="event-text-container"
>
<strong class="gl-font-lg" data-testid="event-time">
<gl-sprintf :message="$options.i18n.timeUTC">
<template #time>{{ time }}</template>
</gl-sprintf>
</strong>
<div v-safe-html="noteHtml"></div>
</div>
</div>
</li>
</template>

View File

@ -1,21 +1,70 @@
<script>
import { GlTab, GlButton } from '@gitlab/ui';
import { GlEmptyState, GlLoadingIcon, GlTab } from '@gitlab/ui';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_ISSUE } from '~/graphql_shared/constants';
import { fetchPolicies } from '~/lib/graphql';
import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql';
import { displayAndLogError } from './utils';
import IncidentTimelineEventsList from './timeline_events_list.vue';
export default {
components: {
GlEmptyState,
GlLoadingIcon,
GlTab,
GlButton,
IncidentTimelineEventsList,
},
inject: ['fullPath', 'issuableId'],
data() {
return {
timelineEvents: [],
};
},
apollo: {
timelineEvents: {
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
query: getTimelineEvents,
variables() {
return {
fullPath: this.fullPath,
incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
};
},
update(data) {
return data.project.incidentManagementTimelineEvents.nodes;
},
error(error) {
displayAndLogError(error);
},
},
},
computed: {
timelineEventLoading() {
return this.$apollo.queries.timelineEvents.loading;
},
hasTimelineEvents() {
return Boolean(this.timelineEvents.length);
},
showEmptyState() {
return !this.timelineEventLoading && !this.hasTimelineEvents;
},
},
};
</script>
<template>
<gl-tab :title="s__('Incident|Timeline')">
<div class="gl-my-4">
<p>{{ s__('Incident|No timeline items have been added yet.') }}</p>
</div>
<gl-button class="gl-my-3">
{{ s__('Incident|Add new timeline event') }}
</gl-button>
<gl-loading-icon v-if="timelineEventLoading" size="lg" color="dark" class="gl-mt-5" />
<gl-empty-state
v-else-if="showEmptyState"
:compact="true"
:description="s__('Incident|No timeline items have been added yet.')"
/>
<incident-timeline-events-list
v-if="hasTimelineEvents"
:timeline-event-loading="timelineEventLoading"
:timeline-events="timelineEvents"
/>
</gl-tab>
</template>

View File

@ -0,0 +1,18 @@
import { createAlert } from '~/flash';
import { s__ } from '~/locale';
export const displayAndLogError = (error) =>
createAlert({
message: s__('Incident|Something went wrong while fetching incident timeline events.'),
captureError: true,
error,
});
const EVENT_ICONS = {
comment: 'comment',
default: 'comment',
};
export const getEventIcon = (actionName) => {
return EVENT_ICONS[actionName] ?? EVENT_ICONS.default;
};

View File

@ -33,6 +33,7 @@ export function initIncidentApp(issueData = {}) {
canCreateIncident,
canUpdate,
iid,
issuableId,
projectNamespace,
projectPath,
projectId,
@ -53,6 +54,7 @@ export function initIncidentApp(issueData = {}) {
canUpdate,
fullPath,
iid,
issuableId,
projectId,
slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable),

View File

@ -47,7 +47,7 @@ export default {
<gl-button category="secondary" @click="cancelHandler">{{ s__('Metrics|Cancel') }}</gl-button>
<gl-button
category="secondary"
variant="info"
variant="confirm"
target="_blank"
:href="addDashboardDocumentationPath"
data-testid="create-dashboard-modal-docs-button"

View File

@ -162,7 +162,7 @@ export default {
ref="viewDocumentationBtn"
category="secondary"
class="gl-xs-w-full gl-xs-mb-3"
variant="info"
variant="confirm"
target="_blank"
:href="addDashboardDocumentationPath"
>

View File

@ -19,7 +19,7 @@ export default class PayloadDownloader {
}
requestPayload() {
this.spinner.classList.add('d-inline-flex');
this.spinner.classList.add('gl-display-inline');
return axios
.get(this.trigger.dataset.endpoint, {
@ -34,7 +34,7 @@ export default class PayloadDownloader {
});
})
.finally(() => {
this.spinner.classList.remove('d-inline-flex');
this.spinner.classList.remove('gl-display-inline');
});
}

View File

@ -29,7 +29,7 @@ export default class PayloadPreviewer {
requestPayload() {
if (this.isInserted) return this.showPayload();
this.spinner.classList.add('gl-display-inline-flex');
this.spinner.classList.add('gl-display-inline');
const container = this.getContainer();
@ -38,11 +38,11 @@ export default class PayloadPreviewer {
responseType: 'text',
})
.then(({ data }) => {
this.spinner.classList.remove('gl-display-inline-flex');
this.spinner.classList.remove('gl-display-inline');
this.insertPayload(data);
})
.catch(() => {
this.spinner.classList.remove('gl-display-inline-flex');
this.spinner.classList.remove('gl-display-inline');
createFlash({
message: __('Error fetching payload data.'),
});

View File

@ -4,7 +4,8 @@ import { localTimeAgo } from '~/lib/utils/datetime_utility';
import initCompareAutocomplete from './compare_autocomplete';
import initTargetProjectDropdown from './target_project_dropdown';
const updateCommitList = (url, $loadingIndicator, $commitList, params) => {
const updateCommitList = (url, $emptyState, $loadingIndicator, $commitList, params) => {
$emptyState.hide();
$loadingIndicator.show();
$commitList.empty();
@ -16,6 +17,10 @@ const updateCommitList = (url, $loadingIndicator, $commitList, params) => {
$loadingIndicator.hide();
$commitList.html(data);
localTimeAgo($commitList.get(0).querySelectorAll('.js-timeago'));
if (!data) {
$emptyState.show();
}
});
};
@ -26,6 +31,7 @@ export default (mrNewCompareNode) => {
const updateSourceBranchCommitList = () =>
updateCommitList(
sourceBranchUrl,
$(mrNewCompareNode).find('.js-source-commit-empty'),
$(mrNewCompareNode).find('.js-source-loading'),
$(mrNewCompareNode).find('.mr_source_commit'),
{
@ -35,6 +41,7 @@ export default (mrNewCompareNode) => {
const updateTargetBranchCommitList = () =>
updateCommitList(
targetBranchUrl,
$(mrNewCompareNode).find('.js-target-commit-empty'),
$(mrNewCompareNode).find('.js-target-loading'),
$(mrNewCompareNode).find('.mr_target_commit'),
{

View File

@ -12,6 +12,7 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = (
$('.js-compare-dropdown').each(function () {
const $dropdown = $(this);
const selected = $dropdown.data('selected');
const defaultText = $dropdown.data('defaultText').trim();
const $dropdownContainer = $dropdown.closest('.dropdown');
const $fieldInput = $(`input[name="${$dropdown.data('fieldName')}"]`, $dropdownContainer);
const $filterInput = $('input[type="search"]', $dropdownContainer);
@ -63,7 +64,11 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = (
return $el.attr('data-ref');
},
toggleLabel(obj, $el) {
return $el.text().trim();
if ($el.hasClass('is-active')) {
return $el.text().trim();
}
return defaultText;
},
clicked: () => clickHandler($dropdown),
});

View File

@ -1,14 +1,16 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import { GlBadge, GlTab, GlTooltipDirective } from '@gitlab/ui';
import { createAlert, VARIANT_SUCCESS } from '~/flash';
import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { redirectTo } from '~/lib/utils/url_utility';
import { formatJobCount } from '../utils';
import RunnerDeleteButton from '../components/runner_delete_button.vue';
import RunnerEditButton from '../components/runner_edit_button.vue';
import RunnerPauseButton from '../components/runner_pause_button.vue';
import RunnerHeader from '../components/runner_header.vue';
import RunnerDetails from '../components/runner_details.vue';
import RunnerJobs from '../components/runner_jobs.vue';
import { I18N_FETCH_ERROR } from '../constants';
import runnerQuery from '../graphql/show/runner.query.graphql';
import { captureException } from '../sentry_utils';
@ -17,11 +19,14 @@ import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_lo
export default {
name: 'AdminRunnerShowApp',
components: {
GlBadge,
GlTab,
RunnerDeleteButton,
RunnerEditButton,
RunnerPauseButton,
RunnerHeader,
RunnerDetails,
RunnerJobs,
},
directives: {
GlTooltip: GlTooltipDirective,
@ -63,6 +68,9 @@ export default {
canDelete() {
return this.runner.userPermissions?.deleteRunner;
},
jobCount() {
return formatJobCount(this.runner?.jobCount);
},
},
errorCaptured(error) {
this.reportToSentry(error);
@ -88,6 +96,24 @@ export default {
</template>
</runner-header>
<runner-details :runner="runner" />
<runner-details :runner="runner">
<template #jobs-tab>
<gl-tab>
<template #title>
{{ s__('Runners|Jobs') }}
<gl-badge
v-if="jobCount"
data-testid="job-count-badge"
class="gl-tab-counter-badge"
size="sm"
>
{{ jobCount }}
</gl-badge>
</template>
<runner-jobs v-if="runner" :runner="runner" />
</gl-tab>
</template>
</runner-details>
</div>
</template>

View File

@ -1,19 +1,16 @@
<script>
import { GlBadge, GlTabs, GlTab, GlIntersperse } from '@gitlab/ui';
import { GlTabs, GlTab, GlIntersperse } from '@gitlab/ui';
import { s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
import { ACCESS_LEVEL_REF_PROTECTED, GROUP_TYPE, PROJECT_TYPE } from '../constants';
import { formatJobCount } from '../utils';
import RunnerDetail from './runner_detail.vue';
import RunnerGroups from './runner_groups.vue';
import RunnerProjects from './runner_projects.vue';
import RunnerJobs from './runner_jobs.vue';
import RunnerTags from './runner_tags.vue';
export default {
components: {
GlBadge,
GlTabs,
GlTab,
GlIntersperse,
@ -22,7 +19,6 @@ export default {
import('ee_component/runner/components/runner_maintenance_note_detail.vue'),
RunnerGroups,
RunnerProjects,
RunnerJobs,
RunnerTags,
TimeAgo,
},
@ -59,9 +55,6 @@ export default {
isProjectRunner() {
return this.runner?.runnerType === PROJECT_TYPE;
},
jobCount() {
return formatJobCount(this.runner?.jobCount);
},
},
ACCESS_LEVEL_REF_PROTECTED,
};
@ -120,15 +113,6 @@ export default {
<runner-projects v-if="isProjectRunner" :runner="runner" />
</template>
</gl-tab>
<gl-tab>
<template #title>
{{ s__('Runners|Jobs') }}
<gl-badge v-if="jobCount" data-testid="job-count-badge" class="gl-ml-1" size="sm">
{{ jobCount }}
</gl-badge>
</template>
<runner-jobs v-if="runner" :runner="runner" />
</gl-tab>
<slot name="jobs-tab"></slot>
</gl-tabs>
</template>

View File

@ -175,10 +175,6 @@
@include btn-outline($white, $red-500, $red-500, $red-100, $red-700, $red-500, $red-200, $red-600, $red-800);
}
&.btn-warning {
@include btn-outline($white, $orange-500, $orange-500, $orange-50, $orange-600, $orange-600, $orange-100, $orange-700, $orange-700);
}
&.btn-primary,
&.btn-info {
@include btn-outline($white, $blue-500, $blue-500, $blue-100, $blue-700, $blue-500, $blue-200, $blue-600, $blue-800);
@ -190,10 +186,6 @@
@include btn-blue;
}
&.btn-warning {
@include btn-orange;
}
&.btn-danger {
@include btn-red;
}

View File

@ -273,6 +273,18 @@
@include scrolling-links();
}
.fade-left::after,
.fade-right::after {
content: '';
pointer-events: none;
z-index: -1;
display: block;
width: 16px;
height: 100%;
position: absolute;
top: 0;
}
.fade-right {
@include fade(left, $gray-light);
right: -5px;
@ -280,6 +292,11 @@
svg {
right: -7px;
}
&::after {
right: 0;
background: linear-gradient(270deg, $white, transparent);
}
}
.fade-left {
@ -290,6 +307,11 @@
svg {
left: -7px;
}
&::after {
left: 0;
background: linear-gradient(90deg, $white, transparent);
}
}
}
@ -316,7 +338,6 @@
.fade-right,
.fade-left {
bottom: $gl-padding;
top: auto;
}

View File

@ -428,7 +428,6 @@ $gl-padding-12: 12px;
$gl-padding: 16px;
$gl-padding-24: 24px;
$gl-padding-32: 32px;
$gl-padding-50: 50px;
$gl-input-padding: 10px;
$gl-vert-padding: 6px;
$gl-padding-top: 10px;

View File

@ -315,7 +315,7 @@ $tabs-holder-z-index: 250;
}
.mr-fast-forward-message {
padding-left: $gl-padding-50;
padding-left: $gl-spacing-scale-9;
padding-bottom: $gl-padding;
}

View File

@ -40,8 +40,8 @@
}
.save-group-loader {
margin-top: $gl-padding-50;
margin-bottom: $gl-padding-50;
margin-top: $gl-spacing-scale-9;
margin-bottom: $gl-spacing-scale-9;
color: $gray-700;
}

View File

@ -937,3 +937,58 @@
margin-right: -7px;
z-index: 1;
}
.issuable-discussion.incident-timeline-events {
.main-notes-list::before {
content: none;
}
.timeline-event-note {
p {
margin-bottom: 0;
}
}
}
/**
* We have a very specific design proposal where we cannot
* use `vertical-line` mixin as it is and have to use
* custom styles, see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81284#note_904867444
*/
.timeline-entry-vertical-line {
&::before,
&::after {
content: '';
border-left: 2px solid $gray-50;
position: absolute;
left: 39px;
height: 80%;
}
&:first-child::before,
&:last-child::after {
content: none;
}
&:first-child {
&::after {
top: 50%;
}
}
&:last-child {
&::before {
bottom: 50%;
}
}
&:not(:first-child):not(:last-child) {
&::before {
top: -10%;
}
&::after {
bottom: -10%;
}
}
}

View File

@ -3,6 +3,7 @@
*
*/
$tabs-holder-z-index: 250;
$comparison-empty-state-height: 62px;
.space-children {
@include clearfix;
@ -70,6 +71,10 @@ $tabs-holder-z-index: 250;
}
}
.compare-commit-empty {
min-height: $comparison-empty-state-height;
}
.commits-empty {
text-align: center;

View File

@ -209,7 +209,6 @@ body.gl-dark {
&.btn-info,
&.btn-success,
&.btn-danger,
&.btn-warning,
&.btn-confirm {
&-tertiary {
mix-blend-mode: screen;

View File

@ -28,6 +28,7 @@ class Key < ApplicationRecord
validate :key_meets_restrictions
validate :expiration, on: :create
validate :banned_key, if: :should_check_for_banned_key?
delegate :name, :email, to: :user, prefix: true
@ -142,6 +143,27 @@ class Key < ApplicationRecord
end
end
def should_check_for_banned_key?
return false unless user
key_changed? && Feature.enabled?(:ssh_banned_key, user)
end
def banned_key
return unless public_key.banned?
help_page_url = Rails.application.routes.url_helpers.help_page_url(
'security/ssh_keys_restrictions',
anchor: 'block-banned-or-compromised-keys'
)
errors.add(
:key,
_('cannot be used because it belongs to a compromised private key. Stop using this key and generate a new one.'),
help_page_url: help_page_url
)
end
def forbidden_key_type_message
allowed_types = Gitlab::CurrentSettings.allowed_key_types.map(&:upcase)

View File

@ -2585,16 +2585,7 @@ class Project < ApplicationRecord
end
def access_request_approvers_to_be_notified
# For a personal project:
# The creator is added as a member with `Owner` access level, starting from GitLab 14.8
# The creator was added as a member with `Maintainer` access level, before GitLab 14.8
# So, to make sure access requests for all personal projects work as expected,
# we need to filter members with the scope `owners_and_maintainers`.
access_request_approvers = if personal?
members.owners_and_maintainers
else
members.maintainers
end
access_request_approvers = members.owners_and_maintainers
access_request_approvers.connected_to_user.order_recent_sign_in.limit(Member::ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT)
end

View File

@ -6,6 +6,10 @@ class MetricImageUploader < GitlabUploader # rubocop:disable Gitlab/NamespacedCl
prepend ObjectStorage::Extension::RecordsUploads
include UploaderHelper
def self.workhorse_local_upload_path
File.join(options.storage_path, 'uploads', TMP_UPLOAD_PATH)
end
private
def dynamic_segment

View File

@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-account-settings'), html: { class: 'fieldset-form', id: 'account-settings' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group

View File

@ -1,6 +1,6 @@
.settings-content
= gitlab_ui_form_for @application_setting, url: ci_cd_admin_application_settings_path(anchor: 'js-ci-cd-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true )
%fieldset
.form-group
@ -72,7 +72,7 @@
- @plans.each_with_index do |plan, index|
.tab-pane{ :id => "plan#{index}", class: index == 0 ? 'active': '' }
= form_for plan.actual_limits, url: admin_plan_limits_path(anchor: 'js-ci-cd-settings'), html: { class: 'fieldset-form' }, method: :post do |f|
= form_errors(plan)
= form_errors(plan, pajamas_alert: true)
%fieldset
= f.hidden_field(:plan_id, value: plan.id)
.form-group

View File

@ -12,7 +12,7 @@
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-floc-settings'), html: { class: 'fieldset-form', id: 'floc-settings' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group

View File

@ -10,7 +10,7 @@
= link_to _('Learn more.'), help_page_path('administration/integration/kroki.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-kroki-settings'), html: { class: 'fieldset-form', id: 'kroki-settings' } do |f|
= form_errors(@application_setting) if expanded
= form_errors(@application_setting, pajamas_alert: true) if expanded
%fieldset
.form-group

View File

@ -26,7 +26,7 @@
- @plans.each_with_index do |plan, index|
.tab-pane{ :id => "plan#{index}", class: index == 0 ? 'active': '' }
= form_for plan.actual_limits, url: admin_plan_limits_path(anchor: 'js-package-settings'), html: { class: 'fieldset-form' }, method: :post do |f|
= form_errors(plan)
= form_errors(plan, pajamas_alert: true)
%fieldset
= f.hidden_field(:plan_id, value: plan.id)
.form-group

View File

@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-signin-settings'), html: { class: 'fieldset-form', id: 'signin-settings' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group

View File

@ -17,7 +17,7 @@
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-sourcegraph-settings'), html: { class: 'fieldset-form', id: 'sourcegraph-settings' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group

View File

@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-terms-settings'), html: { class: 'fieldset-form', id: 'terms-settings' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group

View File

@ -8,12 +8,12 @@
%h3= name
- if @service_ping_data_present
%button.gl-button.btn.btn-default.js-payload-preview-trigger{ type: 'button', data: { payload_selector: ".#{payload_class}" } }
= gl_loading_icon(css_class: 'js-spinner gl-display-none gl-mr-2')
.js-text.gl-display-inline= _('Preview payload')
%button.gl-button.btn.btn-default.js-payload-download-trigger{ type: 'button', data: { endpoint: usage_data_admin_application_settings_path(format: :json) } }
= gl_loading_icon(css_class: 'js-spinner gl-display-none gl-mr-2')
.js-text.d-inline= _('Download payload')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-payload-preview-trigger gl-mr-2', data: { payload_selector: ".#{payload_class}" } } ) do
= gl_loading_icon(css_class: 'js-spinner gl-display-none', inline: true)
%span.js-text.gl-display-inline= _('Preview payload')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-payload-download-trigger gl-mr-2', data: { endpoint: usage_data_admin_application_settings_path(format: :json) } } ) do
= gl_loading_icon(css_class: 'js-spinner gl-display-none', inline: true)
%span.js-text.gl-display-inline= _('Download payload')
%pre.js-syntax-highlight.code.highlight.gl-mt-2.gl-display-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } }
- else
= render Pajamas::AlertComponent.new(variant: :warning,

View File

@ -3,7 +3,6 @@
- billable_users_url = help_page_path('subscriptions/self_managed/index', anchor: 'billable-users')
- billable_users_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer nofollow">'.html_safe % { url: billable_users_url }
= render_if_exists 'shared/manual_renewal_banner'
= render_if_exists 'shared/manual_quarterly_reconciliation_banner'
= render_if_exists 'shared/submit_license_usage_data_banner'
= render_if_exists 'shared/qrtly_reconciliation_alert'

View File

@ -4,14 +4,17 @@
- breadcrumb_title _("Jobs")
- page_title _("Jobs")
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
- build_path_proc = ->(scope) { admin_jobs_path(scope: scope) }
= render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope
.top-area
.scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0.gl-w-full
.fade-left= sprite_icon('chevron-lg-left', size: 12)
.fade-right= sprite_icon('chevron-lg-right', size: 12)
- build_path_proc = ->(scope) { admin_jobs_path(scope: scope) }
= render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope
- if @all_builds.running_or_pending.any?
#js-stop-jobs-modal
.nav-controls
%button#js-stop-jobs-button.btn.gl-button.btn-danger{ data: { url: cancel_all_admin_jobs_path } }
= render Pajamas::ButtonComponent.new(variant: :danger, button_options: { id: 'js-stop-jobs-button', data: { url: cancel_all_admin_jobs_path } }) do
= s_('AdminArea|Stop all jobs')
.row-content-block.second-block

View File

@ -1,14 +1,18 @@
- page_title _('Projects')
- params[:visibility_level] ||= []
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
= gl_tabs_nav({ class: 'gl-border-b-0 gl-overflow-x-auto gl-flex-grow-1 gl-flex-nowrap gl-webkit-scrollbar-display-none' }) do
= gl_tab_link_to _('All'), admin_projects_path(visibility_level: nil), { item_active: params[:visibility_level].empty? }
= gl_tab_link_to _('Private'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
= gl_tab_link_to _('Internal'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
= gl_tab_link_to _('Public'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
.top-area
.scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0.gl-w-full
.fade-left= sprite_icon('chevron-lg-left', size: 12)
.fade-right= sprite_icon('chevron-lg-right', size: 12)
= gl_tabs_nav({ class: 'scrolling-tabs nav-links gl-display-flex gl-flex-grow-1 gl-w-full nav gl-tabs-nav nav gl-tabs-nav' }) do
= gl_tab_link_to _('All'), admin_projects_path(visibility_level: nil), { item_active: params[:visibility_level].empty? }
= gl_tab_link_to _('Private'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
= gl_tab_link_to _('Internal'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
= gl_tab_link_to _('Public'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
.nav-controls
.nav-controls.gl-pl-2
.search-holder
= render 'shared/projects/search_form', autofocus: true, admin_view: true
- current_namespace = _('Namespace')

View File

@ -11,10 +11,11 @@
.page-title-controls
= link_to _("New project"), new_project_path, class: "gl-button btn btn-confirm", data: { qa_selector: 'new_project_button' }
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
.fade-left= sprite_icon('chevron-lg-left', size: 12)
.fade-right= sprite_icon('chevron-lg-right', size: 12)
= render 'dashboard/projects_nav'
.top-area
.scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0.gl-w-full
.fade-left= sprite_icon('chevron-lg-left', size: 12)
.fade-right= sprite_icon('chevron-lg-right', size: 12)
= render 'dashboard/projects_nav'
- unless feature_project_list_filter_bar
.nav-controls
= render 'shared/projects/search_form'

View File

@ -1,7 +1,7 @@
- is_your_projects_path = current_page?(dashboard_projects_path) || current_page?(root_path)
- is_explore_projects_path = current_page?(explore_root_path) || current_page?(trending_explore_projects_path) || current_page?(starred_explore_projects_path) || current_page?(explore_projects_path)
= gl_tabs_nav({ class: 'scrolling-tabs nav-links gl-display-flex gl-flex-grow-1 gl-flex-nowrap gl-border-0' }) do
= gl_tabs_nav({ class: 'scrolling-tabs nav-links gl-display-flex gl-flex-grow-1 gl-w-full nav gl-tabs-nav' }) do
= gl_tab_link_to dashboard_projects_path, { item_active: is_your_projects_path, class: 'shortcuts-activity', data: { placement: 'right' } } do
= _("Your projects")
= gl_tab_counter_badge(limited_counter_with_delimiter(@total_user_projects_count))

View File

@ -1,9 +1,10 @@
.page-title-holder.d-flex.align-items-center
%h1.page-title.gl-font-size-h-display= _('Projects')
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
.fade-left= sprite_icon('chevron-lg-left', size: 12)
.fade-right= sprite_icon('chevron-lg-right', size: 12)
= render 'dashboard/projects_nav'
.top-area
.scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0.gl-w-full
.fade-left= sprite_icon('chevron-lg-left', size: 12)
.fade-right= sprite_icon('chevron-lg-right', size: 12)
= render 'dashboard/projects_nav'
.nav-controls
= render 'shared/topics/search_form'

View File

@ -11,4 +11,4 @@
%li.page-item.js-next-button{ class: ('disabled' if current_page.last?) }
= link_to page_url, rel: 'next', remote: remote, class: 'page-link' do
= s_('Pagination|Next')
= sprite_icon('angle-right', size: 8)
= sprite_icon('chevron-lg-right', size: 8)

View File

@ -10,5 +10,5 @@
%li.page-item.js-previous-button{ class: ('disabled' if current_page.first?) }
= link_to page_url, rel: 'prev', remote: remote, class: 'page-link' do
= sprite_icon('angle-left', size: 8)
= sprite_icon('chevron-lg-left', size: 8)
= s_('Pagination|Prev')

View File

@ -23,7 +23,7 @@
%li.commit-header.js-commit-header
%span.font-weight-bold= n_("%d previously merged commit", "%d previously merged commits", context_commits.count) % context_commits.count
- if can_update_merge_request
%button.gl-button.btn.btn-default.ml-3.add-review-item-modal-trigger{ type: "button", data: { context_commits_empty: 'false' } }
= render Pajamas::ButtonComponent.new(button_options: { class: 'gl-ml-3 add-review-item-modal-trigger', data: { context_commits_empty: 'false' } }) do
= _('Add/remove')
%li.commits-row
@ -41,7 +41,7 @@
= n_('%s additional commit has been omitted to prevent performance issues.', '%s additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden)
- if can_update_merge_request && context_commits&.empty?
%button.gl-button.btn.btn-default.mt-3.add-review-item-modal-trigger{ type: "button", data: { context_commits_empty: 'true' } }
= render Pajamas::ButtonComponent.new(button_options: { class: 'gl-mt-5', data: { context_commits_empty: 'true' } }) do
= _('Add previously merged commits')
- if commits.size == 0 && context_commits.nil?

View File

@ -12,24 +12,27 @@
.clearfix
.merge-request-select.dropdown
= f.hidden_field :source_project_id
= dropdown_toggle @merge_request.source_project_path, { toggle: "dropdown", 'field-name': "#{f.object_name}[source_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-source-project" }
= dropdown_toggle @merge_request.source_project_path, { toggle: "dropdown", 'field-name': "#{f.object_name}[source_project_id]", disabled: @merge_request.persisted?, default_text: _("Select source project") }, { toggle_class: "js-compare-dropdown js-source-project" }
.dropdown-menu.dropdown-menu-selectable.dropdown-source-project
= dropdown_title("Select source project")
= dropdown_filter("Search projects")
= dropdown_title(_("Select source project"))
= dropdown_filter(_("Search projects"))
= dropdown_content do
= render 'projects/merge_requests/dropdowns/project',
projects: [@merge_request.source_project],
selected: f.object.source_project_id
.merge-request-select.dropdown
= f.hidden_field :source_branch
= dropdown_toggle f.object.source_branch.presence || _("Select source branch"), { toggle: "dropdown", 'field-name': "#{f.object_name}[source_branch]", 'refs-url': refs_project_path(@source_project), selected: f.object.source_branch, qa_selector: "source_branch_dropdown" }, { toggle_class: "js-compare-dropdown js-source-branch monospace" }
= dropdown_toggle f.object.source_branch.presence || _("Select source branch"), { toggle: "dropdown", 'field-name': "#{f.object_name}[source_branch]", 'refs-url': refs_project_path(@source_project), selected: f.object.source_branch, default_text: _("Select target branch"), qa_selector: "source_branch_dropdown" }, { toggle_class: "js-compare-dropdown js-source-branch monospace" }
.dropdown-menu.dropdown-menu-selectable.js-source-branch-dropdown.git-revision-dropdown
= dropdown_title(_("Select source branch"))
= dropdown_filter(_("Search branches"))
= dropdown_content
= dropdown_loading
.gl-bg-gray-50.gl-rounded-base.gl-mx-2.gl-my-4
= gl_loading_icon(css_class: 'js-source-loading gl-my-3')
.compare-commit-empty.js-source-commit-empty.gl-display-flex.gl-align-items-center.gl-p-5{ style: 'display: none;' }
= sprite_icon('branch', size: 16, css_class: 'gl-mr-3')
= _('Select a branch to compare')
= gl_loading_icon(css_class: 'js-source-loading gl-py-3')
%ul.list-unstyled.mr_source_commit
.col-lg-6
@ -40,24 +43,27 @@
- projects = target_projects(@project)
.merge-request-select.dropdown
= f.hidden_field :target_project_id
= dropdown_toggle f.object.target_project.full_path, { toggle: "dropdown", 'field-name': "#{f.object_name}[target_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-target-project" }
= dropdown_toggle f.object.target_project.full_path, { toggle: "dropdown", 'field-name': "#{f.object_name}[target_project_id]", disabled: @merge_request.persisted?, default_text: _("Select target project") }, { toggle_class: "js-compare-dropdown js-target-project" }
.dropdown-menu.dropdown-menu-selectable.dropdown-target-project
= dropdown_title("Select target project")
= dropdown_filter("Search projects")
= dropdown_title(_("Select target project"))
= dropdown_filter(_("Search projects"))
= dropdown_content do
= render 'projects/merge_requests/dropdowns/project',
projects: projects,
selected: f.object.target_project_id
.merge-request-select.dropdown
= f.hidden_field :target_branch
= dropdown_toggle f.object.target_branch.presence || _("Select target branch"), { toggle: "dropdown", 'field-name': "#{f.object_name}[target_branch]", 'refs-url': refs_project_path(f.object.target_project), selected: f.object.target_branch }, { toggle_class: "js-compare-dropdown js-target-branch monospace" }
= dropdown_toggle f.object.target_branch.presence || _("Select target branch"), { toggle: "dropdown", 'field-name': "#{f.object_name}[target_branch]", 'refs-url': refs_project_path(f.object.target_project), selected: f.object.target_branch, default_text: _("Select target branch") }, { toggle_class: "js-compare-dropdown js-target-branch monospace" }
.dropdown-menu.dropdown-menu-selectable.js-target-branch-dropdown.git-revision-dropdown
= dropdown_title(_("Select target branch"))
= dropdown_filter(_("Search branches"))
= dropdown_content
= dropdown_loading
.gl-bg-gray-50.gl-rounded-base.gl-mx-2.gl-my-4
= gl_loading_icon(css_class: 'js-target-loading gl-my-3')
.compare-commit-empty.js-target-commit-empty.gl-display-flex.gl-align-items-center.gl-p-5{ style: 'display: none;' }
= sprite_icon('branch', size: 16, css_class: 'gl-mr-3')
= _('Select a branch to compare')
= gl_loading_icon(css_class: 'js-target-loading gl-py-3')
%ul.list-unstyled.mr_target_commit
- if @merge_request.errors.any?

View File

@ -14,8 +14,9 @@
dom_id: dom_id(label), type: label.type } }
%button.add-priority.btn.gl-button.btn-default-tertiary.btn-sm.has-tooltip{ title: _('Prioritize'), type: 'button', data: { placement: 'bottom' }, aria_label: _('Prioritize label') }
= sprite_icon('star-o')
%button.remove-priority.btn.gl-button.btn-default-tertiary.btn-sm.has-tooltip{ title: _('Remove priority'), type: 'button', data: { placement: 'bottom' }, aria_label: _('Deprioritize label') }
= sprite_icon('star')
= render Pajamas::ButtonComponent.new(category: :tertiary,
icon: 'star',
button_options: { class: 'remove-priority has-tooltip', 'title': _('Remove priority'), 'aria_label': _('Deprioritize label'), data: { placement: 'bottom' } })
- if can?(current_user, :admin_label, label)
%li.gl-display-inline-block
= link_to label.edit_path, class: 'btn gl-button btn-default-tertiary btn-sm edit has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
@ -23,8 +24,9 @@
- if can?(current_user, :admin_label, label)
%li.gl-display-inline-block
.dropdown
%button{ type: 'button', class: 'btn gl-button btn-default-tertiary btn-sm js-label-options-dropdown', data: { toggle: 'dropdown' }, aria_label: _('Label actions dropdown') }
= sprite_icon('ellipsis_v')
= render Pajamas::ButtonComponent.new(category: :tertiary,
icon: 'ellipsis_v',
button_options: { class: 'js-label-options-dropdown', 'aria_label': _('Label actions dropdown'), data: { toggle: 'dropdown' } })
.dropdown-menu.dropdown-open-left
%ul
- if label.project_label? && label.project.group && can?(current_user, :admin_label, label.project.group)

View File

@ -1,6 +1,6 @@
- count_badge_classes = 'gl-display-none gl-sm-display-inline-flex'
= gl_tabs_nav( {class: 'gl-border-b-0 gl-flex-grow-1', data: { testid: 'jobs-tabs' } } ) do
= gl_tabs_nav( {class: 'scrolling-tabs nav-links gl-display-flex gl-flex-grow-1 gl-w-full nav gl-border-b-0', data: { testid: 'jobs-tabs' } } ) do
= gl_tab_link_to build_path_proc.call(nil), { item_active: scope.nil? } do
= _('All')
= gl_tab_counter_badge(limited_counter_with_delimiter(all_builds), { class: count_badge_classes })

View File

@ -3,7 +3,7 @@
.issue-details.issuable-details.js-issue-details
.detail-page-description.content-block.js-detail-page-description
#js-issuable-app{ data: { initial: issuable_initial_data(issuable).to_json, full_path: @project.full_path } }
#js-issuable-app{ data: { initial: issuable_initial_data(issuable).to_json, issuable_id: issuable.id, full_path: @project.full_path } }
.title-container
%h1.title.page-title.gl-font-size-h-display= markdown_field(issuable, :title)
- if issuable.description.present?

View File

@ -0,0 +1,8 @@
---
name: ssh_banned_key
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87541
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/363410
milestone: '15.1'
type: development
group: 'group::source code'
default_enabled: false

View File

@ -1,7 +1,7 @@
---
name: use_primary_and_secondary_stores_for_duplicate_jobs
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/
rollout_issue_url:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85740
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/364381
milestone:
type: development
group: group::scalability

View File

@ -1,7 +1,7 @@
---
name: use_primary_and_secondary_stores_for_sidekiq_status
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/
rollout_issue_url:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89561
rollout_issue_url: https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/924
milestone:
type: development
group: group::scalability

View File

@ -1,7 +1,7 @@
---
name: use_primary_store_as_default_for_duplicate_jobs
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/
rollout_issue_url:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85740
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/364381
milestone:
type: development
group: group::scalability

View File

@ -1,7 +1,7 @@
---
name: use_primary_store_as_default_for_sidekiq_status
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/
rollout_issue_url:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89561
rollout_issue_url: https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/924
milestone:
type: development
group: group::scalability

View File

@ -54,6 +54,13 @@ module Mail
decoded = dec.decode(raw_source)
if defined?(Encoding) && charset && charset != "US-ASCII"
# Sometimes, the decoded string is frozen. Encoders in
# Mail::Encodings behave differently in this case. Unlike the
# original implementation which does not modify this string, we
# enforce the encoding below. That may lead to FrozenError.
# Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/364619
decoded = decoded.dup if decoded.frozen?
# PATCH
# We need to force the encoding: in the case of quoted-printable
# this will throw an exception otherwise, because `decoded` will have

View File

@ -0,0 +1,9 @@
---
table_name: audit_events_streaming_headers
classes:
- AuditEvents::Streaming::Header
feature_categories:
- audit_events
description: Represents a HTTP header sent with streaming audit events
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/88063
milestone: '15.1'

View File

@ -4,6 +4,6 @@ classes:
- Ci::JobVariable
feature_categories:
- pipeline_authoring
description: TODO
description: CI/CD variables set to a job when running it manually.
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/14784
milestone: '12.2'

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
class CreateAuditEventsStreamingHeaders < Gitlab::Database::Migration[2.0]
INDEX_NAME = 'idx_streaming_headers_on_external_audit_event_destination_id'
UNIQ_INDEX_NAME = 'idx_external_audit_event_destination_id_key_uniq'
def change
create_table :audit_events_streaming_headers do |t|
t.timestamps_with_timezone null: false
t.references :external_audit_event_destination,
null: false,
index: { name: INDEX_NAME },
foreign_key: { to_table: 'audit_events_external_audit_event_destinations', on_delete: :cascade }
t.text :key, null: false, limit: 255
t.text :value, null: false, limit: 255
t.index [:key, :external_audit_event_destination_id], unique: true, name: UNIQ_INDEX_NAME
end
end
end

View File

@ -0,0 +1 @@
9dddbbdb3e72763cc331b5690536312970c92c64d66d7cb2efc118c107ae204c

View File

@ -11655,6 +11655,26 @@ CREATE SEQUENCE audit_events_id_seq
ALTER SEQUENCE audit_events_id_seq OWNED BY audit_events.id;
CREATE TABLE audit_events_streaming_headers (
id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
external_audit_event_destination_id bigint NOT NULL,
key text NOT NULL,
value text NOT NULL,
CONSTRAINT check_53c3152034 CHECK ((char_length(key) <= 255)),
CONSTRAINT check_ac213cca22 CHECK ((char_length(value) <= 255))
);
CREATE SEQUENCE audit_events_streaming_headers_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE audit_events_streaming_headers_id_seq OWNED BY audit_events_streaming_headers.id;
CREATE TABLE authentication_events (
id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
@ -22545,6 +22565,8 @@ ALTER TABLE ONLY audit_events ALTER COLUMN id SET DEFAULT nextval('audit_events_
ALTER TABLE ONLY audit_events_external_audit_event_destinations ALTER COLUMN id SET DEFAULT nextval('audit_events_external_audit_event_destinations_id_seq'::regclass);
ALTER TABLE ONLY audit_events_streaming_headers ALTER COLUMN id SET DEFAULT nextval('audit_events_streaming_headers_id_seq'::regclass);
ALTER TABLE ONLY authentication_events ALTER COLUMN id SET DEFAULT nextval('authentication_events_id_seq'::regclass);
ALTER TABLE ONLY award_emoji ALTER COLUMN id SET DEFAULT nextval('award_emoji_id_seq'::regclass);
@ -24166,6 +24188,9 @@ ALTER TABLE ONLY audit_events_external_audit_event_destinations
ALTER TABLE ONLY audit_events
ADD CONSTRAINT audit_events_pkey PRIMARY KEY (id, created_at);
ALTER TABLE ONLY audit_events_streaming_headers
ADD CONSTRAINT audit_events_streaming_headers_pkey PRIMARY KEY (id);
ALTER TABLE ONLY authentication_events
ADD CONSTRAINT authentication_events_pkey PRIMARY KEY (id);
@ -26782,6 +26807,8 @@ CREATE INDEX idx_elastic_reindexing_slices_on_elastic_reindexing_subtask_id ON e
CREATE UNIQUE INDEX idx_environment_merge_requests_unique_index ON deployment_merge_requests USING btree (environment_id, merge_request_id);
CREATE UNIQUE INDEX idx_external_audit_event_destination_id_key_uniq ON audit_events_streaming_headers USING btree (key, external_audit_event_destination_id);
CREATE INDEX idx_geo_con_rep_updated_events_on_container_repository_id ON geo_container_repository_updated_events USING btree (container_repository_id);
CREATE INDEX idx_installable_helm_pkgs_on_project_id_id ON packages_packages USING btree (project_id, id);
@ -26892,6 +26919,8 @@ CREATE INDEX idx_security_scans_on_scan_type ON security_scans USING btree (scan
CREATE UNIQUE INDEX idx_serverless_domain_cluster_on_clusters_applications_knative ON serverless_domain_cluster USING btree (clusters_applications_knative_id);
CREATE INDEX idx_streaming_headers_on_external_audit_event_destination_id ON audit_events_streaming_headers USING btree (external_audit_event_destination_id);
CREATE INDEX idx_user_details_on_provisioned_by_group_id_user_id ON user_details USING btree (provisioned_by_group_id, user_id);
CREATE UNIQUE INDEX idx_vuln_signatures_on_occurrences_id_and_signature_sha ON vulnerability_finding_signatures USING btree (finding_id, signature_sha);
@ -32289,6 +32318,9 @@ ALTER TABLE ONLY vulnerability_exports
ALTER TABLE ONLY prometheus_alert_events
ADD CONSTRAINT fk_rails_106f901176 FOREIGN KEY (prometheus_alert_id) REFERENCES prometheus_alerts(id) ON DELETE CASCADE;
ALTER TABLE ONLY audit_events_streaming_headers
ADD CONSTRAINT fk_rails_109fcf96e2 FOREIGN KEY (external_audit_event_destination_id) REFERENCES audit_events_external_audit_event_destinations(id) ON DELETE CASCADE;
ALTER TABLE ONLY ci_sources_projects
ADD CONSTRAINT fk_rails_10a1eb379a FOREIGN KEY (pipeline_id) REFERENCES ci_pipelines(id) ON DELETE CASCADE;

View File

@ -143,6 +143,31 @@ Destination is deleted if:
- The returned `errors` object is empty.
- The API responds with `200 OK`.
## Custom HTTP header values
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/361216) in GitLab 15.1 [with a flag](feature_flags.md) named `streaming_audit_event_headers`. Disabled by default.
FLAG:
On self-managed GitLab, by default this feature is not available. To make it available per group, ask an administrator to [enable the feature flag](../administration/feature_flags.md) named `streaming_audit_event_headers`.
On GitLab.com, this feature is not available.
The feature is not ready for production use.
Each streaming destination can have up to 20 custom HTTP headers included with each streamed event.
### Add with the API
Group owners can add a HTTP header using the GraphQL `auditEventsStreamingHeadersCreate` mutation.
```graphql
mutation {
auditEventsStreamingHeadersCreate(input: { destinationId: "gid://gitlab/AuditEvents::ExternalAuditEventDestination/24601", key: "foo", value: "bar" }) {
errors
}
}
```
The header is created if the returned `errors` object is empty.
## Verify event authenticity
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/345424) in GitLab 14.8.

View File

@ -717,6 +717,27 @@ Input type: `ApiFuzzingCiConfigurationCreateInput`
| <a id="mutationapifuzzingciconfigurationcreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationapifuzzingciconfigurationcreategitlabciyamleditpath"></a>`gitlabCiYamlEditPath` **{warning-solid}** | [`String`](#string) | **Deprecated:** The configuration snippet is now generated client-side. Deprecated in 14.6. |
### `Mutation.auditEventsStreamingHeadersCreate`
Input type: `AuditEventsStreamingHeadersCreateInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationauditeventsstreamingheaderscreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationauditeventsstreamingheaderscreatedestinationid"></a>`destinationId` | [`AuditEventsExternalAuditEventDestinationID!`](#auditeventsexternalauditeventdestinationid) | Destination to associate header with. |
| <a id="mutationauditeventsstreamingheaderscreatekey"></a>`key` | [`String!`](#string) | Header key. |
| <a id="mutationauditeventsstreamingheaderscreatevalue"></a>`value` | [`String!`](#string) | Header value. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationauditeventsstreamingheaderscreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationauditeventsstreamingheaderscreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationauditeventsstreamingheaderscreateheader"></a>`header` | [`AuditEventStreamingHeader`](#auditeventstreamingheader) | Created header. |
### `Mutation.awardEmojiAdd`
Input type: `AwardEmojiAddInput`
@ -5679,6 +5700,29 @@ The edge type for [`AlertManagementIntegration`](#alertmanagementintegration).
| <a id="alertmanagementintegrationedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
| <a id="alertmanagementintegrationedgenode"></a>`node` | [`AlertManagementIntegration`](#alertmanagementintegration) | The item at the end of the edge. |
#### `AuditEventStreamingHeaderConnection`
The connection type for [`AuditEventStreamingHeader`](#auditeventstreamingheader).
##### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="auditeventstreamingheaderconnectionedges"></a>`edges` | [`[AuditEventStreamingHeaderEdge]`](#auditeventstreamingheaderedge) | A list of edges. |
| <a id="auditeventstreamingheaderconnectionnodes"></a>`nodes` | [`[AuditEventStreamingHeader]`](#auditeventstreamingheader) | A list of nodes. |
| <a id="auditeventstreamingheaderconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
#### `AuditEventStreamingHeaderEdge`
The edge type for [`AuditEventStreamingHeader`](#auditeventstreamingheader).
##### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="auditeventstreamingheaderedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
| <a id="auditeventstreamingheaderedgenode"></a>`node` | [`AuditEventStreamingHeader`](#auditeventstreamingheader) | The item at the end of the edge. |
#### `AwardEmojiConnection`
The connection type for [`AwardEmoji`](#awardemoji).
@ -9137,6 +9181,18 @@ Represents a vulnerability asset type.
| <a id="assettypetype"></a>`type` | [`String!`](#string) | Type of the asset. |
| <a id="assettypeurl"></a>`url` | [`String!`](#string) | URL of the asset. |
### `AuditEventStreamingHeader`
Represents a HTTP header key/value that belongs to an audit streaming destination.
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="auditeventstreamingheaderid"></a>`id` | [`ID!`](#id) | ID of the header. |
| <a id="auditeventstreamingheaderkey"></a>`key` | [`String!`](#string) | Key of the header. |
| <a id="auditeventstreamingheadervalue"></a>`value` | [`String!`](#string) | Value of the header. |
### `AwardEmoji`
An emoji awarded by a user.
@ -11293,6 +11349,7 @@ Represents an external resource to send audit events to.
| ---- | ---- | ----------- |
| <a id="externalauditeventdestinationdestinationurl"></a>`destinationUrl` | [`String!`](#string) | External destination to send audit events to. |
| <a id="externalauditeventdestinationgroup"></a>`group` | [`Group!`](#group) | Group the destination belongs to. |
| <a id="externalauditeventdestinationheaders"></a>`headers` | [`AuditEventStreamingHeaderConnection!`](#auditeventstreamingheaderconnection) | List of additional HTTP headers sent with each event. Available only when feature flag `streaming_audit_event_headers` is enabled. This flag is disabled by default, because the feature is experimental and is subject to change without notice. (see [Connections](#connections)) |
| <a id="externalauditeventdestinationid"></a>`id` | [`ID!`](#id) | ID of the destination. |
| <a id="externalauditeventdestinationverificationtoken"></a>`verificationToken` | [`String!`](#string) | Verification token to validate source of event. |

View File

@ -228,17 +228,23 @@ Having this configuration in place:
### Protect critical environments under a group
To protect a group-level environment:
To protect a group-level environment, make sure your environments have the correct
[`deployment_tier`](index.md#deployment-tier-of-environments) defined in `.gitlab-ci.yml`.
1. Make sure your environments have the correct
[`deployment_tier`](index.md#deployment-tier-of-environments) defined in
`.gitlab-ci.yml`.
1. Configure the group-level protected environments by using the
[REST API](../../api/group_protected_environments.md).
#### Using the UI
NOTE:
Configuration [with the UI](https://gitlab.com/gitlab-org/gitlab/-/issues/325249)
is scheduled for a later release.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/325249) in GitLab 15.1 with a flag named `group_level_protected_environment`. Disabled by default.
1. On the top bar, select **Menu > Groups** and find your group.
1. On the left sidebar, select **Settings > CI/CD**.
1. Expand **Protected environments**.
1. From the **Environment** list, select the [deployment tier of environments](index.md#deployment-tier-of-environments) you want to protect.
1. In the **Allowed to deploy** list, select the [subgroups](../../user/group/subgroups/index.md) you want to give deploy access to.
1. Select **Protect**.
#### Using the API
Configure the group-level protected environments by using the [REST API](../../api/group_protected_environments.md).
## Deployment approvals

View File

@ -293,16 +293,18 @@ When authenticating using LDAP, the user's name and email are always synced.
> Introduced in GitLab 12.3.
With certain OmniAuth providers, users can sign in without
using two-factor authentication.
With certain OmniAuth providers, users can sign in without using two-factor authentication (2FA).
To bypass two-factor authentication, you can either:
Because of a [known issue](https://gitlab.com/gitlab-org/gitlab/-/issues/196131) users must
[set up 2FA](../user/profile/account/two_factor_authentication.md#enable-two-factor-authentication) on their GitLab
account to bypass 2FA. Otherwise, they are prompted to set up 2FA when they sign in to GitLab.
To bypass 2FA, you can either:
- Define the allowed providers using an array (for example, `['twitter', 'google_oauth2']`).
- Specify `true` to allow all providers, or `false` to allow none.
This option should be configured only for providers that already have
two-factor authentication. The default is `false`.
This option should be configured only for providers that already have 2FA. The default is `false`.
This configuration doesn't apply to SAML.

View File

@ -48,6 +48,26 @@ By default, the GitLab.com and self-managed settings for the
- ECDSA_SK SSH keys are allowed (GitLab 14.8 and later).
- ED25519_SK SSH keys are allowed (GitLab 14.8 and later).
### Block banned or compromised keys **(FREE)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/24614) in GitLab 15.1 [with a flag](../administration/feature_flags.md) named `ssh_banned_key`. Disabled by default.
FLAG:
On self-managed GitLab, by default this feature is not available. To make it available,
ask an administrator to [enable the feature flag](../administration/feature_flags.md) named `ssh_banned_key`.
On GitLab.com, this feature is available but can be configured by GitLab.com administrators only.
The feature is not ready for production use.
When users attempt to [add a new SSH key](../user/ssh.md#add-an-ssh-key-to-your-gitlab-account)
to GitLab accounts, the key is checked against a list of SSH keys which are known
to be compromised. Users can't add keys from this list to any GitLab account.
This restriction cannot be configured. This restriction exists because the private
keys associated with the key pair are publicly known, and can be used to access
accounts using the key pair.
If your key is disallowed by this restriction, [generate a new SSH key pair](../user/ssh.md#generate-an-ssh-key-pair)
to use instead.
<!-- ## Troubleshooting
Include any troubleshooting steps that you can foresee. If you know beforehand what issues

View File

@ -52,6 +52,7 @@ By default both administrators and anyone with the **Owner** role can delete a p
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/255449) in GitLab 14.2 for groups created after August 12, 2021.
> - [Renamed](https://gitlab.com/gitlab-org/gitlab/-/issues/352960) from default delayed project deletion in GitLab 15.1.
> - [Enabled for projects in personal namespaces](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89466) in GitLab 15.1.
Instance-level protection against accidental deletion of groups and projects.

View File

@ -455,7 +455,9 @@ in GitLab 12.6, and then to [immediate deletion](https://gitlab.com/gitlab-org/g
### Delayed project deletion **(PREMIUM)**
Projects in a group (not a personal namespace) can be deleted after a delay period. Multiple settings can affect whether
> [Enabled for projects in personal namespaces](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89466) in GitLab 15.1.
Projects can be deleted after a delay period. Multiple settings can affect whether
delayed project deletion is enabled for a particular project:
- Self-managed instance [settings](../../admin_area/settings/visibility_and_access_controls.md#deletion-protection).

View File

@ -39,6 +39,7 @@ ar_internal_metadata: :gitlab_shared
atlassian_identities: :gitlab_main
audit_events_external_audit_event_destinations: :gitlab_main
audit_events: :gitlab_main
audit_events_streaming_headers: :gitlab_main
authentication_events: :gitlab_main
award_emoji: :gitlab_main
aws_roles: :gitlab_main

View File

@ -0,0 +1,74 @@
# frozen_string_literal: true
# Signature verification with ed25519 keys
# requires this gem to be loaded.
require 'ed25519'
module Gitlab
module Ssh
class Signature
include Gitlab::Utils::StrongMemoize
def initialize(signature_text, signed_text, committer_email)
@signature_text = signature_text
@signed_text = signed_text
@committer_email = committer_email
end
def verification_status
strong_memoize(:verification_status) do
next :unverified unless all_attributes_present?
next :unverified unless valid_signature_blob? && committer
next :unknown_key unless signed_by_key
next :other_user unless signed_by_key.user == committer
:verified
end
end
private
def all_attributes_present?
# Signing an empty string is valid, but signature_text and committer_email
# must be non-empty.
@signed_text && @signature_text.present? && @committer_email.present?
end
# Verifies the signature using the public key embedded in the blob.
# This proves that the signed_text was signed by the private key
# of the public key identified by `key_fingerprint`. Afterwards, we
# still need to check that the key belongs to the committer.
def valid_signature_blob?
return false unless signature
signature.verify(@signed_text)
end
def committer
# Lookup by email because users can push verified commits that were made
# by someone else. For example: Doing a rebase.
strong_memoize(:committer) { User.find_by_any_email(@committer_email, confirmed: true) }
end
def signature
strong_memoize(:signature) do
::SSHData::Signature.parse_pem(@signature_text)
rescue SSHData::DecodeError
nil
end
end
def key_fingerprint
strong_memoize(:key_fingerprint) { signature&.public_key&.fingerprint }
end
def signed_by_key
strong_memoize(:signed_by_key) do
next unless key_fingerprint
Key.find_by_fingerprint_sha256(key_fingerprint)
end
end
end
end
end

View File

@ -15,6 +15,29 @@ module Gitlab
Technology.new(:ed25519_sk, SSHData::PublicKey::SKED25519, [256], %w(sk-ssh-ed25519@openssh.com))
].freeze
BANNED_SSH_KEY_FINGERPRINTS = [
# https://github.com/rapid7/ssh-badkeys/tree/master/authorized
# banned ssh rsa keys
"SHA256:Z+q4XhSwWY7q0BIDVPR1v/S306FjGBsid7tLq/8kIxM",
"SHA256:uy5wXyEgbRCGsk23+J6f85om7G55Cu3UIPwC7oMZhNQ",
"SHA256:9prMbqhS4QteoFQ1ZRJDqSBLWoHXPyKB0iWR05Ghro4",
"SHA256:1M4RzhMyWuFS/86uPY/ce2prh/dVTHW7iD2RhpquOZA",
# banned ssh dsa keys
"SHA256:/JLp6z6uGE3BPcs70RQob6QOdEWQ6nDC0xY7ejPOCc0",
"SHA256:whDP3xjKBEettbDuecxtGsfWBST+78gb6McdB9P7jCU",
"SHA256:MEc4HfsOlMqJ3/9QMTmrKn5Xj/yfnMITMW8EwfUfTww",
"SHA256:aPoYT2nPIfhqv6BIlbCCpbDjirBxaDFOtPfZ2K20uWw",
"SHA256:VtjqZ5fiaeoZ3mXOYi49Lk9aO31iT4pahKFP9JPiQPc",
# other banned ssh keys
# https://github.com/BenBE/kompromat/commit/c8d9a05ea155a1ed609c617d4516f0ac978e8559
"SHA256:Z+q4XhSwWY7q0BIDVPR1v/S306FjGBsid7tLq/8kIxM",
# https://www.ctrlu.net/vuln/0006.html
"SHA256:2ewGtK7Dc8XpnfNKShczdc8HSgoEGpoX+MiJkfH2p5I"
].to_set.freeze
def self.technologies
if Gitlab::FIPS.enabled?
Gitlab::FIPS::SSH_KEY_TECHNOLOGIES
@ -115,6 +138,10 @@ module Gitlab
end
end
def banned?
BANNED_SSH_KEY_FINGERPRINTS.include?(fingerprint_sha256)
end
private
def technology

View File

@ -1045,6 +1045,9 @@ msgstr ""
msgid "%{timebox_type} must have a start and due date"
msgstr ""
msgid "%{time} UTC"
msgstr ""
msgid "%{title} %{operator} %{threshold}"
msgstr ""
@ -20261,9 +20264,6 @@ msgstr ""
msgid "Incidents|Must start with http or https"
msgstr ""
msgid "Incident|Add new timeline event"
msgstr ""
msgid "Incident|Alert details"
msgstr ""
@ -20288,6 +20288,9 @@ msgstr ""
msgid "Incident|No timeline items have been added yet."
msgstr ""
msgid "Incident|Something went wrong while fetching incident timeline events."
msgstr ""
msgid "Incident|Summary"
msgstr ""
@ -34605,6 +34608,9 @@ msgstr ""
msgid "Select a branch"
msgstr ""
msgid "Select a branch to compare"
msgstr ""
msgid "Select a compliance framework to apply to this project. %{linkStart}How are these added?%{linkEnd}"
msgstr ""
@ -34719,6 +34725,9 @@ msgstr ""
msgid "Select source branch"
msgstr ""
msgid "Select source project"
msgstr ""
msgid "Select start date"
msgstr ""
@ -34740,6 +34749,9 @@ msgstr ""
msgid "Select target branch or tag"
msgstr ""
msgid "Select target project"
msgstr ""
msgid "Select timezone"
msgstr ""
@ -39781,9 +39793,6 @@ msgstr ""
msgid "To reactivate your account, sign in to GitLab at %{gitlab_url}."
msgstr ""
msgid "To renew, export your license usage file and email it to %{renewal_service_email}. A new license will be emailed to the email address registered in the %{customers_dot}. You can add this license to your instance."
msgstr ""
msgid "To resolve this, try to:"
msgstr ""
@ -43992,12 +44001,6 @@ msgstr ""
msgid "Your %{host} account was signed in to from a new location"
msgstr ""
msgid "Your %{plan} subscription expired on %{expiry_date}"
msgstr ""
msgid "Your %{plan} subscription expires on %{expiry_date}"
msgstr ""
msgid "Your %{spammable_entity_type} has been recognized as spam and has been discarded."
msgstr ""
@ -44300,9 +44303,6 @@ msgid_plural "Your subscription has %{remaining_seat_count} out of %{total_seat_
msgstr[0] ""
msgstr[1] ""
msgid "Your subscription is now expired. To renew, export your license usage file and email it to %{renewal_service_email}. A new license will be emailed to the email address registered in the %{customers_dot}. You can add this license to your instance. To use Free tier, remove your current license."
msgstr ""
msgid "Your subscription will expire in %{remaining_days} day."
msgid_plural "Your subscription will expire in %{remaining_days} days."
msgstr[0] ""
@ -44548,6 +44548,9 @@ msgstr ""
msgid "cannot be enabled until a valid credit card is on file"
msgstr ""
msgid "cannot be used because it belongs to a compromised private key. Stop using this key and generate a new one."
msgstr ""
msgid "cannot be used for user namespace"
msgstr ""

View File

@ -11,7 +11,7 @@ module QA
module_function
def shell(command, stdin_data: nil, fail_on_exception: true, stream_progress: true) # rubocop:disable Metrics/CyclomaticComplexity
def shell(command, stdin_data: nil, fail_on_exception: true, stream_progress: true, mask_secrets: []) # rubocop:disable Metrics/CyclomaticComplexity
cmd_string = Array(command).join(' ')
QA::Runtime::Logger.info("Executing: `#{cmd_string.cyan}`")
@ -24,6 +24,8 @@ module QA
cmd_output = ''
out.each do |line|
line = mask_secrets_on_string(line, mask_secrets)
cmd_output += line
yield line if block_given?
@ -36,7 +38,7 @@ module QA
if wait.value.exited? && wait.value.exitstatus.nonzero? && fail_on_exception
Runtime::Logger.error("Command output:\n#{cmd_output.strip}") unless cmd_output.empty?
raise CommandError, "Command: `#{cmd_string}` failed! ✘"
raise CommandError, "Command: `#{mask_secrets_on_string(cmd_string, mask_secrets)}` failed! ✘"
end
Runtime::Logger.debug("Command output:\n#{cmd_output.strip}") unless cmd_output.empty?
@ -66,6 +68,10 @@ module QA
line =~ regex
end
end
def mask_secrets_on_string(str, secrets)
secrets.reduce(str) { |s, secret| s.gsub(secret, '****') }
end
end
end
end

View File

@ -0,0 +1,58 @@
# frozen_string_literal: true
module QA
RSpec.describe Service::Shellout do
let(:wait_thread) { instance_double('Thread') }
let(:errored_wait) { instance_double('Process::Status', exited?: true, exitstatus: 1) }
let(:non_errored_wait) { instance_double('Process::Status', exited?: true, exitstatus: 0) }
let(:stdin) { StringIO.new }
let(:stdout) { [+'logged in as user with password secret'] }
context 'when masking secrets' do
before do
allow(Open3).to receive(:popen2e).and_yield(stdin, stdout, wait_thread)
end
it 'masks command secrets on CommandError' do
expect(wait_thread).to receive(:value).twice.and_return(errored_wait)
expect { subject.shell('docker login -u user -p secret', mask_secrets: %w[secret user]) }
.to raise_error(Service::Shellout::CommandError) do |error|
expect(error.to_s).to include('Command: `docker login -u **** -p ****` failed')
end
end
it 'masking secrets is optional' do
expect(wait_thread).to receive(:value).twice.and_return(errored_wait)
expect { subject.shell('docker pull ruby:3') }.to raise_error(Service::Shellout::CommandError) do |error|
expect(error.to_s).to include('Command: `docker pull ruby:3` failed')
end
end
it 'masks secrets when yielding output' do
expect(wait_thread).to receive(:value).twice.and_return(non_errored_wait)
subject.shell('docker login -u user -p secret', mask_secrets: %w[secret user]) do |output|
expect(output).not_to be(nil)
expect(output).to eql('logged in as **** with password ****')
end
end
it 'masks secrets in debug logs' do
expect(Runtime::Logger).to receive(:debug).with(/logged in as \*\*\*\* with password \*\*\*\*/)
expect(wait_thread).to receive(:value).twice.and_return(non_errored_wait)
subject.shell('docker login -u user -p secret', mask_secrets: %w[secret user])
end
it 'masks secrets in error logs' do
expect(Runtime::Logger).to receive(:error).with(/logged in as \*\*\*\* with password \*\*\*\*/)
expect(wait_thread).to receive(:value).twice.and_return(errored_wait)
expect { subject.shell('docker login -u user -p secret', mask_secrets: %w[secret user]) }
.to raise_error(Service::Shellout::CommandError)
end
end
end
end

View File

@ -2,63 +2,118 @@
module RuboCop
module Cop
# This cop flags translation definitions in static scopes because changing
# locales has no effect and won't translate this text again.
#
# See https://docs.gitlab.com/ee/development/i18n/externalization.html#keep-translations-dynamic
#
# @example
#
# # bad
# class MyExample
# # Constant
# Translation = _('A translation.')
#
# # Class scope
# field :foo, title: _('A title')
#
# validates :title, :presence, message: _('is missing')
#
# # Memoized
# def self.translations
# @cached ||= { text: _('A translation.') }
# end
#
# included do # or prepended or class_methods
# self.error_message = _('Something went wrong.')
# end
# end
#
# # good
# class MyExample
# # Keep translations dynamic.
# Translation = -> { _('A translation.') }
# # OR
# def translation
# _('A translation.')
# end
#
# field :foo, title: -> { _('A title') }
#
# validates :title, :presence, message: -> { _('is missing') }
#
# def self.translations
# { text: _('A translation.') }
# end
#
# included do # or prepended or class_methods
# self.error_message = -> { _('Something went wrong.') }
# end
# end
#
class StaticTranslationDefinition < RuboCop::Cop::Cop
MSG = "The text you're translating will be already in the translated form when it's assigned to the constant. When a users changes the locale, these texts won't be translated again. Consider moving the translation logic to a method."
MSG = <<~TEXT.tr("\n", ' ')
Translation is defined in static scope.
Keep translations dynamic. See https://docs.gitlab.com/ee/development/i18n/externalization.html#keep-translations-dynamic
TEXT
TRANSLATION_METHODS = %i[_ s_ n_].freeze
RESTRICT_ON_SEND = %i[_ s_ n_].freeze
# List of method names which are not considered real method definitions.
# See https://api.rubyonrails.org/classes/ActiveSupport/Concern.html
NON_METHOD_DEFINITIONS = %i[class_methods included prepended].to_set.freeze
def_node_matcher :translation_method?, <<~PATTERN
(send _ _ str*)
PATTERN
def_node_matcher :lambda_node?, <<~PATTERN
(send _ :lambda)
PATTERN
def_node_matcher :struct_constant_assignment?, <<~PATTERN
(casgn _ _ `(const _ :Struct))
(send _ {#{RESTRICT_ON_SEND.map(&:inspect).join(' ')}} str*)
PATTERN
def on_send(node)
return unless translation_method?(node)
method_name = node.children[1]
return unless TRANSLATION_METHODS.include?(method_name)
translation_memoized = false
static = true
memoized = false
node.each_ancestor do |ancestor|
receiver, _ = *ancestor
break if lambda_node?(receiver) # translations defined in lambda nodes should be allowed
if constant_assignment?(ancestor) && !struct_constant_assignment?(ancestor)
add_offense(node, location: :expression)
break
end
translation_memoized = true if memoization?(ancestor)
if translation_memoized && class_method_definition?(ancestor)
add_offense(node, location: :expression)
memoized = true if memoized?(ancestor)
if dynamic?(ancestor, memoized)
static = false
break
end
end
add_offense(node) if static
end
private
def constant_assignment?(node)
node.type == :casgn
end
def memoization?(node)
def memoized?(node)
node.type == :or_asgn
end
def class_method_definition?(node)
node.type == :defs
def dynamic?(node, memoized)
lambda_or_proc?(node) ||
named_block?(node) ||
instance_method_definition?(node) ||
unmemoized_class_method_definition?(node, memoized)
end
def lambda_or_proc?(node)
node.lambda_or_proc?
end
def named_block?(node)
return unless node.block_type?
!NON_METHOD_DEFINITIONS.include?(node.method_name) # rubocop:disable Rails/NegateInclude
end
def instance_method_definition?(node)
node.type == :def
end
def unmemoized_class_method_definition?(node, memoized)
node.type == :defs && !memoized
end
end
end

View File

@ -6,10 +6,11 @@ FactoryBot.define do
source { association(:project) }
maintainer
trait(:guest) { access_level { ProjectMember::GUEST } }
trait(:reporter) { access_level { ProjectMember::REPORTER } }
trait(:guest) { access_level { ProjectMember::GUEST } }
trait(:reporter) { access_level { ProjectMember::REPORTER } }
trait(:developer) { access_level { ProjectMember::DEVELOPER } }
trait(:maintainer) { access_level { ProjectMember::MAINTAINER } }
trait(:owner) { access_level { ProjectMember::OWNER } }
trait(:access_request) { requested_at { Time.now } }
trait(:invited) do

View File

@ -77,7 +77,7 @@ describe('fetchBoard', () => {
},
};
it('should commit mutation RECEIVE_BOARD_SUCCESS and dispatch setBoardConfig on success', async () => {
it('should commit mutation REQUEST_CURRENT_BOARD and dispatch setBoard on success', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
await testAction({
@ -87,12 +87,8 @@ describe('fetchBoard', () => {
{
type: types.REQUEST_CURRENT_BOARD,
},
{
type: types.RECEIVE_BOARD_SUCCESS,
payload: mockBoard,
},
],
expectedActions: [{ type: 'setBoardConfig', payload: mockBoard }],
expectedActions: [{ type: 'setBoard', payload: mockBoard }],
});
});
@ -978,10 +974,6 @@ describe('fetchItemsForList', () => {
{ listId },
state,
[
{
type: types.RESET_ITEMS_FOR_LIST,
payload: listId,
},
{
type: types.REQUEST_ITEMS_FOR_LIST,
payload: { listId, fetchNext: false },
@ -1003,10 +995,6 @@ describe('fetchItemsForList', () => {
{ listId },
state,
[
{
type: types.RESET_ITEMS_FOR_LIST,
payload: listId,
},
{
type: types.REQUEST_ITEMS_FOR_LIST,
payload: { listId, fetchNext: false },

View File

@ -300,24 +300,6 @@ describe('Board Store Mutations', () => {
});
});
describe('RESET_ITEMS_FOR_LIST', () => {
it('should remove issues from boardItemsByListId state', () => {
const listId = 'gid://gitlab/List/1';
const boardItemsByListId = {
[listId]: [mockIssue.id],
};
state = {
...state,
boardItemsByListId,
};
mutations[types.RESET_ITEMS_FOR_LIST](state, listId);
expect(state.boardItemsByListId[listId]).toEqual([]);
});
});
describe('REQUEST_ITEMS_FOR_LIST', () => {
const listId = 'gid://gitlab/List/1';
const boardItemsByListId = {

View File

@ -13,6 +13,7 @@ import {
trackCheckout,
trackTransaction,
trackAddToCartUsageTab,
getNamespaceId,
} from '~/google_tag_manager';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { logError } from '~/lib/logger';
@ -401,6 +402,7 @@ describe('~/google_tag_manager/index', () => {
{
brand: 'GitLab',
category: 'DevOps',
dimension36: 'not available',
id,
name,
price: revenue.toString(),
@ -478,4 +480,26 @@ describe('~/google_tag_manager/index', () => {
resetHTMLFixture();
});
});
describe('when getting the namespace_id from Snowplow standard context', () => {
describe('when window.gl.snowplowStandardContext.data.namespace_id has a value', () => {
beforeEach(() => {
window.gl = { snowplowStandardContext: { data: { namespace_id: '321' } } };
});
it('returns the value', () => {
expect(getNamespaceId()).toBe('321');
});
});
describe('when window.gl.snowplowStandardContext.data.namespace_id is undefined', () => {
beforeEach(() => {
window.gl = {};
});
it('returns a placeholder value', () => {
expect(getNamespaceId()).toBe('not available');
});
});
});
});

View File

@ -57,12 +57,12 @@ exports[`Alert integration settings form should match the default snapshot 1`] =
</gl-button-stub>
<gl-modal-stub
actioncancel="[object Object]"
actionprimary="[object Object]"
arialabel=""
dismisslabel="Close"
modalclass=""
modalid="resetWebhookModal"
ok-title="Reset webhook URL"
ok-variant="danger"
size="md"
title="Reset webhook URL"
titletag="h4"

View File

@ -47,7 +47,7 @@ describe('Alert integration settings form', () => {
resetWebhookUrl.mockResolvedValueOnce({
data: { pagerduty_webhook_url: newWebhookUrl },
});
findModal().vm.$emit('ok');
findModal().vm.$emit('primary');
await waitForPromises();
expect(resetWebhookUrl).toHaveBeenCalled();
expect(findWebhookInput().attributes('value')).toBe(newWebhookUrl);
@ -56,7 +56,7 @@ describe('Alert integration settings form', () => {
it('should show error message and NOT reset webhook url', async () => {
resetWebhookUrl.mockRejectedValueOnce();
findModal().vm.$emit('ok');
findModal().vm.$emit('primary');
await waitForPromises();
expect(findAlert().attributes('variant')).toBe('danger');
});

View File

@ -36,6 +36,7 @@ describe('Incident Tabs component', () => {
fullPath: '',
iid: '',
projectId: '',
issuableId: '',
uploadMetricsFeatureAvailable: true,
glFeatures: { incidentTimeline: true },
},
@ -48,6 +49,9 @@ describe('Incident Tabs component', () => {
alert: {
loading: true,
},
timelineEvents: {
loading: false,
},
},
},
},

View File

@ -0,0 +1,72 @@
export const mockEvents = [
{
action: 'comment',
author: {
__typename: 'UserCore',
id: 'gid://gitlab/User/1',
name: 'Administrator',
username: 'root',
},
createdAt: '2022-03-22T15:59:08Z',
id: 'gid://gitlab/IncidentManagement::TimelineEvent/132',
note: 'Dummy event 1',
noteHtml: '<p>Dummy event 1</p>',
occurredAt: '2022-03-22T15:59:00Z',
updatedAt: '2022-03-22T15:59:08Z',
__typename: 'TimelineEventType',
},
{
action: 'comment',
author: {
__typename: 'UserCore',
id: 'gid://gitlab/User/1',
name: 'Administrator',
username: 'root',
},
createdAt: '2022-03-23T14:57:08Z',
id: 'gid://gitlab/IncidentManagement::TimelineEvent/131',
note: 'Dummy event 2',
noteHtml: '<p>Dummy event 2</p>',
occurredAt: '2022-03-23T14:57:00Z',
updatedAt: '2022-03-23T14:57:08Z',
__typename: 'TimelineEventType',
},
{
action: 'comment',
author: {
__typename: 'UserCore',
id: 'gid://gitlab/User/1',
name: 'Administrator',
username: 'root',
},
createdAt: '2022-03-23T15:59:08Z',
id: 'gid://gitlab/IncidentManagement::TimelineEvent/132',
note: 'Dummy event 3',
noteHtml: '<p>Dummy event 3</p>',
occurredAt: '2022-03-23T15:59:00Z',
updatedAt: '2022-03-23T15:59:08Z',
__typename: 'TimelineEventType',
},
];
export const timelineEventsQueryListResponse = {
data: {
project: {
id: 'gid://gitlab/Project/8',
incidentManagementTimelineEvents: {
nodes: mockEvents,
},
},
},
};
export const timelineEventsQueryEmptyResponse = {
data: {
project: {
id: 'gid://gitlab/Project/8',
incidentManagementTimelineEvents: {
nodes: [],
},
},
},
};

View File

@ -0,0 +1,87 @@
import timezoneMock from 'timezone-mock';
import merge from 'lodash/merge';
import { GlIcon } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import IncidentTimelineEventListItem from '~/issues/show/components/incidents/timeline_events_list_item.vue';
import { mockEvents } from './mock_data';
describe('IncidentTimelineEventList', () => {
let wrapper;
const mountComponent = (propsData) => {
const { action, noteHtml, occurredAt } = mockEvents[0];
wrapper = mountExtended(
IncidentTimelineEventListItem,
merge({
propsData: {
action,
noteHtml,
occurredAt,
isLastItem: false,
...propsData,
},
}),
);
};
const findCommentIcon = () => wrapper.findComponent(GlIcon);
const findTextContainer = () => wrapper.findByTestId('event-text-container');
const findEventTime = () => wrapper.findByTestId('event-time');
describe('template', () => {
it('shows comment icon', () => {
mountComponent();
expect(findCommentIcon().exists()).toBe(true);
});
it('sets correct props for icon', () => {
mountComponent();
expect(findCommentIcon().props('name')).toBe(mockEvents[0].action);
});
it('displays the correct time', () => {
mountComponent();
expect(findEventTime().text()).toBe('15:59 UTC');
});
describe('last item in list', () => {
it('shows a bottom border when not the last item', () => {
mountComponent();
expect(findTextContainer().classes()).toContain('gl-border-1');
});
it('does not show a bottom border when the last item', () => {
mountComponent({ isLastItem: true });
expect(wrapper.classes()).not.toContain('gl-border-1');
});
});
describe.each`
timezone
${'Europe/London'}
${'US/Pacific'}
${'Australia/Adelaide'}
`('when viewing in timezone', ({ timezone }) => {
describe(timezone, () => {
beforeEach(() => {
timezoneMock.register(timezone);
mountComponent();
});
afterEach(() => {
timezoneMock.unregister();
});
it('displays the correct time', () => {
expect(findEventTime().text()).toBe('15:59 UTC');
});
});
});
});
});

View File

@ -0,0 +1,87 @@
import timezoneMock from 'timezone-mock';
import merge from 'lodash/merge';
import { shallowMountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
import IncidentTimelineEventList from '~/issues/show/components/incidents/timeline_events_list.vue';
import { mockEvents } from './mock_data';
describe('IncidentTimelineEventList', () => {
let wrapper;
const mountComponent = () => {
wrapper = shallowMountExtended(
IncidentTimelineEventList,
merge({
provide: {
fullPath: 'group/project',
issuableId: '1',
},
propsData: {
timelineEvents: mockEvents,
},
}),
);
};
const findGroups = () => wrapper.findAllByTestId('timeline-group');
const findItems = (base = wrapper) => base.findAllByTestId('timeline-event');
const findFirstGroup = () => extendedWrapper(findGroups().at(0));
const findSecondGroup = () => extendedWrapper(findGroups().at(1));
const findDates = () => wrapper.findAllByTestId('event-date');
describe('template', () => {
it('groups items correctly', () => {
mountComponent();
expect(findGroups()).toHaveLength(2);
expect(findItems(findFirstGroup())).toHaveLength(1);
expect(findItems(findSecondGroup())).toHaveLength(2);
});
it('sets the isLastItem prop correctly', () => {
mountComponent();
expect(findItems().at(0).props('isLastItem')).toBe(false);
expect(findItems().at(1).props('isLastItem')).toBe(false);
expect(findItems().at(2).props('isLastItem')).toBe(true);
});
it('sets the event props correctly', () => {
mountComponent();
expect(findItems().at(1).props('occurredAt')).toBe(mockEvents[1].occurredAt);
expect(findItems().at(1).props('action')).toBe(mockEvents[1].action);
expect(findItems().at(1).props('noteHtml')).toBe(mockEvents[1].noteHtml);
});
it('formats dates correctly', () => {
mountComponent();
expect(findDates().at(0).text()).toBe('2022-03-22');
expect(findDates().at(1).text()).toBe('2022-03-23');
});
describe.each`
timezone
${'Europe/London'}
${'US/Pacific'}
${'Australia/Adelaide'}
`('when viewing in timezone', ({ timezone }) => {
describe(timezone, () => {
beforeEach(() => {
timezoneMock.register(timezone);
mountComponent();
});
afterEach(() => {
timezoneMock.unregister();
});
it('displays the correct time', () => {
expect(findDates().at(0).text()).toBe('2022-03-22');
});
});
});
});
});

Some files were not shown because too many files have changed in this diff Show More