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