Skip to main content

plan_tooling/
split_prs.rs

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    // Deterministic group mode requires full explicit mappings.
355    // Auto group mode can derive missing assignments from topology/conflict signals.
356    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    // Anchor selection is deterministic because records are emitted in stable sprint/task order.
704    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}