diff --git a/Cargo.toml b/Cargo.toml index ab0c9c23..02284269 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,8 @@ chrono = "0.4" itertools = "0.14.0" +strsim = "0.10.0" + [dev-dependencies] insta = "1.26" derive_builder = "0.20.0" diff --git a/src/bors/handlers/review.rs b/src/bors/handlers/review.rs index 36dbd6eb..e8796746 100644 --- a/src/bors/handlers/review.rs +++ b/src/bors/handlers/review.rs @@ -30,7 +30,16 @@ pub(super) async fn command_approve( }; let approver = match approver { Approver::Myself => author.username.clone(), - Approver::Specified(approver) => approver.clone(), + Approver::Specified(approver) => { + if let Some(error_comment) = repo_state.client.validate_reviewers(approver).await? { + repo_state + .client + .post_comment(pr.number, error_comment) + .await?; + return Ok(()); + } + approver.clone() + } }; db.approve(repo_state.repository(), pr.number, approver.as_str()) .await?; @@ -274,6 +283,21 @@ mod tests { .await; } + #[sqlx::test] + async fn approve_empty_reviewer_in_list(pool: sqlx::PgPool) { + BorsBuilder::new(pool) + .world(create_world_with_approve_config()) + .run_test(|mut tester| async { + tester.post_comment("@bors r=user1,,user2").await?; + assert_eq!( + tester.get_comment().await?, + "Error: Empty reviewer name provided. Use r=username to specify a reviewer." + ); + Ok(tester) + }) + .await; + } + #[sqlx::test] async fn insufficient_permission_approve(pool: sqlx::PgPool) { let world = World::default(); diff --git a/src/github/api/client.rs b/src/github/api/client.rs index 23cef28a..be58c3c9 100644 --- a/src/github/api/client.rs +++ b/src/github/api/client.rs @@ -1,6 +1,7 @@ use anyhow::Context; use octocrab::models::{App, Repository}; use octocrab::{Error, Octocrab}; +use strsim::levenshtein; use tracing::log; use crate::bors::event::PullRequestComment; @@ -287,6 +288,72 @@ impl GithubRepositoryClient { run_ids.map(|workflow_id| self.get_workflow_url(workflow_id)) } + /// Validates a reviewer's GitHub username and returns validation results if the username is invalid. + /// If multiple reviewers are provided (comma-separated), validates each one. + /// Returns a validation result for the first invalid username encountered, or None if all usernames are valid. + pub async fn validate_reviewers(&self, reviewer_list: &str) -> anyhow::Result> { + if reviewer_list.trim().is_empty() { + return Ok(Some(Comment::new( + "Error: No reviewer specified. Use r=username to specify a reviewer.".to_string(), + ))); + } + + for username in reviewer_list.split(',').map(|s| s.trim()) { + if username.is_empty() { + return Ok(Some(Comment::new( + "Error: Empty reviewer name provided. Use r=username to specify a reviewer." + .to_string(), + ))); + } + + match self.client.users(username).profile().await { + Ok(_) => continue, // user exist continue + Err(octocrab::Error::GitHub { source, .. }) => { + if source.message.contains("Not Found") { + let similar_usernames = self.find_similar_usernames(username).await?; + + let mut message = format!("Invalid reviewer username: `{}`", username); + if !similar_usernames.is_empty() { + message.push_str("\nDid you mean one of these users?\n"); + for similar in similar_usernames { + message.push_str(&format!("- {}\n", similar)); + } + } + + return Ok(Some(Comment::new(message))); + } + } + Err(_) => continue, + } + } + Ok(None) + } + + /// Searches for GitHub usernames similar to the provided username using Levenshtein distance. + async fn find_similar_usernames(&self, username: &str) -> anyhow::Result> { + const MAX_DISTANCE: usize = 2; + + let search_results = self + .client + .search() + .users(username) + .send() + .await + .context("Failed to search for similar usernames")?; + + let similar_usernames = search_results + .items + .into_iter() + .filter(|user| { + let distance = levenshtein(username, &user.login); + distance > 0 && distance <= MAX_DISTANCE + }) + .map(|user| user.login) + .collect(); + + Ok(similar_usernames) + } + fn format_pr(&self, pr: PullRequestNumber) -> String { format!("{}/{}", self.repository(), pr) }