use crate::AppService; use crate::error::AppError; use chrono::Utc; use models::issues::{ IssueState, issue, issue_assignee, issue_comment, issue_label, issue_repo, issue_subscriber, }; use models::projects::project_members; use models::users::user; use redis::AsyncCommands; use sea_orm::*; use serde::{Deserialize, Serialize}; use session::Session; use utoipa::ToSchema; use uuid::Uuid; #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct IssueCreateRequest { pub title: String, pub body: Option, pub milestone: Option, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct IssueUpdateRequest { pub title: Option, pub body: Option, pub milestone: Option, pub state: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct IssueResponse { pub id: Uuid, pub project: Uuid, pub number: i64, pub title: String, pub body: Option, pub state: String, pub author: Uuid, pub author_username: Option, pub milestone: Option, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, pub closed_at: Option>, pub created_by_ai: bool, } impl From for IssueResponse { fn from(i: issue::Model) -> Self { Self { id: i.id, project: i.project, number: i.number, title: i.title, body: i.body, state: i.state, author: i.author, author_username: None, milestone: i.milestone, created_at: i.created_at, updated_at: i.updated_at, closed_at: i.closed_at, created_by_ai: i.created_by_ai, } } } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct IssueListResponse { pub issues: Vec, pub total: i64, pub page: i64, pub per_page: i64, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct IssueSummaryResponse { pub total: i64, pub open: i64, pub closed: i64, } impl AppService { /// List issues for a project with optional state filter. pub async fn issue_list( &self, project_name: String, state: Option, page: Option, per_page: Option, ctx: &Session, ) -> Result { let project = self.utils_find_project_by_name(project_name).await?; // Check membership for private projects if let Some(uid) = ctx.user() { self.check_project_access(project.id, uid).await?; } let page = page.unwrap_or(1); let per_page = per_page.unwrap_or(20); let offset = (page - 1) * per_page; let mut query = issue::Entity::find() .filter(issue::Column::Project.eq(project.id)) .order_by_desc(issue::Column::CreatedAt); if let Some(ref s) = state { query = query.filter(issue::Column::State.eq(s)); } let total = query.clone().count(&self.db).await?; let issues = query .offset(offset as u64) .limit(per_page as u64) .all(&self.db) .await?; let author_ids: Vec = issues.iter().map(|i| i.author).collect(); let authors = if author_ids.is_empty() { vec![] } else { user::Entity::find() .filter(user::Column::Uid.is_in(author_ids)) .all(&self.db) .await? }; let responses: Vec = issues .into_iter() .map(|i| { let username = authors .iter() .find(|u| u.uid == i.author) .map(|u| u.username.clone()); IssueResponse { author_username: username, ..IssueResponse::from(i) } }) .collect(); Ok(IssueListResponse { issues: responses, total: total as i64, page, per_page, }) } /// Get a single issue by project + number. pub async fn issue_get( &self, project_name: String, number: i64, ctx: &Session, ) -> Result { let project = self.utils_find_project_by_name(project_name).await?; if let Some(uid) = ctx.user() { self.check_project_access(project.id, uid).await?; } let cache_key = format!("issue:get:{}:{}", project.id, number); if let Ok(mut conn) = self.cache.conn().await { if let Ok(cached) = conn.get::<_, String>(cache_key.clone()).await { if let Ok(cached) = serde_json::from_str::(&cached) { return Ok(cached); } } } let issue = issue::Entity::find() .filter(issue::Column::Project.eq(project.id)) .filter(issue::Column::Number.eq(number)) .one(&self.db) .await? .ok_or(AppError::NotFound("Issue not found".to_string()))?; let author = user::Entity::find_by_id(issue.author) .one(&self.db) .await .ok() .flatten(); let username = author.map(|u| u.username); let response = IssueResponse { author_username: username, ..IssueResponse::from(issue) }; if let Ok(mut conn) = self.cache.conn().await { let _: Option<()> = conn .set_ex::( cache_key, serde_json::to_string(&response).unwrap_or_default(), 300, ) .await .ok(); } Ok(response) } /// Get the next sequential issue number for a project. async fn next_issue_number(&self, project_id: Uuid) -> Result { let max_num: Option> = issue::Entity::find() .filter(issue::Column::Project.eq(project_id)) .select_only() .column_as(issue::Column::Number.max(), "max_num") .into_tuple::>() .one(&self.db) .await?; Ok(max_num.flatten().unwrap_or(0) + 1) } /// Create a new issue. pub async fn issue_create( &self, project_name: String, request: IssueCreateRequest, ctx: &Session, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let project = self.utils_find_project_by_name(project_name).await?; // Any project member can create issues let member = project_members::Entity::find() .filter(project_members::Column::Project.eq(project.id)) .filter(project_members::Column::User.eq(user_uid)) .one(&self.db) .await?; if member.is_none() { return Err(AppError::NoPower); } let number = self.next_issue_number(project.id).await?; let now = Utc::now(); let active = issue::ActiveModel { id: Set(Uuid::now_v7()), project: Set(project.id), number: Set(number), title: Set(request.title), body: Set(request.body), state: Set(IssueState::Open.to_string()), author: Set(user_uid), milestone: Set(request.milestone), created_at: Set(now), updated_at: Set(now), closed_at: Set(None), created_by_ai: Set(false), ..Default::default() }; let model = active.insert(&self.db).await?; // Log activity let actor_username = user::Entity::find_by_id(user_uid) .one(&self.db) .await .ok() .flatten() .map(|u| u.username) .unwrap_or_default(); let _ = self .project_log_activity( project.id, None, user_uid, super::super::project::activity::ActivityLogParams { event_type: "issue_open".to_string(), title: format!("{} opened issue #{}", actor_username, number), repo_id: None, content: Some(model.title.clone()), event_id: Some(model.id), event_sub_id: Some(model.number), metadata: None, is_private: false, }, ) .await; Ok(IssueResponse::from(model)) } /// Update an issue (title, body, milestone). pub async fn issue_update( &self, project_name: String, number: i64, request: IssueUpdateRequest, ctx: &Session, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let project = self.utils_find_project_by_name(project_name).await?; let member = project_members::Entity::find() .filter(project_members::Column::Project.eq(project.id)) .filter(project_members::Column::User.eq(user_uid)) .one(&self.db) .await?; if member.is_none() { return Err(AppError::NoPower); } let issue = issue::Entity::find() .filter(issue::Column::Project.eq(project.id)) .filter(issue::Column::Number.eq(number)) .one(&self.db) .await? .ok_or(AppError::NotFound("Issue not found".to_string()))?; let mut active: issue::ActiveModel = issue.clone().into(); if let Some(title) = request.title { active.title = Set(title); } if let Some(body) = request.body { active.body = Set(Some(body)); } if let Some(milestone) = request.milestone { active.milestone = Set(Some(milestone)); } active.updated_at = Set(Utc::now()); let model = active.update(&self.db).await?; self.invalidate_issue_cache(project.id, number).await; let actor_username = user::Entity::find_by_id(user_uid) .one(&self.db) .await .ok() .flatten() .map(|u| u.username) .unwrap_or_default(); let _ = self .project_log_activity( project.id, None, user_uid, super::super::project::activity::ActivityLogParams { event_type: "issue_update".to_string(), title: format!("{} updated issue #{}", actor_username, number), repo_id: None, content: Some(model.title.clone()), event_id: Some(model.id), event_sub_id: Some(model.number), metadata: None, is_private: false, }, ) .await; Ok(IssueResponse::from(model)) } /// Close an issue. pub async fn issue_close( &self, project_name: String, number: i64, ctx: &Session, ) -> Result { self.issue_set_state(project_name, number, IssueState::Closed, ctx) .await } /// Reopen a closed issue. pub async fn issue_reopen( &self, project_name: String, number: i64, ctx: &Session, ) -> Result { self.issue_set_state(project_name, number, IssueState::Open, ctx) .await } async fn issue_set_state( &self, project_name: String, number: i64, state: IssueState, ctx: &Session, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let project = self.utils_find_project_by_name(project_name).await?; let member = project_members::Entity::find() .filter(project_members::Column::Project.eq(project.id)) .filter(project_members::Column::User.eq(user_uid)) .one(&self.db) .await?; if member.is_none() { return Err(AppError::NoPower); } let issue = issue::Entity::find() .filter(issue::Column::Project.eq(project.id)) .filter(issue::Column::Number.eq(number)) .one(&self.db) .await? .ok_or(AppError::NotFound("Issue not found".to_string()))?; let now = Utc::now(); let closed_at = if state == IssueState::Closed { Some(now) } else { None }; let mut active: issue::ActiveModel = issue.clone().into(); active.state = Set(state.to_string()); active.updated_at = Set(now); active.closed_at = Set(closed_at); let model = active.update(&self.db).await?; self.invalidate_issue_cache(project.id, number).await; let actor_username = user::Entity::find_by_id(user_uid) .one(&self.db) .await .ok() .flatten() .map(|u| u.username) .unwrap_or_default(); let event_type = if state == IssueState::Closed { "issue_close" } else { "issue_reopen" }; let _ = self .project_log_activity( project.id, None, user_uid, super::super::project::activity::ActivityLogParams { event_type: event_type.to_string(), title: format!( "{} {} issue #{}", actor_username, if state == IssueState::Closed { "closed" } else { "reopened" }, number ), repo_id: None, content: Some(model.title.clone()), event_id: Some(model.id), event_sub_id: Some(model.number), metadata: None, is_private: false, }, ) .await; Ok(IssueResponse::from(model)) } /// Delete an issue. Only author or admin/owner can delete. pub async fn issue_delete( &self, project_name: String, number: i64, ctx: &Session, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let project = self.utils_find_project_by_name(project_name).await?; let issue = issue::Entity::find() .filter(issue::Column::Project.eq(project.id)) .filter(issue::Column::Number.eq(number)) .one(&self.db) .await? .ok_or(AppError::NotFound("Issue not found".to_string()))?; // Allow if user is author OR admin/owner let member = project_members::Entity::find() .filter(project_members::Column::Project.eq(project.id)) .filter(project_members::Column::User.eq(user_uid)) .one(&self.db) .await? .ok_or(AppError::NoPower)?; let role = member.scope_role().map_err(|_| AppError::RoleParseError)?; let is_author = issue.author == user_uid; let is_admin = role == models::projects::MemberRole::Admin || role == models::projects::MemberRole::Owner; if !is_author && !is_admin { return Err(AppError::NoPower); } // Cascade delete related records issue_comment::Entity::delete_many() .filter(issue_comment::Column::Issue.eq(issue.id)) .exec(&self.db) .await?; issue_assignee::Entity::delete_many() .filter(issue_assignee::Column::Issue.eq(issue.id)) .exec(&self.db) .await?; issue_label::Entity::delete_many() .filter(issue_label::Column::Issue.eq(issue.id)) .exec(&self.db) .await?; issue_subscriber::Entity::delete_many() .filter(issue_subscriber::Column::Issue.eq(issue.id)) .exec(&self.db) .await?; issue_repo::Entity::delete_many() .filter(issue_repo::Column::Issue.eq(issue.id)) .exec(&self.db) .await?; issue::Entity::delete_by_id((issue.id, issue.number)) .exec(&self.db) .await?; self.invalidate_issue_cache(project.id, number).await; let actor_username = user::Entity::find_by_id(user_uid) .one(&self.db) .await .ok() .flatten() .map(|u| u.username) .unwrap_or_default(); let _ = self .project_log_activity( project.id, None, user_uid, super::super::project::activity::ActivityLogParams { event_type: "issue_delete".to_string(), title: format!("{} deleted issue #{}", actor_username, number), repo_id: None, content: None, event_id: None, event_sub_id: Some(number), metadata: None, is_private: false, }, ) .await; Ok(()) } /// Get issue summary (open/closed counts). pub async fn issue_summary( &self, project_name: String, ctx: &Session, ) -> Result { let project = self.utils_find_project_by_name(project_name).await?; if let Some(uid) = ctx.user() { self.check_project_access(project.id, uid).await?; } let total: u64 = issue::Entity::find() .filter(issue::Column::Project.eq(project.id)) .count(&self.db) .await?; let open: u64 = issue::Entity::find() .filter(issue::Column::Project.eq(project.id)) .filter(issue::Column::State.eq(IssueState::Open.to_string())) .count(&self.db) .await?; let closed = total - open; Ok(IssueSummaryResponse { total: total as i64, open: open as i64, closed: closed as i64, }) } pub(crate) async fn invalidate_issue_cache(&self, project_id: Uuid, number: i64) { if let Ok(mut conn) = self.cache.conn().await { let key = format!("issue:get:{}:{}", project_id, number); let _: Option<()> = conn.del::<_, ()>(key).await.ok(); } } }