Files
gitlab-ce/app/models/todo.rb
2025-06-13 03:14:15 +00:00

516 lines
15 KiB
Ruby

# frozen_string_literal: true
class Todo < ApplicationRecord
include Sortable
include FromUnion
include EachBatch
# Time to wait for todos being removed when not visible for user anymore.
# Prevents TODOs being removed by mistake, for example, removing access from a user
# and giving it back again.
WAIT_FOR_DELETE = 1.hour
# Actions
ASSIGNED = 1
MENTIONED = 2
BUILD_FAILED = 3
MARKED = 4
APPROVAL_REQUIRED = 5 # This is an EE-only feature
UNMERGEABLE = 6
DIRECTLY_ADDRESSED = 7
MERGE_TRAIN_REMOVED = 8 # This is an EE-only feature
REVIEW_REQUESTED = 9
MEMBER_ACCESS_REQUESTED = 10
REVIEW_SUBMITTED = 11
OKR_CHECKIN_REQUESTED = 12 # This is an EE-only feature
ADDED_APPROVER = 13 # This is an EE-only feature,
SSH_KEY_EXPIRED = 14
SSH_KEY_EXPIRING_SOON = 15
DUO_PRO_ACCESS_GRANTED = 16 # This is an EE-only feature
DUO_ENTERPRISE_ACCESS_GRANTED = 17 # This is an EE-only feature
DUO_CORE_ACCESS_GRANTED = 18 # This is an EE-only feature
# EE Action names should be defined in EE_ACTION_NAMES in ee/app/models/ee/todo.rb
ACTION_NAMES = {
ASSIGNED => :assigned,
REVIEW_REQUESTED => :review_requested,
MENTIONED => :mentioned,
BUILD_FAILED => :build_failed,
MARKED => :marked,
APPROVAL_REQUIRED => :approval_required,
UNMERGEABLE => :unmergeable,
DIRECTLY_ADDRESSED => :directly_addressed,
MEMBER_ACCESS_REQUESTED => :member_access_requested,
REVIEW_SUBMITTED => :review_submitted,
SSH_KEY_EXPIRED => :ssh_key_expired,
SSH_KEY_EXPIRING_SOON => :ssh_key_expiring_soon
}.freeze
ACTIONS_MULTIPLE_ALLOWED = [Todo::MENTIONED, Todo::DIRECTLY_ADDRESSED, Todo::MEMBER_ACCESS_REQUESTED].freeze
# EE parentless action types should be defined in EE_PARENTLESS_ACTION_TYPES in ee/app/models/ee/todo.rb
PARENTLESS_ACTION_TYPES = [
SSH_KEY_EXPIRED,
SSH_KEY_EXPIRING_SOON
].freeze
BATCH_DELETE_SIZE = 100
belongs_to :author, class_name: "User"
belongs_to :note
belongs_to :project
belongs_to :group
belongs_to :target, -> {
if self.klass.respond_to?(:with_api_entity_associations)
self.with_api_entity_associations
else
self
end
}, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :user
belongs_to :issue, -> { where("target_type = 'Issue'") }, foreign_key: :target_id
delegate :name, :email, to: :author, prefix: true, allow_nil: true
validates :action, :target_type, :user, presence: true
validates :author, presence: true
validates :target_id, presence: true, unless: :for_commit?
validates :commit_id, presence: true, if: :for_commit?
validates :project, presence: true, unless: -> { group_id || parentless_type? }
validates :group, presence: true, unless: -> { project_id || parentless_type? }
scope :pending, -> { with_state(:pending) }
scope :snoozed, -> { where(arel_table[:snoozed_until].gt(Time.current)) }
scope :not_snoozed, -> { where(arel_table[:snoozed_until].lteq(Time.current)).or(where(snoozed_until: nil)) }
scope :done, -> { with_state(:done) }
scope :for_action, ->(action) { where(action: action) }
scope :for_author, ->(author) { where(author: author) }
scope :for_user, ->(user) { where(user: user) }
scope :for_project, ->(projects) { where(project: projects) }
scope :for_note, ->(notes) { where(note: notes) }
scope :for_undeleted_projects, -> { joins(:project).merge(Project.without_deleted) }
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) }
scope :not_in_users, ->(user_ids) { where.not('todos.user_id' => user_ids) }
scope :with_entity_associations, -> do
preload(:target, :author, :note, group: :route, project: [:route, :group, { namespace: [:route, :owner] }, :project_setting])
end
scope :joins_issue_and_assignees, -> { left_joins(issue: :assignees) }
scope :for_internal_notes, -> { joins(:note).where(note: { confidential: true }) }
scope :with_preloaded_user, -> { preload(:user) }
scope :without_banned_user, -> { joins("LEFT JOIN banned_users ON todos.author_id = banned_users.user_id").where(banned_users: { user_id: nil }) }
scope :pending_without_hidden, -> { pending.without_banned_user }
scope :all_without_hidden, -> { without_banned_user.or(where.not(state: :pending)) }
enum :resolved_by_action, { system_done: 0, api_all_done: 1, api_done: 2, mark_all_done: 3, mark_done: 4 }, prefix: :resolved_by
state_machine :state, initial: :pending do
event :done do
transition [:pending] => :done
end
state :pending
state :done
end
class << self
# Returns all todos for the given group ids and their descendants.
#
# group_ids - Group Ids to retrieve todos for.
#
# Returns an `ActiveRecord::Relation`.
def for_group_ids_and_descendants(group_ids)
groups_and_descendants_cte = Gitlab::SQL::CTE.new(
:groups_and_descendants_ids,
Group.where(id: group_ids).self_and_descendant_ids
)
groups_and_descendants = Namespace.from(groups_and_descendants_cte.table)
with(groups_and_descendants_cte.to_arel)
.from_union([
for_project(Project.for_group(groups_and_descendants)),
for_group(groups_and_descendants)
], remove_duplicates: false)
end
def pending_for_expiring_ssh_keys(ssh_key_ids)
where(
target_type: Key,
target_id: ssh_key_ids,
action: ::Todo::SSH_KEY_EXPIRING_SOON,
state: :pending
)
end
# Returns `true` if the current user has any todos for the given target with the optional given state.
#
# target - The value of the `target_type` column, such as `Issue`.
# state - The value of the `state` column, such as `pending` or `done`.
def any_for_target?(target, state = nil)
conditions = {}
if target.respond_to?(:todoable_target_type_name)
conditions[:target_type] = target.todoable_target_type_name
conditions[:target_id] = target.id
else
conditions[:target] = target
end
conditions[:state] = state unless state.nil?
exists?(conditions)
end
# Updates attributes of a relation of todos to the new state.
#
# new_attributes - The new attributes of the todos.
#
# Returns an `Array` containing the IDs of the updated todos.
def batch_update(**new_attributes)
# We force a `WHERE todo.id IN ()` SQL clause to circumvent issues where resolving todos
# associated to a specific group would also resolve other todos due to limitations of the
# `update_all` which doesn't handle UNIONs well.
ids_to_update = select(:id)
todos_to_update = where(id: ids_to_update)
# Only update those that have different state
base = todos_to_update.where.not(state: new_attributes[:state]).except(:order)
ids = base.pluck(:id)
base.update_all(new_attributes.merge(updated_at: Time.current))
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.
def sort_by_attribute(method)
sorted =
case method.to_s
when 'snoozed_and_creation_dates_asc' then sort_by_snoozed_and_creation_dates(direction: :asc)
when 'snoozed_and_creation_dates_desc' then sort_by_snoozed_and_creation_dates
when 'priority', 'label_priority', 'label_priority_asc' then order_by_labels_priority(asc: true)
when 'label_priority_desc' then order_by_labels_priority(asc: false)
else order_by(method)
end
return sorted if Gitlab::Pagination::Keyset::Order.keyset_aware?(sorted)
# Break ties with the ID column for pagination
sorted.order(id: :desc)
end
def sort_by_snoozed_and_creation_dates(direction: :desc)
coalesced_arel = Arel.sql('timestamp_coalesce(todos.snoozed_until, todos.created_at)')
attribute_name = 'coalesced_date'
order = Gitlab::Pagination::Keyset::Order.build([
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: attribute_name,
column_expression: coalesced_arel,
order_expression: direction == :asc ? coalesced_arel.asc : coalesced_arel.desc,
reversed_order_expression: direction == :asc ? coalesced_arel.desc : coalesced_arel.asc,
nullable: :not_nullable,
order_direction: direction
)
])
select("todos.*, #{coalesced_arel} AS #{attribute_name}").order(order)
end
# Order by priority depending on which issue/merge request the Todo belongs to
# Todos with highest priority first then oldest todos
# Need to order by created_at last because of differences on Mysql and Postgres when joining by type "Merge_request/Issue"
def order_by_labels_priority(asc: true)
highest_priority = highest_label_priority(
target_type_column: "todos.target_type",
target_column: "todos.target_id",
project_column: "todos.project_id"
).arel.as('highest_priority')
highest_priority_arel = Arel.sql('highest_priority')
order = Gitlab::Pagination::Keyset::Order.build([
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'highest_priority',
column_expression: highest_priority_arel,
order_expression: asc ? highest_priority_arel.asc.nulls_last : highest_priority_arel.desc.nulls_first,
reversed_order_expression: asc ? highest_priority_arel.desc.nulls_first : highest_priority_arel.asc.nulls_last,
nullable: asc ? :nulls_last : :nulls_first,
order_direction: asc ? :asc : :desc
),
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'created_at',
order_expression: asc ? Todo.arel_table[:created_at].asc : Todo.arel_table[:created_at].desc,
nullable: :not_nullable
),
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'id',
order_expression: asc ? Todo.arel_table[:id].asc : Todo.arel_table[:id].desc,
nullable: :not_nullable
)
])
select(arel_table[Arel.star], highest_priority).order(order)
end
def action_names
ACTION_NAMES
end
def parentless_action_types
PARENTLESS_ACTION_TYPES
end
def distinct_user_ids
# When used from the todos finder that applies a default order, we need to reset it.
reorder(nil).distinct.pluck(:user_id)
end
# Count pending todos grouped by user_id and state
# so we can utilize the index on state / user id.
def pending_count_by_user_id
where(state: :pending)
.group(:user_id)
.count(:id)
end
end
def resource_parent
project || group
end
def unmergeable?
action == UNMERGEABLE
end
def build_failed?
action == BUILD_FAILED
end
def assigned?
action == ASSIGNED
end
def review_requested?
action == REVIEW_REQUESTED
end
def merge_train_removed?
action == MERGE_TRAIN_REMOVED
end
def member_access_requested?
action == MEMBER_ACCESS_REQUESTED
end
def review_submitted?
action == REVIEW_SUBMITTED
end
def member_access_type
target.class.name.downcase
end
def access_request_url(only_path: false)
if target.instance_of? Group
Gitlab::Routing.url_helpers.group_group_members_url(self.target, tab: 'access_requests', only_path: only_path)
elsif target.instance_of? Project
Gitlab::Routing.url_helpers.project_project_members_url(self.target, tab: 'access_requests', only_path: only_path)
else
""
end
end
def done?
state == 'done'
end
def action_name
self.class.action_names[action]
end
def body
if note.present?
note.note
elsif member_access_requested?
target.full_path
else
target.title
end
end
def for_commit?
target_type == "Commit"
end
def for_design?
target_type == DesignManagement::Design.name
end
def for_alert?
target_type == AlertManagement::Alert.name
end
def for_issue_or_work_item?
[Issue.name, WorkItem.name].any?(target_type)
end
def for_ssh_key?
target_type == Key.name
end
def parentless_type?
self.class.parentless_action_types.include?(action)
end
# override to return commits, which are not active record
def target
if for_commit?
begin
project.commit(commit_id)
rescue StandardError
nil
end
else
super
end
end
def target_reference
if for_commit?
target.reference_link_text
elsif member_access_requested?
target.full_path
else
target.to_reference
end
end
def target_url
return if target.nil?
case target
when WorkItem
build_work_item_target_url
when Issue
build_issue_target_url
when MergeRequest
build_merge_request_target_url
when ::DesignManagement::Design
build_design_target_url
when ::AlertManagement::Alert
build_alert_target_url
when Commit
build_commit_target_url
when Project
build_project_target_url
when Group
build_group_target_url
when Key
build_ssh_key_target_url
when WikiPage::Meta
build_wiki_page_target_url
end
end
def self_added?
author == user
end
def self_assigned?
self_added? && (assigned? || review_requested?)
end
private
def build_work_item_target_url
::Gitlab::UrlBuilder.build(
target,
anchor: note.present? ? ActionView::RecordIdentifier.dom_id(note) : nil
)
end
def build_issue_target_url
::Gitlab::UrlBuilder.build(
target,
anchor: note.present? ? ActionView::RecordIdentifier.dom_id(note) : nil
)
end
def build_merge_request_target_url
path = [target.project, target]
path.unshift(:pipelines) if build_failed?
::Gitlab::Routing.url_helpers.polymorphic_url(
path,
{
anchor: note.present? ? ActionView::RecordIdentifier.dom_id(note) : nil
}
)
end
def build_design_target_url
::Gitlab::Routing.url_helpers.designs_project_issue_url(
target.project,
target.issue,
{
anchor: note.present? ? ActionView::RecordIdentifier.dom_id(note) : nil,
vueroute: target.filename
}
)
end
def build_alert_target_url
::Gitlab::Routing.url_helpers.details_project_alert_management_url(
target.project,
target
)
end
def build_commit_target_url
::Gitlab::Routing.url_helpers.project_commit_url(
target.project,
target,
{
anchor: note.present? ? ActionView::RecordIdentifier.dom_id(note) : nil
}
)
end
def build_project_target_url
return unless member_access_requested?
::Gitlab::Routing.url_helpers.project_project_members_url(
target,
tab: 'access_requests'
)
end
def build_group_target_url
return unless member_access_requested?
::Gitlab::Routing.url_helpers.group_group_members_url(
target,
tab: 'access_requests'
)
end
def build_ssh_key_target_url
::Gitlab::Routing.url_helpers.user_settings_ssh_key_url(target)
end
def build_wiki_page_target_url
::Gitlab::UrlBuilder.build(
target,
anchor: note.present? ? ActionView::RecordIdentifier.dom_id(note) : nil
)
end
end
Todo.prepend_mod_with('Todo')