Commit 0ddf25f1 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch 'invite-members-for-task' into 'master'

Invite members for task experiment

See merge request gitlab-org/gitlab!69299
parents 2e6bf2ba ca2b82b4
...@@ -1165,6 +1165,7 @@ Gitlab/NamespacedClass: ...@@ -1165,6 +1165,7 @@ Gitlab/NamespacedClass:
- 'app/models/members/group_member.rb' - 'app/models/members/group_member.rb'
- 'app/models/members/last_group_owner_assigner.rb' - 'app/models/members/last_group_owner_assigner.rb'
- 'app/models/members/project_member.rb' - 'app/models/members/project_member.rb'
- 'app/models/members/member_task.rb'
- 'app/models/members_preloader.rb' - 'app/models/members_preloader.rb'
- 'app/models/merge_request.rb' - 'app/models/merge_request.rb'
- 'app/models/merge_request_assignee.rb' - 'app/models/merge_request_assignee.rb'
......
<script> <script>
import { import {
GlAlert,
GlFormGroup, GlFormGroup,
GlModal, GlModal,
GlDropdown, GlDropdown,
...@@ -16,12 +17,14 @@ import Api from '~/api'; ...@@ -16,12 +17,14 @@ import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking'; import ExperimentTracking from '~/experimentation/experiment_tracking';
import { sanitize } from '~/lib/dompurify'; import { sanitize } from '~/lib/dompurify';
import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { getParameterValues } from '~/lib/utils/url_utility';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import { import {
INVITE_MEMBERS_IN_COMMENT, INVITE_MEMBERS_IN_COMMENT,
GROUP_FILTERS, GROUP_FILTERS,
USERS_FILTER_ALL, USERS_FILTER_ALL,
MEMBER_AREAS_OF_FOCUS, MEMBER_AREAS_OF_FOCUS,
INVITE_MEMBERS_FOR_TASK,
} from '../constants'; } from '../constants';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import { import {
...@@ -34,6 +37,7 @@ import MembersTokenSelect from './members_token_select.vue'; ...@@ -34,6 +37,7 @@ import MembersTokenSelect from './members_token_select.vue';
export default { export default {
name: 'InviteMembersModal', name: 'InviteMembersModal',
components: { components: {
GlAlert,
GlFormGroup, GlFormGroup,
GlDatepicker, GlDatepicker,
GlLink, GlLink,
...@@ -47,6 +51,7 @@ export default { ...@@ -47,6 +51,7 @@ export default {
MembersTokenSelect, MembersTokenSelect,
GroupSelect, GroupSelect,
}, },
inject: ['newProjectPath'],
props: { props: {
id: { id: {
type: String, type: String,
...@@ -100,6 +105,14 @@ export default { ...@@ -100,6 +105,14 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
tasksToBeDoneOptions: {
type: Array,
required: true,
},
projects: {
type: Array,
required: true,
},
}, },
data() { data() {
return { return {
...@@ -110,6 +123,8 @@ export default { ...@@ -110,6 +123,8 @@ export default {
newUsersToInvite: [], newUsersToInvite: [],
selectedDate: undefined, selectedDate: undefined,
selectedAreasOfFocus: [], selectedAreasOfFocus: [],
selectedTasksToBeDone: [],
selectedTaskProject: this.projects[0],
groupToBeSharedWith: {}, groupToBeSharedWith: {},
source: 'unknown', source: 'unknown',
invalidFeedbackMessage: '', invalidFeedbackMessage: '',
...@@ -156,7 +171,7 @@ export default { ...@@ -156,7 +171,7 @@ export default {
); );
}, },
areasOfFocusEnabled() { areasOfFocusEnabled() {
return this.areasOfFocusOptions.length !== 0; return !this.tasksToBeDoneEnabled && this.areasOfFocusOptions.length !== 0;
}, },
areasOfFocusForPost() { areasOfFocusForPost() {
if (this.selectedAreasOfFocus.length === 0 && this.areasOfFocusEnabled) { if (this.selectedAreasOfFocus.length === 0 && this.areasOfFocusEnabled) {
...@@ -172,12 +187,40 @@ export default { ...@@ -172,12 +187,40 @@ export default {
return this.$options.labels[this.inviteeType].placeHolder; return this.$options.labels[this.inviteeType].placeHolder;
}, },
tasksToBeDoneEnabled() {
return (
getParameterValues('open_modal')[0] === 'invite_members_for_task' &&
this.tasksToBeDoneOptions.length
);
},
showTasksToBeDone() {
return (
this.tasksToBeDoneEnabled &&
this.selectedAccessLevel >= INVITE_MEMBERS_FOR_TASK.minimum_access_level
);
},
showTaskProjects() {
return !this.isProject && this.selectedTasksToBeDone.length;
},
tasksToBeDoneForPost() {
return this.showTasksToBeDone ? this.selectedTasksToBeDone : [];
},
tasksProjectForPost() {
return this.showTasksToBeDone && this.selectedTasksToBeDone.length
? this.selectedTaskProject.id
: '';
},
}, },
mounted() { mounted() {
eventHub.$on('openModal', (options) => { eventHub.$on('openModal', (options) => {
this.openModal(options); this.openModal(options);
this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.view); this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.view);
}); });
if (this.tasksToBeDoneEnabled) {
this.openModal({ inviteeType: 'members', source: 'in_product_marketing_email' });
this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, INVITE_MEMBERS_FOR_TASK.view);
}
}, },
methods: { methods: {
partitionNewUsersToInvite() { partitionNewUsersToInvite() {
...@@ -219,6 +262,12 @@ export default { ...@@ -219,6 +262,12 @@ export default {
this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.submit); this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.submit);
}, },
trackinviteMembersForTask() {
const label = 'selected_tasks_to_be_done';
const property = this.selectedTasksToBeDone.join(',');
const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property });
tracking.event(INVITE_MEMBERS_FOR_TASK.submit);
},
resetFields() { resetFields() {
this.isLoading = false; this.isLoading = false;
this.selectedAccessLevel = this.defaultAccessLevel; this.selectedAccessLevel = this.defaultAccessLevel;
...@@ -227,10 +276,15 @@ export default { ...@@ -227,10 +276,15 @@ export default {
this.groupToBeSharedWith = {}; this.groupToBeSharedWith = {};
this.invalidFeedbackMessage = ''; this.invalidFeedbackMessage = '';
this.selectedAreasOfFocus = []; this.selectedAreasOfFocus = [];
this.selectedTasksToBeDone = [];
[this.selectedTaskProject] = this.projects;
}, },
changeSelectedItem(item) { changeSelectedItem(item) {
this.selectedAccessLevel = item; this.selectedAccessLevel = item;
}, },
changeSelectedTaskProject(project) {
this.selectedTaskProject = project;
},
submitShareWithGroup() { submitShareWithGroup() {
const apiShareWithGroup = this.isProject const apiShareWithGroup = this.isProject
? Api.projectShareWithGroup.bind(Api) ? Api.projectShareWithGroup.bind(Api)
...@@ -263,6 +317,7 @@ export default { ...@@ -263,6 +317,7 @@ export default {
promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById))); promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById)));
} }
this.trackInvite(); this.trackInvite();
this.trackinviteMembersForTask();
Promise.all(promises) Promise.all(promises)
.then(this.conditionallyShowToastSuccess) .then(this.conditionallyShowToastSuccess)
...@@ -275,6 +330,8 @@ export default { ...@@ -275,6 +330,8 @@ export default {
access_level: this.selectedAccessLevel, access_level: this.selectedAccessLevel,
invite_source: this.source, invite_source: this.source,
areas_of_focus: this.areasOfFocusForPost, areas_of_focus: this.areasOfFocusForPost,
tasks_to_be_done: this.tasksToBeDoneForPost,
tasks_project_id: this.tasksProjectForPost,
}; };
}, },
addByUserIdPostData(usersToAddById) { addByUserIdPostData(usersToAddById) {
...@@ -284,6 +341,8 @@ export default { ...@@ -284,6 +341,8 @@ export default {
access_level: this.selectedAccessLevel, access_level: this.selectedAccessLevel,
invite_source: this.source, invite_source: this.source,
areas_of_focus: this.areasOfFocusForPost, areas_of_focus: this.areasOfFocusForPost,
tasks_to_be_done: this.tasksToBeDoneForPost,
tasks_project_id: this.tasksProjectForPost,
}; };
}, },
shareWithGroupPostData(groupToBeSharedWith) { shareWithGroupPostData(groupToBeSharedWith) {
...@@ -337,6 +396,17 @@ export default { ...@@ -337,6 +396,17 @@ export default {
"InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} project.", "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} project.",
), ),
}, },
tasksToBeDone: {
title: s__(
'InviteMembersModal|Create issues for your new team member to work on (optional)',
),
noProjects: s__(
'InviteMembersModal|To assign issues to a new team member, you need a project for the issues. %{linkStart}Create a project to get started.%{linkEnd}',
),
},
tasksProject: {
title: s__('InviteMembersModal|Choose a project for the issues'),
},
}, },
group: { group: {
modalTitle: s__('InviteMembersModal|Invite a group'), modalTitle: s__('InviteMembersModal|Invite a group'),
...@@ -476,6 +546,54 @@ export default { ...@@ -476,6 +546,54 @@ export default {
data-testid="area-of-focus-checks" data-testid="area-of-focus-checks"
/> />
</div> </div>
<div v-if="showTasksToBeDone" data-testid="invite-members-modal-tasks-to-be-done">
<label class="gl-mt-5">
{{ $options.labels.members.tasksToBeDone.title }}
</label>
<template v-if="projects.length">
<gl-form-checkbox-group
v-model="selectedTasksToBeDone"
:options="tasksToBeDoneOptions"
data-testid="invite-members-modal-tasks"
/>
<template v-if="showTaskProjects">
<label class="gl-mt-5 gl-display-block">
{{ $options.labels.members.tasksProject.title }}
</label>
<gl-dropdown
class="gl-w-half gl-xs-w-full"
:text="selectedTaskProject.title"
data-testid="invite-members-modal-project-select"
>
<template v-for="project in projects">
<gl-dropdown-item
:key="project.id"
active-class="is-active"
is-check-item
:is-checked="project.id === selectedTaskProject.id"
@click="changeSelectedTaskProject(project)"
>
{{ project.title }}
</gl-dropdown-item>
</template>
</gl-dropdown>
</template>
</template>
<gl-alert
v-else-if="tasksToBeDoneEnabled"
variant="tip"
:dismissible="false"
data-testid="invite-members-modal-no-projects-alert"
>
<gl-sprintf :message="$options.labels.members.tasksToBeDone.noProjects">
<template #link="{ content }">
<gl-link :href="newProjectPath" target="_blank" class="gl-label-link">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</gl-alert>
</div>
</div> </div>
<template #modal-footer> <template #modal-footer>
......
...@@ -8,6 +8,12 @@ export const MEMBER_AREAS_OF_FOCUS = { ...@@ -8,6 +8,12 @@ export const MEMBER_AREAS_OF_FOCUS = {
view: 'view', view: 'view',
submit: 'submit', submit: 'submit',
}; };
export const INVITE_MEMBERS_FOR_TASK = {
minimum_access_level: 30,
name: 'invite_members_for_task',
view: 'modal_opened_from_email',
submit: 'submit',
};
export const GROUP_FILTERS = { export const GROUP_FILTERS = {
ALL: 'all', ALL: 'all',
......
...@@ -14,6 +14,9 @@ export default function initInviteMembersModal() { ...@@ -14,6 +14,9 @@ export default function initInviteMembersModal() {
return new Vue({ return new Vue({
el, el,
provide: {
newProjectPath: el.dataset.newProjectPath,
},
render: (createElement) => render: (createElement) =>
createElement(InviteMembersModal, { createElement(InviteMembersModal, {
props: { props: {
...@@ -24,6 +27,8 @@ export default function initInviteMembersModal() { ...@@ -24,6 +27,8 @@ export default function initInviteMembersModal() {
groupSelectFilter: el.dataset.groupsFilter, groupSelectFilter: el.dataset.groupsFilter,
groupSelectParentId: parseInt(el.dataset.parentId, 10), groupSelectParentId: parseInt(el.dataset.parentId, 10),
areasOfFocusOptions: JSON.parse(el.dataset.areasOfFocusOptions), areasOfFocusOptions: JSON.parse(el.dataset.areasOfFocusOptions),
tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'),
projects: JSON.parse(el.dataset.projects || '[]'),
noSelectionAreasOfFocus: JSON.parse(el.dataset.noSelectionAreasOfFocus), noSelectionAreasOfFocus: JSON.parse(el.dataset.noSelectionAreasOfFocus),
usersFilter: el.dataset.usersFilter, usersFilter: el.dataset.usersFilter,
filterId: parseInt(el.dataset.filterId, 10), filterId: parseInt(el.dataset.filterId, 10),
......
...@@ -143,6 +143,10 @@ table.content { ...@@ -143,6 +143,10 @@ table.content {
line-height: 1.4; line-height: 1.4;
padding: 15px 5px; padding: 15px 5px;
text-align: center; text-align: center;
ul.list-style-position-inside {
list-style-position: inside;
}
} }
td.mailer-align-left { td.mailer-align-left {
......
...@@ -92,6 +92,7 @@ class GroupsController < Groups::ApplicationController ...@@ -92,6 +92,7 @@ class GroupsController < Groups::ApplicationController
if @group.import_state&.in_progress? if @group.import_state&.in_progress?
redirect_to group_import_path(@group) redirect_to group_import_path(@group)
else else
publish_invite_members_for_task_experiment
render_show_html render_show_html
end end
end end
...@@ -379,6 +380,13 @@ class GroupsController < Groups::ApplicationController ...@@ -379,6 +380,13 @@ class GroupsController < Groups::ApplicationController
def captcha_required? def captcha_required?
captcha_enabled? && !params[:parent_id] captcha_enabled? && !params[:parent_id]
end end
def publish_invite_members_for_task_experiment
return unless params[:open_modal] == 'invite_members_for_task'
return unless current_user&.can?(:admin_group_member, @group)
experiment(:invite_members_for_task, namespace: @group).publish_to_client
end
end end
GroupsController.prepend_mod_with('GroupsController') GroupsController.prepend_mod_with('GroupsController')
...@@ -16,6 +16,8 @@ module Registrations ...@@ -16,6 +16,8 @@ module Registrations
result = ::Users::SignupService.new(current_user, update_params).execute result = ::Users::SignupService.new(current_user, update_params).execute
if result[:status] == :success if result[:status] == :success
return redirect_to issues_dashboard_path(assignee_username: current_user.username) if show_tasks_to_be_done?
return redirect_to experiment(:combined_registration, user: current_user).redirect_path(trial_params) if show_signup_onboarding? return redirect_to experiment(:combined_registration, user: current_user).redirect_path(trial_params) if show_signup_onboarding?
members = current_user.members members = current_user.members
...@@ -68,6 +70,12 @@ module Registrations ...@@ -68,6 +70,12 @@ module Registrations
false false
end end
def show_tasks_to_be_done?
return unless experiment(:invite_members_for_task).enabled?
MemberTask.for_members(current_user.members).exists?
end
def trial_params def trial_params
nil nil
end end
......
...@@ -42,6 +42,14 @@ module InviteMembersHelper ...@@ -42,6 +42,14 @@ module InviteMembersHelper
e.candidate { dataset.merge!(areas_of_focus_options: member_areas_of_focus_options.to_json, no_selection_areas_of_focus: ['no_selection']) } e.candidate { dataset.merge!(areas_of_focus_options: member_areas_of_focus_options.to_json, no_selection_areas_of_focus: ['no_selection']) }
end end
if show_invite_members_for_task?
dataset.merge!(
tasks_to_be_done_options: tasks_to_be_done_options.to_json,
projects: projects_for_source(source).to_json,
new_project_path: source.is_a?(Group) ? new_project_path(namespace_id: source.id) : ''
)
end
dataset dataset
end end
...@@ -71,4 +79,19 @@ module InviteMembersHelper ...@@ -71,4 +79,19 @@ module InviteMembersHelper
def users_filter_data(group) def users_filter_data(group)
{} {}
end end
def show_invite_members_for_task?
return unless current_user && experiment(:invite_members_for_task).enabled?
params[:open_modal] == 'invite_members_for_task'
end
def tasks_to_be_done_options
::MemberTask::TASKS.keys.map { |task| { value: task, text: localized_tasks_to_be_done_choices[task] } }
end
def projects_for_source(source)
projects = source.is_a?(Project) ? [source] : source.projects
projects.map { |project| { id: project.id, title: project.title } }
end
end end
...@@ -56,6 +56,14 @@ module MembersHelper ...@@ -56,6 +56,14 @@ module MembersHelper
end end
end end
def localized_tasks_to_be_done_choices
{
code: s_('TasksToBeDone|Create/import code into a project (repository)'),
ci: s_('TasksToBeDone|Set up CI/CD pipelines to build, test, deploy, and monitor code'),
issues: s_('TasksToBeDone|Create/import issues (tickets) to collaborate on ideas and plan work')
}.freeze
end
private private
def source_text(member) def source_text(member)
......
...@@ -317,13 +317,15 @@ class Group < Namespace ...@@ -317,13 +317,15 @@ class Group < Namespace
owners.include?(user) owners.include?(user)
end end
def add_users(users, access_level, current_user: nil, expires_at: nil) def add_users(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil)
Members::Groups::BulkCreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass Members::Groups::BulkCreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass
self, self,
users, users,
access_level, access_level,
current_user: current_user, current_user: current_user,
expires_at: expires_at expires_at: expires_at,
tasks_to_be_done: tasks_to_be_done,
tasks_project_id: tasks_project_id
) )
end end
......
...@@ -13,6 +13,7 @@ class Member < ApplicationRecord ...@@ -13,6 +13,7 @@ class Member < ApplicationRecord
include FromUnion include FromUnion
include UpdateHighestRole include UpdateHighestRole
include RestrictedSignup include RestrictedSignup
include Gitlab::Experiment::Dsl
AVATAR_SIZE = 40 AVATAR_SIZE = 40
ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10 ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10
...@@ -22,8 +23,10 @@ class Member < ApplicationRecord ...@@ -22,8 +23,10 @@ class Member < ApplicationRecord
belongs_to :created_by, class_name: "User" belongs_to :created_by, class_name: "User"
belongs_to :user belongs_to :user
belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
has_one :member_task
delegate :name, :username, :email, to: :user, prefix: true delegate :name, :username, :email, to: :user, prefix: true
delegate :tasks_to_be_done, to: :member_task, allow_nil: true
validates :expires_at, allow_blank: true, future_date: true validates :expires_at, allow_blank: true, future_date: true
validates :user, presence: true, unless: :invite? validates :user, presence: true, unless: :invite?
...@@ -413,6 +416,14 @@ class Member < ApplicationRecord ...@@ -413,6 +416,14 @@ class Member < ApplicationRecord
def after_accept_invite def after_accept_invite
post_create_hook post_create_hook
if experiment(:invite_members_for_task).enabled?
run_after_commit_or_now do
if member_task
TasksToBeDone::CreateWorker.perform_async(member_task.id, created_by_id, [user_id.to_i])
end
end
end
end end
def after_decline_invite def after_decline_invite
......
# frozen_string_literal: true
class MemberTask < ApplicationRecord
TASKS = {
code: 0,
ci: 1,
issues: 2
}.freeze
belongs_to :member
belongs_to :project
validates :member, :project, presence: true
validates :tasks, inclusion: { in: TASKS.values }
validate :tasks_uniqueness
validate :project_in_member_source
scope :for_members, -> (members) { joins(:member).where(member: members) }
def tasks_to_be_done
Array(self[:tasks]).map { |task| TASKS.key(task) }
end
def tasks_to_be_done=(tasks)
self[:tasks] = Array(tasks).map do |task|
TASKS[task.to_sym]
end.uniq
end
private
def tasks_uniqueness
errors.add(:tasks, 'are not unique') unless Array(tasks).length == Array(tasks).uniq.length
end
def project_in_member_source
if member.is_a?(GroupMember)
errors.add(:project, _('is not in the member group')) unless project.namespace == member.source
elsif member.is_a?(ProjectMember)
errors.add(:project, _('is not the member project')) unless project == member.source
end
end
end
...@@ -41,13 +41,15 @@ class ProjectTeam ...@@ -41,13 +41,15 @@ class ProjectTeam
member member
end end
def add_users(users, access_level, current_user: nil, expires_at: nil) def add_users(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil)
Members::Projects::BulkCreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass Members::Projects::BulkCreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass
project, project,
users, users,
access_level, access_level,
current_user: current_user, current_user: current_user,
expires_at: expires_at expires_at: expires_at,
tasks_to_be_done: tasks_to_be_done,
tasks_project_id: tasks_project_id
) )
end end
......
...@@ -6,7 +6,7 @@ module Members ...@@ -6,7 +6,7 @@ module Members
included do included do
class << self class << self
def add_users(source, users, access_level, current_user: nil, expires_at: nil) def add_users(source, users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil)
return [] unless users.present? return [] unless users.present?
emails, users, existing_members = parse_users_list(source, users) emails, users, existing_members = parse_users_list(source, users)
...@@ -18,7 +18,9 @@ module Members ...@@ -18,7 +18,9 @@ module Members
access_level, access_level,
existing_members: existing_members, existing_members: existing_members,
current_user: current_user, current_user: current_user,
expires_at: expires_at) expires_at: expires_at,
tasks_to_be_done: tasks_to_be_done,
tasks_project_id: tasks_project_id)
.execute .execute
end end
end end
......
...@@ -63,10 +63,14 @@ module Members ...@@ -63,10 +63,14 @@ module Members
invites, invites,
params[:access_level], params[:access_level],
expires_at: params[:expires_at], expires_at: params[:expires_at],
current_user: current_user current_user: current_user,
tasks_to_be_done: params[:tasks_to_be_done],
tasks_project_id: params[:tasks_project_id]
) )
members.each { |member| process_result(member) } members.each { |member| process_result(member) }
create_tasks_to_be_done
end end
def process_result(member) def process_result(member)
...@@ -112,6 +116,19 @@ module Members ...@@ -112,6 +116,19 @@ module Members
end end
end end
def create_tasks_to_be_done
return unless experiment(:invite_members_for_task).enabled?
return if params[:tasks_to_be_done].blank? || params[:tasks_project_id].blank?
valid_members = members.select { |member| member.valid? && member.member_task.valid? }
return unless valid_members.present?
# We can take the first `member_task` here, since all tasks will have the same attributes needed
# for the `TasksToBeDone::CreateWorker`, ie. `project` and `tasks_to_be_done`.
member_task = valid_members[0].member_task
TasksToBeDone::CreateWorker.perform_async(member_task.id, current_user.id, valid_members.map(&:user_id))
end
def areas_of_focus def areas_of_focus
params[:areas_of_focus] || [] params[:areas_of_focus] || []
end end
......
...@@ -4,6 +4,8 @@ module Members ...@@ -4,6 +4,8 @@ module Members
# This class serves as more of an app-wide way we add/create members # This class serves as more of an app-wide way we add/create members
# All roads to add members should take this path. # All roads to add members should take this path.
class CreatorService class CreatorService
include Gitlab::Experiment::Dsl
class << self class << self
def parsed_access_level(access_level) def parsed_access_level(access_level)
access_levels.fetch(access_level) { access_level.to_i } access_levels.fetch(access_level) { access_level.to_i }
...@@ -24,6 +26,7 @@ module Members ...@@ -24,6 +26,7 @@ module Members
def execute def execute
find_or_build_member find_or_build_member
update_member update_member
create_member_task
member member
end end
...@@ -61,6 +64,21 @@ module Members ...@@ -61,6 +64,21 @@ module Members
} }
end end
def create_member_task
return unless experiment(:invite_members_for_task).enabled?
return unless member.persisted?
return if member_task_attributes.value?(nil)
member.create_member_task(member_task_attributes)
end
def member_task_attributes
{
tasks_to_be_done: args[:tasks_to_be_done],
project_id: args[:tasks_project_id]
}
end
def approve_request def approve_request
::Members::ApproveAccessRequestService.new(current_user, ::Members::ApproveAccessRequestService.new(current_user,
access_level: access_level) access_level: access_level)
......
...@@ -39,6 +39,11 @@ module Members ...@@ -39,6 +39,11 @@ module Members
errors[invite_email(member)] = member.errors.full_messages.to_sentence errors[invite_email(member)] = member.errors.full_messages.to_sentence
end end
override :create_tasks_to_be_done
def create_tasks_to_be_done
# Only create task issues for existing users. Tasks for new users are created when they signup.
end
def invite_email(member) def invite_email(member)
member.invite_email || member.user.email member.invite_email || member.user.email
end end
......
# frozen_string_literal: true
module TasksToBeDone
class BaseService < ::IssuableBaseService
LABEL_PREFIX = 'tasks to be done'
def initialize(project:, current_user:, assignee_ids: [])
params = {
assignee_ids: assignee_ids,
title: title,
description: description,
add_labels: label_name
}
super(project: project, current_user: current_user, params: params)
end
def execute
if (issue = existing_task_issue)
update_service = Issues::UpdateService.new(project: project, current_user: current_user, params: { add_assignee_ids: params[:assignee_ids] })
update_service.execute(issue)
else
build_service = Issues::BuildService.new(project: project, current_user: current_user, params: params)
create(build_service.execute)
end
end
private
def existing_task_issue
IssuesFinder.new(
current_user,
project_id: project.id,
state: 'opened',
non_archived: true,
label_name: label_name
).execute.last
end
def title
raise NotImplementedError
end
def description
raise NotImplementedError
end
def label_suffix
raise NotImplementedError
end
def label_name
"#{LABEL_PREFIX}:#{label_suffix}"
end
end
end
# frozen_string_literal: true
module TasksToBeDone
class CreateCiTaskService < BaseService
protected
def title
'Set up CI/CD'
end
def description
<<~DESCRIPTION
GitLab CI/CD is a tool built into GitLab for software development through the [continuous methodologies](https://docs.gitlab.com/ee/ci/introduction/index.html#introduction-to-cicd-methodologies):
* Continuous Integration (CI)
* Continuous Delivery (CD)
* Continuous Deployment (CD)
Continuous Integration works by pushing small changes to your application’s codebase hosted in a Git repository, and, to every push, run a pipeline of scripts to build, test, and validate the code changes before merging them into the main branch.
Continuous Delivery and Deployment consist of a step further CI, deploying your application to production at every push to the default branch of the repository.
These methodologies allow you to catch bugs and errors early in the development cycle, ensuring that all the code deployed to production complies with the code standards you established for your app.
* :book: [Read the documentation](https://docs.gitlab.com/ee/ci/introduction/index.html)
* :clapper: [Watch a Demo](https://www.youtube.com/watch?v=1iXFbchozdY)
## Next steps
* [ ] To start we recommend reviewing the following documentation:
* [ ] [How GitLab CI/CD works.](https://docs.gitlab.com/ee/ci/introduction/index.html#how-gitlab-cicd-works)
* [ ] [Fundamental pipeline architectures.](https://docs.gitlab.com/ee/ci/pipelines/pipeline_architectures.html)
* [ ] [GitLab CI/CD basic workflow.](https://docs.gitlab.com/ee/ci/introduction/index.html#basic-cicd-workflow)
* [ ] [Step-by-step guide for writing .gitlab-ci.yml for the first time.](https://docs.gitlab.com/ee/user/project/pages/getting_started_part_four.html)
* [ ] When you're ready select **Projects** (in the top navigation bar) > **Your projects** > select the Project you've already created.
* [ ] Select **CI / CD** in the left navigation to start setting up CI / CD in your project.
DESCRIPTION
end
def label_suffix
'ci'
end
end
end
# frozen_string_literal: true
module TasksToBeDone
class CreateCodeTaskService < BaseService
protected
def title
'Create or import your code into your Project (Repository)'
end
def description
<<~DESCRIPTION
You've already created your Group and Project within GitLab; we'll quickly review this hierarchy below. Once you're within your project you can easily create or import repositories.
**With GitLab Groups, you can:**
* Create one or multiple Projects for hosting your codebase (repositories).
* Assemble related projects together.
* Grant members access to several projects at once.
Groups can also be nested in subgroups.
Read more about groups in our [documentation](https://docs.gitlab.com/ee/user/group/).
**Within GitLab Projects, you can**
* Use it as an issue tracker.
* Collaborate on code.
* Continuously build, test, and deploy your app with built-in GitLab CI/CD.
You can also import an existing repository by providing the Git URL.
* :book: [Read the documentation](https://docs.gitlab.com/ee/user/project/index.html).
## Next steps
Create or import your first repository into the project you created:
* [ ] Click **Projects** in the top navigation bar, then click **Your projects**.
* [ ] Select the Project that you created, then select **Repository**.
* [ ] Once on the Repository page you can select the **+** icon to add or import files.
* [ ] You can review our full documentation on creating [repositories](https://docs.gitlab.com/ee/user/project/repository/) in GitLab.
:tada: All done, you can close this issue!
DESCRIPTION
end
def label_suffix
'code'
end
end
end
# frozen_string_literal: true
module TasksToBeDone
class CreateIssuesTaskService < BaseService
protected
def title
'Create/import issues (tickets) to collaborate on ideas and plan work'
end
def description
<<~DESCRIPTION
Issues allow you and your team to discuss proposals before, and during, their implementation. They can be used for a variety of other purposes, customized to your needs and workflow.
Issues are always associated with a specific project. If you have multiple projects in a group, you can view all the issues at the group level. [You can review our full Issue documentation here.](https://docs.gitlab.com/ee/user/project/issues/)
If you have existing issues or equivalent tickets you can import them as long as they are formatted as a CSV file, [the import process is covered here](https://docs.gitlab.com/ee/user/project/issues/csv_import.html).
**Common use cases include:**
* Discussing the implementation of a new idea
* Tracking tasks and work status
* Accepting feature proposals, questions, support requests, or bug reports
* Elaborating on new code implementations
## Next steps
* [ ] Select **Projects** in the top navigation > **Your Projects** > select the Project you've already created.
* [ ] Once you've selected that project, you can select **Issues** in the left navigation, then click **New issue**.
* [ ] Fill in the title and description in the **New issue** page.
* [ ] Click on **Create issue**.
Pro tip: When you're in a group or project you can always utilize the **+** icon in the top navigation (located to the left of the search bar) to quickly create new issues.
That's it! You can close this issue.
DESCRIPTION
end
def label_suffix
'issues'
end
end
end
...@@ -210,6 +210,12 @@ ...@@ -210,6 +210,12 @@
%td{ style: "padding: 10px 20px 30px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 18px; line-height: 24px;" } %td{ style: "padding: 10px 20px 30px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 18px; line-height: 24px;" }
%p{ style: "margin: 0 0 50px 0;" } %p{ style: "margin: 0 0 50px 0;" }
= @message.feedback_thanks = @message.feedback_thanks
- if @message.invite_members?
%tr
%td{ align: "center", style: "padding: 0 20px 80px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" }
= @message.invite_text
%br
= @message.invite_link
%tr{ style: "background-color: #ffffff;" } %tr{ style: "background-color: #ffffff;" }
%td{ align: "center", style: "padding:75px 20px 25px;" } %td{ align: "center", style: "padding:75px 20px 25px;" }
= about_link('gitlab_logo.png', 80) = about_link('gitlab_logo.png', 80)
......
...@@ -21,6 +21,10 @@ ...@@ -21,6 +21,10 @@
<%= @message.feedback_thanks %> <%= @message.feedback_thanks %>
<% end %> <% end %>
<% if @message.invite_members? %>
<%= @message.invite_text %>
<%= @message.invite_link %>
<% end %>
......
...@@ -8,7 +8,11 @@ ...@@ -8,7 +8,11 @@
%td.text-content %td.text-content
%p %p
= _('You have been granted %{access_level} access to the %{source_link} %{source_type}.').html_safe % { access_level: access_level, source_link: source_link, source_type: source_type } = _('You have been granted %{access_level} access to the %{source_link} %{source_type}.').html_safe % { access_level: access_level, source_link: source_link, source_type: source_type }
- if member.tasks_to_be_done.present?
= s_("InviteEmail|You were assigned the following tasks:")
%ul.list-style-position-inside
- member.tasks_to_be_done.each do |task|
%li= localized_tasks_to_be_done_choices[task]
%p %p
- leave_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: leave_link } - leave_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: leave_link }
= _('If this was a mistake you can %{leave_link_start}leave the %{source_type}%{link_end}.').html_safe % { source_type: source_type, leave_link_start: leave_link_start, link_end: link_end } = _('If this was a mistake you can %{leave_link_start}leave the %{source_type}%{link_end}.').html_safe % { source_type: source_type, leave_link_start: leave_link_start, link_end: link_end }
...@@ -24,6 +24,11 @@ ...@@ -24,6 +24,11 @@
%p %p
- if member.created_by - if member.created_by
= html_escape(s_("InviteEmail|%{inviter} invited you to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders.merge({ inviter: (link_to inviter_name, user_url(member.created_by)).html_safe }) = html_escape(s_("InviteEmail|%{inviter} invited you to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders.merge({ inviter: (link_to inviter_name, user_url(member.created_by)).html_safe })
- if member.tasks_to_be_done.present?
= s_("InviteEmail|and has assigned you the following tasks:")
%ul.list-style-position-inside
- member.tasks_to_be_done.each do |task|
%li= localized_tasks_to_be_done_choices[task]
- else - else
= html_escape(s_("InviteEmail|You are invited to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders = html_escape(s_("InviteEmail|You are invited to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders
%p.invite-actions %p.invite-actions
......
...@@ -2808,6 +2808,15 @@ ...@@ -2808,6 +2808,15 @@
:weight: 1 :weight: 1
:idempotent: :idempotent:
:tags: [] :tags: []
- :name: tasks_to_be_done_create
:worker_name: TasksToBeDone::CreateWorker
:feature_category: :onboarding
:has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 1
:idempotent: true
:tags: []
- :name: update_external_pull_requests - :name: update_external_pull_requests
:worker_name: UpdateExternalPullRequestsWorker :worker_name: UpdateExternalPullRequestsWorker
:feature_category: :source_code_management :feature_category: :source_code_management
......
# frozen_string_literal: true
module TasksToBeDone
class CreateWorker
include ApplicationWorker
data_consistency :always
idempotent!
feature_category :onboarding
urgency :low
worker_resource_boundary :cpu
def perform(member_task_id, current_user_id, assignee_ids = [])
member_task = MemberTask.find(member_task_id)
current_user = User.find(current_user_id)
project = member_task.project
member_task.tasks_to_be_done.each do |task|
service_class(task)
.new(project: project, current_user: current_user, assignee_ids: assignee_ids)
.execute
end
end
private
def service_class(task)
"TasksToBeDone::Create#{task.to_s.camelize}TaskService".constantize
end
end
end
---
name: invite_members_for_task
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69299
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/339747
milestone: '14.5'
type: experiment
group: group::activation
default_enabled: false
...@@ -393,6 +393,8 @@ ...@@ -393,6 +393,8 @@
- 1 - 1
- - system_hook_push - - system_hook_push
- 1 - 1
- - tasks_to_be_done_create
- 1
- - todos_destroyer - - todos_destroyer
- 1 - 1
- - unassign_issuables - - unassign_issuables
......
# frozen_string_literal: true
class CreateMemberTasks < Gitlab::Database::Migration[1.0]
def change
create_table :member_tasks do |t|
t.references :member, index: true, null: false
t.references :project, index: true, null: false
t.timestamps_with_timezone null: false
t.integer :tasks, limit: 2, array: true, null: false, default: []
t.index [:member_id, :project_id], unique: true
end
end
end
# frozen_string_literal: true
class AddMemberIdForeignKeyToMemberTasks < Gitlab::Database::Migration[1.0]
disable_ddl_transaction!
def up
add_concurrent_foreign_key :member_tasks, :members, column: :member_id, on_delete: :cascade
end
def down
with_lock_retries do
remove_foreign_key :member_tasks, column: :member_id
end
end
end
# frozen_string_literal: true
class AddProjectIdForeignKeyToMemberTasks < Gitlab::Database::Migration[1.0]
disable_ddl_transaction!
def up
add_concurrent_foreign_key :member_tasks, :projects, column: :project_id, on_delete: :cascade
end
def down
with_lock_retries do
remove_foreign_key :member_tasks, column: :project_id
end
end
end
72358f01061f5296e21647d5da9bbb6a33e94055c9c9aded6088cfb9126564b2
\ No newline at end of file
f4fe6c4a2860dd35f767d98d5025326142cab7fc9c12b5efb1541e2604791691
\ No newline at end of file
59e5de7766dc55e820ec714fbb61b5db61a73959f1e877e66caf668f93d0d633
\ No newline at end of file
...@@ -15671,6 +15671,24 @@ CREATE SEQUENCE lists_id_seq ...@@ -15671,6 +15671,24 @@ CREATE SEQUENCE lists_id_seq
ALTER SEQUENCE lists_id_seq OWNED BY lists.id; ALTER SEQUENCE lists_id_seq OWNED BY lists.id;
CREATE TABLE member_tasks (
id bigint NOT NULL,
member_id bigint NOT NULL,
project_id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
tasks smallint[] DEFAULT '{}'::smallint[] NOT NULL
);
CREATE SEQUENCE member_tasks_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE member_tasks_id_seq OWNED BY member_tasks.id;
CREATE TABLE members ( CREATE TABLE members (
id integer NOT NULL, id integer NOT NULL,
access_level integer NOT NULL, access_level integer NOT NULL,
...@@ -21502,6 +21520,8 @@ ALTER TABLE ONLY lists ALTER COLUMN id SET DEFAULT nextval('lists_id_seq'::regcl ...@@ -21502,6 +21520,8 @@ ALTER TABLE ONLY lists ALTER COLUMN id SET DEFAULT nextval('lists_id_seq'::regcl
ALTER TABLE ONLY loose_foreign_keys_deleted_records ALTER COLUMN id SET DEFAULT nextval('loose_foreign_keys_deleted_records_id_seq'::regclass); ALTER TABLE ONLY loose_foreign_keys_deleted_records ALTER COLUMN id SET DEFAULT nextval('loose_foreign_keys_deleted_records_id_seq'::regclass);
ALTER TABLE ONLY member_tasks ALTER COLUMN id SET DEFAULT nextval('member_tasks_id_seq'::regclass);
ALTER TABLE ONLY members ALTER COLUMN id SET DEFAULT nextval('members_id_seq'::regclass); ALTER TABLE ONLY members ALTER COLUMN id SET DEFAULT nextval('members_id_seq'::regclass);
ALTER TABLE ONLY merge_request_assignees ALTER COLUMN id SET DEFAULT nextval('merge_request_assignees_id_seq'::regclass); ALTER TABLE ONLY merge_request_assignees ALTER COLUMN id SET DEFAULT nextval('merge_request_assignees_id_seq'::regclass);
...@@ -23204,6 +23224,9 @@ ALTER TABLE ONLY list_user_preferences ...@@ -23204,6 +23224,9 @@ ALTER TABLE ONLY list_user_preferences
ALTER TABLE ONLY lists ALTER TABLE ONLY lists
ADD CONSTRAINT lists_pkey PRIMARY KEY (id); ADD CONSTRAINT lists_pkey PRIMARY KEY (id);
ALTER TABLE ONLY member_tasks
ADD CONSTRAINT member_tasks_pkey PRIMARY KEY (id);
ALTER TABLE ONLY members ALTER TABLE ONLY members
ADD CONSTRAINT members_pkey PRIMARY KEY (id); ADD CONSTRAINT members_pkey PRIMARY KEY (id);
...@@ -25654,6 +25677,12 @@ CREATE INDEX index_lists_on_milestone_id ON lists USING btree (milestone_id); ...@@ -25654,6 +25677,12 @@ CREATE INDEX index_lists_on_milestone_id ON lists USING btree (milestone_id);
CREATE INDEX index_lists_on_user_id ON lists USING btree (user_id); CREATE INDEX index_lists_on_user_id ON lists USING btree (user_id);
CREATE INDEX index_member_tasks_on_member_id ON member_tasks USING btree (member_id);
CREATE UNIQUE INDEX index_member_tasks_on_member_id_and_project_id ON member_tasks USING btree (member_id, project_id);
CREATE INDEX index_member_tasks_on_project_id ON member_tasks USING btree (project_id);
CREATE INDEX index_members_on_access_level ON members USING btree (access_level); CREATE INDEX index_members_on_access_level ON members USING btree (access_level);
CREATE INDEX index_members_on_expires_at ON members USING btree (expires_at); CREATE INDEX index_members_on_expires_at ON members USING btree (expires_at);
...@@ -27588,6 +27617,9 @@ ALTER TABLE ONLY project_pages_metadata ...@@ -27588,6 +27617,9 @@ ALTER TABLE ONLY project_pages_metadata
ALTER TABLE ONLY group_deletion_schedules ALTER TABLE ONLY group_deletion_schedules
ADD CONSTRAINT fk_11e3ebfcdd FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ADD CONSTRAINT fk_11e3ebfcdd FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY member_tasks
ADD CONSTRAINT fk_12816d4bbb FOREIGN KEY (member_id) REFERENCES members(id) ON DELETE CASCADE;
ALTER TABLE ONLY vulnerabilities ALTER TABLE ONLY vulnerabilities
ADD CONSTRAINT fk_1302949740 FOREIGN KEY (last_edited_by_id) REFERENCES users(id) ON DELETE SET NULL; ADD CONSTRAINT fk_1302949740 FOREIGN KEY (last_edited_by_id) REFERENCES users(id) ON DELETE SET NULL;
...@@ -28068,6 +28100,9 @@ ALTER TABLE ONLY identities ...@@ -28068,6 +28100,9 @@ ALTER TABLE ONLY identities
ALTER TABLE ONLY boards ALTER TABLE ONLY boards
ADD CONSTRAINT fk_ab0a250ff6 FOREIGN KEY (iteration_cadence_id) REFERENCES iterations_cadences(id) ON DELETE CASCADE; ADD CONSTRAINT fk_ab0a250ff6 FOREIGN KEY (iteration_cadence_id) REFERENCES iterations_cadences(id) ON DELETE CASCADE;
ALTER TABLE ONLY member_tasks
ADD CONSTRAINT fk_ab636303dd FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY dep_ci_build_trace_sections ALTER TABLE ONLY dep_ci_build_trace_sections
ADD CONSTRAINT fk_ab7c104e26 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; ADD CONSTRAINT fk_ab7c104e26 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
...@@ -43,6 +43,8 @@ POST /projects/:id/invitations ...@@ -43,6 +43,8 @@ POST /projects/:id/invitations
| `expires_at` | string | no | A date string in the format YEAR-MONTH-DAY | | `expires_at` | string | no | A date string in the format YEAR-MONTH-DAY |
| `invite_source` | string | no | The source of the invitation that starts the member creation process. See [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/327120). | | `invite_source` | string | no | The source of the invitation that starts the member creation process. See [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/327120). |
| `areas_of_focus` | string | no | Areas the inviter wants the member to focus upon. | | `areas_of_focus` | string | no | Areas the inviter wants the member to focus upon. |
| `tasks_to_be_done` | array of strings | no | Tasks the inviter wants the member to focus on. The tasks are added as issues to a specified project. The possible values are: `ci`, `code` and `issues`. If specified, requires `tasks_project_id`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69299) in GitLab 14.5 [with a flag](../administration/feature_flags.md) named `invite_members_for_task`. Disabled by default. |
| `tasks_project_id` | integer | no | The project ID in which to create the task issues. If specified, requires `tasks_to_be_done`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69299) in GitLab 14.5 [with a flag](../administration/feature_flags.md) named `invite_members_for_task`. Disabled by default. |
```shell ```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \ curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \
......
...@@ -422,6 +422,8 @@ POST /projects/:id/members ...@@ -422,6 +422,8 @@ POST /projects/:id/members
| `expires_at` | string | no | A date string in the format `YEAR-MONTH-DAY` | | `expires_at` | string | no | A date string in the format `YEAR-MONTH-DAY` |
| `invite_source` | string | no | The source of the invitation that starts the member creation process. See [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/327120). | | `invite_source` | string | no | The source of the invitation that starts the member creation process. See [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/327120). |
| `areas_of_focus` | string | no | Areas the inviter wants the member to focus upon. | | `areas_of_focus` | string | no | Areas the inviter wants the member to focus upon. |
| `tasks_to_be_done` | array of strings | no | Tasks the inviter wants the member to focus on. The tasks are added as issues to a specified project. The possible values are: `ci`, `code` and `issues`. If specified, requires `tasks_project_id`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69299) in GitLab 14.5 [with a flag](../administration/feature_flags.md) named `invite_members_for_task`. Disabled by default. |
| `tasks_project_id` | integer | no | The project ID in which to create the task issues. If specified, requires `tasks_to_be_done`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69299) in GitLab 14.5 [with a flag](../administration/feature_flags.md) named `invite_members_for_task`. Disabled by default. |
```shell ```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \ curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \
......
...@@ -6,7 +6,7 @@ module EE ...@@ -6,7 +6,7 @@ module EE
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
override :add_users override :add_users
def add_users(users, access_level, current_user: nil, expires_at: nil) def add_users(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil)
return false if group_member_lock return false if group_member_lock
super super
......
...@@ -87,4 +87,38 @@ RSpec.describe Members::CreateService do ...@@ -87,4 +87,38 @@ RSpec.describe Members::CreateService do
expect(project.users).to include(*project_users) expect(project.users).to include(*project_users)
end end
end end
context 'when assigning tasks to be done' do
let(:params) do
{
user_ids: project_users.map(&:id).join(','),
access_level: Gitlab::Access::DEVELOPER,
tasks_to_be_done: %w(ci code),
tasks_project_id: project.id,
invite_source: '_invite_source_'
}
end
before do
stub_experiments(invite_members_for_task: true)
end
context 'when passing many user ids' do
it 'creates 2 task issues', :aggregate_failures, :sidekiq_inline do
expect(TasksToBeDone::CreateWorker)
.to receive(:perform_async)
.with(anything, user.id, array_including(*project_users.map(&:id)))
.once
.and_call_original
expect { subject }.to change { project.issues.reload.count }.by(2)
expect(project.issues).to all have_attributes(
project: project,
author: user,
assignees: project_users
)
end
end
end
end end
...@@ -25,6 +25,8 @@ module API ...@@ -25,6 +25,8 @@ module API
optional :expires_at, type: DateTime, desc: 'Date string in the format YEAR-MONTH-DAY' optional :expires_at, type: DateTime, desc: 'Date string in the format YEAR-MONTH-DAY'
optional :invite_source, type: String, desc: 'Source that triggered the member creation process', default: 'invitations-api' optional :invite_source, type: String, desc: 'Source that triggered the member creation process', default: 'invitations-api'
optional :areas_of_focus, type: Array[String], coerce_with: Validations::Types::CommaSeparatedToArray.coerce, desc: 'Areas the inviter wants the member to focus upon' optional :areas_of_focus, type: Array[String], coerce_with: Validations::Types::CommaSeparatedToArray.coerce, desc: 'Areas the inviter wants the member to focus upon'
optional :tasks_to_be_done, type: Array[String], coerce_with: Validations::Types::CommaSeparatedToArray.coerce, desc: 'Tasks the inviter wants the member to do'
optional :tasks_project_id, type: Integer, desc: 'The project ID in which to create the task issues'
end end
post ":id/invitations" do post ":id/invitations" do
params[:source] = find_source(source_type, params[:id]) params[:source] = find_source(source_type, params[:id])
......
...@@ -95,6 +95,8 @@ module API ...@@ -95,6 +95,8 @@ module API
optional :expires_at, type: DateTime, desc: 'Date string in the format YEAR-MONTH-DAY' optional :expires_at, type: DateTime, desc: 'Date string in the format YEAR-MONTH-DAY'
optional :invite_source, type: String, desc: 'Source that triggered the member creation process', default: 'members-api' optional :invite_source, type: String, desc: 'Source that triggered the member creation process', default: 'members-api'
optional :areas_of_focus, type: Array[String], coerce_with: Validations::Types::CommaSeparatedToArray.coerce, desc: 'Areas the inviter wants the member to focus upon' optional :areas_of_focus, type: Array[String], coerce_with: Validations::Types::CommaSeparatedToArray.coerce, desc: 'Areas the inviter wants the member to focus upon'
optional :tasks_to_be_done, type: Array[String], coerce_with: Validations::Types::CommaSeparatedToArray.coerce, desc: 'Tasks the inviter wants the member to do'
optional :tasks_project_id, type: Integer, desc: 'The project ID in which to create the task issues'
end end
post ":id/members" do post ":id/members" do
......
...@@ -36,6 +36,10 @@ module Gitlab ...@@ -36,6 +36,10 @@ module Gitlab
def progress def progress
super(track_name: 'Admin') super(track_name: 'Admin')
end end
def invite_members?
invite_members_for_task_experiment_enabled?
end
end end
end end
end end
......
...@@ -7,6 +7,7 @@ module Gitlab ...@@ -7,6 +7,7 @@ module Gitlab
class Base class Base
include Gitlab::Email::Message::InProductMarketing::Helper include Gitlab::Email::Message::InProductMarketing::Helper
include Gitlab::Routing include Gitlab::Routing
include Gitlab::Experiment::Dsl
attr_accessor :format attr_accessor :format
...@@ -56,6 +57,18 @@ module Gitlab ...@@ -56,6 +57,18 @@ module Gitlab
end end
end end
def invite_members?
false
end
def invite_text
s_('InProductMarketing|Do you have a teammate who would be perfect for this task?')
end
def invite_link
action_link(s_('InProductMarketing|Invite them to help out.'), group_url(group, open_modal: 'invite_members_for_task'))
end
def unsubscribe def unsubscribe
parts = Gitlab.com? ? unsubscribe_com : unsubscribe_self_managed(track, series) parts = Gitlab.com? ? unsubscribe_com : unsubscribe_self_managed(track, series)
...@@ -148,6 +161,16 @@ module Gitlab ...@@ -148,6 +161,16 @@ module Gitlab
link(s_('InProductMarketing|update your preferences'), preference_link) link(s_('InProductMarketing|update your preferences'), preference_link)
end end
def invite_members_for_task_experiment_enabled?
return unless user.can?(:admin_group_member, group)
experiment(:invite_members_for_task, namespace: group) do |e|
e.candidate { true }
e.record!
e.run
end
end
end end
end end
end end
......
...@@ -61,6 +61,10 @@ module Gitlab ...@@ -61,6 +61,10 @@ module Gitlab
][series] ][series]
end end
def invite_members?
invite_members_for_task_experiment_enabled?
end
private private
def project_link def project_link
......
...@@ -36,6 +36,15 @@ module Gitlab ...@@ -36,6 +36,15 @@ module Gitlab
"#{text} (#{link})" "#{text} (#{link})"
end end
end end
def action_link(text, link)
case format
when :html
ActionController::Base.helpers.link_to text, link, target: '_blank', rel: 'noopener noreferrer'
else
[text, link].join(' >> ')
end
end
end end
end end
end end
......
...@@ -65,6 +65,10 @@ module Gitlab ...@@ -65,6 +65,10 @@ module Gitlab
][series] ][series]
end end
def invite_members?
invite_members_for_task_experiment_enabled?
end
private private
def ci_link def ci_link
......
...@@ -17544,6 +17544,9 @@ msgstr "" ...@@ -17544,6 +17544,9 @@ msgstr ""
msgid "InProductMarketing|Do you have a minute?" msgid "InProductMarketing|Do you have a minute?"
msgstr "" msgstr ""
msgid "InProductMarketing|Do you have a teammate who would be perfect for this task?"
msgstr ""
msgid "InProductMarketing|Easy" msgid "InProductMarketing|Easy"
msgstr "" msgstr ""
...@@ -17658,6 +17661,9 @@ msgstr "" ...@@ -17658,6 +17661,9 @@ msgstr ""
msgid "InProductMarketing|Increase Operational Efficiencies" msgid "InProductMarketing|Increase Operational Efficiencies"
msgstr "" msgstr ""
msgid "InProductMarketing|Invite them to help out."
msgstr ""
msgid "InProductMarketing|Invite your colleagues and start shipping code faster." msgid "InProductMarketing|Invite your colleagues and start shipping code faster."
msgstr "" msgstr ""
...@@ -18692,6 +18698,12 @@ msgstr "" ...@@ -18692,6 +18698,12 @@ msgstr ""
msgid "InviteEmail|You have been invited to join the %{project_or_group_name} %{project_or_group} as a %{role}" msgid "InviteEmail|You have been invited to join the %{project_or_group_name} %{project_or_group} as a %{role}"
msgstr "" msgstr ""
msgid "InviteEmail|You were assigned the following tasks:"
msgstr ""
msgid "InviteEmail|and has assigned you the following tasks:"
msgstr ""
msgid "InviteMembersBanner|Collaborate with your team" msgid "InviteMembersBanner|Collaborate with your team"
msgstr "" msgstr ""
...@@ -18710,6 +18722,9 @@ msgstr "" ...@@ -18710,6 +18722,9 @@ msgstr ""
msgid "InviteMembersModal|Cancel" msgid "InviteMembersModal|Cancel"
msgstr "" msgstr ""
msgid "InviteMembersModal|Choose a project for the issues"
msgstr ""
msgid "InviteMembersModal|Close invite team members" msgid "InviteMembersModal|Close invite team members"
msgstr "" msgstr ""
...@@ -18725,6 +18740,9 @@ msgstr "" ...@@ -18725,6 +18740,9 @@ msgstr ""
msgid "InviteMembersModal|Contribute to the codebase" msgid "InviteMembersModal|Contribute to the codebase"
msgstr "" msgstr ""
msgid "InviteMembersModal|Create issues for your new team member to work on (optional)"
msgstr ""
msgid "InviteMembersModal|GitLab member or email address" msgid "InviteMembersModal|GitLab member or email address"
msgstr "" msgstr ""
...@@ -18758,6 +18776,9 @@ msgstr "" ...@@ -18758,6 +18776,9 @@ msgstr ""
msgid "InviteMembersModal|Something went wrong" msgid "InviteMembersModal|Something went wrong"
msgstr "" msgstr ""
msgid "InviteMembersModal|To assign issues to a new team member, you need a project for the issues. %{linkStart}Create a project to get started.%{linkEnd}"
msgstr ""
msgid "InviteMembersModal|What would you like new member(s) to focus on? (optional)" msgid "InviteMembersModal|What would you like new member(s) to focus on? (optional)"
msgstr "" msgstr ""
...@@ -33412,6 +33433,15 @@ msgstr "" ...@@ -33412,6 +33433,15 @@ msgstr ""
msgid "Task ID: %{elastic_task}" msgid "Task ID: %{elastic_task}"
msgstr "" msgstr ""
msgid "TasksToBeDone|Create/import code into a project (repository)"
msgstr ""
msgid "TasksToBeDone|Create/import issues (tickets) to collaborate on ideas and plan work"
msgstr ""
msgid "TasksToBeDone|Set up CI/CD pipelines to build, test, deploy, and monitor code"
msgstr ""
msgid "Team" msgid "Team"
msgstr "" msgstr ""
...@@ -40558,6 +40588,12 @@ msgstr "" ...@@ -40558,6 +40588,12 @@ msgstr ""
msgid "is not in the group enforcing Group Managed Account" msgid "is not in the group enforcing Group Managed Account"
msgstr "" msgstr ""
msgid "is not in the member group"
msgstr ""
msgid "is not the member project"
msgstr ""
msgid "is not valid. The iteration group has to match the iteration cadence group." msgid "is not valid. The iteration group has to match the iteration cadence group."
msgstr "" msgstr ""
......
...@@ -82,6 +82,16 @@ RSpec.describe GroupsController, factory_default: :keep do ...@@ -82,6 +82,16 @@ RSpec.describe GroupsController, factory_default: :keep do
expect(subject).to redirect_to group_import_path(group) expect(subject).to redirect_to group_import_path(group)
end end
end end
context 'publishing the invite_members_for_task experiment' do
it 'publishes the experiment data to the client' do
wrapped_experiment(experiment(:invite_members_for_task)) do |e|
expect(e).to receive(:publish_to_client)
end
get :show, params: { id: group.to_param, open_modal: 'invite_members_for_task' }, format: format
end
end
end end
describe 'GET #details' do describe 'GET #details' do
......
...@@ -97,6 +97,16 @@ RSpec.describe Registrations::WelcomeController do ...@@ -97,6 +97,16 @@ RSpec.describe Registrations::WelcomeController do
expect(subject).to redirect_to(dashboard_projects_path) expect(subject).to redirect_to(dashboard_projects_path)
end end
end end
context 'when tasks to be done are assigned' do
let!(:member1) { create(:group_member, user: user, tasks_to_be_done: %w(ci code)) }
before do
stub_experiments(invite_members_for_task: true)
end
it { is_expected.to redirect_to(issues_dashboard_path(assignee_username: user.username)) }
end
end end
end end
end end
......
...@@ -34,5 +34,18 @@ FactoryBot.define do ...@@ -34,5 +34,18 @@ FactoryBot.define do
access_level { GroupMember::MINIMAL_ACCESS } access_level { GroupMember::MINIMAL_ACCESS }
end end
transient do
tasks_to_be_done { [] }
end
after(:build) do |group_member, evaluator|
if evaluator.tasks_to_be_done.present?
build(:member_task,
member: group_member,
project: build(:project, namespace: group_member.source),
tasks_to_be_done: evaluator.tasks_to_be_done)
end
end
end end
end end
# frozen_string_literal: true
FactoryBot.define do
factory :member_task do
member { association(:group_member, :invited) }
project { association(:project, namespace: member.source) }
tasks_to_be_done { [:ci, :code] }
end
end
...@@ -23,5 +23,15 @@ FactoryBot.define do ...@@ -23,5 +23,15 @@ FactoryBot.define do
trait :blocked do trait :blocked do
after(:build) { |project_member, _| project_member.user.block! } after(:build) { |project_member, _| project_member.user.block! }
end end
transient do
tasks_to_be_done { [] }
end
after(:build) do |project_member, evaluator|
if evaluator.tasks_to_be_done.present?
build(:member_task, member: project_member, project: project_member.source, tasks_to_be_done: evaluator.tasks_to_be_done)
end
end
end end
end end
...@@ -75,6 +75,7 @@ RSpec.describe 'factories' do ...@@ -75,6 +75,7 @@ RSpec.describe 'factories' do
group_member group_member
import_state import_state
issue_customer_relations_contact issue_customer_relations_contact
member_task
milestone_release milestone_release
namespace namespace
project_broken_repo project_broken_repo
......
...@@ -16,16 +16,25 @@ import Api from '~/api'; ...@@ -16,16 +16,25 @@ import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking'; import ExperimentTracking from '~/experimentation/experiment_tracking';
import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
import { INVITE_MEMBERS_IN_COMMENT, MEMBER_AREAS_OF_FOCUS } from '~/invite_members/constants'; import {
INVITE_MEMBERS_IN_COMMENT,
MEMBER_AREAS_OF_FOCUS,
INVITE_MEMBERS_FOR_TASK,
} from '~/invite_members/constants';
import eventHub from '~/invite_members/event_hub'; import eventHub from '~/invite_members/event_hub';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
import { getParameterValues } from '~/lib/utils/url_utility';
import { apiPaths, membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses'; import { apiPaths, membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses';
let wrapper; let wrapper;
let mock; let mock;
jest.mock('~/experimentation/experiment_tracking'); jest.mock('~/experimentation/experiment_tracking');
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
getParameterValues: jest.fn(() => []),
}));
const id = '1'; const id = '1';
const name = 'test name'; const name = 'test name';
...@@ -40,6 +49,15 @@ const areasOfFocusOptions = [ ...@@ -40,6 +49,15 @@ const areasOfFocusOptions = [
{ text: 'area1', value: 'area1' }, { text: 'area1', value: 'area1' },
{ text: 'area2', value: 'area2' }, { text: 'area2', value: 'area2' },
]; ];
const tasksToBeDoneOptions = [
{ text: 'First task', value: 'first' },
{ text: 'Second task', value: 'second' },
];
const newProjectPath = 'projects/new';
const projects = [
{ text: 'First project', value: '1' },
{ text: 'Second project', value: '2' },
];
const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' }; const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' };
const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: '' }; const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: '' };
...@@ -59,6 +77,9 @@ const sharedGroup = { id: '981' }; ...@@ -59,6 +77,9 @@ const sharedGroup = { id: '981' };
const createComponent = (data = {}, props = {}) => { const createComponent = (data = {}, props = {}) => {
wrapper = shallowMountExtended(InviteMembersModal, { wrapper = shallowMountExtended(InviteMembersModal, {
provide: {
newProjectPath,
},
propsData: { propsData: {
id, id,
name, name,
...@@ -68,6 +89,8 @@ const createComponent = (data = {}, props = {}) => { ...@@ -68,6 +89,8 @@ const createComponent = (data = {}, props = {}) => {
areasOfFocusOptions, areasOfFocusOptions,
defaultAccessLevel, defaultAccessLevel,
noSelectionAreasOfFocus, noSelectionAreasOfFocus,
tasksToBeDoneOptions,
projects,
helpLink, helpLink,
...props, ...props,
}, },
...@@ -131,6 +154,10 @@ describe('InviteMembersModal', () => { ...@@ -131,6 +154,10 @@ describe('InviteMembersModal', () => {
const membersFormGroupDescription = () => findMembersFormGroup().props('description'); const membersFormGroupDescription = () => findMembersFormGroup().props('description');
const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect); const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect);
const findAreaofFocusCheckBoxGroup = () => wrapper.findComponent(GlFormCheckboxGroup); const findAreaofFocusCheckBoxGroup = () => wrapper.findComponent(GlFormCheckboxGroup);
const findTasksToBeDone = () => wrapper.findByTestId('invite-members-modal-tasks-to-be-done');
const findTasks = () => wrapper.findByTestId('invite-members-modal-tasks');
const findProjectSelect = () => wrapper.findByTestId('invite-members-modal-project-select');
const findNoProjectsAlert = () => wrapper.findByTestId('invite-members-modal-no-projects-alert');
describe('rendering the modal', () => { describe('rendering the modal', () => {
beforeEach(() => { beforeEach(() => {
...@@ -191,6 +218,127 @@ describe('InviteMembersModal', () => { ...@@ -191,6 +218,127 @@ describe('InviteMembersModal', () => {
}); });
}); });
describe('rendering the tasks to be done', () => {
const setupComponent = (
extraData = {},
props = {},
urlParameter = ['invite_members_for_task'],
) => {
const data = {
selectedAccessLevel: 30,
selectedTasksToBeDone: ['ci', 'code'],
...extraData,
};
getParameterValues.mockImplementation(() => urlParameter);
createComponent(data, props);
};
afterAll(() => {
getParameterValues.mockImplementation(() => []);
});
it('renders the tasks to be done', () => {
setupComponent();
expect(findTasksToBeDone().exists()).toBe(true);
});
describe('when the selected access level is lower than 30', () => {
it('does not render the tasks to be done', () => {
setupComponent({ selectedAccessLevel: 20 });
expect(findTasksToBeDone().exists()).toBe(false);
});
});
describe('when the url does not contain the parameter `open_modal=invite_members_for_task`', () => {
it('does not render the tasks to be done', () => {
setupComponent({}, {}, []);
expect(findTasksToBeDone().exists()).toBe(false);
});
});
describe('rendering the tasks', () => {
it('renders the tasks', () => {
setupComponent();
expect(findTasks().exists()).toBe(true);
});
it('does not render an alert', () => {
setupComponent();
expect(findNoProjectsAlert().exists()).toBe(false);
});
describe('when there are no projects passed in the data', () => {
it('does not render the tasks', () => {
setupComponent({}, { projects: [] });
expect(findTasks().exists()).toBe(false);
});
it('renders an alert with a link to the new projects path', () => {
setupComponent({}, { projects: [] });
expect(findNoProjectsAlert().exists()).toBe(true);
expect(findNoProjectsAlert().findComponent(GlLink).attributes('href')).toBe(
newProjectPath,
);
});
});
});
describe('rendering the project dropdown', () => {
it('renders the project select', () => {
setupComponent();
expect(findProjectSelect().exists()).toBe(true);
});
describe('when the modal is shown for a project', () => {
it('does not render the project select', () => {
setupComponent({}, { isProject: true });
expect(findProjectSelect().exists()).toBe(false);
});
});
describe('when no tasks are selected', () => {
it('does not render the project select', () => {
setupComponent({ selectedTasksToBeDone: [] });
expect(findProjectSelect().exists()).toBe(false);
});
});
});
describe('tracking events', () => {
it('tracks the view for invite_members_for_task', () => {
setupComponent();
expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name);
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(
INVITE_MEMBERS_FOR_TASK.view,
);
});
it('tracks the submit for invite_members_for_task', () => {
setupComponent();
clickInviteButton();
expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name, {
label: 'selected_tasks_to_be_done',
property: 'ci,code',
});
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(
INVITE_MEMBERS_FOR_TASK.submit,
);
});
});
});
describe('displaying the correct introText and form group description', () => { describe('displaying the correct introText and form group description', () => {
describe('when inviting to a project', () => { describe('when inviting to a project', () => {
describe('when inviting members', () => { describe('when inviting members', () => {
...@@ -267,6 +415,8 @@ describe('InviteMembersModal', () => { ...@@ -267,6 +415,8 @@ describe('InviteMembersModal', () => {
invite_source: inviteSource, invite_source: inviteSource,
format: 'json', format: 'json',
areas_of_focus: noSelectionAreasOfFocus, areas_of_focus: noSelectionAreasOfFocus,
tasks_to_be_done: [],
tasks_project_id: '',
}; };
describe('when member is added successfully', () => { describe('when member is added successfully', () => {
...@@ -448,6 +598,8 @@ describe('InviteMembersModal', () => { ...@@ -448,6 +598,8 @@ describe('InviteMembersModal', () => {
email: 'email@example.com', email: 'email@example.com',
invite_source: inviteSource, invite_source: inviteSource,
areas_of_focus: noSelectionAreasOfFocus, areas_of_focus: noSelectionAreasOfFocus,
tasks_to_be_done: [],
tasks_project_id: '',
format: 'json', format: 'json',
}; };
...@@ -576,6 +728,8 @@ describe('InviteMembersModal', () => { ...@@ -576,6 +728,8 @@ describe('InviteMembersModal', () => {
invite_source: inviteSource, invite_source: inviteSource,
areas_of_focus: noSelectionAreasOfFocus, areas_of_focus: noSelectionAreasOfFocus,
format: 'json', format: 'json',
tasks_to_be_done: [],
tasks_project_id: '',
}; };
const emailPostData = { ...postData, email: 'email@example.com' }; const emailPostData = { ...postData, email: 'email@example.com' };
......
...@@ -59,7 +59,84 @@ RSpec.describe InviteMembersHelper do ...@@ -59,7 +59,84 @@ RSpec.describe InviteMembersHelper do
no_selection_areas_of_focus: [] no_selection_areas_of_focus: []
} }
expect(helper.common_invite_modal_dataset(project)).to match(attributes) expect(helper.common_invite_modal_dataset(project)).to include(attributes)
end
end
context 'tasks_to_be_done' do
subject(:output) { helper.common_invite_modal_dataset(source) }
let_it_be(:source) { project }
before do
stub_experiments(invite_members_for_task: true)
end
context 'when not logged in' do
before do
allow(helper).to receive(:params).and_return({ open_modal: 'invite_members_for_task' })
end
it "doesn't have the tasks to be done attributes" do
expect(output[:tasks_to_be_done_options]).to be_nil
expect(output[:projects]).to be_nil
expect(output[:new_project_path]).to be_nil
end
end
context 'when logged in but the open_modal param is not present' do
before do
allow(helper).to receive(:current_user).and_return(developer)
end
it "doesn't have the tasks to be done attributes" do
expect(output[:tasks_to_be_done_options]).to be_nil
expect(output[:projects]).to be_nil
expect(output[:new_project_path]).to be_nil
end
end
context 'when logged in and the open_modal param is present' do
before do
allow(helper).to receive(:current_user).and_return(developer)
allow(helper).to receive(:params).and_return({ open_modal: 'invite_members_for_task' })
end
context 'for a group' do
let_it_be(:source) { create(:group, projects: [project]) }
it 'has the expected attributes', :aggregate_failures do
expect(output[:tasks_to_be_done_options]).to eq(
[
{ value: :code, text: 'Create/import code into a project (repository)' },
{ value: :ci, text: 'Set up CI/CD pipelines to build, test, deploy, and monitor code' },
{ value: :issues, text: 'Create/import issues (tickets) to collaborate on ideas and plan work' }
].to_json
)
expect(output[:projects]).to eq(
[{ id: project.id, title: project.title }].to_json
)
expect(output[:new_project_path]).to eq(
new_project_path(namespace_id: source.id)
)
end
end
context 'for a project' do
it 'has the expected attributes', :aggregate_failures do
expect(output[:tasks_to_be_done_options]).to eq(
[
{ value: :code, text: 'Create/import code into a project (repository)' },
{ value: :ci, text: 'Set up CI/CD pipelines to build, test, deploy, and monitor code' },
{ value: :issues, text: 'Create/import issues (tickets) to collaborate on ideas and plan work' }
].to_json
)
expect(output[:projects]).to eq(
[{ id: project.id, title: project.title }].to_json
)
expect(output[:new_project_path]).to eq('')
end
end
end end
end end
end end
......
...@@ -68,4 +68,10 @@ RSpec.describe MembersHelper do ...@@ -68,4 +68,10 @@ RSpec.describe MembersHelper do
it { expect(leave_confirmation_message(project)).to eq "Are you sure you want to leave the \"#{project.full_name}\" project?" } it { expect(leave_confirmation_message(project)).to eq "Are you sure you want to leave the \"#{project.full_name}\" project?" }
it { expect(leave_confirmation_message(group)).to eq "Are you sure you want to leave the \"#{group.name}\" group?" } it { expect(leave_confirmation_message(group)).to eq "Are you sure you want to leave the \"#{group.name}\" group?" }
end end
describe '#localized_tasks_to_be_done_choices' do
it 'has a translation for all `TASKS_TO_BE_DONE` keys' do
expect(localized_tasks_to_be_done_choices).to include(*MemberTask::TASKS.keys)
end
end
end end
...@@ -133,6 +133,7 @@ project_members: ...@@ -133,6 +133,7 @@ project_members:
- user - user
- source - source
- project - project
- member_task
merge_requests: merge_requests:
- status_check_responses - status_check_responses
- subscriptions - subscriptions
......
...@@ -47,22 +47,30 @@ RSpec.describe Emails::InProductMarketing do ...@@ -47,22 +47,30 @@ RSpec.describe Emails::InProductMarketing do
end end
where(:track, :series) do where(:track, :series) do
:create | 0 :create | 0
:create | 1 :create | 1
:create | 2 :create | 2
:verify | 0 :verify | 0
:verify | 1 :verify | 1
:verify | 2 :verify | 2
:trial | 0 :trial | 0
:trial | 1 :trial | 1
:trial | 2 :trial | 2
:team | 0 :team | 0
:team | 1 :team | 1
:team | 2 :team | 2
:experience | 0 :experience | 0
:team_short | 0
:trial_short | 0
:admin_verify | 0
end end
with_them do with_them do
before do
stub_experiments(invite_members_for_task: :candidate)
group.add_owner(user)
end
it 'has the correct subject and content' do it 'has the correct subject and content' do
message = Gitlab::Email::Message::InProductMarketing.for(track).new(group: group, user: user, series: series) message = Gitlab::Email::Message::InProductMarketing.for(track).new(group: group, user: user, series: series)
...@@ -76,6 +84,14 @@ RSpec.describe Emails::InProductMarketing do ...@@ -76,6 +84,14 @@ RSpec.describe Emails::InProductMarketing do
else else
is_expected.to have_body_text(CGI.unescapeHTML(message.cta_link)) is_expected.to have_body_text(CGI.unescapeHTML(message.cta_link))
end end
if track =~ /(create|verify)/
is_expected.to have_body_text(message.invite_text)
is_expected.to have_body_text(CGI.unescapeHTML(message.invite_link))
else
is_expected.not_to have_body_text(message.invite_text)
is_expected.not_to have_body_text(CGI.unescapeHTML(message.invite_link))
end
end end
end end
end end
......
...@@ -8,6 +8,7 @@ RSpec.describe Notify do ...@@ -8,6 +8,7 @@ RSpec.describe Notify do
include EmailSpec::Matchers include EmailSpec::Matchers
include EmailHelpers include EmailHelpers
include RepoHelpers include RepoHelpers
include MembersHelper
include_context 'gitlab email notification' include_context 'gitlab email notification'
...@@ -761,10 +762,21 @@ RSpec.describe Notify do ...@@ -761,10 +762,21 @@ RSpec.describe Notify do
is_expected.to have_body_text project_member.human_access is_expected.to have_body_text project_member.human_access
is_expected.to have_body_text 'leave the project' is_expected.to have_body_text 'leave the project'
is_expected.to have_body_text project_url(project, leave: 1) is_expected.to have_body_text project_url(project, leave: 1)
is_expected.not_to have_body_text 'You were assigned the following tasks:'
end
context 'with tasks to be done present' do
let(:project_member) { create(:project_member, project: project, user: user, tasks_to_be_done: [:ci, :code]) }
it 'contains the assigned tasks to be done' do
is_expected.to have_body_text 'You were assigned the following tasks:'
is_expected.to have_body_text localized_tasks_to_be_done_choices[:ci]
is_expected.to have_body_text localized_tasks_to_be_done_choices[:code]
end
end end
end end
def invite_to_project(project, inviter:, user: nil) def invite_to_project(project, inviter:, user: nil, tasks_to_be_done: [])
create( create(
:project_member, :project_member,
:developer, :developer,
...@@ -772,7 +784,8 @@ RSpec.describe Notify do ...@@ -772,7 +784,8 @@ RSpec.describe Notify do
invite_token: '1234', invite_token: '1234',
invite_email: 'toto@example.com', invite_email: 'toto@example.com',
user: user, user: user,
created_by: inviter created_by: inviter,
tasks_to_be_done: tasks_to_be_done
) )
end end
...@@ -804,6 +817,7 @@ RSpec.describe Notify do ...@@ -804,6 +817,7 @@ RSpec.describe Notify do
is_expected.to have_content("#{inviter.name} invited you to join the") is_expected.to have_content("#{inviter.name} invited you to join the")
is_expected.to have_content('Project details') is_expected.to have_content('Project details')
is_expected.to have_content("What's it about?") is_expected.to have_content("What's it about?")
is_expected.not_to have_body_text 'and has assigned you the following tasks:'
end end
end end
...@@ -890,6 +904,16 @@ RSpec.describe Notify do ...@@ -890,6 +904,16 @@ RSpec.describe Notify do
end end
end end
end end
context 'with tasks to be done present', :aggregate_failures do
let(:project_member) { invite_to_project(project, inviter: inviter, tasks_to_be_done: [:ci, :code]) }
it 'contains the assigned tasks to be done' do
is_expected.to have_body_text 'and has assigned you the following tasks:'
is_expected.to have_body_text localized_tasks_to_be_done_choices[:ci]
is_expected.to have_body_text localized_tasks_to_be_done_choices[:code]
end
end
end end
describe 'project invitation accepted' do describe 'project invitation accepted' do
...@@ -1398,7 +1422,7 @@ RSpec.describe Notify do ...@@ -1398,7 +1422,7 @@ RSpec.describe Notify do
end end
end end
def invite_to_group(group, inviter:, user: nil) def invite_to_group(group, inviter:, user: nil, tasks_to_be_done: [])
create( create(
:group_member, :group_member,
:developer, :developer,
...@@ -1406,7 +1430,8 @@ RSpec.describe Notify do ...@@ -1406,7 +1430,8 @@ RSpec.describe Notify do
invite_token: '1234', invite_token: '1234',
invite_email: 'toto@example.com', invite_email: 'toto@example.com',
user: user, user: user,
created_by: inviter created_by: inviter,
tasks_to_be_done: tasks_to_be_done
) )
end end
...@@ -1431,6 +1456,7 @@ RSpec.describe Notify do ...@@ -1431,6 +1456,7 @@ RSpec.describe Notify do
is_expected.to have_body_text group.name is_expected.to have_body_text group.name
is_expected.to have_body_text group_member.human_access.downcase is_expected.to have_body_text group_member.human_access.downcase
is_expected.to have_body_text group_member.invite_token is_expected.to have_body_text group_member.invite_token
is_expected.not_to have_body_text 'and has assigned you the following tasks:'
end end
end end
...@@ -1444,6 +1470,24 @@ RSpec.describe Notify do ...@@ -1444,6 +1470,24 @@ RSpec.describe Notify do
is_expected.to have_body_text group_member.invite_token is_expected.to have_body_text group_member.invite_token
end end
end end
context 'with tasks to be done present', :aggregate_failures do
let(:group_member) { invite_to_group(group, inviter: inviter, tasks_to_be_done: [:ci, :code]) }
it 'contains the assigned tasks to be done' do
is_expected.to have_body_text 'and has assigned you the following tasks:'
is_expected.to have_body_text localized_tasks_to_be_done_choices[:ci]
is_expected.to have_body_text localized_tasks_to_be_done_choices[:code]
end
context 'when there is no inviter' do
let(:inviter) { nil }
it 'does not contain the assigned tasks to be done' do
is_expected.not_to have_body_text 'and has assigned you the following tasks:'
end
end
end
end end
describe 'group invitation reminders' do describe 'group invitation reminders' do
......
...@@ -718,6 +718,22 @@ RSpec.describe Group do ...@@ -718,6 +718,22 @@ RSpec.describe Group do
expect(group.group_members.developers.map(&:user)).to include(user) expect(group.group_members.developers.map(&:user)).to include(user)
expect(group.group_members.guests.map(&:user)).not_to include(user) expect(group.group_members.guests.map(&:user)).not_to include(user)
end end
context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do
let!(:project) { create(:project, group: group) }
before do
stub_experiments(invite_members_for_task: true)
group.add_users([create(:user)], :developer, tasks_to_be_done: %w(ci code), tasks_project_id: project.id)
end
it 'creates a member_task with the correct attributes', :aggregate_failures do
member = group.group_members.last
expect(member.tasks_to_be_done).to match_array([:ci, :code])
expect(member.member_task.project).to eq(project)
end
end
end end
describe '#avatar_type' do describe '#avatar_type' do
......
...@@ -9,6 +9,7 @@ RSpec.describe Member do ...@@ -9,6 +9,7 @@ RSpec.describe Member do
describe 'Associations' do describe 'Associations' do
it { is_expected.to belong_to(:user) } it { is_expected.to belong_to(:user) }
it { is_expected.to have_one(:member_task) }
end end
describe 'Validation' do describe 'Validation' do
...@@ -678,6 +679,19 @@ RSpec.describe Member do ...@@ -678,6 +679,19 @@ RSpec.describe Member do
expect(member.invite_token).not_to be_nil expect(member.invite_token).not_to be_nil
expect_any_instance_of(Member).not_to receive(:after_accept_invite) expect_any_instance_of(Member).not_to receive(:after_accept_invite)
end end
it 'schedules a TasksToBeDone::CreateWorker task' do
stub_experiments(invite_members_for_task: true)
member_task = create(:member_task, member: member, project: member.project)
expect(TasksToBeDone::CreateWorker)
.to receive(:perform_async)
.with(member_task.id, member.created_by_id, [user.id])
.once
member.accept_invite!(user)
end
end end
describe '#decline_invite!' do describe '#decline_invite!' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe MemberTask do
describe 'Associations' do
it { is_expected.to belong_to(:member) }
it { is_expected.to belong_to(:project) }
end
describe 'Validations' do
it { is_expected.to validate_presence_of(:member) }
it { is_expected.to validate_presence_of(:project) }
it { is_expected.to validate_inclusion_of(:tasks).in_array(MemberTask::TASKS.values) }
describe 'unique tasks validation' do
subject do
build(:member_task, tasks: [0, 0])
end
it 'expects the task values to be unique' do
expect(subject).to be_invalid
expect(subject.errors[:tasks]).to include('are not unique')
end
end
describe 'project validations' do
let_it_be(:project) { create(:project) }
subject do
build(:member_task, member: member, project: project, tasks_to_be_done: [:ci, :code])
end
context 'when the member source is a group' do
let_it_be(:member) { create(:group_member) }
it "expects the project to be part of the member's group projects" do
expect(subject).to be_invalid
expect(subject.errors[:project]).to include('is not in the member group')
end
context "when the project is part of the member's group projects" do
let_it_be(:project) { create(:project, namespace: member.source) }
it { is_expected.to be_valid }
end
end
context 'when the member source is a project' do
let_it_be(:member) { create(:project_member) }
it "expects the project to be the member's project" do
expect(subject).to be_invalid
expect(subject.errors[:project]).to include('is not the member project')
end
context "when the project is the member's project" do
let_it_be(:project) { member.source }
it { is_expected.to be_valid }
end
end
end
end
describe '.for_members' do
it 'returns the member_tasks for multiple members' do
member1 = create(:group_member)
member_task1 = create(:member_task, member: member1)
create(:member_task)
expect(described_class.for_members([member1])).to match_array([member_task1])
end
end
describe '#tasks_to_be_done' do
subject { member_task.tasks_to_be_done }
let_it_be(:member_task) { build(:member_task) }
before do
member_task[:tasks] = [0, 1]
end
it 'returns an array of symbols for the corresponding integers' do
expect(subject).to match_array([:ci, :code])
end
end
describe '#tasks_to_be_done=' do
let_it_be(:member_task) { build(:member_task) }
context 'when passing valid values' do
subject { member_task[:tasks] }
before do
member_task.tasks_to_be_done = tasks
end
context 'when passing tasks as strings' do
let_it_be(:tasks) { %w(ci code) }
it 'sets an array of integers for the corresponding tasks' do
expect(subject).to match_array([0, 1])
end
end
context 'when passing a single task' do
let_it_be(:tasks) { :ci }
it 'sets an array of integers for the corresponding tasks' do
expect(subject).to match_array([1])
end
end
context 'when passing a task twice' do
let_it_be(:tasks) { %w(ci ci) }
it 'is set only once' do
expect(subject).to match_array([1])
end
end
end
end
end
...@@ -234,6 +234,20 @@ RSpec.describe ProjectTeam do ...@@ -234,6 +234,20 @@ RSpec.describe ProjectTeam do
expect(project.team.reporter?(user1)).to be(true) expect(project.team.reporter?(user1)).to be(true)
expect(project.team.reporter?(user2)).to be(true) expect(project.team.reporter?(user2)).to be(true)
end end
context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do
before do
stub_experiments(invite_members_for_task: true)
project.team.add_users([user1], :developer, tasks_to_be_done: %w(ci code), tasks_project_id: project.id)
end
it 'creates a member_task with the correct attributes', :aggregate_failures do
member = project.project_members.last
expect(member.tasks_to_be_done).to match_array([:ci, :code])
expect(member.member_task.project).to eq(project)
end
end
end end
describe '#add_user' do describe '#add_user' do
......
...@@ -166,6 +166,38 @@ RSpec.describe API::Invitations do ...@@ -166,6 +166,38 @@ RSpec.describe API::Invitations do
end end
end end
context 'with tasks_to_be_done and tasks_project_id in the params' do
before do
stub_experiments(invite_members_for_task: true)
end
let(:project_id) { source_type == 'project' ? source.id : create(:project, namespace: source).id }
context 'when there is 1 invitation' do
it 'creates a member_task with the tasks_to_be_done and the project' do
post invitations_url(source, maintainer),
params: { email: email, access_level: Member::DEVELOPER, tasks_to_be_done: %w(code ci), tasks_project_id: project_id }
member = source.members.find_by(invite_email: email)
expect(member.tasks_to_be_done).to match_array([:code, :ci])
expect(member.member_task.project_id).to eq(project_id)
end
end
context 'when there are multiple invitations' do
it 'creates a member_task with the tasks_to_be_done and the project' do
post invitations_url(source, maintainer),
params: { email: [email, email2].join(','), access_level: Member::DEVELOPER, tasks_to_be_done: %w(code ci), tasks_project_id: project_id }
members = source.members.where(invite_email: [email, email2])
members.each do |member|
expect(member.tasks_to_be_done).to match_array([:code, :ci])
expect(member.member_task.project_id).to eq(project_id)
end
end
end
end
context 'with invite_source considerations', :snowplow do context 'with invite_source considerations', :snowplow do
let(:params) { { email: email, access_level: Member::DEVELOPER } } let(:params) { { email: email, access_level: Member::DEVELOPER } }
......
...@@ -406,6 +406,38 @@ RSpec.describe API::Members do ...@@ -406,6 +406,38 @@ RSpec.describe API::Members do
end end
end end
context 'with tasks_to_be_done and tasks_project_id in the params' do
before do
stub_experiments(invite_members_for_task: true)
end
let(:project_id) { source_type == 'project' ? source.id : create(:project, namespace: source).id }
context 'when there is 1 user to add' do
it 'creates a member_task with the correct attributes' do
post api("/#{source_type.pluralize}/#{source.id}/members", maintainer),
params: { user_id: stranger.id, access_level: Member::DEVELOPER, tasks_to_be_done: %w(code ci), tasks_project_id: project_id }
member = source.members.find_by(user_id: stranger.id)
expect(member.tasks_to_be_done).to match_array([:code, :ci])
expect(member.member_task.project_id).to eq(project_id)
end
end
context 'when there are multiple users to add' do
it 'creates a member_task with the correct attributes' do
post api("/#{source_type.pluralize}/#{source.id}/members", maintainer),
params: { user_id: [developer.id, stranger.id].join(','), access_level: Member::DEVELOPER, tasks_to_be_done: %w(code ci), tasks_project_id: project_id }
members = source.members.where(user_id: [developer.id, stranger.id])
members.each do |member|
expect(member.tasks_to_be_done).to match_array([:code, :ci])
expect(member.member_task.project_id).to eq(project_id)
end
end
end
end
it "returns 409 if member already exists" do it "returns 409 if member already exists" do
post api("/#{source_type.pluralize}/#{source.id}/members", maintainer), post api("/#{source_type.pluralize}/#{source.id}/members", maintainer),
params: { user_id: maintainer.id, access_level: Member::MAINTAINER } params: { user_id: maintainer.id, access_level: Member::MAINTAINER }
......
...@@ -196,4 +196,110 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_ ...@@ -196,4 +196,110 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
end end
end end
end end
context 'when assigning tasks to be done' do
let(:additional_params) do
{ invite_source: '_invite_source_', tasks_to_be_done: %w(ci code), tasks_project_id: source.id }
end
before do
stub_experiments(invite_members_for_task: true)
end
it 'creates 2 task issues', :aggregate_failures do
expect(TasksToBeDone::CreateWorker)
.to receive(:perform_async)
.with(anything, user.id, [member.id])
.once
.and_call_original
expect { execute_service }.to change { source.issues.count }.by(2)
expect(source.issues).to all have_attributes(
project: source,
author: user,
assignees: array_including(member)
)
end
context 'when passing many user ids' do
before do
stub_licensed_features(multiple_issue_assignees: false)
end
let(:another_user) { create(:user) }
let(:user_ids) { [member.id, another_user.id].join(',') }
it 'still creates 2 task issues', :aggregate_failures do
expect(TasksToBeDone::CreateWorker)
.to receive(:perform_async)
.with(anything, user.id, array_including(member.id, another_user.id))
.once
.and_call_original
expect { execute_service }.to change { source.issues.count }.by(2)
expect(source.issues).to all have_attributes(
project: source,
author: user,
assignees: array_including(member)
)
end
end
context 'when a `tasks_project_id` is missing' do
let(:additional_params) do
{ invite_source: '_invite_source_', tasks_to_be_done: %w(ci code) }
end
it 'does not create task issues' do
expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async)
execute_service
end
end
context 'when `tasks_to_be_done` are missing' do
let(:additional_params) do
{ invite_source: '_invite_source_', tasks_project_id: source.id }
end
it 'does not create task issues' do
expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async)
execute_service
end
end
context 'when invalid `tasks_to_be_done` are passed' do
let(:additional_params) do
{ invite_source: '_invite_source_', tasks_project_id: source.id, tasks_to_be_done: %w(invalid_task) }
end
it 'does not create task issues' do
expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async)
execute_service
end
end
context 'when invalid `tasks_project_id` is passed' do
let(:another_project) { create(:project) }
let(:additional_params) do
{ invite_source: '_invite_source_', tasks_project_id: another_project.id, tasks_to_be_done: %w(ci code) }
end
it 'does not create task issues' do
expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async)
execute_service
end
end
context 'when a member was already invited' do
let(:user_ids) { create(:project_member, :invited, project: source).invite_email }
let(:additional_params) do
{ invite_source: '_invite_source_', tasks_project_id: source.id, tasks_to_be_done: %w(ci code) }
end
it 'does not create task issues' do
expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async)
execute_service
end
end
end
end end
...@@ -22,6 +22,11 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_ ...@@ -22,6 +22,11 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end end
it_behaves_like 'records an onboarding progress action', :user_added it_behaves_like 'records an onboarding progress action', :user_added
it 'does not create task issues' do
expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async)
expect { result }.not_to change { project.issues.count }
end
end end
context 'when email belongs to an existing user as a secondary email' do context 'when email belongs to an existing user as a secondary email' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe TasksToBeDone::BaseService do
let_it_be(:project) { create(:project) }
let_it_be(:current_user) { create(:user) }
let_it_be(:assignee_one) { create(:user) }
let_it_be(:assignee_two) { create(:user) }
let_it_be(:assignee_ids) { [assignee_one.id] }
let_it_be(:label) { create(:label, title: 'tasks to be done:ci', project: project) }
before do
project.add_maintainer(current_user)
project.add_developer(assignee_one)
project.add_developer(assignee_two)
end
subject(:service) do
TasksToBeDone::CreateCiTaskService.new(
project: project,
current_user: current_user,
assignee_ids: assignee_ids
)
end
context 'no existing task issue', :aggregate_failures do
it 'creates an issue' do
params = {
assignee_ids: assignee_ids,
title: 'Set up CI/CD',
description: anything,
add_labels: label.title
}
expect(Issues::BuildService)
.to receive(:new)
.with(project: project, current_user: current_user, params: params)
.and_call_original
expect { service.execute }.to change(Issue, :count).by(1)
expect(project.issues.last).to have_attributes(
author: current_user,
title: params[:title],
assignees: [assignee_one],
labels: [label]
)
end
end
context 'an open issue with the same label already exists', :aggregate_failures do
let_it_be(:assignee_ids) { [assignee_two.id] }
it 'assigns the user to the existing issue' do
issue = create(:labeled_issue, project: project, labels: [label], assignees: [assignee_one])
params = { add_assignee_ids: assignee_ids }
expect(Issues::UpdateService)
.to receive(:new)
.with(project: project, current_user: current_user, params: params)
.and_call_original
expect { service.execute }.not_to change(Issue, :count)
expect(issue.reload.assignees).to match_array([assignee_one, assignee_two])
end
end
end
...@@ -286,6 +286,7 @@ licenses: :gitlab_main ...@@ -286,6 +286,7 @@ licenses: :gitlab_main
lists: :gitlab_main lists: :gitlab_main
list_user_preferences: :gitlab_main list_user_preferences: :gitlab_main
loose_foreign_keys_deleted_records: :gitlab_main loose_foreign_keys_deleted_records: :gitlab_main
member_tasks: :gitlab_main
members: :gitlab_main members: :gitlab_main
merge_request_assignees: :gitlab_main merge_request_assignees: :gitlab_main
merge_request_blocks: :gitlab_main merge_request_blocks: :gitlab_main
......
...@@ -299,6 +299,22 @@ RSpec.shared_examples_for "member creation" do ...@@ -299,6 +299,22 @@ RSpec.shared_examples_for "member creation" do
end end
end end
end end
context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do
before do
stub_experiments(invite_members_for_task: true)
end
it 'creates a member_task with the correct attributes', :aggregate_failures do
task_project = source.is_a?(Group) ? create(:project, group: source) : source
described_class.new(source, user, :developer, tasks_to_be_done: %w(ci code), tasks_project_id: task_project.id).execute
member = source.members.last
expect(member.tasks_to_be_done).to match_array([:ci, :code])
expect(member.member_task.project).to eq(task_project)
end
end
end end
end end
...@@ -379,5 +395,20 @@ RSpec.shared_examples_for "bulk member creation" do ...@@ -379,5 +395,20 @@ RSpec.shared_examples_for "bulk member creation" do
expect(members).to all(be_persisted) expect(members).to all(be_persisted)
end end
end end
context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do
before do
stub_experiments(invite_members_for_task: true)
end
it 'creates a member_task with the correct attributes', :aggregate_failures do
task_project = source.is_a?(Group) ? create(:project, group: source) : source
members = described_class.add_users(source, [user1], :developer, tasks_to_be_done: %w(ci code), tasks_project_id: task_project.id)
member = members.last
expect(member.tasks_to_be_done).to match_array([:ci, :code])
expect(member.member_task.project).to eq(task_project)
end
end
end end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe TasksToBeDone::CreateWorker do
let_it_be(:member_task) { create(:member_task, tasks: MemberTask::TASKS.values) }
let_it_be(:current_user) { create(:user) }
let(:assignee_ids) { [1, 2] }
let(:job_args) { [member_task.id, current_user.id, assignee_ids] }
before do
member_task.project.group.add_owner(current_user)
end
describe '.perform' do
it 'executes the task services for all tasks to be done', :aggregate_failures do
MemberTask::TASKS.each_key do |task|
service_class = "TasksToBeDone::Create#{task.to_s.camelize}TaskService".constantize
expect(service_class)
.to receive(:new)
.with(project: member_task.project, current_user: current_user, assignee_ids: assignee_ids)
.and_call_original
end
expect { described_class.new.perform(*job_args) }.to change(Issue, :count).by(3)
end
end
include_examples 'an idempotent worker' do
it 'creates 3 task issues' do
expect { subject }.to change(Issue, :count).by(3)
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment