summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/finders/pending_todos_finder.rb64
-rw-r--r--app/finders/todos_finder.rb93
-rw-r--r--app/finders/users_with_pending_todos_finder.rb16
-rw-r--r--app/models/project.rb8
-rw-r--r--app/models/todo.rb43
-rw-r--r--app/models/user.rb5
-rw-r--r--app/services/todo_service.rb26
-rw-r--r--spec/finders/pending_todos_finder_spec.rb63
-rw-r--r--spec/finders/todos_finder_spec.rb17
-rw-r--r--spec/finders/users_with_pending_todos_finder_spec.rb19
-rw-r--r--spec/models/project_spec.rb23
-rw-r--r--spec/models/todo_spec.rb125
12 files changed, 429 insertions, 73 deletions
diff --git a/app/finders/pending_todos_finder.rb b/app/finders/pending_todos_finder.rb
new file mode 100644
index 00000000000..c21d90c9182
--- /dev/null
+++ b/app/finders/pending_todos_finder.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+# Finder for retrieving the pending todos of a user, optionally filtered using
+# various fields.
+#
+# While this finder is a bit more verbose compared to use
+# `where(params.slice(...))`, it allows us to decouple the input parameters from
+# the actual column names. For example, if we ever decide to use separate
+# columns for target types (e.g. `issue_id`, `merge_request_id`, etc), we no
+# longer need to change _everything_ that uses this finder. Instead, we just
+# change the various `by_*` methods in this finder, without having to touch
+# everything that uses it.
+class PendingTodosFinder
+ attr_reader :current_user, :params
+
+ # current_user - The user to retrieve the todos for.
+ # params - A Hash containing columns and values to use for filtering todos.
+ def initialize(current_user, params = {})
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ todos = current_user.todos.pending
+ todos = by_project(todos)
+ todos = by_target_id(todos)
+ todos = by_target_type(todos)
+ todos = by_commit_id(todos)
+
+ todos
+ end
+
+ def by_project(todos)
+ if (id = params[:project_id])
+ todos.for_project(id)
+ else
+ todos
+ end
+ end
+
+ def by_target_id(todos)
+ if (id = params[:target_id])
+ todos.for_target(id)
+ else
+ todos
+ end
+ end
+
+ def by_target_type(todos)
+ if (type = params[:target_type])
+ todos.for_type(type)
+ else
+ todos
+ end
+ end
+
+ def by_commit_id(todos)
+ if (id = params[:commit_id])
+ todos.for_commit(id)
+ else
+ todos
+ end
+ end
+end
diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb
index 74baf79e4f2..d001e18fea9 100644
--- a/app/finders/todos_finder.rb
+++ b/app/finders/todos_finder.rb
@@ -23,6 +23,8 @@ class TodosFinder
NONE = '0'.freeze
+ TODO_TYPES = Set.new(%w(Issue MergeRequest Epic)).freeze
+
attr_accessor :current_user, :params
def initialize(current_user, params = {})
@@ -45,6 +47,13 @@ class TodosFinder
sort(items)
end
+ # Returns `true` if the current user has any todos for the given target.
+ #
+ # target - The value of the `target_type` column, such as `Issue`.
+ def any_for_target?(target)
+ current_user.todos.any_for_target?(target)
+ end
+
private
def action_id?
@@ -72,14 +81,11 @@ class TodosFinder
end
def author
- return @author if defined?(@author)
-
- @author =
+ strong_memoize(:author) do
if author? && params[:author_id] != NONE
User.find(params[:author_id])
- else
- nil
end
+ end
end
def project?
@@ -91,17 +97,9 @@ class TodosFinder
end
def project
- return @project if defined?(@project)
-
- if project?
- @project = Project.find(params[:project_id])
-
- @project = nil if @project.pending_delete?
- else
- @project = nil
+ strong_memoize(:project) do
+ Project.find_without_deleted(params[:project_id]) if project?
end
-
- @project
end
def group
@@ -111,7 +109,7 @@ class TodosFinder
end
def type?
- type.present? && %w(Issue MergeRequest Epic).include?(type)
+ type.present? && TODO_TYPES.include?(type)
end
def type
@@ -119,77 +117,66 @@ class TodosFinder
end
def sort(items)
- params[:sort] ? items.sort_by_attribute(params[:sort]) : items.order_id_desc
+ if params[:sort]
+ items.sort_by_attribute(params[:sort])
+ else
+ items.order_id_desc
+ end
end
- # rubocop: disable CodeReuse/ActiveRecord
def by_action(items)
if action?
- items = items.where(action: to_action_id)
+ items.for_action(to_action_id)
+ else
+ items
end
-
- items
end
- # rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def by_action_id(items)
if action_id?
- items = items.where(action: action_id)
+ items.for_action(action_id)
+ else
+ items
end
-
- items
end
- # rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def by_author(items)
if author?
- items = items.where(author_id: author.try(:id))
+ items.for_author(author)
+ else
+ items
end
-
- items
end
- # rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def by_project(items)
if project?
- items = items.where(project: project)
+ items.for_project(project)
+ else
+ items
end
-
- items
end
- # rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def by_group(items)
- return items unless group?
-
- groups = group.self_and_descendants
- project_todos = items.where(project_id: Project.where(group: groups).select(:id))
- group_todos = items.where(group_id: groups.select(:id))
-
- Todo.from_union([project_todos, group_todos])
+ if group?
+ items.for_group_and_descendants(group)
+ else
+ items
+ end
end
- # rubocop: enable CodeReuse/ActiveRecord
def by_state(items)
- case params[:state].to_s
- when 'done'
+ if params[:state].to_s == 'done'
items.done
else
items.pending
end
end
- # rubocop: disable CodeReuse/ActiveRecord
def by_type(items)
if type?
- items = items.where(target_type: type)
+ items.for_type(type)
+ else
+ items
end
-
- items
end
- # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/finders/users_with_pending_todos_finder.rb b/app/finders/users_with_pending_todos_finder.rb
new file mode 100644
index 00000000000..461bd92a366
--- /dev/null
+++ b/app/finders/users_with_pending_todos_finder.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# Finder that given a target (e.g. an issue) finds all the users that have
+# pending todos for said target.
+class UsersWithPendingTodosFinder
+ attr_reader :target
+
+ # target - The target, such as an Issue or MergeRequest.
+ def initialize(target)
+ @target = target
+ end
+
+ def execute
+ User.for_todos(target.todos.pending)
+ end
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index 0cdd876dc20..05e14c578b5 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -386,6 +386,13 @@ class Project < ActiveRecord::Base
only_integer: true,
message: 'needs to be beetween 10 minutes and 1 month' }
+ # Returns a project, if it is not about to be removed.
+ #
+ # id - The ID of the project to retrieve.
+ def self.find_without_deleted(id)
+ without_deleted.find_by_id(id)
+ end
+
# Paginates a collection using a `WHERE id < ?` condition.
#
# before - A project ID to use for filtering out projects with an equal or
@@ -450,6 +457,7 @@ class Project < ActiveRecord::Base
scope :joins_import_state, -> { joins("LEFT JOIN project_mirror_data import_state ON import_state.project_id = projects.id") }
scope :import_started, -> { joins_import_state.where("import_state.status = 'started' OR projects.import_status = 'started'") }
+ scope :for_group, -> (group) { where(group: group) }
class << self
# Searches for a list of projects based on the query given in `query`.
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 265fb932f7c..7b64615f699 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -40,6 +40,13 @@ class Todo < ActiveRecord::Base
scope :pending, -> { with_state(:pending) }
scope :done, -> { with_state(:done) }
+ scope :for_action, -> (action) { where(action: action) }
+ scope :for_author, -> (author) { where(author: author) }
+ scope :for_project, -> (project) { where(project: project) }
+ scope :for_group, -> (group) { where(group: group) }
+ scope :for_type, -> (type) { where(target_type: type) }
+ scope :for_target, -> (id) { where(target_id: id) }
+ scope :for_commit, -> (id) { where(commit_id: id) }
state_machine :state, initial: :pending do
event :done do
@@ -53,6 +60,42 @@ class Todo < ActiveRecord::Base
after_save :keep_around_commit, if: :commit_id
class << self
+ # Returns all todos for the given group and its descendants.
+ #
+ # group - A `Group` to retrieve todos for.
+ #
+ # Returns an `ActiveRecord::Relation`.
+ def for_group_and_descendants(group)
+ groups = group.self_and_descendants
+
+ from_union([
+ for_project(Project.for_group(groups)),
+ for_group(groups)
+ ])
+ end
+
+ # Returns `true` if the current user has any todos for the given target.
+ #
+ # target - The value of the `target_type` column, such as `Issue`.
+ def any_for_target?(target)
+ exists?(target: target)
+ end
+
+ # Updates the state of a relation of todos to the new state.
+ #
+ # new_state - The new state of the todos.
+ #
+ # Returns an `Array` containing the IDs of the updated todos.
+ def update_state(new_state)
+ # Only update those that are not really on that state
+ base = where.not(state: new_state).except(:order)
+ ids = base.pluck(:id)
+
+ base.update_all(state: new_state)
+
+ ids
+ end
+
# Priority sorting isn't displayed in the dropdown, because we don't show
# milestones, but still show something if the user has a URL with that
# selected.
diff --git a/app/models/user.rb b/app/models/user.rb
index cd3b1c95b7e..8a7acfb73b1 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -265,6 +265,7 @@ class User < ActiveRecord::Base
scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) }
scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :by_username, -> (usernames) { iwhere(username: usernames) }
+ scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) }
# Limits the users to those that have TODOs, optionally in the given state.
#
@@ -1366,6 +1367,10 @@ class User < ActiveRecord::Base
!consented_usage_stats? && 7.days.ago > self.created_at && !has_current_license? && User.single_user?
end
+ def todos_limited_to(ids)
+ todos.where(id: ids)
+ end
+
# @deprecated
alias_method :owned_or_masters_groups, :owned_or_maintainers_groups
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index 4fe6c1ec986..f357dc37fe7 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -41,15 +41,13 @@ class TodoService
# collects the todo users before the todos themselves are deleted, then
# updates the todo counts for those users.
#
- # rubocop: disable CodeReuse/ActiveRecord
def destroy_target(target)
- todo_users = User.where(id: target.todos.pending.select(:user_id)).to_a
+ todo_users = UsersWithPendingTodosFinder.new(target).execute.to_a
yield target
todo_users.each(&:update_todos_count_cache)
end
- # rubocop: enable CodeReuse/ActiveRecord
# When we reassign an issue we should:
#
@@ -200,30 +198,23 @@ class TodoService
create_todos(current_user, attributes)
end
- # rubocop: disable CodeReuse/ActiveRecord
def todo_exist?(issuable, current_user)
- TodosFinder.new(current_user).execute.exists?(target: issuable)
+ TodosFinder.new(current_user).any_for_target?(issuable)
end
- # rubocop: enable CodeReuse/ActiveRecord
private
- # rubocop: disable CodeReuse/ActiveRecord
def todos_by_ids(ids, current_user)
- current_user.todos.where(id: Array(ids))
+ current_user.todos_limited_to(Array(ids))
end
- # rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def update_todos_state(todos, current_user, state)
- # Only update those that are not really on that state
- todos = todos.where.not(state: state)
- todos_ids = todos.pluck(:id)
- todos.unscope(:order).update_all(state: state)
+ todos_ids = todos.update_state(state)
+
current_user.update_todos_count_cache
+
todos_ids
end
- # rubocop: enable CodeReuse/ActiveRecord
def create_todos(users, attributes)
Array(users).map do |user|
@@ -348,10 +339,7 @@ class TodoService
end
end
- # rubocop: disable CodeReuse/ActiveRecord
def pending_todos(user, criteria = {})
- valid_keys = [:project_id, :target_id, :target_type, :commit_id]
- user.todos.pending.where(criteria.slice(*valid_keys))
+ PendingTodosFinder.new(user, criteria).execute
end
- # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/spec/finders/pending_todos_finder_spec.rb b/spec/finders/pending_todos_finder_spec.rb
new file mode 100644
index 00000000000..32fad5e225f
--- /dev/null
+++ b/spec/finders/pending_todos_finder_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe PendingTodosFinder do
+ let(:user) { create(:user) }
+
+ describe '#execute' do
+ it 'returns only pending todos' do
+ create(:todo, :done, user: user)
+
+ todo = create(:todo, :pending, user: user)
+ todos = described_class.new(user).execute
+
+ expect(todos).to eq([todo])
+ end
+
+ it 'supports retrieving of todos for a specific project' do
+ project1 = create(:project)
+ project2 = create(:project)
+
+ create(:todo, :pending, user: user, project: project2)
+
+ todo = create(:todo, :pending, user: user, project: project1)
+ todos = described_class.new(user, project_id: project1.id).execute
+
+ expect(todos).to eq([todo])
+ end
+
+ it 'supports retrieving of todos for a specific todo target' do
+ issue = create(:issue)
+ note = create(:note)
+ todo = create(:todo, :pending, user: user, target: issue)
+
+ create(:todo, :pending, user: user, target: note)
+
+ todos = described_class.new(user, target_id: issue.id).execute
+
+ expect(todos).to eq([todo])
+ end
+
+ it 'supports retrieving of todos for a specific target type' do
+ issue = create(:issue)
+ note = create(:note)
+ todo = create(:todo, :pending, user: user, target: issue)
+
+ create(:todo, :pending, user: user, target: note)
+
+ todos = described_class.new(user, target_type: issue.class).execute
+
+ expect(todos).to eq([todo])
+ end
+
+ it 'supports retrieving of todos for a specific commit ID' do
+ create(:todo, :pending, user: user, commit_id: '456')
+
+ todo = create(:todo, :pending, user: user, commit_id: '123')
+ todos = described_class.new(user, commit_id: '123').execute
+
+ expect(todos).to eq([todo])
+ end
+ end
+end
diff --git a/spec/finders/todos_finder_spec.rb b/spec/finders/todos_finder_spec.rb
index 7f7cfb2cb98..d4ed41d54f0 100644
--- a/spec/finders/todos_finder_spec.rb
+++ b/spec/finders/todos_finder_spec.rb
@@ -105,9 +105,24 @@ describe TodosFinder do
todos = finder.new(user, { sort: 'priority' }).execute
- puts todos.to_sql
expect(todos).to eq([todo_3, todo_5, todo_4, todo_2, todo_1])
end
end
end
+
+ describe '#any_for_target?' do
+ it 'returns true if there are any todos for the given target' do
+ todo = create(:todo, :pending)
+ finder = described_class.new(todo.user)
+
+ expect(finder.any_for_target?(todo.target)).to eq(true)
+ end
+
+ it 'returns false if there are no todos for the given target' do
+ issue = create(:issue)
+ finder = described_class.new(issue.author)
+
+ expect(finder.any_for_target?(issue)).to eq(false)
+ end
+ end
end
diff --git a/spec/finders/users_with_pending_todos_finder_spec.rb b/spec/finders/users_with_pending_todos_finder_spec.rb
new file mode 100644
index 00000000000..fa15355531c
--- /dev/null
+++ b/spec/finders/users_with_pending_todos_finder_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe UsersWithPendingTodosFinder do
+ describe '#execute' do
+ it 'returns the users for all pending todos of a target' do
+ issue = create(:issue)
+ note = create(:note)
+ todo = create(:todo, :pending, target: issue)
+
+ create(:todo, :pending, target: note)
+
+ users = described_class.new(issue).execute
+
+ expect(users).to eq([todo.user])
+ end
+ end
+end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 31c69e5bd2c..3fecddefff2 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -4073,6 +4073,29 @@ describe Project do
end
end
+ describe '.find_without_deleted' do
+ it 'returns nil if the project is about to be removed' do
+ project = create(:project, pending_delete: true)
+
+ expect(described_class.find_without_deleted(project.id)).to be_nil
+ end
+
+ it 'returns a project when it is not about to be removed' do
+ project = create(:project)
+
+ expect(described_class.find_without_deleted(project.id)).to eq(project)
+ end
+ end
+
+ describe '.for_group' do
+ it 'returns the projects for a given group' do
+ group = create(:group)
+ project = create(:project, namespace: group)
+
+ expect(described_class.for_group(group)).to eq([project])
+ end
+ end
+
def rugged_config
rugged_repo(project.repository).config
end
diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb
index f29abcf536e..2c01578aaca 100644
--- a/spec/models/todo_spec.rb
+++ b/spec/models/todo_spec.rb
@@ -173,4 +173,129 @@ describe Todo do
expect(subject).not_to be_self_assigned
end
end
+
+ describe '.for_action' do
+ it 'returns the todos for a given action' do
+ create(:todo, action: Todo::MENTIONED)
+
+ todo = create(:todo, action: Todo::ASSIGNED)
+
+ expect(described_class.for_action(Todo::ASSIGNED)).to eq([todo])
+ end
+ end
+
+ describe '.for_author' do
+ it 'returns the todos for a given author' do
+ user1 = create(:user)
+ user2 = create(:user)
+ todo = create(:todo, author: user1)
+
+ create(:todo, author: user2)
+
+ expect(described_class.for_author(user1)).to eq([todo])
+ end
+ end
+
+ describe '.for_project' do
+ it 'returns the todos for a given project' do
+ project1 = create(:project)
+ project2 = create(:project)
+ todo = create(:todo, project: project1)
+
+ create(:todo, project: project2)
+
+ expect(described_class.for_project(project1)).to eq([todo])
+ end
+ end
+
+ describe '.for_group' do
+ it 'returns the todos for a given group' do
+ group1 = create(:group)
+ group2 = create(:group)
+ todo = create(:todo, group: group1)
+
+ create(:todo, group: group2)
+
+ expect(described_class.for_group(group1)).to eq([todo])
+ end
+ end
+
+ describe '.for_type' do
+ it 'returns the todos for a given target type' do
+ todo = create(:todo, target: create(:issue))
+
+ create(:todo, target: create(:merge_request))
+
+ expect(described_class.for_type(Issue)).to eq([todo])
+ end
+ end
+
+ describe '.for_target' do
+ it 'returns the todos for a given target' do
+ todo = create(:todo, target: create(:issue))
+
+ create(:todo, target: create(:merge_request))
+
+ expect(described_class.for_target(todo.target)).to eq([todo])
+ end
+ end
+
+ describe '.for_commit' do
+ it 'returns the todos for a commit ID' do
+ todo = create(:todo, commit_id: '123')
+
+ create(:todo, commit_id: '456')
+
+ expect(described_class.for_commit('123')).to eq([todo])
+ end
+ end
+
+ describe '.for_group_and_descendants' do
+ it 'returns the todos for a group and its descendants' do
+ parent_group = create(:group)
+ child_group = create(:group, parent: parent_group)
+
+ todo1 = create(:todo, group: parent_group)
+ todo2 = create(:todo, group: child_group)
+ todos = described_class.for_group_and_descendants(parent_group)
+
+ expect(todos).to include(todo1)
+
+ # Nested groups only work on PostgreSQL, so on MySQL todo2 won't be
+ # present.
+ expect(todos).to include(todo2) if Gitlab::Database.postgresql?
+ end
+ end
+
+ describe '.any_for_target?' do
+ it 'returns true if there are todos for a given target' do
+ todo = create(:todo)
+
+ expect(described_class.any_for_target?(todo.target)).to eq(true)
+ end
+
+ it 'returns false if there are no todos for a given target' do
+ issue = create(:issue)
+
+ expect(described_class.any_for_target?(issue)).to eq(false)
+ end
+ end
+
+ describe '.update_state' do
+ it 'updates the state of todos' do
+ todo = create(:todo, :pending)
+ ids = described_class.update_state(:done)
+
+ todo.reload
+
+ expect(ids).to eq([todo.id])
+ expect(todo.state).to eq('done')
+ end
+
+ it 'does not update todos that already have the given state' do
+ create(:todo, :pending)
+
+ expect(described_class.update_state(:pending)).to be_empty
+ end
+ end
end