gitdataai/libs/service/issue/issue.rs
2026-04-14 19:02:01 +08:00

588 lines
19 KiB
Rust

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<String>,
pub milestone: Option<String>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct IssueUpdateRequest {
pub title: Option<String>,
pub body: Option<String>,
pub milestone: Option<String>,
pub state: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct IssueResponse {
pub id: Uuid,
pub project: Uuid,
pub number: i64,
pub title: String,
pub body: Option<String>,
pub state: String,
pub author: Uuid,
pub author_username: Option<String>,
pub milestone: Option<String>,
pub created_at: chrono::DateTime<Utc>,
pub updated_at: chrono::DateTime<Utc>,
pub closed_at: Option<chrono::DateTime<Utc>>,
pub created_by_ai: bool,
}
impl From<issue::Model> 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<IssueResponse>,
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<String>,
page: Option<i64>,
per_page: Option<i64>,
ctx: &Session,
) -> Result<IssueListResponse, AppError> {
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<Uuid> = 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<IssueResponse> = 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<IssueResponse, AppError> {
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::<IssueResponse>(&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::<String, String, ()>(
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<i64, AppError> {
let max_num: Option<Option<i64>> = issue::Entity::find()
.filter(issue::Column::Project.eq(project_id))
.select_only()
.column_as(issue::Column::Number.max(), "max_num")
.into_tuple::<Option<i64>>()
.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<IssueResponse, AppError> {
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<IssueResponse, AppError> {
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<IssueResponse, AppError> {
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<IssueResponse, AppError> {
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<IssueResponse, AppError> {
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<IssueSummaryResponse, AppError> {
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();
}
}
}