588 lines
19 KiB
Rust
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();
|
|
}
|
|
}
|
|
}
|