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