Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-06-09 00:08:47 +00:00
parent d48cbe1786
commit 7258040618
79 changed files with 2608 additions and 589 deletions

View File

@ -41,7 +41,6 @@
/ee/app/finders/ @gitlab-org/maintainers/database
# Feature specific owners
/ee/lib/gitlab/code_owners/ @reprazent @kerrizor
/ee/lib/ee/gitlab/auth/ldap/ @dblessing @mkozono
/lib/gitlab/auth/ldap/ @dblessing @mkozono
/lib/gitlab/ci/templates/ @nolith @zj
@ -50,6 +49,11 @@
/ee/app/models/project_alias.rb @patrickbajao
/ee/lib/api/project_aliases.rb @patrickbajao
# Code Owners
#
/ee/lib/gitlab/code_owners/ @reprazent @kerrizor @garyh
/doc/user/project/code_owners.md @reprazent @kerrizor @garyh
# Quality owned files
/qa/ @gl-quality

View File

@ -38,6 +38,7 @@ const Api = {
userPostStatusPath: '/api/:version/user/status',
commitPath: '/api/:version/projects/:id/repository/commits',
applySuggestionPath: '/api/:version/suggestions/:id/apply',
applySuggestionBatchPath: '/api/:version/suggestions/batch_apply',
commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
@ -322,6 +323,12 @@ const Api = {
return axios.put(url);
},
applySuggestionBatch(ids) {
const url = Api.buildUrl(Api.applySuggestionBatchPath);
return axios.put(url, { ids });
},
commitPipelines(projectId, sha) {
const encodedProjectId = projectId
.split('/')

View File

@ -72,10 +72,7 @@ export default {
},
methods: {
...mapActions(['fetchClusters', 'setPage']),
statusClass(status) {
const iconClass = STATUSES[status] || STATUSES.default;
return iconClass.className;
},
statusTitle(status) {
const iconTitle = STATUSES[status] || STATUSES.default;
return sprintf(__('Status: %{title}'), { title: iconTitle.title }, false);
@ -96,19 +93,12 @@ export default {
</gl-link>
<gl-loading-icon
v-if="item.status === 'deleting'"
v-if="item.status === 'deleting' || item.status === 'creating'"
v-tooltip
:title="statusTitle(item.status)"
size="sm"
class="mr-2 ml-md-2"
/>
<div
v-else
v-tooltip
class="cluster-status-indicator rounded-circle align-self-center gl-w-4 gl-h-4 mr-2 ml-md-2"
:class="statusClass(item.status)"
:title="statusTitle(item.status)"
></div>
</div>
</template>

View File

@ -15,4 +15,5 @@ export const STATUSES = {
unreachable: { className: 'bg-danger', title: __('Unreachable') },
authentication_failure: { className: 'bg-warning', title: __('Authentication Failure') },
deleting: { title: __('Deleting') },
creating: { title: __('Creating') },
};

View File

@ -1,5 +1,5 @@
<script>
import { mapActions, mapGetters } from 'vuex';
import { mapActions, mapGetters, mapState } from 'vuex';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
import noteEditedText from './note_edited_text.vue';
@ -50,6 +50,9 @@ export default {
return this.getDiscussion(this.note.discussion_id);
},
...mapState({
batchSuggestionsInfo: state => state.notes.batchSuggestionsInfo,
}),
noteBody() {
return this.note.note;
},
@ -79,7 +82,12 @@ export default {
}
},
methods: {
...mapActions(['submitSuggestion']),
...mapActions([
'submitSuggestion',
'submitSuggestionBatch',
'addSuggestionInfoToBatch',
'removeSuggestionInfoFromBatch',
]),
renderGFM() {
$(this.$refs['note-body']).renderGFM();
},
@ -96,6 +104,17 @@ export default {
callback,
);
},
applySuggestionBatch({ flashContainer }) {
return this.submitSuggestionBatch({ flashContainer });
},
addSuggestionToBatch(suggestionId) {
const { discussion_id: discussionId, id: noteId } = this.note;
this.addSuggestionInfoToBatch({ suggestionId, discussionId, noteId });
},
removeSuggestionFromBatch(suggestionId) {
this.removeSuggestionInfoFromBatch(suggestionId);
},
},
};
</script>
@ -105,10 +124,14 @@ export default {
<suggestions
v-if="hasSuggestion && !isEditing"
:suggestions="note.suggestions"
:batch-suggestions-info="batchSuggestionsInfo"
:note-html="note.note_html"
:line-type="lineType"
:help-page-path="helpPagePath"
@apply="applySuggestion"
@applyBatch="applySuggestionBatch"
@addToBatch="addSuggestionToBatch"
@removeFromBatch="removeSuggestionFromBatch"
/>
<div v-else class="note-text md" v-html="note.note_html"></div>
<note-form

View File

@ -524,12 +524,55 @@ export const submitSuggestion = (
const defaultMessage = __(
'Something went wrong while applying the suggestion. Please try again.',
);
const flashMessage = err.response.data ? `${err.response.data.message}.` : defaultMessage;
const errorMessage = err.response.data?.message;
const flashMessage = errorMessage || defaultMessage;
Flash(__(flashMessage), 'alert', flashContainer);
});
};
export const submitSuggestionBatch = ({ commit, dispatch, state }, { flashContainer }) => {
const suggestionIds = state.batchSuggestionsInfo.map(({ suggestionId }) => suggestionId);
const applyAllSuggestions = () =>
state.batchSuggestionsInfo.map(suggestionInfo =>
commit(types.APPLY_SUGGESTION, suggestionInfo),
);
const resolveAllDiscussions = () =>
state.batchSuggestionsInfo.map(suggestionInfo => {
const { discussionId } = suggestionInfo;
return dispatch('resolveDiscussion', { discussionId }).catch(() => {});
});
commit(types.SET_APPLYING_BATCH_STATE, true);
return Api.applySuggestionBatch(suggestionIds)
.then(() => Promise.all(applyAllSuggestions()))
.then(() => Promise.all(resolveAllDiscussions()))
.then(() => commit(types.CLEAR_SUGGESTION_BATCH))
.catch(err => {
const defaultMessage = __(
'Something went wrong while applying the batch of suggestions. Please try again.',
);
const errorMessage = err.response.data?.message;
const flashMessage = errorMessage || defaultMessage;
Flash(__(flashMessage), 'alert', flashContainer);
})
.finally(() => commit(types.SET_APPLYING_BATCH_STATE, false));
};
export const addSuggestionInfoToBatch = ({ commit }, { suggestionId, noteId, discussionId }) =>
commit(types.ADD_SUGGESTION_TO_BATCH, { suggestionId, noteId, discussionId });
export const removeSuggestionInfoFromBatch = ({ commit }, suggestionId) =>
commit(types.REMOVE_SUGGESTION_FROM_BATCH, suggestionId);
export const convertToDiscussion = ({ commit }, noteId) =>
commit(types.CONVERT_TO_DISCUSSION, noteId);

View File

@ -11,6 +11,7 @@ export default () => ({
targetNoteHash: null,
lastFetchedAt: null,
currentDiscussionId: null,
batchSuggestionsInfo: [],
// View layer
isToggleStateButtonLoading: false,

View File

@ -17,6 +17,10 @@ export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE';
export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE';
export const DISABLE_COMMENTS = 'DISABLE_COMMENTS';
export const APPLY_SUGGESTION = 'APPLY_SUGGESTION';
export const SET_APPLYING_BATCH_STATE = 'SET_APPLYING_BATCH_STATE';
export const ADD_SUGGESTION_TO_BATCH = 'ADD_SUGGESTION_TO_BATCH';
export const REMOVE_SUGGESTION_FROM_BATCH = 'REMOVE_SUGGESTION_FROM_BATCH';
export const CLEAR_SUGGESTION_BATCH = 'CLEAR_SUGGESTION_BATCH';
export const CONVERT_TO_DISCUSSION = 'CONVERT_TO_DISCUSSION';
export const REMOVE_CONVERTED_DISCUSSION = 'REMOVE_CONVERTED_DISCUSSION';

View File

@ -225,6 +225,39 @@ export default {
}));
},
[types.SET_APPLYING_BATCH_STATE](state, isApplyingBatch) {
state.batchSuggestionsInfo.forEach(suggestionInfo => {
const { discussionId, noteId, suggestionId } = suggestionInfo;
const noteObj = utils.findNoteObjectById(state.discussions, discussionId);
const comment = utils.findNoteObjectById(noteObj.notes, noteId);
comment.suggestions = comment.suggestions.map(suggestion => ({
...suggestion,
is_applying_batch: suggestion.id === suggestionId && isApplyingBatch,
}));
});
},
[types.ADD_SUGGESTION_TO_BATCH](state, { noteId, discussionId, suggestionId }) {
state.batchSuggestionsInfo.push({
suggestionId,
noteId,
discussionId,
});
},
[types.REMOVE_SUGGESTION_FROM_BATCH](state, id) {
const index = state.batchSuggestionsInfo.findIndex(({ suggestionId }) => suggestionId === id);
if (index !== -1) {
state.batchSuggestionsInfo.splice(index, 1);
}
},
[types.CLEAR_SUGGESTION_BATCH](state) {
state.batchSuggestionsInfo.splice(0, state.batchSuggestionsInfo.length);
},
[types.UPDATE_DISCUSSION](state, noteData) {
const note = noteData;
const selectedDiscussion = state.discussions.find(disc => disc.id === note.id);

View File

@ -13,6 +13,11 @@ export default {
type: Object,
required: true,
},
batchSuggestionsInfo: {
type: Array,
required: false,
default: () => [],
},
disabled: {
type: Boolean,
required: false,
@ -24,6 +29,14 @@ export default {
},
},
computed: {
batchSuggestionsCount() {
return this.batchSuggestionsInfo.length;
},
isBatched() {
return Boolean(
this.batchSuggestionsInfo.find(({ suggestionId }) => suggestionId === this.suggestion.id),
);
},
lines() {
return selectDiffLines(this.suggestion.diff_lines);
},
@ -32,6 +45,15 @@ export default {
applySuggestion(callback) {
this.$emit('apply', { suggestionId: this.suggestion.id, callback });
},
applySuggestionBatch() {
this.$emit('applyBatch');
},
addSuggestionToBatch() {
this.$emit('addToBatch', this.suggestion.id);
},
removeSuggestionFromBatch() {
this.$emit('removeFromBatch', this.suggestion.id);
},
},
};
</script>
@ -42,8 +64,14 @@ export default {
class="qa-suggestion-diff-header js-suggestion-diff-header"
:can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled"
:is-applied="suggestion.applied"
:is-batched="isBatched"
:is-applying-batch="suggestion.is_applying_batch"
:batch-suggestions-count="batchSuggestionsCount"
:help-page-path="helpPagePath"
@apply="applySuggestion"
@applyBatch="applySuggestionBatch"
@addToBatch="addSuggestionToBatch"
@removeFromBatch="removeSuggestionFromBatch"
/>
<table class="mb-3 md-suggestion-diff js-syntax-highlight code">
<tbody>

View File

@ -1,11 +1,17 @@
<script>
import { GlDeprecatedButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { __ } from '~/locale';
export default {
components: { Icon, GlDeprecatedButton, GlLoadingIcon },
directives: { 'gl-tooltip': GlTooltipDirective },
props: {
batchSuggestionsCount: {
type: Number,
required: false,
default: 0,
},
canApply: {
type: Boolean,
required: false,
@ -16,6 +22,16 @@ export default {
required: true,
default: false,
},
isBatched: {
type: Boolean,
required: false,
default: false,
},
isApplyingBatch: {
type: Boolean,
required: false,
default: false,
},
helpPagePath: {
type: String,
required: true,
@ -23,17 +39,38 @@ export default {
},
data() {
return {
isApplying: false,
isApplyingSingle: false,
};
},
computed: {
isApplying() {
return this.isApplyingSingle || this.isApplyingBatch;
},
applyingSuggestionsMessage() {
if (this.isApplyingSingle || this.batchSuggestionsCount < 2) {
return __('Applying suggestion...');
}
return __('Applying suggestions...');
},
},
methods: {
applySuggestion() {
if (!this.canApply) return;
this.isApplying = true;
this.isApplyingSingle = true;
this.$emit('apply', this.applySuggestionCallback);
},
applySuggestionCallback() {
this.isApplying = false;
this.isApplyingSingle = false;
},
applySuggestionBatch() {
if (!this.canApply) return;
this.$emit('applyBatch');
},
addSuggestionToBatch() {
this.$emit('addToBatch');
},
removeSuggestionFromBatch() {
this.$emit('removeFromBatch');
},
},
};
@ -47,20 +84,49 @@ export default {
<icon name="question-o" css-classes="link-highlight" />
</a>
</div>
<span v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</span>
<div v-if="isApplying" class="d-flex align-items-center text-secondary">
<div v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</div>
<div v-else-if="isApplying" class="d-flex align-items-center text-secondary">
<gl-loading-icon class="d-flex-center mr-2" />
<span>{{ __('Applying suggestion') }}</span>
<span>{{ applyingSuggestionsMessage }}</span>
</div>
<div v-else-if="canApply && isBatched" class="d-flex align-items-center">
<gl-deprecated-button
class="btn-inverted js-remove-from-batch-btn btn-grouped"
:disabled="isApplying"
@click="removeSuggestionFromBatch"
>
{{ __('Remove from batch') }}
</gl-deprecated-button>
<gl-deprecated-button
v-gl-tooltip.viewport="__('This also resolves all related threads')"
class="btn-inverted js-apply-batch-btn btn-grouped"
:disabled="isApplying"
variant="success"
@click="applySuggestionBatch"
>
{{ __('Apply suggestions') }}
<span class="badge badge-pill badge-pill-success">
{{ batchSuggestionsCount }}
</span>
</gl-deprecated-button>
</div>
<div v-else-if="canApply" class="d-flex align-items-center">
<gl-deprecated-button
class="btn-inverted js-add-to-batch-btn btn-grouped"
:disabled="isApplying"
@click="addSuggestionToBatch"
>
{{ __('Add suggestion to batch') }}
</gl-deprecated-button>
<gl-deprecated-button
v-gl-tooltip.viewport="__('This also resolves the thread')"
class="btn-inverted js-apply-btn btn-grouped"
:disabled="isApplying"
variant="success"
@click="applySuggestion"
>
{{ __('Apply suggestion') }}
</gl-deprecated-button>
</div>
<gl-deprecated-button
v-else-if="canApply"
v-gl-tooltip.viewport="__('This also resolves the discussion')"
class="btn-inverted js-apply-btn"
:disabled="isApplying"
variant="success"
@click="applySuggestion"
>
{{ __('Apply suggestion') }}
</gl-deprecated-button>
</div>
</template>

View File

@ -16,6 +16,11 @@ export default {
required: false,
default: () => [],
},
batchSuggestionsInfo: {
type: Array,
required: false,
default: () => [],
},
noteHtml: {
type: String,
required: true,
@ -68,18 +73,30 @@ export default {
this.isRendered = true;
},
generateDiff(suggestionIndex) {
const { suggestions, disabled, helpPagePath } = this;
const { suggestions, disabled, batchSuggestionsInfo, helpPagePath } = this;
const suggestion =
suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {};
const SuggestionDiffComponent = Vue.extend(SuggestionDiff);
const suggestionDiff = new SuggestionDiffComponent({
propsData: { disabled, suggestion, helpPagePath },
propsData: { disabled, suggestion, batchSuggestionsInfo, helpPagePath },
});
suggestionDiff.$on('apply', ({ suggestionId, callback }) => {
this.$emit('apply', { suggestionId, callback, flashContainer: this.$el });
});
suggestionDiff.$on('applyBatch', () => {
this.$emit('applyBatch', { flashContainer: this.$el });
});
suggestionDiff.$on('addToBatch', suggestionId => {
this.$emit('addToBatch', suggestionId);
});
suggestionDiff.$on('removeFromBatch', suggestionId => {
this.$emit('removeFromBatch', suggestionId);
});
return suggestionDiff;
},
reset() {

View File

@ -3,4 +3,12 @@
background-color: $badge-bg;
color: $gray-800;
vertical-align: baseline;
// Do not use this!
// This is a temporary workaround until the new GlBadge component
// is available: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/481
&.badge-pill-success {
background-color: rgba($green-500, 0.2);
color: $green;
}
}

View File

@ -15,6 +15,8 @@
# blocked: boolean
# external: boolean
# without_projects: boolean
# sort: string
# id: integer
#
class UsersFinder
include CreatedAtFilter
@ -30,6 +32,7 @@ class UsersFinder
def execute
users = User.all.order_id_desc
users = by_username(users)
users = by_id(users)
users = by_search(users)
users = by_blocked(users)
users = by_active(users)
@ -40,7 +43,7 @@ class UsersFinder
users = by_without_projects(users)
users = by_custom_attributes(users)
users
order(users)
end
private
@ -51,6 +54,12 @@ class UsersFinder
users.by_username(params[:username])
end
def by_id(users)
return users unless params[:id]
users.id_in(params[:id])
end
def by_search(users)
return users unless params[:search].present?
@ -102,6 +111,14 @@ class UsersFinder
users.without_projects
end
# rubocop: disable CodeReuse/ActiveRecord
def order(users)
return users unless params[:sort]
users.order_by(params[:sort])
end
# rubocop: enable CodeReuse/ActiveRecord
end
UsersFinder.prepend_if_ee('EE::UsersFinder')

View File

@ -32,7 +32,7 @@ module Mutations
return unless project
resolver = Resolvers::AlertManagementAlertResolver.single.new(object: project, context: context, field: nil)
resolver = Resolvers::AlertManagement::AlertResolver.single.new(object: project, context: context, field: nil)
resolver.resolve(iid: iid)
end
end

View File

@ -27,7 +27,7 @@ module Mutations
def prepare_response(result)
{
alert: result.payload[:alert],
errors: result.error? ? [result.message] : []
errors: result.errors
}
end
end

View File

@ -48,7 +48,7 @@ module Mutations
{
container_expiration_policy: result.payload[:container_expiration_policy],
errors: result.error? ? [result.message] : []
errors: result.errors
}
end

View File

@ -23,29 +23,21 @@ module Mutations
description: 'Project name of the importer Jira project'
def resolve(project_path:, jira_project_key:)
project = find_project!(project_path: project_path)
raise_resource_not_available_error! unless project
project = authorized_find!(full_path: project_path)
service_response = ::JiraImport::StartImportService
.new(context[:current_user], project, jira_project_key)
.execute
jira_import = service_response.success? ? service_response.payload[:import_data] : nil
errors = service_response.error? ? [service_response.message] : []
{
jira_import: jira_import,
errors: errors
errors: service_response.errors
}
end
private
def find_project!(project_path:)
return unless project_path.present?
authorized_find!(full_path: project_path)
end
def find_object(full_path:)
resolve_project(full_path: full_path)
end

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
module Resolvers
module AlertManagement
class AlertResolver < BaseResolver
argument :iid, GraphQL::STRING_TYPE,
required: false,
description: 'IID of the alert. For example, "1"'
argument :statuses, [Types::AlertManagement::StatusEnum],
as: :status,
required: false,
description: 'Alerts with the specified statues. For example, [TRIGGERED]'
argument :sort, Types::AlertManagement::AlertSortEnum,
description: 'Sort alerts by this criteria',
required: false
argument :search, GraphQL::STRING_TYPE,
description: 'Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool.',
required: false
type Types::AlertManagement::AlertType, null: true
def resolve(**args)
parent = object.respond_to?(:sync) ? object.sync : object
return ::AlertManagement::Alert.none if parent.nil?
::AlertManagement::AlertsFinder.new(context[:current_user], parent, args).execute
end
end
end
end

View File

@ -1,31 +0,0 @@
# frozen_string_literal: true
module Resolvers
class AlertManagementAlertResolver < BaseResolver
argument :iid, GraphQL::STRING_TYPE,
required: false,
description: 'IID of the alert. For example, "1"'
argument :statuses, [Types::AlertManagement::StatusEnum],
as: :status,
required: false,
description: 'Alerts with the specified statues. For example, [TRIGGERED]'
argument :sort, Types::AlertManagement::AlertSortEnum,
description: 'Sort alerts by this criteria',
required: false
argument :search, GraphQL::STRING_TYPE,
description: 'Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool.',
required: false
type Types::AlertManagement::AlertType, null: true
def resolve(**args)
parent = object.respond_to?(:sync) ? object.sync : object
return ::AlertManagement::Alert.none if parent.nil?
::AlertManagement::AlertsFinder.new(context[:current_user], parent, args).execute
end
end
end

View File

@ -16,7 +16,11 @@ module Resolvers
response, start_cursor, end_cursor = jira_projects(name: name, **compute_pagination_params(args))
end_cursor = nil if !!response.payload[:is_last]
response.success? ? Gitlab::Graphql::ExternallyPaginatedArray.new(start_cursor, end_cursor, *response.payload[:projects]) : nil
if response.success?
Gitlab::Graphql::ExternallyPaginatedArray.new(start_cursor, end_cursor, *response.payload[:projects])
else
raise Gitlab::Graphql::Errors::BaseError, response.message
end
end
def authorized_resource?(project)
@ -58,6 +62,9 @@ module Resolvers
args = { query: name, start_at: start_at, limit: limit }.compact
response = Jira::Requests::Projects.new(project.jira_service, args).execute
return [response, nil, nil] if response.error?
projects = response.payload[:projects]
start_cursor = start_at == 0 ? nil : Base64.encode64((start_at - 1).to_s)
end_cursor = Base64.encode64((start_at + projects.size - 1).to_s)

View File

@ -0,0 +1,57 @@
# frozen_string_literal: true
module Resolvers
class UsersResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
description 'Find Users'
argument :ids, [GraphQL::ID_TYPE],
required: false,
description: 'List of user Global IDs'
argument :usernames, [GraphQL::STRING_TYPE], required: false,
description: 'List of usernames'
argument :sort, Types::SortEnum,
description: 'Sort users by this criteria',
required: false,
default_value: 'created_desc'
def resolve(ids: nil, usernames: nil, sort: nil)
authorize!
::UsersFinder.new(context[:current_user], finder_params(ids, usernames, sort)).execute
end
def ready?(**args)
args = { ids: nil, usernames: nil }.merge!(args)
return super if args.values.compact.blank?
if args.values.all?
raise Gitlab::Graphql::Errors::ArgumentError, 'Provide either a list of usernames or ids'
end
super
end
def authorize!
Ability.allowed?(context[:current_user], :read_users_list) || raise_resource_not_available_error!
end
private
def finder_params(ids, usernames, sort)
params = {}
params[:sort] = sort if sort
params[:username] = usernames if usernames
params[:id] = parse_gids(ids) if ids
params
end
def parse_gids(gids)
gids.map { |gid| GitlabSchema.parse_gid(gid, expected_type: ::User).model_id }
end
end
end

View File

@ -216,13 +216,13 @@ module Types
Types::AlertManagement::AlertType.connection_type,
null: true,
description: 'Alert Management alerts of the project',
resolver: Resolvers::AlertManagementAlertResolver
resolver: Resolvers::AlertManagement::AlertResolver
field :alert_management_alert,
Types::AlertManagement::AlertType,
null: true,
description: 'A single Alert Management alert of the project',
resolver: Resolvers::AlertManagementAlertResolver.single
resolver: Resolvers::AlertManagement::AlertResolver.single
field :alert_management_alert_status_counts,
Types::AlertManagement::AlertStatusCountsType,

View File

@ -52,6 +52,11 @@ module Types
description: 'Find a user',
resolver: Resolvers::UserResolver
field :users, Types::UserType.connection_type,
null: true,
description: 'Find users',
resolver: Resolvers::UsersResolver
field :echo, GraphQL::STRING_TYPE, null: false,
description: 'Text to echo back',
resolver: Resolvers::EchoResolver

View File

@ -35,7 +35,7 @@ module Jira
response = client.get(url)
build_service_response(response)
rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => error
error_message = error.message
error_message = "Jira request error: #{error.message}"
log_error("Error sending message", client_url: client.options[:site], error: error_message)
ServiceResponse.error(message: error_message)
end

View File

@ -2,109 +2,49 @@
module Suggestions
class ApplyService < ::BaseService
DEFAULT_SUGGESTION_COMMIT_MESSAGE = 'Apply suggestion to %{file_path}'
PLACEHOLDERS = {
'project_path' => ->(suggestion, user) { suggestion.project.path },
'project_name' => ->(suggestion, user) { suggestion.project.name },
'file_path' => ->(suggestion, user) { suggestion.file_path },
'branch_name' => ->(suggestion, user) { suggestion.branch },
'username' => ->(suggestion, user) { user.username },
'user_full_name' => ->(suggestion, user) { user.name }
}.freeze
# This regex is built dynamically using the keys from the PLACEHOLDER struct.
# So, we can easily add new placeholder just by modifying the PLACEHOLDER hash.
# This regex will build the new PLACEHOLDER_REGEX with the new information
PLACEHOLDERS_REGEX = Regexp.union(PLACEHOLDERS.keys.map { |key| Regexp.new(Regexp.escape(key)) }).freeze
attr_reader :current_user
def initialize(current_user)
def initialize(current_user, *suggestions)
@current_user = current_user
@suggestion_set = Gitlab::Suggestions::SuggestionSet.new(suggestions)
end
def execute(suggestion)
unless suggestion.appliable?(cached: false)
return error('Suggestion is not appliable')
def execute
if suggestion_set.valid?
result
else
error(suggestion_set.error_message)
end
unless latest_source_head?(suggestion)
return error('The file has been changed')
end
diff_file = suggestion.diff_file
unless diff_file
return error('The file was not found')
end
params = file_update_params(suggestion, diff_file)
result = ::Files::UpdateService.new(suggestion.project, current_user, params).execute
if result[:status] == :success
suggestion.update(commit_id: result[:result], applied: true)
end
result
rescue Files::UpdateService::FileChangedError
error('The file has been changed')
end
private
# Checks whether the latest source branch HEAD matches with
# the position HEAD we're using to update the file content. Since
# the persisted HEAD is updated async (for MergeRequest),
# it's more consistent to fetch this data directly from the
# repository.
def latest_source_head?(suggestion)
suggestion.position.head_sha == suggestion.noteable.source_branch_sha
end
attr_reader :current_user, :suggestion_set
def file_update_params(suggestion, diff_file)
blob = diff_file.new_blob
project = suggestion.project
file_path = suggestion.file_path
branch_name = suggestion.branch
file_content = new_file_content(suggestion, blob)
commit_message = processed_suggestion_commit_message(suggestion)
file_last_commit =
Gitlab::Git::Commit.last_for_path(project.repository,
blob.commit_id,
blob.path)
{
file_path: file_path,
branch_name: branch_name,
start_branch: branch_name,
commit_message: commit_message,
file_content: file_content,
last_commit_sha: file_last_commit&.id
}
end
def new_file_content(suggestion, blob)
range = suggestion.from_line_index..suggestion.to_line_index
blob.load_all_data!
content = blob.data.lines
content[range] = suggestion.to_content
content.join
end
def suggestion_commit_message(project)
project.suggestion_commit_message.presence || DEFAULT_SUGGESTION_COMMIT_MESSAGE
end
def processed_suggestion_commit_message(suggestion)
message = suggestion_commit_message(suggestion.project)
Gitlab::StringPlaceholderReplacer.replace_string_placeholders(message, PLACEHOLDERS_REGEX) do |key|
PLACEHOLDERS[key].call(suggestion, current_user)
def result
multi_service.execute.tap do |result|
update_suggestions(result)
end
end
def update_suggestions(result)
return unless result[:status] == :success
Suggestion.id_in(suggestion_set.suggestions)
.update_all(commit_id: result[:result], applied: true)
end
def multi_service
params = {
commit_message: commit_message,
branch_name: suggestion_set.branch,
start_branch: suggestion_set.branch,
actions: suggestion_set.actions
}
::Files::MultiService.new(suggestion_set.project, current_user, params)
end
def commit_message
Gitlab::Suggestions::CommitMessage.new(current_user, suggestion_set).message
end
end
end

View File

@ -59,6 +59,8 @@
= render_if_exists 'namespaces/shared_runner_status', namespace: @group
= render 'shared/custom_attributes', custom_attributes: @group.custom_attributes
= render_if_exists 'ldap_group_links/ldap_group_links_show', group: @group
.card

View File

@ -108,6 +108,8 @@
= visibility_level_icon(@project.visibility_level)
= visibility_level_label(@project.visibility_level)
= render 'shared/custom_attributes', custom_attributes: @project.custom_attributes
= render_if_exists 'admin/projects/geo_status_widget', locals: { project: @project }
.card

View File

@ -141,6 +141,8 @@
= render_if_exists 'namespaces/shared_runner_status', namespace: @user.namespace
= render 'shared/custom_attributes', custom_attributes: @user.custom_attributes
.col-md-6
- unless @user == current_user
- unless @user.confirmed?

View File

@ -9,9 +9,9 @@
anchor: 'configure-the-commit-message-for-applied-suggestions'),
target: '_blank'
.mb-2
= form.text_field :suggestion_commit_message, class: 'form-control mb-2', placeholder: Suggestions::ApplyService::DEFAULT_SUGGESTION_COMMIT_MESSAGE
= form.text_field :suggestion_commit_message, class: 'form-control mb-2', placeholder: Gitlab::Suggestions::CommitMessage::DEFAULT_SUGGESTION_COMMIT_MESSAGE
%p.form-text.text-muted
= s_('ProjectSettings|The variables GitLab supports:')
- Suggestions::ApplyService::PLACEHOLDERS.keys.each do |placeholder|
- Gitlab::Suggestions::CommitMessage::PLACEHOLDERS.keys.each do |placeholder|
%code
= "%{#{placeholder}}".html_safe

View File

@ -0,0 +1,12 @@
- return unless custom_attributes.present?
.card
.card-header
= link_to(_('Custom Attributes'), help_page_path('api/custom_attributes.md'))
%ul.content-list
- custom_attributes.each do |custom_attribute|
%li
%span.light
= custom_attribute.key
%strong
= custom_attribute.value

View File

@ -0,0 +1,5 @@
---
title: Add root users query to GraphQL API
merge_request: 33195
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Resolve Fix Incomplete Kubernetes Cluster Status List
merge_request: 33344
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: User can apply multiple suggestions at the same time.
merge_request: 22439
author: Jesse Hall
type: added

View File

@ -0,0 +1,5 @@
---
title: Show custom attributes within Admin Pages
merge_request: 34017
author: Roger Meier
type: added

View File

@ -60,6 +60,7 @@ The GraphQL API includes the following queries at the root level:
1. `user` : Information about a particular user.
1. `namespace` : Within a namespace it is also possible to fetch `projects`.
1. `currentUser`: Information about the currently logged in user.
1. `users`: Information about a collection of users.
1. `metaData`: Metadata about GitLab and the GraphQL API.
1. `snippets`: Snippets visible to the currently logged in user.

View File

@ -9635,6 +9635,46 @@ type Query {
username: String
): User
"""
Find users
"""
users(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
List of user Global IDs
"""
ids: [ID!]
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
Sort users by this criteria
"""
sort: Sort = created_desc
"""
List of usernames
"""
usernames: [String!]
): UserConnection
"""
Vulnerabilities reported on projects on the current user's instance security dashboard
"""

View File

@ -28307,6 +28307,105 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "users",
"description": "Find users",
"args": [
{
"name": "ids",
"description": "List of user Global IDs",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
}
},
"defaultValue": null
},
{
"name": "usernames",
"description": "List of usernames",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null
},
{
"name": "sort",
"description": "Sort users by this criteria",
"type": {
"kind": "ENUM",
"name": "Sort",
"ofType": null
},
"defaultValue": "created_desc"
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "UserConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "vulnerabilities",
"description": "Vulnerabilities reported on projects on the current user's instance security dashboard",

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -459,32 +459,60 @@ instead of the usual three.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/13086) in GitLab 12.7.
GitLab uses `Apply suggestion to %{file_path}` by default as commit messages
when applying Suggestions. This commit message can be customized to
follow any guidelines you might have. To do so, expand the **Merge requests**
GitLab uses a default commit message
when applying Suggestions: `Apply %{suggestions_count} suggestion(s) to %{files_count} file(s)`
For example, consider that a user applied 3 suggestions to 2 different files, the default commit message will be: **Apply 3 suggestion(s) to 2 file(s)**
These commit messages can be customized to follow any guidelines you might have. To do so, expand the **Merge requests**
tab within your project's **General** settings and change the
**Merge suggestions** text:
![Custom commit message for applied Suggestions](img/suggestions_custom_commit_messages_v12_7.png)
![Custom commit message for applied Suggestions](img/suggestions_custom_commit_messages_v13_1.jpg)
You can also use following variables besides static text:
| Variable | Description | Output example |
|---|---|---|
| `%{branch_name}` | The name of the branch the Suggestion(s) was(were) applied to. | `my-feature-branch` |
| `%{files_count}` | The number of file(s) to which Suggestion(s) was(were) applied.| **2** |
| `%{file_paths}` | The path(s) of the file(s) Suggestion(s) was(were) applied to. Paths are separated by commas.| `docs/index.md, docs/about.md` |
| `%{project_path}` | The project path. | `my-group/my-project` |
| `%{project_name}` | The human-readable name of the project. | **My Project** |
| `%{file_path}` | The path of the file the Suggestion is applied to. | `docs/index.md` |
| `%{branch_name}` | The name of the branch the Suggestion is applied on. | `my-feature-branch` |
| `%{username}` | The username of the user applying the Suggestion. | `user_1` |
| `%{user_full_name}` | The full name of the user applying the Suggestion. | **User 1** |
| `%{suggestions_count}` | The number of Suggestions applied.| **3** |
| `%{username}` | The username of the user applying Suggestion(s). | `user_1` |
| `%{user_full_name}` | The full name of the user applying Suggestion(s). | **User 1** |
For example, to customize the commit message to output
**Addresses user_1's review**, set the custom text to
`Addresses %{username}'s review`.
NOTE: **Note:**
Custom commit messages for each applied Suggestion will be
introduced by [#25381](https://gitlab.com/gitlab-org/gitlab/-/issues/25381).
Custom commit messages for each applied Suggestion (and for batch Suggestions) will be
introduced by [#25381](https://gitlab.com/gitlab-org/gitlab/issues/25381).
### Batch Suggestions
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/25486) in GitLab 13.1.
You can apply multiple suggestions at once to reduce the number of commits added
to your branch to address your reviewers' requests.
1. To start a batch of suggestions that will be applied with a single commit, click **Add suggestion to batch**:
![A code change suggestion displayed, with the button to add the suggestion to a batch highlighted.](img/add_first_suggestion_to_batch_v13_1.jpg "Add a suggestion to a batch")
1. Add as many additional suggestions to the batch as you wish:
![A code change suggestion displayed, with the button to add an additional suggestion to a batch highlighted.](img/add_another_suggestion_to_batch_v13_1.jpg "Add another suggestion to a batch")
1. To remove suggestions, click **Remove from batch**:
![A code change suggestion displayed, with the button to remove that suggestion from its batch highlighted.](img/remove_suggestion_from_batch_v13_1.jpg "Remove a suggestion from a batch")
1. Having added all the suggestions to your liking, when ready, click **Apply suggestions**:
![A code change suggestion displayed, with the button to apply the batch of suggestions highlighted.](img/apply_batch_of_suggestions_v13_1.jpg "Apply a batch of suggestions")
## Start a thread by replying to a standard comment

View File

@ -14,18 +14,51 @@ module API
put ':id/apply' do
suggestion = Suggestion.find_by_id(params[:id])
not_found! unless suggestion
authorize! :apply_suggestion, suggestion
if suggestion
apply_suggestions(suggestion, current_user)
else
render_api_error!(_('Suggestion is not applicable as the suggestion was not found.'), :not_found)
end
end
result = ::Suggestions::ApplyService.new(current_user).execute(suggestion)
desc 'Apply multiple suggestion patches in the Merge Request where they were created' do
success Entities::Suggestion
end
params do
requires :ids, type: Array[String], desc: "An array of suggestion ID's"
end
put 'batch_apply' do
ids = params[:ids]
suggestions = Suggestion.id_in(ids)
if suggestions.size == ids.length
apply_suggestions(suggestions, current_user)
else
render_api_error!(_('Suggestions are not applicable as one or more suggestions were not found.'), :not_found)
end
end
end
helpers do
def apply_suggestions(suggestions, current_user)
authorize_suggestions(*suggestions)
result = ::Suggestions::ApplyService.new(current_user, *suggestions).execute
if result[:status] == :success
present suggestion, with: Entities::Suggestion, current_user: current_user
present suggestions, with: Entities::Suggestion, current_user: current_user
else
http_status = result[:http_status] || 400
http_status = result[:http_status] || :bad_request
render_api_error!(result[:message], http_status)
end
end
def authorize_suggestions(*suggestions)
suggestions.each do |suggestion|
authorize! :apply_suggestion, suggestion
end
end
end
end
end

View File

@ -9,16 +9,12 @@ module Gitlab
def instrument(_type, field)
service = AuthorizeFieldService.new(field)
if service.authorizations? && !resolver_skips_authorizations?(field)
if service.authorizations?
field.redefine { resolve(service.authorized_resolve) }
else
field
end
end
def resolver_skips_authorizations?(field)
field.metadata[:resolver].try(:skip_authorizations?)
end
end
end
end

View File

@ -1,14 +0,0 @@
# frozen_string_literal: true
module Gitlab
module Graphql
class FilterableArray < Array
attr_reader :filter_callback
def initialize(filter_callback, *args)
super(args)
@filter_callback = filter_callback
end
end
end
end

View File

@ -9,10 +9,6 @@ module Gitlab
ActiveRecord::Relation,
Gitlab::Graphql::Pagination::Keyset::Connection)
schema.connections.add(
Gitlab::Graphql::FilterableArray,
Gitlab::Graphql::Pagination::FilterableArrayConnection)
schema.connections.add(
Gitlab::Graphql::ExternallyPaginatedArray,
Gitlab::Graphql::Pagination::ExternallyPaginatedArrayConnection)

View File

@ -1,17 +0,0 @@
# frozen_string_literal: true
module Gitlab
module Graphql
module Pagination
# FilterableArrayConnection is useful especially for lazy-loaded values.
# It allows us to call a callback only on the slice of array being
# rendered in the "after loaded" phase. For example we can check
# permissions only on a small subset of items.
class FilterableArrayConnection < GraphQL::Pagination::ArrayConnection
def nodes
@nodes ||= items.filter_callback.call(super)
end
end
end
end
end

View File

@ -0,0 +1,54 @@
# frozen_string_literal: true
module Gitlab
module Suggestions
class CommitMessage
DEFAULT_SUGGESTION_COMMIT_MESSAGE =
'Apply %{suggestions_count} suggestion(s) to %{files_count} file(s)'
def initialize(user, suggestion_set)
@user = user
@suggestion_set = suggestion_set
end
def message
project = suggestion_set.project
user_defined_message = project.suggestion_commit_message.presence
message = user_defined_message || DEFAULT_SUGGESTION_COMMIT_MESSAGE
Gitlab::StringPlaceholderReplacer
.replace_string_placeholders(message, PLACEHOLDERS_REGEX) do |key|
PLACEHOLDERS[key].call(user, suggestion_set)
end
end
def self.format_paths(paths)
paths.sort.join(', ')
end
private_class_method :format_paths
private
attr_reader :user, :suggestion_set
PLACEHOLDERS = {
'branch_name' => ->(user, suggestion_set) { suggestion_set.branch },
'files_count' => ->(user, suggestion_set) { suggestion_set.file_paths.length },
'file_paths' => ->(user, suggestion_set) { format_paths(suggestion_set.file_paths) },
'project_name' => ->(user, suggestion_set) { suggestion_set.project.name },
'project_path' => ->(user, suggestion_set) { suggestion_set.project.path },
'user_full_name' => ->(user, suggestion_set) { user.name },
'username' => ->(user, suggestion_set) { user.username },
'suggestions_count' => ->(user, suggestion_set) { suggestion_set.suggestions.size }
}.freeze
# This regex is built dynamically using the keys from the PLACEHOLDER struct.
# So, we can easily add new placeholder just by modifying the PLACEHOLDER hash.
# This regex will build the new PLACEHOLDER_REGEX with the new information
PLACEHOLDERS_REGEX = Regexp.union(PLACEHOLDERS.keys.map do |key|
Regexp.new(Regexp.escape(key))
end).freeze
end
end
end

View File

@ -0,0 +1,107 @@
# frozen_string_literal: true
module Gitlab
module Suggestions
class FileSuggestion
include Gitlab::Utils::StrongMemoize
SuggestionForDifferentFileError = Class.new(StandardError)
def initialize
@suggestions = []
end
def add_suggestion(new_suggestion)
if for_different_file?(new_suggestion)
raise SuggestionForDifferentFileError,
'Only add suggestions for the same file.'
end
suggestions << new_suggestion
end
def line_conflict?
strong_memoize(:line_conflict) do
_line_conflict?
end
end
def new_content
@new_content ||= _new_content
end
def file_path
@file_path ||= _file_path
end
private
attr_accessor :suggestions
def blob
first_suggestion&.diff_file&.new_blob
end
def blob_data_lines
blob.load_all_data!
blob.data.lines
end
def current_content
@current_content ||= blob.nil? ? [''] : blob_data_lines
end
def _new_content
current_content.tap do |content|
suggestions.each do |suggestion|
range = line_range(suggestion)
content[range] = suggestion.to_content
end
end.join
end
def line_range(suggestion)
suggestion.from_line_index..suggestion.to_line_index
end
def for_different_file?(suggestion)
file_path && file_path != suggestion_file_path(suggestion)
end
def suggestion_file_path(suggestion)
suggestion&.diff_file&.file_path
end
def first_suggestion
suggestions.first
end
def _file_path
suggestion_file_path(first_suggestion)
end
def _line_conflict?
has_conflict = false
suggestions.each_with_object([]) do |suggestion, ranges|
range_in_test = line_range(suggestion)
if has_range_conflict?(range_in_test, ranges)
has_conflict = true
break
end
ranges << range_in_test
end
has_conflict
end
def has_range_conflict?(range_in_test, ranges)
ranges.any? do |range|
range.overlaps?(range_in_test)
end
end
end
end
end

View File

@ -0,0 +1,120 @@
# frozen_string_literal: true
module Gitlab
module Suggestions
class SuggestionSet
attr_reader :suggestions
def initialize(suggestions)
@suggestions = suggestions
end
def project
first_suggestion.project
end
def branch
first_suggestion.branch
end
def valid?
error_message.nil?
end
def error_message
@error_message ||= _error_message
end
def actions
@actions ||= suggestions_per_file.map do |file_path, file_suggestion|
{
action: 'update',
file_path: file_path,
content: file_suggestion.new_content
}
end
end
def file_paths
@file_paths ||= suggestions.map(&:file_path).uniq
end
private
def first_suggestion
suggestions.first
end
def suggestions_per_file
@suggestions_per_file ||= _suggestions_per_file
end
def _suggestions_per_file
suggestions.each_with_object({}) do |suggestion, result|
file_path = suggestion.diff_file.file_path
file_suggestion = result[file_path] ||= FileSuggestion.new
file_suggestion.add_suggestion(suggestion)
end
end
def file_suggestions
suggestions_per_file.values
end
def first_file_suggestion
file_suggestions.first
end
def _error_message
suggestions.each do |suggestion|
message = error_for_suggestion(suggestion)
return message if message
end
has_line_conflict = file_suggestions.any? do |file_suggestion|
file_suggestion.line_conflict?
end
if has_line_conflict
return _('Suggestions are not applicable as their lines cannot overlap.')
end
nil
end
def error_for_suggestion(suggestion)
unless suggestion.diff_file
return _('A file was not found.')
end
unless on_same_branch?(suggestion)
return _('Suggestions must all be on the same branch.')
end
unless suggestion.appliable?(cached: false)
return _('A suggestion is not applicable.')
end
unless latest_source_head?(suggestion)
return _('A file has been changed.')
end
nil
end
def on_same_branch?(suggestion)
branch == suggestion.branch
end
# Checks whether the latest source branch HEAD matches with
# the position HEAD we're using to update the file content. Since
# the persisted HEAD is updated async (for MergeRequest),
# it's more consistent to fetch this data directly from the
# repository.
def latest_source_head?(suggestion)
suggestion.position.head_sha == suggestion.noteable.source_branch_sha
end
end
end
end

View File

@ -954,6 +954,12 @@ msgstr ""
msgid "A deleted user"
msgstr ""
msgid "A file has been changed."
msgstr ""
msgid "A file was not found."
msgstr ""
msgid "A file with '%{file_name}' already exists in %{branch} branch"
msgstr ""
@ -1020,6 +1026,9 @@ msgstr ""
msgid "A subscription will trigger a new pipeline on the default branch of this project when a pipeline successfully completes for a new tag on the %{default_branch_docs} of the subscribed project."
msgstr ""
msgid "A suggestion is not applicable."
msgstr ""
msgid "A terraform report was generated in your pipelines."
msgstr ""
@ -1379,6 +1388,9 @@ msgstr ""
msgid "Add strikethrough text"
msgstr ""
msgid "Add suggestion to batch"
msgstr ""
msgid "Add system hook"
msgstr ""
@ -2584,6 +2596,9 @@ msgstr ""
msgid "Apply suggestion"
msgstr ""
msgid "Apply suggestions"
msgstr ""
msgid "Apply template"
msgstr ""
@ -2602,7 +2617,10 @@ msgstr ""
msgid "Applying multiple commands"
msgstr ""
msgid "Applying suggestion"
msgid "Applying suggestion..."
msgstr ""
msgid "Applying suggestions..."
msgstr ""
msgid "Approval rules"
@ -6618,6 +6636,9 @@ msgstr ""
msgid "Creates branch '%{branch_name}' and a merge request to resolve this issue."
msgstr ""
msgid "Creating"
msgstr ""
msgid "Creating epic"
msgstr ""
@ -6684,6 +6705,9 @@ msgstr ""
msgid "CurrentUser|Upgrade"
msgstr ""
msgid "Custom Attributes"
msgstr ""
msgid "Custom CI configuration path"
msgstr ""
@ -18317,6 +18341,9 @@ msgstr ""
msgid "Remove fork relationship"
msgstr ""
msgid "Remove from batch"
msgstr ""
msgid "Remove from board"
msgstr ""
@ -20563,6 +20590,9 @@ msgstr ""
msgid "Something went wrong while adding your award. Please try again."
msgstr ""
msgid "Something went wrong while applying the batch of suggestions. Please try again."
msgstr ""
msgid "Something went wrong while applying the suggestion. Please try again."
msgstr ""
@ -21520,6 +21550,18 @@ msgstr ""
msgid "SuggestedColors|Very pale orange"
msgstr ""
msgid "Suggestion is not applicable as the suggestion was not found."
msgstr ""
msgid "Suggestions are not applicable as one or more suggestions were not found."
msgstr ""
msgid "Suggestions are not applicable as their lines cannot overlap."
msgstr ""
msgid "Suggestions must all be on the same branch."
msgstr ""
msgid "Suggestions:"
msgstr ""
@ -22489,7 +22531,10 @@ msgstr ""
msgid "This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention."
msgstr ""
msgid "This also resolves the discussion"
msgid "This also resolves all related threads"
msgstr ""
msgid "This also resolves the thread"
msgstr ""
msgid "This application was created by %{link_to_owner}."

View File

@ -93,6 +93,100 @@ describe 'User comments on a diff', :js do
end
end
context 'applying suggestions in batches' do
def hash(path)
diff_file = merge_request.diffs(paths: [path]).diff_files.first
Digest::SHA1.hexdigest(diff_file.file_path)
end
file1 = 'files/ruby/popen.rb'
file2 = 'files/ruby/regex.rb'
let(:files) do
[
{
hash: hash(file1),
line_code: "#{hash(file1)}_12_12"
},
{
hash: hash(file2),
line_code: "#{hash(file2)}_21_21"
}
]
end
it 'can add and remove suggestions from a batch' do
files.each_with_index do |file, index|
page.within("[id='#{file[:hash]}']") do
find("button[title='Show full file']").click
wait_for_requests
click_diff_line(find("[id='#{file[:line_code]}']"))
page.within('.js-discussion-note-form') do
fill_in('note_note', with: "```suggestion\n# change to a comment\n```")
click_button('Add comment now')
wait_for_requests
end
end
page.within("[id='#{file[:hash]}']") do
expect(page).not_to have_content('Applied')
click_button('Add suggestion to batch')
wait_for_requests
expect(page).to have_content('Remove from batch')
expect(page).to have_content("Apply suggestions #{index + 1}")
end
end
page.within("[id='#{files[0][:hash]}']") do
click_button('Remove from batch')
wait_for_requests
expect(page).to have_content('Apply suggestion')
expect(page).to have_content('Add suggestion to batch')
end
page.within("[id='#{files[1][:hash]}']") do
expect(page).to have_content('Remove from batch')
expect(page).to have_content('Apply suggestions 1')
end
end
it 'can apply multiple suggestions as a batch' do
files.each_with_index do |file, index|
page.within("[id='#{file[:hash]}']") do
find("button[title='Show full file']").click
wait_for_requests
click_diff_line(find("[id='#{file[:line_code]}']"))
page.within('.js-discussion-note-form') do
fill_in('note_note', with: "```suggestion\n# change to a comment\n```")
click_button('Add comment now')
wait_for_requests
end
end
page.within("[id='#{file[:hash]}']") do
click_button('Add suggestion to batch')
wait_for_requests
end
end
expect(page).not_to have_content('Applied')
page.within("[id='#{files[0][:hash]}']") do
click_button('Apply suggestions 2')
wait_for_requests
end
expect(page).to have_content('Applied').twice
end
end
context 'multiple suggestions in expanded lines' do
# https://gitlab.com/gitlab-org/gitlab/issues/38277
it 'suggestions are appliable', :quarantine do

View File

@ -21,6 +21,12 @@ describe UsersFinder do
expect(users).to contain_exactly(normal_user)
end
it 'filters by id' do
users = described_class.new(user, id: normal_user.id).execute
expect(users).to contain_exactly(normal_user)
end
it 'filters by username (case insensitive)' do
users = described_class.new(user, username: 'joHNdoE').execute
@ -70,6 +76,12 @@ describe UsersFinder do
expect(users).to contain_exactly(user, normal_user, blocked_user, omniauth_user)
end
it 'orders returned results' do
users = described_class.new(user, sort: 'id_asc').execute
expect(users).to eq([normal_user, blocked_user, omniauth_user, user])
end
end
context 'with an admin user' do

View File

@ -83,24 +83,21 @@ describe('Clusters', () => {
describe('cluster status', () => {
it.each`
statusName | className | lineNumber
${'disabled'} | ${'disabled'} | ${0}
${'unreachable'} | ${'bg-danger'} | ${1}
${'authentication_failure'} | ${'bg-warning'} | ${2}
${'deleting'} | ${null} | ${3}
${'created'} | ${'bg-success'} | ${4}
${'default'} | ${'bg-white'} | ${5}
`('renders a status for each cluster', ({ statusName, className, lineNumber }) => {
const statuses = findStatuses();
const status = statuses.at(lineNumber);
if (statusName !== 'deleting') {
const statusIndicator = status.find('.cluster-status-indicator');
expect(statusIndicator.exists()).toBe(true);
expect(statusIndicator.classes()).toContain(className);
} else {
expect(status.find(GlLoadingIcon).exists()).toBe(true);
}
});
statusName | lineNumber | result
${'creating'} | ${0} | ${true}
${null} | ${1} | ${false}
${null} | ${2} | ${false}
${'deleting'} | ${3} | ${true}
${null} | ${4} | ${false}
${null} | ${5} | ${false}
`(
'renders $result when status=$statusName and lineNumber=$lineNumber',
({ lineNumber, result }) => {
const statuses = findStatuses();
const status = statuses.at(lineNumber);
expect(status.find(GlLoadingIcon).exists()).toBe(result);
},
);
});
describe('nodes present', () => {

View File

@ -3,7 +3,7 @@ export const clusterList = [
name: 'My Cluster 1',
environment_scope: '*',
cluster_type: 'group_type',
status: 'disabled',
status: 'creating',
nodes: null,
},
{

View File

@ -1254,3 +1254,16 @@ export const discussionFiltersMock = [
value: 2,
},
];
export const batchSuggestionsInfoMock = [
{
suggestionId: 'a123',
noteId: 'b456',
discussionId: 'c789',
},
{
suggestionId: 'a001',
noteId: 'b002',
discussionId: 'c003',
},
];

View File

@ -15,6 +15,7 @@ import {
userDataMock,
noteableDataMock,
individualNote,
batchSuggestionsInfoMock,
} from '../mock_data';
import axios from '~/lib/utils/axios_utils';
@ -890,7 +891,23 @@ describe('Actions Notes Store', () => {
testSubmitSuggestion(done, () => {
expect(commit).not.toHaveBeenCalled();
expect(dispatch).not.toHaveBeenCalled();
expect(Flash).toHaveBeenCalledWith(`${TEST_ERROR_MESSAGE}.`, 'alert', flashContainer);
expect(Flash).toHaveBeenCalledWith(TEST_ERROR_MESSAGE, 'alert', flashContainer);
});
});
it('when service fails, and no error message available, uses default message', done => {
const response = { response: 'foo' };
Api.applySuggestion.mockReturnValue(Promise.reject(response));
testSubmitSuggestion(done, () => {
expect(commit).not.toHaveBeenCalled();
expect(dispatch).not.toHaveBeenCalled();
expect(Flash).toHaveBeenCalledWith(
'Something went wrong while applying the suggestion. Please try again.',
'alert',
flashContainer,
);
});
});
@ -903,6 +920,130 @@ describe('Actions Notes Store', () => {
});
});
describe('submitSuggestionBatch', () => {
const discussionIds = batchSuggestionsInfoMock.map(({ discussionId }) => discussionId);
const batchSuggestionsInfo = batchSuggestionsInfoMock;
let flashContainer;
beforeEach(() => {
jest.spyOn(Api, 'applySuggestionBatch');
dispatch.mockReturnValue(Promise.resolve());
Api.applySuggestionBatch.mockReturnValue(Promise.resolve());
state = { batchSuggestionsInfo };
flashContainer = {};
});
const testSubmitSuggestionBatch = (done, expectFn) => {
actions
.submitSuggestionBatch({ commit, dispatch, state }, { flashContainer })
.then(expectFn)
.then(done)
.catch(done.fail);
};
it('when service succeeds, commits, resolves discussions, resets batch and applying batch state', done => {
testSubmitSuggestionBatch(done, () => {
expect(commit.mock.calls).toEqual([
[mutationTypes.SET_APPLYING_BATCH_STATE, true],
[mutationTypes.APPLY_SUGGESTION, batchSuggestionsInfo[0]],
[mutationTypes.APPLY_SUGGESTION, batchSuggestionsInfo[1]],
[mutationTypes.CLEAR_SUGGESTION_BATCH],
[mutationTypes.SET_APPLYING_BATCH_STATE, false],
]);
expect(dispatch.mock.calls).toEqual([
['resolveDiscussion', { discussionId: discussionIds[0] }],
['resolveDiscussion', { discussionId: discussionIds[1] }],
]);
expect(Flash).not.toHaveBeenCalled();
});
});
it('when service fails, flashes error message, resets applying batch state', done => {
const response = { response: { data: { message: TEST_ERROR_MESSAGE } } };
Api.applySuggestionBatch.mockReturnValue(Promise.reject(response));
testSubmitSuggestionBatch(done, () => {
expect(commit.mock.calls).toEqual([
[mutationTypes.SET_APPLYING_BATCH_STATE, true],
[mutationTypes.SET_APPLYING_BATCH_STATE, false],
]);
expect(dispatch).not.toHaveBeenCalled();
expect(Flash).toHaveBeenCalledWith(TEST_ERROR_MESSAGE, 'alert', flashContainer);
});
});
it('when service fails, and no error message available, uses default message', done => {
const response = { response: 'foo' };
Api.applySuggestionBatch.mockReturnValue(Promise.reject(response));
testSubmitSuggestionBatch(done, () => {
expect(commit.mock.calls).toEqual([
[mutationTypes.SET_APPLYING_BATCH_STATE, true],
[mutationTypes.SET_APPLYING_BATCH_STATE, false],
]);
expect(dispatch).not.toHaveBeenCalled();
expect(Flash).toHaveBeenCalledWith(
'Something went wrong while applying the batch of suggestions. Please try again.',
'alert',
flashContainer,
);
});
});
it('when resolve discussions fails, fails gracefully, resets batch and applying batch state', done => {
dispatch.mockReturnValue(Promise.reject());
testSubmitSuggestionBatch(done, () => {
expect(commit.mock.calls).toEqual([
[mutationTypes.SET_APPLYING_BATCH_STATE, true],
[mutationTypes.APPLY_SUGGESTION, batchSuggestionsInfo[0]],
[mutationTypes.APPLY_SUGGESTION, batchSuggestionsInfo[1]],
[mutationTypes.CLEAR_SUGGESTION_BATCH],
[mutationTypes.SET_APPLYING_BATCH_STATE, false],
]);
expect(Flash).not.toHaveBeenCalled();
});
});
});
describe('addSuggestionInfoToBatch', () => {
const suggestionInfo = batchSuggestionsInfoMock[0];
it("adds a suggestion's info to the current batch", done => {
testAction(
actions.addSuggestionInfoToBatch,
suggestionInfo,
{ batchSuggestionsInfo: [] },
[{ type: 'ADD_SUGGESTION_TO_BATCH', payload: suggestionInfo }],
[],
done,
);
});
});
describe('removeSuggestionInfoFromBatch', () => {
const suggestionInfo = batchSuggestionsInfoMock[0];
it("removes a suggestion's info the current batch", done => {
testAction(
actions.removeSuggestionInfoFromBatch,
suggestionInfo.suggestionId,
{ batchSuggestionsInfo: [suggestionInfo] },
[{ type: 'REMOVE_SUGGESTION_FROM_BATCH', payload: suggestionInfo.suggestionId }],
[],
done,
);
});
});
describe('filterDiscussion', () => {
const path = 'some-discussion-path';
const filter = 0;

View File

@ -9,6 +9,7 @@ import {
noteableDataMock,
individualNote,
notesWithDescriptionChanges,
batchSuggestionsInfoMock,
} from '../mock_data';
const RESOLVED_NOTE = { resolvable: true, resolved: true };
@ -700,4 +701,108 @@ describe('Notes Store mutations', () => {
expect(state.isToggleBlockedIssueWarning).toEqual(false);
});
});
describe('SET_APPLYING_BATCH_STATE', () => {
const buildDiscussions = suggestionsInfo => {
const suggestions = suggestionsInfo.map(({ suggestionId }) => ({ id: suggestionId }));
const notes = suggestionsInfo.map(({ noteId }, index) => ({
id: noteId,
suggestions: [suggestions[index]],
}));
return suggestionsInfo.map(({ discussionId }, index) => ({
id: discussionId,
notes: [notes[index]],
}));
};
let state;
let batchedSuggestionInfo;
let discussions;
let suggestions;
beforeEach(() => {
[batchedSuggestionInfo] = batchSuggestionsInfoMock;
suggestions = batchSuggestionsInfoMock.map(({ suggestionId }) => ({ id: suggestionId }));
discussions = buildDiscussions(batchSuggestionsInfoMock);
state = {
batchSuggestionsInfo: [batchedSuggestionInfo],
discussions,
};
});
it('sets is_applying_batch to a boolean value for all batched suggestions', () => {
mutations.SET_APPLYING_BATCH_STATE(state, true);
const updatedSuggestion = {
...suggestions[0],
is_applying_batch: true,
};
const expectedSuggestions = [updatedSuggestion, suggestions[1]];
const actualSuggestions = state.discussions
.map(discussion => discussion.notes.map(n => n.suggestions))
.flat(2);
expect(actualSuggestions).toEqual(expectedSuggestions);
});
});
describe('ADD_SUGGESTION_TO_BATCH', () => {
let state;
beforeEach(() => {
state = { batchSuggestionsInfo: [] };
});
it("adds a suggestion's info to a batch", () => {
const suggestionInfo = {
suggestionId: 'a123',
noteId: 'b456',
discussionId: 'c789',
};
mutations.ADD_SUGGESTION_TO_BATCH(state, suggestionInfo);
expect(state.batchSuggestionsInfo).toEqual([suggestionInfo]);
});
});
describe('REMOVE_SUGGESTION_FROM_BATCH', () => {
let state;
let suggestionInfo1;
let suggestionInfo2;
beforeEach(() => {
[suggestionInfo1, suggestionInfo2] = batchSuggestionsInfoMock;
state = {
batchSuggestionsInfo: [suggestionInfo1, suggestionInfo2],
};
});
it("removes a suggestion's info from a batch", () => {
mutations.REMOVE_SUGGESTION_FROM_BATCH(state, suggestionInfo1.suggestionId);
expect(state.batchSuggestionsInfo).toEqual([suggestionInfo2]);
});
});
describe('CLEAR_SUGGESTION_BATCH', () => {
let state;
beforeEach(() => {
state = {
batchSuggestionsInfo: batchSuggestionsInfoMock,
};
});
it('removes info for all suggestions from a batch', () => {
mutations.CLEAR_SUGGESTION_BATCH(state);
expect(state.batchSuggestionsInfo.length).toEqual(0);
});
});
});

View File

@ -5,8 +5,11 @@ exports[`Suggestion Diff component matches snapshot 1`] = `
class="md-suggestion"
>
<suggestion-diff-header-stub
batchsuggestionscount="1"
class="qa-suggestion-diff-header js-suggestion-diff-header"
helppagepath="path_to_docs"
isapplyingbatch="true"
isbatched="true"
/>
<table

View File

@ -3,8 +3,11 @@ import { shallowMount } from '@vue/test-utils';
import SuggestionDiffHeader from '~/vue_shared/components/markdown/suggestion_diff_header.vue';
const DEFAULT_PROPS = {
batchSuggestionsCount: 2,
canApply: true,
isApplied: false,
isBatched: false,
isApplyingBatch: false,
helpPagePath: 'path_to_docs',
};
@ -25,6 +28,9 @@ describe('Suggestion Diff component', () => {
});
const findApplyButton = () => wrapper.find('.js-apply-btn');
const findApplyBatchButton = () => wrapper.find('.js-apply-batch-btn');
const findAddToBatchButton = () => wrapper.find('.js-add-to-batch-btn');
const findRemoveFromBatchButton = () => wrapper.find('.js-remove-from-batch-btn');
const findHeader = () => wrapper.find('.js-suggestion-diff-header');
const findHelpButton = () => wrapper.find('.js-help-btn');
const findLoading = () => wrapper.find(GlLoadingIcon);
@ -44,19 +50,24 @@ describe('Suggestion Diff component', () => {
expect(findHelpButton().exists()).toBe(true);
});
it('renders an apply button', () => {
it('renders apply suggestion and add to batch buttons', () => {
createComponent();
const applyBtn = findApplyButton();
const addToBatchBtn = findAddToBatchButton();
expect(applyBtn.exists()).toBe(true);
expect(applyBtn.html().includes('Apply suggestion')).toBe(true);
expect(addToBatchBtn.exists()).toBe(true);
expect(addToBatchBtn.html().includes('Add suggestion to batch')).toBe(true);
});
it('does not render an apply button if `canApply` is set to false', () => {
it('does not render apply suggestion and add to batch buttons if `canApply` is set to false', () => {
createComponent({ canApply: false });
expect(findApplyButton().exists()).toBe(false);
expect(findAddToBatchButton().exists()).toBe(false);
});
describe('when apply suggestion is clicked', () => {
@ -73,13 +84,14 @@ describe('Suggestion Diff component', () => {
});
});
it('hides apply button', () => {
it('hides apply suggestion and add to batch buttons', () => {
expect(findApplyButton().exists()).toBe(false);
expect(findAddToBatchButton().exists()).toBe(false);
});
it('shows loading', () => {
expect(findLoading().exists()).toBe(true);
expect(wrapper.text()).toContain('Applying suggestion');
expect(wrapper.text()).toContain('Applying suggestion...');
});
it('when callback of apply is called, hides loading', () => {
@ -93,4 +105,104 @@ describe('Suggestion Diff component', () => {
});
});
});
describe('when add to batch is clicked', () => {
it('emits addToBatch', () => {
createComponent();
findAddToBatchButton().vm.$emit('click');
expect(wrapper.emittedByOrder()).toContainEqual({
name: 'addToBatch',
args: [],
});
});
});
describe('when remove from batch is clicked', () => {
it('emits removeFromBatch', () => {
createComponent({ isBatched: true });
findRemoveFromBatchButton().vm.$emit('click');
expect(wrapper.emittedByOrder()).toContainEqual({
name: 'removeFromBatch',
args: [],
});
});
});
describe('apply suggestions is clicked', () => {
it('emits applyBatch', () => {
createComponent({ isBatched: true });
findApplyBatchButton().vm.$emit('click');
expect(wrapper.emittedByOrder()).toContainEqual({
name: 'applyBatch',
args: [],
});
});
});
describe('when isBatched is true', () => {
it('shows remove from batch and apply batch buttons and displays the batch count', () => {
createComponent({
batchSuggestionsCount: 9,
isBatched: true,
});
const applyBatchBtn = findApplyBatchButton();
const removeFromBatchBtn = findRemoveFromBatchButton();
expect(removeFromBatchBtn.exists()).toBe(true);
expect(removeFromBatchBtn.html().includes('Remove from batch')).toBe(true);
expect(applyBatchBtn.exists()).toBe(true);
expect(applyBatchBtn.html().includes('Apply suggestions')).toBe(true);
expect(applyBatchBtn.html().includes(String('9'))).toBe(true);
});
it('hides add to batch and apply buttons', () => {
createComponent({
isBatched: true,
});
expect(findApplyButton().exists()).toBe(false);
expect(findAddToBatchButton().exists()).toBe(false);
});
describe('when isBatched and isApplyingBatch are true', () => {
it('shows loading', () => {
createComponent({
isBatched: true,
isApplyingBatch: true,
});
expect(findLoading().exists()).toBe(true);
expect(wrapper.text()).toContain('Applying suggestions...');
});
it('adjusts message for batch with single suggestion', () => {
createComponent({
batchSuggestionsCount: 1,
isBatched: true,
isApplyingBatch: true,
});
expect(findLoading().exists()).toBe(true);
expect(wrapper.text()).toContain('Applying suggestion...');
});
it('hides remove from batch and apply suggestions buttons', () => {
createComponent({
isBatched: true,
isApplyingBatch: true,
});
expect(findRemoveFromBatchButton().exists()).toBe(false);
expect(findApplyBatchButton().exists()).toBe(false);
});
});
});
});

View File

@ -3,9 +3,10 @@ import SuggestionDiffComponent from '~/vue_shared/components/markdown/suggestion
import SuggestionDiffHeader from '~/vue_shared/components/markdown/suggestion_diff_header.vue';
import SuggestionDiffRow from '~/vue_shared/components/markdown/suggestion_diff_row.vue';
const suggestionId = 1;
const MOCK_DATA = {
suggestion: {
id: 1,
id: suggestionId,
diff_lines: [
{
can_receive_suggestion: false,
@ -38,8 +39,10 @@ const MOCK_DATA = {
type: 'new',
},
],
is_applying_batch: true,
},
helpPagePath: 'path_to_docs',
batchSuggestionsInfo: [{ suggestionId }],
};
describe('Suggestion Diff component', () => {
@ -70,17 +73,24 @@ describe('Suggestion Diff component', () => {
expect(wrapper.findAll(SuggestionDiffRow)).toHaveLength(3);
});
it('emits apply event on sugestion diff header apply', () => {
wrapper.find(SuggestionDiffHeader).vm.$emit('apply', 'test-event');
it.each`
event | childArgs | args
${'apply'} | ${['test-event']} | ${[{ callback: 'test-event', suggestionId }]}
${'applyBatch'} | ${[]} | ${[]}
${'addToBatch'} | ${[]} | ${[suggestionId]}
${'removeFromBatch'} | ${[]} | ${[suggestionId]}
`('emits $event event on sugestion diff header $event', ({ event, childArgs, args }) => {
wrapper.find(SuggestionDiffHeader).vm.$emit(event, ...childArgs);
expect(wrapper.emitted('apply')).toBeDefined();
expect(wrapper.emitted('apply')).toEqual([
[
{
callback: 'test-event',
suggestionId: 1,
},
],
]);
expect(wrapper.emitted(event)).toBeDefined();
expect(wrapper.emitted(event)).toEqual([args]);
});
it('passes suggestion batch props to suggestion diff header', () => {
expect(wrapper.find(SuggestionDiffHeader).props()).toMatchObject({
batchSuggestionsCount: 1,
isBatched: true,
isApplyingBatch: MOCK_DATA.suggestion.is_applying_batch,
});
});
});

View File

@ -46,12 +46,6 @@ describe GitlabSchema do
expect(connection).to eq(Gitlab::Graphql::Pagination::ExternallyPaginatedArrayConnection)
end
it 'paginates FilterableArray using `Pagination::FilterableArrayConnection`' do
connection = connections[Gitlab::Graphql::FilterableArray]
expect(connection).to eq(Gitlab::Graphql::Pagination::FilterableArrayConnection)
end
describe '.execute' do
context 'for different types of users' do
context 'when no context' do

View File

@ -33,7 +33,7 @@ describe Mutations::AlertManagement::UpdateAlertStatus do
context 'error occurs when updating' do
it 'returns the alert with errors' do
# Stub an error on the alert
allow_next_instance_of(Resolvers::AlertManagementAlertResolver) do |resolver|
allow_next_instance_of(Resolvers::AlertManagement::AlertResolver) do |resolver|
allow(resolver).to receive(:resolve).and_return(alert)
end

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Resolvers::AlertManagementAlertResolver do
describe Resolvers::AlertManagement::AlertResolver do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }

View File

@ -41,22 +41,35 @@ describe Resolvers::Projects::JiraProjectsResolver do
end
context 'when user is a maintainer' do
include_context 'jira projects request context'
before do
project.add_maintainer(user)
end
it 'returns jira projects' do
jira_projects = resolve_jira_projects
project_keys = jira_projects.map(&:key)
project_names = jira_projects.map(&:name)
project_ids = jira_projects.map(&:id)
context 'when Jira connection is valid' do
include_context 'jira projects request context'
expect(jira_projects.size).to eq 2
expect(project_keys).to eq(%w(EX ABC))
expect(project_names).to eq(%w(Example Alphabetical))
expect(project_ids).to eq(%w(10000 10001))
it 'returns jira projects' do
jira_projects = resolve_jira_projects
project_keys = jira_projects.map(&:key)
project_names = jira_projects.map(&:name)
project_ids = jira_projects.map(&:id)
expect(jira_projects.size).to eq 2
expect(project_keys).to eq(%w(EX ABC))
expect(project_names).to eq(%w(Example Alphabetical))
expect(project_ids).to eq(%w(10000 10001))
end
end
context 'when Jira connection is not valid' do
before do
WebMock.stub_request(:get, 'https://jira.example.com/rest/api/2/project/search?maxResults=50&query=&startAt=0')
.to_raise(JIRA::HTTPError.new(double(message: 'Some failure.')))
end
it 'raises failure error' do
expect { resolve_jira_projects }.to raise_error('Jira request error: Some failure.')
end
end
end
end

View File

@ -0,0 +1,51 @@
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::UsersResolver do
include GraphqlHelpers
let_it_be(:user1) { create(:user) }
let_it_be(:user2) { create(:user) }
describe '#resolve' do
it 'raises an error when read_users_list is not authorized' do
expect(Ability).to receive(:allowed?).with(nil, :read_users_list).and_return(false)
expect { resolve_users }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
context 'when no arguments are passed' do
it 'returns all users' do
expect(resolve_users).to contain_exactly(user1, user2)
end
end
context 'when both ids and usernames are passed ' do
it 'raises an error' do
expect { resolve_users(ids: [user1.to_global_id.to_s], usernames: [user1.username]) }
.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
end
end
context 'when a set of IDs is passed' do
it 'returns those users' do
expect(
resolve_users(ids: [user1.to_global_id.to_s, user2.to_global_id.to_s])
).to contain_exactly(user1, user2)
end
end
context 'when a set of usernames is passed' do
it 'returns those users' do
expect(
resolve_users(usernames: [user1.username, user2.username])
).to contain_exactly(user1, user2)
end
end
end
def resolve_users(args = {})
resolve(described_class, args: args)
end
end

View File

@ -18,6 +18,7 @@ describe GitlabSchema.types['Query'] do
snippets
design_management
user
users
]
expect(described_class).to have_graphql_fields(*expected_fields).at_least

View File

@ -1,29 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Graphql::Pagination::FilterableArrayConnection do
let(:callback) { proc { |nodes| nodes } }
let(:all_nodes) { Gitlab::Graphql::FilterableArray.new(callback, 1, 2, 3, 4, 5) }
let(:arguments) { {} }
subject(:connection) do
described_class.new(all_nodes, { max_page_size: 3 }.merge(arguments))
end
describe '#nodes' do
let(:paged_nodes) { subject.nodes }
it_behaves_like 'connection with paged nodes' do
let(:paged_nodes_size) { 3 }
end
context 'when callback filters some nodes' do
let(:callback) { proc { |nodes| nodes[1..-1] } }
it 'does not return filtered elements' do
expect(subject.nodes).to contain_exactly(all_nodes[1], all_nodes[2])
end
end
end
end

View File

@ -0,0 +1,87 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Suggestions::CommitMessage do
def create_suggestion(file_path, new_line, to_content)
position = Gitlab::Diff::Position.new(old_path: file_path,
new_path: file_path,
old_line: nil,
new_line: new_line,
diff_refs: merge_request.diff_refs)
diff_note = create(:diff_note_on_merge_request,
noteable: merge_request,
position: position,
project: project)
create(:suggestion,
:content_from_repo,
note: diff_note,
to_content: to_content)
end
let_it_be(:user) do
create(:user, :commit_email, name: 'Test User', username: 'test.user')
end
let_it_be(:project) do
create(:project, :repository, path: 'project-1', name: 'Project_1')
end
let_it_be(:merge_request) do
create(:merge_request, source_project: project, target_project: project)
end
let_it_be(:suggestion_set) do
suggestion1 = create_suggestion('files/ruby/popen.rb', 9, '*** SUGGESTION 1 ***')
suggestion2 = create_suggestion('files/ruby/popen.rb', 13, '*** SUGGESTION 2 ***')
suggestion3 = create_suggestion('files/ruby/regex.rb', 22, '*** SUGGESTION 3 ***')
Gitlab::Suggestions::SuggestionSet.new([suggestion1, suggestion2, suggestion3])
end
describe '#message' do
before do
# Updating the suggestion_commit_message on a project shared across specs
# avoids recreating the repository for each spec.
project.update!(suggestion_commit_message: message)
end
context 'when a custom commit message is not specified' do
let(:expected_message) { 'Apply 3 suggestion(s) to 2 file(s)' }
context 'and is nil' do
let(:message) { nil }
it 'uses the default commit message' do
expect(described_class
.new(user, suggestion_set)
.message).to eq(expected_message)
end
end
context 'and is an empty string' do
let(:message) { '' }
it 'uses the default commit message' do
expect(described_class
.new(user, suggestion_set)
.message).to eq(expected_message)
end
end
end
context 'is specified and includes all placeholders' do
let(:message) do
'*** %{branch_name} %{files_count} %{file_paths} %{project_name} %{project_path} %{user_full_name} %{username} %{suggestions_count} ***'
end
it 'generates a custom commit message' do
expect(Gitlab::Suggestions::CommitMessage
.new(user, suggestion_set)
.message).to eq('*** master 2 files/ruby/popen.rb, files/ruby/regex.rb Project_1 project-1 Test User test.user 3 ***')
end
end
end
end

View File

@ -0,0 +1,241 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Suggestions::FileSuggestion do
def create_suggestion(new_line, to_content)
position = Gitlab::Diff::Position.new(old_path: file_path,
new_path: file_path,
old_line: nil,
new_line: new_line,
diff_refs: merge_request.diff_refs)
diff_note = create(:diff_note_on_merge_request,
noteable: merge_request,
position: position,
project: project)
create(:suggestion,
:content_from_repo,
note: diff_note,
to_content: to_content)
end
let_it_be(:user) { create(:user) }
let_it_be(:file_path) { 'files/ruby/popen.rb'}
let_it_be(:project) { create(:project, :repository) }
let_it_be(:merge_request) do
create(:merge_request, source_project: project, target_project: project)
end
let_it_be(:suggestion1) do
create_suggestion(9, " *** SUGGESTION 1 ***\n")
end
let_it_be(:suggestion2) do
create_suggestion(15, " *** SUGGESTION 2 ***\n")
end
let(:file_suggestion) { described_class.new }
describe '#add_suggestion' do
it 'succeeds when adding a suggestion for the same file as the original' do
file_suggestion.add_suggestion(suggestion1)
expect { file_suggestion.add_suggestion(suggestion2) }.not_to raise_error
end
it 'raises an error when adding a suggestion for a different file' do
allow(suggestion2)
.to(receive_message_chain(:diff_file, :file_path)
.and_return('path/to/different/file'))
file_suggestion.add_suggestion(suggestion1)
expect { file_suggestion.add_suggestion(suggestion2) }.to(
raise_error(described_class::SuggestionForDifferentFileError)
)
end
end
describe '#line_conflict' do
def stub_suggestions(line_index_spans)
fake_suggestions = line_index_spans.map do |span|
double("Suggestion",
from_line_index: span[:from_line_index],
to_line_index: span[:to_line_index])
end
allow(file_suggestion).to(receive(:suggestions).and_return(fake_suggestions))
end
context 'when line ranges do not overlap' do
it 'return false' do
stub_suggestions(
[
{
from_line_index: 0,
to_line_index: 10
},
{
from_line_index: 11,
to_line_index: 20
}
]
)
expect(file_suggestion.line_conflict?).to be(false)
end
end
context 'when line ranges are identical' do
it 'returns true' do
stub_suggestions(
[
{
from_line_index: 0,
to_line_index: 10
},
{
from_line_index: 0,
to_line_index: 10
}
]
)
expect(file_suggestion.line_conflict?).to be(true)
end
end
context 'when one range starts, and the other ends, on the same line' do
it 'returns true' do
stub_suggestions(
[
{
from_line_index: 0,
to_line_index: 10
},
{
from_line_index: 10,
to_line_index: 20
}
]
)
expect(file_suggestion.line_conflict?).to be(true)
end
end
context 'when one line range contains the other' do
it 'returns true' do
stub_suggestions(
[
{
from_line_index: 0,
to_line_index: 10
},
{
from_line_index: 5,
to_line_index: 7
}
]
)
expect(file_suggestion.line_conflict?).to be(true)
end
end
context 'when line ranges overlap' do
it 'returns true' do
stub_suggestions(
[
{
from_line_index: 0,
to_line_index: 10
},
{
from_line_index: 8,
to_line_index: 15
}
]
)
expect(file_suggestion.line_conflict?).to be(true)
end
end
context 'when no suggestions have been added' do
it 'returns false' do
expect(file_suggestion.line_conflict?).to be(false)
end
end
end
describe '#new_content' do
it 'returns a blob with the suggestions applied to it' do
file_suggestion.add_suggestion(suggestion1)
file_suggestion.add_suggestion(suggestion2)
expected_content = <<-CONTENT.strip_heredoc
require 'fileutils'
require 'open3'
module Popen
extend self
def popen(cmd, path=nil)
unless cmd.is_a?(Array)
*** SUGGESTION 1 ***
end
path ||= Dir.pwd
vars = {
*** SUGGESTION 2 ***
}
options = {
chdir: path
}
unless File.directory?(path)
FileUtils.mkdir_p(path)
end
@cmd_output = ""
@cmd_status = 0
Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
@cmd_output << stdout.read
@cmd_output << stderr.read
@cmd_status = wait_thr.value.exitstatus
end
return @cmd_output, @cmd_status
end
end
CONTENT
expect(file_suggestion.new_content).to eq(expected_content)
end
it 'returns an empty string when no suggestions have been added' do
expect(file_suggestion.new_content).to eq('')
end
end
describe '#file_path' do
it 'returns the path of the file associated with the suggestions' do
file_suggestion.add_suggestion(suggestion1)
expect(file_suggestion.file_path).to eq(file_path)
end
it 'returns nil if no suggestions have been added' do
expect(file_suggestion.file_path).to be(nil)
end
end
end

View File

@ -0,0 +1,110 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Suggestions::SuggestionSet do
def create_suggestion(file_path, new_line, to_content)
position = Gitlab::Diff::Position.new(old_path: file_path,
new_path: file_path,
old_line: nil,
new_line: new_line,
diff_refs: merge_request.diff_refs)
diff_note = create(:diff_note_on_merge_request,
noteable: merge_request,
position: position,
project: project)
create(:suggestion,
:content_from_repo,
note: diff_note,
to_content: to_content)
end
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
let_it_be(:merge_request) do
create(:merge_request, source_project: project, target_project: project)
end
let_it_be(:suggestion) { create(:suggestion)}
let_it_be(:suggestion2) do
create_suggestion('files/ruby/popen.rb', 13, "*** SUGGESTION 2 ***")
end
let_it_be(:suggestion3) do
create_suggestion('files/ruby/regex.rb', 22, "*** SUGGESTION 3 ***")
end
let_it_be(:unappliable_suggestion) { create(:suggestion, :unappliable) }
let(:suggestion_set) { described_class.new([suggestion]) }
describe '#project' do
it 'returns the project associated with the suggestions' do
expected_project = suggestion.project
expect(suggestion_set.project).to be(expected_project)
end
end
describe '#branch' do
it 'returns the branch associated with the suggestions' do
expected_branch = suggestion.branch
expect(suggestion_set.branch).to be(expected_branch)
end
end
describe '#valid?' do
it 'returns true if no errors are found' do
expect(suggestion_set.valid?).to be(true)
end
it 'returns false if an error is found' do
suggestion_set = described_class.new([unappliable_suggestion])
expect(suggestion_set.valid?).to be(false)
end
end
describe '#error_message' do
it 'returns an error message if an error is found' do
suggestion_set = described_class.new([unappliable_suggestion])
expect(suggestion_set.error_message).to be_a(String)
end
it 'returns nil if no errors are found' do
expect(suggestion_set.error_message).to be(nil)
end
end
describe '#actions' do
it 'returns an array of hashes with proper key/value pairs' do
first_action = suggestion_set.actions.first
file_path, file_suggestion = suggestion_set
.send(:suggestions_per_file).first
expect(first_action[:action]).to be('update')
expect(first_action[:file_path]).to eq(file_path)
expect(first_action[:content]).to eq(file_suggestion.new_content)
end
end
describe '#file_paths' do
it 'returns an array of unique file paths associated with the suggestions' do
suggestion_set = described_class.new([suggestion, suggestion2, suggestion3])
expected_paths = %w(files/ruby/popen.rb files/ruby/regex.rb)
actual_paths = suggestion_set.file_paths
expect(actual_paths.sort).to eq(expected_paths)
end
end
end

View File

@ -0,0 +1,90 @@
# frozen_string_literal: true
require 'spec_helper'
describe 'Users' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user, created_at: 1.day.ago) }
let_it_be(:user1) { create(:user, created_at: 2.days.ago) }
let_it_be(:user2) { create(:user, created_at: 3.days.ago) }
let_it_be(:user3) { create(:user, created_at: 4.days.ago) }
describe '.users' do
shared_examples 'a working users query' do
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: current_user)
end
end
it 'includes a list of users' do
post_graphql(query)
expect(graphql_data.dig('users', 'nodes')).not_to be_empty
end
end
context 'with no arguments' do
let_it_be(:query) { graphql_query_for(:users, { usernames: [user1.username] }, 'nodes { id }') }
it_behaves_like 'a working users query'
end
context 'with a list of usernames' do
let(:query) { graphql_query_for(:users, { usernames: [user1.username] }, 'nodes { id }') }
it_behaves_like 'a working users query'
end
context 'with a list of IDs' do
let(:query) { graphql_query_for(:users, { ids: [user1.to_global_id.to_s] }, 'nodes { id }') }
it_behaves_like 'a working users query'
end
context 'when usernames and ids parameter are used' do
let_it_be(:query) { graphql_query_for(:users, { ids: user1.to_global_id.to_s, usernames: user1.username }, 'nodes { id }') }
it 'displays an error' do
post_graphql(query)
expect(graphql_errors).to include(
a_hash_including('message' => a_string_matching(%r{Provide either a list of usernames or ids}))
)
end
end
end
describe 'sorting and pagination' do
let_it_be(:data_path) { [:users] }
def pagination_query(params, page_info)
graphql_query_for("users", params, "#{page_info} edges { node { id } }")
end
def pagination_results_data(data)
data.map { |user| user.dig('node', 'id') }
end
context 'when sorting by created_at' do
let_it_be(:ascending_users) { [user3, user2, user1, current_user].map(&:to_global_id).map(&:to_s) }
context 'when ascending' do
it_behaves_like 'sorted paginated query' do
let(:sort_param) { 'created_asc' }
let(:first_param) { 1 }
let(:expected_results) { ascending_users }
end
end
context 'when descending' do
it_behaves_like 'sorted paginated query' do
let(:sort_param) { 'created_desc' }
let(:first_param) { 1 }
let(:expected_results) { ascending_users.reverse }
end
end
end
end
end

View File

@ -7,8 +7,7 @@ describe API::Suggestions do
let(:user) { create(:user) }
let(:merge_request) do
create(:merge_request, source_project: project,
target_project: project)
create(:merge_request, source_project: project, target_project: project)
end
let(:position) do
@ -19,26 +18,45 @@ describe API::Suggestions do
diff_refs: merge_request.diff_refs)
end
let(:position2) do
Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb",
new_path: "files/ruby/popen.rb",
old_line: nil,
new_line: 15,
diff_refs: merge_request.diff_refs)
end
let(:diff_note) do
create(:diff_note_on_merge_request,
noteable: merge_request,
position: position,
project: project)
end
let(:diff_note2) do
create(:diff_note_on_merge_request, noteable: merge_request,
position: position,
project: project)
position: position2,
project: project)
end
let(:suggestion) do
create(:suggestion, note: diff_note,
from_content: " raise RuntimeError, \"System commands must be given as an array of strings\"\n",
to_content: " raise RuntimeError, 'Explosion'\n # explosion?")
end
let(:unappliable_suggestion) do
create(:suggestion, :unappliable, note: diff_note2)
end
describe "PUT /suggestions/:id/apply" do
let(:url) { "/suggestions/#{suggestion.id}/apply" }
context 'when successfully applies patch' do
let(:suggestion) do
create(:suggestion, note: diff_note,
from_content: " raise RuntimeError, \"System commands must be given as an array of strings\"\n",
to_content: " raise RuntimeError, 'Explosion'\n # explosion?")
end
it 'returns 200 with json content' do
it 'renders an ok response and returns json content' do
project.add_maintainer(user)
put api(url, user), params: { id: suggestion.id }
put api(url, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response)
@ -48,31 +66,105 @@ describe API::Suggestions do
end
context 'when not able to apply patch' do
let(:suggestion) do
create(:suggestion, :unappliable, note: diff_note)
end
let(:url) { "/suggestions/#{unappliable_suggestion.id}/apply" }
it 'returns 400 with json content' do
it 'renders a bad request error and returns json content' do
project.add_maintainer(user)
put api(url, user), params: { id: suggestion.id }
put api(url, user)
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response).to eq({ 'message' => 'Suggestion is not appliable' })
expect(json_response).to eq({ 'message' => 'A suggestion is not applicable.' })
end
end
context 'when suggestion is not found' do
let(:url) { "/suggestions/foo-123/apply" }
it 'renders a not found error and returns json content' do
project.add_maintainer(user)
put api(url, user)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response).to eq({ 'message' => 'Suggestion is not applicable as the suggestion was not found.' })
end
end
context 'when unauthorized' do
let(:suggestion) do
create(:suggestion, note: diff_note,
from_content: " raise RuntimeError, \"System commands must be given as an array of strings\"\n",
to_content: " raise RuntimeError, 'Explosion'\n # explosion?")
end
it 'returns 403 with json content' do
it 'renders a forbidden error and returns json content' do
project.add_reporter(user)
put api(url, user), params: { id: suggestion.id }
put api(url, user)
expect(response).to have_gitlab_http_status(:forbidden)
expect(json_response).to eq({ 'message' => '403 Forbidden' })
end
end
end
describe "PUT /suggestions/batch_apply" do
let(:suggestion2) do
create(:suggestion, note: diff_note2,
from_content: " \"PWD\" => path\n",
to_content: " *** FOO ***\n")
end
let(:url) { "/suggestions/batch_apply" }
context 'when successfully applies multiple patches as a batch' do
it 'renders an ok response and returns json content' do
project.add_maintainer(user)
put api(url, user), params: { ids: [suggestion.id, suggestion2.id] }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to all(include('id', 'from_line', 'to_line',
'appliable', 'applied',
'from_content', 'to_content'))
end
end
context 'when not able to apply one or more of the patches' do
it 'renders a bad request error and returns json content' do
project.add_maintainer(user)
put api(url, user),
params: { ids: [suggestion.id, unappliable_suggestion.id] }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response).to eq({ 'message' => 'A suggestion is not applicable.' })
end
end
context 'with missing suggestions' do
it 'renders a not found error and returns json content if any suggestion is not found' do
project.add_maintainer(user)
put api(url, user), params: { ids: [suggestion.id, 'foo-123'] }
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response)
.to eq({ 'message' => 'Suggestions are not applicable as one or more suggestions were not found.' })
end
it 'renders a bad request error and returns json content when no suggestions are provided' do
project.add_maintainer(user)
put api(url, user), params: {}
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response)
.to eq({ 'error' => "ids is missing" })
end
end
context 'when unauthorized' do
it 'renders a forbidden error and returns json content' do
project.add_reporter(user)
put api(url, user),
params: { ids: [suggestion.id, suggestion2.id] }
expect(response).to have_gitlab_http_status(:forbidden)
expect(json_response).to eq({ 'message' => '403 Forbidden' })

View File

@ -54,7 +54,7 @@ describe Jira::Requests::Projects do
it 'returns an error response' do
expect(subject.error?).to be_truthy
expect(subject.message).to eq('Timeout::Error')
expect(subject.message).to eq('Jira request error: Timeout::Error')
end
end

View File

@ -5,41 +5,66 @@ require 'spec_helper'
describe Suggestions::ApplyService do
include ProjectForksHelper
def build_position(args = {})
default_args = { old_path: "files/ruby/popen.rb",
new_path: "files/ruby/popen.rb",
old_line: nil,
new_line: 9,
diff_refs: merge_request.diff_refs }
def build_position(**optional_args)
args = { old_path: "files/ruby/popen.rb",
new_path: "files/ruby/popen.rb",
old_line: nil,
new_line: 9,
diff_refs: merge_request.diff_refs,
**optional_args }
Gitlab::Diff::Position.new(default_args.merge(args))
Gitlab::Diff::Position.new(args)
end
shared_examples 'successfully creates commit and updates suggestion' do
def apply(suggestion)
result = subject.execute(suggestion)
expect(result[:status]).to eq(:success)
end
def create_suggestion(args)
position_args = args.slice(:old_path, :new_path, :old_line, :new_line)
content_args = args.slice(:from_content, :to_content)
it 'updates the file with the new contents' do
apply(suggestion)
position = build_position(position_args)
blob = project.repository.blob_at_branch(merge_request.source_branch,
position.new_path)
diff_note = create(:diff_note_on_merge_request,
noteable: merge_request,
position: position,
project: project)
expect(blob.data).to eq(expected_content)
suggestion_args = { note: diff_note }.merge(content_args)
create(:suggestion, :content_from_repo, suggestion_args)
end
def apply(suggestions)
result = apply_service.new(user, *suggestions).execute
suggestions.map { |suggestion| suggestion.reload }
expect(result[:status]).to eq(:success)
end
shared_examples 'successfully creates commit and updates suggestions' do
it 'updates the files with the new content' do
apply(suggestions)
suggestions.each do |suggestion|
path = suggestion.diff_file.file_path
blob = project.repository.blob_at_branch(merge_request.source_branch,
path)
expect(blob.data).to eq(expected_content_by_path[path.to_sym])
end
end
it 'updates suggestion applied and commit_id columns' do
expect { apply(suggestion) }
.to change(suggestion, :applied)
.from(false).to(true)
.and change(suggestion, :commit_id)
.from(nil)
expect(suggestions.map(&:applied)).to all(be false)
expect(suggestions.map(&:commit_id)).to all(be nil)
apply(suggestions)
expect(suggestions.map(&:applied)).to all(be true)
expect(suggestions.map(&:commit_id)).to all(be_present)
end
it 'created commit has users email and name' do
apply(suggestion)
apply(suggestions)
commit = project.repository.commit
@ -53,125 +78,214 @@ describe Suggestions::ApplyService do
before do
project.update!(suggestion_commit_message: message)
apply(suggestion)
apply(suggestions)
end
context 'is not specified' do
let(:expected_value) { "Apply suggestion to files/ruby/popen.rb" }
let(:message) { '' }
context 'is nil' do
let(:message) { nil }
it 'sets default commit message' do
expect(project.repository.commit.message).to eq(expected_value)
end
end
context 'is an empty string' do
let(:message) { '' }
it 'sets default commit message' do
expect(project.repository.commit.message).to eq(expected_value)
end
it 'uses the default commit message' do
expect(project.repository.commit.message).to(
match(/\AApply #{suggestions.size} suggestion\(s\) to \d+ file\(s\)\z/)
)
end
end
context 'is specified' do
let(:message) { 'refactor: %{project_path} %{project_name} %{file_path} %{branch_name} %{username} %{user_full_name}' }
let(:message) do
'refactor: %{project_name} %{branch_name} %{username}'
end
it 'sets custom commit message' do
expect(project.repository.commit.message).to eq("refactor: project-1 Project_1 files/ruby/popen.rb master test.user Test User")
it 'generates a custom commit message' do
expect(project.repository.commit.message).to(
eq("refactor: Project_1 master test.user")
)
end
end
end
end
let(:project) { create(:project, :repository, path: 'project-1', name: 'Project_1') }
let(:user) { create(:user, :commit_email, name: 'Test User', username: 'test.user') }
subject(:apply_service) { described_class }
let_it_be(:user) do
create(:user, :commit_email, name: 'Test User', username: 'test.user')
end
let(:project) do
create(:project, :repository, path: 'project-1', name: 'Project_1')
end
let(:merge_request) do
create(:merge_request, source_project: project,
target_project: project,
source_branch: 'master')
end
let(:position) { build_position }
let(:diff_note) do
create(:diff_note_on_merge_request, noteable: merge_request, position: position, project: project)
create(:diff_note_on_merge_request, noteable: merge_request,
position: position, project: project)
end
let(:suggestion) do
create(:suggestion, :content_from_repo, note: diff_note,
to_content: " raise RuntimeError, 'Explosion'\n # explosion?\n")
to_content: " raise RuntimeError, 'Explosion'\n # explosion?\n")
end
subject { described_class.new(user) }
let(:suggestion2) do
create_suggestion(
to_content: " *** SUGGESTION CHANGE ***\n",
new_line: 15)
end
let(:suggestion3) do
create_suggestion(
to_content: " *** ANOTHER SUGGESTION CHANGE ***\n",
old_path: "files/ruby/regex.rb",
new_path: "files/ruby/regex.rb",
new_line: 22)
end
let(:suggestions) { [suggestion, suggestion2, suggestion3] }
context 'patch is appliable' do
let(:expected_content) do
let(:popen_content) do
<<-CONTENT.strip_heredoc
require 'fileutils'
require 'open3'
require 'fileutils'
require 'open3'
module Popen
extend self
module Popen
extend self
def popen(cmd, path=nil)
unless cmd.is_a?(Array)
raise RuntimeError, 'Explosion'
# explosion?
end
path ||= Dir.pwd
vars = {
"PWD" => path
}
options = {
chdir: path
}
unless File.directory?(path)
FileUtils.mkdir_p(path)
end
@cmd_output = ""
@cmd_status = 0
Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
@cmd_output << stdout.read
@cmd_output << stderr.read
@cmd_status = wait_thr.value.exitstatus
end
return @cmd_output, @cmd_status
def popen(cmd, path=nil)
unless cmd.is_a?(Array)
raise RuntimeError, 'Explosion'
# explosion?
end
path ||= Dir.pwd
vars = {
*** SUGGESTION CHANGE ***
}
options = {
chdir: path
}
unless File.directory?(path)
FileUtils.mkdir_p(path)
end
@cmd_output = ""
@cmd_status = 0
Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
@cmd_output << stdout.read
@cmd_output << stderr.read
@cmd_status = wait_thr.value.exitstatus
end
return @cmd_output, @cmd_status
end
end
CONTENT
end
context 'non-fork project' do
let(:merge_request) do
create(:merge_request, source_project: project,
target_project: project,
source_branch: 'master')
end
let(:regex_content) do
<<-CONTENT.strip_heredoc
module Gitlab
module Regex
extend self
def username_regex
default_regex
end
def project_name_regex
/\\A[a-zA-Z0-9][a-zA-Z0-9_\\-\\. ]*\\z/
end
def name_regex
/\\A[a-zA-Z0-9_\\-\\. ]*\\z/
end
def path_regex
default_regex
end
def archive_formats_regex
*** ANOTHER SUGGESTION CHANGE ***
end
def git_reference_regex
# Valid git ref regex, see:
# https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html
%r{
(?!
(?# doesn't begins with)
\\/| (?# rule #6)
(?# doesn't contain)
.*(?:
[\\\/.]\\\.| (?# rule #1,3)
\\/\\/| (?# rule #6)
@\\{| (?# rule #8)
\\\\ (?# rule #9)
)
)
[^\\000-\\040\\177~^:?*\\[]+ (?# rule #4-5)
(?# doesn't end with)
(?<!\\.lock) (?# rule #1)
(?<![\\/.]) (?# rule #6-7)
}x
end
protected
def default_regex
/\\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\\-\\.]*(?<!\\.git)\\z/
end
end
end
CONTENT
end
let(:expected_content_by_path) do
{
"files/ruby/popen.rb": popen_content,
"files/ruby/regex.rb": regex_content
}
end
context 'non-fork project' do
before do
project.add_maintainer(user)
end
it_behaves_like 'successfully creates commit and updates suggestion'
it_behaves_like 'successfully creates commit and updates suggestions'
context 'when it fails to apply because a file was changed' do
before do
params = {
file_path: suggestion3.diff_file.file_path,
start_branch: suggestion3.branch,
branch_name: suggestion3.branch,
commit_message: 'Update file',
file_content: 'New content'
}
# Reload the suggestion so it's memoized values get reset after the
# file was changed.
suggestion3.reload
Files::UpdateService.new(project, user, params).execute
end
context 'when it fails to apply because the file was changed' do
it 'returns error message' do
service = instance_double(Files::UpdateService)
result = apply_service.new(user, suggestion, suggestion3, suggestion2).execute
expect(Files::UpdateService).to receive(:new)
.and_return(service)
allow(service).to receive(:execute)
.and_raise(Files::UpdateService::FileChangedError)
result = subject.execute(suggestion)
expect(result).to eq(message: 'The file has been changed', status: :error)
expect(result).to eq(message: 'A file has been changed.', status: :error)
end
end
@ -181,78 +295,20 @@ describe Suggestions::ApplyService do
allow(suggestion.position).to receive(:head_sha) { 'old-sha' }
allow(suggestion.noteable).to receive(:source_branch_sha) { 'new-sha' }
result = subject.execute(suggestion)
result = apply_service.new(user, suggestion).execute
expect(result).to eq(message: 'The file has been changed', status: :error)
expect(result).to eq(message: 'A file has been changed.', status: :error)
end
end
context 'multiple suggestions applied' do
let(:expected_content) do
<<-CONTENT.strip_heredoc
require 'fileutils'
require 'open3'
module Popen
extend self
def popen(cmd, path=nil)
unless cmd.is_a?(Array)
# v1 change
end
path ||= Dir.pwd
# v1 change
vars = {
"PWD" => path
}
options = {
chdir: path
}
# v2 change
unless File.directory?(path)
FileUtils.mkdir_p(path)
end
@cmd_output = ""
# v2 change
Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
@cmd_output << stdout.read
@cmd_output << stderr.read
@cmd_status = wait_thr.value.exitstatus
end
return @cmd_output, @cmd_status
end
end
CONTENT
end
def create_suggestion(diff, old_line: nil, new_line: nil, from_content:, to_content:, path:)
position = Gitlab::Diff::Position.new(old_path: path,
new_path: path,
old_line: old_line,
new_line: new_line,
diff_refs: diff.diff_refs)
suggestion_note = create(:diff_note_on_merge_request, noteable: merge_request,
original_position: position,
position: position,
project: project)
create(:suggestion, note: suggestion_note,
from_content: from_content,
to_content: to_content)
end
context 'multiple suggestions applied sequentially' do
def apply_suggestion(suggestion)
suggestion.reload
merge_request.reload
merge_request.clear_memoized_shas
result = subject.execute(suggestion)
result = apply_service.new(user, suggestion).execute
suggestion.reload
expect(result[:status]).to eq(:success)
refresh = MergeRequests::RefreshService.new(project, user)
@ -264,34 +320,31 @@ describe Suggestions::ApplyService do
end
def fetch_raw_diff(suggestion)
project.reload.commit(suggestion.commit_id).diffs.diff_files.first.diff.diff
project.reload.commit(suggestion.commit_id)
.diffs.diff_files.first.diff.diff
end
it 'applies multiple suggestions in subsequent versions correctly' do
diff = merge_request.merge_request_diff
path = 'files/ruby/popen.rb'
suggestion1 = create_suggestion(
from_content: "\n",
to_content: "# v1 change\n",
old_line: nil,
new_line: 13)
suggestion_1_changes = { old_line: nil,
new_line: 13,
from_content: "\n",
to_content: "# v1 change\n",
path: path }
suggestion2 = create_suggestion(
from_content: " @cmd_output << stderr.read\n",
to_content: "# v2 change\n",
old_line: 24,
new_line: 31)
suggestion_2_changes = { old_line: 24,
new_line: 31,
from_content: " @cmd_output << stderr.read\n",
to_content: "# v2 change\n",
path: path }
apply_suggestion(suggestion1)
apply_suggestion(suggestion2)
suggestion_1 = create_suggestion(diff, suggestion_1_changes)
suggestion_2 = create_suggestion(diff, suggestion_2_changes)
apply_suggestion(suggestion_1)
suggestion_1_diff = fetch_raw_diff(suggestion_1)
suggestion1_diff = fetch_raw_diff(suggestion1)
suggestion2_diff = fetch_raw_diff(suggestion2)
# rubocop: disable Layout/TrailingWhitespace
expected_suggestion_1_diff = <<-CONTENT.strip_heredoc
expected_suggestion1_diff = <<-CONTENT.strip_heredoc
@@ -10,7 +10,7 @@ module Popen
end
@ -304,12 +357,8 @@ describe Suggestions::ApplyService do
CONTENT
# rubocop: enable Layout/TrailingWhitespace
apply_suggestion(suggestion_2)
suggestion_2_diff = fetch_raw_diff(suggestion_2)
# rubocop: disable Layout/TrailingWhitespace
expected_suggestion_2_diff = <<-CONTENT.strip_heredoc
expected_suggestion2_diff = <<-CONTENT.strip_heredoc
@@ -28,7 +28,7 @@ module Popen
Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
@ -321,14 +370,14 @@ describe Suggestions::ApplyService do
CONTENT
# rubocop: enable Layout/TrailingWhitespace
expect(suggestion_1_diff.strip).to eq(expected_suggestion_1_diff.strip)
expect(suggestion_2_diff.strip).to eq(expected_suggestion_2_diff.strip)
expect(suggestion1_diff.strip).to eq(expected_suggestion1_diff.strip)
expect(suggestion2_diff.strip).to eq(expected_suggestion2_diff.strip)
end
end
context 'multi-line suggestion' do
let(:expected_content) do
<<~CONTENT
let(:popen_content) do
<<~CONTENT.strip_heredoc
require 'fileutils'
require 'open3'
@ -365,19 +414,27 @@ describe Suggestions::ApplyService do
CONTENT
end
let(:suggestion) do
create(:suggestion, :content_from_repo, note: diff_note,
lines_above: 2,
lines_below: 3,
to_content: "# multi\n# line\n")
let(:expected_content_by_path) do
{
"files/ruby/popen.rb": popen_content
}
end
it_behaves_like 'successfully creates commit and updates suggestion'
let(:suggestion) do
create(:suggestion, :content_from_repo, note: diff_note,
lines_above: 2,
lines_below: 3,
to_content: "# multi\n# line\n")
end
let(:suggestions) { [suggestion] }
it_behaves_like 'successfully creates commit and updates suggestions'
end
context 'remove an empty line suggestion' do
let(:expected_content) do
<<~CONTENT
let(:popen_content) do
<<~CONTENT.strip_heredoc
require 'fileutils'
require 'open3'
@ -417,12 +474,19 @@ describe Suggestions::ApplyService do
CONTENT
end
let(:position) { build_position(new_line: 13) }
let(:suggestion) do
create(:suggestion, :content_from_repo, note: diff_note, to_content: "")
let(:expected_content_by_path) do
{
"files/ruby/popen.rb": popen_content
}
end
it_behaves_like 'successfully creates commit and updates suggestion'
let(:suggestion) do
create_suggestion( to_content: "", new_line: 13)
end
let(:suggestions) { [suggestion] }
it_behaves_like 'successfully creates commit and updates suggestions'
end
end
@ -430,17 +494,23 @@ describe Suggestions::ApplyService do
let(:project) { create(:project, :public, :repository) }
let(:forked_project) do
fork_project_with_submodules(project, user, repository: project.repository)
fork_project_with_submodules(project,
user, repository: project.repository)
end
let(:merge_request) do
create(:merge_request,
source_branch: 'conflict-resolvable-fork', source_project: forked_project,
target_branch: 'conflict-start', target_project: project)
source_branch: 'conflict-resolvable-fork',
source_project: forked_project,
target_branch: 'conflict-start',
target_project: project)
end
let!(:diff_note) do
create(:diff_note_on_merge_request, noteable: merge_request, position: position, project: project)
create(:diff_note_on_merge_request,
noteable: merge_request,
position: position,
project: project)
end
before do
@ -448,11 +518,12 @@ describe Suggestions::ApplyService do
end
it 'updates file in the source project' do
expect(Files::UpdateService).to receive(:new)
.with(merge_request.source_project, user, anything)
.and_call_original
expect(Files::MultiService).to receive(:new)
.with(merge_request.source_project,
user,
anything).and_call_original
subject.execute(suggestion)
apply_service.new(user, suggestion).execute
end
end
end
@ -460,13 +531,13 @@ describe Suggestions::ApplyService do
context 'no permission' do
let(:merge_request) do
create(:merge_request, source_project: project,
target_project: project)
target_project: project)
end
let(:diff_note) do
create(:diff_note_on_merge_request, noteable: merge_request,
position: position,
project: project)
position: position,
project: project)
end
context 'user cannot write in project repo' do
@ -475,7 +546,7 @@ describe Suggestions::ApplyService do
end
it 'returns error' do
result = subject.execute(suggestion)
result = apply_service.new(user, suggestion).execute
expect(result).to eq(message: "You are not allowed to push into this branch",
status: :error)
@ -486,13 +557,13 @@ describe Suggestions::ApplyService do
context 'patch is not appliable' do
let(:merge_request) do
create(:merge_request, source_project: project,
target_project: project)
target_project: project)
end
let(:diff_note) do
create(:diff_note_on_merge_request, noteable: merge_request,
position: position,
project: project)
position: position,
project: project)
end
before do
@ -503,9 +574,37 @@ describe Suggestions::ApplyService do
it 'returns error message' do
expect(suggestion.note).to receive(:latest_diff_file) { nil }
result = subject.execute(suggestion)
result = apply_service.new(user, suggestion).execute
expect(result).to eq(message: 'Suggestion is not appliable',
expect(result).to eq(message: 'A file was not found.',
status: :error)
end
end
context 'when not all suggestions belong to the same branch' do
it 'renders error message' do
merge_request2 = create(:merge_request,
:conflict,
source_project: project,
target_project: project)
position2 = Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb",
new_path: "files/ruby/popen.rb",
old_line: nil,
new_line: 15,
diff_refs: merge_request2
.diff_refs)
diff_note2 = create(:diff_note_on_merge_request,
noteable: merge_request2,
position: position2,
project: project)
other_branch_suggestion = create(:suggestion, note: diff_note2)
result = apply_service.new(user, suggestion, other_branch_suggestion).execute
expect(result).to eq(message: 'Suggestions must all be on the same branch.',
status: :error)
end
end
@ -514,30 +613,22 @@ describe Suggestions::ApplyService do
it 'returns error message' do
expect(suggestion).to receive(:outdated?) { true }
result = subject.execute(suggestion)
result = apply_service.new(user, suggestion).execute
expect(result).to eq(message: 'Suggestion is not appliable',
expect(result).to eq(message: 'A suggestion is not applicable.',
status: :error)
end
end
context 'suggestion was already applied' do
it 'returns success status' do
result = subject.execute(suggestion)
expect(result[:status]).to eq(:success)
end
end
context 'note is outdated' do
before do
allow(diff_note).to receive(:active?) { false }
end
it 'returns error message' do
result = subject.execute(suggestion)
result = apply_service.new(user, suggestion).execute
expect(result).to eq(message: 'Suggestion is not appliable',
expect(result).to eq(message: 'A suggestion is not applicable.',
status: :error)
end
end
@ -548,9 +639,27 @@ describe Suggestions::ApplyService do
end
it 'returns error message' do
result = subject.execute(suggestion)
result = apply_service.new(user, suggestion).execute
expect(result).to eq(message: 'Suggestion is not appliable',
expect(result).to eq(message: 'A suggestion is not applicable.',
status: :error)
end
end
context 'lines of suggestions overlap' do
let(:suggestion) do
create_suggestion(
to_content: " raise RuntimeError, 'Explosion'\n # explosion?\n")
end
let(:overlapping_suggestion) do
create_suggestion(to_content: "I Overlap!")
end
it 'returns error message' do
result = apply_service.new(user, suggestion, overlapping_suggestion).execute
expect(result).to eq(message: 'Suggestions are not applicable as their lines cannot overlap.',
status: :error)
end
end

View File

@ -32,26 +32,28 @@ describe 'projects/edit' do
it 'displays all possible variables' do
render
expect(rendered).to have_content('%{project_path}')
expect(rendered).to have_content('%{project_name}')
expect(rendered).to have_content('%{file_path}')
expect(rendered).to have_content('%{branch_name}')
expect(rendered).to have_content('%{username}')
expect(rendered).to have_content('%{files_count}')
expect(rendered).to have_content('%{file_paths}')
expect(rendered).to have_content('%{project_name}')
expect(rendered).to have_content('%{project_path}')
expect(rendered).to have_content('%{user_full_name}')
expect(rendered).to have_content('%{username}')
expect(rendered).to have_content('%{suggestions_count}')
end
it 'displays a placeholder if none is set' do
render
expect(rendered).to have_field('project[suggestion_commit_message]', placeholder: 'Apply suggestion to %{file_path}')
expect(rendered).to have_field('project[suggestion_commit_message]', placeholder: "Apply %{suggestions_count} suggestion(s) to %{files_count} file(s)")
end
it 'displays the user entered value' do
project.update!(suggestion_commit_message: 'refactor: changed %{file_path}')
project.update!(suggestion_commit_message: 'refactor: changed %{file_paths}')
render
expect(rendered).to have_field('project[suggestion_commit_message]', with: 'refactor: changed %{file_path}')
expect(rendered).to have_field('project[suggestion_commit_message]', with: 'refactor: changed %{file_paths}')
end
end