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