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