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 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 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}