Skip to main content

jj_ryu/platform/
github.rs

1//! GitHub platform service implementation
2
3use 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// GraphQL response types for publish_pr mutation
12
13#[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
62/// GitHub service using octocrab
63pub struct GitHubService {
64    client: Octocrab,
65    config: PlatformConfig,
66}
67
68impl GitHubService {
69    /// Create a new GitHub service
70    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
96/// Helper to convert octocrab PR to our `PullRequest` type
97fn 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        // Fetch PR to get node_id for GraphQL mutation
175        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        // Execute GraphQL mutation to mark PR as ready for review
186        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        // Check for GraphQL errors
212        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        // Extract typed response
223        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}