use std::collections::HashMap;
use std::path::PathBuf;
use crate::errors::Result;
use crate::markers;
pub struct IssueRouter {
base_dir: PathBuf,
}
impl IssueRouter {
pub fn new(base_dir: PathBuf) -> Self {
Self { base_dir }
}
fn pr_map_path(&self, repo: &str) -> PathBuf {
let safe_repo = repo.replace('/', "_");
self.base_dir.join(safe_repo).join("pr_map.json")
}
fn load_pr_map(&self, repo: &str) -> Result<HashMap<u64, u64>> {
let path = self.pr_map_path(repo);
if !path.exists() {
return Ok(HashMap::new());
}
let contents = std::fs::read_to_string(&path)?;
let map: HashMap<String, u64> = serde_json::from_str(&contents)?;
Ok(map
.into_iter()
.filter_map(|(k, v)| k.parse::<u64>().ok().map(|pk| (pk, v)))
.collect())
}
fn save_pr_map(&self, repo: &str, map: &HashMap<u64, u64>) -> Result<()> {
let path = self.pr_map_path(repo);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let string_map: HashMap<String, u64> =
map.iter().map(|(k, v)| (k.to_string(), *v)).collect();
let json = serde_json::to_string_pretty(&string_map)?;
std::fs::write(&path, json)?;
Ok(())
}
pub fn register_pr(&self, repo: &str, pr_number: u64, root_issue: u64) -> Result<()> {
let mut map = self.load_pr_map(repo)?;
map.insert(pr_number, root_issue);
self.save_pr_map(repo, &map)?;
tracing::debug!(
repo = repo,
pr = pr_number,
root_issue = root_issue,
"registered PR → root issue mapping"
);
Ok(())
}
pub fn lookup_pr(&self, repo: &str, pr_number: u64) -> Result<Option<u64>> {
let map = self.load_pr_map(repo)?;
Ok(map.get(&pr_number).copied())
}
pub fn unregister_pr(&self, repo: &str, pr_number: u64) -> Result<()> {
let mut map = self.load_pr_map(repo)?;
map.remove(&pr_number);
self.save_pr_map(repo, &map)?;
Ok(())
}
pub fn route_event(&self, repo: &str, event: &serde_json::Value) -> Result<Option<u64>> {
let event_type = event
.get("_githubclaw_event_type")
.and_then(|v| v.as_str())
.unwrap_or("");
match event_type {
"issues" => {
let issue_number = event.pointer("/issue/number").and_then(|v| v.as_u64());
if let Some(num) = issue_number {
let body = event
.pointer("/issue/body")
.and_then(|v| v.as_str())
.unwrap_or("");
if let Some(root) = markers::extract_ref_issue(body) {
return Ok(Some(root));
}
return Ok(Some(num));
}
Ok(None)
}
"issue_comment" => {
let issue_number = event.pointer("/issue/number").and_then(|v| v.as_u64());
let comment_body = event
.pointer("/comment/body")
.and_then(|v| v.as_str())
.unwrap_or("");
if let Some(root) = markers::extract_ref_issue(comment_body) {
return Ok(Some(root));
}
let issue_body = event
.pointer("/issue/body")
.and_then(|v| v.as_str())
.unwrap_or("");
if let Some(root) = markers::extract_ref_issue(issue_body) {
return Ok(Some(root));
}
Ok(issue_number)
}
"pull_request" => {
let pr_number = event
.pointer("/pull_request/number")
.and_then(|v| v.as_u64());
let pr_body = event
.pointer("/pull_request/body")
.and_then(|v| v.as_str())
.unwrap_or("");
if let Some(root) = markers::extract_ref_issue(pr_body) {
if let Some(pr) = pr_number {
let _ = self.register_pr(repo, pr, root);
}
return Ok(Some(root));
}
if let Some(pr) = pr_number {
return self.lookup_pr(repo, pr);
}
Ok(None)
}
"pull_request_review" => {
let review_body = event
.pointer("/review/body")
.and_then(|v| v.as_str())
.unwrap_or("");
if let Some(root) = markers::extract_ref_issue(review_body) {
return Ok(Some(root));
}
let pr_number = event
.pointer("/pull_request/number")
.and_then(|v| v.as_u64());
if let Some(pr) = pr_number {
return self.lookup_pr(repo, pr);
}
Ok(None)
}
"check_run" => {
let prs = event
.pointer("/check_run/pull_requests")
.and_then(|v| v.as_array());
if let Some(pr_list) = prs {
for pr in pr_list {
if let Some(pr_number) = pr.get("number").and_then(|v| v.as_u64()) {
if let Ok(Some(root)) = self.lookup_pr(repo, pr_number) {
return Ok(Some(root));
}
}
}
}
Ok(None)
}
_ => Ok(None),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::TempDir;
fn test_router(tmp: &TempDir) -> IssueRouter {
IssueRouter::new(tmp.path().join("sessions"))
}
#[test]
fn route_issue_opened_root() {
let tmp = TempDir::new().unwrap();
let router = test_router(&tmp);
let event = json!({
"_githubclaw_event_type": "issues",
"action": "opened",
"issue": { "number": 42, "body": "Some bug report" }
});
let root = router.route_event("org/repo", &event).unwrap();
assert_eq!(root, Some(42));
}
#[test]
fn route_sub_issue_to_root() {
let tmp = TempDir::new().unwrap();
let router = test_router(&tmp);
let event = json!({
"_githubclaw_event_type": "issues",
"action": "opened",
"issue": { "number": 100, "body": "Sub-task: implement auth\n\n_ref #42_" }
});
let root = router.route_event("org/repo", &event).unwrap();
assert_eq!(root, Some(42));
}
#[test]
fn route_issue_comment_ref_in_body() {
let tmp = TempDir::new().unwrap();
let router = test_router(&tmp);
let event = json!({
"_githubclaw_event_type": "issue_comment",
"action": "created",
"issue": { "number": 100, "body": "" },
"comment": { "body": "<!-- githubclaw:verified -->\n\nref #42" }
});
let root = router.route_event("org/repo", &event).unwrap();
assert_eq!(root, Some(42));
}
#[test]
fn route_issue_comment_fallback_issue_body() {
let tmp = TempDir::new().unwrap();
let router = test_router(&tmp);
let event = json!({
"_githubclaw_event_type": "issue_comment",
"action": "created",
"issue": { "number": 100, "body": "Sub-issue\n_ref #42_" },
"comment": { "body": "LGTM" }
});
let root = router.route_event("org/repo", &event).unwrap();
assert_eq!(root, Some(42));
}
#[test]
fn route_issue_comment_fallback_issue_number() {
let tmp = TempDir::new().unwrap();
let router = test_router(&tmp);
let event = json!({
"_githubclaw_event_type": "issue_comment",
"action": "created",
"issue": { "number": 42, "body": "Root issue" },
"comment": { "body": "Some comment" }
});
let root = router.route_event("org/repo", &event).unwrap();
assert_eq!(root, Some(42));
}
#[test]
fn route_pr_with_ref_and_register() {
let tmp = TempDir::new().unwrap();
let router = test_router(&tmp);
let event = json!({
"_githubclaw_event_type": "pull_request",
"action": "opened",
"pull_request": { "number": 50, "body": "Fixes auth module\n\n_ref #42_" }
});
let root = router.route_event("org/repo", &event).unwrap();
assert_eq!(root, Some(42));
let mapped = router.lookup_pr("org/repo", 50).unwrap();
assert_eq!(mapped, Some(42));
}
#[test]
fn route_check_run_via_pr_map() {
let tmp = TempDir::new().unwrap();
let router = test_router(&tmp);
router.register_pr("org/repo", 50, 42).unwrap();
let event = json!({
"_githubclaw_event_type": "check_run",
"action": "completed",
"check_run": {
"conclusion": "failure",
"pull_requests": [{ "number": 50 }]
}
});
let root = router.route_event("org/repo", &event).unwrap();
assert_eq!(root, Some(42));
}
#[test]
fn route_check_run_no_map_returns_none() {
let tmp = TempDir::new().unwrap();
let router = test_router(&tmp);
let event = json!({
"_githubclaw_event_type": "check_run",
"action": "completed",
"check_run": {
"conclusion": "failure",
"pull_requests": [{ "number": 99 }]
}
});
let root = router.route_event("org/repo", &event).unwrap();
assert_eq!(root, None);
}
#[test]
fn route_pr_review_ref_in_body() {
let tmp = TempDir::new().unwrap();
let router = test_router(&tmp);
let event = json!({
"_githubclaw_event_type": "pull_request_review",
"action": "submitted",
"review": { "body": "LGTM\n\nref #42" },
"pull_request": { "number": 50 }
});
let root = router.route_event("org/repo", &event).unwrap();
assert_eq!(root, Some(42));
}
#[test]
fn route_pr_review_fallback_pr_map() {
let tmp = TempDir::new().unwrap();
let router = test_router(&tmp);
router.register_pr("org/repo", 50, 42).unwrap();
let event = json!({
"_githubclaw_event_type": "pull_request_review",
"action": "submitted",
"review": { "body": "" },
"pull_request": { "number": 50 }
});
let root = router.route_event("org/repo", &event).unwrap();
assert_eq!(root, Some(42));
}
#[test]
fn route_unknown_event_none() {
let tmp = TempDir::new().unwrap();
let router = test_router(&tmp);
let event = json!({
"_githubclaw_event_type": "ping",
"zen": "hello"
});
let root = router.route_event("org/repo", &event).unwrap();
assert_eq!(root, None);
}
#[test]
fn pr_map_persistence() {
let tmp = TempDir::new().unwrap();
let router = test_router(&tmp);
router.register_pr("org/repo", 10, 100).unwrap();
router.register_pr("org/repo", 20, 200).unwrap();
let router2 = test_router(&tmp);
assert_eq!(router2.lookup_pr("org/repo", 10).unwrap(), Some(100));
assert_eq!(router2.lookup_pr("org/repo", 20).unwrap(), Some(200));
}
#[test]
fn unregister_pr_removes_mapping() {
let tmp = TempDir::new().unwrap();
let router = test_router(&tmp);
router.register_pr("org/repo", 50, 42).unwrap();
assert_eq!(router.lookup_pr("org/repo", 50).unwrap(), Some(42));
router.unregister_pr("org/repo", 50).unwrap();
assert_eq!(router.lookup_pr("org/repo", 50).unwrap(), None);
}
#[test]
fn pr_map_repo_isolation() {
let tmp = TempDir::new().unwrap();
let router = test_router(&tmp);
router.register_pr("org/repo-a", 50, 1).unwrap();
router.register_pr("org/repo-b", 50, 2).unwrap();
assert_eq!(router.lookup_pr("org/repo-a", 50).unwrap(), Some(1));
assert_eq!(router.lookup_pr("org/repo-b", 50).unwrap(), Some(2));
}
}