Skip to main content

oven_cli/issues/
github.rs

1use std::sync::Arc;
2
3use anyhow::Result;
4use async_trait::async_trait;
5
6use super::{IssueOrigin, IssueProvider, PipelineIssue};
7use crate::{
8    github::{self, GhClient, issues::parse_issue_frontmatter},
9    process::CommandRunner,
10};
11
12/// Wraps `GhClient` to implement `IssueProvider` for GitHub issues.
13pub struct GithubIssueProvider<R: CommandRunner> {
14    client: Arc<GhClient<R>>,
15    target_field: String,
16}
17
18impl<R: CommandRunner> GithubIssueProvider<R> {
19    pub fn new(client: Arc<GhClient<R>>, target_field: &str) -> Self {
20        Self { client, target_field: target_field.to_string() }
21    }
22}
23
24#[async_trait]
25impl<R: CommandRunner + 'static> IssueProvider for GithubIssueProvider<R> {
26    async fn get_ready_issues(&self, label: &str) -> Result<Vec<PipelineIssue>> {
27        let issues = self.client.get_issues_by_label(label).await?;
28        Ok(issues
29            .into_iter()
30            .map(|i| {
31                let parsed = parse_issue_frontmatter(&i, &self.target_field);
32                PipelineIssue {
33                    number: i.number,
34                    title: i.title,
35                    body: parsed.body_without_frontmatter,
36                    source: IssueOrigin::Github,
37                    target_repo: parsed.target_repo,
38                    author: i.author.map(|a| a.login),
39                }
40            })
41            .collect())
42    }
43
44    async fn get_issue(&self, number: u32) -> Result<PipelineIssue> {
45        let issue = self.client.get_issue(number).await?;
46        let parsed = parse_issue_frontmatter(&issue, &self.target_field);
47        Ok(PipelineIssue {
48            number: issue.number,
49            title: issue.title,
50            body: parsed.body_without_frontmatter,
51            source: IssueOrigin::Github,
52            target_repo: parsed.target_repo,
53            author: issue.author.map(|a| a.login),
54        })
55    }
56
57    async fn transition(&self, number: u32, from: &str, to: &str) -> Result<()> {
58        github::transition_issue(&self.client, number, from, to).await
59    }
60
61    async fn comment(&self, number: u32, body: &str) -> Result<()> {
62        self.client.comment_on_issue(number, body).await
63    }
64
65    async fn close(&self, number: u32, comment: Option<&str>) -> Result<()> {
66        self.client.close_issue(number, comment).await
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use std::path::Path;
73
74    use super::*;
75    use crate::process::{CommandOutput, MockCommandRunner};
76
77    #[tokio::test]
78    async fn get_ready_issues_maps_to_pipeline_issues() {
79        let mut mock = MockCommandRunner::new();
80        mock.expect_run_gh().returning(|_, _| {
81            Box::pin(async {
82                Ok(CommandOutput {
83                    stdout: r#"[{"number":1,"title":"Fix bug","body":"details","labels":[],"author":{"login":"me"}}]"#
84                        .to_string(),
85                    stderr: String::new(),
86                    success: true,
87                })
88            })
89        });
90
91        let client = Arc::new(GhClient::new(mock, Path::new("/tmp")));
92        let provider = GithubIssueProvider::new(client, "target_repo");
93        let issues = provider.get_ready_issues("o-ready").await.unwrap();
94
95        assert_eq!(issues.len(), 1);
96        assert_eq!(issues[0].number, 1);
97        assert_eq!(issues[0].source, IssueOrigin::Github);
98        assert!(issues[0].target_repo.is_none());
99        assert_eq!(issues[0].author.as_deref(), Some("me"));
100    }
101
102    #[tokio::test]
103    async fn get_ready_issues_extracts_target_repo() {
104        let mut mock = MockCommandRunner::new();
105        mock.expect_run_gh().returning(|_, _| {
106            Box::pin(async {
107                Ok(CommandOutput {
108                    stdout: r#"[{"number":2,"title":"Multi","body":"---\ntarget_repo: api\n---\n\nBody","labels":[]}]"#
109                        .to_string(),
110                    stderr: String::new(),
111                    success: true,
112                })
113            })
114        });
115
116        let client = Arc::new(GhClient::new(mock, Path::new("/tmp")));
117        let provider = GithubIssueProvider::new(client, "target_repo");
118        let issues = provider.get_ready_issues("o-ready").await.unwrap();
119
120        assert_eq!(issues[0].target_repo.as_deref(), Some("api"));
121        assert_eq!(issues[0].body, "Body");
122    }
123
124    #[tokio::test]
125    async fn get_issue_propagates_author() {
126        let mut mock = MockCommandRunner::new();
127        mock.expect_run_gh().returning(|_, _| {
128            Box::pin(async {
129                Ok(CommandOutput {
130                    stdout: r#"{"number":5,"title":"Test","body":"b","labels":[],"author":{"login":"bob"}}"#
131                        .to_string(),
132                    stderr: String::new(),
133                    success: true,
134                })
135            })
136        });
137
138        let client = Arc::new(GhClient::new(mock, Path::new("/tmp")));
139        let provider = GithubIssueProvider::new(client, "target_repo");
140        let issue = provider.get_issue(5).await.unwrap();
141
142        assert_eq!(issue.author.as_deref(), Some("bob"));
143    }
144
145    #[tokio::test]
146    async fn get_issue_author_none_when_missing() {
147        let mut mock = MockCommandRunner::new();
148        mock.expect_run_gh().returning(|_, _| {
149            Box::pin(async {
150                Ok(CommandOutput {
151                    stdout: r#"{"number":6,"title":"No author","body":"b","labels":[]}"#
152                        .to_string(),
153                    stderr: String::new(),
154                    success: true,
155                })
156            })
157        });
158
159        let client = Arc::new(GhClient::new(mock, Path::new("/tmp")));
160        let provider = GithubIssueProvider::new(client, "target_repo");
161        let issue = provider.get_issue(6).await.unwrap();
162
163        assert!(issue.author.is_none());
164    }
165
166    #[tokio::test]
167    async fn transition_delegates_to_gh_client() {
168        let mut mock = MockCommandRunner::new();
169        mock.expect_run_gh().returning(|_, _| {
170            Box::pin(async {
171                Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
172            })
173        });
174
175        let client = Arc::new(GhClient::new(mock, Path::new("/tmp")));
176        let provider = GithubIssueProvider::new(client, "target_repo");
177        let result = provider.transition(1, "o-ready", "o-cooking").await;
178        assert!(result.is_ok());
179    }
180}