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