Skip to main content

orch/cli/
plan.rs

1//! `orch plan import` creates and updates issues from a roadmap file.
2
3use std::collections::{HashMap, HashSet};
4use std::path::Path;
5
6use anyhow::Context;
7
8use crate::config;
9use crate::github::http::GhHttp;
10use crate::plan::{
11    diff_import, diff_links, issue_body, issue_title, labels_for, marker_id, missing_blockers,
12    render, Binding, DepRef, ExistingIssue, LinkEdit, PlanDiff, Task, HUMAN_LABEL, NO_AGENT_LABEL,
13    PLAN_LABEL,
14};
15
16pub async fn import(file: &str, dry_run: bool, milestone: Option<&str>) -> anyhow::Result<()> {
17    let path = Path::new(file);
18    let markdown = std::fs::read_to_string(path)
19        .with_context(|| format!("failed to read {}", path.display()))?;
20    let roadmap = crate::plan::parse_roadmap(&markdown)?;
21    if let Some(id) = milestone {
22        crate::plan::select(&roadmap, Some(id))?;
23    }
24    let repo = config::get_current_repo()
25        .context("'repo' not set — run `orch init` or set gh.repo in .orch.yml")?;
26    let rel_path = repo_relative(path).unwrap_or_else(|| {
27        let name = path
28            .file_name()
29            .map(|name| name.to_string_lossy().into_owned())
30            .unwrap_or_else(|| file.to_string());
31        eprintln!("warning: {file} is outside the git checkout, the issue link uses {name}");
32        name
33    });
34
35    let http = GhHttp::new()?;
36    let branch = http.get_default_branch(&repo).await?;
37    let existing = load_existing(&http, &repo).await?;
38    let body_for = |task: &Task| issue_body(task, &repo, &branch, &rel_path);
39    let diff = diff_import(&roadmap, &existing, milestone, body_for)?;
40
41    let known: HashSet<String> = existing.iter().map(|issue| issue.task_id.clone()).collect();
42    let selected: Vec<Task> = diff.tasks.iter().map(|plan| plan.task.clone()).collect();
43    let missing = missing_blockers(&selected, &known);
44    if !missing.is_empty() {
45        print!("{}", crate::plan::render_tasks(&diff));
46        anyhow::bail!("{}", missing.join("\n"));
47    }
48
49    let bindings = bindings_for(&diff, &existing);
50    let current = load_blocked_by(&http, &repo, &diff).await?;
51    let links = diff_links(&selected, &bindings, &current)?;
52    if dry_run {
53        print!("{}", render(&diff, &links, true));
54        return Ok(());
55    }
56
57    apply(&http, &repo, &diff, &links, &rel_path, &branch).await?;
58    print!("{}", render(&diff, &links, false));
59    Ok(())
60}
61
62async fn load_existing(http: &GhHttp, repo: &str) -> anyhow::Result<Vec<ExistingIssue>> {
63    let issues = match http.list_issues_with_state(repo, PLAN_LABEL, "all").await {
64        Ok(issues) => issues,
65        Err(err) if err.to_string().contains("404") => Vec::new(),
66        Err(err) => return Err(err),
67    };
68    let mut existing = Vec::new();
69    for issue in issues {
70        let body = issue.body.unwrap_or_default();
71        let Some(task_id) = marker_id(&body) else {
72            eprintln!(
73                "warning: #{} has the {PLAN_LABEL} label and no plan marker",
74                issue.number
75            );
76            continue;
77        };
78        if issue.id == 0 {
79            anyhow::bail!("issue #{} did not include a database id", issue.number);
80        }
81        existing.push(ExistingIssue {
82            task_id,
83            number: issue.number,
84            database_id: issue.id,
85            title: issue.title,
86            body,
87            labels: issue.labels.into_iter().map(|label| label.name).collect(),
88            state: issue.state,
89        });
90    }
91    Ok(existing)
92}
93
94fn bindings_for(diff: &PlanDiff, existing: &[ExistingIssue]) -> Vec<Binding> {
95    let mut bindings: Vec<Binding> = diff
96        .tasks
97        .iter()
98        .map(|plan| Binding {
99            task_id: plan.task.id.clone(),
100            number: plan.number,
101            database_id: plan.database_id,
102        })
103        .collect();
104    for issue in existing {
105        if bindings
106            .iter()
107            .any(|binding| binding.task_id == issue.task_id)
108        {
109            continue;
110        }
111        bindings.push(Binding {
112            task_id: issue.task_id.clone(),
113            number: Some(issue.number),
114            database_id: Some(issue.database_id),
115        });
116    }
117    bindings
118}
119
120async fn load_blocked_by(
121    http: &GhHttp,
122    repo: &str,
123    diff: &PlanDiff,
124) -> anyhow::Result<HashMap<u64, Vec<DepRef>>> {
125    let mut current = HashMap::new();
126    for plan in &diff.tasks {
127        let Some(number) = plan.number else {
128            continue;
129        };
130        if current.contains_key(&number) {
131            continue;
132        }
133        let blocked = http.list_blocked_by(repo, number).await?;
134        current.insert(
135            number,
136            blocked
137                .into_iter()
138                .map(|item| DepRef {
139                    number: item.number,
140                    database_id: item.id,
141                })
142                .collect(),
143        );
144    }
145    Ok(current)
146}
147
148async fn apply(
149    http: &GhHttp,
150    repo: &str,
151    diff: &PlanDiff,
152    links: &[LinkEdit],
153    rel_path: &str,
154    branch: &str,
155) -> anyhow::Result<()> {
156    http.ensure_label(repo, PLAN_LABEL, "6f42c1", "Imported from a roadmap file")
157        .await?;
158    http.ensure_label(repo, HUMAN_LABEL, "fbca04", "A person does this task")
159        .await?;
160    http.ensure_label(repo, NO_AGENT_LABEL, "ededed", "Skip agent dispatch")
161        .await?;
162
163    let mut numbers: HashMap<String, (u64, u64)> = HashMap::new();
164    for plan in &diff.tasks {
165        if let (Some(number), Some(database_id)) = (plan.number, plan.database_id) {
166            numbers.insert(plan.task.id.clone(), (number, database_id));
167        }
168    }
169
170    for plan in &diff.tasks {
171        match plan.kind {
172            crate::plan::ChangeKind::Unchanged => {}
173            crate::plan::ChangeKind::Create => {
174                let title = issue_title(&plan.task);
175                let body = issue_body(&plan.task, repo, branch, rel_path);
176                let labels = labels_for(&[], plan.task.owner_human);
177                let issue = http.create_issue(repo, &title, &body, &labels).await?;
178                if issue.id == 0 {
179                    anyhow::bail!(
180                        "created issue #{} did not include a database id",
181                        issue.number
182                    );
183                }
184                numbers.insert(plan.task.id.clone(), (issue.number, issue.id));
185            }
186            crate::plan::ChangeKind::Update => {
187                let number = plan.number.context("update is missing an issue number")?;
188                let title = issue_title(&plan.task);
189                let body = issue_body(&plan.task, repo, branch, rel_path);
190                let labels = labels_for(&plan.existing_labels, plan.task.owner_human);
191                let issue = http
192                    .update_issue(repo, number, &title, &body, &labels)
193                    .await?;
194                let database_id = if issue.id == 0 {
195                    plan.database_id.unwrap_or(0)
196                } else {
197                    issue.id
198                };
199                if database_id == 0 {
200                    anyhow::bail!("issue #{number} did not include a database id");
201                }
202                numbers.insert(plan.task.id.clone(), (issue.number, database_id));
203            }
204        }
205    }
206
207    for link in links {
208        match link {
209            LinkEdit::Add {
210                task_id,
211                blocking_task_id,
212                blocking_database_id,
213                ..
214            } => {
215                let (number, _) = numbers
216                    .get(task_id)
217                    .with_context(|| format!("{task_id} has no issue number"))?;
218                let blocking_id = blocking_database_id.or_else(|| {
219                    numbers
220                        .get(blocking_task_id)
221                        .map(|(_, database_id)| *database_id)
222                });
223                let Some(blocking_id) = blocking_id else {
224                    anyhow::bail!("{task_id} depends on {blocking_task_id}, which has no issue id");
225                };
226                http.add_blocked_by(repo, *number, blocking_id).await?;
227            }
228            LinkEdit::Remove {
229                issue_number,
230                blocking_database_id,
231                ..
232            } => {
233                http.remove_blocked_by(repo, *issue_number, *blocking_database_id)
234                    .await?;
235            }
236        }
237    }
238    Ok(())
239}
240
241fn repo_relative(path: &Path) -> Option<String> {
242    let output = std::process::Command::new("git")
243        .args(["rev-parse", "--show-toplevel"])
244        .output()
245        .ok()?;
246    if !output.status.success() {
247        return None;
248    }
249    let top = String::from_utf8(output.stdout).ok()?;
250    let top = Path::new(top.trim()).to_path_buf();
251    let abs = if path.is_absolute() {
252        path.to_path_buf()
253    } else {
254        std::env::current_dir().ok()?.join(path)
255    };
256    let abs = abs.canonicalize().unwrap_or(abs);
257    let top = top.canonicalize().unwrap_or(top);
258    let rel = abs.strip_prefix(&top).ok()?;
259    Some(
260        rel.components()
261            .map(|component| component.as_os_str().to_string_lossy())
262            .collect::<Vec<_>>()
263            .join("/"),
264    )
265}