1use std::path::Path;
2
3use anyhow::{Context, Result};
4
5use super::{GlobalOpts, TicketArgs, TicketCommands};
6use crate::{
7 issues::local::{parse_label_array, rewrite_frontmatter_labels},
8 pipeline::executor::truncate,
9};
10
11#[allow(clippy::unused_async)]
12pub async fn run(args: TicketArgs, _global: &GlobalOpts) -> Result<()> {
13 let project_dir = std::env::current_dir().context("getting current directory")?;
14 let issues_dir = project_dir.join(".oven").join("issues");
15
16 match args.command {
17 TicketCommands::Create(create_args) => {
18 std::fs::create_dir_all(&issues_dir).context("creating issues directory")?;
19 let id = next_ticket_id(&issues_dir)?;
20 let labels = if create_args.ready { vec!["o-ready".to_string()] } else { Vec::new() };
21 let body = create_args.body.unwrap_or_default();
22 let content = format_ticket(
23 id,
24 &create_args.title,
25 "open",
26 &labels,
27 &body,
28 create_args.repo.as_deref(),
29 );
30 let path = issues_dir.join(format!("{id}.md"));
31 std::fs::write(&path, content).context("writing ticket")?;
32 println!("created ticket #{id}: {}", create_args.title);
33 }
34 TicketCommands::List(list_args) => {
35 if !issues_dir.exists() {
36 println!("no tickets found");
37 return Ok(());
38 }
39 let tickets = read_all_tickets(&issues_dir)?;
40 let filtered: Vec<_> = tickets
41 .iter()
42 .filter(|t| {
43 list_args.label.as_ref().is_none_or(|l| t.labels.contains(l))
44 && list_args.status.as_ref().is_none_or(|s| t.status == *s)
45 })
46 .collect();
47
48 if filtered.is_empty() {
49 println!("no tickets found");
50 } else {
51 println!("{:<5} {:<8} {:<40} Labels", "ID", "Status", "Title");
52 println!("{}", "-".repeat(70));
53 for t in &filtered {
54 println!(
55 "{:<5} {:<8} {:<40} {}",
56 t.id,
57 t.status,
58 truncate(&t.title, 38),
59 t.labels.join(", ")
60 );
61 }
62 }
63 }
64 TicketCommands::View(view_args) => {
65 let path = issues_dir.join(format!("{}.md", view_args.id));
66 let content = std::fs::read_to_string(&path)
67 .with_context(|| format!("ticket #{} not found", view_args.id))?;
68 println!("{content}");
69 }
70 TicketCommands::Close(close_args) => {
71 let path = issues_dir.join(format!("{}.md", close_args.id));
72 let content = std::fs::read_to_string(&path)
73 .with_context(|| format!("ticket #{} not found", close_args.id))?;
74 let updated = replace_frontmatter_status(&content, "open", "closed");
75 std::fs::write(&path, updated).context("updating ticket")?;
76 println!("closed ticket #{}", close_args.id);
77 }
78 TicketCommands::Label(label_args) => {
79 let path = issues_dir.join(format!("{}.md", label_args.id));
80 let content = std::fs::read_to_string(&path)
81 .with_context(|| format!("ticket #{} not found", label_args.id))?;
82 let mut ticket =
83 parse_ticket_frontmatter(&content).context("failed to parse ticket frontmatter")?;
84 if label_args.remove {
85 ticket.labels.retain(|l| l != &label_args.label);
86 } else if !ticket.labels.contains(&label_args.label) {
87 ticket.labels.push(label_args.label.clone());
88 }
89 let updated = rewrite_frontmatter_labels(&content, &ticket.labels);
90 std::fs::write(&path, updated).context("updating ticket")?;
91 println!("updated ticket #{}", label_args.id);
92 }
93 TicketCommands::Edit(edit_args) => {
94 let path = issues_dir.join(format!("{}.md", edit_args.id));
95 if !path.exists() {
96 anyhow::bail!("ticket #{} not found", edit_args.id);
97 }
98 let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string());
99 let mut parts = editor.split_whitespace();
100 let bin = parts.next().unwrap_or("vim");
101 let extra_args: Vec<&str> = parts.collect();
102 std::process::Command::new(bin)
103 .args(&extra_args)
104 .arg(&path)
105 .status()
106 .with_context(|| format!("opening {editor}"))?;
107 }
108 }
109
110 Ok(())
111}
112
113struct Ticket {
114 id: u32,
115 title: String,
116 status: String,
117 labels: Vec<String>,
118}
119
120fn format_ticket(
121 id: u32,
122 title: &str,
123 status: &str,
124 labels: &[String],
125 body: &str,
126 target_repo: Option<&str>,
127) -> String {
128 let labels_str = if labels.is_empty() {
129 "[]".to_string()
130 } else {
131 format!("[{}]", labels.iter().map(|l| format!("\"{l}\"")).collect::<Vec<_>>().join(", "))
132 };
133 let now = chrono::Utc::now().to_rfc3339();
134 let target_line = target_repo.map_or_else(String::new, |r| format!("target_repo: {r}\n"));
135 format!(
136 "---\nid: {id}\ntitle: {title}\nstatus: {status}\nlabels: {labels_str}\n{target_line}created_at: {now}\n---\n\n{body}\n"
137 )
138}
139
140fn next_ticket_id(issues_dir: &Path) -> Result<u32> {
141 let mut max_id = 0u32;
142 if issues_dir.exists() {
143 for entry in std::fs::read_dir(issues_dir).context("reading issues directory")? {
144 let entry = entry?;
145 if let Some(stem) = entry.path().file_stem().and_then(|s| s.to_str()) {
146 if let Ok(id) = stem.parse::<u32>() {
147 max_id = max_id.max(id);
148 }
149 }
150 }
151 }
152 Ok(max_id + 1)
153}
154
155fn read_all_tickets(issues_dir: &Path) -> Result<Vec<Ticket>> {
156 let mut tickets = Vec::new();
157
158 for entry in std::fs::read_dir(issues_dir).context("reading issues directory")? {
159 let entry = entry?;
160 let path = entry.path();
161 if path.extension().and_then(|e| e.to_str()) != Some("md") {
162 continue;
163 }
164 let content = std::fs::read_to_string(&path)?;
165 if let Some(ticket) = parse_ticket_frontmatter(&content) {
166 tickets.push(ticket);
167 }
168 }
169
170 tickets.sort_by_key(|t| t.id);
171 Ok(tickets)
172}
173
174fn parse_ticket_frontmatter(content: &str) -> Option<Ticket> {
175 let content = content.strip_prefix("---\n")?;
176 let end = content.find("---")?;
177 let frontmatter = &content[..end];
178
179 let mut id = 0u32;
180 let mut title = String::new();
181 let mut status = String::new();
182 let mut labels = Vec::new();
183
184 for line in frontmatter.lines() {
185 if let Some(val) = line.strip_prefix("id: ") {
186 id = val.trim().parse().unwrap_or(0);
187 } else if let Some(val) = line.strip_prefix("title: ") {
188 title = val.trim().to_string();
189 } else if let Some(val) = line.strip_prefix("status: ") {
190 status = val.trim().to_string();
191 } else if let Some(val) = line.strip_prefix("labels: ") {
192 labels = parse_label_array(val);
193 }
194 }
195
196 if id > 0 { Some(Ticket { id, title, status, labels }) } else { None }
197}
198
199fn replace_frontmatter_status(content: &str, from: &str, to: &str) -> String {
202 let old = format!("status: {from}");
203 let new = format!("status: {to}");
204
205 if let Some(rest) = content.strip_prefix("---\n") {
207 if let Some(end) = rest.find("\n---") {
208 let frontmatter = &rest[..end];
209 let after = &rest[end..];
210 let replaced = frontmatter.replace(&old, &new);
211 return format!("---\n{replaced}{after}");
212 }
213 }
214 content.to_string()
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn format_ticket_with_labels() {
224 let content = format_ticket(
225 1,
226 "Add retry logic",
227 "open",
228 &["o-ready".to_string()],
229 "Implement retry.",
230 None,
231 );
232 assert!(content.contains("id: 1"));
233 assert!(content.contains("title: Add retry logic"));
234 assert!(content.contains("status: open"));
235 assert!(content.contains("\"o-ready\""));
236 assert!(content.contains("Implement retry."));
237 assert!(!content.contains("target_repo:"));
238 }
239
240 #[test]
241 fn format_ticket_no_labels() {
242 let content = format_ticket(1, "Test", "open", &[], "body", None);
243 assert!(content.contains("labels: []"));
244 }
245
246 #[test]
247 fn format_ticket_with_target_repo() {
248 let content = format_ticket(1, "Multi", "open", &[], "body", Some("api"));
249 assert!(content.contains("target_repo: api"));
250 }
251
252 #[test]
253 fn next_ticket_id_starts_at_1() {
254 let dir = tempfile::tempdir().unwrap();
255 let id = next_ticket_id(dir.path()).unwrap();
256 assert_eq!(id, 1);
257 }
258
259 #[test]
260 fn next_ticket_id_increments() {
261 let dir = tempfile::tempdir().unwrap();
262 std::fs::write(dir.path().join("1.md"), "---\nid: 1\n---").unwrap();
263 std::fs::write(dir.path().join("3.md"), "---\nid: 3\n---").unwrap();
264 let id = next_ticket_id(dir.path()).unwrap();
265 assert_eq!(id, 4);
266 }
267
268 #[test]
269 fn parse_ticket_frontmatter_valid() {
270 let content =
271 "---\nid: 42\ntitle: Fix bug\nstatus: open\nlabels: [\"o-ready\"]\n---\n\nbody";
272 let ticket = parse_ticket_frontmatter(content).unwrap();
273 assert_eq!(ticket.id, 42);
274 assert_eq!(ticket.title, "Fix bug");
275 assert_eq!(ticket.status, "open");
276 assert_eq!(ticket.labels, vec!["o-ready"]);
277 }
278
279 #[test]
280 fn parse_ticket_frontmatter_no_labels() {
281 let content = "---\nid: 1\ntitle: Test\nstatus: open\nlabels: []\n---\n\n";
282 let ticket = parse_ticket_frontmatter(content).unwrap();
283 assert_eq!(ticket.id, 1);
284 assert!(ticket.labels.is_empty());
285 }
286
287 #[test]
288 fn parse_ticket_frontmatter_invalid() {
289 assert!(parse_ticket_frontmatter("no frontmatter").is_none());
290 }
291
292 #[test]
293 fn close_ticket_updates_status() {
294 let content = "---\nid: 1\ntitle: Test\nstatus: open\nlabels: []\n---\n\nbody\n";
295 let updated = replace_frontmatter_status(content, "open", "closed");
296 assert!(updated.contains("status: closed"));
297 assert!(!updated.contains("\nstatus: open"));
298 }
299
300 #[test]
301 fn close_ticket_does_not_corrupt_body() {
302 let content = "---\nid: 1\ntitle: Test\nstatus: open\nlabels: []\n---\n\nThe status: open field is an example.\n";
303 let updated = replace_frontmatter_status(content, "open", "closed");
304 assert!(updated.contains("status: closed"));
305 assert!(updated.contains("The status: open field is an example."));
307 }
308
309 #[test]
310 fn label_add_and_remove() {
311 let content = "---\nid: 1\ntitle: Test\nstatus: open\nlabels: [\"o-ready\"]\n---\n\nbody";
312 let mut ticket = parse_ticket_frontmatter(content).unwrap();
313 assert_eq!(ticket.labels, vec!["o-ready"]);
314
315 if !ticket.labels.contains(&"o-cooking".to_string()) {
317 ticket.labels.push("o-cooking".to_string());
318 }
319 let updated = rewrite_frontmatter_labels(content, &ticket.labels);
320 assert!(updated.contains("\"o-ready\""));
321 assert!(updated.contains("\"o-cooking\""));
322
323 ticket.labels.retain(|l| l != "o-ready");
325 let updated2 = rewrite_frontmatter_labels(content, &ticket.labels);
326 assert!(!updated2.contains("\"o-ready\""));
327 assert!(updated2.contains("\"o-cooking\""));
328 }
329
330 #[test]
331 fn list_filters_by_status() {
332 let dir = tempfile::tempdir().unwrap();
333 std::fs::write(
334 dir.path().join("1.md"),
335 "---\nid: 1\ntitle: Open\nstatus: open\nlabels: []\n---\n\n",
336 )
337 .unwrap();
338 std::fs::write(
339 dir.path().join("2.md"),
340 "---\nid: 2\ntitle: Closed\nstatus: closed\nlabels: []\n---\n\n",
341 )
342 .unwrap();
343
344 let tickets = read_all_tickets(dir.path()).unwrap();
345 let open: Vec<_> = tickets.iter().filter(|t| t.status == "open").collect();
346 assert_eq!(open.len(), 1);
347 assert_eq!(open[0].id, 1);
348
349 let closed: Vec<_> = tickets.iter().filter(|t| t.status == "closed").collect();
350 assert_eq!(closed.len(), 1);
351 assert_eq!(closed[0].id, 2);
352 }
353
354 #[test]
355 fn editor_split_simple() {
356 let editor = "vim";
357 let mut parts = editor.split_whitespace();
358 assert_eq!(parts.next(), Some("vim"));
359 assert_eq!(parts.next(), None);
360 }
361
362 #[test]
363 fn editor_split_with_args() {
364 let editor = "code --wait";
365 let mut parts = editor.split_whitespace();
366 assert_eq!(parts.next(), Some("code"));
367 let args: Vec<&str> = parts.collect();
368 assert_eq!(args, vec!["--wait"]);
369 }
370
371 #[test]
372 fn editor_split_multiple_args() {
373 let editor = "emacs -nw --no-splash";
374 let mut parts = editor.split_whitespace();
375 assert_eq!(parts.next(), Some("emacs"));
376 let args: Vec<&str> = parts.collect();
377 assert_eq!(args, vec!["-nw", "--no-splash"]);
378 }
379
380 #[test]
381 fn read_all_tickets_sorts_by_id() {
382 let dir = tempfile::tempdir().unwrap();
383 std::fs::write(
384 dir.path().join("3.md"),
385 "---\nid: 3\ntitle: Third\nstatus: open\nlabels: []\n---\n\n",
386 )
387 .unwrap();
388 std::fs::write(
389 dir.path().join("1.md"),
390 "---\nid: 1\ntitle: First\nstatus: open\nlabels: []\n---\n\n",
391 )
392 .unwrap();
393
394 let tickets = read_all_tickets(dir.path()).unwrap();
395 assert_eq!(tickets.len(), 2);
396 assert_eq!(tickets[0].id, 1);
397 assert_eq!(tickets[1].id, 3);
398 }
399}