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/3] 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/3] 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/3] 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(), })