Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-09-28 15:09:17 +00:00
parent eb3a23aaaa
commit 1bb7f81e23
123 changed files with 2303 additions and 1371 deletions

View File

@ -39,7 +39,10 @@ will also determine whether the bug is fixed in a more recent version. -->
### Output of checks
<!-- If you are reporting a bug on GitLab.com, write: This bug happens on GitLab.com -->
<!-- If you are reporting a bug on GitLab.com, uncomment below -->
<!-- This bug happens on GitLab.com -->
<!-- /label ~"reproduced on GitLab.com" -->
#### Results of GitLab environment info

View File

@ -168,3 +168,4 @@ You can either [create a follow-up issue for Feature Flag Cleanup](https://gitla
```
/label ~"feature flag" ~"type::feature" ~"feature::addition"
/label ~group::

View File

@ -1,6 +1,6 @@
import $ from 'jquery';
import { merge } from 'lodash';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import FilesCommentButton from './files_comment_button';
@ -82,7 +82,7 @@ export default class Diff {
.get(link, { params })
.then(({ data }) => $target.parent().replaceWith(data))
.catch(() =>
createFlash({
createAlert({
message: __('An error occurred while loading diff'),
}),
);

View File

@ -11,7 +11,7 @@ import {
MR_COMMITS_NEXT_COMMIT,
MR_COMMITS_PREVIOUS_COMMIT,
} from '~/behaviors/shortcuts/keybindings';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import { isSingleViewStyle } from '~/helpers/diffs_helper';
import { helpPagePath } from '~/helpers/help_page_helper';
import { parseBoolean } from '~/lib/utils/common_utils';
@ -480,7 +480,7 @@ export default {
this.updateChangesTabCount();
})
.catch(() => {
createFlash({
createAlert({
message: __('Something went wrong on our end. Please try again!'),
});
});
@ -495,7 +495,7 @@ export default {
this.setDiscussions();
})
.catch(() => {
createFlash({
createAlert({
message: __('Something went wrong on our end. Please try again!'),
});
});

View File

@ -1,7 +1,7 @@
<script>
import { GlTooltipDirective, GlSafeHtmlDirective, GlIcon, GlLoadingIcon } from '@gitlab/ui';
import { mapActions } from 'vuex';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import { s__, sprintf } from '~/locale';
import { UNFOLD_COUNT, INLINE_DIFF_LINES_KEY } from '../constants';
import * as utils from '../store/utils';
@ -92,7 +92,7 @@ export default {
) {
this.loadMoreLines({ endpoint, params, lineNumbers, fileHash, isExpandDown, nextLineNumbers })
.catch(() => {
createFlash({
createAlert({
message: s__('Diffs|Something went wrong while fetching diff lines.'),
});
})

View File

@ -10,7 +10,7 @@ import { escape } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
import { IdState } from 'vendor/vue-virtual-scroller';
import DiffContent from 'jh_else_ce/diffs/components/diff_content.vue';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import { hasDiff } from '~/helpers/diffs_helper';
import { diffViewerErrors } from '~/ide/constants';
import { scrollToElement } from '~/lib/utils/common_utils';
@ -309,7 +309,7 @@ export default {
})
.catch(() => {
idState.isLoadingCollapsedDiff = false;
createFlash({
createAlert({
message: this.$options.i18n.genericError,
});
});

View File

@ -5,7 +5,7 @@ import {
historyPushState,
scrollToElement,
} from '~/lib/utils/common_utils';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import { diffViewerModes } from '~/ide/constants';
import axios from '~/lib/utils/axios_utils';
@ -246,7 +246,7 @@ export const fetchCoverageFiles = ({ commit, state }) => {
}
},
errorCallback: () =>
createFlash({
createAlert({
message: __('Something went wrong on our end. Please try again!'),
}),
});
@ -509,7 +509,7 @@ export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => {
.then(() => dispatch('updateResolvableDiscussionsCounts', null, { root: true }))
.then(() => dispatch('closeDiffFileCommentForm', formData.diffFile.file_hash))
.catch(() =>
createFlash({
createAlert({
message: s__('MergeRequests|Saving the comment failed'),
}),
);
@ -619,7 +619,7 @@ export const cacheTreeListWidth = (_, size) => {
export const receiveFullDiffError = ({ commit }, filePath) => {
commit(types.RECEIVE_FULL_DIFF_ERROR, filePath);
createFlash({
createAlert({
message: s__('MergeRequest|Error loading full diff. Please try again.'),
});
};
@ -757,7 +757,7 @@ export const setSuggestPopoverDismissed = ({ commit, state }) =>
commit(types.SET_SHOW_SUGGEST_POPOVER);
})
.catch(() => {
createFlash({
createAlert({
message: s__('MergeRequest|Error dismissing suggestion popover. Please try again.'),
});
});

View File

@ -1,7 +1,7 @@
import { KeyMod, KeyCode } from 'monaco-editor';
import { debounce } from 'lodash';
import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import { sanitize } from '~/lib/dompurify';
import axios from '~/lib/utils/axios_utils';
import syntaxHighlight from '~/syntax_highlight';
@ -152,7 +152,7 @@ export class EditorMarkdownPreviewExtension {
syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight'));
previewEl.style.display = 'block';
})
.catch(() => createFlash(BLOB_PREVIEW_ERROR));
.catch(() => createAlert(BLOB_PREVIEW_ERROR));
}
setupPreviewAction(instance) {

View File

@ -1,6 +1,6 @@
<script>
import { GlTooltipDirective, GlModal } from '@gitlab/ui';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import { __, s__, sprintf } from '~/locale';
import eventHub from '../event_hub';
import deleteEnvironmentMutation from '../graphql/mutations/delete_environment.mutation.graphql';
@ -65,11 +65,11 @@ export default {
.then(({ data }) => {
const [message] = data?.deleteEvironment?.errors ?? [];
if (message) {
createFlash({ message });
createAlert({ message });
}
})
.catch((error) =>
createFlash({
createAlert({
message: s__(
'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.',
),

View File

@ -10,7 +10,7 @@ import {
import { __, s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import deploymentDetails from '../graphql/queries/deployment_details.query.graphql';
import DeploymentStatusBadge from './deployment_status_badge.vue';
import Commit from './commit.vue';
@ -119,7 +119,7 @@ export default {
return data?.project?.deployment?.tags;
},
error(error) {
createFlash({
createAlert({
message: this.$options.i18n.LOAD_ERROR_MESSAGE,
captureError: true,
error,

View File

@ -1,5 +1,5 @@
<script>
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import EnvironmentForm from './environment_form.vue';
@ -39,7 +39,7 @@ export default {
.then(({ data: { path } }) => visitUrl(path))
.catch((error) => {
const message = error.response.data.message[0];
createFlash({ message });
createAlert({ message });
this.loading = false;
});
},

View File

@ -1,5 +1,5 @@
<script>
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import EnvironmentForm from './environment_form.vue';
@ -32,7 +32,7 @@ export default {
.then(({ data: { path } }) => visitUrl(path))
.catch((error) => {
const message = error.response.data.message[0];
createFlash({ message });
createAlert({ message });
this.loading = false;
});
},

View File

@ -3,7 +3,7 @@
*/
import { isEqual, isFunction, omitBy } from 'lodash';
import Visibility from 'visibilityjs';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import Poll from '~/lib/utils/poll';
import { getParameterByName } from '~/lib/utils/url_utility';
import { s__, __ } from '~/locale';
@ -94,7 +94,7 @@ export default {
errorCallback() {
this.isLoading = false;
createFlash({
createAlert({
message: s__('Environments|An error occurred while fetching the environments.'),
});
},
@ -123,7 +123,7 @@ export default {
})
.catch((err) => {
this.isLoading = false;
createFlash({
createAlert({
message: isFunction(errorMessage) ? errorMessage(err.response.data) : errorMessage,
});
});
@ -179,7 +179,7 @@ export default {
window.location.href = url.join('/');
})
.catch(() => {
createFlash({
createAlert({
message: errorMessage,
});
});

View File

@ -142,7 +142,7 @@ export default {
:edited-at="discussion.resolved_at"
:edited-by="discussion.resolved_by"
:action-text="resolvedText"
class-name="discussion-headline-light js-discussion-headline discussion-resolved-text gl-mb-2"
class-name="discussion-headline-light js-discussion-headline discussion-resolved-text gl-mb-2 gl-ml-3"
/>
</template>
<template #avatar-badge>

View File

@ -281,6 +281,7 @@ export default {
>
{{ __('Contributor') }}
</user-access-role-badge>
<span class="note-actions__mobile-spacer"></span>
<gl-button
v-if="canResolve"
ref="resolveButton"

View File

@ -0,0 +1,84 @@
<script>
import { GlCard, GlLink } from '@gitlab/ui';
import { s__ } from '~/locale';
import ProtectionRow from './protection_row.vue';
export const i18n = {
rolesTitle: s__('BranchRules|Roles'),
usersTitle: s__('BranchRules|Users'),
groupsTitle: s__('BranchRules|Groups'),
};
export default {
name: 'ProtectionDetail',
i18n,
components: { GlCard, GlLink, ProtectionRow },
props: {
header: {
type: String,
required: true,
},
headerLinkTitle: {
type: String,
required: true,
},
headerLinkHref: {
type: String,
required: true,
},
roles: {
type: Array,
required: false,
default: () => [],
},
users: {
type: Array,
required: false,
default: () => [],
},
groups: {
type: Array,
required: false,
default: () => [],
},
},
computed: {
showUsersDivider() {
return Boolean(this.roles.length);
},
showGroupsDivider() {
return Boolean(this.roles.length || this.users.length);
},
},
};
</script>
<template>
<gl-card class="gl-mb-5" body-class="gl-py-0">
<template #header>
<div class="gl-display-flex gl-justify-content-space-between">
<strong>{{ header }}</strong>
<gl-link :href="headerLinkHref" target="_blank">{{ headerLinkTitle }}</gl-link>
</div>
</template>
<!-- Roles -->
<protection-row v-if="roles.length" :title="$options.i18n.rolesTitle" :access-levels="roles" />
<!-- Users -->
<protection-row
v-if="users.length"
:show-divider="showUsersDivider"
:users="users"
:title="$options.i18n.usersTitle"
/>
<!-- Groups -->
<protection-row
v-if="groups.length"
:show-divider="showGroupsDivider"
:title="$options.i18n.groupsTitle"
:access-levels="groups"
/>
</gl-card>
</template>

View File

@ -0,0 +1,86 @@
<script>
import { GlAvatarsInline, GlAvatar, GlAvatarLink, GlTooltipDirective } from '@gitlab/ui';
import { n__ } from '~/locale';
const AVATAR_TOOLTIP_MAX_CHARS = 100;
export const MAX_VISIBLE_AVATARS = 4;
export const AVATAR_SIZE = 32;
export default {
name: 'ProtectionRow',
AVATAR_TOOLTIP_MAX_CHARS,
MAX_VISIBLE_AVATARS,
AVATAR_SIZE,
components: { GlAvatarsInline, GlAvatar, GlAvatarLink },
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
title: {
type: String,
required: false,
default: null,
},
accessLevels: {
type: Array,
required: false,
default: () => [],
},
showDivider: {
type: Boolean,
required: false,
default: false,
},
users: {
type: Array,
required: false,
default: () => [],
},
},
computed: {
avatarBadgeSrOnlyText() {
return n__(
'%d additional user',
'%d additional users',
this.users.length - this.$options.MAX_VISIBLE_AVATARS,
);
},
},
};
</script>
<template>
<div
class="gl-display-flex gl-align-items-center gl-border-gray-100 gl-mb-4 gl-pt-4"
:class="{ 'gl-border-t-solid': showDivider }"
>
<div class="gl-mr-7">{{ title }}</div>
<gl-avatars-inline
v-if="users.length"
:avatars="users"
:collapsed="true"
:max-visible="$options.MAX_VISIBLE_AVATARS"
:avatar-size="$options.AVATAR_SIZE"
badge-tooltip-prop="name"
:badge-tooltip-max-chars="$options.AVATAR_TOOLTIP_MAX_CHARS"
:badge-sr-only-text="avatarBadgeSrOnlyText"
>
<template #avatar="{ avatar }">
<gl-avatar-link
:key="avatar.username"
v-gl-tooltip
target="_blank"
:href="avatar.webUrl"
:title="avatar.name"
>
<gl-avatar :src="avatar.avatarUrl" :label="avatar.name" :size="$options.AVATAR_SIZE" />
</gl-avatar-link>
</template>
</gl-avatars-inline>
<div v-for="(item, index) in accessLevels" :key="index" data-testid="access-level">
{{ item.accessLevelDescription }}
</div>
</div>
</template>

View File

@ -74,7 +74,7 @@ export default {
</script>
<template>
<div>
<div class="gl-display-flex">
<gl-dropdown
v-if="tertiaryButtons.length"
:text="dropdownLabel"

View File

@ -4,6 +4,7 @@ import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_m
import { visitUrl } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../../event_hub';
import MRWidgetService from '../../services/mr_widget_service';
import {
MANUAL_DEPLOY,
@ -134,6 +135,7 @@ export default {
});
})
.finally(() => {
eventHub.$emit('FetchDeployments');
this.actionInProgress = null;
});
}

View File

@ -324,7 +324,7 @@ export default {
@mouseup="onRowMouseUp"
>
<div
class="media-body gl-display-flex gl-flex-direction-row! gl-align-self-center"
class="media-body gl-display-flex gl-flex-direction-row! gl-w-full"
data-testid="widget-extension-top-level"
>
<div class="gl-flex-grow-1" data-testid="widget-extension-top-level-summary">

View File

@ -506,6 +506,13 @@ export default {
eventHub.$on('DisablePolling', () => {
this.stopPolling();
});
eventHub.$on('FetchDeployments', () => {
this.fetchPreMergeDeployments();
if (this.shouldRenderMergedPipeline) {
this.fetchPostMergeDeployments();
}
});
},
dismissSuggestPipelines() {
this.mr.isDismissedSuggestPipeline = true;

View File

@ -3,7 +3,7 @@ import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
import { debounce, unescape } from 'lodash';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import GLForm from '~/gl_form';
import axios from '~/lib/utils/axios_utils';
import { stripHtml } from '~/lib/utils/text_utility';
@ -272,7 +272,7 @@ export default {
this.fetchMarkdown()
.then((data) => this.renderMarkdown(data))
.catch(() =>
createFlash({
createAlert({
message: __('Error loading markdown preview'),
}),
);
@ -315,7 +315,7 @@ export default {
this.$nextTick()
.then(() => $(this.$refs['markdown-preview']).renderGFM())
.catch(() =>
createFlash({
createAlert({
message: __('Error rendering Markdown preview'),
}),
);

View File

@ -1,7 +1,7 @@
<script>
import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import Vue from 'vue';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import { __ } from '~/locale';
import SuggestionDiff from './suggestion_diff.vue';
@ -91,7 +91,7 @@ export default {
const suggestionElements = container.querySelectorAll('.js-render-suggestion');
if (this.lineType === 'old') {
createFlash({
createAlert({
message: __('Unable to apply suggestions to a deleted line.'),
parent: this.$el,
});

View File

@ -1,4 +1,4 @@
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import { s__ } from '~/locale';
import * as types from './mutation_types';
@ -11,7 +11,7 @@ export const fetchImagesFactory = (service) => async ({ state, commit }) => {
commit(types.RECEIVE_METRIC_IMAGES_SUCCESS, response);
} catch (error) {
commit(types.RECEIVE_METRIC_IMAGES_ERROR);
createFlash({ message: s__('MetricImages|There was an issue loading metric images.') });
createAlert({ message: s__('MetricImages|There was an issue loading metric images.') });
}
};
@ -34,7 +34,7 @@ export const uploadImageFactory = (service) => async (
commit(types.RECEIVE_METRIC_UPLOAD_SUCCESS, response);
} catch (error) {
commit(types.RECEIVE_METRIC_UPLOAD_ERROR);
createFlash({ message: s__('MetricImages|There was an issue uploading your image.') });
createAlert({ message: s__('MetricImages|There was an issue uploading your image.') });
}
};
@ -57,7 +57,7 @@ export const updateImageFactory = (service) => async (
commit(types.RECEIVE_METRIC_UPDATE_SUCCESS, response);
} catch (error) {
commit(types.RECEIVE_METRIC_UPLOAD_ERROR);
createFlash({ message: s__('MetricImages|There was an issue updating your image.') });
createAlert({ message: s__('MetricImages|There was an issue updating your image.') });
}
};
@ -68,7 +68,7 @@ export const deleteImageFactory = (service) => async ({ state, commit }, imageId
await service.deleteMetricImage({ imageId, id: projectId, modelIid });
commit(types.RECEIVE_METRIC_DELETE_SUCCESS, imageId);
} catch (error) {
createFlash({ message: s__('MetricImages|There was an issue deleting the image.') });
createAlert({ message: s__('MetricImages|There was an issue deleting the image.') });
}
};

View File

@ -1,4 +1,4 @@
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import * as types from './mutation_types';
@ -16,7 +16,7 @@ export const receiveLabelsSuccess = ({ commit }, labels) =>
commit(types.RECEIVE_SET_LABELS_SUCCESS, labels);
export const receiveLabelsFailure = ({ commit }) => {
commit(types.RECEIVE_SET_LABELS_FAILURE);
createFlash({
createAlert({
message: __('Error fetching labels.'),
});
};
@ -38,7 +38,7 @@ export const requestCreateLabel = ({ commit }) => commit(types.REQUEST_CREATE_LA
export const receiveCreateLabelSuccess = ({ commit }) => commit(types.RECEIVE_CREATE_LABEL_SUCCESS);
export const receiveCreateLabelFailure = ({ commit }) => {
commit(types.RECEIVE_CREATE_LABEL_FAILURE);
createFlash({
createAlert({
message: __('Error creating label.'),
});
};

View File

@ -8,7 +8,7 @@ import {
GlLoadingIcon,
} from '@gitlab/ui';
import produce from 'immer';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import { __ } from '~/locale';
import { workspaceLabelsQueries } from '~/sidebar/constants';
import createLabelMutation from './graphql/create_label.mutation.graphql';
@ -129,7 +129,7 @@ export default {
this.$emit('hideCreateView');
}
} catch {
createFlash({ message: errorMessage });
createAlert({ message: errorMessage });
}
this.labelCreateInProgress = false;
},

View File

@ -1,7 +1,7 @@
<script>
import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import { workspaceLabelsQueries } from '~/sidebar/constants';
@ -62,7 +62,7 @@ export default {
},
update: (data) => data.workspace?.labels?.nodes || [],
error() {
createFlash({ message: __('Error fetching labels.') });
createAlert({ message: __('Error fetching labels.') });
},
},
},

View File

@ -2,7 +2,7 @@
import { debounce } from 'lodash';
import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql';
import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IssuableType } from '~/issues/constants';
@ -151,7 +151,7 @@ export default {
return data.workspace?.issuable;
},
error() {
createFlash({ message: __('Error fetching labels.') });
createAlert({ message: __('Error fetching labels.') });
},
subscribeToMore: {
document() {
@ -275,7 +275,7 @@ export default {
});
})
.catch((error) =>
createFlash({
createAlert({
message: __('An error occurred while updating labels.'),
captureError: true,
error,

View File

@ -53,7 +53,7 @@ export default {
},
computed: {
splitContent() {
return this.content.split('\n');
return this.content.split(/\r?\n/);
},
lineNumbers() {
return this.splitContent.length;

View File

@ -10,7 +10,7 @@ import {
GlAvatarLabeled,
} from '@gitlab/ui';
import { glEmojiTag } from '~/emoji';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import { followUser, unfollowUser } from '~/rest_api';
import { isUserBusy } from '~/set_status_modal/utils';
import Tracking from '~/tracking';
@ -141,7 +141,7 @@ export default {
await followUser(this.user.id);
this.$emit('follow');
} catch (error) {
createFlash({
createAlert({
message: I18N_ERROR_FOLLOW,
error,
captureError: true,
@ -161,7 +161,7 @@ export default {
await unfollowUser(this.user.id);
this.$emit('unfollow');
} catch (error) {
createFlash({
createAlert({
message: I18N_ERROR_UNFOLLOW,
error,
captureError: true,

View File

@ -1,6 +1,6 @@
<script>
import { reportTypeToSecurityReportTypeEnum } from 'ee_else_ce/vue_shared/security_reports/constants';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import { s__ } from '~/locale';
import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue';
import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql';
@ -67,7 +67,7 @@ export default {
},
methods: {
showError(error) {
createFlash({
createAlert({
message: this.$options.i18n.apiError,
captureError: true,
error,

View File

@ -1,6 +1,6 @@
<script>
import { mapActions, mapGetters } from 'vuex';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import { s__ } from '~/locale';
import ReportSection from '~/reports/components/report_section.vue';
import { ERROR, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '~/reports/constants';
@ -160,7 +160,7 @@ export default {
this.fetchCounts();
},
showError(error) {
createFlash({
createAlert({
message: this.$options.i18n.apiError,
captureError: true,
error,

View File

@ -98,7 +98,14 @@ $system-note-svg-size: 1rem;
border-left: 1px solid $border-color;
border-right: 1px solid $border-color;
background-color: $white;
padding: $gl-padding-8 $gl-padding-8 $gl-padding-8 $gl-padding;
.timeline-content {
padding: $gl-padding-8 $gl-padding-8 $gl-padding-8 $gl-padding;
}
.timeline-avatar {
margin: $gl-padding-8 0 0 $gl-padding;
}
.timeline-discussion-body {
margin-left: 2rem;
@ -252,7 +259,7 @@ $system-note-svg-size: 1rem;
}
.note-body {
padding: $gl-padding-8;
padding: 0 $gl-padding-8 $gl-padding-8;
overflow-x: auto;
overflow-y: hidden;
@ -281,7 +288,7 @@ $system-note-svg-size: 1rem;
padding: $gl-padding-8 0;
margin: $gl-padding 0;
background-color: transparent;
font-size: $gl-font-size-sm;
font-size: $gl-font-size;
.note-header-info {
padding-bottom: 0;
@ -815,17 +822,20 @@ $system-note-svg-size: 1rem;
}
.note-actions {
align-self: flex-start;
justify-content: flex-end;
flex-shrink: 1;
display: inline-flex;
align-items: center;
margin-left: 10px;
margin-left: $gl-padding-8;
color: $gray-400;
@include notes-media('max', map-get($grid-breakpoints, sm) - 1) {
justify-content: flex-start;
float: none;
margin-left: 0;
.note-actions__mobile-spacer {
flex-grow: 1;
}
}
}

View File

@ -8,38 +8,37 @@ module BoardsActions
include BoardsResponses
before_action :authorize_read_board!, only: [:index, :show]
before_action :boards, only: :index
before_action :board, only: :show
before_action :redirect_to_recent_board, only: [:index]
before_action :board, only: [:index, :show]
before_action :push_licensed_features, only: [:index, :show]
end
def index
respond_with_boards
# if no board exists, create one
@board = board_create_service.execute.payload unless board # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
def show
# Add / update the board in the recent visits table
board_visit_service.new(parent, current_user).execute(board) if request.format.html?
return render_404 unless board
respond_with_board
# Add / update the board in the recent visits table
board_visit_service.new(parent, current_user).execute(board)
end
private
# Noop on FOSS
def push_licensed_features
def redirect_to_recent_board
return if !parent.multiple_issue_boards_available? || !latest_visited_board
redirect_to board_path(latest_visited_board.board)
end
def boards
strong_memoize(:boards) do
existing_boards = boards_finder.execute
if existing_boards.any?
existing_boards
else
# if no board exists, create one
[board_create_service.execute.payload]
end
end
def latest_visited_board
@latest_visited_board ||= Boards::VisitsFinder.new(parent, current_user).latest
end
# Noop on FOSS
def push_licensed_features
end
def board
@ -48,21 +47,9 @@ module BoardsActions
end
end
def board_type
board_klass.to_type
end
def board_visit_service
Boards::Visits::CreateService
end
def serializer
BoardSerializer.new(current_user: current_user)
end
def serialize_as_json(resource)
serializer.represent(resource, serializer: 'board', include_full_project_path: board.group_board?)
end
end
BoardsActions.prepend_mod_with('BoardsActions')

View File

@ -60,35 +60,6 @@ module BoardsResponses
def authorize_action_for!(resource, ability)
return render_403 unless can?(current_user, ability, resource)
end
def respond_with_boards
respond_with(@boards) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
def respond_with_board
# rubocop:disable Gitlab/ModuleWithInstanceVariables
return render_404 unless @board
respond_with(@board)
# rubocop:enable Gitlab/ModuleWithInstanceVariables
end
def serialize_as_json(resource)
serializer.represent(resource).as_json
end
def respond_with(resource)
respond_to do |format|
format.html
format.json do
render json: serialize_as_json(resource)
end
end
end
def serializer
BoardSerializer.new
end
end
BoardsResponses.prepend_mod_with('BoardsResponses')

View File

@ -1,93 +0,0 @@
# frozen_string_literal: true
module MultipleBoardsActions
include Gitlab::Utils::StrongMemoize
extend ActiveSupport::Concern
included do
include BoardsActions
before_action :redirect_to_recent_board, only: [:index]
before_action :authenticate_user!, only: [:recent]
before_action :authorize_create_board!, only: [:create]
before_action :authorize_admin_board!, only: [:create, :update, :destroy]
end
def recent
recent_visits = ::Boards::VisitsFinder.new(parent, current_user).latest(Board::RECENT_BOARDS_SIZE)
recent_boards = recent_visits.map(&:board)
render json: serialize_as_json(recent_boards)
end
def create
response = Boards::CreateService.new(parent, current_user, board_params).execute
respond_to do |format|
format.json do
board = response.payload
if response.success?
extra_json = { board_path: board_path(board) }
render json: serialize_as_json(board).merge(extra_json)
else
render json: board.errors, status: :unprocessable_entity
end
end
end
end
def update
service = Boards::UpdateService.new(parent, current_user, board_params)
respond_to do |format|
format.json do
if service.execute(board)
extra_json = { board_path: board_path(board) }
render json: serialize_as_json(board).merge(extra_json)
else
render json: board.errors, status: :unprocessable_entity
end
end
end
end
def destroy
service = Boards::DestroyService.new(parent, current_user)
service.execute(board)
respond_to do |format|
format.json { head :ok }
format.html { redirect_to boards_path, status: :found }
end
end
private
def redirect_to_recent_board
return unless board_type == Board.to_type
return if request.format.json? || !parent.multiple_issue_boards_available? || !latest_visited_board
redirect_to board_path(latest_visited_board.board)
end
def latest_visited_board
@latest_visited_board ||= Boards::VisitsFinder.new(parent, current_user).latest
end
def authorize_create_board!
check_multiple_group_issue_boards_available! if group?
end
def authorize_admin_board!
return render_404 unless can?(current_user, :admin_issue_board, parent)
end
def serializer
BoardSerializer.new(current_user: current_user)
end
def serialize_as_json(resource)
serializer.represent(resource, serializer: 'board', include_full_project_path: board.group_board?)
end
end

View File

@ -20,16 +20,6 @@ class Groups::BoardsController < Groups::ApplicationController
private
def board_klass
Board
end
def boards_finder
strong_memoize :boards_finder do
Boards::BoardsFinder.new(parent, current_user)
end
end
def board_finder
strong_memoize :board_finder do
Boards::BoardsFinder.new(parent, current_user, board_id: params[:id])

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
module JiraConnect
class PublicKeysController < ::ApplicationController
# This is not inheriting from JiraConnect::Application controller because
# it doesn't need to handle JWT authentication.
feature_category :integrations
skip_before_action :authenticate_user!
def show
return render_404 if Feature.disabled?(:jira_connect_oauth_self_managed) || !Gitlab.com?
render plain: public_key.key
end
private
def public_key
JiraConnect::PublicKey.find(params[:id])
end
end
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
class Projects::BoardsController < Projects::ApplicationController
include MultipleBoardsActions
include BoardsActions
include IssuableCollections
before_action :check_issues_available!
@ -20,16 +20,6 @@ class Projects::BoardsController < Projects::ApplicationController
private
def board_klass
Board
end
def boards_finder
strong_memoize :boards_finder do
Boards::BoardsFinder.new(parent, current_user)
end
end
def board_finder
strong_memoize :board_finder do
Boards::BoardsFinder.new(parent, current_user, board_id: params[:id])

View File

@ -41,9 +41,9 @@ class Projects::PagesDomainsController < Projects::ApplicationController
end
def create
@domain = @project.pages_domains.create(create_params)
@domain = PagesDomains::CreateService.new(@project, current_user, create_params).execute
if @domain.valid?
if @domain&.persisted?
redirect_to project_pages_domain_path(@project, @domain)
else
render 'new'
@ -63,7 +63,9 @@ class Projects::PagesDomainsController < Projects::ApplicationController
end
def destroy
@domain.destroy
PagesDomains::DeleteService
.new(@project, current_user)
.execute(@domain)
respond_to do |format|
format.html do

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
module PagesDomains
class PagesDomainCreatedEvent < ::Gitlab::EventStore::Event
def schema
{
'type' => 'object',
'properties' => {
'project_id' => { 'type' => 'integer' },
'namespace_id' => { 'type' => 'integer' },
'root_namespace_id' => { 'type' => 'integer' },
'domain' => { 'type' => 'string' }
},
'required' => %w[project_id namespace_id root_namespace_id]
}
end
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
module PagesDomains
class PagesDomainDeletedEvent < ::Gitlab::EventStore::Event
def schema
{
'type' => 'object',
'properties' => {
'project_id' => { 'type' => 'integer' },
'namespace_id' => { 'type' => 'integer' },
'root_namespace_id' => { 'type' => 'integer' },
'domain' => { 'type' => 'string' }
},
'required' => %w[project_id namespace_id root_namespace_id]
}
end
end
end

View File

@ -2,7 +2,7 @@
module BoardsHelper
def board
@board ||= @board || @boards.first
@board
end
def board_data
@ -125,14 +125,6 @@ module BoardsHelper
def can_admin_issue?
can?(current_user, :admin_issue, current_board_parent)
end
def serializer
CurrentBoardSerializer.new
end
def current_board_json
serializer.represent(board).as_json
end
end
BoardsHelper.prepend_mod_with('BoardsHelper')

View File

@ -0,0 +1,48 @@
# frozen_string_literal: true
module JiraConnect
class PublicKey
# Public keys are created with JWT tokens via JiraConnect::CreateAsymmetricJwtService
# They need to be available for third party applications to verify the token.
# This should happen right after the application received the token so public keys
# only need to exist for a few minutes.
REDIS_EXPIRY_TIME = 5.minutes.to_i.freeze
attr_reader :key, :uuid
def self.create!(key:)
new(key: key, uuid: Gitlab::UUID.v5(SecureRandom.hex)).save!
end
def self.find(uuid)
Gitlab::Redis::SharedState.with do |redis|
key = redis.get(redis_key(uuid))
raise ActiveRecord::RecordNotFound if key.nil?
new(key: key, uuid: uuid)
end
end
def initialize(key:, uuid:)
key = OpenSSL::PKey.read(key) unless key.is_a?(OpenSSL::PKey::RSA)
@key = key.to_s
@uuid = uuid
rescue OpenSSL::PKey::PKeyError
raise ArgumentError, 'Invalid public key'
end
def save!
Gitlab::Redis::SharedState.with do |redis|
redis.set(self.class.redis_key(uuid), key, ex: REDIS_EXPIRY_TIME)
end
self
end
def self.redis_key(uuid)
"JiraConnect:public_key:uuid=#{uuid}"
end
end
end

View File

@ -33,4 +33,20 @@ class JiraConnectInstallation < ApplicationRecord
instance_url
end
def audience_url
return unless proxy?
Gitlab::Utils.append_path(instance_url, '/-/jira_connect')
end
def audience_installed_event_url
return unless proxy?
Gitlab::Utils.append_path(instance_url, '/-/jira_connect/events/installed')
end
def proxy?
instance_url.present?
end
end

View File

@ -1,5 +0,0 @@
# frozen_string_literal: true
class BoardSerializer < BaseSerializer
entity BoardSimpleEntity
end

View File

@ -1,8 +0,0 @@
# frozen_string_literal: true
class BoardSimpleEntity < Grape::Entity
expose :id
expose :name
end
BoardSimpleEntity.prepend_mod_with('BoardSimpleEntity')

View File

@ -1,10 +0,0 @@
# frozen_string_literal: true
class CurrentBoardEntity < Grape::Entity
expose :id
expose :name
expose :hide_backlog_list
expose :hide_closed_list
end
CurrentBoardEntity.prepend_mod_with('CurrentBoardEntity')

View File

@ -1,5 +0,0 @@
# frozen_string_literal: true
class CurrentBoardSerializer < BaseSerializer
entity CurrentBoardEntity
end

View File

@ -0,0 +1,51 @@
# frozen_string_literal: true
module JiraConnect
class CreateAsymmetricJwtService
ARGUMENT_ERROR_MESSAGE = 'jira_connect_installation is not a proxy installation'
def initialize(jira_connect_installation)
raise ArgumentError, ARGUMENT_ERROR_MESSAGE unless jira_connect_installation.proxy?
@jira_connect_installation = jira_connect_installation
end
def execute
JWT.encode(jwt_claims, private_key, 'RS256', jwt_headers)
end
private
def jwt_claims
{ aud: aud_claim, iss: iss_claim, qsh: qsh_claim }
end
def aud_claim
@jira_connect_installation.audience_url
end
def iss_claim
@jira_connect_installation.client_key
end
def qsh_claim
Atlassian::Jwt.create_query_string_hash(
@jira_connect_installation.audience_installed_event_url,
'POST',
@jira_connect_installation.audience_url
)
end
def private_key
@private_key ||= OpenSSL::PKey::RSA.generate(3072)
end
def public_key_storage
@public_key_storage ||= JiraConnect::PublicKey.create!(key: private_key.public_key)
end
def jwt_headers
{ kid: public_key_storage.uuid }
end
end
end

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
module PagesDomains
class CreateService < BaseService
def execute
return unless authorized?
domain = project.pages_domains.create(params)
publish_event(domain) if domain.persisted?
domain
end
private
def authorized?
current_user.can?(:update_pages, project)
end
def publish_event(domain)
event = PagesDomainCreatedEvent.new(
data: {
project_id: project.id,
namespace_id: project.namespace_id,
root_namespace_id: project.root_namespace.id,
domain: domain.domain
}
)
Gitlab::EventStore.publish(event)
end
end
end

View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
module PagesDomains
class DeleteService < BaseService
def execute(domain)
return unless authorized?
domain.destroy
publish_event(domain)
end
private
def authorized?
current_user.can?(:update_pages, project)
end
def publish_event(domain)
event = PagesDomainDeletedEvent.new(
data: {
project_id: project.id,
namespace_id: project.namespace_id,
root_namespace_id: project.root_namespace.id,
domain: domain.domain
}
)
Gitlab::EventStore.publish(event)
end
end
end

View File

@ -60,23 +60,6 @@ module Projects
service.execute(container_repository)
end
def can_destroy?
return true if container_expiration_policy
can?(current_user, :destroy_container_image, project)
end
def valid_regex?
%w[name_regex_delete name_regex name_regex_keep].each do |param_name|
regex = params[param_name]
::Gitlab::UntrustedRegexp.new(regex) unless regex.blank?
end
true
rescue RegexpError => e
::Gitlab::ErrorTracking.log_exception(e, project_id: project.id)
false
end
def older_than
params['older_than']
end

View File

@ -2,101 +2,58 @@
module Projects
module ContainerRepository
class CleanupTagsService < CleanupTagsBaseService
def initialize(container_repository:, current_user: nil, params: {})
super
@params = params.dup
@counts = { cached_tags_count: 0 }
end
class CleanupTagsService < BaseContainerRepositoryService
def execute
return error('access denied') unless can_destroy?
return error('invalid regex') unless valid_regex?
tags = container_repository.tags
@counts[:original_size] = tags.size
filter_out_latest!(tags)
filter_by_name!(tags)
tags = truncate(tags)
populate_from_cache(tags)
tags = filter_keep_n(tags)
tags = filter_by_older_than(tags)
@counts[:before_delete_size] = tags.size
delete_tags(tags).merge(@counts).tap do |result|
result[:deleted_size] = result[:deleted]&.size
result[:status] = :error if @counts[:before_truncate_size] != @counts[:after_truncate_size]
end
cleanup_tags_service_class.new(container_repository: container_repository, current_user: current_user, params: params)
.execute
end
private
def filter_keep_n(tags)
tags, tags_to_keep = partition_by_keep_n(tags)
def cleanup_tags_service_class
log_data = {
container_repository_id: container_repository.id,
container_repository_path: container_repository.path,
project_id: project.id
}
cache_tags(tags_to_keep)
tags
end
def filter_by_older_than(tags)
tags, tags_to_keep = partition_by_older_than(tags)
cache_tags(tags_to_keep)
tags
end
def pushed_at(tag)
tag.created_at
end
def truncate(tags)
@counts[:before_truncate_size] = tags.size
@counts[:after_truncate_size] = tags.size
return tags if max_list_size == 0
# truncate the list to make sure that after the #filter_keep_n
# execution, the resulting list will be max_list_size
truncated_size = max_list_size + keep_n_as_integer
return tags if tags.size <= truncated_size
tags = tags.sample(truncated_size)
@counts[:after_truncate_size] = tags.size
tags
end
def populate_from_cache(tags)
@counts[:cached_tags_count] = cache.populate(tags) if caching_enabled?
end
def cache_tags(tags)
cache.insert(tags, older_than_in_seconds) if caching_enabled?
end
def cache
strong_memoize(:cache) do
::Gitlab::ContainerRepository::Tags::Cache.new(container_repository)
if use_gitlab_service?
log_info(log_data.merge(gitlab_cleanup_tags_service: true))
::Projects::ContainerRepository::Gitlab::CleanupTagsService
else
log_info(log_data.merge(third_party_cleanup_tags_service: true))
::Projects::ContainerRepository::ThirdParty::CleanupTagsService
end
end
def caching_enabled?
result = ::Gitlab::CurrentSettings.current_application_settings.container_registry_expiration_policies_caching &&
container_expiration_policy &&
older_than.present?
!!result
def use_gitlab_service?
Feature.enabled?(:container_registry_new_cleanup_service, project) &&
container_repository.migrated? &&
container_repository.gitlab_api_client.supports_gitlab_api?
end
def max_list_size
::Gitlab::CurrentSettings.current_application_settings.container_registry_cleanup_tags_service_max_list_size.to_i
def can_destroy?
return true if container_expiration_policy
can?(current_user, :destroy_container_image, project)
end
def valid_regex?
%w[name_regex_delete name_regex name_regex_keep].each do |param_name|
regex = params[param_name]
::Gitlab::UntrustedRegexp.new(regex) unless regex.blank?
end
true
rescue RegexpError => e
::Gitlab::ErrorTracking.log_exception(e, project_id: project.id)
false
end
def container_expiration_policy
params['container_expiration_policy']
end
end
end

View File

@ -14,9 +14,6 @@ module Projects
end
def execute
return error('access denied') unless can_destroy?
return error('invalid regex') unless valid_regex?
with_timeout do |start_time, result|
container_repository.each_tags_page(page_size: TAGS_PAGE_SIZE) do |tags|
execute_for_tags(tags, result)

View File

@ -0,0 +1,106 @@
# frozen_string_literal: true
module Projects
module ContainerRepository
module ThirdParty
class CleanupTagsService < CleanupTagsBaseService
def initialize(container_repository:, current_user: nil, params: {})
super
@params = params.dup
@counts = { cached_tags_count: 0 }
end
def execute
tags = container_repository.tags
@counts[:original_size] = tags.size
filter_out_latest!(tags)
filter_by_name!(tags)
tags = truncate(tags)
populate_from_cache(tags)
tags = filter_keep_n(tags)
tags = filter_by_older_than(tags)
@counts[:before_delete_size] = tags.size
delete_tags(tags).merge(@counts).tap do |result|
result[:deleted_size] = result[:deleted]&.size
result[:status] = :error if @counts[:before_truncate_size] != @counts[:after_truncate_size]
end
end
private
def filter_keep_n(tags)
tags, tags_to_keep = partition_by_keep_n(tags)
cache_tags(tags_to_keep)
tags
end
def filter_by_older_than(tags)
tags, tags_to_keep = partition_by_older_than(tags)
cache_tags(tags_to_keep)
tags
end
def pushed_at(tag)
tag.created_at
end
def truncate(tags)
@counts[:before_truncate_size] = tags.size
@counts[:after_truncate_size] = tags.size
return tags if max_list_size == 0
# truncate the list to make sure that after the #filter_keep_n
# execution, the resulting list will be max_list_size
truncated_size = max_list_size + keep_n_as_integer
return tags if tags.size <= truncated_size
tags = tags.sample(truncated_size)
@counts[:after_truncate_size] = tags.size
tags
end
def populate_from_cache(tags)
@counts[:cached_tags_count] = cache.populate(tags) if caching_enabled?
end
def cache_tags(tags)
cache.insert(tags, older_than_in_seconds) if caching_enabled?
end
def cache
strong_memoize(:cache) do
::Gitlab::ContainerRepository::Tags::Cache.new(container_repository)
end
end
def caching_enabled?
result = current_application_settings.container_registry_expiration_policies_caching &&
container_expiration_policy &&
older_than.present?
!!result
end
def max_list_size
current_application_settings.container_registry_cleanup_tags_service_max_list_size.to_i
end
def current_application_settings
::Gitlab::CurrentSettings.current_application_settings
end
end
end
end
end

View File

@ -1,13 +1,17 @@
- if local_assigns.has_key?(:contributed_projects) && contributed_projects.present?
.card.contributed-projects
.card-header= _('Projects contributed to')
= render 'shared/projects/list',
projects: contributed_projects.sort_by(&:star_count).reverse,
projects_limit: 5, stars: true, avatar: false
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-0' }) do |c|
- c.header do
= _('Projects contributed to')
- c.body do
= render 'shared/projects/list',
projects: contributed_projects.sort_by(&:star_count).reverse,
projects_limit: 5, stars: true, avatar: false
- if local_assigns.has_key?(:projects) && projects.present?
.card
.card-header= _('Personal projects')
= render 'shared/projects/list',
projects: projects.sort_by(&:star_count).reverse,
projects_limit: 10, stars: true, avatar: false
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-0' }) do |c|
- c.header do
= _('Personal projects')
- c.body do
= render 'shared/projects/list',
projects: projects.sort_by(&:star_count).reverse,
projects_limit: 10, stars: true, avatar: false

View File

@ -1 +1 @@
= render "shared/boards/show", board: @boards.first
= render "shared/boards/show", board: @board

View File

@ -2,45 +2,49 @@
- page_title _("Projects")
- @content_class = "limit-container-width" unless fluid_layout
.card.gl-mt-3.js-search-settings-section
.card-header
%strong= @group.name
projects:
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mt-3 js-search-settings-section' }, header_options: { class: 'gl-display-flex' }, body_options: { class: 'gl-py-0' }) do |c|
- c.header do
.gl-flex-grow-1
%strong= @group.name
projects:
- if can? current_user, :admin_group, @group
.controls
= link_to new_project_path(namespace_id: @group.id), class: "btn gl-button btn-sm btn-confirm" do
New project
%ul.projects-list.content-list.group-settings-projects
- @projects.each do |project|
%li.project-row{ class: ('no-description' if project.description.blank?) }
.controls
= link_to _('Members'), project_project_members_path(project), id: "edit_#{dom_id(project)}", class: "btn gl-button"
= link_to _('Edit'), edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn gl-button"
= render 'delete_project_button', project: project
- c.body do
%ul.projects-list.content-list.group-settings-projects
- @projects.each do |project|
%li.project-row{ class: ('no-description' if project.description.blank?) }
.controls
= render Pajamas::ButtonComponent.new(href: project_project_members_path(project), button_options: { id: "edit_#{dom_id(project)}" }) do
= _('Members')
= render Pajamas::ButtonComponent.new(href: edit_project_path(project), button_options: { id: "edit_#{dom_id(project)}" }) do
= _('Edit')
= render 'delete_project_button', project: project
.stats
= gl_badge_tag storage_counter(project.statistics&.storage_size)
= render 'project_badges', project: project
.stats
= gl_badge_tag storage_counter(project.statistics&.storage_size)
= render 'project_badges', project: project
.title
= link_to project_path(project), class: 'js-prefetch-document' do
.dash-project-avatar
.avatar-container.rect-avatar.s40
= project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40)
%span.project-full-name
%span.namespace-name
- if project.namespace
= project.namespace.human_name
\/
%span.project-name
= project.name
%span{ class: visibility_level_color(project.visibility_level) }
= visibility_level_icon(project.visibility_level)
.title
= link_to project_path(project), class: 'js-prefetch-document' do
.dash-project-avatar
.avatar-container.rect-avatar.s40
= project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40)
%span.project-full-name
%span.namespace-name
- if project.namespace
= project.namespace.human_name
\/
%span.project-name
= project.name
%span{ class: visibility_level_color(project.visibility_level) }
= visibility_level_icon(project.visibility_level)
- if project.description.present?
.description
= markdown_field(project, :description)
- if @projects.blank?
.nothing-here-block This group has no projects yet
- if project.description.present?
.description
= markdown_field(project, :description)
- if @projects.blank?
.nothing-here-block This group has no projects yet
= paginate @projects, theme: "gitlab"

View File

@ -1 +1 @@
= render "shared/boards/show", board: @boards.first
= render "shared/boards/show", board: @board

View File

@ -1,7 +1,7 @@
by
%a{ href: user_path(@build.user) }
%span.d-none.d-sm-inline
= image_tag avatar_icon_for_user(@build.user, 24), class: "avatar s24"
= render Pajamas::AvatarComponent.new(@build.user, size: 24, alt: "")
%strong{ data: { toggle: 'tooltip', placement: 'top', title: @build.user.to_reference } }
= @build.user.name
%strong.d-inline.d-sm-none= @build.user.to_reference

View File

@ -0,0 +1,8 @@
---
name: container_registry_new_cleanup_service
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/98651
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/375037
milestone: '15.5'
type: development
group: group::package
default_enabled: false

View File

@ -14,6 +14,7 @@ namespace :jira_connect do
resources :subscriptions, only: [:index, :create, :destroy]
resources :branches, only: [:new]
resources :public_keys, only: :show
resources :installations, only: [:index] do
collection do

View File

@ -223,11 +223,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end
end
resources :boards, only: [:index, :show, :create, :update, :destroy], constraints: { id: /\d+/ } do
collection do
get :recent
end
end
resources :boards, only: [:index, :show], constraints: { id: /\d+/ }
get 'releases/permalink/latest(/)(*suffix_path)', to: 'releases#latest_permalink', as: :latest_release_permalink, format: false

View File

@ -165,7 +165,7 @@ This setting limits global search requests as follows:
| Authenticated user | 30 |
| Unauthenticated user | 10 |
Depending on the number of enabled [scopes](../user/search/advanced_search.md#global-search-scopes), a global search request can consume two to seven requests per minute. You may want to disable one or more scopes to use fewer requests. Global search requests that exceed the search rate limit per minute return the following error:
Depending on the number of enabled [scopes](../user/search/index.md#global-search-scopes), a global search request can consume two to seven requests per minute. You may want to disable one or more scopes to use fewer requests. Global search requests that exceed the search rate limit per minute return the following error:
```plaintext
This endpoint has been requested too many times. Try again later.

View File

@ -260,3 +260,8 @@ For very active repositories with a large number of references and files, you ca
- Optimize your CI/CD jobs by seeding repository data in a pre-clone step with the
[`pre_clone_script`](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runners-section) of GitLab Runner. See
[SaaS runners on Linux](../runners/saas/linux_saas_runner.md#pre-clone-script) for details.
Besides speeding up pipelines in large and active projects,
seeding the repository data also helps avoid
`429 Too many requests` errors from Cloudflare.
This error can occur if you have many runners behind a single,
NAT'ed IP address that pulls from GitLab.com.

View File

@ -16,6 +16,6 @@ When implementing new features, please refer to these existing features to avoid
- [CODEOWNERS](../user/project/code_owners.md#set-up-code-owners): `.gitlab/CODEOWNERS`.
- [Route Maps](../ci/review_apps/index.md#route-maps): `.gitlab/route-map.yml`.
- [Customize Auto DevOps Helm Values](../topics/autodevops/customize.md#customize-values-for-helm-chart): `.gitlab/auto-deploy-values.yaml`.
- [Insights](../user/project/insights/index.md#configure-your-insights): `.gitlab/insights.yml`.
- [Insights](../user/project/insights/index.md#configure-project-insights): `.gitlab/insights.yml`.
- [Service Desk Templates](../user/project/service_desk.md#using-customized-email-templates): `.gitlab/service_desk_templates/`.
- [Web IDE](../user/project/web_ide/index.md#web-ide-configuration-file): `.gitlab/.gitlab-webide.yml`.

View File

@ -35,7 +35,7 @@ You can also create custom Insights charts that are more relevant for your group
To customize your Insights:
1. Create a new file [`.gitlab/insights.yml`](../../project/insights/index.md#writing-your-gitlabinsightsyml)
1. Create a new file [`.gitlab/insights.yml`](../../project/insights/index.md#configure-project-insights)
in a project that belongs to your group.
1. On the top bar, select **Main menu > Groups** and find your group.
1. On the left sidebar, select **Settings > General**.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

@ -4,61 +4,65 @@ group: Optimize
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments
---
# Insights **(ULTIMATE)**
# Insights for projects **(ULTIMATE)**
> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/725) in GitLab 12.0.
Configure project insights to explore data such as:
Configure the Insights that matter for your projects to explore data such as
triage hygiene, issues created/closed per a given period, average time for merge
requests to be merged and much more.
- Issues created and closed during a specified period.
- Average time for merge requests to be merged.
- Triage hygiene.
![Insights example bar chart](img/project_insights.png)
Insights are also available for [groups](../../group/insights/index.md).
NOTE:
This feature is [also available at the group level](../../group/insights/index.md).
## View project insights
## View your project's Insights
Prerequisites:
You can access your project's Insights by clicking the **Analytics > Insights**
link in the left sidebar.
- You must have:
- Access to a project to view information about its merge requests and issues.
- Permission to view confidential merge requests and issues in the project.
## Configure your Insights
To view project insights:
Insights are configured using a YAML file called `.gitlab/insights.yml` within
a project. That file is used in the project's Insights page.
1. On the top bar, select **Main menu > Projects** and find your project.
1. On the left sidebar, select **Analytics > Insights**.
1. To view a report, select the **Select page** dropdown list.
See [Writing your `.gitlab/insights.yml`](#writing-your-gitlabinsightsyml) below
for details about the content of this file.
## Configure project insights
NOTE:
After the configuration file is created, you can also
[use it for your project's group](../../group/insights/index.md#configure-your-insights).
Prerequisites:
NOTE:
If the project doesn't have any configuration file, it attempts to use
the group configuration if possible. If the group doesn't have any
configuration, the default configuration is used.
- Depending on your project configuration, you must have at least the Developer role.
## Permissions
Project insights are configured with the [`.gitlab/insights.yml`](#insights-configuration-file) file in the project. If a project doesn't have a configuration file, it uses the [group configuration](../../group/insights/index.md#configure-your-insights).
If you have access to view a project, then you have access to view their
Insights.
The `.gitlab/insights.yml` file is a YAML file where you define:
NOTE:
Issues or merge requests that you don't have access to (because you don't have
access to the project they belong to, or because they are confidential) are
filtered out of the Insights charts.
- The structure and order of charts in a report.
- The style of charts displayed in the report of your project or group.
You may also consult the [group permissions table](../../permissions.md#group-members-permissions).
To configure project insights, either:
## Writing your `.gitlab/insights.yml`
- Create a `.gitlab/insights.yml` file locally in the root directory of your project, and push your changes.
- Create a `.gitlab/insights.yml` file in the UI:
1. On the top bar, select **Main menu > Projects** and find your project.
1. Above the file list, select the branch you want to commit to, select the plus icon, then select **New file**.
1. In the **File name** text box, enter `.gitlab/insights.yml`.
1. In the large text box, update the file contents.
1. Select **Commit changes**.
The `.gitlab/insights.yml` file defines the structure and order of the Insights
charts displayed in each Insights page of your project or group.
After you create the configuration file, you can also
[use it for the project's group](../../group/insights/index.md#configure-your-insights).
Each page has a unique key and a collection of charts to fetch and display.
## Insights configuration file
For example, here's a single definition for Insights that displays one page with one chart:
In the `.gitlab/insights.yml` file:
- [Configuration parameters](#insights-configuration-parameters) define the chart behavior.
- Each report has a unique key and a collection of charts to fetch and display.
- Each chart definition is made up of a hash composed of key-value pairs.
The following example shows a single definition that displays one report with one chart.
```yaml
bugsCharts:
@ -78,30 +82,9 @@ bugsCharts:
period_limit: 24
```
Each chart definition is made up of a hash composed of key-value pairs.
## Insights configuration parameters
For example, here's single chart definition:
```yaml
- title: "Monthly bugs created"
description: "Open bugs created per month"
type: bar
query:
data_source: issuables
params:
issuable_type: issue
issuable_state: opened
filter_labels:
- bug
group_by: month
period_limit: 24
```
## Configuration parameters
A chart is defined as a list of parameters that define the chart's behavior.
The following table lists available parameters for charts:
The following table lists the chart parameters:
| Keyword | Description |
|:---------------------------------------------------|:------------|
@ -110,11 +93,6 @@ The following table lists available parameters for charts:
| [`type`](#type) | The type of chart: `bar`, `line` or `stacked-bar`. |
| [`query`](#query) | A hash that defines the data source and filtering conditions for the chart. |
## Parameter details
The following are detailed explanations for parameters used to configure
Insights charts.
### `title`
`title` is the title of the chart as it displays on the Insights page.
@ -405,8 +383,6 @@ An array of environments to include into the calculation (default: production).
### `projects`
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/10904) in GitLab 12.4.
You can limit where the "issuables" can be queried from:
- If `.gitlab/insights.yml` is used for a [group's insights](../../group/insights/index.md#configure-your-insights), with `projects`, you can limit the projects to be queried. By default, all projects currently under the group are used.

View File

@ -43,42 +43,3 @@ See [Advanced Search syntax](global_search/advanced_search_syntax.md) for more i
- To search by issue ID, use the `#` prefix followed by the issue ID (for example, [`#23456`](https://gitlab.com/search?snippets=&scope=issues&repository_ref=&search=%2323456&group_id=9970&project_id=278964)).
- To search by merge request ID, use the `!` prefix followed by the merge request ID (for example, [`!23456`](https://gitlab.com/search?snippets=&scope=merge_requests&repository_ref=&search=%2123456&group_id=9970&project_id=278964)).
## Global search scopes **(FREE SELF)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68640) in GitLab 14.3.
To improve the performance of your instance's global search, you can limit
the scope of the search. To do so, you can exclude global search scopes by disabling
[`ops` feature flags](../../development/feature_flags/index.md#ops-type).
Global search has all its scopes **enabled** by default in GitLab SaaS and
self-managed instances. A GitLab administrator can disable the following `ops`
feature flags to limit the scope of your instance's global search and optimize
its performance:
| Scope | Feature flag | Description |
|--|--|--|
| Code | `global_search_code_tab` | When enabled, the global search includes code as part of the search. |
| Commits | `global_search_commits_tab` | When enabled, the global search includes commits as part of the search. |
| Issues | `global_search_issues_tab` | When enabled, the global search includes issues as part of the search. |
| Merge Requests | `global_search_merge_requests_tab` | When enabled, the global search includes merge requests as part of the search. |
| Users | `global_search_users_tab` | When enabled, the global search includes users as part of the search. |
| Wiki | `global_search_wiki_tab` | When enabled, the global search includes wiki as part of the search. [Group wikis](../project/wiki/group.md) are not included. |
## Global Search validation
To prevent abusive searches, such as searches that may result in a Distributed Denial of Service (DDoS), Global Search ignores, logs, and
doesn't return any results for searches considered abusive according to the following criteria:
- Searches with less than 2 characters.
- Searches with any term greater than 100 characters. URL search terms have a maximum of 200 characters.
- Searches with a stop word as the only term (for example, "the", "and", "if", etc.).
- Searches with a `group_id` or `project_id` parameter that is not completely numeric.
- Searches with a `repository_ref` or `project_ref` parameter that has special characters not allowed by [Git refname](https://git-scm.com/docs/git-check-ref-format).
- Searches with a `scope` that is unknown.
Searches that don't comply with the criteria described below aren't logged as abusive but are flagged with an error:
- Searches with more than 4096 characters.
- Searches with more than 64 terms.

View File

@ -13,20 +13,40 @@ Both types of search are the same, except when you are searching through code.
- When you use basic search to search code, your search includes one project at a time.
- When you use [advanced search](advanced_search.md) to search code, your search includes all projects at once.
## Basic search
## Global search scopes **(FREE SELF)**
Use basic search to find:
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68640) in GitLab 14.3.
- Projects
- Issues
- Merge requests
- Milestones
- Users
- Epics (when searching in a group only)
- Code
- Comments
- Commits
- Wiki
To improve the performance of your instance's global search, a GitLab administrator
can limit the search scope by disabling the following [`ops` feature flags](../../development/feature_flags/index.md#ops-type).
| Scope | Feature flag | Description |
|--|--|--|
| Code | `global_search_code_tab` | When enabled, global search includes code. |
| Commits | `global_search_commits_tab` | When enabled, global search includes commits. |
| Issues | `global_search_issues_tab` | When enabled, global search includes issues. |
| Merge requests | `global_search_merge_requests_tab` | When enabled, global search includes merge requests. |
| Users | `global_search_users_tab` | When enabled, global search includes users. |
| Wiki | `global_search_wiki_tab` | When enabled, global search includes project wikis (not [group wikis](../project/wiki/group.md)). |
All global search scopes are enabled by default on GitLab.com
and self-managed instances.
## Global search validation
Global search ignores and logs as abusive any search with:
- Fewer than 2 characters
- A term longer than 100 characters (URL search terms must not exceed 200 characters)
- A stop word only (for example, `the`, `and`, or `if`)
- An unknown `scope`
- `group_id` or `project_id` that is not completely numeric
- `repository_ref` or `project_ref` with special characters not allowed by [Git refname](https://git-scm.com/docs/git-check-ref-format)
Global search only flags with an error any search that includes more than:
- 4096 characters
- 64 terms
## Perform a search

View File

@ -106,7 +106,9 @@ module API
pages_domain_params = declared(params, include_parent_namespaces: false)
pages_domain = user_project.pages_domains.create(pages_domain_params)
pages_domain = ::PagesDomains::CreateService
.new(user_project, current_user, pages_domain_params)
.execute
if pages_domain.persisted?
present pages_domain, with: Entities::PagesDomain
@ -152,7 +154,9 @@ module API
delete ":id/pages/domains/:domain", requirements: PAGES_DOMAINS_ENDPOINT_REQUIREMENTS do
authorize! :update_pages, user_project
pages_domain.destroy
::PagesDomains::DeleteService
.new(user_project, current_user)
.execute(pages_domain)
no_content!
end

View File

@ -138,6 +138,11 @@ msgid_plural "%d additional committers"
msgstr[0] ""
msgstr[1] ""
msgid "%d additional user"
msgid_plural "%d additional users"
msgstr[0] ""
msgstr[1] ""
msgid "%d approver"
msgid_plural "%d approvers"
msgstr[0] ""
@ -6785,6 +6790,9 @@ msgstr ""
msgid "BranchRules|Create wildcard: %{searchTerm}"
msgstr ""
msgid "BranchRules|Groups"
msgstr ""
msgid "BranchRules|Keep stable branches secure and force developers to use merge requests. %{linkStart}What are protected branches?%{linkEnd}"
msgstr ""
@ -6800,9 +6808,15 @@ msgstr ""
msgid "BranchRules|Require approval from code owners."
msgstr ""
msgid "BranchRules|Roles"
msgstr ""
msgid "BranchRules|Target Branch"
msgstr ""
msgid "BranchRules|Users"
msgstr ""
msgid "BranchRules|default"
msgstr ""

View File

@ -22,13 +22,15 @@ gem 'timecop', '~> 0.9.1'
gem 'parallel', '~> 1.19'
gem 'rainbow', '~> 3.0.0'
gem 'rspec-parameterized', '~> 0.4.2'
gem 'octokit', '~> 4.21'
gem 'octokit', '~> 5.6.1'
gem "faraday-retry", "~> 2.0"
gem 'webdrivers', '~> 5.0'
gem 'zeitwerk', '~> 2.4'
gem 'influxdb-client', '~> 1.17'
gem 'terminal-table', '~> 3.0.0', require: false
gem 'slack-notifier', '~> 2.4', require: false
gem 'fog-google', '~> 1.17', require: false
gem 'fog-google', '~> 1.19', require: false
gem 'fog-core', '2.1.0', require: false # fog-google generates a ton of warnings with latest core
gem "warning", "~> 1.3"
gem 'confiner', '~> 0.3'

View File

@ -67,26 +67,15 @@ GEM
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
equalizer (0.0.11)
excon (0.88.0)
excon (0.92.4)
faker (2.19.0)
i18n (>= 1.6, < 2)
faraday (1.5.1)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0.1)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.1)
faraday-patron (~> 1.0)
multipart-post (>= 1.2, < 3)
faraday (2.5.2)
faraday-net_http (>= 2.0, < 3.1)
ruby2_keywords (>= 0.0.4)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-net_http (3.0.0)
faraday-retry (2.0.0)
faraday (~> 2.0)
ffi (1.15.5)
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
@ -96,8 +85,8 @@ GEM
excon (~> 0.58)
formatador (~> 0.2)
mime-types
fog-google (1.17.0)
fog-core (<= 2.1.0)
fog-google (1.19.0)
fog-core (< 2.3)
fog-json (~> 1.2)
fog-xml (~> 0.1.0)
google-apis-compute_v1 (~> 0.14)
@ -118,7 +107,7 @@ GEM
gitlab (4.18.0)
httparty (~> 0.18)
terminal-table (>= 1.5.1)
gitlab-qa (8.4.2)
gitlab-qa (8.5.0)
activesupport (~> 6.1)
gitlab (~> 4.18.0)
http (~> 5.0)
@ -126,9 +115,9 @@ GEM
rainbow (~> 3.0.0)
table_print (= 1.5.7)
zeitwerk (~> 2.4)
google-apis-compute_v1 (0.21.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-core (0.4.1)
google-apis-compute_v1 (0.51.0)
google-apis-core (>= 0.7.2, < 2.a)
google-apis-core (0.9.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
@ -137,22 +126,22 @@ GEM
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-dns_v1 (0.16.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-iamcredentials_v1 (0.8.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-monitoring_v3 (0.18.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-pubsub_v1 (0.10.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-sqladmin_v1beta4 (0.21.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-storage_v1 (0.9.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-dns_v1 (0.27.0)
google-apis-core (>= 0.7.2, < 2.a)
google-apis-iamcredentials_v1 (0.14.0)
google-apis-core (>= 0.7.2, < 2.a)
google-apis-monitoring_v3 (0.33.0)
google-apis-core (>= 0.7, < 2.a)
google-apis-pubsub_v1 (0.28.0)
google-apis-core (>= 0.7.2, < 2.a)
google-apis-sqladmin_v1beta4 (0.36.0)
google-apis-core (>= 0.7.2, < 2.a)
google-apis-storage_v1 (0.18.0)
google-apis-core (>= 0.7, < 2.a)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
googleauth (1.1.0)
faraday (>= 0.17.3, < 2.0)
googleauth (1.2.0)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11)
@ -175,7 +164,7 @@ GEM
concurrent-ruby (~> 1.0)
ice_nine (0.11.2)
influxdb-client (1.17.0)
jwt (2.3.0)
jwt (2.5.0)
knapsack (4.0.0)
rake
launchy (2.4.3)
@ -197,12 +186,11 @@ GEM
minitest (5.16.3)
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.1.1)
netrc (0.11.0)
nokogiri (1.13.8)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
octokit (4.25.1)
octokit (5.6.1)
faraday (>= 1, < 3)
sawyer (~> 0.9)
oj (3.13.11)
@ -231,7 +219,7 @@ GEM
rainbow (3.0.0)
rake (13.0.6)
regexp_parser (2.1.1)
representable (3.1.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
@ -269,7 +257,7 @@ GEM
rspec-core (>= 2, < 4, != 2.12.0)
ruby-debug-ide (0.7.2)
rake (>= 0.8.1)
ruby2_keywords (0.0.4)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
sawyer (0.9.2)
addressable (>= 2.3.5)
@ -278,9 +266,9 @@ GEM
childprocess (>= 0.5, < 5.0)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2)
signet (0.16.0)
signet (0.17.0)
addressable (~> 2.8)
faraday (>= 0.17.3, < 2.0)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
slack-notifier (2.4.0)
@ -297,7 +285,7 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode-display_width (2.2.0)
unicode-display_width (2.3.0)
unparser (0.4.7)
abstract_type (~> 0.0.7)
adamantium (~> 0.2.0)
@ -335,12 +323,14 @@ DEPENDENCIES
confiner (~> 0.3)
deprecation_toolkit (~> 1.5.1)
faker (~> 2.19, >= 2.19.0)
fog-google (~> 1.17)
faraday-retry (~> 2.0)
fog-core (= 2.1.0)
fog-google (~> 1.19)
gitlab-qa (~> 8)
influxdb-client (~> 1.17)
knapsack (~> 4.0)
nokogiri (~> 1.12)
octokit (~> 4.21)
octokit (~> 5.6.1)
parallel (~> 1.19)
parallel_tests (~> 2.29)
pry-byebug (~> 3.5.1)

View File

@ -9,7 +9,7 @@ module Gitlab
link :pipelines_tab
link :storage_tab
link :buy_ci_minutes
link :buy_storage
link :purchase_more_storage
div :plan_ci_minutes
div :additional_ci_minutes
span :purchased_usage_total

View File

@ -104,6 +104,33 @@ module QA
api_post_to(api_comments_path, body: body, confidential: confidential)
end
# Issue label events
#
# @param [Boolean] auto_paginate
# @param [Integer] attempts
# @return [Array<Hash>]
def label_events(auto_paginate: false, attempts: 0)
events("label", auto_paginate: auto_paginate, attempts: attempts)
end
# Issue state events
#
# @param [Boolean] auto_paginate
# @param [Integer] attempts
# @return [Array<Hash>]
def state_events(auto_paginate: false, attempts: 0)
events("state", auto_paginate: auto_paginate, attempts: attempts)
end
# Issue milestone events
#
# @param [Boolean] auto_paginate
# @param [Integer] attempts
# @return [Array<Hash>]
def milestone_events(auto_paginate: false, attempts: 0)
events("milestone", auto_paginate: auto_paginate, attempts: attempts)
end
protected
# Return subset of fields for comparing issues
@ -134,6 +161,23 @@ module QA
:created_at
)
end
private
# Issue events
#
# @param [String] name event name
# @param [Boolean] auto_paginate
# @param [Integer] attempts
# @return [Array<Hash>]
def events(name, auto_paginate:, attempts:)
return parse_body(api_get_from("#{api_get_path}/resource_#{name}_events")) unless auto_paginate
auto_paginated_response(
Runtime::API::Request.new(api_client, "#{api_get_path}/resource_#{name}_events", per_page: '100').url,
attempts: attempts
)
end
end
end
end

View File

@ -186,6 +186,33 @@ module QA
api_post_to(api_comments_path, body: body)
end
# Merge request label events
#
# @param [Boolean] auto_paginate
# @param [Integer] attempts
# @return [Array<Hash>]
def label_events(auto_paginate: false, attempts: 0)
events("label", auto_paginate: auto_paginate, attempts: attempts)
end
# Merge request state events
#
# @param [Boolean] auto_paginate
# @param [Integer] attempts
# @return [Array<Hash>]
def state_events(auto_paginate: false, attempts: 0)
events("state", auto_paginate: auto_paginate, attempts: attempts)
end
# Merge request milestone events
#
# @param [Boolean] auto_paginate
# @param [Integer] attempts
# @return [Array<Hash>]
def milestone_events(auto_paginate: false, attempts: 0)
events("milestone", auto_paginate: auto_paginate, attempts: attempts)
end
# Return subset of fields for comparing merge requests
#
# @return [Hash]
@ -239,6 +266,21 @@ module QA
def create_target?
!(project.initialize_with_readme && target_branch == project.default_branch) && target_new_branch
end
# Merge request events
#
# @param [String] name event name
# @param [Boolean] auto_paginate
# @param [Integer] attempts
# @return [Array<Hash>]
def events(name, auto_paginate:, attempts:)
return parse_body(api_get_from("#{api_get_path}/resource_#{name}_events")) unless auto_paginate
auto_paginated_response(
Runtime::API::Request.new(api_client, "#{api_get_path}/resource_#{name}_events", per_page: '100').url,
attempts: attempts
)
end
end
end
end

View File

@ -324,8 +324,11 @@ module QA
result = parse_body(response)
if result[:import_status] == "failed"
Runtime::Logger.error("Import failed: #{result[:import_error]}")
Runtime::Logger.error("Failed relations: #{result[:failed_relations]}")
Runtime::Logger.error(<<~ERR)
Import of project '#{full_path}' failed!
error: '#{result[:import_error]}'
failed relations: '#{result[:failed_relations]}'
ERR
end
result

View File

@ -1,21 +1,76 @@
# frozen_string_literal: true
require "etc"
# Lifesize project import test executed from https://gitlab.com/gitlab-org/manage/import/import-metrics
# rubocop:disable Rails/Pluck
module QA
RSpec.describe 'Manage', :github, requires_admin: 'creates users', only: { job: 'large-github-import' } do
describe 'Project import' do
describe 'Project import' do # rubocop:disable RSpec/MultipleMemoizedHelpers
let(:github_repo) { ENV['QA_LARGE_IMPORT_REPO'] || 'rspec/rspec-core' }
let(:import_max_duration) { ENV['QA_LARGE_IMPORT_DURATION']&.to_i || 7200 }
let(:logger) { Runtime::Logger.logger }
let(:differ) { RSpec::Support::Differ.new(color: true) }
let(:gitlab_address) { QA::Runtime::Scenario.gitlab_address }
let(:dummy_url) { "https://example.com" }
let(:api_request_params) { { auto_paginate: true, attempts: 2 } }
let(:created_by_pattern) { /\*Created by: \S+\*\n\n/ }
let(:suggestion_pattern) { /suggestion:-\d+\+\d+/ }
let(:gh_link_pattern) { %r{https://github.com/#{github_repo}/(issues|pull)} }
let(:gl_link_pattern) { %r{#{gitlab_address}/#{imported_project.path_with_namespace}/-/(issues|merge_requests)} }
let(:event_pattern) { %r{(un)?assigned( to)? @\S+|mentioned in (issue|merge request) [!#]\d+|changed title from \*\*.*\*\* to \*\*.*\*\*} } # rubocop:disable Layout/LineLength
# rubocop:disable Lint/MixedRegexpCaptureTypes
let(:event_pattern) do
Regexp.union(
[
/(?<event>(un)?assigned)( to)? @\S+/,
/(?<event>mentioned) in (issue|merge request) [!#]\d+/,
/(?<event>changed title) from \*\*.*\*\* to \*\*.*\*\*/,
/(?<event>requested review) from @\w+/,
/\*(?<event>Merged) by:/,
/\*\*(Review):\*\*/
]
)
end
# rubocop:enable Lint/MixedRegexpCaptureTypes
# mapping from gitlab to github names
let(:event_mapping) do
{
"label_add" => "labeled",
"label_remove" => "unlabeled",
"milestone_add" => "milestoned",
"milestone_remove" => "demilestoned",
"assigned" => "assigned",
"unassigned" => "unassigned",
"changed title" => "renamed",
"requested review" => "review_requested",
"Merged" => "merged"
}
end
# github events that are not migrated or are not correctly mapable in gitlab
let(:unsupported_events) do
[
"head_ref_deleted",
"head_ref_force_pushed",
"head_ref_restored",
"auto_squash_enabled",
"auto_merge_disabled",
"comment_deleted",
"convert_to_draft",
"ready_for_review",
"subscribed",
"unsubscribed",
"transferred",
# mentions are supported but they can be reported differently on gitlab's side
# for example mention of issue creation in pr will be reported in the issue on gitlab side
# or referenced in github will still create a 'mentioned in' comment in gitlab
"referenced",
"mentioned"
]
end
let(:api_client) { Runtime::API::Client.as_admin }
@ -25,79 +80,105 @@ module QA
end
end
let(:github_repo) { ENV['QA_LARGE_IMPORT_REPO'] || 'rspec/rspec-core' }
let(:import_max_duration) { ENV['QA_LARGE_IMPORT_DURATION'] ? ENV['QA_LARGE_IMPORT_DURATION'].to_i : 7200 }
let(:github_client) do
Octokit::Client.new(
access_token: ENV['QA_LARGE_IMPORT_GH_TOKEN'] || Runtime::Env.github_access_token,
auto_paginate: true
auto_paginate: true,
middleware: Faraday::RackBuilder.new do |builder|
builder.use(Faraday::Retry::Middleware, exceptions: [Octokit::InternalServerError, Octokit::ServerError])
end
)
end
let(:gh_repo) { github_client.repository(github_repo) }
let(:gh_branches) do
logger.debug("= Fetching branches =")
logger.info("= Fetching branches =")
github_client.branches(github_repo).map(&:name)
end
let(:gh_commits) do
logger.debug("= Fetching commits =")
logger.info("= Fetching commits =")
github_client.commits(github_repo).map(&:sha)
end
let(:gh_labels) do
logger.debug("= Fetching labels =")
logger.info("= Fetching labels =")
github_client.labels(github_repo).map { |label| { name: label.name, color: "##{label.color}" } }
end
let(:gh_milestones) do
logger.debug("= Fetching milestones =")
logger.info("= Fetching milestones =")
github_client
.list_milestones(github_repo, state: 'all')
.map { |ms| { title: ms.title, description: ms.description } }
end
let(:gh_all_issues) do
logger.debug("= Fetching issues and prs =")
github_client.list_issues(github_repo, state: 'all')
end
let(:gh_prs) do
gh_all_issues.select(&:pull_request).each_with_object({}) do |pr, hash|
hash[pr.number] = {
id = pr.number
hash[id] = {
url: pr.html_url,
title: pr.title,
body: pr.body || '',
comments: [*gh_pr_comments[pr.html_url], *gh_issue_comments[pr.html_url]].compact
comments: [*gh_pr_comments[id], *gh_issue_comments[id]].compact,
events: gh_pr_events[id].reject { |event| unsupported_events.include?(event) }
}
end
end
let(:gh_issues) do
gh_all_issues.reject(&:pull_request).each_with_object({}) do |issue, hash|
hash[issue.number] = {
id = issue.number
hash[id] = {
url: issue.html_url,
title: issue.title,
body: issue.body || '',
comments: gh_issue_comments[issue.html_url]
comments: gh_issue_comments[id],
events: gh_issue_events[id].reject { |event| unsupported_events.include?(event) }
}
end
end
let(:gh_all_issues) do
logger.info("= Fetching issues and prs =")
github_client.list_issues(github_repo, state: 'all')
end
let(:gh_all_events) do
logger.info("- Fetching issue and pr events -")
github_client.repository_issue_events(github_repo).map do |event|
{ name: event[:event], **(event[:issue] || {}) } # some events don't have issue object at all
end
end
let(:gh_issue_events) do
gh_all_events.each_with_object(Hash.new { |h, k| h[k] = [] }) do |event, hash|
next if event[:pull_request] || !event[:number]
hash[event[:number]] << event[:name]
end
end
let(:gh_pr_events) do
gh_all_events.each_with_object(Hash.new { |h, k| h[k] = [] }) do |event, hash|
next unless event[:pull_request]
hash[event[:number]] << event[:name]
end
end
let(:gh_issue_comments) do
logger.debug("= Fetching issue comments =")
logger.info("- Fetching issue comments -")
github_client.issues_comments(github_repo).each_with_object(Hash.new { |h, k| h[k] = [] }) do |c, hash|
# use base html url as key
hash[c.html_url.gsub(/\#\S+/, "")] << c.body&.gsub(gh_link_pattern, dummy_url)
hash[id_from_url(c.html_url)] << c.body&.gsub(gh_link_pattern, dummy_url)
end
end
let(:gh_pr_comments) do
logger.debug("= Fetching pr comments =")
logger.info("- Fetching pr comments -")
github_client.pull_requests_comments(github_repo).each_with_object(Hash.new { |h, k| h[k] = [] }) do |c, hash|
# use base html url as key
hash[c.html_url.gsub(/\#\S+/, "")] << c.body
hash[id_from_url(c.html_url)] << c.body
# some suggestions can contain extra whitespaces which gitlab will remove
&.gsub(/suggestion\s+\r/, "suggestion\r")
&.gsub(gh_link_pattern, dummy_url)
@ -115,7 +196,6 @@ module QA
end
end
# rubocop:disable RSpec/InstanceVariable
after do |example|
next unless defined?(@import_time)
@ -164,7 +244,6 @@ module QA
}
)
end
# rubocop:enable RSpec/InstanceVariable
it(
'imports large Github repo via api',
@ -172,17 +251,18 @@ module QA
) do
start = Time.now
# import the project and log gitlab path
logger.info("== Importing project '#{github_repo}' in to '#{imported_project.reload!.full_path}' ==")
# fetch all objects right after import has started
fetch_github_objects
# import the project and log gitlab path
logger.info("== Importing project '#{github_repo}' in to '#{imported_project.reload!.full_path}' ==")
import_status = lambda do
imported_project.project_import_status.yield_self do |status|
@stats = status.dig(:stats, :imported)
# fail fast if import explicitly failed
raise "Import of '#{imported_project.name}' failed!" if status[:import_status] == 'failed'
raise "Import of '#{imported_project.full_path}' failed!" if status[:import_status] == 'failed'
status[:import_status]
end
@ -294,7 +374,7 @@ module QA
actual.each_with_object([]) do |(key, actual_item), missing_comments|
expected_item = expected[key]
title = actual_item[:title]
msg = "expected #{type} with title '#{title}' to have"
msg = "expected #{type} with iid '#{key}' to have"
# Print title in the error message to see which object is missing
#
@ -320,6 +400,14 @@ module QA
expect(expected_comments.length).to eq(actual_comments.length), comment_count_msg
expect(expected_comments).to match_array(actual_comments)
expected_events = expected_item[:events]
actual_events = actual_item[:events]
event_count_msg = <<~MSG
#{msg} same amount of events. Gitlab: #{expected_events.length}, Github: #{actual_events.length}
MSG
expect(expected_events.length).to eq(actual_events.length), event_count_msg
expect(expected_events).to match_array(actual_events)
# Save missing comments
#
comment_diff = actual_comments - expected_comments
@ -380,26 +468,27 @@ module QA
def mrs
@mrs ||= begin
logger.debug("= Fetching merge requests =")
imported_mrs = imported_project.merge_requests(auto_paginate: true, attempts: 2)
imported_mrs = imported_project.merge_requests(**api_request_params)
logger.debug("= Fetching merge request comments =")
Parallel.map(imported_mrs, in_threads: 4) do |mr|
Parallel.map(imported_mrs, in_threads: Etc.nprocessors) do |mr|
resource = Resource::MergeRequest.init do |resource|
resource.project = imported_project
resource.iid = mr[:iid]
resource.api_client = api_client
end
logger.debug("Fetching comments for mr '#{mr[:title]}'")
comments = resource
.comments(auto_paginate: true, attempts: 2)
.reject { |c| c[:system] || c[:body].match?(/^(\*\*Review:\*\*)|(\*Merged by:).*/) }
logger.debug("Fetching events and comments for mr '!#{mr[:iid]}'")
comments = resource.comments(**api_request_params)
label_events = resource.label_events(**api_request_params)
state_events = resource.state_events(**api_request_params)
milestone_events = resource.milestone_events(**api_request_params)
[mr[:iid], {
url: mr[:web_url],
title: mr[:title],
body: sanitize_description(mr[:description]) || '',
events: events(comments),
events: events(comments, label_events, state_events, milestone_events),
comments: non_event_comments(comments)
}]
end.to_h
@ -412,48 +501,59 @@ module QA
def gl_issues
@gl_issues ||= begin
logger.debug("= Fetching issues =")
imported_issues = imported_project.issues(auto_paginate: true, attempts: 2)
imported_issues = imported_project.issues(**api_request_params)
logger.debug("= Fetching issue comments =")
Parallel.map(imported_issues, in_threads: 4) do |issue|
Parallel.map(imported_issues, in_threads: Etc.nprocessors) do |issue|
resource = Resource::Issue.init do |issue_resource|
issue_resource.project = imported_project
issue_resource.iid = issue[:iid]
issue_resource.api_client = api_client
end
logger.debug("Fetching comments for issue '#{issue[:title]}'")
comments = resource.comments(auto_paginate: true, attempts: 2)
logger.debug("Fetching events and comments for issue '!#{issue[:iid]}'")
comments = resource.comments(**api_request_params)
label_events = resource.label_events(**api_request_params)
state_events = resource.state_events(**api_request_params)
milestone_events = resource.milestone_events(**api_request_params)
[issue[:iid], {
url: issue[:web_url],
title: issue[:title],
body: sanitize_description(issue[:description]) || '',
events: events(comments),
events: events(comments, label_events, state_events, milestone_events),
comments: non_event_comments(comments)
}]
end.to_h
end
end
# Fetch comments without events
# Filter out event comments
#
# @param [Array] comments
# @return [Array]
def non_event_comments(comments)
comments
.reject { |c| c[:body].match?(event_pattern) }
.reject { |c| c[:system] || c[:body].match?(event_pattern) }
.map { |c| sanitize_comment(c[:body]) }
end
# Events
#
# @param [Array] comments
# @param [Array] label_events
# @param [Array] state_events
# @param [Array] milestone_events
# @return [Array]
def events(comments)
comments
.select { |c| c[:body].match?(event_pattern) }
.map { |c| c[:body] }
def events(comments, label_events, state_events, milestone_events)
mapped_label_events = label_events.map { |event| event_mapping["label_#{event[:action]}"] }
mapped_milestone_events = milestone_events.map { |event| event_mapping["milestone_#{event[:action]}"] }
mapped_state_event = state_events.map { |event| event[:state] }
mapped_comment_events = comments.map do |c|
event_mapping[c[:body].match(event_pattern)&.named_captures&.fetch("event", nil)]
end
[*mapped_label_events, *mapped_milestone_events, *mapped_state_event, *mapped_comment_events].compact
end
# Normalize comments and make them directly comparable
@ -489,6 +589,16 @@ module QA
def save_json(name, json)
File.open("tmp/#{name}.json", "w") { |file| file.write(JSON.pretty_generate(json)) }
end
# Extract id number from web url of issue or pull request
#
# Some endpoints don't return object id as separate parameter so web url can be used as a workaround
#
# @param [String] url
# @return [Integer]
def id_from_url(url)
url.match(%r{(?<type>issues|pull)/(?<id>\d+)})&.named_captures&.fetch(:id, nil).to_i
end
end
end
end

View File

@ -6,7 +6,7 @@ module QA
it 'succeeds' do
Runtime::Browser.visit(:gitlab, Page::Main::Login)
expect(page).to have_text('A complete DevOps platform')
expect(page).to have_text('GitLab')
end
end

View File

@ -1,23 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe BoardsResponses do
let(:controller_class) do
Class.new do
include BoardsResponses
end
end
subject(:controller) { controller_class.new }
describe '#serialize_as_json' do
let!(:board) { create(:board) }
it 'serializes properly' do
expected = { "id" => board.id }
expect(subject.serialize_as_json(board)).to include(expected)
end
end
end

View File

@ -3,11 +3,14 @@
require 'spec_helper'
RSpec.describe Groups::BoardsController do
let(:group) { create(:group) }
let(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:user) { create(:user) }
before_all do
group.add_maintainer(user)
end
before do
group.add_maintainer(user)
sign_in(user)
end
@ -57,46 +60,17 @@ RSpec.describe Groups::BoardsController do
end
end
context 'when format is JSON' do
it 'return an array with one group board' do
create(:board, group: group)
expect(Boards::VisitsFinder).not_to receive(:new)
list_boards format: :json
expect(response).to match_response_schema('boards')
expect(json_response.length).to eq 1
end
context 'with unauthorized user' do
before do
expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original
allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :read_issue_board, group).and_return(false)
end
it 'returns a not found 404 response' do
list_boards format: :json
expect(response).to have_gitlab_http_status(:not_found)
expect(response.media_type).to eq 'application/json'
end
end
end
it_behaves_like 'disabled when using an external authorization service' do
subject { list_boards }
end
def list_boards(format: :html)
get :index, params: { group_id: group }, format: format
def list_boards
get :index, params: { group_id: group }
end
end
describe 'GET show' do
let!(:board) { create(:board, group: group) }
let_it_be(:board) { create(:board, group: group) }
context 'when format is HTML' do
it 'renders template' do
@ -123,12 +97,12 @@ RSpec.describe Groups::BoardsController do
end
context 'when user is signed out' do
let(:group) { create(:group, :public) }
let(:public_board) { create(:board, group: create(:group, :public)) }
it 'does not save visit' do
sign_out(user)
expect { read_board board: board }.to change(BoardGroupRecentVisit, :count).by(0)
expect { read_board board: public_board }.to change(BoardGroupRecentVisit, :count).by(0)
expect(response).to render_template :show
expect(response.media_type).to eq 'text/html'
@ -136,37 +110,11 @@ RSpec.describe Groups::BoardsController do
end
end
context 'when format is JSON' do
it 'returns project board' do
expect(Boards::Visits::CreateService).not_to receive(:new)
read_board board: board, format: :json
expect(response).to match_response_schema('board')
end
context 'with unauthorized user' do
before do
expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original
allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(false)
end
it 'returns a not found 404 response' do
read_board board: board, format: :json
expect(response).to have_gitlab_http_status(:not_found)
expect(response.media_type).to eq 'application/json'
end
end
end
context 'when board does not belong to group' do
it 'returns a not found 404 response' do
another_board = create(:board)
read_board board: another_board
get :show, params: { group_id: group, id: another_board.to_param }
expect(response).to have_gitlab_http_status(:not_found)
end
@ -176,12 +124,8 @@ RSpec.describe Groups::BoardsController do
subject { read_board board: board }
end
def read_board(board:, format: :html)
get :show, params: {
group_id: group,
id: board.to_param
},
format: format
def read_board(board:)
get :show, params: { group_id: board.group, id: board.to_param }
end
end
end

View File

@ -3,11 +3,14 @@
require 'spec_helper'
RSpec.describe Projects::BoardsController do
let(:project) { create(:project) }
let(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
before_all do
project.add_maintainer(user)
end
before do
project.add_maintainer(user)
sign_in(user)
end
@ -22,72 +25,64 @@ RSpec.describe Projects::BoardsController do
expect(assigns(:boards_endpoint)).to eq project_boards_path(project)
end
context 'when format is HTML' do
it 'renders template' do
list_boards
expect(response).to render_template :index
expect(response.media_type).to eq 'text/html'
end
context 'when there are recently visited boards' do
let_it_be(:boards) { create_list(:board, 3, resource_parent: project) }
before_all do
visit_board(boards[2], Time.current + 1.minute)
visit_board(boards[0], Time.current + 2.minutes)
visit_board(boards[1], Time.current + 5.minutes)
end
it 'redirects to latest visited board' do
list_boards
expect(response).to redirect_to(
namespace_project_board_path(namespace_id: project.namespace, project_id: project, id: boards[1].id)
)
end
def visit_board(board, time)
create(:board_project_recent_visit, project: project, board: board, user: user, updated_at: time)
end
end
context 'with unauthorized user' do
before do
expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original
allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :read_issue_board, project).and_return(false)
end
it 'returns a not found 404 response' do
list_boards
expect(response).to have_gitlab_http_status(:not_found)
expect(response.media_type).to eq 'text/html'
end
end
context 'when user is signed out' do
let(:project) { create(:project, :public) }
it 'renders template' do
sign_out(user)
board = create(:board, project: project)
create(:board_project_recent_visit, project: board.project, board: board, user: user)
list_boards
expect(response).to render_template :index
expect(response.media_type).to eq 'text/html'
end
context 'with unauthorized user' do
before do
expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original
allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :read_issue_board, project).and_return(false)
end
it 'returns a not found 404 response' do
list_boards
expect(response).to have_gitlab_http_status(:not_found)
expect(response.media_type).to eq 'text/html'
end
end
context 'when user is signed out' do
let(:project) { create(:project, :public) }
it 'renders template' do
sign_out(user)
board = create(:board, project: project)
create(:board_project_recent_visit, project: board.project, board: board, user: user)
list_boards
expect(response).to render_template :index
expect(response.media_type).to eq 'text/html'
end
end
end
context 'when format is JSON' do
it 'returns a list of project boards' do
create_list(:board, 2, project: project)
expect(Boards::VisitsFinder).not_to receive(:new)
list_boards format: :json
expect(response).to match_response_schema('boards')
expect(json_response.length).to eq 2
end
context 'with unauthorized user' do
before do
expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original
allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :read_issue_board, project).and_return(false)
end
it 'returns a not found 404 response' do
list_boards format: :json
expect(response).to have_gitlab_http_status(:not_found)
expect(response.media_type).to eq 'application/json'
end
end
end
context 'issues are disabled' do
@ -104,17 +99,16 @@ RSpec.describe Projects::BoardsController do
subject { list_boards }
end
def list_boards(format: :html)
def list_boards
get :index, params: {
namespace_id: project.namespace,
project_id: project
},
format: format
}
end
end
describe 'GET show' do
let!(:board) { create(:board, project: project) }
let_it_be(:board) { create(:board, project: project) }
it 'sets boards_endpoint instance variable to a boards path' do
read_board board: board
@ -146,12 +140,12 @@ RSpec.describe Projects::BoardsController do
end
context 'when user is signed out' do
let(:project) { create(:project, :public) }
let(:public_board) { create(:board, project: create(:project, :public)) }
it 'does not save visit' do
sign_out(user)
expect { read_board board: board }.to change(BoardProjectRecentVisit, :count).by(0)
expect { read_board board: public_board }.to change(BoardProjectRecentVisit, :count).by(0)
expect(response).to render_template :show
expect(response.media_type).to eq 'text/html'
@ -159,48 +153,18 @@ RSpec.describe Projects::BoardsController do
end
end
context 'when format is JSON' do
it 'returns project board' do
expect(Boards::Visits::CreateService).not_to receive(:new)
read_board board: board, format: :json
expect(response).to match_response_schema('board')
end
context 'with unauthorized user' do
before do
expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original
allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :read_issue_board, project).and_return(false)
end
it 'returns a not found 404 response' do
read_board board: board, format: :json
expect(response).to have_gitlab_http_status(:not_found)
expect(response.media_type).to eq 'application/json'
end
end
end
context 'when board does not belong to project' do
it 'returns a not found 404 response' do
another_board = create(:board)
read_board board: another_board
get :show, params: { namespace_id: project.namespace, project_id: project, id: another_board.to_param }
expect(response).to have_gitlab_http_status(:not_found)
end
end
def read_board(board:, format: :html)
get :show, params: {
namespace_id: project.namespace,
project_id: project,
id: board.to_param
},
format: format
def read_board(board:)
get :show, params: { namespace_id: board.project.namespace, project_id: board.project, id: board.to_param }
end
end
end

View File

@ -63,9 +63,15 @@ RSpec.describe Projects::PagesDomainsController do
describe 'POST create' do
it "creates a new pages domain" do
expect do
post(:create, params: request_params.merge(pages_domain: pages_domain_params))
end.to change { PagesDomain.count }.by(1)
expect { post(:create, params: request_params.merge(pages_domain: pages_domain_params)) }
.to change { PagesDomain.count }.by(1)
.and publish_event(PagesDomains::PagesDomainCreatedEvent)
.with(
project_id: project.id,
namespace_id: project.namespace.id,
root_namespace_id: project.root_namespace.id,
domain: pages_domain_params[:domain]
)
created_domain = PagesDomain.reorder(:id).last
@ -213,9 +219,15 @@ RSpec.describe Projects::PagesDomainsController do
describe 'DELETE destroy' do
it "deletes the pages domain" do
expect do
delete(:destroy, params: request_params.merge(id: pages_domain.domain))
end.to change { PagesDomain.count }.by(-1)
expect { delete(:destroy, params: request_params.merge(id: pages_domain.domain)) }
.to change(PagesDomain, :count).by(-1)
.and publish_event(PagesDomains::PagesDomainDeletedEvent)
.with(
project_id: project.id,
namespace_id: project.namespace.id,
root_namespace_id: project.root_namespace.id,
domain: pages_domain.domain
)
expect(response).to redirect_to(project_pages_path(project))
end

View File

@ -1,10 +0,0 @@
{
"type": "object",
"required" : [
"id"
],
"properties" : {
"id": { "type": "integer" },
"name": { "type": "string" }
}
}

View File

@ -1,4 +0,0 @@
{
"type": "array",
"items": { "$ref": "board.json" }
}

View File

@ -1,16 +0,0 @@
{
"type": "object",
"allOf": [
{ "$ref": "board.json" },
{
"required" : [
"id",
"name"
],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" }
}
}
]
}

View File

@ -13,7 +13,7 @@ import * as diffActions from '~/diffs/store/actions';
import * as types from '~/diffs/store/mutation_types';
import * as utils from '~/diffs/store/utils';
import * as treeWorkerUtils from '~/diffs/utils/tree_worker_utils';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import * as commonUtils from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
@ -54,7 +54,7 @@ describe('DiffsStoreActions', () => {
['requestAnimationFrame', 'requestIdleCallback'].forEach((method) => {
global[method] = originalMethods[method];
});
createFlash.mockClear();
createAlert.mockClear();
mock.restore();
});
@ -254,8 +254,8 @@ describe('DiffsStoreActions', () => {
mock.onGet(endpointCoverage).reply(400);
await testAction(diffActions.fetchCoverageFiles, {}, { endpointCoverage }, [], []);
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith({
expect(createAlert).toHaveBeenCalledTimes(1);
expect(createAlert).toHaveBeenCalledWith({
message: expect.stringMatching('Something went wrong'),
});
});

View File

@ -12,7 +12,7 @@ import {
} from '~/editor/constants';
import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext';
import SourceEditor from '~/editor/source_editor';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import syntaxHighlight from '~/syntax_highlight';
import { spyOnApi } from './helpers';
@ -279,7 +279,7 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
mockAxios.onPost().reply(500);
await fetchPreview();
expect(createFlash).toHaveBeenCalled();
expect(createAlert).toHaveBeenCalled();
});
});

View File

@ -6,7 +6,7 @@ import { s__, sprintf } from '~/locale';
import DeleteEnvironmentModal from '~/environments/components/delete_environment_modal.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import { resolvedEnvironment } from './graphql/mock_data';
jest.mock('~/flash');
@ -57,7 +57,7 @@ describe('~/environments/components/delete_environment_modal.vue', () => {
await nextTick();
expect(createFlash).not.toHaveBeenCalled();
expect(createAlert).not.toHaveBeenCalled();
expect(deleteResolver).toHaveBeenCalledWith(
expect.anything(),
@ -76,7 +76,7 @@ describe('~/environments/components/delete_environment_modal.vue', () => {
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith(
expect(createAlert).toHaveBeenCalledWith(
expect.objectContaining({
message: s__(
'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.',

View File

@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import EditEnvironment from '~/environments/components/edit_environment.vue';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
@ -85,7 +85,7 @@ describe('~/environments/components/edit.vue', () => {
await submitForm(expected, [400, { message: ['uh oh!'] }]);
expect(createFlash).toHaveBeenCalledWith({ message: 'uh oh!' });
expect(createAlert).toHaveBeenCalledWith({ message: 'uh oh!' });
expect(showsLoading()).toBe(false);
});

View File

@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import NewEnvironment from '~/environments/components/new_environment.vue';
import createFlash from '~/flash';
import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
@ -94,7 +94,7 @@ describe('~/environments/components/new.vue', () => {
await submitForm(expected, [400, { message: ['name taken'] }]);
expect(createFlash).toHaveBeenCalledWith({ message: 'name taken' });
expect(createAlert).toHaveBeenCalledWith({ message: 'name taken' });
expect(showsLoading()).toBe(false);
});
});

View File

@ -0,0 +1,54 @@
const usersMock = [
{
username: 'usr1',
webUrl: 'http://test.test/usr1',
name: 'User 1',
avatarUrl: 'http://test.test/avt1.png',
},
{
username: 'usr2',
webUrl: 'http://test.test/usr2',
name: 'User 2',
avatarUrl: 'http://test.test/avt2.png',
},
{
username: 'usr3',
webUrl: 'http://test.test/usr3',
name: 'User 3',
avatarUrl: 'http://test.test/avt3.png',
},
{
username: 'usr4',
webUrl: 'http://test.test/usr4',
name: 'User 4',
avatarUrl: 'http://test.test/avt4.png',
},
{
username: 'usr5',
webUrl: 'http://test.test/usr5',
name: 'User 5',
avatarUrl: 'http://test.test/avt5.png',
},
];
const accessLevelsMock = [
{ accessLevelDescription: 'Administrator' },
{ accessLevelDescription: 'Maintainer' },
];
const groupsMock = [{ name: 'test_group_1' }, { name: 'test_group_2' }];
export const protectionPropsMock = {
header: 'Test protection',
headerLinkTitle: 'Test link title',
headerLinkHref: 'Test link href',
roles: accessLevelsMock,
users: usersMock,
groups: groupsMock,
};
export const protectionRowPropsMock = {
title: 'Test title',
users: usersMock,
accessLevels: accessLevelsMock,
};

View File

@ -0,0 +1,63 @@
import { GlAvatarsInline, GlAvatar, GlAvatarLink } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ProtectionRow, {
MAX_VISIBLE_AVATARS,
AVATAR_SIZE,
} from '~/projects/settings/branch_rules/components/view/protection_row.vue';
import { protectionRowPropsMock } from './mock_data';
describe('Branch rule protection row', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMountExtended(ProtectionRow, {
propsData: protectionRowPropsMock,
stubs: { GlAvatarsInline },
});
};
beforeEach(() => createComponent());
afterEach(() => wrapper.destroy());
const findTitle = () => wrapper.findByText(protectionRowPropsMock.title);
const findAvatarsInline = () => wrapper.findComponent(GlAvatarsInline);
const findAvatarLinks = () => wrapper.findAllComponents(GlAvatarLink);
const findAvatars = () => wrapper.findAllComponents(GlAvatar);
const findAccessLevels = () => wrapper.findAllByTestId('access-level');
it('renders a title', () => {
expect(findTitle().exists()).toBe(true);
});
it('renders an avatars-inline component', () => {
expect(findAvatarsInline().props('avatars')).toMatchObject(protectionRowPropsMock.users);
expect(findAvatarsInline().props('badgeSrOnlyText')).toBe('1 additional user');
});
it('renders avatar-link components', () => {
expect(findAvatarLinks().length).toBe(MAX_VISIBLE_AVATARS);
expect(findAvatarLinks().at(1).attributes('href')).toBe(protectionRowPropsMock.users[1].webUrl);
expect(findAvatarLinks().at(1).attributes('title')).toBe(protectionRowPropsMock.users[1].name);
});
it('renders avatar components', () => {
expect(findAvatars().length).toBe(MAX_VISIBLE_AVATARS);
expect(findAvatars().at(1).attributes('src')).toBe(protectionRowPropsMock.users[1].avatarUrl);
expect(findAvatars().at(1).attributes('label')).toBe(protectionRowPropsMock.users[1].name);
expect(findAvatars().at(1).props('size')).toBe(AVATAR_SIZE);
});
it('renders access level descriptions', () => {
expect(findAccessLevels().length).toBe(protectionRowPropsMock.accessLevels.length);
expect(findAccessLevels().at(0).text()).toBe(
protectionRowPropsMock.accessLevels[0].accessLevelDescription,
);
expect(findAccessLevels().at(1).text()).toBe(
protectionRowPropsMock.accessLevels[1].accessLevelDescription,
);
});
});

View File

@ -0,0 +1,59 @@
import { GlCard, GlLink } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import Protection, { i18n } from '~/projects/settings/branch_rules/components/view/protection.vue';
import ProtectionRow from '~/projects/settings/branch_rules/components/view/protection_row.vue';
import { protectionPropsMock } from './mock_data';
describe('Branch rule protection', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMountExtended(Protection, {
propsData: protectionPropsMock,
stubs: { GlCard },
});
};
beforeEach(() => createComponent());
afterEach(() => wrapper.destroy());
const findCard = () => wrapper.findComponent(GlCard);
const findHeader = () => wrapper.findByText(protectionPropsMock.header);
const findLink = () => wrapper.findComponent(GlLink);
const findProtectionRows = () => wrapper.findAllComponents(ProtectionRow);
it('renders a card component', () => {
expect(findCard().exists()).toBe(true);
});
it('renders a header with a link', () => {
expect(findHeader().exists()).toBe(true);
expect(findLink().text()).toBe(protectionPropsMock.headerLinkTitle);
expect(findLink().attributes('href')).toBe(protectionPropsMock.headerLinkHref);
});
it('renders a protection row for roles', () => {
expect(findProtectionRows().at(0).props()).toMatchObject({
accessLevels: protectionPropsMock.roles,
showDivider: false,
title: i18n.rolesTitle,
});
});
it('renders a protection row for users', () => {
expect(findProtectionRows().at(1).props()).toMatchObject({
users: protectionPropsMock.users,
showDivider: true,
title: i18n.usersTitle,
});
});
it('renders a protection row for groups', () => {
expect(findProtectionRows().at(2).props()).toMatchObject({
accessLevels: protectionPropsMock.groups,
showDivider: true,
title: i18n.groupsTitle,
});
});
});

View File

@ -72,7 +72,9 @@ exports[`MRWidgetAutoMergeEnabled when graphql is disabled template should have
<div
class="gl-display-flex gl-md-display-block gl-font-size-0 gl-ml-auto"
>
<div>
<div
class="gl-display-flex"
>
<div
class="dropdown b-dropdown gl-new-dropdown gl-display-block gl-md-display-none! btn-group"
lazy=""
@ -246,7 +248,9 @@ exports[`MRWidgetAutoMergeEnabled when graphql is enabled template should have c
<div
class="gl-display-flex gl-md-display-block gl-font-size-0 gl-ml-auto"
>
<div>
<div
class="gl-display-flex"
>
<div
class="dropdown b-dropdown gl-new-dropdown gl-display-block gl-md-display-none! btn-group"
lazy=""

View File

@ -11,6 +11,7 @@ import {
REDEPLOYING,
STOPPING,
} from '~/vue_merge_request_widget/components/deployment/constants';
import eventHub from '~/vue_merge_request_widget/event_hub';
import DeploymentActions from '~/vue_merge_request_widget/components/deployment/deployment_actions.vue';
import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service';
import {
@ -192,6 +193,7 @@ describe('DeploymentAction component', () => {
describe('it should call the executeAction method', () => {
beforeEach(async () => {
jest.spyOn(wrapper.vm, 'executeAction').mockImplementation();
jest.spyOn(eventHub, '$emit');
await waitForPromises();
@ -206,11 +208,16 @@ describe('DeploymentAction component', () => {
actionButtonMocks[configConst],
);
});
it('emits the FetchDeployments event', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('FetchDeployments');
});
});
describe('when executeInlineAction errors', () => {
beforeEach(async () => {
executeActionSpy.mockRejectedValueOnce();
jest.spyOn(eventHub, '$emit');
await waitForPromises();
@ -224,6 +231,10 @@ describe('DeploymentAction component', () => {
message: actionButtonMocks[configConst].errorMessage,
});
});
it('emits the FetchDeployments event', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('FetchDeployments');
});
});
});
});

View File

@ -368,12 +368,13 @@ describe('MrWidgetOptions', () => {
describe('bindEventHubListeners', () => {
it.each`
event | method | methodArgs
${'MRWidgetUpdateRequested'} | ${'checkStatus'} | ${(x) => [x]}
${'MRWidgetRebaseSuccess'} | ${'checkStatus'} | ${(x) => [x, true]}
${'FetchActionsContent'} | ${'fetchActionsContent'} | ${() => []}
${'EnablePolling'} | ${'resumePolling'} | ${() => []}
${'DisablePolling'} | ${'stopPolling'} | ${() => []}
event | method | methodArgs
${'MRWidgetUpdateRequested'} | ${'checkStatus'} | ${(x) => [x]}
${'MRWidgetRebaseSuccess'} | ${'checkStatus'} | ${(x) => [x, true]}
${'FetchActionsContent'} | ${'fetchActionsContent'} | ${() => []}
${'EnablePolling'} | ${'resumePolling'} | ${() => []}
${'DisablePolling'} | ${'stopPolling'} | ${() => []}
${'FetchDeployments'} | ${'fetchPreMergeDeployments'} | ${() => []}
`('should bind to $event', ({ event, method, methodArgs }) => {
jest.spyOn(wrapper.vm, method).mockImplementation();

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