1use std::{fmt::Write, path::PathBuf};
2
3use anyhow::{Context, Result};
4use async_trait::async_trait;
5
6use super::{IssueOrigin, IssueProvider, PipelineIssue};
7
8pub 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#[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
30fn 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
67fn 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
83pub(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
98pub 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 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 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}