diff --git a/src/git.rs b/src/git.rs index 047a2ed..1fb10a3 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,7 +1,7 @@ -use std::process::{self, Command, Stdio}; -use std::{fmt, io, str}; +use std::process::{Command, Stdio}; +use std::str; -use thiserror::Error; +use crate::shell::{self, Error, Output}; // TODO: Mark all subcommands types as must use mod branch; @@ -22,20 +22,6 @@ pub use push::push; pub use rev_list::rev_list; pub use switch::switch; -#[derive(Debug, Error)] -pub enum Error { - IO(#[from] io::Error), - Status(process::Output), - Utf8(#[from] str::Utf8Error), -} - -// FIXME: -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{self:#?}") - } -} - // FIXME: Move to `log` module? #[derive(Clone, Copy)] pub enum Format { @@ -58,43 +44,20 @@ pub struct Branch>(pub T); pub struct Commit>(pub T); pub struct Remote>(pub T); -pub struct Output { - pub status: process::ExitStatus, - pub stdout: String, - pub stderr: Vec, -} - -impl TryFrom for Output { - type Error = str::Utf8Error; - - fn try_from(out: process::Output) -> Result { - let stdout = str::from_utf8(out.stdout.as_slice())?; - let stdout = stdout.trim_end().to_string(); - - Ok(Output { - status: out.status, - stderr: out.stderr, - stdout, - }) - } +pub trait GitCmd: Sized { + fn setup(self, cmd: &mut Command); } -pub trait GitCmd: Sized { - // FIXME: Spawn needs to check the exit code and encode that in its return type - non-zero should be Err +impl shell::Command for T +where + T: GitCmd, +{ fn spawn(self) -> Result { let mut cmd = Command::new("git"); cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); self.setup(&mut cmd); - let output = cmd.spawn()?.wait_with_output()?; - - if output.status.success() { - Ok(output.try_into()?) - } else { - Err(Error::Status(output)) - } + T::spawn_with_output(cmd) } - - fn setup(self, cmd: &mut Command); } diff --git a/src/github.rs b/src/github.rs new file mode 100644 index 0000000..e4fdc32 --- /dev/null +++ b/src/github.rs @@ -0,0 +1,43 @@ +//! Github specific part of `gerris` - this module concerns the formatting of the PR's body, as well as any operations +//! related to interacting with Github. + +use crate::upstream::BuildError; + +const FAILURE: &str = "❌"; +const SUCCESS: &str = "✅"; + +pub fn prepare_body( + last_commit: String, /* FIXME: Should this be a Commit type? */ + commits: Vec<(&str, Option)>, +) -> String { + let tab = String::from("|Commit|Build|Test|\n|---|:-:|:-:|"); + + let tab = commits.iter().fold(tab, |tab, (commit, result)| { + let (build_result, test_result) = match result { + Some(BuildError::Build) => (FAILURE, FAILURE), + Some(BuildError::Tests) => (SUCCESS, FAILURE), + _ => (SUCCESS, SUCCESS), + }; + + format!("{tab}\n|{commit}|{build_result}|{test_result}|") + }); + + // TODO: Put this in a const somewhere. Cleanup that file overall + format!( + " +This pull-request aims to help upstreaming commits to the GCC repository by formatting them \ +and checking that they can be cherry-picked/rebased properly. + +The last commit upstreamed was: + +`{}` + +The list of commits prepared is as follows: + +{} + +🐙 +", + last_commit, tab + ) +} diff --git a/src/main.rs b/src/main.rs index f931358..38c6324 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,8 +7,11 @@ use clap::{Parser, Subcommand}; // FIXME: If not, use nom instead of the hand-written combinator mod clog; -pub mod git; +mod git; +mod github; +mod make; mod parser; +mod shell; mod upstream; #[derive(Clone, Subcommand)] @@ -35,6 +38,40 @@ enum SubCmd { help = "work directory which contains a copy of the gccrs respository" )] work: PathBuf, + + #[arg( + short, + long, + help = "repository on which to submit the GitHub pull-request" + )] + repo: String, + }, + PullRequest { + #[arg(short, long, help = "branch from which to create the pull-request")] + branch: String, + + #[arg(short, long, help = "GitHub token to perform actions as gerris")] + token: String, + + #[arg( + long, + help = "branch on which to base the pull-request gerris will create" + )] + to: String, + + #[arg( + short, + long, + help = "work directory which contains a copy of the gccrs respository and the branch you would like to create a pull-request from (--branch)" + )] + work: PathBuf, + + #[arg( + short, + long, + help = "repository on which to submit the GitHub pull-request" + )] + repo: String, }, } @@ -51,14 +88,27 @@ async fn main() -> anyhow::Result<()> { match args.cmd { SubCmd::ChangeLogs => clog::check_clog_checker_output()?, - SubCmd::Upstream { token, to, work } => { + SubCmd::Upstream { + token, + to, + work, + repo, + } => { upstream::prepare_commits(upstream::UpstreamOpt { token, branch: to, gccrs: work, + repo, }) .await? } + SubCmd::PullRequest { + branch, + token, + to, + work, + repo, + } => upstream::create_pull_request(token, repo, branch, to, work).await?, } Ok(()) diff --git a/src/make.rs b/src/make.rs new file mode 100644 index 0000000..5ec7081 --- /dev/null +++ b/src/make.rs @@ -0,0 +1,62 @@ +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use crate::shell::{self, Error, Output}; + +#[derive(Default, Debug)] +pub struct Make { + directory: Option, + jobs: Option, + load: Option, + recipes: Vec, +} + +pub fn new() -> Make { + Make::default() +} + +impl Make { + pub fn directory(self, dir: impl Into) -> Make { + Make { + directory: Some(dir.into().display().to_string()), + ..self + } + } + + pub fn jobs(self, jobs: usize) -> Make { + Make { + jobs: Some(jobs), + ..self + } + } + + pub fn load(self, load: usize) -> Make { + Make { + load: Some(load), + ..self + } + } + + pub fn recipe(self, recipe: impl Into) -> Make { + let mut recipes = self.recipes; + recipes.push(recipe.into()); + + Make { recipes, ..self } + } +} + +impl shell::Command for Make { + fn spawn(self) -> Result { + let mut cmd = Command::new("make"); + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + + self.directory.map(|d| cmd.arg("-C").arg(d)); + self.jobs.map(|jobs| cmd.arg(format!("-j{jobs}"))); + self.load.map(|load| cmd.arg(format!("-l{load}"))); + self.recipes.iter().for_each(|recipe| { + cmd.arg(recipe); + }); + + Make::spawn_with_output(cmd) + } +} diff --git a/src/shell.rs b/src/shell.rs new file mode 100644 index 0000000..ad5d5e7 --- /dev/null +++ b/src/shell.rs @@ -0,0 +1,54 @@ +use std::io; +use std::process; +use std::str; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("i/o error: {0}")] + IO(#[from] io::Error), + #[error("non-zero exit code: {} {0:?}", .0.status)] + Status(process::Output), + #[error("invalid UTF8: {0}")] + Utf8(#[from] str::Utf8Error), +} + +#[derive(Debug)] +pub struct Output { + pub status: process::ExitStatus, + pub stdout: String, + pub stderr: Vec, +} + +impl TryFrom for Output { + type Error = str::Utf8Error; + + fn try_from(out: process::Output) -> Result { + let stdout = str::from_utf8(out.stdout.as_slice())?; + let stdout = stdout.trim_end().to_string(); + + Ok(Output { + status: out.status, + stderr: out.stderr, + stdout, + }) + } +} + +// FIXME: Should we have something like a shell::Command::new() function +// which setups the process::Command and pipes both stderr and stdout? +pub trait Command: Sized { + fn spawn_with_output(mut cmd: process::Command) -> Result { + let output = cmd.spawn()?.wait_with_output()?; + + if output.status.success() { + Ok(output.try_into()?) + } else { + Err(Error::Status(output)) + } + } + + // FIXME: Documentation: Spawn needs to check the exit code and encode that in its return type - non-zero should be Err + fn spawn(self) -> Result; +} diff --git a/src/upstream.rs b/src/upstream.rs index 62f950c..ad602d5 100644 --- a/src/upstream.rs +++ b/src/upstream.rs @@ -73,7 +73,6 @@ // git push -u origin HEAD // create_pr() -use std::fmt::{Display, Formatter, Result as FmtResult}; use std::io; use std::path::PathBuf; use std::string; @@ -83,25 +82,26 @@ use log::{error, info, warn}; use octocrab::OctocrabBuilder; use thiserror::Error; -use crate::git::{self, GitCmd}; +use crate::git; +use crate::github; +use crate::make; +use crate::shell::{self, Command}; pub struct UpstreamOpt { pub token: Option, pub branch: String, pub gccrs: PathBuf, + pub repo: String, } #[derive(Debug, Error)] pub enum Error { + #[error("i/o error: {0}")] Io(#[from] io::Error), + #[error("invalid UTF8: {0}")] Utf8(#[from] string::FromUtf8Error), - Git(#[from] git::Error), -} - -impl Display for Error { - fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - write!(f, "{self:#?}") - } + #[error("shell issue: {0}")] + Shell(#[from] shell::Error), } // shell script equivalent: @@ -122,7 +122,8 @@ impl Display for Error { // git push -u origin HEAD // create_pr() -pub fn maybe_prefix_cherry_picked_commit() -> Result<(), Error> { +// FIXME: It would be nice if this function had a better API since it mutates "global" state +fn maybe_prefix_cherry_picked_commit() -> Result<(), Error> { let msg = git::log() .amount(1) .format(git::Format::Body) @@ -141,23 +142,56 @@ pub fn maybe_prefix_cherry_picked_commit() -> Result<(), Error> { Ok(()) } -fn prepare_body(last_commit: String, rev_list: String) -> String { - format!( - " -This pull-request aims to help upstreaming commits to the GCC repository by formatting them \ -and checking that they can be cherry-picked/rebased properly. - -The last commit upstreamed was: - -`{last_commit}` - -The list of commits prepared is as follows: - -{rev_list} - -🐙 - " - ) +pub enum BuildError { + Build, + Tests, +} + +fn build_prefixed_commit() -> Result<(), BuildError> { + let build_dir = PathBuf::from("build-gerris"); + + info!("building last applied commit..."); + make::new() + .directory(&build_dir) + .jobs(14) + .load(6) + .spawn() + .map_err(|_| BuildError::Build)?; + + info!("testing last applied commit..."); + let test_output = make::new() + .directory(&build_dir) + .recipe("check-rust") + .jobs(14) + .load(6) + .spawn() + .map_err(|_| BuildError::Tests)? + .stdout; + + if test_output.contains("unexpected") || test_output.contains("unresolved") { + warn!("unexpected test failure in last commit"); + Err(BuildError::Tests) + } else { + Ok(()) + } +} + +fn escape_regex_characters(s: &str) -> String { + let is_regex_character = |c| match c { + // FIXME: Add more regex characters as understood by git grep + '*' | '+' | '?' => true, + _ => false, + }; + + s.chars().into_iter().fold(String::new(), |mut acc, c| { + if is_regex_character(c) { + acc.push('\\'); + } + + acc.push(c); + + acc + }) } pub async fn prepare_commits( @@ -165,10 +199,10 @@ pub async fn prepare_commits( token, branch, gccrs, + repo, }: UpstreamOpt, ) -> Result<(), Error> { - // let _ = CdRaii::change_path(gccrs); - std::env::set_current_dir(gccrs)?; + std::env::set_current_dir(&gccrs)?; info!("fetching `upstream`..."); git::fetch().remote("upstream").spawn()?; @@ -183,6 +217,7 @@ pub async fn prepare_commits( .format(git::Format::Title) .spawn()? .stdout; + let last_upstreamed_commit = "gccrs: Fix macro parsing for trait items.".to_string(); info!("found last upstreamed commit: {}", last_upstreamed_commit); @@ -190,15 +225,15 @@ pub async fn prepare_commits( .strip_prefix("gccrs: ") .unwrap() .trim_end(); + let last_msg = escape_regex_characters(last_msg); - let last_commit_us = git::log() + let last_commit_us = dbg!(git::log() .amount(1) .grep(last_msg) .branch(git::Branch("upstream/master")) - .grep(last_msg) .format(git::Format::Hash) - .spawn()? - .stdout; + .spawn()?) + .stdout; info!("found equivalent commit: {}", last_commit_us); @@ -213,6 +248,7 @@ pub async fn prepare_commits( .stdout; warn!("found {} commits to upstream", rev_list.lines().count()); + info!("rev-list: {}", rev_list); let now = Local::now(); let new_branch = format!("prepare-{}-{}", now.date_naive(), now.timestamp_micros()); @@ -224,13 +260,26 @@ pub async fn prepare_commits( info!("created branch `{new_branch}`"); - rev_list.lines().try_for_each(|commit| { - info!("cherry-picking {commit}..."); - git::cherry_pick(git::Commit(commit)).spawn()?; + let commits = rev_list + .lines() + .map(|commit| { + info!("cherry-picking {commit}..."); + + // FIXME: Can we unwrap here? + git::cherry_pick(git::Commit(commit)) + .spawn() + .expect("couldn't cherry pick commit"); + + let result = build_prefixed_commit().err(); - maybe_prefix_cherry_picked_commit() - })?; + // FIXME: Can we unwrap here? + maybe_prefix_cherry_picked_commit().expect("couldn't prefix commit"); + (commit, result) + }) + .collect(); + + // FIXME: Factor this in a function in github module info!("pushing branch..."); git::push() .upstream(git::Remote("origin")) @@ -247,14 +296,14 @@ pub async fn prepare_commits( .unwrap(); instance - .pulls("rust-gcc", "gccrs") + .pulls(repo, "gccrs") .create( format!("[upstream] [{}] Prepare commits", Local::now().date_naive()), - // FIXME: Will branches always be created and pushed from my fork? Add CLI parameter for this maybe? + // FIXME: Will branches always be created and pushed from my fork? Add CLI parameter for this format!("cohenarthur:{new_branch}"), branch, ) - .body(prepare_body(last_upstreamed_commit, rev_list)) + .body(github::prepare_body(last_upstreamed_commit, commits)) .maintainer_can_modify(true) .send() .await @@ -265,3 +314,35 @@ pub async fn prepare_commits( Ok(()) } + +pub async fn create_pull_request( + token: String, + repo: String, + new_branch: String, + base: String, + gccrs: PathBuf, +) -> Result<(), Error> { + std::env::set_current_dir(&gccrs)?; + + info!("creating pull-request for `{new_branch}`..."); + + let instance = OctocrabBuilder::new() + .personal_token(token) + .build() + .unwrap(); + + instance + .pulls(repo, "gccrs") + .create( + format!("[upstream] [{}] Prepare commits", Local::now().date_naive()), + // FIXME: Will branches always be created and pushed from my fork? Add CLI parameter for this + format!("cohenarthur:{new_branch}"), + base, + ) + .maintainer_can_modify(true) + .send() + .await + .unwrap(); + + Ok(()) +}