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