git-bot-feedback 0.5.5

A library designed for CI tools that posts comments on a Pull Request.
Documentation
use super::RestApiClient;
use crate::{OutputVariable, RestClientError as ClientError, ReviewOptions, ThreadCommentOptions};

#[cfg(feature = "file-changes")]
use crate::{FileDiffLines, FileFilter, LinesChangedOnly, parse_diff};
#[cfg(feature = "file-changes")]
use std::{collections::HashMap, process::Command};

/// A (mostly) non-operational implementation of [`RestApiClient`].
///
/// This is primarily meant for use in local contexts (or in unsupported CI
/// platforms/contexts) because the following methods silently do nothing:
///
/// - [`Self::post_thread_comment`]
/// - [`Self::cull_pr_reviews`]
/// - [`Self::post_pr_review`]
/// - [`Self::set_user_agent`]
///
/// However, [`Self::get_list_of_changed_files`] does use the git CLI
/// to get a list of changed files.
///
/// Instantiate with [`Default::default()`].
/// ```rust
/// use git_bot_feedback::client::LocalClient;
///
/// let client = LocalClient::default();
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct LocalClient;

/// Helper function to resolve a git reference to a commit hash using `git rev-parse`.
#[cfg(feature = "file-changes")]
fn git_rev_parse(base: &str) -> Result<String, ClientError> {
    match Command::new("git").args(["rev-parse", base]).output() {
        Err(e) => Err(ClientError::Io {
            task: format!("invoke `git rev-parse {base}` to validate reference"),
            source: e,
        }),
        Ok(output) => {
            if output.status.success() {
                Ok(String::from_utf8_lossy(output.stdout.trim_ascii()).to_string())
            } else if base.chars().all(|c| c.is_ascii_digit()) {
                // if all chars from a decimal number, then
                // try using it as a number of parents from HEAD.
                // This is not infinitely recursive because
                // the adapted `base` is not all digits.
                git_rev_parse(format!("HEAD~{base}").as_str())
            } else {
                let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
                // Given diff base did not resolve to a valid git reference
                Err(ClientError::GitCommand(err_msg))
            }
        }
    }
}

#[async_trait::async_trait]
impl RestApiClient for LocalClient {
    #[cfg(feature = "file-changes")]
    async fn get_list_of_changed_files(
        &self,
        file_filter: &FileFilter,
        lines_changed_only: &LinesChangedOnly,
        base_diff: Option<String>,
        ignore_index: bool,
    ) -> Result<HashMap<String, FileDiffLines>, ClientError> {
        let git_status = if ignore_index {
            0
        } else {
            match Command::new("git").args(["status", "--short"]).output() {
                Err(e) => {
                    return Err(ClientError::io("invoke `git status`", e));
                }
                Ok(output) => {
                    if output.status.success() {
                        String::from_utf8_lossy(&output.stdout)
                            .to_string()
                            // trim last newline to prevent an extra empty line being counted as a changed file
                            .trim_end_matches('\n')
                            .lines()
                            // we only care about staged changes
                            .filter(|l| !l.starts_with(' '))
                            .count()
                    } else {
                        let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
                        return Err(ClientError::GitCommand(err_msg));
                    }
                }
            }
        };
        let mut diff_args = vec![];
        let mut git_sub_cmd = vec!["--no-pager"];
        if git_status != 0 {
            // There are changes in the working directory.
            // So, compare include the staged changes.
            diff_args.push("--staged".to_string());
        }
        if let Some(base) = base_diff {
            let resolved_base = git_rev_parse(&base)?;
            diff_args.push(resolved_base);
        } else if git_status == 0 {
            // No base diff provided and there are no staged changes,
            // just get the diff of the last commit.
            let resolved_head = git_rev_parse("HEAD~1")?;
            diff_args.push(resolved_head);
        }
        if ignore_index {
            // When ignoring the index, we want to compare
            // the working directory changes, not the staged changes.
            diff_args.push("--format=%b".to_string());
            git_sub_cmd.push("show");
        } else {
            git_sub_cmd.push("diff");
        };
        log::debug!(
            "Getting diff with `git {} {}`",
            git_sub_cmd.join(" "),
            diff_args.join(" ")
        );
        match Command::new("git")
            .args(&git_sub_cmd)
            .args(&diff_args)
            .output()
        {
            Err(e) => Err(ClientError::Io {
                task: format!(
                    "invoke `git {} {}`",
                    git_sub_cmd.join(" "),
                    diff_args.join(" ")
                ),
                source: e,
            }),
            Ok(output) => {
                if output.status.success() {
                    let diff_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
                    let files = parse_diff(&diff_str, file_filter, lines_changed_only)?;
                    Ok(files)
                } else {
                    let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
                    Err(ClientError::GitCommand(err_msg))
                }
            }
        }
    }

    fn is_pr_event(&self) -> bool {
        false
    }

    fn set_user_agent(&mut self, _user_agent: &str) -> Result<(), ClientError> {
        Ok(())
    }

    async fn post_thread_comment(&self, _options: ThreadCommentOptions) -> Result<(), ClientError> {
        Ok(())
    }

    async fn cull_pr_reviews(&mut self, _options: &mut ReviewOptions) -> Result<(), ClientError> {
        Ok(())
    }

    async fn post_pr_review(&mut self, _options: &ReviewOptions) -> Result<(), ClientError> {
        Ok(())
    }

    fn write_output_variables(&self, vars: &[OutputVariable]) -> Result<(), ClientError> {
        for var in vars {
            log::info!("{}: {}", var.name, var.value);
        }
        Ok(())
    }
}