1use crate::error::{Error, Result};
4use crate::platform::PlatformService;
5use crate::types::{Platform, PlatformConfig, PrComment, PullRequest};
6use async_trait::async_trait;
7use octocrab::Octocrab;
8use serde::Deserialize;
9use tracing::debug;
10
11#[derive(Deserialize)]
14struct GraphQlResponse<T> {
15 data: Option<T>,
16 errors: Option<Vec<GraphQlError>>,
17}
18
19#[derive(Deserialize)]
20struct GraphQlError {
21 message: String,
22}
23
24#[derive(Deserialize)]
25#[serde(rename_all = "camelCase")]
26struct MarkReadyForReviewData {
27 mark_pull_request_ready_for_review: MarkReadyPayload,
28}
29
30#[derive(Deserialize)]
31#[serde(rename_all = "camelCase")]
32struct MarkReadyPayload {
33 pull_request: GraphQlPullRequest,
34}
35
36#[derive(Deserialize)]
37#[serde(rename_all = "camelCase")]
38struct GraphQlPullRequest {
39 number: u64,
40 url: String,
41 base_ref_name: String,
42 head_ref_name: String,
43 title: String,
44 id: String,
45 is_draft: bool,
46}
47
48impl From<GraphQlPullRequest> for PullRequest {
49 fn from(pr: GraphQlPullRequest) -> Self {
50 Self {
51 number: pr.number,
52 html_url: pr.url,
53 base_ref: pr.base_ref_name,
54 head_ref: pr.head_ref_name,
55 title: pr.title,
56 node_id: Some(pr.id),
57 is_draft: pr.is_draft,
58 }
59 }
60}
61
62pub struct GitHubService {
64 client: Octocrab,
65 config: PlatformConfig,
66}
67
68impl GitHubService {
69 pub fn new(token: &str, owner: String, repo: String, host: Option<String>) -> Result<Self> {
71 let mut builder = Octocrab::builder().personal_token(token.to_string());
72
73 if let Some(ref h) = host {
74 let base_url = format!("https://{h}/api/v3");
75 builder = builder
76 .base_uri(&base_url)
77 .map_err(|e| Error::GitHubApi(e.to_string()))?;
78 }
79
80 let client = builder
81 .build()
82 .map_err(|e| Error::GitHubApi(e.to_string()))?;
83
84 Ok(Self {
85 client,
86 config: PlatformConfig {
87 platform: Platform::GitHub,
88 owner,
89 repo,
90 host,
91 },
92 })
93 }
94}
95
96fn pr_from_octocrab(pr: &octocrab::models::pulls::PullRequest) -> PullRequest {
98 PullRequest {
99 number: pr.number,
100 html_url: pr
101 .html_url
102 .as_ref()
103 .map(ToString::to_string)
104 .unwrap_or_default(),
105 base_ref: pr.base.ref_field.clone(),
106 head_ref: pr.head.ref_field.clone(),
107 title: pr.title.as_deref().unwrap_or_default().to_string(),
108 node_id: pr.node_id.clone(),
109 is_draft: pr.draft.unwrap_or(false),
110 }
111}
112
113#[async_trait]
114impl PlatformService for GitHubService {
115 async fn find_existing_pr(&self, head_branch: &str) -> Result<Option<PullRequest>> {
116 debug!(head_branch, "finding existing PR");
117 let head = format!("{}:{}", &self.config.owner, head_branch);
118
119 let prs = self
120 .client
121 .pulls(&self.config.owner, &self.config.repo)
122 .list()
123 .head(head)
124 .state(octocrab::params::State::Open)
125 .send()
126 .await?;
127
128 let result = prs.items.first().map(pr_from_octocrab);
129 if let Some(ref pr) = result {
130 debug!(pr_number = pr.number, "found existing PR");
131 } else {
132 debug!("no existing PR found");
133 }
134 Ok(result)
135 }
136
137 async fn create_pr_with_options(
138 &self,
139 head: &str,
140 base: &str,
141 title: &str,
142 draft: bool,
143 ) -> Result<PullRequest> {
144 debug!(head, base, draft, "creating PR");
145 let pr = self
146 .client
147 .pulls(&self.config.owner, &self.config.repo)
148 .create(title, head, base)
149 .draft(draft)
150 .send()
151 .await?;
152
153 let result = pr_from_octocrab(&pr);
154 debug!(pr_number = result.number, "created PR");
155 Ok(result)
156 }
157
158 async fn update_pr_base(&self, pr_number: u64, new_base: &str) -> Result<PullRequest> {
159 debug!(pr_number, new_base, "updating PR base");
160 let pr = self
161 .client
162 .pulls(&self.config.owner, &self.config.repo)
163 .update(pr_number)
164 .base(new_base)
165 .send()
166 .await?;
167
168 debug!(pr_number, "updated PR base");
169 Ok(pr_from_octocrab(&pr))
170 }
171
172 async fn publish_pr(&self, pr_number: u64) -> Result<PullRequest> {
173 debug!(pr_number, "publishing PR");
174 let pr = self
176 .client
177 .pulls(&self.config.owner, &self.config.repo)
178 .get(pr_number)
179 .await?;
180
181 let node_id = pr.node_id.as_ref().ok_or_else(|| {
182 Error::GitHubApi("PR missing node_id for GraphQL mutation".to_string())
183 })?;
184
185 let response: GraphQlResponse<MarkReadyForReviewData> = self
187 .client
188 .graphql(&serde_json::json!({
189 "query": r"
190 mutation MarkPullRequestReadyForReview($pullRequestId: ID!) {
191 markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {
192 pullRequest {
193 number
194 url
195 baseRefName
196 headRefName
197 title
198 id
199 isDraft
200 }
201 }
202 }
203 ",
204 "variables": {
205 "pullRequestId": node_id
206 }
207 }))
208 .await
209 .map_err(|e| Error::GitHubApi(format!("GraphQL mutation failed: {e}")))?;
210
211 if let Some(errors) = response.errors
213 && !errors.is_empty()
214 {
215 let messages: Vec<_> = errors.into_iter().map(|e| e.message).collect();
216 return Err(Error::GitHubApi(format!(
217 "GraphQL error: {}",
218 messages.join(", ")
219 )));
220 }
221
222 let data = response
224 .data
225 .ok_or_else(|| Error::GitHubApi("No data in GraphQL response".to_string()))?;
226
227 debug!(pr_number, "published PR");
228 Ok(data.mark_pull_request_ready_for_review.pull_request.into())
229 }
230
231 async fn list_pr_comments(&self, pr_number: u64) -> Result<Vec<PrComment>> {
232 debug!(pr_number, "listing PR comments");
233 let comments = self
234 .client
235 .issues(&self.config.owner, &self.config.repo)
236 .list_comments(pr_number)
237 .send()
238 .await?;
239
240 let result: Vec<PrComment> = comments
241 .items
242 .into_iter()
243 .map(|c| PrComment {
244 id: c.id.0,
245 body: c.body.unwrap_or_default(),
246 })
247 .collect();
248 debug!(pr_number, count = result.len(), "listed PR comments");
249 Ok(result)
250 }
251
252 async fn create_pr_comment(&self, pr_number: u64, body: &str) -> Result<()> {
253 debug!(pr_number, "creating PR comment");
254 self.client
255 .issues(&self.config.owner, &self.config.repo)
256 .create_comment(pr_number, body)
257 .await?;
258 debug!(pr_number, "created PR comment");
259 Ok(())
260 }
261
262 async fn update_pr_comment(&self, _pr_number: u64, comment_id: u64, body: &str) -> Result<()> {
263 debug!(comment_id, "updating PR comment");
264 self.client
265 .issues(&self.config.owner, &self.config.repo)
266 .update_comment(octocrab::models::CommentId(comment_id), body)
267 .await?;
268 debug!(comment_id, "updated PR comment");
269 Ok(())
270 }
271
272 fn config(&self) -> &PlatformConfig {
273 &self.config
274 }
275}