feat: init

This commit is contained in:
zhenyi
2026-06-07 11:30:56 +08:00
commit 563381c1ca
361 changed files with 41327 additions and 0 deletions
+278
View File
@@ -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(&params.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(())
}
}
+241
View File
@@ -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(&params.target_url)
.bind(&params.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(&params.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(())
}
}
+789
View File
@@ -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)
}
}
+174
View File
@@ -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()
}
+258
View File
@@ -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()))
}
}
+43
View File
@@ -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())
}
}
+142
View File
@@ -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())
}
}
+79
View File
@@ -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())
}
}
+72
View File
@@ -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())
}
}
+372
View File
@@ -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(&params.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(&params.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(&params.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(&params.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(&params.base)),
head: Some(rev(&params.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())
}
}
+32
View File
@@ -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()))
}
}
+123
View File
@@ -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())
}
}
+96
View File
@@ -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(())
}
}
+77
View File
@@ -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())
}
}
+343
View File
@@ -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()
}
+317
View File
@@ -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(())
}
}
+16
View File
@@ -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;
+389
View File
@@ -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();
}
}
+244
View File
@@ -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(&params.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(())
}
}
+142
View File
@@ -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)
}
}
+152
View File
@@ -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)
}
}
+159
View File
@@ -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(&params.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(())
}
}
+3
View File
@@ -0,0 +1,3 @@
pub use crate::service::util::{
clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text, role_level,
};
+166
View File
@@ -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)
}
}
+269
View File
@@ -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(&params.secret_ciphertext)
.bind(&params.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(())
}
}