pukbot 0.3.7

Post disclosed GitHub comments through the Pukbot GitHub App
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};

use crate::model::Operation;

const WORKFLOW_REPOSITORY: &str = "pulkitxm/pukbot";
const WORKFLOW_FILE: &str = "operation.yml";
const WORKFLOW_REF: &str = "main";
const RUN_LOOKUP_ATTEMPTS: usize = 30;

#[derive(Debug, Serialize)]
struct WorkflowInputs<'a> {
    payload: &'a str,
    requester: &'a str,
    request_id: &'a str,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WorkflowRun {
    database_id: u64,
    display_title: String,
    conclusion: String,
    url: String,
}

pub struct WorkflowResult {
    pub workflow_url: String,
    pub resource_url: Option<String>,
}

pub fn dispatch(operation: &Operation, stream: bool) -> Result<WorkflowResult> {
    let requester = github_login()?;
    let request_id = request_id()?;
    let payload = serde_json::to_string(operation).context("failed to encode operation")?;
    let inputs = WorkflowInputs {
        payload: &payload,
        requester: &requester,
        request_id: &request_id,
    };
    let mut dispatch = Command::new("gh");
    dispatch.args([
        "workflow",
        "run",
        WORKFLOW_FILE,
        "--repo",
        WORKFLOW_REPOSITORY,
        "--ref",
        WORKFLOW_REF,
        "--json",
    ]);
    if !stream {
        dispatch.stdout(Stdio::null()).stderr(Stdio::null());
    }
    let mut child = dispatch
        .stdin(Stdio::piped())
        .spawn()
        .context("failed to launch gh; install and authenticate GitHub CLI")?;
    let stdin = child.stdin.take().context("failed to open gh input")?;
    serde_json::to_writer(stdin, &inputs).context("failed to encode workflow input")?;
    let status = child.wait().context("failed to wait for gh")?;
    if !status.success() {
        bail!("GitHub rejected the Pukbot workflow dispatch");
    }
    let run = find_run(&request_id)?;
    let mut watch = Command::new("gh");
    watch.args([
        "run",
        "watch",
        &run.database_id.to_string(),
        "--repo",
        WORKFLOW_REPOSITORY,
        "--exit-status",
    ]);
    if !stream {
        watch.stdout(Stdio::null()).stderr(Stdio::null());
    }
    let status = watch
        .status()
        .context("failed to watch the Pukbot workflow run")?;
    let completed = inspect_run(run.database_id)?;
    if !status.success() || completed.conclusion != "success" {
        show_failed_logs(completed.database_id);
        bail!("Pukbot workflow failed: {}", completed.url);
    }
    Ok(WorkflowResult {
        workflow_url: completed.url,
        resource_url: find_result_url(completed.database_id)?,
    })
}

fn github_login() -> Result<String> {
    let output = Command::new("gh")
        .args(["api", "user", "--jq", ".login"])
        .output()
        .context("failed to launch gh; install and authenticate GitHub CLI")?;
    if !output.status.success() {
        bail!("failed to resolve the authenticated GitHub user");
    }
    let login = String::from_utf8(output.stdout)
        .context("GitHub CLI returned a non-UTF-8 login")?
        .trim()
        .to_owned();
    if login.is_empty()
        || !login
            .chars()
            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
    {
        bail!("GitHub CLI returned an invalid login");
    }
    Ok(login)
}

fn request_id() -> Result<String> {
    let elapsed = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .context("system clock is before the Unix epoch")?;
    Ok(format!("{}-{}", elapsed.as_nanos(), std::process::id()))
}

fn find_run(request_id: &str) -> Result<WorkflowRun> {
    let expected_title = format!("Pukbot operation {request_id}");
    for _ in 0..RUN_LOOKUP_ATTEMPTS {
        let output = Command::new("gh")
            .args([
                "run",
                "list",
                "--repo",
                WORKFLOW_REPOSITORY,
                "--workflow",
                WORKFLOW_FILE,
                "--event",
                "workflow_dispatch",
                "--limit",
                "30",
                "--json",
                "databaseId,displayTitle,conclusion,url",
            ])
            .output()
            .context("failed to list Pukbot workflow runs")?;
        if !output.status.success() {
            bail!("failed to list Pukbot workflow runs");
        }
        let runs = serde_json::from_slice::<Vec<WorkflowRun>>(&output.stdout)
            .context("failed to decode Pukbot workflow runs")?;
        if let Some(run) = runs
            .into_iter()
            .find(|run| run.display_title == expected_title)
        {
            return Ok(run);
        }
        thread::sleep(Duration::from_secs(1));
    }
    bail!("could not find the dispatched Pukbot workflow run")
}

fn inspect_run(run_id: u64) -> Result<WorkflowRun> {
    let output = Command::new("gh")
        .args([
            "run",
            "view",
            &run_id.to_string(),
            "--repo",
            WORKFLOW_REPOSITORY,
            "--json",
            "databaseId,displayTitle,conclusion,url",
        ])
        .output()
        .context("failed to inspect the Pukbot workflow run")?;
    if !output.status.success() {
        bail!("failed to inspect the Pukbot workflow run");
    }
    serde_json::from_slice(&output.stdout).context("failed to decode the Pukbot workflow run")
}

fn show_failed_logs(run_id: u64) {
    drop(
        Command::new("gh")
            .args([
                "run",
                "view",
                &run_id.to_string(),
                "--repo",
                WORKFLOW_REPOSITORY,
                "--log-failed",
            ])
            .status(),
    );
}

fn find_result_url(run_id: u64) -> Result<Option<String>> {
    let output = Command::new("gh")
        .args([
            "run",
            "view",
            &run_id.to_string(),
            "--repo",
            WORKFLOW_REPOSITORY,
            "--log",
        ])
        .output()
        .context("failed to read Pukbot workflow logs")?;
    if !output.status.success() {
        return Ok(None);
    }
    let logs = String::from_utf8(output.stdout).context("workflow logs were not UTF-8")?;
    Ok(parse_result_url(&logs))
}

fn parse_result_url(logs: &str) -> Option<String> {
    logs.lines().find_map(|line| {
        line.split_once("pukbot-result-url=")
            .map(|(_, url)| url.trim())
            .filter(|url| url.starts_with("https://github.com/"))
            .map(str::to_owned)
    })
}

#[cfg(test)]
mod tests {
    use super::parse_result_url;

    #[test]
    fn parses_result_url() {
        let logs = "step pukbot-result-url=https://github.com/owner/repo/issues/1";
        assert_eq!(
            parse_result_url(logs),
            Some("https://github.com/owner/repo/issues/1".to_owned())
        );
    }
}