use hmac::{Hmac, Mac};
use serde::Deserialize;
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
pub fn verify_signature(secret: &str, body: &[u8], signature_header: Option<&str>) -> bool {
let Some(sig) = signature_header else {
return false;
};
let Some(hex_sig) = sig.strip_prefix("sha256=") else {
return false;
};
let Ok(expected) = hex::decode(hex_sig) else {
return false;
};
let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
return false;
};
mac.update(body);
mac.verify_slice(&expected).is_ok()
}
pub struct WebhookPr {
pub repo: String,
pub pr: u64,
pub action: String,
}
pub fn parse_pull_request_event(body: &[u8]) -> anyhow::Result<WebhookPr> {
#[derive(Deserialize)]
struct Repo {
full_name: String,
}
#[derive(Deserialize)]
struct PullRequest {
number: u64,
}
#[derive(Deserialize)]
struct Event {
action: String,
repository: Repo,
pull_request: PullRequest,
}
let ev: Event = serde_json::from_slice(body)?;
Ok(WebhookPr {
repo: ev.repository.full_name,
pr: ev.pull_request.number,
action: ev.action,
})
}
pub fn should_review(action: &str, review_on_update: bool) -> bool {
match action {
"opened" | "reopened" | "ready_for_review" => true,
"synchronize" => review_on_update,
_ => false,
}
}
pub fn parse_bitbucket_pr_event(body: &[u8]) -> anyhow::Result<(String, u64)> {
#[derive(Deserialize)]
struct Repo {
full_name: String,
}
#[derive(Deserialize)]
struct Pr {
id: u64,
}
#[derive(Deserialize)]
struct Event {
repository: Repo,
pullrequest: Pr,
}
let ev: Event = serde_json::from_slice(body)?;
Ok((ev.repository.full_name, ev.pullrequest.id))
}
pub fn should_review_bitbucket(event_key: &str, review_on_update: bool) -> bool {
match event_key {
"pullrequest:created" => true,
"pullrequest:updated" => review_on_update,
_ => false,
}
}
pub fn parse_bitbucket_comment_event(body: &[u8]) -> anyhow::Result<(String, u64, String)> {
#[derive(Deserialize)]
struct Repo {
full_name: String,
}
#[derive(Deserialize)]
struct Pr {
id: u64,
}
#[derive(Deserialize)]
struct Content {
raw: String,
}
#[derive(Deserialize)]
struct Comment {
content: Content,
}
#[derive(Deserialize)]
struct Event {
repository: Repo,
pullrequest: Pr,
comment: Comment,
}
let ev: Event = serde_json::from_slice(body)?;
Ok((
ev.repository.full_name,
ev.pullrequest.id,
ev.comment.content.raw,
))
}
pub struct WebhookComment {
pub repo: String,
pub pr: u64,
pub action: String,
pub body: String,
pub is_pull_request: bool,
}
pub fn parse_issue_comment_event(body: &[u8]) -> anyhow::Result<WebhookComment> {
#[derive(Deserialize)]
struct Repo {
full_name: String,
}
#[derive(Deserialize)]
struct Issue {
number: u64,
#[serde(default)]
pull_request: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct Comment {
body: String,
}
#[derive(Deserialize)]
struct Event {
action: String,
issue: Issue,
comment: Comment,
repository: Repo,
}
let ev: Event = serde_json::from_slice(body)?;
Ok(WebhookComment {
repo: ev.repository.full_name,
pr: ev.issue.number,
action: ev.action,
body: ev.comment.body,
is_pull_request: ev.issue.pull_request.is_some(),
})
}
pub fn is_review_command(action: &str, is_pull_request: bool, body: &str) -> bool {
action == "created" && is_pull_request && body.trim() == "/review"
}
#[cfg(test)]
mod tests {
use super::{
is_review_command, parse_bitbucket_comment_event, should_review, should_review_bitbucket,
};
#[test]
fn opened_reopened_ready_always_review() {
for action in ["opened", "reopened", "ready_for_review"] {
assert!(should_review(action, false), "{action} should review");
assert!(should_review(action, true), "{action} should review");
}
}
#[test]
fn synchronize_is_gated_by_flag() {
assert!(!should_review("synchronize", false));
assert!(should_review("synchronize", true));
}
#[test]
fn unknown_github_action_ignored() {
assert!(!should_review("labeled", false));
assert!(!should_review("labeled", true));
}
#[test]
fn bitbucket_updated_is_gated_by_flag() {
assert!(should_review_bitbucket("pullrequest:created", false));
assert!(!should_review_bitbucket("pullrequest:updated", false));
assert!(should_review_bitbucket("pullrequest:updated", true));
assert!(!should_review_bitbucket("pullrequest:fulfilled", false));
}
#[test]
fn review_command_only_on_created_pr_comment() {
assert!(is_review_command("created", true, "/review"));
assert!(is_review_command("created", true, " /review\n")); assert!(!is_review_command("edited", true, "/review")); assert!(!is_review_command("created", false, "/review")); assert!(!is_review_command("created", true, "please /review")); assert!(!is_review_command("created", true, "/reviews")); }
#[test]
fn parse_bitbucket_comment_extracts_repo_pr_and_body() {
let body = br#"{
"repository": { "full_name": "ws/repo" },
"pullrequest": { "id": 42 },
"comment": { "content": { "raw": "/review" } }
}"#;
let (repo, pr, text) = parse_bitbucket_comment_event(body).unwrap();
assert_eq!(repo, "ws/repo");
assert_eq!(pr, 42);
assert_eq!(text, "/review");
assert!(is_review_command("created", true, &text));
}
}