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