Skip to main content

exomonad_core/handlers/
github.rs

1//! GitHub effect handler for the `github.*` namespace.
2//!
3//! Uses proto-generated types from `exomonad_proto::effects::github`.
4
5use crate::effects::{
6    dispatch_github_effect, EffectError, EffectHandler, EffectResult, GitHubEffects,
7};
8use crate::services::github::{CreatePRSpec, GitHubService, IssueFilter, PRFilter, Repo};
9use async_trait::async_trait;
10use exomonad_proto::effects::github::*;
11
12/// GitHub effect handler.
13///
14/// Handles all effects in the `github.*` namespace by delegating to
15/// the generated `dispatch_github_effect` function.
16pub struct GitHubHandler {
17    service: GitHubService,
18}
19
20impl GitHubHandler {
21    pub fn new(service: GitHubService) -> Self {
22        Self { service }
23    }
24}
25
26#[async_trait]
27impl EffectHandler for GitHubHandler {
28    fn namespace(&self) -> &str {
29        "github"
30    }
31
32    async fn handle(&self, effect_type: &str, payload: &[u8]) -> EffectResult<Vec<u8>> {
33        dispatch_github_effect(self, effect_type, payload).await
34    }
35}
36
37#[async_trait]
38impl GitHubEffects for GitHubHandler {
39    async fn list_issues(&self, req: ListIssuesRequest) -> EffectResult<ListIssuesResponse> {
40        let repo = make_repo(&req.owner, &req.repo);
41
42        let state_str = issue_state_to_string(req.state());
43        let filter = if state_str.is_empty() && req.labels.is_empty() {
44            None
45        } else {
46            Some(IssueFilter {
47                state: Some(if state_str.is_empty() {
48                    "open".to_string()
49                } else {
50                    state_str
51                }),
52                labels: if req.labels.is_empty() {
53                    None
54                } else {
55                    Some(req.labels.clone())
56                },
57            })
58        };
59
60        let raw_issues = self
61            .service
62            .list_issues(&repo, filter.as_ref())
63            .await
64            .map_err(|e| EffectError::network_error(e.to_string()))?;
65
66        let limit = if req.limit <= 0 {
67            30
68        } else {
69            req.limit as usize
70        };
71        let issues: Vec<Issue> = raw_issues
72            .into_iter()
73            .take(limit)
74            .map(convert_issue)
75            .collect();
76
77        Ok(ListIssuesResponse { issues })
78    }
79
80    async fn get_issue(&self, req: GetIssueRequest) -> EffectResult<GetIssueResponse> {
81        let repo = make_repo(&req.owner, &req.repo);
82
83        let raw_issue = self
84            .service
85            .get_issue(&repo, req.number as u64)
86            .await
87            .map_err(|e| EffectError::network_error(e.to_string()))?;
88
89        let issue = convert_issue(raw_issue);
90        let comments: Vec<IssueComment> = Vec::new();
91
92        Ok(GetIssueResponse {
93            issue: Some(issue),
94            comments,
95        })
96    }
97
98    async fn list_pull_requests(
99        &self,
100        req: ListPullRequestsRequest,
101    ) -> EffectResult<ListPullRequestsResponse> {
102        let repo = make_repo(&req.owner, &req.repo);
103
104        let state_str = issue_state_to_string(req.state());
105        let limit = if req.limit <= 0 { 30 } else { req.limit as u32 };
106
107        let filter = Some(PRFilter {
108            state: Some(if state_str.is_empty() {
109                "open".to_string()
110            } else {
111                state_str
112            }),
113            limit: Some(limit),
114        });
115
116        let raw_prs = self
117            .service
118            .list_prs(&repo, filter.as_ref())
119            .await
120            .map_err(|e| EffectError::network_error(e.to_string()))?;
121
122        let pull_requests: Vec<PullRequest> = raw_prs.into_iter().map(convert_pr).collect();
123
124        Ok(ListPullRequestsResponse { pull_requests })
125    }
126
127    async fn get_pull_request(
128        &self,
129        req: GetPullRequestRequest,
130    ) -> EffectResult<GetPullRequestResponse> {
131        let repo = make_repo(&req.owner, &req.repo);
132
133        let filter = Some(PRFilter {
134            state: Some("all".to_string()),
135            limit: Some(100),
136        });
137
138        let raw_prs = self
139            .service
140            .list_prs(&repo, filter.as_ref())
141            .await
142            .map_err(|e| EffectError::network_error(e.to_string()))?;
143
144        let raw_pr = raw_prs
145            .into_iter()
146            .find(|pr| pr.number == req.number as u64)
147            .ok_or_else(|| EffectError::not_found(format!("PR #{}", req.number)))?;
148
149        let pull_request = convert_pr(raw_pr);
150        let reviews: Vec<Review> = Vec::new();
151
152        Ok(GetPullRequestResponse {
153            pull_request: Some(pull_request),
154            reviews,
155        })
156    }
157
158    async fn get_pull_request_for_branch(
159        &self,
160        req: GetPullRequestForBranchRequest,
161    ) -> EffectResult<GetPullRequestForBranchResponse> {
162        let repo = make_repo(&req.owner, &req.repo);
163
164        let result = self
165            .service
166            .get_pr_for_branch(&repo, &req.branch)
167            .await
168            .map_err(|e| EffectError::network_error(e.to_string()))?;
169
170        let found = result.is_some();
171        Ok(GetPullRequestForBranchResponse {
172            pull_request: result.map(convert_pr),
173            found,
174        })
175    }
176
177    async fn create_pull_request(
178        &self,
179        req: CreatePullRequestRequest,
180    ) -> EffectResult<CreatePullRequestResponse> {
181        let repo = make_repo(&req.owner, &req.repo);
182
183        let spec = CreatePRSpec {
184            title: req.title,
185            body: req.body,
186            head: req.head.clone(),
187            base: if req.base.is_empty() {
188                "main".to_string()
189            } else {
190                req.base
191            },
192        };
193
194        let raw_pr = self
195            .service
196            .create_pr(&repo, spec)
197            .await
198            .map_err(|e| EffectError::network_error(e.to_string()))?;
199
200        let url = raw_pr.url.clone();
201        let pull_request = convert_pr(raw_pr);
202
203        Ok(CreatePullRequestResponse {
204            pull_request: Some(pull_request),
205            url,
206        })
207    }
208
209    async fn get_pull_request_review_comments(
210        &self,
211        req: GetPullRequestReviewCommentsRequest,
212    ) -> EffectResult<GetPullRequestReviewCommentsResponse> {
213        // Review comments require additional API work - return empty for now
214        tracing::debug!(
215            owner = %req.owner,
216            repo = %req.repo,
217            number = req.number,
218            "get_pull_request_review_comments: not yet implemented"
219        );
220        Ok(GetPullRequestReviewCommentsResponse {
221            comments: Vec::new(),
222        })
223    }
224}
225
226fn make_repo(owner: &str, repo: &str) -> Repo {
227    Repo {
228        owner: owner.into(),
229        name: repo.into(),
230    }
231}
232
233fn issue_state_to_string(state: IssueState) -> String {
234    match state {
235        IssueState::Unspecified => String::new(),
236        IssueState::Open => "open".to_string(),
237        IssueState::Closed => "closed".to_string(),
238        IssueState::All => "all".to_string(),
239    }
240}
241
242fn convert_issue(i: crate::services::github::Issue) -> Issue {
243    Issue {
244        number: i.number as i32,
245        title: i.title,
246        body: i.body,
247        state: IssueState::Open as i32, // Service doesn't parse state enum
248        author: Some(User {
249            login: i.author,
250            id: 0,
251            avatar_url: String::new(),
252        }),
253        labels: i
254            .labels
255            .into_iter()
256            .map(|l| Label {
257                name: l,
258                color: String::new(),
259                description: String::new(),
260            })
261            .collect(),
262        created_at: 0,
263        updated_at: 0,
264        comments_count: 0,
265    }
266}
267
268fn convert_pr(pr: crate::services::github::PullRequest) -> PullRequest {
269    PullRequest {
270        number: pr.number as i32,
271        title: pr.title,
272        body: pr.body,
273        state: IssueState::Open as i32,
274        author: Some(User {
275            login: pr.author,
276            id: 0,
277            avatar_url: String::new(),
278        }),
279        head_ref: pr.head_ref,
280        base_ref: pr.base_ref,
281        merged: pr.merged_at.is_some(),
282        draft: false,
283        labels: Vec::new(),
284        created_at: 0,
285        updated_at: 0,
286    }
287}