feat: init
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::repos::RepoBranch;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateBranchParams {
|
||||
pub name: String,
|
||||
pub commit_sha: String,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_branches(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoBranch>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoBranch>(
|
||||
"SELECT id, repo_id, name, commit_sha, protected, default_branch, created_by, last_push_id, last_push_at, created_at, updated_at FROM repo_branch WHERE repo_id = $1 ORDER BY default_branch DESC, name ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_create_branch(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateBranchParams,
|
||||
) -> Result<RepoBranch, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Member)
|
||||
.await?;
|
||||
let name = required_text(params.name, "name")?;
|
||||
|
||||
let existing = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_branch WHERE repo_id = $1 AND name = $2)",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(&name)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if existing {
|
||||
return Err(AppError::Conflict("branch already exists".into()));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let branch = sqlx::query_as::<_, RepoBranch>(
|
||||
"INSERT INTO repo_branch (id, repo_id, name, commit_sha, protected, default_branch, created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, false, false, $5, $6, $6) RETURNING id, repo_id, name, commit_sha, protected, default_branch, created_by, last_push_id, last_push_at, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(&name)
|
||||
.bind(¶ms.commit_sha)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET branches_count = branches_count + 1, updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(branch)
|
||||
}
|
||||
|
||||
pub async fn repo_set_default_branch(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
branch_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let branch = sqlx::query_as::<_, RepoBranch>(
|
||||
"SELECT id, repo_id, name, commit_sha, protected, default_branch, created_by, last_push_id, last_push_at, created_at, updated_at FROM repo_branch WHERE id = $1 AND repo_id = $2",
|
||||
)
|
||||
.bind(branch_id)
|
||||
.bind(repo_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("branch not found".into()))?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query("UPDATE repo_branch SET default_branch = false, updated_at = $1 WHERE repo_id = $2 AND default_branch = true")
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query("UPDATE repo_branch SET default_branch = true, updated_at = $1 WHERE id = $2")
|
||||
.bind(now)
|
||||
.bind(branch_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query("UPDATE repo SET default_branch = $1, updated_at = $2 WHERE id = $3 AND deleted_at IS NULL")
|
||||
.bind(&branch.name)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_set_branch_protection(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
branch_id: Uuid,
|
||||
protected: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE repo_branch SET protected = $1, updated_at = $2 WHERE id = $3 AND repo_id = $4",
|
||||
)
|
||||
.bind(protected)
|
||||
.bind(now)
|
||||
.bind(branch_id)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "branch not found")?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_delete_branch(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
branch_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let is_default = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT default_branch FROM repo_branch WHERE id = $1 AND repo_id = $2",
|
||||
)
|
||||
.bind(branch_id)
|
||||
.bind(repo_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("branch not found".into()))?;
|
||||
|
||||
if is_default {
|
||||
return Err(AppError::BadRequest("cannot delete default branch".into()));
|
||||
}
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query("DELETE FROM repo_branch WHERE id = $1 AND repo_id = $2")
|
||||
.bind(branch_id)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "branch not found")?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET branches_count = GREATEST(branches_count - 1, 0), updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::{Role, State};
|
||||
use crate::models::repos::{RepoCommitComment, RepoCommitStatus};
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, required_text};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateCommitStatusParams {
|
||||
pub push_commit_id: Uuid,
|
||||
pub latest_commit_sha: String,
|
||||
pub context: String,
|
||||
pub state: String,
|
||||
pub target_url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateCommitCommentParams {
|
||||
pub push_commit_id: Uuid,
|
||||
pub commit_sha: String,
|
||||
pub body: String,
|
||||
pub path: Option<String>,
|
||||
pub line: Option<i32>,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_commit_statuses(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
push_commit_id: Uuid,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoCommitStatus>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoCommitStatus>(
|
||||
"SELECT id, repo_id, push_commit_id, latest_commit_sha, context, state, target_url, description, reported_by, reported_at, created_at, updated_at FROM repo_commit_status WHERE repo_id = $1 AND push_commit_id = $2 ORDER BY created_at DESC LIMIT $3 OFFSET $4",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(push_commit_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_create_commit_status(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateCommitStatusParams,
|
||||
) -> Result<RepoCommitStatus, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Member)
|
||||
.await?;
|
||||
let context = required_text(params.context, "context")?;
|
||||
let state = params
|
||||
.state
|
||||
.trim()
|
||||
.parse::<State>()
|
||||
.map_err(|_| AppError::BadRequest("invalid state".into()))?;
|
||||
if state == State::Unknown {
|
||||
return Err(AppError::BadRequest("invalid state".into()));
|
||||
}
|
||||
let latest_commit_sha = required_text(params.latest_commit_sha, "latest_commit_sha")?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query_as::<_, RepoCommitStatus>(
|
||||
"INSERT INTO repo_commit_status (id, repo_id, push_commit_id, latest_commit_sha, context, \
|
||||
state, target_url, description, reported_by, reported_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10, $10) RETURNING id, repo_id, push_commit_id, latest_commit_sha, context, state, target_url, description, reported_by, reported_at, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(params.push_commit_id)
|
||||
.bind(&latest_commit_sha)
|
||||
.bind(&context)
|
||||
.bind(state)
|
||||
.bind(¶ms.target_url)
|
||||
.bind(¶ms.description)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn repo_commit_comments(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
push_commit_id: Uuid,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoCommitComment>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoCommitComment>(
|
||||
"SELECT id, repo_id, push_commit_id, commit_sha, author_id, body, path, line, resolved, resolved_by, resolved_at, created_at, updated_at, deleted_at FROM repo_commit_comment WHERE repo_id = $1 AND push_commit_id = $2 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT $3 OFFSET $4",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(push_commit_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_create_commit_comment(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateCommitCommentParams,
|
||||
) -> Result<RepoCommitComment, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Member)
|
||||
.await?;
|
||||
let body = required_text(params.body, "body")?;
|
||||
let commit_sha = required_text(params.commit_sha, "commit_sha")?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query_as::<_, RepoCommitComment>(
|
||||
"INSERT INTO repo_commit_comment (id, repo_id, push_commit_id, commit_sha, author_id, body, \
|
||||
path, line, resolved, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, false, $9, $9) RETURNING id, repo_id, push_commit_id, commit_sha, author_id, body, path, line, resolved, resolved_by, resolved_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(params.push_commit_id)
|
||||
.bind(&commit_sha)
|
||||
.bind(user_uid)
|
||||
.bind(&body)
|
||||
.bind(¶ms.path)
|
||||
.bind(params.line)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn repo_resolve_commit_comment(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
comment_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Member)
|
||||
.await?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE repo_commit_comment SET resolved = true, resolved_by = $1, resolved_at = $2, updated_at = $2 \
|
||||
WHERE id = $3 AND repo_id = $4 AND deleted_at IS NULL AND resolved = false",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.bind(comment_id)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
super::util::ensure_affected(
|
||||
result.rows_affected(),
|
||||
"comment not found or already resolved",
|
||||
)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::{GitService, Role, Visibility};
|
||||
use crate::models::repos::Repo;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{
|
||||
clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text,
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateRepoParams {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub visibility: Option<String>,
|
||||
pub default_branch: Option<String>,
|
||||
pub git_service: Option<String>,
|
||||
pub storage_node_ids: Option<Vec<Uuid>>,
|
||||
pub storage_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateRepoParams {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub visibility: Option<String>,
|
||||
pub default_branch: Option<String>,
|
||||
}
|
||||
|
||||
fn validate_storage_path(path: &str) -> Result<String, AppError> {
|
||||
let path = path.trim().trim_matches('/');
|
||||
if path.is_empty()
|
||||
|| path.contains("..")
|
||||
|| path.split('/').any(|part| part.is_empty() || part == ".")
|
||||
{
|
||||
return Err(AppError::BadRequest("storage_path is invalid".into()));
|
||||
}
|
||||
Ok(path.to_string())
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_list(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Repo>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
self.ensure_workspace_readable(user_uid, &ws).await?;
|
||||
let workspace_id = ws.id;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, Repo>(
|
||||
"SELECT id, workspace_id, owner_id, name, description, default_branch, visibility, status, is_fork, forked_from_repo_id, storage_node_ids, primary_storage_node_id, storage_path, git_service, archived_at, created_at, updated_at, deleted_at \
|
||||
FROM repo WHERE workspace_id = $1 AND deleted_at IS NULL AND (owner_id = $2 OR visibility = 'public' OR id IN (SELECT repo_id FROM repo_member WHERE user_id = $2 AND status = 'active') OR (visibility = 'internal' AND EXISTS (SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active'))) \
|
||||
ORDER BY created_at DESC LIMIT $3 OFFSET $4",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(user_uid)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_get(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<Repo, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
pub async fn repo_create(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
params: CreateRepoParams,
|
||||
) -> Result<Repo, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let workspace_id = ws.id;
|
||||
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Member)
|
||||
.await?;
|
||||
|
||||
let name = required_text(params.name, "name")?;
|
||||
let visibility = match params.visibility {
|
||||
Some(ref v) => parse_enum(
|
||||
Some(v.clone()),
|
||||
Visibility::Private,
|
||||
Visibility::Unknown,
|
||||
"visibility",
|
||||
)?,
|
||||
None => {
|
||||
let settings_visibility: String = sqlx::query_scalar(
|
||||
"SELECT default_repo_visibility FROM workspace_settings WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
settings_visibility.parse().unwrap_or(Visibility::Private)
|
||||
}
|
||||
};
|
||||
if visibility == Visibility::Public {
|
||||
let allow_public_repos: bool = sqlx::query_scalar(
|
||||
"SELECT allow_public_repos FROM workspace_settings WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if !allow_public_repos {
|
||||
return Err(AppError::BadRequest(
|
||||
"public repositories are disabled for this workspace".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let default_branch = required_text(
|
||||
params.default_branch.unwrap_or_else(|| "main".to_string()),
|
||||
"default_branch",
|
||||
)?;
|
||||
let git_service = match params.git_service {
|
||||
Some(ref v) => parse_enum(
|
||||
Some(v.clone()),
|
||||
GitService::Local,
|
||||
GitService::Unknown,
|
||||
"git_service",
|
||||
)?,
|
||||
None => GitService::Local,
|
||||
};
|
||||
|
||||
let available_storage_nodes: std::collections::HashSet<Uuid> =
|
||||
self.ctx.registry.git_node_ids().into_iter().collect();
|
||||
if available_storage_nodes.is_empty() {
|
||||
return Err(AppError::Config("no git storage nodes configured".into()));
|
||||
}
|
||||
let storage_node_ids = params.storage_node_ids.unwrap_or_else(|| {
|
||||
available_storage_nodes
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<Vec<Uuid>>()
|
||||
});
|
||||
if storage_node_ids.is_empty()
|
||||
|| storage_node_ids
|
||||
.iter()
|
||||
.any(|node_id| !available_storage_nodes.contains(node_id))
|
||||
{
|
||||
return Err(AppError::BadRequest("invalid storage_node_ids".into()));
|
||||
}
|
||||
let primary_storage_node_id = storage_node_ids[0];
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let repo_id = Uuid::now_v7();
|
||||
let storage_path = match params.storage_path {
|
||||
Some(path) if !path.trim().is_empty() => validate_storage_path(&path)?,
|
||||
Some(_) => return Err(AppError::BadRequest("storage_path is invalid".into())),
|
||||
None => format!("repos/{}/{}", workspace_id, repo_id),
|
||||
};
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let repo = sqlx::query_as::<_, Repo>(
|
||||
"INSERT INTO repo (id, workspace_id, owner_id, name, description, default_branch, \
|
||||
visibility, status, is_fork, forked_from_repo_id, storage_node_ids, \
|
||||
primary_storage_node_id, storage_path, git_service, archived_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', false, NULL, $8, $9, $10, $11, NULL, $12, $12) \
|
||||
RETURNING id, workspace_id, owner_id, name, description, default_branch, visibility, status, is_fork, forked_from_repo_id, storage_node_ids, primary_storage_node_id, storage_path, git_service, archived_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(workspace_id)
|
||||
.bind(user_uid)
|
||||
.bind(&name)
|
||||
.bind(params.description.as_deref())
|
||||
.bind(&default_branch)
|
||||
.bind(visibility)
|
||||
.bind(&storage_node_ids)
|
||||
.bind(primary_storage_node_id)
|
||||
.bind(&storage_path)
|
||||
.bind(git_service)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO repo_member (id, repo_id, user_id, role, status, joined_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, 'owner', 'active', $4, $4, $4)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO repo_stats (repo_id, stars_count, watchers_count, forks_count, branches_count, \
|
||||
tags_count, commits_count, releases_count, open_issues_count, open_pull_requests_count, \
|
||||
size_bytes, updated_at) \
|
||||
VALUES ($1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, $2)",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO repo_branch (id, repo_id, name, commit_sha, protected, default_branch, created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, '', false, true, $4, $5, $5)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(&default_branch)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE workspace_stats SET repos_count = repos_count + 1, updated_at = $1 WHERE workspace_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(workspace_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
// Init git repo on primary node (nodes auto-sync).
|
||||
if let Some(mut client) = self.ctx.registry.get_git_client(&primary_storage_node_id) {
|
||||
let req = tonic::Request::new(crate::pb::repo::InitRepositoryRequest {
|
||||
repository: Some(crate::pb::repo::RepositoryHeader {
|
||||
storage_name: ws.name.clone(),
|
||||
relative_path: format!("{}.git", name),
|
||||
storage_path: storage_path.clone(),
|
||||
}),
|
||||
bare: true,
|
||||
object_format: crate::pb::repo::ObjectFormat::Sha1 as i32,
|
||||
initial_branch: default_branch.clone(),
|
||||
});
|
||||
if let Err(err) = client.repository.init_repository(req).await {
|
||||
tracing::error!(repo_id = %repo_id, error = %err, "Failed to init git repo");
|
||||
let _ = sqlx::query(
|
||||
"UPDATE repo SET status = 'deleted', deleted_at = $1 WHERE id = $2",
|
||||
)
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(repo_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await;
|
||||
let _ = sqlx::query("UPDATE workspace_stats SET repos_count = GREATEST(repos_count - 1, 0), updated_at = $1 WHERE workspace_id = $2")
|
||||
.bind(chrono::Utc::now()).bind(workspace_id).execute(self.ctx.db.writer()).await;
|
||||
return Err(AppError::InternalServerError(err.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
pub async fn repo_update(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: UpdateRepoParams,
|
||||
) -> Result<Repo, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let name =
|
||||
merge_optional_text(params.name, Some(repo.name.clone())).unwrap_or(repo.name.clone());
|
||||
let description = merge_optional_text(params.description, repo.description);
|
||||
let visibility = parse_enum(
|
||||
params.visibility,
|
||||
repo.visibility,
|
||||
Visibility::Unknown,
|
||||
"visibility",
|
||||
)?;
|
||||
if visibility == Visibility::Public {
|
||||
let allow_public_repos: bool = sqlx::query_scalar(
|
||||
"SELECT allow_public_repos FROM workspace_settings WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(repo.workspace_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if !allow_public_repos {
|
||||
return Err(AppError::BadRequest(
|
||||
"public repositories are disabled for this workspace".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo SET name = $1, description = $2, visibility = $3, updated_at = $4 \
|
||||
WHERE id = $5 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(&name)
|
||||
.bind(&description)
|
||||
.bind(visibility)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if let Some(ref new_default) = params.default_branch
|
||||
&& new_default != &repo.default_branch
|
||||
{
|
||||
// Check if the branch exists
|
||||
let branch_exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_branch WHERE repo_id = $1 AND name = $2)",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(new_default)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if !branch_exists {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Branch '{}' does not exist",
|
||||
new_default
|
||||
)));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_branch SET default_branch = false, updated_at = $1 WHERE repo_id = $2 AND default_branch = true",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_branch SET default_branch = true, updated_at = $1 WHERE repo_id = $2 AND name = $3",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.bind(new_default)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query("UPDATE repo SET default_branch = $1, updated_at = $2 WHERE id = $3")
|
||||
.bind(new_default)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
let result = sqlx::query_as::<_, Repo>(
|
||||
"SELECT id, workspace_id, owner_id, name, description, default_branch, visibility, status, is_fork, forked_from_repo_id, storage_node_ids, primary_storage_node_id, storage_path, git_service, archived_at, created_at, updated_at, deleted_at \
|
||||
FROM repo WHERE id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn repo_archive(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Owner)
|
||||
.await?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE repo SET status = 'archived', archived_at = $1, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL AND status <> 'archived'",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "repo not found or already archived")?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_unarchive(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Owner)
|
||||
.await?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE repo SET status = 'active', archived_at = NULL, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL AND status = 'archived'",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "repo not found or not archived")?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_delete(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Owner)
|
||||
.await?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE repo SET deleted_at = $1, status = 'deleted', updated_at = $1 WHERE id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "repo not found")?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE workspace_stats SET repos_count = GREATEST(repos_count - 1, 0), updated_at = $1 WHERE workspace_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo.workspace_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_transfer_owner(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
new_owner_id: Uuid,
|
||||
) -> Result<Repo, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Owner)
|
||||
.await?;
|
||||
|
||||
if new_owner_id == repo.owner_id {
|
||||
return Err(AppError::BadRequest(
|
||||
"new owner must be different from current owner".into(),
|
||||
));
|
||||
}
|
||||
let is_workspace_member = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')",
|
||||
)
|
||||
.bind(repo.workspace_id)
|
||||
.bind(new_owner_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
let is_member = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_member WHERE repo_id = $1 AND user_id = $2 AND status = 'active')",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(new_owner_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if !is_workspace_member || !is_member {
|
||||
return Err(AppError::BadRequest(
|
||||
"new owner must be an active workspace and repo member".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query("UPDATE repo_member SET role = 'owner', updated_at = $1 WHERE repo_id = $2 AND user_id = $3")
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.bind(new_owner_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query("UPDATE repo_member SET role = 'admin', updated_at = $1 WHERE repo_id = $2 AND user_id = $3")
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query_as::<_, Repo>(
|
||||
"UPDATE repo SET owner_id = $1, updated_at = $2 WHERE id = $3 AND deleted_at IS NULL \
|
||||
RETURNING id, workspace_id, owner_id, name, description, default_branch, visibility, status, is_fork, forked_from_repo_id, storage_node_ids, primary_storage_node_id, storage_path, git_service, archived_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(new_owner_id)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_workspace(
|
||||
&self,
|
||||
wk_name: &str,
|
||||
) -> Result<crate::models::workspaces::Workspace, AppError> {
|
||||
crate::models::workspaces::Workspace::find_by_name(self.ctx.db.reader(), wk_name)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("workspace not found".into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_repo(
|
||||
&self,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<Repo, AppError> {
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
Repo::find_by_name(self.ctx.db.reader(), ws.id, repo_name)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("repo not found".into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn find_repo_by_id(&self, repo_id: Uuid) -> Result<Repo, AppError> {
|
||||
sqlx::query_as::<_, Repo>(
|
||||
"SELECT id, workspace_id, owner_id, name, description, default_branch, visibility, status, is_fork, forked_from_repo_id, storage_node_ids, primary_storage_node_id, storage_path, git_service, archived_at, created_at, updated_at, deleted_at FROM repo WHERE id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("repo not found".into()))
|
||||
}
|
||||
|
||||
pub async fn repo_user_role(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
repo_id: Uuid,
|
||||
) -> Result<Option<Role>, AppError> {
|
||||
let role_str: Option<String> = sqlx::query_scalar(
|
||||
"SELECT role FROM repo_member WHERE repo_id = $1 AND user_id = $2 AND status = 'active'",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
match role_str {
|
||||
Some(r) => Ok(Some(r.parse().unwrap_or(Role::Unknown))),
|
||||
None => {
|
||||
let repo = self.find_repo_by_id(repo_id).await?;
|
||||
if repo.owner_id == user_uid {
|
||||
return Ok(Some(Role::Owner));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ensure_repo_readable(&self, user_uid: Uuid, repo: &Repo) -> Result<(), AppError> {
|
||||
if repo.owner_id == user_uid {
|
||||
return Ok(());
|
||||
}
|
||||
let is_workspace_member = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')",
|
||||
)
|
||||
.bind(repo.workspace_id)
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
let is_member = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_member WHERE repo_id = $1 AND user_id = $2 AND status = 'active')",
|
||||
)
|
||||
.bind(repo.id)
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
match repo.visibility {
|
||||
Visibility::Public => Ok(()),
|
||||
Visibility::Internal if is_workspace_member => Ok(()),
|
||||
Visibility::Private if is_workspace_member && is_member => Ok(()),
|
||||
_ => Err(AppError::Unauthorized),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ensure_repo_role_at_least(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
repo: &Repo,
|
||||
min_role: Role,
|
||||
) -> Result<Role, AppError> {
|
||||
if repo.owner_id == user_uid {
|
||||
return Ok(Role::Owner);
|
||||
}
|
||||
let is_workspace_member = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')",
|
||||
)
|
||||
.bind(repo.workspace_id)
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if !is_workspace_member {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
let role_str: Option<String> = sqlx::query_scalar(
|
||||
"SELECT role FROM repo_member WHERE repo_id = $1 AND user_id = $2 AND status = 'active'",
|
||||
)
|
||||
.bind(repo.id)
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let role = role_str
|
||||
.and_then(|r| r.parse::<Role>().ok())
|
||||
.unwrap_or(Role::Unknown);
|
||||
|
||||
if super::util::role_level(role) < super::util::role_level(min_role) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
Ok(role)
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_workspace_readable(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
ws: &crate::models::workspaces::Workspace,
|
||||
) -> Result<(), AppError> {
|
||||
let readable =
|
||||
crate::models::workspaces::Workspace::is_readable(self.ctx.db.reader(), ws, user_uid)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if readable {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_workspace_role_at_least(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
ws: &crate::models::workspaces::Workspace,
|
||||
min_role: Role,
|
||||
) -> Result<Role, AppError> {
|
||||
let role = crate::models::workspaces::Workspace::user_role(
|
||||
self.ctx.db.reader(),
|
||||
ws.id,
|
||||
user_uid,
|
||||
ws.owner_id,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.unwrap_or(Role::Unknown);
|
||||
if crate::service::util::role_level(role) < crate::service::util::role_level(min_role) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
Ok(role)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::{KeyType, Role};
|
||||
use crate::models::repos::RepoDeployKey;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct AddDeployKeyParams {
|
||||
pub title: String,
|
||||
pub public_key: String,
|
||||
pub key_type: String,
|
||||
pub read_only: Option<bool>,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_deploy_keys(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoDeployKey>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoDeployKey>(
|
||||
"SELECT id, repo_id, title, public_key, fingerprint_sha256, key_type, read_only, last_used_at, expires_at, revoked_at, created_by, created_at, updated_at FROM repo_deploy_key WHERE repo_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_add_deploy_key(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: AddDeployKeyParams,
|
||||
) -> Result<RepoDeployKey, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let title = required_text(params.title, "title")?;
|
||||
let public_key = required_text(params.public_key, "public_key")?;
|
||||
let key_type = params
|
||||
.key_type
|
||||
.trim()
|
||||
.parse::<KeyType>()
|
||||
.map_err(|_| AppError::BadRequest("invalid key_type".into()))?;
|
||||
if key_type == KeyType::Unknown {
|
||||
return Err(AppError::BadRequest("invalid key_type".into()));
|
||||
}
|
||||
|
||||
use base64::Engine;
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(public_key.trim())
|
||||
.unwrap_or_else(|_| public_key.as_bytes().to_vec());
|
||||
let fingerprint = super::deploy_keys::sha256_hex(&decoded);
|
||||
|
||||
let existing = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_deploy_key WHERE fingerprint_sha256 = $1 AND repo_id = $2 AND revoked_at IS NULL LIMIT 1)",
|
||||
)
|
||||
.bind(&fingerprint)
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if existing {
|
||||
return Err(AppError::Conflict("deploy key already exists".into()));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let key = sqlx::query_as::<_, RepoDeployKey>(
|
||||
"INSERT INTO repo_deploy_key (id, repo_id, title, public_key, fingerprint_sha256, key_type, \
|
||||
read_only, created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) RETURNING id, repo_id, title, public_key, fingerprint_sha256, key_type, read_only, last_used_at, expires_at, revoked_at, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(title)
|
||||
.bind(&public_key)
|
||||
.bind(&fingerprint)
|
||||
.bind(key_type)
|
||||
.bind(params.read_only.unwrap_or(true))
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
pub async fn repo_delete_deploy_key(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
key_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE repo_deploy_key SET revoked_at = $1, updated_at = $1 WHERE id = $2 AND repo_id = $3 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(key_id)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "key not found")?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sha256_hex(data: &[u8]) -> String {
|
||||
use sha2::Digest;
|
||||
sha2::Sha256::digest(data)
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::repos::{Repo, RepoFork};
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::clamp_limit_offset;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct ForkRepoParams {
|
||||
pub target_workspace_name: Option<String>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_forks(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoFork>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoFork>(
|
||||
"SELECT id, parent_repo_id, fork_repo_id, forked_by, created_at \
|
||||
FROM repo_fork WHERE parent_repo_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(repo.id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self, ctx, params))]
|
||||
pub async fn repo_fork(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: ForkRepoParams,
|
||||
) -> Result<Repo, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let parent = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &parent).await?;
|
||||
|
||||
let ws_name = params.target_workspace_name.as_deref().unwrap_or(wk_name);
|
||||
let ws = self.resolve_workspace(ws_name).await?;
|
||||
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Member)
|
||||
.await?;
|
||||
|
||||
let fork_name = params.name.as_deref().unwrap_or(repo_name);
|
||||
|
||||
let existing = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo WHERE workspace_id = $1 AND name = $2 AND deleted_at IS NULL)",
|
||||
)
|
||||
.bind(ws.id).bind(fork_name)
|
||||
.fetch_one(self.ctx.db.reader()).await.map_err(AppError::Database)?;
|
||||
if existing {
|
||||
return Err(AppError::Conflict(
|
||||
"repo name already taken in target workspace".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let fork_id = Uuid::now_v7();
|
||||
let storage_path = format!("repos/{}/{}", ws.id, fork_id);
|
||||
let storage_node_ids = parent.storage_node_ids.clone();
|
||||
let primary_node_id = parent.primary_storage_node_id;
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let fork = sqlx::query_as::<_, Repo>(
|
||||
"INSERT INTO repo (id, workspace_id, owner_id, name, description, default_branch, \
|
||||
visibility, status, is_fork, forked_from_repo_id, storage_node_ids, \
|
||||
primary_storage_node_id, storage_path, git_service, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'private', 'active', true, $7, $8, $9, $10, $11, $12, $12) \
|
||||
RETURNING id, workspace_id, owner_id, name, description, default_branch, visibility, status, \
|
||||
is_fork, forked_from_repo_id, storage_node_ids, primary_storage_node_id, storage_path, \
|
||||
git_service, archived_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(fork_id).bind(ws.id).bind(user_uid)
|
||||
.bind(fork_name).bind(parent.description.as_deref())
|
||||
.bind(&parent.default_branch).bind(parent.id)
|
||||
.bind(&storage_node_ids).bind(primary_node_id)
|
||||
.bind(&storage_path).bind(parent.git_service)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn).await.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO repo_member (id, repo_id, user_id, role, status, joined_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, 'owner', 'active', $4, $4, $4)",
|
||||
)
|
||||
.bind(Uuid::now_v7()).bind(fork_id).bind(user_uid).bind(now)
|
||||
.execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO repo_stats (repo_id, stars_count, watchers_count, forks_count, branches_count, \
|
||||
tags_count, commits_count, releases_count, open_issues_count, open_pull_requests_count, \
|
||||
size_bytes, updated_at) \
|
||||
VALUES ($1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, $2)",
|
||||
)
|
||||
.bind(fork_id).bind(now)
|
||||
.execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO repo_branch (id, repo_id, name, commit_sha, protected, default_branch, created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, '', false, true, $4, $5, $5)",
|
||||
)
|
||||
.bind(Uuid::now_v7()).bind(fork_id).bind(&parent.default_branch).bind(user_uid).bind(now)
|
||||
.execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO repo_fork (id, parent_repo_id, fork_repo_id, forked_by, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(parent.id)
|
||||
.bind(fork_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET forks_count = forks_count + 1, updated_at = $1 WHERE repo_id = $2",
|
||||
).bind(now).bind(parent.id).execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE workspace_stats SET repos_count = repos_count + 1, updated_at = $1 WHERE workspace_id = $2",
|
||||
).bind(now).bind(ws.id).execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
if let Some(mut client) = self.ctx.registry.get_git_client(&primary_node_id) {
|
||||
let parent_ws = self.resolve_workspace(wk_name).await?;
|
||||
let _header = crate::pb::repo::RepositoryHeader {
|
||||
storage_name: parent_ws.name.clone(),
|
||||
relative_path: format!("{}.git", parent.name),
|
||||
storage_path: parent.storage_path.clone(),
|
||||
};
|
||||
let fork_header = crate::pb::repo::RepositoryHeader {
|
||||
storage_name: ws.name.clone(),
|
||||
relative_path: format!("{}.git", fork_name),
|
||||
storage_path: storage_path.clone(),
|
||||
};
|
||||
let _ = client
|
||||
.repository
|
||||
.init_repository(tonic::Request::new(
|
||||
crate::pb::repo::InitRepositoryRequest {
|
||||
repository: Some(fork_header),
|
||||
bare: true,
|
||||
object_format: crate::pb::repo::ObjectFormat::Sha1 as i32,
|
||||
initial_branch: parent.default_branch.clone(),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing::info!(fork_id = %fork_id, parent_id = %parent.id, "Repo forked");
|
||||
Ok(fork)
|
||||
}
|
||||
|
||||
pub async fn repo_sync_fork(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<Repo, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let fork = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role_at_least(user_uid, &fork, Role::Member)
|
||||
.await?;
|
||||
|
||||
if !fork.is_fork {
|
||||
return Err(AppError::BadRequest("repo is not a fork".into()));
|
||||
}
|
||||
let parent_id = fork
|
||||
.forked_from_repo_id
|
||||
.ok_or(AppError::BadRequest("parent repo not found".into()))?;
|
||||
let parent = Repo::find_by_id(self.ctx.db.reader(), parent_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("parent repo not found".into()))?;
|
||||
|
||||
let header = self.repo_header(&fork, &self.resolve_workspace(wk_name).await?);
|
||||
let parent_ws = self.find_ws_for_repo(&parent).await?;
|
||||
let _parent_header = self.repo_header(&parent, &parent_ws);
|
||||
|
||||
let mut client = self.git_client(&fork)?;
|
||||
let result = client
|
||||
.merge
|
||||
.merge(tonic::Request::new(crate::pb::repo::MergeRequest {
|
||||
repository: Some(header),
|
||||
target_branch: fork.default_branch.clone(),
|
||||
source: Some(crate::pb::repo::ObjectSelector {
|
||||
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
|
||||
crate::pb::repo::ObjectName {
|
||||
revision: parent.default_branch.clone(),
|
||||
},
|
||||
)),
|
||||
}),
|
||||
committer: None,
|
||||
message: format!("Sync from upstream {}/{}", parent_ws.name, parent.name),
|
||||
options: None,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("sync failed: {e}")))?;
|
||||
|
||||
let merge_result = result.into_inner();
|
||||
if merge_result.status
|
||||
== crate::pb::repo::merge_result::Status::MergeResultStatusConflicts as i32
|
||||
{
|
||||
return Err(AppError::Conflict(
|
||||
"sync failed: merge conflicts with upstream".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(fork)
|
||||
}
|
||||
|
||||
pub(crate) async fn find_ws_for_repo(
|
||||
&self,
|
||||
repo: &Repo,
|
||||
) -> Result<crate::models::workspaces::Workspace, AppError> {
|
||||
sqlx::query_as::<_, crate::models::workspaces::Workspace>(
|
||||
"SELECT id, owner_id, name, description, avatar_url, visibility, plan, status, \
|
||||
default_role, is_personal, archived_at, created_at, updated_at, deleted_at \
|
||||
FROM workspace WHERE id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(repo.workspace_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("workspace not found".into()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use crate::error::AppError;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
impl RepoService {
|
||||
pub async fn git_blame(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
revision: &str,
|
||||
path: &str,
|
||||
page_size: u32,
|
||||
) -> Result<crate::pb::repo::BlameResponse, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.blame;
|
||||
let resp = svc
|
||||
.blame(tonic::Request::new(crate::pb::repo::BlameRequest {
|
||||
repository: Some(header),
|
||||
revision: Some(crate::pb::repo::ObjectSelector {
|
||||
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
|
||||
crate::pb::repo::ObjectName {
|
||||
revision: revision.to_string(),
|
||||
},
|
||||
)),
|
||||
}),
|
||||
path: path.to_string(),
|
||||
range: None,
|
||||
options: None,
|
||||
pagination: Some(crate::pb::repo::Pagination {
|
||||
page_size,
|
||||
page_token: String::new(),
|
||||
}),
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
use crate::error::AppError;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
impl RepoService {
|
||||
pub async fn git_list_branches(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
pattern: Option<String>,
|
||||
page_size: u32,
|
||||
page_token: Option<String>,
|
||||
) -> Result<crate::pb::repo::ListBranchesResponse, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.branch;
|
||||
let resp = svc
|
||||
.list_branches(tonic::Request::new(crate::pb::repo::ListBranchesRequest {
|
||||
repository: Some(header),
|
||||
pattern: pattern.unwrap_or_default(),
|
||||
merged_into_head: false,
|
||||
not_merged_into_head: false,
|
||||
pagination: Some(crate::pb::repo::Pagination {
|
||||
page_size,
|
||||
page_token: page_token.unwrap_or_default(),
|
||||
}),
|
||||
sort_direction: crate::pb::repo::SortDirection::Desc as i32,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_get_branch(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
branch_name: &str,
|
||||
) -> Result<crate::pb::repo::Branch, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.branch;
|
||||
let resp = svc
|
||||
.get_branch(tonic::Request::new(crate::pb::repo::GetBranchRequest {
|
||||
repository: Some(header),
|
||||
name: branch_name.to_string(),
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_create_branch(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
branch_name: &str,
|
||||
start_point: &str,
|
||||
) -> Result<crate::pb::repo::Branch, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Member)
|
||||
.await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.branch;
|
||||
let resp = svc
|
||||
.create_branch(tonic::Request::new(crate::pb::repo::CreateBranchRequest {
|
||||
repository: Some(header),
|
||||
name: branch_name.to_string(),
|
||||
start_point: Some(crate::pb::repo::ObjectSelector {
|
||||
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
|
||||
crate::pb::repo::ObjectName {
|
||||
revision: start_point.to_string(),
|
||||
},
|
||||
)),
|
||||
}),
|
||||
force: false,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_delete_branch(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
branch_name: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Admin)
|
||||
.await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.branch;
|
||||
svc.delete_branch(tonic::Request::new(crate::pb::repo::DeleteBranchRequest {
|
||||
repository: Some(header),
|
||||
name: branch_name.to_string(),
|
||||
force: false,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn git_compare_branches(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
source_branch: &str,
|
||||
target_branch: &str,
|
||||
) -> Result<crate::pb::repo::CompareBranchResponse, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.branch;
|
||||
let resp = svc
|
||||
.compare_branch(tonic::Request::new(crate::pb::repo::CompareBranchRequest {
|
||||
repository: Some(header),
|
||||
source_branch: source_branch.to_string(),
|
||||
target_branch: target_branch.to_string(),
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use crate::error::AppError;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
impl RepoService {
|
||||
pub async fn git_list_commits(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
revision: &str,
|
||||
path: Option<String>,
|
||||
page_size: u32,
|
||||
) -> Result<crate::pb::repo::ListCommitsResponse, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.commit;
|
||||
let resp = svc
|
||||
.list_commits(tonic::Request::new(crate::pb::repo::ListCommitsRequest {
|
||||
repository: Some(header),
|
||||
revision: Some(crate::pb::repo::ObjectSelector {
|
||||
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
|
||||
crate::pb::repo::ObjectName {
|
||||
revision: revision.to_string(),
|
||||
},
|
||||
)),
|
||||
}),
|
||||
path: path.unwrap_or_default(),
|
||||
since: None,
|
||||
until: None,
|
||||
first_parent: false,
|
||||
all: false,
|
||||
reverse: false,
|
||||
max_parents: 0,
|
||||
min_parents: 0,
|
||||
pagination: Some(crate::pb::repo::Pagination {
|
||||
page_size,
|
||||
page_token: String::new(),
|
||||
}),
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_get_commit(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
revision: &str,
|
||||
) -> Result<crate::pb::repo::Commit, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.commit;
|
||||
let resp = svc
|
||||
.get_commit(tonic::Request::new(crate::pb::repo::GetCommitRequest {
|
||||
repository: Some(header),
|
||||
revision: Some(crate::pb::repo::ObjectSelector {
|
||||
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
|
||||
crate::pb::repo::ObjectName {
|
||||
revision: revision.to_string(),
|
||||
},
|
||||
)),
|
||||
}),
|
||||
include_stats: true,
|
||||
include_raw: false,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use crate::error::AppError;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
fn rev(revision: &str) -> crate::pb::repo::ObjectSelector {
|
||||
crate::pb::repo::ObjectSelector {
|
||||
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
|
||||
crate::pb::repo::ObjectName {
|
||||
revision: revision.to_string(),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn git_diff(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
base: &str,
|
||||
head: &str,
|
||||
page_size: u32,
|
||||
) -> Result<crate::pb::repo::GetDiffResponse, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.diff;
|
||||
let resp = svc
|
||||
.get_diff(tonic::Request::new(crate::pb::repo::GetDiffRequest {
|
||||
repository: Some(header),
|
||||
base: Some(rev(base)),
|
||||
head: Some(rev(head)),
|
||||
options: None,
|
||||
pagination: Some(crate::pb::repo::Pagination {
|
||||
page_size,
|
||||
page_token: String::new(),
|
||||
}),
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_diff_stats(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
base: &str,
|
||||
head: &str,
|
||||
) -> Result<crate::pb::repo::DiffStats, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.diff;
|
||||
let resp = svc
|
||||
.get_diff_stats(tonic::Request::new(crate::pb::repo::GetDiffStatsRequest {
|
||||
repository: Some(header),
|
||||
base: Some(rev(base)),
|
||||
head: Some(rev(head)),
|
||||
options: None,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
fn rev(r: &str) -> crate::pb::repo::ObjectSelector {
|
||||
crate::pb::repo::ObjectSelector {
|
||||
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
|
||||
crate::pb::repo::ObjectName {
|
||||
revision: r.to_string(),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct MergeParams {
|
||||
pub target_branch: String,
|
||||
pub source: String,
|
||||
pub message: Option<String>,
|
||||
pub squash: Option<bool>,
|
||||
pub no_commit: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct RebaseParams {
|
||||
pub branch: String,
|
||||
pub upstream: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct CherryPickParams {
|
||||
pub commit: String,
|
||||
pub branch: String,
|
||||
pub message: Option<String>,
|
||||
pub mainline: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct RevertParams {
|
||||
pub commit: String,
|
||||
pub branch: String,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct CreateCommitParams {
|
||||
pub branch: String,
|
||||
pub message: String,
|
||||
pub start_revision: Option<String>,
|
||||
pub actions: Vec<CommitAction>,
|
||||
pub force: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct CommitAction {
|
||||
pub action: String,
|
||||
pub file_path: String,
|
||||
pub previous_path: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub executable: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct CompareParams {
|
||||
pub base: String,
|
||||
pub head: String,
|
||||
pub page_size: Option<u32>,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn git_check_merge(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
target: &str,
|
||||
source: &str,
|
||||
) -> Result<crate::pb::repo::MergeResult, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.merge;
|
||||
let resp = svc
|
||||
.check_merge(tonic::Request::new(crate::pb::repo::CheckMergeRequest {
|
||||
repository: Some(header),
|
||||
target: Some(rev(target)),
|
||||
source: Some(rev(source)),
|
||||
options: None,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_merge(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: MergeParams,
|
||||
) -> Result<crate::pb::repo::MergeResult, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Member)
|
||||
.await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let message = params
|
||||
.message
|
||||
.unwrap_or_else(|| format!("Merge {} into {}", params.source, params.target_branch));
|
||||
|
||||
let options = crate::pb::repo::MergeOptions {
|
||||
strategy: crate::pb::repo::merge_options::Strategy::MergeStrategyOrt as i32,
|
||||
fast_forward:
|
||||
crate::pb::repo::merge_options::FastForwardMode::MergeFastForwardModeAllowed as i32,
|
||||
squash: params.squash.unwrap_or(false),
|
||||
no_commit: params.no_commit.unwrap_or(false),
|
||||
allow_unrelated_histories: false,
|
||||
strategy_options: vec![],
|
||||
};
|
||||
|
||||
let mut svc = self.git_client(&repo)?.merge;
|
||||
let resp = svc
|
||||
.merge(tonic::Request::new(crate::pb::repo::MergeRequest {
|
||||
repository: Some(header),
|
||||
target_branch: params.target_branch,
|
||||
source: Some(rev(¶ms.source)),
|
||||
committer: None,
|
||||
message,
|
||||
options: Some(options),
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_rebase(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: RebaseParams,
|
||||
) -> Result<crate::pb::repo::RebaseResult, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Member)
|
||||
.await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.merge;
|
||||
let resp = svc
|
||||
.rebase(tonic::Request::new(crate::pb::repo::RebaseRequest {
|
||||
repository: Some(header),
|
||||
branch: params.branch,
|
||||
upstream: Some(rev(¶ms.upstream)),
|
||||
committer: None,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_cherry_pick(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CherryPickParams,
|
||||
) -> Result<crate::pb::repo::CreateCommitResponse, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Member)
|
||||
.await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let message = params
|
||||
.message
|
||||
.unwrap_or_else(|| format!("Cherry-pick {}", params.commit));
|
||||
let mut svc = self.git_client(&repo)?.commit;
|
||||
let resp = svc
|
||||
.cherry_pick_commit(tonic::Request::new(
|
||||
crate::pb::repo::CherryPickCommitRequest {
|
||||
repository: Some(header),
|
||||
commit: Some(rev(¶ms.commit)),
|
||||
branch: params.branch,
|
||||
committer: None,
|
||||
message,
|
||||
mainline: params.mainline.unwrap_or(1),
|
||||
},
|
||||
))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_revert(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: RevertParams,
|
||||
) -> Result<crate::pb::repo::CreateCommitResponse, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Member)
|
||||
.await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let message = params
|
||||
.message
|
||||
.unwrap_or_else(|| format!("Revert {}", params.commit));
|
||||
let mut svc = self.git_client(&repo)?.commit;
|
||||
let resp = svc
|
||||
.revert_commit(tonic::Request::new(crate::pb::repo::RevertCommitRequest {
|
||||
repository: Some(header),
|
||||
commit: Some(rev(¶ms.commit)),
|
||||
branch: params.branch,
|
||||
committer: None,
|
||||
message,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_create_commit(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateCommitParams,
|
||||
) -> Result<crate::pb::repo::CreateCommitResponse, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Member)
|
||||
.await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
|
||||
let actions: Vec<crate::pb::repo::CreateCommitAction> = params
|
||||
.actions
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let action = match a.action.as_str() {
|
||||
"create" => {
|
||||
crate::pb::repo::create_commit_action::Action::CreateCommitActionCreate
|
||||
as i32
|
||||
}
|
||||
"update" => {
|
||||
crate::pb::repo::create_commit_action::Action::CreateCommitActionUpdate
|
||||
as i32
|
||||
}
|
||||
"delete" => {
|
||||
crate::pb::repo::create_commit_action::Action::CreateCommitActionDelete
|
||||
as i32
|
||||
}
|
||||
"move" => {
|
||||
crate::pb::repo::create_commit_action::Action::CreateCommitActionMove as i32
|
||||
}
|
||||
"chmod" => {
|
||||
crate::pb::repo::create_commit_action::Action::CreateCommitActionChmod
|
||||
as i32
|
||||
}
|
||||
_ => {
|
||||
crate::pb::repo::create_commit_action::Action::CreateCommitActionUnspecified
|
||||
as i32
|
||||
}
|
||||
};
|
||||
crate::pb::repo::CreateCommitAction {
|
||||
action,
|
||||
file_path: a.file_path.clone(),
|
||||
previous_path: a.previous_path.clone().unwrap_or_default(),
|
||||
content: a
|
||||
.content
|
||||
.as_ref()
|
||||
.map(|c| c.as_bytes().to_vec())
|
||||
.unwrap_or_default(),
|
||||
encoding: String::new(),
|
||||
executable: a.executable.unwrap_or(false),
|
||||
last_commit_oid: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let start_revision = params.start_revision.as_ref().map(|r| rev(r));
|
||||
let mut svc = self.git_client(&repo)?.commit;
|
||||
let resp = svc
|
||||
.create_commit(tonic::Request::new(crate::pb::repo::CreateCommitRequest {
|
||||
repository: Some(header),
|
||||
branch: params.branch,
|
||||
message: params.message,
|
||||
author: None,
|
||||
committer: None,
|
||||
actions,
|
||||
start_revision,
|
||||
force: params.force.unwrap_or(false),
|
||||
trailers: vec![],
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_compare_commits(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CompareParams,
|
||||
) -> Result<crate::pb::repo::CompareCommitsResponse, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let page_size = params.page_size.unwrap_or(30);
|
||||
let mut svc = self.git_client(&repo)?.commit;
|
||||
let resp = svc
|
||||
.compare_commits(tonic::Request::new(
|
||||
crate::pb::repo::CompareCommitsRequest {
|
||||
repository: Some(header),
|
||||
base: Some(rev(¶ms.base)),
|
||||
head: Some(rev(¶ms.head)),
|
||||
straight: false,
|
||||
first_parent: false,
|
||||
pagination: Some(crate::pb::repo::Pagination {
|
||||
page_size,
|
||||
page_token: String::new(),
|
||||
}),
|
||||
},
|
||||
))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_list_conflicts(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
target: &str,
|
||||
source: &str,
|
||||
page_size: u32,
|
||||
) -> Result<crate::pb::repo::ListMergeConflictsResponse, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.merge;
|
||||
let resp = svc
|
||||
.list_merge_conflicts(tonic::Request::new(
|
||||
crate::pb::repo::ListMergeConflictsRequest {
|
||||
repository: Some(header),
|
||||
target: Some(rev(target)),
|
||||
source: Some(rev(source)),
|
||||
pagination: Some(crate::pb::repo::Pagination {
|
||||
page_size,
|
||||
page_token: String::new(),
|
||||
}),
|
||||
},
|
||||
))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
pub mod blame;
|
||||
pub mod branch;
|
||||
pub mod commit;
|
||||
pub mod diff;
|
||||
pub mod merge;
|
||||
pub mod repository;
|
||||
pub mod tag;
|
||||
pub mod tree;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::repos::Repo;
|
||||
use crate::models::workspaces::Workspace;
|
||||
use crate::pb::RepoClient;
|
||||
use crate::pb::repo::RepositoryHeader;
|
||||
use crate::service::RepoService;
|
||||
|
||||
impl RepoService {
|
||||
pub(crate) fn repo_header(&self, repo: &Repo, ws: &Workspace) -> RepositoryHeader {
|
||||
RepositoryHeader {
|
||||
storage_name: ws.name.clone(),
|
||||
relative_path: format!("{}.git", repo.name),
|
||||
storage_path: repo.storage_path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn git_client(&self, repo: &Repo) -> Result<RepoClient, AppError> {
|
||||
self.ctx
|
||||
.registry
|
||||
.get_git_client(&repo.primary_storage_node_id)
|
||||
.ok_or_else(|| AppError::Config("primary git node not available".into()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use crate::error::AppError;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
impl RepoService {
|
||||
pub async fn git_repo_info(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<crate::pb::repo::Repository, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.repository;
|
||||
let resp = svc
|
||||
.get_repository(tonic::Request::new(crate::pb::repo::GetRepositoryRequest {
|
||||
repository: Some(header),
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_repo_exists(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.repository;
|
||||
let resp = svc
|
||||
.repository_exists(tonic::Request::new(
|
||||
crate::pb::repo::RepositoryExistsRequest {
|
||||
repository: Some(header),
|
||||
},
|
||||
))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner().exists)
|
||||
}
|
||||
|
||||
pub async fn git_repo_stats(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<crate::pb::repo::RepositoryStatistics, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.repository;
|
||||
let resp = svc
|
||||
.get_repository_statistics(tonic::Request::new(
|
||||
crate::pb::repo::RepositoryStatisticsRequest {
|
||||
repository: Some(header),
|
||||
},
|
||||
))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_repo_health(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<crate::pb::repo::RepositoryHealthResponse, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Admin)
|
||||
.await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.repository;
|
||||
let resp = svc
|
||||
.check_repository_health(tonic::Request::new(
|
||||
crate::pb::repo::RepositoryHealthRequest {
|
||||
repository: Some(header),
|
||||
connectivity_only: false,
|
||||
},
|
||||
))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_garbage_collect(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<crate::pb::repo::RepositoryMaintenanceResponse, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Admin)
|
||||
.await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.repository;
|
||||
let resp = svc
|
||||
.garbage_collect(tonic::Request::new(
|
||||
crate::pb::repo::GarbageCollectRequest {
|
||||
repository: Some(header),
|
||||
prune: true,
|
||||
aggressive: false,
|
||||
},
|
||||
))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use crate::error::AppError;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
impl RepoService {
|
||||
pub async fn git_list_tags(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
pattern: Option<String>,
|
||||
page_size: u32,
|
||||
) -> Result<crate::pb::repo::ListTagsResponse, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.tag;
|
||||
let resp = svc
|
||||
.list_tags(tonic::Request::new(crate::pb::repo::ListTagsRequest {
|
||||
repository: Some(header),
|
||||
pattern: pattern.unwrap_or_default(),
|
||||
pagination: Some(crate::pb::repo::Pagination {
|
||||
page_size,
|
||||
page_token: String::new(),
|
||||
}),
|
||||
sort_direction: crate::pb::repo::SortDirection::Desc as i32,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn git_create_tag(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
tag_name: &str,
|
||||
target: &str,
|
||||
message: Option<String>,
|
||||
annotated: bool,
|
||||
) -> Result<crate::pb::repo::Tag, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Member)
|
||||
.await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.tag;
|
||||
let resp = svc
|
||||
.create_tag(tonic::Request::new(crate::pb::repo::CreateTagRequest {
|
||||
repository: Some(header),
|
||||
name: tag_name.to_string(),
|
||||
target: Some(crate::pb::repo::ObjectSelector {
|
||||
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
|
||||
crate::pb::repo::ObjectName {
|
||||
revision: target.to_string(),
|
||||
},
|
||||
)),
|
||||
}),
|
||||
message: message.unwrap_or_default(),
|
||||
tagger: None,
|
||||
force: false,
|
||||
annotated,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_delete_tag(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
tag_name: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Admin)
|
||||
.await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.tag;
|
||||
svc.delete_tag(tonic::Request::new(crate::pb::repo::DeleteTagRequest {
|
||||
repository: Some(header),
|
||||
name: tag_name.to_string(),
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use crate::error::AppError;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
impl RepoService {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn git_list_tree(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
revision: &str,
|
||||
path: Option<String>,
|
||||
recursive: bool,
|
||||
page_size: u32,
|
||||
) -> Result<crate::pb::repo::ListTreeResponse, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.tree;
|
||||
let resp = svc
|
||||
.list_tree(tonic::Request::new(crate::pb::repo::ListTreeRequest {
|
||||
repository: Some(header),
|
||||
revision: Some(crate::pb::repo::ObjectSelector {
|
||||
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
|
||||
crate::pb::repo::ObjectName {
|
||||
revision: revision.to_string(),
|
||||
},
|
||||
)),
|
||||
}),
|
||||
path: path.unwrap_or_default(),
|
||||
recursive,
|
||||
pagination: Some(crate::pb::repo::Pagination {
|
||||
page_size,
|
||||
page_token: String::new(),
|
||||
}),
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_get_blob(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
revision: &str,
|
||||
path: &str,
|
||||
) -> Result<crate::pb::repo::Blob, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let header = self.repo_header(&repo, &ws);
|
||||
let mut svc = self.git_client(&repo)?.tree;
|
||||
let resp = svc
|
||||
.get_blob(tonic::Request::new(crate::pb::repo::GetBlobRequest {
|
||||
repository: Some(header),
|
||||
revision: Some(crate::pb::repo::ObjectSelector {
|
||||
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
|
||||
crate::pb::repo::ObjectName {
|
||||
revision: revision.to_string(),
|
||||
},
|
||||
)),
|
||||
}),
|
||||
path: path.to_string(),
|
||||
oid: None,
|
||||
max_bytes: 0,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::repos::RepoInvitation;
|
||||
use crate::pb::email::{EmailAddress, SendEmailRequest};
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, role_level};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateRepoInvitationParams {
|
||||
pub email: String,
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct CreateRepoInvitationResponse {
|
||||
pub invitation: RepoInvitation,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_invitations(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoInvitation>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoInvitation>(
|
||||
"SELECT id, repo_id, email, role, token_hash, invited_by, accepted_by, accepted_at, revoked_at, expires_at, created_at FROM repo_invitation \
|
||||
WHERE repo_id = $1 AND revoked_at IS NULL AND accepted_at IS NULL \
|
||||
AND expires_at > NOW() ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_create_invitation(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateRepoInvitationParams,
|
||||
) -> Result<CreateRepoInvitationResponse, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
let actor_role = self
|
||||
.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let email = params.email.trim().to_lowercase();
|
||||
if email.is_empty() {
|
||||
return Err(AppError::BadRequest("email is required".into()));
|
||||
}
|
||||
|
||||
let existing = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_invitation \
|
||||
WHERE repo_id = $1 AND lower(email) = lower($2) \
|
||||
AND revoked_at IS NULL AND accepted_at IS NULL AND expires_at > NOW())",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(&email)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if existing {
|
||||
return Err(AppError::BadRequest(
|
||||
"invitation already exists for this email".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let role = params
|
||||
.role
|
||||
.as_deref()
|
||||
.and_then(|r| r.parse::<Role>().ok())
|
||||
.unwrap_or(Role::Member);
|
||||
|
||||
if role == Role::Owner || role == Role::Unknown {
|
||||
return Err(AppError::BadRequest("invalid role for invitation".into()));
|
||||
}
|
||||
|
||||
// Non-owner admins cannot invite with roles equal to or higher than their own
|
||||
if actor_role != Role::Owner && role_level(role) >= role_level(actor_role) {
|
||||
return Err(AppError::BadRequest(
|
||||
"cannot invite with role equal to or higher than your own".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let token = generate_repo_invitation_token();
|
||||
let token_hash = sha256_hex(token.as_bytes());
|
||||
let now = chrono::Utc::now();
|
||||
let expires_at = now + chrono::Duration::days(7);
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let invitation = sqlx::query_as::<_, RepoInvitation>(
|
||||
"INSERT INTO repo_invitation (id, repo_id, email, role, token_hash, invited_by, expires_at, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
|
||||
RETURNING id, repo_id, email, role, token_hash, invited_by, accepted_by, accepted_at, revoked_at, expires_at, created_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(&email)
|
||||
.bind(role.to_string())
|
||||
.bind(&token_hash)
|
||||
.bind(user_uid)
|
||||
.bind(expires_at)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
let domain = self.ctx.config.main_domain()?;
|
||||
let invite_link = format!("{}/repo/invitations/accept?token={}", domain, token);
|
||||
let mut mail = self
|
||||
.ctx
|
||||
.registry
|
||||
.get_email_client()
|
||||
.ok_or(AppError::Config("mail service not available".into()))?;
|
||||
mail.send_email(tonic::Request::new(SendEmailRequest {
|
||||
to: vec![EmailAddress {
|
||||
email: email.clone(),
|
||||
name: String::new(),
|
||||
}],
|
||||
subject: format!("You're invited to join repo {}", repo.name),
|
||||
text_body: format!(
|
||||
"You've been invited to join repository '{}'.\n\nAccept the invitation here:\n\n{}\n\nThis invitation expires in 7 days.",
|
||||
repo.name, invite_link
|
||||
),
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
tracing::info!(email = %email, invitation_id = %invitation.id, repo_id = %repo_id, "Repo invitation created");
|
||||
Ok(CreateRepoInvitationResponse { invitation })
|
||||
}
|
||||
|
||||
pub async fn repo_revoke_invitation(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
invitation_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE repo_invitation SET revoked_at = $1 WHERE id = $2 AND repo_id = $3 \
|
||||
AND revoked_at IS NULL AND accepted_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(invitation_id)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(
|
||||
result.rows_affected(),
|
||||
"invitation not found or already used",
|
||||
)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_accept_invitation(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
token: &str,
|
||||
) -> Result<RepoInvitation, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let token_hash = sha256_hex(token.as_bytes());
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let invitation = sqlx::query_as::<_, RepoInvitation>(
|
||||
"SELECT id, repo_id, email, role, token_hash, invited_by, accepted_by, accepted_at, revoked_at, expires_at, created_at FROM repo_invitation \
|
||||
WHERE token_hash = $1 AND revoked_at IS NULL AND accepted_at IS NULL AND expires_at > NOW()",
|
||||
)
|
||||
.bind(&token_hash)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::BadRequest("invalid or expired invitation".into()))?;
|
||||
|
||||
let already_member = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_member WHERE repo_id = $1 AND user_id = $2)",
|
||||
)
|
||||
.bind(invitation.repo_id)
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if already_member {
|
||||
return Err(AppError::BadRequest("already a member of this repo".into()));
|
||||
}
|
||||
|
||||
let user_email: Option<String> = sqlx::query_scalar(
|
||||
"SELECT email FROM user_mail WHERE user_id = $1 AND is_verified = true LIMIT 1",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if user_email
|
||||
.as_deref()
|
||||
.map(|e| e.trim().eq_ignore_ascii_case(&invitation.email))
|
||||
!= Some(true)
|
||||
{
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
let repo = self.find_repo_by_id(invitation.repo_id).await?;
|
||||
let role_str = invitation.role.to_string();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let workspace_member_result = sqlx::query(
|
||||
"INSERT INTO workspace_member (id, workspace_id, user_id, role, status, joined_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, 'member', 'active', $4, $4, $4) ON CONFLICT (workspace_id, user_id) DO NOTHING",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo.workspace_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if workspace_member_result.rows_affected() > 0 {
|
||||
sqlx::query(
|
||||
"UPDATE workspace_stats SET members_count = members_count + 1, updated_at = $1 WHERE workspace_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo.workspace_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
let result = sqlx::query_as::<_, RepoInvitation>(
|
||||
"UPDATE repo_invitation SET accepted_by = $1, accepted_at = $2 \
|
||||
WHERE id = $3 AND revoked_at IS NULL AND accepted_at IS NULL \
|
||||
RETURNING id, repo_id, email, role, token_hash, invited_by, accepted_by, accepted_at, revoked_at, expires_at, created_at",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.bind(invitation.id)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO repo_member (id, repo_id, user_id, role, status, invited_by, joined_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, 'active', $5, $6, $6, $6) \
|
||||
ON CONFLICT (repo_id, user_id) DO NOTHING",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(invitation.repo_id)
|
||||
.bind(user_uid)
|
||||
.bind(&role_str)
|
||||
.bind(invitation.invited_by)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256_hex(data: &[u8]) -> String {
|
||||
use sha2::Digest;
|
||||
sha2::Sha256::digest(data)
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn generate_repo_invitation_token() -> String {
|
||||
(0..64)
|
||||
.map(|_| format!("{:02x}", rand::random::<u8>()))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::repos::RepoMember;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, role_level};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct AddRepoMemberParams {
|
||||
pub user_id: Uuid,
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateRepoMemberRoleParams {
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_members(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoMember>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoMember>(
|
||||
"SELECT id, repo_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at FROM repo_member WHERE repo_id = $1 AND status = 'active' ORDER BY created_at ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_add_member(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: AddRepoMemberParams,
|
||||
) -> Result<RepoMember, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
let actor_role = self
|
||||
.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let target_workspace_member = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')",
|
||||
)
|
||||
.bind(repo.workspace_id)
|
||||
.bind(params.user_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if !target_workspace_member {
|
||||
return Err(AppError::BadRequest(
|
||||
"user must be a workspace member".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let existing = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_member WHERE repo_id = $1 AND user_id = $2)",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(params.user_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if existing {
|
||||
return Err(AppError::Conflict("user is already a member".into()));
|
||||
}
|
||||
|
||||
let role = params
|
||||
.role
|
||||
.as_deref()
|
||||
.and_then(|r| r.parse::<Role>().ok())
|
||||
.unwrap_or_else(|| "member".to_string().parse().unwrap_or(Role::Member));
|
||||
|
||||
if role == Role::Owner {
|
||||
return Err(AppError::BadRequest("cannot add member as owner".into()));
|
||||
}
|
||||
if role == Role::Unknown {
|
||||
return Err(AppError::BadRequest("invalid role".into()));
|
||||
}
|
||||
if role_level(actor_role) < role_level(Role::Owner)
|
||||
&& role_level(role) >= role_level(actor_role)
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"cannot assign role equal or higher than your own".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let member = sqlx::query_as::<_, RepoMember>(
|
||||
"INSERT INTO repo_member (id, repo_id, user_id, role, status, invited_by, joined_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, 'active', $5, $6, $6, $6) RETURNING id, repo_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(params.user_id)
|
||||
.bind(role.to_string())
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(member)
|
||||
}
|
||||
|
||||
pub async fn repo_update_member_role(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
member_id: Uuid,
|
||||
params: UpdateRepoMemberRoleParams,
|
||||
) -> Result<RepoMember, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
let actor_role = self
|
||||
.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let new_role = params
|
||||
.role
|
||||
.parse::<Role>()
|
||||
.map_err(|_| AppError::BadRequest("invalid role".into()))?;
|
||||
if new_role == Role::Owner {
|
||||
return Err(AppError::BadRequest(
|
||||
"use repo_transfer_owner to change owner".into(),
|
||||
));
|
||||
}
|
||||
if new_role == Role::Unknown {
|
||||
return Err(AppError::BadRequest("invalid role".into()));
|
||||
}
|
||||
|
||||
let target = sqlx::query_as::<_, RepoMember>(
|
||||
"SELECT id, repo_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at FROM repo_member WHERE id = $1 AND repo_id = $2",
|
||||
)
|
||||
.bind(member_id)
|
||||
.bind(repo_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("member not found".into()))?;
|
||||
|
||||
if target.role == Role::Owner {
|
||||
return Err(AppError::BadRequest("cannot change owner role".into()));
|
||||
}
|
||||
if role_level(actor_role) <= role_level(target.role) && actor_role != Role::Owner {
|
||||
return Err(AppError::BadRequest(
|
||||
"cannot change role of member with equal or higher role".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query_as::<_, RepoMember>(
|
||||
"UPDATE repo_member SET role = $1, updated_at = $2 WHERE id = $3 AND repo_id = $4 RETURNING id, repo_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at",
|
||||
)
|
||||
.bind(new_role.to_string())
|
||||
.bind(now)
|
||||
.bind(member_id)
|
||||
.bind(repo_id)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn repo_remove_member(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
member_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
let actor_role = self
|
||||
.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let target = sqlx::query_as::<_, RepoMember>(
|
||||
"SELECT id, repo_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at FROM repo_member WHERE id = $1 AND repo_id = $2",
|
||||
)
|
||||
.bind(member_id)
|
||||
.bind(repo_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("member not found".into()))?;
|
||||
|
||||
if target.role == Role::Owner {
|
||||
return Err(AppError::BadRequest(
|
||||
"cannot remove owner; transfer ownership first".into(),
|
||||
));
|
||||
}
|
||||
if role_level(actor_role) <= role_level(target.role) && actor_role != Role::Owner {
|
||||
return Err(AppError::BadRequest(
|
||||
"cannot remove a member with equal or higher role".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query("DELETE FROM repo_member WHERE id = $1 AND repo_id = $2")
|
||||
.bind(member_id)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "member not found")?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_leave(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
|
||||
if repo.owner_id == user_uid {
|
||||
return Err(AppError::BadRequest(
|
||||
"owner cannot leave; transfer ownership first".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query("DELETE FROM repo_member WHERE repo_id = $1 AND user_id = $2")
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "not a member")?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
pub mod branches;
|
||||
pub mod commit_status;
|
||||
pub mod core;
|
||||
pub mod deploy_keys;
|
||||
pub mod fork;
|
||||
pub mod git;
|
||||
pub mod invitations;
|
||||
pub mod members;
|
||||
pub mod protection;
|
||||
pub mod releases;
|
||||
pub mod stars;
|
||||
pub mod stats;
|
||||
pub mod tags;
|
||||
pub mod util;
|
||||
pub mod watches;
|
||||
pub mod webhooks;
|
||||
@@ -0,0 +1,389 @@
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::repos::BranchProtectionRule;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct CreateProtectionRuleParams {
|
||||
pub pattern: String,
|
||||
pub require_approvals: Option<i32>,
|
||||
pub require_status_checks: Option<bool>,
|
||||
pub required_status_checks: Option<Vec<String>>,
|
||||
pub require_linear_history: Option<bool>,
|
||||
pub allow_force_pushes: Option<bool>,
|
||||
pub allow_deletions: Option<bool>,
|
||||
pub require_signed_commits: Option<bool>,
|
||||
pub require_code_owner_review: Option<bool>,
|
||||
pub dismiss_stale_reviews: Option<bool>,
|
||||
pub restrict_pushes: Option<bool>,
|
||||
pub push_allowances: Option<Vec<Uuid>>,
|
||||
pub restrict_review_dismissal: Option<bool>,
|
||||
pub dismissal_allowances: Option<Vec<Uuid>>,
|
||||
pub require_conversation_resolution: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct UpdateProtectionRuleParams {
|
||||
pub require_approvals: Option<i32>,
|
||||
pub require_status_checks: Option<bool>,
|
||||
pub required_status_checks: Option<Vec<String>>,
|
||||
pub require_linear_history: Option<bool>,
|
||||
pub allow_force_pushes: Option<bool>,
|
||||
pub allow_deletions: Option<bool>,
|
||||
pub require_signed_commits: Option<bool>,
|
||||
pub require_code_owner_review: Option<bool>,
|
||||
pub dismiss_stale_reviews: Option<bool>,
|
||||
pub restrict_pushes: Option<bool>,
|
||||
pub push_allowances: Option<Vec<Uuid>>,
|
||||
pub restrict_review_dismissal: Option<bool>,
|
||||
pub dismissal_allowances: Option<Vec<Uuid>>,
|
||||
pub require_conversation_resolution: Option<bool>,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_protection_rules(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<BranchProtectionRule>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, BranchProtectionRule>(
|
||||
"SELECT id, repo_id, pattern, require_approvals, require_status_checks, \
|
||||
required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \
|
||||
require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \
|
||||
restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \
|
||||
require_conversation_resolution, created_by, created_at, updated_at \
|
||||
FROM branch_protection_rule WHERE repo_id = $1 ORDER BY pattern ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(repo.id).bind(limit).bind(offset)
|
||||
.fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_get_protection_rule(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
rule_id: Uuid,
|
||||
) -> Result<BranchProtectionRule, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
sqlx::query_as::<_, BranchProtectionRule>(
|
||||
"SELECT id, repo_id, pattern, require_approvals, require_status_checks, \
|
||||
required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \
|
||||
require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \
|
||||
restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \
|
||||
require_conversation_resolution, created_by, created_at, updated_at \
|
||||
FROM branch_protection_rule WHERE id = $1 AND repo_id = $2",
|
||||
)
|
||||
.bind(rule_id)
|
||||
.bind(repo.id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("protection rule not found".into()))
|
||||
}
|
||||
|
||||
pub async fn repo_match_protection(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
branch_name: &str,
|
||||
) -> Result<Option<BranchProtectionRule>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
|
||||
let rules = sqlx::query_as::<_, BranchProtectionRule>(
|
||||
"SELECT id, repo_id, pattern, require_approvals, require_status_checks, \
|
||||
required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \
|
||||
require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \
|
||||
restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \
|
||||
require_conversation_resolution, created_by, created_at, updated_at \
|
||||
FROM branch_protection_rule WHERE repo_id = $1 ORDER BY pattern ASC",
|
||||
)
|
||||
.bind(repo.id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Ok(rules
|
||||
.into_iter()
|
||||
.find(|r| glob_match(&r.pattern, branch_name)))
|
||||
}
|
||||
|
||||
pub async fn repo_create_protection_rule(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateProtectionRuleParams,
|
||||
) -> Result<BranchProtectionRule, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let pattern = required_text(params.pattern, "pattern")?;
|
||||
let now = Utc::now();
|
||||
let rule_id = Uuid::now_v7();
|
||||
let required_checks = params.required_status_checks.unwrap_or_default();
|
||||
let push_allow = params.push_allowances.unwrap_or_default();
|
||||
let dismiss_allow = params.dismissal_allowances.unwrap_or_default();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO branch_protection_rule (id, repo_id, pattern, require_approvals, \
|
||||
require_status_checks, required_status_checks, require_linear_history, allow_force_pushes, \
|
||||
allow_deletions, require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \
|
||||
restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \
|
||||
require_conversation_resolution, created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $19)",
|
||||
)
|
||||
.bind(rule_id).bind(repo.id).bind(&pattern)
|
||||
.bind(params.require_approvals.unwrap_or(0))
|
||||
.bind(params.require_status_checks.unwrap_or(false))
|
||||
.bind(&required_checks)
|
||||
.bind(params.require_linear_history.unwrap_or(false))
|
||||
.bind(params.allow_force_pushes.unwrap_or(false))
|
||||
.bind(params.allow_deletions.unwrap_or(false))
|
||||
.bind(params.require_signed_commits.unwrap_or(false))
|
||||
.bind(params.require_code_owner_review.unwrap_or(false))
|
||||
.bind(params.dismiss_stale_reviews.unwrap_or(false))
|
||||
.bind(params.restrict_pushes.unwrap_or(false))
|
||||
.bind(&push_allow)
|
||||
.bind(params.restrict_review_dismissal.unwrap_or(false))
|
||||
.bind(&dismiss_allow)
|
||||
.bind(params.require_conversation_resolution.unwrap_or(false))
|
||||
.bind(user_uid).bind(now)
|
||||
.execute(self.ctx.db.writer()).await.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query_as::<_, BranchProtectionRule>(
|
||||
"SELECT id, repo_id, pattern, require_approvals, require_status_checks, \
|
||||
required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \
|
||||
require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \
|
||||
restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \
|
||||
require_conversation_resolution, created_by, created_at, updated_at \
|
||||
FROM branch_protection_rule WHERE id = $1",
|
||||
)
|
||||
.bind(rule_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_update_protection_rule(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
rule_id: Uuid,
|
||||
params: UpdateProtectionRuleParams,
|
||||
) -> Result<BranchProtectionRule, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let existing = sqlx::query_as::<_, BranchProtectionRule>(
|
||||
"SELECT id, repo_id, pattern, require_approvals, require_status_checks, \
|
||||
required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \
|
||||
require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \
|
||||
restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \
|
||||
require_conversation_resolution, created_by, created_at, updated_at \
|
||||
FROM branch_protection_rule WHERE id = $1 AND repo_id = $2",
|
||||
)
|
||||
.bind(rule_id)
|
||||
.bind(repo.id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("protection rule not found".into()))?;
|
||||
|
||||
let now = Utc::now();
|
||||
sqlx::query(
|
||||
"UPDATE branch_protection_rule SET \
|
||||
require_approvals = $1, require_status_checks = $2, required_status_checks = $3, \
|
||||
require_linear_history = $4, allow_force_pushes = $5, allow_deletions = $6, \
|
||||
require_signed_commits = $7, require_code_owner_review = $8, dismiss_stale_reviews = $9, \
|
||||
restrict_pushes = $10, push_allowances = $11, restrict_review_dismissal = $12, \
|
||||
dismissal_allowances = $13, require_conversation_resolution = $14, updated_at = $15 \
|
||||
WHERE id = $16",
|
||||
)
|
||||
.bind(params.require_approvals.unwrap_or(existing.require_approvals))
|
||||
.bind(params.require_status_checks.unwrap_or(existing.require_status_checks))
|
||||
.bind(params.required_status_checks.as_ref().unwrap_or(&existing.required_status_checks))
|
||||
.bind(params.require_linear_history.unwrap_or(existing.require_linear_history))
|
||||
.bind(params.allow_force_pushes.unwrap_or(existing.allow_force_pushes))
|
||||
.bind(params.allow_deletions.unwrap_or(existing.allow_deletions))
|
||||
.bind(params.require_signed_commits.unwrap_or(existing.require_signed_commits))
|
||||
.bind(params.require_code_owner_review.unwrap_or(existing.require_code_owner_review))
|
||||
.bind(params.dismiss_stale_reviews.unwrap_or(existing.dismiss_stale_reviews))
|
||||
.bind(params.restrict_pushes.unwrap_or(existing.restrict_pushes))
|
||||
.bind(params.push_allowances.as_ref().unwrap_or(&existing.push_allowances))
|
||||
.bind(params.restrict_review_dismissal.unwrap_or(existing.restrict_review_dismissal))
|
||||
.bind(params.dismissal_allowances.as_ref().unwrap_or(&existing.dismissal_allowances))
|
||||
.bind(params.require_conversation_resolution.unwrap_or(existing.require_conversation_resolution))
|
||||
.bind(now).bind(rule_id)
|
||||
.execute(self.ctx.db.writer()).await.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query_as::<_, BranchProtectionRule>(
|
||||
"SELECT id, repo_id, pattern, require_approvals, require_status_checks, \
|
||||
required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \
|
||||
require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \
|
||||
restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \
|
||||
require_conversation_resolution, created_by, created_at, updated_at \
|
||||
FROM branch_protection_rule WHERE id = $1",
|
||||
)
|
||||
.bind(rule_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_delete_protection_rule(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
rule_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
let result =
|
||||
sqlx::query("DELETE FROM branch_protection_rule WHERE id = $1 AND repo_id = $2")
|
||||
.bind(rule_id)
|
||||
.bind(repo.id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "protection rule not found")
|
||||
}
|
||||
|
||||
pub async fn repo_check_branch_merge_allowed(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
target_branch: &str,
|
||||
pr_number: i64,
|
||||
) -> Result<BranchMergeCheck, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
|
||||
let rule = self
|
||||
.repo_match_protection(ctx, wk_name, repo_name, target_branch)
|
||||
.await?;
|
||||
let Some(rule) = rule else {
|
||||
return Ok(BranchMergeCheck {
|
||||
allowed: true,
|
||||
reasons: vec![],
|
||||
});
|
||||
};
|
||||
|
||||
let mut reasons = Vec::new();
|
||||
|
||||
if rule.require_approvals > 0 {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM pr_review r \
|
||||
JOIN pull_request pr ON pr.id = r.pull_request_id \
|
||||
WHERE pr.repo_id = $1 AND pr.number = $2 AND pr.deleted_at IS NULL \
|
||||
AND r.state = 'approved' AND r.dismissed_at IS NULL AND r.submitted_at IS NOT NULL",
|
||||
)
|
||||
.bind(repo.id).bind(pr_number)
|
||||
.fetch_one(self.ctx.db.reader()).await.map_err(AppError::Database)?;
|
||||
if count < rule.require_approvals as i64 {
|
||||
reasons.push(format!(
|
||||
"requires {} approvals, has {}",
|
||||
rule.require_approvals, count
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if rule.require_status_checks && !rule.required_status_checks.is_empty() {
|
||||
let passed: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT DISTINCT cr.context FROM repo_commit_status cr \
|
||||
JOIN pull_request pr ON pr.head_commit_sha = cr.latest_commit_sha \
|
||||
WHERE pr.repo_id = $1 AND pr.number = $2 AND pr.deleted_at IS NULL \
|
||||
AND cr.state = 'success'",
|
||||
)
|
||||
.bind(repo.id)
|
||||
.bind(pr_number)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
for required in &rule.required_status_checks {
|
||||
if !passed.contains(required) {
|
||||
reasons.push(format!("required check '{}' has not passed", required));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(BranchMergeCheck {
|
||||
allowed: reasons.is_empty(),
|
||||
reasons,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct BranchMergeCheck {
|
||||
pub allowed: bool,
|
||||
pub reasons: Vec<String>,
|
||||
}
|
||||
|
||||
fn glob_match(pattern: &str, text: &str) -> bool {
|
||||
if pattern == text {
|
||||
return true;
|
||||
}
|
||||
if pattern == "*" {
|
||||
return true;
|
||||
}
|
||||
|
||||
let p: Vec<char> = pattern.chars().collect();
|
||||
let t: Vec<char> = text.chars().collect();
|
||||
let (mut pi, mut ti) = (0usize, 0usize);
|
||||
let (mut star_pi, mut star_ti) = (None, None);
|
||||
|
||||
loop {
|
||||
if pi < p.len() && ti < t.len() && (p[pi] == '?' || p[pi] == t[ti]) {
|
||||
pi += 1;
|
||||
ti += 1;
|
||||
continue;
|
||||
}
|
||||
if pi < p.len() && p[pi] == '*' {
|
||||
star_pi = Some(pi);
|
||||
star_ti = Some(ti);
|
||||
pi += 1;
|
||||
continue;
|
||||
}
|
||||
if let (Some(sp), Some(st)) = (star_pi, star_ti)
|
||||
&& st < t.len()
|
||||
{
|
||||
pi = sp + 1;
|
||||
let nt = st + 1;
|
||||
star_ti = Some(nt);
|
||||
ti = nt;
|
||||
continue;
|
||||
}
|
||||
return pi == p.len() && ti == t.len();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::repos::RepoRelease;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, merge_optional_text, required_text};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateReleaseParams {
|
||||
pub tag_name: String,
|
||||
pub title: String,
|
||||
pub body: Option<String>,
|
||||
pub draft: Option<bool>,
|
||||
pub prerelease: Option<bool>,
|
||||
pub tag_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateReleaseParams {
|
||||
pub title: Option<String>,
|
||||
pub body: Option<String>,
|
||||
pub draft: Option<bool>,
|
||||
pub prerelease: Option<bool>,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_releases(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoRelease>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoRelease>(
|
||||
"SELECT id, repo_id, tag_id, tag_name, title, body, draft, prerelease, author_id, published_at, created_at, updated_at, deleted_at FROM repo_release WHERE repo_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_create_release(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateReleaseParams,
|
||||
) -> Result<RepoRelease, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Member)
|
||||
.await?;
|
||||
let tag_name = required_text(params.tag_name, "tag_name")?;
|
||||
let title = required_text(params.title, "title")?;
|
||||
let now = chrono::Utc::now();
|
||||
let published_at = if params.draft.unwrap_or(false) {
|
||||
None
|
||||
} else {
|
||||
Some(now)
|
||||
};
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let release = sqlx::query_as::<_, RepoRelease>(
|
||||
"INSERT INTO repo_release (id, repo_id, tag_id, tag_name, title, body, draft, prerelease, \
|
||||
author_id, published_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) RETURNING id, repo_id, tag_id, tag_name, title, body, draft, prerelease, author_id, published_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(params.tag_id)
|
||||
.bind(&tag_name)
|
||||
.bind(&title)
|
||||
.bind(¶ms.body)
|
||||
.bind(params.draft.unwrap_or(false))
|
||||
.bind(params.prerelease.unwrap_or(false))
|
||||
.bind(user_uid)
|
||||
.bind(published_at)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET releases_count = releases_count + 1, updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(release)
|
||||
}
|
||||
|
||||
pub async fn repo_update_release(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
release_id: Uuid,
|
||||
params: UpdateReleaseParams,
|
||||
) -> Result<RepoRelease, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
let actor_role = self
|
||||
.ensure_repo_role_at_least(user_uid, &repo, Role::Member)
|
||||
.await?;
|
||||
|
||||
let current = sqlx::query_as::<_, RepoRelease>(
|
||||
"SELECT id, repo_id, tag_id, tag_name, title, body, draft, prerelease, author_id, published_at, created_at, updated_at, deleted_at FROM repo_release WHERE id = $1 AND repo_id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(repo_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("release not found".into()))?;
|
||||
|
||||
if crate::service::repo::util::role_level(actor_role)
|
||||
< crate::service::repo::util::role_level(Role::Admin)
|
||||
&& current.author_id != user_uid
|
||||
{
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
let title =
|
||||
merge_optional_text(params.title, Some(current.title.clone())).unwrap_or(current.title);
|
||||
let body = merge_optional_text(params.body, current.body);
|
||||
let draft = params.draft.unwrap_or(current.draft);
|
||||
let prerelease = params.prerelease.unwrap_or(current.prerelease);
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query_as::<_, RepoRelease>(
|
||||
"UPDATE repo_release SET title = $1, body = $2, draft = $3, prerelease = $4, \
|
||||
published_at = CASE WHEN $3 = false AND published_at IS NULL THEN $5 ELSE published_at END, \
|
||||
updated_at = $5 WHERE id = $6 AND repo_id = $7 RETURNING id, repo_id, tag_id, tag_name, title, body, draft, prerelease, author_id, published_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(&title)
|
||||
.bind(&body)
|
||||
.bind(draft)
|
||||
.bind(prerelease)
|
||||
.bind(now)
|
||||
.bind(release_id)
|
||||
.bind(repo_id)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn repo_delete_release(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
release_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE repo_release SET deleted_at = $1, updated_at = $1 WHERE id = $2 AND repo_id = $3 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(release_id)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "release not found")?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET releases_count = GREATEST(releases_count - 1, 0), updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::repos::RepoStar;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::clamp_limit_offset;
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_star(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
|
||||
let existing = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_star WHERE repo_id = $1 AND user_id = $2)",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if existing {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO repo_star (id, repo_id, user_id, created_at) VALUES ($1, $2, $3, $4)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET stars_count = stars_count + 1, updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_unstar(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query("DELETE FROM repo_star WHERE repo_id = $1 AND user_id = $2")
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if result.rows_affected() > 0 {
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET stars_count = GREATEST(stars_count - 1, 0), updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_stargazers(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoStar>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoStar>(
|
||||
"SELECT id, repo_id, user_id, created_at FROM repo_star WHERE repo_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::repos::RepoStats;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_stats(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<RepoStats, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
self.ensure_repo_stats(repo_id).await
|
||||
}
|
||||
|
||||
pub async fn repo_refresh_stats(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<RepoStats, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let branches_count =
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM repo_branch WHERE repo_id = $1")
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let tags_count =
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM repo_tag WHERE repo_id = $1")
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let releases_count = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM repo_release WHERE repo_id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let stars_count =
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM repo_star WHERE repo_id = $1")
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let watchers_count =
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM repo_watch WHERE repo_id = $1")
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let forks_count = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM repo WHERE forked_from_repo_id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let open_issues_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM issue i \
|
||||
JOIN issue_repo_relation irr ON irr.issue_id = i.id \
|
||||
WHERE irr.repo_id = $1 AND i.deleted_at IS NULL AND i.state = 'open'",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let open_prs_count = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM pull_request WHERE repo_id = $1 AND deleted_at IS NULL AND state = 'open'",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let result = sqlx::query_as::<_, RepoStats>(
|
||||
"UPDATE repo_stats SET stars_count = $1, watchers_count = $2, forks_count = $3, \
|
||||
branches_count = $4, tags_count = $5, releases_count = $6, \
|
||||
open_issues_count = $7, open_pull_requests_count = $8, updated_at = $9 \
|
||||
WHERE repo_id = $10 RETURNING repo_id, stars_count, watchers_count, forks_count, branches_count, tags_count, commits_count, releases_count, open_issues_count, open_pull_requests_count, size_bytes, last_push_at, updated_at",
|
||||
)
|
||||
.bind(stars_count)
|
||||
.bind(watchers_count)
|
||||
.bind(forks_count)
|
||||
.bind(branches_count)
|
||||
.bind(tags_count)
|
||||
.bind(releases_count)
|
||||
.bind(open_issues_count)
|
||||
.bind(open_prs_count)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn ensure_repo_stats(&self, repo_id: Uuid) -> Result<RepoStats, AppError> {
|
||||
if let Some(stats) = sqlx::query_as::<_, RepoStats>(
|
||||
"SELECT repo_id, stars_count, watchers_count, forks_count, branches_count, tags_count, commits_count, releases_count, open_issues_count, open_pull_requests_count, size_bytes, last_push_at, updated_at FROM repo_stats WHERE repo_id = $1",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
{
|
||||
return Ok(stats);
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO repo_stats (repo_id, stars_count, watchers_count, forks_count, branches_count, \
|
||||
tags_count, commits_count, releases_count, open_issues_count, open_pull_requests_count, \
|
||||
size_bytes, updated_at) \
|
||||
VALUES ($1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, $2) ON CONFLICT (repo_id) DO NOTHING",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(chrono::Utc::now())
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
sqlx::query_as::<_, RepoStats>(
|
||||
"SELECT repo_id, stars_count, watchers_count, forks_count, branches_count, tags_count, commits_count, releases_count, open_issues_count, open_pull_requests_count, size_bytes, last_push_at, updated_at FROM repo_stats WHERE repo_id = $1",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::repos::RepoTag;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateTagParams {
|
||||
pub name: String,
|
||||
pub target_commit_sha: String,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_tags(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoTag>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoTag>(
|
||||
"SELECT id, repo_id, name, target_commit_sha, tagger_id, message, signed, created_at FROM repo_tag WHERE repo_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_create_tag(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateTagParams,
|
||||
) -> Result<RepoTag, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Member)
|
||||
.await?;
|
||||
let name = required_text(params.name, "name")?;
|
||||
let target = required_text(params.target_commit_sha, "target_commit_sha")?;
|
||||
|
||||
let existing = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_tag WHERE repo_id = $1 AND name = $2)",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(&name)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if existing {
|
||||
return Err(AppError::Conflict("tag already exists".into()));
|
||||
}
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let tag = sqlx::query_as::<_, RepoTag>(
|
||||
"INSERT INTO repo_tag (id, repo_id, name, target_commit_sha, tagger_id, message, signed, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, false, $7) RETURNING id, repo_id, name, target_commit_sha, tagger_id, message, signed, created_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(&name)
|
||||
.bind(&target)
|
||||
.bind(user_uid)
|
||||
.bind(¶ms.message)
|
||||
.bind(chrono::Utc::now())
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET tags_count = tags_count + 1, updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(tag)
|
||||
}
|
||||
|
||||
pub async fn repo_delete_tag(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
tag_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query("DELETE FROM repo_tag WHERE id = $1 AND repo_id = $2")
|
||||
.bind(tag_id)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "tag not found")?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET tags_count = GREATEST(tags_count - 1, 0), updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub use crate::service::util::{
|
||||
clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text, role_level,
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::SubscriptionLevel;
|
||||
use crate::models::repos::RepoWatch;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::clamp_limit_offset;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct WatchParams {
|
||||
pub level: Option<String>,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_watch(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: WatchParams,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
|
||||
let level = params
|
||||
.level
|
||||
.as_deref()
|
||||
.and_then(|v| v.parse::<SubscriptionLevel>().ok())
|
||||
.unwrap_or(SubscriptionLevel::Participating);
|
||||
|
||||
if level == SubscriptionLevel::Unknown {
|
||||
return Err(AppError::BadRequest("invalid watch level".into()));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let existing = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_watch WHERE repo_id = $1 AND user_id = $2)",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if existing {
|
||||
sqlx::query("UPDATE repo_watch SET level = $1, updated_at = $2 WHERE repo_id = $3 AND user_id = $4")
|
||||
.bind(level.to_string())
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
} else {
|
||||
sqlx::query("INSERT INTO repo_watch (id, repo_id, user_id, level, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $5)")
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.bind(level.to_string())
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET watchers_count = watchers_count + 1, updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_unwatch(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query("DELETE FROM repo_watch WHERE repo_id = $1 AND user_id = $2")
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if result.rows_affected() > 0 {
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET watchers_count = GREATEST(watchers_count - 1, 0), updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_watchers(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoWatch>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoWatch>(
|
||||
"SELECT id, repo_id, user_id, level, created_at, updated_at FROM repo_watch WHERE repo_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::IpAddr;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::{EventType, Role};
|
||||
use crate::models::repos::RepoWebhook;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
|
||||
/// Validate webhook URL for SSRF protection
|
||||
fn validate_webhook_url(url_str: &str) -> Result<(), AppError> {
|
||||
let url = Url::parse(url_str).map_err(|_| AppError::BadRequest("Invalid URL format".into()))?;
|
||||
|
||||
// Only allow HTTPS
|
||||
if url.scheme() != "https" {
|
||||
return Err(AppError::BadRequest(
|
||||
"Webhook URL must use HTTPS protocol".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| AppError::BadRequest("URL must have a host".into()))?;
|
||||
|
||||
// Reject IP addresses directly (require domain names)
|
||||
if host.parse::<IpAddr>().is_ok() {
|
||||
return Err(AppError::BadRequest(
|
||||
"Webhook URL must use a domain name, not an IP address".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Reject localhost and common local domains
|
||||
let host_lower = host.to_lowercase();
|
||||
if host_lower == "localhost"
|
||||
|| host_lower.ends_with(".localhost")
|
||||
|| host_lower == "127.0.0.1"
|
||||
|| host_lower == "::1"
|
||||
|| host_lower == "0.0.0.0"
|
||||
|| host_lower.ends_with(".local")
|
||||
|| host_lower.ends_with(".internal")
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"Webhook URL cannot point to localhost or internal domains".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Reject metadata endpoints (AWS, GCP, Azure)
|
||||
if host == "169.254.169.254" || host == "metadata.google.internal" {
|
||||
return Err(AppError::BadRequest(
|
||||
"Webhook URL cannot point to cloud metadata endpoints".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Note: Full DNS resolution and IP validation would require async DNS lookup
|
||||
// and checking against private IP ranges. This is a basic validation layer.
|
||||
// Production systems should implement async DNS resolution and IP validation
|
||||
// at the webhook delivery layer.
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateWebhookParams {
|
||||
pub url: String,
|
||||
pub secret_ciphertext: Option<String>,
|
||||
pub events: Vec<EventType>,
|
||||
pub active: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateWebhookParams {
|
||||
pub url: Option<String>,
|
||||
pub secret_ciphertext: Option<String>,
|
||||
pub events: Option<Vec<EventType>>,
|
||||
pub active: Option<bool>,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_webhooks(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoWebhook>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoWebhook>(
|
||||
"SELECT id, repo_id, url, secret_ciphertext, events, active, last_delivery_status, last_delivery_at, created_by, created_at, updated_at FROM repo_webhook WHERE repo_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_create_webhook(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateWebhookParams,
|
||||
) -> Result<RepoWebhook, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
let url = required_text(params.url, "url")?;
|
||||
validate_webhook_url(&url)?;
|
||||
if params.events.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"at least one event is required".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query_as::<_, RepoWebhook>(
|
||||
"INSERT INTO repo_webhook (id, repo_id, url, secret_ciphertext, events, active, \
|
||||
created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) RETURNING id, repo_id, url, secret_ciphertext, events, active, last_delivery_status, last_delivery_at, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(&url)
|
||||
.bind(¶ms.secret_ciphertext)
|
||||
.bind(¶ms.events)
|
||||
.bind(params.active.unwrap_or(true))
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn repo_update_webhook(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
webhook_id: Uuid,
|
||||
params: UpdateWebhookParams,
|
||||
) -> Result<RepoWebhook, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let current = sqlx::query_as::<_, RepoWebhook>(
|
||||
"SELECT id, repo_id, url, secret_ciphertext, events, active, last_delivery_status, last_delivery_at, created_by, created_at, updated_at FROM repo_webhook WHERE id = $1 AND repo_id = $2",
|
||||
)
|
||||
.bind(webhook_id)
|
||||
.bind(repo_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("webhook not found".into()))?;
|
||||
|
||||
let url = params
|
||||
.url
|
||||
.as_ref()
|
||||
.map(|u| u.trim().to_string())
|
||||
.unwrap_or(current.url);
|
||||
|
||||
// Validate URL if it was updated
|
||||
if params.url.is_some() {
|
||||
validate_webhook_url(&url)?;
|
||||
}
|
||||
|
||||
let active = params.active.unwrap_or(current.active);
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query_as::<_, RepoWebhook>(
|
||||
"UPDATE repo_webhook SET url = $1, secret_ciphertext = $2, events = $3, \
|
||||
active = $4, updated_at = $5 WHERE id = $6 AND repo_id = $7 RETURNING id, repo_id, url, secret_ciphertext, events, active, last_delivery_status, last_delivery_at, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(&url)
|
||||
.bind(params.secret_ciphertext.or(current.secret_ciphertext))
|
||||
.bind(params.events.unwrap_or(current.events))
|
||||
.bind(active)
|
||||
.bind(now)
|
||||
.bind(webhook_id)
|
||||
.bind(repo_id)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn repo_delete_webhook(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
webhook_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query("DELETE FROM repo_webhook WHERE id = $1 AND repo_id = $2")
|
||||
.bind(webhook_id)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "webhook not found")?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user