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