Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 90 additions & 13 deletions ceres/src/application/code_edit/model.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -8,6 +8,7 @@ use jupiter::{
storage::{Storage, mono_storage::MonoStorage},
utils::converter::FromMegaModel,
};
use serde::Deserialize;

use crate::{
application::{
Expand Down Expand Up @@ -118,27 +119,18 @@ pub(crate) trait Director<T: ApiHandler + Clone> {
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);
}

// 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);
Expand All @@ -148,6 +140,91 @@ pub(crate) trait Director<T: ApiHandler + Clone> {
}
}

#[derive(Debug, Deserialize)]
struct CampsiteMemberIdentity {
campsite_user_id: String,
#[serde(default)]
github_login: Option<String>,
}

/// 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<String, String> {
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<HashMap<String, String>, 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<CampsiteMemberIdentity> = 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
Expand Down
27 changes: 26 additions & 1 deletion jupiter/src/storage/cl_reviewer_storage.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<HashMap<String, String>, 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,
Expand Down
22 changes: 21 additions & 1 deletion jupiter/src/storage/user_storage.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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<HashMap<String, String>, 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)]
Expand Down
10 changes: 9 additions & 1 deletion moon/apps/web/components/CodeView/TreeView/CustomTreeItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,15 @@ export const CustomTreeItem = React.forwardRef(function CustomTreeItem(
<TreeItemProvider {...getContextProviderProps()}>
<TreeItemRoot {...getRootProps(other)}>
<TreeItemContent {...getContentProps()} sx={{ paddingLeft: 1 }}>
<TreeItemIconContainer {...getIconContainerProps()}>
<TreeItemIconContainer
{...getIconContainerProps({
// Icon sits inside content; without this, expand/collapse clicks bubble to
// onItemClick and re-expand the folder via navigation handlers.
onClick: (event: React.MouseEvent) => {
event.stopPropagation()
}
})}
>
{isNodeLoading ? <CircularProgress size={12} sx={{ color: 'black' }} /> : <TreeItemIcon status={status} />}
</TreeItemIconContainer>

Expand Down
11 changes: 6 additions & 5 deletions moon/apps/web/components/CodeView/TreeView/RepoTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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, '/')

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,13 @@ export const CustomTreeItem = React.forwardRef(function CustomTreeItem(
<TreeItemProvider {...getContextProviderProps()}>
<TreeItemRoot {...getRootProps(other)}>
<TreeItemContent {...getContentProps()} sx={{ paddingLeft: 1 }}>
<TreeItemIconContainer {...getIconContainerProps()}>
<TreeItemIconContainer
{...getIconContainerProps({
onClick: (event: React.MouseEvent) => {
event.stopPropagation()
}
})}
>
<TreeItemIcon status={status} />
</TreeItemIconContainer>

Expand Down
Loading