Skip to main content

oven_cli/cli/
ticket.rs

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
199/// Replace `status: <from>` with `status: <to>` only within the frontmatter block,
200/// leaving the body untouched so example text like "status: open" isn't corrupted.
201fn replace_frontmatter_status(content: &str, from: &str, to: &str) -> String {
202    let old = format!("status: {from}");
203    let new = format!("status: {to}");
204
205    // Find the frontmatter region (between first and second "---")
206    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    // Fallback: no valid frontmatter, do nothing
215    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        // Body text should be untouched
306        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        // Add a label
316        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        // Remove a label
324        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}