use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use crate::core::review::ReviewReport;
const USER_AGENT: &str = "trusty-analyze";
#[derive(Debug, thiserror::Error)]
pub enum GithubError {
#[error("GITHUB_TOKEN is not set; a GitHub token is required for this operation")]
MissingToken,
#[error("github request failed: {0}")]
Transport(String),
#[error("github API returned {status}: {body}")]
Api { status: u16, body: String },
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct GithubPrRequest {
pub owner: String,
pub repo: String,
pub pr: u64,
pub index_id: String,
#[serde(default)]
pub post_comment: bool,
}
pub async fn fetch_pr_diff(
client: &reqwest::Client,
owner: &str,
repo: &str,
pr: u64,
token: &str,
) -> Result<String, GithubError> {
let url = format!("https://api.github.com/repos/{owner}/{repo}/pulls/{pr}");
let resp = client
.get(&url)
.header("Accept", "application/vnd.github.v3.diff")
.header("Authorization", format!("Bearer {token}"))
.header("User-Agent", USER_AGENT)
.send()
.await
.map_err(|e| GithubError::Transport(format!("GET {url}: {e}")))?;
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| GithubError::Transport(format!("read body of {url}: {e}")))?;
if !status.is_success() {
return Err(GithubError::Api {
status: status.as_u16(),
body,
});
}
Ok(body)
}
pub async fn post_pr_comment(
client: &reqwest::Client,
owner: &str,
repo: &str,
pr: u64,
body: &str,
token: &str,
) -> Result<(), GithubError> {
let url = format!("https://api.github.com/repos/{owner}/{repo}/issues/{pr}/comments");
let resp = client
.post(&url)
.header("Accept", "application/vnd.github+json")
.header("Authorization", format!("Bearer {token}"))
.header("User-Agent", USER_AGENT)
.json(&serde_json::json!({ "body": body }))
.send()
.await
.map_err(|e| GithubError::Transport(format!("POST {url}: {e}")))?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(GithubError::Api {
status: status.as_u16(),
body: text,
});
}
Ok(())
}
pub fn format_review_as_markdown(report: &ReviewReport) -> String {
let mut out = String::new();
out.push_str("## 🔍 trusty-analyze Review\n\n");
out.push_str(&format!(
"**Overall grade: {}** | {} changed lines | {} smell{}\n\n",
report.overall_grade,
report.changed_lines,
report.smell_count,
if report.smell_count == 1 { "" } else { "s" },
));
if report.files.is_empty() {
out.push_str("_No files changed._\n");
} else {
out.push_str("### Files\n\n");
out.push_str("| File | Grade | Cyclomatic | Cognitive | Smells |\n");
out.push_str("|------|-------|-----------|---------|--------|\n");
for f in &report.files {
out.push_str(&format!(
"| `{}` | {} | {} | {} | {} |\n",
f.path,
f.grade,
f.complexity.cyclomatic,
f.complexity.cognitive,
f.smells.len(),
));
}
out.push('\n');
let any_smells = report.files.iter().any(|f| !f.smells.is_empty());
if any_smells {
out.push_str("### Smells\n\n");
for f in &report.files {
for s in &f.smells {
out.push_str(&format!(
"- `{}:{}` — **{}** ({})\n",
f.path, s.line, s.category, s.severity,
));
}
}
out.push('\n');
}
}
out.push_str("---\n");
out.push_str("*Generated by [trusty-analyze](https://github.com/bobmatnyc/trusty-analyze)*\n");
out
}
pub fn verify_webhook_signature(secret: &str, body: &[u8], signature_header: &str) -> bool {
let Some(hex_sig) = signature_header.strip_prefix("sha256=") else {
return false;
};
let Ok(expected) = hex::decode(hex_sig) else {
return false;
};
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else {
return false;
};
mac.update(body);
mac.verify_slice(&expected).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::review::{FileReview, ReviewComplexity, ReviewSource, SmellHit};
use crate::types::complexity::ComplexityGrade;
fn sample_report() -> ReviewReport {
ReviewReport {
files: vec![FileReview {
path: "src/foo.rs".into(),
grade: ComplexityGrade::B,
complexity: ReviewComplexity {
cyclomatic: 12,
cognitive: 8,
},
smells: vec![SmellHit {
category: "long_method".into(),
line: 42,
severity: "medium".into(),
}],
recommendations: vec!["extract a helper".into()],
source: ReviewSource::NewFile,
}],
overall_grade: ComplexityGrade::B,
changed_lines: 143,
smell_count: 1,
summary: "1 file analyzed".into(),
}
}
#[test]
fn github_pr_request_round_trips_json() {
let req = GithubPrRequest {
owner: "bobmatnyc".into(),
repo: "trusty-analyze".into(),
pr: 12,
index_id: "idx".into(),
post_comment: true,
};
let json = serde_json::to_string(&req).unwrap();
let back: GithubPrRequest = serde_json::from_str(&json).unwrap();
assert_eq!(req, back);
}
#[test]
fn github_pr_request_post_comment_defaults_false() {
let req: GithubPrRequest =
serde_json::from_str(r#"{"owner":"o","repo":"r","pr":1,"index_id":"i"}"#).unwrap();
assert!(!req.post_comment);
}
#[test]
fn markdown_renders_summary_and_files() {
let md = format_review_as_markdown(&sample_report());
assert!(md.contains("## 🔍 trusty-analyze Review"));
assert!(md.contains("Overall grade: B"));
assert!(md.contains("143 changed lines"));
assert!(md.contains("1 smell"));
assert!(md.contains("| `src/foo.rs` | B | 12 | 8 | 1 |"));
assert!(md.contains("`src/foo.rs:42` — **long_method** (medium)"));
assert!(md.contains("Generated by [trusty-analyze]"));
}
#[test]
fn markdown_handles_empty_report() {
let report = ReviewReport {
files: vec![],
overall_grade: ComplexityGrade::A,
changed_lines: 0,
smell_count: 0,
summary: "nothing".into(),
};
let md = format_review_as_markdown(&report);
assert!(md.contains("Overall grade: A"));
assert!(md.contains("0 smells"));
assert!(md.contains("_No files changed._"));
}
#[test]
fn webhook_signature_accepts_valid() {
let secret = "test-hmac-key"; let body = br#"{"action":"opened"}"#;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
let digest = hex::encode(mac.finalize().into_bytes());
let header = format!("sha256={digest}");
assert!(verify_webhook_signature(secret, body, &header));
}
#[test]
fn webhook_signature_rejects_invalid() {
let body = br#"{"action":"opened"}"#;
assert!(!verify_webhook_signature("secret", body, "sha256=deadbeef"));
assert!(!verify_webhook_signature("secret", body, "deadbeef"));
let mut mac = Hmac::<Sha256>::new_from_slice(b"other").unwrap();
mac.update(body);
let digest = hex::encode(mac.finalize().into_bytes());
assert!(!verify_webhook_signature(
"secret",
body,
&format!("sha256={digest}")
));
}
}