diff --git a/ceres/src/application/code_edit/model.rs b/ceres/src/application/code_edit/model.rs index 6cc1b4258..74094efc8 100644 --- a/ceres/src/application/code_edit/model.rs +++ b/ceres/src/application/code_edit/model.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use callisto::{entity_ext::generate_link, mega_cl, mega_refs, sea_orm_active_enums::ConvTypeEnum}; use common::errors::MegaError; @@ -8,6 +8,7 @@ use jupiter::{ storage::{Storage, mono_storage::MonoStorage}, utils::converter::FromMegaModel, }; +use serde::Deserialize; use crate::{ application::{ @@ -118,14 +119,10 @@ pub(crate) trait Director { Ok(()) } else { let reviewer_service = self.get_review_service(storage).await?; + let login_to_id = load_github_login_map(storage).await; if let Err(e) = reviewer_service - .assign_system_reviewers( - &cl.link, - &policy_contents, - &changed_files, - &std::collections::HashMap::new(), - ) + .assign_system_reviewers(&cl.link, &policy_contents, &changed_files, &login_to_id) .await { tracing::warn!("Failed to assign Cedar reviewers: {}", e); @@ -133,12 +130,7 @@ pub(crate) trait Director { // Resync reviewers when existing CL updates policy files if let Err(e) = reviewer_service - .sync_system_reviewers( - &cl.link, - &policy_contents, - &changed_files, - &std::collections::HashMap::new(), - ) + .sync_system_reviewers(&cl.link, &policy_contents, &changed_files, &login_to_id) .await { tracing::warn!("Failed to resync Cedar reviewers: {}", e); @@ -148,6 +140,91 @@ pub(crate) trait Director { } } +#[derive(Debug, Deserialize)] +struct CampsiteMemberIdentity { + campsite_user_id: String, + #[serde(default)] + github_login: Option, +} + +/// Resolve Cedar github logins → campsite public ids from local tables and, +/// when configured, Campsite `internal/member_identities`. +async fn load_github_login_map(storage: &Storage) -> HashMap { + let mut map = HashMap::new(); + + match storage.user_storage().github_login_to_campsite_ids().await { + Ok(from_tokens) => map.extend(from_tokens), + Err(e) => tracing::warn!(error = %e, "failed to load github_login map from access_token"), + } + match storage + .reviewer_storage() + .github_login_to_campsite_ids() + .await + { + Ok(from_reviewers) => map.extend(from_reviewers), + Err(e) => tracing::warn!(error = %e, "failed to load github_login map from reviewers"), + } + + match fetch_campsite_github_login_map(storage).await { + Ok(from_campsite) => map.extend(from_campsite), + Err(e) => { + tracing::debug!(error = %e, "campsite member_identities unavailable for reviewer map") + } + } + + map +} + +async fn fetch_campsite_github_login_map( + storage: &Storage, +) -> Result, MegaError> { + let config = storage.config(); + let secret = config.oauth.mega_internal_secret.trim(); + let api_base = config.oauth.campsite_api_domain.trim(); + if secret.is_empty() || api_base.is_empty() { + return Ok(HashMap::new()); + } + + let url = format!( + "{}/v1/organizations/mega/internal/member_identities", + api_base.trim_end_matches('/') + ); + let client = reqwest::Client::builder() + .no_proxy() + .build() + .map_err(|e| MegaError::Other(e.to_string()))?; + let resp = client + .get(&url) + .header("X-Mega-Internal-Secret", secret) + .send() + .await + .map_err(|e| MegaError::Other(format!("campsite member_identities request failed: {e}")))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(MegaError::Other(format!( + "campsite member_identities HTTP {status}: {body}" + ))); + } + let identities: Vec = resp + .json() + .await + .map_err(|e| MegaError::Other(format!("parse member_identities JSON: {e}")))?; + + let mut map = HashMap::new(); + for identity in identities { + let id = identity.campsite_user_id.trim(); + let Some(login) = identity.github_login.as_deref().map(str::trim) else { + continue; + }; + if id.is_empty() || login.is_empty() { + continue; + } + map.insert(login.to_string(), id.to_string()); + } + Ok(map) +} + fn cl_with_latest_to_hash(mut cl: mega_cl::Model, to_hash: &str) -> mega_cl::Model { cl.to_hash = to_hash.to_string(); cl diff --git a/jupiter/src/storage/cl_reviewer_storage.rs b/jupiter/src/storage/cl_reviewer_storage.rs index 357fd1d36..b823f2c0a 100644 --- a/jupiter/src/storage/cl_reviewer_storage.rs +++ b/jupiter/src/storage/cl_reviewer_storage.rs @@ -1,4 +1,4 @@ -use std::ops::Deref; +use std::{collections::HashMap, ops::Deref}; use callisto::{entity_ext::generate_id, mega_cl_reviewer}; use common::errors::MegaError; @@ -186,6 +186,31 @@ impl ClReviewerStorage { Ok(reviewers) } + /// Distinct `github_login → campsite_user_id` pairs already stored on reviewers. + pub async fn github_login_to_campsite_ids(&self) -> Result, MegaError> { + let rows = mega_cl_reviewer::Entity::find() + .filter(mega_cl_reviewer::Column::GithubLogin.is_not_null()) + .all(self.get_connection()) + .await?; + let mut map = HashMap::new(); + for row in rows { + let Some(login) = row.github_login.as_deref().map(str::trim) else { + continue; + }; + if login.is_empty() || row.campsite_user_id.trim().is_empty() { + continue; + } + // Prefer public-id shaped campsite_user_id when duplicates exist. + let id = row.campsite_user_id.trim(); + if !map.contains_key(login) + || (id.len() == 12 && id.chars().all(|c| c.is_ascii_alphanumeric())) + { + map.insert(login.to_string(), id.to_string()); + } + } + Ok(map) + } + pub async fn reviewer_change_state( &self, cl_link: &str, diff --git a/jupiter/src/storage/user_storage.rs b/jupiter/src/storage/user_storage.rs index 2c5b6cfae..5d166bb40 100644 --- a/jupiter/src/storage/user_storage.rs +++ b/jupiter/src/storage/user_storage.rs @@ -1,4 +1,4 @@ -use std::ops::Deref; +use std::{collections::HashMap, ops::Deref}; use callisto::{access_token, ssh_keys}; use common::{errors::MegaError, utils::generate_id}; @@ -155,6 +155,26 @@ impl UserStorage { None => Ok(None), } } + + /// Build `github_login → campsite_user_id` from access tokens that store both. + pub async fn github_login_to_campsite_ids(&self) -> Result, MegaError> { + let rows = access_token::Entity::find() + .filter(access_token::Column::GithubLogin.is_not_null()) + .all(self.get_connection()) + .await?; + let mut map = HashMap::new(); + for row in rows { + let Some(login) = row.github_login else { + continue; + }; + let login = login.trim(); + if login.is_empty() || row.campsite_user_id.trim().is_empty() { + continue; + } + map.insert(login.to_string(), row.campsite_user_id); + } + Ok(map) + } } #[cfg(test)] diff --git a/moon/apps/web/components/CodeView/TreeView/CustomTreeItem.tsx b/moon/apps/web/components/CodeView/TreeView/CustomTreeItem.tsx index 29d4ca337..2d4ed831c 100644 --- a/moon/apps/web/components/CodeView/TreeView/CustomTreeItem.tsx +++ b/moon/apps/web/components/CodeView/TreeView/CustomTreeItem.tsx @@ -71,7 +71,15 @@ export const CustomTreeItem = React.forwardRef(function CustomTreeItem( - + { + event.stopPropagation() + } + })} + > {isNodeLoading ? : } diff --git a/moon/apps/web/components/CodeView/TreeView/RepoTree.tsx b/moon/apps/web/components/CodeView/TreeView/RepoTree.tsx index 7f09c4be4..cf23cae51 100644 --- a/moon/apps/web/components/CodeView/TreeView/RepoTree.tsx +++ b/moon/apps/web/components/CodeView/TreeView/RepoTree.tsx @@ -80,7 +80,8 @@ const RepoTree = ({ onCommitInfoChange }: { onCommitInfoChange?: Function }) => // Merge ancestors of the current path into the existing expanded set so that // navigating into a nested folder does not collapse previously opened parents - // or sibling branches. + // or sibling branches. Only runs on basePath change — intentional collapses + // while staying on the same path are preserved by handleNodeToggle. setExpandedNodes(Array.from(new Set([...expandedNodes, ...pathsToExpand]))) // eslint-disable-next-line react-hooks/exhaustive-deps }, [basePath]) @@ -168,9 +169,9 @@ const RepoTree = ({ onCommitInfoChange }: { onCommitInfoChange?: Function }) => const handleLabelClick = useCallback( (path: string, isDirectory: boolean) => { if (isDirectory) { - // Keep ancestors (and this folder) expanded across navigation. - setExpandedNodes(Array.from(new Set([...expandedNodes, ...generateExpandedPaths(path)]))) - + // Do not force-expand here — icon clicks used to bubble into onItemClick and + // this would immediately undo a collapse. Ancestors are expanded by the + // basePath effect after navigation. const fullPath = `/${scope}/code/tree/${version}${path}` const cleanPath = fullPath.replace(/\/+/g, '/') @@ -183,7 +184,7 @@ const RepoTree = ({ onCommitInfoChange }: { onCommitInfoChange?: Function }) => router.push(blobPath) } }, - [router, scope, version, expandedNodes, setExpandedNodes] + [router, scope, version] ) // Navigate on click, not focus — focus also fires after remount/selection and diff --git a/moon/apps/web/components/DiffView/TreeView/CustomTreeItem.tsx b/moon/apps/web/components/DiffView/TreeView/CustomTreeItem.tsx index d2224a7f7..7c06d47b5 100644 --- a/moon/apps/web/components/DiffView/TreeView/CustomTreeItem.tsx +++ b/moon/apps/web/components/DiffView/TreeView/CustomTreeItem.tsx @@ -66,7 +66,13 @@ export const CustomTreeItem = React.forwardRef(function CustomTreeItem( - + { + event.stopPropagation() + } + })} + >