Skip to main content

exomonad_core/services/
github.rs

1use crate::domain::ItemState;
2use crate::{FFIBoundary, GithubOwner, GithubRepo};
3use anyhow::{anyhow, Result};
4use octocrab::{models, params, Octocrab, OctocrabBuilder};
5use serde::{Deserialize, Serialize};
6use tokio::time::{timeout, Duration};
7use tracing::info;
8
9const API_TIMEOUT: Duration = Duration::from_secs(30);
10
11fn octocrab_issue_state(state: models::IssueState) -> ItemState {
12    match state {
13        models::IssueState::Open => ItemState::Open,
14        models::IssueState::Closed => ItemState::Closed,
15        _ => ItemState::Unknown,
16    }
17}
18
19fn octocrab_optional_issue_state(state: Option<models::IssueState>) -> ItemState {
20    state
21        .map(octocrab_issue_state)
22        .unwrap_or(ItemState::Unknown)
23}
24
25// ============================================================================
26// Types
27// ============================================================================
28
29/// GitHub repository identifier.
30///
31/// Uniquely identifies a repository by owner and name (e.g., "anthropics/exomonad").
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct Repo {
34    /// Repository owner (user or organization name).
35    pub owner: GithubOwner,
36
37    /// Repository name.
38    pub name: GithubRepo,
39}
40
41impl FFIBoundary for Repo {}
42
43/// Filter criteria for listing GitHub issues.
44///
45/// Used with [`GitHubService::list_issues()`].
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct IssueFilter {
48    /// Filter by issue state: "open", "closed", or "all".
49    pub state: Option<String>,
50
51    /// Filter by label names (AND logic - issue must have all labels).
52    pub labels: Option<Vec<String>>,
53}
54
55impl FFIBoundary for IssueFilter {}
56
57/// Specification for creating a pull request.
58///
59/// Used with [`GitHubService::create_pr()`].
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61pub struct CreatePRSpec {
62    /// PR title.
63    pub title: String,
64
65    /// PR body (markdown description).
66    pub body: String,
67
68    /// Head branch (source branch containing changes).
69    pub head: String,
70
71    /// Base branch (target branch to merge into, usually "main").
72    pub base: String,
73}
74
75impl FFIBoundary for CreatePRSpec {}
76
77/// Filter criteria for listing pull requests.
78///
79/// Used with [`GitHubService::list_prs()`].
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81pub struct PRFilter {
82    /// Filter by PR state: "open", "closed", or "all".
83    pub state: Option<String>,
84
85    /// Maximum number of PRs to return (default: API default, usually 30).
86    pub limit: Option<u32>,
87}
88
89impl FFIBoundary for PRFilter {}
90
91/// A GitHub issue with metadata.
92///
93/// Returned by [`GitHubService::list_issues()`] and [`GitHubService::get_issue()`].
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95pub struct Issue {
96    /// Issue number (unique within repository).
97    pub number: u64,
98
99    /// Issue title.
100    pub title: String,
101
102    /// Issue body (markdown description).
103    pub body: String,
104
105    /// Issue state.
106    pub state: ItemState,
107
108    /// Web URL to the issue.
109    pub url: String,
110
111    /// Issue author's GitHub username.
112    pub author: String,
113
114    /// Label names attached to the issue.
115    pub labels: Vec<String>,
116}
117
118impl FFIBoundary for Issue {}
119
120impl From<models::issues::Issue> for Issue {
121    fn from(i: models::issues::Issue) -> Self {
122        Self {
123            number: i.number,
124            title: i.title,
125            body: i.body.unwrap_or_default(),
126            state: octocrab_issue_state(i.state),
127            url: i.html_url.to_string(),
128            author: i.user.login,
129            labels: i.labels.into_iter().map(|l| l.name).collect(),
130        }
131    }
132}
133
134/// A GitHub pull request with metadata.
135///
136/// Returned by [`GitHubService::list_prs()`] and [`GitHubService::get_pr_for_branch()`].
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
138pub struct PullRequest {
139    /// PR number (unique within repository).
140    pub number: u64,
141
142    /// PR title.
143    pub title: String,
144
145    /// PR body (markdown description).
146    pub body: String,
147
148    /// PR state.
149    pub state: ItemState,
150
151    /// Web URL to the PR.
152    pub url: String,
153
154    /// PR author's GitHub username.
155    pub author: String,
156
157    /// Head branch (source branch with changes).
158    pub head_ref: String,
159
160    /// Base branch (target branch for merge).
161    pub base_ref: String,
162
163    /// Creation timestamp (ISO 8601).
164    pub created_at: String,
165
166    /// Merge timestamp (ISO 8601, if merged).
167    pub merged_at: Option<String>,
168}
169
170impl FFIBoundary for PullRequest {}
171
172impl From<models::pulls::PullRequest> for PullRequest {
173    fn from(pr: models::pulls::PullRequest) -> Self {
174        Self {
175            number: pr.number,
176            title: pr.title.unwrap_or_default(),
177            body: pr.body.unwrap_or_default(),
178            state: octocrab_optional_issue_state(pr.state),
179            url: pr.html_url.map(|u| u.to_string()).unwrap_or_default(),
180            author: pr.user.map(|u| u.login).unwrap_or_else(|| "unknown".into()),
181            head_ref: pr.head.ref_field,
182            base_ref: pr.base.ref_field,
183            created_at: pr.created_at.map(|t| t.to_rfc3339()).unwrap_or_default(),
184            merged_at: pr.merged_at.map(|t| t.to_rfc3339()),
185        }
186    }
187}
188
189/// A review comment on a pull request.
190///
191/// Returned by [`GitHubService::get_pr_review_comments()`].
192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
193pub struct ReviewComment {
194    /// Comment ID (unique).
195    pub id: u64,
196
197    /// Comment body (markdown).
198    pub body: String,
199
200    /// File path the comment is attached to.
201    pub path: String,
202
203    /// Line number in the file (if available).
204    pub line: Option<u32>,
205
206    /// Comment author's GitHub username.
207    pub author: String,
208
209    /// Creation timestamp (ISO 8601).
210    pub created_at: String,
211}
212
213impl FFIBoundary for ReviewComment {}
214
215#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
216pub struct GithubListIssuesInput {
217    pub repo: Repo,
218    pub filter: Option<IssueFilter>,
219}
220
221impl FFIBoundary for GithubListIssuesInput {}
222
223#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
224pub struct GithubGetIssueInput {
225    pub repo: Repo,
226    pub number: u64,
227}
228
229impl FFIBoundary for GithubGetIssueInput {}
230
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
232pub struct GithubCreatePRInput {
233    pub repo: Repo,
234    pub spec: CreatePRSpec,
235}
236
237impl FFIBoundary for GithubCreatePRInput {}
238
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
240pub struct GithubListPRsInput {
241    pub repo: Repo,
242    pub filter: Option<PRFilter>,
243}
244
245impl FFIBoundary for GithubListPRsInput {}
246
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
248pub struct GithubGetPRForBranchInput {
249    pub repo: Repo,
250    pub head: String,
251}
252
253impl FFIBoundary for GithubGetPRForBranchInput {}
254
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
256pub struct GithubGetPRReviewCommentsInput {
257    pub repo: Repo,
258    pub pr_number: u64,
259}
260
261impl FFIBoundary for GithubGetPRReviewCommentsInput {}
262
263// ============================================================================
264// Service Implementation
265// ============================================================================
266
267/// GitHub API service.
268///
269/// Provides access to GitHub REST API for issues, pull requests, and review comments.
270/// Uses octocrab for API access and requires a personal access token for authentication.
271///
272/// # Authentication
273///
274/// Requires a GitHub personal access token with appropriate scopes:
275/// - `repo` - Required for private repositories
276/// - `public_repo` - Sufficient for public repositories
277///
278/// # Examples
279///
280/// ```ignore
281/// use crate::services::github::{GitHubService, Repo};
282/// use crate::{GithubOwner, GithubRepo};
283///
284/// # async fn example() -> anyhow::Result<()> {
285/// let github = GitHubService::new("ghp_...".to_string())?;
286///
287/// let repo = Repo {
288///     owner: GithubOwner::from("anthropics"),
289///     name: GithubRepo::from("exomonad"),
290/// };
291///
292/// let issues = github.list_issues(&repo, None).await?;
293/// println!("Found {} issues", issues.len());
294/// # Ok(())
295/// # }
296/// ```
297#[derive(Clone)]
298pub struct GitHubService {
299    client: Octocrab,
300}
301
302impl GitHubService {
303    /// Create a new GitHubService with the given personal access token.
304    ///
305    /// # Arguments
306    ///
307    /// * `token` - GitHub personal access token (starts with "ghp_" or "github_pat_")
308    ///
309    /// # Errors
310    ///
311    /// Returns an error if the octocrab client fails to initialize.
312    pub fn new(token: String) -> Result<Self> {
313        let client = OctocrabBuilder::new().personal_token(token).build()?;
314        Ok(Self { client })
315    }
316
317    /// List issues in a repository.
318    ///
319    /// # Arguments
320    ///
321    /// * `repo` - Repository identifier (owner + name)
322    /// * `filter` - Optional filter criteria (state, labels)
323    ///
324    /// # Returns
325    ///
326    /// A vector of issues matching the filter criteria.
327    ///
328    /// # Errors
329    ///
330    /// Returns an error if:
331    /// - Repository doesn't exist or is not accessible
332    /// - Network request fails
333    /// - Authentication fails
334    #[tracing::instrument(skip(self))]
335    pub async fn list_issues(
336        &self,
337        repo: &Repo,
338        filter: Option<&IssueFilter>,
339    ) -> Result<Vec<Issue>> {
340        let repo_name = format!("{}/{}", repo.owner, repo.name);
341        info!(repo = %repo_name, "GitHub API: Listing issues");
342
343        let issues_handler = self.client.issues(repo.owner.as_str(), repo.name.as_str());
344        let mut builder = issues_handler.list();
345
346        if let Some(f) = filter {
347            if let Some(state) = &f.state {
348                let s = match state.as_str() {
349                    "open" => params::State::Open,
350                    "closed" => params::State::Closed,
351                    _ => params::State::All,
352                };
353                builder = builder.state(s);
354            }
355            if let Some(labels) = &f.labels {
356                if !labels.is_empty() {
357                    // Octocrab expects generic iterable
358                    builder = builder.labels(labels);
359                }
360            }
361        }
362
363        let page = timeout(API_TIMEOUT, builder.send()).await.map_err(|_| {
364            anyhow!(
365                "GitHub API list_issues timed out after {}s",
366                API_TIMEOUT.as_secs()
367            )
368        })??;
369
370        let issues = timeout(API_TIMEOUT, self.client.all_pages(page))
371            .await
372            .map_err(|_| {
373                anyhow!(
374                    "GitHub API all_pages timed out after {}s",
375                    API_TIMEOUT.as_secs()
376                )
377            })??;
378
379        info!(
380            repo = %repo_name,
381            count = issues.len(),
382            "GitHub API: List issues successful"
383        );
384
385        Ok(issues.into_iter().map(Issue::from).collect())
386    }
387
388    #[tracing::instrument(skip(self))]
389    pub async fn get_issue(&self, repo: &Repo, number: u64) -> Result<Issue> {
390        let repo_name = format!("{}/{}", repo.owner, repo.name);
391        info!(repo = %repo_name, number, "GitHub API: Get issue");
392
393        let issue = timeout(
394            API_TIMEOUT,
395            self.client
396                .issues(repo.owner.as_str(), repo.name.as_str())
397                .get(number),
398        )
399        .await
400        .map_err(|_| {
401            anyhow!(
402                "GitHub API get_issue timed out after {}s",
403                API_TIMEOUT.as_secs()
404            )
405        })??;
406
407        info!(repo = %repo_name, number, "GitHub API: Get issue successful");
408
409        Ok(Issue::from(issue))
410    }
411
412    #[tracing::instrument(skip(self))]
413    pub async fn create_pr(&self, repo: &Repo, spec: CreatePRSpec) -> Result<PullRequest> {
414        let repo_name = format!("{}/{}", repo.owner, repo.name);
415        info!(repo = %repo_name, title = %spec.title, "GitHub API: Create PR");
416
417        let pr = timeout(
418            API_TIMEOUT,
419            self.client
420                .pulls(repo.owner.as_str(), repo.name.as_str())
421                .create(spec.title, spec.head, spec.base)
422                .body(spec.body)
423                .send(),
424        )
425        .await
426        .map_err(|_| {
427            anyhow!(
428                "GitHub API create_pr timed out after {}s",
429                API_TIMEOUT.as_secs()
430            )
431        })??;
432
433        info!(
434            repo = %repo_name,
435            number = pr.number,
436            "GitHub API: Create PR successful"
437        );
438
439        Ok(PullRequest::from(pr))
440    }
441
442    #[tracing::instrument(skip(self))]
443    pub async fn list_prs(
444        &self,
445        repo: &Repo,
446        filter: Option<&PRFilter>,
447    ) -> Result<Vec<PullRequest>> {
448        let repo_name = format!("{}/{}", repo.owner, repo.name);
449        info!(repo = %repo_name, "GitHub API: List PRs");
450
451        let pulls_handler = self.client.pulls(repo.owner.as_str(), repo.name.as_str());
452        let mut builder = pulls_handler.list();
453
454        if let Some(f) = filter {
455            if let Some(state) = &f.state {
456                let s = match state.as_str() {
457                    "open" => params::State::Open,
458                    "closed" => params::State::Closed,
459                    _ => params::State::All,
460                };
461                builder = builder.state(s);
462            }
463            if let Some(limit) = f.limit {
464                builder = builder.per_page(limit as u8);
465            }
466        }
467
468        let page = timeout(API_TIMEOUT, builder.send()).await.map_err(|_| {
469            anyhow!(
470                "GitHub API list_prs timed out after {}s",
471                API_TIMEOUT.as_secs()
472            )
473        })??;
474        // For PRs, we might not want all pages if a limit was set, but octocrab's list() returns a Page.
475        // If limit was set, we used per_page.
476
477        info!(
478            repo = %repo_name,
479            "GitHub API: List PRs successful (page 1)"
480        );
481
482        Ok(page.into_iter().map(PullRequest::from).collect())
483    }
484
485    #[tracing::instrument(skip(self))]
486    pub async fn get_pr_for_branch(&self, repo: &Repo, head: &str) -> Result<Option<PullRequest>> {
487        let pulls_handler = self.client.pulls(repo.owner.as_str(), repo.name.as_str());
488        let page = timeout(
489            API_TIMEOUT,
490            pulls_handler
491                .list()
492                .state(params::State::Open)
493                .head(format!("{}:{}", repo.owner, head))
494                .send(),
495        )
496        .await
497        .map_err(|_| {
498            anyhow!(
499                "GitHub API get_pr_for_branch timed out after {}s",
500                API_TIMEOUT.as_secs()
501            )
502        })??;
503
504        let pr = page.into_iter().next();
505
506        match &pr {
507            Some(p) => tracing::info!(number = p.number, head, "Found PR for branch"),
508            None => tracing::info!(head, "No PR found for branch"),
509        }
510
511        Ok(pr.map(PullRequest::from))
512    }
513
514    #[tracing::instrument(skip(self))]
515    pub async fn get_pr_review_comments(
516        &self,
517        _repo: &Repo,
518        pr_number: u64,
519    ) -> Result<Vec<ReviewComment>> {
520        tracing::debug!(
521            pr_number,
522            "Review comment checking is simplified - returning empty"
523        );
524        Ok(vec![])
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use wiremock::matchers::{method, path};
532    use wiremock::{Mock, MockServer, ResponseTemplate};
533
534    async fn create_mock_service() -> (GitHubService, MockServer) {
535        let mock_server = MockServer::start().await;
536        let client = OctocrabBuilder::new()
537            .personal_token("test_token".to_string())
538            .base_uri(mock_server.uri())
539            .unwrap()
540            .build()
541            .unwrap();
542        (GitHubService { client }, mock_server)
543    }
544
545    #[tokio::test]
546    async fn test_list_issues() {
547        let (service, mock_server) = create_mock_service().await;
548
549        let mock_response = serde_json::json!([
550            {
551                "id": 1,
552                "node_id": "MDU6SXNzdWUx",
553                "number": 1,
554                "title": "Test Issue",
555                "state": "open",
556                "html_url": "http://github.com/owner/repo/issues/1",
557                "user": { "login": "testuser", "id": 1, "node_id": "MDQ6VXNlcjE=", "gravatar_id": "", "url": "http://example.com", "avatar_url": "http://example.com", "html_url": "http://example.com", "followers_url": "http://example.com", "following_url": "http://example.com", "gists_url": "http://example.com", "starred_url": "http://example.com", "subscriptions_url": "http://example.com", "organizations_url": "http://example.com", "repos_url": "http://example.com", "events_url": "http://example.com", "received_events_url": "http://example.com", "type": "User", "site_admin": false },
558                "labels": [],
559                "body": "Test Body",
560                "created_at": "2023-01-01T00:00:00Z",
561                "updated_at": "2023-01-01T00:00:00Z",
562                "url": "http://api.github.com/repos/owner/repo/issues/1",
563                "repository_url": "http://api.github.com/repos/owner/repo",
564                "labels_url": "http://api.github.com/repos/owner/repo/issues/1/labels{/name}",
565                "comments_url": "http://api.github.com/repos/owner/repo/issues/1/comments",
566                "events_url": "http://api.github.com/repos/owner/repo/issues/1/events",
567                "comments": 0,
568                "assignees": [],
569                "author_association": "NONE",
570                "locked": false
571            }
572        ]);
573
574        Mock::given(method("GET"))
575            .and(path("/repos/owner/repo/issues"))
576            .respond_with(ResponseTemplate::new(200).set_body_json(mock_response))
577            .mount(&mock_server)
578            .await;
579
580        let repo = Repo {
581            owner: "owner".into(),
582            name: "repo".into(),
583        };
584
585        let issues = service.list_issues(&repo, None).await.unwrap();
586        assert_eq!(issues.len(), 1);
587        assert_eq!(issues[0].title, "Test Issue");
588        assert_eq!(issues[0].author, "testuser");
589    }
590
591    #[tokio::test]
592    async fn test_get_issue() {
593        let (service, mock_server) = create_mock_service().await;
594
595        let mock_response = serde_json::json!({
596            "id": 1,
597            "node_id": "MDU6SXNzdWUx",
598            "number": 1,
599            "title": "Test Issue",
600            "state": "open",
601            "html_url": "http://github.com/owner/repo/issues/1",
602            "user": { "login": "testuser", "id": 1, "node_id": "MDQ6VXNlcjE=", "gravatar_id": "", "url": "http://example.com", "avatar_url": "http://example.com", "html_url": "http://example.com", "followers_url": "http://example.com", "following_url": "http://example.com", "gists_url": "http://example.com", "starred_url": "http://example.com", "subscriptions_url": "http://example.com", "organizations_url": "http://example.com", "repos_url": "http://example.com", "events_url": "http://example.com", "received_events_url": "http://example.com", "type": "User", "site_admin": false },
603            "labels": [],
604            "body": "Test Body",
605            "created_at": "2023-01-01T00:00:00Z",
606            "updated_at": "2023-01-01T00:00:00Z",
607            "url": "http://api.github.com/repos/owner/repo/issues/1",
608            "repository_url": "http://api.github.com/repos/owner/repo",
609            "labels_url": "http://api.github.com/repos/owner/repo/issues/1/labels{/name}",
610            "comments_url": "http://api.github.com/repos/owner/repo/issues/1/comments",
611            "events_url": "http://api.github.com/repos/owner/repo/issues/1/events",
612            "comments": 0,
613            "assignees": [],
614            "author_association": "NONE",
615            "locked": false
616        });
617
618        Mock::given(method("GET"))
619            .and(path("/repos/owner/repo/issues/1"))
620            .respond_with(ResponseTemplate::new(200).set_body_json(mock_response))
621            .mount(&mock_server)
622            .await;
623
624        let repo = Repo {
625            owner: "owner".into(),
626            name: "repo".into(),
627        };
628
629        let issue = service.get_issue(&repo, 1).await.unwrap();
630        assert_eq!(issue.number, 1);
631        assert_eq!(issue.title, "Test Issue");
632    }
633
634    #[tokio::test]
635    async fn test_create_pr() {
636        let (service, mock_server) = create_mock_service().await;
637
638        let mock_response = serde_json::json!({
639            "id": 2,
640            "node_id": "MDExOlB1bGxSZXF1ZXN0Mg==",
641            "number": 2,
642            "title": "New PR",
643            "state": "open",
644            "html_url": "http://github.com/owner/repo/pulls/2",
645            "user": { "login": "testuser", "id": 1, "node_id": "MDQ6VXNlcjE=", "gravatar_id": "", "url": "http://example.com", "avatar_url": "http://example.com", "html_url": "http://example.com", "followers_url": "http://example.com", "following_url": "http://example.com", "gists_url": "http://example.com", "starred_url": "http://example.com", "subscriptions_url": "http://example.com", "organizations_url": "http://example.com", "repos_url": "http://example.com", "events_url": "http://example.com", "received_events_url": "http://example.com", "type": "User", "site_admin": false },
646            "body": "PR Body",
647            "head": { "ref": "feature", "sha": "sha", "repo": { "id": 1, "node_id": "MDEwOlJlcG9zaXRvcnkx", "url": "http://example.com", "name": "repo", "full_name": "owner/repo", "owner": { "login": "owner", "id": 1, "node_id": "MDQ6VXNlcjE=", "gravatar_id": "", "url": "http://example.com", "avatar_url": "http://example.com", "html_url": "http://example.com", "followers_url": "http://example.com", "following_url": "http://example.com", "gists_url": "http://example.com", "starred_url": "http://example.com", "subscriptions_url": "http://example.com", "organizations_url": "http://example.com", "repos_url": "http://example.com", "events_url": "http://example.com", "received_events_url": "http://example.com", "type": "User", "site_admin": false } }, "user": { "login": "testuser", "id": 1, "node_id": "MDQ6VXNlcjE=", "gravatar_id": "", "url": "http://example.com", "avatar_url": "http://example.com", "html_url": "http://example.com", "followers_url": "http://example.com", "following_url": "http://example.com", "gists_url": "http://example.com", "starred_url": "http://example.com", "subscriptions_url": "http://example.com", "organizations_url": "http://example.com", "repos_url": "http://example.com", "events_url": "http://example.com", "received_events_url": "http://example.com", "type": "User", "site_admin": false }, "label": "label" },
648            "base": { "ref": "main", "sha": "sha", "repo": { "id": 1, "node_id": "MDEwOlJlcG9zaXRvcnkx", "url": "http://example.com", "name": "repo", "full_name": "owner/repo", "owner": { "login": "owner", "id": 1, "node_id": "MDQ6VXNlcjE=", "gravatar_id": "", "url": "http://example.com", "avatar_url": "http://example.com", "html_url": "http://example.com", "followers_url": "http://example.com", "following_url": "http://example.com", "gists_url": "http://example.com", "starred_url": "http://example.com", "subscriptions_url": "http://example.com", "organizations_url": "http://example.com", "repos_url": "http://example.com", "events_url": "http://example.com", "received_events_url": "http://example.com", "type": "User", "site_admin": false } }, "user": { "login": "testuser", "id": 1, "node_id": "MDQ6VXNlcjE=", "gravatar_id": "", "url": "http://example.com", "avatar_url": "http://example.com", "html_url": "http://example.com", "followers_url": "http://example.com", "following_url": "http://example.com", "gists_url": "http://example.com", "starred_url": "http://example.com", "subscriptions_url": "http://example.com", "organizations_url": "http://example.com", "repos_url": "http://example.com", "events_url": "http://example.com", "received_events_url": "http://example.com", "type": "User", "site_admin": false }, "label": "label" },
649            "created_at": "2023-01-01T00:00:00Z",
650            "updated_at": "2023-01-01T00:00:00Z",
651            "url": "http://api.github.com/repos/owner/repo/pulls/2",
652            "diff_url": "http://github.com/owner/repo/pulls/2.diff",
653            "patch_url": "http://github.com/owner/repo/pulls/2.patch",
654            "issue_url": "http://api.github.com/repos/owner/repo/issues/2",
655            "commits_url": "http://api.github.com/repos/owner/repo/pulls/2/commits",
656            "review_comments_url": "http://api.github.com/repos/owner/repo/pulls/2/comments",
657            "review_comment_url": "http://api.github.com/repos/owner/repo/pulls/comments{/number}",
658            "comments_url": "http://api.github.com/repos/owner/repo/issues/2/comments",
659            "statuses_url": "http://api.github.com/repos/owner/repo/statuses/sha",
660            "author_association": "NONE"
661        });
662
663        Mock::given(method("POST"))
664            .and(path("/repos/owner/repo/pulls"))
665            .respond_with(ResponseTemplate::new(201).set_body_json(mock_response))
666            .mount(&mock_server)
667            .await;
668
669        let repo = Repo {
670            owner: "owner".into(),
671            name: "repo".into(),
672        };
673
674        let spec = CreatePRSpec {
675            title: "New PR".to_string(),
676            body: "PR Body".to_string(),
677            head: "feature".to_string(),
678            base: "main".to_string(),
679        };
680
681        let pr = service.create_pr(&repo, spec).await.unwrap();
682        assert_eq!(pr.number, 2);
683        assert_eq!(pr.title, "New PR");
684    }
685
686    #[tokio::test]
687    async fn test_list_prs() {
688        let (service, mock_server) = create_mock_service().await;
689
690        let mock_response = serde_json::json!([
691            {
692                "id": 2,
693                "node_id": "MDExOlB1bGxSZXF1ZXN0Mg==",
694                "number": 2,
695                "title": "New PR",
696                "state": "open",
697                "html_url": "http://github.com/owner/repo/pulls/2",
698                "user": { "login": "testuser", "id": 1, "node_id": "MDQ6VXNlcjE=", "gravatar_id": "", "url": "http://example.com", "avatar_url": "http://example.com", "html_url": "http://example.com", "followers_url": "http://example.com", "following_url": "http://example.com", "gists_url": "http://example.com", "starred_url": "http://example.com", "subscriptions_url": "http://example.com", "organizations_url": "http://example.com", "repos_url": "http://example.com", "events_url": "http://example.com", "received_events_url": "http://example.com", "type": "User", "site_admin": false },
699                "body": "PR Body",
700                "head": { "ref": "feature", "sha": "sha", "repo": { "id": 1, "node_id": "MDEwOlJlcG9zaXRvcnkx", "url": "http://example.com", "name": "repo", "full_name": "owner/repo", "owner": { "login": "owner", "id": 1, "node_id": "MDQ6VXNlcjE=", "gravatar_id": "", "url": "http://example.com", "avatar_url": "http://example.com", "html_url": "http://example.com", "followers_url": "http://example.com", "following_url": "http://example.com", "gists_url": "http://example.com", "starred_url": "http://example.com", "subscriptions_url": "http://example.com", "organizations_url": "http://example.com", "repos_url": "http://example.com", "events_url": "http://example.com", "received_events_url": "http://example.com", "type": "User", "site_admin": false } }, "user": { "login": "testuser", "id": 1, "node_id": "MDQ6VXNlcjE=", "gravatar_id": "", "url": "http://example.com", "avatar_url": "http://example.com", "html_url": "http://example.com", "followers_url": "http://example.com", "following_url": "http://example.com", "gists_url": "http://example.com", "starred_url": "http://example.com", "subscriptions_url": "http://example.com", "organizations_url": "http://example.com", "repos_url": "http://example.com", "events_url": "http://example.com", "received_events_url": "http://example.com", "type": "User", "site_admin": false }, "label": "label" },
701                "base": { "ref": "main", "sha": "sha", "repo": { "id": 1, "node_id": "MDEwOlJlcG9zaXRvcnkx", "url": "http://example.com", "name": "repo", "full_name": "owner/repo", "owner": { "login": "owner", "id": 1, "node_id": "MDQ6VXNlcjE=", "gravatar_id": "", "url": "http://example.com", "avatar_url": "http://example.com", "html_url": "http://example.com", "followers_url": "http://example.com", "following_url": "http://example.com", "gists_url": "http://example.com", "starred_url": "http://example.com", "subscriptions_url": "http://example.com", "organizations_url": "http://example.com", "repos_url": "http://example.com", "events_url": "http://example.com", "received_events_url": "http://example.com", "type": "User", "site_admin": false } }, "user": { "login": "testuser", "id": 1, "node_id": "MDQ6VXNlcjE=", "gravatar_id": "", "url": "http://example.com", "avatar_url": "http://example.com", "html_url": "http://example.com", "followers_url": "http://example.com", "following_url": "http://example.com", "gists_url": "http://example.com", "starred_url": "http://example.com", "subscriptions_url": "http://example.com", "organizations_url": "http://example.com", "repos_url": "http://example.com", "events_url": "http://example.com", "received_events_url": "http://example.com", "type": "User", "site_admin": false }, "label": "label" },
702                "created_at": "2023-01-01T00:00:00Z",
703                "updated_at": "2023-01-01T00:00:00Z",
704                "url": "http://api.github.com/repos/owner/repo/pulls/2",
705                "diff_url": "http://github.com/owner/repo/pulls/2.diff",
706                "patch_url": "http://github.com/owner/repo/pulls/2.patch",
707                "issue_url": "http://api.github.com/repos/owner/repo/issues/2",
708                "commits_url": "http://api.github.com/repos/owner/repo/pulls/2/commits",
709                "review_comments_url": "http://api.github.com/repos/owner/repo/pulls/2/comments",
710                "review_comment_url": "http://api.github.com/repos/owner/repo/pulls/comments{/number}",
711                "comments_url": "http://api.github.com/repos/owner/repo/issues/2/comments",
712                "statuses_url": "http://api.github.com/repos/owner/repo/statuses/sha",
713                "author_association": "NONE"
714            }
715        ]);
716
717        Mock::given(method("GET"))
718            .and(path("/repos/owner/repo/pulls"))
719            .respond_with(ResponseTemplate::new(200).set_body_json(mock_response))
720            .mount(&mock_server)
721            .await;
722
723        let repo = Repo {
724            owner: "owner".into(),
725            name: "repo".into(),
726        };
727
728        let prs = service.list_prs(&repo, None).await.unwrap();
729        assert_eq!(prs.len(), 1);
730        assert_eq!(prs[0].title, "New PR");
731    }
732}