Skip to main content

github_bot_sdk/client/
issue.rs

1// Spec: docs/specs/interfaces/issue-operations.md, labels-client.md,
2//       milestones-client.md, reactions.md
3// Issue, labels-on-issue, comments, reactions, assignees, lock, timeline,
4// and milestone-definition operations.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use crate::client::{parse_link_header, InstallationClient, PagedResponse};
10use crate::error::ApiError;
11
12/// Milestone state.
13///
14/// See docs/specs/interfaces/milestones-client.md
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum MilestoneState {
18    Open,
19    Closed,
20}
21
22/// GitHub issue.
23///
24/// Represents a GitHub issue with all its metadata.
25///
26/// See docs/specs/interfaces/issue-operations.md
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct Issue {
29    /// Unique issue identifier
30    pub id: u64,
31
32    /// Node ID for GraphQL API
33    pub node_id: String,
34
35    /// Issue number (repository-specific)
36    pub number: u64,
37
38    /// Issue title
39    pub title: String,
40
41    /// Issue body content (Markdown)
42    pub body: Option<String>,
43
44    /// Issue state
45    pub state: String, // "open" | "closed"
46
47    /// Whether the issue is locked
48    #[serde(default)]
49    pub locked: bool,
50
51    /// User who created the issue
52    pub user: IssueUser,
53
54    /// Assigned users
55    pub assignees: Vec<IssueUser>,
56
57    /// Applied labels
58    pub labels: Vec<Label>,
59
60    /// Milestone
61    pub milestone: Option<Milestone>,
62
63    /// Number of comments
64    pub comments: u64,
65
66    /// Creation timestamp
67    pub created_at: DateTime<Utc>,
68
69    /// Last update timestamp
70    pub updated_at: DateTime<Utc>,
71
72    /// Close timestamp
73    pub closed_at: Option<DateTime<Utc>>,
74
75    /// Issue URL
76    pub html_url: String,
77}
78
79/// User associated with an issue.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct IssueUser {
82    /// User login name
83    pub login: String,
84
85    /// User ID
86    pub id: u64,
87
88    /// User node ID
89    pub node_id: String,
90
91    /// User type
92    #[serde(rename = "type")]
93    pub user_type: String,
94}
95
96/// Milestone associated with an issue or pull request.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct Milestone {
99    /// Unique milestone identifier
100    pub id: u64,
101
102    /// Node ID for GraphQL API
103    pub node_id: String,
104
105    /// Milestone number (repository-specific)
106    pub number: u64,
107
108    /// Milestone title
109    pub title: String,
110
111    /// Milestone description
112    pub description: Option<String>,
113
114    /// Milestone state
115    pub state: MilestoneState,
116
117    /// Number of open issues
118    pub open_issues: u32,
119
120    /// Number of closed issues
121    pub closed_issues: u32,
122
123    /// Due date
124    pub due_on: Option<DateTime<Utc>>,
125
126    /// Creation timestamp
127    pub created_at: DateTime<Utc>,
128
129    /// Last update timestamp
130    pub updated_at: DateTime<Utc>,
131
132    /// Close timestamp
133    pub closed_at: Option<DateTime<Utc>>,
134}
135
136/// GitHub label.
137///
138/// Labels are used to categorize issues and pull requests.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct Label {
141    /// Unique label identifier
142    pub id: u64,
143
144    /// Node ID for GraphQL API
145    pub node_id: String,
146
147    /// Label name
148    pub name: String,
149
150    /// Label description
151    pub description: Option<String>,
152
153    /// Label color (6-digit hex code without #)
154    pub color: String,
155
156    /// Whether this is a default label
157    pub default: bool,
158}
159
160/// Comment on an issue.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct Comment {
163    /// Unique comment identifier
164    pub id: u64,
165
166    /// Node ID for GraphQL API
167    pub node_id: String,
168
169    /// Comment body content (Markdown)
170    pub body: String,
171
172    /// User who created the comment
173    pub user: IssueUser,
174
175    /// Creation timestamp
176    pub created_at: DateTime<Utc>,
177
178    /// Last update timestamp
179    pub updated_at: DateTime<Utc>,
180
181    /// Comment URL
182    pub html_url: String,
183}
184
185/// Request to create a new issue.
186#[derive(Debug, Clone, Serialize)]
187pub struct CreateIssueRequest {
188    /// Issue title (required)
189    pub title: String,
190
191    /// Issue body content (Markdown)
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub body: Option<String>,
194
195    /// Usernames to assign
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub assignees: Option<Vec<String>>,
198
199    /// Milestone number
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub milestone: Option<u64>,
202
203    /// Label names to apply
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub labels: Option<Vec<String>>,
206}
207
208/// Request to update an existing issue.
209#[derive(Debug, Clone, Serialize, Default)]
210pub struct UpdateIssueRequest {
211    /// Issue title
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub title: Option<String>,
214
215    /// Issue body content (Markdown)
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub body: Option<String>,
218
219    /// Issue state
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub state: Option<String>, // "open" or "closed"
222
223    /// Usernames to assign (replaces existing assignees)
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub assignees: Option<Vec<String>>,
226
227    /// Milestone number (None to clear milestone)
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub milestone: Option<u64>,
230
231    /// Label names (replaces existing labels)
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub labels: Option<Vec<String>>,
234}
235
236/// Request to create a label.
237#[derive(Debug, Clone, Serialize)]
238pub struct CreateLabelRequest {
239    /// Label name (required)
240    pub name: String,
241
242    /// Label color (6-digit hex code without #)
243    pub color: String,
244
245    /// Label description
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub description: Option<String>,
248}
249
250/// Request to update a label.
251#[derive(Debug, Clone, Serialize, Default)]
252pub struct UpdateLabelRequest {
253    /// New label name. The JSON key sent to GitHub is `"name"`.
254    #[serde(rename = "name", skip_serializing_if = "Option::is_none")]
255    pub new_name: Option<String>,
256
257    /// Label color (6-digit hex code without #)
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub color: Option<String>,
260
261    /// Label description
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub description: Option<String>,
264}
265
266/// Request to create a comment.
267#[derive(Debug, Clone, Serialize)]
268pub struct CreateCommentRequest {
269    /// Comment body content (Markdown, required)
270    pub body: String,
271}
272
273/// Request to update a comment.
274#[derive(Debug, Clone, Serialize)]
275pub struct UpdateCommentRequest {
276    /// Comment body content (Markdown, required)
277    pub body: String,
278}
279
280// ============================================================================
281// New types introduced by ADR-003 spec
282// ============================================================================
283
284/// Reason used when locking an issue.
285///
286/// See docs/specs/interfaces/issue-operations.md
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(rename_all = "kebab-case")]
289pub enum LockReason {
290    OffTopic,
291    TooHeated,
292    Resolved,
293    Spam,
294}
295
296/// A discrete activity event recorded on an issue.
297///
298/// Returned by the issue events REST endpoint — different from webhook events.
299///
300/// See docs/specs/interfaces/issue-operations.md
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct IssueActivityEvent {
303    pub id: u64,
304    pub event: String, // "labeled", "assigned", "closed", etc.
305    pub actor: IssueUser,
306    pub created_at: DateTime<Utc>,
307    pub label: Option<Label>,
308    pub assignee: Option<IssueUser>,
309    pub milestone: Option<MilestoneSummary>,
310    pub rename: Option<IssueRename>,
311}
312
313/// Brief milestone reference used inside activity events.
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct MilestoneSummary {
316    pub title: String,
317}
318
319/// Issue rename payload inside activity events.
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct IssueRename {
322    pub from: String,
323    pub to: String,
324}
325
326/// A single item in an issue's full timeline.
327///
328/// The `event` field drives deserialization.  Unknown event kinds map to
329/// `TimelineEvent::Unknown` without error (via `#[serde(other)]`).
330///
331/// See docs/specs/interfaces/issue-operations.md
332#[derive(Debug, Clone, Serialize, Deserialize)]
333#[serde(tag = "event", rename_all = "snake_case")]
334pub enum TimelineEvent {
335    /// A comment posted on the issue. Note: GitHub returns comment objects
336    /// here (with `user`, not `actor`) so this variant is structurally
337    /// different from the other event kinds.
338    #[serde(rename = "commented")]
339    Commented {
340        id: u64,
341        user: IssueUser,
342        body: Option<String>,
343        created_at: DateTime<Utc>,
344        updated_at: DateTime<Utc>,
345        html_url: String,
346    },
347    Labeled {
348        id: u64,
349        actor: IssueUser,
350        label: Label,
351        created_at: DateTime<Utc>,
352    },
353    Unlabeled {
354        id: u64,
355        actor: IssueUser,
356        label: Label,
357        created_at: DateTime<Utc>,
358    },
359    Assigned {
360        id: u64,
361        actor: IssueUser,
362        assignee: IssueUser,
363        created_at: DateTime<Utc>,
364    },
365    Unassigned {
366        id: u64,
367        actor: IssueUser,
368        assignee: IssueUser,
369        created_at: DateTime<Utc>,
370    },
371    Milestoned {
372        id: u64,
373        actor: IssueUser,
374        milestone: MilestoneSummary,
375        created_at: DateTime<Utc>,
376    },
377    Demilestoned {
378        id: u64,
379        actor: IssueUser,
380        milestone: MilestoneSummary,
381        created_at: DateTime<Utc>,
382    },
383    Closed {
384        id: u64,
385        actor: IssueUser,
386        created_at: DateTime<Utc>,
387    },
388    Reopened {
389        id: u64,
390        actor: IssueUser,
391        created_at: DateTime<Utc>,
392    },
393    Locked {
394        id: u64,
395        actor: IssueUser,
396        lock_reason: Option<String>,
397        created_at: DateTime<Utc>,
398    },
399    Unlocked {
400        id: u64,
401        actor: IssueUser,
402        created_at: DateTime<Utc>,
403    },
404    Renamed {
405        id: u64,
406        actor: IssueUser,
407        rename: IssueRename,
408        created_at: DateTime<Utc>,
409    },
410    Referenced {
411        id: u64,
412        actor: IssueUser,
413        created_at: DateTime<Utc>,
414    },
415    /// Catch-all: unknown event kind — must not cause a deserialization error.
416    #[serde(other)]
417    Unknown,
418}
419
420/// Emoji content for a GitHub reaction.
421///
422/// The `+1` and `-1` variants require explicit `#[serde(rename)]` because
423/// their GitHub API names start with symbols.
424///
425/// See docs/specs/interfaces/reactions.md
426#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
427#[serde(rename_all = "snake_case")]
428pub enum ReactionContent {
429    /// 👍
430    #[serde(rename = "+1")]
431    PlusOne,
432    /// 👎
433    #[serde(rename = "-1")]
434    MinusOne,
435    /// 😄
436    Laugh,
437    /// 😕
438    Confused,
439    /// ❤️
440    Heart,
441    /// 🎉
442    Hooray,
443    /// 🚀
444    Rocket,
445    /// 👀
446    Eyes,
447}
448
449/// A single reaction on an issue or issue comment.
450///
451/// See docs/specs/interfaces/reactions.md
452#[derive(Debug, Clone, Serialize, Deserialize)]
453pub struct Reaction {
454    pub id: u64,
455    pub node_id: String,
456    pub user: IssueUser,
457    pub content: ReactionContent,
458    pub created_at: DateTime<Utc>,
459}
460
461// ============================================================================
462// Milestone-management types (MilestonesClient)
463// ============================================================================
464
465/// Sort direction for list queries.
466///
467/// See docs/specs/interfaces/milestones-client.md
468#[derive(Debug, Clone)]
469pub enum SortDirection {
470    Asc,
471    Desc,
472}
473
474/// Sort field for milestone list queries.
475///
476/// See docs/specs/interfaces/milestones-client.md
477#[derive(Debug, Clone)]
478pub enum MilestoneSortField {
479    DueOn,
480    Completeness,
481}
482
483/// Filter and sort options for listing milestones.
484///
485/// See docs/specs/interfaces/milestones-client.md
486#[derive(Debug, Clone, Default)]
487pub struct ListMilestonesQuery {
488    pub state: Option<MilestoneState>,
489    pub sort: Option<MilestoneSortField>,
490    pub direction: Option<SortDirection>,
491}
492
493/// Request to create a new milestone.
494///
495/// See docs/specs/interfaces/milestones-client.md
496#[derive(Debug, Clone, Serialize)]
497pub struct CreateMilestoneRequest {
498    pub title: String,
499    #[serde(skip_serializing_if = "Option::is_none")]
500    pub state: Option<MilestoneState>,
501    #[serde(skip_serializing_if = "Option::is_none")]
502    pub description: Option<String>,
503    #[serde(skip_serializing_if = "Option::is_none")]
504    pub due_on: Option<DateTime<Utc>>,
505}
506
507/// Request to update an existing milestone.
508///
509/// See docs/specs/interfaces/milestones-client.md
510#[derive(Debug, Clone, Default, Serialize)]
511pub struct UpdateMilestoneRequest {
512    #[serde(skip_serializing_if = "Option::is_none")]
513    pub title: Option<String>,
514    #[serde(skip_serializing_if = "Option::is_none")]
515    pub state: Option<MilestoneState>,
516    #[serde(skip_serializing_if = "Option::is_none")]
517    pub description: Option<String>,
518    #[serde(skip_serializing_if = "Option::is_none")]
519    pub due_on: Option<DateTime<Utc>>,
520}
521
522// ============================================================================
523// Private request body helpers (not part of the public API)
524// ============================================================================
525
526#[derive(Debug, Clone, Serialize)]
527pub(crate) struct LabelsRequest {
528    pub(crate) labels: Vec<String>,
529}
530
531#[derive(Debug, Clone, Serialize)]
532struct LockIssueRequest {
533    #[serde(skip_serializing_if = "Option::is_none")]
534    lock_reason: Option<LockReason>,
535}
536
537#[derive(Debug, Clone, Serialize)]
538struct AssigneesRequest {
539    assignees: Vec<String>,
540}
541
542#[derive(Debug, Clone, Serialize)]
543struct CreateReactionRequest {
544    content: ReactionContent,
545}
546
547// ============================================================================
548// IssuesClient
549// ============================================================================
550
551/// Domain client for GitHub issue operations.
552///
553/// Obtained via [`InstallationClient::issues()`].  Cheap to clone (Arc-backed).
554///
555/// Covers issue CRUD, comment CRUD, reactions, label application, assignees,
556/// lock/unlock, and timeline queries.
557///
558/// See docs/specs/interfaces/issue-operations.md
559#[derive(Debug, Clone)]
560pub struct IssuesClient {
561    client: InstallationClient,
562}
563
564impl IssuesClient {
565    pub(crate) fn new(client: InstallationClient) -> Self {
566        Self { client }
567    }
568
569    // --- Issue CRUD ---
570
571    /// List issues in a repository (manual pagination).
572    ///
573    /// # Arguments
574    /// * `state` — `"open"` (default), `"closed"`, or `"all"`
575    /// * `page` — 1-indexed page number; omit for first page
576    pub async fn list(
577        &self,
578        owner: &str,
579        repo: &str,
580        state: Option<&str>,
581        page: Option<u32>,
582    ) -> Result<PagedResponse<Issue>, ApiError> {
583        let mut path = format!("/repos/{}/{}/issues", owner, repo);
584        let mut params: Vec<String> = Vec::new();
585        if let Some(s) = state {
586            params.push(format!("state={}", s));
587        }
588        if let Some(p) = page {
589            params.push(format!("page={}", p));
590        }
591        if !params.is_empty() {
592            path = format!("{}?{}", path, params.join("&"));
593        }
594
595        let response = self.client.get(&path).await?;
596        let status = response.status();
597        if !status.is_success() {
598            return Err(super::map_http_error(status, response).await);
599        }
600
601        let pagination = response
602            .headers()
603            .get("Link")
604            .and_then(|h| h.to_str().ok())
605            .map(|h| parse_link_header(Some(h)))
606            .unwrap_or_default();
607
608        let items: Vec<Issue> = response.json().await.map_err(ApiError::from)?;
609        Ok(PagedResponse {
610            items,
611            total_count: None,
612            pagination,
613        })
614    }
615
616    /// Get a single issue by number.
617    ///
618    /// # Errors
619    /// * `ApiError::NotFound` — issue does not exist
620    pub async fn get(&self, owner: &str, repo: &str, issue_number: u64) -> Result<Issue, ApiError> {
621        let path = format!("/repos/{}/{}/issues/{}", owner, repo, issue_number);
622        let response = self.client.get(&path).await?;
623        let status = response.status();
624        if !status.is_success() {
625            return Err(super::map_http_error(status, response).await);
626        }
627        response.json().await.map_err(ApiError::from)
628    }
629
630    /// Create a new issue.
631    ///
632    /// # Errors
633    /// * `ApiError::InvalidRequest` — validation failed (empty title, etc.)
634    pub async fn create(
635        &self,
636        owner: &str,
637        repo: &str,
638        request: CreateIssueRequest,
639    ) -> Result<Issue, ApiError> {
640        let path = format!("/repos/{}/{}/issues", owner, repo);
641        let response = self.client.post(&path, &request).await?;
642        let status = response.status();
643        if !status.is_success() {
644            return Err(super::map_http_error(status, response).await);
645        }
646        response.json().await.map_err(ApiError::from)
647    }
648
649    /// Update an existing issue (patch semantics — only set fields are changed).
650    ///
651    /// # Errors
652    /// * `ApiError::NotFound` — issue does not exist
653    pub async fn update(
654        &self,
655        owner: &str,
656        repo: &str,
657        issue_number: u64,
658        request: UpdateIssueRequest,
659    ) -> Result<Issue, ApiError> {
660        let path = format!("/repos/{}/{}/issues/{}", owner, repo, issue_number);
661        let response = self.client.patch(&path, &request).await?;
662        let status = response.status();
663        if !status.is_success() {
664            return Err(super::map_http_error(status, response).await);
665        }
666        response.json().await.map_err(ApiError::from)
667    }
668
669    /// Set (or clear) the milestone on an issue.
670    ///
671    /// Pass `None` to remove the milestone.
672    pub async fn set_milestone(
673        &self,
674        owner: &str,
675        repo: &str,
676        issue_number: u64,
677        milestone_number: Option<u64>,
678    ) -> Result<Issue, ApiError> {
679        self.update(
680            owner,
681            repo,
682            issue_number,
683            UpdateIssueRequest {
684                milestone: milestone_number,
685                ..Default::default()
686            },
687        )
688        .await
689    }
690
691    // --- Comment operations ---
692
693    /// List all comments on an issue.
694    ///
695    /// Auto-paginates with `per_page=100` (ADR-002).  Oldest comments first.
696    ///
697    /// # Errors
698    /// * `ApiError::NotFound` — issue does not exist (no partial list returned)
699    pub async fn list_comments(
700        &self,
701        owner: &str,
702        repo: &str,
703        issue_number: u64,
704    ) -> Result<Vec<Comment>, ApiError> {
705        let base = format!("/repos/{}/{}/issues/{}/comments", owner, repo, issue_number);
706        self.fetch_all(&base).await
707    }
708
709    /// Get a single comment by its repository-scoped ID.
710    pub async fn get_comment(
711        &self,
712        owner: &str,
713        repo: &str,
714        comment_id: u64,
715    ) -> Result<Comment, ApiError> {
716        let path = format!("/repos/{}/{}/issues/comments/{}", owner, repo, comment_id);
717        let response = self.client.get(&path).await?;
718        let status = response.status();
719        if !status.is_success() {
720            return Err(super::map_http_error(status, response).await);
721        }
722        response.json().await.map_err(ApiError::from)
723    }
724
725    /// Create a comment on an issue.
726    pub async fn create_comment(
727        &self,
728        owner: &str,
729        repo: &str,
730        issue_number: u64,
731        request: CreateCommentRequest,
732    ) -> Result<Comment, ApiError> {
733        let path = format!("/repos/{}/{}/issues/{}/comments", owner, repo, issue_number);
734        let response = self.client.post(&path, &request).await?;
735        let status = response.status();
736        if !status.is_success() {
737            return Err(super::map_http_error(status, response).await);
738        }
739        response.json().await.map_err(ApiError::from)
740    }
741
742    /// Update an existing comment.
743    pub async fn update_comment(
744        &self,
745        owner: &str,
746        repo: &str,
747        comment_id: u64,
748        request: UpdateCommentRequest,
749    ) -> Result<Comment, ApiError> {
750        let path = format!("/repos/{}/{}/issues/comments/{}", owner, repo, comment_id);
751        let response = self.client.patch(&path, &request).await?;
752        let status = response.status();
753        if !status.is_success() {
754            return Err(super::map_http_error(status, response).await);
755        }
756        response.json().await.map_err(ApiError::from)
757    }
758
759    /// Delete a comment.
760    pub async fn delete_comment(
761        &self,
762        owner: &str,
763        repo: &str,
764        comment_id: u64,
765    ) -> Result<(), ApiError> {
766        let path = format!("/repos/{}/{}/issues/comments/{}", owner, repo, comment_id);
767        let response = self.client.delete(&path).await?;
768        let status = response.status();
769        if !status.is_success() {
770            return Err(super::map_http_error(status, response).await);
771        }
772        Ok(())
773    }
774
775    // --- Label application on issue ---
776
777    /// List all labels applied to an issue (auto-paginated).
778    pub async fn list_labels(
779        &self,
780        owner: &str,
781        repo: &str,
782        issue_number: u64,
783    ) -> Result<Vec<Label>, ApiError> {
784        let base = format!("/repos/{}/{}/issues/{}/labels", owner, repo, issue_number);
785        self.fetch_all(&base).await
786    }
787
788    /// Add one or more labels to an issue (idempotent — duplicates ignored).
789    ///
790    /// Returns the updated label list on the issue.
791    pub async fn add_labels(
792        &self,
793        owner: &str,
794        repo: &str,
795        issue_number: u64,
796        labels: Vec<String>,
797    ) -> Result<Vec<Label>, ApiError> {
798        let path = format!("/repos/{}/{}/issues/{}/labels", owner, repo, issue_number);
799        let body = LabelsRequest { labels };
800        let response = self.client.post(&path, &body).await?;
801        let status = response.status();
802        if !status.is_success() {
803            return Err(super::map_http_error(status, response).await);
804        }
805        response.json().await.map_err(ApiError::from)
806    }
807
808    /// Remove a single label from an issue.
809    ///
810    /// Returns the remaining label list.
811    ///
812    /// # Errors
813    /// * `ApiError::NotFound` — label is not applied to this issue
814    pub async fn remove_label(
815        &self,
816        owner: &str,
817        repo: &str,
818        issue_number: u64,
819        label_name: &str,
820    ) -> Result<Vec<Label>, ApiError> {
821        let path = format!(
822            "/repos/{}/{}/issues/{}/labels/{}",
823            owner,
824            repo,
825            issue_number,
826            urlencoding::encode(label_name)
827        );
828        let response = self.client.delete(&path).await?;
829        let status = response.status();
830        if !status.is_success() {
831            return Err(super::map_http_error(status, response).await);
832        }
833        response.json().await.map_err(ApiError::from)
834    }
835
836    /// Replace all labels on an issue atomically.
837    ///
838    /// Any labels not in `labels` are removed.  Pass an empty vec to remove all labels.
839    /// Returns the new label list as applied by GitHub.
840    pub async fn replace_labels(
841        &self,
842        owner: &str,
843        repo: &str,
844        issue_number: u64,
845        labels: Vec<String>,
846    ) -> Result<Vec<Label>, ApiError> {
847        let path = format!("/repos/{}/{}/issues/{}/labels", owner, repo, issue_number);
848        let body = LabelsRequest { labels };
849        let response = self.client.put(&path, &body).await?;
850        let status = response.status();
851        if !status.is_success() {
852            return Err(super::map_http_error(status, response).await);
853        }
854        response.json().await.map_err(ApiError::from)
855    }
856
857    // --- Reactions ---
858
859    /// List all reactions on an issue (auto-paginated).
860    pub async fn list_reactions(
861        &self,
862        owner: &str,
863        repo: &str,
864        issue_number: u64,
865    ) -> Result<Vec<Reaction>, ApiError> {
866        let base = format!(
867            "/repos/{}/{}/issues/{}/reactions",
868            owner, repo, issue_number
869        );
870        self.fetch_all(&base).await
871    }
872
873    /// Add a reaction to an issue.
874    ///
875    /// If the same user has already reacted with this emoji, GitHub returns the
876    /// existing reaction (HTTP 200). Both 200 and 201 map to `Ok(Reaction)`.
877    pub async fn create_reaction(
878        &self,
879        owner: &str,
880        repo: &str,
881        issue_number: u64,
882        content: ReactionContent,
883    ) -> Result<Reaction, ApiError> {
884        let path = format!(
885            "/repos/{}/{}/issues/{}/reactions",
886            owner, repo, issue_number
887        );
888        let body = CreateReactionRequest { content };
889        let response = self.client.post(&path, &body).await?;
890        let status = response.status();
891        if !status.is_success() {
892            return Err(super::map_http_error(status, response).await);
893        }
894        response.json().await.map_err(ApiError::from)
895    }
896
897    /// Remove a reaction from an issue.
898    pub async fn delete_reaction(
899        &self,
900        owner: &str,
901        repo: &str,
902        issue_number: u64,
903        reaction_id: u64,
904    ) -> Result<(), ApiError> {
905        let path = format!(
906            "/repos/{}/{}/issues/{}/reactions/{}",
907            owner, repo, issue_number, reaction_id
908        );
909        let response = self.client.delete(&path).await?;
910        let status = response.status();
911        if !status.is_success() {
912            return Err(super::map_http_error(status, response).await);
913        }
914        Ok(())
915    }
916
917    /// List all reactions on an issue comment (auto-paginated).
918    pub async fn list_comment_reactions(
919        &self,
920        owner: &str,
921        repo: &str,
922        comment_id: u64,
923    ) -> Result<Vec<Reaction>, ApiError> {
924        let base = format!(
925            "/repos/{}/{}/issues/comments/{}/reactions",
926            owner, repo, comment_id
927        );
928        self.fetch_all(&base).await
929    }
930
931    /// Add a reaction to an issue comment.
932    pub async fn create_comment_reaction(
933        &self,
934        owner: &str,
935        repo: &str,
936        comment_id: u64,
937        content: ReactionContent,
938    ) -> Result<Reaction, ApiError> {
939        let path = format!(
940            "/repos/{}/{}/issues/comments/{}/reactions",
941            owner, repo, comment_id
942        );
943        let body = CreateReactionRequest { content };
944        let response = self.client.post(&path, &body).await?;
945        let status = response.status();
946        if !status.is_success() {
947            return Err(super::map_http_error(status, response).await);
948        }
949        response.json().await.map_err(ApiError::from)
950    }
951
952    /// Remove a reaction from an issue comment.
953    pub async fn delete_comment_reaction(
954        &self,
955        owner: &str,
956        repo: &str,
957        comment_id: u64,
958        reaction_id: u64,
959    ) -> Result<(), ApiError> {
960        let path = format!(
961            "/repos/{}/{}/issues/comments/{}/reactions/{}",
962            owner, repo, comment_id, reaction_id
963        );
964        let response = self.client.delete(&path).await?;
965        let status = response.status();
966        if !status.is_success() {
967            return Err(super::map_http_error(status, response).await);
968        }
969        Ok(())
970    }
971
972    // --- Assignees ---
973
974    /// List all users eligible to be assigned in this repository (auto-paginated).
975    pub async fn list_available_assignees(
976        &self,
977        owner: &str,
978        repo: &str,
979    ) -> Result<Vec<IssueUser>, ApiError> {
980        let base = format!("/repos/{}/{}/assignees", owner, repo);
981        self.fetch_all(&base).await
982    }
983
984    /// Add assignees to an issue.
985    ///
986    /// GitHub silently ignores users who are not eligible.
987    /// Returns the updated issue with the new assignee list.
988    pub async fn add_assignees(
989        &self,
990        owner: &str,
991        repo: &str,
992        issue_number: u64,
993        assignees: Vec<String>,
994    ) -> Result<Issue, ApiError> {
995        let path = format!(
996            "/repos/{}/{}/issues/{}/assignees",
997            owner, repo, issue_number
998        );
999        let body = AssigneesRequest { assignees };
1000        let response = self.client.post(&path, &body).await?;
1001        let status = response.status();
1002        if !status.is_success() {
1003            return Err(super::map_http_error(status, response).await);
1004        }
1005        response.json().await.map_err(ApiError::from)
1006    }
1007
1008    /// Remove assignees from an issue.
1009    ///
1010    /// GitHub silently ignores users not currently assigned.
1011    /// Returns the updated issue with the revised assignee list.
1012    pub async fn remove_assignees(
1013        &self,
1014        owner: &str,
1015        repo: &str,
1016        issue_number: u64,
1017        assignees: Vec<String>,
1018    ) -> Result<Issue, ApiError> {
1019        let path = format!(
1020            "/repos/{}/{}/issues/{}/assignees",
1021            owner, repo, issue_number
1022        );
1023        let body = AssigneesRequest { assignees };
1024        let response = self.client.delete_with_body(&path, &body).await?;
1025        let status = response.status();
1026        if !status.is_success() {
1027            return Err(super::map_http_error(status, response).await);
1028        }
1029        response.json().await.map_err(ApiError::from)
1030    }
1031
1032    // --- Lock / Unlock ---
1033
1034    /// Lock an issue, preventing non-collaborator comments.
1035    ///
1036    /// # Arguments
1037    /// * `reason` — optional lock reason shown to users attempting to comment
1038    ///
1039    /// # Errors
1040    /// * `ApiError::AuthorizationFailed` — requires admin or maintain permission
1041    pub async fn lock(
1042        &self,
1043        owner: &str,
1044        repo: &str,
1045        issue_number: u64,
1046        reason: Option<LockReason>,
1047    ) -> Result<(), ApiError> {
1048        let path = format!("/repos/{}/{}/issues/{}/lock", owner, repo, issue_number);
1049        let body = LockIssueRequest {
1050            lock_reason: reason,
1051        };
1052        let response = self.client.put(&path, &body).await?;
1053        let status = response.status();
1054        if !status.is_success() {
1055            return Err(super::map_http_error(status, response).await);
1056        }
1057        Ok(())
1058    }
1059
1060    /// Unlock a previously locked issue.
1061    ///
1062    /// # Errors
1063    /// * `ApiError::AuthorizationFailed` — requires admin or maintain permission
1064    pub async fn unlock(&self, owner: &str, repo: &str, issue_number: u64) -> Result<(), ApiError> {
1065        let path = format!("/repos/{}/{}/issues/{}/lock", owner, repo, issue_number);
1066        let response = self.client.delete(&path).await?;
1067        let status = response.status();
1068        if !status.is_success() {
1069            return Err(super::map_http_error(status, response).await);
1070        }
1071        Ok(())
1072    }
1073
1074    // --- Activity events & timeline ---
1075
1076    /// List all discrete activity events on an issue (auto-paginated).
1077    ///
1078    /// Returns events in ascending chronological order (oldest first).
1079    pub async fn list_activity_events(
1080        &self,
1081        owner: &str,
1082        repo: &str,
1083        issue_number: u64,
1084    ) -> Result<Vec<IssueActivityEvent>, ApiError> {
1085        let base = format!("/repos/{}/{}/issues/{}/events", owner, repo, issue_number);
1086        self.fetch_all(&base).await
1087    }
1088
1089    /// List the complete timeline of an issue (auto-paginated).
1090    ///
1091    /// Superset of [`list_activity_events`]: also includes comments and
1092    /// cross-references.  Unknown event kinds yield `TimelineEvent::Unknown`.
1093    pub async fn list_timeline(
1094        &self,
1095        owner: &str,
1096        repo: &str,
1097        issue_number: u64,
1098    ) -> Result<Vec<TimelineEvent>, ApiError> {
1099        let base = format!("/repos/{}/{}/issues/{}/timeline", owner, repo, issue_number);
1100        self.fetch_all(&base).await
1101    }
1102
1103    // --- Private: auto-pagination ---
1104
1105    /// Fetch all pages of a GET endpoint that returns `Vec<T>`.
1106    ///
1107    /// Delegates to [`InstallationClient::fetch_all_pages`] with `per_page=100`
1108    /// appended to `base_path` (ADR-002).
1109    async fn fetch_all<T: serde::de::DeserializeOwned>(
1110        &self,
1111        base_path: &str,
1112    ) -> Result<Vec<T>, ApiError> {
1113        let first_page = format!("{}?per_page=100", base_path);
1114        self.client.fetch_all_pages(&first_page).await
1115    }
1116}
1117
1118// ============================================================================
1119// LabelsClient
1120// ============================================================================
1121
1122/// Domain client for repository-level label catalogue operations.
1123///
1124/// Obtained via [`InstallationClient::labels()`].  Cheap to clone (Arc-backed).
1125///
1126/// Manages label *definitions*.  To apply labels to a specific issue use
1127/// [`IssuesClient`]; to a PR use `PullRequestsClient`.
1128///
1129/// See docs/specs/interfaces/labels-client.md
1130#[derive(Debug, Clone)]
1131pub struct LabelsClient {
1132    client: InstallationClient,
1133}
1134
1135impl LabelsClient {
1136    pub(crate) fn new(client: InstallationClient) -> Self {
1137        Self { client }
1138    }
1139
1140    /// List all label definitions in a repository (auto-paginated).
1141    ///
1142    /// # Errors
1143    /// * `ApiError::NotFound` — repository does not exist
1144    pub async fn list(&self, owner: &str, repo: &str) -> Result<Vec<Label>, ApiError> {
1145        let first_page = format!("/repos/{}/{}/labels?per_page=100", owner, repo);
1146        self.client.fetch_all_pages(&first_page).await
1147    }
1148
1149    /// Get a single label definition by name.
1150    ///
1151    /// # Errors
1152    /// * `ApiError::NotFound` — label does not exist
1153    pub async fn get(&self, owner: &str, repo: &str, name: &str) -> Result<Label, ApiError> {
1154        let path = format!(
1155            "/repos/{}/{}/labels/{}",
1156            owner,
1157            repo,
1158            urlencoding::encode(name)
1159        );
1160        let response = self.client.get(&path).await?;
1161        let status = response.status();
1162        if !status.is_success() {
1163            return Err(super::map_http_error(status, response).await);
1164        }
1165        response.json().await.map_err(ApiError::from)
1166    }
1167
1168    /// Create a new label definition.
1169    ///
1170    /// # Errors
1171    /// * `ApiError::InvalidRequest` — name already exists (422)
1172    /// * `ApiError::AuthorizationFailed` — missing `issues: write`
1173    pub async fn create(
1174        &self,
1175        owner: &str,
1176        repo: &str,
1177        request: CreateLabelRequest,
1178    ) -> Result<Label, ApiError> {
1179        let path = format!("/repos/{}/{}/labels", owner, repo);
1180        let response = self.client.post(&path, &request).await?;
1181        let status = response.status();
1182        if !status.is_success() {
1183            return Err(super::map_http_error(status, response).await);
1184        }
1185        response.json().await.map_err(ApiError::from)
1186    }
1187
1188    /// Update an existing label definition.
1189    ///
1190    /// Renaming via `new_name` updates all existing issue/PR references.
1191    ///
1192    /// # Errors
1193    /// * `ApiError::NotFound` — label does not exist
1194    /// * `ApiError::AuthorizationFailed` — missing `issues: write`
1195    pub async fn update(
1196        &self,
1197        owner: &str,
1198        repo: &str,
1199        name: &str,
1200        request: UpdateLabelRequest,
1201    ) -> Result<Label, ApiError> {
1202        let path = format!(
1203            "/repos/{}/{}/labels/{}",
1204            owner,
1205            repo,
1206            urlencoding::encode(name)
1207        );
1208        let response = self.client.patch(&path, &request).await?;
1209        let status = response.status();
1210        if !status.is_success() {
1211            return Err(super::map_http_error(status, response).await);
1212        }
1213        response.json().await.map_err(ApiError::from)
1214    }
1215
1216    /// Delete a label definition.
1217    ///
1218    /// Deleting a label removes it from all issues and PRs it was applied to.
1219    ///
1220    /// # Errors
1221    /// * `ApiError::NotFound` — label does not exist
1222    /// * `ApiError::AuthorizationFailed` — missing `issues: write`
1223    pub async fn delete(&self, owner: &str, repo: &str, name: &str) -> Result<(), ApiError> {
1224        let path = format!(
1225            "/repos/{}/{}/labels/{}",
1226            owner,
1227            repo,
1228            urlencoding::encode(name)
1229        );
1230        let response = self.client.delete(&path).await?;
1231        let status = response.status();
1232        if !status.is_success() {
1233            return Err(super::map_http_error(status, response).await);
1234        }
1235        Ok(())
1236    }
1237}
1238
1239// ============================================================================
1240// MilestonesClient
1241// ============================================================================
1242
1243/// Domain client for milestone lifecycle operations.
1244///
1245/// Obtained via [`InstallationClient::milestones()`].  Cheap to clone (Arc-backed).
1246///
1247/// Manages milestone *definitions*.  To assign a milestone to a specific issue
1248/// use [`IssuesClient::set_milestone`]; for a PR use `PullRequestsClient::set_milestone`.
1249///
1250/// See docs/specs/interfaces/milestones-client.md
1251#[derive(Debug, Clone)]
1252pub struct MilestonesClient {
1253    client: InstallationClient,
1254}
1255
1256impl MilestonesClient {
1257    pub(crate) fn new(client: InstallationClient) -> Self {
1258        Self { client }
1259    }
1260
1261    /// List all milestones in a repository (auto-paginated).
1262    ///
1263    /// # Errors
1264    /// * `ApiError::NotFound` — repository does not exist
1265    pub async fn list(
1266        &self,
1267        owner: &str,
1268        repo: &str,
1269        query: Option<ListMilestonesQuery>,
1270    ) -> Result<Vec<Milestone>, ApiError> {
1271        let base = format!("/repos/{}/{}/milestones", owner, repo);
1272        let mut params: Vec<String> = vec!["per_page=100".to_string()];
1273
1274        if let Some(q) = query {
1275            if let Some(state) = q.state {
1276                let s = match state {
1277                    MilestoneState::Open => "open",
1278                    MilestoneState::Closed => "closed",
1279                };
1280                params.push(format!("state={}", s));
1281            }
1282            if let Some(sort) = q.sort {
1283                let s = match sort {
1284                    MilestoneSortField::DueOn => "due_on",
1285                    MilestoneSortField::Completeness => "completeness",
1286                };
1287                params.push(format!("sort={}", s));
1288            }
1289            if let Some(dir) = q.direction {
1290                let d = match dir {
1291                    SortDirection::Asc => "asc",
1292                    SortDirection::Desc => "desc",
1293                };
1294                params.push(format!("direction={}", d));
1295            }
1296        }
1297
1298        let first_page = format!("{}?{}", base, params.join("&"));
1299        self.client.fetch_all_pages(&first_page).await
1300    }
1301
1302    /// Get a single milestone by its repository-scoped number.
1303    ///
1304    /// # Errors
1305    /// * `ApiError::NotFound` — milestone does not exist
1306    pub async fn get(
1307        &self,
1308        owner: &str,
1309        repo: &str,
1310        milestone_number: u64,
1311    ) -> Result<Milestone, ApiError> {
1312        let path = format!("/repos/{}/{}/milestones/{}", owner, repo, milestone_number);
1313        let response = self.client.get(&path).await?;
1314        let status = response.status();
1315        if !status.is_success() {
1316            return Err(super::map_http_error(status, response).await);
1317        }
1318        response.json().await.map_err(ApiError::from)
1319    }
1320
1321    /// Create a new milestone.
1322    ///
1323    /// # Errors
1324    /// * `ApiError::InvalidRequest` — title is empty
1325    /// * `ApiError::AuthorizationFailed` — missing `issues: write`
1326    pub async fn create(
1327        &self,
1328        owner: &str,
1329        repo: &str,
1330        request: CreateMilestoneRequest,
1331    ) -> Result<Milestone, ApiError> {
1332        let path = format!("/repos/{}/{}/milestones", owner, repo);
1333        let response = self.client.post(&path, &request).await?;
1334        let status = response.status();
1335        if !status.is_success() {
1336            return Err(super::map_http_error(status, response).await);
1337        }
1338        response.json().await.map_err(ApiError::from)
1339    }
1340
1341    /// Update an existing milestone.
1342    ///
1343    /// # Errors
1344    /// * `ApiError::NotFound` — milestone does not exist
1345    /// * `ApiError::AuthorizationFailed` — missing `issues: write`
1346    pub async fn update(
1347        &self,
1348        owner: &str,
1349        repo: &str,
1350        milestone_number: u64,
1351        request: UpdateMilestoneRequest,
1352    ) -> Result<Milestone, ApiError> {
1353        let path = format!("/repos/{}/{}/milestones/{}", owner, repo, milestone_number);
1354        let response = self.client.patch(&path, &request).await?;
1355        let status = response.status();
1356        if !status.is_success() {
1357            return Err(super::map_http_error(status, response).await);
1358        }
1359        response.json().await.map_err(ApiError::from)
1360    }
1361
1362    /// Delete a milestone.
1363    ///
1364    /// Issues assigned to the deleted milestone are unlinked but otherwise unaffected.
1365    ///
1366    /// # Errors
1367    /// * `ApiError::NotFound` — milestone does not exist
1368    /// * `ApiError::AuthorizationFailed` — missing `issues: write`
1369    pub async fn delete(
1370        &self,
1371        owner: &str,
1372        repo: &str,
1373        milestone_number: u64,
1374    ) -> Result<(), ApiError> {
1375        let path = format!("/repos/{}/{}/milestones/{}", owner, repo, milestone_number);
1376        let response = self.client.delete(&path).await?;
1377        let status = response.status();
1378        if !status.is_success() {
1379            return Err(super::map_http_error(status, response).await);
1380        }
1381        Ok(())
1382    }
1383}
1384
1385#[cfg(test)]
1386#[path = "issue_tests.rs"]
1387mod tests;