提交 70732753 编写于 作者: G GitLab Bot

Add latest changes from gitlab-org/gitlab@master

上级 bf593ae6
<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>
......@@ -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
......
......@@ -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>
......
......@@ -3,76 +3,114 @@ 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';
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'));
import { releaseToApiJson, apiJsonToRelease } from '~/releases/util';
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 addEmptyAssetLink = ({ commit }) => {
commit(types.ADD_EMPTY_ASSET_LINK);
};
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 updateAssetLinkUrl = ({ commit }, { linkIdToUpdate, newUrl }) => {
commit(types.UPDATE_ASSET_LINK_URL, { linkIdToUpdate, newUrl });
};
export const updateRelease = ({ dispatch, state, getters }) => {
dispatch('requestUpdateRelease');
export const updateAssetLinkName = ({ commit }, { linkIdToUpdate, newName }) => {
commit(types.UPDATE_ASSET_LINK_NAME, { linkIdToUpdate, newName });
};
const { release } = state;
const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : [];
export const updateAssetLinkType = ({ commit }, { linkIdToUpdate, newType }) => {
commit(types.UPDATE_ASSET_LINK_TYPE, { 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);
const updatedRelease = convertObjectPropsToSnakeCase(
dispatch(getters.isExistingRelease ? 'updateRelease' : 'createRelease');
};
export const createRelease = ({ commit, dispatch, state, getters }) => {
const apiJson = releaseToApiJson(
{
name: release.name,
description: release.description,
milestones,
...state.release,
assets: {
links: getters.releaseLinksToCreate,
},
},
{ deep: true },
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, updatedRelease)
.updateRelease(state.projectId, state.tagName, apiJson)
/**
* Currently, we delete all existing links and then
......@@ -90,54 +128,31 @@ export const updateRelease = ({ dispatch, state, getters }) => {
* 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;
.then(() => {
// Delete all links currently associated with this Release
return Promise.all(
getters.releaseLinksToDelete.map(l =>
api.deleteReleaseLink(state.projectId, release.tagName, l.id),
api.deleteReleaseLink(state.projectId, state.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 }),
),
apiJson.assets.links.map(l =>
api.createReleaseLink(state.projectId, state.release.tagName, l),
),
);
})
.then(() => dispatch('receiveUpdateReleaseSuccess'))
.then(() => {
dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(updatedRelease));
})
.catch(error => {
dispatch('receiveUpdateReleaseError', error);
commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while saving the release details'));
})
);
};
export const navigateToReleasesPage = ({ state }) => {
redirectTo(state.releasesPagePath);
};
export const addEmptyAssetLink = ({ commit }) => {
commit(types.ADD_EMPTY_ASSET_LINK);
};
export const updateAssetLinkUrl = ({ commit }, { linkIdToUpdate, newUrl }) => {
commit(types.UPDATE_ASSET_LINK_URL, { linkIdToUpdate, newUrl });
};
export const updateAssetLinkName = ({ commit }, { linkIdToUpdate, newName }) => {
commit(types.UPDATE_ASSET_LINK_NAME, { linkIdToUpdate, newName });
};
export const updateAssetLinkType = ({ commit }, { linkIdToUpdate, newType }) => {
commit(types.UPDATE_ASSET_LINK_TYPE, { linkIdToUpdate, newType });
};
export const removeAssetLink = ({ commit }, linkIdToRemove) => {
commit(types.REMOVE_ASSET_LINK, linkIdToRemove);
};
......@@ -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);
};
/**
......
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';
......
......@@ -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;
},
......
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;
};
/**
* 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 nonErbMarkupRegex = new RegExp(`^((<(?!%).+>){1}(${template})+(</.+>){1})$`, 'gm');
const embeddedRubyBlockRegex = new RegExp(`(^<%(${template})+%>$)`, 'gm');
const embeddedRubyInlineRegex = new RegExp(`(^.*[<|&lt;]%(${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 unwrap = source => {
let text = source;
const matches = text.match(templatedRegex);
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()}`;
if (matches) {
matches.forEach(match => {
const initial = match.replace(`${prefix}`, '').replace(`${postfix}`, '');
text = text.replace(match, initial);
});
}
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');
return text;
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 flag = (source, patterns) => {
const mark = (source, groups) => {
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;
});
}
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 wrap = source => {
const { text, hash } = flag(unwrap(source), orderedPatterns);
const unmark = (text, hash) => {
let source = text;
let wrappedSource = text;
Object.entries(hash).forEach(([key, value]) => {
wrappedSource = wrappedSource.replace(key, `${prefix}${value}${postfix}`);
const newVal = key.includes('ignore') ? value : `${wrapPrefix}${value}${wrapPostfix}`;
source = source.replace(key, newVal);
});
return wrappedSource;
return source;
};
const unwrap = source => {
let text = source;
const matches = text.match(reTemplated);
if (matches) {
matches.forEach(match => {
const initial = match.replace(`${wrapPrefix}`, '').replace(`${wrapPostfix}`, '');
text = text.replace(match, initial);
});
}
return text;
};
const wrap = source => {
const { text, hash } = mark(unwrap(source), patternGroups);
return unmark(text, hash);
};
export default { wrap, unwrap };
# 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
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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',
];
......
---
title: GraphQL mutation to move issue within board lists
merge_request: 38309
author:
type: added
---
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
---
title: Fix multiline comment rendering
merge_request: 38721
author:
type: fixed
---
title: Remove Internet Explorer 11 from babel transpilation
merge_request: 36840
author:
type: removed
......@@ -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).
......@@ -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
......
......@@ -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,
......@@ -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
......
......@@ -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."
......
---
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.
......
---
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).
......
---
redirect_to: 'documentation/styleguide.md'
---
This document was moved to [another location](documentation/styleguide.md).
---
redirect_to: 'feature_flags/index.md'
---
This document was moved to [another location](feature_flags/index.md).
......@@ -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 ""
......
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);
});
......
......@@ -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);
......
......@@ -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);
......
......@@ -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', () => {
......
......@@ -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);
});
......
......@@ -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);
......
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);
});
});
});
......@@ -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`
......
# 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
# 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
......@@ -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
......
......@@ -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
......@@ -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/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/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.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"
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册