Skip to main content

github_bot_sdk/client/
pull_request.rs

1// Spec: docs/specs/interfaces/pull-request-operations.md
2// Pull request, review, comment, and label operations.
3
4use 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/// 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///
226/// Note: milestone assignment is not supported here — the GitHub Pulls API
227/// silently ignores the `milestone` field. Use `PullRequestsClient::set_milestone`
228/// which delegates to the Issues API endpoint that actually applies the milestone.
229#[derive(Debug, Clone, Serialize, Default)]
230pub struct UpdatePullRequestRequest {
231    /// Pull request title
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub title: Option<String>,
234
235    /// Pull request body content (Markdown)
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub body: Option<String>,
238
239    /// Pull request state
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub state: Option<String>, // "open" or "closed"
242
243    /// Base branch
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub base: Option<String>,
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 update a pull request comment.
318#[derive(Debug, Clone, Serialize)]
319pub struct UpdatePullRequestCommentRequest {
320    /// Comment body content (Markdown, required)
321    pub body: String,
322}
323
324// ============================================================================
325// PullRequestsClient
326// ============================================================================
327
328/// Domain client for pull request operations.
329///
330/// Obtained via [`InstallationClient::pull_requests()`]. Cheap to clone (Arc-backed).
331///
332/// See docs/specs/interfaces/pull-request-operations.md
333#[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    // --- Pull Request CRUD ---
344
345    /// List pull requests in a repository.
346    ///
347    /// Returns a paginated response with pull requests and pagination metadata.
348    /// Use the pagination information to fetch subsequent pages if needed.
349    ///
350    /// # Arguments
351    ///
352    /// * `owner` - Repository owner
353    /// * `repo` - Repository name
354    /// * `state` - Filter by state (`"open"`, `"closed"`, or `"all"`)
355    /// * `page` - Page number (1-indexed, omit for first page)
356    ///
357    /// # Examples
358    ///
359    /// ```no_run
360    /// # use github_bot_sdk::client::PullRequestsClient;
361    /// # async fn example(client: &PullRequestsClient) -> Result<(), Box<dyn std::error::Error>> {
362    /// // Get first page
363    /// let response = client.list("owner", "repo", None, None).await?;
364    /// println!("Got {} pull requests", response.items.len());
365    ///
366    /// // Check if more pages exist
367    /// if response.has_next() {
368    ///     if let Some(next_page) = response.next_page_number() {
369    ///         let next_response = client.list("owner", "repo", None, Some(next_page)).await?;
370    ///         println!("Got {} more PRs", next_response.items.len());
371    ///     }
372    /// }
373    /// # Ok(())
374    /// # }
375    /// ```
376    ///
377    /// See docs/spec/interfaces/pull-request-operations.md
378    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        // Parse Link header for pagination
421        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        // Parse response body
429        let items: Vec<PullRequest> = response.json().await.map_err(ApiError::from)?;
430
431        Ok(PagedResponse {
432            items,
433            total_count: None, // GitHub doesn't provide total count in list responses
434            pagination,
435        })
436    }
437
438    /// Get a specific pull request by number.
439    ///
440    /// See docs/spec/interfaces/pull-request-operations.md
441    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    /// Create a new pull request.
472    ///
473    /// See docs/spec/interfaces/pull-request-operations.md
474    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    /// Update an existing pull request.
512    ///
513    /// See docs/spec/interfaces/pull-request-operations.md
514    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    /// Merge a pull request.
553    ///
554    /// See docs/spec/interfaces/pull-request-operations.md
555    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    /// Set the milestone on a pull request.
607    ///
608    /// The GitHub Pulls API silently ignores the milestone field, so this method
609    /// delegates to the Issues API (PATCH /repos/{owner}/{repo}/issues/{number})
610    /// which correctly applies the milestone, then re-fetches the PR to return
611    /// the updated state.
612    ///
613    /// # Partial-failure note
614    ///
615    /// If the Issues API call succeeds but the subsequent `get()` call fails
616    /// (e.g. due to a transient network error), this method returns an error even
617    /// though the milestone **was** actually set on the PR. Callers should treat
618    /// an error response as "milestone state unknown" rather than "milestone not
619    /// set" and may wish to re-fetch the PR to confirm the current state.
620    ///
621    /// See docs/specs/interfaces/pull-request-operations.md
622    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    // ========================================================================
637    // Pull Request Review Operations
638    // ========================================================================
639
640    /// List reviews on a pull request.
641    ///
642    /// See docs/spec/interfaces/pull-request-operations.md
643    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    /// Get a specific review by ID.
674    ///
675    /// See docs/spec/interfaces/pull-request-operations.md
676    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    /// Create a review on a pull request.
711    ///
712    /// See docs/spec/interfaces/pull-request-operations.md
713    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    /// Update a pending review.
752    ///
753    /// See docs/spec/interfaces/pull-request-operations.md
754    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    /// Dismiss a review.
797    ///
798    /// See docs/spec/interfaces/pull-request-operations.md
799    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    // ========================================================================
842    // Pull Request Comment Operations
843    // ========================================================================
844
845    /// List all conversation-thread comments on a pull request (auto-paginated).
846    ///
847    /// Uses the Issues comments endpoint per GitHub API design.
848    ///
849    /// See docs/specs/interfaces/pull-request-operations.md
850    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    /// Add a conversation-thread comment to a pull request.
864    ///
865    /// Uses the Issues comments endpoint per GitHub API design.
866    ///
867    /// See docs/specs/interfaces/pull-request-operations.md
868    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    /// Update an existing pull request conversation-thread comment.
885    ///
886    /// See docs/specs/interfaces/pull-request-operations.md
887    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    /// Delete a pull request conversation-thread comment.
904    ///
905    /// See docs/specs/interfaces/pull-request-operations.md
906    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    // ========================================================================
922    // Pull Request Label Operations
923    // ========================================================================
924
925    /// Add labels to a pull request.
926    ///
927    /// See docs/specs/interfaces/pull-request-operations.md
928    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        // PRs use the same label endpoint as issues
936        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    /// Replace all labels on a pull request.
948    ///
949    /// Replaces the entire set of labels. Pass an empty vec to clear all labels.
950    ///
951    /// See docs/specs/interfaces/pull-request-operations.md
952    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        // PRs use the same label endpoint as issues
960        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    /// Remove a label from a pull request.
971    ///
972    /// See docs/spec/interfaces/pull-request-operations.md
973    ///
974    /// # Error mapping
975    ///
976    /// GitHub returns HTTP 422 when the label name is unprocessable (e.g. does
977    /// not exist on the repository).  This method maps that to
978    /// [`ApiError::InvalidRequest`], which is the correct semantic mapping and
979    /// is consistent with how other label methods in this file behave.  Callers
980    /// that previously matched on `ApiError::HttpError { status: 422, .. }`
981    /// must be updated to match `ApiError::InvalidRequest { .. }` instead.
982    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        // PRs use the same label endpoint as issues
990        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;