Skip to content
Clone
69f12aa
320 lines 10.6 KB Rust
use crate::context::facade::GitContext;
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<String>,
pub sha: String,
pub num_commits: i32,
pub is_draft: bool,
pub is_prerelease: bool,
pub publisher: Option<UserDto>,
pub publisher_name: String,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}
pub type ReleasePage = Page<ReleaseDto>;
#[derive(Debug, Clone, Deserialize, IntoParams)]
pub struct ListReleasesQuery {
#[serde(default)]
pub limit: Option<u64>,
#[serde(default)]
pub offset: Option<u64>,
/// 仅返回草稿(true)或仅返回已发布(false);缺省返回全部。
#[serde(default)]
pub draft: Option<bool>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateReleaseCmd {
pub tag_name: String,
pub target: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub is_draft: Option<bool>,
#[serde(default)]
pub is_prerelease: Option<bool>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpdateReleaseCmd {
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub is_draft: Option<bool>,
#[serde(default)]
pub is_prerelease: Option<bool>,
}
impl super::GitService {
pub async fn list_releases(
&self,
space_name: String,
repo_name: String,
query: ListReleasesQuery,
) -> Result<ReleasePage, GitError> {
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<ReleaseDto, GitError> {
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<ReleaseDto, GitError> {
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<ReleaseDto, 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);
}
// 已发布的 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<repo_release::Model, GitError> {
self.find_release_optional(repo_id, tag)
.await?
.ok_or(GitError::NotFound)
}
async fn find_release_optional(
&self,
repo_id: Uuid,
tag: &str,
) -> Result<Option<repo_release::Model>, 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<Vec<ReleaseDto>, GitError> {
let uids: Vec<i64> = 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(())
}