use anyhow::{Context, Result};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const BLOCK_GATE_HTTP_TIMEOUT: Duration = Duration::from_secs(10);
pub fn is_blocked_marker(text: Option<&str>) -> bool {
match text {
Some(t) => t.trim() == "/bloqueado",
None => false,
}
}
#[derive(Debug, Deserialize)]
struct BotConfig {
integrations: Option<BotIntegrations>,
jira: Option<JiraCfg>,
github: Option<GitHubCfg>,
}
#[derive(Debug, Deserialize)]
struct BotIntegrations {
work_tracker: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub(crate) struct JiraCfg {
pub base_url: String,
pub api_token: String,
pub user: String,
}
#[derive(Debug, Deserialize, Clone)]
pub(crate) struct GitHubCfg {
pub owner: String,
pub repo: String,
pub auth: Option<GitHubAuthCfg>,
}
#[derive(Debug, Deserialize, Clone)]
pub(crate) struct GitHubAuthCfg {
pub token: Option<String>,
pub token_env: Option<String>,
pub app_id: Option<String>,
pub installation_id: Option<String>,
pub private_key: Option<String>,
pub private_key_env: Option<String>,
pub private_key_path: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GitHubInstallationTokenResponse {
token: String,
}
#[derive(Debug, Serialize)]
struct GitHubAppClaims {
iat: u64,
exp: u64,
iss: String,
}
fn load_bot_config(root: &Path) -> Option<BotConfig> {
let path = root.join(".sdd").join("bot").join("sdd-bot.config.yaml");
let content = std::fs::read_to_string(&path).ok()?;
serde_yaml::from_str(&content).ok()
}
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 = block_gate_http_client()?;
let resp = send_read_request(
client
.get(&url)
.basic_auth(&jira.user, Some(&jira.api_token))
.header("Accept", "application/json"),
)?;
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,
http_error_kind(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 fetch_github_last_comment(github: &GitHubCfg, issue_number: u64) -> Result<Option<String>> {
let url = format!(
"https://api.github.com/repos/{}/{}/issues/{}/comments?per_page=100",
github.owner, github.repo, issue_number
);
let token = github_token(github)?;
let client = block_gate_http_client()?;
let (first_page, last_url) = fetch_github_comments_page(&client, &url, &token, issue_number)?;
let json = if let Some(last_url) = last_url {
fetch_github_comments_page(&client, &last_url, &token, issue_number)?.0
} else {
first_page
};
let last = json.as_array().and_then(|items| items.last());
Ok(last
.and_then(|comment| comment.get("body"))
.and_then(|body| body.as_str())
.map(ToOwned::to_owned))
}
fn fetch_github_comments_page(
client: &reqwest::blocking::Client,
url: &str,
token: &str,
issue_number: u64,
) -> Result<(serde_json::Value, Option<String>)> {
let resp = send_read_request(
client
.get(url)
.bearer_auth(token)
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2026-03-10")
.header("User-Agent", "sdd-layer"),
)?;
let last_url = resp
.headers()
.get(reqwest::header::LINK)
.and_then(|value| value.to_str().ok())
.and_then(github_last_link_url);
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().unwrap_or_default();
anyhow::bail!(
"GitHub API retornou status {} ({}) ao buscar comentários da issue #{}: {}",
status,
http_error_kind(status),
issue_number,
sanitize_error_body(&body)
);
}
Ok((resp.json()?, last_url))
}
fn github_last_link_url(link: &str) -> Option<String> {
link.split(',').find_map(|raw_part| {
let part = raw_part.trim();
let has_last_rel = part.split(';').any(|segment| {
let segment = segment.trim();
segment == "rel=\"last\"" || segment == "rel=last"
});
if !has_last_rel {
return None;
}
let start = part.find('<')? + 1;
let end = part[start..].find('>')? + start;
Some(part[start..end].to_owned())
})
}
fn github_token(github: &GitHubCfg) -> Result<String> {
let auth = github.auth.as_ref();
if let Some(token) = auth.and_then(|a| a.token.as_ref()) {
if !token.trim().is_empty() {
return Ok(token.clone());
}
}
let env_name = auth
.and_then(|a| a.token_env.as_deref())
.unwrap_or("GITHUB_TOKEN");
if let Ok(token) = std::env::var(env_name).or_else(|_| std::env::var("GH_TOKEN")) {
if !token.trim().is_empty() {
return Ok(token);
}
}
if auth.map(has_github_app_auth).unwrap_or(false) {
return github_installation_token(github);
}
Err(anyhow::anyhow!(
"token GitHub ausente: defina {env_name} ou GH_TOKEN, ou configure GitHub App"
))
}
fn has_github_app_auth(auth: &GitHubAuthCfg) -> bool {
auth.app_id
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
&& auth
.installation_id
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
&& (auth
.private_key
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
|| auth
.private_key_env
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
|| auth
.private_key_path
.as_deref()
.is_some_and(|value| !value.trim().is_empty()))
}
fn github_installation_token(github: &GitHubCfg) -> Result<String> {
let auth = github
.auth
.as_ref()
.ok_or_else(|| anyhow::anyhow!("GitHub App auth ausente"))?;
let installation_id = required_auth_value(auth.installation_id.as_deref(), "installation_id")?;
let jwt = github_app_jwt(auth)?;
let url = format!(
"https://api.github.com/app/installations/{}/access_tokens",
installation_id
);
let resp = block_gate_http_client()?
.post(&url)
.bearer_auth(jwt)
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2026-03-10")
.header("User-Agent", "sdd-layer")
.send()?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().unwrap_or_default();
anyhow::bail!(
"GitHub App retornou status {} ({}) ao gerar installation token: {}",
status,
http_error_kind(status),
sanitize_error_body(&body)
);
}
let body: GitHubInstallationTokenResponse = resp.json()?;
Ok(body.token)
}
fn github_app_jwt(auth: &GitHubAuthCfg) -> Result<String> {
let app_id = required_auth_value(auth.app_id.as_deref(), "app_id")?;
let private_key = github_private_key(auth)?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("relógio do sistema antes do UNIX_EPOCH")?
.as_secs();
let claims = GitHubAppClaims {
iat: now.saturating_sub(60),
exp: now + 540,
iss: app_id.to_string(),
};
let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
header.typ = Some("JWT".to_string());
jsonwebtoken::encode(
&header,
&claims,
&jsonwebtoken::EncodingKey::from_rsa_pem(private_key.as_bytes())
.context("GitHub App private key inválida para RS256")?,
)
.context("falha ao assinar JWT do GitHub App")
}
fn github_private_key(auth: &GitHubAuthCfg) -> Result<String> {
let raw = auth
.private_key
.clone()
.or_else(|| {
auth.private_key_env
.as_ref()
.and_then(|name| std::env::var(name).ok())
})
.or_else(|| {
auth.private_key_path
.as_ref()
.and_then(|path| std::fs::read_to_string(path).ok())
})
.ok_or_else(|| anyhow::anyhow!("GitHub App private key ausente"))?;
Ok(raw.replace("\\n", "\n"))
}
fn required_auth_value<'a>(value: Option<&'a str>, field: &str) -> Result<&'a str> {
value
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!("GitHub App auth sem {field}"))
}
fn block_gate_http_client() -> Result<reqwest::blocking::Client> {
reqwest::blocking::Client::builder()
.timeout(BLOCK_GATE_HTTP_TIMEOUT)
.build()
.context("falha ao criar HTTP client do block gate")
}
fn send_read_request(
request: reqwest::blocking::RequestBuilder,
) -> Result<reqwest::blocking::Response> {
let max_attempts = 2;
for attempt in 0..max_attempts {
let cloned = request
.try_clone()
.ok_or_else(|| anyhow::anyhow!("HTTP request do block gate não pode ser clonada"))?;
match cloned.send() {
Ok(response)
if attempt + 1 < max_attempts && should_retry_http_status(response.status()) =>
{
std::thread::sleep(Duration::from_millis(200));
}
Ok(response) => return Ok(response),
Err(error)
if attempt + 1 < max_attempts && (error.is_timeout() || error.is_connect()) =>
{
std::thread::sleep(Duration::from_millis(200));
}
Err(error) => return Err(error).context("falha HTTP no block gate"),
}
}
unreachable!("loop de retry do block gate sempre retorna")
}
fn should_retry_http_status(status: StatusCode) -> bool {
status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
}
fn http_error_kind(status: StatusCode) -> &'static str {
match status.as_u16() {
401 | 403 => "auth_forbidden",
404 => "not_found",
429 => "rate_limited",
500..=599 => "server_error",
_ => "http_error",
}
}
fn sanitize_error_body(body: &str) -> String {
let redacted = redact_token_like(&body.replace("Basic ", "Basic [REDACTED] "));
redacted.chars().take(200).collect()
}
fn redact_token_like(body: &str) -> String {
let mut out = String::with_capacity(body.len());
let mut index = 0;
while index < body.len() {
let rest = &body[index..];
let matched = ["ghp_", "gho_", "ghu_", "ghs_", "ghr_", "github_pat_"]
.into_iter()
.find(|prefix| rest.starts_with(prefix));
if let Some(prefix) = matched {
out.push_str(prefix);
out.push_str("[REDACTED]");
index += prefix.len();
while index < body.len() {
let ch = body[index..].chars().next().unwrap();
if ch.is_ascii_alphanumeric() || ch == '_' {
index += ch.len_utf8();
} else {
break;
}
}
} else {
let ch = rest.chars().next().unwrap();
out.push(ch);
index += ch.len_utf8();
}
}
out
}
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 parse_github_issue_number(card_id: &str) -> Option<u64> {
let trimmed = card_id.trim();
if let Some(rest) = trimmed.strip_prefix("GH-") {
return rest.parse::<u64>().ok();
}
if let Some((_, number)) = trimmed.rsplit_once('#') {
if trimmed.contains('/') {
return number.parse::<u64>().ok();
}
}
let marker = "github.com/";
let after_host = trimmed.strip_prefix("https://").and_then(|value| {
value
.strip_prefix(marker)
.or_else(|| value.strip_prefix("www.github.com/"))
})?;
let parts = after_host.split(['/', '?', '#']).collect::<Vec<_>>();
if parts.len() >= 4 && parts[2] == "issues" {
return parts[3].parse::<u64>().ok();
}
None
}
pub fn ensure_not_blocked(root: &Path, card_id: &str) -> Result<()> {
let Some(cfg) = load_bot_config(root) else {
return Ok(());
};
let selected = cfg
.integrations
.as_ref()
.and_then(|i| i.work_tracker.as_deref());
let last = match selected {
Some("github") => {
let github_cfg = cfg.github.as_ref().ok_or_else(|| {
anyhow::anyhow!("block gate configurado para GitHub, mas seção github ausente")
})?;
let issue_number = parse_github_issue_number(card_id).ok_or_else(|| {
anyhow::anyhow!(
"block gate GitHub não reconhece o card id `{}`; use GH-123, owner/repo#123 ou URL de issue GitHub",
card_id
)
})?;
fetch_github_last_comment(github_cfg, issue_number)?
}
Some("jira") => {
let jira_cfg = cfg.jira.as_ref().ok_or_else(|| {
anyhow::anyhow!("block gate configurado para Jira, mas seção jira ausente")
})?;
fetch_last_comment(jira_cfg, card_id)?
}
Some(other) => anyhow::bail!("work_tracker desconhecido no block gate: {other}"),
None if cfg.github.is_some() && parse_github_issue_number(card_id).is_some() => {
let github_cfg = cfg.github.as_ref().expect("checked above");
let issue_number = parse_github_issue_number(card_id).expect("checked above");
fetch_github_last_comment(github_cfg, issue_number)?
}
None if cfg.jira.is_some() => {
let jira_cfg = cfg.jira.as_ref().expect("checked above");
fetch_last_comment(jira_cfg, card_id)?
}
None => return Ok(()),
};
if is_blocked_marker(last.as_deref()) {
anyhow::bail!(
"card {} está bloqueado (último comentário do work tracker: /bloqueado). \
Remova o bloqueio antes de orquestrar.",
card_id
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
github_last_link_url, http_error_kind, is_blocked_marker, parse_github_issue_number,
sanitize_error_body, should_retry_http_status,
};
use reqwest::StatusCode;
#[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 parseia_ids_github_suportados() {
assert_eq!(parse_github_issue_number("GH-123"), Some(123));
assert_eq!(parse_github_issue_number("owner/repo#456"), Some(456));
assert_eq!(
parse_github_issue_number("https://github.com/owner/repo/issues/789"),
Some(789)
);
assert_eq!(
parse_github_issue_number("https://github.com/owner/repo/issues/789?x=1"),
Some(789)
);
}
#[test]
fn nao_parseia_ids_nao_github() {
assert_eq!(parse_github_issue_number("PROJ-1"), None);
assert_eq!(
parse_github_issue_number("https://gitlab.com/owner/repo/issues/1"),
None
);
}
#[test]
fn extrai_rel_last_do_header_link_github() {
let header = r#"<https://api.github.com/repos/acme/api/issues/1/comments?page=2&per_page=100>; rel="next", <https://api.github.com/repos/acme/api/issues/1/comments?page=4&per_page=100>; rel="last""#;
assert_eq!(
github_last_link_url(header),
Some(
"https://api.github.com/repos/acme/api/issues/1/comments?page=4&per_page=100"
.to_string()
)
);
}
#[test]
fn sem_rel_last_no_header_link_github() {
let header =
r#"<https://api.github.com/repos/acme/api/issues/1/comments?page=2>; rel="next""#;
assert_eq!(github_last_link_url(header), None);
}
#[test]
fn classifica_status_http_externo() {
assert_eq!(http_error_kind(StatusCode::UNAUTHORIZED), "auth_forbidden");
assert_eq!(http_error_kind(StatusCode::FORBIDDEN), "auth_forbidden");
assert_eq!(http_error_kind(StatusCode::NOT_FOUND), "not_found");
assert_eq!(
http_error_kind(StatusCode::TOO_MANY_REQUESTS),
"rate_limited"
);
assert_eq!(
http_error_kind(StatusCode::INTERNAL_SERVER_ERROR),
"server_error"
);
}
#[test]
fn retry_somente_para_rate_limit_ou_5xx() {
assert!(should_retry_http_status(StatusCode::TOO_MANY_REQUESTS));
assert!(should_retry_http_status(StatusCode::BAD_GATEWAY));
assert!(!should_retry_http_status(StatusCode::UNAUTHORIZED));
assert!(!should_retry_http_status(StatusCode::FORBIDDEN));
assert!(!should_retry_http_status(StatusCode::NOT_FOUND));
}
#[test]
fn github_configurado_rejeita_card_id_nao_github() {
let tmp = std::env::temp_dir().join("sdd_test_github_invalid_card_block_gate");
std::fs::remove_dir_all(&tmp).ok();
let config_dir = tmp.join(".sdd/bot");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("sdd-bot.config.yaml"),
r#"
integrations:
work_tracker: github
github:
owner: acme
repo: api
auth:
token: ghp_test
"#,
)
.unwrap();
let err = super::ensure_not_blocked(&tmp, "PROJ-1").unwrap_err();
assert!(format!("{err:#}").contains("não reconhece o card id"));
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn jira_configurado_nao_faz_fallback_para_github() {
let tmp = std::env::temp_dir().join("sdd_test_jira_no_github_fallback_block_gate");
std::fs::remove_dir_all(&tmp).ok();
let config_dir = tmp.join(".sdd/bot");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("sdd-bot.config.yaml"),
r#"
integrations:
work_tracker: jira
github:
owner: acme
repo: api
auth:
token: ghp_test
"#,
)
.unwrap();
let err =
super::ensure_not_blocked(&tmp, "https://github.com/acme/api/issues/42").unwrap_err();
assert!(format!("{err:#}").contains("seção jira ausente"));
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn sanitize_error_body_redige_tokens_e_trunca() {
let body = format!("token ghp_{} Basic abc {}", "a".repeat(40), "x".repeat(260));
let sanitized = sanitize_error_body(&body);
assert!(sanitized.contains("ghp_[REDACTED]"));
assert!(sanitized.contains("Basic [REDACTED]"));
assert!(!sanitized.contains(&"a".repeat(40)));
assert!(sanitized.chars().count() <= 200);
}
#[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();
}
}