use std::collections::{HashMap, HashSet}; use std::sync::Arc; use config::AppConfig; use db::AppDatabase; use models::issue::issue_event_line::{self, IssueEventType}; use models::issue::issue_item; use sea_orm::{ActiveModelTrait, ConnectionTrait, EntityTrait, Set}; use space::Space; use time::OffsetDateTime; use uuid::Uuid; use crate::errors::IssueError; #[derive(Clone)] pub struct IssueService { pub(crate) database: Arc, pub(crate) storage: Arc, pub(crate) audience: crate::services::audience::Audience, pub(crate) settings: IssueSettings, pub(crate) access: crate::services::admission::Admission, } /// The optional "what changed" columns of an issue activity row. Grouped into a /// struct so `write_event` does not take four positionally-interchangeable /// `Option` arguments. #[derive(Default)] pub(crate) struct IssueEventDetail { pub comment_id: Option, pub old_title: Option, pub new_title: Option, pub content: Option, } impl IssueService { pub fn new( database: Arc, storage: Arc, audience: crate::services::audience::Audience, settings: IssueSettings, access: crate::services::admission::Admission, ) -> Self { Self { database, storage, audience, settings, access, } } pub fn paginate( &self, limit: Option, offset: Option, ) -> Result<(u64, u64), api::pagination::PageError> { api::pagination::page_bounds( limit, offset, self.settings.issues.default_pagination, self.settings.issues.max_pagination, ) } #[allow(clippy::too_many_arguments)] pub async fn resolve_issue_ref( &self, caller: i64, space_name: &str, board_id: Uuid, issue_ref: &str, ) -> Result { let space = crate::share::lookup::lookup_space_by_name(self, space_name).await?; crate::share::lookup::require_member(self, &space, caller).await?; let board = crate::share::lookup::lookup_board(self, &space, board_id, caller).await?; Ok( crate::share::lookup::lookup_issue_by_ref(self, &board, issue_ref) .await? .id, ) } /// Record one activity row. The optional columns all describe "what changed" /// and are grouped so the call sites stay readable. pub(crate) async fn write_event( &self, txn: &impl ConnectionTrait, issue_id: Uuid, user_id: i64, event_type: IssueEventType, detail: IssueEventDetail, ) -> Result<(), IssueError> { let IssueEventDetail { comment_id, old_title, new_title, content, } = detail; let mut payload = serde_json::json!({ "issue_id": issue_id, "user_id": user_id, "event_type": event_type, "comment_id": &comment_id, "old_title": &old_title, "new_title": &new_title, "content": &content, }); if let Ok(Some(issue)) = issue_item::Entity::find_by_id(issue_id).one(txn).await { payload["issue"] = serde_json::json!({ "id": issue.id, "number": issue.autoincrement_id, "collection_id": issue.collection_id, "author_id": issue.author_id, "title": &issue.title, "description": &issue.description, "priority": issue.priority, "milestone_id": issue.milestone_id, "created_at": issue.created_at, "updated_at": issue.updated_at, "closed_at": issue.closed_at, }); } issue_event_line::ActiveModel { id: Set(Uuid::now_v7()), issue_id: Set(Some(issue_id)), project_id: Set(None), user_id: Set(user_id), event_type: Set(event_type), comment_id: Set(comment_id), commit_sha: Set(None), old_title: Set(old_title), new_title: Set(new_title), old_ref: Set(None), new_ref: Set(None), content: Set(content), created_at: Set(OffsetDateTime::now_utc()), } .insert(txn) .await?; let labels = queue::Labels::new() .with("issue_id", issue_id) .with("user_id", user_id); events::emit(txn, event_kind(event_type), labels, payload).await?; Ok(()) } #[allow(clippy::too_many_arguments)] pub(crate) async fn collect_mentioned_uids( &self, space: &Space, text: &str, exclude: i64, ) -> Result, IssueError> { let members = self.access.members(space.id(), space.owner()).await?; let mut member_uids: Vec = members.into_iter().map(|m| m.user_id).collect(); if !member_uids.contains(&space.owner()) { member_uids.push(space.owner()); } if member_uids.is_empty() { return Ok(Vec::new()); } let users = auth::users::load_public_users(&self.audience.accounts, &member_uids).await?; let mut name_to_uid: HashMap = HashMap::new(); for (uid, user) in users { name_to_uid.insert(user.username.to_lowercase(), uid); name_to_uid.insert(uid.to_string(), uid); } let mut result_set: HashSet = HashSet::new(); let punctuation = [ ',', '.', ';', ':', '!', '?', '\'', '"', '“', '”', '`', ')', ']', '}', '>', ]; for token in text.split_whitespace() { if let Some(rest) = token.strip_prefix('@') { let clean = rest.trim_end_matches(&punctuation[..]); if clean.is_empty() { continue; } let clean_lower = clean.to_lowercase(); if let Some(&uid) = name_to_uid.get(&clean_lower) && uid != exclude { result_set.insert(uid); } } } let mut uids: Vec = result_set.into_iter().collect(); uids.sort(); let max_mentions = self.settings.issues.max_mentions_per_message; if uids.len() > max_mentions { uids.truncate(max_mentions); } Ok(uids) } } fn event_kind(event_type: IssueEventType) -> events::EventKind { match event_type { IssueEventType::Created => events::EventKind::IssueIssueCreate, IssueEventType::TitleUpdated => events::EventKind::IssueTitleUpdate, IssueEventType::DescriptionUpdated => events::EventKind::IssueDescriptionUpdate, IssueEventType::ContentUpdated => events::EventKind::IssueContentUpdate, IssueEventType::MilestoneUpdated => events::EventKind::IssueMilestoneUpdate, IssueEventType::PriorityUpdated => events::EventKind::IssuePriorityUpdate, IssueEventType::DueUpdated => events::EventKind::IssueDueUpdate, IssueEventType::Closed => events::EventKind::IssueIssueClose, IssueEventType::Reopened => events::EventKind::IssueIssueReopen, IssueEventType::CommentCreated => events::EventKind::IssueCommentCreate, IssueEventType::CommentUpdated => events::EventKind::IssueCommentUpdate, IssueEventType::CommentDeleted => events::EventKind::IssueCommentDelete, IssueEventType::MemberAdded => events::EventKind::IssueMemberCreate, IssueEventType::MemberRemoved => events::EventKind::IssueMemberDelete, IssueEventType::LabelAdded => events::EventKind::IssueLabelCreate, IssueEventType::LabelRemoved => events::EventKind::IssueLabelDelete, IssueEventType::DependencyAdded => events::EventKind::IssueDependencyCreate, IssueEventType::DependencyRemoved => events::EventKind::IssueDependencyDelete, IssueEventType::Pinned => events::EventKind::IssuePinCreate, IssueEventType::Unpinned => events::EventKind::IssuePinDelete, IssueEventType::RepoRelationAdded => events::EventKind::IssueRepoRelationCreate, IssueEventType::RepoRelationRemoved => events::EventKind::IssueRepoRelationDelete, IssueEventType::PullLinked => events::EventKind::IssuePullLinkCreate, IssueEventType::PullUnlinked => events::EventKind::IssuePullLinkDelete, } } #[derive(Clone)] pub struct IssueSettings { pub issues: config::issues::IssueConfig, pub web: config::web::WebConfig, } impl IssueSettings { pub fn from_config(config: &AppConfig) -> Self { Self { issues: config.issues.clone(), web: config.web.clone(), } } }