Skip to main content

codesynapse_core/
prs.rs

1use chrono::{DateTime, Utc};
2use serde_json::Value;
3use std::collections::HashMap;
4use std::process::Command;
5
6use crate::types::Node;
7
8pub struct PRInfo {
9    pub number: i64,
10    pub title: String,
11    pub branch: String,
12    pub base_branch: String,
13    pub author: String,
14    pub is_draft: bool,
15    pub review_decision: String,
16    pub ci_status: String,
17    pub updated_at: DateTime<Utc>,
18    pub expected_base: String,
19    pub worktree_path: Option<String>,
20    pub communities_touched: Vec<usize>,
21    pub nodes_affected: usize,
22    pub files_changed: Vec<String>,
23}
24
25impl PRInfo {
26    pub fn days_old(&self) -> i64 {
27        (Utc::now() - self.updated_at).num_days()
28    }
29
30    pub fn status(&self) -> String {
31        classify(self, &self.expected_base)
32    }
33
34    pub fn blast_radius(&self) -> String {
35        if self.nodes_affected == 0 {
36            return String::new();
37        }
38        let n = self.nodes_affected;
39        let c = self.communities_touched.len();
40        let node_s = if n != 1 { "s" } else { "" };
41        let comm_s = if c != 1 { "ies" } else { "y" };
42        format!("{n} node{node_s} / {c} communit{comm_s}")
43    }
44}
45
46const STALE_DAYS: i64 = 14;
47
48pub const STATUS_ORDER: &[&str] = &[
49    "WRONG-BASE",
50    "CI-FAIL",
51    "CHANGES-REQ",
52    "DRAFT",
53    "STALE",
54    "PENDING",
55    "APPROVED",
56    "READY",
57];
58
59pub fn classify(pr: &PRInfo, base: &str) -> String {
60    if pr.base_branch != base {
61        return "WRONG-BASE".into();
62    }
63    if pr.ci_status == "FAILURE" {
64        return "CI-FAIL".into();
65    }
66    if pr.review_decision == "CHANGES_REQUESTED" {
67        return "CHANGES-REQ".into();
68    }
69    if pr.is_draft {
70        return "DRAFT".into();
71    }
72    if pr.days_old() >= STALE_DAYS {
73        return "STALE".into();
74    }
75    if pr.review_decision == "APPROVED" {
76        return "APPROVED".into();
77    }
78    if pr.ci_status == "PENDING" {
79        return "PENDING".into();
80    }
81    "READY".into()
82}
83
84const CI_FAILURE_CONCLUSIONS: &[&str] = &[
85    "FAILURE",
86    "CANCELLED",
87    "TIMED_OUT",
88    "ACTION_REQUIRED",
89    "STARTUP_FAILURE",
90];
91
92pub fn parse_ci(rollup: &[HashMap<String, Value>]) -> String {
93    if rollup.is_empty() {
94        return "NONE".into();
95    }
96    for r in rollup {
97        if let Some(c) = r.get("conclusion").and_then(|v| v.as_str()) {
98            if CI_FAILURE_CONCLUSIONS.contains(&c) {
99                return "FAILURE".into();
100            }
101        }
102    }
103    for r in rollup {
104        if let Some(s) = r.get("status").and_then(|v| v.as_str()) {
105            if s == "IN_PROGRESS" || s == "QUEUED" {
106                return "PENDING".into();
107            }
108        }
109    }
110    for r in rollup {
111        if let Some(c) = r.get("conclusion").and_then(|v| v.as_str()) {
112            if c == "SUCCESS" {
113                return "SUCCESS".into();
114            }
115        }
116    }
117    "NONE".into()
118}
119
120pub fn path_match(graph_src: &str, pr_file: &str) -> bool {
121    if graph_src == pr_file {
122        return true;
123    }
124    let sep_pr = format!("/{pr_file}");
125    let sep_graph = format!("/{graph_src}");
126    graph_src.ends_with(&sep_pr) || pr_file.ends_with(&sep_graph)
127}
128
129pub fn compute_pr_impact(files: &[&str], nodes: &[Node]) -> (Vec<usize>, usize) {
130    let mut file_comms: HashMap<&str, std::collections::HashSet<usize>> = HashMap::new();
131    let mut file_count: HashMap<&str, usize> = HashMap::new();
132
133    for node in nodes {
134        let src = node.source_file.as_str();
135        if src.is_empty() {
136            continue;
137        }
138        let entry = file_comms.entry(src).or_default();
139        *file_count.entry(src).or_insert(0) += 1;
140        if let Some(c) = node.community {
141            entry.insert(c);
142        }
143    }
144
145    let mut comms: std::collections::HashSet<usize> = std::collections::HashSet::new();
146    let mut total_nodes = 0usize;
147    let mut matched: std::collections::HashSet<&str> = std::collections::HashSet::new();
148
149    for f in files {
150        for (src, src_comms) in &file_comms {
151            if !matched.contains(src) && path_match(src, f) {
152                comms.extend(src_comms.iter().copied());
153                total_nodes += file_count.get(src).copied().unwrap_or(0);
154                matched.insert(src);
155            }
156        }
157    }
158
159    let mut sorted_comms: Vec<usize> = comms.into_iter().collect();
160    sorted_comms.sort_unstable();
161    (sorted_comms, total_nodes)
162}
163
164pub fn fetch_prs(
165    repo: Option<&str>,
166    base: Option<&str>,
167    limit: usize,
168) -> Result<Vec<PRInfo>, String> {
169    let resolved_base = base
170        .map(|s| s.to_string())
171        .unwrap_or_else(detect_default_branch);
172    let limit_str = limit.to_string();
173    let args = [
174        "pr", "list", "--state", "open",
175        "--limit", &limit_str,
176        "--json",
177        "number,title,headRefName,baseRefName,author,isDraft,reviewDecision,statusCheckRollup,updatedAt",
178    ];
179    let mut repo_args: Vec<String> = Vec::new();
180    if let Some(r) = repo {
181        repo_args.push("--repo".into());
182        repo_args.push(r.into());
183    }
184    let args_owned: Vec<String> = args
185        .iter()
186        .map(|s| s.to_string())
187        .chain(repo_args)
188        .collect();
189
190    let result = Command::new("gh")
191        .args(&args_owned)
192        .output()
193        .map_err(|e| format!("gh CLI not found: {e}. Run: gh auth login"))?;
194
195    if !result.status.success() {
196        let stderr = String::from_utf8_lossy(&result.stderr);
197        return Err(format!("gh pr list failed: {stderr}"));
198    }
199
200    let json: Value =
201        serde_json::from_slice(&result.stdout).map_err(|e| format!("parse gh output: {e}"))?;
202
203    let items = json.as_array().ok_or("gh output not array")?;
204    let mut prs = Vec::new();
205    for item in items {
206        let updated_str = item["updatedAt"].as_str().unwrap_or("1970-01-01T00:00:00Z");
207        let updated: DateTime<Utc> = updated_str
208            .parse()
209            .unwrap_or_else(|_| DateTime::from_timestamp(0, 0).unwrap());
210        let rollup: Vec<HashMap<String, Value>> = item
211            .get("statusCheckRollup")
212            .and_then(|v| serde_json::from_value(v.clone()).ok())
213            .unwrap_or_default();
214        prs.push(PRInfo {
215            number: item["number"].as_i64().unwrap_or(0),
216            title: item["title"].as_str().unwrap_or("").to_string(),
217            branch: item["headRefName"].as_str().unwrap_or("").to_string(),
218            base_branch: item["baseRefName"].as_str().unwrap_or("").to_string(),
219            author: item
220                .get("author")
221                .and_then(|a| a.get("login"))
222                .and_then(|l| l.as_str())
223                .unwrap_or("?")
224                .to_string(),
225            is_draft: item["isDraft"].as_bool().unwrap_or(false),
226            review_decision: item
227                .get("reviewDecision")
228                .and_then(|v| v.as_str())
229                .unwrap_or("")
230                .to_string(),
231            ci_status: parse_ci(&rollup),
232            updated_at: updated,
233            expected_base: resolved_base.clone(),
234            worktree_path: None,
235            communities_touched: vec![],
236            nodes_affected: 0,
237            files_changed: vec![],
238        });
239    }
240    Ok(prs)
241}
242
243pub fn fetch_worktrees() -> HashMap<String, String> {
244    let result = match Command::new("git")
245        .args(["worktree", "list", "--porcelain"])
246        .output()
247    {
248        Ok(r) => r,
249        Err(_) => return HashMap::new(),
250    };
251
252    if !result.status.success() {
253        return HashMap::new();
254    }
255
256    let stdout = String::from_utf8_lossy(&result.stdout);
257    let mut mapping = HashMap::new();
258    let mut current_path: Option<String> = None;
259
260    for line in stdout.lines() {
261        if line.is_empty() {
262            current_path = None;
263        } else if let Some(path) = line.strip_prefix("worktree ") {
264            current_path = Some(path.to_string());
265        } else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
266            if let Some(ref p) = current_path {
267                mapping.insert(branch.to_string(), p.clone());
268            }
269        }
270    }
271
272    mapping
273}
274
275pub fn detect_default_branch() -> String {
276    if let Some(branch) = try_gh_default_branch() {
277        return branch;
278    }
279    try_git_symbolic_ref().unwrap_or_else(|| "main".into())
280}
281
282pub fn detect_default_branch_from(gh_json: Option<&str>, git_ref: Option<&str>) -> String {
283    if let Some(json) = gh_json {
284        if let Some(branch) = parse_gh_default_branch(json) {
285            return branch;
286        }
287    }
288    if let Some(ref_str) = git_ref {
289        if let Some(branch) = parse_git_symbolic_ref(ref_str) {
290            return branch;
291        }
292    }
293    "main".into()
294}
295
296pub fn parse_gh_default_branch(json: &str) -> Option<String> {
297    let data: Value = serde_json::from_str(json).ok()?;
298    data.get("defaultBranchRef")?
299        .get("name")?
300        .as_str()
301        .map(|s| s.to_string())
302}
303
304pub fn parse_git_symbolic_ref(ref_str: &str) -> Option<String> {
305    let trimmed = ref_str.trim();
306    if trimmed.is_empty() {
307        return None;
308    }
309    trimmed.split('/').next_back().map(|s| s.to_string())
310}
311
312fn try_gh_default_branch() -> Option<String> {
313    let result = Command::new("gh")
314        .args(["repo", "view", "--json", "defaultBranchRef"])
315        .output()
316        .ok()?;
317    if !result.status.success() {
318        return None;
319    }
320    let json = String::from_utf8_lossy(&result.stdout);
321    parse_gh_default_branch(&json)
322}
323
324fn try_git_symbolic_ref() -> Option<String> {
325    let result = Command::new("git")
326        .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
327        .output()
328        .ok()?;
329    if !result.status.success() {
330        return None;
331    }
332    let ref_str = String::from_utf8_lossy(&result.stdout);
333    parse_git_symbolic_ref(&ref_str)
334}
335
336pub fn build_community_labels(nodes: &[Value], top_n: usize) -> HashMap<usize, Vec<String>> {
337    let mut comm_labels: HashMap<usize, Vec<String>> = HashMap::new();
338    for node in nodes {
339        let c = match node.get("community").and_then(|v| v.as_u64()) {
340            Some(v) => v as usize,
341            None => continue,
342        };
343        let label = node
344            .get("label")
345            .and_then(|v| v.as_str())
346            .or_else(|| node.get("id").and_then(|v| v.as_str()))
347            .unwrap_or("")
348            .to_string();
349        if !label.is_empty() {
350            comm_labels.entry(c).or_default().push(label);
351        }
352    }
353    comm_labels
354        .into_iter()
355        .map(|(c, mut labels)| {
356            labels.truncate(top_n);
357            (c, labels)
358        })
359        .collect()
360}
361
362pub fn format_prs_text(prs: &[PRInfo], base: &str) -> String {
363    let actionable: Vec<&PRInfo> = prs.iter().filter(|p| p.base_branch == base).collect();
364    let wrong = prs.len() - actionable.len();
365
366    let mut lines = vec![format!(
367        "Open PRs targeting {base}: {}  ({wrong} on wrong base, not shown)",
368        actionable.len()
369    )];
370
371    let mut sorted = actionable.clone();
372    sorted.sort_by_key(|p| {
373        let status = classify(p, base);
374        let order = STATUS_ORDER.iter().position(|s| *s == status).unwrap_or(99);
375        (order, p.days_old())
376    });
377
378    for p in sorted {
379        let status = classify(p, base);
380        let review = if p.review_decision.is_empty() {
381            "none".to_string()
382        } else {
383            p.review_decision.clone()
384        };
385        let impact = if !p.blast_radius().is_empty() {
386            format!("  blast_radius={}", p.blast_radius())
387        } else {
388            String::new()
389        };
390        lines.push(format!(
391            "#{} [{}] CI={} review={} age={}d author={}{}  {}",
392            p.number,
393            status,
394            p.ci_status,
395            review,
396            p.days_old(),
397            p.author,
398            impact,
399            p.title,
400        ));
401    }
402
403    lines.join("\n\n")
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use chrono::{Duration, Utc};
410    use std::collections::HashMap;
411
412    #[allow(clippy::too_many_arguments)]
413    fn make_pr(
414        number: i64,
415        title: &str,
416        branch: &str,
417        base_branch: &str,
418        author: &str,
419        is_draft: bool,
420        review_decision: &str,
421        ci_status: &str,
422        days_ago: i64,
423        expected_base: &str,
424    ) -> PRInfo {
425        PRInfo {
426            number,
427            title: title.to_string(),
428            branch: branch.to_string(),
429            base_branch: base_branch.to_string(),
430            author: author.to_string(),
431            is_draft,
432            review_decision: review_decision.to_string(),
433            ci_status: ci_status.to_string(),
434            updated_at: Utc::now() - Duration::days(days_ago),
435            expected_base: expected_base.to_string(),
436            worktree_path: None,
437            communities_touched: vec![],
438            nodes_affected: 0,
439            files_changed: vec![],
440        }
441    }
442
443    fn default_pr() -> PRInfo {
444        make_pr(
445            1, "Test PR", "feature", "v8", "alice", false, "", "SUCCESS", 1, "v8",
446        )
447    }
448
449    // ── _classify ─────────────────────────────────────────────────────────
450
451    #[test]
452    fn test_classify_ready() {
453        let pr = default_pr();
454        assert_eq!(classify(&pr, "v8"), "READY");
455    }
456
457    #[test]
458    fn test_classify_ci_fail() {
459        let pr = make_pr(1, "T", "f", "v8", "a", false, "", "FAILURE", 1, "v8");
460        assert_eq!(classify(&pr, "v8"), "CI-FAIL");
461    }
462
463    #[test]
464    fn test_classify_changes_req() {
465        let pr = make_pr(
466            1,
467            "T",
468            "f",
469            "v8",
470            "a",
471            false,
472            "CHANGES_REQUESTED",
473            "SUCCESS",
474            1,
475            "v8",
476        );
477        assert_eq!(classify(&pr, "v8"), "CHANGES-REQ");
478    }
479
480    #[test]
481    fn test_classify_draft() {
482        let pr = make_pr(1, "T", "f", "v8", "a", true, "", "SUCCESS", 1, "v8");
483        assert_eq!(classify(&pr, "v8"), "DRAFT");
484    }
485
486    #[test]
487    fn test_classify_stale() {
488        let pr = make_pr(1, "T", "f", "v8", "a", false, "", "SUCCESS", 20, "v8");
489        assert_eq!(classify(&pr, "v8"), "STALE");
490    }
491
492    #[test]
493    fn test_classify_draft_not_marked_stale() {
494        let pr = make_pr(1, "T", "f", "v8", "a", true, "", "SUCCESS", 20, "v8");
495        assert_eq!(classify(&pr, "v8"), "DRAFT");
496    }
497
498    #[test]
499    fn test_classify_pending() {
500        let pr = make_pr(1, "T", "f", "v8", "a", false, "", "PENDING", 1, "v8");
501        assert_eq!(classify(&pr, "v8"), "PENDING");
502    }
503
504    #[test]
505    fn test_classify_wrong_base() {
506        let pr = make_pr(1, "T", "f", "master", "a", false, "", "FAILURE", 1, "v8");
507        assert_eq!(classify(&pr, "v8"), "WRONG-BASE");
508    }
509
510    // ── _parse_ci ─────────────────────────────────────────────────────────
511
512    fn rollup(conclusion: Option<&str>, status: &str) -> HashMap<String, Value> {
513        let mut m = HashMap::new();
514        m.insert(
515            "conclusion".into(),
516            match conclusion {
517                Some(c) => Value::String(c.into()),
518                None => Value::Null,
519            },
520        );
521        m.insert("status".into(), Value::String(status.into()));
522        m
523    }
524
525    #[test]
526    fn test_parse_ci_empty_returns_none() {
527        assert_eq!(parse_ci(&[]), "NONE");
528    }
529
530    #[test]
531    fn test_parse_ci_failure_conclusion() {
532        let r = vec![rollup(Some("FAILURE"), "COMPLETED")];
533        assert_eq!(parse_ci(&r), "FAILURE");
534    }
535
536    #[test]
537    fn test_parse_ci_cancelled_is_failure() {
538        let r = vec![rollup(Some("CANCELLED"), "COMPLETED")];
539        assert_eq!(parse_ci(&r), "FAILURE");
540    }
541
542    #[test]
543    fn test_parse_ci_timed_out_is_failure() {
544        let r = vec![rollup(Some("TIMED_OUT"), "COMPLETED")];
545        assert_eq!(parse_ci(&r), "FAILURE");
546    }
547
548    #[test]
549    fn test_parse_ci_in_progress_is_pending() {
550        let r = vec![rollup(None, "IN_PROGRESS")];
551        assert_eq!(parse_ci(&r), "PENDING");
552    }
553
554    #[test]
555    fn test_parse_ci_success() {
556        let r = vec![rollup(Some("SUCCESS"), "COMPLETED")];
557        assert_eq!(parse_ci(&r), "SUCCESS");
558    }
559
560    #[test]
561    fn test_parse_ci_mixed_success_and_failure_is_failure() {
562        let r = vec![
563            rollup(Some("SUCCESS"), "COMPLETED"),
564            rollup(Some("FAILURE"), "COMPLETED"),
565        ];
566        assert_eq!(parse_ci(&r), "FAILURE");
567    }
568
569    // ── _path_match ────────────────────────────────────────────────────────
570
571    #[test]
572    fn test_path_match_exact() {
573        assert!(path_match("src/auth/api.py", "src/auth/api.py"));
574    }
575
576    #[test]
577    fn test_path_match_graph_path_longer() {
578        assert!(path_match("src/auth/api.py", "api.py"));
579    }
580
581    #[test]
582    fn test_path_match_no_false_positive_partial_filename() {
583        assert!(!path_match("config.py", "g.py"));
584        assert!(!path_match("g.py", "config.py"));
585    }
586
587    #[test]
588    fn test_path_match_both_directions() {
589        assert!(path_match("api.py", "src/auth/api.py"));
590        assert!(path_match("src/auth/api.py", "api.py"));
591    }
592
593    // ── compute_pr_impact ─────────────────────────────────────────────────
594
595    fn make_node(id: &str, source_file: &str, community: Option<usize>) -> Node {
596        Node {
597            id: id.to_string(),
598            label: id.to_string(),
599            file_type: "function".to_string(),
600            source_file: source_file.to_string(),
601            source_location: None,
602            community,
603            rationale: None,
604            docstring: None,
605            metadata: HashMap::new(),
606        }
607    }
608
609    fn three_node_graph() -> Vec<Node> {
610        vec![
611            make_node("n1", "src/auth/api.py", Some(0)),
612            make_node("n2", "src/auth/api.py", Some(0)),
613            make_node("n3", "src/utils/helpers.py", Some(1)),
614        ]
615    }
616
617    #[test]
618    fn test_compute_pr_impact_matching_files() {
619        let nodes = three_node_graph();
620        let (comms, count) = compute_pr_impact(&["src/auth/api.py"], &nodes);
621        assert_eq!(comms, vec![0]);
622        assert_eq!(count, 2);
623    }
624
625    #[test]
626    fn test_compute_pr_impact_both_files() {
627        let nodes = three_node_graph();
628        let (comms, count) =
629            compute_pr_impact(&["src/auth/api.py", "src/utils/helpers.py"], &nodes);
630        assert_eq!(comms, vec![0, 1]);
631        assert_eq!(count, 3);
632    }
633
634    #[test]
635    fn test_compute_pr_impact_empty_files() {
636        let nodes = three_node_graph();
637        let (comms, count) = compute_pr_impact(&[], &nodes);
638        assert!(comms.is_empty());
639        assert_eq!(count, 0);
640    }
641
642    #[test]
643    fn test_compute_pr_impact_no_matching_files() {
644        let nodes = three_node_graph();
645        let (comms, count) = compute_pr_impact(&["docs/README.md"], &nodes);
646        assert!(comms.is_empty());
647        assert_eq!(count, 0);
648    }
649
650    #[test]
651    fn test_compute_pr_impact_no_double_count_basename() {
652        let nodes = vec![
653            make_node("a1", "src/auth/api.py", Some(0)),
654            make_node("a2", "src/admin/api.py", Some(1)),
655        ];
656        let (comms, count) = compute_pr_impact(&["src/auth/api.py"], &nodes);
657        assert_eq!(count, 1);
658        assert_eq!(comms, vec![0]);
659    }
660
661    #[test]
662    fn test_compute_pr_impact_no_double_count_same_graph_file() {
663        let nodes = vec![
664            make_node("n1", "src/auth/api.py", Some(0)),
665            make_node("n2", "src/auth/api.py", Some(0)),
666        ];
667        let (comms, count) = compute_pr_impact(&["src/auth/api.py", "api.py"], &nodes);
668        assert_eq!(count, 2);
669        assert_eq!(comms, vec![0]);
670    }
671
672    // ── fetch_worktrees ───────────────────────────────────────────────────
673    // These tests exercise the parser directly by calling a testable parse fn.
674
675    fn parse_worktree_output(stdout: &str, success: bool) -> HashMap<String, String> {
676        if !success {
677            return HashMap::new();
678        }
679        let mut mapping = HashMap::new();
680        let mut current_path: Option<String> = None;
681        for line in stdout.lines() {
682            if line.is_empty() {
683                current_path = None;
684            } else if let Some(path) = line.strip_prefix("worktree ") {
685                current_path = Some(path.to_string());
686            } else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
687                if let Some(ref p) = current_path {
688                    mapping.insert(branch.to_string(), p.clone());
689                }
690            }
691        }
692        mapping
693    }
694
695    #[test]
696    fn test_fetch_worktrees_normal_case() {
697        let porcelain = "worktree /home/user/proj\nHEAD abc123\nbranch refs/heads/main\n\nworktree /home/user/proj-feature\nHEAD def456\nbranch refs/heads/feature-x\n\n";
698        let m = parse_worktree_output(porcelain, true);
699        assert_eq!(m.get("main").map(|s| s.as_str()), Some("/home/user/proj"));
700        assert_eq!(
701            m.get("feature-x").map(|s| s.as_str()),
702            Some("/home/user/proj-feature")
703        );
704    }
705
706    #[test]
707    fn test_fetch_worktrees_detached_head() {
708        let porcelain = "worktree /home/user/detached\nHEAD abc123\ndetached\n\nworktree /home/user/proj-feature\nHEAD def456\nbranch refs/heads/feature-x\n\n";
709        let m = parse_worktree_output(porcelain, true);
710        assert_eq!(
711            m.get("feature-x").map(|s| s.as_str()),
712            Some("/home/user/proj-feature")
713        );
714        assert!(!m.values().any(|v| v == "/home/user/detached"));
715    }
716
717    #[test]
718    fn test_fetch_worktrees_empty_output() {
719        let m = parse_worktree_output("", true);
720        assert!(m.is_empty());
721    }
722
723    #[test]
724    fn test_fetch_worktrees_nonzero_returncode() {
725        let m = parse_worktree_output("anything", false);
726        assert!(m.is_empty());
727    }
728
729    #[test]
730    fn test_fetch_worktrees_subprocess_failure() {
731        // Simulate by calling with non-existent binary via parse fn with empty
732        let m = parse_worktree_output("", false);
733        assert!(m.is_empty());
734    }
735
736    // ── format_prs_text ───────────────────────────────────────────────────
737
738    #[test]
739    fn test_format_prs_text_contains_metadata_and_header() {
740        let prs = vec![
741            make_pr(
742                101,
743                "Add awesome feature",
744                "f1",
745                "v8",
746                "alice",
747                false,
748                "",
749                "SUCCESS",
750                1,
751                "v8",
752            ),
753            make_pr(
754                102,
755                "Fix flaky test",
756                "f2",
757                "v8",
758                "bob",
759                false,
760                "",
761                "FAILURE",
762                1,
763                "v8",
764            ),
765            make_pr(
766                103,
767                "Wrong base PR",
768                "f3",
769                "master",
770                "carol",
771                false,
772                "",
773                "SUCCESS",
774                1,
775                "v8",
776            ),
777        ];
778        let out = format_prs_text(&prs, "v8");
779        assert!(out.contains("Open PRs targeting v8: 2"));
780        assert!(out.contains("(1 on wrong base, not shown)"));
781        assert!(out.contains("#101"));
782        assert!(out.contains("Add awesome feature"));
783        assert!(out.contains("#102"));
784        assert!(out.contains("Fix flaky test"));
785        assert!(out.contains("[READY]"));
786        assert!(out.contains("[CI-FAIL]"));
787        assert!(!out.contains("#103"));
788    }
789
790    #[test]
791    fn test_format_prs_text_empty() {
792        let out = format_prs_text(&[], "v8");
793        assert!(out.contains("Open PRs targeting v8: 0"));
794        assert!(out.contains("(0 on wrong base, not shown)"));
795    }
796
797    // ── build_community_labels ─────────────────────────────────────────────
798
799    #[test]
800    fn test_build_community_labels_basic() {
801        let nodes = vec![
802            serde_json::json!({"id": "a", "label": "Alpha", "community": 0}),
803            serde_json::json!({"id": "b", "label": "Beta",  "community": 0}),
804            serde_json::json!({"id": "c", "label": "Gamma", "community": 1}),
805        ];
806        let labels = build_community_labels(&nodes, 4);
807        let c0: std::collections::HashSet<&str> = labels[&0].iter().map(|s| s.as_str()).collect();
808        assert_eq!(c0, ["Alpha", "Beta"].iter().copied().collect());
809        assert_eq!(labels[&1], vec!["Gamma"]);
810    }
811
812    #[test]
813    fn test_build_community_labels_top_n_capped() {
814        let nodes: Vec<Value> = (0..10)
815            .map(|i| serde_json::json!({"id": i.to_string(), "label": format!("Node{i}"), "community": 0}))
816            .collect();
817        let labels = build_community_labels(&nodes, 4);
818        assert_eq!(labels[&0].len(), 4);
819    }
820
821    #[test]
822    fn test_build_community_labels_no_community_skipped() {
823        let nodes = vec![serde_json::json!({"id": "x", "label": "X"})];
824        assert!(build_community_labels(&nodes, 4).is_empty());
825    }
826
827    #[test]
828    fn test_build_community_labels_empty() {
829        assert!(build_community_labels(&[], 4).is_empty());
830    }
831
832    // ── _detect_default_branch ─────────────────────────────────────────────
833
834    #[test]
835    fn test_detect_default_branch_gh_returns_main() {
836        let json = r#"{"defaultBranchRef":{"name":"main"}}"#;
837        assert_eq!(detect_default_branch_from(Some(json), None), "main");
838    }
839
840    #[test]
841    fn test_detect_default_branch_falls_back_to_git_symbolic_ref() {
842        assert_eq!(
843            detect_default_branch_from(None, Some("refs/remotes/origin/develop\n")),
844            "develop"
845        );
846    }
847
848    #[test]
849    fn test_detect_default_branch_both_fail_returns_main() {
850        assert_eq!(detect_default_branch_from(None, None), "main");
851    }
852
853    #[test]
854    fn test_detect_default_branch_gh_empty_dict_falls_back() {
855        assert_eq!(
856            detect_default_branch_from(Some("{}"), Some("refs/remotes/origin/trunk\n")),
857            "trunk"
858        );
859    }
860
861    #[test]
862    fn test_detect_default_branch_git_empty_ref_returns_main() {
863        assert_eq!(detect_default_branch_from(None, Some("\n")), "main");
864    }
865}