main
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<AppDatabase>,
pub(crate) storage: Arc<storage::S3Client>,
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<Uuid>,
pub old_title: Option<String>,
pub new_title: Option<String>,
pub content: Option<String>,
}
impl IssueService {
pub fn new(
database: Arc<AppDatabase>,
storage: Arc<storage::S3Client>,
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<u64>,
offset: Option<u64>,
) -> 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<Uuid, IssueError> {
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<Vec<i64>, IssueError> {
let members = self.access.members(space.id(), space.owner()).await?;
let mut member_uids: Vec<i64> = 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<String, i64> = 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<i64> = 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<i64> = 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(),
}
}
}