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