sdd-layer 0.17.0

Spec-Driven Development CLI and agent harness
//! Gate de bloqueio do orquestrador (TASK-09 — AD-5, CP-2).
//!
//! Impede que `sdd orchestrator <CARD-ID>` avance quando o último comentário
//! Jira do card for exatamente `/bloqueado` (case-sensitive, trim apenas).
//!
//! Responsabilidades:
//!   A) `is_blocked_marker` — lógica pura, sem I/O.
//!   B) `ensure_not_blocked` — integração com config + HTTP ao Jira.

use anyhow::Result;
use serde::Deserialize;
use std::path::Path;

// ─── A. Lógica pura ──────────────────────────────────────────────────────────

/// Retorna `true` se o texto for exatamente `"/bloqueado"` após trim.
/// Case-sensitive (AD-5, confirmado CP-2).
pub fn is_blocked_marker(text: Option<&str>) -> bool {
    match text {
        Some(t) => t.trim() == "/bloqueado",
        None => false,
    }
}

// ─── B. Config + integração HTTP ─────────────────────────────────────────────

/// Configuração Jira extraída de `.sdd/bot/sdd-bot.config.yaml`.
#[derive(Debug, Deserialize)]
struct BotConfig {
    jira: Option<JiraCfg>,
}

#[derive(Debug, Deserialize, Clone)]
pub(crate) struct JiraCfg {
    /// URL base do workspace, ex: `https://<workspace>.atlassian.net`
    pub base_url: String,
    /// Token de API Jira (gerado em id.atlassian.com).
    pub api_token: String,
    /// E-mail do usuário Jira associado ao token.
    pub user: String,
}

/// Carrega a config Jira de `.sdd/bot/sdd-bot.config.yaml`.
/// Retorna `None` se o arquivo não existir ou não tiver bloco `jira`.
fn load_jira_config(root: &Path) -> Option<JiraCfg> {
    let path = root.join(".sdd").join("bot").join("sdd-bot.config.yaml");
    let content = std::fs::read_to_string(&path).ok()?;
    let cfg: BotConfig = serde_yaml::from_str(&content).ok()?;
    cfg.jira
}

/// Busca o último comentário do card via API Jira REST v3.
/// Retorna `Ok(None)` se o card não tiver comentários.
fn fetch_last_comment(jira: &JiraCfg, card_id: &str) -> Result<Option<String>> {
    let url = format!(
        "{}/rest/api/3/issue/{}/comment?orderBy=-created&maxResults=1",
        jira.base_url.trim_end_matches('/'),
        card_id
    );

    let client = reqwest::blocking::Client::new();
    let resp = client
        .get(&url)
        .basic_auth(&jira.user, Some(&jira.api_token))
        .header("Accept", "application/json")
        .send()?;

    if !resp.status().is_success() {
        let status = resp.status();
        let body = resp.text().unwrap_or_default();
        anyhow::bail!(
            "Jira API retornou status {} ao buscar comentários de {}: {}",
            status,
            card_id,
            sanitize_error_body(&body)
        );
    }

    let json: serde_json::Value = resp.json()?;

    // Estrutura: { "comments": [ { "body": { "content": [...] } } ] }
    // O campo body do Jira API v3 é Atlassian Document Format (ADF).
    // Extraímos o texto plano percorrendo os nós de conteúdo.
    let comments = json.get("comments").and_then(|c| c.as_array());
    let first = match comments.and_then(|arr| arr.first()) {
        Some(c) => c,
        None => return Ok(None),
    };

    let text = extract_adf_text(first.get("body"));
    Ok(Some(text))
}

/// Sanitiza o body de erro HTTP para evitar vazamento de credenciais em logs.
/// Trunca a 200 chars e remove possíveis prefixos Basic auth.
fn sanitize_error_body(body: &str) -> String {
    let truncated = if body.len() > 200 { &body[..200] } else { body };
    // Basic auth não deveria aparecer em um body de resposta, mas por precaução:
    truncated.replace("Basic ", "Basic [REDACTED] ")
}

/// Extrai texto plano de um nó ADF (Atlassian Document Format) de forma recursiva.
fn extract_adf_text(node: Option<&serde_json::Value>) -> String {
    let node = match node {
        Some(n) => n,
        None => return String::new(),
    };

    // Nó de texto simples
    if let Some(text) = node.get("text").and_then(|t| t.as_str()) {
        return text.to_owned();
    }

    // Nó composto: percorre `content`
    if let Some(children) = node.get("content").and_then(|c| c.as_array()) {
        return children
            .iter()
            .map(|child| extract_adf_text(Some(child)))
            .collect::<Vec<_>>()
            .join("");
    }

    String::new()
}

// ─── C. Gate público ─────────────────────────────────────────────────────────

/// Verifica se o card está bloqueado consultando o último comentário Jira.
///
/// - Sem config Jira: gate inativo — retorna `Ok(())` sem bloquear.
/// - Com config: busca o último comentário; se for `/bloqueado`, retorna erro.
pub fn ensure_not_blocked(root: &Path, card_id: &str) -> Result<()> {
    let Some(jira_cfg) = load_jira_config(root) else {
        // Sem config Jira: gate inativo — não bloqueia.
        return Ok(());
    };

    let last = fetch_last_comment(&jira_cfg, card_id)?;
    if is_blocked_marker(last.as_deref()) {
        anyhow::bail!(
            "card {} está bloqueado (último comentário Jira: /bloqueado). \
             Remova o bloqueio antes de orquestrar.",
            card_id
        );
    }
    Ok(())
}

// ─── Unit tests ───────────────────────────────────────────────────────────────

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

    #[test]
    fn bloqueado_minusculo_bloqueia() {
        assert!(is_blocked_marker(Some("/bloqueado")));
    }

    #[test]
    fn bloqueado_maiusculo_nao_bloqueia() {
        assert!(!is_blocked_marker(Some("/BLOQUEADO")));
    }

    #[test]
    fn bloqueado_mixed_case_nao_bloqueia() {
        assert!(!is_blocked_marker(Some("/BloquEado")));
    }

    #[test]
    fn bloqueado_com_espacos_bloqueia() {
        assert!(is_blocked_marker(Some("  /bloqueado  ")));
    }

    #[test]
    fn aprovado_nao_bloqueia() {
        assert!(!is_blocked_marker(Some("/aprovado")));
    }

    #[test]
    fn none_nao_bloqueia() {
        assert!(!is_blocked_marker(None));
    }

    #[test]
    fn vazio_nao_bloqueia() {
        assert!(!is_blocked_marker(Some("")));
    }

    #[test]
    fn texto_parcial_nao_bloqueia() {
        assert!(!is_blocked_marker(Some("card bloqueado")));
    }

    #[test]
    fn sem_config_jira_nao_bloqueia() {
        let tmp = std::env::temp_dir().join("sdd_test_no_config_block_gate");
        std::fs::create_dir_all(&tmp).unwrap();
        // Diretório sem .sdd/bot/sdd-bot.config.yaml → gate inativo
        let result = super::ensure_not_blocked(&tmp, "PROJ-1");
        assert!(
            result.is_ok(),
            "Esperado Ok(()), obtido: {:?}",
            result.err()
        );
        std::fs::remove_dir_all(&tmp).ok();
    }
}