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