Commit ca2b82b4 authored by Alex Buijs's avatar Alex Buijs

Reviewer feedback implemented

Frontend:
- extract 'minimum_access_level' into constant
- prefix testid's with 'invite-members-modal'
- Use inject/provide instead of props for newProjectPath

Database:
- move 'tasks_to_be_done' to a separate table
- add a unique index for [member_id, project_id]
- search all member_tasks for a user when redirecting

Backend:
- removed unneeded 'with_indifferent_access'
- added model validations
- added idempotency spec
- added test for missing 'tasks_to_be_done'
- search by label instead of title when creating issues

Technical writer/UX:
- textual changes

Dangerbot:
- use 'match_array' instead of 'eq'
parent 56afab22
......@@ -1165,6 +1165,7 @@ Gitlab/NamespacedClass:
- 'app/models/members/group_member.rb'
- 'app/models/members/last_group_owner_assigner.rb'
- 'app/models/members/project_member.rb'
- 'app/models/members/member_task.rb'
- 'app/models/members_preloader.rb'
- 'app/models/merge_request.rb'
- 'app/models/merge_request_assignee.rb'
......
......@@ -51,6 +51,7 @@ export default {
MembersTokenSelect,
GroupSelect,
},
inject: ['newProjectPath'],
props: {
id: {
type: String,
......@@ -108,10 +109,6 @@ export default {
type: Array,
required: true,
},
newProjectPath: {
type: String,
required: true,
},
projects: {
type: Array,
required: true,
......@@ -191,10 +188,16 @@ export default {
return this.$options.labels[this.inviteeType].placeHolder;
},
tasksToBeDoneEnabled() {
return getParameterValues('open_modal')[0] === 'invite_members_for_task';
return (
getParameterValues('open_modal')[0] === 'invite_members_for_task' &&
this.tasksToBeDoneOptions.length
);
},
showTasksToBeDone() {
return this.tasksToBeDoneEnabled && this.selectedAccessLevel >= 30;
return (
this.tasksToBeDoneEnabled &&
this.selectedAccessLevel >= INVITE_MEMBERS_FOR_TASK.minimum_access_level
);
},
showTaskProjects() {
return !this.isProject && this.selectedTasksToBeDone.length;
......@@ -395,10 +398,10 @@ export default {
},
tasksToBeDone: {
title: s__(
'InviteMembersModal|Create an issue for your new team member to work on (optional)',
'InviteMembersModal|Create issues for your new team member to work on (optional)',
),
noProjects: s__(
'InviteMembersModal|To assign an issue to a new team member, you need a project for the issue. %{linkStart}Create a project to get started.%{linkEnd}',
'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: {
......@@ -543,7 +546,7 @@ export default {
data-testid="area-of-focus-checks"
/>
</div>
<div v-if="showTasksToBeDone" data-testid="tasks-to-be-done">
<div v-if="showTasksToBeDone" data-testid="invite-members-modal-tasks-to-be-done">
<label class="gl-mt-5">
{{ $options.labels.members.tasksToBeDone.title }}
</label>
......@@ -551,7 +554,7 @@ export default {
<gl-form-checkbox-group
v-model="selectedTasksToBeDone"
:options="tasksToBeDoneOptions"
data-testid="tasks"
data-testid="invite-members-modal-tasks"
/>
<template v-if="showTaskProjects">
<label class="gl-mt-5 gl-display-block">
......@@ -560,7 +563,7 @@ export default {
<gl-dropdown
class="gl-w-half gl-xs-w-full"
:text="selectedTaskProject.title"
data-testid="project-select"
data-testid="invite-members-modal-project-select"
>
<template v-for="project in projects">
<gl-dropdown-item
......@@ -576,7 +579,12 @@ export default {
</gl-dropdown>
</template>
</template>
<gl-alert v-else variant="tip" :dismissible="false" data-testid="no-projects-alert">
<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">
......
......@@ -9,6 +9,7 @@ export const MEMBER_AREAS_OF_FOCUS = {
submit: 'submit',
};
export const INVITE_MEMBERS_FOR_TASK = {
minimum_access_level: 30,
name: 'invite_members_for_task',
view: 'modal_opened_from_email',
submit: 'submit',
......
......@@ -14,6 +14,9 @@ export default function initInviteMembersModal() {
return new Vue({
el,
provide: {
newProjectPath: el.dataset.newProjectPath,
},
render: (createElement) =>
createElement(InviteMembersModal, {
props: {
......@@ -24,9 +27,8 @@ export default function initInviteMembersModal() {
groupSelectFilter: el.dataset.groupsFilter,
groupSelectParentId: parseInt(el.dataset.parentId, 10),
areasOfFocusOptions: JSON.parse(el.dataset.areasOfFocusOptions),
tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions),
projects: JSON.parse(el.dataset.projects),
newProjectPath: el.dataset.newProjectPath,
tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'),
projects: JSON.parse(el.dataset.projects || '[]'),
noSelectionAreasOfFocus: JSON.parse(el.dataset.noSelectionAreasOfFocus),
usersFilter: el.dataset.usersFilter,
filterId: parseInt(el.dataset.filterId, 10),
......
......@@ -71,7 +71,9 @@ module Registrations
end
def show_tasks_to_be_done?
current_user.members.last&.tasks_to_be_done.present?
return unless experiment(:invite_members_for_task).enabled?
MemberTask.for_members(current_user.members).exists?
end
def trial_params
......
......@@ -32,10 +32,7 @@ module InviteMembersHelper
dataset = {
id: source.id,
name: source.name,
default_access_level: Gitlab::Access::GUEST,
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) : ''
default_access_level: Gitlab::Access::GUEST
}
experiment(:member_areas_of_focus, user: current_user) do |e|
......@@ -45,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']) }
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
end
......@@ -75,8 +80,14 @@ module InviteMembersHelper
{}
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
::Member::TASKS_TO_BE_DONE.keys.map { |task| { value: task, text: localized_tasks_to_be_done_choices[task] } }
::MemberTask::TASKS.keys.map { |task| { value: task, text: localized_tasks_to_be_done_choices[task] } }
end
def projects_for_source(source)
......
......@@ -61,7 +61,7 @@ module MembersHelper
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')
}.with_indifferent_access.freeze
}.freeze
end
private
......
......@@ -13,23 +13,20 @@ class Member < ApplicationRecord
include FromUnion
include UpdateHighestRole
include RestrictedSignup
include Gitlab::Experiment::Dsl
AVATAR_SIZE = 40
ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10
TASKS_TO_BE_DONE = {
code: 0,
ci: 1,
issues: 2
}.freeze
attr_accessor :raw_invite_token
belongs_to :created_by, class_name: "User"
belongs_to :user
belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :tasks_project, class_name: 'Project'
has_one :member_task
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 :user, presence: true, unless: :invite?
......@@ -383,16 +380,6 @@ class Member < ApplicationRecord
created_by&.name
end
def tasks_to_be_done
Array(self[:tasks_to_be_done]).map { |task| TASKS_TO_BE_DONE.key(task) }
end
def tasks_to_be_done=(tasks)
self[:tasks_to_be_done] = Array(tasks).map do |task|
TASKS_TO_BE_DONE[task.to_sym] || raise(ArgumentError, "#{task} is not a valid value for tasks_to_be_done")
end.uniq
end
private
def send_invite
......@@ -430,8 +417,12 @@ class Member < ApplicationRecord
def after_accept_invite
post_create_hook
run_after_commit_or_now do
TasksToBeDone::CreateWorker.perform_async(tasks_project_id, created_by_id, [user_id.to_i], tasks_to_be_done)
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
......
# 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
......@@ -117,11 +117,16 @@ module Members
end
def create_tasks_to_be_done
# Only create task issues for existing users. Tasks for new users are created when they signup.
return if self.instance_of?(Members::InviteService)
return unless experiment(:invite_members_for_task).enabled?
return if params[:tasks_to_be_done].blank? || params[:tasks_project_id].blank?
TasksToBeDone::CreateWorker.perform_async(params[:tasks_project_id], current_user.id, invites.map(&:to_i), params[:tasks_to_be_done])
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
......
......@@ -4,6 +4,8 @@ module Members
# This class serves as more of an app-wide way we add/create members
# All roads to add members should take this path.
class CreatorService
include Gitlab::Experiment::Dsl
class << self
def parsed_access_level(access_level)
access_levels.fetch(access_level) { access_level.to_i }
......@@ -24,6 +26,7 @@ module Members
def execute
find_or_build_member
update_member
create_member_task
member
end
......@@ -57,9 +60,22 @@ module Members
{
created_by: member.created_by || current_user,
access_level: access_level,
expires_at: args[:expires_at],
expires_at: args[:expires_at]
}
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],
tasks_project_id: args[:tasks_project_id]
project_id: args[:tasks_project_id]
}
end
......
......@@ -39,6 +39,11 @@ module Members
errors[invite_email(member)] = member.errors.full_messages.to_sentence
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)
member.invite_email || member.user.email
end
......
......@@ -2,11 +2,14 @@
module TasksToBeDone
class BaseService < ::IssuableBaseService
def initialize(project:, current_user:, assignee_ids:)
LABEL_PREFIX = 'tasks to be done'
def initialize(project:, current_user:, assignee_ids: [])
params = {
assignee_ids: assignee_ids,
title: title,
description: description
description: description,
add_labels: label_name
}
super(project: project, current_user: current_user, params: params)
end
......@@ -24,7 +27,29 @@ module TasksToBeDone
private
def existing_task_issue
project.issues.opened.where(title: params[:title]).last # rubocop: disable CodeReuse/ActiveRecord
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
......@@ -36,5 +36,9 @@ module TasksToBeDone
* [ ] 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
......@@ -14,6 +14,7 @@ module TasksToBeDone
**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.
......@@ -23,7 +24,6 @@ module TasksToBeDone
**Within GitLab Projects, you can**
* Create one or multiple Projects for hosting your codebase (repositories).
* Use it as an issue tracker.
* Collaborate on code.
* Continuously build, test, and deploy your app with built-in GitLab CI/CD.
......@@ -44,5 +44,9 @@ module TasksToBeDone
:tada: All done, you can close this issue!
DESCRIPTION
end
def label_suffix
'code'
end
end
end
......@@ -35,5 +35,9 @@ module TasksToBeDone
That's it! You can close this issue.
DESCRIPTION
end
def label_suffix
'issues'
end
end
end
......@@ -5,19 +5,17 @@ module TasksToBeDone
include ApplicationWorker
data_consistency :always
sidekiq_options retry: 3
idempotent!
feature_category :onboarding
urgency :low
worker_resource_boundary :cpu
def perform(project_id, current_user_id, assignee_ids, tasks_to_be_done)
project = Project.find(project_id)
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
tasks_to_be_done.each do |task|
member_task.tasks_to_be_done.each do |task|
service_class(task)
.new(project: project, current_user: current_user, assignee_ids: assignee_ids)
.execute
......
......@@ -2,7 +2,7 @@
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.4'
milestone: '14.5'
type: experiment
group: group::activation
default_enabled: false
# frozen_string_literal: true
class AddTasksToBeDoneToMembers < Gitlab::Database::Migration[1.0]
def change
add_column :members, :tasks_to_be_done, :integer, array: true, null: true
add_column :members, :tasks_project_id, :bigint, null: true
end
end
# frozen_string_literal: true
class AddTaskProjectForeignKeyToMembers < Gitlab::Database::Migration[1.0]
disable_ddl_transaction!
INDEX_NAME = 'index_members_on_tasks_project_id'
def up
add_concurrent_index :members, :tasks_project_id, name: INDEX_NAME
add_concurrent_foreign_key :members, :projects, column: :tasks_project_id, on_delete: :nullify
end
def down
with_lock_retries do
remove_foreign_key_if_exists :members, column: :tasks_project_id
end
remove_concurrent_index_by_name :members, name: INDEX_NAME
end
end
# 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
3744a9e1da77adb40fb7c3f9e3b6116fe0ced1b9e601c3f44fbdc6a31c30c632
\ No newline at end of file
97d8d04db020bc7dbcecf27b69fa82df0667c7c8a0862ca69cdccbb99d7d0c9c
\ No newline at end of file
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
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 (
id integer NOT NULL,
access_level integer NOT NULL,
......@@ -15690,9 +15708,7 @@ CREATE TABLE members (
ldap boolean DEFAULT false NOT NULL,
override boolean DEFAULT false NOT NULL,
state smallint DEFAULT 0,
invite_email_success boolean DEFAULT true NOT NULL,
tasks_to_be_done integer[],
tasks_project_id bigint
invite_email_success boolean DEFAULT true NOT NULL
);
CREATE SEQUENCE members_id_seq
......@@ -21503,6 +21519,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 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 merge_request_assignees ALTER COLUMN id SET DEFAULT nextval('merge_request_assignees_id_seq'::regclass);
......@@ -23205,6 +23223,9 @@ ALTER TABLE ONLY list_user_preferences
ALTER TABLE ONLY lists
ADD CONSTRAINT lists_pkey PRIMARY KEY (id);
ALTER TABLE ONLY member_tasks
ADD CONSTRAINT member_tasks_pkey PRIMARY KEY (id);
ALTER TABLE ONLY members
ADD CONSTRAINT members_pkey PRIMARY KEY (id);
......@@ -25651,6 +25672,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_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_expires_at ON members USING btree (expires_at);
......@@ -25663,8 +25690,6 @@ CREATE INDEX index_members_on_requested_at ON members USING btree (requested_at)
CREATE INDEX index_members_on_source_id_and_source_type ON members USING btree (source_id, source_type);
CREATE INDEX index_members_on_tasks_project_id ON members USING btree (tasks_project_id);
CREATE INDEX index_members_on_user_id_and_access_level_requested_at_is_null ON members USING btree (user_id, access_level) WHERE (requested_at IS NULL);
CREATE INDEX index_members_on_user_id_created_at ON members USING btree (user_id, created_at) WHERE ((ldap = true) AND ((type)::text = 'GroupMember'::text) AND ((source_type)::text = 'Namespace'::text));
......@@ -27576,9 +27601,6 @@ ALTER TABLE ONLY notification_settings
ALTER TABLE ONLY lists
ADD CONSTRAINT fk_0d3f677137 FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE;
ALTER TABLE ONLY members
ADD CONSTRAINT fk_0d981b659b FOREIGN KEY (tasks_project_id) REFERENCES projects(id) ON DELETE SET NULL;
ALTER TABLE ONLY ci_unit_test_failures
ADD CONSTRAINT fk_0f09856e1f FOREIGN KEY (build_id) REFERENCES ci_builds(id) ON DELETE CASCADE;
......@@ -27588,6 +27610,9 @@ ALTER TABLE ONLY project_pages_metadata
ALTER TABLE ONLY group_deletion_schedules
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
ADD CONSTRAINT fk_1302949740 FOREIGN KEY (last_edited_by_id) REFERENCES users(id) ON DELETE SET NULL;
......@@ -28068,6 +28093,9 @@ ALTER TABLE ONLY identities
ALTER TABLE ONLY boards
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
ADD CONSTRAINT fk_ab7c104e26 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
......@@ -43,8 +43,8 @@ POST /projects/:id/invitations
| `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). |
| `areas_of_focus` | string | no | Areas the inviter wants the member to focus upon. |
| `tasks_to_be_done` | array of strings | no | Areas the inviter wants the member to focus upon. |
| `tasks_project_id` | integer | no | The project ID in which to create the task issues. |
| `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
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \
......
......@@ -422,8 +422,8 @@ POST /projects/:id/members
| `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). |
| `areas_of_focus` | string | no | Areas the inviter wants the member to focus upon. |
| `tasks_to_be_done` | array of strings | no | Areas the inviter wants the member to focus upon. |
| `tasks_project_id` | integer | no | The project ID in which to create the task issues. |
| `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
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \
......
......@@ -87,4 +87,38 @@ RSpec.describe Members::CreateService do
expect(project.users).to include(*project_users)
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
......@@ -36,6 +36,10 @@ module Gitlab
def progress
super(track_name: 'Admin')
end
def invite_members?
invite_members_for_task_experiment_enabled?
end
end
end
end
......
......@@ -58,13 +58,7 @@ module Gitlab
end
def invite_members?
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
false
end
def invite_text
......@@ -167,6 +161,16 @@ module Gitlab
link(s_('InProductMarketing|update your preferences'), preference_link)
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
......
......@@ -61,6 +61,10 @@ module Gitlab
][series]
end
def invite_members?
invite_members_for_task_experiment_enabled?
end
private
def project_link
......
......@@ -60,10 +60,6 @@ module Gitlab
s_('InProductMarketing|Feedback from users like you really improves our product. Thanks for your help!')
end
def invite_members?
false
end
private
def onboarding_progress
......
......@@ -77,10 +77,6 @@ module Gitlab
def progress
super(current: series + 2, total: 4)
end
def invite_members?
false
end
end
end
end
......
......@@ -40,10 +40,6 @@ module Gitlab
def logo_path
'mailers/in_product_marketing/team-0.png'
end
def invite_members?
false
end
end
end
end
......
......@@ -72,10 +72,6 @@ module Gitlab
def progress
super(current: series + 2, total: 4)
end
def invite_members?
false
end
end
end
end
......
......@@ -40,10 +40,6 @@ module Gitlab
def logo_path
'mailers/in_product_marketing/trial-0.png'
end
def invite_members?
false
end
end
end
end
......
......@@ -65,6 +65,10 @@ module Gitlab
][series]
end
def invite_members?
invite_members_for_task_experiment_enabled?
end
private
def ci_link
......
......@@ -18729,7 +18729,7 @@ msgstr ""
msgid "InviteMembersModal|Contribute to the codebase"
msgstr ""
msgid "InviteMembersModal|Create an issue for your new team member to work on (optional)"
msgid "InviteMembersModal|Create issues for your new team member to work on (optional)"
msgstr ""
msgid "InviteMembersModal|GitLab member or email address"
......@@ -18765,7 +18765,7 @@ msgstr ""
msgid "InviteMembersModal|Something went wrong"
msgstr ""
msgid "InviteMembersModal|To assign an issue to a new team member, you need a project for the issue. %{linkStart}Create a project to get started.%{linkEnd}"
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)"
......@@ -40589,6 +40589,12 @@ msgstr ""
msgid "is not in the group enforcing Group Managed Account"
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."
msgstr ""
......
......@@ -101,6 +101,10 @@ RSpec.describe Registrations::WelcomeController do
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
......
......@@ -34,5 +34,18 @@ FactoryBot.define do
access_level { GroupMember::MINIMAL_ACCESS }
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
# 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
trait :blocked do
after(:build) { |project_member, _| project_member.user.block! }
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
......@@ -75,6 +75,7 @@ RSpec.describe 'factories' do
group_member
import_state
issue_customer_relations_contact
member_task
milestone_release
namespace
project_broken_repo
......
......@@ -77,6 +77,9 @@ const sharedGroup = { id: '981' };
const createComponent = (data = {}, props = {}) => {
wrapper = shallowMountExtended(InviteMembersModal, {
provide: {
newProjectPath,
},
propsData: {
id,
name,
......@@ -87,7 +90,6 @@ const createComponent = (data = {}, props = {}) => {
defaultAccessLevel,
noSelectionAreasOfFocus,
tasksToBeDoneOptions,
newProjectPath,
projects,
helpLink,
...props,
......@@ -152,10 +154,10 @@ describe('InviteMembersModal', () => {
const membersFormGroupDescription = () => findMembersFormGroup().props('description');
const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect);
const findAreaofFocusCheckBoxGroup = () => wrapper.findComponent(GlFormCheckboxGroup);
const findTasksToBeDone = () => wrapper.findByTestId('tasks-to-be-done');
const findTasks = () => wrapper.findByTestId('tasks');
const findProjectSelect = () => wrapper.findByTestId('project-select');
const findNoProjectsAlert = () => wrapper.findByTestId('no-projects-alert');
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', () => {
beforeEach(() => {
......
......@@ -66,41 +66,76 @@ RSpec.describe InviteMembersHelper do
context 'tasks_to_be_done' do
subject(:output) { helper.common_invite_modal_dataset(source) }
context 'for a group' do
let(: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)
)
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 'for a project' do
let(:source) { 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('')
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
......
......@@ -71,7 +71,7 @@ RSpec.describe MembersHelper do
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(*Member::TASKS_TO_BE_DONE.keys)
expect(localized_tasks_to_be_done_choices).to include(*MemberTask::TASKS.keys)
end
end
end
......@@ -133,6 +133,7 @@ project_members:
- user
- source
- project
- member_task
merge_requests:
- status_check_responses
- subscriptions
......
......@@ -723,14 +723,15 @@ RSpec.describe Group do
let!(:project) { create(:project, group: group) }
before do
group.add_users([user], :developer, tasks_to_be_done: %w(ci code), tasks_project_id: project.id)
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 'updates the attributes', :aggregate_failures do
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.tasks_project).to eq(project)
expect(member.member_task.project).to eq(project)
end
end
end
......
......@@ -9,7 +9,7 @@ RSpec.describe Member do
describe 'Associations' do
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:tasks_project).class_name('Project') }
it { is_expected.to have_one(:member_task) }
end
describe 'Validation' do
......@@ -681,12 +681,13 @@ RSpec.describe Member do
end
it 'schedules a TasksToBeDone::CreateWorker task' do
member.tasks_to_be_done = %w(ci code)
member.tasks_project = member.project
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.project.id, member.created_by_id, [user.id], [:ci, :code])
.with(member_task.id, member.created_by_id, [user.id])
.once
member.accept_invite!(user)
......@@ -798,66 +799,6 @@ RSpec.describe Member do
end
end
describe '#tasks_to_be_done' do
subject { member.tasks_to_be_done }
let(:member) { Member.new }
before do
member[:tasks_to_be_done] = [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(:member) { Member.new }
context 'when passing valid values' do
subject { member[:tasks_to_be_done] }
before do
member.tasks_to_be_done = tasks
end
context 'when passing tasks as strings' do
let(: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(: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(:tasks) { %w(ci ci) }
it 'is set only once' do
expect(subject).to match_array([1])
end
end
end
context 'when passing an invalid value' do
it 'raises an error' do
expect do
member.tasks_to_be_done = 'invalid_task'
end.to raise_error(
ArgumentError, 'invalid_task is not a valid value for tasks_to_be_done'
)
end
end
end
describe 'destroying a record', :delete, :sidekiq_inline do
it "refreshes user's authorized projects" do
project = create(:project, :private)
......
# 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
......@@ -237,14 +237,15 @@ RSpec.describe ProjectTeam do
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 'updates the attributes', :aggregate_failures do
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.tasks_project).to eq(project)
expect(member.member_task.project).to eq(project)
end
end
end
......
......@@ -167,26 +167,32 @@ RSpec.describe API::Invitations do
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 'saves the tasks_to_be_done and the tasks_projects_id' 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 }
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.tasks_project_id).to eq(project.id)
expect(member.member_task.project_id).to eq(project_id)
end
end
context 'when there are multiple invitations' do
it 'saves the tasks_to_be_done and the tasks_projects_id' 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 }
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.tasks_project_id).to eq(project.id)
expect(member.member_task.project_id).to eq(project_id)
end
end
end
......
......@@ -407,26 +407,32 @@ RSpec.describe API::Members do
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 'saves the tasks_to_be_done and the tasks_projects_id' 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 }
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.tasks_project_id).to eq(project.id)
expect(member.member_task.project_id).to eq(project_id)
end
end
context 'when there are multiple users to add' do
it 'saves the tasks_to_be_done and the tasks_projects_id' 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 }
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.tasks_project_id).to eq(project.id)
expect(member.member_task.project_id).to eq(project_id)
end
end
end
......
......@@ -202,38 +202,46 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
{ 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(source.id, user.id, [member.id], %w(ci code))
.with(anything, user.id, [member.id])
.once
.and_call_original
expect { execute_service }.to change { source.issues.count }.by(2)
source.issues.each do |issue|
expect(issue.project).to eq(source)
expect(issue.author).to eq(user)
expect(issue.assignees).to match_array([member])
end
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(source.id, user.id, [member.id, another_user.id], %w(ci code))
.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)
source.issues.each do |issue|
expect(issue.project).to eq(source)
expect(issue.author).to eq(user)
expect(issue.assignees).to match_array([member, another_user])
end
expect(source.issues).to all have_attributes(
project: source,
author: user,
assignees: array_including(member)
)
end
end
......@@ -242,8 +250,55 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
{ invite_source: '_invite_source_', tasks_to_be_done: %w(ci code) }
end
it 'still creates 2 task issues', :aggregate_failures do
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
......
......@@ -8,6 +8,7 @@ RSpec.describe TasksToBeDone::BaseService do
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)
......@@ -28,7 +29,8 @@ RSpec.describe TasksToBeDone::BaseService do
params = {
assignee_ids: assignee_ids,
title: 'Set up CI/CD',
description: anything
description: anything,
add_labels: label.title
}
expect(Issues::BuildService)
......@@ -38,18 +40,20 @@ RSpec.describe TasksToBeDone::BaseService do
expect { service.execute }.to change(Issue, :count).by(1)
issue = project.issues.last
expect(issue.author).to eq(current_user)
expect(issue.title).to eq('Set up CI/CD')
expect(issue.assignees).to eq([assignee_one])
expect(project.issues.last).to have_attributes(
author: current_user,
title: params[:title],
assignees: [assignee_one],
labels: [label]
)
end
end
context 'an issue with the same title already exists', :aggregate_failures do
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(:issue, project: project, author: current_user, title: 'Set up CI/CD', assignees: [assignee_one])
issue = create(:labeled_issue, project: project, labels: [label], assignees: [assignee_one])
params = { add_assignee_ids: assignee_ids }
expect(Issues::UpdateService)
......
......@@ -286,6 +286,7 @@ licenses: :gitlab_main
lists: :gitlab_main
list_user_preferences: :gitlab_main
loose_foreign_keys_deleted_records: :gitlab_main
member_tasks: :gitlab_main
members: :gitlab_main
merge_request_assignees: :gitlab_main
merge_request_blocks: :gitlab_main
......
......@@ -301,14 +301,18 @@ RSpec.shared_examples_for "member creation" do
end
context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do
it 'updates the attributes', :aggregate_failures 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.tasks_project).to eq(task_project)
expect(member.member_task.project).to eq(task_project)
end
end
end
......@@ -393,13 +397,17 @@ RSpec.shared_examples_for "bulk member creation" do
end
context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do
it 'updates the attributes', :aggregate_failures 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.tasks_project).to eq(task_project)
expect(member.member_task.project).to eq(task_project)
end
end
end
......
......@@ -3,28 +3,34 @@
require 'spec_helper'
RSpec.describe TasksToBeDone::CreateWorker do
subject(:worker) { described_class.new }
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
let(:project) { create(:project) }
let(:current_user) { create(:user) }
let(:assignee_ids) { [1, 2] }
it 'executes the task services for all available tasks to be done', :aggregate_failures do
expect(TasksToBeDone::CreateCodeTaskService)
.to receive(:new)
.with(project: project, current_user: current_user, assignee_ids: assignee_ids)
.and_call_original
expect(TasksToBeDone::CreateCiTaskService)
.to receive(:new)
.with(project: project, current_user: current_user, assignee_ids: assignee_ids)
.and_call_original
expect(TasksToBeDone::CreateIssuesTaskService)
.to receive(:new)
.with(project: project, current_user: current_user, assignee_ids: assignee_ids)
.and_call_original
worker.perform(project.id, current_user.id, assignee_ids, Member::TASKS_TO_BE_DONE.keys)
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