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 = match http.list_blocked_by(repo, number).await? {
134            crate::backends::BlockedByFetch::Native(items) => items,
135            crate::backends::BlockedByFetch::Unavailable => {
136                anyhow::bail!("blocked-by API unavailable for #{number}");
137            }
138            crate::backends::BlockedByFetch::Failed(err) => {
139                anyhow::bail!("blocked-by API failed for #{number}: {err}");
140            }
141        };
142        current.insert(
143            number,
144            blocked
145                .into_iter()
146                .map(|item| DepRef {
147                    number: item.number as u64,
148                    database_id: item.github_id as u64,
149                })
150                .collect(),
151        );
152    }
153    Ok(current)
154}
155
156async fn apply(
157    http: &GhHttp,
158    repo: &str,
159    diff: &PlanDiff,
160    links: &[LinkEdit],
161    rel_path: &str,
162    branch: &str,
163) -> anyhow::Result<()> {
164    http.ensure_label(repo, PLAN_LABEL, "6f42c1", "Imported from a roadmap file")
165        .await?;
166    http.ensure_label(repo, HUMAN_LABEL, "fbca04", "A person does this task")
167        .await?;
168    http.ensure_label(repo, NO_AGENT_LABEL, "ededed", "Skip agent dispatch")
169        .await?;
170
171    let mut numbers: HashMap<String, (u64, u64)> = HashMap::new();
172    for plan in &diff.tasks {
173        if let (Some(number), Some(database_id)) = (plan.number, plan.database_id) {
174            numbers.insert(plan.task.id.clone(), (number, database_id));
175        }
176    }
177
178    for plan in &diff.tasks {
179        match plan.kind {
180            crate::plan::ChangeKind::Unchanged => {}
181            crate::plan::ChangeKind::Create => {
182                let title = issue_title(&plan.task);
183                let body = issue_body(&plan.task, repo, branch, rel_path);
184                let labels = labels_for(&[], plan.task.owner_human);
185                let issue = http.create_issue(repo, &title, &body, &labels).await?;
186                if issue.id == 0 {
187                    anyhow::bail!(
188                        "created issue #{} did not include a database id",
189                        issue.number
190                    );
191                }
192                numbers.insert(plan.task.id.clone(), (issue.number, issue.id));
193            }
194            crate::plan::ChangeKind::Update => {
195                let number = plan.number.context("update is missing an issue number")?;
196                let title = issue_title(&plan.task);
197                let body = issue_body(&plan.task, repo, branch, rel_path);
198                let labels = labels_for(&plan.existing_labels, plan.task.owner_human);
199                let issue = http
200                    .update_issue(repo, number, &title, &body, &labels)
201                    .await?;
202                let database_id = if issue.id == 0 {
203                    plan.database_id.unwrap_or(0)
204                } else {
205                    issue.id
206                };
207                if database_id == 0 {
208                    anyhow::bail!("issue #{number} did not include a database id");
209                }
210                numbers.insert(plan.task.id.clone(), (issue.number, database_id));
211            }
212        }
213    }
214
215    for link in links {
216        match link {
217            LinkEdit::Add {
218                task_id,
219                blocking_task_id,
220                blocking_database_id,
221                ..
222            } => {
223                let (number, _) = numbers
224                    .get(task_id)
225                    .with_context(|| format!("{task_id} has no issue number"))?;
226                let blocking_id = blocking_database_id.or_else(|| {
227                    numbers
228                        .get(blocking_task_id)
229                        .map(|(_, database_id)| *database_id)
230                });
231                let Some(blocking_id) = blocking_id else {
232                    anyhow::bail!("{task_id} depends on {blocking_task_id}, which has no issue id");
233                };
234                http.add_blocked_by(repo, *number, blocking_id as i64)
235                    .await?;
236            }
237            LinkEdit::Remove {
238                issue_number,
239                blocking_database_id,
240                ..
241            } => {
242                http.remove_blocked_by(repo, *issue_number, *blocking_database_id)
243                    .await?;
244            }
245        }
246    }
247    Ok(())
248}
249
250fn repo_relative(path: &Path) -> Option<String> {
251    let output = std::process::Command::new("git")
252        .args(["rev-parse", "--show-toplevel"])
253        .output()
254        .ok()?;
255    if !output.status.success() {
256        return None;
257    }
258    let top = String::from_utf8(output.stdout).ok()?;
259    let top = Path::new(top.trim()).to_path_buf();
260    let abs = if path.is_absolute() {
261        path.to_path_buf()
262    } else {
263        std::env::current_dir().ok()?.join(path)
264    };
265    let abs = abs.canonicalize().unwrap_or(abs);
266    let top = top.canonicalize().unwrap_or(top);
267    let rel = abs.strip_prefix(&top).ok()?;
268    Some(
269        rel.components()
270            .map(|component| component.as_os_str().to_string_lossy())
271            .collect::<Vec<_>>()
272            .join("/"),
273    )
274}