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,author",
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,author",
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    /// Fetch the authenticated GitHub user's login.
120    pub async fn get_current_user(&self) -> Result<String> {
121        let output = self
122            .runner
123            .run_gh(&Self::s(&["api", "user", "--jq", ".login"]), &self.repo_dir)
124            .await
125            .context("fetching current user")?;
126        Self::check_output(&output, "fetch current user")?;
127        Ok(output.stdout.trim().to_string())
128    }
129
130    /// Post a comment on an issue.
131    pub async fn comment_on_issue(&self, issue_number: u32, body: &str) -> Result<()> {
132        let output = self
133            .runner
134            .run_gh(
135                &Self::s(&["issue", "comment", &issue_number.to_string(), "--body", body]),
136                &self.repo_dir,
137            )
138            .await
139            .context("commenting on issue")?;
140        Self::check_output(&output, "comment on issue")?;
141        Ok(())
142    }
143
144    /// Close an issue with an optional comment.
145    pub async fn close_issue(&self, issue_number: u32, comment: Option<&str>) -> Result<()> {
146        let num_str = issue_number.to_string();
147        let mut args = vec!["issue", "close", &num_str];
148        if let Some(body) = comment {
149            args.extend(["--comment", body]);
150        }
151        let output =
152            self.runner.run_gh(&Self::s(&args), &self.repo_dir).await.context("closing issue")?;
153        Self::check_output(&output, "close issue")?;
154        Ok(())
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use std::path::Path;
161
162    use super::*;
163    use crate::{
164        github::GhClient,
165        process::{CommandOutput, MockCommandRunner},
166    };
167
168    #[tokio::test]
169    async fn get_issues_by_label_parses_json() {
170        let mut mock = MockCommandRunner::new();
171        mock.expect_run_gh().returning(|_, _| {
172            Box::pin(async {
173                Ok(CommandOutput {
174                    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(),
175                    stderr: String::new(),
176                    success: true,
177                })
178            })
179        });
180
181        let client = GhClient::new(mock, Path::new("/tmp"));
182        let issues = client.get_issues_by_label("o-ready").await.unwrap();
183
184        assert_eq!(issues.len(), 3);
185        // Should be sorted oldest first (by number)
186        assert_eq!(issues[0].number, 1);
187        assert_eq!(issues[1].number, 2);
188        assert_eq!(issues[2].number, 3);
189    }
190
191    #[tokio::test]
192    async fn get_issues_by_label_filters_by_current_user() {
193        let mut mock = MockCommandRunner::new();
194        mock.expect_run_gh().returning(|args, _| {
195            assert!(args.contains(&"--author".to_string()));
196            assert!(args.contains(&"@me".to_string()));
197            Box::pin(async {
198                Ok(CommandOutput { stdout: "[]".to_string(), stderr: String::new(), success: true })
199            })
200        });
201
202        let client = GhClient::new(mock, Path::new("/tmp"));
203        let issues = client.get_issues_by_label("o-ready").await.unwrap();
204        assert!(issues.is_empty());
205    }
206
207    #[tokio::test]
208    async fn get_issue_parses_single() {
209        let mut mock = MockCommandRunner::new();
210        mock.expect_run_gh().returning(|_, _| {
211            Box::pin(async {
212                Ok(CommandOutput {
213                    stdout: r#"{"number":42,"title":"Fix bug","body":"details","labels":[]}"#
214                        .to_string(),
215                    stderr: String::new(),
216                    success: true,
217                })
218            })
219        });
220
221        let client = GhClient::new(mock, Path::new("/tmp"));
222        let issue = client.get_issue(42).await.unwrap();
223
224        assert_eq!(issue.number, 42);
225        assert_eq!(issue.title, "Fix bug");
226        assert_eq!(issue.body, "details");
227    }
228
229    #[tokio::test]
230    async fn get_issue_parses_author_login() {
231        let mut mock = MockCommandRunner::new();
232        mock.expect_run_gh().returning(|_, _| {
233            Box::pin(async {
234                Ok(CommandOutput {
235                    stdout: r#"{"number":10,"title":"Auth","body":"b","labels":[],"author":{"login":"alice"}}"#
236                        .to_string(),
237                    stderr: String::new(),
238                    success: true,
239                })
240            })
241        });
242
243        let client = GhClient::new(mock, Path::new("/tmp"));
244        let issue = client.get_issue(10).await.unwrap();
245
246        assert_eq!(issue.author.as_ref().unwrap().login, "alice");
247    }
248
249    #[tokio::test]
250    async fn get_issue_handles_missing_author() {
251        let mut mock = MockCommandRunner::new();
252        mock.expect_run_gh().returning(|_, _| {
253            Box::pin(async {
254                Ok(CommandOutput {
255                    stdout: r#"{"number":11,"title":"No author","body":"b","labels":[]}"#
256                        .to_string(),
257                    stderr: String::new(),
258                    success: true,
259                })
260            })
261        });
262
263        let client = GhClient::new(mock, Path::new("/tmp"));
264        let issue = client.get_issue(11).await.unwrap();
265
266        assert!(issue.author.is_none());
267    }
268
269    #[tokio::test]
270    async fn get_current_user_returns_login() {
271        let mut mock = MockCommandRunner::new();
272        mock.expect_run_gh().returning(|args, _| {
273            assert!(args.contains(&"api".to_string()));
274            assert!(args.contains(&"user".to_string()));
275            Box::pin(async {
276                Ok(CommandOutput {
277                    stdout: "octocat\n".to_string(),
278                    stderr: String::new(),
279                    success: true,
280                })
281            })
282        });
283
284        let client = GhClient::new(mock, Path::new("/tmp"));
285        let user = client.get_current_user().await.unwrap();
286
287        assert_eq!(user, "octocat");
288    }
289
290    #[tokio::test]
291    async fn comment_on_issue_succeeds() {
292        let mut mock = MockCommandRunner::new();
293        mock.expect_run_gh().returning(|_, _| {
294            Box::pin(async {
295                Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
296            })
297        });
298
299        let client = GhClient::new(mock, Path::new("/tmp"));
300        let result = client.comment_on_issue(42, "hello").await;
301        assert!(result.is_ok());
302    }
303
304    #[tokio::test]
305    async fn close_issue_with_comment() {
306        let mut mock = MockCommandRunner::new();
307        mock.expect_run_gh().returning(|args, _| {
308            assert!(args.contains(&"issue".to_string()));
309            assert!(args.contains(&"close".to_string()));
310            assert!(args.contains(&"--comment".to_string()));
311            Box::pin(async {
312                Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
313            })
314        });
315
316        let client = GhClient::new(mock, Path::new("/tmp"));
317        let result = client.close_issue(42, Some("Done")).await;
318        assert!(result.is_ok());
319    }
320
321    #[tokio::test]
322    async fn close_issue_without_comment() {
323        let mut mock = MockCommandRunner::new();
324        mock.expect_run_gh().returning(|args, _| {
325            assert!(!args.contains(&"--comment".to_string()));
326            Box::pin(async {
327                Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
328            })
329        });
330
331        let client = GhClient::new(mock, Path::new("/tmp"));
332        let result = client.close_issue(42, None).await;
333        assert!(result.is_ok());
334    }
335
336    fn make_issue(body: &str) -> Issue {
337        Issue {
338            number: 1,
339            title: "Test".to_string(),
340            body: body.to_string(),
341            labels: vec![],
342            author: None,
343        }
344    }
345
346    #[test]
347    fn parse_frontmatter_extracts_target_repo() {
348        let issue = make_issue("---\ntarget_repo: my-service\n---\n\nFix the bug");
349        let parsed = parse_issue_frontmatter(&issue, "target_repo");
350        assert_eq!(parsed.target_repo.as_deref(), Some("my-service"));
351        assert_eq!(parsed.body_without_frontmatter, "Fix the bug");
352    }
353
354    #[test]
355    fn parse_frontmatter_custom_field_name() {
356        let issue = make_issue("---\nrepo: other-thing\n---\n\nDo stuff");
357        let parsed = parse_issue_frontmatter(&issue, "repo");
358        assert_eq!(parsed.target_repo.as_deref(), Some("other-thing"));
359    }
360
361    #[test]
362    fn parse_frontmatter_no_frontmatter() {
363        let issue = make_issue("Just a regular issue body");
364        let parsed = parse_issue_frontmatter(&issue, "target_repo");
365        assert!(parsed.target_repo.is_none());
366        assert_eq!(parsed.body_without_frontmatter, "Just a regular issue body");
367    }
368
369    #[test]
370    fn parse_frontmatter_unclosed_delimiters() {
371        let issue = make_issue("---\ntarget_repo: oops\nno closing delimiter");
372        let parsed = parse_issue_frontmatter(&issue, "target_repo");
373        assert!(parsed.target_repo.is_none());
374        assert_eq!(parsed.body_without_frontmatter, issue.body);
375    }
376
377    #[test]
378    fn parse_frontmatter_missing_field() {
379        let issue = make_issue("---\nother_key: value\n---\n\nBody here");
380        let parsed = parse_issue_frontmatter(&issue, "target_repo");
381        assert!(parsed.target_repo.is_none());
382        assert_eq!(parsed.body_without_frontmatter, "Body here");
383    }
384
385    #[test]
386    fn parse_frontmatter_strips_leading_newlines() {
387        let issue = make_issue("---\ntarget_repo: svc\n---\n\n\nBody");
388        let parsed = parse_issue_frontmatter(&issue, "target_repo");
389        assert_eq!(parsed.body_without_frontmatter, "Body");
390    }
391
392    #[test]
393    fn parse_frontmatter_preserves_issue() {
394        let issue = make_issue("---\ntarget_repo: api\n---\nContent");
395        let parsed = parse_issue_frontmatter(&issue, "target_repo");
396        assert_eq!(parsed.issue.number, 1);
397        assert_eq!(parsed.issue.title, "Test");
398    }
399
400    #[test]
401    fn parse_frontmatter_with_extra_fields() {
402        let issue =
403            make_issue("---\npriority: high\ntarget_repo: backend\nlabel: bug\n---\n\nDetails");
404        let parsed = parse_issue_frontmatter(&issue, "target_repo");
405        assert_eq!(parsed.target_repo.as_deref(), Some("backend"));
406        assert_eq!(parsed.body_without_frontmatter, "Details");
407    }
408
409    #[test]
410    fn parse_frontmatter_empty_body() {
411        let issue = make_issue("");
412        let parsed = parse_issue_frontmatter(&issue, "target_repo");
413        assert!(parsed.target_repo.is_none());
414        assert_eq!(parsed.body_without_frontmatter, "");
415    }
416}