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