gitdataai/lib/service/issues/issue.rs
2026-05-30 01:38:40 +08:00

502 lines
17 KiB
Rust

use db::{sqlx, sqlx::AssertSqlSafe};
use model::{issues::IssueModel, users::UserModel};
use serde::Deserialize;
use session::Session;
use super::types::{IssueFilter, IssueResponse, issue_author};
use crate::{AppService, Pagination, error::AppError, session_user};
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct CreateIssue {
pub title: String,
pub body: Option<String>,
pub priority: Option<String>,
pub due_at: Option<String>,
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct UpdateIssue {
pub title: Option<String>,
pub body: Option<String>,
pub priority: Option<String>,
pub due_at: Option<String>,
}
impl AppService {
pub async fn issue_create(
&self,
ctx: &Session,
wk_name: &str,
params: CreateIssue,
) -> Result<IssueResponse, AppError> {
let user_uid = session_user(ctx)?;
let wk = self.workspace_resolve(wk_name).await?;
self.workspace_require_member(wk.id, user_uid).await?;
let title = params.title.trim();
if title.is_empty() {
return Err(AppError::BadRequest(
"issue title is required".to_string(),
));
}
let now = chrono::Utc::now();
let id = uuid::Uuid::now_v7();
let priority = params.priority.unwrap_or_else(|| "normal".to_string());
let due_at = params.due_at.and_then(|d| {
chrono::DateTime::parse_from_rfc3339(&d)
.ok()
.map(|dt| dt.to_utc())
});
let mut txn = self.db.begin().await.map_err(|_| AppError::TxnError)?;
let issue = sqlx::query_as::<_, IssueModel>(
"INSERT INTO issue (id, wk, number, title, body, state, priority, author, due_at, created_at, updated_at) \
VALUES ($1, $2, (SELECT COALESCE(MAX(number), 0) + 1 FROM issue WHERE wk = $2 AND deleted_at IS NULL), \
$3, $4, 'open', $5, $6, $7, $8, $8) \
RETURNING id, wk, number, title, body, state, priority, author, closed_by, closed_at, due_at, created_at, updated_at, deleted_at",
)
.bind(id)
.bind(wk.id)
.bind(title)
.bind(&params.body)
.bind(&priority)
.bind(user_uid)
.bind(due_at)
.bind(now)
.fetch_one(&mut **txn.inner_mut())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
sqlx::query(
"INSERT INTO issue_event (id, issue, actor, event, from_value, to_value, created_at) \
VALUES ($1, $2, $3, 'created', NULL, $4, $5)",
)
.bind(uuid::Uuid::now_v7())
.bind(issue.id)
.bind(user_uid)
.bind(&issue.title)
.bind(now)
.execute(&mut **txn.inner_mut())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
let author = self.users_find_by_id(user_uid).await?;
Ok(IssueResponse {
number: issue.number,
title: issue.title,
body: issue.body,
state: issue.state,
priority: issue.priority,
due_at: issue.due_at,
author: issue_author(author),
closed_by: None,
closed_at: None,
created_at: issue.created_at,
updated_at: issue.updated_at,
labels: Vec::new(),
assignees: Vec::new(),
milestone: None,
repos: Vec::new(),
pull_requests: Vec::new(),
})
}
pub async fn issue_list(
&self,
ctx: &Session,
wk_name: &str,
filter: IssueFilter,
pagination: Pagination,
) -> Result<Vec<IssueResponse>, AppError> {
let user_uid = session_user(ctx)?;
let wk = self.workspace_resolve(wk_name).await?;
self.workspace_require_member(wk.id, user_uid).await?;
let mut conditions =
vec!["i.wk = $1".to_string(), "i.deleted_at IS NULL".to_string()];
let mut param_idx = 2;
if filter.state.is_some() {
conditions.push(format!("i.state = ${param_idx}"));
param_idx += 1;
}
if filter.priority.is_some() {
conditions.push(format!("i.priority = ${param_idx}"));
param_idx += 1;
}
if filter.label.is_some() {
conditions.push(format!(
"EXISTS(SELECT 1 FROM issue_label il INNER JOIN label l ON l.id = il.label WHERE il.issue = i.id AND l.name = ${param_idx})"
));
param_idx += 1;
}
if filter.milestone.is_some() {
conditions.push(format!(
"EXISTS(SELECT 1 FROM issue_milestone im INNER JOIN milestone m ON m.id = im.milestone WHERE im.issue = i.id AND m.title = ${param_idx})"
));
param_idx += 1;
}
if filter.assignee.is_some() {
conditions.push(format!(
"EXISTS(SELECT 1 FROM issue_assignee ia INNER JOIN \"user\" u ON u.id = ia.\"user\" WHERE ia.issue = i.id AND u.username = ${param_idx})"
));
param_idx += 1;
}
let where_clause = conditions.join(" AND ");
let limit_idx = param_idx;
let offset_idx = param_idx + 1;
let query = format!(
"SELECT i.id, i.wk, i.number, i.title, i.body, i.state, i.priority, i.author, \
i.closed_by, i.closed_at, i.due_at, i.created_at, i.updated_at, i.deleted_at \
FROM issue i WHERE {where_clause} \
ORDER BY i.created_at DESC LIMIT ${limit_idx} OFFSET ${offset_idx}"
);
let mut q =
sqlx::query_as::<_, IssueModel>(AssertSqlSafe(query)).bind(wk.id);
if let Some(state) = &filter.state {
q = q.bind(state);
}
if let Some(priority) = &filter.priority {
q = q.bind(priority);
}
if let Some(label_name) = &filter.label {
q = q.bind(label_name);
}
if let Some(milestone_title) = &filter.milestone {
q = q.bind(milestone_title);
}
if let Some(assignee_username) = &filter.assignee {
q = q.bind(assignee_username);
}
q = q
.bind(pagination.limit() as i64)
.bind(pagination.offset() as i64);
let issues = q
.fetch_all(self.db.reader())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
let mut results = Vec::new();
for issue in issues {
results.push(self.issue_build_response(issue).await?);
}
Ok(results)
}
pub async fn issue_get(
&self,
ctx: &Session,
wk_name: &str,
number: i64,
) -> Result<IssueResponse, AppError> {
let user_uid = session_user(ctx)?;
let wk = self.workspace_resolve(wk_name).await?;
self.workspace_require_member(wk.id, user_uid).await?;
let issue = self.issue_resolve(wk.id, number).await?;
self.issue_build_response(issue).await
}
pub async fn issue_update(
&self,
ctx: &Session,
wk_name: &str,
number: i64,
params: UpdateIssue,
) -> Result<IssueResponse, AppError> {
let user_uid = session_user(ctx)?;
let wk = self.workspace_resolve(wk_name).await?;
self.workspace_require_member(wk.id, user_uid).await?;
let mut issue = self.issue_resolve(wk.id, number).await?;
let now = chrono::Utc::now();
let mut txn = self.db.begin().await.map_err(|_| AppError::TxnError)?;
if let Some(title) = &params.title {
let title = title.trim();
if title.is_empty() {
return Err(AppError::BadRequest(
"issue title is required".to_string(),
));
}
if title != &issue.title {
sqlx::query(
"INSERT INTO issue_event (id, issue, actor, event, from_value, to_value, created_at) \
VALUES ($1, $2, $3, 'title_changed', $4, $5, $6)",
)
.bind(uuid::Uuid::now_v7())
.bind(issue.id)
.bind(user_uid)
.bind(&issue.title)
.bind(title)
.bind(now)
.execute(&mut **txn.inner_mut())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
issue.title = title.to_string();
}
}
if let Some(priority) = &params.priority {
if priority != &issue.priority {
sqlx::query(
"INSERT INTO issue_event (id, issue, actor, event, from_value, to_value, created_at) \
VALUES ($1, $2, $3, 'priority_changed', $4, $5, $6)",
)
.bind(uuid::Uuid::now_v7())
.bind(issue.id)
.bind(user_uid)
.bind(&issue.priority)
.bind(priority)
.bind(now)
.execute(&mut **txn.inner_mut())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
issue.priority = priority.to_string();
}
}
let next_body = params.body.map(Some).unwrap_or(issue.body.clone());
let next_due_at = params
.due_at
.and_then(|d| {
chrono::DateTime::parse_from_rfc3339(&d)
.ok()
.map(|dt| dt.to_utc())
})
.or(issue.due_at);
issue = sqlx::query_as::<_, IssueModel>(
"UPDATE issue SET title = $1, body = $2, priority = $3, due_at = $4, updated_at = $5 WHERE id = $6 \
RETURNING id, wk, number, title, body, state, priority, author, closed_by, closed_at, due_at, created_at, updated_at, deleted_at",
)
.bind(&issue.title)
.bind(&next_body)
.bind(&issue.priority)
.bind(next_due_at)
.bind(now)
.bind(issue.id)
.fetch_one(&mut **txn.inner_mut())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
self.issue_build_response(issue).await
}
pub async fn issue_close(
&self,
ctx: &Session,
wk_name: &str,
number: i64,
) -> Result<IssueResponse, AppError> {
let user_uid = session_user(ctx)?;
let wk = self.workspace_resolve(wk_name).await?;
self.workspace_require_member(wk.id, user_uid).await?;
let issue = self.issue_resolve(wk.id, number).await?;
if issue.state == "closed" {
return Err(AppError::BadRequest(
"issue is already closed".to_string(),
));
}
let now = chrono::Utc::now();
sqlx::query(
"UPDATE issue SET state = 'closed', closed_by = $1, closed_at = $2, updated_at = $2 WHERE id = $3",
)
.bind(user_uid)
.bind(now)
.bind(issue.id)
.execute(self.db.writer())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
sqlx::query(
"INSERT INTO issue_event (id, issue, actor, event, from_value, to_value, created_at) \
VALUES ($1, $2, $3, 'closed', $4, 'closed', $5)",
)
.bind(uuid::Uuid::now_v7())
.bind(issue.id)
.bind(user_uid)
.bind(&issue.state)
.bind(now)
.execute(self.db.writer())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
let issue = self.issue_resolve(wk.id, number).await?;
self.issue_build_response(issue).await
}
pub async fn issue_reopen(
&self,
ctx: &Session,
wk_name: &str,
number: i64,
) -> Result<IssueResponse, AppError> {
let user_uid = session_user(ctx)?;
let wk = self.workspace_resolve(wk_name).await?;
self.workspace_require_member(wk.id, user_uid).await?;
let issue = self.issue_resolve(wk.id, number).await?;
if issue.state == "open" {
return Err(AppError::BadRequest(
"issue is already open".to_string(),
));
}
let now = chrono::Utc::now();
sqlx::query(
"UPDATE issue SET state = 'open', closed_by = NULL, closed_at = NULL, updated_at = $1 WHERE id = $2",
)
.bind(now)
.bind(issue.id)
.execute(self.db.writer())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
sqlx::query(
"INSERT INTO issue_event (id, issue, actor, event, from_value, to_value, created_at) \
VALUES ($1, $2, $3, 'reopened', $4, 'open', $5)",
)
.bind(uuid::Uuid::now_v7())
.bind(issue.id)
.bind(user_uid)
.bind(&issue.state)
.bind(now)
.execute(self.db.writer())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
let issue = self.issue_resolve(wk.id, number).await?;
self.issue_build_response(issue).await
}
pub async fn issue_delete(
&self,
ctx: &Session,
wk_name: &str,
number: i64,
) -> Result<(), AppError> {
let user_uid = session_user(ctx)?;
let wk = self.workspace_resolve(wk_name).await?;
self.workspace_require_admin(wk.id, user_uid).await?;
let issue = self.issue_resolve(wk.id, number).await?;
sqlx::query("UPDATE issue SET deleted_at = $1 WHERE id = $2")
.bind(chrono::Utc::now())
.bind(issue.id)
.execute(self.db.writer())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
Ok(())
}
pub(crate) async fn issue_resolve(
&self,
wk_id: uuid::Uuid,
number: i64,
) -> Result<IssueModel, AppError> {
sqlx::query_as::<_, IssueModel>(
"SELECT id, wk, number, title, body, state, priority, author, closed_by, closed_at, due_at, \
created_at, updated_at, deleted_at \
FROM issue WHERE wk = $1 AND number = $2 AND deleted_at IS NULL",
)
.bind(wk_id)
.bind(number)
.fetch_optional(self.db.reader())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?
.ok_or(AppError::IssueNotFound)
}
async fn issue_build_response(
&self,
issue: IssueModel,
) -> Result<IssueResponse, AppError> {
let author = self.users_find_by_id(issue.author).await?;
let closed_by = if let Some(closed_uid) = issue.closed_by {
let user = self.users_find_by_id(closed_uid).await?;
Some(issue_author(user))
} else {
None
};
let labels = self.issue_labels(issue.id).await?;
let assignees = self.issue_assignees(issue.id).await?;
let milestone = self.issue_milestone(issue.id).await?;
let repos = self.issue_repos(issue.id).await?;
let pull_requests = self.issue_pull_requests(issue.id).await?;
Ok(IssueResponse {
number: issue.number,
title: issue.title,
body: issue.body,
state: issue.state,
priority: issue.priority,
due_at: issue.due_at,
author: issue_author(author),
closed_by,
closed_at: issue.closed_at,
created_at: issue.created_at,
updated_at: issue.updated_at,
labels,
assignees,
milestone,
repos,
pull_requests,
})
}
pub(crate) async fn users_find_by_id(
&self,
uid: uuid::Uuid,
) -> Result<UserModel, AppError> {
sqlx::query_as::<_, UserModel>(
"SELECT id, username, display_name, avatar_url, website_url, allow_use, can_search, \
last_sign_in_at, created_at, updated_at \
FROM \"user\" WHERE id = $1 AND allow_use = true",
)
.bind(uid)
.fetch_optional(self.db.reader())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?
.ok_or(AppError::UserNotFound)
}
/// CMDK BFF: list issue numbers, titles, states for a workspace.
pub async fn issue_list_inner(
&self,
wk_name: &str,
) -> Result<Vec<(i32, String, String)>, AppError> {
let wk = sqlx::query_as::<_, (uuid::Uuid,)>(
"SELECT id FROM workspace WHERE name = $1"
)
.bind(wk_name)
.fetch_optional(self.db.reader())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?
.ok_or_else(|| AppError::NotFound("workspace not found".to_string()))?;
let rows = sqlx::query_as::<_, (i32, String, String)>(
"SELECT number, title, state FROM issue WHERE wk = $1 AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 50"
)
.bind(wk.0)
.fetch_all(self.db.reader())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
Ok(rows)
}
}