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                        author: None,
144                    });
145                }
146            }
147        }
148
149        Ok(issues)
150    }
151
152    async fn get_issue(&self, number: u32) -> Result<PipelineIssue> {
153        let path = self.issues_dir.join(format!("{number}.md"));
154        let content = std::fs::read_to_string(&path)
155            .with_context(|| format!("ticket #{number} not found"))?;
156        let ticket = parse_local_issue(&content)?;
157        Ok(PipelineIssue {
158            number: ticket.id,
159            title: ticket.title,
160            body: ticket.body,
161            source: IssueOrigin::Local,
162            target_repo: ticket.target_repo,
163            author: None,
164        })
165    }
166
167    async fn transition(&self, number: u32, from: &str, to: &str) -> Result<()> {
168        let path = self.issues_dir.join(format!("{number}.md"));
169        let content = std::fs::read_to_string(&path)
170            .with_context(|| format!("ticket #{number} not found"))?;
171        let mut ticket = parse_local_issue(&content)?;
172        ticket.labels.retain(|l| l != from);
173        if !ticket.labels.contains(&to.to_string()) {
174            ticket.labels.push(to.to_string());
175        }
176        let updated = rewrite_frontmatter_labels(&content, &ticket.labels);
177        std::fs::write(&path, updated).context("writing updated ticket")?;
178        Ok(())
179    }
180
181    async fn comment(&self, number: u32, body: &str) -> Result<()> {
182        let path = self.issues_dir.join(format!("{number}.md"));
183        let mut content = std::fs::read_to_string(&path)
184            .with_context(|| format!("ticket #{number} not found"))?;
185        let now = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC");
186        let _ = write!(content, "\n---\n\n**Comment ({now}):**\n\n{body}\n");
187        std::fs::write(&path, content).context("writing comment")?;
188        Ok(())
189    }
190
191    async fn close(&self, number: u32, comment: Option<&str>) -> Result<()> {
192        let path = self.issues_dir.join(format!("{number}.md"));
193        let content = std::fs::read_to_string(&path)
194            .with_context(|| format!("ticket #{number} not found"))?;
195        let updated = replace_frontmatter_status(&content, "open", "closed");
196        std::fs::write(&path, &updated).context("writing closed ticket")?;
197
198        if let Some(body) = comment {
199            self.comment(number, body).await?;
200        }
201        Ok(())
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn create_issue_file(dir: &std::path::Path, id: u32, content: &str) {
210        std::fs::create_dir_all(dir).unwrap();
211        std::fs::write(dir.join(format!("{id}.md")), content).unwrap();
212    }
213
214    fn issue_content(id: u32, title: &str, status: &str, labels: &[&str]) -> String {
215        let labels_str = labels.iter().map(|l| format!("\"{l}\"")).collect::<Vec<_>>().join(", ");
216        format!(
217            "---\nid: {id}\ntitle: {title}\nstatus: {status}\nlabels: [{labels_str}]\n---\n\nIssue body for {title}"
218        )
219    }
220
221    #[tokio::test]
222    async fn get_ready_issues_returns_matching() {
223        let dir = tempfile::tempdir().unwrap();
224        let issues_dir = dir.path().join(".oven").join("issues");
225
226        create_issue_file(&issues_dir, 1, &issue_content(1, "First", "open", &["o-ready"]));
227        create_issue_file(&issues_dir, 2, &issue_content(2, "Second", "open", &["o-cooking"]));
228        create_issue_file(&issues_dir, 3, &issue_content(3, "Third", "open", &["o-ready"]));
229
230        let provider = LocalIssueProvider::new(dir.path());
231        let issues = provider.get_ready_issues("o-ready").await.unwrap();
232
233        assert_eq!(issues.len(), 2);
234        assert_eq!(issues[0].number, 1);
235        assert_eq!(issues[1].number, 3);
236        assert_eq!(issues[0].source, IssueOrigin::Local);
237    }
238
239    #[tokio::test]
240    async fn get_ready_issues_skips_closed() {
241        let dir = tempfile::tempdir().unwrap();
242        let issues_dir = dir.path().join(".oven").join("issues");
243
244        create_issue_file(&issues_dir, 1, &issue_content(1, "Open", "open", &["o-ready"]));
245        create_issue_file(&issues_dir, 2, &issue_content(2, "Closed", "closed", &["o-ready"]));
246
247        let provider = LocalIssueProvider::new(dir.path());
248        let issues = provider.get_ready_issues("o-ready").await.unwrap();
249
250        assert_eq!(issues.len(), 1);
251        assert_eq!(issues[0].number, 1);
252    }
253
254    #[tokio::test]
255    async fn get_ready_issues_empty_dir() {
256        let dir = tempfile::tempdir().unwrap();
257        let provider = LocalIssueProvider::new(dir.path());
258        let issues = provider.get_ready_issues("o-ready").await.unwrap();
259        assert!(issues.is_empty());
260    }
261
262    #[tokio::test]
263    async fn get_issue_returns_specific() {
264        let dir = tempfile::tempdir().unwrap();
265        let issues_dir = dir.path().join(".oven").join("issues");
266
267        create_issue_file(&issues_dir, 42, &issue_content(42, "Specific", "open", &["o-ready"]));
268
269        let provider = LocalIssueProvider::new(dir.path());
270        let issue = provider.get_issue(42).await.unwrap();
271
272        assert_eq!(issue.number, 42);
273        assert_eq!(issue.title, "Specific");
274    }
275
276    #[tokio::test]
277    async fn get_issue_author_is_none() {
278        let dir = tempfile::tempdir().unwrap();
279        let issues_dir = dir.path().join(".oven").join("issues");
280
281        create_issue_file(&issues_dir, 7, &issue_content(7, "Local", "open", &["o-ready"]));
282
283        let provider = LocalIssueProvider::new(dir.path());
284        let issue = provider.get_issue(7).await.unwrap();
285
286        assert!(issue.author.is_none());
287    }
288
289    #[tokio::test]
290    async fn get_issue_nonexistent_errors() {
291        let dir = tempfile::tempdir().unwrap();
292        let provider = LocalIssueProvider::new(dir.path());
293        let result = provider.get_issue(999).await;
294        assert!(result.is_err());
295    }
296
297    #[tokio::test]
298    async fn transition_updates_labels() {
299        let dir = tempfile::tempdir().unwrap();
300        let issues_dir = dir.path().join(".oven").join("issues");
301
302        create_issue_file(&issues_dir, 1, &issue_content(1, "Test", "open", &["o-ready"]));
303
304        let provider = LocalIssueProvider::new(dir.path());
305        provider.transition(1, "o-ready", "o-cooking").await.unwrap();
306
307        let issue = provider.get_issue(1).await.unwrap();
308        // Verify by re-reading: the issue should no longer match o-ready
309        let issues = provider.get_ready_issues("o-ready").await.unwrap();
310        assert!(issues.is_empty());
311
312        let cooking = provider.get_ready_issues("o-cooking").await.unwrap();
313        assert_eq!(cooking.len(), 1);
314        assert_eq!(cooking[0].number, issue.number);
315    }
316
317    #[tokio::test]
318    async fn comment_appends_to_file() {
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.comment(1, "Pipeline started").await.unwrap();
326
327        let content = std::fs::read_to_string(issues_dir.join("1.md")).unwrap();
328        assert!(content.contains("Pipeline started"));
329        assert!(content.contains("Comment"));
330    }
331
332    #[tokio::test]
333    async fn close_sets_status() {
334        let dir = tempfile::tempdir().unwrap();
335        let issues_dir = dir.path().join(".oven").join("issues");
336
337        create_issue_file(&issues_dir, 1, &issue_content(1, "Test", "open", &["o-ready"]));
338
339        let provider = LocalIssueProvider::new(dir.path());
340        provider.close(1, Some("Done")).await.unwrap();
341
342        let content = std::fs::read_to_string(issues_dir.join("1.md")).unwrap();
343        assert!(content.contains("status: closed"));
344        assert!(content.contains("Done"));
345    }
346
347    #[tokio::test]
348    async fn target_repo_parsed_from_frontmatter() {
349        let dir = tempfile::tempdir().unwrap();
350        let issues_dir = dir.path().join(".oven").join("issues");
351
352        let content = "---\nid: 5\ntitle: Multi-repo\nstatus: open\nlabels: [\"o-ready\"]\ntarget_repo: api\n---\n\nDo work";
353        create_issue_file(&issues_dir, 5, content);
354
355        let provider = LocalIssueProvider::new(dir.path());
356        let issue = provider.get_issue(5).await.unwrap();
357
358        assert_eq!(issue.target_repo.as_deref(), Some("api"));
359    }
360
361    #[tokio::test]
362    async fn target_repo_none_when_not_in_frontmatter() {
363        let dir = tempfile::tempdir().unwrap();
364        let issues_dir = dir.path().join(".oven").join("issues");
365
366        create_issue_file(&issues_dir, 1, &issue_content(1, "Normal", "open", &["o-ready"]));
367
368        let provider = LocalIssueProvider::new(dir.path());
369        let issue = provider.get_issue(1).await.unwrap();
370
371        assert!(issue.target_repo.is_none());
372    }
373
374    #[test]
375    fn parse_local_issue_valid() {
376        let content =
377            "---\nid: 1\ntitle: Test\nstatus: open\nlabels: [\"o-ready\"]\n---\n\nBody text";
378        let ticket = parse_local_issue(content).unwrap();
379        assert_eq!(ticket.id, 1);
380        assert_eq!(ticket.title, "Test");
381        assert_eq!(ticket.status, "open");
382        assert_eq!(ticket.labels, vec!["o-ready"]);
383        assert_eq!(ticket.body, "Body text");
384    }
385
386    #[test]
387    fn parse_local_issue_missing_frontmatter() {
388        let result = parse_local_issue("No frontmatter here");
389        assert!(result.is_err());
390    }
391
392    #[test]
393    fn rewrite_labels_preserves_rest() {
394        let content = "---\nid: 1\ntitle: Test\nstatus: open\nlabels: [\"o-ready\"]\n---\n\nBody";
395        let result = rewrite_frontmatter_labels(content, &["o-cooking".to_string()]);
396        assert!(result.contains("labels: [\"o-cooking\"]"));
397        assert!(result.contains("id: 1"));
398        assert!(result.contains("title: Test"));
399    }
400}