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