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 #[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 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 #[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 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 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 let m = parse_worktree_output("", false);
733 assert!(m.is_empty());
734 }
735
736 #[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 #[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 #[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}