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::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
192/// Replace `status: <from>` with `status: <to>` only within the frontmatter block,
193/// leaving the body untouched so example text like "status: open" isn't corrupted.
194fn replace_frontmatter_status(content: &str, from: &str, to: &str) -> String {
195    let old = format!("status: {from}");
196    let new = format!("status: {to}");
197
198    // Find the frontmatter region (between first and second "---")
199    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    // Fallback: no valid frontmatter, do nothing
208    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        // Body text should be untouched
311        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        // Add a label
321        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        // Remove a label
329        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}