Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-08-10 15:09:49 +00:00
parent bf593ae68b
commit 7073275386
45 changed files with 1617 additions and 568 deletions

View File

@ -1,12 +1,10 @@
<script>
import { GlTooltipDirective, GlDeprecatedButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { GlTooltipDirective, GlButton } from '@gitlab/ui';
export default {
name: 'ResolveWithIssueButton',
components: {
Icon,
GlDeprecatedButton,
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
@ -22,13 +20,12 @@ export default {
<template>
<div class="btn-group" role="group">
<gl-deprecated-button
<gl-button
v-gl-tooltip
:href="url"
:title="s__('MergeRequests|Resolve this thread in a new issue')"
class="new-issue-for-discussion discussion-create-issue-btn"
>
<icon name="issue-new" />
</gl-deprecated-button>
icon="issue-new"
/>
</div>
</template>

View File

@ -23,7 +23,6 @@ import {
commentLineOptions,
formatLineRange,
} from './multiline_comment_utils';
import MultilineCommentForm from './multiline_comment_form.vue';
export default {
name: 'NoteableNote',
@ -34,7 +33,6 @@ export default {
noteActions,
NoteBody,
TimelineEntryItem,
MultilineCommentForm,
},
mixins: [noteable, resolvable, glFeatureFlagsMixin()],
props: {
@ -147,14 +145,16 @@ export default {
return getEndLineNumber(this.lineRange);
},
showMultiLineComment() {
if (!this.glFeatures.multilineComments || !this.discussionRoot) return false;
if (this.isEditing) return true;
if (
!this.glFeatures.multilineComments ||
!this.discussionRoot ||
this.startLineNumber.length === 0 ||
this.endLineNumber.length === 0
)
return false;
return this.line && this.startLineNumber !== this.endLineNumber;
},
showMultilineCommentForm() {
return Boolean(this.isEditing && this.note.position && this.diffFile && this.line);
},
commentLineOptions() {
const sideA = this.line.type === 'new' ? 'right' : 'left';
const sideB = sideA === 'left' ? 'right' : 'left';
@ -344,28 +344,19 @@ export default {
:data-note-id="note.id"
class="note note-wrapper qa-noteable-note-item"
>
<div v-if="showMultiLineComment" data-testid="multiline-comment">
<multiline-comment-form
v-if="showMultilineCommentForm"
v-model="commentLineStart"
:line="line"
:comment-line-options="commentLineOptions"
:line-range="note.position.line_range"
class="gl-mb-3 gl-text-gray-700 gl-pb-3"
/>
<div
v-else
class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3"
>
<gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')">
<template #startLine>
<span :class="getLineClasses(startLineNumber)">{{ startLineNumber }}</span>
</template>
<template #endLine>
<span :class="getLineClasses(endLineNumber)">{{ endLineNumber }}</span>
</template>
</gl-sprintf>
</div>
<div
v-if="showMultiLineComment"
data-testid="multiline-comment"
class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3"
>
<gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')">
<template #startLine>
<span :class="getLineClasses(startLineNumber)">{{ startLineNumber }}</span>
</template>
<template #endLine>
<span :class="getLineClasses(endLineNumber)">{{ endLineNumber }}</span>
</template>
</gl-sprintf>
</div>
<div v-once class="timeline-icon">
<user-avatar-link

View File

@ -3,7 +3,6 @@ import { mapState, mapActions, mapGetters } from 'vuex';
import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import { BACK_URL_PARAM } from '~/releases/constants';
import { getParameterByName } from '~/lib/utils/common_utils';
import AssetLinksForm from './asset_links_form.vue';
@ -22,9 +21,6 @@ export default {
MilestoneCombobox,
TagField,
},
directives: {
autofocusonshow,
},
mixins: [glFeatureFlagsMixin()],
computed: {
...mapState('detail', [
@ -40,9 +36,9 @@ export default {
'manageMilestonesPath',
'projectId',
]),
...mapGetters('detail', ['isValid']),
...mapGetters('detail', ['isValid', 'isExistingRelease']),
showForm() {
return !this.isFetchingRelease && !this.fetchError;
return Boolean(!this.isFetchingRelease && !this.fetchError && this.release);
},
subtitleText() {
return sprintf(
@ -86,6 +82,9 @@ export default {
showAssetLinksForm() {
return this.glFeatures.releaseAssetLinkEditing;
},
saveButtonLabel() {
return this.isExistingRelease ? __('Save changes') : __('Create release');
},
isSaveChangesDisabled() {
return this.isUpdatingRelease || !this.isValid;
},
@ -102,13 +101,17 @@ export default {
];
},
},
created() {
this.fetchRelease();
mounted() {
// eslint-disable-next-line promise/catch-or-return
this.initializeRelease().then(() => {
// Focus the first non-disabled input element
this.$el.querySelector('input:enabled').focus();
});
},
methods: {
...mapActions('detail', [
'fetchRelease',
'updateRelease',
'initializeRelease',
'saveRelease',
'updateReleaseTitle',
'updateReleaseNotes',
'updateReleaseMilestones',
@ -119,7 +122,7 @@ export default {
<template>
<div class="d-flex flex-column">
<p class="pt-3 js-subtitle-text" v-html="subtitleText"></p>
<form v-if="showForm" @submit.prevent="updateRelease()">
<form v-if="showForm" @submit.prevent="saveRelease()">
<tag-field />
<gl-form-group>
<label for="release-title">{{ __('Release title') }}</label>
@ -127,8 +130,6 @@ export default {
id="release-title"
ref="releaseTitleInput"
v-model="releaseTitle"
v-autofocusonshow
autofocus
type="text"
class="form-control"
/>
@ -162,8 +163,8 @@ export default {
data-supports-quick-actions="false"
:aria-label="__('Release notes')"
:placeholder="__('Write your release notes or drag your files here…')"
@keydown.meta.enter="updateRelease()"
@keydown.ctrl.enter="updateRelease()"
@keydown.meta.enter="saveRelease()"
@keydown.ctrl.enter="saveRelease()"
></textarea>
</template>
</markdown-field>
@ -178,10 +179,11 @@ export default {
category="primary"
variant="success"
type="submit"
:aria-label="__('Save changes')"
:disabled="isSaveChangesDisabled"
>{{ __('Save changes') }}</gl-button
data-testid="submit-button"
>
{{ saveButtonLabel }}
</gl-button>
<gl-button :href="cancelPath" class="js-cancel-button">{{ __('Cancel') }}</gl-button>
</div>
</form>

View File

@ -3,125 +3,48 @@ import api from '~/api';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility';
import {
convertObjectPropsToCamelCase,
convertObjectPropsToSnakeCase,
} from '~/lib/utils/common_utils';
import { releaseToApiJson, apiJsonToRelease } from '~/releases/util';
export const requestRelease = ({ commit }) => commit(types.REQUEST_RELEASE);
export const receiveReleaseSuccess = ({ commit }, data) =>
commit(types.RECEIVE_RELEASE_SUCCESS, data);
export const receiveReleaseError = ({ commit }, error) => {
commit(types.RECEIVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while getting the release details'));
export const initializeRelease = ({ commit, dispatch, getters }) => {
if (getters.isExistingRelease) {
// When editing an existing release,
// fetch the release object from the API
return dispatch('fetchRelease');
}
// When creating a new release, initialize the
// store with an empty release object
commit(types.INITIALIZE_EMPTY_RELEASE);
return Promise.resolve();
};
export const fetchRelease = ({ dispatch, state }) => {
dispatch('requestRelease');
export const fetchRelease = ({ commit, state }) => {
commit(types.REQUEST_RELEASE);
return api
.release(state.projectId, state.tagName)
.then(({ data }) => {
const release = {
...data,
milestones: data.milestones || [],
};
dispatch('receiveReleaseSuccess', convertObjectPropsToCamelCase(release, { deep: true }));
commit(types.RECEIVE_RELEASE_SUCCESS, apiJsonToRelease(data));
})
.catch(error => {
dispatch('receiveReleaseError', error);
commit(types.RECEIVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while getting the release details'));
});
};
export const updateReleaseTagName = ({ commit }, tagName) =>
commit(types.UPDATE_RELEASE_TAG_NAME, tagName);
export const updateCreateFrom = ({ commit }, createFrom) =>
commit(types.UPDATE_CREATE_FROM, createFrom);
export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title);
export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes);
export const updateReleaseMilestones = ({ commit }, milestones) =>
commit(types.UPDATE_RELEASE_MILESTONES, milestones);
export const requestUpdateRelease = ({ commit }) => commit(types.REQUEST_UPDATE_RELEASE);
export const receiveUpdateReleaseSuccess = ({ commit, state, rootState }) => {
commit(types.RECEIVE_UPDATE_RELEASE_SUCCESS);
redirectTo(
rootState.featureFlags.releaseShowPage ? state.release._links.self : state.releasesPagePath,
);
};
export const receiveUpdateReleaseError = ({ commit }, error) => {
commit(types.RECEIVE_UPDATE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while saving the release details'));
};
export const updateRelease = ({ dispatch, state, getters }) => {
dispatch('requestUpdateRelease');
const { release } = state;
const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : [];
const updatedRelease = convertObjectPropsToSnakeCase(
{
name: release.name,
description: release.description,
milestones,
},
{ deep: true },
);
return (
api
.updateRelease(state.projectId, state.tagName, updatedRelease)
/**
* Currently, we delete all existing links and then
* recreate new ones on each edit. This is because the
* REST API doesn't support bulk updating of Release links,
* and updating individual links can lead to validation
* race conditions (in particular, the "URLs must be unique")
* constraint.
*
* This isn't ideal since this is no longer an atomic
* operation - parts of it can fail while others succeed,
* leaving the Release in an inconsistent state.
*
* This logic should be refactored to use GraphQL once
* https://gitlab.com/gitlab-org/gitlab/-/issues/208702
* is closed.
*/
.then(() => {
// Delete all links currently associated with this Release
return Promise.all(
getters.releaseLinksToDelete.map(l =>
api.deleteReleaseLink(state.projectId, release.tagName, l.id),
),
);
})
.then(() => {
// Create a new link for each link in the form
return Promise.all(
getters.releaseLinksToCreate.map(l =>
api.createReleaseLink(
state.projectId,
release.tagName,
convertObjectPropsToSnakeCase(l, { deep: true }),
),
),
);
})
.then(() => dispatch('receiveUpdateReleaseSuccess'))
.catch(error => {
dispatch('receiveUpdateReleaseError', error);
})
);
};
export const navigateToReleasesPage = ({ state }) => {
redirectTo(state.releasesPagePath);
};
export const addEmptyAssetLink = ({ commit }) => {
commit(types.ADD_EMPTY_ASSET_LINK);
};
@ -141,3 +64,95 @@ export const updateAssetLinkType = ({ commit }, { linkIdToUpdate, newType }) =>
export const removeAssetLink = ({ commit }, linkIdToRemove) => {
commit(types.REMOVE_ASSET_LINK, linkIdToRemove);
};
export const receiveSaveReleaseSuccess = ({ commit, state, rootState }, release) => {
commit(types.RECEIVE_SAVE_RELEASE_SUCCESS);
redirectTo(rootState.featureFlags.releaseShowPage ? release._links.self : state.releasesPagePath);
};
export const saveRelease = ({ commit, dispatch, getters }) => {
commit(types.REQUEST_SAVE_RELEASE);
dispatch(getters.isExistingRelease ? 'updateRelease' : 'createRelease');
};
export const createRelease = ({ commit, dispatch, state, getters }) => {
const apiJson = releaseToApiJson(
{
...state.release,
assets: {
links: getters.releaseLinksToCreate,
},
},
state.createFrom,
);
return api
.createRelease(state.projectId, apiJson)
.then(({ data }) => {
dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(data));
})
.catch(error => {
commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while creating a new release'));
});
};
export const updateRelease = ({ commit, dispatch, state, getters }) => {
const apiJson = releaseToApiJson({
...state.release,
assets: {
links: getters.releaseLinksToCreate,
},
});
let updatedRelease = null;
return (
api
.updateRelease(state.projectId, state.tagName, apiJson)
/**
* Currently, we delete all existing links and then
* recreate new ones on each edit. This is because the
* REST API doesn't support bulk updating of Release links,
* and updating individual links can lead to validation
* race conditions (in particular, the "URLs must be unique")
* constraint.
*
* This isn't ideal since this is no longer an atomic
* operation - parts of it can fail while others succeed,
* leaving the Release in an inconsistent state.
*
* This logic should be refactored to use GraphQL once
* https://gitlab.com/gitlab-org/gitlab/-/issues/208702
* is closed.
*/
.then(({ data }) => {
// Save this response since we need it later in the Promise chain
updatedRelease = data;
// Delete all links currently associated with this Release
return Promise.all(
getters.releaseLinksToDelete.map(l =>
api.deleteReleaseLink(state.projectId, state.release.tagName, l.id),
),
);
})
.then(() => {
// Create a new link for each link in the form
return Promise.all(
apiJson.assets.links.map(l =>
api.createReleaseLink(state.projectId, state.release.tagName, l),
),
);
})
.then(() => {
dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(updatedRelease));
})
.catch(error => {
commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while saving the release details'));
})
);
};

View File

@ -6,7 +6,7 @@ import { hasContent } from '~/lib/utils/text_utility';
* `false` if the app is creating a new release.
*/
export const isExistingRelease = state => {
return Boolean(state.originalRelease);
return Boolean(state.tagName);
};
/**

View File

@ -1,3 +1,5 @@
export const INITIALIZE_EMPTY_RELEASE = 'INITIALIZE_EMPTY_RELEASE';
export const REQUEST_RELEASE = 'REQUEST_RELEASE';
export const RECEIVE_RELEASE_SUCCESS = 'RECEIVE_RELEASE_SUCCESS';
export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR';
@ -8,9 +10,9 @@ export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE';
export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES';
export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES';
export const REQUEST_UPDATE_RELEASE = 'REQUEST_UPDATE_RELEASE';
export const RECEIVE_UPDATE_RELEASE_SUCCESS = 'RECEIVE_UPDATE_RELEASE_SUCCESS';
export const RECEIVE_UPDATE_RELEASE_ERROR = 'RECEIVE_UPDATE_RELEASE_ERROR';
export const REQUEST_SAVE_RELEASE = 'REQUEST_SAVE_RELEASE';
export const RECEIVE_SAVE_RELEASE_SUCCESS = 'RECEIVE_SAVE_RELEASE_SUCCESS';
export const RECEIVE_SAVE_RELEASE_ERROR = 'RECEIVE_SAVE_RELEASE_ERROR';
export const ADD_EMPTY_ASSET_LINK = 'ADD_EMPTY_ASSET_LINK';
export const UPDATE_ASSET_LINK_URL = 'UPDATE_ASSET_LINK_URL';

View File

@ -7,6 +7,18 @@ const findReleaseLink = (release, id) => {
};
export default {
[types.INITIALIZE_EMPTY_RELEASE](state) {
state.release = {
tagName: null,
name: '',
description: '',
milestones: [],
assets: {
links: [],
},
};
},
[types.REQUEST_RELEASE](state) {
state.isFetchingRelease = true;
},
@ -39,14 +51,14 @@ export default {
state.release.milestones = milestones;
},
[types.REQUEST_UPDATE_RELEASE](state) {
[types.REQUEST_SAVE_RELEASE](state) {
state.isUpdatingRelease = true;
},
[types.RECEIVE_UPDATE_RELEASE_SUCCESS](state) {
[types.RECEIVE_SAVE_RELEASE_SUCCESS](state) {
state.updateError = undefined;
state.isUpdatingRelease = false;
},
[types.RECEIVE_UPDATE_RELEASE_ERROR](state, error) {
[types.RECEIVE_SAVE_RELEASE_ERROR](state, error) {
state.updateError = error;
state.isUpdatingRelease = false;
},

View File

@ -0,0 +1,39 @@
import {
convertObjectPropsToCamelCase,
convertObjectPropsToSnakeCase,
} from '~/lib/utils/common_utils';
/**
* Converts a release object into a JSON object that can sent to the public
* API to create or update a release.
* @param {Object} release The release object to convert
* @param {string} createFrom The ref to create a new tag from, if necessary
*/
export const releaseToApiJson = (release, createFrom = null) => {
const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : [];
return convertObjectPropsToSnakeCase(
{
tagName: release.tagName,
ref: createFrom,
name: release.name,
description: release.description,
milestones,
assets: release.assets,
},
{ deep: true },
);
};
/**
* Converts a JSON release object returned by the Release API
* into the structure this Vue application can work with.
* @param {Object} json The JSON object received from the release API
*/
export const apiJsonToRelease = json => {
const release = convertObjectPropsToCamelCase(json, { deep: true });
release.milestones = release.milestones || [];
return release;
};

View File

@ -1,26 +1,79 @@
/**
* The purpose of this file is to modify Markdown source such that templated code (embedded ruby currently) can be temporarily wrapped and unwrapped in codeblocks:
* 1. `wrap()`: temporarily wrap in codeblocks (useful for a WYSIWYG editing experience)
* 2. `unwrap()`: undo the temporarily wrapped codeblocks (useful for Markdown editing experience and saving edits)
*
* Without this `templater`, the templated code is otherwise interpreted as Markdown content resulting in loss of spacing, indentation, escape characters, etc.
*
*/
const ticks = '```';
const marker = 'sse';
const prefix = `${ticks} ${marker}\n`; // Space intentional due to https://github.com/nhn/tui.editor/blob/6bcec75c69028570d93d973aa7533090257eaae0/libs/to-mark/src/renderer.gfm.js#L26
const postfix = `\n${ticks}`;
const flagPrefix = `${marker}-${Date.now()}`;
const template = `.| |\\t|\\n(?!(\\n|${flagPrefix}))`;
const templatedRegex = new RegExp(`(^${prefix}(${template})+?${postfix}$)`, 'gm');
const wrapPrefix = `${ticks} ${marker}\n`; // Space intentional due to https://github.com/nhn/tui.editor/blob/6bcec75c69028570d93d973aa7533090257eaae0/libs/to-mark/src/renderer.gfm.js#L26
const wrapPostfix = `\n${ticks}`;
const markPrefix = `${marker}-${Date.now()}`;
const nonErbMarkupRegex = new RegExp(`^((<(?!%).+>){1}(${template})+(</.+>){1})$`, 'gm');
const embeddedRubyBlockRegex = new RegExp(`(^<%(${template})+%>$)`, 'gm');
const embeddedRubyInlineRegex = new RegExp(`(^.*[<|&lt;]%(${template})+$)`, 'gm');
const reHelpers = {
template: `.| |\\t|\\n(?!(\\n|${markPrefix}))`,
openTag: '<[a-zA-Z]+.*?>',
closeTag: '</.+>',
};
const reTemplated = new RegExp(`(^${wrapPrefix}(${reHelpers.template})+?${wrapPostfix}$)`, 'gm');
const rePreexistingCodeBlocks = new RegExp(`(^${ticks}.*\\n(.|\\s)+?${ticks}$)`, 'gm');
const reHtmlMarkup = new RegExp(
`^((${reHelpers.openTag}){1}(${reHelpers.template})*(${reHelpers.closeTag}){1})$`,
'gm',
);
const reEmbeddedRubyBlock = new RegExp(`(^<%(${reHelpers.template})+%>$)`, 'gm');
const reEmbeddedRubyInline = new RegExp(`(^.*[<|&lt;]%(${reHelpers.template})+$)`, 'gm');
// Order is intentional (general to specific) where HTML markup is flagged first, then ERB blocks, then inline ERB
// Order in combo with the `flag()` algorithm is used to mitigate potential duplicate pattern matches (ERB nested in HTML for example)
const orderedPatterns = [nonErbMarkupRegex, embeddedRubyBlockRegex, embeddedRubyInlineRegex];
const patternGroups = {
ignore: [rePreexistingCodeBlocks],
// Order is intentional (general to specific) where HTML markup is marked first, then ERB blocks, then inline ERB
// Order in combo with the `mark()` algorithm is used to mitigate potential duplicate pattern matches (ERB nested in HTML for example)
allow: [reHtmlMarkup, reEmbeddedRubyBlock, reEmbeddedRubyInline],
};
const mark = (source, groups) => {
let text = source;
let id = 0;
const hash = {};
Object.entries(groups).forEach(([groupKey, group]) => {
group.forEach(pattern => {
const matches = text.match(pattern);
if (matches) {
matches.forEach(match => {
const key = `${markPrefix}-${groupKey}-${id}`;
text = text.replace(match, key);
hash[key] = match;
id += 1;
});
}
});
});
return { text, hash };
};
const unmark = (text, hash) => {
let source = text;
Object.entries(hash).forEach(([key, value]) => {
const newVal = key.includes('ignore') ? value : `${wrapPrefix}${value}${wrapPostfix}`;
source = source.replace(key, newVal);
});
return source;
};
const unwrap = source => {
let text = source;
const matches = text.match(templatedRegex);
const matches = text.match(reTemplated);
if (matches) {
matches.forEach(match => {
const initial = match.replace(`${prefix}`, '').replace(`${postfix}`, '');
const initial = match.replace(`${wrapPrefix}`, '').replace(`${wrapPostfix}`, '');
text = text.replace(match, initial);
});
}
@ -28,35 +81,9 @@ const unwrap = source => {
return text;
};
const flag = (source, patterns) => {
let text = source;
let id = 0;
const hash = {};
patterns.forEach(pattern => {
const matches = text.match(pattern);
if (matches) {
matches.forEach(match => {
const key = `${flagPrefix}${id}`;
text = text.replace(match, key);
hash[key] = match;
id += 1;
});
}
});
return { text, hash };
};
const wrap = source => {
const { text, hash } = flag(unwrap(source), orderedPatterns);
let wrappedSource = text;
Object.entries(hash).forEach(([key, value]) => {
wrappedSource = wrappedSource.replace(key, `${prefix}${value}${postfix}`);
});
return wrappedSource;
const { text, hash } = mark(unwrap(source), patternGroups);
return unmark(text, hash);
};
export default { wrap, unwrap };

View File

@ -0,0 +1,91 @@
# frozen_string_literal: true
module Mutations
module Boards
module Issues
class IssueMoveList < Mutations::Issues::Base
graphql_name 'IssueMoveList'
argument :board_id, GraphQL::ID_TYPE,
required: true,
loads: Types::BoardType,
description: 'Global ID of the board that the issue is in'
argument :project_path, GraphQL::ID_TYPE,
required: true,
description: 'Project the issue to mutate is in'
argument :iid, GraphQL::STRING_TYPE,
required: true,
description: 'IID of the issue to mutate'
argument :from_list_id, GraphQL::ID_TYPE,
required: false,
description: 'ID of the board list that the issue will be moved from'
argument :to_list_id, GraphQL::ID_TYPE,
required: false,
description: 'ID of the board list that the issue will be moved to'
argument :move_before_id, GraphQL::ID_TYPE,
required: false,
description: 'ID of issue before which the current issue will be positioned at'
argument :move_after_id, GraphQL::ID_TYPE,
required: false,
description: 'ID of issue after which the current issue will be positioned at'
def ready?(**args)
if move_arguments(args).blank?
raise Gitlab::Graphql::Errors::ArgumentError,
'At least one of the arguments fromListId, toListId, afterId or beforeId is required'
end
if move_list_arguments(args).one?
raise Gitlab::Graphql::Errors::ArgumentError,
'Both fromListId and toListId must be present'
end
super
end
def resolve(board:, **args)
raise_resource_not_available_error! unless board
authorize_board!(board)
issue = authorized_find!(project_path: args[:project_path], iid: args[:iid])
move_params = { id: issue.id, board_id: board.id }.merge(move_arguments(args))
move_issue(board, issue, move_params)
{
issue: issue.reset,
errors: issue.errors.full_messages
}
end
private
def move_issue(board, issue, move_params)
service = ::Boards::Issues::MoveService.new(board.resource_parent, current_user, move_params)
service.execute(issue)
end
def move_list_arguments(args)
args.slice(:from_list_id, :to_list_id)
end
def move_arguments(args)
args.slice(:from_list_id, :to_list_id, :move_after_id, :move_before_id)
end
def authorize_board!(board)
return if Ability.allowed?(current_user, :read_board, board.resource_parent)
raise_resource_not_available_error!
end
end
end
end
end

View File

@ -14,6 +14,7 @@ module Types
mount_mutation Mutations::AwardEmojis::Add
mount_mutation Mutations::AwardEmojis::Remove
mount_mutation Mutations::AwardEmojis::Toggle
mount_mutation Mutations::Boards::Issues::IssueMoveList
mount_mutation Mutations::Branches::Create, calls_gitaly: true
mount_mutation Mutations::Commits::Create, calls_gitaly: true
mount_mutation Mutations::Discussions::ToggleResolve

View File

@ -12,7 +12,8 @@ module TriggerableHooks
merge_request_hooks: :merge_requests_events,
job_hooks: :job_events,
pipeline_hooks: :pipeline_events,
wiki_page_hooks: :wiki_page_events
wiki_page_hooks: :wiki_page_events,
deployment_hooks: :deployment_events
}.freeze
extend ActiveSupport::Concern

View File

@ -148,6 +148,7 @@ class Deployment < ApplicationRecord
def execute_hooks
deployment_data = Gitlab::DataBuilder::Deployment.build(self)
project.execute_hooks(deployment_data, :deployment_hooks) if Feature.enabled?(:deployment_webhooks, project)
project.execute_services(deployment_data, :deployment_hooks)
end

View File

@ -17,7 +17,8 @@ class ProjectHook < WebHook
:merge_request_hooks,
:job_hooks,
:pipeline_hooks,
:wiki_page_hooks
:wiki_page_hooks,
:deployment_hooks
]
belongs_to :project

View File

@ -75,8 +75,6 @@ module Git
end
def merge_request_branches_for(changes)
return if Feature.disabled?(:refresh_only_existing_merge_requests_on_push, default_enabled: true)
@merge_requests_branches ||= MergeRequests::PushedBranchesService.new(project, current_user, changes: changes).execute
end
end

View File

@ -9,8 +9,23 @@ let presets = [
useBuiltIns: 'usage',
corejs: { version: 3, proposals: true },
modules: false,
/**
* This list of browsers is a conservative first definition, based on
* https://docs.gitlab.com/ee/install/requirements.html#supported-web-browsers
* with the following reasoning:
*
* - Edge: Pick the last two major version before the Chrome switch
* - Rest: We should support the latest ESR of Firefox: 68, because it used quite a lot.
* For the rest, pick browser versions that have a similar age to Firefox 68.
*
* See also this follow-up epic:
* https://gitlab.com/groups/gitlab-org/-/epics/3957
*/
targets: {
ie: '11',
chrome: '73',
edge: '17',
firefox: '68',
safari: '12',
},
},
],
@ -22,6 +37,8 @@ const plugins = [
'@babel/plugin-proposal-class-properties',
'@babel/plugin-proposal-json-strings',
'@babel/plugin-proposal-private-methods',
// See: https://gitlab.com/gitlab-org/gitlab/-/issues/229146
'@babel/plugin-transform-arrow-functions',
'lodash',
];

View File

@ -0,0 +1,5 @@
---
title: GraphQL mutation to move issue within board lists
merge_request: 38309
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Add pre-processing step so preexisting codeblocks are preserved prior to flagging content as code in the static site editor's WYSIWYG mode.
merge_request: 38834
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Fix multiline comment rendering
merge_request: 38721
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Remove Internet Explorer 11 from babel transpilation
merge_request: 36840
author:
type: removed

View File

@ -15,7 +15,7 @@ is generally stable and can handle many requests, so it is an acceptable
trade off to have only a single instance. See the [reference architectures](../reference_architectures/index.md)
page for an overview of GitLab scaling options.
## Set up a standalone Redis instance
## Set up the standalone Redis instance
The steps below are the minimum necessary to configure a Redis server with
Omnibus GitLab:
@ -28,36 +28,49 @@ Omnibus GitLab:
1. Edit `/etc/gitlab/gitlab.rb` and add the contents:
```ruby
## Enable Redis
redis['enable'] = true
## Disable all other services
sidekiq['enable'] = false
gitlab_workhorse['enable'] = false
puma['enable'] = false
postgresql['enable'] = false
nginx['enable'] = false
prometheus['enable'] = false
alertmanager['enable'] = false
pgbouncer_exporter['enable'] = false
gitlab_exporter['enable'] = false
gitaly['enable'] = false
## Enable Redis and disable all other services
## https://docs.gitlab.com/omnibus/roles/
roles ['redis_master_role']
## Redis configuration
redis['bind'] = '0.0.0.0'
redis['port'] = 6379
redis['password'] = 'SECRET_PASSWORD_HERE'
redis['password'] = '<redis_password>'
gitlab_rails['enable'] = false
## Disable automatic database migrations
## Only the primary GitLab application server should handle migrations
gitlab_rails['auto_migrate'] = false
```
1. [Reconfigure Omnibus GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
1. Note the Redis node's IP address or hostname, port, and
Redis password. These will be necessary when configuring the GitLab
application servers later.
Redis password. These will be necessary when [configuring the GitLab
application servers](#set-up-the-gitlab-rails-application-instance).
[Advanced configuration options](https://docs.gitlab.com/omnibus/settings/redis.html)
are supported and can be added if needed.
## Set up the GitLab Rails application instance
On the instance where GitLab is installed:
1. Edit the `/etc/gitlab/gitlab.rb` file and add the following contents:
```ruby
## Disable Redis
redis['enable'] = false
gitlab_rails['redis_host'] = 'redis.example.com'
gitlab_rails['redis_port'] = 6379
## Required if Redis authentication is configured on the Redis node
gitlab_rails['redis_password'] = '<redis_password>'
```
1. Save your changes to `/etc/gitlab/gitlab.rb`.
1. [Reconfigure Omnibus GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
## Troubleshooting
See the [Redis troubleshooting guide](troubleshooting.md).

View File

@ -6667,6 +6667,71 @@ type IssueEdge {
node: Issue
}
"""
Autogenerated input type of IssueMoveList
"""
input IssueMoveListInput {
"""
Global ID of the board that the issue is in
"""
boardId: ID!
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
ID of the board list that the issue will be moved from
"""
fromListId: ID
"""
IID of the issue to mutate
"""
iid: String!
"""
ID of issue after which the current issue will be positioned at
"""
moveAfterId: ID
"""
ID of issue before which the current issue will be positioned at
"""
moveBeforeId: ID
"""
Project the issue to mutate is in
"""
projectPath: ID!
"""
ID of the board list that the issue will be moved to
"""
toListId: ID
}
"""
Autogenerated return type of IssueMoveList
"""
type IssueMoveListPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
The issue after mutation
"""
issue: Issue
}
"""
Check permissions for the current user on a issue
"""
@ -8971,6 +9036,7 @@ type Mutation {
epicAddIssue(input: EpicAddIssueInput!): EpicAddIssuePayload
epicSetSubscription(input: EpicSetSubscriptionInput!): EpicSetSubscriptionPayload
epicTreeReorder(input: EpicTreeReorderInput!): EpicTreeReorderPayload
issueMoveList(input: IssueMoveListInput!): IssueMoveListPayload
issueSetAssignees(input: IssueSetAssigneesInput!): IssueSetAssigneesPayload
issueSetConfidential(input: IssueSetConfidentialInput!): IssueSetConfidentialPayload
issueSetDueDate(input: IssueSetDueDateInput!): IssueSetDueDatePayload

View File

@ -18428,6 +18428,176 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "IssueMoveListInput",
"description": "Autogenerated input type of IssueMoveList",
"fields": null,
"inputFields": [
{
"name": "projectPath",
"description": "Project the issue to mutate is in",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "iid",
"description": "IID of the issue to mutate",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "boardId",
"description": "Global ID of the board that the issue is in",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "fromListId",
"description": "ID of the board list that the issue will be moved from",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{
"name": "toListId",
"description": "ID of the board list that the issue will be moved to",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{
"name": "moveBeforeId",
"description": "ID of issue before which the current issue will be positioned at",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{
"name": "moveAfterId",
"description": "ID of issue after which the current issue will be positioned at",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "IssueMoveListPayload",
"description": "Autogenerated return type of IssueMoveList",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "issue",
"description": "The issue after mutation",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Issue",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "IssuePermissions",
@ -26040,6 +26210,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "issueMoveList",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "IssueMoveListInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "IssueMoveListPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "issueSetAssignees",
"description": null,

View File

@ -995,6 +995,16 @@ Represents a Group Member
| `webUrl` | String! | Web URL of the issue |
| `weight` | Int | Weight of the issue |
## IssueMoveListPayload
Autogenerated return type of IssueMoveList
| Name | Type | Description |
| --- | ---- | ---------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `issue` | Issue | The issue after mutation |
## IssuePermissions
Check permissions for the current user on a issue

View File

@ -37,6 +37,8 @@ the `author` field. GitLab team members **should not**.
- Any user-facing change **should** have a changelog entry. Example: "GitLab now
uses system fonts for all text."
- Performance improvements **should** have a changelog entry.
- Changes that need to be documented in the Telemetry [Event Dictionary](telemetry/event_dictionary.md)
also require a changelog entry.
- _Any_ contribution from a community member, no matter how small, **may** have
a changelog entry regardless of these guidelines if the contributor wants one.
Example: "Fixed a typo on the search results page."

View File

@ -1,3 +1,10 @@
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: index, concepts, howto
---
# CI/CD development documentation
Development guides that are specific to CI/CD are listed here.

View File

@ -1,3 +1,10 @@
---
stage: Release
group: Progressive Delivery
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: index, concepts, howto
---
# Development guide for GitLab CI/CD templates
This document explains how to develop [GitLab CI/CD templates](../../ci/examples/README.md).

View File

@ -1,3 +1,5 @@
---
redirect_to: 'documentation/styleguide.md'
---
This document was moved to [another location](documentation/styleguide.md).

View File

@ -1 +1,5 @@
---
redirect_to: 'feature_flags/index.md'
---
This document was moved to [another location](feature_flags/index.md).

View File

@ -7093,6 +7093,9 @@ msgstr ""
msgid "Create project label"
msgstr ""
msgid "Create release"
msgstr ""
msgid "Create requirement"
msgstr ""
@ -20099,6 +20102,9 @@ msgstr ""
msgid "Releases|New Release"
msgstr ""
msgid "Release|Something went wrong while creating a new release"
msgstr ""
msgid "Release|Something went wrong while getting the release details"
msgstr ""

View File

@ -42,8 +42,8 @@
"@babel/plugin-syntax-import-meta": "^7.10.1",
"@babel/preset-env": "^7.10.1",
"@gitlab/at.js": "1.5.5",
"@gitlab/svgs": "1.157.0",
"@gitlab/ui": "18.1.0",
"@gitlab/svgs": "1.158.0",
"@gitlab/ui": "18.3.0",
"@gitlab/visual-review-tools": "1.6.1",
"@rails/actioncable": "^6.0.3-1",
"@sentry/browser": "^5.10.2",

View File

@ -1,4 +1,4 @@
import { GlDeprecatedButton } from '@gitlab/ui';
import { GlButton } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { TEST_HOST } from 'spec/test_constants';
import ResolveWithIssueButton from '~/notes/components/discussion_resolve_with_issue_button.vue';
@ -23,7 +23,7 @@ describe('ResolveWithIssueButton', () => {
});
it('it should have a link with the provided link property as href', () => {
const button = wrapper.find(GlDeprecatedButton);
const button = wrapper.find(GlButton);
expect(button.attributes().href).toBe(url);
});

View File

@ -83,18 +83,34 @@ describe('issue_note', () => {
});
});
it('should render multiline comment if editing discussion root', () => {
wrapper.setProps({ discussionRoot: true });
wrapper.vm.isEditing = true;
return wrapper.vm.$nextTick().then(() => {
expect(findMultilineComment().exists()).toBe(true);
it('should only render if it has everything it needs', () => {
const position = {
line_range: {
start: {
line_code: 'abc_1_1',
type: null,
old_line: '',
new_line: '',
},
end: {
line_code: 'abc_2_2',
type: null,
old_line: '2',
new_line: '2',
},
},
};
const line = {
line_code: 'abc_1_1',
type: null,
old_line: '1',
new_line: '1',
};
wrapper.setProps({
note: { ...note, position },
discussionRoot: true,
line,
});
});
it('should only render multiline comment form if it has everything it needs', () => {
wrapper.setProps({ line: { line_code: '' } });
wrapper.vm.isEditing = true;
return wrapper.vm.$nextTick().then(() => {
expect(findMultilineComment().exists()).toBe(false);

View File

@ -27,8 +27,8 @@ describe('Release edit/new component', () => {
};
actions = {
fetchRelease: jest.fn(),
updateRelease: jest.fn(),
initializeRelease: jest.fn(),
saveRelease: jest.fn(),
addEmptyAssetLink: jest.fn(),
};
@ -64,6 +64,8 @@ describe('Release edit/new component', () => {
glFeatures: featureFlags,
},
});
wrapper.element.querySelectorAll('input').forEach(input => jest.spyOn(input, 'focus'));
};
beforeEach(() => {
@ -87,8 +89,18 @@ describe('Release edit/new component', () => {
factory();
});
it('calls fetchRelease when the component is created', () => {
expect(actions.fetchRelease).toHaveBeenCalledTimes(1);
it('calls initializeRelease when the component is created', () => {
expect(actions.initializeRelease).toHaveBeenCalledTimes(1);
});
it('focuses the first non-disabled input element once the page is shown', () => {
const firstEnabledInput = wrapper.element.querySelector('input:enabled');
const allInputs = wrapper.element.querySelectorAll('input');
allInputs.forEach(input => {
const expectedFocusCalls = input === firstEnabledInput ? 1 : 0;
expect(input.focus).toHaveBeenCalledTimes(expectedFocusCalls);
});
});
it('renders the description text at the top of the page', () => {
@ -109,9 +121,9 @@ describe('Release edit/new component', () => {
expect(findSubmitButton().attributes('type')).toBe('submit');
});
it('calls updateRelease when the form is submitted', () => {
it('calls saveRelease when the form is submitted', () => {
wrapper.find('form').trigger('submit');
expect(actions.updateRelease).toHaveBeenCalledTimes(1);
expect(actions.saveRelease).toHaveBeenCalledTimes(1);
});
});
@ -143,6 +155,34 @@ describe('Release edit/new component', () => {
});
});
describe('when creating a new release', () => {
beforeEach(() => {
factory({
store: {
modules: {
detail: {
getters: {
isExistingRelease: () => false,
},
},
},
},
});
});
it('renders the submit button with the text "Create release"', () => {
expect(findSubmitButton().text()).toBe('Create release');
});
});
describe('when editing an existing release', () => {
beforeEach(factory);
it('renders the submit button with the text "Save changes"', () => {
expect(findSubmitButton().text()).toBe('Save changes');
});
});
describe('asset links form', () => {
const findAssetLinksForm = () => wrapper.find(AssetLinksForm);

View File

@ -9,14 +9,14 @@ describe('releases/components/tag_field', () => {
let store;
let wrapper;
const createComponent = ({ originalRelease }) => {
const createComponent = ({ tagName }) => {
store = createStore({
modules: {
detail: createDetailModule({}),
},
});
store.state.detail.originalRelease = originalRelease;
store.state.detail.tagName = tagName;
wrapper = shallowMount(TagField, { store });
};
@ -31,8 +31,7 @@ describe('releases/components/tag_field', () => {
describe('when an existing release is being edited', () => {
beforeEach(() => {
const originalRelease = { name: 'Version 1.0' };
createComponent({ originalRelease });
createComponent({ tagName: 'v1.0' });
});
it('renders the TagFieldExisting component', () => {
@ -46,7 +45,7 @@ describe('releases/components/tag_field', () => {
describe('when a new release is being created', () => {
beforeEach(() => {
createComponent({ originalRelease: null });
createComponent({ tagName: null });
});
it('renders the TagFieldNew component', () => {

View File

@ -1,7 +1,7 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import { cloneDeep, merge } from 'lodash';
import { cloneDeep } from 'lodash';
import * as actions from '~/releases/stores/modules/detail/actions';
import * as types from '~/releases/stores/modules/detail/mutation_types';
import { release as originalRelease } from '../../../mock_data';
@ -10,7 +10,9 @@ import createFlash from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { redirectTo } from '~/lib/utils/url_utility';
import api from '~/api';
import httpStatus from '~/lib/utils/http_status';
import { ASSET_LINK_TYPE } from '~/releases/constants';
import { releaseToApiJson, apiJsonToRelease } from '~/releases/util';
jest.mock('~/flash', () => jest.fn());
@ -25,15 +27,26 @@ describe('Release detail actions', () => {
let mock;
let error;
const setupState = (updates = {}) => {
const getters = {
isExistingRelease: true,
};
state = {
...createState({
projectId: '18',
tagName: release.tag_name,
releasesPagePath: 'path/to/releases/page',
markdownDocsPath: 'path/to/markdown/docs',
markdownPreviewPath: 'path/to/markdown/preview',
updateReleaseApiDocsPath: 'path/to/api/docs',
}),
...getters,
...updates,
};
};
beforeEach(() => {
state = createState({
projectId: '18',
tagName: 'v1.3',
releasesPagePath: 'path/to/releases/page',
markdownDocsPath: 'path/to/markdown/docs',
markdownPreviewPath: 'path/to/markdown/preview',
updateReleaseApiDocsPath: 'path/to/api/docs',
});
release = cloneDeep(originalRelease);
mock = new MockAdapter(axios);
gon.api_version = 'v4';
@ -45,302 +58,424 @@ describe('Release detail actions', () => {
mock.restore();
});
describe('requestRelease', () => {
it(`commits ${types.REQUEST_RELEASE}`, () =>
testAction(actions.requestRelease, undefined, state, [{ type: types.REQUEST_RELEASE }]));
});
describe('receiveReleaseSuccess', () => {
it(`commits ${types.RECEIVE_RELEASE_SUCCESS}`, () =>
testAction(actions.receiveReleaseSuccess, release, state, [
{ type: types.RECEIVE_RELEASE_SUCCESS, payload: release },
]));
});
describe('receiveReleaseError', () => {
it(`commits ${types.RECEIVE_RELEASE_ERROR}`, () =>
testAction(actions.receiveReleaseError, error, state, [
{ type: types.RECEIVE_RELEASE_ERROR, payload: error },
]));
it('shows a flash with an error message', () => {
actions.receiveReleaseError({ commit: jest.fn() }, error);
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong while getting the release details',
);
});
});
describe('fetchRelease', () => {
let getReleaseUrl;
describe('when creating a new release', () => {
beforeEach(() => {
state.projectId = '18';
state.tagName = 'v1.3';
getReleaseUrl = `/api/v4/projects/${state.projectId}/releases/${state.tagName}`;
setupState({ isExistingRelease: false });
});
it(`dispatches requestRelease and receiveReleaseSuccess with the camel-case'd release object`, () => {
mock.onGet(getReleaseUrl).replyOnce(200, release);
return testAction(
actions.fetchRelease,
undefined,
state,
[],
[
{ type: 'requestRelease' },
{
type: 'receiveReleaseSuccess',
payload: convertObjectPropsToCamelCase(release, { deep: true }),
},
],
);
});
it(`dispatches requestRelease and receiveReleaseError with an error object`, () => {
mock.onGet(getReleaseUrl).replyOnce(500);
return testAction(
actions.fetchRelease,
undefined,
state,
[],
[{ type: 'requestRelease' }, { type: 'receiveReleaseError', payload: expect.anything() }],
);
});
});
describe('updateReleaseTagName', () => {
it(`commits ${types.UPDATE_RELEASE_TAG_NAME} with the updated tag name`, () => {
const newTag = 'updated-tag-name';
return testAction(actions.updateReleaseTagName, newTag, state, [
{ type: types.UPDATE_RELEASE_TAG_NAME, payload: newTag },
]);
});
});
describe('updateCreateFrom', () => {
it(`commits ${types.UPDATE_CREATE_FROM} with the updated ref`, () => {
const newRef = 'my-feature-branch';
return testAction(actions.updateCreateFrom, newRef, state, [
{ type: types.UPDATE_CREATE_FROM, payload: newRef },
]);
});
});
describe('updateReleaseTitle', () => {
it(`commits ${types.UPDATE_RELEASE_TITLE} with the updated release title`, () => {
const newTitle = 'The new release title';
return testAction(actions.updateReleaseTitle, newTitle, state, [
{ type: types.UPDATE_RELEASE_TITLE, payload: newTitle },
]);
});
});
describe('updateReleaseNotes', () => {
it(`commits ${types.UPDATE_RELEASE_NOTES} with the updated release notes`, () => {
const newReleaseNotes = 'The new release notes';
return testAction(actions.updateReleaseNotes, newReleaseNotes, state, [
{ type: types.UPDATE_RELEASE_NOTES, payload: newReleaseNotes },
]);
});
});
describe('updateAssetLinkUrl', () => {
it(`commits ${types.UPDATE_ASSET_LINK_URL} with the updated link URL`, () => {
const params = {
linkIdToUpdate: 2,
newUrl: 'https://example.com/updated',
};
return testAction(actions.updateAssetLinkUrl, params, state, [
{ type: types.UPDATE_ASSET_LINK_URL, payload: params },
]);
});
});
describe('updateAssetLinkName', () => {
it(`commits ${types.UPDATE_ASSET_LINK_NAME} with the updated link name`, () => {
const params = {
linkIdToUpdate: 2,
newName: 'Updated link name',
};
return testAction(actions.updateAssetLinkName, params, state, [
{ type: types.UPDATE_ASSET_LINK_NAME, payload: params },
]);
});
});
describe('updateAssetLinkType', () => {
it(`commits ${types.UPDATE_ASSET_LINK_TYPE} with the updated link type`, () => {
const params = {
linkIdToUpdate: 2,
newType: ASSET_LINK_TYPE.RUNBOOK,
};
return testAction(actions.updateAssetLinkType, params, state, [
{ type: types.UPDATE_ASSET_LINK_TYPE, payload: params },
]);
});
});
describe('removeAssetLink', () => {
it(`commits ${types.REMOVE_ASSET_LINK} with the ID of the asset link to remove`, () => {
const idToRemove = 2;
return testAction(actions.removeAssetLink, idToRemove, state, [
{ type: types.REMOVE_ASSET_LINK, payload: idToRemove },
]);
});
});
describe('updateReleaseMilestones', () => {
it(`commits ${types.UPDATE_RELEASE_MILESTONES} with the updated release milestones`, () => {
const newReleaseMilestones = ['v0.0', 'v0.1'];
return testAction(actions.updateReleaseMilestones, newReleaseMilestones, state, [
{ type: types.UPDATE_RELEASE_MILESTONES, payload: newReleaseMilestones },
]);
});
});
describe('requestUpdateRelease', () => {
it(`commits ${types.REQUEST_UPDATE_RELEASE}`, () =>
testAction(actions.requestUpdateRelease, undefined, state, [
{ type: types.REQUEST_UPDATE_RELEASE },
]));
});
describe('receiveUpdateReleaseSuccess', () => {
it(`commits ${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () =>
testAction(actions.receiveUpdateReleaseSuccess, undefined, { ...state, featureFlags: {} }, [
{ type: types.RECEIVE_UPDATE_RELEASE_SUCCESS },
]));
it('redirects to the releases page if releaseShowPage feature flag is enabled', () => {
const rootState = { featureFlags: { releaseShowPage: true } };
const updatedState = merge({}, state, {
releasesPagePath: 'path/to/releases/page',
release: {
_links: {
self: 'path/to/self',
},
},
});
actions.receiveUpdateReleaseSuccess({ commit: jest.fn(), state: updatedState, rootState });
expect(redirectTo).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith(updatedState.release._links.self);
});
describe('when the releaseShowPage feature flag is disabled', () => {});
});
describe('receiveUpdateReleaseError', () => {
it(`commits ${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () =>
testAction(actions.receiveUpdateReleaseError, error, state, [
{ type: types.RECEIVE_UPDATE_RELEASE_ERROR, payload: error },
]));
it('shows a flash with an error message', () => {
actions.receiveUpdateReleaseError({ commit: jest.fn() }, error);
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong while saving the release details',
);
});
});
describe('updateRelease', () => {
let getters;
let dispatch;
let callOrder;
beforeEach(() => {
state.release = convertObjectPropsToCamelCase(release);
state.projectId = '18';
state.tagName = state.release.tagName;
getters = {
releaseLinksToDelete: [{ id: '1' }, { id: '2' }],
releaseLinksToCreate: [{ id: 'new-link-1' }, { id: 'new-link-2' }],
};
dispatch = jest.fn();
callOrder = [];
jest.spyOn(api, 'updateRelease').mockImplementation(() => {
callOrder.push('updateRelease');
return Promise.resolve();
});
jest.spyOn(api, 'deleteReleaseLink').mockImplementation(() => {
callOrder.push('deleteReleaseLink');
return Promise.resolve();
});
jest.spyOn(api, 'createReleaseLink').mockImplementation(() => {
callOrder.push('createReleaseLink');
return Promise.resolve();
});
});
it('dispatches requestUpdateRelease and receiveUpdateReleaseSuccess', () => {
return actions.updateRelease({ dispatch, state, getters }).then(() => {
expect(dispatch.mock.calls).toEqual([
['requestUpdateRelease'],
['receiveUpdateReleaseSuccess'],
describe('initializeRelease', () => {
it(`commits ${types.INITIALIZE_EMPTY_RELEASE}`, () => {
testAction(actions.initializeRelease, undefined, state, [
{ type: types.INITIALIZE_EMPTY_RELEASE },
]);
});
});
it('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', () => {
jest.spyOn(api, 'updateRelease').mockRejectedValue(error);
describe('saveRelease', () => {
it(`commits ${types.REQUEST_SAVE_RELEASE} and then dispatched "createRelease"`, () => {
testAction(
actions.saveRelease,
undefined,
state,
[{ type: types.REQUEST_SAVE_RELEASE }],
[{ type: 'createRelease' }],
);
});
});
});
return actions.updateRelease({ dispatch, state, getters }).then(() => {
expect(dispatch.mock.calls).toEqual([
['requestUpdateRelease'],
['receiveUpdateReleaseError', error],
]);
describe('when editing an existing release', () => {
beforeEach(setupState);
describe('initializeRelease', () => {
it('dispatches "fetchRelease"', () => {
testAction(actions.initializeRelease, undefined, state, [], [{ type: 'fetchRelease' }]);
});
});
it('updates the Release, then deletes all existing links, and then recreates new links', () => {
return actions.updateRelease({ dispatch, state, getters }).then(() => {
expect(callOrder).toEqual([
'updateRelease',
'deleteReleaseLink',
'deleteReleaseLink',
'createReleaseLink',
'createReleaseLink',
]);
describe('saveRelease', () => {
it(`commits ${types.REQUEST_SAVE_RELEASE} and then dispatched "updateRelease"`, () => {
testAction(
actions.saveRelease,
undefined,
state,
[{ type: types.REQUEST_SAVE_RELEASE }],
[{ type: 'updateRelease' }],
);
});
});
});
expect(api.updateRelease.mock.calls).toEqual([
[
state.projectId,
state.tagName,
{
name: state.release.name,
description: state.release.description,
milestones: state.release.milestones.map(milestone => milestone.title),
},
],
]);
describe('actions that behave the same whether creating a new release or editing an existing release', () => {
beforeEach(setupState);
expect(api.deleteReleaseLink).toHaveBeenCalledTimes(getters.releaseLinksToDelete.length);
getters.releaseLinksToDelete.forEach(link => {
expect(api.deleteReleaseLink).toHaveBeenCalledWith(
state.projectId,
state.tagName,
link.id,
);
describe('fetchRelease', () => {
let getReleaseUrl;
beforeEach(() => {
getReleaseUrl = `/api/v4/projects/${state.projectId}/releases/${state.tagName}`;
});
describe('when the network request to the Release API is successful', () => {
beforeEach(() => {
mock.onGet(getReleaseUrl).replyOnce(httpStatus.OK, release);
});
expect(api.createReleaseLink).toHaveBeenCalledTimes(getters.releaseLinksToCreate.length);
getters.releaseLinksToCreate.forEach(link => {
expect(api.createReleaseLink).toHaveBeenCalledWith(state.projectId, state.tagName, link);
it(`commits ${types.REQUEST_RELEASE} and then commits ${types.RECEIVE_RELEASE_SUCCESS} with the converted release object`, () => {
return testAction(actions.fetchRelease, undefined, state, [
{
type: types.REQUEST_RELEASE,
},
{
type: types.RECEIVE_RELEASE_SUCCESS,
payload: apiJsonToRelease(release, { deep: true }),
},
]);
});
});
describe('when the network request to the Release API fails', () => {
beforeEach(() => {
mock.onGet(getReleaseUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR);
});
it(`commits ${types.REQUEST_RELEASE} and then commits ${types.RECEIVE_RELEASE_ERROR} with an error object`, () => {
return testAction(actions.fetchRelease, undefined, state, [
{
type: types.REQUEST_RELEASE,
},
{
type: types.RECEIVE_RELEASE_ERROR,
payload: expect.any(Error),
},
]);
});
it(`shows a flash message`, () => {
return actions.fetchRelease({ commit: jest.fn(), state }).then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong while getting the release details',
);
});
});
});
});
describe('updateReleaseTagName', () => {
it(`commits ${types.UPDATE_RELEASE_TAG_NAME} with the updated tag name`, () => {
const newTag = 'updated-tag-name';
return testAction(actions.updateReleaseTagName, newTag, state, [
{ type: types.UPDATE_RELEASE_TAG_NAME, payload: newTag },
]);
});
});
describe('updateCreateFrom', () => {
it(`commits ${types.UPDATE_CREATE_FROM} with the updated ref`, () => {
const newRef = 'my-feature-branch';
return testAction(actions.updateCreateFrom, newRef, state, [
{ type: types.UPDATE_CREATE_FROM, payload: newRef },
]);
});
});
describe('updateReleaseTitle', () => {
it(`commits ${types.UPDATE_RELEASE_TITLE} with the updated release title`, () => {
const newTitle = 'The new release title';
return testAction(actions.updateReleaseTitle, newTitle, state, [
{ type: types.UPDATE_RELEASE_TITLE, payload: newTitle },
]);
});
});
describe('updateReleaseNotes', () => {
it(`commits ${types.UPDATE_RELEASE_NOTES} with the updated release notes`, () => {
const newReleaseNotes = 'The new release notes';
return testAction(actions.updateReleaseNotes, newReleaseNotes, state, [
{ type: types.UPDATE_RELEASE_NOTES, payload: newReleaseNotes },
]);
});
});
describe('updateReleaseMilestones', () => {
it(`commits ${types.UPDATE_RELEASE_MILESTONES} with the updated release milestones`, () => {
const newReleaseMilestones = ['v0.0', 'v0.1'];
return testAction(actions.updateReleaseMilestones, newReleaseMilestones, state, [
{ type: types.UPDATE_RELEASE_MILESTONES, payload: newReleaseMilestones },
]);
});
});
describe('addEmptyAssetLink', () => {
it(`commits ${types.ADD_EMPTY_ASSET_LINK}`, () => {
return testAction(actions.addEmptyAssetLink, undefined, state, [
{ type: types.ADD_EMPTY_ASSET_LINK },
]);
});
});
describe('updateAssetLinkUrl', () => {
it(`commits ${types.UPDATE_ASSET_LINK_URL} with the updated link URL`, () => {
const params = {
linkIdToUpdate: 2,
newUrl: 'https://example.com/updated',
};
return testAction(actions.updateAssetLinkUrl, params, state, [
{ type: types.UPDATE_ASSET_LINK_URL, payload: params },
]);
});
});
describe('updateAssetLinkName', () => {
it(`commits ${types.UPDATE_ASSET_LINK_NAME} with the updated link name`, () => {
const params = {
linkIdToUpdate: 2,
newName: 'Updated link name',
};
return testAction(actions.updateAssetLinkName, params, state, [
{ type: types.UPDATE_ASSET_LINK_NAME, payload: params },
]);
});
});
describe('updateAssetLinkType', () => {
it(`commits ${types.UPDATE_ASSET_LINK_TYPE} with the updated link type`, () => {
const params = {
linkIdToUpdate: 2,
newType: ASSET_LINK_TYPE.RUNBOOK,
};
return testAction(actions.updateAssetLinkType, params, state, [
{ type: types.UPDATE_ASSET_LINK_TYPE, payload: params },
]);
});
});
describe('removeAssetLink', () => {
it(`commits ${types.REMOVE_ASSET_LINK} with the ID of the asset link to remove`, () => {
const idToRemove = 2;
return testAction(actions.removeAssetLink, idToRemove, state, [
{ type: types.REMOVE_ASSET_LINK, payload: idToRemove },
]);
});
});
describe('receiveSaveReleaseSuccess', () => {
it(`commits ${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () =>
testAction(actions.receiveSaveReleaseSuccess, undefined, { ...state, featureFlags: {} }, [
{ type: types.RECEIVE_SAVE_RELEASE_SUCCESS },
]));
describe('when the releaseShowPage feature flag is enabled', () => {
beforeEach(() => {
const rootState = { featureFlags: { releaseShowPage: true } };
actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state, rootState }, release);
});
it("redirects to the release's dedicated page", () => {
expect(redirectTo).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith(release._links.self);
});
});
describe('when the releaseShowPage feature flag is disabled', () => {
beforeEach(() => {
const rootState = { featureFlags: { releaseShowPage: false } };
actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state, rootState }, release);
});
it("redirects to the project's main Releases page", () => {
expect(redirectTo).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith(state.releasesPagePath);
});
});
});
describe('createRelease', () => {
let createReleaseUrl;
let releaseLinksToCreate;
beforeEach(() => {
const camelCasedRelease = convertObjectPropsToCamelCase(release);
releaseLinksToCreate = camelCasedRelease.assets.links.slice(0, 1);
setupState({
release: camelCasedRelease,
releaseLinksToCreate,
});
createReleaseUrl = `/api/v4/projects/${state.projectId}/releases`;
});
describe('when the network request to the Release API is successful', () => {
beforeEach(() => {
const expectedRelease = releaseToApiJson({
...state.release,
assets: {
links: releaseLinksToCreate,
},
});
mock.onPost(createReleaseUrl, expectedRelease).replyOnce(httpStatus.CREATED, release);
});
it(`dispatches "receiveSaveReleaseSuccess" with the converted release object`, () => {
return testAction(
actions.createRelease,
undefined,
state,
[],
[
{
type: 'receiveSaveReleaseSuccess',
payload: apiJsonToRelease(release, { deep: true }),
},
],
);
});
});
describe('when the network request to the Release API fails', () => {
beforeEach(() => {
mock.onPost(createReleaseUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR);
});
it(`commits ${types.RECEIVE_SAVE_RELEASE_ERROR} with an error object`, () => {
return testAction(actions.createRelease, undefined, state, [
{
type: types.RECEIVE_SAVE_RELEASE_ERROR,
payload: expect.any(Error),
},
]);
});
it(`shows a flash message`, () => {
return actions
.createRelease({ commit: jest.fn(), dispatch: jest.fn(), state, getters: {} })
.then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong while creating a new release',
);
});
});
});
});
describe('updateRelease', () => {
let getters;
let dispatch;
let commit;
let callOrder;
beforeEach(() => {
getters = {
releaseLinksToDelete: [{ id: '1' }, { id: '2' }],
releaseLinksToCreate: [{ id: 'new-link-1' }, { id: 'new-link-2' }],
};
setupState({
release: convertObjectPropsToCamelCase(release),
...getters,
});
dispatch = jest.fn();
commit = jest.fn();
callOrder = [];
jest.spyOn(api, 'updateRelease').mockImplementation(() => {
callOrder.push('updateRelease');
return Promise.resolve({ data: release });
});
jest.spyOn(api, 'deleteReleaseLink').mockImplementation(() => {
callOrder.push('deleteReleaseLink');
return Promise.resolve();
});
jest.spyOn(api, 'createReleaseLink').mockImplementation(() => {
callOrder.push('createReleaseLink');
return Promise.resolve();
});
});
describe('when the network request to the Release API is successful', () => {
it('dispatches receiveSaveReleaseSuccess', () => {
return actions.updateRelease({ commit, dispatch, state, getters }).then(() => {
expect(dispatch.mock.calls).toEqual([
['receiveSaveReleaseSuccess', apiJsonToRelease(release)],
]);
});
});
it('updates the Release, then deletes all existing links, and then recreates new links', () => {
return actions.updateRelease({ dispatch, state, getters }).then(() => {
expect(callOrder).toEqual([
'updateRelease',
'deleteReleaseLink',
'deleteReleaseLink',
'createReleaseLink',
'createReleaseLink',
]);
expect(api.updateRelease.mock.calls).toEqual([
[
state.projectId,
state.tagName,
releaseToApiJson({
...state.release,
assets: {
links: getters.releaseLinksToCreate,
},
}),
],
]);
expect(api.deleteReleaseLink).toHaveBeenCalledTimes(
getters.releaseLinksToDelete.length,
);
getters.releaseLinksToDelete.forEach(link => {
expect(api.deleteReleaseLink).toHaveBeenCalledWith(
state.projectId,
state.tagName,
link.id,
);
});
expect(api.createReleaseLink).toHaveBeenCalledTimes(
getters.releaseLinksToCreate.length,
);
getters.releaseLinksToCreate.forEach(link => {
expect(api.createReleaseLink).toHaveBeenCalledWith(
state.projectId,
state.tagName,
link,
);
});
});
});
});
describe('when the network request to the Release API fails', () => {
beforeEach(() => {
jest.spyOn(api, 'updateRelease').mockRejectedValue(error);
});
it('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', () => {
return actions.updateRelease({ commit, dispatch, state, getters }).then(() => {
expect(commit.mock.calls).toEqual([[types.RECEIVE_SAVE_RELEASE_ERROR, error]]);
});
});
it('shows a flash message', () => {
return actions.updateRelease({ commit, dispatch, state, getters }).then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong while saving the release details',
);
});
});
});
});

View File

@ -3,13 +3,13 @@ import * as getters from '~/releases/stores/modules/detail/getters';
describe('Release detail getters', () => {
describe('isExistingRelease', () => {
it('returns true if the release is an existing release that already exists in the database', () => {
const state = { originalRelease: { name: 'The first release' } };
const state = { tagName: 'test-tag-name' };
expect(getters.isExistingRelease(state)).toBe(true);
});
it('returns false if the release is a new release that has not yet been saved to the database', () => {
const state = { originalRelease: null };
const state = { tagName: null };
expect(getters.isExistingRelease(state)).toBe(false);
});

View File

@ -21,6 +21,22 @@ describe('Release detail mutations', () => {
release = convertObjectPropsToCamelCase(originalRelease);
});
describe(`${types.INITIALIZE_EMPTY_RELEASE}`, () => {
it('set state.release to an empty release object', () => {
mutations[types.INITIALIZE_EMPTY_RELEASE](state);
expect(state.release).toEqual({
tagName: null,
name: '',
description: '',
milestones: [],
assets: {
links: [],
},
});
});
});
describe(`${types.REQUEST_RELEASE}`, () => {
it('set state.isFetchingRelease to true', () => {
mutations[types.REQUEST_RELEASE](state);
@ -96,17 +112,17 @@ describe('Release detail mutations', () => {
});
});
describe(`${types.REQUEST_UPDATE_RELEASE}`, () => {
describe(`${types.REQUEST_SAVE_RELEASE}`, () => {
it('set state.isUpdatingRelease to true', () => {
mutations[types.REQUEST_UPDATE_RELEASE](state);
mutations[types.REQUEST_SAVE_RELEASE](state);
expect(state.isUpdatingRelease).toBe(true);
});
});
describe(`${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () => {
describe(`${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () => {
it('handles a successful response from the server', () => {
mutations[types.RECEIVE_UPDATE_RELEASE_SUCCESS](state, release);
mutations[types.RECEIVE_SAVE_RELEASE_SUCCESS](state, release);
expect(state.updateError).toBeUndefined();
@ -114,10 +130,10 @@ describe('Release detail mutations', () => {
});
});
describe(`${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () => {
describe(`${types.RECEIVE_SAVE_RELEASE_ERROR}`, () => {
it('handles an unsuccessful response from the server', () => {
const error = { message: 'An error occurred!' };
mutations[types.RECEIVE_UPDATE_RELEASE_ERROR](state, error);
mutations[types.RECEIVE_SAVE_RELEASE_ERROR](state, error);
expect(state.isUpdatingRelease).toBe(false);

View File

@ -0,0 +1,85 @@
import { releaseToApiJson, apiJsonToRelease } from '~/releases/util';
describe('releases/util.js', () => {
describe('releaseToApiJson', () => {
it('converts a release JavaScript object into JSON that the Release API can accept', () => {
const release = {
tagName: 'tag-name',
name: 'Release name',
description: 'Release description',
milestones: [{ id: 1, title: '13.2' }, { id: 2, title: '13.3' }],
assets: {
links: [{ url: 'https://gitlab.example.com/link', linkType: 'other' }],
},
};
const expectedJson = {
tag_name: 'tag-name',
ref: null,
name: 'Release name',
description: 'Release description',
milestones: ['13.2', '13.3'],
assets: {
links: [{ url: 'https://gitlab.example.com/link', link_type: 'other' }],
},
};
expect(releaseToApiJson(release)).toEqual(expectedJson);
});
describe('when createFrom is provided', () => {
it('adds the provided createFrom ref to the JSON as a "ref" property', () => {
const createFrom = 'main';
const release = {};
const expectedJson = {
ref: createFrom,
};
expect(releaseToApiJson(release, createFrom)).toMatchObject(expectedJson);
});
});
describe('when release.milestones is falsy', () => {
it('includes a "milestone" property in the returned result as an empty array', () => {
const release = {};
const expectedJson = {
milestones: [],
};
expect(releaseToApiJson(release)).toMatchObject(expectedJson);
});
});
});
describe('apiJsonToRelease', () => {
it('converts JSON received from the Release API into an object usable by the Vue application', () => {
const json = {
tag_name: 'tag-name',
assets: {
links: [
{
link_type: 'other',
},
],
},
};
const expectedRelease = {
tagName: 'tag-name',
assets: {
links: [
{
linkType: 'other',
},
],
},
milestones: [],
};
expect(apiJsonToRelease(json)).toEqual(expectedRelease);
});
});
});

View File

@ -30,6 +30,15 @@ Below this line is a block of HTML.
<h1>Heading</h1>
<p>Some paragraph...</p>
</div>
Below this line is a codeblock of the same HTML that should be ignored and preserved.
\`\`\` html
<div>
<h1>Heading</h1>
<p>Some paragraph...</p>
</div>
\`\`\`
`;
const sourceTemplated = `Below this line is a simple ERB (single-line erb block) example.
@ -69,6 +78,15 @@ Below this line is a block of HTML.
<p>Some paragraph...</p>
</div>
\`\`\`
Below this line is a codeblock of the same HTML that should be ignored and preserved.
\`\`\` html
<div>
<h1>Heading</h1>
<p>Some paragraph...</p>
</div>
\`\`\`
`;
it.each`

View File

@ -0,0 +1,90 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Boards::Issues::IssueMoveList do
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:board) { create(:board, group: group) }
let_it_be(:user) { create(:user) }
let_it_be(:guest) { create(:user) }
let_it_be(:development) { create(:label, project: project, name: 'Development') }
let_it_be(:testing) { create(:label, project: project, name: 'Testing') }
let_it_be(:list1) { create(:list, board: board, label: development, position: 0) }
let_it_be(:list2) { create(:list, board: board, label: testing, position: 1) }
let_it_be(:issue1) { create(:labeled_issue, project: project, labels: [development]) }
let_it_be(:existing_issue1) { create(:labeled_issue, project: project, labels: [testing], relative_position: 10) }
let_it_be(:existing_issue2) { create(:labeled_issue, project: project, labels: [testing], relative_position: 50) }
let(:current_user) { user }
let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
let(:params) { { board: board, project_path: project.full_path, iid: issue1.iid } }
let(:move_params) do
{
from_list_id: list1.id,
to_list_id: list2.id,
move_before_id: existing_issue2.id,
move_after_id: existing_issue1.id
}
end
before_all do
group.add_maintainer(user)
group.add_guest(guest)
end
subject do
mutation.resolve(params.merge(move_params))
end
describe '#ready?' do
it 'raises an error if required arguments are missing' do
expect { mutation.ready?(params) }
.to raise_error(Gitlab::Graphql::Errors::ArgumentError, "At least one of the arguments " \
"fromListId, toListId, afterId or beforeId is required")
end
it 'raises an error if only one of fromListId and toListId is present' do
expect { mutation.ready?(params.merge(from_list_id: list1.id)) }
.to raise_error(Gitlab::Graphql::Errors::ArgumentError,
'Both fromListId and toListId must be present'
)
end
end
describe '#resolve' do
context 'when user have access to resources' do
it 'moves and repositions issue' do
subject
expect(issue1.reload.labels).to eq([testing])
expect(issue1.relative_position).to be < existing_issue2.relative_position
expect(issue1.relative_position).to be > existing_issue1.relative_position
end
end
context 'when user have no access to resources' do
shared_examples 'raises a resource not available error' do
it { expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) }
end
context 'when user cannot update issue' do
let(:current_user) { guest }
it_behaves_like 'raises a resource not available error'
end
context 'when user cannot access board' do
let(:board) { create(:board, group: create(:group, :private)) }
it_behaves_like 'raises a resource not available error'
end
context 'when passing board_id as nil' do
let(:board) { nil }
it_behaves_like 'raises a resource not available error'
end
end
end
end

View File

@ -0,0 +1,109 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Reposition and move issue within board lists' do
include GraphqlHelpers
let_it_be(:group) { create(:group, :private) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:board) { create(:board, group: group) }
let_it_be(:user) { create(:user) }
let_it_be(:development) { create(:label, project: project, name: 'Development') }
let_it_be(:testing) { create(:label, project: project, name: 'Testing') }
let_it_be(:list1) { create(:list, board: board, label: development, position: 0) }
let_it_be(:list2) { create(:list, board: board, label: testing, position: 1) }
let_it_be(:existing_issue1) { create(:labeled_issue, project: project, labels: [testing], relative_position: 10) }
let_it_be(:existing_issue2) { create(:labeled_issue, project: project, labels: [testing], relative_position: 50) }
let_it_be(:issue1) { create(:labeled_issue, project: project, labels: [development]) }
let(:mutation_class) { Mutations::Boards::Issues::IssueMoveList }
let(:mutation_name) { mutation_class.graphql_name }
let(:mutation_result_identifier) { mutation_name.camelize(:lower) }
let(:current_user) { user }
let(:params) { { board_id: board.to_global_id.to_s, project_path: project.full_path, iid: issue1.iid.to_s } }
let(:issue_move_params) do
{
from_list_id: list1.id,
to_list_id: list2.id
}
end
before_all do
group.add_maintainer(user)
end
shared_examples 'returns an error' do
it 'fails with error' do
message = "The resource that you are attempting to access does not exist or you don't have "\
"permission to perform this action"
post_graphql_mutation(mutation(params), current_user: current_user)
expect(graphql_errors).to include(a_hash_including('message' => message))
end
end
context 'when user has access to resources' do
context 'when repositioning an issue' do
let(:issue_move_params) { { move_after_id: existing_issue1.id, move_before_id: existing_issue2.id } }
it 'repositions an issue' do
post_graphql_mutation(mutation(params), current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
response_issue = json_response['data'][mutation_result_identifier]['issue']
expect(response_issue['iid']).to eq(issue1.iid.to_s)
expect(response_issue['relativePosition']).to be > existing_issue1.relative_position
expect(response_issue['relativePosition']).to be < existing_issue2.relative_position
end
end
context 'when moving an issue to a different list' do
let(:issue_move_params) { { from_list_id: list1.id, to_list_id: list2.id } }
it 'moves issue to a different list' do
post_graphql_mutation(mutation(params), current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
response_issue = json_response['data'][mutation_result_identifier]['issue']
expect(response_issue['iid']).to eq(issue1.iid.to_s)
expect(response_issue['labels']['edges'][0]['node']['title']).to eq(testing.title)
end
end
end
context 'when user has no access to resources' do
context 'the user is not allowed to update the issue' do
let(:current_user) { create(:user) }
it_behaves_like 'returns an error'
end
context 'when the user can not read board' do
let(:board) { create(:board, group: create(:group, :private)) }
it_behaves_like 'returns an error'
end
end
def mutation(additional_params = {})
graphql_mutation(mutation_name, issue_move_params.merge(additional_params),
<<-QL.strip_heredoc
clientMutationId
issue {
iid,
relativePosition
labels {
edges {
node{
title
}
}
}
}
errors
QL
)
end
end

View File

@ -190,18 +190,6 @@ RSpec.describe Git::ProcessRefChangesService do
subject.execute
end
context 'refresh_only_existing_merge_requests_on_push disabled' do
before do
stub_feature_flags(refresh_only_existing_merge_requests_on_push: false)
end
it 'refreshes all merge requests' do
expect(UpdateMergeRequestsWorker).to receive(:perform_async).exactly(3).times
subject.execute
end
end
end
end

View File

@ -49,5 +49,29 @@ RSpec.describe Deployments::FinishedWorker do
expect(ProjectServiceWorker).not_to have_received(:perform_async)
end
it 'execute webhooks' do
deployment = create(:deployment)
project = deployment.project
web_hook = create(:project_hook, deployment_events: true, project: project)
expect_next_instance_of(WebHookService, web_hook, an_instance_of(Hash), "deployment_hooks") do |service|
expect(service).to receive(:async_execute)
end
worker.perform(deployment.id)
end
it 'does not execute webhooks if feature flag is disabled' do
stub_feature_flags(deployment_webhooks: false)
deployment = create(:deployment)
project = deployment.project
create(:project_hook, deployment_events: true, project: project)
expect(WebHookService).not_to receive(:new)
worker.perform(deployment.id)
end
end
end

View File

@ -843,15 +843,15 @@
eslint-plugin-vue "^6.2.1"
vue-eslint-parser "^7.0.0"
"@gitlab/svgs@1.157.0":
version "1.157.0"
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.157.0.tgz#ada33c2b706836a2f5baa2c539f1348791d74859"
integrity sha512-H07Rn4Cy2QW+wnadvuFBSIWrtn8l4hGFLn62f1fT0iYZy58zb/q5/FsShxk9cSKnZYNkXp8I4Nnk/4R7y1MEOw==
"@gitlab/svgs@1.158.0":
version "1.158.0"
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.158.0.tgz#300d416184a2b0e05f15a96547f726e1825b08a1"
integrity sha512-5OJl+7TsXN9PJhY6/uwi+mTwmDZa9n/6119rf77orQ/joFYUypaYhBmy/1TcKVPsy5Zs6KCxE1kmGsfoXc1TYA==
"@gitlab/ui@18.1.0":
version "18.1.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-18.1.0.tgz#36c1e292cae47d1580d2a3918fe5dd16893e2219"
integrity sha512-oXKTJ07hMFYxXZiJOgbNzVCpz/ooz0rY7D3ISG9ocawGVFVjrwLj41wgNtOzYAnQntxUcgvxNeBt3X6SS/zeTg==
"@gitlab/ui@18.3.0":
version "18.3.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-18.3.0.tgz#c582eca1a0a851823700dabc7f4456feef882d9a"
integrity sha512-H0I3ExZJIqDd9rFDzyZwUerS3ZHDxRf2wHmAzMzK9smq/kr8aL5Pvb2E0KPcgDsVhGQCt7coCBN5NI0p+kf8oQ==
dependencies:
"@babel/standalone" "^7.0.0"
"@gitlab/vue-toasted" "^1.3.0"