Skip to content
Merged
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
51 changes: 51 additions & 0 deletions guards/github-guard/rust-guard/src/labels/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,29 @@ fn repo_owner_type_cache() -> &'static Mutex<HashMap<String, bool>> {
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}

/// Cache for collaborator permission lookups keyed by "owner/repo:username" (all lowercase).
/// This is a process-wide static cache that persists across requests. Because the WASM guard
/// is instantiated per-request in the gateway, entries accumulate over the process lifetime.
/// All key components (owner, repo, username) are lowercased since GitHub treats them as
/// case-insensitive.
fn collaborator_permission_cache() -> &'static Mutex<HashMap<String, Option<String>>> {
static CACHE: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}

fn get_cached_collaborator_permission(key: &str) -> Option<Option<String>> {
collaborator_permission_cache()
.lock()
.ok()
.and_then(|cache| cache.get(key).cloned())
}

fn set_cached_collaborator_permission(key: &str, permission: Option<String>) {
if let Ok(mut cache) = collaborator_permission_cache().lock() {
cache.insert(key.to_string(), permission);
}
}

fn get_cached_repo_visibility(repo_id: &str) -> Option<bool> {
repo_visibility_cache()
.lock()
Expand Down Expand Up @@ -449,6 +472,9 @@ pub fn get_issue_author_info(
/// to GET /repos/{owner}/{repo}/collaborators/{username}/permission.
/// Returns the user's effective permission (including inherited org permissions),
/// which is more accurate than author_association for org admins.
///
/// Results are cached per `(owner, repo, username)` to avoid duplicate enrichment
/// calls when the same reactor appears on multiple items in a response collection.
pub fn get_collaborator_permission_with_callback(
callback: GithubMcpCallback,
owner: &str,
Expand All @@ -463,6 +489,28 @@ pub fn get_collaborator_permission_with_callback(
return None;
}

// Cache key lowercases owner, repo, and username since GitHub treats all three
// as case-insensitive. This ensures "Org/Repo:Alice" and "org/repo:alice" share
// the same cache entry.
let cache_key = format!(
"{}/{}:{}",
owner.to_ascii_lowercase(),
repo.to_ascii_lowercase(),
username.to_ascii_lowercase()
);

// Return cached permission if available.
if let Some(cached) = get_cached_collaborator_permission(&cache_key) {
crate::log_debug(&format!(
"get_collaborator_permission: cache hit for {}/{} user {} → permission={:?}",
owner, repo, username, cached
));
return cached.map(|permission| CollaboratorPermission {
permission: Some(permission),
login: Some(username.to_string()),
});
}

crate::log_debug(&format!(
"get_collaborator_permission: fetching permission for {}/{} user {}",
owner, repo, username
Expand All @@ -484,6 +532,7 @@ pub fn get_collaborator_permission_with_callback(
"get_collaborator_permission: empty response for {}/{} user {}",
owner, repo, username
));
set_cached_collaborator_permission(&cache_key, None);
return None;
}
Err(code) => {
Expand Down Expand Up @@ -535,6 +584,8 @@ pub fn get_collaborator_permission_with_callback(
owner, repo, username, permission, login
));

set_cached_collaborator_permission(&cache_key, permission.clone());

Some(CollaboratorPermission { permission, login })
}

Expand Down
Loading
Loading