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