From 7d524c8b4de2246adcf957f2ea9c96a4d602ba5c Mon Sep 17 00:00:00 2001 From: codcod Date: Wed, 29 Oct 2025 21:48:03 +0100 Subject: [PATCH] feat: add prs report --- plugins/repos-health/Cargo.toml | 2 + plugins/repos-health/src/main.rs | 482 +++++++++++++++++++++++++++++-- 2 files changed, 458 insertions(+), 26 deletions(-) diff --git a/plugins/repos-health/Cargo.toml b/plugins/repos-health/Cargo.toml index 595d572..60e743c 100644 --- a/plugins/repos-health/Cargo.toml +++ b/plugins/repos-health/Cargo.toml @@ -12,6 +12,8 @@ anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = { version = "0.4", features = ["serde"] } +tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.11", features = ["json"] } [dependencies.repos] path = "../.." diff --git a/plugins/repos-health/src/main.rs b/plugins/repos-health/src/main.rs index 01e0b08..72012c3 100644 --- a/plugins/repos-health/src/main.rs +++ b/plugins/repos-health/src/main.rs @@ -1,40 +1,164 @@ use anyhow::{Context, Result}; use chrono::Utc; -use repos::{Repository, load_default_config}; +use repos::{Config, Repository, load_default_config}; +use serde::{Deserialize, Serialize}; use std::env; use std::path::Path; use std::process::{Command, Stdio}; -fn main() -> Result<()> { +#[derive(Debug, Serialize, Deserialize)] +struct PrUser { + login: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PullRequest { + number: u64, + title: String, + html_url: String, + user: PrUser, + draft: bool, + #[serde(default)] + requested_reviewers: Vec, +} + +#[derive(Debug)] +struct PrReport { + repo_name: String, + total_prs: usize, + awaiting_approval: Vec, +} + +#[derive(Debug)] +struct PrSummary { + number: u64, + title: String, + author: String, + url: String, +} + +#[tokio::main] +async fn main() -> Result<()> { let args: Vec = env::args().collect(); - // Handle --help - if args.len() > 1 && (args[1] == "--help" || args[1] == "-h") { - print_help(); - return Ok(()); + // Parse arguments + let mut config_path: Option = None; + let mut include_tags: Vec = Vec::new(); + let mut exclude_tags: Vec = Vec::new(); + let mut debug = false; + let mut mode = "deps"; // default mode + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--help" | "-h" => { + print_help(); + return Ok(()); + } + "--config" => { + if i + 1 < args.len() { + config_path = Some(args[i + 1].clone()); + i += 2; + } else { + eprintln!("Error: --config requires a path argument"); + std::process::exit(1); + } + } + "--tag" => { + if i + 1 < args.len() { + include_tags.push(args[i + 1].clone()); + i += 2; + } else { + eprintln!("Error: --tag requires a tag argument"); + std::process::exit(1); + } + } + "--exclude-tag" => { + if i + 1 < args.len() { + exclude_tags.push(args[i + 1].clone()); + i += 2; + } else { + eprintln!("Error: --exclude-tag requires a tag argument"); + std::process::exit(1); + } + } + "--debug" | "-d" => { + debug = true; + i += 1; + } + arg if !arg.starts_with("--") => { + mode = arg; + i += 1; + } + _ => { + eprintln!("Unknown option: {}", args[i]); + print_help(); + std::process::exit(1); + } + } } - let config = load_default_config().context("load repos config")?; - let repos = config.repositories; - let mut processed = 0; - for repo in repos { - if let Err(e) = process_repo(&repo) { - eprintln!("health: {} skipped: {}", repo.name, e); - } else { - processed += 1; + // Load config (custom path or default) + let config = if let Some(path) = config_path { + Config::load_config(&path) + .with_context(|| format!("Failed to load config from {}", path))? + } else { + load_default_config().context("Failed to load default config")? + }; + + // Apply tag filters + let filtered_repos = filter_repositories(&config.repositories, &include_tags, &exclude_tags); + + if debug { + eprintln!("DEBUG: Loaded {} repositories", config.repositories.len()); + eprintln!( + "DEBUG: After filtering: {} repositories", + filtered_repos.len() + ); + } + + match mode { + "deps" => run_deps_check(filtered_repos).await, + "prs" => run_pr_report(filtered_repos, debug).await, + _ => { + eprintln!("Unknown mode: {}. Use 'deps' or 'prs'", mode); + print_help(); + std::process::exit(1); } } - println!("health: processed {} repositories", processed); - Ok(()) +} + +fn filter_repositories( + repos: &[Repository], + include_tags: &[String], + exclude_tags: &[String], +) -> Vec { + let mut filtered = repos.to_vec(); + + // Apply include tags (intersection) + if !include_tags.is_empty() { + filtered.retain(|repo| include_tags.iter().any(|tag| repo.tags.contains(tag))); + } + + // Apply exclude tags (difference) + if !exclude_tags.is_empty() { + filtered.retain(|repo| !exclude_tags.iter().any(|tag| repo.tags.contains(tag))); + } + + filtered } fn print_help() { - println!("repos-health - Check and update npm dependencies in repositories"); + println!("repos-health - Repository health checks and reports"); println!(); println!("USAGE:"); - println!(" repos health [OPTIONS]"); + println!(" repos health [OPTIONS] [MODE]"); + println!(); + println!("MODES:"); + println!(" deps Check and update npm dependencies (default)"); + println!(" prs Generate PR report showing PRs awaiting approval"); println!(); - println!("DESCRIPTION:"); + println!("DEPS MODE:"); println!(" Scans repositories for outdated npm packages and automatically"); println!(" updates them, creates branches, and commits changes."); println!(); @@ -44,16 +168,233 @@ fn print_help() { println!(" 3. Creates a branch and commits changes"); println!(" 4. Pushes the branch to origin"); println!(); - println!(" To create pull requests for the updated branches, use:"); - println!(" repos pr --title 'chore: dependency updates' "); + println!("PRS MODE:"); + println!(" Generates a report of open pull requests awaiting approval"); + println!(" across all configured repositories. Shows:"); + println!(" - Total number of PRs per repository"); + println!(" - PRs without requested reviewers"); + println!(" - PR number, title, author, and URL"); println!(); - println!("REQUIREMENTS:"); - println!(" - npm must be installed and available in PATH"); - println!(" - Repositories must have package.json files"); - println!(" - Git repositories must be properly initialized"); + println!(" Requires:"); + println!(" - GITHUB_TOKEN environment variable for API access"); + println!(" - Repositories must be GitHub repositories"); println!(); println!("OPTIONS:"); - println!(" -h, --help Print this help message"); + println!(" -h, --help Print this help message"); + println!(" -d, --debug Enable debug output (shows URL parsing)"); + println!(" --config Use custom config file instead of default"); + println!( + " --tag Filter to repositories with this tag (can be used multiple times)" + ); + println!( + " --exclude-tag Exclude repositories with this tag (can be used multiple times)" + ); + println!(); + println!("EXAMPLES:"); + println!( + " repos health # Run dependency check (default)" + ); + println!( + " repos health deps # Explicitly run dependency check" + ); + println!(" repos health prs # Generate PR report"); + println!( + " repos health prs --debug # Generate PR report with debug info" + ); + println!( + " repos health prs --tag flow # PRs for 'flow' tagged repos only" + ); + println!( + " repos health deps --exclude-tag deprecated # Deps check excluding deprecated repos" + ); + println!(" repos health prs --config custom.yaml --tag ci # Custom config with tag filter"); +} + +async fn run_deps_check(repos: Vec) -> Result<()> { + let mut processed = 0; + for repo in repos { + if let Err(e) = process_repo(&repo) { + eprintln!("health: {} skipped: {}", repo.name, e); + } else { + processed += 1; + } + } + println!("health: processed {} repositories", processed); + Ok(()) +} + +async fn run_pr_report(repos: Vec, debug: bool) -> Result<()> { + let token = env::var("GITHUB_TOKEN") + .context("GITHUB_TOKEN environment variable required for PR reporting")?; + + println!("================================================="); + println!(" GitHub Pull Requests - Approval Status Report"); + println!("================================================="); + println!(); + + let mut total_repos = 0; + let mut total_prs = 0; + let mut total_awaiting = 0; + + for repo in repos { + if debug { + eprintln!("DEBUG: Processing repo: {} ({})", repo.name, repo.url); + } + + match fetch_pr_report(&repo, &token, debug).await { + Ok(report) => { + total_repos += 1; + total_prs += report.total_prs; + total_awaiting += report.awaiting_approval.len(); + + print_repo_report(&report); + } + Err(e) => { + eprintln!("❌ {}: {}", repo.name, e); + } + } + } + + println!(); + println!("================================================="); + println!("Summary:"); + println!(" Repositories checked: {}", total_repos); + println!(" Total open PRs: {}", total_prs); + println!(" PRs awaiting approval: {}", total_awaiting); + println!("================================================="); + + Ok(()) +} + +async fn fetch_pr_report(repo: &Repository, token: &str, debug: bool) -> Result { + // Parse owner/repo from URL + let (owner, repo_name) = parse_github_repo(&repo.url) + .with_context(|| format!("Failed to parse GitHub URL: {}", repo.url))?; + + if debug { + eprintln!( + "DEBUG: Parsed {} => owner: {}, repo: {}", + repo.url, owner, repo_name + ); + } + + // Fetch open PRs from GitHub API + let client = reqwest::Client::new(); + let url = format!( + "https://api.github.com/repos/{}/{}/pulls?state=open", + owner, repo_name + ); + + if debug { + eprintln!("DEBUG: API URL: {}", url); + } + + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .header("User-Agent", "repos-health") + .header("Accept", "application/vnd.github.v3+json") + .send() + .await + .context("Failed to fetch PRs from GitHub")?; + + if !response.status().is_success() { + anyhow::bail!( + "GitHub API error: {} - {} (URL parsed as: {}/{})", + response.status(), + response.text().await.unwrap_or_default(), + owner, + repo_name + ); + } + + let prs: Vec = response + .json() + .await + .context("Failed to parse PR response")?; + + let total_prs = prs.len(); + let awaiting_approval: Vec = prs + .into_iter() + .filter(|pr| !pr.draft && pr.requested_reviewers.is_empty()) + .map(|pr| PrSummary { + number: pr.number, + title: pr.title, + author: pr.user.login, + url: pr.html_url, + }) + .collect(); + + Ok(PrReport { + repo_name: repo.name.clone(), + total_prs, + awaiting_approval, + }) +} + +fn parse_github_repo(url: &str) -> Result<(String, String)> { + // Parse GitHub URL: https://github.com/owner/repo.git or git@github.com:owner/repo.git + let url = url.trim_end_matches(".git"); + + // Handle SSH format: git@github.com:owner/repo + if url.contains("git@github.com:") { + let parts: Vec<&str> = url.split(':').collect(); + if parts.len() >= 2 { + let repo_path = parts[1]; + let path_parts: Vec<&str> = repo_path.split('/').collect(); + if path_parts.len() >= 2 { + return Ok(( + path_parts[path_parts.len() - 2].to_string(), + path_parts[path_parts.len() - 1].to_string(), + )); + } + } + } + + // Handle HTTPS format: https://github.com/owner/repo + let parts: Vec<&str> = url.split('/').collect(); + + if parts.len() < 2 { + anyhow::bail!("Invalid GitHub URL format: {}", url); + } + + let owner = parts[parts.len() - 2].to_string(); + let repo = parts[parts.len() - 1].to_string(); + + Ok((owner, repo)) +} + +fn print_repo_report(report: &PrReport) { + if report.total_prs == 0 { + println!("✅ {}: No open PRs", report.repo_name); + return; + } + + println!( + "📊 {}: {} open PR{}", + report.repo_name, + report.total_prs, + if report.total_prs == 1 { "" } else { "s" } + ); + + if report.awaiting_approval.is_empty() { + println!(" ✓ All PRs have reviewers assigned"); + } else { + println!( + " ⚠️ {} PR{} awaiting reviewer assignment:", + report.awaiting_approval.len(), + if report.awaiting_approval.len() == 1 { + "" + } else { + "s" + } + ); + for pr in &report.awaiting_approval { + println!(" #{} - {} (by @{})", pr.number, pr.title, pr.author); + println!(" {}", pr.url); + } + } + println!(); } fn process_repo(repo: &Repository) -> Result<()> { @@ -211,6 +552,80 @@ mod tests { assert!(timestamp.chars().all(|c| c.is_ascii_digit())); } + #[test] + fn test_parse_github_repo_valid() { + let url = "https://github.com/owner/repo.git"; + let result = parse_github_repo(url); + assert!(result.is_ok()); + let (owner, repo) = result.unwrap(); + assert_eq!(owner, "owner"); + assert_eq!(repo, "repo"); + } + + #[test] + fn test_parse_github_repo_without_git_extension() { + let url = "https://github.com/owner/repo"; + let result = parse_github_repo(url); + assert!(result.is_ok()); + let (owner, repo) = result.unwrap(); + assert_eq!(owner, "owner"); + assert_eq!(repo, "repo"); + } + + #[test] + fn test_parse_github_repo_ssh_format() { + let url = "git@github.com:owner/repo.git"; + let result = parse_github_repo(url); + assert!(result.is_ok()); + let (owner, repo) = result.unwrap(); + assert_eq!(owner, "owner"); + assert_eq!(repo, "repo"); + } + + #[test] + fn test_parse_github_repo_ssh_without_git() { + let url = "git@github.com:owner/repo"; + let result = parse_github_repo(url); + assert!(result.is_ok()); + let (owner, repo) = result.unwrap(); + assert_eq!(owner, "owner"); + assert_eq!(repo, "repo"); + } + + #[test] + fn test_parse_github_repo_invalid() { + let url = "invalid"; + let result = parse_github_repo(url); + assert!(result.is_err()); + } + + #[test] + fn test_print_repo_report_no_prs() { + let report = PrReport { + repo_name: "test-repo".to_string(), + total_prs: 0, + awaiting_approval: vec![], + }; + print_repo_report(&report); + // Should complete without panic + } + + #[test] + fn test_print_repo_report_with_prs() { + let report = PrReport { + repo_name: "test-repo".to_string(), + total_prs: 2, + awaiting_approval: vec![PrSummary { + number: 123, + title: "Test PR".to_string(), + author: "testuser".to_string(), + url: "https://github.com/owner/repo/pull/123".to_string(), + }], + }; + print_repo_report(&report); + // Should complete without panic + } + #[test] fn test_check_outdated_execution() { // Test execution path for check_outdated function @@ -293,4 +708,19 @@ mod tests { let result = run(repo_path, ["nonexistent_command_12345"]); assert!(result.is_err()); } + + #[tokio::test] + async fn test_fetch_pr_report_invalid_url() { + let repo = Repository { + name: "test".to_string(), + url: "invalid".to_string(), + path: None, + branch: None, + tags: vec![], + config_dir: None, + }; + + let result = fetch_pr_report(&repo, "fake-token", false).await; + assert!(result.is_err()); + } }