use models::issue::issue_event_line::IssueEventType; use models::issue::{issue_label, issue_relation_label, issues}; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use crate::errors::IssueError; use crate::services::service::{IssueEventDetail, IssueService}; use crate::share::authz; use crate::share::lookup::{ board_context, lookup_board, lookup_issue, require_issue_access, space_context, }; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct LabelDto { pub id: Uuid, pub board_id: Uuid, pub name: String, pub exclusive: bool, pub exclusive_order: i32, pub description: Option, pub color: String, pub is_archived: bool, pub created_at: OffsetDateTime, pub updated_at: Option, } impl From for LabelDto { fn from(m: issue_label::Model) -> Self { Self { id: m.id, board_id: m.board_id, name: m.name, exclusive: m.exclusive, exclusive_order: m.exclusive_order, description: m.description, color: m.color, is_archived: m.is_archived, created_at: m.created_at, updated_at: m.updated_at, } } } pub type LabelPage = api::pagination::Page; #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateLabelCmd { pub name: String, pub color: String, #[serde(default)] pub exclusive: Option, #[serde(default)] pub exclusive_order: Option, #[serde(default)] pub description: Option, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdateLabelCmd { #[serde(default)] pub name: Option, #[serde(default)] pub color: Option, #[serde(default)] pub exclusive: Option, #[serde(default)] pub exclusive_order: Option, #[serde(default, deserialize_with = "api::serde_patch::deserialize_opt_opt")] pub description: Option>, #[serde(default)] pub is_archived: Option, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] #[serde(deny_unknown_fields)] pub struct ListLabelQuery { pub q: Option, pub is_archived: Option, pub limit: Option, pub offset: Option, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct LabelPath { pub space_name: String, pub board_id: Uuid, pub label_id: Uuid, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct IssueLabelPath { pub space_name: String, pub board_id: Uuid, pub issue_id: String, pub label_id: Uuid, } fn validate_hex_color(color: &str, max_len: usize) -> bool { let clean = color.trim(); if clean.len() != 7 || clean.len() > max_len || !clean.starts_with('#') { return false; } clean[1..].chars().all(|c| c.is_ascii_hexdigit()) } impl IssueService { async fn lookup_managed_label( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, label_id: Uuid, ) -> Result { let space = space_context(self, caller, space_name).await?; authz::require_board_manage(self, &space, caller).await?; let board = lookup_board(self, &space, board_id, caller).await?; let _ = lookup_issue(self, &board, issue_id).await?; let label = issue_label::Entity::find_by_id(label_id) .filter(issue_label::Column::BoardId.eq(board.id)) .one(self.database.read()) .await? .ok_or(IssueError::NotFound)?; Ok(label) } #[tracing::instrument(name = "issues.create_label", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id))] pub async fn create_label( &self, caller: i64, space_name: &str, board_id: Uuid, cmd: CreateLabelCmd, ) -> Result { let space = space_context(self, caller, space_name).await?; authz::require_board_manage(self, &space, caller).await?; let board = lookup_board(self, &space, board_id, caller).await?; let name = cmd.name.trim(); if name.is_empty() || name.chars().count() > self.settings.issues.max_label_name_len { return Err(IssueError::BadRequest( "label name is empty or too long".to_string(), )); } let color = cmd.color.trim(); if !validate_hex_color(color, self.settings.issues.max_label_color_len) { return Err(IssueError::BadRequest( "invalid color format: must be #rrggbb hex code".to_string(), )); } if let Some(eo) = cmd.exclusive_order && eo < 0 { return Err(IssueError::BadRequest( "exclusive_order must be non-negative".to_string(), )); } let now = OffsetDateTime::now_utc(); let row = issue_label::ActiveModel { id: Set(Uuid::now_v7()), board_id: Set(board.id), name: Set(name.to_string()), exclusive: Set(cmd.exclusive.unwrap_or(false)), exclusive_order: Set(cmd.exclusive_order.unwrap_or(0)), description: Set(cmd.description), color: Set(color.to_string()), is_archived: Set(false), created_at: Set(now), updated_at: Set(None), }; let inserted = row.insert(self.database.write()).await?; Ok(LabelDto::from(inserted)) } pub async fn list_labels( &self, caller: i64, space_name: &str, board_id: Uuid, q: ListLabelQuery, ) -> Result { let (_, board) = board_context(self, caller, space_name, board_id).await?; let (limit, offset) = self.paginate(q.limit, q.offset)?; let mut select = issue_label::Entity::find().filter(issue_label::Column::BoardId.eq(board.id)); if let Some(archived) = q.is_archived { select = select.filter(issue_label::Column::IsArchived.eq(archived)); } if let Some(ref search) = q.q { let kw = search.trim(); if !kw.is_empty() { select = select.filter(issue_label::Column::Name.contains(kw)); } } select = select.order_by_desc(issue_label::Column::CreatedAt); let total = select.clone().count(self.database.read()).await?; let rows = select .limit(limit) .offset(offset) .all(self.database.read()) .await?; let items = rows.into_iter().map(LabelDto::from).collect(); Ok(LabelPage { items, total, limit, offset, }) } pub async fn get_label( &self, caller: i64, space_name: &str, board_id: Uuid, label_id: Uuid, ) -> Result { let (_, board) = board_context(self, caller, space_name, board_id).await?; let l = issue_label::Entity::find_by_id(label_id) .filter(issue_label::Column::BoardId.eq(board.id)) .one(self.database.read()) .await? .ok_or(IssueError::NotFound)?; Ok(LabelDto::from(l)) } #[tracing::instrument(name = "issues.update_label", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, label_id = %label_id))] pub async fn update_label( &self, caller: i64, space_name: &str, board_id: Uuid, label_id: Uuid, cmd: UpdateLabelCmd, ) -> Result { let (_, board) = board_context(self, caller, space_name, board_id).await?; let l = issue_label::Entity::find_by_id(label_id) .filter(issue_label::Column::BoardId.eq(board.id)) .one(self.database.read()) .await? .ok_or(IssueError::NotFound)?; let mut am: issue_label::ActiveModel = l.into(); if let Some(new_name) = cmd.name { let name = new_name.trim(); if name.is_empty() || name.chars().count() > self.settings.issues.max_label_name_len { return Err(IssueError::BadRequest( "label name is empty or too long".to_string(), )); } am.name = Set(name.to_string()); } if let Some(new_color) = cmd.color { let color = new_color.trim(); if !validate_hex_color(color, self.settings.issues.max_label_color_len) { return Err(IssueError::BadRequest( "invalid color format: must be #rrggbb hex code".to_string(), )); } am.color = Set(color.to_string()); } if let Some(ex) = cmd.exclusive { am.exclusive = Set(ex); } if let Some(eo) = cmd.exclusive_order { if eo < 0 { return Err(IssueError::BadRequest( "exclusive_order must be non-negative".to_string(), )); } am.exclusive_order = Set(eo); } if let Some(desc) = cmd.description { am.description = Set(desc); } if let Some(archived) = cmd.is_archived { am.is_archived = Set(archived); } let now = OffsetDateTime::now_utc(); am.updated_at = Set(Some(now)); let updated = am.update(self.database.write()).await?; Ok(LabelDto::from(updated)) } #[tracing::instrument(name = "issues.delete_label", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, label_id = %label_id))] pub async fn delete_label( &self, caller: i64, space_name: &str, board_id: Uuid, label_id: Uuid, ) -> Result<(), IssueError> { let (_, board) = board_context(self, caller, space_name, board_id).await?; let _ = issue_label::Entity::find_by_id(label_id) .filter(issue_label::Column::BoardId.eq(board.id)) .one(self.database.read()) .await? .ok_or(IssueError::NotFound)?; Ok(()) } pub async fn list_labels_for_issue( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, ) -> Result, IssueError> { require_issue_access(self, caller, space_name, board_id, issue_id).await?; let rels = issue_relation_label::Entity::find() .filter(issue_relation_label::Column::IssueId.eq(issue_id)) .all(self.database.read()) .await?; if rels.is_empty() { return Ok(Vec::new()); } let label_ids: Vec = rels.into_iter().map(|r| r.label_id).collect(); let labels = issue_label::Entity::find() .filter(issue_label::Column::Id.is_in(label_ids)) .all(self.database.read()) .await?; Ok(labels.into_iter().map(LabelDto::from).collect()) } #[tracing::instrument(name = "issues.bind_label", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id, label_id = %label_id))] pub async fn bind_label( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, label_id: Uuid, ) -> Result<(), IssueError> { let label = self .lookup_managed_label(caller, space_name, board_id, issue_id, label_id) .await?; let existing = issue_relation_label::Entity::find() .filter(issue_relation_label::Column::IssueId.eq(issue_id)) .filter(issue_relation_label::Column::LabelId.eq(label_id)) .one(self.database.read()) .await?; if existing.is_some() { return Err(IssueError::Conflict( "label already bound to issue".to_string(), )); } let now = OffsetDateTime::now_utc(); let label_name = label.name.clone(); let this = self.clone(); self.database .primary() .transaction::<_, (), IssueError>(move |txn| { let name_clone = label_name.clone(); Box::pin(async move { let row = issue_relation_label::ActiveModel { id: Set(Uuid::now_v7()), issue_id: Set(issue_id), label_id: Set(label_id), created_at: Set(now), }; row.insert(txn).await?; this.write_event( txn, issue_id, caller, IssueEventType::LabelAdded, IssueEventDetail { comment_id: None, old_title: None, new_title: None, content: Some(name_clone), }, ) .await?; Ok(()) }) }) .await?; Ok(()) } #[tracing::instrument(name = "issues.unbind_label", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id, label_id = %label_id))] pub async fn unbind_label( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, label_id: Uuid, ) -> Result<(), IssueError> { let label = self .lookup_managed_label(caller, space_name, board_id, issue_id, label_id) .await?; let rel = issue_relation_label::Entity::find() .filter(issue_relation_label::Column::IssueId.eq(issue_id)) .filter(issue_relation_label::Column::LabelId.eq(label_id)) .one(self.database.read()) .await? .ok_or(IssueError::NotFound)?; let label_name = label.name.clone(); let this = self.clone(); self.database .primary() .transaction::<_, (), IssueError>(move |txn| { let name_clone = label_name.clone(); Box::pin(async move { issue_relation_label::Entity::delete_by_id(rel.id) .exec(txn) .await?; this.write_event( txn, issue_id, caller, IssueEventType::LabelRemoved, IssueEventDetail { comment_id: None, old_title: None, new_title: None, content: Some(name_clone), }, ) .await?; Ok(()) }) }) .await?; Ok(()) } } impl IssueService { pub async fn resolve_directory_label( &self, board_id: Uuid, reference: &str, ) -> Result { let reference = reference.trim(); let select = issue_label::Entity::find() .filter(issue_label::Column::BoardId.eq(board_id)) .filter(issue_label::Column::IsArchived.eq(false)); let row = if let Ok(id) = Uuid::parse_str(reference) { select.filter(issue_label::Column::Id.eq(id)) } else { select.filter(issue_label::Column::Name.eq(reference)) } .one(self.database.read()) .await?; row.map(LabelDto::from).ok_or_else(|| { IssueError::BadRequest(format!( "label `{reference}` not found in this repository directory" )) }) } pub async fn directory_labels(&self, board_id: Uuid) -> Result, IssueError> { let rows = issue_label::Entity::find() .filter(issue_label::Column::BoardId.eq(board_id)) .filter(issue_label::Column::IsArchived.eq(false)) .order_by_asc(issue_label::Column::Name) .all(self.database.read()) .await?; Ok(rows.into_iter().map(LabelDto::from).collect()) } /// 拉取请求没有自己的标签容器,借用同一空间内所有看板的标签。 pub async fn resolve_space_label( &self, space_id: Uuid, reference: &str, ) -> Result { let reference = reference.trim(); let boards: Vec = issues::Entity::find() .filter(issues::Column::SpaceId.eq(space_id)) .select_only() .column(issues::Column::Id) .into_tuple() .all(self.database.read()) .await?; let select = issue_label::Entity::find() .filter(issue_label::Column::BoardId.is_in(boards)) .filter(issue_label::Column::IsArchived.eq(false)); let row = if let Ok(id) = Uuid::parse_str(reference) { select.filter(issue_label::Column::Id.eq(id)) } else { select.filter(issue_label::Column::Name.eq(reference)) } .one(self.database.read()) .await?; row.map(LabelDto::from).ok_or_else(|| { IssueError::BadRequest(format!("label `{reference}` not found in this space")) }) } pub async fn space_labels(&self, space_id: Uuid) -> Result, IssueError> { let boards: Vec = issues::Entity::find() .filter(issues::Column::SpaceId.eq(space_id)) .select_only() .column(issues::Column::Id) .into_tuple() .all(self.database.read()) .await?; if boards.is_empty() { return Ok(Vec::new()); } let rows = issue_label::Entity::find() .filter(issue_label::Column::BoardId.is_in(boards)) .filter(issue_label::Column::IsArchived.eq(false)) .order_by_asc(issue_label::Column::Name) .all(self.database.read()) .await?; Ok(rows.into_iter().map(LabelDto::from).collect()) } pub async fn labels_by_ids(&self, ids: Vec) -> Result, IssueError> { if ids.is_empty() { return Ok(Vec::new()); } let rows = issue_label::Entity::find() .filter(issue_label::Column::Id.is_in(ids)) .all(self.database.read()) .await?; Ok(rows.into_iter().map(LabelDto::from).collect()) } }