use chrono::Utc; use db::sqlx; use model::repos::{ RepoReleaseAssetModel, RepoReleaseModel, }; use session::Session; use uuid::Uuid; use crate::error::AppError; use crate::AppService; #[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] pub struct ReleaseResponse { pub id: Uuid, pub tag_name: String, pub target_commit_sha: String, pub name: String, pub body: Option, pub draft: bool, pub prerelease: bool, pub author: Uuid, pub assets: Vec, pub published_at: Option>, pub created_at: chrono::DateTime, } #[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] pub struct ReleaseAssetResponse { pub id: Uuid, pub name: String, pub content_type: Option, pub size: i64, pub download_count: i64, pub created_at: chrono::DateTime, } #[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)] pub struct CreateRelease { pub tag_name: String, pub target_commit_sha: Option, pub name: String, pub body: Option, #[serde(default)] pub draft: bool, #[serde(default)] pub prerelease: bool, } #[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)] pub struct UpdateRelease { pub tag_name: Option, pub name: Option, pub body: Option>, pub draft: Option, pub prerelease: Option, } impl AppService { pub async fn git_release_list( &self, repo_id: Uuid, ) -> Result, AppError> { let releases = sqlx::query_as::<_, RepoReleaseModel>( "SELECT id, repo, tag_name, target_commit_sha, name, body, \ draft, prerelease, author, published_at, created_at, updated_at \ FROM repo_release WHERE repo = $1 ORDER BY created_at DESC LIMIT 50", ) .bind(repo_id) .fetch_all(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; let mut result = Vec::with_capacity(releases.len()); for r in releases { let assets = self.git_release_assets(r.id).await?; result.push(release_to_response(r, assets)); } Ok(result) } pub async fn git_release_get( &self, repo_id: Uuid, release_id: Uuid, ) -> Result { let r = sqlx::query_as::<_, RepoReleaseModel>( "SELECT id, repo, tag_name, target_commit_sha, name, body, \ draft, prerelease, author, published_at, created_at, updated_at \ FROM repo_release WHERE id = $1 AND repo = $2", ) .bind(release_id) .bind(repo_id) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .ok_or_else(|| AppError::NotFound("release not found".to_string()))?; let assets = self.git_release_assets(r.id).await?; Ok(release_to_response(r, assets)) } pub async fn git_release_get_by_tag( &self, repo_id: Uuid, tag: &str, ) -> Result { let r = sqlx::query_as::<_, RepoReleaseModel>( "SELECT id, repo, tag_name, target_commit_sha, name, body, \ draft, prerelease, author, published_at, created_at, updated_at \ FROM repo_release WHERE repo = $1 AND tag_name = $2", ) .bind(repo_id) .bind(tag) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .ok_or_else(|| AppError::NotFound("release not found".to_string()))?; let assets = self.git_release_assets(r.id).await?; Ok(release_to_response(r, assets)) } pub async fn git_release_create( &self, _session: &Session, repo_id: Uuid, user_id: Uuid, params: CreateRelease, ) -> Result { let id = Uuid::now_v7(); let now = Utc::now(); let published_at = if params.draft { None } else { Some(now) }; let target = if let Some(ref sha) = params.target_commit_sha { if !sha.trim().is_empty() { sha.clone() } else { self.default_branch_sha(repo_id).await? } } else { self.default_branch_sha(repo_id).await? }; let r = sqlx::query_as::<_, RepoReleaseModel>( "INSERT INTO repo_release (id, repo, tag_name, target_commit_sha, name, body, \ draft, prerelease, author, published_at, created_at, updated_at) \ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$11) \ RETURNING id, repo, tag_name, target_commit_sha, name, body, \ draft, prerelease, author, published_at, created_at, updated_at", ) .bind(id) .bind(repo_id) .bind(¶ms.tag_name) .bind(&target) .bind(¶ms.name) .bind(¶ms.body) .bind(params.draft) .bind(params.prerelease) .bind(user_id) .bind(published_at) .bind(now) .fetch_one(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(release_to_response(r, Vec::new())) } pub async fn git_release_update( &self, repo_id: Uuid, release_id: Uuid, params: UpdateRelease, ) -> Result { let existing = self.git_release_get(repo_id, release_id).await?; let now = Utc::now(); let tag_name = params.tag_name.unwrap_or(existing.tag_name); let name = params.name.unwrap_or(existing.name); let body = params.body.unwrap_or(existing.body); let draft = params.draft.unwrap_or(existing.draft); let prerelease = params.prerelease.unwrap_or(existing.prerelease); let published_at = if draft { None } else { existing.published_at.or(Some(now)) }; let r = sqlx::query_as::<_, RepoReleaseModel>( "UPDATE repo_release SET tag_name=$1, name=$2, body=$3, draft=$4, \ prerelease=$5, published_at=$6, updated_at=$7 \ WHERE id=$8 AND repo=$9 \ RETURNING id, repo, tag_name, target_commit_sha, name, body, \ draft, prerelease, author, published_at, created_at, updated_at", ) .bind(&tag_name) .bind(&name) .bind(&body) .bind(draft) .bind(prerelease) .bind(published_at) .bind(now) .bind(release_id) .bind(repo_id) .fetch_one(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; let assets = self.git_release_assets(r.id).await?; Ok(release_to_response(r, assets)) } pub async fn git_release_delete( &self, repo_id: Uuid, release_id: Uuid, ) -> Result<(), AppError> { let rows = sqlx::query( "DELETE FROM repo_release WHERE id = $1 AND repo = $2", ) .bind(release_id) .bind(repo_id) .execute(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; if rows.rows_affected() == 0 { return Err(AppError::NotFound("release not found".to_string())); } Ok(()) } pub async fn git_release_delete_by_tag( &self, repo_id: Uuid, tag: &str, ) -> Result<(), AppError> { let release_id: Option = sqlx::query_scalar( "SELECT id FROM repo_release WHERE repo = $1 AND tag_name = $2", ) .bind(repo_id) .bind(tag) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; let release_id = release_id .ok_or_else(|| AppError::NotFound("release not found".to_string()))?; self.git_release_delete(repo_id, release_id).await } async fn git_release_assets( &self, release_id: Uuid, ) -> Result, AppError> { let assets = sqlx::query_as::<_, RepoReleaseAssetModel>( "SELECT id, release_id, name, content_type, size, download_count, \ storage_path, uploader, created_at \ FROM repo_release_asset WHERE release_id = $1 ORDER BY created_at", ) .bind(release_id) .fetch_all(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(assets.into_iter().map(|a| ReleaseAssetResponse { id: a.id, name: a.name, content_type: a.content_type, size: a.size, download_count: a.download_count, created_at: a.created_at, }).collect()) } pub async fn git_release_asset_create( &self, repo_id: Uuid, release_id: Uuid, user_id: Uuid, name: String, content_type: Option, size: i64, storage_path: String, ) -> Result { let _ = self.git_release_get(repo_id, release_id).await?; let id = Uuid::now_v7(); let now = Utc::now(); let a = sqlx::query_as::<_, RepoReleaseAssetModel>( "INSERT INTO repo_release_asset (id, release_id, name, content_type, size, \ download_count, storage_path, uploader, created_at) \ VALUES ($1,$2,$3,$4,$5,0,$6,$7,$8) RETURNING *", ) .bind(id) .bind(release_id) .bind(&name) .bind(&content_type) .bind(size) .bind(&storage_path) .bind(user_id) .bind(now) .fetch_one(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(ReleaseAssetResponse { id: a.id, name: a.name, content_type: a.content_type, size: a.size, download_count: a.download_count, created_at: a.created_at, }) } pub async fn git_release_asset_delete( &self, repo_id: Uuid, release_id: Uuid, asset_id: Uuid, ) -> Result<(), AppError> { let _ = self.git_release_get(repo_id, release_id).await?; sqlx::query( "DELETE FROM repo_release_asset WHERE id = $1 AND release_id = $2", ) .bind(asset_id) .bind(release_id) .execute(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(()) } } impl AppService { pub async fn git_release_list_by_name(&self, ctx: &Session, _user_id: Uuid, wk: &str, repo: &str) -> Result, AppError> { let repo = self.git_require_member(ctx, wk, repo).await?; self.git_release_list(repo.id).await } pub async fn git_release_get_by_name(&self, ctx: &Session, _user_id: Uuid, wk: &str, repo: &str, id: Uuid) -> Result { let repo = self.git_require_member(ctx, wk, repo).await?; self.git_release_get(repo.id, id).await } pub async fn git_release_get_by_tag_name(&self, ctx: &Session, _user_id: Uuid, wk: &str, repo: &str, tag: &str) -> Result { let repo = self.git_require_member(ctx, wk, repo).await?; self.git_release_get_by_tag(repo.id, tag).await } pub async fn git_release_create_by_name(&self, ctx: &Session, user_id: Uuid, wk: &str, repo: &str, params: CreateRelease) -> Result { let repo = self.git_require_member(ctx, wk, repo).await?; self.git_release_create(ctx, repo.id, user_id, params).await } pub async fn git_release_update_by_name(&self, ctx: &Session, _user_id: Uuid, wk: &str, repo: &str, id: Uuid, params: UpdateRelease) -> Result { let repo = self.git_require_member(ctx, wk, repo).await?; self.git_release_update(repo.id, id, params).await } pub async fn git_release_delete_by_name(&self, ctx: &Session, _user_id: Uuid, wk: &str, repo: &str, id: Uuid) -> Result<(), AppError> { let repo = self.git_require_member(ctx, wk, repo).await?; self.git_release_delete(repo.id, id).await } pub async fn git_release_delete_by_tag_name(&self, ctx: &Session, _user_id: Uuid, wk: &str, repo: &str, tag: &str) -> Result<(), AppError> { let repo = self.git_require_member(ctx, wk, repo).await?; self.git_release_delete_by_tag(repo.id, tag).await } } impl AppService { async fn default_branch_sha(&self, repo_id: Uuid) -> Result { sqlx::query_scalar("SELECT target_sha FROM repo_ref WHERE repo = $1 AND is_default = true") .bind(repo_id) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .ok_or_else(|| AppError::BadRequest("no default branch to target".to_string())) } } fn release_to_response( r: RepoReleaseModel, assets: Vec, ) -> ReleaseResponse { ReleaseResponse { id: r.id, tag_name: r.tag_name, target_commit_sha: r.target_commit_sha, name: r.name, body: r.body, draft: r.draft, prerelease: r.prerelease, author: r.author, assets, published_at: r.published_at, created_at: r.created_at, } }