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::github::types::GitHubIssue;
11use crate::plan::{
12    diff_import, diff_links, issue_body_with, issue_title, labels_for, marker_id, render_report,
13    skip_unmet_ids, unmet_externals, Binding, DepRef, ExistingIssue, LinkEdit, PlanDiff, Task,
14    UnmetExternal, HUMAN_LABEL, NO_AGENT_LABEL, PLAN_LABEL,
15};
16
17pub async fn import(
18    file: &str,
19    dry_run: bool,
20    milestone: Option<&str>,
21    skip_unmet: bool,
22) -> anyhow::Result<()> {
23    let path = Path::new(file);
24    let markdown = std::fs::read_to_string(path)
25        .with_context(|| format!("failed to read {}", path.display()))?;
26    let (roadmap, warnings) = crate::plan::parse_roadmap_for(&markdown, milestone)?;
27    for warning in warnings {
28        eprintln!("warning: {warning}");
29    }
30    if let Some(id) = milestone {
31        crate::plan::select(&roadmap, Some(id))?;
32    }
33    let repo = config::get_current_repo()
34        .context("'repo' not set — run `orch init` or set gh.repo in .orch.yml")?;
35    let rel_path = repo_relative(path).unwrap_or_else(|| {
36        let name = path
37            .file_name()
38            .map(|name| name.to_string_lossy().into_owned())
39            .unwrap_or_else(|| file.to_string());
40        eprintln!("warning: {file} is outside the git checkout, the issue link uses {name}");
41        name
42    });
43
44    let http = GhHttp::new()?;
45    let branch = http.get_default_branch(&repo).await?;
46    let existing = load_existing(&http, &repo).await?;
47    let known: HashSet<String> = existing.iter().map(|issue| issue.task_id.clone()).collect();
48    let selected_all: Vec<Task> = crate::plan::select(&roadmap, milestone)?
49        .into_iter()
50        .cloned()
51        .collect();
52    let unmet = unmet_externals(&selected_all, &roadmap, &known)?;
53    let extras = extras_by_task(&unmet);
54    let body_for = |task: &Task| {
55        let extra = extras.get(&task.id).map(Vec::as_slice).unwrap_or(&[]);
56        issue_body_with(task, &repo, &branch, &rel_path, extra)
57    };
58    let mut diff = diff_import(&roadmap, &existing, milestone, body_for)?;
59    let skipped = if skip_unmet {
60        let skip = skip_unmet_ids(&selected_all, &unmet);
61        let mut skipped = Vec::new();
62        diff.tasks.retain(|plan| {
63            if plan.kind == crate::plan::ChangeKind::Create && skip.contains(&plan.task.id) {
64                skipped.push(plan.task.id.clone());
65                false
66            } else {
67                true
68            }
69        });
70        skipped
71    } else {
72        Vec::new()
73    };
74
75    let selected: Vec<Task> = diff.tasks.iter().map(|plan| plan.task.clone()).collect();
76    let bindings = bindings_for(&diff, &existing);
77    let current = load_blocked_by(&http, &repo, &diff).await?;
78    let links = diff_links(&selected, &bindings, &current)?;
79    if dry_run {
80        print!("{}", render_report(&diff, &links, &unmet, &skipped, true));
81        return Ok(());
82    }
83
84    apply(&http, &repo, &diff, &links, &rel_path, &branch, &extras).await?;
85    print!("{}", render_report(&diff, &links, &unmet, &skipped, false));
86    Ok(())
87}
88
89fn extras_by_task(unmet: &[UnmetExternal]) -> HashMap<String, Vec<String>> {
90    let mut extras: HashMap<String, Vec<String>> = HashMap::new();
91    for item in unmet {
92        extras
93            .entry(item.task_id.clone())
94            .or_default()
95            .push(item.blocking_task_id.clone());
96    }
97    extras
98}
99
100async fn load_existing(http: &GhHttp, repo: &str) -> anyhow::Result<Vec<ExistingIssue>> {
101    // No label filter, so an issue with the marker and no label still matches
102    let issues = match http.list_issues_with_state(repo, "", "all").await {
103        Ok(issues) => issues,
104        Err(err) if err.to_string().contains("404") => Vec::new(),
105        Err(err) => return Err(err),
106    };
107    existing_from_issues(issues)
108}
109
110fn existing_from_issues(issues: Vec<GitHubIssue>) -> anyhow::Result<Vec<ExistingIssue>> {
111    let mut existing = Vec::new();
112    for issue in issues {
113        let labeled = issue.labels.iter().any(|label| label.name == PLAN_LABEL);
114        // Only a label or a repo writer vouches for an unlabeled marker, so a stranger cannot bind an issue
115        let trusted = labeled
116            || matches!(
117                issue.author_association.as_deref(),
118                Some("OWNER" | "MEMBER" | "COLLABORATOR")
119            );
120        let body = issue.body.unwrap_or_default();
121        let Some(task_id) = marker_id(&body).filter(|_| trusted) else {
122            if labeled {
123                eprintln!(
124                    "warning: #{} has the {PLAN_LABEL} label and no plan marker",
125                    issue.number
126                );
127            }
128            continue;
129        };
130        if issue.id == 0 {
131            anyhow::bail!("issue #{} did not include a database id", issue.number);
132        }
133        existing.push(ExistingIssue {
134            task_id,
135            number: issue.number,
136            database_id: issue.id,
137            title: issue.title,
138            body,
139            labels: issue.labels.into_iter().map(|label| label.name).collect(),
140            state: issue.state,
141        });
142    }
143    Ok(existing)
144}
145
146fn bindings_for(diff: &PlanDiff, existing: &[ExistingIssue]) -> Vec<Binding> {
147    let mut bindings: Vec<Binding> = diff
148        .tasks
149        .iter()
150        .map(|plan| Binding {
151            task_id: plan.task.id.clone(),
152            number: plan.number,
153            database_id: plan.database_id,
154        })
155        .collect();
156    for issue in existing {
157        if bindings
158            .iter()
159            .any(|binding| binding.task_id == issue.task_id)
160        {
161            continue;
162        }
163        bindings.push(Binding {
164            task_id: issue.task_id.clone(),
165            number: Some(issue.number),
166            database_id: Some(issue.database_id),
167        });
168    }
169    bindings
170}
171
172async fn load_blocked_by(
173    http: &GhHttp,
174    repo: &str,
175    diff: &PlanDiff,
176) -> anyhow::Result<HashMap<u64, Vec<DepRef>>> {
177    let mut current = HashMap::new();
178    for plan in &diff.tasks {
179        let Some(number) = plan.number else {
180            continue;
181        };
182        if current.contains_key(&number) {
183            continue;
184        }
185        let blocked = match http.list_blocked_by(repo, number).await? {
186            crate::backends::BlockedByFetch::Native(items) => items,
187            crate::backends::BlockedByFetch::Unavailable => {
188                anyhow::bail!("blocked-by API unavailable for #{number}");
189            }
190            crate::backends::BlockedByFetch::Failed(err) => {
191                anyhow::bail!("blocked-by API failed for #{number}: {err}");
192            }
193        };
194        current.insert(
195            number,
196            blocked
197                .into_iter()
198                .map(|item| DepRef {
199                    number: item.number as u64,
200                    database_id: item.github_id as u64,
201                })
202                .collect(),
203        );
204    }
205    Ok(current)
206}
207
208async fn apply(
209    http: &GhHttp,
210    repo: &str,
211    diff: &PlanDiff,
212    links: &[LinkEdit],
213    rel_path: &str,
214    branch: &str,
215    blocked_external: &HashMap<String, Vec<String>>,
216) -> anyhow::Result<()> {
217    http.ensure_label(repo, PLAN_LABEL, "6f42c1", "Imported from a roadmap file")
218        .await?;
219    http.ensure_label(repo, HUMAN_LABEL, "fbca04", "A person does this task")
220        .await?;
221    http.ensure_label(repo, NO_AGENT_LABEL, "ededed", "Skip agent dispatch")
222        .await?;
223
224    let mut numbers: HashMap<String, (u64, u64)> = HashMap::new();
225    for plan in &diff.tasks {
226        if let (Some(number), Some(database_id)) = (plan.number, plan.database_id) {
227            numbers.insert(plan.task.id.clone(), (number, database_id));
228        }
229    }
230
231    for plan in &diff.tasks {
232        match plan.kind {
233            crate::plan::ChangeKind::Unchanged => {}
234            crate::plan::ChangeKind::Create => {
235                let title = issue_title(&plan.task);
236                let extra = blocked_external
237                    .get(&plan.task.id)
238                    .map(Vec::as_slice)
239                    .unwrap_or(&[]);
240                let body = issue_body_with(&plan.task, repo, branch, rel_path, extra);
241                let labels = labels_for(&[], plan.task.owner_human);
242                let issue = http.create_issue(repo, &title, &body, &labels).await?;
243                if issue.id == 0 {
244                    anyhow::bail!(
245                        "created issue #{} did not include a database id",
246                        issue.number
247                    );
248                }
249                numbers.insert(plan.task.id.clone(), (issue.number, issue.id));
250            }
251            crate::plan::ChangeKind::Update => {
252                let number = plan.number.context("update is missing an issue number")?;
253                let title = issue_title(&plan.task);
254                let extra = blocked_external
255                    .get(&plan.task.id)
256                    .map(Vec::as_slice)
257                    .unwrap_or(&[]);
258                let body = issue_body_with(&plan.task, repo, branch, rel_path, extra);
259                let labels = labels_for(&plan.existing_labels, plan.task.owner_human);
260                let issue = http
261                    .update_issue(repo, number, &title, &body, &labels)
262                    .await?;
263                let database_id = if issue.id == 0 {
264                    plan.database_id.unwrap_or(0)
265                } else {
266                    issue.id
267                };
268                if database_id == 0 {
269                    anyhow::bail!("issue #{number} did not include a database id");
270                }
271                numbers.insert(plan.task.id.clone(), (issue.number, database_id));
272            }
273        }
274    }
275
276    for link in links {
277        match link {
278            LinkEdit::Add {
279                task_id,
280                blocking_task_id,
281                blocking_database_id,
282                ..
283            } => {
284                let (number, _) = numbers
285                    .get(task_id)
286                    .with_context(|| format!("{task_id} has no issue number"))?;
287                let blocking_id = blocking_database_id.or_else(|| {
288                    numbers
289                        .get(blocking_task_id)
290                        .map(|(_, database_id)| *database_id)
291                });
292                let Some(blocking_id) = blocking_id else {
293                    anyhow::bail!("{task_id} depends on {blocking_task_id}, which has no issue id");
294                };
295                http.add_blocked_by(repo, *number, blocking_id as i64)
296                    .await?;
297            }
298            LinkEdit::Remove {
299                issue_number,
300                blocking_database_id,
301                ..
302            } => {
303                http.remove_blocked_by(repo, *issue_number, *blocking_database_id)
304                    .await?;
305            }
306        }
307    }
308    Ok(())
309}
310
311fn repo_relative(path: &Path) -> Option<String> {
312    let output = std::process::Command::new("git")
313        .args(["rev-parse", "--show-toplevel"])
314        .output()
315        .ok()?;
316    if !output.status.success() {
317        return None;
318    }
319    let top = String::from_utf8(output.stdout).ok()?;
320    let top = Path::new(top.trim()).to_path_buf();
321    let abs = if path.is_absolute() {
322        path.to_path_buf()
323    } else {
324        std::env::current_dir().ok()?.join(path)
325    };
326    let abs = abs.canonicalize().unwrap_or(abs);
327    let top = top.canonicalize().unwrap_or(top);
328    let rel = abs.strip_prefix(&top).ok()?;
329    Some(
330        rel.components()
331            .map(|component| component.as_os_str().to_string_lossy())
332            .collect::<Vec<_>>()
333            .join("/"),
334    )
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use crate::github::types::{GitHubLabel, GitHubUser};
341
342    fn issue(number: u64, body: &str, labels: &[&str]) -> GitHubIssue {
343        issue_by(number, body, labels, "OWNER")
344    }
345
346    fn issue_by(number: u64, body: &str, labels: &[&str], association: &str) -> GitHubIssue {
347        GitHubIssue {
348            number,
349            id: number * 10,
350            title: format!("issue {number}"),
351            body: Some(body.to_string()),
352            state: "open".to_string(),
353            labels: labels
354                .iter()
355                .map(|name| GitHubLabel {
356                    name: name.to_string(),
357                    color: None,
358                })
359                .collect(),
360            user: GitHubUser {
361                login: "u".to_string(),
362            },
363            created_at: String::new(),
364            updated_at: String::new(),
365            html_url: String::new(),
366            node_id: None,
367            pull_request: None,
368            author_association: Some(association.to_string()),
369            state_reason: None,
370        }
371    }
372
373    #[test]
374    fn marker_matches_with_or_without_the_label_and_no_marker_is_skipped() {
375        let found = existing_from_issues(vec![
376            issue(1, "<!-- orch-plan: M1-01 -->", &[]),
377            issue(2, "<!-- orch-plan: M1-02 -->", &[PLAN_LABEL]),
378            issue(3, "no marker", &[PLAN_LABEL]),
379            issue(4, "no marker", &[]),
380            issue_by(5, "<!-- orch-plan: M1-03 -->", &[], "NONE"),
381        ])
382        .unwrap();
383        let ids: Vec<&str> = found.iter().map(|item| item.task_id.as_str()).collect();
384        assert_eq!(ids, ["M1-01", "M1-02"]);
385    }
386}