1use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4
5use serde::Serialize;
6
7use crate::parse::{Plan, Sprint, parse_plan_with_display};
8
9const USAGE: &str = r#"Usage:
10 plan-tooling split-prs --file <plan.md> --pr-grouping <per-sprint|group> [options]
11
12Purpose:
13 Build task-to-PR split records from a Plan Format v1 file.
14
15Required:
16 --file <path> Plan file to parse
17 --pr-grouping <mode> per-sprint | group
18
19Options:
20 --scope <plan|sprint> Scope to split (default: sprint)
21 --sprint <n> Sprint number when --scope sprint
22 --pr-group <task=group> Group pin; repeatable (group mode only)
23 deterministic/group: required for every task
24 auto/group: optional pins + auto assignment for remaining tasks
25 --strategy <deterministic|auto> Split strategy (default: deterministic)
26 --owner-prefix <text> Owner prefix (default: subagent)
27 --branch-prefix <text> Branch prefix (default: issue)
28 --worktree-prefix <text> Worktree prefix (default: issue__)
29 --format <json|tsv> Output format (default: json)
30 -h, --help Show help
31
32Exit:
33 0: success
34 1: runtime or validation error
35 2: usage error
36"#;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum SplitScope {
40 Plan,
41 Sprint(i32),
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum SplitPrGrouping {
46 PerSprint,
47 Group,
48}
49
50impl SplitPrGrouping {
51 pub fn as_str(self) -> &'static str {
52 match self {
53 Self::PerSprint => "per-sprint",
54 Self::Group => "group",
55 }
56 }
57
58 fn from_cli(value: &str) -> Option<Self> {
59 match value {
60 "per-sprint" => Some(Self::PerSprint),
61 "group" => Some(Self::Group),
62 _ => None,
63 }
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum SplitPrStrategy {
69 Deterministic,
70 Auto,
71}
72
73impl SplitPrStrategy {
74 pub fn as_str(self) -> &'static str {
75 match self {
76 Self::Deterministic => "deterministic",
77 Self::Auto => "auto",
78 }
79 }
80
81 fn from_cli(value: &str) -> Option<Self> {
82 match value {
83 "deterministic" => Some(Self::Deterministic),
84 "auto" => Some(Self::Auto),
85 _ => None,
86 }
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct SplitPlanOptions {
92 pub pr_grouping: SplitPrGrouping,
93 pub strategy: SplitPrStrategy,
94 pub pr_group_entries: Vec<String>,
95 pub owner_prefix: String,
96 pub branch_prefix: String,
97 pub worktree_prefix: String,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct SplitPlanRecord {
102 pub task_id: String,
103 pub sprint: i32,
104 pub summary: String,
105 pub branch: String,
106 pub worktree: String,
107 pub owner: String,
108 pub notes: String,
109 pub pr_group: String,
110}
111
112#[derive(Debug, Clone)]
113struct Record {
114 task_id: String,
115 plan_task_id: String,
116 sprint: i32,
117 summary: String,
118 branch: String,
119 worktree: String,
120 owner: String,
121 notes_parts: Vec<String>,
122 complexity: i32,
123 location_paths: Vec<String>,
124 dependency_keys: Vec<String>,
125 pr_group: String,
126}
127
128#[derive(Debug, Serialize)]
129struct Output {
130 file: String,
131 scope: String,
132 sprint: Option<i32>,
133 pr_grouping: String,
134 strategy: String,
135 records: Vec<OutputRecord>,
136}
137
138#[derive(Debug, Serialize, PartialEq, Eq)]
139struct OutputRecord {
140 task_id: String,
141 summary: String,
142 branch: String,
143 worktree: String,
144 owner: String,
145 notes: String,
146 pr_group: String,
147}
148
149pub fn run(args: &[String]) -> i32 {
150 let mut file: Option<String> = None;
151 let mut scope = String::from("sprint");
152 let mut sprint: Option<String> = None;
153 let mut pr_grouping: Option<String> = None;
154 let mut pr_group_entries: Vec<String> = Vec::new();
155 let mut strategy = String::from("deterministic");
156 let mut owner_prefix = String::from("subagent");
157 let mut branch_prefix = String::from("issue");
158 let mut worktree_prefix = String::from("issue__");
159 let mut format = String::from("json");
160
161 let mut i = 0usize;
162 while i < args.len() {
163 match args[i].as_str() {
164 "--file" => {
165 let Some(v) = args.get(i + 1) else {
166 return die("missing value for --file");
167 };
168 if v.is_empty() {
169 return die("missing value for --file");
170 }
171 file = Some(v.to_string());
172 i += 2;
173 }
174 "--scope" => {
175 let Some(v) = args.get(i + 1) else {
176 return die("missing value for --scope");
177 };
178 if v.is_empty() {
179 return die("missing value for --scope");
180 }
181 scope = v.to_string();
182 i += 2;
183 }
184 "--sprint" => {
185 let Some(v) = args.get(i + 1) else {
186 return die("missing value for --sprint");
187 };
188 if v.is_empty() {
189 return die("missing value for --sprint");
190 }
191 sprint = Some(v.to_string());
192 i += 2;
193 }
194 "--pr-grouping" => {
195 let Some(v) = args.get(i + 1) else {
196 return die("missing value for --pr-grouping");
197 };
198 if v.is_empty() {
199 return die("missing value for --pr-grouping");
200 }
201 pr_grouping = Some(v.to_string());
202 i += 2;
203 }
204 "--pr-group" => {
205 let Some(v) = args.get(i + 1) else {
206 return die("missing value for --pr-group");
207 };
208 if v.is_empty() {
209 return die("missing value for --pr-group");
210 }
211 pr_group_entries.push(v.to_string());
212 i += 2;
213 }
214 "--strategy" => {
215 let Some(v) = args.get(i + 1) else {
216 return die("missing value for --strategy");
217 };
218 if v.is_empty() {
219 return die("missing value for --strategy");
220 }
221 strategy = v.to_string();
222 i += 2;
223 }
224 "--owner-prefix" => {
225 let Some(v) = args.get(i + 1) else {
226 return die("missing value for --owner-prefix");
227 };
228 if v.is_empty() {
229 return die("missing value for --owner-prefix");
230 }
231 owner_prefix = v.to_string();
232 i += 2;
233 }
234 "--branch-prefix" => {
235 let Some(v) = args.get(i + 1) else {
236 return die("missing value for --branch-prefix");
237 };
238 if v.is_empty() {
239 return die("missing value for --branch-prefix");
240 }
241 branch_prefix = v.to_string();
242 i += 2;
243 }
244 "--worktree-prefix" => {
245 let Some(v) = args.get(i + 1) else {
246 return die("missing value for --worktree-prefix");
247 };
248 if v.is_empty() {
249 return die("missing value for --worktree-prefix");
250 }
251 worktree_prefix = v.to_string();
252 i += 2;
253 }
254 "--format" => {
255 let Some(v) = args.get(i + 1) else {
256 return die("missing value for --format");
257 };
258 if v.is_empty() {
259 return die("missing value for --format");
260 }
261 format = v.to_string();
262 i += 2;
263 }
264 "-h" | "--help" => {
265 print_usage();
266 return 0;
267 }
268 other => {
269 return die(&format!("unknown argument: {other}"));
270 }
271 }
272 }
273
274 let Some(file_arg) = file else {
275 print_usage();
276 return 2;
277 };
278 let Some(mut pr_grouping) = pr_grouping else {
279 print_usage();
280 return 2;
281 };
282
283 if pr_grouping == "per-spring" {
284 pr_grouping = String::from("per-sprint");
285 }
286 if scope != "plan" && scope != "sprint" {
287 return die(&format!(
288 "invalid --scope (expected plan|sprint): {}",
289 crate::repr::py_repr(&scope)
290 ));
291 }
292 if pr_grouping != "per-sprint" && pr_grouping != "group" {
293 return die(&format!(
294 "invalid --pr-grouping (expected per-sprint|group): {}",
295 crate::repr::py_repr(&pr_grouping)
296 ));
297 }
298 if strategy != "deterministic" && strategy != "auto" {
299 return die(&format!(
300 "invalid --strategy (expected deterministic|auto): {}",
301 crate::repr::py_repr(&strategy)
302 ));
303 }
304 if format != "json" && format != "tsv" {
305 return die(&format!(
306 "invalid --format (expected json|tsv): {}",
307 crate::repr::py_repr(&format)
308 ));
309 }
310
311 let sprint_num = if scope == "sprint" {
312 let Some(raw) = sprint.as_deref() else {
313 return die("--sprint is required when --scope sprint");
314 };
315 match raw.parse::<i32>() {
316 Ok(v) if v > 0 => Some(v),
317 _ => {
318 eprintln!(
319 "error: invalid --sprint (expected positive int): {}",
320 crate::repr::py_repr(raw)
321 );
322 return 2;
323 }
324 }
325 } else {
326 None
327 };
328
329 if pr_grouping == "group" && strategy == "deterministic" && pr_group_entries.is_empty() {
332 return die(
333 "--pr-grouping group requires at least one --pr-group <task-or-plan-id>=<group> entry",
334 );
335 }
336 if pr_grouping != "group" && !pr_group_entries.is_empty() {
337 return die("--pr-group can only be used when --pr-grouping group");
338 }
339
340 let repo_root = crate::repo_root::detect();
341 let display_path = file_arg.clone();
342 let read_path = resolve_repo_relative(&repo_root, Path::new(&file_arg));
343 if !read_path.is_file() {
344 eprintln!("error: plan file not found: {display_path}");
345 return 1;
346 }
347
348 let plan: Plan;
349 let parse_errors: Vec<String>;
350 match parse_plan_with_display(&read_path, &display_path) {
351 Ok((p, errs)) => {
352 plan = p;
353 parse_errors = errs;
354 }
355 Err(err) => {
356 eprintln!("error: {display_path}: {err}");
357 return 1;
358 }
359 }
360 if !parse_errors.is_empty() {
361 for err in parse_errors {
362 eprintln!("error: {display_path}: error: {err}");
363 }
364 return 1;
365 }
366
367 let split_scope = match scope.as_str() {
368 "plan" => SplitScope::Plan,
369 "sprint" => {
370 let Some(want) = sprint_num else {
371 return die("internal error: missing sprint number");
372 };
373 SplitScope::Sprint(want)
374 }
375 _ => return die("internal error: invalid scope"),
376 };
377 let Some(grouping_mode) = SplitPrGrouping::from_cli(&pr_grouping) else {
378 return die("internal error: invalid pr-grouping");
379 };
380 let Some(strategy_mode) = SplitPrStrategy::from_cli(&strategy) else {
381 return die("internal error: invalid strategy");
382 };
383
384 let selected_sprints = match select_sprints_for_scope(&plan, split_scope) {
385 Ok(sprints) => sprints,
386 Err(err) => {
387 eprintln!("error: {display_path}: {err}");
388 return 1;
389 }
390 };
391
392 let options = SplitPlanOptions {
393 pr_grouping: grouping_mode,
394 strategy: strategy_mode,
395 pr_group_entries,
396 owner_prefix,
397 branch_prefix,
398 worktree_prefix,
399 };
400 let split_records = match build_split_plan_records(&selected_sprints, &options) {
401 Ok(records) => records,
402 Err(err) => {
403 eprintln!("error: {err}");
404 return 1;
405 }
406 };
407
408 let out_records: Vec<OutputRecord> = split_records
409 .iter()
410 .map(OutputRecord::from_split_record)
411 .collect();
412
413 if format == "tsv" {
414 print_tsv(&out_records);
415 return 0;
416 }
417
418 let output = Output {
419 file: path_to_posix(&maybe_relativize(&read_path, &repo_root)),
420 scope: scope.clone(),
421 sprint: sprint_num,
422 pr_grouping,
423 strategy,
424 records: out_records,
425 };
426 match serde_json::to_string(&output) {
427 Ok(json) => {
428 println!("{json}");
429 0
430 }
431 Err(err) => {
432 eprintln!("error: failed to encode JSON: {err}");
433 1
434 }
435 }
436}
437
438impl OutputRecord {
439 fn from_split_record(record: &SplitPlanRecord) -> Self {
440 Self {
441 task_id: record.task_id.clone(),
442 summary: record.summary.clone(),
443 branch: record.branch.clone(),
444 worktree: record.worktree.clone(),
445 owner: record.owner.clone(),
446 notes: record.notes.clone(),
447 pr_group: record.pr_group.clone(),
448 }
449 }
450}
451
452pub fn select_sprints_for_scope(plan: &Plan, scope: SplitScope) -> Result<Vec<Sprint>, String> {
453 let selected = match scope {
454 SplitScope::Plan => plan
455 .sprints
456 .iter()
457 .filter(|s| !s.tasks.is_empty())
458 .cloned()
459 .collect::<Vec<_>>(),
460 SplitScope::Sprint(want) => match plan.sprints.iter().find(|s| s.number == want) {
461 Some(sprint) if !sprint.tasks.is_empty() => vec![sprint.clone()],
462 Some(_) => return Err(format!("sprint {want} has no tasks")),
463 None => return Err(format!("sprint not found: {want}")),
464 },
465 };
466 if selected.is_empty() {
467 return Err("selected scope has no tasks".to_string());
468 }
469 Ok(selected)
470}
471
472pub fn build_split_plan_records(
473 selected_sprints: &[Sprint],
474 options: &SplitPlanOptions,
475) -> Result<Vec<SplitPlanRecord>, String> {
476 if selected_sprints.is_empty() {
477 return Err("selected scope has no tasks".to_string());
478 }
479 if options.pr_grouping == SplitPrGrouping::Group
480 && options.strategy == SplitPrStrategy::Deterministic
481 && options.pr_group_entries.is_empty()
482 {
483 return Err(
484 "--pr-grouping group requires at least one --pr-group <task-or-plan-id>=<group> entry"
485 .to_string(),
486 );
487 }
488 if options.pr_grouping != SplitPrGrouping::Group && !options.pr_group_entries.is_empty() {
489 return Err("--pr-group can only be used when --pr-grouping group".to_string());
490 }
491
492 let branch_prefix_norm = normalize_branch_prefix(&options.branch_prefix);
493 let worktree_prefix_norm = normalize_worktree_prefix(&options.worktree_prefix);
494 let owner_prefix_norm = normalize_owner_prefix(&options.owner_prefix);
495
496 let mut records: Vec<Record> = Vec::new();
497 for sprint in selected_sprints {
498 for (idx, task) in sprint.tasks.iter().enumerate() {
499 let ordinal = idx + 1;
500 let task_id = format!("S{}T{ordinal}", sprint.number);
501 let plan_task_id = task.id.trim().to_string();
502 let summary = normalize_spaces(if task.name.trim().is_empty() {
503 if plan_task_id.is_empty() {
504 format!("sprint-{}-task-{ordinal}", sprint.number)
505 } else {
506 plan_task_id.clone()
507 }
508 } else {
509 task.name.trim().to_string()
510 });
511 let slug = normalize_token(&summary, &format!("task-{ordinal}"), 48);
512
513 let deps: Vec<String> = task
514 .dependencies
515 .clone()
516 .unwrap_or_default()
517 .into_iter()
518 .map(|d| d.trim().to_string())
519 .filter(|d| !d.is_empty())
520 .filter(|d| !is_placeholder(d))
521 .collect();
522 let location_paths: Vec<String> = task
523 .location
524 .iter()
525 .map(|p| p.trim().to_string())
526 .filter(|p| !p.is_empty())
527 .filter(|p| !is_placeholder(p))
528 .collect();
529 let complexity = match task.complexity {
530 Some(value) if value > 0 => value,
531 _ => 5,
532 };
533
534 let validations: Vec<String> = task
535 .validation
536 .iter()
537 .map(|v| v.trim().to_string())
538 .filter(|v| !v.is_empty())
539 .filter(|v| !is_placeholder(v))
540 .collect();
541
542 let mut notes_parts = vec![
543 format!("sprint=S{}", sprint.number),
544 format!(
545 "plan-task:{}",
546 if plan_task_id.is_empty() {
547 task_id.clone()
548 } else {
549 plan_task_id.clone()
550 }
551 ),
552 ];
553 if !deps.is_empty() {
554 notes_parts.push(format!("deps={}", deps.join(",")));
555 }
556 if let Some(first) = validations.first() {
557 notes_parts.push(format!("validate={first}"));
558 }
559
560 records.push(Record {
561 task_id,
562 plan_task_id,
563 sprint: sprint.number,
564 summary,
565 branch: format!("{branch_prefix_norm}/s{}-t{ordinal}-{slug}", sprint.number),
566 worktree: format!("{worktree_prefix_norm}-s{}-t{ordinal}", sprint.number),
567 owner: format!("{owner_prefix_norm}-s{}-t{ordinal}", sprint.number),
568 notes_parts,
569 complexity,
570 location_paths,
571 dependency_keys: deps,
572 pr_group: String::new(),
573 });
574 }
575 }
576
577 if records.is_empty() {
578 return Err("selected scope has no tasks".to_string());
579 }
580
581 let mut group_assignments: HashMap<String, String> = HashMap::new();
582 let mut assignment_sources: Vec<String> = Vec::new();
583 for entry in &options.pr_group_entries {
584 let trimmed = entry.trim();
585 if trimmed.is_empty() {
586 continue;
587 }
588 let Some((raw_key, raw_group)) = trimmed.split_once('=') else {
589 return Err("--pr-group must use <task-or-plan-id>=<group> format".to_string());
590 };
591 let key = raw_key.trim();
592 let group = normalize_token(raw_group.trim(), "", 48);
593 if key.is_empty() || group.is_empty() {
594 return Err("--pr-group must include both task key and group".to_string());
595 }
596 assignment_sources.push(key.to_string());
597 group_assignments.insert(key.to_ascii_lowercase(), group);
598 }
599
600 if options.pr_grouping == SplitPrGrouping::Group && !assignment_sources.is_empty() {
601 let mut known: HashMap<String, bool> = HashMap::new();
602 for rec in &records {
603 known.insert(rec.task_id.to_ascii_lowercase(), true);
604 if !rec.plan_task_id.is_empty() {
605 known.insert(rec.plan_task_id.to_ascii_lowercase(), true);
606 }
607 }
608
609 let unknown: Vec<String> = assignment_sources
610 .iter()
611 .filter(|key| !known.contains_key(&key.to_ascii_lowercase()))
612 .cloned()
613 .collect();
614 if !unknown.is_empty() {
615 return Err(format!(
616 "--pr-group references unknown task keys: {}",
617 unknown
618 .iter()
619 .take(5)
620 .cloned()
621 .collect::<Vec<_>>()
622 .join(", ")
623 ));
624 }
625 }
626
627 if options.pr_grouping == SplitPrGrouping::Group {
628 let mut missing: Vec<String> = Vec::new();
629 for rec in &mut records {
630 rec.pr_group.clear();
631 for key in [&rec.task_id, &rec.plan_task_id] {
632 if key.is_empty() {
633 continue;
634 }
635 if let Some(v) = group_assignments.get(&key.to_ascii_lowercase()) {
636 rec.pr_group = v.to_string();
637 break;
638 }
639 }
640 if rec.pr_group.is_empty() {
641 missing.push(rec.task_id.clone());
642 }
643 }
644 if options.strategy == SplitPrStrategy::Deterministic {
645 if !missing.is_empty() {
646 return Err(format!(
647 "--pr-grouping group requires explicit mapping for every task; missing: {}",
648 missing
649 .iter()
650 .take(8)
651 .cloned()
652 .collect::<Vec<_>>()
653 .join(", ")
654 ));
655 }
656 } else if !missing.is_empty() {
657 assign_auto_groups(&mut records);
658 }
659 } else {
660 for rec in &mut records {
661 rec.pr_group =
662 normalize_token(&format!("s{}", rec.sprint), &format!("s{}", rec.sprint), 48);
663 }
664 }
665
666 let mut group_sizes: HashMap<String, usize> = HashMap::new();
668 let mut group_anchor: HashMap<String, String> = HashMap::new();
669 for rec in &records {
670 let size = group_sizes.entry(rec.pr_group.clone()).or_insert(0);
671 *size += 1;
672 group_anchor
673 .entry(rec.pr_group.clone())
674 .or_insert_with(|| rec.task_id.clone());
675 }
676
677 let mut out: Vec<SplitPlanRecord> = Vec::new();
678 for rec in records {
679 let mut notes = rec.notes_parts.clone();
680 notes.push(format!("pr-grouping={}", options.pr_grouping.as_str()));
681 notes.push(format!("pr-group={}", rec.pr_group));
682 if group_sizes.get(&rec.pr_group).copied().unwrap_or(0) > 1
683 && let Some(anchor) = group_anchor.get(&rec.pr_group)
684 {
685 notes.push(format!("shared-pr-anchor={anchor}"));
686 }
687 out.push(SplitPlanRecord {
688 task_id: rec.task_id,
689 sprint: rec.sprint,
690 summary: rec.summary,
691 branch: rec.branch,
692 worktree: rec.worktree,
693 owner: rec.owner,
694 notes: notes.join("; "),
695 pr_group: rec.pr_group,
696 });
697 }
698
699 Ok(out)
700}
701
702#[derive(Debug)]
703struct AutoMergeCandidate {
704 i: usize,
705 j: usize,
706 score_key: i64,
707 key_a: String,
708 key_b: String,
709}
710
711fn assign_auto_groups(records: &mut [Record]) {
712 let mut sprint_to_indices: BTreeMap<i32, Vec<usize>> = BTreeMap::new();
713 for (idx, rec) in records.iter().enumerate() {
714 if rec.pr_group.is_empty() {
715 sprint_to_indices.entry(rec.sprint).or_default().push(idx);
716 }
717 }
718
719 for (sprint, indices) in sprint_to_indices {
720 let assignments = auto_groups_for_sprint(records, sprint, &indices);
721 for (idx, group) in assignments {
722 if let Some(rec) = records.get_mut(idx)
723 && rec.pr_group.is_empty()
724 {
725 rec.pr_group = group;
726 }
727 }
728 }
729}
730
731fn auto_groups_for_sprint(
732 records: &[Record],
733 sprint: i32,
734 indices: &[usize],
735) -> BTreeMap<usize, String> {
736 let mut lookup: HashMap<String, usize> = HashMap::new();
737 for idx in indices {
738 let rec = &records[*idx];
739 lookup.insert(rec.task_id.to_ascii_lowercase(), *idx);
740 if !rec.plan_task_id.is_empty() {
741 lookup.insert(rec.plan_task_id.to_ascii_lowercase(), *idx);
742 }
743 }
744
745 let mut deps: BTreeMap<usize, BTreeSet<usize>> = BTreeMap::new();
746 let mut paths: BTreeMap<usize, BTreeSet<String>> = BTreeMap::new();
747 for idx in indices {
748 let rec = &records[*idx];
749 let mut resolved_deps: BTreeSet<usize> = BTreeSet::new();
750 for dep in &rec.dependency_keys {
751 let dep_key = dep.trim().to_ascii_lowercase();
752 if dep_key.is_empty() {
753 continue;
754 }
755 if let Some(dep_idx) = lookup.get(&dep_key)
756 && dep_idx != idx
757 {
758 resolved_deps.insert(*dep_idx);
759 }
760 }
761 deps.insert(*idx, resolved_deps);
762
763 let normalized_paths: BTreeSet<String> = rec
764 .location_paths
765 .iter()
766 .map(|path| normalize_location_path(path))
767 .filter(|path| !path.is_empty())
768 .collect();
769 paths.insert(*idx, normalized_paths);
770 }
771
772 let batch_by_idx = compute_batch_index(records, indices, &deps);
773 let mut parent: HashMap<usize, usize> = indices.iter().copied().map(|idx| (idx, idx)).collect();
774
775 let mut by_batch: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
776 for idx in indices {
777 let batch = batch_by_idx.get(idx).copied().unwrap_or(0);
778 by_batch.entry(batch).or_default().push(*idx);
779 }
780
781 for members in by_batch.values_mut() {
782 members.sort_by_key(|idx| task_sort_key(records, *idx));
783
784 let mut path_to_members: BTreeMap<String, Vec<usize>> = BTreeMap::new();
785 for idx in members {
786 for path in paths.get(idx).into_iter().flatten() {
787 path_to_members.entry(path.clone()).or_default().push(*idx);
788 }
789 }
790 for overlap_members in path_to_members.values() {
791 if overlap_members.len() < 2 {
792 continue;
793 }
794 let first = overlap_members[0];
795 for other in overlap_members.iter().skip(1) {
796 uf_union(&mut parent, first, *other);
797 }
798 }
799 }
800
801 let mut grouped: BTreeMap<usize, BTreeSet<usize>> = BTreeMap::new();
802 for idx in indices {
803 let root = uf_find(&mut parent, *idx);
804 grouped.entry(root).or_default().insert(*idx);
805 }
806 let mut groups: Vec<BTreeSet<usize>> = grouped.into_values().collect();
807
808 loop {
809 let mut candidates: Vec<AutoMergeCandidate> = Vec::new();
810 for i in 0..groups.len() {
811 for j in (i + 1)..groups.len() {
812 let merged_complexity =
813 group_complexity(records, &groups[i]) + group_complexity(records, &groups[j]);
814 if merged_complexity > 20 {
815 continue;
816 }
817
818 let dep_cross = dependency_cross_edges(&deps, &groups[i], &groups[j]);
819 let overlap_paths = overlap_path_count(&paths, &groups[i], &groups[j]);
820 let min_group_size = groups[i].len().min(groups[j].len()).max(1) as f64;
821 let dep_affinity = ((dep_cross as f64) / min_group_size).min(1.0);
822 let ovl_affinity = ((overlap_paths as f64) / 2.0).min(1.0);
823 let size_fit = (1.0 - ((merged_complexity as f64 - 12.0).abs() / 12.0)).max(0.0);
824 let span = group_span(&batch_by_idx, &groups[i], &groups[j]);
825 let serial_penalty = ((span as f64 - 1.0).max(0.0)) / 3.0;
826 let oversize_penalty = ((merged_complexity as f64 - 20.0).max(0.0)) / 20.0;
827
828 let score = (0.45 * dep_affinity) + (0.35 * ovl_affinity) + (0.20 * size_fit)
829 - (0.25 * serial_penalty)
830 - (0.45 * oversize_penalty);
831 if score < 0.30 {
832 continue;
833 }
834
835 let mut key_a = group_min_task_key(records, &groups[i]);
836 let mut key_b = group_min_task_key(records, &groups[j]);
837 if key_b < key_a {
838 std::mem::swap(&mut key_a, &mut key_b);
839 }
840 candidates.push(AutoMergeCandidate {
841 i,
842 j,
843 score_key: (score * 1_000_000.0).round() as i64,
844 key_a,
845 key_b,
846 });
847 }
848 }
849
850 if candidates.is_empty() {
851 break;
852 }
853
854 candidates.sort_by(|a, b| {
855 b.score_key
856 .cmp(&a.score_key)
857 .then_with(|| a.key_a.cmp(&b.key_a))
858 .then_with(|| a.key_b.cmp(&b.key_b))
859 .then_with(|| a.i.cmp(&b.i))
860 .then_with(|| a.j.cmp(&b.j))
861 });
862 let chosen = &candidates[0];
863
864 let mut merged = groups[chosen.i].clone();
865 merged.extend(groups[chosen.j].iter().copied());
866 groups[chosen.i] = merged;
867 groups.remove(chosen.j);
868 }
869
870 groups.sort_by(|a, b| {
871 group_min_batch(&batch_by_idx, a)
872 .cmp(&group_min_batch(&batch_by_idx, b))
873 .then_with(|| group_min_task_key(records, a).cmp(&group_min_task_key(records, b)))
874 });
875
876 let mut out: BTreeMap<usize, String> = BTreeMap::new();
877 for (idx, group) in groups.iter().enumerate() {
878 let fallback = format!("s{sprint}-auto-g{}", idx + 1);
879 let group_key = normalize_token(&fallback, &fallback, 48);
880 for member in group {
881 out.insert(*member, group_key.clone());
882 }
883 }
884 out
885}
886
887fn compute_batch_index(
888 records: &[Record],
889 indices: &[usize],
890 deps: &BTreeMap<usize, BTreeSet<usize>>,
891) -> BTreeMap<usize, usize> {
892 let mut in_deg: HashMap<usize, usize> = indices.iter().copied().map(|idx| (idx, 0)).collect();
893 let mut reverse: HashMap<usize, BTreeSet<usize>> = indices
894 .iter()
895 .copied()
896 .map(|idx| (idx, BTreeSet::new()))
897 .collect();
898
899 for idx in indices {
900 for dep in deps.get(idx).cloned().unwrap_or_default() {
901 if !in_deg.contains_key(&dep) {
902 continue;
903 }
904 if let Some(value) = in_deg.get_mut(idx) {
905 *value += 1;
906 }
907 if let Some(children) = reverse.get_mut(&dep) {
908 children.insert(*idx);
909 }
910 }
911 }
912
913 let mut remaining: BTreeSet<usize> = indices.iter().copied().collect();
914 let mut batch_by_idx: BTreeMap<usize, usize> = BTreeMap::new();
915 let mut layer = 0usize;
916 let mut ready: VecDeque<usize> = {
917 let mut start: Vec<usize> = indices
918 .iter()
919 .copied()
920 .filter(|idx| in_deg.get(idx).copied().unwrap_or(0) == 0)
921 .collect();
922 start.sort_by_key(|idx| task_sort_key(records, *idx));
923 start.into_iter().collect()
924 };
925
926 while !remaining.is_empty() {
927 let mut batch_members: Vec<usize> = ready.drain(..).collect();
928 batch_members.sort_by_key(|idx| task_sort_key(records, *idx));
929
930 if batch_members.is_empty() {
931 let mut cycle_members: Vec<usize> = remaining.iter().copied().collect();
932 cycle_members.sort_by_key(|idx| task_sort_key(records, *idx));
933 for idx in cycle_members {
934 remaining.remove(&idx);
935 batch_by_idx.insert(idx, layer);
936 }
937 break;
938 }
939
940 for idx in &batch_members {
941 remaining.remove(idx);
942 batch_by_idx.insert(*idx, layer);
943 }
944
945 let mut next: Vec<usize> = Vec::new();
946 for idx in batch_members {
947 for child in reverse.get(&idx).cloned().unwrap_or_default() {
948 if let Some(value) = in_deg.get_mut(&child) {
949 *value = value.saturating_sub(1);
950 if *value == 0 && remaining.contains(&child) {
951 next.push(child);
952 }
953 }
954 }
955 }
956 next.sort_by_key(|idx| task_sort_key(records, *idx));
957 next.dedup();
958 ready.extend(next);
959 layer += 1;
960 }
961
962 for idx in indices {
963 batch_by_idx.entry(*idx).or_insert(0);
964 }
965 batch_by_idx
966}
967
968fn task_sort_key(records: &[Record], idx: usize) -> (String, String) {
969 let rec = &records[idx];
970 let primary = if rec.plan_task_id.trim().is_empty() {
971 rec.task_id.to_ascii_lowercase()
972 } else {
973 rec.plan_task_id.to_ascii_lowercase()
974 };
975 (primary, rec.task_id.to_ascii_lowercase())
976}
977
978fn normalize_location_path(path: &str) -> String {
979 path.split_whitespace()
980 .collect::<Vec<_>>()
981 .join(" ")
982 .to_ascii_lowercase()
983}
984
985fn group_complexity(records: &[Record], group: &BTreeSet<usize>) -> i32 {
986 group
987 .iter()
988 .map(|idx| records[*idx].complexity.max(1))
989 .sum::<i32>()
990}
991
992fn group_min_task_key(records: &[Record], group: &BTreeSet<usize>) -> String {
993 group
994 .iter()
995 .map(|idx| task_sort_key(records, *idx).0)
996 .min()
997 .unwrap_or_default()
998}
999
1000fn group_min_batch(batch_by_idx: &BTreeMap<usize, usize>, group: &BTreeSet<usize>) -> usize {
1001 group
1002 .iter()
1003 .filter_map(|idx| batch_by_idx.get(idx).copied())
1004 .min()
1005 .unwrap_or(0)
1006}
1007
1008fn group_span(
1009 batch_by_idx: &BTreeMap<usize, usize>,
1010 left: &BTreeSet<usize>,
1011 right: &BTreeSet<usize>,
1012) -> usize {
1013 let mut min_batch = usize::MAX;
1014 let mut max_batch = 0usize;
1015 for idx in left.union(right) {
1016 let batch = batch_by_idx.get(idx).copied().unwrap_or(0);
1017 min_batch = min_batch.min(batch);
1018 max_batch = max_batch.max(batch);
1019 }
1020 if min_batch == usize::MAX {
1021 0
1022 } else {
1023 max_batch.saturating_sub(min_batch)
1024 }
1025}
1026
1027fn dependency_cross_edges(
1028 deps: &BTreeMap<usize, BTreeSet<usize>>,
1029 left: &BTreeSet<usize>,
1030 right: &BTreeSet<usize>,
1031) -> usize {
1032 let mut count = 0usize;
1033 for src in left {
1034 if let Some(edges) = deps.get(src) {
1035 count += edges.iter().filter(|dep| right.contains(dep)).count();
1036 }
1037 }
1038 for src in right {
1039 if let Some(edges) = deps.get(src) {
1040 count += edges.iter().filter(|dep| left.contains(dep)).count();
1041 }
1042 }
1043 count
1044}
1045
1046fn overlap_path_count(
1047 paths: &BTreeMap<usize, BTreeSet<String>>,
1048 left: &BTreeSet<usize>,
1049 right: &BTreeSet<usize>,
1050) -> usize {
1051 let mut left_paths: BTreeSet<String> = BTreeSet::new();
1052 let mut right_paths: BTreeSet<String> = BTreeSet::new();
1053 for idx in left {
1054 for path in paths.get(idx).into_iter().flatten() {
1055 left_paths.insert(path.clone());
1056 }
1057 }
1058 for idx in right {
1059 for path in paths.get(idx).into_iter().flatten() {
1060 right_paths.insert(path.clone());
1061 }
1062 }
1063 left_paths.intersection(&right_paths).count()
1064}
1065
1066fn uf_find(parent: &mut HashMap<usize, usize>, node: usize) -> usize {
1067 let parent_node = parent.get(&node).copied().unwrap_or(node);
1068 if parent_node == node {
1069 return node;
1070 }
1071 let root = uf_find(parent, parent_node);
1072 parent.insert(node, root);
1073 root
1074}
1075
1076fn uf_union(parent: &mut HashMap<usize, usize>, left: usize, right: usize) {
1077 let left_root = uf_find(parent, left);
1078 let right_root = uf_find(parent, right);
1079 if left_root == right_root {
1080 return;
1081 }
1082 if left_root < right_root {
1083 parent.insert(right_root, left_root);
1084 } else {
1085 parent.insert(left_root, right_root);
1086 }
1087}
1088
1089fn print_tsv(records: &[OutputRecord]) {
1090 println!("# task_id\tsummary\tbranch\tworktree\towner\tnotes\tpr_group");
1091 for rec in records {
1092 println!(
1093 "{}\t{}\t{}\t{}\t{}\t{}\t{}",
1094 rec.task_id.replace('\t', " "),
1095 rec.summary.replace('\t', " "),
1096 rec.branch.replace('\t', " "),
1097 rec.worktree.replace('\t', " "),
1098 rec.owner.replace('\t', " "),
1099 rec.notes.replace('\t', " "),
1100 rec.pr_group.replace('\t', " "),
1101 );
1102 }
1103}
1104
1105fn print_usage() {
1106 let _ = std::io::stderr().write_all(USAGE.as_bytes());
1107}
1108
1109fn die(msg: &str) -> i32 {
1110 eprintln!("split-prs: {msg}");
1111 2
1112}
1113
1114fn resolve_repo_relative(repo_root: &Path, path: &Path) -> PathBuf {
1115 if path.is_absolute() {
1116 return path.to_path_buf();
1117 }
1118 repo_root.join(path)
1119}
1120
1121fn maybe_relativize(path: &Path, repo_root: &Path) -> PathBuf {
1122 let Ok(path_abs) = path.canonicalize() else {
1123 return path.to_path_buf();
1124 };
1125 let Ok(root_abs) = repo_root.canonicalize() else {
1126 return path_abs;
1127 };
1128 match path_abs.strip_prefix(&root_abs) {
1129 Ok(rel) => rel.to_path_buf(),
1130 Err(_) => path_abs,
1131 }
1132}
1133
1134fn path_to_posix(path: &Path) -> String {
1135 path.to_string_lossy()
1136 .replace(std::path::MAIN_SEPARATOR, "/")
1137}
1138
1139fn normalize_branch_prefix(value: &str) -> String {
1140 let trimmed = value.trim().trim_end_matches('/');
1141 if trimmed.is_empty() {
1142 "issue".to_string()
1143 } else {
1144 trimmed.to_string()
1145 }
1146}
1147
1148fn normalize_worktree_prefix(value: &str) -> String {
1149 let trimmed = value.trim().trim_end_matches(['-', '_']);
1150 if trimmed.is_empty() {
1151 "issue".to_string()
1152 } else {
1153 trimmed.to_string()
1154 }
1155}
1156
1157fn normalize_owner_prefix(value: &str) -> String {
1158 let trimmed = value.trim();
1159 if trimmed.is_empty() {
1160 "subagent".to_string()
1161 } else if trimmed.to_ascii_lowercase().contains("subagent") {
1162 trimmed.to_string()
1163 } else {
1164 format!("subagent-{trimmed}")
1165 }
1166}
1167
1168fn normalize_spaces(value: String) -> String {
1169 let joined = value.split_whitespace().collect::<Vec<_>>().join(" ");
1170 if joined.is_empty() {
1171 String::from("task")
1172 } else {
1173 joined
1174 }
1175}
1176
1177fn normalize_token(value: &str, fallback: &str, max_len: usize) -> String {
1178 let mut out = String::new();
1179 let mut last_dash = false;
1180 for ch in value.chars().flat_map(char::to_lowercase) {
1181 if ch.is_ascii_alphanumeric() {
1182 out.push(ch);
1183 last_dash = false;
1184 } else if !last_dash {
1185 out.push('-');
1186 last_dash = true;
1187 }
1188 }
1189 let normalized = out.trim_matches('-').to_string();
1190 let mut final_token = if normalized.is_empty() {
1191 fallback.to_string()
1192 } else {
1193 normalized
1194 };
1195 if final_token.len() > max_len {
1196 final_token.truncate(max_len);
1197 final_token = final_token.trim_matches('-').to_string();
1198 }
1199 final_token
1200}
1201
1202fn is_placeholder(value: &str) -> bool {
1203 let token = value.trim().to_ascii_lowercase();
1204 if matches!(token.as_str(), "" | "-" | "none" | "n/a" | "na" | "...") {
1205 return true;
1206 }
1207 if token.starts_with('<') && token.ends_with('>') {
1208 return true;
1209 }
1210 token.contains("task ids")
1211}
1212
1213#[cfg(test)]
1214mod tests {
1215 use super::{is_placeholder, normalize_token};
1216 use pretty_assertions::assert_eq;
1217
1218 #[test]
1219 fn normalize_token_collapses_non_alnum_and_limits_length() {
1220 assert_eq!(
1221 normalize_token("Sprint 2 :: Shared Pair", "fallback", 20),
1222 "sprint-2-shared-pair"
1223 );
1224 assert_eq!(normalize_token("!!!!", "fallback-value", 8), "fallback");
1225 }
1226
1227 #[test]
1228 fn placeholder_rules_cover_common_plan_values() {
1229 assert!(is_placeholder("none"));
1230 assert!(is_placeholder("<task ids>"));
1231 assert!(is_placeholder("Task IDs here"));
1232 assert!(!is_placeholder("Task 1.1"));
1233 }
1234}