Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-02-25 12:11:01 +00:00
parent bcfab67c0f
commit b2452a3692
99 changed files with 1926 additions and 435 deletions

View File

@ -1 +1 @@
b7d1a76c7837d4df1896d52b8d10097216750ac7
ef061fd0ccb16fadf3d8550c12b27c4cb3159990

View File

@ -45,7 +45,7 @@ export default {
};
},
computed: {
...mapState(['labels', 'labelsLoading']),
...mapState(['labels', 'labelsLoading', 'isEpicBoard']),
...mapGetters(['getListByLabelId', 'shouldUseGraphQL']),
selectedLabel() {
return this.labels.find(({ id }) => id === this.selectedLabelId);
@ -57,7 +57,7 @@ export default {
methods: {
...mapActions(['createList', 'fetchLabels', 'highlightList', 'setAddColumnFormVisibility']),
getListByLabel(label) {
if (this.shouldUseGraphQL) {
if (this.shouldUseGraphQL || this.isEpicBoard) {
return this.getListByLabelId(label);
}
return boardsStore.findListByLabelId(label.id);
@ -66,7 +66,7 @@ export default {
return Boolean(this.getListByLabel(label));
},
highlight(listId) {
if (this.shouldUseGraphQL) {
if (this.shouldUseGraphQL || this.isEpicBoard) {
this.highlightList(listId);
} else {
const list = boardsStore.state.lists.find(({ id }) => id === listId);
@ -95,7 +95,7 @@ export default {
return;
}
if (this.shouldUseGraphQL) {
if (this.shouldUseGraphQL || this.isEpicBoard) {
this.createList({ labelId: this.selectedLabelId });
} else {
boardsStore.new({
@ -127,6 +127,7 @@ export default {
<template>
<div
class="board-add-new-list board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal gl-flex-shrink-0"
data-testid="board-add-new-column"
data-qa-selector="board_add_new_list"
>
<div

View File

@ -128,7 +128,11 @@ export default {
}, flashAnimationDuration);
},
createList: (
createList: ({ dispatch }, { backlog, labelId, milestoneId, assigneeId }) => {
dispatch('createIssueList', { backlog, labelId, milestoneId, assigneeId });
},
createIssueList: (
{ state, commit, dispatch, getters },
{ backlog, labelId, milestoneId, assigneeId },
) => {
@ -172,7 +176,7 @@ export default {
},
fetchLabels: ({ state, commit, getters }, searchTerm) => {
const { fullPath, boardType } = state;
const { fullPath, boardType, isEpicBoard } = state;
const variables = {
fullPath,
@ -191,7 +195,7 @@ export default {
.then(({ data }) => {
let labels = data[boardType]?.labels.nodes;
if (!getters.shouldUseGraphQL) {
if (!getters.shouldUseGraphQL && !isEpicBoard) {
labels = labels.map((label) => ({
...label,
id: getIdFromGraphQLId(label.id),

View File

@ -34,6 +34,11 @@ export default {
type: Boolean,
required: true,
},
labelFilterParam: {
type: String,
required: false,
default: 'label_name',
},
showCheckbox: {
type: Boolean,
required: true,
@ -105,9 +110,8 @@ export default {
},
labelTarget(label) {
if (this.enableLabelPermalinks) {
const key = encodeURIComponent('label_name[]');
const value = encodeURIComponent(this.labelTitle(label));
return `?${key}=${value}`;
return `?${this.labelFilterParam}[]=${value}`;
}
return '#';
},

View File

@ -122,6 +122,11 @@ export default {
required: false,
default: true,
},
labelFilterParam: {
type: String,
required: false,
default: null,
},
},
data() {
return {
@ -180,7 +185,7 @@ export default {
handler(params) {
if (Object.keys(params).length) {
updateHistory({
url: setUrlParams(params, window.location.href, true),
url: setUrlParams(params, window.location.href, true, false, true),
title: document.title,
replace: true,
});
@ -258,6 +263,7 @@ export default {
:issuable-symbol="issuableSymbol"
:issuable="issuable"
:enable-label-permalinks="enableLabelPermalinks"
:label-filter-param="labelFilterParam"
:show-checkbox="showBulkEditSidebar"
:checked="issuableChecked(issuable)"
@checked-input="handleIssuableCheckedInput(issuable, $event)"

View File

@ -4,7 +4,6 @@ import $ from 'jquery';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { s__, sprintf } from '~/locale';
import TaskList from '../../task_list';
import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor';
import animateMixin from '../mixins/animate';
export default {
@ -12,7 +11,7 @@ export default {
SafeHtml,
},
mixins: [animateMixin, recaptchaModalImplementor],
mixins: [animateMixin],
props: {
canUpdate: {
@ -87,21 +86,11 @@ export default {
fieldName: 'description',
lockVersion: this.lockVersion,
selector: '.detail-page-description',
onSuccess: this.taskListUpdateSuccess.bind(this),
onError: this.taskListUpdateError.bind(this),
});
}
},
taskListUpdateSuccess(data) {
try {
this.checkForSpam(data);
this.closeRecaptcha();
} catch (error) {
if (error && error.name === 'SpamError') this.openRecaptcha();
}
},
taskListUpdateError() {
createFlash(
sprintf(
@ -165,7 +154,5 @@ export default {
>
</textarea>
<!-- eslint-enable vue/no-mutating-props -->
<recaptcha-modal v-show="showRecaptcha" :html="recaptchaHTML" @close="closeRecaptcha" />
</div>
</template>

View File

@ -473,6 +473,7 @@ export const setUrlParams = (
url = window.location.href,
clearParams = false,
railsArraySyntax = false,
decodeParams = false,
) => {
const urlObj = new URL(url);
const queryString = urlObj.search;
@ -495,7 +496,9 @@ export const setUrlParams = (
}
});
urlObj.search = searchParams.toString();
urlObj.search = decodeParams
? decodeURIComponent(searchParams.toString())
: searchParams.toString();
return urlObj.toString();
};

View File

@ -363,7 +363,7 @@ export default {
data-qa-selector="comment_field"
data-testid="comment-field"
:data-supports-quick-actions="!glFeatures.tributeAutocomplete"
:aria-label="__('Description')"
:aria-label="__('Comment')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.up="editCurrentUserLastNote()"
@keydown.meta.enter="handleSave()"

View File

@ -17,6 +17,7 @@ import commentForm from './comment_form.vue';
import discussionFilterNote from './discussion_filter_note.vue';
import noteableDiscussion from './noteable_discussion.vue';
import noteableNote from './noteable_note.vue';
import SidebarSubscription from './sidebar_subscription.vue';
export default {
name: 'NotesApp',
@ -30,6 +31,7 @@ export default {
skeletonLoadingContainer,
discussionFilterNote,
OrderedLayout,
SidebarSubscription,
},
mixins: [glFeatureFlagsMixin()],
props: {
@ -261,6 +263,7 @@ export default {
<template>
<div v-show="shouldShow" id="notes">
<sidebar-subscription :iid="noteableData.iid" :noteable-data="noteableData" />
<ordered-layout :slot-keys="slotKeys">
<template #form>
<comment-form

View File

@ -0,0 +1,58 @@
<script>
import { mapActions } from 'vuex';
import { IssuableType } from '~/issue_show/constants';
import { fetchPolicies } from '~/lib/graphql';
import { confidentialityQueries } from '~/sidebar/constants';
import { defaultClient as gqlClient } from '~/sidebar/graphql';
export default {
props: {
noteableData: {
type: Object,
required: true,
},
iid: {
type: Number,
required: true,
},
},
computed: {
fullPath() {
if (this.noteableData.web_url) {
return this.noteableData.web_url.split('/-/')[0].substring(1);
}
return null;
},
issuableType() {
return this.noteableData.noteableType.toLowerCase();
},
},
created() {
if (this.issuableType !== IssuableType.Issue) {
return;
}
gqlClient
.watchQuery({
query: confidentialityQueries[this.issuableType].query,
variables: {
iid: String(this.iid),
fullPath: this.fullPath,
},
fetchPolicy: fetchPolicies.CACHE_ONLY,
})
.subscribe((res) => {
const issuable = res.data?.workspace?.issuable;
if (issuable) {
this.setConfidentiality(issuable.confidential);
}
});
},
methods: {
...mapActions(['setConfidentiality']),
},
render() {
return null;
},
};
</script>

View File

@ -4,7 +4,7 @@ import Vue from 'vue';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
import updateIssueConfidentialMutation from '~/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql';
import { confidentialWidget } from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql';
import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql';
import loadAwardsHandler from '../../awards_handler';
@ -340,6 +340,15 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
if (hasQuickActions && message) {
eTagPoll.makeRequest();
// synchronizing the quick action with the sidebar widget
// this is a temporary solution until we have confidentiality real-time updates
if (
confidentialWidget.setConfidentiality &&
message.some((m) => m.includes('confidential'))
) {
confidentialWidget.setConfidentiality();
}
$('.js-gfm-input').trigger('clear-commands-cache.atwho');
Flash(message || __('Commands applied'), 'notice', noteData.flashContainer);
@ -719,33 +728,3 @@ export const updateAssignees = ({ commit }, assignees) => {
export const updateDiscussionPosition = ({ commit }, updatedPosition) => {
commit(types.UPDATE_DISCUSSION_POSITION, updatedPosition);
};
export const updateConfidentialityOnIssuable = (
{ getters, commit },
{ confidential, fullPath },
) => {
const { iid } = getters.getNoteableData;
return utils.gqClient
.mutate({
mutation: updateIssueConfidentialMutation,
variables: {
input: {
projectPath: fullPath,
iid: String(iid),
confidential,
},
},
})
.then(({ data }) => {
const {
issueSetConfidential: { issue, errors },
} = data;
if (errors?.length) {
Flash(errors[0], 'alert');
} else {
setConfidentiality({ commit }, issue.confidential);
}
});
};

View File

@ -1,8 +1,5 @@
import ProjectsList from '~/projects_list';
import initCustomizeHomepageBanner from './init_customize_homepage_banner';
document.addEventListener('DOMContentLoaded', () => {
new ProjectsList(); // eslint-disable-line no-new
initCustomizeHomepageBanner();
});
new ProjectsList(); // eslint-disable-line no-new
initCustomizeHomepageBanner();

View File

@ -64,13 +64,6 @@ export default {
hasHighlightedJob() {
return Boolean(this.highlightedJob);
},
alert() {
if (this.hasError) {
return this.failure;
}
return this.warning;
},
failure() {
switch (this.failureType) {
case DRAW_FAILURE:
@ -210,11 +203,11 @@ export default {
<div>
<gl-alert
v-if="hasError"
:variant="alert.variant"
:dismissible="alert.dismissible"
@dismiss="alert.dismissible ? resetFailure : null"
:variant="failure.variant"
:dismissible="failure.dismissible"
@dismiss="resetFailure"
>
{{ alert.text }}
{{ failure.text }}
</gl-alert>
<div
v-if="!hideGraph"

View File

@ -6,8 +6,8 @@ import CommitComponent from '~/vue_shared/components/commit.vue';
import eventHub from '../../event_hub';
import PipelineTriggerer from './pipeline_triggerer.vue';
import PipelineUrl from './pipeline_url.vue';
import PipelinesActionsComponent from './pipelines_actions.vue';
import PipelinesArtifactsComponent from './pipelines_artifacts.vue';
import PipelinesManualActionsComponent from './pipelines_manual_actions.vue';
import PipelineStage from './stage.vue';
import PipelinesTimeago from './time_ago.vue';
@ -21,7 +21,7 @@ export default {
GlModalDirective,
},
components: {
PipelinesActionsComponent,
PipelinesManualActionsComponent,
PipelinesArtifactsComponent,
CommitComponent,
PipelineStage,
@ -242,7 +242,7 @@ export default {
class="table-section section-20 table-button-footer pipeline-actions"
>
<div class="btn-group table-action-buttons">
<pipelines-actions-component v-if="actions.length > 0" :actions="actions" />
<pipelines-manual-actions-component v-if="actions.length > 0" :actions="actions" />
<pipelines-artifacts-component
v-if="pipeline.details.artifacts.length"

View File

@ -26,6 +26,8 @@ Sidebar.prototype.removeListeners = function () {
// eslint-disable-next-line @gitlab/no-global-event-off
this.sidebar.off('hidden.gl.dropdown');
// eslint-disable-next-line @gitlab/no-global-event-off
this.sidebar.off('hiddenGlDropdown');
// eslint-disable-next-line @gitlab/no-global-event-off
$('.dropdown').off('loading.gl.dropdown');
// eslint-disable-next-line @gitlab/no-global-event-off
$('.dropdown').off('loaded.gl.dropdown');
@ -37,6 +39,7 @@ Sidebar.prototype.addEventListeners = function () {
this.sidebar.on('click', '.sidebar-collapsed-icon', this, this.sidebarCollapseClicked);
this.sidebar.on('hidden.gl.dropdown', this, this.onSidebarDropdownHidden);
this.sidebar.on('hiddenGlDropdown', this, this.onSidebarDropdownHidden);
$document.on('click', '.js-sidebar-toggle', this.sidebarToggleClicked);
return $(document)

View File

@ -0,0 +1,54 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
export default {
components: {
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
confidential: {
type: Boolean,
required: true,
},
},
computed: {
confidentialText() {
return this.confidential
? sprintf(__('This %{issuableType} is confidential'), {
issuableType: this.issuableType,
})
: __('Not confidential');
},
confidentialIcon() {
return this.confidential ? 'eye-slash' : 'eye';
},
tooltipLabel() {
return this.confidential ? __('Confidential') : __('Not confidential');
},
},
};
</script>
<template>
<div>
<div v-gl-tooltip.viewport.left :title="tooltipLabel" class="sidebar-collapsed-icon">
<gl-icon
:size="16"
:name="confidentialIcon"
class="sidebar-item-icon inline"
:class="{ 'is-active': confidential }"
/>
</div>
<gl-icon
:size="16"
:name="confidentialIcon"
class="sidebar-item-icon inline hide-collapsed"
:class="{ 'is-active': confidential }"
/>
<span class="hide-collapsed" data-testid="confidential-text">{{ confidentialText }}</span>
</div>
</template>

View File

@ -0,0 +1,126 @@
<script>
import { GlSprintf, GlButton } from '@gitlab/ui';
import createFlash from '~/flash';
import { __, sprintf } from '~/locale';
import { confidentialityQueries } from '~/sidebar/constants';
export default {
i18n: {
confidentialityOnWarning: __(
'You are going to turn on confidentiality. Only team members with %{strongStart}at least Reporter access%{strongEnd} will be able to see and leave comments on the %{issuableType}.',
),
confidentialityOffWarning: __(
'You are going to turn off the confidentiality. This means %{strongStart}everyone%{strongEnd} will be able to see and leave a comment on this %{issuableType}.',
),
},
components: {
GlSprintf,
GlButton,
},
inject: ['fullPath', 'iid'],
props: {
confidential: {
required: true,
type: Boolean,
},
issuableType: {
required: true,
type: String,
},
},
data() {
return {
loading: false,
};
},
computed: {
toggleButtonText() {
if (this.loading) {
return __('Applying');
}
return this.confidential ? __('Turn off') : __('Turn on');
},
warningMessage() {
return this.confidential
? this.$options.i18n.confidentialityOffWarning
: this.$options.i18n.confidentialityOnWarning;
},
},
methods: {
submitForm() {
this.loading = true;
this.$apollo
.mutate({
mutation: confidentialityQueries[this.issuableType].mutation,
variables: {
input: {
projectPath: this.fullPath,
iid: this.iid,
confidential: !this.confidential,
},
},
})
.then(
({
data: {
issuableSetConfidential: { errors },
},
}) => {
if (errors.length) {
createFlash({
message: errors[0],
});
} else {
this.$emit('closeForm');
}
},
)
.catch(() => {
createFlash({
message: sprintf(
__('Something went wrong while setting %{issuableType} confidentiality.'),
{
issuableType: this.issuableType,
},
),
});
})
.finally(() => {
this.loading = false;
});
},
},
};
</script>
<template>
<div class="dropdown show">
<div class="dropdown-menu sidebar-item-warning-message">
<div>
<p data-testid="warning-message">
<gl-sprintf :message="warningMessage">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
<template #issuableType>{{ issuableType }}</template>
</gl-sprintf>
</p>
<div class="sidebar-item-warning-message-actions">
<gl-button class="gl-mr-3" data-testid="confidential-cancel" @click="$emit('closeForm')">
{{ __('Cancel') }}
</gl-button>
<gl-button
category="secondary"
variant="warning"
:disabled="loading"
:loading="loading"
data-testid="confidential-toggle"
@click.prevent="submitForm"
>
{{ toggleButtonText }}
</gl-button>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,130 @@
<script>
import produce from 'immer';
import Vue from 'vue';
import createFlash from '~/flash';
import { __, sprintf } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { confidentialityQueries } from '~/sidebar/constants';
import SidebarConfidentialityContent from './sidebar_confidentiality_content.vue';
import SidebarConfidentialityForm from './sidebar_confidentiality_form.vue';
export const confidentialWidget = Vue.observable({
setConfidentiality: null,
});
const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', {
bubbles: true,
});
export default {
tracking: {
event: 'click_edit_button',
label: 'right_sidebar',
property: 'confidentiality',
},
components: {
SidebarEditableItem,
SidebarConfidentialityContent,
SidebarConfidentialityForm,
},
inject: ['fullPath', 'iid'],
props: {
issuableType: {
required: true,
type: String,
},
},
data() {
return {
confidential: false,
};
},
apollo: {
confidential: {
query() {
return confidentialityQueries[this.issuableType].query;
},
variables() {
return {
fullPath: this.fullPath,
iid: this.iid,
};
},
update(data) {
return data.workspace?.issuable?.confidential || false;
},
error() {
createFlash({
message: sprintf(
__('Something went wrong while setting %{issuableType} confidentiality.'),
{
issuableType: this.issuableType,
},
),
});
},
},
},
computed: {
isLoading() {
return this.$apollo.queries.confidential.loading;
},
},
mounted() {
confidentialWidget.setConfidentiality = this.setConfidentiality;
},
destroyed() {
confidentialWidget.setConfidentiality = null;
},
methods: {
closeForm() {
this.$refs.editable.collapse();
this.$el.dispatchEvent(hideDropdownEvent);
},
// synchronizing the quick action with the sidebar widget
// this is a temporary solution until we have confidentiality real-time updates
setConfidentiality() {
const { defaultClient: client } = this.$apollo.provider.clients;
const sourceData = client.readQuery({
query: confidentialityQueries[this.issuableType].query,
variables: { fullPath: this.fullPath, iid: this.iid },
});
const data = produce(sourceData, (draftData) => {
// eslint-disable-next-line no-param-reassign
draftData.workspace.issuable.confidential = !this.confidential;
});
client.writeQuery({
query: confidentialityQueries[this.issuableType].query,
variables: { fullPath: this.fullPath, iid: this.iid },
data,
});
},
},
};
</script>
<template>
<sidebar-editable-item
ref="editable"
:title="__('Confidentiality')"
:tracking="$options.tracking"
:loading="isLoading"
class="block confidentiality"
>
<template #collapsed>
<div>
<sidebar-confidentiality-content v-if="!isLoading" :confidential="confidential" />
</div>
</template>
<template #default>
<sidebar-confidentiality-content :confidential="confidential" />
<sidebar-confidentiality-form
:confidential="confidential"
:issuable-type="issuableType"
@closeForm="closeForm"
/>
</template>
</sidebar-editable-item>
</template>

View File

@ -15,6 +15,15 @@ export default {
required: false,
default: false,
},
tracking: {
type: Object,
required: false,
default: () => ({
event: null,
label: null,
property: null,
}),
},
},
data() {
return {
@ -71,14 +80,18 @@ export default {
<template>
<div>
<div class="gl-display-flex gl-align-items-center gl-mb-3" @click.self="collapse">
<span data-testid="title">{{ title }}</span>
<gl-loading-icon v-if="loading" inline class="gl-ml-2" />
<div class="gl-display-flex gl-align-items-center" @click.self="collapse">
<span class="hide-collapsed" data-testid="title">{{ title }}</span>
<gl-loading-icon v-if="loading" inline class="gl-ml-2 hide-collapsed" />
<gl-loading-icon v-if="loading" inline class="gl-mx-auto gl-my-0 hide-expanded" />
<gl-button
v-if="canUpdate"
variant="link"
class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto js-sidebar-dropdown-toggle"
class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto js-sidebar-dropdown-toggle hide-collapsed"
data-testid="edit-button"
:data-track-event="tracking.event"
:data-track-label="tracking.label"
:data-track-property="tracking.property"
@keyup.esc="toggle"
@click="toggle"
>

View File

@ -1,4 +1,6 @@
import { IssuableType } from '~/issue_show/constants';
import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql';
import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql';
import getIssueParticipants from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql';
import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
@ -14,3 +16,10 @@ export const assigneesQueries = {
mutation: updateMergeRequestParticipantsMutation,
},
};
export const confidentialityQueries = {
[IssuableType.Issue]: {
query: issueConfidentialQuery,
mutation: updateIssueConfidentialMutation,
},
};

View File

@ -0,0 +1,8 @@
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
export const defaultClient = createDefaultClient();
export const apolloProvider = new VueApollo({
defaultClient,
});

View File

@ -2,7 +2,6 @@ import $ from 'jquery';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createFlash from '~/flash';
import createDefaultClient from '~/lib/graphql';
import {
isInIssuePage,
isInDesignPage,
@ -10,9 +9,10 @@ import {
parseBoolean,
} from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
import { apolloProvider } from '~/sidebar/graphql';
import Translate from '../vue_shared/translate';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue';
import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue';
import SidebarLabels from './components/labels/sidebar_labels.vue';
import IssuableLockForm from './components/lock/issuable_lock_form.vue';
@ -54,9 +54,6 @@ function getSidebarAssigneeAvailabilityData() {
function mountAssigneesComponent(mediator) {
const el = document.getElementById('js-vue-sidebar-assignees');
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
if (!el) return;
@ -87,9 +84,6 @@ function mountAssigneesComponent(mediator) {
function mountReviewersComponent(mediator) {
const el = document.getElementById('js-vue-sidebar-reviewers');
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
if (!el) return;
@ -121,10 +115,6 @@ export function mountSidebarLabels() {
return false;
}
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
apolloProvider,
@ -139,39 +129,37 @@ export function mountSidebarLabels() {
});
}
function mountConfidentialComponent(mediator) {
function mountConfidentialComponent() {
const el = document.getElementById('js-confidential-entry-point');
if (!el) {
return;
}
const { fullPath, iid } = getSidebarOptions();
if (!el) return;
const dataNode = document.getElementById('js-confidential-issue-data');
const initialData = JSON.parse(dataNode.innerHTML);
import(/* webpackChunkName: 'notesStore' */ '~/notes/stores')
.then(
({ store }) =>
new Vue({
el,
store,
components: {
ConfidentialIssueSidebar,
},
render: (createElement) =>
createElement('confidential-issue-sidebar', {
props: {
iid: String(iid),
fullPath,
isEditable: initialData.is_editable,
service: mediator.service,
},
}),
}),
)
.catch(() => {
createFlash({ message: __('Failed to load sidebar confidential toggle') });
});
// eslint-disable-next-line no-new
new Vue({
el,
apolloProvider,
components: {
SidebarConfidentialityWidget,
},
provide: {
iid: String(iid),
fullPath,
canUpdate: initialData.is_editable,
},
render: (createElement) =>
createElement('sidebar-confidentiality-widget', {
props: {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage() ? 'issue' : 'merge_request',
},
}),
});
}
function mountLockComponent() {
@ -280,9 +268,6 @@ function mountSeverityComponent() {
if (!severityContainerEl) {
return false;
}
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
const { fullPath, iid, severity } = getSidebarOptions();

View File

@ -0,0 +1,10 @@
query issueConfidential($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
__typename
issuable: issue(iid: $iid) {
__typename
id
confidential
}
}
}

View File

@ -0,0 +1,9 @@
mutation updateIssueConfidential($input: IssueSetConfidentialInput!) {
issuableSetConfidential: issueSetConfidential(input: $input) {
issuable: issue {
id
confidential
}
errors
}
}

View File

@ -30,6 +30,7 @@
color: var(--gray-500, $gray-500);
}
[data-page$='epic_boards:show'],
.issue-boards-page {
.content-wrapper {
padding-bottom: 0;

View File

@ -50,15 +50,16 @@ module Emails
end
# rubocop: enable CodeReuse/ActiveRecord
def access_token_about_to_expire_email(user)
def access_token_about_to_expire_email(user, token_names)
return unless user
@user = user
@token_names = token_names
@target_url = profile_personal_access_tokens_url
@days_to_expire = PersonalAccessToken::DAYS_TO_EXPIRE
Gitlab::I18n.with_locale(@user.preferred_language) do
mail(to: @user.notification_email, subject: subject(_("Your Personal Access Tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire }))
mail(to: @user.notification_email, subject: subject(_("Your personal access tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire }))
end
end

View File

@ -259,12 +259,8 @@ class Namespace < ApplicationRecord
# Includes projects from this namespace and projects from all subgroups
# that belongs to this namespace
def all_projects
if Feature.enabled?(:recursive_approach_for_all_projects)
namespace = user? ? self : self_and_descendants
Project.where(namespace: namespace)
else
Project.inside_path(full_path)
end
namespace = user? ? self : self_and_descendants
Project.where(namespace: namespace)
end
# Includes pipelines from this namespace and pipelines from all subgroups

View File

@ -57,8 +57,7 @@ class Namespace::RootStorageStatistics < ApplicationRecord
end
def attributes_from_personal_snippets
# Return if the type of namespace does not belong to a user
return {} unless namespace.type.nil?
return {} unless namespace.user?
from_personal_snippets.take.slice(SNIPPETS_SIZE_STAT_NAME)
end
@ -70,3 +69,5 @@ class Namespace::RootStorageStatistics < ApplicationRecord
.select("COALESCE(SUM(s.repository_size), 0) AS #{SNIPPETS_SIZE_STAT_NAME}")
end
end
Namespace::RootStorageStatistics.prepend_if_ee('EE::Namespace::RootStorageStatistics')

View File

@ -4,6 +4,7 @@ class PersonalAccessToken < ApplicationRecord
include Expirable
include TokenAuthenticatable
include Sortable
include EachBatch
extend ::Gitlab::Utils::Override
add_authentication_token_field :token, digest: true
@ -97,6 +98,10 @@ class PersonalAccessToken < ApplicationRecord
end
def set_default_scopes
# When only loading a select set of attributes, for example using `EachBatch`,
# the `scopes` attribute is not present, so we can't initialize it.
return unless has_attribute?(:scopes)
self.scopes = Gitlab::Auth::DEFAULT_SCOPES if self.scopes.empty?
end

View File

@ -2,8 +2,8 @@
module AuthorizedProjectUpdate
class PeriodicRecalculateService
BATCH_SIZE = 480
DELAY_INTERVAL = 30.seconds.to_i
BATCH_SIZE = 450
DELAY_INTERVAL = 50.seconds.to_i
def execute
# Using this approach (instead of eg. User.each_batch) keeps the arguments

View File

@ -66,10 +66,10 @@ class NotificationService
# Notify the owner of the personal access token, when it is about to expire
# And mark the token with about_to_expire_delivered
def access_token_about_to_expire(user)
def access_token_about_to_expire(user, token_names)
return unless user.can?(:receive_notifications)
mailer.access_token_about_to_expire_email(user).deliver_later
mailer.access_token_about_to_expire_email(user, token_names).deliver_later
end
# Notify the user when at least one of their personal access tokens has expired today

View File

@ -1,6 +1,6 @@
- expanded = local_assigns.fetch(:expanded)
%h4
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Variables')
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }

View File

@ -1,7 +1,11 @@
%p
= _('Hi %{username}!') % { username: sanitize_name(@user.name) }
%p
= _('One or more of your personal access tokens will expire in %{days_to_expire} days or less.') % { days_to_expire: @days_to_expire }
= _('One or more of your personal access tokens will expire in %{days_to_expire} days or less:') % { days_to_expire: @days_to_expire }
%p
- pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
%ul
- @token_names.each do |token|
%li= token
%p
- pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
= html_escape(_('You can create a new one or check them in your %{pat_link_start}personal access tokens%{pat_link_end} settings')) % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe }

View File

@ -1,5 +1,9 @@
<%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %>
<%= _('One or more of your personal access tokens will expire in %{days_to_expire} days or less.') % { days_to_expire: @days_to_expire} %>
<%= _('One or more of your personal access tokens will expire in %{days_to_expire} days or less:') % { days_to_expire: @days_to_expire } %>
<% @token_names.each do |token| %>
- <%= token %>
<% end %>
<%= _('You can create a new one or check them in your personal access tokens settings %{pat_link}') % { pat_link: @target_url } %>

View File

@ -197,7 +197,7 @@
#js-board-epics-swimlanes-toggle
.js-board-config{ data: { can_admin_list: user_can_admin_list.to_s, has_scope: board.scoped?.to_s } }
- if user_can_admin_list
- if Feature.enabled?(:board_new_list, board.resource_parent, default_enabled: :yaml)
- if Feature.enabled?(:board_new_list, board.resource_parent, default_enabled: :yaml) || board.to_type == "EpicBoard"
.js-create-column-trigger{ data: board_list_data }
- else
= render 'shared/issuable/board_create_list_dropdown', board: board

View File

@ -133,5 +133,13 @@ module WorkerAttributes
def get_deduplication_options
class_attributes[:deduplication_options] || {}
end
def big_payload!
class_attributes[:big_payload] = true
end
def big_payload?
class_attributes[:big_payload]
end
end
end

View File

@ -7,17 +7,32 @@ module PersonalAccessTokens
feature_category :authentication_and_authorization
MAX_TOKENS = 100
def perform(*args)
notification_service = NotificationService.new
limit_date = PersonalAccessToken::DAYS_TO_EXPIRE.days.from_now.to_date
User.with_expiring_and_not_notified_personal_access_tokens(limit_date).find_each do |user|
with_context(user: user) do
notification_service.access_token_about_to_expire(user)
expiring_user_tokens = user.personal_access_tokens.without_impersonation.expiring_and_not_notified(limit_date)
# rubocop: disable CodeReuse/ActiveRecord
# We never materialise the token instances. We need the names to mention them in the
# email. Later we trigger an update query on the entire relation, not on individual instances.
token_names = expiring_user_tokens.limit(MAX_TOKENS).pluck(:name)
# We're limiting to 100 tokens so we avoid loading too many tokens into memory.
# At the time of writing this would only affect 69 users on GitLab.com
# rubocop: enable CodeReuse/ActiveRecord
notification_service.access_token_about_to_expire(user, token_names)
Gitlab::AppLogger.info "#{self.class}: Notifying User #{user.id} about expiring tokens"
user.personal_access_tokens.without_impersonation.expiring_and_not_notified(limit_date).update_all(expire_notification_delivered: true)
expiring_user_tokens.each_batch do |expiring_tokens|
expiring_tokens.update_all(expire_notification_delivered: true)
end
end
end
end

View File

@ -0,0 +1,5 @@
---
title: Project Settings CI/CD Variables header expands/collapses on click / tap
merge_request: 54117
author: Daniel Schömer
type: changed

View File

@ -0,0 +1,5 @@
---
title: Use recursive approach to query all projects for a namespace
merge_request: 55043
author:
type: performance

View File

@ -0,0 +1,5 @@
---
title: Sidebar confidentiality component updates in real-time
merge_request: 53858
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Provide name of expiring token in personal access token expiration mail
merge_request: 53766
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Harden added metrics
merge_request: 54805
author:
type: performance

View File

@ -0,0 +1,5 @@
---
title: Add API endpoint for fetching a single job by CI_JOB_TOKEN
merge_request: 51727
author: ahmet2mir
type: added

View File

@ -0,0 +1,5 @@
---
title: Rename comment form textarea label to `Comment`
merge_request: 55088
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: 'Github Importer: Import Pull request "merged_at" attribute'
merge_request: 54862
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Limit the payload size of Sidekiq jobs before scheduling
merge_request: 53829
author:
type: added

View File

@ -1,8 +0,0 @@
---
name: recursive_approach_for_all_projects
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44740
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/263442
milestone: '13.5'
type: development
group: group::fulfillment
default_enabled: false

View File

@ -7,7 +7,7 @@ product_group: group::ops release
product_category:
value_type: number
status: data_available
milestone: 13.2
milestone: "13.2"
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35493
time_frame: 28d
data_source: database

View File

@ -8,7 +8,7 @@ product_group: group::product planning
product_category: issue_tracking
value_type: number
status: implemented
milestone: 13.10
milestone: "13.10"
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49264
time_frame: 28d
data_source: redis_hll

View File

@ -8,7 +8,7 @@ product_group: group::product planning
product_category: issue_tracking
value_type: number
status: implemented
milestone: 13.10
milestone: "13.10"
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49264
time_frame: 28d
data_source: redis_hll

View File

@ -7,7 +7,7 @@ product_group: group::project management
product_category: issue_tracking
value_type: number
status: data_available
milestone: 13.6
milestone: "13.6"
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
time_frame: 7d
data_source: redis_hll

View File

@ -6,7 +6,7 @@ product_stage: release
product_group: group::ops release
value_type: number
status: data_available
milestone: 8.12
milestone: "8.12"
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/735
time_frame: all
data_source: database

View File

@ -7,7 +7,7 @@ product_group: group::product intelligence
product_category: collection
value_type: string
status: data_available
milestone: 8.10
milestone: "8.10"
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/557
time_frame: none
data_source: ruby

View File

@ -7,7 +7,7 @@ product_group: group::product intelligence
product_category: collection
value_type: string
status: data_available
milestone: 9.1
milestone: "9.1"
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1521
time_frame: none
data_source: database

View File

@ -29,10 +29,12 @@
"enum": ["data_available", "planned", "in_progress", "implemented", "not_used", "deprecated"]
},
"milestone": {
"type": ["number", "null"]
"type": ["string", "null"],
"pattern": "^[0-9]+\\.[0-9]+$"
},
"milestone_removed": {
"type": ["number", "null"]
"type": ["string", "null"],
"pattern": "^[0-9]+\\.[0-9]+$"
},
"introduced_by_url": {
"type": ["string", "null"]

View File

@ -211,6 +211,7 @@ to authenticate with the API:
- [Package Registry for PyPI](../user/packages/pypi_repository/index.md#authenticate-with-a-ci-job-token)
- [Package Registry for generic packages](../user/packages/generic_packages/index.md#publish-a-generic-package-by-using-cicd)
- [Get job artifacts](job_artifacts.md#get-job-artifacts)
- [Get job token's job](jobs.md#get-job-tokens-job)
- [Pipeline triggers](pipeline_triggers.md) (using the `token=` parameter)
- [Release creation](releases/index.md#create-a-release)
- [Terraform plan](../user/infrastructure/index.md)

View File

@ -146,6 +146,7 @@ The following API resources are available outside of project and group contexts
| [Instance clusters](instance_clusters.md) | `/admin/clusters` |
| [Issues](issues.md) | `/issues` (also available for groups and projects) |
| [Issues Statistics](issues_statistics.md) | `/issues_statistics` (also available for groups and projects) |
| [Jobs](jobs.md) | `/job` |
| [Keys](keys.md) | `/keys` |
| [License](license.md) **(FREE SELF)** | `/license` |
| [Markdown](markdown.md) | `/markdown` |

View File

@ -380,6 +380,76 @@ Example of response
]
```
## Get job token's job
Retrieve the job that generated a job token.
```plaintext
GET /job
```
Examples
```shell
curl --header "JOB-TOKEN: <your_job_token>" "https://gitlab.example.com/api/v4/job"
curl "https://gitlab.example.com/api/v4/job?job_token=<your_job_token>"
```
Example of response
```json
{
"commit": {
"author_email": "admin@example.com",
"author_name": "Administrator",
"created_at": "2015-12-24T16:51:14.000+01:00",
"id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd",
"message": "Test the CI integration.",
"short_id": "0ff3ae19",
"title": "Test the CI integration."
},
"coverage": null,
"allow_failure": false,
"created_at": "2015-12-24T15:51:21.880Z",
"started_at": "2015-12-24T17:54:30.733Z",
"finished_at": "2015-12-24T17:54:31.198Z",
"duration": 0.465,
"artifacts_expire_at": "2016-01-23T17:54:31.198Z",
"id": 8,
"name": "rubocop",
"pipeline": {
"id": 6,
"ref": "master",
"sha": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd",
"status": "pending"
},
"ref": "master",
"artifacts": [],
"runner": null,
"stage": "test",
"status": "failed",
"tag": false,
"web_url": "https://example.com/foo/bar/-/jobs/8",
"user": {
"id": 1,
"name": "Administrator",
"username": "root",
"state": "active",
"avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
"web_url": "http://gitlab.dev/root",
"created_at": "2015-12-21T13:14:24.077Z",
"bio": null,
"location": null,
"public_email": "",
"skype": "",
"linkedin": "",
"twitter": "",
"website_url": "",
"organization": ""
}
}
```
## Get a single job
Get a single job of a project

View File

@ -19,7 +19,7 @@ it should be restricted on namespace scope.
1. Add the feature symbol on `EES_FEATURES`, `EEP_FEATURES` or `EEU_FEATURES` constants in
`ee/app/models/license.rb`. Note on `ee/app/models/ee/namespace.rb` that _Bronze_ GitLab.com
features maps to on-premise _EES_, _Silver_ to _EEP_ and _Gold_ to _EEU_.
features maps to on-premise _EES_, _Silver/Premium_ to _EEP_ and _Gold/Ultimate_ to _EEU_.
1. Check using:
```ruby

View File

@ -539,21 +539,6 @@ When looking at this initially you'd suspect that the component is setup before
This is however not entirely true as the `destroy` method does not remove everything which has been mutated on the `wrapper` object. For functional components, destroy only removes the rendered DOM elements from the document.
In order to ensure that a clean wrapper object and DOM are being used in each test, the breakdown of the component should rather be performed as follows:
```javascript
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
```
<!-- vale gitlab.Spelling = NO -->
See also the [Vue Test Utils documentation on `destroy`](https://vue-test-utils.vuejs.org/api/wrapper/#destroy).
<!-- vale gitlab.Spelling = YES -->
### Jest best practices
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/34209) in GitLab 13.2.

View File

@ -11211,7 +11211,7 @@ When the Usage Ping computation was started
| `product_category` | `collection` |
| `value_type` | string |
| `status` | data_available |
| `milestone` | 8.1 |
| `milestone` | 8.10 |
| `introduced_by_url` | [Introduced by](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/557) |
| `time_frame` | none |
| `data_source` | Ruby |
@ -17653,7 +17653,7 @@ Missing description
| `product_category` | `issue_tracking` |
| `value_type` | number |
| `status` | implemented |
| `milestone` | 13.1 |
| `milestone` | 13.10 |
| `introduced_by_url` | [Introduced by](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49264) |
| `time_frame` | 28d |
| `data_source` | Redis_hll |
@ -17674,7 +17674,7 @@ Missing description
| `product_category` | `issue_tracking` |
| `value_type` | number |
| `status` | implemented |
| `milestone` | 13.1 |
| `milestone` | 13.10 |
| `introduced_by_url` | [Introduced by](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49264) |
| `time_frame` | 28d |
| `data_source` | Redis_hll |

View File

@ -8,7 +8,7 @@ product_group:
product_category:
value_type: <%= value_type %>
status: implemented
milestone: <%= milestone %>
milestone: "<%= milestone %>"
introduced_by_url:
time_frame: <%= time_frame %>
data_source:

View File

@ -8,10 +8,11 @@ module API
feature_category :continuous_integration
params do
requires :id, type: String, desc: 'The ID of a project'
end
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
params do
requires :id, type: String, desc: 'The ID of a project'
end
helpers do
params :optional_scope do
optional :scope, types: [String, Array[String]], desc: 'The scope of builds to show',
@ -168,6 +169,20 @@ module API
end
end
resource :job do
desc 'Get current project using job token' do
success Entities::Ci::Job
end
route_setting :authentication, job_token_allowed: true
get do
# current_authenticated_job will be nil if user is using
# a valid authentication that is not CI_JOB_TOKEN
not_found!('Job') unless current_authenticated_job
present current_authenticated_job, with: Entities::Ci::Job
end
end
helpers do
# rubocop: disable CodeReuse/ActiveRecord
def filter_builds(builds, scope)

View File

@ -61,9 +61,6 @@ module Gitlab
remove_known_trial_form_fields: {
tracking_category: 'Growth::Conversion::Experiment::RemoveKnownTrialFormFields'
},
trimmed_skip_trial_copy: {
tracking_category: 'Growth::Conversion::Experiment::TrimmedSkipTrialCopy'
},
trial_registration_with_social_signin: {
tracking_category: 'Growth::Conversion::Experiment::TrialRegistrationWithSocialSigning'
},

View File

@ -12,25 +12,27 @@ module Gitlab
def execute
merge_request = project.merge_requests.find_by_iid(pull_request.iid)
timestamp = Time.new.utc
merged_at = pull_request.merged_at
user_finder = GithubImport::UserFinder.new(project, client)
gitlab_user_id = user_finder.user_id_for(pull_request.merged_by)
if gitlab_user_id
timestamp = Time.new.utc
MergeRequest::Metrics.upsert({
target_project_id: project.id,
merge_request_id: merge_request.id,
merged_by_id: gitlab_user_id,
created_at: timestamp,
updated_at: timestamp
}, unique_by: :merge_request_id)
else
MergeRequest::Metrics.upsert({
target_project_id: project.id,
merge_request_id: merge_request.id,
merged_by_id: gitlab_user_id,
merged_at: merged_at,
created_at: timestamp,
updated_at: timestamp
}, unique_by: :merge_request_id)
unless gitlab_user_id
merge_request.notes.create!(
importing: true,
note: "*Merged by: #{pull_request.merged_by.login}*",
note: missing_author_note,
author_id: project.creator_id,
project: project,
created_at: pull_request.created_at
created_at: merged_at
)
end
end
@ -38,6 +40,13 @@ module Gitlab
private
attr_reader :project, :pull_request, :client
def missing_author_note
s_("GitHubImporter|*Merged by: %{author} at %{timestamp}*") % {
author: pull_request.merged_by.login,
timestamp: pull_request.merged_at
}
end
end
end
end

View File

@ -36,6 +36,8 @@ module Gitlab
chain.add ::Gitlab::SidekiqMiddleware::DuplicateJobs::Client
chain.add ::Gitlab::SidekiqStatus::ClientMiddleware
chain.add ::Gitlab::SidekiqMiddleware::AdminMode::Client
# Size limiter should be placed at the bottom, but before the metrics midleware
chain.add ::Gitlab::SidekiqMiddleware::SizeLimiter::Client
chain.add ::Gitlab::SidekiqMiddleware::ClientMetrics
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
module Gitlab
module SidekiqMiddleware
module SizeLimiter
# This midleware is inserted into Sidekiq **client** middleware chain. It
# prevents the caller from dispatching a too-large job payload. The job
# payload should be small and simple. Read more at:
# https://github.com/mperham/sidekiq/wiki/Best-Practices#1-make-your-job-parameters-small-and-simple
class Client
def call(worker_class, job, queue, _redis_pool)
Validator.validate!(worker_class, job)
yield
end
end
end
end
end

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
module Gitlab
module SidekiqMiddleware
module SizeLimiter
# A custom exception for size limiter. It contains worker class and its
# size to easier track later
class ExceedLimitError < StandardError
attr_reader :worker_class, :size, :size_limit
def initialize(worker_class, size, size_limit)
@worker_class = worker_class
@size = size
@size_limit = size_limit
super "#{@worker_class} job exceeds payload size limit (#{size}/#{size_limit})"
end
def sentry_extra_data
{
worker_class: @worker_class.to_s,
size: @size.to_i,
size_limit: @size_limit.to_i
}
end
end
end
end
end

View File

@ -0,0 +1,97 @@
# frozen_string_literal: true
module Gitlab
module SidekiqMiddleware
module SizeLimiter
# Validate a Sidekiq job payload limit based on current configuration.
# This validator pulls the configuration from the environment variables:
#
# - GITLAB_SIDEKIQ_SIZE_LIMITER_MODE: the current mode of the size
# limiter. This must be either `track` or `raise`.
#
# - GITLAB_SIDEKIQ_SIZE_LIMITER_LIMIT_BYTES: the size limit in bytes.
#
# If the size of job payload after serialization exceeds the limit, an
# error is tracked raised adhering to the mode.
class Validator
def self.validate!(worker_class, job)
new(worker_class, job).validate!
end
DEFAULT_SIZE_LIMIT = 0
MODES = [
TRACK_MODE = 'track',
RAISE_MODE = 'raise'
].freeze
attr_reader :mode, :size_limit
def initialize(
worker_class, job,
mode: ENV['GITLAB_SIDEKIQ_SIZE_LIMITER_MODE'],
size_limit: ENV['GITLAB_SIDEKIQ_SIZE_LIMITER_LIMIT_BYTES']
)
@worker_class = worker_class
@job = job
@mode = (mode || TRACK_MODE).to_s.strip
unless MODES.include?(@mode)
::Sidekiq.logger.warn "Invalid Sidekiq size limiter mode: #{@mode}. Fallback to #{TRACK_MODE} mode."
@mode = TRACK_MODE
end
@size_limit = (size_limit || DEFAULT_SIZE_LIMIT).to_i
if @size_limit < 0
::Sidekiq.logger.warn "Invalid Sidekiq size limiter limit: #{@size_limit}"
end
end
def validate!
return unless @size_limit > 0
return if allow_big_payload?
return if job_size <= @size_limit
exception = ExceedLimitError.new(@worker_class, job_size, @size_limit)
# This should belong to Gitlab::ErrorTracking. We'll remove this
# after this epic is done:
# https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/396
exception.set_backtrace(backtrace)
if raise_mode?
raise exception
else
track(exception)
end
end
private
def job_size
# This maynot be the optimal solution, but can be acceptable solution
# for now. Internally, Sidekiq calls Sidekiq.dump_json everywhere.
# There is no clean way to intefere to prevent double serialization.
@job_size ||= ::Sidekiq.dump_json(@job).bytesize
end
def allow_big_payload?
worker_class = @worker_class.to_s.safe_constantize
worker_class.respond_to?(:big_payload?) && worker_class.big_payload?
end
def raise_mode?
@mode == RAISE_MODE
end
def track(exception)
Gitlab::ErrorTracking.track_exception(exception)
end
def backtrace
Gitlab::BacktraceCleaner.clean_backtrace(caller)
end
end
end
end
end

View File

@ -192,7 +192,7 @@ module Gitlab
container_expiration_policies_usage,
service_desk_counts
).tap do |data|
data[:snippets] = data[:personal_snippets] + data[:project_snippets]
data[:snippets] = add(data[:personal_snippets], data[:project_snippets])
end
}
end
@ -227,7 +227,7 @@ module Gitlab
snowplow_event_counts(last_28_days_time_period(column: :collector_tstamp)),
aggregated_metrics_monthly
).tap do |data|
data[:snippets] = data[:personal_snippets] + data[:project_snippets]
data[:snippets] = add(data[:personal_snippets], data[:project_snippets])
end
}
end
@ -821,11 +821,9 @@ module Gitlab
def total_alert_issues
# Remove prometheus table queries once they are deprecated
# To be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/217407.
[
count(Issue.with_alert_management_alerts, start: issue_minimum_id, finish: issue_maximum_id),
add count(Issue.with_alert_management_alerts, start: issue_minimum_id, finish: issue_maximum_id),
count(::Issue.with_self_managed_prometheus_alert_events, start: issue_minimum_id, finish: issue_maximum_id),
count(::Issue.with_prometheus_alert_events, start: issue_minimum_id, finish: issue_maximum_id)
].reduce(:+)
end
def user_minimum_id
@ -952,7 +950,7 @@ module Gitlab
csv_issue_imports = distinct_count(Issues::CsvImport.where(time_period), :user_id)
group_imports = distinct_count(::GroupImportState.where(time_period), :user_id)
project_imports + bulk_imports + jira_issue_imports + csv_issue_imports + group_imports
add(project_imports, bulk_imports, jira_issue_imports, csv_issue_imports, group_imports)
end
# rubocop:enable CodeReuse/ActiveRecord

View File

@ -8884,6 +8884,9 @@ msgstr ""
msgid "Credentials"
msgstr ""
msgid "CredentialsInventory|GPG Keys"
msgstr ""
msgid "CredentialsInventory|No credentials found"
msgstr ""
@ -9785,7 +9788,7 @@ msgstr ""
msgid "DeleteValueStream|'%{name}' Value Stream deleted"
msgstr ""
msgid "DeleteValueStream|Are you sure you want to delete \"%{name}\" Value Stream?"
msgid "DeleteValueStream|Are you sure you want to delete the \"%{name}\" Value Stream?"
msgstr ""
msgid "DeleteValueStream|Delete %{name}"
@ -12493,9 +12496,6 @@ msgstr ""
msgid "Failed to load related branches"
msgstr ""
msgid "Failed to load sidebar confidential toggle"
msgstr ""
msgid "Failed to load sidebar lock status"
msgstr ""
@ -13746,6 +13746,9 @@ msgstr ""
msgid "GitHub import"
msgstr ""
msgid "GitHubImporter|*Merged by: %{author} at %{timestamp}*"
msgstr ""
msgid "GitLab"
msgstr ""
@ -14922,6 +14925,9 @@ msgstr ""
msgid "Hi %{username}!"
msgstr ""
msgid "Hide"
msgstr ""
msgid "Hide archived projects"
msgstr ""
@ -19571,6 +19577,9 @@ msgstr ""
msgid "Move"
msgstr ""
msgid "Move down"
msgstr ""
msgid "Move issue"
msgstr ""
@ -19589,6 +19598,9 @@ msgstr ""
msgid "Move this issue to another project."
msgstr ""
msgid "Move up"
msgstr ""
msgid "MoveIssue|Cannot move issue due to insufficient permissions!"
msgstr ""
@ -21058,7 +21070,7 @@ msgstr ""
msgid "One or more of your personal access tokens has expired."
msgstr ""
msgid "One or more of your personal access tokens will expire in %{days_to_expire} days or less."
msgid "One or more of your personal access tokens will expire in %{days_to_expire} days or less:"
msgstr ""
msgid "Only 'Reporter' roles and above on tiers Premium and above can see Value Stream Analytics."
@ -27809,6 +27821,9 @@ msgstr ""
msgid "Something went wrong while resolving this discussion. Please try again."
msgstr ""
msgid "Something went wrong while setting %{issuableType} confidentiality."
msgstr ""
msgid "Something went wrong while stopping this environment. Please try again."
msgstr ""
@ -31356,9 +31371,6 @@ msgstr ""
msgid "Trials|Skip Trial"
msgstr ""
msgid "Trials|Skip Trial (Continue with Free Account)"
msgstr ""
msgid "Trials|You can always resume this process by selecting your avatar and choosing 'Start an Ultimate trial'"
msgstr ""
@ -31500,6 +31512,12 @@ msgstr ""
msgid "Turn On"
msgstr ""
msgid "Turn off"
msgstr ""
msgid "Turn on"
msgstr ""
msgid "Turn on %{strongStart}usage ping%{strongEnd} to activate analysis of user activity, known as %{docLinkStart}Cohorts%{docLinkEnd}."
msgstr ""
@ -33804,6 +33822,9 @@ msgstr ""
msgid "You are going to turn off the confidentiality. This means %{strongStart}everyone%{strongEnd} will be able to see and leave a comment on this %{issuableType}."
msgstr ""
msgid "You are going to turn on confidentiality. Only team members with %{strongStart}at least Reporter access%{strongEnd} will be able to see and leave comments on the %{issuableType}."
msgstr ""
msgid "You are going to turn on the confidentiality. This means that only team members with %{strongStart}at least Reporter access%{strongEnd} are able to see and leave comments on the %{issuableType}."
msgstr ""
@ -34371,9 +34392,6 @@ msgstr ""
msgid "Your Personal Access Token was revoked"
msgstr ""
msgid "Your Personal Access Tokens will expire in %{days_to_expire} days or less"
msgstr ""
msgid "Your Primary Email will be used for avatar detection."
msgstr ""
@ -34545,6 +34563,9 @@ msgstr ""
msgid "Your personal access token has expired"
msgstr ""
msgid "Your personal access tokens will expire in %{days_to_expire} days or less"
msgstr ""
msgid "Your profile"
msgstr ""

View File

@ -54,9 +54,9 @@
"@babel/preset-env": "^7.10.1",
"@gitlab/at.js": "1.5.7",
"@gitlab/favicon-overlay": "2.0.0",
"@gitlab/svgs": "1.182.0",
"@gitlab/svgs": "1.183.0",
"@gitlab/tributejs": "1.0.0",
"@gitlab/ui": "27.7.1",
"@gitlab/ui": "27.14.0",
"@gitlab/visual-review-tools": "1.6.1",
"@rails/actioncable": "^6.0.3-4",
"@rails/ujs": "^6.0.3-4",
@ -168,7 +168,7 @@
"devDependencies": {
"@babel/plugin-transform-modules-commonjs": "^7.10.1",
"@gitlab/eslint-plugin": "8.1.0",
"@gitlab/stylelint-config": "2.2.0",
"@gitlab/stylelint-config": "2.3.0",
"@testing-library/dom": "^7.16.2",
"@vue/test-utils": "1.1.2",
"acorn": "^6.3.0",

View File

@ -209,16 +209,16 @@ RSpec.describe 'GFM autocomplete', :js do
# Regression test for https://gitlab.com/gitlab-org/gitlab/-/issues/321925
it 'shows username when pasting then pressing Enter' do
fill_in 'Description', with: "@#{user.username}\n"
fill_in 'Comment', with: "@#{user.username}\n"
expect(find_field('Description').value).to have_content "@#{user.username}"
expect(find_field('Comment').value).to have_content "@#{user.username}"
end
it 'does not show `@undefined` when pressing `@` then Enter' do
fill_in 'Description', with: "@\n"
fill_in 'Comment', with: "@\n"
expect(find_field('Description').value).to have_content "@"
expect(find_field('Description').value).not_to have_content "@undefined"
expect(find_field('Comment').value).to have_content "@"
expect(find_field('Comment').value).not_to have_content "@undefined"
end
it 'selects the first item for non-assignee dropdowns if a query is entered' do

View File

@ -210,6 +210,16 @@ describe('fetchIssueLists', () => {
});
describe('createList', () => {
it('should dispatch createIssueList action', () => {
testAction({
action: actions.createList,
payload: { backlog: true },
expectedActions: [{ type: 'createIssueList', payload: { backlog: true } }],
});
});
});
describe('createIssueList', () => {
let commit;
let dispatch;
let getters;
@ -249,7 +259,7 @@ describe('createList', () => {
}),
);
await actions.createList({ getters, state, commit, dispatch }, { backlog: true });
await actions.createIssueList({ getters, state, commit, dispatch }, { backlog: true });
expect(dispatch).toHaveBeenCalledWith('addList', backlogList);
});
@ -271,7 +281,7 @@ describe('createList', () => {
},
});
await actions.createList({ getters, state, commit, dispatch }, { labelId: '4' });
await actions.createIssueList({ getters, state, commit, dispatch }, { labelId: '4' });
expect(dispatch).toHaveBeenCalledWith('addList', list);
expect(dispatch).toHaveBeenCalledWith('highlightList', list.id);
@ -289,7 +299,7 @@ describe('createList', () => {
}),
);
await actions.createList({ getters, state, commit, dispatch }, { backlog: true });
await actions.createIssueList({ getters, state, commit, dispatch }, { backlog: true });
expect(commit).toHaveBeenCalledWith(types.CREATE_LIST_FAILURE);
});
@ -306,7 +316,7 @@ describe('createList', () => {
getListByLabelId: jest.fn().mockReturnValue(existingList),
};
await actions.createList({ getters, state, commit, dispatch }, { backlog: true });
await actions.createIssueList({ getters, state, commit, dispatch }, { backlog: true });
expect(dispatch).toHaveBeenCalledWith('highlightList', existingList.id);
expect(dispatch).toHaveBeenCalledTimes(1);

View File

@ -202,7 +202,7 @@ describe('IssuableItem', () => {
describe('labelTarget', () => {
it('returns target string for a provided label param when `enableLabelPermalinks` is true', () => {
expect(wrapper.vm.labelTarget(mockRegularLabel)).toBe(
'?label_name%5B%5D=Documentation%20Update',
'?label_name[]=Documentation%20Update',
);
});

View File

@ -70,36 +70,6 @@ describe('Description component', () => {
});
});
it('opens reCAPTCHA dialog if update rejected as spam', () => {
let modal;
const recaptchaChild = vm.$children.find(
// eslint-disable-next-line no-underscore-dangle
(child) => child.$options._componentTag === 'recaptcha-modal',
);
recaptchaChild.scriptSrc = '//scriptsrc';
vm.taskListUpdateSuccess({
recaptcha_html: '<div class="g-recaptcha">recaptcha_html</div>',
});
return vm
.$nextTick()
.then(() => {
modal = vm.$el.querySelector('.js-recaptcha-modal');
expect(modal.style.display).not.toEqual('none');
expect(modal.querySelector('.g-recaptcha').textContent).toEqual('recaptcha_html');
expect(document.body.querySelector('.js-recaptcha-script').src).toMatch('//scriptsrc');
})
.then(() => modal.querySelector('.close').click())
.then(() => vm.$nextTick())
.then(() => {
expect(modal.style.display).toEqual('none');
expect(document.body.querySelector('.js-recaptcha-script')).toBeNull();
});
});
it('applies syntax highlighting and math when description changed', () => {
const vmSpy = jest.spyOn(vm, 'renderGFM');
const prototypeSpy = jest.spyOn($.prototype, 'renderGFM');
@ -144,7 +114,6 @@ describe('Description component', () => {
dataType: 'issuableType',
fieldName: 'description',
selector: '.detail-page-description',
onSuccess: expect.any(Function),
onError: expect.any(Function),
lockVersion: 0,
});

View File

@ -814,6 +814,14 @@ describe('URL utility', () => {
);
});
it('decodes URI when decodeURI=true', () => {
const url = 'https://gitlab.com/test';
expect(urlUtils.setUrlParams({ labels: ['foo', 'bar'] }, url, false, true, true)).toEqual(
'https://gitlab.com/test?labels[]=foo&labels[]=bar',
);
});
it('removes all existing URL params and sets a new param when cleanParams=true', () => {
const url = 'https://gitlab.com/test?group_id=gitlab-org&project_id=my-project';

View File

@ -10,7 +10,6 @@ import * as actions from '~/notes/stores/actions';
import * as mutationTypes from '~/notes/stores/mutation_types';
import mutations from '~/notes/stores/mutations';
import * as utils from '~/notes/stores/utils';
import updateIssueConfidentialMutation from '~/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql';
import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql';
import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql';
import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub';
@ -1276,68 +1275,6 @@ describe('Actions Notes Store', () => {
});
});
describe('updateConfidentialityOnIssuable', () => {
state = { noteableData: { confidential: false } };
const iid = '1';
const projectPath = 'full/path';
const getters = { getNoteableData: { iid } };
const actionArgs = { fullPath: projectPath, confidential: true };
const confidential = true;
beforeEach(() => {
jest
.spyOn(utils.gqClient, 'mutate')
.mockResolvedValue({ data: { issueSetConfidential: { issue: { confidential } } } });
});
it('calls gqClient mutation one time', () => {
actions.updateConfidentialityOnIssuable({ commit: () => {}, state, getters }, actionArgs);
expect(utils.gqClient.mutate).toHaveBeenCalledTimes(1);
});
it('calls gqClient mutation with the correct values', () => {
actions.updateConfidentialityOnIssuable({ commit: () => {}, state, getters }, actionArgs);
expect(utils.gqClient.mutate).toHaveBeenCalledWith({
mutation: updateIssueConfidentialMutation,
variables: { input: { iid, projectPath, confidential } },
});
});
describe('on success of mutation', () => {
it('calls commit with the correct values', () => {
const commitSpy = jest.fn();
return actions
.updateConfidentialityOnIssuable({ commit: commitSpy, state, getters }, actionArgs)
.then(() => {
expect(Flash).not.toHaveBeenCalled();
expect(commitSpy).toHaveBeenCalledWith(
mutationTypes.SET_ISSUE_CONFIDENTIAL,
confidential,
);
});
});
});
describe('on user recoverable error', () => {
it('sends the error to Flash', () => {
const error = 'error';
jest
.spyOn(utils.gqClient, 'mutate')
.mockResolvedValue({ data: { issueSetConfidential: { errors: [error] } } });
return actions
.updateConfidentialityOnIssuable({ commit: () => {}, state, getters }, actionArgs)
.then(() => {
expect(Flash).toHaveBeenCalledWith(error, 'alert');
});
});
});
});
describe.each`
issuableType
${'issue'} | ${'merge_request'}

View File

@ -5,7 +5,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import { TEST_HOST } from 'spec/test_constants';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import PipelinesActions from '~/pipelines/components/pipelines_list/pipelines_actions.vue';
import PipelinesManualActions from '~/pipelines/components/pipelines_list/pipelines_manual_actions.vue';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
jest.mock('~/flash');
@ -15,7 +15,7 @@ describe('Pipelines Actions dropdown', () => {
let mock;
const createComponent = (props, mountFn = shallowMount) => {
wrapper = mountFn(PipelinesActions, {
wrapper = mountFn(PipelinesManualActions, {
propsData: {
...props,
},

View File

@ -0,0 +1,58 @@
import { GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import SidebarConfidentialityContent from '~/sidebar/components/confidential/sidebar_confidentiality_content.vue';
describe('Sidebar Confidentiality Content', () => {
let wrapper;
const findIcon = () => wrapper.findComponent(GlIcon);
const findText = () => wrapper.find('[data-testid="confidential-text"]');
const createComponent = (confidential = false) => {
wrapper = shallowMount(SidebarConfidentialityContent, {
propsData: {
confidential,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when issue is non-confidential', () => {
beforeEach(() => {
createComponent();
});
it('renders a non-confidential icon', () => {
expect(findIcon().props('name')).toBe('eye');
});
it('does not add `is-active` class to the icon', () => {
expect(findIcon().classes()).not.toContain('is-active');
});
it('displays a non-confidential text', () => {
expect(findText().text()).toBe('Not confidential');
});
});
describe('when issue is confidential', () => {
beforeEach(() => {
createComponent(true);
});
it('renders a non-confidential icon', () => {
expect(findIcon().props('name')).toBe('eye-slash');
});
it('does not add `is-active` class to the icon', () => {
expect(findIcon().classes()).toContain('is-active');
});
it('displays a non-confidential text', () => {
expect(findText().text()).toBe('This is confidential');
});
});
});

View File

@ -0,0 +1,146 @@
import { GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import SidebarConfidentialityForm from '~/sidebar/components/confidential/sidebar_confidentiality_form.vue';
import { confidentialityQueries } from '~/sidebar/constants';
jest.mock('~/flash');
describe('Sidebar Confidentiality Form', () => {
let wrapper;
const findWarningMessage = () => wrapper.find(`[data-testid="warning-message"]`);
const findConfidentialToggle = () => wrapper.find(`[data-testid="confidential-toggle"]`);
const findCancelButton = () => wrapper.find(`[data-testid="confidential-cancel"]`);
const createComponent = ({
props = {},
mutate = jest.fn().mockResolvedValue('Success'),
} = {}) => {
wrapper = shallowMount(SidebarConfidentialityForm, {
provide: {
fullPath: 'group/project',
iid: '1',
},
propsData: {
confidential: false,
issuableType: 'issue',
...props,
},
mocks: {
$apollo: {
mutate,
},
},
stubs: {
GlSprintf,
},
});
};
afterEach(() => {
wrapper.destroy();
});
it('emits a `closeForm` event when Cancel button is clicked', () => {
createComponent();
findCancelButton().vm.$emit('click');
expect(wrapper.emitted().closeForm).toHaveLength(1);
});
it('renders a loading state after clicking on turn on/off button', async () => {
createComponent();
findConfidentialToggle().vm.$emit('click', new MouseEvent('click'));
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled();
await nextTick();
expect(findConfidentialToggle().props('loading')).toBe(true);
});
it('creates a flash if mutation is rejected', async () => {
createComponent({ mutate: jest.fn().mockRejectedValue('Error!') });
findConfidentialToggle().vm.$emit('click', new MouseEvent('click'));
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: 'Something went wrong while setting issue confidentiality.',
});
});
it('creates a flash if mutation contains errors', async () => {
createComponent({
mutate: jest.fn().mockResolvedValue({
data: { issuableSetConfidential: { errors: ['Houston, we have a problem!'] } },
}),
});
findConfidentialToggle().vm.$emit('click', new MouseEvent('click'));
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: 'Houston, we have a problem!',
});
});
describe('when issue is not confidential', () => {
beforeEach(() => {
createComponent();
});
it('renders a message about making an issue confidential', () => {
expect(findWarningMessage().text()).toBe(
'You are going to turn on confidentiality. Only team members with at least Reporter access will be able to see and leave comments on the issue.',
);
});
it('has a `Turn on` button text', () => {
expect(findConfidentialToggle().text()).toBe('Turn on');
});
it('calls a mutation to set confidential to true on button click', () => {
findConfidentialToggle().vm.$emit('click', new MouseEvent('click'));
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: confidentialityQueries[wrapper.vm.issuableType].mutation,
variables: {
input: {
confidential: true,
iid: '1',
projectPath: 'group/project',
},
},
});
});
});
describe('when issue is confidential', () => {
beforeEach(() => {
createComponent({ props: { confidential: true } });
});
it('renders a message about making an issue non-confidential', () => {
expect(findWarningMessage().text()).toBe(
'You are going to turn off the confidentiality. This means everyone will be able to see and leave a comment on this issue.',
);
});
it('has a `Turn off` button text', () => {
expect(findConfidentialToggle().text()).toBe('Turn off');
});
it('calls a mutation to set confidential to false on button click', () => {
findConfidentialToggle().vm.$emit('click', new MouseEvent('click'));
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: confidentialityQueries[wrapper.vm.issuableType].mutation,
variables: {
input: {
confidential: false,
iid: '1',
projectPath: 'group/project',
},
},
});
});
});
});

View File

@ -0,0 +1,142 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import SidebarConfidentialityContent from '~/sidebar/components/confidential/sidebar_confidentiality_content.vue';
import SidebarConfidentialityForm from '~/sidebar/components/confidential/sidebar_confidentiality_form.vue';
import SidebarConfidentialityWidget, {
confidentialWidget,
} from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql';
import { issueConfidentialityResponse } from '../../mock_data';
jest.mock('~/flash');
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Sidebar Confidentiality Widget', () => {
let wrapper;
let fakeApollo;
const findEditableItem = () => wrapper.findComponent(SidebarEditableItem);
const findConfidentialityForm = () => wrapper.findComponent(SidebarConfidentialityForm);
const findConfidentialityContent = () => wrapper.findComponent(SidebarConfidentialityContent);
const createComponent = ({
confidentialQueryHandler = jest.fn().mockResolvedValue(issueConfidentialityResponse()),
} = {}) => {
fakeApollo = createMockApollo([[issueConfidentialQuery, confidentialQueryHandler]]);
wrapper = shallowMount(SidebarConfidentialityWidget, {
localVue,
apolloProvider: fakeApollo,
provide: {
fullPath: 'group/project',
iid: '1',
canUpdate: true,
},
propsData: {
issuableType: 'issue',
},
stubs: {
SidebarEditableItem,
},
});
};
afterEach(() => {
wrapper.destroy();
fakeApollo = null;
});
it('passes a `loading` prop as true to editable item when query is loading', () => {
createComponent();
expect(findEditableItem().props('loading')).toBe(true);
});
it('exposes a method via external observable', () => {
createComponent();
expect(confidentialWidget.setConfidentiality).toEqual(wrapper.vm.setConfidentiality);
});
describe('when issue is not confidential', () => {
beforeEach(async () => {
createComponent();
await waitForPromises();
});
it('passes a `loading` prop as false to editable item', () => {
expect(findEditableItem().props('loading')).toBe(false);
});
it('passes false to `confidential` prop of child components', () => {
expect(findConfidentialityForm().props('confidential')).toBe(false);
expect(findConfidentialityContent().props('confidential')).toBe(false);
});
it('changes confidentiality to true after setConfidentiality is called', async () => {
confidentialWidget.setConfidentiality();
await nextTick();
expect(findConfidentialityForm().props('confidential')).toBe(true);
expect(findConfidentialityContent().props('confidential')).toBe(true);
});
});
describe('when issue is confidential', () => {
beforeEach(async () => {
createComponent({
confidentialQueryHandler: jest.fn().mockResolvedValue(issueConfidentialityResponse(true)),
});
await waitForPromises();
});
it('passes a `loading` prop as false to editable item', () => {
expect(findEditableItem().props('loading')).toBe(false);
});
it('passes false to `confidential` prop of child components', () => {
expect(findConfidentialityForm().props('confidential')).toBe(true);
expect(findConfidentialityContent().props('confidential')).toBe(true);
});
it('changes confidentiality to false after setConfidentiality is called', async () => {
confidentialWidget.setConfidentiality();
await nextTick();
expect(findConfidentialityForm().props('confidential')).toBe(false);
expect(findConfidentialityContent().props('confidential')).toBe(false);
});
});
it('displays a flash message when query is rejected', async () => {
createComponent({
confidentialQueryHandler: jest.fn().mockRejectedValue('Houston, we have a problem'),
});
await waitForPromises();
expect(createFlash).toHaveBeenCalled();
});
it('closes the form and dispatches an event when `closeForm` is emitted', async () => {
createComponent();
const el = wrapper.vm.$el;
jest.spyOn(el, 'dispatchEvent');
await waitForPromises();
wrapper.vm.$refs.editable.expand();
await nextTick();
expect(findConfidentialityForm().isVisible()).toBe(true);
findConfidentialityForm().vm.$emit('closeForm');
await nextTick();
expect(findConfidentialityForm().isVisible()).toBe(false);
expect(el.dispatchEvent).toHaveBeenCalled();
});
});

View File

@ -220,4 +220,17 @@ const mockData = {
},
};
export const issueConfidentialityResponse = (confidential = false) => ({
data: {
workspace: {
__typename: 'Project',
issuable: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/4',
confidential,
},
},
},
});
export default mockData;

View File

@ -5,37 +5,46 @@ require 'spec_helper'
RSpec.describe Gitlab::GithubImport::Importer::PullRequestMergedByImporter, :clean_gitlab_redis_cache do
let_it_be(:merge_request) { create(:merged_merge_request) }
let(:project) { merge_request.project }
let(:created_at) { Time.new(2017, 1, 1, 12, 00).utc }
let(:merged_at) { Time.new(2017, 1, 1, 12, 00).utc }
let(:client_double) { double(user: double(id: 999, login: 'merger', email: 'merger@email.com')) }
let(:pull_request) do
instance_double(
Gitlab::GithubImport::Representation::PullRequest,
iid: merge_request.iid,
created_at: created_at,
merged_at: merged_at,
merged_by: double(id: 999, login: 'merger')
)
end
subject { described_class.new(pull_request, project, client_double) }
it 'assigns the merged by user when mapped' do
merge_user = create(:user, email: 'merger@email.com')
context 'when the merger user can be mapped' do
it 'assigns the merged by user when mapped' do
merge_user = create(:user, email: 'merger@email.com')
subject.execute
subject.execute
expect(merge_request.metrics.reload.merged_by).to eq(merge_user)
metrics = merge_request.metrics.reload
expect(metrics.merged_by).to eq(merge_user)
expect(metrics.merged_at).to eq(merged_at)
end
end
it 'adds a note referencing the merger user when the user cannot be mapped' do
expect { subject.execute }
.to change(Note, :count).by(1)
.and not_change(merge_request, :updated_at)
context 'when the merger user cannot be mapped to a gitlab user' do
it 'adds a note referencing the merger user' do
expect { subject.execute }
.to change(Note, :count).by(1)
.and not_change(merge_request, :updated_at)
last_note = merge_request.notes.last
metrics = merge_request.metrics.reload
expect(metrics.merged_by).to be_nil
expect(metrics.merged_at).to eq(merged_at)
expect(last_note.note).to eq("*Merged by: merger*")
expect(last_note.created_at).to eq(created_at)
expect(last_note.author).to eq(project.creator)
last_note = merge_request.notes.last
expect(last_note.note).to eq("*Merged by: merger at 2017-01-01 12:00:00 UTC*")
expect(last_note.created_at).to eq(merged_at)
expect(last_note.author).to eq(project.creator)
end
end
end

View File

@ -0,0 +1,99 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Client, :clean_gitlab_redis_queues do
let(:worker_class) do
Class.new do
def self.name
"TestSizeLimiterWorker"
end
include ApplicationWorker
def perform(*args); end
end
end
before do
stub_const("TestSizeLimiterWorker", worker_class)
end
describe '#call' do
context 'when the validator rejects the job' do
before do
allow(Gitlab::SidekiqMiddleware::SizeLimiter::Validator).to receive(:validate!).and_raise(
Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError.new(
TestSizeLimiterWorker, 500, 300
)
)
end
it 'raises an exception when scheduling job with #perform_at' do
expect do
TestSizeLimiterWorker.perform_at(30.seconds.from_now, 1, 2, 3)
end.to raise_error Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError
end
it 'raises an exception when scheduling job with #perform_async' do
expect do
TestSizeLimiterWorker.perform_async(1, 2, 3)
end.to raise_error Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError
end
it 'raises an exception when scheduling job with #perform_in' do
expect do
TestSizeLimiterWorker.perform_in(3.seconds, 1, 2, 3)
end.to raise_error Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError
end
end
context 'when the validator validates the job suscessfully' do
before do
# Do nothing
allow(Gitlab::SidekiqMiddleware::SizeLimiter::Client).to receive(:validate!)
end
it 'raises an exception when scheduling job with #perform_at' do
expect do
TestSizeLimiterWorker.perform_at(30.seconds.from_now, 1, 2, 3)
end.not_to raise_error
expect(TestSizeLimiterWorker.jobs).to contain_exactly(
a_hash_including(
"class" => "TestSizeLimiterWorker",
"args" => [1, 2, 3],
"at" => be_a(Float)
)
)
end
it 'raises an exception when scheduling job with #perform_async' do
expect do
TestSizeLimiterWorker.perform_async(1, 2, 3)
end.not_to raise_error
expect(TestSizeLimiterWorker.jobs).to contain_exactly(
a_hash_including(
"class" => "TestSizeLimiterWorker",
"args" => [1, 2, 3]
)
)
end
it 'raises an exception when scheduling job with #perform_in' do
expect do
TestSizeLimiterWorker.perform_in(3.seconds, 1, 2, 3)
end.not_to raise_error
expect(TestSizeLimiterWorker.jobs).to contain_exactly(
a_hash_including(
"class" => "TestSizeLimiterWorker",
"args" => [1, 2, 3],
"at" => be_a(Float)
)
)
end
end
end
end

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError do
let(:worker_class) do
Class.new do
def self.name
"TestSizeLimiterWorker"
end
include ApplicationWorker
def perform(*args); end
end
end
before do
stub_const("TestSizeLimiterWorker", worker_class)
end
it 'encapsulates worker info' do
exception = described_class.new(TestSizeLimiterWorker, 500, 300)
expect(exception.message).to eql("TestSizeLimiterWorker job exceeds payload size limit (500/300)")
expect(exception.worker_class).to eql(TestSizeLimiterWorker)
expect(exception.size).to be(500)
expect(exception.size_limit).to be(300)
expect(exception.sentry_extra_data).to eql(
worker_class: 'TestSizeLimiterWorker',
size: 500,
size_limit: 300
)
end
end

View File

@ -0,0 +1,253 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do
let(:worker_class) do
Class.new do
def self.name
"TestSizeLimiterWorker"
end
include ApplicationWorker
def perform(*args); end
end
end
before do
stub_const("TestSizeLimiterWorker", worker_class)
end
describe '#initialize' do
context 'when the input mode is valid' do
it 'does not log a warning message' do
expect(::Sidekiq.logger).not_to receive(:warn)
described_class.new(TestSizeLimiterWorker, {}, mode: 'track')
described_class.new(TestSizeLimiterWorker, {}, mode: 'raise')
end
end
context 'when the input mode is invalid' do
it 'defaults to track mode and logs a warning message' do
expect(::Sidekiq.logger).to receive(:warn).with('Invalid Sidekiq size limiter mode: invalid. Fallback to track mode.')
validator = described_class.new(TestSizeLimiterWorker, {}, mode: 'invalid')
expect(validator.mode).to eql('track')
end
end
context 'when the input mode is empty' do
it 'defaults to track mode' do
expect(::Sidekiq.logger).not_to receive(:warn)
validator = described_class.new(TestSizeLimiterWorker, {})
expect(validator.mode).to eql('track')
end
end
context 'when the size input is valid' do
it 'does not log a warning message' do
expect(::Sidekiq.logger).not_to receive(:warn)
described_class.new(TestSizeLimiterWorker, {}, size_limit: 300)
described_class.new(TestSizeLimiterWorker, {}, size_limit: 0)
end
end
context 'when the size input is invalid' do
it 'defaults to 0 and logs a warning message' do
expect(::Sidekiq.logger).to receive(:warn).with('Invalid Sidekiq size limiter limit: -1')
described_class.new(TestSizeLimiterWorker, {}, size_limit: -1)
end
end
context 'when the size input is empty' do
it 'defaults to 0' do
expect(::Sidekiq.logger).not_to receive(:warn)
validator = described_class.new(TestSizeLimiterWorker, {})
expect(validator.size_limit).to be(0)
end
end
end
shared_examples 'validate limit job payload size' do
context 'in track mode' do
let(:mode) { 'track' }
context 'when size limit negative' do
let(:size_limit) { -1 }
it 'does not track jobs' do
expect(Gitlab::ErrorTracking).not_to receive(:track_exception)
validate.call(TestSizeLimiterWorker, { a: 'a' * 300 })
end
it 'does not raise exception' do
expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error
end
end
context 'when size limit is 0' do
let(:size_limit) { 0 }
it 'does not track jobs' do
expect(Gitlab::ErrorTracking).not_to receive(:track_exception)
validate.call(TestSizeLimiterWorker, { a: 'a' * 300 })
end
it 'does not raise exception' do
expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error
end
end
context 'when job size is bigger than size limit' do
let(:size_limit) { 50 }
it 'tracks job' do
expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
be_a(Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError)
)
validate.call(TestSizeLimiterWorker, { a: 'a' * 100 })
end
it 'does not raise an exception' do
expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error
end
context 'when the worker has big_payload attribute' do
before do
worker_class.big_payload!
end
it 'does not track jobs' do
expect(Gitlab::ErrorTracking).not_to receive(:track_exception)
validate.call(TestSizeLimiterWorker, { a: 'a' * 300 })
validate.call('TestSizeLimiterWorker', { a: 'a' * 300 })
end
it 'does not raise an exception' do
expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error
expect { validate.call('TestSizeLimiterWorker', { a: 'a' * 300 }) }.not_to raise_error
end
end
end
context 'when job size is less than size limit' do
let(:size_limit) { 50 }
it 'does not track job' do
expect(Gitlab::ErrorTracking).not_to receive(:track_exception)
validate.call(TestSizeLimiterWorker, { a: 'a' })
end
it 'does not raise an exception' do
expect { validate.call(TestSizeLimiterWorker, { a: 'a' }) }.not_to raise_error
end
end
end
context 'in raise mode' do
let(:mode) { 'raise' }
context 'when size limit is negative' do
let(:size_limit) { -1 }
it 'does not raise exception' do
expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error
end
end
context 'when size limit is 0' do
let(:size_limit) { 0 }
it 'does not raise exception' do
expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error
end
end
context 'when job size is bigger than size limit' do
let(:size_limit) { 50 }
it 'raises an exception' do
expect do
validate.call(TestSizeLimiterWorker, { a: 'a' * 300 })
end.to raise_error(
Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError,
/TestSizeLimiterWorker job exceeds payload size limit/i
)
end
context 'when the worker has big_payload attribute' do
before do
worker_class.big_payload!
end
it 'does not raise an exception' do
expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error
expect { validate.call('TestSizeLimiterWorker', { a: 'a' * 300 }) }.not_to raise_error
end
end
end
context 'when job size is less than size limit' do
let(:size_limit) { 50 }
it 'does not raise an exception' do
expect { validate.call(TestSizeLimiterWorker, { a: 'a' }) }.not_to raise_error
end
end
end
end
describe '#validate!' do
context 'when calling SizeLimiter.validate!' do
let(:validate) { ->(worker_clas, job) { described_class.validate!(worker_class, job) } }
before do
stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_MODE', mode)
stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_LIMIT_BYTES', size_limit)
end
it_behaves_like 'validate limit job payload size'
end
context 'when creating an instance with the related ENV variables' do
let(:validate) do
->(worker_clas, job) do
validator = described_class.new(worker_class, job, mode: mode, size_limit: size_limit)
validator.validate!
end
end
before do
stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_MODE', mode)
stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_LIMIT_BYTES', size_limit)
end
it_behaves_like 'validate limit job payload size'
end
context 'when creating an instance with mode and size limit' do
let(:validate) do
->(worker_clas, job) do
validator = described_class.new(worker_class, job, mode: mode, size_limit: size_limit)
validator.validate!
end
end
it_behaves_like 'validate limit job payload size'
end
end
end

View File

@ -177,6 +177,7 @@ RSpec.describe Gitlab::SidekiqMiddleware do
::Gitlab::SidekiqMiddleware::DuplicateJobs::Client,
::Gitlab::SidekiqStatus::ClientMiddleware,
::Gitlab::SidekiqMiddleware::AdminMode::Client,
::Gitlab::SidekiqMiddleware::SizeLimiter::Client,
::Gitlab::SidekiqMiddleware::ClientMetrics
]
end

View File

@ -125,8 +125,9 @@ RSpec.describe Emails::Profile do
describe 'user personal access token is about to expire' do
let_it_be(:user) { create(:user) }
let_it_be(:expiring_token) { create(:personal_access_token, user: user, expires_at: 5.days.from_now) }
subject { Notify.access_token_about_to_expire_email(user) }
subject { Notify.access_token_about_to_expire_email(user, [expiring_token.name]) }
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
@ -137,13 +138,17 @@ RSpec.describe Emails::Profile do
end
it 'has the correct subject' do
is_expected.to have_subject /^Your Personal Access Tokens will expire in 7 days or less$/i
is_expected.to have_subject /^Your personal access tokens will expire in 7 days or less$/i
end
it 'mentions the access tokens will expire' do
is_expected.to have_body_text /One or more of your personal access tokens will expire in 7 days or less/
end
it 'provides the names of expiring tokens' do
is_expected.to have_body_text /#{expiring_token.name}/
end
it 'includes a link to personal access tokens page' do
is_expected.to have_body_text /#{profile_personal_access_tokens_path}/
end

View File

@ -833,7 +833,7 @@ RSpec.describe Namespace do
end
describe '#all_projects' do
shared_examples 'all projects for a group' do
context 'when namespace is a group' do
let(:namespace) { create(:group) }
let(:child) { create(:group, parent: namespace) }
let!(:project1) { create(:project_empty_repo, namespace: namespace) }
@ -841,49 +841,25 @@ RSpec.describe Namespace do
it { expect(namespace.all_projects.to_a).to match_array([project2, project1]) }
it { expect(child.all_projects.to_a).to match_array([project2]) }
it 'queries for the namespace and its descendants' do
expect(Project).to receive(:where).with(namespace: [namespace, child])
namespace.all_projects
end
end
shared_examples 'all projects for personal namespace' do
context 'when namespace is a user namespace' do
let_it_be(:user) { create(:user) }
let_it_be(:user_namespace) { create(:namespace, owner: user) }
let_it_be(:project) { create(:project, namespace: user_namespace) }
it { expect(user_namespace.all_projects.to_a).to match_array([project]) }
end
context 'with recursive approach' do
context 'when namespace is a group' do
include_examples 'all projects for a group'
it 'only queries for the namespace itself' do
expect(Project).to receive(:where).with(namespace: user_namespace)
it 'queries for the namespace and its descendants' do
expect(Project).to receive(:where).with(namespace: [namespace, child])
namespace.all_projects
end
end
context 'when namespace is a user namespace' do
include_examples 'all projects for personal namespace'
it 'only queries for the namespace itself' do
expect(Project).to receive(:where).with(namespace: user_namespace)
user_namespace.all_projects
end
end
end
context 'with route path wildcard approach' do
before do
stub_feature_flags(recursive_approach_for_all_projects: false)
end
context 'when namespace is a group' do
include_examples 'all projects for a group'
end
context 'when namespace is a user namespace' do
include_examples 'all projects for personal namespace'
user_namespace.all_projects
end
end
end

View File

@ -3,6 +3,9 @@
require 'spec_helper'
RSpec.describe API::Jobs do
include HttpBasicAuthHelpers
include DependencyProxyHelpers
using RSpec::Parameterized::TableSyntax
include HttpIOHelpers
@ -16,20 +19,150 @@ RSpec.describe API::Jobs do
ref: project.default_branch)
end
let!(:job) do
create(:ci_build, :success, :tags, pipeline: pipeline,
artifacts_expire_at: 1.day.since)
end
let(:user) { create(:user) }
let(:api_user) { user }
let(:reporter) { create(:project_member, :reporter, project: project).user }
let(:guest) { create(:project_member, :guest, project: project).user }
let(:running_job) do
create(:ci_build, :running, project: project,
user: user,
pipeline: pipeline,
artifacts_expire_at: 1.day.since)
end
let!(:job) do
create(:ci_build, :success, :tags, pipeline: pipeline,
artifacts_expire_at: 1.day.since)
end
before do
project.add_developer(user)
end
shared_examples 'returns common pipeline data' do
it 'returns common pipeline data' do
expect(json_response['pipeline']).not_to be_empty
expect(json_response['pipeline']['id']).to eq jobx.pipeline.id
expect(json_response['pipeline']['ref']).to eq jobx.pipeline.ref
expect(json_response['pipeline']['sha']).to eq jobx.pipeline.sha
expect(json_response['pipeline']['status']).to eq jobx.pipeline.status
end
end
shared_examples 'returns common job data' do
it 'returns common job data' do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['id']).to eq(jobx.id)
expect(json_response['status']).to eq(jobx.status)
expect(json_response['stage']).to eq(jobx.stage)
expect(json_response['name']).to eq(jobx.name)
expect(json_response['ref']).to eq(jobx.ref)
expect(json_response['tag']).to eq(jobx.tag)
expect(json_response['coverage']).to eq(jobx.coverage)
expect(json_response['allow_failure']).to eq(jobx.allow_failure)
expect(Time.parse(json_response['created_at'])).to be_like_time(jobx.created_at)
expect(Time.parse(json_response['started_at'])).to be_like_time(jobx.started_at)
expect(Time.parse(json_response['artifacts_expire_at'])).to be_like_time(jobx.artifacts_expire_at)
expect(json_response['artifacts_file']).to be_nil
expect(json_response['artifacts']).to be_an Array
expect(json_response['artifacts']).to be_empty
expect(json_response['web_url']).to be_present
end
end
shared_examples 'returns unauthorized' do
it 'returns unauthorized' do
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
describe 'GET /job' do
shared_context 'with auth headers' do
let(:headers_with_token) { header }
let(:params_with_token) { {} }
end
shared_context 'with auth params' do
let(:headers_with_token) { {} }
let(:params_with_token) { param }
end
shared_context 'without auth' do
let(:headers_with_token) { {} }
let(:params_with_token) { {} }
end
before do |example|
unless example.metadata[:skip_before_request]
get api('/job'), headers: headers_with_token, params: params_with_token
end
end
context 'with job token authentication header' do
include_context 'with auth headers' do
let(:header) { { API::Helpers::Runner::JOB_TOKEN_HEADER => running_job.token } }
end
it_behaves_like 'returns common job data' do
let(:jobx) { running_job }
end
it 'returns specific job data' do
expect(json_response['finished_at']).to be_nil
end
it_behaves_like 'returns common pipeline data' do
let(:jobx) { running_job }
end
end
context 'with job token authentication params' do
include_context 'with auth params' do
let(:param) { { job_token: running_job.token } }
end
it_behaves_like 'returns common job data' do
let(:jobx) { running_job }
end
it 'returns specific job data' do
expect(json_response['finished_at']).to be_nil
end
it_behaves_like 'returns common pipeline data' do
let(:jobx) { running_job }
end
end
context 'with non running job' do
include_context 'with auth headers' do
let(:header) { { API::Helpers::Runner::JOB_TOKEN_HEADER => job.token } }
end
it_behaves_like 'returns unauthorized'
end
context 'with basic auth header' do
let(:personal_access_token) { create(:personal_access_token, user: user) }
let(:token) { personal_access_token.token}
include_context 'with auth headers' do
let(:header) { { Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER => token } }
end
it 'does not return a job' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'without authentication' do
include_context 'without auth'
it_behaves_like 'returns unauthorized'
end
end
describe 'GET /projects/:id/jobs' do
let(:query) { {} }
@ -150,39 +283,21 @@ RSpec.describe API::Jobs do
end
context 'authorized user' do
it_behaves_like 'returns common job data' do
let(:jobx) { job }
end
it 'returns specific job data' do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['id']).to eq(job.id)
expect(json_response['status']).to eq(job.status)
expect(json_response['stage']).to eq(job.stage)
expect(json_response['name']).to eq(job.name)
expect(json_response['ref']).to eq(job.ref)
expect(json_response['tag']).to eq(job.tag)
expect(json_response['coverage']).to eq(job.coverage)
expect(json_response['allow_failure']).to eq(job.allow_failure)
expect(Time.parse(json_response['created_at'])).to be_like_time(job.created_at)
expect(Time.parse(json_response['started_at'])).to be_like_time(job.started_at)
expect(Time.parse(json_response['finished_at'])).to be_like_time(job.finished_at)
expect(Time.parse(json_response['artifacts_expire_at'])).to be_like_time(job.artifacts_expire_at)
expect(json_response['artifacts_file']).to be_nil
expect(json_response['artifacts']).to be_an Array
expect(json_response['artifacts']).to be_empty
expect(json_response['duration']).to eq(job.duration)
expect(json_response['web_url']).to be_present
end
it_behaves_like 'a job with artifacts and trace', result_is_array: false do
let(:api_endpoint) { "/projects/#{project.id}/jobs/#{second_job.id}" }
end
it 'returns pipeline data' do
json_job = json_response
expect(json_job['pipeline']).not_to be_empty
expect(json_job['pipeline']['id']).to eq job.pipeline.id
expect(json_job['pipeline']['ref']).to eq job.pipeline.ref
expect(json_job['pipeline']['sha']).to eq job.pipeline.sha
expect(json_job['pipeline']['status']).to eq job.pipeline.status
it_behaves_like 'returns common pipeline data' do
let(:jobx) { job }
end
end

View File

@ -243,11 +243,12 @@ RSpec.describe NotificationService, :mailer do
describe 'AccessToken' do
describe '#access_token_about_to_expire' do
let_it_be(:user) { create(:user) }
let_it_be(:pat) { create(:personal_access_token, user: user, expires_at: 5.days.from_now) }
subject { notification.access_token_about_to_expire(user) }
subject { notification.access_token_about_to_expire(user, [pat.name]) }
it 'sends email to the token owner' do
expect { subject }.to have_enqueued_email(user, mail: "access_token_about_to_expire_email")
expect { subject }.to have_enqueued_email(user, [pat.name], mail: "access_token_about_to_expire_email")
end
end

View File

@ -279,6 +279,10 @@ module GpgHelpers
KEY
end
def primary_keyid2
fingerprint2[-16..-1]
end
def fingerprint2
'C447A6F6BFD9CEF8FB371785571625A930241179'
end

View File

@ -7,18 +7,23 @@ RSpec.describe PersonalAccessTokens::ExpiringWorker, type: :worker do
describe '#perform' do
context 'when a token needs to be notified' do
let_it_be(:pat) { create(:personal_access_token, expires_at: 5.days.from_now) }
let_it_be(:user) { create(:user) }
let_it_be(:expiring_token) { create(:personal_access_token, user: user, expires_at: 5.days.from_now) }
let_it_be(:expiring_token2) { create(:personal_access_token, user: user, expires_at: 3.days.from_now) }
let_it_be(:notified_token) { create(:personal_access_token, user: user, expires_at: 5.days.from_now, expire_notification_delivered: true) }
let_it_be(:not_expiring_token) { create(:personal_access_token, user: user, expires_at: 1.month.from_now) }
let_it_be(:impersonation_token) { create(:personal_access_token, user: user, expires_at: 5.days.from_now, impersonation: true) }
it 'uses notification service to send the email' do
expect_next_instance_of(NotificationService) do |notification_service|
expect(notification_service).to receive(:access_token_about_to_expire).with(pat.user)
expect(notification_service).to receive(:access_token_about_to_expire).with(user, match_array([expiring_token.name, expiring_token2.name]))
end
worker.perform
end
it 'marks the notification as delivered' do
expect { worker.perform }.to change { pat.reload.expire_notification_delivered }.from(false).to(true)
expect { worker.perform }.to change { expiring_token.reload.expire_notification_delivered }.from(false).to(true)
end
end
@ -27,7 +32,7 @@ RSpec.describe PersonalAccessTokens::ExpiringWorker, type: :worker do
it "doesn't use notification service to send the email" do
expect_next_instance_of(NotificationService) do |notification_service|
expect(notification_service).not_to receive(:access_token_about_to_expire).with(pat.user)
expect(notification_service).not_to receive(:access_token_about_to_expire).with(pat.user, [pat.name])
end
worker.perform
@ -43,7 +48,7 @@ RSpec.describe PersonalAccessTokens::ExpiringWorker, type: :worker do
it "doesn't use notification service to send the email" do
expect_next_instance_of(NotificationService) do |notification_service|
expect(notification_service).not_to receive(:access_token_about_to_expire).with(pat.user)
expect(notification_service).not_to receive(:access_token_about_to_expire).with(pat.user, [pat.name])
end
worker.perform

View File

@ -889,29 +889,29 @@
resolved "https://registry.yarnpkg.com/@gitlab/favicon-overlay/-/favicon-overlay-2.0.0.tgz#2f32d0b6a4d5b8ac44e2927083d9ab478a78c984"
integrity sha512-GNcORxXJ98LVGzOT9dDYKfbheqH6lNgPDD72lyXRnQIH7CjgGyos8i17aSBPq1f4s3zF3PyedFiAR4YEZbva2Q==
"@gitlab/stylelint-config@2.2.0":
version "2.2.0"
resolved "https://registry.yarnpkg.com/@gitlab/stylelint-config/-/stylelint-config-2.2.0.tgz#f0139c8bd29525b51ee9f16d26b66283bd2be5bb"
integrity sha512-yLBwRu/geN7nGzoOtF6VV2Fbjhcu2w3PwVnJ5/6wX3MILLO7Wh8zzurIjjSnls8124WUoD7n51Catrjl0hyqDw==
"@gitlab/stylelint-config@2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@gitlab/stylelint-config/-/stylelint-config-2.3.0.tgz#b27e8544ff52a4c5e23ff7a104c7efff1f7078f0"
integrity sha512-8aGdBjNO05xadeGb8GSCyRdr1QcRDaDTUTTjKlD2CfFqvAmaQpCk6NdFMMSHlWQpHHMxS5hQeWiC47sIWON4iQ==
dependencies:
stylelint "^13.2.1"
stylelint-declaration-strict-value "^1.7.7"
stylelint-scss "^3.18.0"
stylelint "13.9.0"
stylelint-declaration-strict-value "1.7.7"
stylelint-scss "3.18.0"
"@gitlab/svgs@1.182.0":
version "1.182.0"
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.182.0.tgz#600cb577c598ff63325c3f6f049e3254abdbb580"
integrity sha512-hV6Hkd92bNyzGm2awdwtyXVjT71QZkfAMVV9qHg9fhu5swA4qLrxzXpYBBfeY3/e2KJIByVfROP8tSFws0R6Kw==
"@gitlab/svgs@1.183.0":
version "1.183.0"
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.183.0.tgz#58a3f49d2355531bfc073f35cbd71855b4a0f9da"
integrity sha512-cOcFlgmTkRhfljT+0arKFtkYMLFMXLk0q74WtMqb9YFRtuPmthqfH0zZd47XZ2yFTSvbEJfcyAXXrPIuX8f8Rg==
"@gitlab/tributejs@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@gitlab/tributejs/-/tributejs-1.0.0.tgz#672befa222aeffc83e7d799b0500a7a4418e59b8"
integrity sha512-nmKw1+hB6MHvlmPz63yPwVs1qQkycHwsKgxpEbzmky16Y6mL4EJMk3w1b8QlOAF/AIAzjCERPhe/R4MJiohbZw==
"@gitlab/ui@27.7.1":
version "27.7.1"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-27.7.1.tgz#e62a9a8d96a5f0e61f09fdbbe1771027caaa7aa4"
integrity sha512-LRmP2SD73NT9ntvKw1pJShPP6DmjCBmu+mBSZiAj535AGpA+GoQcyi+PTXY12nUjnsPwulaFOOhvFPo8IeFm2Q==
"@gitlab/ui@27.14.0":
version "27.14.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-27.14.0.tgz#f80c11df3650380f66aa763867afa32d1901d407"
integrity sha512-cQaMN4pjcV0lCic3w1eiZUru9kV8XHwI3ZRT3LOWoBRQEbK0r3qv71SNKzEGDOtVPYq+WBl0/PRDlThk9H+CAg==
dependencies:
"@babel/standalone" "^7.0.0"
"@gitlab/vue-toasted" "^1.3.0"
@ -919,7 +919,7 @@
copy-to-clipboard "^3.0.8"
dompurify "^2.2.6"
echarts "^4.9.0"
highlight.js "^9.13.1"
highlight.js "^10.6.0"
js-beautify "^1.8.8"
lodash "^4.17.20"
portal-vue "^2.1.6"
@ -5923,7 +5923,12 @@ he@^1.1.0, he@^1.1.1, he@^1.2.0:
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
highlight.js@^9.13.1, highlight.js@~9.13.0:
highlight.js@^10.6.0:
version "10.6.0"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.6.0.tgz#0073aa71d566906965ba6e1b7be7b2682f5e18b6"
integrity sha512-8mlRcn5vk/r4+QcqerapwBYTe+iPL5ih6xrNylxrnBdHQiijDETfXX7VIxC3UiCRiINBJfANBAsPzAvRQj8RpQ==
highlight.js@~9.13.0:
version "9.13.1"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.13.1.tgz#054586d53a6863311168488a0f58d6c505ce641e"
integrity sha512-Sc28JNQNDzaH6PORtRLMvif9RSn1mYuOoX3omVjnb0+HbpPygU2ALBI0R/wsiqCb4/fcp07Gdo8g+fhtFrQl6A==
@ -11278,7 +11283,7 @@ style-search@^0.1.0:
resolved "https://registry.yarnpkg.com/style-search/-/style-search-0.1.0.tgz#7958c793e47e32e07d2b5cafe5c0bf8e12e77902"
integrity sha1-eVjHk+R+MuB9K1yv5cC/jhLneQI=
stylelint-declaration-strict-value@^1.7.7:
stylelint-declaration-strict-value@1.7.7:
version "1.7.7"
resolved "https://registry.yarnpkg.com/stylelint-declaration-strict-value/-/stylelint-declaration-strict-value-1.7.7.tgz#d2f0aabc7f3e701a8988207f27d9696bd1d1ed0d"
integrity sha512-Gr5RZYMIS7af6N6PGRD3vmCJM/NlKY4D/jWdidQqxcXkhBtsmV6C99GjQOB0nfdYtjfJEQZLMlTNBztY4tRGfA==
@ -11286,10 +11291,10 @@ stylelint-declaration-strict-value@^1.7.7:
css-values "^0.1.0"
shortcss "^0.1.3"
stylelint-scss@^3.18.0:
version "3.19.0"
resolved "https://registry.yarnpkg.com/stylelint-scss/-/stylelint-scss-3.19.0.tgz#528006d5a4c5a0f1f4d709b02fd3f626ed66d742"
integrity sha512-Ic5bsmpS4wVucOw44doC1Yi9f5qbeVL4wPFiEOaUElgsOuLEN6Ofn/krKI8BeNL2gAn53Zu+IcVV4E345r6rBw==
stylelint-scss@3.18.0:
version "3.18.0"
resolved "https://registry.yarnpkg.com/stylelint-scss/-/stylelint-scss-3.18.0.tgz#8f06371c223909bf3f62e839548af1badeed31e9"
integrity sha512-LD7+hv/6/ApNGt7+nR/50ft7cezKP2HM5rI8avIdGaUWre3xlHfV4jKO/DRZhscfuN+Ewy9FMhcTq0CcS0C/SA==
dependencies:
lodash "^4.17.15"
postcss-media-query-parser "^0.2.3"
@ -11297,7 +11302,7 @@ stylelint-scss@^3.18.0:
postcss-selector-parser "^6.0.2"
postcss-value-parser "^4.1.0"
stylelint@^13.2.1:
stylelint@13.9.0:
version "13.9.0"
resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-13.9.0.tgz#93921ee6e11d4556b9f31131f485dc813b68e32a"
integrity sha512-VVWH2oixOAxpWL1vH+V42ReCzBjW2AeqskSAbi8+3OjV1Xg3VZkmTcAqBZfRRvJeF4BvYuDLXebW3tIHxgZDEg==