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
73 changes: 48 additions & 25 deletions src/authorship/agent_detection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,46 +11,53 @@ use crate::authorship::authorship_log_serialization::{
};
use crate::authorship::working_log::AgentId;

/// Known agent email mappings: (email, tool_name)
/// Known agent email mappings: (email_suffix, tool_name)
/// For GitHub noreply emails, we match after the `+` to ignore numeric user ID prefixes.
const AGENT_EMAIL_MAPPINGS: &[(&str, &str)] = &[
("cursoragent@cursor.com", "cursor"),
("cursoragent@cursor.com", "cursor-agent"),
("+copilot@users.noreply.github.com", "github-copilot-agent"),
(
"198982749+Copilot@users.noreply.github.com",
"github-copilot",
),
(
"158243242+devin-ai-integration[bot]@users.noreply.github.com",
Comment thread
svarlamov marked this conversation as resolved.
"+devin-ai-integration[bot]@users.noreply.github.com",
"devin",
),
("noreply@anthropic.com", "claude"),
("noreply@openai.com", "codex"),
("noreply@anthropic.com", "claude-web"),
("noreply@openai.com", "codex-cloud"),
("roomote@roocode.com", "roo-background"),
];

/// Known GitHub username mappings: (username, platform)
const AGENT_USERNAME_MAPPINGS: &[(&str, &str)] = &[
("copilot-swe-agent[bot]", "github-copilot"),
("copilot-swe-agent[bot]", "github-copilot-agent"),
("devin-ai-integration[bot]", "devin"),
("cursor[bot]", "cursor"),
("cursor[bot]", "cursor-agent"),
];

/// Match a commit author email to a known AI agent tool name.
///
/// Returns the tool name (e.g. "cursor", "github-copilot", "devin", "claude", "codex")
/// Returns the tool name (e.g. "cursor-agent", "github-copilot-agent", "devin", "claude-web", "codex-cloud")
/// if the email matches a known agent pattern, or `None` otherwise.
///
/// # Examples
/// ```
/// use git_ai::authorship::agent_detection::match_email_to_agent;
///
/// assert_eq!(match_email_to_agent("cursoragent@cursor.com"), Some("cursor"));
/// assert_eq!(match_email_to_agent("noreply@anthropic.com"), Some("claude"));
/// assert_eq!(match_email_to_agent("cursoragent@cursor.com"), Some("cursor-agent"));
/// assert_eq!(match_email_to_agent("noreply@anthropic.com"), Some("claude-web"));
/// assert_eq!(match_email_to_agent("user@example.com"), None);
/// ```
pub fn match_email_to_agent(email: &str) -> Option<&'static str> {
let email_lower = email.to_lowercase();
AGENT_EMAIL_MAPPINGS
.iter()
.find(|(pattern, _)| pattern.to_lowercase() == email_lower)
.find(|(pattern, _)| {
let pattern_lower = pattern.to_lowercase();
if pattern_lower.starts_with('+') {
// Suffix match: ignore numeric ID prefix in GitHub noreply emails
email_lower.ends_with(&pattern_lower)
} else {
email_lower == pattern_lower
}
})
.map(|(_, tool)| *tool)
}

Expand All @@ -63,7 +70,7 @@ pub fn match_email_to_agent(email: &str) -> Option<&'static str> {
/// ```
/// use git_ai::authorship::agent_detection::match_username_to_platform;
///
/// assert_eq!(match_username_to_platform("copilot-swe-agent[bot]"), Some("github-copilot"));
/// assert_eq!(match_username_to_platform("copilot-swe-agent[bot]"), Some("github-copilot-agent"));
/// assert_eq!(match_username_to_platform("devin-ai-integration[bot]"), Some("devin"));
/// assert_eq!(match_username_to_platform("regular-user"), None);
/// ```
Expand Down Expand Up @@ -160,15 +167,20 @@ mod tests {
fn test_match_email_cursor() {
assert_eq!(
match_email_to_agent("cursoragent@cursor.com"),
Some("cursor")
Some("cursor-agent")
);
}

#[test]
fn test_match_email_copilot() {
assert_eq!(
match_email_to_agent("198982749+Copilot@users.noreply.github.com"),
Some("github-copilot")
Some("github-copilot-agent")
);
// Different numeric prefix should still match
assert_eq!(
match_email_to_agent("999999+Copilot@users.noreply.github.com"),
Some("github-copilot-agent")
);
}

Expand All @@ -178,30 +190,38 @@ mod tests {
match_email_to_agent("158243242+devin-ai-integration[bot]@users.noreply.github.com"),
Some("devin")
);
// Different numeric prefix should still match
assert_eq!(
match_email_to_agent("12345+devin-ai-integration[bot]@users.noreply.github.com"),
Some("devin")
);
}

#[test]
fn test_match_email_claude() {
assert_eq!(
match_email_to_agent("noreply@anthropic.com"),
Some("claude")
Some("claude-web")
);
}

#[test]
fn test_match_email_codex() {
assert_eq!(match_email_to_agent("noreply@openai.com"), Some("codex"));
assert_eq!(
match_email_to_agent("noreply@openai.com"),
Some("codex-cloud")
);
}

#[test]
fn test_match_email_case_insensitive() {
assert_eq!(
match_email_to_agent("CursorAgent@Cursor.com"),
Some("cursor")
Some("cursor-agent")
);
assert_eq!(
match_email_to_agent("NOREPLY@ANTHROPIC.COM"),
Some("claude")
Some("claude-web")
);
}

Expand All @@ -220,7 +240,7 @@ mod tests {
fn test_match_username_copilot() {
assert_eq!(
match_username_to_platform("copilot-swe-agent[bot]"),
Some("github-copilot")
Some("github-copilot-agent")
);
}

Expand All @@ -234,14 +254,17 @@ mod tests {

#[test]
fn test_match_username_cursor() {
assert_eq!(match_username_to_platform("cursor[bot]"), Some("cursor"));
assert_eq!(
match_username_to_platform("cursor[bot]"),
Some("cursor-agent")
);
}

#[test]
fn test_match_username_case_insensitive() {
assert_eq!(
match_username_to_platform("Copilot-SWE-Agent[bot]"),
Some("github-copilot")
Some("github-copilot-agent")
);
}

Expand Down
10 changes: 5 additions & 5 deletions tests/integration/agent_commits_blame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -476,8 +476,8 @@ fn test_agent_blame_json_output() {
let prompt_entry = prompts.values().next().unwrap();
assert_eq!(
prompt_entry["agent_id"]["tool"].as_str().unwrap(),
"cursor",
"Prompt should have tool=cursor"
"cursor-agent",
"Prompt should have tool=cursor-agent"
);
assert_eq!(
prompt_entry["agent_id"]["model"].as_str().unwrap(),
Expand Down Expand Up @@ -549,12 +549,12 @@ fn test_agent_blame_json_mixed_human_agent() {
prompts.keys().collect::<Vec<_>>()
);

// The prompt should have tool=cursor
// The prompt should have tool=cursor-agent
let prompt = &prompts[&line2_hash];
assert_eq!(
prompt["agent_id"]["tool"].as_str().unwrap(),
"cursor",
"Prompt should have tool=cursor"
"cursor-agent",
"Prompt should have tool=cursor-agent"
);
}

Expand Down
Loading