cora-code 0.17.1

CLI-first AI code review — BYOK, diff/scan/branch, pre-commit hooks
use std::io::Read;

use anyhow::{Context, Result, bail};
use colored::Colorize;
use serde_json::json;

/// Upload options for the upload-sarif subcommand.
pub struct UploadOptions {
    /// Path to SARIF file to upload (None = read from stdin).
    pub file: Option<String>,
    /// GitHub repository in "owner/repo" format.
    pub repo: Option<String>,
    /// Git ref name for the upload.
    pub ref_name: Option<String>,
    /// GitHub token for authentication.
    pub token: Option<String>,
}

/// Upload a SARIF file to GitHub Code Scanning.
///
/// Reads the SARIF content from the specified file or stdin, then POSTs it
/// to the GitHub Code Scanning API.
#[allow(clippy::items_after_statements, clippy::cast_precision_loss)]
pub async fn execute_upload(opts: &UploadOptions) -> Result<i32> {
    let token = opts
        .token
        .as_deref()
        .context("GITHUB_TOKEN is required (set env var or pass --token)")?
        .to_string();

    let (owner, repo) = resolve_repo(opts.repo.as_deref())?;
    let ref_name = resolve_ref(opts.ref_name.as_deref())?;

    tracing::debug!(
        owner = %owner,
        repo = %repo,
        ref_name = %ref_name,
        "uploading SARIF to GitHub Code Scanning"
    );

    // Read SARIF content
    let sarif_content = read_sarif(opts.file.as_deref())?;

    // Validate file size (GitHub limits SARIF uploads to 10MB)
    const MAX_SARIF_SIZE: usize = 10 * 1024 * 1024; // 10MB
    if sarif_content.len() > MAX_SARIF_SIZE {
        let size_mb = sarif_content.len() as f64 / (1024.0 * 1024.0);
        bail!(
            "SARIF file is {} bytes ({:.2}MB), exceeding GitHub's 10MB limit. Consider reviewing a smaller diff.",
            sarif_content.len(),
            size_mb
        );
    }

    // Validate it's valid JSON (basic sanity check)
    let parsed: serde_json::Value =
        serde_json::from_str(&sarif_content).context("SARIF file does not contain valid JSON")?;

    // Ensure it has the $schema field (basic SARIF validation)
    if parsed.get("$schema").is_none() {
        bail!("File does not appear to be a valid SARIF document (missing $schema)");
    }

    println!(
        "{} Uploading SARIF to {}/{} (ref: {})",
        "→".cyan(),
        owner,
        repo,
        ref_name
    );

    // Build the upload request
    let url = format!("https://api.github.com/repos/{owner}/{repo}/code-scanning/sarifs");

    let client = reqwest::Client::new();
    let response = client
        .post(&url)
        .header("Accept", "application/vnd.github+json")
        .header("X-GitHub-Api-Version", "2022-11-28")
        .header("Authorization", format!("Bearer {token}"))
        .json(&json!({ "commit_sha": ref_name, "sarif": &sarif_content }))
        .send()
        .await
        .context("Failed to send request to GitHub API")?;

    let status = response.status();
    let body = response.text().await.unwrap_or_default();

    if status.is_success() {
        println!(
            "{} SARIF uploaded successfully to GitHub Code Scanning.",
            "✓".green()
        );

        // Parse the response for additional info
        if let Ok(resp_json) = serde_json::from_str::<serde_json::Value>(&body) {
            if let Some(id) = resp_json.get("id").and_then(|v| v.as_str()) {
                println!("   Analysis ID: {id}");
            }
        }

        Ok(0)
    } else {
        // Try to extract a useful error message
        let error_msg = serde_json::from_str::<serde_json::Value>(&body)
            .ok()
            .and_then(|v| {
                v.get("message")
                    .or_else(|| v.get("errors")?.get(0)?.get("message"))
                    .and_then(|m| m.as_str())
                    .map(std::string::ToString::to_string)
            })
            .unwrap_or_else(|| body.clone());

        eprintln!(
            "{} Upload failed (HTTP {}): {}",
            "✗".red(),
            status,
            error_msg
        );

        // Common error hints
        let hint = match status.as_u16() {
            401 => Some("Check that your GITHUB_TOKEN is valid and not expired."),
            403 => Some(
                "The token may lack security_events write permission. Use a token with the 'security_events' scope.",
            ),
            404 => Some("Check the repository name and that you have access to it."),
            422 => Some("The SARIF file may be invalid or exceed size limits (max 10MB)."),
            429 => Some("Rate limited. Wait a moment and try again."),
            _ => None,
        };

        if let Some(h) = hint {
            eprintln!("   {}", h.yellow());
        }

        Ok(1)
    }
}

/// Read SARIF content from a file or stdin.
fn read_sarif(file: Option<&str>) -> Result<String> {
    if let Some(path) = file {
        std::fs::read_to_string(path).with_context(|| format!("Failed to read SARIF file: {path}"))
    } else {
        eprintln!(
            "{} Reading SARIF from stdin (Ctrl+D to finish)...",
            "ℹ".blue()
        );
        let mut content = String::new();
        std::io::stdin()
            .read_to_string(&mut content)
            .context("Failed to read from stdin")?;
        Ok(content)
    }
}

/// Resolve the GitHub repository from CLI flag, env var, or git remote.
fn resolve_repo(cli_repo: Option<&str>) -> Result<(String, String)> {
    if let Some(repo) = cli_repo {
        let parts: Vec<&str> = repo.split('/').collect();
        if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
            bail!("Invalid repository format '{repo}'. Expected 'owner/repo'.");
        }
        return Ok((parts[0].to_string(), parts[1].to_string()));
    }

    // Try to detect from git remote
    match crate::git::get_repo_info() {
        Ok((owner, repo_name, _branch)) => {
            if owner == "unknown" || repo_name == "unknown" {
                bail!(
                    "Could not detect repository from git remote. \
                     Set GITHUB_REPOSITORY or pass --repo owner/repo."
                );
            }
            Ok((owner, repo_name))
        }
        Err(_) => {
            bail!(
                "Could not detect repository. Set GITHUB_REPOSITORY env var or pass --repo owner/repo."
            );
        }
    }
}

/// Resolve the git ref name from CLI flag, env var, or current HEAD.
fn resolve_ref(cli_ref: Option<&str>) -> Result<String> {
    if let Some(r) = cli_ref {
        return Ok(r.to_string());
    }

    // Try to get from git
    if let Ok(branch) = crate::git::get_current_branch() {
        Ok(branch)
    } else {
        // Try to get the current HEAD commit SHA as fallback
        let output = std::process::Command::new("git")
            .args(["rev-parse", "HEAD"])
            .output();

        match output {
            Ok(out) if out.status.success() => {
                let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
                Ok(sha)
            }
            _ => {
                bail!(
                    "Could not detect current git ref. Pass --ref-name or run inside a git repo."
                );
            }
        }
    }
}