pukbot 0.3.8

Post disclosed GitHub comments through the Pukbot GitHub App
use std::fmt::Write as _;
use std::process::{Command, Stdio};

use anyhow::{Context, Result, bail};
use serde_json::{Map, Value, json};

use crate::model::{Operation, Reaction, ReviewEvent};

pub const fn runs_locally(operation: &Operation) -> bool {
    matches!(
        operation,
        Operation::PullRequestCreate { .. }
            | Operation::PullRequestEdit { .. }
            | Operation::PullRequestClose { .. }
            | Operation::PullRequestReopen { .. }
            | Operation::PullRequestMerge { .. }
            | Operation::PullRequestReady { .. }
            | Operation::PullRequestDraft { .. }
            | Operation::PullRequestReview { .. }
            | Operation::PullRequestLabels { .. }
            | Operation::PullRequestAssignees { .. }
            | Operation::PullRequestReact { .. }
            | Operation::PullRequestUpdateBranch { .. }
    )
}

#[expect(
    clippy::too_many_lines,
    reason = "the exhaustive locally executed operation match is kept in one place"
)]
pub fn execute(operation: &Operation) -> Result<String> {
    match operation {
        Operation::PullRequestCreate {
            owner,
            repository,
            title,
            body,
            head,
            base,
            draft,
        } => {
            let mut request = Map::new();
            request.insert("title".to_owned(), json!(title));
            request.insert("head".to_owned(), json!(head));
            request.insert("base".to_owned(), json!(base));
            request.insert("draft".to_owned(), json!(draft));
            insert_optional(&mut request, "body", body.as_deref());
            api(
                "POST",
                &format!("repos/{}/pulls", slug(owner, repository)),
                Some(&Value::Object(request)),
                Some(".html_url"),
            )
        }
        Operation::PullRequestEdit {
            owner,
            repository,
            number,
            title,
            body,
            base,
        } => {
            let mut request = Map::new();
            insert_optional(&mut request, "title", title.as_deref());
            insert_optional(&mut request, "body", body.as_deref());
            insert_optional(&mut request, "base", base.as_deref());
            api(
                "PATCH",
                &format!("repos/{}/pulls/{number}", slug(owner, repository)),
                Some(&Value::Object(request)),
                Some(".html_url"),
            )
        }
        Operation::PullRequestClose {
            owner,
            repository,
            number,
        } => api(
            "PATCH",
            &format!("repos/{}/pulls/{number}", slug(owner, repository)),
            Some(&json!({"state": "closed"})),
            Some(".html_url"),
        ),
        Operation::PullRequestReopen {
            owner,
            repository,
            number,
        } => api(
            "PATCH",
            &format!("repos/{}/pulls/{number}", slug(owner, repository)),
            Some(&json!({"state": "open"})),
            Some(".html_url"),
        ),
        Operation::PullRequestMerge {
            owner,
            repository,
            number,
        } => {
            let slug = slug(owner, repository);
            api(
                "PUT",
                &format!("repos/{slug}/pulls/{number}/merge"),
                Some(&json!({"merge_method": "squash"})),
                None,
            )?;
            Ok(pull_request_url(&slug, number.get()))
        }
        Operation::PullRequestReady {
            owner,
            repository,
            number,
        } => set_draft(owner, repository, number.get(), false),
        Operation::PullRequestDraft {
            owner,
            repository,
            number,
        } => set_draft(owner, repository, number.get(), true),
        Operation::PullRequestReview {
            owner,
            repository,
            number,
            event,
            body,
        } => {
            let slug = slug(owner, repository);
            let mut request = Map::new();
            request.insert("event".to_owned(), json!(review_event(*event)));
            insert_optional(&mut request, "body", body.as_deref());
            api(
                "POST",
                &format!("repos/{slug}/pulls/{number}/reviews"),
                Some(&Value::Object(request)),
                None,
            )?;
            Ok(pull_request_url(&slug, number.get()))
        }
        Operation::PullRequestLabels {
            owner,
            repository,
            number,
            add,
            remove,
        } => {
            let slug = slug(owner, repository);
            if !add.is_empty() {
                api(
                    "POST",
                    &format!("repos/{slug}/issues/{number}/labels"),
                    Some(&json!({"labels": add})),
                    None,
                )?;
            }
            for label in remove {
                api(
                    "DELETE",
                    &format!(
                        "repos/{slug}/issues/{number}/labels/{}",
                        encode_path_segment(label)
                    ),
                    None,
                    None,
                )?;
            }
            Ok(pull_request_url(&slug, number.get()))
        }
        Operation::PullRequestAssignees {
            owner,
            repository,
            number,
            add,
            remove,
        } => {
            let slug = slug(owner, repository);
            let path = format!("repos/{slug}/issues/{number}/assignees");
            if !add.is_empty() {
                api("POST", &path, Some(&json!({"assignees": add})), None)?;
            }
            if !remove.is_empty() {
                api("DELETE", &path, Some(&json!({"assignees": remove})), None)?;
            }
            Ok(pull_request_url(&slug, number.get()))
        }
        Operation::PullRequestReact {
            owner,
            repository,
            number,
            reaction,
        } => {
            let slug = slug(owner, repository);
            api(
                "POST",
                &format!("repos/{slug}/issues/{number}/reactions"),
                Some(&json!({"content": reaction_content(*reaction)})),
                None,
            )?;
            Ok(pull_request_url(&slug, number.get()))
        }
        Operation::PullRequestUpdateBranch {
            owner,
            repository,
            number,
        } => {
            let slug = slug(owner, repository);
            api(
                "PUT",
                &format!("repos/{slug}/pulls/{number}/update-branch"),
                Some(&json!({})),
                None,
            )?;
            Ok(pull_request_url(&slug, number.get()))
        }
        _ => bail!(
            "operation {} is executed by the Pukbot GitHub App",
            operation.name()
        ),
    }
}

fn set_draft(owner: &str, repository: &str, number: u64, draft: bool) -> Result<String> {
    let slug = slug(owner, repository);
    let mut command = Command::new("gh");
    command.args(["pr", "ready", &number.to_string(), "--repo", &slug]);
    if draft {
        command.arg("--undo");
    }
    let output = command
        .output()
        .context("failed to launch gh; install and authenticate GitHub CLI")?;
    if !output.status.success() {
        bail!(
            "GitHub rejected the draft state change: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }
    Ok(pull_request_url(&slug, number))
}

fn api(method: &str, path: &str, request: Option<&Value>, filter: Option<&str>) -> Result<String> {
    let mut command = Command::new("gh");
    command.args(["api", "--method", method, path]);
    if request.is_some() {
        command.args(["--input", "-"]);
    }
    if let Some(expression) = filter {
        command.args(["--jq", expression]);
    }
    let mut child = command
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .context("failed to launch gh; install and authenticate GitHub CLI")?;
    if let Some(request) = request {
        let stdin = child.stdin.take().context("failed to open gh input")?;
        serde_json::to_writer(stdin, request).context("failed to encode the GitHub request")?;
    } else {
        drop(child.stdin.take());
    }
    let output = child.wait_with_output().context("failed to wait for gh")?;
    if !output.status.success() {
        bail!(
            "GitHub rejected {method} {path}: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }
    Ok(String::from_utf8(output.stdout)
        .context("GitHub CLI returned non-UTF-8 output")?
        .trim()
        .to_owned())
}

fn insert_optional(request: &mut Map<String, Value>, key: &str, value: Option<&str>) {
    if let Some(value) = value {
        request.insert(key.to_owned(), json!(value));
    }
}

fn slug(owner: &str, repository: &str) -> String {
    format!("{owner}/{repository}")
}

fn pull_request_url(slug: &str, number: u64) -> String {
    format!("https://github.com/{slug}/pull/{number}")
}

const fn review_event(event: ReviewEvent) -> &'static str {
    match event {
        ReviewEvent::Approve => "APPROVE",
        ReviewEvent::RequestChanges => "REQUEST_CHANGES",
        ReviewEvent::Comment => "COMMENT",
    }
}

const fn reaction_content(reaction: Reaction) -> &'static str {
    match reaction {
        Reaction::PlusOne => "+1",
        Reaction::MinusOne => "-1",
        Reaction::Laugh => "laugh",
        Reaction::Confused => "confused",
        Reaction::Heart => "heart",
        Reaction::Hooray => "hooray",
        Reaction::Rocket => "rocket",
        Reaction::Eyes => "eyes",
    }
}

fn encode_path_segment(value: &str) -> String {
    let mut encoded = String::with_capacity(value.len());
    for byte in value.bytes() {
        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
            encoded.push(char::from(byte));
        } else {
            write!(encoded, "%{byte:02X}").expect("writing to a String cannot fail");
        }
    }
    encoded
}

#[cfg(test)]
mod tests {
    use super::{encode_path_segment, pull_request_url, review_event};
    use crate::model::ReviewEvent;

    #[test]
    fn encodes_label_path_segments() {
        assert_eq!(encode_path_segment("needs review"), "needs%20review");
        assert_eq!(encode_path_segment("type/bug"), "type%2Fbug");
    }

    #[test]
    fn builds_pull_request_urls() {
        assert_eq!(
            pull_request_url("owner/repository", 7),
            "https://github.com/owner/repository/pull/7"
        );
    }

    #[test]
    fn maps_review_events() {
        assert_eq!(review_event(ReviewEvent::RequestChanges), "REQUEST_CHANGES");
    }
}