loopflow 0.10.0

Run steps and flows with coding agents
Documentation
use std::path::Path;
use std::process::Command;

use hmac::{Hmac, Mac};
use reqwest::header::{ACCEPT, USER_AGENT};
use reqwest::Method;
use serde::Deserialize;
use sha2::Sha256;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;

use crate::lfd::http_client::SafeHttpClient;
use crate::lfd::types::{LivePrState, LivePullRequestState};

type HmacSha256 = Hmac<Sha256>;

#[derive(Debug, Clone, Deserialize)]
pub struct GitHubCheckRunEvent {
    pub action: String,
    pub check_run: CheckRun,
    pub repository: GitHubRepository,
}

#[derive(Debug, Clone, Deserialize)]
pub struct GitHubPullRequestEvent {
    pub action: String,
    pub pull_request: GitHubWebhookPullRequest,
    pub repository: GitHubRepository,
}

#[derive(Debug, Clone, Deserialize)]
pub struct GitHubPushEvent {
    #[serde(rename = "ref")]
    pub git_ref: String,
    pub before: String,
    pub after: String,
    pub repository: GitHubRepository,
}

#[derive(Debug, Clone, Deserialize)]
pub struct GitHubWebhookPullRequest {
    pub number: u32,
    #[serde(default)]
    pub merged: bool,
    pub merged_at: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct CheckRun {
    pub id: u64,
    pub name: String,
    pub head_sha: String,
    pub status: String,
    pub conclusion: Option<String>,
    pub pull_requests: Vec<CheckRunPR>,
    pub html_url: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct CheckRunPR {
    pub number: u32,
    pub head: CheckRunRef,
}

#[derive(Debug, Clone, Deserialize)]
pub struct CheckRunRef {
    #[serde(rename = "ref")]
    pub branch: String,
    pub sha: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct GitHubRepository {
    pub full_name: String,
}

#[derive(Debug, Deserialize)]
struct CheckRunsResponse {
    check_runs: Vec<CheckRun>,
}

#[derive(Debug, Clone)]
pub struct GitHubPullRequestState {
    pub number: u32,
    pub state: LivePrState,
    pub is_draft: bool,
    pub head_ref: String,
    pub head_sha: String,
    pub base_ref: String,
    pub updated_at: OffsetDateTime,
    pub merged_at: Option<OffsetDateTime>,
}

pub fn into_live_pull_request_state(
    repo_id: String,
    pull_request: GitHubPullRequestState,
    synced_at: OffsetDateTime,
) -> LivePullRequestState {
    LivePullRequestState {
        repo_id,
        pr_number: pull_request.number,
        state: pull_request.state,
        is_draft: pull_request.is_draft,
        head_ref: pull_request.head_ref,
        head_sha: pull_request.head_sha,
        base_ref: pull_request.base_ref,
        updated_at: pull_request.updated_at,
        merged_at: pull_request.merged_at,
        synced_at,
    }
}

#[derive(Debug, Deserialize)]
struct PullRequestResponse {
    number: u32,
    state: String,
    #[serde(default)]
    draft: bool,
    head: PullRequestRef,
    base: PullRequestRef,
    updated_at: String,
    merged_at: Option<String>,
}

#[derive(Debug, Deserialize)]
struct PullRequestRef {
    #[serde(rename = "ref")]
    branch: String,
    sha: String,
}

pub fn verify_webhook_signature(secret: &str, body: &[u8], signature_header: &str) -> bool {
    if secret.trim().is_empty() {
        return false;
    }

    let Some(signature) = signature_header.trim().strip_prefix("sha256=") else {
        return false;
    };
    let Ok(signature) = hex::decode(signature) else {
        return false;
    };

    let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
        return false;
    };
    mac.update(body);
    mac.verify_slice(&signature).is_ok()
}

pub fn github_repo_from_local(repo_path: &Path) -> Option<String> {
    let output = Command::new("git")
        .arg("-C")
        .arg(repo_path)
        .args(["config", "--get", "remote.origin.url"])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let remote = String::from_utf8_lossy(&output.stdout).trim().to_string();
    github_repo_from_remote_url(&remote)
}

pub fn github_repo_from_remote_url(remote: &str) -> Option<String> {
    let mut value = remote.trim().trim_end_matches('/').to_string();
    if value.is_empty() {
        return None;
    }
    if let Some(stripped) = value.strip_suffix(".git") {
        value = stripped.to_string();
    }

    if let Some(rest) = value.strip_prefix("git@") {
        let (host, path) = rest.split_once(':')?;
        if !host.eq_ignore_ascii_case("github.com") {
            return None;
        }
        return owner_repo_from_path(path);
    }

    if let Some((_scheme, rest)) = value.split_once("://") {
        let (host, path) = rest.split_once('/')?;
        let host = host.rsplit('@').next().unwrap_or(host);
        if !host.eq_ignore_ascii_case("github.com") {
            return None;
        }
        return owner_repo_from_path(path);
    }

    None
}

fn owner_repo_from_path(path: &str) -> Option<String> {
    let mut parts = path.split('/').filter(|part| !part.trim().is_empty());
    let owner = parts.next()?.trim();
    let repo = parts.next()?.trim().trim_end_matches(".git");
    if owner.is_empty() || repo.is_empty() {
        return None;
    }
    Some(format!("{owner}/{repo}"))
}

pub async fn poll_check_runs(
    repo_full_name: &str,
    branch: &str,
    token: &str,
) -> Result<Vec<CheckRun>, String> {
    let branch = branch.replace('/', "%2F");
    let url = format!("https://api.github.com/repos/{repo_full_name}/commits/{branch}/check-runs");
    let response = require_github_success(github_get(&url, token).await?).await?;

    response
        .json::<CheckRunsResponse>()
        .await
        .map(|data| data.check_runs)
        .map_err(|err| err.to_string())
}

pub async fn fetch_pull_request(
    repo_full_name: &str,
    pr_number: u32,
    token: &str,
) -> Result<Option<GitHubPullRequestState>, String> {
    let url = format!("https://api.github.com/repos/{repo_full_name}/pulls/{pr_number}");
    let response = github_get(&url, token).await?;

    if response.status() == reqwest::StatusCode::NOT_FOUND {
        return Ok(None);
    }

    let response = require_github_success(response).await?;

    let payload = response
        .json::<PullRequestResponse>()
        .await
        .map_err(|err| err.to_string())?;

    let updated_at = OffsetDateTime::parse(&payload.updated_at, &Rfc3339)
        .map_err(|err| format!("invalid updated_at timestamp: {err}"))?;
    let merged_at = payload
        .merged_at
        .as_deref()
        .map(|value| OffsetDateTime::parse(value, &Rfc3339))
        .transpose()
        .map_err(|err| format!("invalid merged_at timestamp: {err}"))?;

    let state = if merged_at.is_some() {
        LivePrState::Merged
    } else if payload.state.eq_ignore_ascii_case("open") {
        LivePrState::Open
    } else if payload.state.eq_ignore_ascii_case("closed") {
        LivePrState::Closed
    } else {
        LivePrState::Unknown
    };

    Ok(Some(GitHubPullRequestState {
        number: payload.number,
        state,
        is_draft: payload.draft,
        head_ref: payload.head.branch,
        head_sha: payload.head.sha,
        base_ref: payload.base.branch,
        updated_at,
        merged_at,
    }))
}

async fn github_get(url: &str, token: &str) -> Result<reqwest::Response, String> {
    let client = SafeHttpClient::new().map_err(|err| err.to_string())?;
    client
        .send(
            client
                .request(Method::GET, url)
                .map_err(|err| err.to_string())?
                .header(ACCEPT, "application/vnd.github+json")
                .header("X-GitHub-Api-Version", "2022-11-28")
                .header(USER_AGENT, "loopflow-lfd")
                .bearer_auth(token),
        )
        .await
        .map_err(|err| err.to_string())
}

async fn require_github_success(response: reqwest::Response) -> Result<reqwest::Response, String> {
    if response.status().is_success() {
        return Ok(response);
    }
    let status = response.status();
    let body = response.text().await.unwrap_or_default();
    Err(format!("github API error {status}: {body}"))
}

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

    #[test]
    fn verify_webhook_signature_accepts_valid_signature() {
        let secret = "topsecret";
        let body = br#"{"hello":"world"}"#;
        let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("mac");
        mac.update(body);
        let signature = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));

        assert!(verify_webhook_signature(secret, body, &signature));
    }

    #[test]
    fn verify_webhook_signature_rejects_invalid_signature() {
        assert!(!verify_webhook_signature(
            "secret",
            b"{}",
            "sha256=deadbeef"
        ));
        assert!(!verify_webhook_signature("secret", b"{}", "bad-format"));
    }

    #[test]
    fn github_repo_from_remote_url_parses_common_formats() {
        assert_eq!(
            github_repo_from_remote_url("git@github.com:loopflowstudio/loopflow.git"),
            Some("loopflowstudio/loopflow".to_string())
        );
        assert_eq!(
            github_repo_from_remote_url("https://github.com/loopflowstudio/loopflow"),
            Some("loopflowstudio/loopflow".to_string())
        );
        assert_eq!(
            github_repo_from_remote_url("ssh://git@github.com/LoopflowStudio/Loopflow.git"),
            Some("LoopflowStudio/Loopflow".to_string())
        );
    }
}