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