1use 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 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 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#[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
178pub 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
413pub 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
507pub 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
541pub 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 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 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), ¤t).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, ¤t).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}