Skip to main content

oven_cli/github/
issues.rs

1use anyhow::{Context, Result};
2
3use super::{GhClient, Issue};
4use crate::process::CommandRunner;
5
6/// An issue with parsed frontmatter metadata.
7///
8/// When multi-repo mode is enabled, issues can include YAML frontmatter at the
9/// top of the body to specify which target repo the work should go to.
10#[derive(Debug, Clone)]
11pub struct ParsedIssue {
12    pub issue: Issue,
13    pub target_repo: Option<String>,
14    pub body_without_frontmatter: String,
15}
16
17/// Parse YAML frontmatter from an issue body, extracting the target repo field.
18///
19/// Frontmatter is delimited by `---` on its own line at the very start of the body.
20/// Only the field named by `target_field` is extracted; all other frontmatter is ignored.
21/// The returned `body_without_frontmatter` has the frontmatter block stripped.
22pub fn parse_issue_frontmatter(issue: &Issue, target_field: &str) -> ParsedIssue {
23    let body = issue.body.trim_start();
24
25    if !body.starts_with("---") {
26        return ParsedIssue {
27            issue: issue.clone(),
28            target_repo: None,
29            body_without_frontmatter: issue.body.clone(),
30        };
31    }
32
33    // Find the closing --- delimiter (skip the opening one)
34    let after_open = &body[3..];
35    let closing = after_open.find("\n---");
36
37    let Some(close_idx) = closing else {
38        // No closing delimiter -- treat entire body as content (not frontmatter)
39        return ParsedIssue {
40            issue: issue.clone(),
41            target_repo: None,
42            body_without_frontmatter: issue.body.clone(),
43        };
44    };
45
46    let frontmatter = &after_open[..close_idx];
47    let rest = &after_open[close_idx + 4..]; // skip "\n---"
48    let body_without = rest.trim_start_matches('\n').to_string();
49
50    // Simple key: value extraction (no full YAML parser needed)
51    let needle = format!("{target_field}:");
52    let target_repo = frontmatter.lines().find_map(|line| {
53        let trimmed = line.trim();
54        if trimmed.starts_with(&needle) {
55            Some(trimmed[needle.len()..].trim().to_string())
56        } else {
57            None
58        }
59    });
60
61    ParsedIssue { issue: issue.clone(), target_repo, body_without_frontmatter: body_without }
62}
63
64impl<R: CommandRunner> GhClient<R> {
65    /// Fetch open issues with the given label, ordered oldest first.
66    pub async fn get_issues_by_label(&self, label: &str) -> Result<Vec<Issue>> {
67        let output = self
68            .runner
69            .run_gh(
70                &Self::s(&[
71                    "issue",
72                    "list",
73                    "--label",
74                    label,
75                    "--author",
76                    "@me",
77                    "--json",
78                    "number,title,body,labels",
79                    "--state",
80                    "open",
81                    "--limit",
82                    "100",
83                ]),
84                &self.repo_dir,
85            )
86            .await
87            .context("fetching issues by label")?;
88        Self::check_output(&output, "fetch issues")?;
89
90        let mut issues: Vec<Issue> =
91            serde_json::from_str(&output.stdout).context("parsing issue list JSON")?;
92        // gh returns newest first; we want oldest first (FIFO)
93        issues.sort_by_key(|i| i.number);
94        Ok(issues)
95    }
96
97    /// Fetch a single issue by number.
98    pub async fn get_issue(&self, issue_number: u32) -> Result<Issue> {
99        let output = self
100            .runner
101            .run_gh(
102                &Self::s(&[
103                    "issue",
104                    "view",
105                    &issue_number.to_string(),
106                    "--json",
107                    "number,title,body,labels",
108                ]),
109                &self.repo_dir,
110            )
111            .await
112            .context("fetching issue")?;
113        Self::check_output(&output, "fetch issue")?;
114
115        let issue: Issue = serde_json::from_str(&output.stdout).context("parsing issue JSON")?;
116        Ok(issue)
117    }
118
119    /// Post a comment on an issue.
120    pub async fn comment_on_issue(&self, issue_number: u32, body: &str) -> Result<()> {
121        let output = self
122            .runner
123            .run_gh(
124                &Self::s(&["issue", "comment", &issue_number.to_string(), "--body", body]),
125                &self.repo_dir,
126            )
127            .await
128            .context("commenting on issue")?;
129        Self::check_output(&output, "comment on issue")?;
130        Ok(())
131    }
132
133    /// Close an issue with an optional comment.
134    pub async fn close_issue(&self, issue_number: u32, comment: Option<&str>) -> Result<()> {
135        let num_str = issue_number.to_string();
136        let mut args = vec!["issue", "close", &num_str];
137        if let Some(body) = comment {
138            args.extend(["--comment", body]);
139        }
140        let output =
141            self.runner.run_gh(&Self::s(&args), &self.repo_dir).await.context("closing issue")?;
142        Self::check_output(&output, "close issue")?;
143        Ok(())
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use std::path::Path;
150
151    use super::*;
152    use crate::{
153        github::GhClient,
154        process::{CommandOutput, MockCommandRunner},
155    };
156
157    #[tokio::test]
158    async fn get_issues_by_label_parses_json() {
159        let mut mock = MockCommandRunner::new();
160        mock.expect_run_gh().returning(|_, _| {
161            Box::pin(async {
162                Ok(CommandOutput {
163                    stdout: r#"[{"number":3,"title":"Third","body":"c","labels":[{"name":"o-ready"}]},{"number":1,"title":"First","body":"a","labels":[{"name":"o-ready"}]},{"number":2,"title":"Second","body":"b","labels":[{"name":"o-ready"}]}]"#.to_string(),
164                    stderr: String::new(),
165                    success: true,
166                })
167            })
168        });
169
170        let client = GhClient::new(mock, Path::new("/tmp"));
171        let issues = client.get_issues_by_label("o-ready").await.unwrap();
172
173        assert_eq!(issues.len(), 3);
174        // Should be sorted oldest first (by number)
175        assert_eq!(issues[0].number, 1);
176        assert_eq!(issues[1].number, 2);
177        assert_eq!(issues[2].number, 3);
178    }
179
180    #[tokio::test]
181    async fn get_issues_by_label_filters_by_current_user() {
182        let mut mock = MockCommandRunner::new();
183        mock.expect_run_gh().returning(|args, _| {
184            assert!(args.contains(&"--author".to_string()));
185            assert!(args.contains(&"@me".to_string()));
186            Box::pin(async {
187                Ok(CommandOutput { stdout: "[]".to_string(), stderr: String::new(), success: true })
188            })
189        });
190
191        let client = GhClient::new(mock, Path::new("/tmp"));
192        let issues = client.get_issues_by_label("o-ready").await.unwrap();
193        assert!(issues.is_empty());
194    }
195
196    #[tokio::test]
197    async fn get_issue_parses_single() {
198        let mut mock = MockCommandRunner::new();
199        mock.expect_run_gh().returning(|_, _| {
200            Box::pin(async {
201                Ok(CommandOutput {
202                    stdout: r#"{"number":42,"title":"Fix bug","body":"details","labels":[]}"#
203                        .to_string(),
204                    stderr: String::new(),
205                    success: true,
206                })
207            })
208        });
209
210        let client = GhClient::new(mock, Path::new("/tmp"));
211        let issue = client.get_issue(42).await.unwrap();
212
213        assert_eq!(issue.number, 42);
214        assert_eq!(issue.title, "Fix bug");
215        assert_eq!(issue.body, "details");
216    }
217
218    #[tokio::test]
219    async fn comment_on_issue_succeeds() {
220        let mut mock = MockCommandRunner::new();
221        mock.expect_run_gh().returning(|_, _| {
222            Box::pin(async {
223                Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
224            })
225        });
226
227        let client = GhClient::new(mock, Path::new("/tmp"));
228        let result = client.comment_on_issue(42, "hello").await;
229        assert!(result.is_ok());
230    }
231
232    #[tokio::test]
233    async fn close_issue_with_comment() {
234        let mut mock = MockCommandRunner::new();
235        mock.expect_run_gh().returning(|args, _| {
236            assert!(args.contains(&"issue".to_string()));
237            assert!(args.contains(&"close".to_string()));
238            assert!(args.contains(&"--comment".to_string()));
239            Box::pin(async {
240                Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
241            })
242        });
243
244        let client = GhClient::new(mock, Path::new("/tmp"));
245        let result = client.close_issue(42, Some("Done")).await;
246        assert!(result.is_ok());
247    }
248
249    #[tokio::test]
250    async fn close_issue_without_comment() {
251        let mut mock = MockCommandRunner::new();
252        mock.expect_run_gh().returning(|args, _| {
253            assert!(!args.contains(&"--comment".to_string()));
254            Box::pin(async {
255                Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
256            })
257        });
258
259        let client = GhClient::new(mock, Path::new("/tmp"));
260        let result = client.close_issue(42, None).await;
261        assert!(result.is_ok());
262    }
263
264    fn make_issue(body: &str) -> Issue {
265        Issue { number: 1, title: "Test".to_string(), body: body.to_string(), labels: vec![] }
266    }
267
268    #[test]
269    fn parse_frontmatter_extracts_target_repo() {
270        let issue = make_issue("---\ntarget_repo: my-service\n---\n\nFix the bug");
271        let parsed = parse_issue_frontmatter(&issue, "target_repo");
272        assert_eq!(parsed.target_repo.as_deref(), Some("my-service"));
273        assert_eq!(parsed.body_without_frontmatter, "Fix the bug");
274    }
275
276    #[test]
277    fn parse_frontmatter_custom_field_name() {
278        let issue = make_issue("---\nrepo: other-thing\n---\n\nDo stuff");
279        let parsed = parse_issue_frontmatter(&issue, "repo");
280        assert_eq!(parsed.target_repo.as_deref(), Some("other-thing"));
281    }
282
283    #[test]
284    fn parse_frontmatter_no_frontmatter() {
285        let issue = make_issue("Just a regular issue body");
286        let parsed = parse_issue_frontmatter(&issue, "target_repo");
287        assert!(parsed.target_repo.is_none());
288        assert_eq!(parsed.body_without_frontmatter, "Just a regular issue body");
289    }
290
291    #[test]
292    fn parse_frontmatter_unclosed_delimiters() {
293        let issue = make_issue("---\ntarget_repo: oops\nno closing delimiter");
294        let parsed = parse_issue_frontmatter(&issue, "target_repo");
295        assert!(parsed.target_repo.is_none());
296        assert_eq!(parsed.body_without_frontmatter, issue.body);
297    }
298
299    #[test]
300    fn parse_frontmatter_missing_field() {
301        let issue = make_issue("---\nother_key: value\n---\n\nBody here");
302        let parsed = parse_issue_frontmatter(&issue, "target_repo");
303        assert!(parsed.target_repo.is_none());
304        assert_eq!(parsed.body_without_frontmatter, "Body here");
305    }
306
307    #[test]
308    fn parse_frontmatter_strips_leading_newlines() {
309        let issue = make_issue("---\ntarget_repo: svc\n---\n\n\nBody");
310        let parsed = parse_issue_frontmatter(&issue, "target_repo");
311        assert_eq!(parsed.body_without_frontmatter, "Body");
312    }
313
314    #[test]
315    fn parse_frontmatter_preserves_issue() {
316        let issue = make_issue("---\ntarget_repo: api\n---\nContent");
317        let parsed = parse_issue_frontmatter(&issue, "target_repo");
318        assert_eq!(parsed.issue.number, 1);
319        assert_eq!(parsed.issue.title, "Test");
320    }
321
322    #[test]
323    fn parse_frontmatter_with_extra_fields() {
324        let issue =
325            make_issue("---\npriority: high\ntarget_repo: backend\nlabel: bug\n---\n\nDetails");
326        let parsed = parse_issue_frontmatter(&issue, "target_repo");
327        assert_eq!(parsed.target_repo.as_deref(), Some("backend"));
328        assert_eq!(parsed.body_without_frontmatter, "Details");
329    }
330
331    #[test]
332    fn parse_frontmatter_empty_body() {
333        let issue = make_issue("");
334        let parsed = parse_issue_frontmatter(&issue, "target_repo");
335        assert!(parsed.target_repo.is_none());
336        assert_eq!(parsed.body_without_frontmatter, "");
337    }
338}