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