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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct Repo {
34 pub owner: GithubOwner,
36
37 pub name: GithubRepo,
39}
40
41impl FFIBoundary for Repo {}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct IssueFilter {
48 pub state: Option<String>,
50
51 pub labels: Option<Vec<String>>,
53}
54
55impl FFIBoundary for IssueFilter {}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61pub struct CreatePRSpec {
62 pub title: String,
64
65 pub body: String,
67
68 pub head: String,
70
71 pub base: String,
73}
74
75impl FFIBoundary for CreatePRSpec {}
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81pub struct PRFilter {
82 pub state: Option<String>,
84
85 pub limit: Option<u32>,
87}
88
89impl FFIBoundary for PRFilter {}
90
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95pub struct Issue {
96 pub number: u64,
98
99 pub title: String,
101
102 pub body: String,
104
105 pub state: ItemState,
107
108 pub url: String,
110
111 pub author: String,
113
114 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
138pub struct PullRequest {
139 pub number: u64,
141
142 pub title: String,
144
145 pub body: String,
147
148 pub state: ItemState,
150
151 pub url: String,
153
154 pub author: String,
156
157 pub head_ref: String,
159
160 pub base_ref: String,
162
163 pub created_at: String,
165
166 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
193pub struct ReviewComment {
194 pub id: u64,
196
197 pub body: String,
199
200 pub path: String,
202
203 pub line: Option<u32>,
205
206 pub author: String,
208
209 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#[derive(Clone)]
298pub struct GitHubService {
299 client: Octocrab,
300}
301
302impl GitHubService {
303 pub fn new(token: String) -> Result<Self> {
313 let client = OctocrabBuilder::new().personal_token(token).build()?;
314 Ok(Self { client })
315 }
316
317 #[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 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 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}