use anyhow::Result;
use serde::Deserialize;
use std::path::Path;
pub fn is_blocked_marker(text: Option<&str>) -> bool {
match text {
Some(t) => t.trim() == "/bloqueado",
None => false,
}
}
#[derive(Debug, Deserialize)]
struct BotConfig {
jira: Option<JiraCfg>,
}
#[derive(Debug, Deserialize, Clone)]
pub(crate) struct JiraCfg {
pub base_url: String,
pub api_token: String,
pub user: String,
}
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
}
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()?;
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))
}
fn sanitize_error_body(body: &str) -> String {
let truncated = if body.len() > 200 { &body[..200] } else { body };
truncated.replace("Basic ", "Basic [REDACTED] ")
}
fn extract_adf_text(node: Option<&serde_json::Value>) -> String {
let node = match node {
Some(n) => n,
None => return String::new(),
};
if let Some(text) = node.get("text").and_then(|t| t.as_str()) {
return text.to_owned();
}
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()
}
pub fn ensure_not_blocked(root: &Path, card_id: &str) -> Result<()> {
let Some(jira_cfg) = load_jira_config(root) else {
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(())
}
#[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();
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();
}
}