Skip to main content

orch/
plan.rs

1//! Roadmap files and the issue plan `orch plan import` applies.
2//!
3//! A milestone is `## M1. Title`. A task is `### M1-03 Title` with Goal, Scope,
4//! Acceptance, Depends on and Owner lines. Later milestones may be a table with
5//! those columns. `none` is no dependency.
6
7use std::collections::{HashMap, HashSet};
8
9pub const PLAN_LABEL: &str = "orch-plan";
10pub const HUMAN_LABEL: &str = "owner:human";
11pub const NO_AGENT_LABEL: &str = "no-agent";
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct PlanError {
15    pub message: String,
16}
17
18impl std::fmt::Display for PlanError {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        f.write_str(&self.message)
21    }
22}
23
24impl std::error::Error for PlanError {}
25
26impl PlanError {
27    fn new(message: impl Into<String>) -> Self {
28        Self {
29            message: message.into(),
30        }
31    }
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Milestone {
36    pub id: String,
37    pub title: String,
38    /// Text after `## `, used for the GitHub heading anchor
39    pub heading: String,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Task {
44    pub id: String,
45    pub title: String,
46    pub milestone_id: String,
47    pub milestone_title: String,
48    pub milestone_heading: String,
49    /// Full `###` heading. Empty for a table row, which links to the milestone heading
50    pub anchor_heading: String,
51    pub goal: String,
52    pub scope: String,
53    pub acceptance: Vec<String>,
54    pub depends_on: Vec<String>,
55    pub owner_human: bool,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Roadmap {
60    pub milestones: Vec<Milestone>,
61    pub tasks: Vec<Task>,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum ChangeKind {
66    Create,
67    Update,
68    Unchanged,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct ExistingIssue {
73    pub task_id: String,
74    pub number: u64,
75    pub database_id: u64,
76    pub title: String,
77    pub body: String,
78    pub labels: Vec<String>,
79    pub state: String,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct TaskPlan {
84    pub task: Task,
85    pub kind: ChangeKind,
86    pub number: Option<u64>,
87    pub database_id: Option<u64>,
88    pub existing_labels: Vec<String>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct RemovedTask {
93    pub task_id: String,
94    pub number: u64,
95    pub state: String,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct PlanDiff {
100    pub tasks: Vec<TaskPlan>,
101    pub removed: Vec<RemovedTask>,
102}
103
104/// A selected task depends on a roadmap id outside the import that has no issue yet
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct UnmetExternal {
107    pub task_id: String,
108    pub blocking_task_id: String,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct Binding {
113    pub task_id: String,
114    pub number: Option<u64>,
115    pub database_id: Option<u64>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct DepRef {
120    pub number: u64,
121    pub database_id: u64,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum LinkEdit {
126    Add {
127        task_id: String,
128        issue_number: Option<u64>,
129        blocking_task_id: String,
130        blocking_number: Option<u64>,
131        blocking_database_id: Option<u64>,
132    },
133    Remove {
134        task_id: String,
135        issue_number: u64,
136        blocking_number: u64,
137        blocking_database_id: u64,
138    },
139}
140
141struct Draft {
142    id: String,
143    title: String,
144    milestone_id: String,
145    milestone_title: String,
146    milestone_heading: String,
147    anchor_heading: String,
148    goal: String,
149    scope: String,
150    acceptance: Vec<String>,
151    depends_raw: Option<String>,
152    owner_human: bool,
153    in_acceptance: bool,
154    line: usize,
155}
156
157#[derive(Clone, Copy)]
158struct TableCols {
159    task: usize,
160    goal: Option<usize>,
161    scope: Option<usize>,
162    acceptance: Option<usize>,
163    depends: Option<usize>,
164    owner: Option<usize>,
165}
166
167pub fn parse_roadmap(markdown: &str) -> Result<Roadmap, PlanError> {
168    let mut milestones = Vec::new();
169    let mut tasks = Vec::new();
170    let mut errors = Vec::new();
171    let mut current: Option<Milestone> = None;
172    let mut draft: Option<Draft> = None;
173    let mut table: Option<TableCols> = None;
174    let mut seen = HashSet::new();
175
176    for (idx, raw) in markdown.lines().enumerate() {
177        let line = raw.trim_end_matches('\r');
178        let line_no = idx + 1;
179
180        if let Some(cols) = table {
181            if line.trim().starts_with('|') {
182                if !is_separator_row(line) {
183                    if let Some(milestone) = current.as_ref() {
184                        match task_from_row(line, &cols, milestone, line_no) {
185                            Ok(Some(task)) => {
186                                push_task(task, &mut tasks, &mut seen, &mut errors);
187                            }
188                            Ok(None) => {}
189                            Err(err) => errors.push(err),
190                        }
191                    }
192                }
193                continue;
194            }
195            table = None;
196        }
197
198        if let Some(rest) = line.strip_prefix("### ") {
199            flush_draft(&mut draft, &mut tasks, &mut seen, &mut errors);
200            let Some(milestone) = current.as_ref() else {
201                errors.push(format!(
202                    "line {line_no}: task heading is outside a milestone"
203                ));
204                continue;
205            };
206            if let Some((id, title)) = parse_task_heading(rest) {
207                draft = Some(Draft::new(id, title, milestone, rest.trim(), line_no));
208            }
209            continue;
210        }
211
212        if let Some(rest) = line.strip_prefix("## ") {
213            flush_draft(&mut draft, &mut tasks, &mut seen, &mut errors);
214            if let Some((id, title)) = parse_milestone_heading(rest) {
215                let milestone = Milestone {
216                    id,
217                    title,
218                    heading: rest.trim().to_string(),
219                };
220                current = Some(milestone.clone());
221                milestones.push(milestone);
222            } else {
223                current = None;
224            }
225            continue;
226        }
227
228        if let Some(cols) = table_cols(line) {
229            flush_draft(&mut draft, &mut tasks, &mut seen, &mut errors);
230            if current.is_some() {
231                table = Some(cols);
232            }
233            continue;
234        }
235
236        if let Some(draft) = draft.as_mut() {
237            if let Some((name, value)) = top_level_field(line) {
238                apply_field(draft, name, value);
239                continue;
240            }
241            if draft.in_acceptance && is_acceptance_line(line) {
242                draft.acceptance.push(unindent(line));
243            }
244        }
245    }
246
247    flush_draft(&mut draft, &mut tasks, &mut seen, &mut errors);
248    check_dependencies(&tasks, &mut errors);
249
250    if errors.is_empty() {
251        Ok(Roadmap { milestones, tasks })
252    } else {
253        Err(PlanError::new(errors.join("\n")))
254    }
255}
256
257pub fn select<'a>(
258    roadmap: &'a Roadmap,
259    milestone: Option<&str>,
260) -> Result<Vec<&'a Task>, PlanError> {
261    let Some(wanted) = milestone else {
262        return Ok(roadmap.tasks.iter().collect());
263    };
264    if !roadmap
265        .milestones
266        .iter()
267        .any(|item| item.id.eq_ignore_ascii_case(wanted))
268    {
269        return Err(PlanError::new(format!("no milestone {wanted}")));
270    }
271    Ok(roadmap
272        .tasks
273        .iter()
274        .filter(|task| task.milestone_id.eq_ignore_ascii_case(wanted))
275        .collect())
276}
277
278pub fn issue_title(task: &Task) -> String {
279    if task.title.is_empty() {
280        task.id.clone()
281    } else {
282        format!("{} {}", task.id, task.title)
283    }
284}
285
286pub fn task_anchor(task: &Task) -> String {
287    if task.anchor_heading.is_empty() {
288        github_anchor(&task.milestone_heading)
289    } else {
290        github_anchor(&task.anchor_heading)
291    }
292}
293
294pub fn issue_body(task: &Task, repo: &str, branch: &str, rel_path: &str) -> String {
295    issue_body_with(task, repo, branch, rel_path, &[])
296}
297
298pub fn issue_body_with(
299    task: &Task,
300    repo: &str,
301    branch: &str,
302    rel_path: &str,
303    blocked_external: &[String],
304) -> String {
305    let mut out = String::new();
306    if !task.goal.is_empty() {
307        out.push_str("## Goal\n\n");
308        out.push_str(task.goal.trim());
309        out.push_str("\n\n");
310    }
311    if !task.scope.is_empty() {
312        out.push_str("## Scope\n\n");
313        out.push_str(task.scope.trim());
314        out.push_str("\n\n");
315    }
316    if !task.acceptance.is_empty() {
317        out.push_str("## Acceptance\n\n");
318        for item in &task.acceptance {
319            let item = item.trim_end();
320            if item.starts_with('-') || item.starts_with('*') || item.starts_with(' ') {
321                out.push_str(item);
322            } else {
323                out.push_str("- ");
324                out.push_str(item);
325            }
326            out.push('\n');
327        }
328        out.push('\n');
329    }
330    if !blocked_external.is_empty() {
331        out.push_str("blocked-external: ");
332        out.push_str(&blocked_external.join(", "));
333        out.push_str(" (no issue yet)\n\n");
334    }
335    let url = format!(
336        "https://github.com/{repo}/blob/{branch}/{path}#{anchor}",
337        branch = encode_git_path(branch),
338        path = encode_git_path(rel_path),
339        anchor = urlencoding::encode(&task_anchor(task)),
340    );
341    out.push_str(&format!("[{rel_path}]({url})\n\n"));
342    if !blocked_external.is_empty() {
343        out.push_str("<!-- orch-blocked-external: ");
344        out.push_str(&blocked_external.join(", "));
345        out.push_str(" -->\n");
346    }
347    out.push_str(&marker_for(&task.id));
348    out.push('\n');
349    out
350}
351
352pub fn marker_for(id: &str) -> String {
353    format!("<!-- orch-plan: {id} -->")
354}
355
356pub fn marker_id(body: &str) -> Option<String> {
357    let start = body.find("<!-- orch-plan:")?;
358    let rest = &body[start + "<!-- orch-plan:".len()..];
359    let end = rest.find("-->")?;
360    let id = rest[..end].trim();
361    is_task_id(id).then(|| id.to_string())
362}
363
364/// Labels for a plan issue. Human tasks also get `no-agent`, which the dispatcher already skips
365pub fn labels_for(existing: &[String], human: bool) -> Vec<String> {
366    let drop_no_agent = existing.iter().any(|label| label == HUMAN_LABEL);
367    let mut labels = vec![PLAN_LABEL.to_string()];
368    if human {
369        labels.push(HUMAN_LABEL.to_string());
370        labels.push(NO_AGENT_LABEL.to_string());
371    }
372    for label in existing {
373        if label == PLAN_LABEL || label == HUMAN_LABEL {
374            continue;
375        }
376        if label == NO_AGENT_LABEL && (human || drop_no_agent) {
377            continue;
378        }
379        if !labels.iter().any(|have| have == label) {
380            labels.push(label.clone());
381        }
382    }
383    labels
384}
385
386pub fn diff_import(
387    roadmap: &Roadmap,
388    existing: &[ExistingIssue],
389    milestone: Option<&str>,
390    mut body_for: impl FnMut(&Task) -> String,
391) -> Result<PlanDiff, PlanError> {
392    let selected = select(roadmap, milestone)?;
393    let mut by_id: HashMap<&str, &ExistingIssue> = HashMap::new();
394    let mut errors = Vec::new();
395    for issue in existing {
396        if by_id.insert(issue.task_id.as_str(), issue).is_some() {
397            errors.push(format!(
398                "more than one issue is marked as {}",
399                issue.task_id
400            ));
401        }
402    }
403    if !errors.is_empty() {
404        return Err(PlanError::new(errors.join("\n")));
405    }
406
407    let mut tasks = Vec::new();
408    let mut selected_ids = HashSet::new();
409    for task in selected {
410        selected_ids.insert(task.id.as_str());
411        let title = issue_title(task);
412        let body = body_for(task);
413        let kind = if let Some(issue) = by_id.get(task.id.as_str()) {
414            let same = issue.title == title
415                && normalize_body(&issue.body) == normalize_body(&body)
416                && same_labels(&issue.labels, &labels_for(&issue.labels, task.owner_human));
417            if same {
418                ChangeKind::Unchanged
419            } else {
420                ChangeKind::Update
421            }
422        } else {
423            ChangeKind::Create
424        };
425        let issue = by_id.get(task.id.as_str()).copied();
426        tasks.push(TaskPlan {
427            task: task.clone(),
428            kind,
429            number: issue.map(|item| item.number),
430            database_id: issue.map(|item| item.database_id),
431            existing_labels: issue.map(|item| item.labels.clone()).unwrap_or_default(),
432        });
433    }
434
435    let mut removed = Vec::new();
436    for issue in existing {
437        if selected_ids.contains(issue.task_id.as_str()) {
438            continue;
439        }
440        if let Some(wanted) = milestone {
441            let Some(prefix) = task_milestone_id(&issue.task_id) else {
442                continue;
443            };
444            if !prefix.eq_ignore_ascii_case(wanted) {
445                continue;
446            }
447        }
448        removed.push(RemovedTask {
449            task_id: issue.task_id.clone(),
450            number: issue.number,
451            state: issue.state.clone(),
452        });
453    }
454
455    Ok(PlanDiff { tasks, removed })
456}
457
458/// Dependencies outside the selected set that have no GitHub issue yet.
459///
460/// An id that is not in the roadmap is an error. An id in the roadmap is reported
461pub fn unmet_externals(
462    tasks: &[Task],
463    roadmap: &Roadmap,
464    known_ids: &HashSet<String>,
465) -> Result<Vec<UnmetExternal>, PlanError> {
466    let selected: HashSet<&str> = tasks.iter().map(|task| task.id.as_str()).collect();
467    let roadmap_ids: HashSet<&str> = roadmap.tasks.iter().map(|task| task.id.as_str()).collect();
468    let mut unmet = Vec::new();
469    let mut errors = Vec::new();
470    for task in tasks {
471        for dep in &task.depends_on {
472            if selected.contains(dep.as_str()) || known_ids.contains(dep) {
473                continue;
474            }
475            if roadmap_ids.contains(dep.as_str()) {
476                unmet.push(UnmetExternal {
477                    task_id: task.id.clone(),
478                    blocking_task_id: dep.clone(),
479                });
480            } else {
481                errors.push(format!("{} depends on unknown task {dep}", task.id));
482            }
483        }
484    }
485    if errors.is_empty() {
486        Ok(unmet)
487    } else {
488        Err(PlanError::new(errors.join("\n")))
489    }
490}
491
492/// Task ids to omit when `--skip-unmet` is set, including tasks that depend on a skipped task
493pub fn skip_unmet_ids(tasks: &[Task], unmet: &[UnmetExternal]) -> HashSet<String> {
494    let mut skip: HashSet<String> = unmet.iter().map(|item| item.task_id.clone()).collect();
495    loop {
496        let mut added = false;
497        for task in tasks {
498            if skip.contains(&task.id) {
499                continue;
500            }
501            if task.depends_on.iter().any(|dep| skip.contains(dep)) {
502                skip.insert(task.id.clone());
503                added = true;
504            }
505        }
506        if !added {
507            break;
508        }
509    }
510    skip
511}
512
513pub fn blocked_external_ids<'a>(unmet: &'a [UnmetExternal], task_id: &str) -> Vec<&'a str> {
514    unmet
515        .iter()
516        .filter(|item| item.task_id == task_id)
517        .map(|item| item.blocking_task_id.as_str())
518        .collect()
519}
520
521pub fn diff_links(
522    tasks: &[Task],
523    bindings: &[Binding],
524    current: &HashMap<u64, Vec<DepRef>>,
525) -> Result<Vec<LinkEdit>, PlanError> {
526    let mut edits = Vec::new();
527    let mut errors = Vec::new();
528    let managed: HashSet<u64> = bindings.iter().filter_map(|item| item.number).collect();
529    for task in tasks {
530        let Some(binding) = bindings.iter().find(|item| item.task_id == task.id) else {
531            errors.push(format!("{} has no issue binding", task.id));
532            continue;
533        };
534        let blocked = binding
535            .number
536            .and_then(|number| current.get(&number))
537            .cloned()
538            .unwrap_or_default();
539        let mut desired_numbers = Vec::new();
540        for dep_id in &task.depends_on {
541            let Some(dep) = bindings.iter().find(|item| item.task_id == dep_id.as_str()) else {
542                continue;
543            };
544            if let Some(number) = dep.number {
545                desired_numbers.push(number);
546            }
547            let already = dep
548                .number
549                .is_some_and(|number| blocked.iter().any(|item| item.number == number));
550            if !already {
551                edits.push(LinkEdit::Add {
552                    task_id: task.id.clone(),
553                    issue_number: binding.number,
554                    blocking_task_id: dep_id.clone(),
555                    blocking_number: dep.number,
556                    blocking_database_id: dep.database_id,
557                });
558            }
559        }
560        if let Some(issue_number) = binding.number {
561            for item in &blocked {
562                // Only drop links to other plan issues, a link a person added by hand stays
563                if desired_numbers.contains(&item.number) || !managed.contains(&item.number) {
564                    continue;
565                }
566                edits.push(LinkEdit::Remove {
567                    task_id: task.id.clone(),
568                    issue_number,
569                    blocking_number: item.number,
570                    blocking_database_id: item.database_id,
571                });
572            }
573        }
574    }
575    if errors.is_empty() {
576        Ok(edits)
577    } else {
578        Err(PlanError::new(errors.join("\n")))
579    }
580}
581
582pub fn render(diff: &PlanDiff, links: &[LinkEdit], dry_run: bool) -> String {
583    render_report(diff, links, &[], &[], dry_run)
584}
585
586pub fn render_report(
587    diff: &PlanDiff,
588    links: &[LinkEdit],
589    unmet: &[UnmetExternal],
590    skipped: &[String],
591    dry_run: bool,
592) -> String {
593    let mut out = render_tasks(diff);
594    out.push_str(&render_unmet(unmet, skipped));
595    out.push_str(&render_links(links));
596    out.push_str(&render_summary(diff, links, dry_run));
597    out
598}
599
600fn render_unmet(unmet: &[UnmetExternal], skipped: &[String]) -> String {
601    let skip: HashSet<&str> = skipped.iter().map(String::as_str).collect();
602    let mut out = String::new();
603    for id in skipped {
604        let deps = blocked_external_ids(unmet, id);
605        if deps.is_empty() {
606            out.push_str(&format!("skip {id}\n"));
607        } else {
608            out.push_str(&format!(
609                "skip {id} depends on {}, which has no issue yet\n",
610                deps.join(", ")
611            ));
612        }
613    }
614    let mut seen = HashSet::new();
615    for item in unmet {
616        if skip.contains(item.task_id.as_str()) || !seen.insert(item.task_id.as_str()) {
617            continue;
618        }
619        let deps = blocked_external_ids(unmet, &item.task_id);
620        out.push_str(&format!(
621            "blocked-external {} depends on {}, which has no issue yet\n",
622            item.task_id,
623            deps.join(", ")
624        ));
625    }
626    out
627}
628
629pub fn render_tasks(diff: &PlanDiff) -> String {
630    let mut out = String::new();
631    for plan in &diff.tasks {
632        let human = if plan.task.owner_human {
633            " [human]"
634        } else {
635            ""
636        };
637        let deps = if plan.task.depends_on.is_empty() {
638            "none".to_string()
639        } else {
640            plan.task.depends_on.join(", ")
641        };
642        out.push_str(&format!(
643            "{kind} {title}{human}\n  depends on: {deps}\n",
644            kind = kind_word(plan.kind),
645            title = issue_title(&plan.task),
646        ));
647    }
648    for removed in &diff.removed {
649        out.push_str(&format!(
650            "removed {} issue #{} ({}), not closed\n",
651            removed.task_id, removed.number, removed.state
652        ));
653    }
654    out
655}
656
657pub fn render_links(links: &[LinkEdit]) -> String {
658    let mut out = String::new();
659    for link in links {
660        match link {
661            LinkEdit::Add {
662                task_id,
663                issue_number,
664                blocking_task_id,
665                blocking_number,
666                ..
667            } => {
668                out.push_str(&format!(
669                    "link {task_id}{issue} blocked by {blocking_task_id}{blocking}\n",
670                    issue = number_suffix(*issue_number),
671                    blocking = number_suffix(*blocking_number),
672                ));
673            }
674            LinkEdit::Remove {
675                task_id,
676                blocking_number,
677                ..
678            } => {
679                out.push_str(&format!("unlink {task_id} blocked by #{blocking_number}\n"));
680            }
681        }
682    }
683    out
684}
685
686pub fn render_summary(diff: &PlanDiff, links: &[LinkEdit], dry_run: bool) -> String {
687    let creates = diff
688        .tasks
689        .iter()
690        .filter(|plan| plan.kind == ChangeKind::Create)
691        .count();
692    let updates = diff
693        .tasks
694        .iter()
695        .filter(|plan| plan.kind == ChangeKind::Update)
696        .count();
697    let unchanged = diff
698        .tasks
699        .iter()
700        .filter(|plan| plan.kind == ChangeKind::Unchanged)
701        .count();
702    let prefix = if dry_run { "dry run: " } else { "" };
703    format!(
704        "{prefix}create {creates}, update {updates}, unchanged {unchanged}, link {}, removed {}\n",
705        links.len(),
706        diff.removed.len()
707    )
708}
709
710pub fn github_anchor(heading: &str) -> String {
711    let mut out = String::new();
712    let mut prev_hyphen = false;
713    for ch in heading.chars() {
714        if ch.is_whitespace() {
715            if !out.is_empty() && !prev_hyphen {
716                out.push('-');
717                prev_hyphen = true;
718            }
719            continue;
720        }
721        if ch == '-' || ch == '_' {
722            if !out.is_empty() && !prev_hyphen {
723                out.push(ch);
724                prev_hyphen = true;
725            }
726            continue;
727        }
728        if ch.is_alphanumeric() {
729            for lower in ch.to_lowercase() {
730                out.push(lower);
731            }
732            prev_hyphen = false;
733        }
734    }
735    while out.ends_with('-') || out.ends_with('_') {
736        out.pop();
737    }
738    out
739}
740
741fn encode_git_path(path: &str) -> String {
742    path.split('/')
743        .map(|segment| urlencoding::encode(segment).into_owned())
744        .collect::<Vec<_>>()
745        .join("/")
746}
747
748fn kind_word(kind: ChangeKind) -> &'static str {
749    match kind {
750        ChangeKind::Create => "create",
751        ChangeKind::Update => "update",
752        ChangeKind::Unchanged => "unchanged",
753    }
754}
755
756fn number_suffix(number: Option<u64>) -> String {
757    match number {
758        Some(number) if number > 0 => format!(" (#{number})"),
759        _ => String::new(),
760    }
761}
762
763fn normalize_body(body: &str) -> String {
764    body.replace("\r\n", "\n").trim().to_string()
765}
766
767fn same_labels(left: &[String], right: &[String]) -> bool {
768    let left: HashSet<&str> = left.iter().map(String::as_str).collect();
769    let right: HashSet<&str> = right.iter().map(String::as_str).collect();
770    left == right
771}
772
773fn task_milestone_id(task_id: &str) -> Option<&str> {
774    if !is_task_id(task_id) {
775        return None;
776    }
777    task_id.split_once('-').map(|(prefix, _)| prefix)
778}
779
780fn push_task(
781    task: Task,
782    tasks: &mut Vec<Task>,
783    seen: &mut HashSet<String>,
784    errors: &mut Vec<String>,
785) {
786    if !seen.insert(task.id.clone()) {
787        errors.push(format!("duplicate task id {}", task.id));
788        return;
789    }
790    tasks.push(task);
791}
792
793fn flush_draft(
794    draft: &mut Option<Draft>,
795    tasks: &mut Vec<Task>,
796    seen: &mut HashSet<String>,
797    errors: &mut Vec<String>,
798) {
799    let Some(draft) = draft.take() else {
800        return;
801    };
802    match draft.into_task() {
803        Ok(task) => push_task(task, tasks, seen, errors),
804        Err(err) => errors.push(err),
805    }
806}
807
808fn check_dependencies(tasks: &[Task], errors: &mut Vec<String>) {
809    let ids: HashSet<&str> = tasks.iter().map(|task| task.id.as_str()).collect();
810    for task in tasks {
811        for dep in &task.depends_on {
812            if dep == &task.id {
813                errors.push(format!("{} depends on itself", task.id));
814            } else if !ids.contains(dep.as_str()) {
815                errors.push(format!("{} depends on unknown task {dep}", task.id));
816            }
817        }
818    }
819}
820
821fn apply_field(draft: &mut Draft, name: &str, value: &str) {
822    if name.eq_ignore_ascii_case("goal") {
823        draft.in_acceptance = false;
824        draft.goal = value.to_string();
825    } else if name.eq_ignore_ascii_case("scope") {
826        draft.in_acceptance = false;
827        draft.scope = value.to_string();
828    } else if name.eq_ignore_ascii_case("acceptance") {
829        draft.in_acceptance = true;
830        if !value.is_empty() {
831            draft.acceptance.push(acceptance_item(value));
832        }
833    } else if name.eq_ignore_ascii_case("depends on") {
834        draft.in_acceptance = false;
835        draft.depends_raw = Some(value.to_string());
836    } else if name.eq_ignore_ascii_case("owner") {
837        draft.in_acceptance = false;
838        draft.owner_human = is_human_owner(value);
839    }
840}
841
842fn acceptance_item(value: &str) -> String {
843    if value.starts_with('-') || value.starts_with('*') {
844        value.to_string()
845    } else {
846        format!("- {value}")
847    }
848}
849
850fn is_acceptance_line(line: &str) -> bool {
851    let trimmed = line.trim();
852    !trimmed.is_empty()
853        && (line.starts_with(' ')
854            || line.starts_with('\t')
855            || trimmed.starts_with("- ")
856            || trimmed.starts_with("* "))
857}
858
859fn unindent(line: &str) -> String {
860    let line = line.trim_end();
861    line.strip_prefix("  ")
862        .or_else(|| line.strip_prefix('\t'))
863        .unwrap_or(line)
864        .to_string()
865}
866
867fn top_level_field(line: &str) -> Option<(&str, &str)> {
868    let rest = line.strip_prefix("- ")?;
869    let (name, value) = rest.split_once(':')?;
870    let name = name.trim();
871    is_known_field(name).then_some((name, value.trim()))
872}
873
874fn is_known_field(name: &str) -> bool {
875    name.eq_ignore_ascii_case("goal")
876        || name.eq_ignore_ascii_case("scope")
877        || name.eq_ignore_ascii_case("acceptance")
878        || name.eq_ignore_ascii_case("depends on")
879        || name.eq_ignore_ascii_case("owner")
880}
881
882fn is_human_owner(value: &str) -> bool {
883    let Some(word) = value.split_whitespace().next() else {
884        return false;
885    };
886    word.trim_matches(|ch: char| matches!(ch, '.' | ',' | ';' | ':'))
887        .eq_ignore_ascii_case("human")
888}
889
890fn parse_depends(raw: &str) -> Result<Vec<String>, String> {
891    let trimmed = raw.trim().trim_end_matches('.').trim();
892    if trimmed.is_empty() {
893        return Ok(Vec::new());
894    }
895    let first = trimmed.split_whitespace().next().unwrap_or("");
896    let first = first.trim_matches(|ch: char| matches!(ch, '.' | ',' | ';' | ':'));
897    // A leading none is no dependency, even when the rest of the line names a task
898    if first.eq_ignore_ascii_case("none") {
899        return Ok(Vec::new());
900    }
901    let ids = extract_ids(trimmed);
902    if ids.is_empty() {
903        Err(format!("depends on value \"{trimmed}\" has no task id"))
904    } else {
905        Ok(ids)
906    }
907}
908
909fn extract_ids(raw: &str) -> Vec<String> {
910    let bytes = raw.as_bytes();
911    let mut ids = Vec::new();
912    let mut index = 0;
913    while index < bytes.len() {
914        if bytes[index].is_ascii_alphabetic() {
915            let start = index;
916            index += 1;
917            while index < bytes.len()
918                && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'-')
919            {
920                index += 1;
921            }
922            let token = &raw[start..index];
923            if is_task_id(token) && !ids.iter().any(|id| id == token) {
924                ids.push(token.to_string());
925            }
926            continue;
927        }
928        index += 1;
929    }
930    ids
931}
932
933fn is_task_id(token: &str) -> bool {
934    let Some((milestone, rest)) = token.split_once('-') else {
935        return false;
936    };
937    if !is_milestone_id(milestone) || rest.is_empty() || !rest.as_bytes()[0].is_ascii_digit() {
938        return false;
939    }
940    rest.bytes().all(|byte| byte.is_ascii_alphanumeric())
941}
942
943fn is_milestone_id(token: &str) -> bool {
944    let mut chars = token.chars();
945    let Some(first) = chars.next() else {
946        return false;
947    };
948    if !first.is_ascii_alphabetic() {
949        return false;
950    }
951    let mut saw_digit = false;
952    for ch in chars {
953        if ch.is_ascii_digit() {
954            saw_digit = true;
955        } else if ch.is_ascii_alphabetic() && !saw_digit {
956            continue;
957        } else {
958            return false;
959        }
960    }
961    saw_digit
962}
963
964fn parse_milestone_heading(rest: &str) -> Option<(String, String)> {
965    let rest = rest.trim();
966    let (first, title) = rest.split_once(char::is_whitespace)?;
967    let id = first.trim_end_matches('.');
968    is_milestone_id(id).then(|| (id.to_string(), title.trim().to_string()))
969}
970
971fn parse_task_heading(rest: &str) -> Option<(String, String)> {
972    let rest = rest.trim();
973    let (id, title) = match rest.split_once(char::is_whitespace) {
974        Some((id, title)) => (id, title.trim()),
975        None => (rest, ""),
976    };
977    is_task_id(id).then(|| (id.to_string(), title.to_string()))
978}
979
980fn table_cols(line: &str) -> Option<TableCols> {
981    if !line.trim().starts_with('|') {
982        return None;
983    }
984    let cells = split_cells(line);
985    let names: Vec<String> = cells.iter().map(|cell| cell.to_ascii_lowercase()).collect();
986    let task = names.iter().position(|name| name == "task")?;
987    if !names.iter().any(|name| name == "depends on") {
988        return None;
989    }
990    let pos = |label: &str| names.iter().position(|name| name == label);
991    Some(TableCols {
992        task,
993        goal: pos("goal"),
994        scope: pos("scope"),
995        acceptance: pos("acceptance"),
996        depends: pos("depends on"),
997        owner: pos("owner"),
998    })
999}
1000
1001fn is_separator_row(line: &str) -> bool {
1002    let cells = split_cells(line);
1003    !cells.is_empty()
1004        && cells.iter().all(|cell| {
1005            let token = cell.trim_matches(':');
1006            !token.is_empty() && token.chars().all(|ch| ch == '-')
1007        })
1008}
1009
1010fn split_cells(line: &str) -> Vec<String> {
1011    line.trim()
1012        .trim_matches('|')
1013        .split('|')
1014        .map(|cell| cell.trim().to_string())
1015        .collect()
1016}
1017
1018fn cell(cells: &[String], index: Option<usize>) -> &str {
1019    index
1020        .and_then(|index| cells.get(index))
1021        .map(String::as_str)
1022        .unwrap_or("")
1023}
1024
1025fn task_from_row(
1026    line: &str,
1027    cols: &TableCols,
1028    milestone: &Milestone,
1029    line_no: usize,
1030) -> Result<Option<Task>, String> {
1031    let cells = split_cells(line);
1032    let task_cell = cell(&cells, Some(cols.task));
1033    let Some((id, title)) = parse_task_heading(task_cell) else {
1034        return Ok(None);
1035    };
1036    let depends_raw = cell(&cells, cols.depends);
1037    let depends_on =
1038        parse_depends(depends_raw).map_err(|err| format!("line {line_no}: {id} {err}"))?;
1039    let owner = cell(&cells, cols.owner);
1040    Ok(Some(Task {
1041        id,
1042        title,
1043        milestone_id: milestone.id.clone(),
1044        milestone_title: milestone.title.clone(),
1045        milestone_heading: milestone.heading.clone(),
1046        anchor_heading: String::new(),
1047        goal: cell(&cells, cols.goal).to_string(),
1048        scope: cell(&cells, cols.scope).to_string(),
1049        acceptance: {
1050            let value = cell(&cells, cols.acceptance);
1051            if value.is_empty() {
1052                Vec::new()
1053            } else {
1054                vec![acceptance_item(value)]
1055            }
1056        },
1057        depends_on,
1058        owner_human: is_human_owner(owner),
1059    }))
1060}
1061
1062impl Draft {
1063    fn new(
1064        id: String,
1065        title: String,
1066        milestone: &Milestone,
1067        anchor_heading: &str,
1068        line: usize,
1069    ) -> Self {
1070        Self {
1071            id,
1072            title,
1073            milestone_id: milestone.id.clone(),
1074            milestone_title: milestone.title.clone(),
1075            milestone_heading: milestone.heading.clone(),
1076            anchor_heading: anchor_heading.to_string(),
1077            goal: String::new(),
1078            scope: String::new(),
1079            acceptance: Vec::new(),
1080            depends_raw: None,
1081            owner_human: false,
1082            in_acceptance: false,
1083            line,
1084        }
1085    }
1086
1087    fn into_task(self) -> Result<Task, String> {
1088        let depends_on = match self.depends_raw {
1089            Some(raw) => parse_depends(&raw)
1090                .map_err(|err| format!("line {}: {} {err}", self.line, self.id))?,
1091            None => Vec::new(),
1092        };
1093        Ok(Task {
1094            id: self.id,
1095            title: self.title,
1096            milestone_id: self.milestone_id,
1097            milestone_title: self.milestone_title,
1098            milestone_heading: self.milestone_heading,
1099            anchor_heading: self.anchor_heading,
1100            goal: self.goal,
1101            scope: self.scope,
1102            acceptance: self.acceptance,
1103            depends_on,
1104            owner_human: self.owner_human,
1105        })
1106    }
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111    use super::*;
1112
1113    const SAMPLE: &str = r#"
1114## M1. Content model and pack format
1115
1116### M1-01 Set up the workspace
1117
1118- Goal: a Rust workspace that builds.
1119- Scope: the `api/` workspace.
1120- Acceptance:
1121  - `cargo test` passes on a clean checkout.
1122    - including the ignored tests
1123  - CI fails on a formatting error.
1124- Depends on: none.
1125
1126### M1-02 Content model types
1127
1128- Goal: serde types.
1129- Scope: crates/content.
1130- Acceptance:
1131  - A sample file round-trips.
1132- Depends on: M1-01.
1133
1134### M1-03 Content validation
1135
1136- Owner: human.
1137- Goal: catch broken content.
1138- Scope: content-cli validate.
1139- Acceptance:
1140  - One test per check.
1141- Depends on: M1-02.
1142
1143### M1-04 Festival dates
1144
1145- Goal: resolve dates.
1146- Acceptance:
1147  - Tests for 2026.
1148- Depends on: none. Do it before M1-05.
1149
1150### M1-05 Scheduler
1151
1152- Goal: the guardrail.
1153- Scope: resolve().
1154- Acceptance:
1155  - At least 15 fixtures.
1156
1157## M5. Release
1158
1159### M5-02 Privacy policy
1160
1161- Owner: human (counsel), with an agent draft.
1162- Goal: legal pages.
1163- Acceptance:
1164  - Counsel signs off.
1165- Depends on: M1-05.
1166
1167## M6. Full year and plan editor
1168
1169| Task | Goal | Acceptance | Depends on |
1170|---|---|---|---|
1171| M6-01 Units 4 to 10 | Write the rest of the year | Each pack validates | M1-05, M1-01 |
1172| M6-02 Festival modules | Attach modules | Each shows in Hoje | M6-01 |
1173"#;
1174
1175    fn roadmap() -> Roadmap {
1176        parse_roadmap(SAMPLE).unwrap()
1177    }
1178
1179    fn task<'a>(roadmap: &'a Roadmap, id: &str) -> &'a Task {
1180        roadmap.tasks.iter().find(|task| task.id == id).unwrap()
1181    }
1182
1183    #[test]
1184    fn parses_nested_acceptance_and_missing_depends_on() {
1185        let roadmap = roadmap();
1186        let first = task(&roadmap, "M1-01");
1187        assert_eq!(
1188            first.acceptance,
1189            vec![
1190                "- `cargo test` passes on a clean checkout.".to_string(),
1191                "  - including the ignored tests".to_string(),
1192                "- CI fails on a formatting error.".to_string(),
1193            ]
1194        );
1195        assert!(first.depends_on.is_empty());
1196        assert_eq!(first.goal, "a Rust workspace that builds.");
1197        assert_eq!(first.scope, "the `api/` workspace.");
1198
1199        let scheduler = task(&roadmap, "M1-05");
1200        assert!(scheduler.depends_on.is_empty());
1201        assert_eq!(
1202            scheduler.acceptance,
1203            vec!["- At least 15 fixtures.".to_string()]
1204        );
1205    }
1206
1207    #[test]
1208    fn none_with_trailing_prose_adds_no_dependency() {
1209        let roadmap = roadmap();
1210        assert!(task(&roadmap, "M1-04").depends_on.is_empty());
1211    }
1212
1213    #[test]
1214    fn parses_owner_human_and_table_dependencies() {
1215        let roadmap = roadmap();
1216        assert!(task(&roadmap, "M1-03").owner_human);
1217        assert!(task(&roadmap, "M5-02").owner_human);
1218        assert!(!task(&roadmap, "M1-01").owner_human);
1219        assert_eq!(
1220            task(&roadmap, "M1-02").depends_on,
1221            vec!["M1-01".to_string()]
1222        );
1223        assert_eq!(
1224            task(&roadmap, "M6-01").depends_on,
1225            vec!["M1-05".to_string(), "M1-01".to_string()]
1226        );
1227        assert_eq!(
1228            task(&roadmap, "M6-02").depends_on,
1229            vec!["M6-01".to_string()]
1230        );
1231        assert!(task(&roadmap, "M6-01").anchor_heading.is_empty());
1232        assert_eq!(task(&roadmap, "M6-01").milestone_id, "M6");
1233    }
1234
1235    #[test]
1236    fn labels_keep_status_and_drop_no_agent_when_a_task_is_no_longer_human() {
1237        let kept = labels_for(
1238            &[
1239                "orch-plan".to_string(),
1240                "owner:human".to_string(),
1241                "no-agent".to_string(),
1242                "status:new".to_string(),
1243            ],
1244            false,
1245        );
1246        assert_eq!(
1247            kept,
1248            vec!["orch-plan".to_string(), "status:new".to_string()]
1249        );
1250
1251        let human = labels_for(&["status:new".to_string(), "no-agent".to_string()], true);
1252        assert_eq!(
1253            human,
1254            vec![
1255                "orch-plan".to_string(),
1256                "owner:human".to_string(),
1257                "no-agent".to_string(),
1258                "status:new".to_string(),
1259            ]
1260        );
1261    }
1262
1263    #[test]
1264    fn depends_on_without_a_task_id_is_an_error() {
1265        let markdown = r#"
1266## M1. Content model
1267
1268### M1-01 First
1269
1270- Goal: one.
1271- Depends on: later today.
1272"#;
1273        let err = parse_roadmap(markdown).unwrap_err();
1274        assert!(err.message.contains("no task id"), "{}", err.message);
1275    }
1276
1277    #[test]
1278    fn unknown_dependency_is_an_error() {
1279        let markdown = r#"
1280## M1. Content model
1281
1282### M1-01 First
1283
1284- Goal: one.
1285- Acceptance:
1286  - done.
1287- Depends on: M1-99.
1288"#;
1289        let err = parse_roadmap(markdown).unwrap_err();
1290        assert!(
1291            err.message.contains("unknown task M1-99"),
1292            "{}",
1293            err.message
1294        );
1295        assert!(err.message.contains("M1-01"), "{}", err.message);
1296    }
1297
1298    #[test]
1299    fn issue_body_links_the_heading_and_carries_the_marker() {
1300        let roadmap = roadmap();
1301        let task = task(&roadmap, "M1-01");
1302        let body = issue_body(task, "acme/app", "main", "docs/ROADMAP.md");
1303        assert!(body.contains("## Goal\n\na Rust workspace that builds."));
1304        assert!(body.contains("## Scope\n"));
1305        assert!(body.contains("- `cargo test` passes on a clean checkout."));
1306        assert!(body.contains("  - including the ignored tests"));
1307        assert!(body.contains("<!-- orch-plan: M1-01 -->"));
1308        assert!(body.contains(
1309            "https://github.com/acme/app/blob/main/docs/ROADMAP.md#m1-01-set-up-the-workspace"
1310        ));
1311        assert_eq!(marker_id(&body).as_deref(), Some("M1-01"));
1312        assert_eq!(issue_title(task), "M1-01 Set up the workspace");
1313    }
1314
1315    #[test]
1316    fn table_task_links_to_the_milestone_heading() {
1317        let roadmap = roadmap();
1318        let body = issue_body(
1319            task(&roadmap, "M6-01"),
1320            "acme/app",
1321            "main",
1322            "docs/ROADMAP.md",
1323        );
1324        assert!(body.contains("#m6-full-year-and-plan-editor"));
1325        assert!(!body.contains("## Scope"));
1326    }
1327
1328    #[test]
1329    fn report_lists_dependencies_and_marks_human_tasks() {
1330        let roadmap = roadmap();
1331        let diff = diff_import(&roadmap, &[], None, |task| {
1332            issue_body(task, "acme/app", "main", "docs/ROADMAP.md")
1333        })
1334        .unwrap();
1335        let links = diff_links(&roadmap.tasks, &bindings_for(&diff), &HashMap::new()).unwrap();
1336        let text = render(&diff, &links, true);
1337        assert!(text.contains("create M1-01 Set up the workspace\n  depends on: none\n"));
1338        assert!(text.contains("create M1-03 Content validation [human]\n  depends on: M1-02\n"));
1339        assert!(text.contains("create M5-02 Privacy policy [human]\n  depends on: M1-05\n"));
1340        assert!(text.contains("link M6-01 blocked by M1-05\n"));
1341        assert!(text.contains("link M6-01 blocked by M1-01\n"));
1342        assert!(text.starts_with("dry run:") || text.contains("\ndry run:"));
1343        assert!(text.contains("create 8,"));
1344    }
1345
1346    #[test]
1347    fn second_import_with_no_file_change_edits_nothing() {
1348        let roadmap = roadmap();
1349        let existing = existing_for(&roadmap);
1350        let diff = diff_import(&roadmap, &existing, None, |task| {
1351            issue_body(task, "acme/app", "main", "docs/ROADMAP.md")
1352        })
1353        .unwrap();
1354        assert!(diff
1355            .tasks
1356            .iter()
1357            .all(|plan| plan.kind == ChangeKind::Unchanged));
1358        assert!(diff.removed.is_empty());
1359        let current = current_links(&roadmap);
1360        let links =
1361            diff_links(&roadmap.tasks, &bindings_from_existing(&existing), &current).unwrap();
1362        assert!(links.is_empty(), "{links:?}");
1363        let text = render(&diff, &links, false);
1364        assert!(text.contains("unchanged M1-02 Content model types"));
1365        assert_eq!(
1366            text.lines()
1367                .filter(|line| line.starts_with("unchanged "))
1368                .count(),
1369            8
1370        );
1371        assert!(!text
1372            .lines()
1373            .any(|line| line.starts_with("link ") || line.starts_with("unlink ")));
1374        assert!(text.contains("create 0, update 0, unchanged 8, link 0, removed 0\n"));
1375    }
1376
1377    #[test]
1378    fn changed_body_is_an_update_and_a_dropped_task_is_reported() {
1379        let roadmap = roadmap();
1380        let mut existing = existing_for(&roadmap);
1381        existing[0].body = "stale\n<!-- orch-plan: M1-01 -->\n".to_string();
1382        existing.push(ExistingIssue {
1383            task_id: "M1-99".to_string(),
1384            number: 99,
1385            database_id: 990,
1386            title: "M1-99 Gone".to_string(),
1387            body: marker_for("M1-99"),
1388            labels: labels_for(&[], false),
1389            state: "open".to_string(),
1390        });
1391        let diff = diff_import(&roadmap, &existing, None, |task| {
1392            issue_body(task, "acme/app", "main", "docs/ROADMAP.md")
1393        })
1394        .unwrap();
1395        assert_eq!(diff.tasks[0].kind, ChangeKind::Update);
1396        assert!(diff.tasks[1..]
1397            .iter()
1398            .all(|plan| plan.kind == ChangeKind::Unchanged));
1399        assert_eq!(diff.removed.len(), 1);
1400        assert_eq!(diff.removed[0].task_id, "M1-99");
1401        assert!(render_tasks(&diff).contains("removed M1-99 issue #99 (open), not closed"));
1402    }
1403
1404    #[test]
1405    fn marker_without_label_is_an_update_that_adds_the_label() {
1406        let roadmap = roadmap();
1407        let mut existing = existing_for(&roadmap);
1408        existing[0].labels = vec!["status:new".to_string()];
1409        let diff = diff_import(&roadmap, &existing, None, |task| {
1410            issue_body(task, "acme/app", "main", "docs/ROADMAP.md")
1411        })
1412        .unwrap();
1413        assert_eq!(diff.tasks[0].kind, ChangeKind::Update);
1414        assert_eq!(diff.tasks[0].number, Some(1));
1415        assert!(labels_for(&diff.tasks[0].existing_labels, false).contains(&PLAN_LABEL.to_string()));
1416    }
1417
1418    #[test]
1419    fn milestone_filter_limits_tasks_and_removed() {
1420        let roadmap = roadmap();
1421        let mut existing = existing_for(&roadmap);
1422        existing.push(ExistingIssue {
1423            task_id: "M6-09".to_string(),
1424            number: 70,
1425            database_id: 700,
1426            title: "M6-09 Gone".to_string(),
1427            body: marker_for("M6-09"),
1428            labels: labels_for(&[], false),
1429            state: "closed".to_string(),
1430        });
1431        let diff = diff_import(&roadmap, &existing, Some("M6"), |task| {
1432            issue_body(task, "acme/app", "main", "docs/ROADMAP.md")
1433        })
1434        .unwrap();
1435        let ids: Vec<&str> = diff
1436            .tasks
1437            .iter()
1438            .map(|plan| plan.task.id.as_str())
1439            .collect();
1440        assert_eq!(ids, vec!["M6-01", "M6-02"]);
1441        assert_eq!(diff.removed.len(), 1);
1442        assert_eq!(diff.removed[0].task_id, "M6-09");
1443        let err = select(&roadmap, Some("M9")).unwrap_err();
1444        assert!(err.message.contains("no milestone M9"));
1445    }
1446
1447    #[test]
1448    fn milestone_import_links_an_existing_external_issue() {
1449        let roadmap = roadmap();
1450        let existing: Vec<ExistingIssue> = existing_for(&roadmap)
1451            .into_iter()
1452            .filter(|issue| issue.task_id.starts_with("M1-"))
1453            .collect();
1454        let known: HashSet<String> = existing.iter().map(|issue| issue.task_id.clone()).collect();
1455        let selected: Vec<Task> = select(&roadmap, Some("M6"))
1456            .unwrap()
1457            .into_iter()
1458            .cloned()
1459            .collect();
1460        assert!(unmet_externals(&selected, &roadmap, &known)
1461            .unwrap()
1462            .is_empty());
1463        let diff = diff_import(&roadmap, &existing, Some("M6"), |task| {
1464            issue_body(task, "acme/app", "main", "docs/ROADMAP.md")
1465        })
1466        .unwrap();
1467        let links = diff_links(
1468            &selected,
1469            &bindings_with_existing(&diff, &existing),
1470            &HashMap::new(),
1471        )
1472        .unwrap();
1473        assert!(links.iter().any(|edit| matches!(
1474            edit,
1475            LinkEdit::Add { task_id, blocking_task_id, blocking_number: Some(5), .. }
1476                if task_id == "M6-01" && blocking_task_id == "M1-05"
1477        )));
1478        assert!(links.iter().any(|edit| matches!(
1479            edit,
1480            LinkEdit::Add { task_id, blocking_task_id, .. }
1481                if task_id == "M6-01" && blocking_task_id == "M1-01"
1482        )));
1483        assert!(links.iter().any(|edit| matches!(
1484            edit,
1485            LinkEdit::Add { task_id, blocking_task_id, .. }
1486                if task_id == "M6-02" && blocking_task_id == "M6-01"
1487        )));
1488    }
1489
1490    #[test]
1491    fn milestone_import_keeps_going_when_an_external_blocker_has_no_issue() {
1492        let roadmap = roadmap();
1493        let selected: Vec<Task> = select(&roadmap, Some("M6"))
1494            .unwrap()
1495            .into_iter()
1496            .cloned()
1497            .collect();
1498        let unmet = unmet_externals(&selected, &roadmap, &HashSet::new()).unwrap();
1499        assert!(
1500            unmet
1501                .iter()
1502                .any(|item| item.task_id == "M6-01" && item.blocking_task_id == "M1-05"),
1503            "{unmet:?}"
1504        );
1505        assert!(
1506            unmet
1507                .iter()
1508                .any(|item| item.task_id == "M6-01" && item.blocking_task_id == "M1-01"),
1509            "{unmet:?}"
1510        );
1511        let extras: Vec<String> = blocked_external_ids(&unmet, "M6-01")
1512            .into_iter()
1513            .map(str::to_string)
1514            .collect();
1515        let body = issue_body_with(
1516            task(&roadmap, "M6-01"),
1517            "acme/app",
1518            "main",
1519            "docs/ROADMAP.md",
1520            &extras,
1521        );
1522        assert!(body.contains("blocked-external: M1-05, M1-01 (no issue yet)"));
1523        assert!(body.contains("<!-- orch-blocked-external: M1-05, M1-01 -->"));
1524        let diff = diff_import(&roadmap, &[], Some("M6"), |task| {
1525            let extra: Vec<String> = blocked_external_ids(&unmet, &task.id)
1526                .into_iter()
1527                .map(str::to_string)
1528                .collect();
1529            issue_body_with(task, "acme/app", "main", "docs/ROADMAP.md", &extra)
1530        })
1531        .unwrap();
1532        assert_eq!(diff.tasks.len(), 2);
1533        assert!(diff
1534            .tasks
1535            .iter()
1536            .all(|plan| plan.kind == ChangeKind::Create));
1537        let links = diff_links(&selected, &bindings_for(&diff), &HashMap::new()).unwrap();
1538        assert!(
1539            !links.iter().any(|edit| matches!(
1540                edit,
1541                LinkEdit::Add { blocking_task_id, .. }
1542                    if blocking_task_id == "M1-05" || blocking_task_id == "M1-01"
1543            )),
1544            "an unmet external blocker must not be linked: {links:?}"
1545        );
1546        assert!(links.iter().any(|edit| matches!(
1547            edit,
1548            LinkEdit::Add { task_id, blocking_task_id, .. }
1549                if task_id == "M6-02" && blocking_task_id == "M6-01"
1550        )));
1551        let text = render_report(&diff, &links, &unmet, &[], true);
1552        assert!(
1553            text.contains("blocked-external M6-01 depends on M1-05, M1-01, which has no issue yet")
1554        );
1555        let mut known = HashSet::new();
1556        known.insert("M1-05".to_string());
1557        known.insert("M1-01".to_string());
1558        assert!(unmet_externals(&selected, &roadmap, &known)
1559            .unwrap()
1560            .is_empty());
1561    }
1562
1563    #[test]
1564    fn skip_unmet_omits_the_dependent_and_tasks_that_wait_on_it() {
1565        let roadmap = roadmap();
1566        let selected: Vec<Task> = select(&roadmap, Some("M6"))
1567            .unwrap()
1568            .into_iter()
1569            .cloned()
1570            .collect();
1571        let unmet = unmet_externals(&selected, &roadmap, &HashSet::new()).unwrap();
1572        let skip = skip_unmet_ids(&selected, &unmet);
1573        assert!(skip.contains("M6-01"));
1574        assert!(skip.contains("M6-02"));
1575        let text = render_report(
1576            &PlanDiff {
1577                tasks: Vec::new(),
1578                removed: Vec::new(),
1579            },
1580            &[],
1581            &unmet,
1582            &["M6-01".to_string(), "M6-02".to_string()],
1583            true,
1584        );
1585        assert!(text.contains("skip M6-01 depends on M1-05, M1-01, which has no issue yet"));
1586        assert!(text.contains("skip M6-02\n"));
1587        assert!(!text.contains("blocked-external M6-01"));
1588    }
1589
1590    #[test]
1591    fn unmet_externals_rejects_an_unknown_id() {
1592        let roadmap = roadmap();
1593        let mut task = task(&roadmap, "M6-01").clone();
1594        task.depends_on = vec!["M9-99".to_string()];
1595        let err = unmet_externals(&[task], &roadmap, &HashSet::new()).unwrap_err();
1596        assert!(
1597            err.message.contains("unknown task M9-99"),
1598            "{}",
1599            err.message
1600        );
1601        assert!(err.message.contains("M6-01"), "{}", err.message);
1602    }
1603
1604    #[test]
1605    fn link_diff_adds_missing_and_removes_extras_then_is_empty() {
1606        let roadmap = roadmap();
1607        let existing = existing_for(&roadmap);
1608        let bindings = bindings_from_existing(&existing);
1609        let stale = number_of(&existing, "M1-05");
1610        let mut current = HashMap::new();
1611        current.insert(
1612            number_of(&existing, "M1-02"),
1613            vec![
1614                DepRef {
1615                    number: stale,
1616                    database_id: stale * 10,
1617                },
1618                DepRef {
1619                    number: 50,
1620                    database_id: 500,
1621                },
1622            ],
1623        );
1624        let edits = diff_links(&roadmap.tasks, &bindings, &current).unwrap();
1625        assert!(edits.iter().any(|edit| matches!(
1626            edit,
1627            LinkEdit::Add { task_id, blocking_task_id, .. }
1628                if task_id == "M1-02" && blocking_task_id == "M1-01"
1629        )));
1630        assert!(edits.iter().any(|edit| matches!(
1631            edit,
1632            LinkEdit::Remove { task_id, blocking_number, .. }
1633                if task_id == "M1-02" && *blocking_number == stale
1634        )));
1635        assert!(
1636            !edits.iter().any(|edit| matches!(
1637                edit,
1638                LinkEdit::Remove {
1639                    blocking_number: 50,
1640                    ..
1641                }
1642            )),
1643            "a blocker outside the plan must stay: {edits:?}"
1644        );
1645
1646        let synced = current_links(&roadmap);
1647        let again = diff_links(&roadmap.tasks, &bindings, &synced).unwrap();
1648        assert!(again.is_empty(), "{again:?}");
1649    }
1650
1651    #[test]
1652    fn anchor_keeps_letters_and_drops_punctuation() {
1653        assert_eq!(
1654            github_anchor("M1. Content model and pack format"),
1655            "m1-content-model-and-pack-format"
1656        );
1657        assert_eq!(
1658            github_anchor("M3-09 Class screen and Modo áudio"),
1659            "m3-09-class-screen-and-modo-áudio"
1660        );
1661    }
1662
1663    fn existing_for(roadmap: &Roadmap) -> Vec<ExistingIssue> {
1664        roadmap
1665            .tasks
1666            .iter()
1667            .enumerate()
1668            .map(|(index, task)| {
1669                let number = (index + 1) as u64;
1670                ExistingIssue {
1671                    task_id: task.id.clone(),
1672                    number,
1673                    database_id: number * 10,
1674                    title: issue_title(task),
1675                    body: issue_body(task, "acme/app", "main", "docs/ROADMAP.md"),
1676                    labels: labels_for(&["status:new".to_string()], task.owner_human),
1677                    state: "open".to_string(),
1678                }
1679            })
1680            .collect()
1681    }
1682
1683    fn bindings_for(diff: &PlanDiff) -> Vec<Binding> {
1684        diff.tasks
1685            .iter()
1686            .map(|plan| Binding {
1687                task_id: plan.task.id.clone(),
1688                number: plan.number,
1689                database_id: plan.database_id,
1690            })
1691            .collect()
1692    }
1693
1694    fn bindings_with_existing(diff: &PlanDiff, existing: &[ExistingIssue]) -> Vec<Binding> {
1695        let mut bindings = bindings_for(diff);
1696        for issue in existing {
1697            if bindings
1698                .iter()
1699                .any(|binding| binding.task_id == issue.task_id)
1700            {
1701                continue;
1702            }
1703            bindings.push(Binding {
1704                task_id: issue.task_id.clone(),
1705                number: Some(issue.number),
1706                database_id: Some(issue.database_id),
1707            });
1708        }
1709        bindings
1710    }
1711
1712    fn bindings_from_existing(existing: &[ExistingIssue]) -> Vec<Binding> {
1713        existing
1714            .iter()
1715            .map(|issue| Binding {
1716                task_id: issue.task_id.clone(),
1717                number: Some(issue.number),
1718                database_id: Some(issue.database_id),
1719            })
1720            .collect()
1721    }
1722
1723    fn number_of(existing: &[ExistingIssue], id: &str) -> u64 {
1724        existing
1725            .iter()
1726            .find(|issue| issue.task_id == id)
1727            .unwrap()
1728            .number
1729    }
1730
1731    fn current_links(roadmap: &Roadmap) -> HashMap<u64, Vec<DepRef>> {
1732        let existing = existing_for(roadmap);
1733        let mut current = HashMap::new();
1734        for task in &roadmap.tasks {
1735            let blocked = task
1736                .depends_on
1737                .iter()
1738                .map(|dep| DepRef {
1739                    number: number_of(&existing, dep),
1740                    database_id: number_of(&existing, dep) * 10,
1741                })
1742                .collect();
1743            current.insert(number_of(&existing, &task.id), blocked);
1744        }
1745        current
1746    }
1747}