1use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::client::InstallationClient;
8use crate::error::ApiError;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Issue {
17 pub id: u64,
19
20 pub node_id: String,
22
23 pub number: u64,
25
26 pub title: String,
28
29 pub body: Option<String>,
31
32 pub state: String, pub user: IssueUser,
37
38 pub assignees: Vec<IssueUser>,
40
41 pub labels: Vec<Label>,
43
44 pub milestone: Option<Milestone>,
46
47 pub comments: u64,
49
50 pub created_at: DateTime<Utc>,
52
53 pub updated_at: DateTime<Utc>,
55
56 pub closed_at: Option<DateTime<Utc>>,
58
59 pub html_url: String,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct IssueUser {
66 pub login: String,
68
69 pub id: u64,
71
72 pub node_id: String,
74
75 #[serde(rename = "type")]
77 pub user_type: String,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct Milestone {
83 pub id: u64,
85
86 pub node_id: String,
88
89 pub number: u64,
91
92 pub title: String,
94
95 pub description: Option<String>,
97
98 pub state: String, pub open_issues: u64,
103
104 pub closed_issues: u64,
106
107 pub due_on: Option<DateTime<Utc>>,
109
110 pub created_at: DateTime<Utc>,
112
113 pub updated_at: DateTime<Utc>,
115
116 pub closed_at: Option<DateTime<Utc>>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct Label {
125 pub id: u64,
127
128 pub node_id: String,
130
131 pub name: String,
133
134 pub description: Option<String>,
136
137 pub color: String,
139
140 pub default: bool,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct Comment {
147 pub id: u64,
149
150 pub node_id: String,
152
153 pub body: String,
155
156 pub user: IssueUser,
158
159 pub created_at: DateTime<Utc>,
161
162 pub updated_at: DateTime<Utc>,
164
165 pub html_url: String,
167}
168
169#[derive(Debug, Clone, Serialize)]
171pub struct CreateIssueRequest {
172 pub title: String,
174
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub body: Option<String>,
178
179 #[serde(skip_serializing_if = "Option::is_none")]
181 pub assignees: Option<Vec<String>>,
182
183 #[serde(skip_serializing_if = "Option::is_none")]
185 pub milestone: Option<u64>,
186
187 #[serde(skip_serializing_if = "Option::is_none")]
189 pub labels: Option<Vec<String>>,
190}
191
192#[derive(Debug, Clone, Serialize, Default)]
194pub struct UpdateIssueRequest {
195 #[serde(skip_serializing_if = "Option::is_none")]
197 pub title: Option<String>,
198
199 #[serde(skip_serializing_if = "Option::is_none")]
201 pub body: Option<String>,
202
203 #[serde(skip_serializing_if = "Option::is_none")]
205 pub state: Option<String>, #[serde(skip_serializing_if = "Option::is_none")]
209 pub assignees: Option<Vec<String>>,
210
211 #[serde(skip_serializing_if = "Option::is_none")]
213 pub milestone: Option<u64>,
214
215 #[serde(skip_serializing_if = "Option::is_none")]
217 pub labels: Option<Vec<String>>,
218}
219
220#[derive(Debug, Clone, Serialize)]
222pub struct CreateLabelRequest {
223 pub name: String,
225
226 pub color: String,
228
229 #[serde(skip_serializing_if = "Option::is_none")]
231 pub description: Option<String>,
232}
233
234#[derive(Debug, Clone, Serialize, Default)]
236pub struct UpdateLabelRequest {
237 #[serde(skip_serializing_if = "Option::is_none")]
239 pub new_name: Option<String>,
240
241 #[serde(skip_serializing_if = "Option::is_none")]
243 pub color: Option<String>,
244
245 #[serde(skip_serializing_if = "Option::is_none")]
247 pub description: Option<String>,
248}
249
250#[derive(Debug, Clone, Serialize)]
252pub struct CreateCommentRequest {
253 pub body: String,
255}
256
257#[derive(Debug, Clone, Serialize)]
259pub struct UpdateCommentRequest {
260 pub body: String,
262}
263
264#[derive(Debug, Clone, Serialize)]
266pub struct SetIssueMilestoneRequest {
267 pub milestone: Option<u64>,
269}
270
271impl InstallationClient {
272 pub async fn list_issues(
308 &self,
309 owner: &str,
310 repo: &str,
311 state: Option<&str>,
312 page: Option<u32>,
313 ) -> Result<crate::client::PagedResponse<Issue>, ApiError> {
314 let mut path = format!("/repos/{}/{}/issues", owner, repo);
315 let mut query_params = Vec::new();
316
317 if let Some(state_value) = state {
318 query_params.push(format!("state={}", state_value));
319 }
320 if let Some(page_num) = page {
321 query_params.push(format!("page={}", page_num));
322 }
323
324 if !query_params.is_empty() {
325 path = format!("{}?{}", path, query_params.join("&"));
326 }
327
328 let response = self.get(&path).await?;
329 let status = response.status();
330
331 if !status.is_success() {
332 return Err(match status.as_u16() {
333 404 => ApiError::NotFound,
334 403 => ApiError::AuthorizationFailed,
335 401 => ApiError::AuthenticationFailed,
336 _ => {
337 let message = response
338 .text()
339 .await
340 .unwrap_or_else(|_| "Unknown error".to_string());
341 ApiError::HttpError {
342 status: status.as_u16(),
343 message,
344 }
345 }
346 });
347 }
348
349 let pagination = response
351 .headers()
352 .get("Link")
353 .and_then(|h| h.to_str().ok())
354 .map(|h| crate::client::parse_link_header(Some(h)))
355 .unwrap_or_default();
356
357 let items: Vec<Issue> = response.json().await.map_err(ApiError::from)?;
359
360 Ok(crate::client::PagedResponse {
361 items,
362 total_count: None, pagination,
364 })
365 }
366
367 pub async fn get_issue(
369 &self,
370 owner: &str,
371 repo: &str,
372 issue_number: u64,
373 ) -> Result<Issue, ApiError> {
374 let path = format!("/repos/{}/{}/issues/{}", owner, repo, issue_number);
375 let response = self.get(&path).await?;
376
377 let status = response.status();
378 if !status.is_success() {
379 return Err(match status.as_u16() {
380 404 => ApiError::NotFound,
381 403 => ApiError::AuthorizationFailed,
382 401 => ApiError::AuthenticationFailed,
383 _ => {
384 let message = response
385 .text()
386 .await
387 .unwrap_or_else(|_| "Unknown error".to_string());
388 ApiError::HttpError {
389 status: status.as_u16(),
390 message,
391 }
392 }
393 });
394 }
395 response.json().await.map_err(ApiError::from)
396 }
397
398 pub async fn create_issue(
400 &self,
401 owner: &str,
402 repo: &str,
403 request: CreateIssueRequest,
404 ) -> Result<Issue, ApiError> {
405 let path = format!("/repos/{}/{}/issues", owner, repo);
406 let response = self.post(&path, &request).await?;
407
408 let status = response.status();
409 if !status.is_success() {
410 return Err(match status.as_u16() {
411 422 => {
412 let message = response
413 .text()
414 .await
415 .unwrap_or_else(|_| "Validation failed".to_string());
416 ApiError::InvalidRequest { message }
417 }
418 404 => ApiError::NotFound,
419 403 => ApiError::AuthorizationFailed,
420 401 => ApiError::AuthenticationFailed,
421 _ => {
422 let message = response
423 .text()
424 .await
425 .unwrap_or_else(|_| "Unknown error".to_string());
426 ApiError::HttpError {
427 status: status.as_u16(),
428 message,
429 }
430 }
431 });
432 }
433 response.json().await.map_err(ApiError::from)
434 }
435
436 pub async fn update_issue(
438 &self,
439 owner: &str,
440 repo: &str,
441 issue_number: u64,
442 request: UpdateIssueRequest,
443 ) -> Result<Issue, ApiError> {
444 let path = format!("/repos/{}/{}/issues/{}", owner, repo, issue_number);
445 let response = self.patch(&path, &request).await?;
446
447 let status = response.status();
448 if !status.is_success() {
449 return Err(match status.as_u16() {
450 422 => {
451 let message = response
452 .text()
453 .await
454 .unwrap_or_else(|_| "Validation failed".to_string());
455 ApiError::InvalidRequest { message }
456 }
457 404 => ApiError::NotFound,
458 403 => ApiError::AuthorizationFailed,
459 401 => ApiError::AuthenticationFailed,
460 _ => {
461 let message = response
462 .text()
463 .await
464 .unwrap_or_else(|_| "Unknown error".to_string());
465 ApiError::HttpError {
466 status: status.as_u16(),
467 message,
468 }
469 }
470 });
471 }
472 response.json().await.map_err(ApiError::from)
473 }
474
475 pub async fn set_issue_milestone(
477 &self,
478 owner: &str,
479 repo: &str,
480 issue_number: u64,
481 milestone_number: Option<u64>,
482 ) -> Result<Issue, ApiError> {
483 let request = UpdateIssueRequest {
484 milestone: milestone_number,
485 ..Default::default()
486 };
487 self.update_issue(owner, repo, issue_number, request).await
488 }
489
490 pub async fn list_labels(&self, owner: &str, repo: &str) -> Result<Vec<Label>, ApiError> {
498 let path = format!("/repos/{}/{}/labels", owner, repo);
499 let response = self.get(&path).await?;
500
501 let status = response.status();
502 if !status.is_success() {
503 return Err(match status.as_u16() {
504 404 => ApiError::NotFound,
505 403 => ApiError::AuthorizationFailed,
506 401 => ApiError::AuthenticationFailed,
507 _ => {
508 let message = response
509 .text()
510 .await
511 .unwrap_or_else(|_| "Unknown error".to_string());
512 ApiError::HttpError {
513 status: status.as_u16(),
514 message,
515 }
516 }
517 });
518 }
519 response.json().await.map_err(ApiError::from)
520 }
521
522 pub async fn get_label(&self, owner: &str, repo: &str, name: &str) -> Result<Label, ApiError> {
526 let path = format!("/repos/{}/{}/labels/{}", owner, repo, name);
527 let response = self.get(&path).await?;
528
529 let status = response.status();
530 if !status.is_success() {
531 return Err(match status.as_u16() {
532 404 => ApiError::NotFound,
533 403 => ApiError::AuthorizationFailed,
534 401 => ApiError::AuthenticationFailed,
535 _ => {
536 let message = response
537 .text()
538 .await
539 .unwrap_or_else(|_| "Unknown error".to_string());
540 ApiError::HttpError {
541 status: status.as_u16(),
542 message,
543 }
544 }
545 });
546 }
547 response.json().await.map_err(ApiError::from)
548 }
549
550 pub async fn create_label(
554 &self,
555 owner: &str,
556 repo: &str,
557 request: CreateLabelRequest,
558 ) -> Result<Label, ApiError> {
559 let path = format!("/repos/{}/{}/labels", owner, repo);
560 let response = self.post(&path, &request).await?;
561
562 let status = response.status();
563 if !status.is_success() {
564 return Err(match status.as_u16() {
565 422 => {
566 let message = response
567 .text()
568 .await
569 .unwrap_or_else(|_| "Validation failed".to_string());
570 ApiError::InvalidRequest { message }
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 pub async fn update_label(
594 &self,
595 owner: &str,
596 repo: &str,
597 name: &str,
598 request: UpdateLabelRequest,
599 ) -> Result<Label, ApiError> {
600 let path = format!("/repos/{}/{}/labels/{}", owner, repo, name);
601 let response = self.patch(&path, &request).await?;
602
603 let status = response.status();
604 if !status.is_success() {
605 return Err(match status.as_u16() {
606 422 => {
607 let message = response
608 .text()
609 .await
610 .unwrap_or_else(|_| "Validation failed".to_string());
611 ApiError::InvalidRequest { message }
612 }
613 404 => ApiError::NotFound,
614 403 => ApiError::AuthorizationFailed,
615 401 => ApiError::AuthenticationFailed,
616 _ => {
617 let message = response
618 .text()
619 .await
620 .unwrap_or_else(|_| "Unknown error".to_string());
621 ApiError::HttpError {
622 status: status.as_u16(),
623 message,
624 }
625 }
626 });
627 }
628 response.json().await.map_err(ApiError::from)
629 }
630
631 pub async fn delete_label(&self, owner: &str, repo: &str, name: &str) -> Result<(), ApiError> {
635 let path = format!("/repos/{}/{}/labels/{}", owner, repo, name);
636 let response = self.delete(&path).await?;
637
638 let status = response.status();
639 if !status.is_success() {
640 return Err(match status.as_u16() {
641 404 => ApiError::NotFound,
642 403 => ApiError::AuthorizationFailed,
643 401 => ApiError::AuthenticationFailed,
644 _ => {
645 let message = response
646 .text()
647 .await
648 .unwrap_or_else(|_| "Unknown error".to_string());
649 ApiError::HttpError {
650 status: status.as_u16(),
651 message,
652 }
653 }
654 });
655 }
656 Ok(())
657 }
658
659 pub async fn add_labels_to_issue(
663 &self,
664 owner: &str,
665 repo: &str,
666 issue_number: u64,
667 labels: Vec<String>,
668 ) -> Result<Vec<Label>, ApiError> {
669 let path = format!("/repos/{}/{}/issues/{}/labels", owner, repo, issue_number);
670 let response = self.post(&path, &labels).await?;
671
672 let status = response.status();
673 if !status.is_success() {
674 return Err(match status.as_u16() {
675 422 => {
676 let message = response
677 .text()
678 .await
679 .unwrap_or_else(|_| "Validation failed".to_string());
680 ApiError::InvalidRequest { message }
681 }
682 404 => ApiError::NotFound,
683 403 => ApiError::AuthorizationFailed,
684 401 => ApiError::AuthenticationFailed,
685 _ => {
686 let message = response
687 .text()
688 .await
689 .unwrap_or_else(|_| "Unknown error".to_string());
690 ApiError::HttpError {
691 status: status.as_u16(),
692 message,
693 }
694 }
695 });
696 }
697 response.json().await.map_err(ApiError::from)
698 }
699
700 pub async fn remove_label_from_issue(
704 &self,
705 owner: &str,
706 repo: &str,
707 issue_number: u64,
708 name: &str,
709 ) -> Result<Vec<Label>, ApiError> {
710 let path = format!(
711 "/repos/{}/{}/issues/{}/labels/{}",
712 owner, repo, issue_number, name
713 );
714 let response = self.delete(&path).await?;
715
716 let status = response.status();
717 if !status.is_success() {
718 return Err(match status.as_u16() {
719 404 => ApiError::NotFound,
720 403 => ApiError::AuthorizationFailed,
721 401 => ApiError::AuthenticationFailed,
722 _ => {
723 let message = response
724 .text()
725 .await
726 .unwrap_or_else(|_| "Unknown error".to_string());
727 ApiError::HttpError {
728 status: status.as_u16(),
729 message,
730 }
731 }
732 });
733 }
734 response.json().await.map_err(ApiError::from)
735 }
736
737 pub async fn list_issue_comments(
745 &self,
746 owner: &str,
747 repo: &str,
748 issue_number: u64,
749 ) -> Result<Vec<Comment>, ApiError> {
750 let path = format!("/repos/{}/{}/issues/{}/comments", owner, repo, issue_number);
751 let response = self.get(&path).await?;
752
753 let status = response.status();
754 if !status.is_success() {
755 return Err(match status.as_u16() {
756 404 => ApiError::NotFound,
757 403 => ApiError::AuthorizationFailed,
758 401 => ApiError::AuthenticationFailed,
759 _ => {
760 let message = response
761 .text()
762 .await
763 .unwrap_or_else(|_| "Unknown error".to_string());
764 ApiError::HttpError {
765 status: status.as_u16(),
766 message,
767 }
768 }
769 });
770 }
771 response.json().await.map_err(ApiError::from)
772 }
773
774 pub async fn get_issue_comment(
778 &self,
779 owner: &str,
780 repo: &str,
781 comment_id: u64,
782 ) -> Result<Comment, ApiError> {
783 let path = format!("/repos/{}/{}/issues/comments/{}", owner, repo, comment_id);
784 let response = self.get(&path).await?;
785
786 let status = response.status();
787 if !status.is_success() {
788 return Err(match status.as_u16() {
789 404 => ApiError::NotFound,
790 403 => ApiError::AuthorizationFailed,
791 401 => ApiError::AuthenticationFailed,
792 _ => {
793 let message = response
794 .text()
795 .await
796 .unwrap_or_else(|_| "Unknown error".to_string());
797 ApiError::HttpError {
798 status: status.as_u16(),
799 message,
800 }
801 }
802 });
803 }
804 response.json().await.map_err(ApiError::from)
805 }
806
807 pub async fn create_issue_comment(
811 &self,
812 owner: &str,
813 repo: &str,
814 issue_number: u64,
815 request: CreateCommentRequest,
816 ) -> Result<Comment, ApiError> {
817 let path = format!("/repos/{}/{}/issues/{}/comments", owner, repo, issue_number);
818 let response = self.post(&path, &request).await?;
819
820 let status = response.status();
821 if !status.is_success() {
822 return Err(match status.as_u16() {
823 422 => {
824 let message = response
825 .text()
826 .await
827 .unwrap_or_else(|_| "Validation failed".to_string());
828 ApiError::InvalidRequest { message }
829 }
830 404 => ApiError::NotFound,
831 403 => ApiError::AuthorizationFailed,
832 401 => ApiError::AuthenticationFailed,
833 _ => {
834 let message = response
835 .text()
836 .await
837 .unwrap_or_else(|_| "Unknown error".to_string());
838 ApiError::HttpError {
839 status: status.as_u16(),
840 message,
841 }
842 }
843 });
844 }
845 response.json().await.map_err(ApiError::from)
846 }
847
848 pub async fn update_issue_comment(
852 &self,
853 owner: &str,
854 repo: &str,
855 comment_id: u64,
856 request: UpdateCommentRequest,
857 ) -> Result<Comment, ApiError> {
858 let path = format!("/repos/{}/{}/issues/comments/{}", owner, repo, comment_id);
859 let response = self.patch(&path, &request).await?;
860
861 let status = response.status();
862 if !status.is_success() {
863 return Err(match status.as_u16() {
864 422 => {
865 let message = response
866 .text()
867 .await
868 .unwrap_or_else(|_| "Validation failed".to_string());
869 ApiError::InvalidRequest { message }
870 }
871 404 => ApiError::NotFound,
872 403 => ApiError::AuthorizationFailed,
873 401 => ApiError::AuthenticationFailed,
874 _ => {
875 let message = response
876 .text()
877 .await
878 .unwrap_or_else(|_| "Unknown error".to_string());
879 ApiError::HttpError {
880 status: status.as_u16(),
881 message,
882 }
883 }
884 });
885 }
886 response.json().await.map_err(ApiError::from)
887 }
888
889 pub async fn delete_issue_comment(
893 &self,
894 owner: &str,
895 repo: &str,
896 comment_id: u64,
897 ) -> Result<(), ApiError> {
898 let path = format!("/repos/{}/{}/issues/comments/{}", owner, repo, comment_id);
899 let response = self.delete(&path).await?;
900
901 let status = response.status();
902 if !status.is_success() {
903 return Err(match status.as_u16() {
904 404 => ApiError::NotFound,
905 403 => ApiError::AuthorizationFailed,
906 401 => ApiError::AuthenticationFailed,
907 _ => {
908 let message = response
909 .text()
910 .await
911 .unwrap_or_else(|_| "Unknown error".to_string());
912 ApiError::HttpError {
913 status: status.as_u16(),
914 message,
915 }
916 }
917 });
918 }
919 Ok(())
920 }
921}
922
923#[cfg(test)]
924#[path = "issue_tests.rs"]
925mod tests;