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