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