feat(service): expand service layer with new domain operations

- Add IM service modules: audit, channel roles, custom emojis, forum
  tags, integrations, invitations, repo links, slash commands, stages,
  voice, webhooks
- Add PR service modules: review requests, templates
- Add repo service modules: contributors, release assets, git extras
  (archive, branch rename, commit extras, diff/merge, tag, tree)
- Add user service: social (follow/block)
- Add internal auth service
- Update existing service modules with expanded functionality
- Remove deleted IM modules: articles, delivery trace, drafts,
  follows, messages, polls, presence, reactions, threads
This commit is contained in:
zhenyi
2026-06-10 18:49:32 +08:00
parent cec6dce955
commit 420dedbc1e
100 changed files with 3797 additions and 3839 deletions
+82 -9
View File
@@ -7,7 +7,7 @@ use crate::models::repos::RepoBranch;
use crate::service::RepoService;
use crate::session::Session;
use super::util::{clamp_limit_offset, ensure_affected, required_text};
use super::util::{clamp_limit_offset, ensure_affected, required_text, set_local_user_id};
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct CreateBranchParams {
@@ -75,8 +75,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -139,8 +138,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -171,6 +169,45 @@ impl RepoService {
Ok(())
}
pub async fn repo_set_default_branch_by_name(
&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?;
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_user_id(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 repo_id = $2 AND name = $3")
.bind(now).bind(repo_id).bind(branch_name).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,
@@ -193,8 +230,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -215,6 +251,44 @@ impl RepoService {
Ok(())
}
pub async fn repo_set_branch_protection_by_name(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
branch_name: &str,
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_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query(
"UPDATE repo_branch SET protected = $1, updated_at = $2 WHERE name = $3 AND repo_id = $4",
)
.bind(protected).bind(now).bind(branch_name).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,
@@ -249,8 +323,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
+51 -7
View File
@@ -7,7 +7,7 @@ use crate::models::repos::{RepoCommitComment, RepoCommitStatus};
use crate::service::RepoService;
use crate::session::Session;
use super::util::{clamp_limit_offset, required_text};
use super::util::{clamp_limit_offset, required_text, set_local_user_id};
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct CreateCommitStatusParams {
@@ -86,8 +86,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -164,8 +163,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -213,8 +211,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -238,4 +235,51 @@ impl RepoService {
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(())
}
pub async fn repo_update_commit_comment(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
comment_id: Uuid,
body: &str,
) -> 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(body.to_string(), "body")?;
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query_as::<_, RepoCommitComment>(
"UPDATE repo_commit_comment SET body = $1, updated_at = $2 \
WHERE id = $3 AND repo_id = $4 AND author_id = $5 AND deleted_at IS NULL \
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(&body)
.bind(now)
.bind(comment_id)
.bind(repo_id)
.bind(user_uid)
.fetch_optional(&mut *txn)
.await
.map_err(AppError::Database)?
.ok_or(AppError::NotFound("comment not found or not authorized".into()))?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(result)
}
}
+61
View File
@@ -0,0 +1,61 @@
use serde::Serialize;
use uuid::Uuid;
use crate::error::AppError;
use crate::models::base_info::UserBaseInfo;
use crate::service::RepoService;
use crate::session::Session;
use super::util::clamp_limit_offset;
#[derive(Serialize, utoipa::ToSchema)]
pub struct Contributor {
pub user: Option<UserBaseInfo>,
pub commits: i64,
}
impl RepoService {
pub async fn repo_contributors(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
limit: i64,
offset: i64,
) -> Result<Vec<Contributor>, 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);
#[derive(sqlx::FromRow)]
struct ContributorRow {
pusher_id: Uuid,
commits: i64,
}
let rows = sqlx::query_as::<_, ContributorRow>(
"SELECT pusher_id, COUNT(*) as commits FROM repo_push_commit \
WHERE repo_id = $1 AND branch_name = $2 \
GROUP BY pusher_id ORDER BY commits DESC LIMIT $3 OFFSET $4",
)
.bind(repo.id)
.bind(&repo.default_branch)
.bind(limit)
.bind(offset)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
let user_ids: Vec<Uuid> = rows.iter().map(|r| r.pusher_id).collect();
let users = crate::models::base_info::resolve_users(&self.ctx.db, &user_ids).await?;
Ok(rows
.into_iter()
.map(|r| Contributor {
user: users.get(&r.pusher_id).cloned(),
commits: r.commits,
})
.collect())
}
}
+3 -5
View File
@@ -7,7 +7,7 @@ use crate::models::repos::RepoDeployKey;
use crate::service::RepoService;
use crate::session::Session;
use super::util::{clamp_limit_offset, ensure_affected, required_text};
use super::util::{clamp_limit_offset, ensure_affected, required_text, set_local_user_id};
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct AddDeployKeyParams {
@@ -94,8 +94,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -143,8 +142,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
+61 -3
View File
@@ -8,7 +8,7 @@ use crate::models::repos::{Repo, RepoFork};
use crate::service::RepoService;
use crate::session::Session;
use super::util::clamp_limit_offset;
use super::util::{clamp_limit_offset, set_local_user_id};
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
pub struct ForkRepoParams {
@@ -84,8 +84,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -240,6 +239,65 @@ impl RepoService {
Ok(fork)
}
pub async fn repo_delete_fork(
&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?;
if !repo.is_fork {
return Err(AppError::BadRequest("repo is not a fork".into()));
}
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
.await?;
let parent_id = repo
.forked_from_repo_id
.ok_or(AppError::BadRequest("parent repo 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_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
sqlx::query("DELETE FROM repo_fork WHERE fork_repo_id = $1")
.bind(repo.id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
sqlx::query(
"UPDATE repo SET deleted_at = $1, status = 'deleted', updated_at = $1 WHERE id = $2",
)
.bind(now)
.bind(repo.id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
sqlx::query(
"UPDATE repo_stats SET forks_count = GREATEST(forks_count - 1, 0), updated_at = $1 WHERE repo_id = $2",
)
.bind(now)
.bind(parent_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(())
}
pub(crate) async fn find_ws_for_repo(
&self,
repo: &Repo,
+42
View File
@@ -0,0 +1,42 @@
use crate::error::AppError;
use crate::service::RepoService;
use crate::session::Session;
impl RepoService {
pub async fn git_archive(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
format: i32,
treeish: &str,
) -> Result<tonic::Streaming<crate::pb::repo::ArchiveChunk>, 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)?.archive;
let resp = svc
.get_archive(tonic::Request::new(crate::pb::repo::ArchiveRequest {
repository: Some(header),
treeish: Some(crate::pb::repo::ObjectSelector {
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
crate::pb::repo::ObjectName {
revision: treeish.to_string(),
},
)),
}),
options: Some(crate::pb::repo::ArchiveOptions {
format,
prefix: String::new(),
pathspec: Vec::new(),
compression_level: 0,
include_global_extended_pax_headers: false,
}),
}))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(resp.into_inner())
}
}
+31
View File
@@ -0,0 +1,31 @@
use crate::error::AppError;
use crate::service::RepoService;
use crate::session::Session;
impl RepoService {
pub async fn git_rename_branch(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
old_name: &str,
new_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_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;
let resp = svc
.rename_branch(tonic::Request::new(crate::pb::repo::RenameBranchRequest {
repository: Some(header),
old_name: old_name.to_string(),
new_name: new_name.to_string(),
}))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(resp.into_inner())
}
}
+213
View File
@@ -0,0 +1,213 @@
use crate::error::AppError;
use crate::service::RepoService;
use crate::session::Session;
use futures_util::StreamExt;
impl RepoService {
pub async fn git_get_commit_diff(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
revision: &str,
) -> 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_commit_diff(tonic::Request::new(crate::pb::repo::GetCommitDiffRequest {
repository: Some(header),
commit: Some(crate::pb::repo::ObjectSelector {
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
crate::pb::repo::ObjectName {
revision: revision.to_string(),
},
)),
}),
options: None,
pagination: None,
}))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(resp.into_inner())
}
pub async fn git_get_patch(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
old_revision: &str,
new_revision: &str,
) -> Result<Vec<u8>, 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 mut stream = svc
.get_patch(tonic::Request::new(crate::pb::repo::GetPatchRequest {
repository: Some(header),
base: Some(crate::pb::repo::ObjectSelector {
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
crate::pb::repo::ObjectName {
revision: old_revision.to_string(),
},
)),
}),
head: Some(crate::pb::repo::ObjectSelector {
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
crate::pb::repo::ObjectName {
revision: new_revision.to_string(),
},
)),
}),
options: None,
}))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.into_inner();
let mut data = Vec::new();
while let Some(Ok(chunk)) = stream.next().await {
data.extend_from_slice(&chunk.data);
}
Ok(data)
}
pub async fn git_raw_diff(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
old_revision: &str,
new_revision: &str,
) -> Result<Vec<u8>, 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 mut stream = svc
.raw_diff(tonic::Request::new(crate::pb::repo::RawDiffRequest {
repository: Some(header),
base: old_revision.to_string(),
head: new_revision.to_string(),
options: None,
}))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.into_inner();
let mut data = Vec::new();
while let Some(Ok(chunk)) = stream.next().await {
data.extend_from_slice(&chunk.data);
}
Ok(data)
}
pub async fn git_find_changed_paths(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
old_revision: &str,
new_revision: &str,
) -> Result<crate::pb::repo::FindChangedPathsResponse, 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
.find_changed_paths(tonic::Request::new(
crate::pb::repo::FindChangedPathsRequest {
repository: Some(header),
base: old_revision.to_string(),
head: new_revision.to_string(),
paths: vec![],
},
))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(resp.into_inner())
}
pub async fn git_stream_blame(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
path: &str,
revision: &str,
) -> Result<tonic::Streaming<crate::pb::repo::BlameHunk>, 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
.stream_blame(tonic::Request::new(crate::pb::repo::BlameRequest {
repository: Some(header),
path: path.to_string(),
revision: Some(crate::pb::repo::ObjectSelector {
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
crate::pb::repo::ObjectName {
revision: revision.to_string(),
},
)),
}),
range: None,
options: None,
pagination: None,
}))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(resp.into_inner())
}
pub async fn git_resolve_merge_conflicts(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
target_branch: &str,
source_revision: &str,
message: &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_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
.resolve_merge_conflicts(tonic::Request::new(
crate::pb::repo::ResolveMergeConflictsRequest {
repository: Some(header),
target_branch: target_branch.to_string(),
source: Some(crate::pb::repo::ObjectSelector {
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
crate::pb::repo::ObjectName {
revision: source_revision.to_string(),
},
)),
}),
resolutions: vec![],
committer: None,
message: message.to_string(),
},
))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(resp.into_inner())
}
}
+9
View File
@@ -1,11 +1,20 @@
pub mod archive;
pub mod blame;
pub mod branch;
pub mod branch_rename;
pub mod commit;
pub mod commit_extras;
pub mod commit_extras2;
pub mod diff;
pub mod diff_merge_extras;
pub mod merge;
pub mod repo_extras;
pub mod repository;
pub mod repository_extras;
pub mod tag;
pub mod tag_get;
pub mod tree;
pub mod tree_extras;
use crate::error::AppError;
use crate::models::repos::Repo;
+25
View File
@@ -120,4 +120,29 @@ impl RepoService {
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(resp.into_inner())
}
pub async fn git_set_default_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)?.repository;
svc.set_default_branch(tonic::Request::new(
crate::pb::repo::SetDefaultBranchRequest {
repository: Some(header),
name: branch_name.to_string(),
},
))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
}
+185
View File
@@ -0,0 +1,185 @@
use crate::error::AppError;
use crate::service::RepoService;
use crate::session::Session;
impl RepoService {
pub async fn git_get_default_branch(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
) -> Result<String, 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_default_branch(tonic::Request::new(
crate::pb::repo::GetDefaultBranchRequest {
repository: Some(header),
},
))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(resp.into_inner().name)
}
pub async fn git_get_object_format(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
) -> Result<i32, 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_object_format(tonic::Request::new(
crate::pb::repo::RepositoryObjectFormatRequest {
repository: Some(header),
},
))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(resp.into_inner().object_format)
}
pub async fn git_objects_size(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
oids: Vec<String>,
) -> Result<crate::pb::repo::ObjectsSizeResponse, 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
.objects_size(tonic::Request::new(crate::pb::repo::ObjectsSizeRequest {
repository: Some(header),
oids,
}))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(resp.into_inner())
}
pub async fn git_repository_size(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
) -> Result<crate::pb::repo::RepositorySizeResponse, 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_size(tonic::Request::new(
crate::pb::repo::RepositorySizeRequest {
repository: Some(header),
},
))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(resp.into_inner())
}
pub async fn git_find_merge_base(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
revisions: Vec<String>,
) -> Result<crate::pb::repo::FindMergeBaseResponse, 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
.find_merge_base(tonic::Request::new(crate::pb::repo::FindMergeBaseRequest {
repository: Some(header),
revisions: revisions
.into_iter()
.map(|s| hex::decode(&s).unwrap_or_default())
.collect(),
}))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(resp.into_inner())
}
pub async fn git_list_archive_entries(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
treeish: &str,
page_size: u32,
) -> Result<crate::pb::repo::ListArchiveEntriesResponse, 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)?.archive;
let resp = svc
.list_archive_entries(tonic::Request::new(
crate::pb::repo::ListArchiveEntriesRequest {
repository: Some(header),
treeish: Some(crate::pb::repo::ObjectSelector {
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
crate::pb::repo::ObjectName {
revision: treeish.to_string(),
},
)),
}),
pathspec: vec![],
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_check_objects_exist(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
revisions: Vec<String>,
) -> Result<crate::pb::repo::CheckObjectsExistResponse, 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
.check_objects_exist(tonic::Request::new(
crate::pb::repo::CheckObjectsExistRequest {
repository: Some(header),
revisions,
},
))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(resp.into_inner())
}
}
+52
View File
@@ -0,0 +1,52 @@
use crate::error::AppError;
use crate::service::RepoService;
use crate::session::Session;
impl RepoService {
pub async fn git_get_tag(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
tag_name: &str,
) -> 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_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
.get_tag(tonic::Request::new(crate::pb::repo::GetTagRequest {
repository: Some(header),
name: tag_name.to_string(),
include_raw: false,
}))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(resp.into_inner())
}
pub async fn git_verify_tag(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
tag_name: &str,
) -> Result<crate::pb::repo::VerifiedSignature, 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
.verify_tag(tonic::Request::new(crate::pb::repo::VerifyTagRequest {
repository: Some(header),
name: tag_name.to_string(),
}))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(resp.into_inner())
}
}
+141
View File
@@ -0,0 +1,141 @@
use crate::error::AppError;
use crate::service::RepoService;
use crate::session::Session;
use futures_util::StreamExt;
impl RepoService {
pub async fn git_get_raw_blob(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
revision: &str,
path: &str,
) -> Result<Vec<u8>, 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 mut stream = svc
.get_raw_blob(tonic::Request::new(crate::pb::repo::GetRawBlobRequest {
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,
}))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.into_inner();
let mut data = Vec::new();
while let Some(Ok(chunk)) = stream.next().await {
data.extend_from_slice(&chunk.data);
}
Ok(data)
}
pub async fn git_get_file_metadata(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
revision: &str,
path: &str,
) -> Result<crate::pb::repo::FileMetadata, 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_file_metadata(tonic::Request::new(
crate::pb::repo::GetFileMetadataRequest {
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(),
},
))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(resp.into_inner())
}
pub async fn git_find_files(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
revision: &str,
pattern: &str,
) -> Result<crate::pb::repo::FindFilesResponse, 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
.find_files(tonic::Request::new(crate::pb::repo::FindFilesRequest {
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(),
},
)),
}),
pattern: pattern.to_string(),
pathspec: vec![],
pagination: None,
}))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(resp.into_inner())
}
pub async fn git_get_tree(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
revision: &str,
path: &str,
) -> Result<crate::pb::repo::Tree, 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_tree(tonic::Request::new(crate::pb::repo::GetTreeRequest {
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(),
}))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(resp.into_inner())
}
}
+4 -7
View File
@@ -8,7 +8,7 @@ use crate::pb::email::{EmailAddress, SendEmailRequest};
use crate::service::RepoService;
use crate::session::Session;
use super::util::{clamp_limit_offset, ensure_affected, role_level};
use super::util::{clamp_limit_offset, ensure_affected, role_level, set_local_user_id};
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct CreateRepoInvitationParams {
@@ -114,8 +114,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -186,8 +185,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -267,8 +265,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
+5 -9
View File
@@ -7,7 +7,7 @@ use crate::models::repos::RepoMember;
use crate::service::RepoService;
use crate::session::Session;
use super::util::{clamp_limit_offset, ensure_affected, role_level};
use super::util::{clamp_limit_offset, ensure_affected, role_level, set_local_user_id};
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct AddRepoMemberParams {
@@ -114,8 +114,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -193,8 +192,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -256,8 +254,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -297,8 +294,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
+2
View File
@@ -1,5 +1,6 @@
pub mod branches;
pub mod commit_status;
pub mod contributors;
pub mod core;
pub mod deploy_keys;
pub mod fork;
@@ -7,6 +8,7 @@ pub mod git;
pub mod invitations;
pub mod members;
pub mod protection;
pub mod release_assets;
pub mod releases;
pub mod stars;
pub mod stats;
+205
View File
@@ -0,0 +1,205 @@
use crate::error::AppError;
use crate::models::repos::{RepoRelease, RepoReleaseAsset};
use crate::service::RepoService;
use crate::session::Session;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::util::clamp_limit_offset;
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct ReleaseAssetData {
pub id: Uuid,
pub filename: String,
pub size_bytes: i64,
pub mime_type: String,
pub download_count: i64,
pub created_at: chrono::DateTime<chrono::Utc>,
}
impl From<RepoReleaseAsset> for ReleaseAssetData {
fn from(a: RepoReleaseAsset) -> Self {
Self {
id: a.id,
filename: a.filename,
size_bytes: a.size_bytes,
mime_type: a.mime_type,
download_count: a.download_count,
created_at: a.created_at,
}
}
}
impl RepoService {
pub async fn repo_upload_release_asset(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
release_id: Uuid,
filename: &str,
data: Vec<u8>,
content_type: &str,
) -> Result<ReleaseAssetData, 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 release: Option<RepoRelease> = sqlx::query_as(
"SELECT * 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)?;
if release.is_none() {
return Err(AppError::NotFound("release not found".into()));
}
let asset_id = Uuid::now_v7();
let storage_key = format!("repos/{}/releases/{}/{}", repo.id, release_id, asset_id);
let size = data.len() as i64;
self.ctx.storage.put(&storage_key, data).await?;
let now = chrono::Utc::now();
let asset = sqlx::query_as::<_, RepoReleaseAsset>(
"INSERT INTO repo_release_asset (id, release_id, filename, size_bytes, mime_type, storage_path, uploaded_by, created_at, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \
RETURNING *",
)
.bind(asset_id)
.bind(release_id)
.bind(filename)
.bind(size)
.bind(content_type)
.bind(&storage_key)
.bind(user_uid)
.bind(now)
.fetch_one(self.ctx.db.writer())
.await
.map_err(|e| {
let storage_key = storage_key.clone();
let storage = self.ctx.storage.clone();
tokio::spawn(async move {
let _ = storage.delete(&storage_key).await;
});
AppError::Database(e)
})?;
Ok(ReleaseAssetData::from(asset))
}
pub async fn repo_list_release_assets(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
release_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<ReleaseAssetData>, 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);
let assets = sqlx::query_as::<_, RepoReleaseAsset>(
"SELECT * FROM repo_release_asset WHERE release_id = $1 AND deleted_at IS NULL ORDER BY created_at ASC LIMIT $2 OFFSET $3",
)
.bind(release_id)
.bind(limit)
.bind(offset)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
Ok(assets.into_iter().map(ReleaseAssetData::from).collect())
}
pub async fn repo_delete_release_asset(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
release_id: Uuid,
asset_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, crate::models::common::Role::Admin)
.await?;
let asset: Option<RepoReleaseAsset> = sqlx::query_as(
"SELECT * FROM repo_release_asset WHERE id = $1 AND release_id = $2 AND deleted_at IS NULL",
)
.bind(asset_id)
.bind(release_id)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
let Some(asset) = asset else {
return Err(AppError::NotFound("asset not found".into()));
};
let now = chrono::Utc::now();
sqlx::query("UPDATE repo_release_asset SET deleted_at = $1, updated_at = $1 WHERE id = $2")
.bind(now)
.bind(asset_id)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
let _ = self.ctx.storage.delete(&asset.storage_path).await;
Ok(())
}
pub async fn repo_get_release_asset_download_url(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
release_id: Uuid,
asset_id: Uuid,
) -> Result<String, 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 asset: Option<RepoReleaseAsset> = sqlx::query_as(
"SELECT * FROM repo_release_asset WHERE id = $1 AND release_id = $2 AND deleted_at IS NULL",
)
.bind(asset_id)
.bind(release_id)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
let Some(asset) = asset else {
return Err(AppError::NotFound("asset not found".into()));
};
if let Some(url) = asset.url {
return Ok(url);
}
let url = self
.ctx
.storage
.presigned_get_url(&asset.storage_path, None)
.await?;
// Update download count and cache the URL
let _ = sqlx::query(
"UPDATE repo_release_asset SET download_count = download_count + 1, url = $1, updated_at = NOW() WHERE id = $2",
)
.bind(&url)
.bind(asset_id)
.execute(self.ctx.db.writer())
.await;
Ok(url)
}
}
+6 -7
View File
@@ -7,7 +7,9 @@ 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};
use super::util::{
clamp_limit_offset, ensure_affected, merge_optional_text, required_text, set_local_user_id,
};
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct CreateReleaseParams {
@@ -80,8 +82,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -165,8 +166,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -212,8 +212,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
+16 -28
View File
@@ -5,7 +5,7 @@ use crate::models::repos::RepoStar;
use crate::service::RepoService;
use crate::session::Session;
use super::util::clamp_limit_offset;
use super::util::{clamp_limit_offset, set_local_user_id};
impl RepoService {
pub async fn repo_star(
@@ -19,19 +19,6 @@ impl RepoService {
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
@@ -40,14 +27,13 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(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)",
let result = sqlx::query(
"INSERT INTO repo_star (id, repo_id, user_id, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT (repo_id, user_id) DO NOTHING",
)
.bind(Uuid::now_v7())
.bind(repo_id)
@@ -57,14 +43,17 @@ impl RepoService {
.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)?;
// Only increment stars_count if the INSERT actually happened
if result.rows_affected() > 0 {
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(())
@@ -88,8 +77,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
+42 -4
View File
@@ -95,11 +95,19 @@ impl RepoService {
.map_err(AppError::Database)?;
let now = chrono::Utc::now();
// Try fetching git-derived stats via gRPC (best-effort)
let (commits_count, size_bytes, last_push_at) = self
.try_fetch_git_stats(ctx, wk_name, repo_name)
.await
.unwrap_or((0, 0, None));
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",
open_issues_count = $7, open_pull_requests_count = $8, \
commits_count = $9, size_bytes = $10, last_push_at = $11, updated_at = $12 \
WHERE repo_id = $13 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)
@@ -109,6 +117,9 @@ impl RepoService {
.bind(releases_count)
.bind(open_issues_count)
.bind(open_prs_count)
.bind(commits_count)
.bind(size_bytes)
.bind(last_push_at)
.bind(now)
.bind(repo_id)
.fetch_one(self.ctx.db.writer())
@@ -118,6 +129,31 @@ impl RepoService {
Ok(result)
}
async fn try_fetch_git_stats(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
) -> Option<(i64, i64, Option<chrono::DateTime<chrono::Utc>>)> {
let commits = self
.git_count_commits(ctx, wk_name, repo_name, None, None, None, None)
.await
.ok()
.map(|r| r.count as i64)
.unwrap_or(0);
let size = self
.git_repo_stats(ctx, wk_name, repo_name)
.await
.ok()
.map(|r| r.size_bytes as i64)
.unwrap_or(0);
let last_push = None; // Not available from current gRPC
Some((commits, size, last_push))
}
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",
@@ -141,12 +177,14 @@ impl RepoService {
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
// Read from writer to avoid replication lag
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())
.fetch_optional(self.ctx.db.writer())
.await
.map_err(AppError::Database)
.map_err(AppError::Database)?
.ok_or(AppError::NotFound("repo stats not found".into()))
}
}
+68 -5
View File
@@ -7,7 +7,7 @@ use crate::models::repos::RepoTag;
use crate::service::RepoService;
use crate::session::Session;
use super::util::{clamp_limit_offset, ensure_affected, required_text};
use super::util::{clamp_limit_offset, ensure_affected, required_text, set_local_user_id};
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct CreateTagParams {
@@ -16,6 +16,12 @@ pub struct CreateTagParams {
pub message: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct UpdateTagParams {
pub name: Option<String>,
pub message: Option<String>,
}
impl RepoService {
pub async fn repo_tags(
&self,
@@ -76,8 +82,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -130,8 +135,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -156,4 +160,63 @@ impl RepoService {
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(())
}
pub async fn repo_update_tag(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
tag_id: Uuid,
params: UpdateTagParams,
) -> 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::Admin)
.await?;
let current = sqlx::query_as::<_, RepoTag>(
"SELECT id, repo_id, name, target_commit_sha, tagger_id, message, signed, created_at \
FROM repo_tag WHERE id = $1 AND repo_id = $2",
)
.bind(tag_id)
.bind(repo_id)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?
.ok_or(AppError::NotFound("tag not found".into()))?;
let name = params.name.as_deref().unwrap_or(&current.name).to_string();
let message = params.message.or(current.message);
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query_as::<_, RepoTag>(
"UPDATE repo_tag SET name = $1, message = $2, created_at = $3 \
WHERE id = $4 AND repo_id = $5 \
RETURNING id, repo_id, name, target_commit_sha, tagger_id, message, signed, created_at",
)
.bind(&name)
.bind(&message)
.bind(now)
.bind(tag_id)
.bind(repo_id)
.fetch_one(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(result)
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
pub use crate::service::util::{
clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text, role_level,
clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text,
role_level, set_local_user_id,
};
+3 -5
View File
@@ -7,7 +7,7 @@ use crate::models::repos::RepoWatch;
use crate::service::RepoService;
use crate::session::Session;
use super::util::clamp_limit_offset;
use super::util::{clamp_limit_offset, set_local_user_id};
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct WatchParams {
@@ -45,8 +45,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -112,8 +111,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
+66 -7
View File
@@ -9,7 +9,7 @@ use crate::models::repos::RepoWebhook;
use crate::service::RepoService;
use crate::session::Session;
use super::util::{clamp_limit_offset, ensure_affected, required_text};
use super::util::{clamp_limit_offset, ensure_affected, required_text, set_local_user_id};
/// Validate webhook URL for SSRF protection
fn validate_webhook_url(url_str: &str) -> Result<(), AppError> {
@@ -133,8 +133,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -204,8 +203,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -249,8 +247,7 @@ impl RepoService {
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
sqlx::query(set_local_user_id(user_uid))
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
@@ -266,4 +263,66 @@ impl RepoService {
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(())
}
pub async fn repo_webhook_deliveries(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
webhook_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<serde_json::Value>, 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 _webhook = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM repo_webhook WHERE id = $1 AND repo_id = $2)",
)
.bind(webhook_id)
.bind(_repo_id)
.fetch_one(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
if !_webhook {
return Err(AppError::NotFound("webhook not found".into()));
}
let _ = (limit, offset);
Ok(vec![])
}
pub async fn repo_retry_webhook_delivery(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
webhook_id: Uuid,
delivery_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 _webhook = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM repo_webhook WHERE id = $1 AND repo_id = $2)",
)
.bind(webhook_id)
.bind(repo.id)
.fetch_one(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
if !_webhook {
return Err(AppError::NotFound("webhook not found".into()));
}
let _ = delivery_id;
Ok(())
}
}