From 31a7c962f6d604c69c04cf11dc5db3b6c9e3202f Mon Sep 17 00:00:00 2001 From: Ruizhi Huang <231220075@smail.nju.edu.cn> Date: Mon, 8 Sep 2025 10:04:38 +0800 Subject: [PATCH 1/7] feat(commit-message-binding):finish binding commit-message to user (1st version) Signed-off-by: Ruizhi Huang <231220075@smail.nju.edu.cn> feat(commit-message-binding):finish binding commit-message to user (2st version) Signed-off-by: Ruizhi Huang <231220075@smail.nju.edu.cn> feat: implement commit-user binding Signed-off-by: Ruizhi Huang <231220075@smail.nju.edu.cn> --- .gitignore | 3 + ceres/src/auth/mod.rs | 78 ++++++++ ceres/src/lib.rs | 1 + ceres/src/protocol/mod.rs | 33 +++- ceres/src/protocol/smart.rs | 170 ++++++++++++++++ docs/api.md | 14 ++ docs/database.md | 16 ++ gateway/Cargo.toml | 1 + gateway/src/api/commit/commit_router.rs | 109 +++++++++++ gateway/src/api/commit/mod.rs | 2 + gateway/src/api/commit/model.rs | 31 +++ gateway/src/api/mod.rs | 1 + gateway/src/https_server.rs | 6 +- jupiter/callisto/src/commit_auths.rs | 26 +++ jupiter/callisto/src/mod.rs | 1 + jupiter/callisto/src/prelude.rs | 1 + .../m20250904_120000_add_commit_auths.rs | 86 +++++++++ jupiter/src/migration/mod.rs | 2 + jupiter/src/storage/commit_binding_storage.rs | 78 ++++++++ jupiter/src/storage/mod.rs | 19 +- jupiter/src/storage/user_storage.rs | 14 ++ jupiter/src/tests.rs | 9 +- mono/src/api/api_router.rs | 5 +- mono/src/api/commit/commit_router.rs | 182 ++++++++++++++++++ mono/src/api/commit/mod.rs | 2 + mono/src/api/commit/model.rs | 31 +++ mono/src/api/mod.rs | 1 + .../web/components/CodeView/CodeTable.tsx | 8 +- .../web/components/CodeView/CommitHistory.tsx | 35 +++- .../CodeView/CommitMessageWithAuthor.tsx | 66 +++++++ moon/apps/web/hooks/useGetCommitBinding.ts | 79 ++++++++ .../web/pages/[org]/code/blob/[...path].tsx | 6 +- 32 files changed, 1093 insertions(+), 23 deletions(-) create mode 100644 ceres/src/auth/mod.rs create mode 100644 gateway/src/api/commit/commit_router.rs create mode 100644 gateway/src/api/commit/mod.rs create mode 100644 gateway/src/api/commit/model.rs create mode 100644 jupiter/callisto/src/commit_auths.rs create mode 100644 jupiter/src/migration/m20250904_120000_add_commit_auths.rs create mode 100644 jupiter/src/storage/commit_binding_storage.rs create mode 100644 mono/src/api/commit/commit_router.rs create mode 100644 mono/src/api/commit/mod.rs create mode 100644 mono/src/api/commit/model.rs create mode 100644 moon/apps/web/components/CodeView/CommitMessageWithAuthor.tsx create mode 100644 moon/apps/web/hooks/useGetCommitBinding.ts diff --git a/.gitignore b/.gitignore index ca19e2ca3..304d7c52e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ monobean/resources/lib # Temporary test cache dir /tests/.cache_tmp + +## local python version file +.python-version \ No newline at end of file diff --git a/ceres/src/auth/mod.rs b/ceres/src/auth/mod.rs new file mode 100644 index 000000000..1522730a8 --- /dev/null +++ b/ceres/src/auth/mod.rs @@ -0,0 +1,78 @@ +use anyhow::Result; +use common::errors::MegaError; +use jupiter::storage::user_storage::UserStorage; + +/// User authentication information extracted from Git push process +#[derive(Debug, Clone)] +pub struct PushUserInfo { + pub user_id: i64, + pub username: String, + pub primary_email: String, + pub all_emails: Vec, // User's email collection +} + +/// Extract user identity from AccessToken during Git push +#[allow(async_fn_in_trait)] +pub trait UserAuthExtractor { + /// Extract user information from AccessToken + async fn extract_user_from_token(&self, access_token: &str) -> Result; + + /// Verify if email belongs to the authenticated user + async fn verify_email_ownership(&self, user_id: i64, email: &str) -> Result; + + /// Extract user information from username (for cases where token is already validated) + async fn extract_user_from_username(&self, username: &str) -> Result; +} + +/// Default implementation for user authentication +pub struct DefaultUserAuthExtractor { + user_storage: UserStorage, +} + +impl DefaultUserAuthExtractor { + pub fn new(user_storage: UserStorage) -> Self { + Self { user_storage } + } +} + +impl UserAuthExtractor for DefaultUserAuthExtractor { + async fn extract_user_from_token(&self, access_token: &str) -> Result { + // Find token in database to get username + let tokens = self.user_storage.list_all_tokens().await?; + + for token_model in tokens { + if token_model.token == access_token { + return self.extract_user_from_username(&token_model.username).await; + } + } + + Err(MegaError::with_message("Invalid or expired access token")) + } + + async fn extract_user_from_username(&self, username: &str) -> Result { + // Get user information + let user = self.user_storage.find_user_by_name(username).await? + .ok_or_else(|| MegaError::with_message("User not found"))?; + + // Get user's email collection - for now, use the primary email + // TODO: Extend to support multiple emails per user + let all_emails = vec![user.email.clone()]; + + Ok(PushUserInfo { + user_id: user.id, + username: user.name.clone(), + primary_email: user.email.clone(), + all_emails, + }) + } + + async fn verify_email_ownership(&self, user_id: i64, email: &str) -> Result { + // Get user information + let user = self.user_storage.find_user_by_id(user_id).await? + .ok_or_else(|| MegaError::with_message("User not found"))?; + + // Check if email matches primary email + // TODO: Extend to check against all user emails when multiple emails are supported + Ok(user.email.to_lowercase() == email.to_lowercase()) + } +} diff --git a/ceres/src/lib.rs b/ceres/src/lib.rs index cc7116ccb..83d2ab001 100644 --- a/ceres/src/lib.rs +++ b/ceres/src/lib.rs @@ -1,4 +1,5 @@ pub mod api_service; +pub mod auth; pub mod lfs; pub mod merge_checker; pub mod model; diff --git a/ceres/src/protocol/mod.rs b/ceres/src/protocol/mod.rs index be54be014..efc63d8ba 100644 --- a/ceres/src/protocol/mod.rs +++ b/ceres/src/protocol/mod.rs @@ -16,6 +16,7 @@ use import_refs::RefCommand; use jupiter::storage::Storage; use repo::Repo; +use crate::auth::{DefaultUserAuthExtractor, UserAuthExtractor, PushUserInfo}; use crate::pack::{import_repo::ImportRepo, monorepo::MonoRepo, RepoHandler}; pub mod import_refs; @@ -31,6 +32,7 @@ pub struct SmartProtocol { pub service_type: Option, pub storage: Storage, pub username: Option, + pub authenticated_user: Option, } #[derive(Debug, PartialEq, Clone, Copy, Default)] @@ -140,6 +142,7 @@ impl SmartProtocol { service_type: None, storage, username: None, + authenticated_user: None, } } @@ -153,6 +156,7 @@ impl SmartProtocol { service_type: None, storage, username: None, + authenticated_user: None, } } @@ -230,18 +234,43 @@ impl SmartProtocol { && username == auth_config.test_user_name && token == auth_config.test_user_token { + // For test user, create a basic PushUserInfo + if let Ok(user_auth) = self.create_user_auth_extractor() { + if let Ok(user_info) = user_auth.extract_user_from_username(username).await { + self.authenticated_user = Some(user_info); + return true; + } + } return true; } - return self + let token_valid = self .storage .user_storage() .check_token(username, token) .await - .unwrap(); + .unwrap_or(false); + + if token_valid { + // Extract user information for valid token + if let Ok(user_auth) = self.create_user_auth_extractor() { + if let Ok(user_info) = user_auth.extract_user_from_username(username).await { + self.authenticated_user = Some(user_info); + return true; + } + } + } + + return token_valid; } } false } + + /// Create a user authentication extractor + fn create_user_auth_extractor(&self) -> Result { + let user_storage = self.storage.user_storage(); + Ok(DefaultUserAuthExtractor::new(user_storage)) + } } #[cfg(test)] diff --git a/ceres/src/protocol/smart.rs b/ceres/src/protocol/smart.rs index c1c47777f..71f2f3782 100644 --- a/ceres/src/protocol/smart.rs +++ b/ceres/src/protocol/smart.rs @@ -8,6 +8,7 @@ use tokio_stream::wrappers::ReceiverStream; use callisto::sea_orm_active_enums::RefTypeEnum; use common::errors::ProtocolError; +use crate::auth::UserAuthExtractor; use crate::pack::monorepo::MonoRepo; use crate::protocol::import_refs::RefCommand; use crate::protocol::ZERO_ID; @@ -251,6 +252,10 @@ impl SmartProtocol { } add_pkt_line_string(&mut report_status, command.get_status()); } + + // 4. Process commit bindings for successful ref updates + self.process_commit_bindings().await; + if repo_handler.is_monorepo() { let any = repo_handler.into_any(); if let Ok(monorepo) = any.downcast::() { @@ -332,6 +337,159 @@ impl SmartProtocol { read_until_white_space(pkt_line), ) } + + /// Process commit bindings for successfully pushed commits + async fn process_commit_bindings(&self) { + for command in &self.command_list { + // Only process successful branch updates (not tags or failed commands) + if command.ref_type == RefTypeEnum::Branch + && command.status == "ok" + && command.new_id != ZERO_ID + { + if let Err(e) = self.bind_commit_to_user(&command.new_id).await { + tracing::warn!("Failed to bind commit {} to user: {}", command.new_id, e); + // Don't fail the push on binding errors + } + } + } + } + + /// Bind a single commit to a user based on authenticated user and author email + async fn bind_commit_to_user( + &self, + commit_sha: &str, + ) -> Result<(), Box> { + let repo_handler = self.repo_handler().await?; + let commit_binding_storage = self.storage.commit_binding_storage(); + + // Get authenticated user info - if not authenticated, mark as anonymous + let (_user_id, _is_anonymous) = if let Some(user_info) = &self.authenticated_user { + (Some(user_info.user_id), false) + } else { + (None, true) + }; + + // Extract author email from commit based on repo type + let author_email = if repo_handler.is_monorepo() { + // For monorepo, get from mono storage + let commit = self + .storage + .mono_storage() + .get_commit_by_hash(commit_sha) + .await?; + + if let Some(commit_model) = commit { + // Extract author email from commit (assuming author field contains "Name " format) + if let Some(author) = &commit_model.author { + extract_email_from_author(author) + } else { + return Ok(()); // Skip if no author info + } + } else { + return Ok(()); // Skip if commit not found + } + } else { + // For import repo, we need to find the repo_id first + let import_dir = self.storage.config().monorepo.import_dir.clone(); + if !self.path.starts_with(import_dir) { + return Ok(()); // Skip if not in import directory + } + + let path_str = self.path.to_str().unwrap(); + let repo = self + .storage + .git_db_storage() + .find_git_repo_exact_match(path_str) + .await?; + + if let Some(repo) = repo { + let commit = self + .storage + .git_db_storage() + .get_commit_by_hash(repo.id, commit_sha) + .await?; + + if let Some(commit_model) = commit { + // Extract author email from commit + if let Some(author) = &commit_model.author { + extract_email_from_author(author) + } else { + return Ok(()); // Skip if no author info + } + } else { + return Ok(()); // Skip if commit not found + } + } else { + return Ok(()); // Skip if repo not found + } + }; + + if author_email.is_empty() { + return Ok(()); // Skip if no valid email + } + + // Enhanced user matching logic based on authentication status + let (final_user_id, is_anonymous) = if let Some(authenticated_user) = &self.authenticated_user { + // User is authenticated - check if commit author email belongs to them + if let Ok(user_auth) = self.create_user_auth_extractor() { + let email_belongs_to_user = user_auth + .verify_email_ownership(authenticated_user.user_id, &author_email) + .await + .unwrap_or(false); + + if email_belongs_to_user { + // Author email belongs to authenticated user + (Some(authenticated_user.user_id.to_string()), false) + } else { + // Author email doesn't belong to authenticated user - try general matching + let user_storage = self.storage.user_storage(); + let matched_user = user_storage.find_user_by_email(&author_email).await?; + + if let Some(user) = matched_user { + (Some(user.id.to_string()), false) + } else { + // Mark as anonymous since we can't match the email + (None, true) + } + } + } else { + // Fallback to email-based matching if auth extractor fails + let user_storage = self.storage.user_storage(); + let matched_user = user_storage.find_user_by_email(&author_email).await?; + + if let Some(user) = matched_user { + (Some(user.id.to_string()), false) + } else { + (None, true) + } + } + } else { + // No authenticated user - try to match by email only + let user_storage = self.storage.user_storage(); + let matched_user = user_storage.find_user_by_email(&author_email).await?; + + if let Some(user) = matched_user { + (Some(user.id.to_string()), false) + } else { + (None, true) + } + }; + + // Upsert the binding + commit_binding_storage + .upsert_binding(commit_sha, &author_email, final_user_id, is_anonymous) + .await?; + + tracing::info!( + "Bound commit {} (author: {}) to user: {:?} (authenticated: {})", + commit_sha, + author_email, + if is_anonymous { "anonymous" } else { "matched" }, + self.authenticated_user.is_some() + ); + + Ok(()) + } } fn read_until_white_space(bytes: &mut Bytes) -> String { @@ -400,6 +558,18 @@ pub fn read_pkt_line(bytes: &mut Bytes) -> (usize, Bytes) { (pkt_length, pkt_line) } +/// Extract email address from Git author string (format: "Name ") +fn extract_email_from_author(author: &str) -> String { + if let Some(start) = author.rfind('<') { + if let Some(end) = author.rfind('>') { + if start < end { + return author[start + 1..end].trim().to_string(); + } + } + } + String::new() +} + #[cfg(test)] pub mod test { use bytes::{Bytes, BytesMut}; diff --git a/docs/api.md b/docs/api.md index 4b1c6a242..11feed020 100644 --- a/docs/api.md +++ b/docs/api.md @@ -105,3 +105,17 @@ This part of the API, prefixed with /api/v1, is primarily for fetching Git raw o ```bash curl -X GET ${MEGA_URL}/api/v1/count-objs?repo_path= ``` + +6. Retrieve commit binding information for associating commits with users + + ```bash + curl -X GET ${MEGA_URL}/api/v1/commits//binding + ``` + +7. Update commit binding to associate a commit with a specific user or mark as anonymous + + ```bash + curl -X PUT ${MEGA_URL}/api/v1/commits//binding \ + -H "Content-Type: application/json" \ + -d '{"user_id": "", "is_anonymous": false}' + ``` diff --git a/docs/database.md b/docs/database.md index 19cfb42ce..85f952d6c 100644 --- a/docs/database.md +++ b/docs/database.md @@ -43,6 +43,7 @@ Similar to the 'tree' in Git, Mega maintains relationships between files and fil | git_issue | Issues sync from third parties like GitHub. | | | | | | lfs_objects | Store objects related to LFS protocol. | | | | | | lfs_locks | Store locks for lfs files. | | | | | +| commit_auths | Store commit binding information associating commits with users. | ✓ | | | | ### ER Diagram @@ -81,6 +82,8 @@ erDiagram GIT-TAG }o--|| GIT-REPO : "belong to" GIT-TAG |o--o| GIT-COMMIT : points GIT-TAG ||--|| RAW-OBJECTS : points + COMMIT-AUTHS ||--o| GIT-COMMIT : binds + COMMIT-AUTHS ||--o| MEGA-COMMITS : binds ``` @@ -347,6 +350,19 @@ erDiagram | exist | BOOLEAN | | +#### commit_auths + +| Column | Type | Constraints | Description | +|------------------|-------------|-------------|--------------------------------------------------| +| id | VARCHAR | PRIMARY KEY | | +| commit_sha | VARCHAR | NOT NULL | Git commit SHA hash | +| author_email | VARCHAR | NOT NULL | Original author email from commit | +| matched_user_id | VARCHAR | NULL | User ID that this commit is bound to | +| is_anonymous | BOOLEAN | NOT NULL | Whether the commit should be treated as anonymous | +| matched_at | TIMESTAMP | NULL | When the binding was created/updated | +| created_at | TIMESTAMP | NOT NULL | When the record was created | + + ## 3. Prerequisites diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index 38bcff04d..13d37d7a7 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -35,3 +35,4 @@ quinn = { workspace = true } utoipa = { workspace = true, features = ["axum_extras"] } utoipa-axum = { workspace = true } utoipa-swagger-ui = { workspace = true, features = ["axum"] } +anyhow = { workspace = true } diff --git a/gateway/src/api/commit/commit_router.rs b/gateway/src/api/commit/commit_router.rs new file mode 100644 index 000000000..31c81a2dd --- /dev/null +++ b/gateway/src/api/commit/commit_router.rs @@ -0,0 +1,109 @@ +use axum::{ + extract::{Path, State}, + Json, +}; +use common::model::CommonResult; +use mono::api::commit::model::{CommitBindingResponse, UserInfo, CommitBinding}; +use mono::api::error::ApiError; +use utoipa_axum::{router::OpenApiRouter, routes}; +use anyhow::anyhow; + +use crate::api::MegaApiServiceState; + +pub fn routers() -> OpenApiRouter { + OpenApiRouter::new().nest( + "/commits", + OpenApiRouter::new().routes(routes!(get_commit_binding)), + ) +} + +/// Get commit binding information by commit SHA +#[utoipa::path( + get, + path = "/{sha}", + params( + ("sha" = String, Path, description = "Git commit SHA hash") + ), + responses( + (status = 200, body = CommonResult, content_type = "application/json"), + (status = 404, description = "Commit binding not found") + ), + tag = "mega-commit" +)] +async fn get_commit_binding( + State(state): State, + Path(sha): Path, +) -> Result>, ApiError> { + let commit_binding_storage = state.inner.storage.commit_binding_storage(); + let user_storage = state.inner.storage.user_storage(); + + match commit_binding_storage.find_by_sha(&sha).await { + Ok(Some(binding_model)) => { + // Try to get user information if not anonymous + let user_info = if !binding_model.is_anonymous && binding_model.matched_user_id.is_some() { + let user_id_str = binding_model.matched_user_id.as_ref().unwrap(); + if let Ok(user_id) = user_id_str.parse::() { + if let Ok(Some(user)) = user_storage.find_user_by_id(user_id).await { + Some(UserInfo { + id: user.id.to_string(), + username: user.name.clone(), + display_name: Some(user.name.clone()), + avatar_url: Some(user.avatar_url.clone()), + email: user.email.clone(), + }) + } else { + None + } + } else { + None + } + } else { + None + }; + + let binding = CommitBinding { + id: binding_model.id, + commit_sha: binding_model.commit_sha, + author_email: binding_model.author_email.clone(), + matched_user_id: binding_model.matched_user_id, + is_anonymous: binding_model.is_anonymous, + matched_at: binding_model.matched_at.map(|dt| dt.and_utc().to_rfc3339()), + created_at: binding_model.created_at.and_utc().to_rfc3339(), + user: user_info.clone(), + }; + + // Prepare display information + let (display_name, avatar_url, is_verified_user) = if binding_model.is_anonymous { + ("匿名提交".to_string(), None, false) + } else if let Some(ref user) = user_info { + ( + user.username.clone(), // Use username instead of display_name field + user.avatar_url.clone(), + true + ) + } else { + (binding_model.author_email.split('@').next().unwrap_or("未知用户").to_string(), None, false) + }; + + Ok(Json(CommonResult::success(Some(CommitBindingResponse { + binding: Some(binding), + display_name, + avatar_url, + is_verified_user, + })))) + } + Ok(None) => Ok(Json(CommonResult::success(Some(CommitBindingResponse { + binding: None, + display_name: "匿名提交".to_string(), + avatar_url: None, + is_verified_user: false, + })))), + Err(e) => { + tracing::error!("Failed to query commit binding for {}: {}", sha, e); + Err(ApiError::from(anyhow!( + "Database query failed: {}", + e + ))) + } + } +} diff --git a/gateway/src/api/commit/mod.rs b/gateway/src/api/commit/mod.rs new file mode 100644 index 000000000..0a2c5f3ad --- /dev/null +++ b/gateway/src/api/commit/mod.rs @@ -0,0 +1,2 @@ +pub mod commit_router; +pub use mono::api::commit::model::*; diff --git a/gateway/src/api/commit/model.rs b/gateway/src/api/commit/model.rs new file mode 100644 index 000000000..a1ca6ce0d --- /dev/null +++ b/gateway/src/api/commit/model.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UserInfo { + pub id: String, + pub username: String, + pub display_name: Option, + pub avatar_url: Option, + pub email: String, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct CommitBinding { + pub id: String, + pub commit_sha: String, + pub author_email: String, + pub matched_user_id: Option, + pub is_anonymous: bool, + pub matched_at: Option, + pub created_at: String, + pub user: Option, // Enhanced: Include user object information +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct CommitBindingResponse { + pub binding: Option, + pub display_name: String, // Enhanced: Ready-to-use display name + pub avatar_url: Option, // Enhanced: Ready-to-use avatar URL + pub is_verified_user: bool, // Enhanced: Whether the user is verified in the system +} diff --git a/gateway/src/api/mod.rs b/gateway/src/api/mod.rs index 9410ed62b..85cf28034 100644 --- a/gateway/src/api/mod.rs +++ b/gateway/src/api/mod.rs @@ -1,6 +1,7 @@ use common::model::P2pOptions; use mono::api::MonoApiServiceState; +pub mod commit; pub mod github_router; mod model; diff --git a/gateway/src/https_server.rs b/gateway/src/https_server.rs index 81964ddd7..011e3321c 100644 --- a/gateway/src/https_server.rs +++ b/gateway/src/https_server.rs @@ -17,7 +17,7 @@ use mono::server::http_server::{get_method_router, post_method_router, ProtocolA use utoipa::OpenApi; use utoipa_axum::router::OpenApiRouter; -use crate::api::{github_router, MegaApiServiceState}; +use crate::api::{commit, github_router, MegaApiServiceState}; #[derive(Args, Clone, Debug)] pub struct HttpOptions { @@ -68,7 +68,9 @@ pub async fn app(storage: Storage, host: String, port: u16, p2p: P2pOptions) -> }; pub fn mega_routers() -> OpenApiRouter { - OpenApiRouter::new().merge(github_router::routers()) + OpenApiRouter::new() + .merge(github_router::routers()) + .merge(commit::commit_router::routers()) } // add RequestDecompressionLayer for handle gzip encode diff --git a/jupiter/callisto/src/commit_auths.rs b/jupiter/callisto/src/commit_auths.rs new file mode 100644 index 000000000..d34f45c23 --- /dev/null +++ b/jupiter/callisto/src/commit_auths.rs @@ -0,0 +1,26 @@ +//! `SeaORM` Entity for commit bindings + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "commit_auths")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + #[sea_orm(column_type = "Text")] + pub commit_sha: String, + #[sea_orm(column_type = "Text")] + pub author_email: String, + // matched_user_id links to user.id (bigint) stored as string to be flexible + #[sea_orm(column_type = "Text", nullable)] + pub matched_user_id: Option, + pub is_anonymous: bool, + pub matched_at: Option, + pub created_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/jupiter/callisto/src/mod.rs b/jupiter/callisto/src/mod.rs index e2e5e81ba..770da7e68 100644 --- a/jupiter/callisto/src/mod.rs +++ b/jupiter/callisto/src/mod.rs @@ -34,6 +34,7 @@ pub mod notes; pub mod path_check_configs; pub mod raw_blob; pub mod reactions; +pub mod commit_auths; pub mod relay_lfs_info; pub mod relay_node; pub mod relay_nostr_event; diff --git a/jupiter/callisto/src/prelude.rs b/jupiter/callisto/src/prelude.rs index 8885732c9..eb4109f38 100644 --- a/jupiter/callisto/src/prelude.rs +++ b/jupiter/callisto/src/prelude.rs @@ -30,6 +30,7 @@ pub use super::mq_storage::Entity as MqStorage; pub use super::notes::Entity as Notes; pub use super::path_check_configs::Entity as PathCheckConfigs; pub use super::raw_blob::Entity as RawBlob; +pub use super::commit_auths::Entity as CommitAuths; pub use super::reactions::Entity as Reactions; pub use super::relay_lfs_info::Entity as RelayLfsInfo; pub use super::relay_node::Entity as RelayNode; diff --git a/jupiter/src/migration/m20250904_120000_add_commit_auths.rs b/jupiter/src/migration/m20250904_120000_add_commit_auths.rs new file mode 100644 index 000000000..6f0ca5b64 --- /dev/null +++ b/jupiter/src/migration/m20250904_120000_add_commit_auths.rs @@ -0,0 +1,86 @@ +use sea_orm::DatabaseBackend; +use sea_orm_migration::prelude::*; +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(CommitAuths::Table) + .if_not_exists() + .col( + ColumnDef::new(CommitAuths::Id) + .string() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(CommitAuths::CommitSha).string().not_null()) + .col(ColumnDef::new(CommitAuths::AuthorEmail).string().not_null()) + .col(ColumnDef::new(CommitAuths::MatchedUserId).string().null()) + .col( + ColumnDef::new(CommitAuths::IsAnonymous) + .boolean() + .not_null() + .default(false), + ) + .col(ColumnDef::new(CommitAuths::MatchedAt).timestamp().null()) + .col( + ColumnDef::new(CommitAuths::CreatedAt) + .timestamp() + .not_null(), + ) + .to_owned(), + ) + .await?; + + // add index on commit_sha for fast lookup + manager + .create_index( + Index::create() + .name("idx_commit_auths_commit_sha") + .table(CommitAuths::Table) + .col(CommitAuths::CommitSha) + .to_owned(), + ) + .await?; + + // set DB-side default for created_at depending on backend + let backend = manager.get_database_backend(); + match backend { + DatabaseBackend::Postgres => { + // set default to now() + manager + .get_connection() + .execute_unprepared( + r#"ALTER TABLE commit_auths ALTER COLUMN created_at SET DEFAULT now();"#, + ) + .await?; + } + DatabaseBackend::Sqlite | DatabaseBackend::MySql => {} + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(CommitAuths::Table).to_owned()) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum CommitAuths { + Table, + Id, + CommitSha, + AuthorEmail, + MatchedUserId, + IsAnonymous, + MatchedAt, + CreatedAt, +} diff --git a/jupiter/src/migration/mod.rs b/jupiter/src/migration/mod.rs index df4fa1a54..e6a008aa4 100644 --- a/jupiter/src/migration/mod.rs +++ b/jupiter/src/migration/mod.rs @@ -55,6 +55,7 @@ mod m20250828_092459_remove_gpg_table; mod m20250828_092729_create_standalone_table; mod m20250903_013904_create_task_table; mod m20250903_071928_add_issue_refs; +mod m20250904_120000_add_commit_auths; /// Creates a primary key column definition with big integer type. /// /// # Arguments @@ -97,6 +98,7 @@ impl MigratorTrait for Migrator { Box::new(m20250828_092729_create_standalone_table::Migration), Box::new(m20250903_013904_create_task_table::Migration), Box::new(m20250903_071928_add_issue_refs::Migration), + Box::new(m20250904_120000_add_commit_auths::Migration), ] } } diff --git a/jupiter/src/storage/commit_binding_storage.rs b/jupiter/src/storage/commit_binding_storage.rs new file mode 100644 index 000000000..f33a59554 --- /dev/null +++ b/jupiter/src/storage/commit_binding_storage.rs @@ -0,0 +1,78 @@ +use crate::storage::base_storage::{BaseStorage, StorageConnector}; +use callisto::commit_auths::Column::{AuthorEmail, CommitSha}; +use callisto::commit_auths::{ActiveModel, Entity}; +use common::errors::MegaError; +use sea_orm::{ + ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, +}; +use std::ops::Deref; +use uuid::Uuid; +#[derive(Clone)] +pub struct CommitBindingStorage { + pub base: BaseStorage, +} +impl Deref for CommitBindingStorage { + type Target = BaseStorage; + fn deref(&self) -> &Self::Target { + &self.base + } +} + +impl CommitBindingStorage { + pub fn new(base: BaseStorage) -> Self { + Self { base } + } + + /// Save or update a commit binding + pub async fn upsert_binding( + &self, + sha: &str, + author_email: &str, + matched_user_id: Option, + is_anonymous: bool, + ) -> Result<(), MegaError> { + let now = chrono::Utc::now().naive_utc(); + let model = ActiveModel { + id: ActiveValue::Set(Uuid::new_v4().to_string()), + commit_sha: ActiveValue::Set(sha.to_string()), + author_email: ActiveValue::Set(author_email.to_string()), + matched_user_id: ActiveValue::Set(matched_user_id.clone()), + is_anonymous: ActiveValue::Set(is_anonymous), + matched_at: ActiveValue::Set(Some(now)), + created_at: ActiveValue::Set(now), + }; + + // Try insert, if conflict update + let conn = self.get_connection(); + // Simple upsert: try find then insert/update + let existing = Entity::find() + .filter(CommitSha.eq(sha.to_string())) + .filter(AuthorEmail.eq(author_email.to_string())) + .one(conn) + .await?; + + if let Some(e) = existing { + let mut am = e.into_active_model(); + am.matched_user_id = ActiveValue::Set(matched_user_id.clone()); + am.is_anonymous = ActiveValue::Set(is_anonymous); + am.matched_at = ActiveValue::Set(Some(now)); + am.update(conn).await?; + } else { + model.insert(conn).await?; + } + + Ok(()) + } + + pub async fn find_by_sha( + &self, + sha: &str, + ) -> Result, MegaError> { + let conn = self.get_connection(); + let res = Entity::find() + .filter(CommitSha.eq(sha.to_string())) + .one(conn) + .await?; + Ok(res) + } +} diff --git a/jupiter/src/storage/mod.rs b/jupiter/src/storage/mod.rs index 00907ab42..7ba1652bb 100644 --- a/jupiter/src/storage/mod.rs +++ b/jupiter/src/storage/mod.rs @@ -1,4 +1,5 @@ pub mod base_storage; +pub mod commit_binding_storage; pub mod conversation_storage; pub mod git_db_storage; pub mod gpg_storage; @@ -24,10 +25,10 @@ use crate::service::mr_service::MRService; use crate::storage::conversation_storage::ConversationStorage; use crate::storage::init::database_connection; use crate::storage::{ - git_db_storage::GitDbStorage, gpg_storage::GpgStorage, issue_storage::IssueStorage, - lfs_db_storage::LfsDbStorage, mono_storage::MonoStorage, mr_storage::MrStorage, - raw_db_storage::RawDbStorage, relay_storage::RelayStorage, user_storage::UserStorage, - vault_storage::VaultStorage, + commit_binding_storage::CommitBindingStorage, git_db_storage::GitDbStorage, + gpg_storage::GpgStorage, issue_storage::IssueStorage, lfs_db_storage::LfsDbStorage, + mono_storage::MonoStorage, mr_storage::MrStorage, raw_db_storage::RawDbStorage, + relay_storage::RelayStorage, user_storage::UserStorage, vault_storage::VaultStorage, }; use crate::storage::base_storage::{BaseStorage, StorageConnector}; @@ -48,6 +49,7 @@ pub struct AppService { pub conversation_storage: ConversationStorage, pub lfs_file_storage: Arc, pub note_storage: NoteStorage, + pub commit_binding_storage: CommitBindingStorage, } impl AppService { @@ -67,6 +69,7 @@ impl AppService { issue_storage: IssueStorage { base: mock.clone() }, conversation_storage: ConversationStorage { base: mock.clone() }, note_storage: NoteStorage { base: mock.clone() }, + commit_binding_storage: CommitBindingStorage { base: mock.clone() }, }) } } @@ -97,6 +100,7 @@ impl Storage { let conversation_storage = ConversationStorage { base: base.clone() }; let lfs_file_storage = lfs_storage::init(config.lfs.clone(), connection.clone()).await; let note_storage = NoteStorage { base: base.clone() }; + let commit_binding_storage = CommitBindingStorage { base: base.clone() }; let app_service = AppService { mono_storage, @@ -112,6 +116,7 @@ impl Storage { conversation_storage, lfs_file_storage, note_storage, + commit_binding_storage, }; Storage { app_service: app_service.into(), @@ -177,6 +182,12 @@ impl Storage { self.app_service.note_storage.clone() } + pub fn commit_binding_storage( + &self, + ) -> crate::storage::commit_binding_storage::CommitBindingStorage { + self.app_service.commit_binding_storage.clone() + } + pub fn mock() -> Self { // During test time, we don't need a AppContext, // Put config in a leaked static variable thus the weak reference will always be valid. diff --git a/jupiter/src/storage/user_storage.rs b/jupiter/src/storage/user_storage.rs index 6e6394499..54fc9e78b 100644 --- a/jupiter/src/storage/user_storage.rs +++ b/jupiter/src/storage/user_storage.rs @@ -39,6 +39,13 @@ impl UserStorage { Ok(res) } + pub async fn find_user_by_id(&self, id: i64) -> Result, MegaError> { + let res = user::Entity::find_by_id(id) + .one(self.get_connection()) + .await?; + Ok(res) + } + pub async fn save_user(&self, user: user::Model) -> Result<(), MegaError> { let a_model = user.into_active_model(); a_model.insert(self.get_connection()).await.unwrap(); @@ -143,6 +150,13 @@ impl UserStorage { None => Ok(false), } } + + pub async fn list_all_tokens(&self) -> Result, MegaError> { + let res = access_token::Entity::find() + .all(self.get_connection()) + .await?; + Ok(res) + } } #[cfg(test)] diff --git a/jupiter/src/tests.rs b/jupiter/src/tests.rs index b05d54506..6323a539c 100644 --- a/jupiter/src/tests.rs +++ b/jupiter/src/tests.rs @@ -14,10 +14,10 @@ use crate::storage::base_storage::{BaseStorage, StorageConnector}; use crate::storage::gpg_storage::GpgStorage; use crate::storage::note_storage::NoteStorage; use crate::storage::{ - conversation_storage::ConversationStorage, git_db_storage::GitDbStorage, - issue_storage::IssueStorage, lfs_db_storage::LfsDbStorage, mono_storage::MonoStorage, - mr_storage::MrStorage, raw_db_storage::RawDbStorage, relay_storage::RelayStorage, - user_storage::UserStorage, vault_storage::VaultStorage, + commit_binding_storage::CommitBindingStorage, conversation_storage::ConversationStorage, + git_db_storage::GitDbStorage, issue_storage::IssueStorage, lfs_db_storage::LfsDbStorage, + mono_storage::MonoStorage, mr_storage::MrStorage, raw_db_storage::RawDbStorage, + relay_storage::RelayStorage, user_storage::UserStorage, vault_storage::VaultStorage, }; use crate::storage::{AppService, Storage}; @@ -60,6 +60,7 @@ pub async fn test_storage(temp_dir: impl AsRef) -> Storage { conversation_storage: ConversationStorage { base: base.clone() }, lfs_file_storage: Arc::new(LocalStorage::mock()), // fix it when you really use it. note_storage: NoteStorage { base: base.clone() }, + commit_binding_storage: CommitBindingStorage { base: base.clone() }, }; apply_migrations(&connection, true).await.unwrap(); diff --git a/mono/src/api/api_router.rs b/mono/src/api/api_router.rs index e6b30ce9f..1733df99a 100644 --- a/mono/src/api/api_router.rs +++ b/mono/src/api/api_router.rs @@ -20,8 +20,8 @@ use common::model::CommonResult; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ - conversation::conv_router, gpg::gpg_router, issue::issue_router, label::label_router, - mr::mr_router, notes::note_router, user::user_router, MonoApiServiceState, + commit::commit_router, conversation::conv_router, gpg::gpg_router, issue::issue_router, + label::label_router, mr::mr_router, notes::note_router, user::user_router, MonoApiServiceState, }; use crate::{api::error::ApiError, server::http_server::GIT_TAG}; @@ -45,6 +45,7 @@ pub fn routers() -> OpenApiRouter { .merge(label_router::routers()) .merge(conv_router::routers()) .merge(note_router::routers()) + .merge(commit_router::routers()) } /// Get blob file as string diff --git a/mono/src/api/commit/commit_router.rs b/mono/src/api/commit/commit_router.rs new file mode 100644 index 000000000..430fb2312 --- /dev/null +++ b/mono/src/api/commit/commit_router.rs @@ -0,0 +1,182 @@ +use super::model::{CommitBinding, CommitBindingResponse, UserInfo}; +use crate::api::{error::ApiError, MonoApiServiceState}; +use crate::server::http_server::GIT_TAG; +use axum::{ + extract::{Path, State}, + Json, + routing::get, +}; +use common::model::CommonResult; +use utoipa_axum::router::OpenApiRouter; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct UpdateCommitBindingRequest { + pub user_id: Option, + pub is_anonymous: bool, +} + +pub fn routers() -> OpenApiRouter { + OpenApiRouter::new().nest( + "/commits", + OpenApiRouter::new() + .route("/{sha}/binding", get(get_commit_binding).put(update_commit_binding)) + ) +} + +/// Get commit binding information by commit SHA +#[utoipa::path( + get, + path = "/{sha}", + params( + ("sha" = String, Path, description = "Git commit SHA hash") + ), + responses( + (status = 200, body = CommonResult, content_type = "application/json"), + (status = 404, description = "Commit binding not found") + ), + tag = GIT_TAG +)] +async fn get_commit_binding( + State(state): State, + Path(sha): Path, +) -> Result>, ApiError> { + let commit_binding_storage = state.storage.commit_binding_storage(); + let user_storage = state.storage.user_storage(); + + match commit_binding_storage.find_by_sha(&sha).await { + Ok(Some(binding_model)) => { + // Try to get user information if not anonymous + let user_info = if !binding_model.is_anonymous && binding_model.matched_user_id.is_some() { + let user_id_str = binding_model.matched_user_id.as_ref().unwrap(); + if let Ok(user_id) = user_id_str.parse::() { + if let Ok(Some(user)) = user_storage.find_user_by_id(user_id).await { + Some(UserInfo { + id: user.id.to_string(), + username: user.name.clone(), + display_name: Some(user.name.clone()), // Use name as display_name since display_name field doesn't exist + avatar_url: Some(user.avatar_url.clone()), + email: user.email.clone(), + }) + } else { + None + } + } else { + None + } + } else { + None + }; + + let binding = CommitBinding { + id: binding_model.id, + commit_sha: binding_model.commit_sha, + author_email: binding_model.author_email.clone(), + matched_user_id: binding_model.matched_user_id, + is_anonymous: binding_model.is_anonymous, + matched_at: binding_model.matched_at.map(|dt| dt.and_utc().to_rfc3339()), + created_at: binding_model.created_at.and_utc().to_rfc3339(), + user: user_info.clone(), + }; + + // Prepare display information + let (display_name, avatar_url, is_verified_user) = if binding_model.is_anonymous { + ("匿名提交".to_string(), None, false) + } else if let Some(ref user) = user_info { + ( + user.display_name.clone().unwrap_or(user.username.clone()), + user.avatar_url.clone(), + true + ) + } else { + // Fallback for cases where user is matched but user info is not available + (binding_model.author_email.split('@').next().unwrap_or("未知用户").to_string(), None, false) + }; + + Ok(Json(CommonResult::success(Some(CommitBindingResponse { + binding: Some(binding), + display_name, + avatar_url, + is_verified_user, + })))) + } + Ok(None) => Ok(Json(CommonResult::success(Some(CommitBindingResponse { + binding: None, + display_name: "匿名提交".to_string(), + avatar_url: None, + is_verified_user: false, + })))), + Err(e) => { + tracing::error!("Failed to query commit binding for {}: {}", sha, e); + Err(ApiError::from(anyhow::anyhow!( + "Database query failed: {}", + e + ))) + } + } +} + +/// Update commit binding information +#[utoipa::path( + put, + path = "/{sha}/binding", + params( + ("sha" = String, Path, description = "Git commit SHA hash") + ), + request_body = UpdateCommitBindingRequest, + responses( + (status = 200, body = CommonResult, content_type = "application/json"), + (status = 404, description = "Commit not found"), + (status = 400, description = "Invalid request") + ), + tag = GIT_TAG +)] +async fn update_commit_binding( + State(state): State, + Path(sha): Path, + Json(request): Json, +) -> Result>, ApiError> { + let commit_binding_storage = state.storage.commit_binding_storage(); + let user_storage = state.storage.user_storage(); + + // First check if commit binding exists + let existing_binding = commit_binding_storage.find_by_sha(&sha).await + .map_err(|e| ApiError::from(anyhow::anyhow!("Database query failed: {}", e)))?; + + let author_email = if let Some(ref binding) = existing_binding { + binding.author_email.clone() + } else { + // If no binding exists, we need the author email - this could be passed in request or derived from git + return Err(ApiError::from(anyhow::anyhow!("No existing binding found for commit {}", sha))); + }; + + // Validate user if not anonymous + if !request.is_anonymous { + if let Some(ref user_id_str) = request.user_id { + if let Ok(user_id) = user_id_str.parse::() { + let user_exists = user_storage.find_user_by_id(user_id).await + .map_err(|e| ApiError::from(anyhow::anyhow!("User validation failed: {}", e)))?; + + if user_exists.is_none() { + return Err(ApiError::from(anyhow::anyhow!("User not found: {}", user_id))); + } + } else { + return Err(ApiError::from(anyhow::anyhow!("Invalid user ID format: {}", user_id_str))); + } + } else { + return Err(ApiError::from(anyhow::anyhow!("User ID required when not anonymous"))); + } + } + + // Update the binding + commit_binding_storage.upsert_binding( + &sha, + &author_email, + request.user_id.clone(), + request.is_anonymous, + ).await + .map_err(|e| ApiError::from(anyhow::anyhow!("Failed to update binding: {}", e)))?; + + // Return updated binding information + get_commit_binding(State(state), Path(sha)).await +} diff --git a/mono/src/api/commit/mod.rs b/mono/src/api/commit/mod.rs new file mode 100644 index 000000000..4cc8c62bf --- /dev/null +++ b/mono/src/api/commit/mod.rs @@ -0,0 +1,2 @@ +pub mod commit_router; +pub mod model; diff --git a/mono/src/api/commit/model.rs b/mono/src/api/commit/model.rs new file mode 100644 index 000000000..a1ca6ce0d --- /dev/null +++ b/mono/src/api/commit/model.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UserInfo { + pub id: String, + pub username: String, + pub display_name: Option, + pub avatar_url: Option, + pub email: String, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct CommitBinding { + pub id: String, + pub commit_sha: String, + pub author_email: String, + pub matched_user_id: Option, + pub is_anonymous: bool, + pub matched_at: Option, + pub created_at: String, + pub user: Option, // Enhanced: Include user object information +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct CommitBindingResponse { + pub binding: Option, + pub display_name: String, // Enhanced: Ready-to-use display name + pub avatar_url: Option, // Enhanced: Ready-to-use avatar URL + pub is_verified_user: bool, // Enhanced: Whether the user is verified in the system +} diff --git a/mono/src/api/mod.rs b/mono/src/api/mod.rs index 8cbfb27c6..95f4fd0d8 100644 --- a/mono/src/api/mod.rs +++ b/mono/src/api/mod.rs @@ -25,6 +25,7 @@ use jupiter::storage::{gpg_storage::GpgStorage, note_storage::NoteStorage}; pub mod api_common; pub mod api_router; +pub mod commit; pub mod conversation; pub mod error; mod gpg; diff --git a/moon/apps/web/components/CodeView/CodeTable.tsx b/moon/apps/web/components/CodeView/CodeTable.tsx index 592479c45..318d2fbfd 100644 --- a/moon/apps/web/components/CodeView/CodeTable.tsx +++ b/moon/apps/web/components/CodeView/CodeTable.tsx @@ -10,6 +10,7 @@ import RTable from './Table' import { columnsType, DirectoryType } from './Table/type' import Markdown from 'react-markdown' import FileIcon from './FileIcon/FileIcon' +import CommitMessageWithAuthor from './CommitMessageWithAuthor' export interface DataType { oid: string @@ -53,8 +54,11 @@ const CodeTable = ({ directory, loading, readmeContent, onCommitInfoChange}: any title: 'Message', dataIndex: ['commit_message'], key: 'commit_message', - render: (_, {commit_message}) => ( - {commit_message} + render: (_, record) => ( + ) }, { diff --git a/moon/apps/web/components/CodeView/CommitHistory.tsx b/moon/apps/web/components/CodeView/CommitHistory.tsx index 60db7eadb..b04775b7a 100644 --- a/moon/apps/web/components/CodeView/CommitHistory.tsx +++ b/moon/apps/web/components/CodeView/CommitHistory.tsx @@ -3,6 +3,7 @@ import { Avatar, Button, ClockIcon, EyeIcon } from '@gitmono/ui' import { MemberHovercard } from '@/components/InlinePost/MemberHovercard' import CommitDetails from './CommitDetails' import { useState } from 'react' +import { useGetCommitBinding } from '@/hooks/useGetCommitBinding' interface UserInfo { avatar_url: string @@ -23,27 +24,49 @@ const CommitHyStyle = { borderRadius: 8 } -export default function CommitHistory({ flag, info }: {flag:string, info: CommitInfo }) { - const [Expand,setExpand] = useState(false) - const ExpandDetails =()=>{ +export default function CommitHistory({ flag, info, commitSha }: { + flag: string, + info: CommitInfo, + commitSha?: string // New prop for commit SHA to fetch binding info +}) { + const [Expand, setExpand] = useState(false) + const { data: commitBinding, isLoading: bindingLoading } = useGetCommitBinding(commitSha) + + const ExpandDetails = () => { setExpand(!Expand) } + // Use binding info if available, otherwise fall back to passed info + const displayUser = commitBinding?.user ? { + avatar_url: commitBinding.avatar_url || info.user.avatar_url, + name: commitBinding.display_name + } : info.user + return ( <>
- + - + - {info.user.name} + {bindingLoading ? '加载中...' : displayUser.name} {info.message} + {commitBinding?.is_anonymous && ( + + 匿名提交 + + )} + {commitBinding?.is_verified_user && ( + + 已验证用户 + + )} { flag === 'contents' && diff --git a/moon/apps/web/components/CodeView/CommitMessageWithAuthor.tsx b/moon/apps/web/components/CodeView/CommitMessageWithAuthor.tsx new file mode 100644 index 000000000..ddad39ea6 --- /dev/null +++ b/moon/apps/web/components/CodeView/CommitMessageWithAuthor.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import Image from 'next/image' +import { useGetCommitBinding } from '@/hooks/useGetCommitBinding' + +interface CommitMessageWithAuthorProps { + commitMessage: string + commitSha?: string +} + +const CommitMessageWithAuthor: React.FC = ({ + commitMessage, + commitSha +}) => { + const { data: commitBinding, isLoading, error } = useGetCommitBinding(commitSha) + + return ( +
+ + {commitMessage} + +
+ {isLoading ? ( + 加载中... + ) : commitBinding ? ( + <> + {/* User Avatar */} + {commitBinding.avatar_url && ( + {commitBinding.display_name} { + // Hide image on error + e.currentTarget.style.display = 'none' + }} + /> + )} + + + by {commitBinding.display_name} + + + {/* Status Indicators */} + {commitBinding.is_anonymous && ( + + 匿名 + + )} + + {commitBinding.is_verified_user && ( + + 已验证 + + )} + + ) : error ? ( + by 匿名提交 + ) : null} +
+
+ ) +} + +export default CommitMessageWithAuthor diff --git a/moon/apps/web/hooks/useGetCommitBinding.ts b/moon/apps/web/hooks/useGetCommitBinding.ts new file mode 100644 index 000000000..55a02f847 --- /dev/null +++ b/moon/apps/web/hooks/useGetCommitBinding.ts @@ -0,0 +1,79 @@ +import { useQuery } from '@tanstack/react-query' + +export interface CommitBindingData { + commit_sha: string + author_email: string + user?: { + id: string + username: string + display_name?: string + avatar_url?: string + email: string + } + is_anonymous: boolean + display_name: string + avatar_url?: string + is_verified_user: boolean +} + +export function useGetCommitBinding(sha: string | undefined) { + return useQuery({ + queryKey: ['commit-binding', sha], + queryFn: async (): Promise => { + const baseUrl = process.env.NEXT_PUBLIC_MONO_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000' + const url = `${baseUrl}/api/v1/mega/commits/${sha}` + + try { + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include' + }) + + if (!response.ok) { + if (response.status === 404) { + return { + commit_sha: sha || 'unknown', + author_email: 'unknown', + is_anonymous: true, + display_name: '匿名提交', + is_verified_user: false + } + } + throw new Error(`Failed to fetch: ${response.status}`) + } + + const result = await response.json() + const data = result.data + + return { + commit_sha: data.binding?.commit_sha || sha || 'unknown', + author_email: data.binding?.author_email || 'unknown', + user: data.binding?.user, + is_anonymous: data.binding?.is_anonymous ?? true, + display_name: data.display_name, + avatar_url: data.avatar_url, + is_verified_user: data.is_verified_user + } + } catch (error) { + if (typeof window !== 'undefined') { + // eslint-disable-next-line no-console + console.error('Commit binding fetch error:', error, 'URL:', url) + } + + return { + commit_sha: sha || 'unknown', + author_email: 'unknown', + is_anonymous: true, + display_name: '匿名提交', + is_verified_user: false + } + } + }, + enabled: !!sha && sha.length >= 3, + staleTime: 5 * 60 * 1000, + retry: 1, + refetchOnWindowFocus: false + }) +} diff --git a/moon/apps/web/pages/[org]/code/blob/[...path].tsx b/moon/apps/web/pages/[org]/code/blob/[...path].tsx index b476287a0..fa7ea205a 100644 --- a/moon/apps/web/pages/[org]/code/blob/[...path].tsx +++ b/moon/apps/web/pages/[org]/code/blob/[...path].tsx @@ -56,7 +56,11 @@ function BlobPage() {
- +
From 10b0e616f468139fdaf2472920e96b04a6b6dcbf Mon Sep 17 00:00:00 2001 From: Ruizhi Huang <231220075@smail.nju.edu.cn> Date: Wed, 10 Sep 2025 15:08:16 +0800 Subject: [PATCH 2/7] feat: implement commit-user binding and fix problems (issue #1409) Signed-off-by: Ruizhi Huang <231220075@smail.nju.edu.cn> --- ceres/src/auth/mod.rs | 21 ++-- ceres/src/pack/monorepo.rs | 2 +- ceres/src/protocol/mod.rs | 30 +++-- ceres/src/protocol/smart.rs | 61 +++++----- docs/api.md | 2 +- docs/database.md | 2 +- gateway/src/api/commit/commit_router.rs | 109 ------------------ gateway/src/api/commit/mod.rs | 2 - gateway/src/api/commit/model.rs | 31 ----- gateway/src/api/mod.rs | 1 - gateway/src/https_server.rs | 3 +- jupiter/callisto/src/commit_auths.rs | 4 +- .../m20250904_120000_add_commit_auths.rs | 4 +- jupiter/src/storage/commit_binding_storage.rs | 9 +- jupiter/src/storage/mono_storage.rs | 81 ++++++++++++- jupiter/src/storage/user_storage.rs | 17 ++- mono/src/api/commit/commit_router.rs | 77 ++++++------- mono/src/api/commit/model.rs | 2 +- .../web/components/CodeView/CodeTable.tsx | 8 +- .../web/components/CodeView/CommitHistory.tsx | 35 +----- .../CodeView/CommitMessageWithAuthor.tsx | 66 ----------- moon/apps/web/hooks/useGetCommitBinding.ts | 79 ------------- .../web/pages/[org]/code/blob/[...path].tsx | 6 +- 23 files changed, 205 insertions(+), 447 deletions(-) delete mode 100644 gateway/src/api/commit/commit_router.rs delete mode 100644 gateway/src/api/commit/mod.rs delete mode 100644 gateway/src/api/commit/model.rs delete mode 100644 moon/apps/web/components/CodeView/CommitMessageWithAuthor.tsx delete mode 100644 moon/apps/web/hooks/useGetCommitBinding.ts diff --git a/ceres/src/auth/mod.rs b/ceres/src/auth/mod.rs index 1522730a8..75a5401c3 100644 --- a/ceres/src/auth/mod.rs +++ b/ceres/src/auth/mod.rs @@ -5,7 +5,6 @@ use jupiter::storage::user_storage::UserStorage; /// User authentication information extracted from Git push process #[derive(Debug, Clone)] pub struct PushUserInfo { - pub user_id: i64, pub username: String, pub primary_email: String, pub all_emails: Vec, // User's email collection @@ -18,7 +17,7 @@ pub trait UserAuthExtractor { async fn extract_user_from_token(&self, access_token: &str) -> Result; /// Verify if email belongs to the authenticated user - async fn verify_email_ownership(&self, user_id: i64, email: &str) -> Result; + async fn verify_email_ownership(&self, username: &str, email: &str) -> Result; /// Extract user information from username (for cases where token is already validated) async fn extract_user_from_username(&self, username: &str) -> Result; @@ -37,16 +36,11 @@ impl DefaultUserAuthExtractor { impl UserAuthExtractor for DefaultUserAuthExtractor { async fn extract_user_from_token(&self, access_token: &str) -> Result { - // Find token in database to get username - let tokens = self.user_storage.list_all_tokens().await?; + // Find username by token + let username = self.user_storage.find_user_by_token(access_token).await? + .ok_or_else(|| MegaError::with_message("Invalid or expired access token"))?; - for token_model in tokens { - if token_model.token == access_token { - return self.extract_user_from_username(&token_model.username).await; - } - } - - Err(MegaError::with_message("Invalid or expired access token")) + self.extract_user_from_username(&username).await } async fn extract_user_from_username(&self, username: &str) -> Result { @@ -59,16 +53,15 @@ impl UserAuthExtractor for DefaultUserAuthExtractor { let all_emails = vec![user.email.clone()]; Ok(PushUserInfo { - user_id: user.id, username: user.name.clone(), primary_email: user.email.clone(), all_emails, }) } - async fn verify_email_ownership(&self, user_id: i64, email: &str) -> Result { + async fn verify_email_ownership(&self, username: &str, email: &str) -> Result { // Get user information - let user = self.user_storage.find_user_by_id(user_id).await? + let user = self.user_storage.find_user_by_name(username).await? .ok_or_else(|| MegaError::with_message("User not found"))?; // Check if email matches primary email diff --git a/ceres/src/pack/monorepo.rs b/ceres/src/pack/monorepo.rs index fabc4eb55..116f007c1 100644 --- a/ceres/src/pack/monorepo.rs +++ b/ceres/src/pack/monorepo.rs @@ -149,7 +149,7 @@ impl RepoHandler for MonoRepo { } else { String::new() }; - storage.save_entry(&commit_id, entry_list).await + storage.save_entry(&commit_id, entry_list, self.username.clone()).await } async fn check_entry(&self, entry: &Entry) -> Result<(), GitError> { diff --git a/ceres/src/protocol/mod.rs b/ceres/src/protocol/mod.rs index efc63d8ba..64da688b0 100644 --- a/ceres/src/protocol/mod.rs +++ b/ceres/src/protocol/mod.rs @@ -235,10 +235,17 @@ impl SmartProtocol { && token == auth_config.test_user_token { // For test user, create a basic PushUserInfo - if let Ok(user_auth) = self.create_user_auth_extractor() { - if let Ok(user_info) = user_auth.extract_user_from_username(username).await { - self.authenticated_user = Some(user_info); - return true; + match self.create_user_auth_extractor() { + Ok(user_auth) => { + if let Ok(user_info) = user_auth.extract_user_from_username(username).await { + self.authenticated_user = Some(user_info); + return true; + } else { + tracing::warn!("Failed to extract user info for test user: {}", username); + } + } + Err(e) => { + tracing::warn!("Failed to create user auth extractor for test user: {}", e); } } return true; @@ -252,10 +259,17 @@ impl SmartProtocol { if token_valid { // Extract user information for valid token - if let Ok(user_auth) = self.create_user_auth_extractor() { - if let Ok(user_info) = user_auth.extract_user_from_username(username).await { - self.authenticated_user = Some(user_info); - return true; + match self.create_user_auth_extractor() { + Ok(user_auth) => { + if let Ok(user_info) = user_auth.extract_user_from_username(username).await { + self.authenticated_user = Some(user_info); + return true; + } else { + tracing::warn!("Failed to extract user info for username: {}", username); + } + } + Err(e) => { + tracing::warn!("Failed to create user auth extractor: {}", e); } } } diff --git a/ceres/src/protocol/smart.rs b/ceres/src/protocol/smart.rs index 71f2f3782..0061cbe2b 100644 --- a/ceres/src/protocol/smart.rs +++ b/ceres/src/protocol/smart.rs @@ -362,13 +362,6 @@ impl SmartProtocol { let repo_handler = self.repo_handler().await?; let commit_binding_storage = self.storage.commit_binding_storage(); - // Get authenticated user info - if not authenticated, mark as anonymous - let (_user_id, _is_anonymous) = if let Some(user_info) = &self.authenticated_user { - (Some(user_info.user_id), false) - } else { - (None, true) - }; - // Extract author email from commit based on repo type let author_email = if repo_handler.is_monorepo() { // For monorepo, get from mono storage @@ -431,37 +424,47 @@ impl SmartProtocol { // Enhanced user matching logic based on authentication status let (final_user_id, is_anonymous) = if let Some(authenticated_user) = &self.authenticated_user { // User is authenticated - check if commit author email belongs to them - if let Ok(user_auth) = self.create_user_auth_extractor() { - let email_belongs_to_user = user_auth - .verify_email_ownership(authenticated_user.user_id, &author_email) - .await - .unwrap_or(false); - - if email_belongs_to_user { - // Author email belongs to authenticated user - (Some(authenticated_user.user_id.to_string()), false) - } else { - // Author email doesn't belong to authenticated user - try general matching + match self.create_user_auth_extractor() { + Ok(user_auth) => { + let email_belongs_to_user = user_auth + .verify_email_ownership(&authenticated_user.username, &author_email) + .await + .unwrap_or(false); + + if email_belongs_to_user { + // Author email belongs to authenticated user + (Some(authenticated_user.username.clone()), false) + } else { + // Author email doesn't belong to authenticated user - try general matching + let user_storage = self.storage.user_storage(); + let matched_user = user_storage.find_user_by_email(&author_email).await?; + + if let Some(user) = matched_user { + (Some(user.id.to_string()), false) + } else { + // Mark as anonymous since we can't match the email + (None, true) + } + } + } + Err(e) => { + // Log the error from create_user_auth_extractor for debugging + tracing::warn!( + "Failed to create user auth extractor for commit binding: {}. \ + Falling back to email-based matching.", + e + ); + + // Fallback to email-based matching if auth extractor fails let user_storage = self.storage.user_storage(); let matched_user = user_storage.find_user_by_email(&author_email).await?; if let Some(user) = matched_user { (Some(user.id.to_string()), false) } else { - // Mark as anonymous since we can't match the email (None, true) } } - } else { - // Fallback to email-based matching if auth extractor fails - let user_storage = self.storage.user_storage(); - let matched_user = user_storage.find_user_by_email(&author_email).await?; - - if let Some(user) = matched_user { - (Some(user.id.to_string()), false) - } else { - (None, true) - } } } else { // No authenticated user - try to match by email only diff --git a/docs/api.md b/docs/api.md index 11feed020..77459f35b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -117,5 +117,5 @@ This part of the API, prefixed with /api/v1, is primarily for fetching Git raw o ```bash curl -X PUT ${MEGA_URL}/api/v1/commits//binding \ -H "Content-Type: application/json" \ - -d '{"user_id": "", "is_anonymous": false}' + -d '{"username": "", "is_anonymous": false}' ``` diff --git a/docs/database.md b/docs/database.md index 85f952d6c..e7a3bdcfb 100644 --- a/docs/database.md +++ b/docs/database.md @@ -357,7 +357,7 @@ erDiagram | id | VARCHAR | PRIMARY KEY | | | commit_sha | VARCHAR | NOT NULL | Git commit SHA hash | | author_email | VARCHAR | NOT NULL | Original author email from commit | -| matched_user_id | VARCHAR | NULL | User ID that this commit is bound to | +| matched_username | VARCHAR | NULL | Username that this commit is bound to | | is_anonymous | BOOLEAN | NOT NULL | Whether the commit should be treated as anonymous | | matched_at | TIMESTAMP | NULL | When the binding was created/updated | | created_at | TIMESTAMP | NOT NULL | When the record was created | diff --git a/gateway/src/api/commit/commit_router.rs b/gateway/src/api/commit/commit_router.rs deleted file mode 100644 index 31c81a2dd..000000000 --- a/gateway/src/api/commit/commit_router.rs +++ /dev/null @@ -1,109 +0,0 @@ -use axum::{ - extract::{Path, State}, - Json, -}; -use common::model::CommonResult; -use mono::api::commit::model::{CommitBindingResponse, UserInfo, CommitBinding}; -use mono::api::error::ApiError; -use utoipa_axum::{router::OpenApiRouter, routes}; -use anyhow::anyhow; - -use crate::api::MegaApiServiceState; - -pub fn routers() -> OpenApiRouter { - OpenApiRouter::new().nest( - "/commits", - OpenApiRouter::new().routes(routes!(get_commit_binding)), - ) -} - -/// Get commit binding information by commit SHA -#[utoipa::path( - get, - path = "/{sha}", - params( - ("sha" = String, Path, description = "Git commit SHA hash") - ), - responses( - (status = 200, body = CommonResult, content_type = "application/json"), - (status = 404, description = "Commit binding not found") - ), - tag = "mega-commit" -)] -async fn get_commit_binding( - State(state): State, - Path(sha): Path, -) -> Result>, ApiError> { - let commit_binding_storage = state.inner.storage.commit_binding_storage(); - let user_storage = state.inner.storage.user_storage(); - - match commit_binding_storage.find_by_sha(&sha).await { - Ok(Some(binding_model)) => { - // Try to get user information if not anonymous - let user_info = if !binding_model.is_anonymous && binding_model.matched_user_id.is_some() { - let user_id_str = binding_model.matched_user_id.as_ref().unwrap(); - if let Ok(user_id) = user_id_str.parse::() { - if let Ok(Some(user)) = user_storage.find_user_by_id(user_id).await { - Some(UserInfo { - id: user.id.to_string(), - username: user.name.clone(), - display_name: Some(user.name.clone()), - avatar_url: Some(user.avatar_url.clone()), - email: user.email.clone(), - }) - } else { - None - } - } else { - None - } - } else { - None - }; - - let binding = CommitBinding { - id: binding_model.id, - commit_sha: binding_model.commit_sha, - author_email: binding_model.author_email.clone(), - matched_user_id: binding_model.matched_user_id, - is_anonymous: binding_model.is_anonymous, - matched_at: binding_model.matched_at.map(|dt| dt.and_utc().to_rfc3339()), - created_at: binding_model.created_at.and_utc().to_rfc3339(), - user: user_info.clone(), - }; - - // Prepare display information - let (display_name, avatar_url, is_verified_user) = if binding_model.is_anonymous { - ("匿名提交".to_string(), None, false) - } else if let Some(ref user) = user_info { - ( - user.username.clone(), // Use username instead of display_name field - user.avatar_url.clone(), - true - ) - } else { - (binding_model.author_email.split('@').next().unwrap_or("未知用户").to_string(), None, false) - }; - - Ok(Json(CommonResult::success(Some(CommitBindingResponse { - binding: Some(binding), - display_name, - avatar_url, - is_verified_user, - })))) - } - Ok(None) => Ok(Json(CommonResult::success(Some(CommitBindingResponse { - binding: None, - display_name: "匿名提交".to_string(), - avatar_url: None, - is_verified_user: false, - })))), - Err(e) => { - tracing::error!("Failed to query commit binding for {}: {}", sha, e); - Err(ApiError::from(anyhow!( - "Database query failed: {}", - e - ))) - } - } -} diff --git a/gateway/src/api/commit/mod.rs b/gateway/src/api/commit/mod.rs deleted file mode 100644 index 0a2c5f3ad..000000000 --- a/gateway/src/api/commit/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod commit_router; -pub use mono::api::commit::model::*; diff --git a/gateway/src/api/commit/model.rs b/gateway/src/api/commit/model.rs deleted file mode 100644 index a1ca6ce0d..000000000 --- a/gateway/src/api/commit/model.rs +++ /dev/null @@ -1,31 +0,0 @@ -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct UserInfo { - pub id: String, - pub username: String, - pub display_name: Option, - pub avatar_url: Option, - pub email: String, -} - -#[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct CommitBinding { - pub id: String, - pub commit_sha: String, - pub author_email: String, - pub matched_user_id: Option, - pub is_anonymous: bool, - pub matched_at: Option, - pub created_at: String, - pub user: Option, // Enhanced: Include user object information -} - -#[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct CommitBindingResponse { - pub binding: Option, - pub display_name: String, // Enhanced: Ready-to-use display name - pub avatar_url: Option, // Enhanced: Ready-to-use avatar URL - pub is_verified_user: bool, // Enhanced: Whether the user is verified in the system -} diff --git a/gateway/src/api/mod.rs b/gateway/src/api/mod.rs index 85cf28034..9410ed62b 100644 --- a/gateway/src/api/mod.rs +++ b/gateway/src/api/mod.rs @@ -1,7 +1,6 @@ use common::model::P2pOptions; use mono::api::MonoApiServiceState; -pub mod commit; pub mod github_router; mod model; diff --git a/gateway/src/https_server.rs b/gateway/src/https_server.rs index 011e3321c..e5e7aecbe 100644 --- a/gateway/src/https_server.rs +++ b/gateway/src/https_server.rs @@ -17,7 +17,7 @@ use mono::server::http_server::{get_method_router, post_method_router, ProtocolA use utoipa::OpenApi; use utoipa_axum::router::OpenApiRouter; -use crate::api::{commit, github_router, MegaApiServiceState}; +use crate::api::{github_router, MegaApiServiceState}; #[derive(Args, Clone, Debug)] pub struct HttpOptions { @@ -70,7 +70,6 @@ pub async fn app(storage: Storage, host: String, port: u16, p2p: P2pOptions) -> pub fn mega_routers() -> OpenApiRouter { OpenApiRouter::new() .merge(github_router::routers()) - .merge(commit::commit_router::routers()) } // add RequestDecompressionLayer for handle gzip encode diff --git a/jupiter/callisto/src/commit_auths.rs b/jupiter/callisto/src/commit_auths.rs index d34f45c23..723836abd 100644 --- a/jupiter/callisto/src/commit_auths.rs +++ b/jupiter/callisto/src/commit_auths.rs @@ -12,9 +12,9 @@ pub struct Model { pub commit_sha: String, #[sea_orm(column_type = "Text")] pub author_email: String, - // matched_user_id links to user.id (bigint) stored as string to be flexible + // matched_username links to user.username (text) stored as string to be flexible #[sea_orm(column_type = "Text", nullable)] - pub matched_user_id: Option, + pub matched_username: Option, pub is_anonymous: bool, pub matched_at: Option, pub created_at: DateTime, diff --git a/jupiter/src/migration/m20250904_120000_add_commit_auths.rs b/jupiter/src/migration/m20250904_120000_add_commit_auths.rs index 6f0ca5b64..1c8467eb4 100644 --- a/jupiter/src/migration/m20250904_120000_add_commit_auths.rs +++ b/jupiter/src/migration/m20250904_120000_add_commit_auths.rs @@ -19,7 +19,7 @@ impl MigrationTrait for Migration { ) .col(ColumnDef::new(CommitAuths::CommitSha).string().not_null()) .col(ColumnDef::new(CommitAuths::AuthorEmail).string().not_null()) - .col(ColumnDef::new(CommitAuths::MatchedUserId).string().null()) + .col(ColumnDef::new(CommitAuths::MatchedUsername).string().null()) .col( ColumnDef::new(CommitAuths::IsAnonymous) .boolean() @@ -79,7 +79,7 @@ enum CommitAuths { Id, CommitSha, AuthorEmail, - MatchedUserId, + MatchedUsername, IsAnonymous, MatchedAt, CreatedAt, diff --git a/jupiter/src/storage/commit_binding_storage.rs b/jupiter/src/storage/commit_binding_storage.rs index f33a59554..a881fbf52 100644 --- a/jupiter/src/storage/commit_binding_storage.rs +++ b/jupiter/src/storage/commit_binding_storage.rs @@ -1,5 +1,5 @@ use crate::storage::base_storage::{BaseStorage, StorageConnector}; -use callisto::commit_auths::Column::{AuthorEmail, CommitSha}; +use callisto::commit_auths::Column::{CommitSha}; use callisto::commit_auths::{ActiveModel, Entity}; use common::errors::MegaError; use sea_orm::{ @@ -28,7 +28,7 @@ impl CommitBindingStorage { &self, sha: &str, author_email: &str, - matched_user_id: Option, + matched_username: Option, is_anonymous: bool, ) -> Result<(), MegaError> { let now = chrono::Utc::now().naive_utc(); @@ -36,7 +36,7 @@ impl CommitBindingStorage { id: ActiveValue::Set(Uuid::new_v4().to_string()), commit_sha: ActiveValue::Set(sha.to_string()), author_email: ActiveValue::Set(author_email.to_string()), - matched_user_id: ActiveValue::Set(matched_user_id.clone()), + matched_username: ActiveValue::Set(matched_username.clone()), is_anonymous: ActiveValue::Set(is_anonymous), matched_at: ActiveValue::Set(Some(now)), created_at: ActiveValue::Set(now), @@ -47,13 +47,12 @@ impl CommitBindingStorage { // Simple upsert: try find then insert/update let existing = Entity::find() .filter(CommitSha.eq(sha.to_string())) - .filter(AuthorEmail.eq(author_email.to_string())) .one(conn) .await?; if let Some(e) = existing { let mut am = e.into_active_model(); - am.matched_user_id = ActiveValue::Set(matched_user_id.clone()); + am.matched_username = ActiveValue::Set(matched_username.clone()); am.is_anonymous = ActiveValue::Set(is_anonymous); am.matched_at = ActiveValue::Set(Some(now)); am.update(conn).await?; diff --git a/jupiter/src/storage/mono_storage.rs b/jupiter/src/storage/mono_storage.rs index dbae94072..696dbd994 100644 --- a/jupiter/src/storage/mono_storage.rs +++ b/jupiter/src/storage/mono_storage.rs @@ -11,10 +11,12 @@ use callisto::{mega_blob, mega_commit, mega_refs, mega_tag, mega_tree, raw_blob} use common::config::MonoConfig; use common::errors::MegaError; use common::utils::{generate_id, MEGA_BRANCH_NAME}; -use mercury::internal::object::MegaObjectModel; +use mercury::internal::object::{MegaObjectModel, ObjectTrait}; use mercury::internal::{object::commit::Commit, pack::entry::Entry}; use crate::storage::base_storage::{BaseStorage, StorageConnector}; +use crate::storage::commit_binding_storage::CommitBindingStorage; +use crate::storage::user_storage::UserStorage; use crate::utils::converter::MegaModelConverter; #[derive(Clone)] @@ -39,6 +41,18 @@ struct GitObjects { } impl MonoStorage { + pub fn user_storage(&self) -> UserStorage { + UserStorage { + base: self.base.clone(), + } + } + + pub fn commit_binding_storage(&self) -> CommitBindingStorage { + CommitBindingStorage { + base: self.base.clone(), + } + } + pub async fn save_ref( &self, path: &str, @@ -137,6 +151,7 @@ impl MonoStorage { &self, commit_id: &str, entry_list: Vec, + authenticated_username: Option, ) -> Result<(), MegaError> { let git_objects = Arc::new(Mutex::new(GitObjects { commits: Vec::new(), @@ -146,15 +161,24 @@ impl MonoStorage { tags: Vec::new(), })); + // Collect commits for binding processing + let commits_to_process = Arc::new(Mutex::new(Vec::<(String, String)>::new())); + stream::iter(entry_list) .for_each_concurrent(None, |entry| { let git_objects = git_objects.clone(); + let commits_to_process = commits_to_process.clone(); async move { let raw_obj = entry.process_entry(); let model = raw_obj.convert_to_mega_model(); let mut git_objects = git_objects.lock().unwrap(); match model { MegaObjectModel::Commit(commit) => { + // Store for binding processing + if let Ok(commit_obj) = mercury::internal::object::commit::Commit::from_bytes(&entry.data, entry.hash) { + let mut commits = commits_to_process.lock().unwrap(); + commits.push((commit_obj.id.to_string(), commit_obj.author.email.clone())); + } git_objects.commits.push(commit.into_active_model()) } MegaObjectModel::Tree(mut tree) => { @@ -183,6 +207,61 @@ impl MonoStorage { self.batch_save_model(git_objects.raw_blobs).await.unwrap(); self.batch_save_model(git_objects.tags).await.unwrap(); + // Process commit author bindings after saving objects + let commits_to_process = Arc::try_unwrap(commits_to_process) + .expect("Failed to unwrap Arc") + .into_inner() + .unwrap(); + + if !commits_to_process.is_empty() { + self.process_commit_bindings(&commits_to_process, authenticated_username.as_deref()).await?; + } + + Ok(()) + } + + /// Process commit author bindings + async fn process_commit_bindings(&self, commits: &[(String, String)], authenticated_username: Option<&str>) -> Result<(), MegaError> { + let user_storage = self.user_storage(); + let commit_binding_storage = self.commit_binding_storage(); + + for (commit_sha, author_email) in commits { + // Try to find user by authenticated username first + let matched_username = if let Some(username) = authenticated_username { + // If authenticated username is available, use it to find user + match user_storage.find_user_by_name(username).await { + Ok(Some(_user)) => { + tracing::info!("Found user for username: {}", username); + Some(username.to_string()) + } + Ok(None) => { + tracing::warn!("Authenticated username {} not found in user table", username); + None + } + Err(e) => { + tracing::error!("Error finding user by username {}: {}", username, e); + None + } + } + } else { + // No authenticated username, commit will be anonymous + tracing::info!("No authenticated username available for commit {}", commit_sha); + None + }; + + let is_anonymous = matched_username.is_none(); + + // Save or update binding + if let Err(e) = commit_binding_storage + .upsert_binding(commit_sha, author_email, matched_username, is_anonymous) + .await + { + tracing::error!("Failed to save commit binding for {}: {}", commit_sha, e); + // Continue processing other commits even if one fails + } else { + tracing::info!("Processed binding for commit {} with email {} (anonymous: {})", commit_sha, author_email, is_anonymous); + } + } Ok(()) } diff --git a/jupiter/src/storage/user_storage.rs b/jupiter/src/storage/user_storage.rs index 54fc9e78b..614981c91 100644 --- a/jupiter/src/storage/user_storage.rs +++ b/jupiter/src/storage/user_storage.rs @@ -39,13 +39,6 @@ impl UserStorage { Ok(res) } - pub async fn find_user_by_id(&self, id: i64) -> Result, MegaError> { - let res = user::Entity::find_by_id(id) - .one(self.get_connection()) - .await?; - Ok(res) - } - pub async fn save_user(&self, user: user::Model) -> Result<(), MegaError> { let a_model = user.into_active_model(); a_model.insert(self.get_connection()).await.unwrap(); @@ -151,11 +144,15 @@ impl UserStorage { } } - pub async fn list_all_tokens(&self) -> Result, MegaError> { + pub async fn find_user_by_token(&self, token: &str) -> Result, MegaError> { let res = access_token::Entity::find() - .all(self.get_connection()) + .filter(access_token::Column::Token.eq(token)) + .one(self.get_connection()) .await?; - Ok(res) + match res { + Some(token_model) => Ok(Some(token_model.username)), + None => Ok(None), + } } } diff --git a/mono/src/api/commit/commit_router.rs b/mono/src/api/commit/commit_router.rs index 430fb2312..c692b54c8 100644 --- a/mono/src/api/commit/commit_router.rs +++ b/mono/src/api/commit/commit_router.rs @@ -4,39 +4,38 @@ use crate::server::http_server::GIT_TAG; use axum::{ extract::{Path, State}, Json, - routing::get, }; use common::model::CommonResult; -use utoipa_axum::router::OpenApiRouter; +use utoipa_axum::{router::OpenApiRouter, routes}; use serde::{Deserialize, Serialize}; #[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] pub struct UpdateCommitBindingRequest { - pub user_id: Option, + pub username: Option, pub is_anonymous: bool, } pub fn routers() -> OpenApiRouter { - OpenApiRouter::new().nest( - "/commits", - OpenApiRouter::new() - .route("/{sha}/binding", get(get_commit_binding).put(update_commit_binding)) - ) + OpenApiRouter::new() + .routes(routes!(get_commit_binding)) + .routes(routes!(update_commit_binding)) } /// Get commit binding information by commit SHA #[utoipa::path( get, - path = "/{sha}", + path = "/commits/{sha}/binding", params( ("sha" = String, Path, description = "Git commit SHA hash") ), responses( - (status = 200, body = CommonResult, content_type = "application/json"), + (status = 200, description = "Get commit binding information successfully", + body = CommonResult, content_type = "application/json"), (status = 404, description = "Commit binding not found") ), tag = GIT_TAG )] +#[axum::debug_handler] async fn get_commit_binding( State(state): State, Path(sha): Path, @@ -47,20 +46,16 @@ async fn get_commit_binding( match commit_binding_storage.find_by_sha(&sha).await { Ok(Some(binding_model)) => { // Try to get user information if not anonymous - let user_info = if !binding_model.is_anonymous && binding_model.matched_user_id.is_some() { - let user_id_str = binding_model.matched_user_id.as_ref().unwrap(); - if let Ok(user_id) = user_id_str.parse::() { - if let Ok(Some(user)) = user_storage.find_user_by_id(user_id).await { - Some(UserInfo { - id: user.id.to_string(), - username: user.name.clone(), - display_name: Some(user.name.clone()), // Use name as display_name since display_name field doesn't exist - avatar_url: Some(user.avatar_url.clone()), - email: user.email.clone(), - }) - } else { - None - } + let user_info = if !binding_model.is_anonymous && binding_model.matched_username.is_some() { + let username = binding_model.matched_username.as_ref().unwrap(); + if let Ok(Some(user)) = user_storage.find_user_by_name(username).await { + Some(UserInfo { + id: user.id.to_string(), + username: user.name.clone(), + display_name: Some(user.name.clone()), // Use name as display_name since display_name field doesn't exist + avatar_url: Some(user.avatar_url.clone()), + email: user.email.clone(), + }) } else { None } @@ -72,7 +67,7 @@ async fn get_commit_binding( id: binding_model.id, commit_sha: binding_model.commit_sha, author_email: binding_model.author_email.clone(), - matched_user_id: binding_model.matched_user_id, + matched_username: binding_model.matched_username, is_anonymous: binding_model.is_anonymous, matched_at: binding_model.matched_at.map(|dt| dt.and_utc().to_rfc3339()), created_at: binding_model.created_at.and_utc().to_rfc3339(), @@ -81,7 +76,7 @@ async fn get_commit_binding( // Prepare display information let (display_name, avatar_url, is_verified_user) = if binding_model.is_anonymous { - ("匿名提交".to_string(), None, false) + ("Anonymous".to_string(), None, false) } else if let Some(ref user) = user_info { ( user.display_name.clone().unwrap_or(user.username.clone()), @@ -90,7 +85,7 @@ async fn get_commit_binding( ) } else { // Fallback for cases where user is matched but user info is not available - (binding_model.author_email.split('@').next().unwrap_or("未知用户").to_string(), None, false) + (binding_model.author_email.split('@').next().unwrap_or(&binding_model.author_email).to_string(), None, false) }; Ok(Json(CommonResult::success(Some(CommitBindingResponse { @@ -102,7 +97,7 @@ async fn get_commit_binding( } Ok(None) => Ok(Json(CommonResult::success(Some(CommitBindingResponse { binding: None, - display_name: "匿名提交".to_string(), + display_name: "Anonymous".to_string(), avatar_url: None, is_verified_user: false, })))), @@ -119,18 +114,20 @@ async fn get_commit_binding( /// Update commit binding information #[utoipa::path( put, - path = "/{sha}/binding", + path = "/commits/{sha}/binding", params( ("sha" = String, Path, description = "Git commit SHA hash") ), request_body = UpdateCommitBindingRequest, responses( - (status = 200, body = CommonResult, content_type = "application/json"), + (status = 200, description = "Update commit binding information successfully", + body = CommonResult, content_type = "application/json"), (status = 404, description = "Commit not found"), (status = 400, description = "Invalid request") ), tag = GIT_TAG )] +#[axum::debug_handler] async fn update_commit_binding( State(state): State, Path(sha): Path, @@ -152,19 +149,15 @@ async fn update_commit_binding( // Validate user if not anonymous if !request.is_anonymous { - if let Some(ref user_id_str) = request.user_id { - if let Ok(user_id) = user_id_str.parse::() { - let user_exists = user_storage.find_user_by_id(user_id).await - .map_err(|e| ApiError::from(anyhow::anyhow!("User validation failed: {}", e)))?; - - if user_exists.is_none() { - return Err(ApiError::from(anyhow::anyhow!("User not found: {}", user_id))); - } - } else { - return Err(ApiError::from(anyhow::anyhow!("Invalid user ID format: {}", user_id_str))); + if let Some(ref username) = request.username { + let user_exists = user_storage.find_user_by_name(username).await + .map_err(|e| ApiError::from(anyhow::anyhow!("User validation failed: {}", e)))?; + + if user_exists.is_none() { + return Err(ApiError::from(anyhow::anyhow!("User not found: {}", username))); } } else { - return Err(ApiError::from(anyhow::anyhow!("User ID required when not anonymous"))); + return Err(ApiError::from(anyhow::anyhow!("Username required when not anonymous"))); } } @@ -172,7 +165,7 @@ async fn update_commit_binding( commit_binding_storage.upsert_binding( &sha, &author_email, - request.user_id.clone(), + request.username.clone(), request.is_anonymous, ).await .map_err(|e| ApiError::from(anyhow::anyhow!("Failed to update binding: {}", e)))?; diff --git a/mono/src/api/commit/model.rs b/mono/src/api/commit/model.rs index a1ca6ce0d..3bcaa5e75 100644 --- a/mono/src/api/commit/model.rs +++ b/mono/src/api/commit/model.rs @@ -15,7 +15,7 @@ pub struct CommitBinding { pub id: String, pub commit_sha: String, pub author_email: String, - pub matched_user_id: Option, + pub matched_username: Option, pub is_anonymous: bool, pub matched_at: Option, pub created_at: String, diff --git a/moon/apps/web/components/CodeView/CodeTable.tsx b/moon/apps/web/components/CodeView/CodeTable.tsx index 318d2fbfd..592479c45 100644 --- a/moon/apps/web/components/CodeView/CodeTable.tsx +++ b/moon/apps/web/components/CodeView/CodeTable.tsx @@ -10,7 +10,6 @@ import RTable from './Table' import { columnsType, DirectoryType } from './Table/type' import Markdown from 'react-markdown' import FileIcon from './FileIcon/FileIcon' -import CommitMessageWithAuthor from './CommitMessageWithAuthor' export interface DataType { oid: string @@ -54,11 +53,8 @@ const CodeTable = ({ directory, loading, readmeContent, onCommitInfoChange}: any title: 'Message', dataIndex: ['commit_message'], key: 'commit_message', - render: (_, record) => ( - + render: (_, {commit_message}) => ( + {commit_message} ) }, { diff --git a/moon/apps/web/components/CodeView/CommitHistory.tsx b/moon/apps/web/components/CodeView/CommitHistory.tsx index b04775b7a..60db7eadb 100644 --- a/moon/apps/web/components/CodeView/CommitHistory.tsx +++ b/moon/apps/web/components/CodeView/CommitHistory.tsx @@ -3,7 +3,6 @@ import { Avatar, Button, ClockIcon, EyeIcon } from '@gitmono/ui' import { MemberHovercard } from '@/components/InlinePost/MemberHovercard' import CommitDetails from './CommitDetails' import { useState } from 'react' -import { useGetCommitBinding } from '@/hooks/useGetCommitBinding' interface UserInfo { avatar_url: string @@ -24,49 +23,27 @@ const CommitHyStyle = { borderRadius: 8 } -export default function CommitHistory({ flag, info, commitSha }: { - flag: string, - info: CommitInfo, - commitSha?: string // New prop for commit SHA to fetch binding info -}) { - const [Expand, setExpand] = useState(false) - const { data: commitBinding, isLoading: bindingLoading } = useGetCommitBinding(commitSha) - - const ExpandDetails = () => { +export default function CommitHistory({ flag, info }: {flag:string, info: CommitInfo }) { + const [Expand,setExpand] = useState(false) + const ExpandDetails =()=>{ setExpand(!Expand) } - // Use binding info if available, otherwise fall back to passed info - const displayUser = commitBinding?.user ? { - avatar_url: commitBinding.avatar_url || info.user.avatar_url, - name: commitBinding.display_name - } : info.user - return ( <>
- + - + - {bindingLoading ? '加载中...' : displayUser.name} + {info.user.name} {info.message} - {commitBinding?.is_anonymous && ( - - 匿名提交 - - )} - {commitBinding?.is_verified_user && ( - - 已验证用户 - - )} { flag === 'contents' && diff --git a/moon/apps/web/components/CodeView/CommitMessageWithAuthor.tsx b/moon/apps/web/components/CodeView/CommitMessageWithAuthor.tsx deleted file mode 100644 index ddad39ea6..000000000 --- a/moon/apps/web/components/CodeView/CommitMessageWithAuthor.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react' -import Image from 'next/image' -import { useGetCommitBinding } from '@/hooks/useGetCommitBinding' - -interface CommitMessageWithAuthorProps { - commitMessage: string - commitSha?: string -} - -const CommitMessageWithAuthor: React.FC = ({ - commitMessage, - commitSha -}) => { - const { data: commitBinding, isLoading, error } = useGetCommitBinding(commitSha) - - return ( -
- - {commitMessage} - -
- {isLoading ? ( - 加载中... - ) : commitBinding ? ( - <> - {/* User Avatar */} - {commitBinding.avatar_url && ( - {commitBinding.display_name} { - // Hide image on error - e.currentTarget.style.display = 'none' - }} - /> - )} - - - by {commitBinding.display_name} - - - {/* Status Indicators */} - {commitBinding.is_anonymous && ( - - 匿名 - - )} - - {commitBinding.is_verified_user && ( - - 已验证 - - )} - - ) : error ? ( - by 匿名提交 - ) : null} -
-
- ) -} - -export default CommitMessageWithAuthor diff --git a/moon/apps/web/hooks/useGetCommitBinding.ts b/moon/apps/web/hooks/useGetCommitBinding.ts deleted file mode 100644 index 55a02f847..000000000 --- a/moon/apps/web/hooks/useGetCommitBinding.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { useQuery } from '@tanstack/react-query' - -export interface CommitBindingData { - commit_sha: string - author_email: string - user?: { - id: string - username: string - display_name?: string - avatar_url?: string - email: string - } - is_anonymous: boolean - display_name: string - avatar_url?: string - is_verified_user: boolean -} - -export function useGetCommitBinding(sha: string | undefined) { - return useQuery({ - queryKey: ['commit-binding', sha], - queryFn: async (): Promise => { - const baseUrl = process.env.NEXT_PUBLIC_MONO_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000' - const url = `${baseUrl}/api/v1/mega/commits/${sha}` - - try { - const response = await fetch(url, { - headers: { - 'Content-Type': 'application/json' - }, - credentials: 'include' - }) - - if (!response.ok) { - if (response.status === 404) { - return { - commit_sha: sha || 'unknown', - author_email: 'unknown', - is_anonymous: true, - display_name: '匿名提交', - is_verified_user: false - } - } - throw new Error(`Failed to fetch: ${response.status}`) - } - - const result = await response.json() - const data = result.data - - return { - commit_sha: data.binding?.commit_sha || sha || 'unknown', - author_email: data.binding?.author_email || 'unknown', - user: data.binding?.user, - is_anonymous: data.binding?.is_anonymous ?? true, - display_name: data.display_name, - avatar_url: data.avatar_url, - is_verified_user: data.is_verified_user - } - } catch (error) { - if (typeof window !== 'undefined') { - // eslint-disable-next-line no-console - console.error('Commit binding fetch error:', error, 'URL:', url) - } - - return { - commit_sha: sha || 'unknown', - author_email: 'unknown', - is_anonymous: true, - display_name: '匿名提交', - is_verified_user: false - } - } - }, - enabled: !!sha && sha.length >= 3, - staleTime: 5 * 60 * 1000, - retry: 1, - refetchOnWindowFocus: false - }) -} diff --git a/moon/apps/web/pages/[org]/code/blob/[...path].tsx b/moon/apps/web/pages/[org]/code/blob/[...path].tsx index fa7ea205a..b476287a0 100644 --- a/moon/apps/web/pages/[org]/code/blob/[...path].tsx +++ b/moon/apps/web/pages/[org]/code/blob/[...path].tsx @@ -56,11 +56,7 @@ function BlobPage() {
- +
From 0578014acae79533203c5b17248af1dd080df3c8 Mon Sep 17 00:00:00 2001 From: Ruizhi Huang <231220075@smail.nju.edu.cn> Date: Wed, 10 Sep 2025 15:08:16 +0800 Subject: [PATCH 3/7] feat: implement commit-user binding and fix problems (issue #1409) Signed-off-by: Ruizhi Huang <231220075@smail.nju.edu.cn> fix bugs in ceres/src/protocol/smart.rs Signed-off-by: Ruizhi Huang <231220075@smail.nju.edu.cn> --- ceres/src/protocol/smart.rs | 6 +++--- gateway/src/https_server.rs | 3 +-- mono/src/api/commit/commit_router.rs | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/ceres/src/protocol/smart.rs b/ceres/src/protocol/smart.rs index 0061cbe2b..12eeaf86a 100644 --- a/ceres/src/protocol/smart.rs +++ b/ceres/src/protocol/smart.rs @@ -440,7 +440,7 @@ impl SmartProtocol { let matched_user = user_storage.find_user_by_email(&author_email).await?; if let Some(user) = matched_user { - (Some(user.id.to_string()), false) + (Some(user.name.clone()), false) } else { // Mark as anonymous since we can't match the email (None, true) @@ -460,7 +460,7 @@ impl SmartProtocol { let matched_user = user_storage.find_user_by_email(&author_email).await?; if let Some(user) = matched_user { - (Some(user.id.to_string()), false) + (Some(user.name.clone()), false) } else { (None, true) } @@ -472,7 +472,7 @@ impl SmartProtocol { let matched_user = user_storage.find_user_by_email(&author_email).await?; if let Some(user) = matched_user { - (Some(user.id.to_string()), false) + (Some(user.name.clone()), false) } else { (None, true) } diff --git a/gateway/src/https_server.rs b/gateway/src/https_server.rs index e5e7aecbe..81964ddd7 100644 --- a/gateway/src/https_server.rs +++ b/gateway/src/https_server.rs @@ -68,8 +68,7 @@ pub async fn app(storage: Storage, host: String, port: u16, p2p: P2pOptions) -> }; pub fn mega_routers() -> OpenApiRouter { - OpenApiRouter::new() - .merge(github_router::routers()) + OpenApiRouter::new().merge(github_router::routers()) } // add RequestDecompressionLayer for handle gzip encode diff --git a/mono/src/api/commit/commit_router.rs b/mono/src/api/commit/commit_router.rs index c692b54c8..ac2bbf570 100644 --- a/mono/src/api/commit/commit_router.rs +++ b/mono/src/api/commit/commit_router.rs @@ -52,7 +52,7 @@ async fn get_commit_binding( Some(UserInfo { id: user.id.to_string(), username: user.name.clone(), - display_name: Some(user.name.clone()), // Use name as display_name since display_name field doesn't exist + display_name: Some(user.name.clone()), // Use name as display_name (fallback if no separate display name is available) avatar_url: Some(user.avatar_url.clone()), email: user.email.clone(), }) From 23253186f51edc12afe70ceaf9b70b9d72b1b903 Mon Sep 17 00:00:00 2001 From: Ruizhi Huang <231220075@smail.nju.edu.cn> Date: Thu, 11 Sep 2025 11:31:15 +0800 Subject: [PATCH 4/7] refactor: merge GET commit-binding into GET latest-commit Signed-off-by: Ruizhi Huang <231220075@smail.nju.edu.cn> --- ceres/src/model/git.rs | 12 ++++ docs/api.md | 8 +-- mono/src/api/api_router.rs | 71 +++++++++++++++---- mono/src/api/commit/commit_router.rs | 102 +++------------------------ 4 files changed, 79 insertions(+), 114 deletions(-) diff --git a/ceres/src/model/git.rs b/ceres/src/model/git.rs index 643280938..2fad9e009 100644 --- a/ceres/src/model/git.rs +++ b/ceres/src/model/git.rs @@ -48,6 +48,17 @@ pub struct LatestCommitInfo { pub author: UserInfo, pub committer: UserInfo, pub status: String, + pub binding_info: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct CommitBindingInfo { + pub matched_username: Option, + pub is_anonymous: bool, + pub is_verified_user: bool, + pub display_name: String, + pub avatar_url: Option, + pub author_email: String, } impl From for LatestCommitInfo { @@ -68,6 +79,7 @@ impl From for LatestCommitInfo { author, committer, status: "success".to_string(), + binding_info: None, // Will be populated at API layer } } } diff --git a/docs/api.md b/docs/api.md index 77459f35b..2296ba505 100644 --- a/docs/api.md +++ b/docs/api.md @@ -106,13 +106,7 @@ This part of the API, prefixed with /api/v1, is primarily for fetching Git raw o curl -X GET ${MEGA_URL}/api/v1/count-objs?repo_path= ``` -6. Retrieve commit binding information for associating commits with users - - ```bash - curl -X GET ${MEGA_URL}/api/v1/commits//binding - ``` - -7. Update commit binding to associate a commit with a specific user or mark as anonymous +6. Update commit binding to associate a commit with a specific user or mark as anonymous ```bash curl -X PUT ${MEGA_URL}/api/v1/commits//binding \ diff --git a/mono/src/api/api_router.rs b/mono/src/api/api_router.rs index 1733df99a..78bc680d8 100644 --- a/mono/src/api/api_router.rs +++ b/mono/src/api/api_router.rs @@ -13,17 +13,18 @@ use ceres::{ api_service::ApiHandler, model::git::{ BlobContentQuery, CodePreviewQuery, CreateFileInfo, FileTreeItem, LatestCommitInfo, - TreeCommitItem, TreeHashItem, TreeQuery, TreeResponse, + TreeCommitItem, TreeHashItem, TreeQuery, TreeResponse, CommitBindingInfo, }, }; use common::model::CommonResult; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ - commit::commit_router, conversation::conv_router, gpg::gpg_router, issue::issue_router, - label::label_router, mr::mr_router, notes::note_router, user::user_router, MonoApiServiceState, + commit::commit_router, conversation::conv_router, error::ApiError, gpg::gpg_router, + issue::issue_router, label::label_router, mr::mr_router, notes::note_router, + user::user_router, MonoApiServiceState, }; -use crate::{api::error::ApiError, server::http_server::GIT_TAG}; +use crate::server::http_server::GIT_TAG; pub fn routers() -> OpenApiRouter { OpenApiRouter::new() @@ -124,23 +125,67 @@ async fn get_latest_commit( state: State, ) -> Result, ApiError> { let query_path: std::path::PathBuf = query.path.into(); - let import_dir = state.storage.config().monorepo.import_dir.clone(); - if let Ok(rest) = query_path.strip_prefix(import_dir) { + let import_dir = state.storage.config().monorepo.import_dir.clone(); + let mut commit_info = if let Ok(rest) = query_path.strip_prefix(import_dir) { if rest.components().count() == 1 { let res = state .monorepo() .get_latest_commit(query_path.clone()) .await?; - return Ok(Json(res)); + res + } else { + let res = state + .api_handler(&query_path) + .await? + .get_latest_commit(query_path) + .await?; + res } + } else { + let res = state + .api_handler(&query_path) + .await? + .get_latest_commit(query_path) + .await?; + res + }; + + // Query commit binding information + let commit_binding_storage = state.storage.commit_binding_storage(); + let user_storage = state.storage.user_storage(); + + if let Ok(Some(binding_model)) = commit_binding_storage.find_by_sha(&commit_info.oid).await { + // Get user information if not anonymous + let user_info = if !binding_model.is_anonymous && binding_model.matched_username.is_some() { + let username = binding_model.matched_username.as_ref().unwrap(); + if let Ok(Some(user)) = user_storage.find_user_by_name(username).await { + Some((user.name.clone(), user.avatar_url.clone())) + } else { + None + } + } else { + None + }; + + let (display_name, avatar_url, is_verified_user) = if binding_model.is_anonymous { + ("Anonymous".to_string(), None, false) + } else if let Some((username, avatar)) = user_info { + (username, Some(avatar), true) + } else { + (binding_model.author_email.split('@').next().unwrap_or(&binding_model.author_email).to_string(), None, false) + }; + + commit_info.binding_info = Some(CommitBindingInfo { + matched_username: binding_model.matched_username, + is_anonymous: binding_model.is_anonymous, + is_verified_user, + display_name, + avatar_url, + author_email: binding_model.author_email, + }); } - let res = state - .api_handler(&query_path) - .await? - .get_latest_commit(query_path) - .await?; - Ok(Json(res)) + Ok(Json(commit_info)) } /// Get tree brief info diff --git a/mono/src/api/commit/commit_router.rs b/mono/src/api/commit/commit_router.rs index ac2bbf570..48d8de95a 100644 --- a/mono/src/api/commit/commit_router.rs +++ b/mono/src/api/commit/commit_router.rs @@ -1,4 +1,4 @@ -use super::model::{CommitBinding, CommitBindingResponse, UserInfo}; +use super::model::CommitBindingResponse; use crate::api::{error::ApiError, MonoApiServiceState}; use crate::server::http_server::GIT_TAG; use axum::{ @@ -17,100 +17,9 @@ pub struct UpdateCommitBindingRequest { pub fn routers() -> OpenApiRouter { OpenApiRouter::new() - .routes(routes!(get_commit_binding)) .routes(routes!(update_commit_binding)) } -/// Get commit binding information by commit SHA -#[utoipa::path( - get, - path = "/commits/{sha}/binding", - params( - ("sha" = String, Path, description = "Git commit SHA hash") - ), - responses( - (status = 200, description = "Get commit binding information successfully", - body = CommonResult, content_type = "application/json"), - (status = 404, description = "Commit binding not found") - ), - tag = GIT_TAG -)] -#[axum::debug_handler] -async fn get_commit_binding( - State(state): State, - Path(sha): Path, -) -> Result>, ApiError> { - let commit_binding_storage = state.storage.commit_binding_storage(); - let user_storage = state.storage.user_storage(); - - match commit_binding_storage.find_by_sha(&sha).await { - Ok(Some(binding_model)) => { - // Try to get user information if not anonymous - let user_info = if !binding_model.is_anonymous && binding_model.matched_username.is_some() { - let username = binding_model.matched_username.as_ref().unwrap(); - if let Ok(Some(user)) = user_storage.find_user_by_name(username).await { - Some(UserInfo { - id: user.id.to_string(), - username: user.name.clone(), - display_name: Some(user.name.clone()), // Use name as display_name (fallback if no separate display name is available) - avatar_url: Some(user.avatar_url.clone()), - email: user.email.clone(), - }) - } else { - None - } - } else { - None - }; - - let binding = CommitBinding { - id: binding_model.id, - commit_sha: binding_model.commit_sha, - author_email: binding_model.author_email.clone(), - matched_username: binding_model.matched_username, - is_anonymous: binding_model.is_anonymous, - matched_at: binding_model.matched_at.map(|dt| dt.and_utc().to_rfc3339()), - created_at: binding_model.created_at.and_utc().to_rfc3339(), - user: user_info.clone(), - }; - - // Prepare display information - let (display_name, avatar_url, is_verified_user) = if binding_model.is_anonymous { - ("Anonymous".to_string(), None, false) - } else if let Some(ref user) = user_info { - ( - user.display_name.clone().unwrap_or(user.username.clone()), - user.avatar_url.clone(), - true - ) - } else { - // Fallback for cases where user is matched but user info is not available - (binding_model.author_email.split('@').next().unwrap_or(&binding_model.author_email).to_string(), None, false) - }; - - Ok(Json(CommonResult::success(Some(CommitBindingResponse { - binding: Some(binding), - display_name, - avatar_url, - is_verified_user, - })))) - } - Ok(None) => Ok(Json(CommonResult::success(Some(CommitBindingResponse { - binding: None, - display_name: "Anonymous".to_string(), - avatar_url: None, - is_verified_user: false, - })))), - Err(e) => { - tracing::error!("Failed to query commit binding for {}: {}", sha, e); - Err(ApiError::from(anyhow::anyhow!( - "Database query failed: {}", - e - ))) - } - } -} - /// Update commit binding information #[utoipa::path( put, @@ -170,6 +79,11 @@ async fn update_commit_binding( ).await .map_err(|e| ApiError::from(anyhow::anyhow!("Failed to update binding: {}", e)))?; - // Return updated binding information - get_commit_binding(State(state), Path(sha)).await + // Return success response + Ok(Json(CommonResult::success(Some(CommitBindingResponse { + binding: None, // Simplified response + display_name: request.username.unwrap_or("Anonymous".to_string()), + avatar_url: None, + is_verified_user: !request.is_anonymous, + })))) } From 145cf4616e2baf9595ba4ae1bdc0a478af9b9227 Mon Sep 17 00:00:00 2001 From: Ruizhi Huang <231220075@smail.nju.edu.cn> Date: Thu, 11 Sep 2025 18:50:22 +0800 Subject: [PATCH 5/7] refactor: format code to standards and move commit-user binding function from router to service layer Signed-off-by: Ruizhi Huang <231220075@smail.nju.edu.cn> --- .github/workflows/cratespro-check.yml | 49 +++ Cargo.toml | 1 + ceres/src/api_service/mod.rs | 63 +++- ceres/src/auth/mod.rs | 31 +- .../merge_checker/gpg_signature_checker.rs | 4 +- ceres/src/pack/monorepo.rs | 4 +- ceres/src/protocol/mod.rs | 31 +- ceres/src/protocol/smart.rs | 12 +- docs/api.md | 2 +- jupiter/callisto/src/mod.rs | 1 + jupiter/callisto/src/prelude.rs | 1 + jupiter/src/migration/mod.rs | 1 + jupiter/src/storage/commit_binding_storage.rs | 2 +- jupiter/src/storage/mod.rs | 1 - jupiter/src/storage/mono_storage.rs | 42 ++- jupiter/src/tests.rs | 4 +- mercury/Cargo.toml | 4 +- mono/src/api/api_router.rs | 70 +--- mono/src/api/commit/commit_router.rs | 69 ++-- mono/src/api/commit/model.rs | 28 +- moon/packages/types/generated.ts | 312 ++++++++++-------- 21 files changed, 445 insertions(+), 287 deletions(-) create mode 100644 .github/workflows/cratespro-check.yml diff --git a/.github/workflows/cratespro-check.yml b/.github/workflows/cratespro-check.yml new file mode 100644 index 000000000..a23df0f24 --- /dev/null +++ b/.github/workflows/cratespro-check.yml @@ -0,0 +1,49 @@ +name: CratesPro Extensions Check, Test and Lints + +on: + pull_request: + branches: + - main + paths: + - '.github/workflows/cratespro-**' + - 'extensions/cratespro' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + clippy: + name: Clippy Check + runs-on: ubuntu-latest + strategy: + fail-fast: false + defaults: + run: + working-directory: extensions/cratespro + env: + CARGO_TERM_COLOR: always + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Run cargo clippy + run: | + cargo build + cargo clippy --all-targets --all-features --no-deps -- -D warnings + + test: + name: Full Test + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + lfs: true + + - name: Run cargo test + run: | + cargo test --manifest-path extensions/cratespro/Cargo.toml --all-features --no-fail-fast -- --nocapture \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 7d5d60247..f5c2699ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -160,6 +160,7 @@ zstd-sys = "2.0.15+zstd.1.5.7" quickcheck = "1.0.3" directories = "6.0.0" dotenv = "0.15" +ahash = "0.8.12" [profile.release] debug = true diff --git a/ceres/src/api_service/mod.rs b/ceres/src/api_service/mod.rs index 58b0238b3..1be664697 100644 --- a/ceres/src/api_service/mod.rs +++ b/ceres/src/api_service/mod.rs @@ -21,7 +21,8 @@ use mercury::{ use tokio::sync::Mutex; use crate::model::git::{ - CreateFileInfo, LatestCommitInfo, TreeBriefItem, TreeCommitItem, TreeHashItem, + CommitBindingInfo, CreateFileInfo, LatestCommitInfo, TreeBriefItem, TreeCommitItem, + TreeHashItem, }; pub mod import_api_service; @@ -104,7 +105,65 @@ pub trait ApiHandler: Send + Sync { )); }; let commit = self.get_tree_relate_commit(tree.id, path).await?; - Ok(commit.into()) + let mut commit_info: LatestCommitInfo = commit.into(); + + // Build commit binding information + commit_info.binding_info = self.build_commit_binding_info(&commit_info.oid).await?; + + Ok(commit_info) + } + + /// Build commit binding information for a given commit SHA + async fn build_commit_binding_info( + &self, + commit_sha: &str, + ) -> Result, GitError> { + let storage = self.get_context(); + let commit_binding_storage = storage.commit_binding_storage(); + let user_storage = storage.user_storage(); + + if let Ok(Some(binding_model)) = commit_binding_storage.find_by_sha(commit_sha).await { + // Get user information if not anonymous + let user_info = + if !binding_model.is_anonymous && binding_model.matched_username.is_some() { + let username = binding_model.matched_username.as_ref().unwrap(); + if let Ok(Some(user)) = user_storage.find_user_by_name(username).await { + Some((user.name.clone(), user.avatar_url.clone())) + } else { + None + } + } else { + None + }; + + let (display_name, avatar_url, is_verified_user) = if binding_model.is_anonymous { + ("Anonymous".to_string(), None, false) + } else if let Some((username, avatar)) = user_info { + (username, Some(avatar), true) + } else { + ( + binding_model + .author_email + .split('@') + .next() + .unwrap_or(&binding_model.author_email) + .to_string(), + None, + false, + ) + }; + + Ok(Some(CommitBindingInfo { + matched_username: binding_model.matched_username, + is_anonymous: binding_model.is_anonymous, + is_verified_user, + display_name, + avatar_url, + author_email: binding_model.author_email, + })) + } else { + Ok(None) + } } async fn get_tree_info(&self, path: &Path) -> Result, GitError> { diff --git a/ceres/src/auth/mod.rs b/ceres/src/auth/mod.rs index 75a5401c3..b4fa0f534 100644 --- a/ceres/src/auth/mod.rs +++ b/ceres/src/auth/mod.rs @@ -15,10 +15,10 @@ pub struct PushUserInfo { pub trait UserAuthExtractor { /// Extract user information from AccessToken async fn extract_user_from_token(&self, access_token: &str) -> Result; - + /// Verify if email belongs to the authenticated user async fn verify_email_ownership(&self, username: &str, email: &str) -> Result; - + /// Extract user information from username (for cases where token is already validated) async fn extract_user_from_username(&self, username: &str) -> Result; } @@ -37,33 +37,42 @@ impl DefaultUserAuthExtractor { impl UserAuthExtractor for DefaultUserAuthExtractor { async fn extract_user_from_token(&self, access_token: &str) -> Result { // Find username by token - let username = self.user_storage.find_user_by_token(access_token).await? + let username = self + .user_storage + .find_user_by_token(access_token) + .await? .ok_or_else(|| MegaError::with_message("Invalid or expired access token"))?; - + self.extract_user_from_username(&username).await } - + async fn extract_user_from_username(&self, username: &str) -> Result { // Get user information - let user = self.user_storage.find_user_by_name(username).await? + let user = self + .user_storage + .find_user_by_name(username) + .await? .ok_or_else(|| MegaError::with_message("User not found"))?; - + // Get user's email collection - for now, use the primary email // TODO: Extend to support multiple emails per user let all_emails = vec![user.email.clone()]; - + Ok(PushUserInfo { username: user.name.clone(), primary_email: user.email.clone(), all_emails, }) } - + async fn verify_email_ownership(&self, username: &str, email: &str) -> Result { // Get user information - let user = self.user_storage.find_user_by_name(username).await? + let user = self + .user_storage + .find_user_by_name(username) + .await? .ok_or_else(|| MegaError::with_message("User not found"))?; - + // Check if email matches primary email // TODO: Extend to check against all user emails when multiple emails are supported Ok(user.email.to_lowercase() == email.to_lowercase()) diff --git a/ceres/src/merge_checker/gpg_signature_checker.rs b/ceres/src/merge_checker/gpg_signature_checker.rs index c9e835d57..ff0c81b5b 100644 --- a/ceres/src/merge_checker/gpg_signature_checker.rs +++ b/ceres/src/merge_checker/gpg_signature_checker.rs @@ -122,9 +122,7 @@ impl GpgSignatureChecker { signature .verify(&public_key, message.as_bytes()) - .map_err(|e| { - MegaError::with_message(format!("Signature verification failed: {e}")) - })?; + .map_err(|e| MegaError::with_message(format!("Signature verification failed: {e}")))?; Ok(()) } diff --git a/ceres/src/pack/monorepo.rs b/ceres/src/pack/monorepo.rs index fabbc00ee..8476b0b4c 100644 --- a/ceres/src/pack/monorepo.rs +++ b/ceres/src/pack/monorepo.rs @@ -158,7 +158,9 @@ impl RepoHandler for MonoRepo { } else { String::new() }; - storage.save_entry(&commit_id, entry_list, self.username.clone()).await + storage + .save_entry(&commit_id, entry_list, self.username.clone()) + .await } async fn check_entry(&self, entry: &Entry) -> Result<(), GitError> { diff --git a/ceres/src/protocol/mod.rs b/ceres/src/protocol/mod.rs index 64da688b0..4b0a03b76 100644 --- a/ceres/src/protocol/mod.rs +++ b/ceres/src/protocol/mod.rs @@ -16,7 +16,7 @@ use import_refs::RefCommand; use jupiter::storage::Storage; use repo::Repo; -use crate::auth::{DefaultUserAuthExtractor, UserAuthExtractor, PushUserInfo}; +use crate::auth::{DefaultUserAuthExtractor, PushUserInfo, UserAuthExtractor}; use crate::pack::{import_repo::ImportRepo, monorepo::MonoRepo, RepoHandler}; pub mod import_refs; @@ -237,15 +237,23 @@ impl SmartProtocol { // For test user, create a basic PushUserInfo match self.create_user_auth_extractor() { Ok(user_auth) => { - if let Ok(user_info) = user_auth.extract_user_from_username(username).await { + if let Ok(user_info) = + user_auth.extract_user_from_username(username).await + { self.authenticated_user = Some(user_info); return true; } else { - tracing::warn!("Failed to extract user info for test user: {}", username); + tracing::warn!( + "Failed to extract user info for test user: {}", + username + ); } } Err(e) => { - tracing::warn!("Failed to create user auth extractor for test user: {}", e); + tracing::warn!( + "Failed to create user auth extractor for test user: {}", + e + ); } } return true; @@ -256,16 +264,21 @@ impl SmartProtocol { .check_token(username, token) .await .unwrap_or(false); - + if token_valid { // Extract user information for valid token match self.create_user_auth_extractor() { Ok(user_auth) => { - if let Ok(user_info) = user_auth.extract_user_from_username(username).await { + if let Ok(user_info) = + user_auth.extract_user_from_username(username).await + { self.authenticated_user = Some(user_info); return true; } else { - tracing::warn!("Failed to extract user info for username: {}", username); + tracing::warn!( + "Failed to extract user info for username: {}", + username + ); } } Err(e) => { @@ -273,13 +286,13 @@ impl SmartProtocol { } } } - + return token_valid; } } false } - + /// Create a user authentication extractor fn create_user_auth_extractor(&self) -> Result { let user_storage = self.storage.user_storage(); diff --git a/ceres/src/protocol/smart.rs b/ceres/src/protocol/smart.rs index 12eeaf86a..57498e908 100644 --- a/ceres/src/protocol/smart.rs +++ b/ceres/src/protocol/smart.rs @@ -422,7 +422,9 @@ impl SmartProtocol { } // Enhanced user matching logic based on authentication status - let (final_user_id, is_anonymous) = if let Some(authenticated_user) = &self.authenticated_user { + let (final_user_id, is_anonymous) = if let Some(authenticated_user) = + &self.authenticated_user + { // User is authenticated - check if commit author email belongs to them match self.create_user_auth_extractor() { Ok(user_auth) => { @@ -438,7 +440,7 @@ impl SmartProtocol { // Author email doesn't belong to authenticated user - try general matching let user_storage = self.storage.user_storage(); let matched_user = user_storage.find_user_by_email(&author_email).await?; - + if let Some(user) = matched_user { (Some(user.name.clone()), false) } else { @@ -451,14 +453,14 @@ impl SmartProtocol { // Log the error from create_user_auth_extractor for debugging tracing::warn!( "Failed to create user auth extractor for commit binding: {}. \ - Falling back to email-based matching.", + Falling back to email-based matching.", e ); - + // Fallback to email-based matching if auth extractor fails let user_storage = self.storage.user_storage(); let matched_user = user_storage.find_user_by_email(&author_email).await?; - + if let Some(user) = matched_user { (Some(user.name.clone()), false) } else { diff --git a/docs/api.md b/docs/api.md index 2296ba505..0b4d4ab6b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -105,7 +105,7 @@ This part of the API, prefixed with /api/v1, is primarily for fetching Git raw o ```bash curl -X GET ${MEGA_URL}/api/v1/count-objs?repo_path= ``` - + 6. Update commit binding to associate a commit with a specific user or mark as anonymous ```bash diff --git a/jupiter/callisto/src/mod.rs b/jupiter/callisto/src/mod.rs index 5da24b718..17dfa12e1 100644 --- a/jupiter/callisto/src/mod.rs +++ b/jupiter/callisto/src/mod.rs @@ -5,6 +5,7 @@ pub mod prelude; pub mod access_token; pub mod builds; pub mod check_result; +pub mod commit_auths; pub mod entity_ext; pub mod git_blob; pub mod git_commit; diff --git a/jupiter/callisto/src/prelude.rs b/jupiter/callisto/src/prelude.rs index 60b3cd483..d884d1a4f 100644 --- a/jupiter/callisto/src/prelude.rs +++ b/jupiter/callisto/src/prelude.rs @@ -3,6 +3,7 @@ pub use super::access_token::Entity as AccessToken; pub use super::builds::Entity as Builds; pub use super::check_result::Entity as CheckResult; +pub use super::commit_auths::Entity as CommitAuths; pub use super::git_blob::Entity as GitBlob; pub use super::git_commit::Entity as GitCommit; pub use super::git_issue::Entity as GitIssue; diff --git a/jupiter/src/migration/mod.rs b/jupiter/src/migration/mod.rs index 1d23de086..e4492bfd4 100644 --- a/jupiter/src/migration/mod.rs +++ b/jupiter/src/migration/mod.rs @@ -57,6 +57,7 @@ mod m20250903_013904_create_task_table; mod m20250903_071928_add_issue_refs; mod m20250904_120000_add_commit_auths; mod m20250904_074945_modify_tasks_and_builds; +mod m20250904_120000_add_commit_auths; mod m20250905_163011_add_mr_reviewer; /// Creates a primary key column definition with big integer type. /// diff --git a/jupiter/src/storage/commit_binding_storage.rs b/jupiter/src/storage/commit_binding_storage.rs index a881fbf52..b4ad8ef5f 100644 --- a/jupiter/src/storage/commit_binding_storage.rs +++ b/jupiter/src/storage/commit_binding_storage.rs @@ -1,5 +1,5 @@ use crate::storage::base_storage::{BaseStorage, StorageConnector}; -use callisto::commit_auths::Column::{CommitSha}; +use callisto::commit_auths::Column::CommitSha; use callisto::commit_auths::{ActiveModel, Entity}; use common::errors::MegaError; use sea_orm::{ diff --git a/jupiter/src/storage/mod.rs b/jupiter/src/storage/mod.rs index 42140bb92..a0db5b0fc 100644 --- a/jupiter/src/storage/mod.rs +++ b/jupiter/src/storage/mod.rs @@ -188,7 +188,6 @@ impl Storage { self.app_service.note_storage.clone() } - pub fn commit_binding_storage(&self) -> CommitBindingStorage { self.app_service.commit_binding_storage.clone() } diff --git a/jupiter/src/storage/mono_storage.rs b/jupiter/src/storage/mono_storage.rs index 696dbd994..34b86715d 100644 --- a/jupiter/src/storage/mono_storage.rs +++ b/jupiter/src/storage/mono_storage.rs @@ -175,9 +175,17 @@ impl MonoStorage { match model { MegaObjectModel::Commit(commit) => { // Store for binding processing - if let Ok(commit_obj) = mercury::internal::object::commit::Commit::from_bytes(&entry.data, entry.hash) { + if let Ok(commit_obj) = + mercury::internal::object::commit::Commit::from_bytes( + &entry.data, + entry.hash, + ) + { let mut commits = commits_to_process.lock().unwrap(); - commits.push((commit_obj.id.to_string(), commit_obj.author.email.clone())); + commits.push(( + commit_obj.id.to_string(), + commit_obj.author.email.clone(), + )); } git_objects.commits.push(commit.into_active_model()) } @@ -212,16 +220,21 @@ impl MonoStorage { .expect("Failed to unwrap Arc") .into_inner() .unwrap(); - + if !commits_to_process.is_empty() { - self.process_commit_bindings(&commits_to_process, authenticated_username.as_deref()).await?; + self.process_commit_bindings(&commits_to_process, authenticated_username.as_deref()) + .await?; } Ok(()) } /// Process commit author bindings - async fn process_commit_bindings(&self, commits: &[(String, String)], authenticated_username: Option<&str>) -> Result<(), MegaError> { + async fn process_commit_bindings( + &self, + commits: &[(String, String)], + authenticated_username: Option<&str>, + ) -> Result<(), MegaError> { let user_storage = self.user_storage(); let commit_binding_storage = self.commit_binding_storage(); @@ -235,7 +248,10 @@ impl MonoStorage { Some(username.to_string()) } Ok(None) => { - tracing::warn!("Authenticated username {} not found in user table", username); + tracing::warn!( + "Authenticated username {} not found in user table", + username + ); None } Err(e) => { @@ -245,10 +261,13 @@ impl MonoStorage { } } else { // No authenticated username, commit will be anonymous - tracing::info!("No authenticated username available for commit {}", commit_sha); + tracing::info!( + "No authenticated username available for commit {}", + commit_sha + ); None }; - + let is_anonymous = matched_username.is_none(); // Save or update binding @@ -259,7 +278,12 @@ impl MonoStorage { tracing::error!("Failed to save commit binding for {}: {}", commit_sha, e); // Continue processing other commits even if one fails } else { - tracing::info!("Processed binding for commit {} with email {} (anonymous: {})", commit_sha, author_email, is_anonymous); + tracing::info!( + "Processed binding for commit {} with email {} (anonymous: {})", + commit_sha, + author_email, + is_anonymous + ); } } Ok(()) diff --git a/jupiter/src/tests.rs b/jupiter/src/tests.rs index ab822f6c7..e6ddd5d2e 100644 --- a/jupiter/src/tests.rs +++ b/jupiter/src/tests.rs @@ -15,8 +15,8 @@ use crate::storage::note_storage::NoteStorage; use crate::storage::{ commit_binding_storage::CommitBindingStorage, conversation_storage::ConversationStorage, git_db_storage::GitDbStorage, issue_storage::IssueStorage, lfs_db_storage::LfsDbStorage, - mr_reviewer_storage::MrReviewerStorage, mono_storage::MonoStorage, mr_storage::MrStorage, - raw_db_storage::RawDbStorage,relay_storage::RelayStorage, user_storage::UserStorage, + mono_storage::MonoStorage, mr_reviewer_storage::MrReviewerStorage, mr_storage::MrStorage, + raw_db_storage::RawDbStorage, relay_storage::RelayStorage, user_storage::UserStorage, vault_storage::VaultStorage, }; use crate::storage::{AppService, Storage}; diff --git a/mercury/Cargo.toml b/mercury/Cargo.toml index 1621f0a8a..f94339222 100644 --- a/mercury/Cargo.toml +++ b/mercury/Cargo.toml @@ -36,8 +36,8 @@ encoding_rs = { workspace = true } rayon = { workspace = true } reqwest = { workspace = true } ring = { workspace = true } -serde_json.workspace = true -ahash = "0.8.12" +serde_json = { workspace = true } +ahash = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["full"] } diff --git a/mono/src/api/api_router.rs b/mono/src/api/api_router.rs index 78bc680d8..56295ee71 100644 --- a/mono/src/api/api_router.rs +++ b/mono/src/api/api_router.rs @@ -13,16 +13,16 @@ use ceres::{ api_service::ApiHandler, model::git::{ BlobContentQuery, CodePreviewQuery, CreateFileInfo, FileTreeItem, LatestCommitInfo, - TreeCommitItem, TreeHashItem, TreeQuery, TreeResponse, CommitBindingInfo, + TreeCommitItem, TreeHashItem, TreeQuery, TreeResponse, }, }; use common::model::CommonResult; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ - commit::commit_router, conversation::conv_router, error::ApiError, gpg::gpg_router, - issue::issue_router, label::label_router, mr::mr_router, notes::note_router, - user::user_router, MonoApiServiceState, + commit::commit_router, conversation::conv_router, error::ApiError, gpg::gpg_router, + issue::issue_router, label::label_router, mr::mr_router, notes::note_router, user::user_router, + MonoApiServiceState, }; use crate::server::http_server::GIT_TAG; @@ -125,67 +125,23 @@ async fn get_latest_commit( state: State, ) -> Result, ApiError> { let query_path: std::path::PathBuf = query.path.into(); - let import_dir = state.storage.config().monorepo.import_dir.clone(); - let mut commit_info = if let Ok(rest) = query_path.strip_prefix(import_dir) { + let import_dir = state.storage.config().monorepo.import_dir.clone(); + if let Ok(rest) = query_path.strip_prefix(import_dir) { if rest.components().count() == 1 { let res = state .monorepo() .get_latest_commit(query_path.clone()) .await?; - res - } else { - let res = state - .api_handler(&query_path) - .await? - .get_latest_commit(query_path) - .await?; - res + return Ok(Json(res)); } - } else { - let res = state - .api_handler(&query_path) - .await? - .get_latest_commit(query_path) - .await?; - res - }; - - // Query commit binding information - let commit_binding_storage = state.storage.commit_binding_storage(); - let user_storage = state.storage.user_storage(); - - if let Ok(Some(binding_model)) = commit_binding_storage.find_by_sha(&commit_info.oid).await { - // Get user information if not anonymous - let user_info = if !binding_model.is_anonymous && binding_model.matched_username.is_some() { - let username = binding_model.matched_username.as_ref().unwrap(); - if let Ok(Some(user)) = user_storage.find_user_by_name(username).await { - Some((user.name.clone(), user.avatar_url.clone())) - } else { - None - } - } else { - None - }; - - let (display_name, avatar_url, is_verified_user) = if binding_model.is_anonymous { - ("Anonymous".to_string(), None, false) - } else if let Some((username, avatar)) = user_info { - (username, Some(avatar), true) - } else { - (binding_model.author_email.split('@').next().unwrap_or(&binding_model.author_email).to_string(), None, false) - }; - - commit_info.binding_info = Some(CommitBindingInfo { - matched_username: binding_model.matched_username, - is_anonymous: binding_model.is_anonymous, - is_verified_user, - display_name, - avatar_url, - author_email: binding_model.author_email, - }); } - Ok(Json(commit_info)) + let res = state + .api_handler(&query_path) + .await? + .get_latest_commit(query_path) + .await?; + Ok(Json(res)) } /// Get tree brief info diff --git a/mono/src/api/commit/commit_router.rs b/mono/src/api/commit/commit_router.rs index 48d8de95a..042732099 100644 --- a/mono/src/api/commit/commit_router.rs +++ b/mono/src/api/commit/commit_router.rs @@ -6,8 +6,8 @@ use axum::{ Json, }; use common::model::CommonResult; -use utoipa_axum::{router::OpenApiRouter, routes}; use serde::{Deserialize, Serialize}; +use utoipa_axum::{router::OpenApiRouter, routes}; #[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] pub struct UpdateCommitBindingRequest { @@ -16,10 +16,8 @@ pub struct UpdateCommitBindingRequest { } pub fn routers() -> OpenApiRouter { - OpenApiRouter::new() - .routes(routes!(update_commit_binding)) + OpenApiRouter::new().routes(routes!(update_commit_binding)) } - /// Update commit binding information #[utoipa::path( put, @@ -46,44 +44,71 @@ async fn update_commit_binding( let user_storage = state.storage.user_storage(); // First check if commit binding exists - let existing_binding = commit_binding_storage.find_by_sha(&sha).await + let existing_binding = commit_binding_storage + .find_by_sha(&sha) + .await .map_err(|e| ApiError::from(anyhow::anyhow!("Database query failed: {}", e)))?; let author_email = if let Some(ref binding) = existing_binding { binding.author_email.clone() } else { // If no binding exists, we need the author email - this could be passed in request or derived from git - return Err(ApiError::from(anyhow::anyhow!("No existing binding found for commit {}", sha))); + return Err(ApiError::from(anyhow::anyhow!( + "No existing binding found for commit {}", + sha + ))); }; // Validate user if not anonymous if !request.is_anonymous { if let Some(ref username) = request.username { - let user_exists = user_storage.find_user_by_name(username).await + let user_exists = user_storage + .find_user_by_name(username) + .await .map_err(|e| ApiError::from(anyhow::anyhow!("User validation failed: {}", e)))?; - + if user_exists.is_none() { - return Err(ApiError::from(anyhow::anyhow!("User not found: {}", username))); + return Err(ApiError::from(anyhow::anyhow!( + "User not found: {}", + username + ))); } } else { - return Err(ApiError::from(anyhow::anyhow!("Username required when not anonymous"))); + return Err(ApiError::from(anyhow::anyhow!( + "Username required when not anonymous" + ))); } } // Update the binding - commit_binding_storage.upsert_binding( - &sha, - &author_email, - request.username.clone(), - request.is_anonymous, - ).await - .map_err(|e| ApiError::from(anyhow::anyhow!("Failed to update binding: {}", e)))?; + commit_binding_storage + .upsert_binding( + &sha, + &author_email, + request.username.clone(), + request.is_anonymous, + ) + .await + .map_err(|e| ApiError::from(anyhow::anyhow!("Failed to update binding: {}", e)))?; + + // Prepare response with updated information + let (display_name, avatar_url, is_verified_user) = if request.is_anonymous { + ("Anonymous".to_string(), None, false) + } else if let Some(ref username) = request.username { + // Get user info for verified response + if let Ok(Some(user)) = user_storage.find_user_by_name(username).await { + (user.name.clone(), Some(user.avatar_url.clone()), true) + } else { + (username.clone(), None, true) + } + } else { + ("Anonymous".to_string(), None, false) + }; - // Return success response + // Return success response with complete information Ok(Json(CommonResult::success(Some(CommitBindingResponse { - binding: None, // Simplified response - display_name: request.username.unwrap_or("Anonymous".to_string()), - avatar_url: None, - is_verified_user: !request.is_anonymous, + display_name, + avatar_url, + is_verified_user, })))) } diff --git a/mono/src/api/commit/model.rs b/mono/src/api/commit/model.rs index 3bcaa5e75..03b1a8777 100644 --- a/mono/src/api/commit/model.rs +++ b/mono/src/api/commit/model.rs @@ -1,31 +1,9 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct UserInfo { - pub id: String, - pub username: String, - pub display_name: Option, - pub avatar_url: Option, - pub email: String, -} - -#[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct CommitBinding { - pub id: String, - pub commit_sha: String, - pub author_email: String, - pub matched_username: Option, - pub is_anonymous: bool, - pub matched_at: Option, - pub created_at: String, - pub user: Option, // Enhanced: Include user object information -} - #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct CommitBindingResponse { - pub binding: Option, - pub display_name: String, // Enhanced: Ready-to-use display name - pub avatar_url: Option, // Enhanced: Ready-to-use avatar URL - pub is_verified_user: bool, // Enhanced: Whether the user is verified in the system + pub display_name: String, // Ready-to-use display name + pub avatar_url: Option, // Ready-to-use avatar URL + pub is_verified_user: bool, // Whether the user is verified in the system } diff --git a/moon/packages/types/generated.ts b/moon/packages/types/generated.ts index 4b3be3fb5..9dbc5fb05 100644 --- a/moon/packages/types/generated.ts +++ b/moon/packages/types/generated.ts @@ -3045,6 +3045,10 @@ export type AssigneeUpdatePayload = { link: string } +export type ChangeReviewerStatePayload = { + state: boolean +} + export enum CheckType { GpgSignature = 'GpgSignature', BranchProtection = 'BranchProtection', @@ -3183,6 +3187,14 @@ export type CommonResultMergeBoxRes = { req_result: boolean } +export type CommonResultReviewersResponse = { + data?: { + result: ReviewerInfo[] + } + err_message: string + req_result: boolean +} + export type CommonResultString = { data?: string err_message: string @@ -3568,6 +3580,19 @@ export enum RequirementsState { MERGEABLE = 'MERGEABLE' } +export type ReviewerInfo = { + approved: boolean + campsite_id: string +} + +export type ReviewerPayload = { + reviewers: string[] +} + +export type ReviewersResponse = { + result: ReviewerInfo[] +} + export type ShowResponse = { description_html: string /** @format int32 */ @@ -3613,13 +3638,28 @@ export type UserInfo = { display_name: string } +/** Data transfer object for build information in API responses */ +export type BuildDTO = { + args?: any[] | null + created_at: string + end_at?: string | null + /** @format int32 */ + exit_code?: number | null + id: string + output_file: string + repo: string + start_at: string + /** Enumeration of possible task statuses */ + status: TaskStatusEnum + target: string + task_id: string +} + /** Request payload for creating a new build task */ export type BuildRequest = { args?: any[] | null buck_hash: string buckconfig_hash: string - mr?: string | null - repo: string } /** Log segment read result */ @@ -3655,50 +3695,25 @@ export type LogSegment = { offset: number } -/** Data transfer object for build information in API responses */ -export type TaskDTO = { - arguments: string - build_ids: any - end_at?: string | null - /** @format int32 */ - exit_code?: number | null - mr: string - output_files: any - repo_name: string - start_at: string - target: string - task_id: string -} - /** Task information including current status */ export type TaskInfoDTO = { - arguments: string - build_id: any - end_at?: string | null - /** @format int32 */ - exit_code?: number | null - mr: string - output_files: any - repo_name: string - start_at: string - /** Enumeration of possible task statuses */ - status: TaskStatusEnum - target: string + build_list: BuildDTO[] + created_at: string + /** @format int64 */ + mr_id: number + task_id: string + task_name?: string | null + template?: any } -/** Response structure for task status queries */ +/** Request structure for creating a task */ export type TaskRequest = { - mr?: string | null + builds: BuildRequest[] + /** @format int64 */ + mr: number repo: string -} - -/** Response structure for task status queries */ -export type TaskStatus = { - /** @format int32 */ - exit_code?: number | null - message?: string | null - /** Enumeration of possible task statuses */ - status: TaskStatusEnum + task_name?: string | null + template?: any } /** Enumeration of possible task statuses */ @@ -4927,6 +4942,14 @@ export type PostApiMrMergeNoAuthData = CommonResultString export type PostApiMrReopenData = CommonResultString +export type PostApiMrReviewerNewStateData = CommonResultString + +export type GetApiMrReviewersData = CommonResultReviewersResponse + +export type PostApiMrReviewersData = CommonResultString + +export type DeleteApiMrReviewersData = CommonResultString + export type PostApiMrTitleData = CommonResultString export type GetApiOrganizationsNotesSyncStateData = ShowResponse @@ -4981,14 +5004,10 @@ export type GetApiUserTokenListData = CommonResultVecListToken export type DeleteApiUserTokenByKeyIdData = CommonResultString -export type GetMrTaskByMrData = TaskDTO[] - export type PostTaskData = any export type GetTaskBuildListByIdData = string[] -export type PostTaskBuildByIdData = any - export type GetTaskHistoryOutputByIdParams = { /** * The type of log retrieval: "full" indicates full retrieval, while "segment" indicates retrieval of segments. @@ -5014,8 +5033,6 @@ export type GetTaskHistoryOutputByIdData = any export type GetTaskOutputByIdData = any -export type GetTaskStatusByIdData = TaskStatus - export type GetTasksByMrData = TaskInfoDTO[] export type QueryParamsType = Record @@ -5306,7 +5323,7 @@ export class Api extends HttpClient { + const base = 'POST:/api/v1/mr/{link}/reviewer-new-state' as const + + return { + baseKey: dataTaggedQueryKey([base]), + requestKey: (link: string) => dataTaggedQueryKey([base, link]), + request: (link: string, data: ChangeReviewerStatePayload, params: RequestParams = {}) => + this.request({ + path: `/api/v1/mr/${link}/reviewer-new-state`, + method: 'POST', + body: data, + type: ContentType.Json, + ...params + }) + } + }, + + /** + * No description + * + * @tags merge_request + * @name GetApiMrReviewers + * @request GET:/api/v1/mr/{link}/reviewers + */ + getApiMrReviewers: () => { + const base = 'GET:/api/v1/mr/{link}/reviewers' as const + + return { + baseKey: dataTaggedQueryKey([base]), + requestKey: (link: string) => dataTaggedQueryKey([base, link]), + request: (link: string, params: RequestParams = {}) => + this.request({ + path: `/api/v1/mr/${link}/reviewers`, + method: 'GET', + ...params + }) + } + }, + + /** + * No description + * + * @tags merge_request + * @name PostApiMrReviewers + * @request POST:/api/v1/mr/{link}/reviewers + */ + postApiMrReviewers: () => { + const base = 'POST:/api/v1/mr/{link}/reviewers' as const + + return { + baseKey: dataTaggedQueryKey([base]), + requestKey: (link: string) => dataTaggedQueryKey([base, link]), + request: (link: string, data: ReviewerPayload, params: RequestParams = {}) => + this.request({ + path: `/api/v1/mr/${link}/reviewers`, + method: 'POST', + body: data, + type: ContentType.Json, + ...params + }) + } + }, + + /** + * No description + * + * @tags merge_request + * @name DeleteApiMrReviewers + * @request DELETE:/api/v1/mr/{link}/reviewers + */ + deleteApiMrReviewers: () => { + const base = 'DELETE:/api/v1/mr/{link}/reviewers' as const + + return { + baseKey: dataTaggedQueryKey([base]), + requestKey: (link: string) => dataTaggedQueryKey([base, link]), + request: (link: string, data: ReviewerPayload, params: RequestParams = {}) => + this.request({ + path: `/api/v1/mr/${link}/reviewers`, + method: 'DELETE', + body: data, + type: ContentType.Json, + ...params + }) + } + }, + /** * No description * @@ -14525,54 +14637,6 @@ It's for local testing purposes. } } } - mr = { - /** - * No description - * - * @tags api - * @name GetMrTaskByMr - * @summary Retrieves all build tasks associated with a specific merge request -Returns a list of task filtered by MR number - * @request GET:/mr-task/{mr} - */ - getMrTaskByMr: () => { - const base = 'GET:/mr-task/{mr}' as const - - return { - baseKey: dataTaggedQueryKey([base]), - requestKey: (mr: string) => dataTaggedQueryKey([base, mr]), - request: (mr: string, params: RequestParams = {}) => - this.request({ - path: `/mr-task/${mr}`, - method: 'GET', - ...params - }) - } - }, - - /** - * No description - * - * @tags api - * @name GetTasksByMr - * @summary Return all tasks with their current status (combining /mr-task and /task-status logic) - * @request GET:/tasks/{mr} - */ - getTasksByMr: () => { - const base = 'GET:/tasks/{mr}' as const - - return { - baseKey: dataTaggedQueryKey([base]), - requestKey: (mr: string) => dataTaggedQueryKey([base, mr]), - request: (mr: string, params: RequestParams = {}) => - this.request({ - path: `/tasks/${mr}`, - method: 'GET', - ...params - }) - } - } - } id = { /** * No description @@ -14596,30 +14660,6 @@ Returns a list of task filtered by MR number } }, - /** - * No description - * - * @tags api - * @name PostTaskBuildById - * @request POST:/task-build/{id} - */ - postTaskBuildById: () => { - const base = 'POST:/task-build/{id}' as const - - return { - baseKey: dataTaggedQueryKey([base]), - requestKey: (id: string) => dataTaggedQueryKey([base, id]), - request: (id: string, data: BuildRequest, params: RequestParams = {}) => - this.request({ - path: `/task-build/${id}`, - method: 'POST', - body: data, - type: ContentType.Json, - ...params - }) - } - }, - /** * No description * @@ -14668,26 +14708,26 @@ Continuously monitors the log file and streams new content as it becomes availab ...params }) } - }, - + } + } + mr = { /** - * No description - * - * @tags api - * @name GetTaskStatusById - * @summary Retrieves the current status of a build task by its ID -Returns status information including exit code and current state - * @request GET:/task-status/{id} - */ - getTaskStatusById: () => { - const base = 'GET:/task-status/{id}' as const + * No description + * + * @tags api + * @name GetTasksByMr + * @summary Return all tasks with their current status (combining /mr-task and /task-status logic) + * @request GET:/tasks/{mr} + */ + getTasksByMr: () => { + const base = 'GET:/tasks/{mr}' as const return { - baseKey: dataTaggedQueryKey([base]), - requestKey: (id: string) => dataTaggedQueryKey([base, id]), - request: (id: string, params: RequestParams = {}) => - this.request({ - path: `/task-status/${id}`, + baseKey: dataTaggedQueryKey([base]), + requestKey: (mr: number) => dataTaggedQueryKey([base, mr]), + request: (mr: number, params: RequestParams = {}) => + this.request({ + path: `/tasks/${mr}`, method: 'GET', ...params }) From e580058cbc8c48c7640fad4b718b42d53bfd0874 Mon Sep 17 00:00:00 2001 From: Ruizhi Huang <231220075@smail.nju.edu.cn> Date: Thu, 11 Sep 2025 19:14:04 +0800 Subject: [PATCH 6/7] fix: fix mutiple define Signed-off-by: Ruizhi Huang <231220075@smail.nju.edu.cn> --- jupiter/callisto/src/mod.rs | 1 - jupiter/callisto/src/prelude.rs | 1 - jupiter/src/migration/mod.rs | 1 - 3 files changed, 3 deletions(-) diff --git a/jupiter/callisto/src/mod.rs b/jupiter/callisto/src/mod.rs index 17dfa12e1..2224ebe4e 100644 --- a/jupiter/callisto/src/mod.rs +++ b/jupiter/callisto/src/mod.rs @@ -37,7 +37,6 @@ pub mod notes; pub mod path_check_configs; pub mod raw_blob; pub mod reactions; -pub mod commit_auths; pub mod relay_lfs_info; pub mod relay_node; pub mod relay_nostr_event; diff --git a/jupiter/callisto/src/prelude.rs b/jupiter/callisto/src/prelude.rs index d884d1a4f..b23ecf8cf 100644 --- a/jupiter/callisto/src/prelude.rs +++ b/jupiter/callisto/src/prelude.rs @@ -33,7 +33,6 @@ pub use super::mq_storage::Entity as MqStorage; pub use super::notes::Entity as Notes; pub use super::path_check_configs::Entity as PathCheckConfigs; pub use super::raw_blob::Entity as RawBlob; -pub use super::commit_auths::Entity as CommitAuths; pub use super::reactions::Entity as Reactions; pub use super::relay_lfs_info::Entity as RelayLfsInfo; pub use super::relay_node::Entity as RelayNode; diff --git a/jupiter/src/migration/mod.rs b/jupiter/src/migration/mod.rs index e4492bfd4..1d23de086 100644 --- a/jupiter/src/migration/mod.rs +++ b/jupiter/src/migration/mod.rs @@ -57,7 +57,6 @@ mod m20250903_013904_create_task_table; mod m20250903_071928_add_issue_refs; mod m20250904_120000_add_commit_auths; mod m20250904_074945_modify_tasks_and_builds; -mod m20250904_120000_add_commit_auths; mod m20250905_163011_add_mr_reviewer; /// Creates a primary key column definition with big integer type. /// From 9a88c449b323832fc79084fddd9640fcf0ef0976 Mon Sep 17 00:00:00 2001 From: Ruizhi Huang <231220075@smail.nju.edu.cn> Date: Thu, 11 Sep 2025 19:29:04 +0800 Subject: [PATCH 7/7] fix: fix missing imports Signed-off-by: Ruizhi Huang <231220075@smail.nju.edu.cn> --- jupiter/src/storage/mono_storage.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/jupiter/src/storage/mono_storage.rs b/jupiter/src/storage/mono_storage.rs index 13b9a051f..436468216 100644 --- a/jupiter/src/storage/mono_storage.rs +++ b/jupiter/src/storage/mono_storage.rs @@ -13,6 +13,7 @@ use common::errors::MegaError; use common::utils::{generate_id, MEGA_BRANCH_NAME}; use mercury::internal::object::{MegaObjectModel, ObjectTrait}; use mercury::internal::{object::commit::Commit, pack::entry::Entry}; +use mercury::internal::object::blob::Blob; use crate::storage::base_storage::{BaseStorage, StorageConnector}; use crate::storage::commit_binding_storage::CommitBindingStorage;