From 75bc6e6e39929e394d252d19523ca6d0d1c87d88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandro=20J=C3=A4ckel?= Date: Wed, 14 Aug 2024 00:40:45 +0200 Subject: [PATCH] Add subcommand to review arbitrary remote --- src/changes.rs | 2 +- src/main.rs | 230 +++++++++++++++++++++++++++++++------------------ 2 files changed, 149 insertions(+), 83 deletions(-) diff --git a/src/changes.rs b/src/changes.rs index 17ea441..7b8f3f8 100644 --- a/src/changes.rs +++ b/src/changes.rs @@ -1,4 +1,4 @@ -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct RepoChange { pub name: String, pub remote: String, diff --git a/src/main.rs b/src/main.rs index 4b89fd0..3146483 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,63 +3,142 @@ mod changes; mod images; -use anyhow::anyhow; -use anyhow::Context; +use anyhow::{anyhow, Context}; use changes::{Change, ChangeCommit, RepoChange}; -use clap::builder::styling::Style; -use clap::Parser; +use clap::{builder::styling::Style, Parser, Subcommand}; use git2::Repository; use images::ContainerImages; use octocrab::commits::PullRequestTarget; -use octocrab::models::pulls; +use octocrab::models::{pulls, pulls::ReviewState}; +use octocrab::Octocrab; use std::{env, str, sync::Arc}; const BOLD_UNDERLINE: Style = Style::new().bold().underline(); /// Program to simplify PCI double approval process across repositories -#[derive(Parser, Debug)] +#[derive(Parser)] #[command(version, about, long_about = None, after_help = format!("{BOLD_UNDERLINE}Environment variables:{BOLD_UNDERLINE:#} GITHUB_TOKEN GitHub token to use for API requests "))] -// see https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/variables for environment variables -struct Args { - /// Git repository where to discover images.yaml files +#[command(propagate_version = true)] +// see https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/variables for environment variablesuse +struct Cli { + /// The git base ref to compare against #[arg( - short, long, - env = "GITHUB_WORKSPACE", + env = "GITHUB_BASE_REF", hide_env_values = true, - required = true + required = false, + global = true )] - workspace: String, + base: String, - /// The git base ref to compare against + /// The git head ref or source branch of the PR to compare against #[arg( long, default_value = "HEAD", - env = "GITHUB_BASE_REF", - hide_env_values = true + env = "GITHUB_HEAD_REF", + hide_env_values = true, + required = false, + global = true )] - base: String, - - /// The git head ref or source branch of the PR to compare against - #[arg(long, env = "GITHUB_HEAD_REF", hide_env_values = true, required = true)] head: String, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +#[command(propagate_version = true)] +enum Commands { + /// Analyzes commits in a repo and finds relevant reviews + Repo { + /// GitHub git remote to use + remote: String, + }, + + /// Analyzes a helm-charts repo, finds sources from values.yaml files and runs repo subcommand on them + HelmChart { + /// Git repository where to discover images.yaml files + #[arg( + short, + long, + env = "GITHUB_WORKSPACE", + hide_env_values = true, + required = false, + global = true + )] + workspace: String, + }, } #[tokio::main] async fn main() -> Result<(), anyhow::Error> { - let args = Args::parse(); + let cli = Cli::parse(); - let repo = Repository::open(args.workspace).context("failed to open repository")?; + octocrab::initialise( + Octocrab::builder() + .personal_token(env::var("GITHUB_TOKEN").context("missing GITHUB_TOKEN env")?) + .build() + .context("failed to build octocrab client")?, + ); + let octocrab = octocrab::instance(); + + match &cli.command { + Commands::Repo { remote } => { + let repo = &mut RepoChange { + name: parse_remote(remote).context("while parsing remote")?.1, + remote: remote.clone(), + base_commit: cli.base, + head_commit: cli.head, + changes: Vec::new(), + }; + find_reviews(&octocrab, repo) + .await + .context("while finding reviews")?; + + print_changes(&[repo.clone()]); + } + Commands::HelmChart { workspace } => { + find_values_yaml(&octocrab, workspace.clone(), cli.base, cli.head) + .await + .context("while finding values.yaml files")?; + } + } + + Ok(()) +} + +fn parse_remote(remote: &String) -> Result<(String, String), anyhow::Error> { + let repo_parts: Vec<&str> = remote + .strip_prefix("https://github.com/") + .ok_or(anyhow!("can't strip https://github.com/ prefix"))? + .split('/') + .collect(); + let repo_owner = repo_parts[0].to_string(); + let repo_name = repo_parts[1] + .strip_suffix(".git") + .ok_or(anyhow!("can't strip .git suffix"))? + .to_string(); + + return Ok((repo_owner, repo_name)); +} + +async fn find_values_yaml( + octocrab: &Arc, + workspace: String, + base: String, + head: String, +) -> Result<(), anyhow::Error> { + let repo = Repository::open(workspace).context("failed to open repository")?; let base_ref = repo - .revparse_single(&args.base) + .revparse_single(&base) .context("can't parse base_ref")? .id(); let head_ref = repo - .revparse_single(&args.head) + .revparse_single(&head) .context("can't parse head_ref")? .id(); @@ -119,75 +198,27 @@ async fn main() -> Result<(), anyhow::Error> { } } - octocrab::initialise( - octocrab::Octocrab::builder() - .personal_token(env::var("GITHUB_TOKEN").context("missing GITHUB_TOKEN env")?) - .build() - .context("failed to build octocrab client")?, - ); - let octocrab = octocrab::instance(); - for repo in &mut changes { - collect_repo_changes(&octocrab, repo) + find_reviews(octocrab, repo) .await - .context("failed collecting repo changes")?; + .context("while collecting repo changes")?; } - for change in &changes { - println!( - "Name {} from {} moved from {} to {}", - change.name, change.remote, change.base_commit, change.head_commit - ); - println!("| Commit link | Pull Request link | Approvals | Reviewer's verdict |"); - println!("|-------------|-------------------|-----------|--------------------|"); - for commit_change in &change.changes { - let pr_link = commit_change.pr_link.clone(); - println!( - "| {} | {} | {} | |", - commit_change - .commits - .iter() - .map(|x| format!( - "[{}]({})", - match x.headline.char_indices().nth(45) { - None => x.headline.clone(), - Some((idx, _)) => x.headline[..idx].to_string() + "…", - }, - x.link - )) - .collect::>() - .join(" ,
"), - match pr_link { - Some(pr_link) => short_md_link(pr_link), - None => String::new(), - }, - commit_change.approvals.join("None"), - ); - } - } + print_changes(&changes); Ok(()) } -async fn collect_repo_changes( - octocrab: &Arc, +async fn find_reviews( + octocrab: &Arc, repo: &mut RepoChange, ) -> Result<(), anyhow::Error> { - let repo_parts: Vec<&str> = repo - .remote - .strip_prefix("https://github.com/") - .ok_or(anyhow!("can't strip https://github.com/ prefix"))? - .split('/') - .collect(); - let repo_owner = repo_parts[0]; - let repo_name = repo_parts[1] - .strip_suffix(".git") - .ok_or(anyhow!("can't strip .git suffix"))?; + let (repo_owner, repo_name) = parse_remote(&repo.remote).context("while parsing remote")?; let link_prefix = format!("https://github.com/{repo_owner}/{repo_name}"); let compare = octocrab - .commits(repo_owner, repo_name) + .commits(repo_owner.clone(), repo_name.clone()) .compare(&repo.base_commit, &repo.head_commit) .send() .await @@ -198,7 +229,7 @@ async fn collect_repo_changes( for commit in &compare.commits { let mut associated_prs_page = octocrab - .commits(repo_owner, repo_name) + .commits(repo_owner.clone(), repo_name.clone()) .associated_pull_requests(PullRequestTarget::Sha(commit.sha.clone())) .send() .await @@ -227,7 +258,7 @@ async fn collect_repo_changes( println!("pr number: {:}", associated_pr.number); let mut pr_reviews_page = octocrab - .pulls(repo_owner, repo_name) + .pulls(repo_owner.clone(), repo_name.clone()) .list_reviews(associated_pr.number) .send() .await @@ -277,13 +308,48 @@ async fn collect_repo_changes( Ok(()) } +fn print_changes(changes: &[RepoChange]) { + for change in changes { + println!( + "Name {} from {} moved from {} to {}", + change.name, change.remote, change.base_commit, change.head_commit + ); + println!("| Commit link | Pull Request link | Approvals | Reviewer's verdict |"); + println!("|-------------|-------------------|-----------|--------------------|"); + for commit_change in &change.changes { + let pr_link = commit_change.pr_link.clone(); + println!( + "| {} | {} | {} | |", + commit_change + .commits + .iter() + .map(|x| format!( + "[{}]({})", + match x.headline.char_indices().nth(45) { + None => x.headline.clone(), + Some((idx, _)) => x.headline[..idx].to_string() + "…", + }, + x.link + )) + .collect::>() + .join(" ,
"), + match pr_link { + Some(pr_link) => short_md_link(pr_link), + None => String::new(), + }, + commit_change.approvals.join("None"), + ); + } + } +} + fn collect_approved_reviews( pr_reviews: &[pulls::Review], review: &mut Change, ) -> Result<(), anyhow::Error> { for pr_review in pr_reviews { // TODO: do we need to check if this is the last review of the user? - if pr_review.state.ok_or(anyhow!("review has no state"))? == pulls::ReviewState::Approved { + if pr_review.state.ok_or(anyhow!("review has no state"))? == ReviewState::Approved { review.approvals.push( pr_review .user