Skip to main content

oven_cli/issues/
local.rs

1use std::{fmt::Write, path::PathBuf};
2
3use anyhow::{Context, Result};
4use async_trait::async_trait;
5
6use super::{IssueOrigin, IssueProvider, PipelineIssue};
7
8/// Reads issues from `.oven/issues/*.md` files.
9pub struct LocalIssueProvider {
10    issues_dir: PathBuf,
11}
12
13impl LocalIssueProvider {
14    pub fn new(project_dir: &std::path::Path) -> Self {
15        Self { issues_dir: project_dir.join(".oven").join("issues") }
16    }
17}
18
19/// Parsed ticket frontmatter from a local issue file.
20#[derive(Debug)]
21struct LocalTicket {
22    id: u32,
23    title: String,
24    status: String,
25    labels: Vec<String>,
26    target_repo: Option<String>,
27    body: String,
28}
29
30/// Parse a local issue markdown file into its components.
31fn parse_local_issue(content: &str) -> Result<LocalTicket> {
32    let content = content.trim_start();
33    if !content.starts_with("---") {
34        anyhow::bail!("missing frontmatter delimiters");
35    }
36
37    let after_open = &content[3..];
38    let close_idx = after_open.find("\n---").context("missing closing frontmatter delimiter")?;
39
40    let frontmatter = &after_open[..close_idx];
41    let body = after_open[close_idx + 4..].trim_start_matches('\n').to_string();
42
43    let mut id = 0u32;
44    let mut title = String::new();
45    let mut status = "open".to_string();
46    let mut labels = Vec::new();
47    let mut target_repo = None;
48
49    for line in frontmatter.lines() {
50        let line = line.trim();
51        if let Some(val) = line.strip_prefix("id:") {
52            id = val.trim().parse().context("invalid id")?;
53        } else if let Some(val) = line.strip_prefix("title:") {
54            title = val.trim().to_string();
55        } else if let Some(val) = line.strip_prefix("status:") {
56            status = val.trim().to_string();
57        } else if let Some(val) = line.strip_prefix("labels:") {
58            labels = parse_label_array(val);
59        } else if let Some(val) = line.strip_prefix("target_repo:") {
60            target_repo = Some(val.trim().to_string());
61        }
62    }
63
64    Ok(LocalTicket { id, title, status, labels, target_repo, body })
65}
66
67/// Replace `status: <from>` with `status: <to>` only within the frontmatter block.
68fn replace_frontmatter_status(content: &str, from: &str, to: &str) -> String {
69    let old = format!("status: {from}");
70    let new = format!("status: {to}");
71
72    if let Some(rest) = content.strip_prefix("---\n") {
73        if let Some(end) = rest.find("\n---") {
74            let frontmatter = &rest[..end];
75            let after = &rest[end..];
76            let replaced = frontmatter.replace(&old, &new);
77            return format!("---\n{replaced}{after}");
78        }
79    }
80    content.to_string()
81}
82
83/// Parse a `["label1", "label2"]` string into a `Vec<String>`.
84pub(crate) fn parse_label_array(val: &str) -> Vec<String> {
85    let val = val.trim();
86    if val.starts_with('[') && val.ends_with(']') {
87        let inner = &val[1..val.len() - 1];
88        inner
89            .split(',')
90            .map(|s| s.trim().trim_matches('"').to_string())
91            .filter(|s| !s.is_empty())
92            .collect()
93    } else {
94        Vec::new()
95    }
96}
97
98/// Rewrite the labels line in a frontmatter block.
99pub fn rewrite_frontmatter_labels(content: &str, labels: &[String]) -> String {
100    let labels_str = labels.iter().map(|l| format!("\"{l}\"")).collect::<Vec<_>>().join(", ");
101    let new_labels_line = format!("labels: [{labels_str}]");
102
103    let mut result = String::new();
104    for line in content.lines() {
105        if line.trim().starts_with("labels:") {
106            result.push_str(&new_labels_line);
107        } else {
108            result.push_str(line);
109        }
110        result.push('\n');
111    }
112    result
113}
114
115#[async_trait]
116impl IssueProvider for LocalIssueProvider {
117    async fn get_ready_issues(&self, label: &str) -> Result<Vec<PipelineIssue>> {
118        if !self.issues_dir.exists() {
119            return Ok(Vec::new());
120        }
121
122        let mut issues = Vec::new();
123        let mut entries: Vec<_> = std::fs::read_dir(&self.issues_dir)
124            .context("reading issues directory")?
125            .filter_map(Result::ok)
126            .filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
127            .collect();
128
129        // Sort by filename for FIFO ordering
130        entries.sort_by_key(std::fs::DirEntry::path);
131
132        for entry in entries {
133            let content = std::fs::read_to_string(entry.path())
134                .with_context(|| format!("reading {}", entry.path().display()))?;
135            if let Ok(ticket) = parse_local_issue(&content) {
136                if ticket.status == "open" && ticket.labels.iter().any(|l| l == label) {
137                    issues.push(PipelineIssue {
138                        number: ticket.id,
139                        title: ticket.title,
140                        body: ticket.body,
141                        source: IssueOrigin::Local,
142                        target_repo: ticket.target_repo,
143                    });
144                }
145            }
146        }
147
148        Ok(issues)
149    }
150
151    async fn get_issue(&self, number: u32) -> Result<PipelineIssue> {
152        let path = self.issues_dir.join(format!("{number}.md"));
153        let content = std::fs::read_to_string(&path)
154            .with_context(|| format!("ticket #{number} not found"))?;
155        let ticket = parse_local_issue(&content)?;
156        Ok(PipelineIssue {
157            number: ticket.id,
158            title: ticket.title,
159            body: ticket.body,
160            source: IssueOrigin::Local,
161            target_repo: ticket.target_repo,
162        })
163    }
164
165    async fn transition(&self, number: u32, from: &str, to: &str) -> Result<()> {
166        let path = self.issues_dir.join(format!("{number}.md"));
167        let content = std::fs::read_to_string(&path)
168            .with_context(|| format!("ticket #{number} not found"))?;
169        let mut ticket = parse_local_issue(&content)?;
170        ticket.labels.retain(|l| l != from);
171        if !ticket.labels.contains(&to.to_string()) {
172            ticket.labels.push(to.to_string());
173        }
174        let updated = rewrite_frontmatter_labels(&content, &ticket.labels);
175        std::fs::write(&path, updated).context("writing updated ticket")?;
176        Ok(())
177    }
178
179    async fn comment(&self, number: u32, body: &str) -> Result<()> {
180        let path = self.issues_dir.join(format!("{number}.md"));
181        let mut content = std::fs::read_to_string(&path)
182            .with_context(|| format!("ticket #{number} not found"))?;
183        let now = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC");
184        let _ = write!(content, "\n---\n\n**Comment ({now}):**\n\n{body}\n");
185        std::fs::write(&path, content).context("writing comment")?;
186        Ok(())
187    }
188
189    async fn close(&self, number: u32, comment: Option<&str>) -> Result<()> {
190        let path = self.issues_dir.join(format!("{number}.md"));
191        let content = std::fs::read_to_string(&path)
192            .with_context(|| format!("ticket #{number} not found"))?;
193        let updated = replace_frontmatter_status(&content, "open", "closed");
194        std::fs::write(&path, &updated).context("writing closed ticket")?;
195
196        if let Some(body) = comment {
197            self.comment(number, body).await?;
198        }
199        Ok(())
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    fn create_issue_file(dir: &std::path::Path, id: u32, content: &str) {
208        std::fs::create_dir_all(dir).unwrap();
209        std::fs::write(dir.join(format!("{id}.md")), content).unwrap();
210    }
211
212    fn issue_content(id: u32, title: &str, status: &str, labels: &[&str]) -> String {
213        let labels_str = labels.iter().map(|l| format!("\"{l}\"")).collect::<Vec<_>>().join(", ");
214        format!(
215            "---\nid: {id}\ntitle: {title}\nstatus: {status}\nlabels: [{labels_str}]\n---\n\nIssue body for {title}"
216        )
217    }
218
219    #[tokio::test]
220    async fn get_ready_issues_returns_matching() {
221        let dir = tempfile::tempdir().unwrap();
222        let issues_dir = dir.path().join(".oven").join("issues");
223
224        create_issue_file(&issues_dir, 1, &issue_content(1, "First", "open", &["o-ready"]));
225        create_issue_file(&issues_dir, 2, &issue_content(2, "Second", "open", &["o-cooking"]));
226        create_issue_file(&issues_dir, 3, &issue_content(3, "Third", "open", &["o-ready"]));
227
228        let provider = LocalIssueProvider::new(dir.path());
229        let issues = provider.get_ready_issues("o-ready").await.unwrap();
230
231        assert_eq!(issues.len(), 2);
232        assert_eq!(issues[0].number, 1);
233        assert_eq!(issues[1].number, 3);
234        assert_eq!(issues[0].source, IssueOrigin::Local);
235    }
236
237    #[tokio::test]
238    async fn get_ready_issues_skips_closed() {
239        let dir = tempfile::tempdir().unwrap();
240        let issues_dir = dir.path().join(".oven").join("issues");
241
242        create_issue_file(&issues_dir, 1, &issue_content(1, "Open", "open", &["o-ready"]));
243        create_issue_file(&issues_dir, 2, &issue_content(2, "Closed", "closed", &["o-ready"]));
244
245        let provider = LocalIssueProvider::new(dir.path());
246        let issues = provider.get_ready_issues("o-ready").await.unwrap();
247
248        assert_eq!(issues.len(), 1);
249        assert_eq!(issues[0].number, 1);
250    }
251
252    #[tokio::test]
253    async fn get_ready_issues_empty_dir() {
254        let dir = tempfile::tempdir().unwrap();
255        let provider = LocalIssueProvider::new(dir.path());
256        let issues = provider.get_ready_issues("o-ready").await.unwrap();
257        assert!(issues.is_empty());
258    }
259
260    #[tokio::test]
261    async fn get_issue_returns_specific() {
262        let dir = tempfile::tempdir().unwrap();
263        let issues_dir = dir.path().join(".oven").join("issues");
264
265        create_issue_file(&issues_dir, 42, &issue_content(42, "Specific", "open", &["o-ready"]));
266
267        let provider = LocalIssueProvider::new(dir.path());
268        let issue = provider.get_issue(42).await.unwrap();
269
270        assert_eq!(issue.number, 42);
271        assert_eq!(issue.title, "Specific");
272    }
273
274    #[tokio::test]
275    async fn get_issue_nonexistent_errors() {
276        let dir = tempfile::tempdir().unwrap();
277        let provider = LocalIssueProvider::new(dir.path());
278        let result = provider.get_issue(999).await;
279        assert!(result.is_err());
280    }
281
282    #[tokio::test]
283    async fn transition_updates_labels() {
284        let dir = tempfile::tempdir().unwrap();
285        let issues_dir = dir.path().join(".oven").join("issues");
286
287        create_issue_file(&issues_dir, 1, &issue_content(1, "Test", "open", &["o-ready"]));
288
289        let provider = LocalIssueProvider::new(dir.path());
290        provider.transition(1, "o-ready", "o-cooking").await.unwrap();
291
292        let issue = provider.get_issue(1).await.unwrap();
293        // Verify by re-reading: the issue should no longer match o-ready
294        let issues = provider.get_ready_issues("o-ready").await.unwrap();
295        assert!(issues.is_empty());
296
297        let cooking = provider.get_ready_issues("o-cooking").await.unwrap();
298        assert_eq!(cooking.len(), 1);
299        assert_eq!(cooking[0].number, issue.number);
300    }
301
302    #[tokio::test]
303    async fn comment_appends_to_file() {
304        let dir = tempfile::tempdir().unwrap();
305        let issues_dir = dir.path().join(".oven").join("issues");
306
307        create_issue_file(&issues_dir, 1, &issue_content(1, "Test", "open", &["o-ready"]));
308
309        let provider = LocalIssueProvider::new(dir.path());
310        provider.comment(1, "Pipeline started").await.unwrap();
311
312        let content = std::fs::read_to_string(issues_dir.join("1.md")).unwrap();
313        assert!(content.contains("Pipeline started"));
314        assert!(content.contains("Comment"));
315    }
316
317    #[tokio::test]
318    async fn close_sets_status() {
319        let dir = tempfile::tempdir().unwrap();
320        let issues_dir = dir.path().join(".oven").join("issues");
321
322        create_issue_file(&issues_dir, 1, &issue_content(1, "Test", "open", &["o-ready"]));
323
324        let provider = LocalIssueProvider::new(dir.path());
325        provider.close(1, Some("Done")).await.unwrap();
326
327        let content = std::fs::read_to_string(issues_dir.join("1.md")).unwrap();
328        assert!(content.contains("status: closed"));
329        assert!(content.contains("Done"));
330    }
331
332    #[tokio::test]
333    async fn target_repo_parsed_from_frontmatter() {
334        let dir = tempfile::tempdir().unwrap();
335        let issues_dir = dir.path().join(".oven").join("issues");
336
337        let content = "---\nid: 5\ntitle: Multi-repo\nstatus: open\nlabels: [\"o-ready\"]\ntarget_repo: api\n---\n\nDo work";
338        create_issue_file(&issues_dir, 5, content);
339
340        let provider = LocalIssueProvider::new(dir.path());
341        let issue = provider.get_issue(5).await.unwrap();
342
343        assert_eq!(issue.target_repo.as_deref(), Some("api"));
344    }
345
346    #[tokio::test]
347    async fn target_repo_none_when_not_in_frontmatter() {
348        let dir = tempfile::tempdir().unwrap();
349        let issues_dir = dir.path().join(".oven").join("issues");
350
351        create_issue_file(&issues_dir, 1, &issue_content(1, "Normal", "open", &["o-ready"]));
352
353        let provider = LocalIssueProvider::new(dir.path());
354        let issue = provider.get_issue(1).await.unwrap();
355
356        assert!(issue.target_repo.is_none());
357    }
358
359    #[test]
360    fn parse_local_issue_valid() {
361        let content =
362            "---\nid: 1\ntitle: Test\nstatus: open\nlabels: [\"o-ready\"]\n---\n\nBody text";
363        let ticket = parse_local_issue(content).unwrap();
364        assert_eq!(ticket.id, 1);
365        assert_eq!(ticket.title, "Test");
366        assert_eq!(ticket.status, "open");
367        assert_eq!(ticket.labels, vec!["o-ready"]);
368        assert_eq!(ticket.body, "Body text");
369    }
370
371    #[test]
372    fn parse_local_issue_missing_frontmatter() {
373        let result = parse_local_issue("No frontmatter here");
374        assert!(result.is_err());
375    }
376
377    #[test]
378    fn rewrite_labels_preserves_rest() {
379        let content = "---\nid: 1\ntitle: Test\nstatus: open\nlabels: [\"o-ready\"]\n---\n\nBody";
380        let result = rewrite_frontmatter_labels(content, &["o-cooking".to_string()]);
381        assert!(result.contains("labels: [\"o-cooking\"]"));
382        assert!(result.contains("id: 1"));
383        assert!(result.contains("title: Test"));
384    }
385}