pub const DEFAULT_BOT_PATTERNS: &[&str] = &[
"dependabot[bot]",
"github-actions[bot]",
"claude-code[bot]",
"copilot[bot]",
"renovate[bot]",
"pre-commit-ci[bot]",
"devin-ai-integration[bot]",
];
const AI_ASSIST_PATTERNS: &[&str] = &[
"co-authored-by: claude",
"co-authored-by: copilot",
"co-authored-by: github copilot",
"co-authored-by: cursor",
"co-authored-by: sourcegraph cody",
"co-authored-by: cody",
"co-authored-by: continue",
"co-authored-by: codeium",
"co-authored-by: windsurf",
"co-authored-by: devin",
"co-authored-by: tabnine",
"co-authored-by: amazon q",
"(aider)",
];
#[must_use]
pub fn is_bot(email: &str, name: &str) -> bool {
DEFAULT_BOT_PATTERNS
.iter()
.any(|p| contains_ignore_ascii_case(email, p) || contains_ignore_ascii_case(name, p))
}
fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
let (hay, ndl) = (haystack.as_bytes(), needle.as_bytes());
if ndl.is_empty() {
return true;
}
if ndl.len() > hay.len() {
return false;
}
hay.windows(ndl.len()).any(|w| w.eq_ignore_ascii_case(ndl))
}
#[derive(Debug, Default, Clone)]
pub struct BotPatterns {
user_patterns: Vec<String>,
}
impl BotPatterns {
#[must_use]
pub fn from_repo(repo_root: &std::path::Path) -> Self {
let path = repo_root.join(".codelorebots");
match std::fs::read_to_string(&path) {
Ok(text) => Self::from_text(&text),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::default(),
Err(e) => {
tracing::warn!(
"failed to read .codelorebots at {}: {e}; using defaults only",
path.display()
);
Self::default()
}
}
}
#[must_use]
pub fn from_text(text: &str) -> Self {
let user_patterns: Vec<String> = text
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(str::to_lowercase)
.collect();
Self { user_patterns }
}
#[must_use]
pub fn is_bot(&self, email: &str, name: &str) -> bool {
let matches =
|p: &str| contains_ignore_ascii_case(email, p) || contains_ignore_ascii_case(name, p);
DEFAULT_BOT_PATTERNS.iter().any(|p| matches(p))
|| self.user_patterns.iter().any(|p| matches(p))
}
}
#[must_use]
pub fn ai_attribution(email: &str, name: &str, message: &str) -> &'static str {
if is_bot(email, name) {
return "ai-authored";
}
let msg_lc = message.to_lowercase();
if AI_ASSIST_PATTERNS.iter().any(|p| msg_lc.contains(p)) {
return "ai-assisted";
}
"human"
}
#[must_use]
pub fn ai_attribution_with(
patterns: &BotPatterns,
email: &str,
name: &str,
message: &str,
) -> &'static str {
if patterns.is_bot(email, name) {
return "ai-authored";
}
let msg_lc = message.to_lowercase();
if AI_ASSIST_PATTERNS.iter().any(|p| msg_lc.contains(p)) {
return "ai-assisted";
}
"human"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dependabot_is_bot() {
assert!(is_bot(
"dependabot[bot]@noreply.github.com",
"dependabot[bot]"
));
}
#[test]
fn human_is_not_bot() {
assert!(!is_bot("alice@example.com", "Alice"));
}
#[test]
fn bot_email_gives_ai_authored() {
assert_eq!(
ai_attribution(
"dependabot[bot]@noreply.github.com",
"dependabot[bot]",
"bump deps"
),
"ai-authored"
);
}
#[test]
fn co_authored_claude_gives_ai_assisted() {
assert_eq!(
ai_attribution(
"alice@example.com",
"Alice",
"feat: do stuff\n\nCo-Authored-By: Claude"
),
"ai-assisted"
);
}
#[test]
fn plain_human_commit_gives_human() {
assert_eq!(
ai_attribution("alice@example.com", "Alice", "fix typo"),
"human"
);
}
#[test]
fn bot_match_is_case_insensitive() {
assert!(is_bot(
"Dependabot[Bot]@noreply.github.com",
"Dependabot[Bot]"
));
assert!(is_bot(
"GITHUB-ACTIONS[BOT]@example.com",
"GitHub-Actions[Bot]"
));
}
#[test]
fn detects_cursor_signature() {
let msg = "feat: refactor auth\n\nCo-Authored-By: Cursor";
assert_eq!(
ai_attribution("alice@example.com", "Alice", msg),
"ai-assisted"
);
}
#[test]
fn detects_cody_signature() {
let msg = "fix: handle null\n\nCo-Authored-By: Sourcegraph Cody";
assert_eq!(
ai_attribution("alice@example.com", "Alice", msg),
"ai-assisted"
);
}
#[test]
fn detects_aider_in_message_body() {
let msg = "refactor: extract helper (aider)";
assert_eq!(
ai_attribution("alice@example.com", "Alice", msg),
"ai-assisted"
);
}
#[test]
fn detects_continue_codeium_windsurf_tabnine_amazon_q() {
for assistant in &["Continue", "Codeium", "Windsurf", "Tabnine", "Amazon Q"] {
let msg = format!("feat: thing\n\nCo-Authored-By: {assistant}");
assert_eq!(
ai_attribution("alice@example.com", "Alice", &msg),
"ai-assisted",
"should detect {assistant} signature"
);
}
}
#[test]
fn devin_bot_email_classifies_as_ai_authored() {
assert_eq!(
ai_attribution(
"devin-ai-integration[bot]@users.noreply.github.com",
"Devin",
"implement feature"
),
"ai-authored"
);
}
#[test]
fn co_authored_by_match_is_case_insensitive() {
let msg = "feat: x\n\nCO-AUTHORED-BY: cursor";
assert_eq!(
ai_attribution("alice@example.com", "Alice", msg),
"ai-assisted"
);
}
#[test]
fn bot_patterns_default_matches_built_in_defaults() {
let patterns = BotPatterns::default();
assert!(patterns.is_bot("dependabot[bot]@noreply.github.com", "dependabot[bot]"));
assert!(!patterns.is_bot("alice@example.com", "Alice"));
}
#[test]
fn bot_patterns_user_additions_classify_as_bots() {
let patterns = BotPatterns::from_text(
"# our internal deploy account\n\
our-deploy-bot\n\
\n\
# release automation\n\
release-automation\n",
);
assert!(patterns.is_bot("our-deploy-bot@example.com", "Deploy Bot"));
assert!(patterns.is_bot("ci@example.com", "release-automation"));
assert!(patterns.is_bot("dependabot[bot]@noreply.github.com", "dependabot[bot]"));
assert!(!patterns.is_bot("alice@example.com", "Alice"));
}
#[test]
fn bot_patterns_user_additions_case_insensitive() {
let patterns = BotPatterns::from_text("OUR-DEPLOY-BOT\n");
assert!(patterns.is_bot("Our-Deploy-Bot@example.com", "Deploy Bot"));
}
#[test]
fn bot_patterns_blank_lines_and_comments_ignored() {
let patterns = BotPatterns::from_text("\n\n# only comments here\n# and another\n\n");
assert!(!patterns.is_bot("alice@example.com", "Alice"));
assert!(patterns.is_bot("dependabot[bot]@x.com", "x"));
}
#[test]
fn bot_patterns_from_missing_repo_file_returns_default() {
let tmp = tempfile::tempdir().expect("tempdir");
let patterns = BotPatterns::from_repo(tmp.path());
assert!(patterns.is_bot("dependabot[bot]@x.com", "x"));
assert!(!patterns.is_bot("custom-bot@example.com", "custom-bot"));
}
#[test]
fn bot_patterns_from_repo_reads_codelorebots() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join(".codelorebots"), "custom-bot\n").expect("write");
let patterns = BotPatterns::from_repo(tmp.path());
assert!(patterns.is_bot("custom-bot@example.com", "Custom Bot"));
}
}