use export::pagination::Page; use models::repo::repo_release; use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, }; use serde::{Deserialize, Serialize}; use space::services::spaces::UserDto; use time::OffsetDateTime; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use crate::share::errors::GitError; const MAX_TITLE_LEN: usize = 255; const MAX_CONTENT_LEN: usize = 65_536; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ReleaseDto { pub id: Uuid, pub repo_id: Uuid, pub tag_name: String, pub target: String, pub title: String, pub content: Option, pub sha: String, pub num_commits: i32, pub is_draft: bool, pub is_prerelease: bool, pub publisher: Option, pub publisher_name: String, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } pub type ReleasePage = Page; #[derive(Debug, Clone, Deserialize, IntoParams)] pub struct ListReleasesQuery { #[serde(default)] pub limit: Option, #[serde(default)] pub offset: Option, /// 仅返回草稿(true)或仅返回已发布(false);缺省返回全部。 #[serde(default)] pub draft: Option, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateReleaseCmd { pub tag_name: String, pub target: Option, #[serde(default)] pub title: Option, #[serde(default)] pub content: Option, #[serde(default)] pub is_draft: Option, #[serde(default)] pub is_prerelease: Option, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdateReleaseCmd { #[serde(default)] pub title: Option, #[serde(default)] pub content: Option, #[serde(default)] pub is_draft: Option, #[serde(default)] pub is_prerelease: Option, } impl super::GitService { pub async fn list_releases( &self, space_name: String, repo_name: String, query: ListReleasesQuery, ) -> Result { let ctx = self.context(space_name, repo_name).await?; let limit = query.limit.unwrap_or(20).clamp(1, 100); let offset = query.offset.unwrap_or(0); let mut select = repo_release::Entity::find().filter(repo_release::Column::RepoId.eq(ctx.model.id)); if let Some(draft) = query.draft { select = select.filter(repo_release::Column::IsDraft.eq(draft)); } let total = select.clone().count(self.db_read()).await? as i64; let rows = select .order_by_desc(repo_release::Column::CreatedAt) .offset(offset) .limit(limit) .all(self.db_read()) .await?; let items = self.releases_dto(&rows).await?; Ok(Page { items, total, limit, offset, }) } pub async fn get_release( &self, space_name: String, repo_name: String, tag: String, ) -> Result { let ctx = self.context(space_name, repo_name).await?; let row = self.find_release(ctx.model.id, &tag).await?; let mut out = self.releases_dto(std::slice::from_ref(&row)).await?; out.pop().ok_or(GitError::NotFound) } pub async fn create_release( &self, space_name: String, repo_name: String, publisher: i64, cmd: CreateReleaseCmd, ) -> Result { let tag = cmd.tag_name.trim(); if tag.is_empty() || tag.contains(['\0', '\n', '\r', ' ']) { return Err(GitError::InvalidArgument("invalid tag name".to_string())); } let title = cmd.title.unwrap_or_else(|| tag.to_string()); validate_len(&title, MAX_TITLE_LEN, "release title")?; if let Some(content) = cmd.content.as_deref() { validate_len(content, MAX_CONTENT_LEN, "release content")?; } let ctx = self.context(space_name, repo_name).await?; if self .find_release_optional(ctx.model.id, tag) .await? .is_some() { return Err(GitError::Conflict(format!( "release for tag `{tag}` already exists" ))); } // 目标提交必须存在;缺省取同名标签指向的提交。 let target = match cmd.target.filter(|value| !value.trim().is_empty()) { Some(target) => self .baby(&ctx) .rev_parse(&target) .await .map_err(crate::share::errors::map_baby)? .to_string(), None => self .baby(&ctx) .rev_parse(&format!("refs/tags/{tag}")) .await .map_err(crate::share::errors::map_baby)? .to_string(), }; let publisher_row = models::users::user::Entity::find_by_id(publisher) .one(self.db_read()) .await? .ok_or(GitError::Unauthorized)?; let now = OffsetDateTime::now_utc(); let row = repo_release::ActiveModel { id: Set(Uuid::now_v7()), repo_id: Set(ctx.model.id), publisher_id: Set(publisher), publisher_name: Set(publisher_row.display_name.clone()), tag_name: Set(tag.to_string()), lower_tag_name: Set(tag.to_lowercase()), target: Set(target.clone()), title: Set(title), content: Set(cmd.content), sha: Set(target), num_commits: Set(0), is_draft: Set(cmd.is_draft.unwrap_or(true)), is_prerelease: Set(cmd.is_prerelease.unwrap_or(false)), is_tag: Set(true), created_at: Set(now), updated_at: Set(now), } .insert(self.db_write()) .await?; let mut out = self.releases_dto(std::slice::from_ref(&row)).await?; out.pop().ok_or(GitError::NotFound) } pub async fn update_release( &self, space_name: String, repo_name: String, tag: String, actor: i64, admin: bool, cmd: UpdateReleaseCmd, ) -> Result { let ctx = self.context(space_name, repo_name).await?; let row = self.find_release(ctx.model.id, &tag).await?; if row.publisher_id != actor && !admin { return Err(GitError::Forbidden); } // 已发布的 release 只允许改发布状态标记,正文不可再被改写。 let published = !row.is_draft; if published && (cmd.title.is_some() || cmd.content.is_some()) { return Err(GitError::Conflict( "published release content is immutable".to_string(), )); } if let Some(title) = cmd.title.as_deref() { validate_len(title, MAX_TITLE_LEN, "release title")?; } if let Some(content) = cmd.content.as_deref() { validate_len(content, MAX_CONTENT_LEN, "release content")?; } let actor_row = models::users::user::Entity::find_by_id(actor) .one(self.db_read()) .await? .ok_or(GitError::Unauthorized)?; let mut model: repo_release::ActiveModel = row.into(); if let Some(title) = cmd.title { model.title = Set(title); } if let Some(content) = cmd.content { model.content = Set(Some(content)); } if let Some(is_draft) = cmd.is_draft { model.is_draft = Set(is_draft); // 首次发布时记录实际发布人。 if !is_draft { model.publisher_id = Set(actor); model.publisher_name = Set(actor_row.display_name.clone()); } } if let Some(is_prerelease) = cmd.is_prerelease { model.is_prerelease = Set(is_prerelease); } model.updated_at = Set(OffsetDateTime::now_utc()); let updated = model.update(self.db_write()).await?; let mut out = self.releases_dto(std::slice::from_ref(&updated)).await?; out.pop().ok_or(GitError::NotFound) } pub async fn delete_release( &self, space_name: String, repo_name: String, tag: String, actor: i64, admin: bool, ) -> Result<(), GitError> { let ctx = self.context(space_name, repo_name).await?; let row = self.find_release(ctx.model.id, &tag).await?; if row.publisher_id != actor && !admin { return Err(GitError::Forbidden); } repo_release::Entity::delete_by_id(row.id) .exec(self.db_write()) .await?; Ok(()) } async fn find_release( &self, repo_id: Uuid, tag: &str, ) -> Result { self.find_release_optional(repo_id, tag) .await? .ok_or(GitError::NotFound) } async fn find_release_optional( &self, repo_id: Uuid, tag: &str, ) -> Result, GitError> { Ok(repo_release::Entity::find() .filter(repo_release::Column::RepoId.eq(repo_id)) .filter(repo_release::Column::LowerTagName.eq(tag.trim().to_lowercase())) .one(self.db_read()) .await?) } async fn releases_dto( &self, rows: &[repo_release::Model], ) -> Result, GitError> { let uids: Vec = rows.iter().map(|row| row.publisher_id).collect(); let users = self.user_dto_map(&uids).await?; Ok(rows .iter() .map(|row| ReleaseDto { id: row.id, repo_id: row.repo_id, tag_name: row.tag_name.clone(), target: row.target.clone(), title: row.title.clone(), content: row.content.clone(), sha: row.sha.clone(), num_commits: row.num_commits, is_draft: row.is_draft, is_prerelease: row.is_prerelease, publisher: users.get(&row.publisher_id).cloned(), publisher_name: row.publisher_name.clone(), created_at: row.created_at, updated_at: row.updated_at, }) .collect()) } } fn validate_len(value: &str, max: usize, label: &str) -> Result<(), GitError> { if value.chars().count() > max { return Err(GitError::InvalidArgument(format!( "{label} exceeds {max} characters" ))); } Ok(()) }