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