1use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::client::issue::{Comment, IssueUser, Label, LabelsRequest, Milestone};
8use crate::client::{parse_link_header, InstallationClient, PagedResponse};
9use crate::error::ApiError;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct PullRequest {
18 pub id: u64,
20
21 pub node_id: String,
23
24 pub number: u64,
26
27 pub title: String,
29
30 pub body: Option<String>,
32
33 pub state: String, pub user: IssueUser,
38
39 pub head: PullRequestBranch,
41
42 pub base: PullRequestBranch,
44
45 pub draft: bool,
47
48 pub merged: bool,
50
51 pub mergeable: Option<bool>,
53
54 pub merge_commit_sha: Option<String>,
56
57 pub assignees: Vec<IssueUser>,
59
60 pub requested_reviewers: Vec<IssueUser>,
62
63 pub labels: Vec<Label>,
65
66 pub milestone: Option<Milestone>,
68
69 pub created_at: DateTime<Utc>,
71
72 pub updated_at: DateTime<Utc>,
74
75 pub closed_at: Option<DateTime<Utc>>,
77
78 pub merged_at: Option<DateTime<Utc>>,
80
81 pub html_url: String,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct PullRequestBranch {
88 #[serde(rename = "ref")]
90 pub branch_ref: String,
91
92 pub sha: String,
94
95 pub repo: PullRequestRepo,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct PullRequestRepo {
102 pub id: u64,
104
105 pub name: String,
107
108 pub full_name: String,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct Review {
115 pub id: u64,
117
118 pub node_id: String,
120
121 pub user: IssueUser,
123
124 pub body: Option<String>,
126
127 pub state: String, pub commit_id: String,
132
133 pub submitted_at: Option<DateTime<Utc>>,
135
136 pub html_url: String,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct PullRequestComment {
143 pub id: u64,
145
146 pub node_id: String,
148
149 pub body: String,
151
152 pub user: IssueUser,
154
155 pub path: String,
157
158 pub line: Option<u64>,
160
161 pub commit_id: String,
163
164 pub created_at: DateTime<Utc>,
166
167 pub updated_at: DateTime<Utc>,
169
170 pub html_url: String,
172}
173
174#[derive(Debug, Clone, Serialize)]
176pub struct CreatePullRequestRequest {
177 pub title: String,
179
180 pub head: String,
182
183 pub base: String,
185
186 #[serde(skip_serializing_if = "Option::is_none")]
188 pub body: Option<String>,
189
190 #[serde(skip_serializing_if = "Option::is_none")]
192 pub draft: Option<bool>,
193
194 #[serde(skip_serializing_if = "Option::is_none")]
196 pub milestone: Option<u64>,
197
198 #[serde(skip_serializing_if = "Option::is_none")]
221 pub maintainer_can_modify: Option<bool>,
222}
223
224#[derive(Debug, Clone, Serialize, Default)]
230pub struct UpdatePullRequestRequest {
231 #[serde(skip_serializing_if = "Option::is_none")]
233 pub title: Option<String>,
234
235 #[serde(skip_serializing_if = "Option::is_none")]
237 pub body: Option<String>,
238
239 #[serde(skip_serializing_if = "Option::is_none")]
241 pub state: Option<String>, #[serde(skip_serializing_if = "Option::is_none")]
245 pub base: Option<String>,
246}
247
248#[derive(Debug, Clone, Serialize, Default)]
250pub struct MergePullRequestRequest {
251 #[serde(skip_serializing_if = "Option::is_none")]
253 pub commit_title: Option<String>,
254
255 #[serde(skip_serializing_if = "Option::is_none")]
257 pub commit_message: Option<String>,
258
259 #[serde(skip_serializing_if = "Option::is_none")]
261 pub sha: Option<String>,
262
263 #[serde(skip_serializing_if = "Option::is_none")]
265 pub merge_method: Option<String>, }
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct MergeResult {
271 pub merged: bool,
273
274 pub sha: String,
276
277 pub message: String,
279}
280
281#[derive(Debug, Clone, Serialize)]
283pub struct CreateReviewRequest {
284 #[serde(skip_serializing_if = "Option::is_none")]
286 pub commit_id: Option<String>,
287
288 #[serde(skip_serializing_if = "Option::is_none")]
290 pub body: Option<String>,
291
292 pub event: String, }
295
296#[derive(Debug, Clone, Serialize)]
298pub struct UpdateReviewRequest {
299 pub body: String,
301}
302
303#[derive(Debug, Clone, Serialize)]
305pub struct DismissReviewRequest {
306 pub message: String,
308}
309
310#[derive(Debug, Clone, Serialize)]
312pub struct CreatePullRequestCommentRequest {
313 pub body: String,
315}
316
317#[derive(Debug, Clone, Serialize)]
319pub struct UpdatePullRequestCommentRequest {
320 pub body: String,
322}
323
324#[derive(Debug, Clone)]
334pub struct PullRequestsClient {
335 client: InstallationClient,
336}
337
338impl PullRequestsClient {
339 pub(crate) fn new(client: InstallationClient) -> Self {
340 Self { client }
341 }
342
343 pub async fn list(
379 &self,
380 owner: &str,
381 repo: &str,
382 state: Option<&str>,
383 page: Option<u32>,
384 ) -> Result<PagedResponse<PullRequest>, ApiError> {
385 let mut path = format!("/repos/{}/{}/pulls", owner, repo);
386 let mut query_params = Vec::new();
387
388 if let Some(state_value) = state {
389 query_params.push(format!("state={}", state_value));
390 }
391 if let Some(page_num) = page {
392 query_params.push(format!("page={}", page_num));
393 }
394
395 if !query_params.is_empty() {
396 path = format!("{}?{}", path, query_params.join("&"));
397 }
398
399 let response = self.client.get(&path).await?;
400 let status = response.status();
401
402 if !status.is_success() {
403 return Err(match status.as_u16() {
404 404 => ApiError::NotFound,
405 403 => ApiError::AuthorizationFailed,
406 401 => ApiError::AuthenticationFailed,
407 _ => {
408 let message = response
409 .text()
410 .await
411 .unwrap_or_else(|_| "Unknown error".to_string());
412 ApiError::HttpError {
413 status: status.as_u16(),
414 message,
415 }
416 }
417 });
418 }
419
420 let pagination = response
422 .headers()
423 .get("Link")
424 .and_then(|h| h.to_str().ok())
425 .map(|h| parse_link_header(Some(h)))
426 .unwrap_or_default();
427
428 let items: Vec<PullRequest> = response.json().await.map_err(ApiError::from)?;
430
431 Ok(PagedResponse {
432 items,
433 total_count: None, pagination,
435 })
436 }
437
438 pub async fn get(
442 &self,
443 owner: &str,
444 repo: &str,
445 pull_number: u64,
446 ) -> Result<PullRequest, ApiError> {
447 let path = format!("/repos/{}/{}/pulls/{}", owner, repo, pull_number);
448 let response = self.client.get(&path).await?;
449
450 let status = response.status();
451 if !status.is_success() {
452 return Err(match status.as_u16() {
453 404 => ApiError::NotFound,
454 403 => ApiError::AuthorizationFailed,
455 401 => ApiError::AuthenticationFailed,
456 _ => {
457 let message = response
458 .text()
459 .await
460 .unwrap_or_else(|_| "Unknown error".to_string());
461 ApiError::HttpError {
462 status: status.as_u16(),
463 message,
464 }
465 }
466 });
467 }
468 response.json().await.map_err(ApiError::from)
469 }
470
471 pub async fn create(
475 &self,
476 owner: &str,
477 repo: &str,
478 request: CreatePullRequestRequest,
479 ) -> Result<PullRequest, ApiError> {
480 let path = format!("/repos/{}/{}/pulls", owner, repo);
481 let response = self.client.post(&path, &request).await?;
482
483 let status = response.status();
484 if !status.is_success() {
485 return Err(match status.as_u16() {
486 422 => {
487 let message = response
488 .text()
489 .await
490 .unwrap_or_else(|_| "Validation failed".to_string());
491 ApiError::InvalidRequest { message }
492 }
493 404 => ApiError::NotFound,
494 403 => ApiError::AuthorizationFailed,
495 401 => ApiError::AuthenticationFailed,
496 _ => {
497 let message = response
498 .text()
499 .await
500 .unwrap_or_else(|_| "Unknown error".to_string());
501 ApiError::HttpError {
502 status: status.as_u16(),
503 message,
504 }
505 }
506 });
507 }
508 response.json().await.map_err(ApiError::from)
509 }
510
511 pub async fn update(
515 &self,
516 owner: &str,
517 repo: &str,
518 pull_number: u64,
519 request: UpdatePullRequestRequest,
520 ) -> Result<PullRequest, ApiError> {
521 let path = format!("/repos/{}/{}/pulls/{}", owner, repo, pull_number);
522 let response = self.client.patch(&path, &request).await?;
523
524 let status = response.status();
525 if !status.is_success() {
526 return Err(match status.as_u16() {
527 422 => {
528 let message = response
529 .text()
530 .await
531 .unwrap_or_else(|_| "Validation failed".to_string());
532 ApiError::InvalidRequest { message }
533 }
534 404 => ApiError::NotFound,
535 403 => ApiError::AuthorizationFailed,
536 401 => ApiError::AuthenticationFailed,
537 _ => {
538 let message = response
539 .text()
540 .await
541 .unwrap_or_else(|_| "Unknown error".to_string());
542 ApiError::HttpError {
543 status: status.as_u16(),
544 message,
545 }
546 }
547 });
548 }
549 response.json().await.map_err(ApiError::from)
550 }
551
552 pub async fn merge(
556 &self,
557 owner: &str,
558 repo: &str,
559 pull_number: u64,
560 request: MergePullRequestRequest,
561 ) -> Result<MergeResult, ApiError> {
562 let path = format!("/repos/{}/{}/pulls/{}/merge", owner, repo, pull_number);
563 let response = self.client.put(&path, &request).await?;
564
565 let status = response.status();
566 if !status.is_success() {
567 return Err(match status.as_u16() {
568 405 => {
569 let message = response
570 .text()
571 .await
572 .unwrap_or_else(|_| "Pull request not mergeable".to_string());
573 ApiError::HttpError {
574 status: 405,
575 message,
576 }
577 }
578 409 => {
579 let message = response
580 .text()
581 .await
582 .unwrap_or_else(|_| "Merge conflict".to_string());
583 ApiError::HttpError {
584 status: 409,
585 message,
586 }
587 }
588 404 => ApiError::NotFound,
589 403 => ApiError::AuthorizationFailed,
590 401 => ApiError::AuthenticationFailed,
591 _ => {
592 let message = response
593 .text()
594 .await
595 .unwrap_or_else(|_| "Unknown error".to_string());
596 ApiError::HttpError {
597 status: status.as_u16(),
598 message,
599 }
600 }
601 });
602 }
603 response.json().await.map_err(ApiError::from)
604 }
605
606 pub async fn set_milestone(
623 &self,
624 owner: &str,
625 repo: &str,
626 pull_number: u64,
627 milestone_number: Option<u64>,
628 ) -> Result<PullRequest, ApiError> {
629 self.client
630 .issues()
631 .set_milestone(owner, repo, pull_number, milestone_number)
632 .await?;
633 self.get(owner, repo, pull_number).await
634 }
635
636 pub async fn list_reviews(
644 &self,
645 owner: &str,
646 repo: &str,
647 pull_number: u64,
648 ) -> Result<Vec<Review>, ApiError> {
649 let path = format!("/repos/{}/{}/pulls/{}/reviews", owner, repo, pull_number);
650 let response = self.client.get(&path).await?;
651
652 let status = response.status();
653 if !status.is_success() {
654 return Err(match status.as_u16() {
655 404 => ApiError::NotFound,
656 403 => ApiError::AuthorizationFailed,
657 401 => ApiError::AuthenticationFailed,
658 _ => {
659 let message = response
660 .text()
661 .await
662 .unwrap_or_else(|_| "Unknown error".to_string());
663 ApiError::HttpError {
664 status: status.as_u16(),
665 message,
666 }
667 }
668 });
669 }
670 response.json().await.map_err(ApiError::from)
671 }
672
673 pub async fn get_review(
677 &self,
678 owner: &str,
679 repo: &str,
680 pull_number: u64,
681 review_id: u64,
682 ) -> Result<Review, ApiError> {
683 let path = format!(
684 "/repos/{}/{}/pulls/{}/reviews/{}",
685 owner, repo, pull_number, review_id
686 );
687 let response = self.client.get(&path).await?;
688
689 let status = response.status();
690 if !status.is_success() {
691 return Err(match status.as_u16() {
692 404 => ApiError::NotFound,
693 403 => ApiError::AuthorizationFailed,
694 401 => ApiError::AuthenticationFailed,
695 _ => {
696 let message = response
697 .text()
698 .await
699 .unwrap_or_else(|_| "Unknown error".to_string());
700 ApiError::HttpError {
701 status: status.as_u16(),
702 message,
703 }
704 }
705 });
706 }
707 response.json().await.map_err(ApiError::from)
708 }
709
710 pub async fn create_review(
714 &self,
715 owner: &str,
716 repo: &str,
717 pull_number: u64,
718 request: CreateReviewRequest,
719 ) -> Result<Review, ApiError> {
720 let path = format!("/repos/{}/{}/pulls/{}/reviews", owner, repo, pull_number);
721 let response = self.client.post(&path, &request).await?;
722
723 let status = response.status();
724 if !status.is_success() {
725 return Err(match status.as_u16() {
726 422 => {
727 let message = response
728 .text()
729 .await
730 .unwrap_or_else(|_| "Validation failed".to_string());
731 ApiError::InvalidRequest { message }
732 }
733 404 => ApiError::NotFound,
734 403 => ApiError::AuthorizationFailed,
735 401 => ApiError::AuthenticationFailed,
736 _ => {
737 let message = response
738 .text()
739 .await
740 .unwrap_or_else(|_| "Unknown error".to_string());
741 ApiError::HttpError {
742 status: status.as_u16(),
743 message,
744 }
745 }
746 });
747 }
748 response.json().await.map_err(ApiError::from)
749 }
750
751 pub async fn update_review(
755 &self,
756 owner: &str,
757 repo: &str,
758 pull_number: u64,
759 review_id: u64,
760 request: UpdateReviewRequest,
761 ) -> Result<Review, ApiError> {
762 let path = format!(
763 "/repos/{}/{}/pulls/{}/reviews/{}",
764 owner, repo, pull_number, review_id
765 );
766 let response = self.client.put(&path, &request).await?;
767
768 let status = response.status();
769 if !status.is_success() {
770 return Err(match status.as_u16() {
771 422 => {
772 let message = response
773 .text()
774 .await
775 .unwrap_or_else(|_| "Validation failed".to_string());
776 ApiError::InvalidRequest { message }
777 }
778 404 => ApiError::NotFound,
779 403 => ApiError::AuthorizationFailed,
780 401 => ApiError::AuthenticationFailed,
781 _ => {
782 let message = response
783 .text()
784 .await
785 .unwrap_or_else(|_| "Unknown error".to_string());
786 ApiError::HttpError {
787 status: status.as_u16(),
788 message,
789 }
790 }
791 });
792 }
793 response.json().await.map_err(ApiError::from)
794 }
795
796 pub async fn dismiss_review(
800 &self,
801 owner: &str,
802 repo: &str,
803 pull_number: u64,
804 review_id: u64,
805 request: DismissReviewRequest,
806 ) -> Result<Review, ApiError> {
807 let path = format!(
808 "/repos/{}/{}/pulls/{}/reviews/{}/dismissals",
809 owner, repo, pull_number, review_id
810 );
811 let response = self.client.put(&path, &request).await?;
812
813 let status = response.status();
814 if !status.is_success() {
815 return Err(match status.as_u16() {
816 422 => {
817 let message = response
818 .text()
819 .await
820 .unwrap_or_else(|_| "Validation failed".to_string());
821 ApiError::InvalidRequest { message }
822 }
823 404 => ApiError::NotFound,
824 403 => ApiError::AuthorizationFailed,
825 401 => ApiError::AuthenticationFailed,
826 _ => {
827 let message = response
828 .text()
829 .await
830 .unwrap_or_else(|_| "Unknown error".to_string());
831 ApiError::HttpError {
832 status: status.as_u16(),
833 message,
834 }
835 }
836 });
837 }
838 response.json().await.map_err(ApiError::from)
839 }
840
841 pub async fn list_comments(
851 &self,
852 owner: &str,
853 repo: &str,
854 pull_number: u64,
855 ) -> Result<Vec<Comment>, ApiError> {
856 let first_page = format!(
857 "/repos/{}/{}/issues/{}/comments?per_page=100",
858 owner, repo, pull_number
859 );
860 self.client.fetch_all_pages(&first_page).await
861 }
862
863 pub async fn create_comment(
869 &self,
870 owner: &str,
871 repo: &str,
872 pull_number: u64,
873 request: CreatePullRequestCommentRequest,
874 ) -> Result<Comment, ApiError> {
875 let path = format!("/repos/{}/{}/issues/{}/comments", owner, repo, pull_number);
876 let response = self.client.post(&path, &request).await?;
877 let status = response.status();
878 if !status.is_success() {
879 return Err(super::map_http_error(status, response).await);
880 }
881 response.json().await.map_err(ApiError::from)
882 }
883
884 pub async fn update_comment(
888 &self,
889 owner: &str,
890 repo: &str,
891 comment_id: u64,
892 request: UpdatePullRequestCommentRequest,
893 ) -> Result<Comment, ApiError> {
894 let path = format!("/repos/{}/{}/issues/comments/{}", owner, repo, comment_id);
895 let response = self.client.patch(&path, &request).await?;
896 let status = response.status();
897 if !status.is_success() {
898 return Err(super::map_http_error(status, response).await);
899 }
900 response.json().await.map_err(ApiError::from)
901 }
902
903 pub async fn delete_comment(
907 &self,
908 owner: &str,
909 repo: &str,
910 comment_id: u64,
911 ) -> Result<(), ApiError> {
912 let path = format!("/repos/{}/{}/issues/comments/{}", owner, repo, comment_id);
913 let response = self.client.delete(&path).await?;
914 let status = response.status();
915 if !status.is_success() {
916 return Err(super::map_http_error(status, response).await);
917 }
918 Ok(())
919 }
920
921 pub async fn add_labels(
929 &self,
930 owner: &str,
931 repo: &str,
932 pull_number: u64,
933 labels: Vec<String>,
934 ) -> Result<Vec<Label>, ApiError> {
935 let path = format!("/repos/{}/{}/issues/{}/labels", owner, repo, pull_number);
937 let body = LabelsRequest { labels };
938 let response = self.client.post(&path, &body).await?;
939
940 let status = response.status();
941 if !status.is_success() {
942 return Err(super::map_http_error(status, response).await);
943 }
944 response.json().await.map_err(ApiError::from)
945 }
946
947 pub async fn replace_labels(
953 &self,
954 owner: &str,
955 repo: &str,
956 pull_number: u64,
957 labels: Vec<String>,
958 ) -> Result<Vec<Label>, ApiError> {
959 let path = format!("/repos/{}/{}/issues/{}/labels", owner, repo, pull_number);
961 let body = LabelsRequest { labels };
962 let response = self.client.put(&path, &body).await?;
963 let status = response.status();
964 if !status.is_success() {
965 return Err(super::map_http_error(status, response).await);
966 }
967 response.json().await.map_err(ApiError::from)
968 }
969
970 pub async fn remove_label(
983 &self,
984 owner: &str,
985 repo: &str,
986 pull_number: u64,
987 name: &str,
988 ) -> Result<Vec<Label>, ApiError> {
989 let path = format!(
991 "/repos/{}/{}/issues/{}/labels/{}",
992 owner,
993 repo,
994 pull_number,
995 urlencoding::encode(name)
996 );
997 let response = self.client.delete(&path).await?;
998
999 let status = response.status();
1000 if !status.is_success() {
1001 return Err(super::map_http_error(status, response).await);
1002 }
1003 response.json().await.map_err(ApiError::from)
1004 }
1005}
1006
1007#[cfg(test)]
1008#[path = "pull_request_tests.rs"]
1009mod tests;