Skip to main content

github_bot_sdk/client/
pull_request.rs

1// GENERATED FROM: docs/spec/interfaces/pull-request-operations.md
2// Pull request and review operations for GitHub API
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::client::issue::{IssueUser, Label, Milestone};
8use crate::client::InstallationClient;
9use crate::error::ApiError;
10
11/// GitHub pull request.
12///
13/// Represents a pull request with all its metadata.
14///
15/// See docs/spec/interfaces/pull-request-operations.md
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct PullRequest {
18    /// Unique pull request identifier
19    pub id: u64,
20
21    /// Node ID for GraphQL API
22    pub node_id: String,
23
24    /// Pull request number (repository-specific)
25    pub number: u64,
26
27    /// Pull request title
28    pub title: String,
29
30    /// Pull request body content (Markdown)
31    pub body: Option<String>,
32
33    /// Pull request state
34    pub state: String, // "open", "closed"
35
36    /// User who created the pull request
37    pub user: IssueUser,
38
39    /// Head branch information
40    pub head: PullRequestBranch,
41
42    /// Base branch information
43    pub base: PullRequestBranch,
44
45    /// Whether the pull request is a draft
46    pub draft: bool,
47
48    /// Whether the pull request is merged
49    pub merged: bool,
50
51    /// Whether the pull request is mergeable
52    pub mergeable: Option<bool>,
53
54    /// Merge commit SHA (if merged)
55    pub merge_commit_sha: Option<String>,
56
57    /// Assigned users
58    pub assignees: Vec<IssueUser>,
59
60    /// Requested reviewers
61    pub requested_reviewers: Vec<IssueUser>,
62
63    /// Applied labels
64    pub labels: Vec<Label>,
65
66    /// Milestone
67    pub milestone: Option<Milestone>,
68
69    /// Creation timestamp
70    pub created_at: DateTime<Utc>,
71
72    /// Last update timestamp
73    pub updated_at: DateTime<Utc>,
74
75    /// Close timestamp
76    pub closed_at: Option<DateTime<Utc>>,
77
78    /// Merge timestamp
79    pub merged_at: Option<DateTime<Utc>>,
80
81    /// Pull request URL
82    pub html_url: String,
83}
84
85/// Branch information in a pull request.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct PullRequestBranch {
88    /// Branch name
89    #[serde(rename = "ref")]
90    pub branch_ref: String,
91
92    /// Commit SHA
93    pub sha: String,
94
95    /// Repository information
96    pub repo: PullRequestRepo,
97}
98
99/// Repository information in a pull request branch.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct PullRequestRepo {
102    /// Repository ID
103    pub id: u64,
104
105    /// Repository name
106    pub name: String,
107
108    /// Full repository name (owner/repo)
109    pub full_name: String,
110}
111
112/// Pull request review.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct Review {
115    /// Unique review identifier
116    pub id: u64,
117
118    /// Node ID for GraphQL API
119    pub node_id: String,
120
121    /// User who submitted the review
122    pub user: IssueUser,
123
124    /// Review body content (Markdown)
125    pub body: Option<String>,
126
127    /// Review state
128    pub state: String, // "APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED", "PENDING"
129
130    /// Commit SHA that was reviewed
131    pub commit_id: String,
132
133    /// Creation timestamp
134    pub submitted_at: Option<DateTime<Utc>>,
135
136    /// Review URL
137    pub html_url: String,
138}
139
140/// Comment on a pull request (review comment on code).
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct PullRequestComment {
143    /// Unique comment identifier
144    pub id: u64,
145
146    /// Node ID for GraphQL API
147    pub node_id: String,
148
149    /// Comment body content (Markdown)
150    pub body: String,
151
152    /// User who created the comment
153    pub user: IssueUser,
154
155    /// File path
156    pub path: String,
157
158    /// Line number (if single-line comment)
159    pub line: Option<u64>,
160
161    /// Commit SHA
162    pub commit_id: String,
163
164    /// Creation timestamp
165    pub created_at: DateTime<Utc>,
166
167    /// Last update timestamp
168    pub updated_at: DateTime<Utc>,
169
170    /// Comment URL
171    pub html_url: String,
172}
173
174/// Request to create a new pull request.
175#[derive(Debug, Clone, Serialize)]
176pub struct CreatePullRequestRequest {
177    /// Pull request title (required)
178    pub title: String,
179
180    /// Head branch (required) - format: "username:branch" for forks
181    pub head: String,
182
183    /// Base branch (required)
184    pub base: String,
185
186    /// Pull request body content (Markdown)
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub body: Option<String>,
189
190    /// Whether to create as draft
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub draft: Option<bool>,
193
194    /// Milestone number
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub milestone: Option<u64>,
197
198    /// Whether maintainers of the base repository can push to the head branch.
199    ///
200    /// When `true`, maintainers of the base repository (contributors with push
201    /// access) can push commits to the head branch of this pull request, even
202    /// when the head branch lives in a fork. Defaults to `true` on the GitHub
203    /// API for fork-sourced pull requests when not provided.
204    ///
205    /// # Example
206    ///
207    /// ```
208    /// use github_bot_sdk::client::CreatePullRequestRequest;
209    ///
210    /// let request = CreatePullRequestRequest {
211    ///     title: "My feature".to_string(),
212    ///     head: "contributor:feature-branch".to_string(),
213    ///     base: "main".to_string(),
214    ///     body: None,
215    ///     draft: None,
216    ///     milestone: None,
217    ///     maintainer_can_modify: Some(true),
218    /// };
219    /// ```
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub maintainer_can_modify: Option<bool>,
222}
223
224/// Request to update an existing pull request.
225#[derive(Debug, Clone, Serialize, Default)]
226pub struct UpdatePullRequestRequest {
227    /// Pull request title
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub title: Option<String>,
230
231    /// Pull request body content (Markdown)
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub body: Option<String>,
234
235    /// Pull request state
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub state: Option<String>, // "open" or "closed"
238
239    /// Base branch
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub base: Option<String>,
242
243    /// Milestone number (None to clear milestone)
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub milestone: Option<u64>,
246}
247
248/// Request to merge a pull request.
249#[derive(Debug, Clone, Serialize, Default)]
250pub struct MergePullRequestRequest {
251    /// Merge commit message title
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub commit_title: Option<String>,
254
255    /// Merge commit message body
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub commit_message: Option<String>,
258
259    /// SHA that pull request head must match
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub sha: Option<String>,
262
263    /// Merge method
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub merge_method: Option<String>, // "merge", "squash", "rebase"
266}
267
268/// Result of merging a pull request.
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct MergeResult {
271    /// Whether the merge was successful
272    pub merged: bool,
273
274    /// Merge commit SHA
275    pub sha: String,
276
277    /// Message describing the result
278    pub message: String,
279}
280
281/// Request to create a review.
282#[derive(Debug, Clone, Serialize)]
283pub struct CreateReviewRequest {
284    /// Commit SHA to review (optional, defaults to PR head)
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub commit_id: Option<String>,
287
288    /// Review body content (Markdown)
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub body: Option<String>,
291
292    /// Review event
293    pub event: String, // "APPROVE", "REQUEST_CHANGES", "COMMENT"
294}
295
296/// Request to update a review.
297#[derive(Debug, Clone, Serialize)]
298pub struct UpdateReviewRequest {
299    /// Review body content (Markdown, required)
300    pub body: String,
301}
302
303/// Request to dismiss a review.
304#[derive(Debug, Clone, Serialize)]
305pub struct DismissReviewRequest {
306    /// Dismissal message (required)
307    pub message: String,
308}
309
310/// Request to create a pull request comment.
311#[derive(Debug, Clone, Serialize)]
312pub struct CreatePullRequestCommentRequest {
313    /// Comment body content (Markdown, required)
314    pub body: String,
315}
316
317/// Request to set milestone on a pull request.
318#[derive(Debug, Clone, Serialize)]
319pub struct SetPullRequestMilestoneRequest {
320    /// Milestone number (None to clear milestone)
321    pub milestone: Option<u64>,
322}
323
324impl InstallationClient {
325    // ========================================================================
326    // Pull Request Operations
327    // ========================================================================
328
329    /// List pull requests in a repository.
330    ///
331    /// Returns a paginated response with pull requests and pagination metadata.
332    /// Use the pagination information to fetch subsequent pages if needed.
333    ///
334    /// # Arguments
335    ///
336    /// * `owner` - Repository owner
337    /// * `repo` - Repository name
338    /// * `state` - Filter by state (`"open"`, `"closed"`, or `"all"`)
339    /// * `page` - Page number (1-indexed, omit for first page)
340    ///
341    /// # Examples
342    ///
343    /// ```no_run
344    /// # use github_bot_sdk::client::InstallationClient;
345    /// # async fn example(client: &InstallationClient) -> Result<(), Box<dyn std::error::Error>> {
346    /// // Get first page
347    /// let response = client.list_pull_requests("owner", "repo", None, None).await?;
348    /// println!("Got {} pull requests", response.items.len());
349    ///
350    /// // Check if more pages exist
351    /// if response.has_next() {
352    ///     if let Some(next_page) = response.next_page_number() {
353    ///         let next_response = client.list_pull_requests("owner", "repo", None, Some(next_page)).await?;
354    ///         println!("Got {} more PRs", next_response.items.len());
355    ///     }
356    /// }
357    /// # Ok(())
358    /// # }
359    /// ```
360    ///
361    /// See docs/spec/interfaces/pull-request-operations.md
362    pub async fn list_pull_requests(
363        &self,
364        owner: &str,
365        repo: &str,
366        state: Option<&str>,
367        page: Option<u32>,
368    ) -> Result<crate::client::PagedResponse<PullRequest>, ApiError> {
369        let mut path = format!("/repos/{}/{}/pulls", owner, repo);
370        let mut query_params = Vec::new();
371
372        if let Some(state_value) = state {
373            query_params.push(format!("state={}", state_value));
374        }
375        if let Some(page_num) = page {
376            query_params.push(format!("page={}", page_num));
377        }
378
379        if !query_params.is_empty() {
380            path = format!("{}?{}", path, query_params.join("&"));
381        }
382
383        let response = self.get(&path).await?;
384        let status = response.status();
385
386        if !status.is_success() {
387            return Err(match status.as_u16() {
388                404 => ApiError::NotFound,
389                403 => ApiError::AuthorizationFailed,
390                401 => ApiError::AuthenticationFailed,
391                _ => {
392                    let message = response
393                        .text()
394                        .await
395                        .unwrap_or_else(|_| "Unknown error".to_string());
396                    ApiError::HttpError {
397                        status: status.as_u16(),
398                        message,
399                    }
400                }
401            });
402        }
403
404        // Parse Link header for pagination
405        let pagination = response
406            .headers()
407            .get("Link")
408            .and_then(|h| h.to_str().ok())
409            .map(|h| crate::client::parse_link_header(Some(h)))
410            .unwrap_or_default();
411
412        // Parse response body
413        let items: Vec<PullRequest> = response.json().await.map_err(ApiError::from)?;
414
415        Ok(crate::client::PagedResponse {
416            items,
417            total_count: None, // GitHub doesn't provide total count in list responses
418            pagination,
419        })
420    }
421
422    /// Get a specific pull request by number.
423    ///
424    /// See docs/spec/interfaces/pull-request-operations.md
425    pub async fn get_pull_request(
426        &self,
427        owner: &str,
428        repo: &str,
429        pull_number: u64,
430    ) -> Result<PullRequest, ApiError> {
431        let path = format!("/repos/{}/{}/pulls/{}", owner, repo, pull_number);
432        let response = self.get(&path).await?;
433
434        let status = response.status();
435        if !status.is_success() {
436            return Err(match status.as_u16() {
437                404 => ApiError::NotFound,
438                403 => ApiError::AuthorizationFailed,
439                401 => ApiError::AuthenticationFailed,
440                _ => {
441                    let message = response
442                        .text()
443                        .await
444                        .unwrap_or_else(|_| "Unknown error".to_string());
445                    ApiError::HttpError {
446                        status: status.as_u16(),
447                        message,
448                    }
449                }
450            });
451        }
452        response.json().await.map_err(ApiError::from)
453    }
454
455    /// Create a new pull request.
456    ///
457    /// See docs/spec/interfaces/pull-request-operations.md
458    pub async fn create_pull_request(
459        &self,
460        owner: &str,
461        repo: &str,
462        request: CreatePullRequestRequest,
463    ) -> Result<PullRequest, ApiError> {
464        let path = format!("/repos/{}/{}/pulls", owner, repo);
465        let response = self.post(&path, &request).await?;
466
467        let status = response.status();
468        if !status.is_success() {
469            return Err(match status.as_u16() {
470                422 => {
471                    let message = response
472                        .text()
473                        .await
474                        .unwrap_or_else(|_| "Validation failed".to_string());
475                    ApiError::InvalidRequest { message }
476                }
477                404 => ApiError::NotFound,
478                403 => ApiError::AuthorizationFailed,
479                401 => ApiError::AuthenticationFailed,
480                _ => {
481                    let message = response
482                        .text()
483                        .await
484                        .unwrap_or_else(|_| "Unknown error".to_string());
485                    ApiError::HttpError {
486                        status: status.as_u16(),
487                        message,
488                    }
489                }
490            });
491        }
492        response.json().await.map_err(ApiError::from)
493    }
494
495    /// Update an existing pull request.
496    ///
497    /// See docs/spec/interfaces/pull-request-operations.md
498    pub async fn update_pull_request(
499        &self,
500        owner: &str,
501        repo: &str,
502        pull_number: u64,
503        request: UpdatePullRequestRequest,
504    ) -> Result<PullRequest, ApiError> {
505        let path = format!("/repos/{}/{}/pulls/{}", owner, repo, pull_number);
506        let response = self.patch(&path, &request).await?;
507
508        let status = response.status();
509        if !status.is_success() {
510            return Err(match status.as_u16() {
511                422 => {
512                    let message = response
513                        .text()
514                        .await
515                        .unwrap_or_else(|_| "Validation failed".to_string());
516                    ApiError::InvalidRequest { message }
517                }
518                404 => ApiError::NotFound,
519                403 => ApiError::AuthorizationFailed,
520                401 => ApiError::AuthenticationFailed,
521                _ => {
522                    let message = response
523                        .text()
524                        .await
525                        .unwrap_or_else(|_| "Unknown error".to_string());
526                    ApiError::HttpError {
527                        status: status.as_u16(),
528                        message,
529                    }
530                }
531            });
532        }
533        response.json().await.map_err(ApiError::from)
534    }
535
536    /// Merge a pull request.
537    ///
538    /// See docs/spec/interfaces/pull-request-operations.md
539    pub async fn merge_pull_request(
540        &self,
541        owner: &str,
542        repo: &str,
543        pull_number: u64,
544        request: MergePullRequestRequest,
545    ) -> Result<MergeResult, ApiError> {
546        let path = format!("/repos/{}/{}/pulls/{}/merge", owner, repo, pull_number);
547        let response = self.put(&path, &request).await?;
548
549        let status = response.status();
550        if !status.is_success() {
551            return Err(match status.as_u16() {
552                405 => {
553                    let message = response
554                        .text()
555                        .await
556                        .unwrap_or_else(|_| "Pull request not mergeable".to_string());
557                    ApiError::HttpError {
558                        status: 405,
559                        message,
560                    }
561                }
562                409 => {
563                    let message = response
564                        .text()
565                        .await
566                        .unwrap_or_else(|_| "Merge conflict".to_string());
567                    ApiError::HttpError {
568                        status: 409,
569                        message,
570                    }
571                }
572                404 => ApiError::NotFound,
573                403 => ApiError::AuthorizationFailed,
574                401 => ApiError::AuthenticationFailed,
575                _ => {
576                    let message = response
577                        .text()
578                        .await
579                        .unwrap_or_else(|_| "Unknown error".to_string());
580                    ApiError::HttpError {
581                        status: status.as_u16(),
582                        message,
583                    }
584                }
585            });
586        }
587        response.json().await.map_err(ApiError::from)
588    }
589
590    /// Set the milestone on a pull request.
591    ///
592    /// See docs/spec/interfaces/pull-request-operations.md
593    pub async fn set_pull_request_milestone(
594        &self,
595        owner: &str,
596        repo: &str,
597        pull_number: u64,
598        milestone_number: Option<u64>,
599    ) -> Result<PullRequest, ApiError> {
600        let request = UpdatePullRequestRequest {
601            milestone: milestone_number,
602            ..Default::default()
603        };
604        self.update_pull_request(owner, repo, pull_number, request)
605            .await
606    }
607
608    // ========================================================================
609    // Pull Request Review Operations
610    // ========================================================================
611
612    /// List reviews on a pull request.
613    ///
614    /// See docs/spec/interfaces/pull-request-operations.md
615    pub async fn list_reviews(
616        &self,
617        owner: &str,
618        repo: &str,
619        pull_number: u64,
620    ) -> Result<Vec<Review>, ApiError> {
621        let path = format!("/repos/{}/{}/pulls/{}/reviews", owner, repo, pull_number);
622        let response = self.get(&path).await?;
623
624        let status = response.status();
625        if !status.is_success() {
626            return Err(match status.as_u16() {
627                404 => ApiError::NotFound,
628                403 => ApiError::AuthorizationFailed,
629                401 => ApiError::AuthenticationFailed,
630                _ => {
631                    let message = response
632                        .text()
633                        .await
634                        .unwrap_or_else(|_| "Unknown error".to_string());
635                    ApiError::HttpError {
636                        status: status.as_u16(),
637                        message,
638                    }
639                }
640            });
641        }
642        response.json().await.map_err(ApiError::from)
643    }
644
645    /// Get a specific review by ID.
646    ///
647    /// See docs/spec/interfaces/pull-request-operations.md
648    pub async fn get_review(
649        &self,
650        owner: &str,
651        repo: &str,
652        pull_number: u64,
653        review_id: u64,
654    ) -> Result<Review, ApiError> {
655        let path = format!(
656            "/repos/{}/{}/pulls/{}/reviews/{}",
657            owner, repo, pull_number, review_id
658        );
659        let response = self.get(&path).await?;
660
661        let status = response.status();
662        if !status.is_success() {
663            return Err(match status.as_u16() {
664                404 => ApiError::NotFound,
665                403 => ApiError::AuthorizationFailed,
666                401 => ApiError::AuthenticationFailed,
667                _ => {
668                    let message = response
669                        .text()
670                        .await
671                        .unwrap_or_else(|_| "Unknown error".to_string());
672                    ApiError::HttpError {
673                        status: status.as_u16(),
674                        message,
675                    }
676                }
677            });
678        }
679        response.json().await.map_err(ApiError::from)
680    }
681
682    /// Create a review on a pull request.
683    ///
684    /// See docs/spec/interfaces/pull-request-operations.md
685    pub async fn create_review(
686        &self,
687        owner: &str,
688        repo: &str,
689        pull_number: u64,
690        request: CreateReviewRequest,
691    ) -> Result<Review, ApiError> {
692        let path = format!("/repos/{}/{}/pulls/{}/reviews", owner, repo, pull_number);
693        let response = self.post(&path, &request).await?;
694
695        let status = response.status();
696        if !status.is_success() {
697            return Err(match status.as_u16() {
698                422 => {
699                    let message = response
700                        .text()
701                        .await
702                        .unwrap_or_else(|_| "Validation failed".to_string());
703                    ApiError::InvalidRequest { message }
704                }
705                404 => ApiError::NotFound,
706                403 => ApiError::AuthorizationFailed,
707                401 => ApiError::AuthenticationFailed,
708                _ => {
709                    let message = response
710                        .text()
711                        .await
712                        .unwrap_or_else(|_| "Unknown error".to_string());
713                    ApiError::HttpError {
714                        status: status.as_u16(),
715                        message,
716                    }
717                }
718            });
719        }
720        response.json().await.map_err(ApiError::from)
721    }
722
723    /// Update a pending review.
724    ///
725    /// See docs/spec/interfaces/pull-request-operations.md
726    pub async fn update_review(
727        &self,
728        owner: &str,
729        repo: &str,
730        pull_number: u64,
731        review_id: u64,
732        request: UpdateReviewRequest,
733    ) -> Result<Review, ApiError> {
734        let path = format!(
735            "/repos/{}/{}/pulls/{}/reviews/{}",
736            owner, repo, pull_number, review_id
737        );
738        let response = self.put(&path, &request).await?;
739
740        let status = response.status();
741        if !status.is_success() {
742            return Err(match status.as_u16() {
743                422 => {
744                    let message = response
745                        .text()
746                        .await
747                        .unwrap_or_else(|_| "Validation failed".to_string());
748                    ApiError::InvalidRequest { message }
749                }
750                404 => ApiError::NotFound,
751                403 => ApiError::AuthorizationFailed,
752                401 => ApiError::AuthenticationFailed,
753                _ => {
754                    let message = response
755                        .text()
756                        .await
757                        .unwrap_or_else(|_| "Unknown error".to_string());
758                    ApiError::HttpError {
759                        status: status.as_u16(),
760                        message,
761                    }
762                }
763            });
764        }
765        response.json().await.map_err(ApiError::from)
766    }
767
768    /// Dismiss a review.
769    ///
770    /// See docs/spec/interfaces/pull-request-operations.md
771    pub async fn dismiss_review(
772        &self,
773        owner: &str,
774        repo: &str,
775        pull_number: u64,
776        review_id: u64,
777        request: DismissReviewRequest,
778    ) -> Result<Review, ApiError> {
779        let path = format!(
780            "/repos/{}/{}/pulls/{}/reviews/{}/dismissals",
781            owner, repo, pull_number, review_id
782        );
783        let response = self.put(&path, &request).await?;
784
785        let status = response.status();
786        if !status.is_success() {
787            return Err(match status.as_u16() {
788                422 => {
789                    let message = response
790                        .text()
791                        .await
792                        .unwrap_or_else(|_| "Validation failed".to_string());
793                    ApiError::InvalidRequest { message }
794                }
795                404 => ApiError::NotFound,
796                403 => ApiError::AuthorizationFailed,
797                401 => ApiError::AuthenticationFailed,
798                _ => {
799                    let message = response
800                        .text()
801                        .await
802                        .unwrap_or_else(|_| "Unknown error".to_string());
803                    ApiError::HttpError {
804                        status: status.as_u16(),
805                        message,
806                    }
807                }
808            });
809        }
810        response.json().await.map_err(ApiError::from)
811    }
812
813    // ========================================================================
814    // Pull Request Comment Operations
815    // ========================================================================
816
817    /// List comments on a pull request.
818    ///
819    /// See docs/spec/interfaces/pull-request-operations.md
820    pub async fn list_pull_request_comments(
821        &self,
822        owner: &str,
823        repo: &str,
824        pull_number: u64,
825    ) -> Result<Vec<PullRequestComment>, ApiError> {
826        let path = format!("/repos/{}/{}/pulls/{}/comments", owner, repo, pull_number);
827        let response = self.get(&path).await?;
828
829        let status = response.status();
830        if !status.is_success() {
831            return Err(match status.as_u16() {
832                404 => ApiError::NotFound,
833                403 => ApiError::AuthorizationFailed,
834                401 => ApiError::AuthenticationFailed,
835                _ => {
836                    let message = response
837                        .text()
838                        .await
839                        .unwrap_or_else(|_| "Unknown error".to_string());
840                    ApiError::HttpError {
841                        status: status.as_u16(),
842                        message,
843                    }
844                }
845            });
846        }
847        response.json().await.map_err(ApiError::from)
848    }
849
850    /// Create a comment on a pull request.
851    ///
852    /// See docs/spec/interfaces/pull-request-operations.md
853    pub async fn create_pull_request_comment(
854        &self,
855        owner: &str,
856        repo: &str,
857        pull_number: u64,
858        request: CreatePullRequestCommentRequest,
859    ) -> Result<PullRequestComment, ApiError> {
860        let path = format!("/repos/{}/{}/pulls/{}/comments", owner, repo, pull_number);
861        let response = self.post(&path, &request).await?;
862
863        let status = response.status();
864        if !status.is_success() {
865            return Err(match status.as_u16() {
866                422 => {
867                    let message = response
868                        .text()
869                        .await
870                        .unwrap_or_else(|_| "Validation failed".to_string());
871                    ApiError::InvalidRequest { message }
872                }
873                404 => ApiError::NotFound,
874                403 => ApiError::AuthorizationFailed,
875                401 => ApiError::AuthenticationFailed,
876                _ => {
877                    let message = response
878                        .text()
879                        .await
880                        .unwrap_or_else(|_| "Unknown error".to_string());
881                    ApiError::HttpError {
882                        status: status.as_u16(),
883                        message,
884                    }
885                }
886            });
887        }
888        response.json().await.map_err(ApiError::from)
889    }
890
891    // ========================================================================
892    // Pull Request Label Operations
893    // ========================================================================
894
895    /// Add labels to a pull request.
896    ///
897    /// See docs/spec/interfaces/pull-request-operations.md
898    pub async fn add_labels_to_pull_request(
899        &self,
900        owner: &str,
901        repo: &str,
902        pull_number: u64,
903        labels: Vec<String>,
904    ) -> Result<Vec<Label>, ApiError> {
905        // PRs use the same label endpoint as issues
906        let path = format!("/repos/{}/{}/issues/{}/labels", owner, repo, pull_number);
907        let response = self.post(&path, &labels).await?;
908
909        let status = response.status();
910        if !status.is_success() {
911            return Err(match status.as_u16() {
912                422 => {
913                    let message = response
914                        .text()
915                        .await
916                        .unwrap_or_else(|_| "Validation failed".to_string());
917                    ApiError::InvalidRequest { message }
918                }
919                404 => ApiError::NotFound,
920                403 => ApiError::AuthorizationFailed,
921                401 => ApiError::AuthenticationFailed,
922                _ => {
923                    let message = response
924                        .text()
925                        .await
926                        .unwrap_or_else(|_| "Unknown error".to_string());
927                    ApiError::HttpError {
928                        status: status.as_u16(),
929                        message,
930                    }
931                }
932            });
933        }
934        response.json().await.map_err(ApiError::from)
935    }
936
937    /// Remove a label from a pull request.
938    ///
939    /// See docs/spec/interfaces/pull-request-operations.md
940    pub async fn remove_label_from_pull_request(
941        &self,
942        owner: &str,
943        repo: &str,
944        pull_number: u64,
945        name: &str,
946    ) -> Result<(), ApiError> {
947        // PRs use the same label endpoint as issues
948        let path = format!(
949            "/repos/{}/{}/issues/{}/labels/{}",
950            owner, repo, pull_number, name
951        );
952        let response = self.delete(&path).await?;
953
954        let status = response.status();
955        if !status.is_success() {
956            return Err(match status.as_u16() {
957                404 => ApiError::NotFound,
958                403 => ApiError::AuthorizationFailed,
959                401 => ApiError::AuthenticationFailed,
960                _ => {
961                    let message = response
962                        .text()
963                        .await
964                        .unwrap_or_else(|_| "Unknown error".to_string());
965                    ApiError::HttpError {
966                        status: status.as_u16(),
967                        message,
968                    }
969                }
970            });
971        }
972        Ok(())
973    }
974}
975
976#[cfg(test)]
977#[path = "pull_request_tests.rs"]
978mod tests;