1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3use std::fmt;
4
5macro_rules! state_enum {
6 ($name:ident { $($variant:ident => $s:literal),+ $(,)? }) => {
7 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
11 pub enum $name {
12 #[default]
13 $($variant),+,
14 Unknown,
15 }
16
17 impl $name {
18 pub fn as_str(&self) -> &'static str {
19 match self {
20 $($name::$variant => $s),+,
21 $name::Unknown => "unknown",
22 }
23 }
24 }
25
26 impl fmt::Display for $name {
27 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28 f.write_str(self.as_str())
29 }
30 }
31
32 impl Serialize for $name {
33 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
34 s.serialize_str(self.as_str())
35 }
36 }
37
38 impl<'de> Deserialize<'de> for $name {
39 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
40 let raw = String::deserialize(d)?;
41 Ok(match raw.to_lowercase().as_str() {
42 $($s => $name::$variant),+,
43 _ => $name::Unknown,
44 })
45 }
46 }
47 };
48}
49
50state_enum!(PrState { Open => "open", Closed => "closed", Merged => "merged" });
51state_enum!(IssueState {
56 Open => "open",
57 Progressing => "progressing",
58 Closed => "closed",
59 Rejected => "rejected",
60});
61
62#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
64pub enum MergeMethod {
65 #[default]
66 Merge,
67 Squash,
68 Rebase,
69}
70
71impl MergeMethod {
72 pub fn as_str(&self) -> &'static str {
73 match self {
74 MergeMethod::Merge => "merge",
75 MergeMethod::Squash => "squash",
76 MergeMethod::Rebase => "rebase",
77 }
78 }
79}
80
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84pub enum PrCommentKind {
85 Diff,
86 General,
87}
88
89impl PrCommentKind {
90 pub fn from_cli(s: &str) -> Option<Self> {
91 match s {
92 "diff" => Some(Self::Diff),
93 "general" => Some(Self::General),
94 _ => None,
95 }
96 }
97
98 pub fn as_api_str(self) -> &'static str {
100 match self {
101 Self::Diff => "diff_comment",
102 Self::General => "pr_comment",
103 }
104 }
105}
106
107#[derive(Deserialize, Serialize, Clone, Debug, Default)]
108pub struct UserBasic {
109 #[serde(default)]
110 pub id: i64,
111 #[serde(default)]
112 pub login: String,
113 #[serde(default)]
114 pub name: Option<String>,
115 #[serde(default)]
116 pub html_url: Option<String>,
117}
118
119#[derive(Deserialize, Serialize, Clone, Debug, Default)]
122pub struct UserAssignee {
123 #[serde(default)]
124 pub id: i64,
125 #[serde(default)]
126 pub login: String,
127 #[serde(default)]
128 pub name: Option<String>,
129 #[serde(default)]
130 pub html_url: Option<String>,
131 #[serde(default)]
133 pub accept: Option<bool>,
134}
135
136#[derive(Deserialize, Serialize, Clone, Debug, Default)]
139pub struct Org {
140 #[serde(default)]
141 pub id: i64,
142 #[serde(default)]
143 pub login: String,
144 #[serde(default)]
145 pub description: Option<String>,
146}
147
148#[derive(Deserialize, Serialize, Clone, Debug, Default)]
149pub struct SshKey {
150 #[serde(default)]
151 pub id: i64,
152 #[serde(default)]
153 pub key: String,
154 #[serde(default)]
155 pub title: Option<String>,
156 #[serde(default)]
157 pub created_at: Option<String>,
158}
159
160#[derive(Deserialize, Serialize, Clone, Debug, Default)]
161pub struct CollaboratorPermissions {
162 #[serde(default)]
163 pub pull: Option<bool>,
164 #[serde(default)]
165 pub push: Option<bool>,
166 #[serde(default)]
167 pub admin: Option<bool>,
168}
169
170#[derive(Deserialize, Serialize, Clone, Debug, Default)]
171pub struct Collaborator {
172 #[serde(default)]
173 pub id: i64,
174 #[serde(default)]
175 pub login: String,
176 #[serde(default)]
177 pub name: Option<String>,
178 #[serde(default)]
179 pub permissions: Option<CollaboratorPermissions>,
180}
181
182#[derive(Deserialize, Serialize, Clone, Debug, Default)]
183pub struct Webhook {
184 #[serde(default)]
185 pub id: i64,
186 #[serde(default)]
187 pub url: Option<String>,
188 #[serde(default, skip_serializing)]
189 pub password: Option<String>,
190 #[serde(default)]
191 pub result_code: Option<i64>,
192 #[serde(default)]
193 pub result_msg: Option<String>,
194 #[serde(default)]
195 pub push_events: Option<bool>,
196 #[serde(default)]
197 pub tag_push_events: Option<bool>,
198 #[serde(default)]
199 pub issues_events: Option<bool>,
200 #[serde(default)]
201 pub merge_requests_events: Option<bool>,
202 #[serde(default)]
203 pub note_events: Option<bool>,
204}
205
206
207#[derive(Deserialize, Serialize, Clone, Debug, Default)]
208pub struct Gist {
209 #[serde(default)]
210 pub id: String,
211 #[serde(default)]
212 pub description: Option<String>,
213 #[serde(default)]
214 pub public: Option<bool>,
215 #[serde(default)]
216 pub files: Option<BTreeMap<String, GistFile>>,
217 #[serde(default)]
218 pub html_url: Option<String>,
219 #[serde(default)]
220 pub created_at: Option<String>,
221 #[serde(default)]
222 pub updated_at: Option<String>,
223 #[serde(default)]
224 pub owner: Option<UserBasic>,
225}
226
227#[derive(Deserialize, Serialize, Clone, Debug, Default)]
228pub struct GistFile {
229 #[serde(default)]
230 pub content: Option<String>,
231 #[serde(default)]
232 pub raw_url: Option<String>,
233 #[serde(default)]
234 pub size: Option<i64>,
235 #[serde(default)]
236 pub truncated: Option<bool>,
237}
238
239#[derive(Deserialize, Serialize, Clone, Debug, Default)]
243pub struct Milestone {
244 #[serde(default)]
245 pub number: i64,
246 #[serde(default)]
247 pub title: String,
248 #[serde(default)]
249 pub state: Option<String>,
250 #[serde(default)]
251 pub description: Option<String>,
252 #[serde(default)]
253 pub html_url: Option<String>,
254 #[serde(default)]
255 pub due_on: Option<String>,
256 #[serde(default)]
257 pub open_issues: Option<i64>,
258 #[serde(default)]
259 pub closed_issues: Option<i64>,
260}
261
262impl Milestone {
263 pub fn resolve(list: &[Milestone], id_or_title: &str) -> Option<i64> {
266 if let Ok(n) = id_or_title.trim().parse::<i64>() {
267 return Some(n);
268 }
269 list.iter()
270 .find(|m| m.title == id_or_title)
271 .map(|m| m.number)
272 }
273}
274
275#[derive(Deserialize, Serialize, Clone, Debug, Default)]
276pub struct Label {
277 #[serde(default)]
278 pub id: i64,
279 #[serde(default)]
280 pub name: String,
281 #[serde(default)]
282 pub color: Option<String>,
283}
284
285#[derive(Deserialize, Serialize, Clone, Debug, Default)]
287pub struct PrRepoRef {
288 #[serde(default)]
289 pub full_name: Option<String>,
290 #[serde(default)]
291 pub html_url: Option<String>,
292 #[serde(default)]
293 pub ssh_url: Option<String>,
294 #[serde(default)]
295 pub clone_url: Option<String>,
296}
297
298impl PrRepoRef {
299 pub fn fetch_url(&self) -> Option<String> {
301 [&self.ssh_url, &self.clone_url, &self.html_url]
302 .into_iter()
303 .flatten()
304 .find(|u| !u.is_empty())
305 .cloned()
306 }
307}
308
309#[derive(Deserialize, Serialize, Clone, Debug, Default)]
312pub struct PrBranch {
313 #[serde(default, rename = "ref")]
314 pub git_ref: String,
315 #[serde(default)]
316 pub label: Option<String>,
317 #[serde(default)]
318 pub sha: Option<String>,
319 #[serde(default)]
320 pub repo: Option<PrRepoRef>,
321}
322
323#[derive(Deserialize, Clone, Debug, Default)]
325struct FilePatchBody {
326 #[serde(default)]
327 diff: Option<String>,
328}
329
330fn deserialize_patch<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
331where
332 D: serde::Deserializer<'de>,
333{
334 let value: Option<FilePatchBody> = Option::deserialize(deserializer)?;
335 Ok(value.and_then(|p| p.diff.filter(|d| !d.is_empty())))
336}
337
338fn serialize_patch<S>(patch: &Option<String>, serializer: S) -> Result<S::Ok, S::Error>
339where
340 S: serde::Serializer,
341{
342 patch.serialize(serializer)
343}
344
345#[derive(Deserialize, Serialize, Clone, Debug, Default)]
351pub struct FileDiff {
352 #[serde(default)]
353 pub sha: Option<String>,
354 #[serde(default, alias = "filename")]
355 pub path: String,
356 #[serde(default)]
357 pub status: Option<String>,
358 #[serde(
359 default,
360 deserialize_with = "deserialize_patch",
361 serialize_with = "serialize_patch"
362 )]
363 pub patch: Option<String>,
364 #[serde(default)]
365 pub additions: Option<String>,
366 #[serde(default)]
367 pub deletions: Option<String>,
368 #[serde(default)]
371 pub changes: Option<String>,
372 #[serde(default)]
373 pub raw_url: Option<String>,
374 #[serde(default)]
375 pub blob_url: Option<String>,
376}
377
378#[derive(Deserialize, Serialize, Clone, Debug, Default)]
379pub struct PullRequest {
380 #[serde(default)]
381 pub number: i64,
382 #[serde(default)]
383 pub title: String,
384 #[serde(default)]
385 pub state: PrState,
386 #[serde(default)]
387 pub body: Option<String>,
388 #[serde(default)]
389 pub html_url: String,
390 #[serde(default)]
391 pub head: PrBranch,
392 #[serde(default)]
393 pub base: PrBranch,
394 #[serde(default)]
395 pub user: Option<UserBasic>,
396 #[serde(default)]
397 pub draft: Option<bool>,
398 #[serde(default)]
399 pub labels: Option<Vec<Label>>,
400 #[serde(default)]
401 pub assignees: Option<Vec<UserAssignee>>,
402 #[serde(default)]
403 pub testers: Option<Vec<UserAssignee>>,
404 #[serde(default)]
405 pub milestone: Option<Milestone>,
406 #[serde(default)]
407 pub created_at: Option<String>,
408 #[serde(default)]
409 pub updated_at: Option<String>,
410 #[serde(default)]
411 pub merged_at: Option<String>,
412 #[serde(default)]
413 pub closed_at: Option<String>,
414 #[serde(default)]
415 pub mergeable: Option<bool>,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub merged: Option<bool>,
419 #[serde(default)]
422 pub files: Option<Vec<FileDiff>>,
423}
424
425#[derive(Deserialize, Serialize, Clone, Debug, Default)]
427pub struct IssueRepoRef {
428 #[serde(default)]
429 pub full_name: Option<String>,
430 #[serde(default)]
431 pub html_url: Option<String>,
432}
433
434#[derive(Deserialize, Serialize, Clone, Debug, Default)]
435pub struct Issue {
436 #[serde(default)]
437 pub number: String,
438 #[serde(default)]
439 pub title: String,
440 #[serde(default)]
441 pub state: IssueState,
442 #[serde(default)]
443 pub body: Option<String>,
444 #[serde(default)]
445 pub html_url: String,
446 #[serde(default)]
447 pub user: Option<UserBasic>,
448 #[serde(default)]
449 pub assignee: Option<UserBasic>,
450 #[serde(default)]
451 pub labels: Option<Vec<Label>>,
452 #[serde(default)]
453 pub security_hole: Option<bool>,
454 #[serde(default)]
455 pub milestone: Option<Milestone>,
456 #[serde(default)]
457 pub comments: Option<i64>,
458 #[serde(default)]
459 pub created_at: Option<String>,
460 #[serde(default)]
461 pub updated_at: Option<String>,
462 #[serde(default)]
464 pub repository: Option<IssueRepoRef>,
465}
466
467#[derive(Deserialize, Serialize, Clone, Debug, Default)]
468pub struct Comment {
469 #[serde(default)]
470 pub id: i64,
471 #[serde(default)]
472 pub body: String,
473 #[serde(default)]
474 pub html_url: Option<String>,
475 #[serde(default)]
476 pub user: Option<UserBasic>,
477 #[serde(default)]
478 pub created_at: Option<String>,
479}
480
481#[derive(Deserialize, Serialize, Clone, Debug, Default)]
486pub struct PrComment {
487 #[serde(default)]
488 pub id: i64,
489 #[serde(default)]
490 pub body: String,
491 #[serde(default)]
492 pub html_url: Option<String>,
493 #[serde(default)]
494 pub user: Option<UserBasic>,
495 #[serde(default)]
496 pub created_at: Option<String>,
497 #[serde(default)]
498 pub updated_at: Option<String>,
499 #[serde(default)]
500 pub path: Option<String>,
501 #[serde(default)]
502 pub position: Option<String>,
503 #[serde(default)]
504 pub new_line: Option<String>,
505 #[serde(default)]
506 pub commit_id: Option<String>,
507 #[serde(default)]
509 pub comment_type: Option<String>,
510}
511
512#[derive(Deserialize, Serialize, Clone, Debug, Default)]
514pub struct GitCommitInfo {
515 #[serde(default)]
516 pub message: Option<String>,
517 #[serde(default)]
518 pub author: Option<GitPerson>,
519 #[serde(default)]
520 pub committer: Option<GitPerson>,
521}
522
523#[derive(Deserialize, Serialize, Clone, Debug, Default)]
525pub struct GitPerson {
526 #[serde(default)]
527 pub name: Option<String>,
528 #[serde(default)]
529 pub email: Option<String>,
530 #[serde(default)]
531 pub date: Option<String>,
532}
533
534#[derive(Deserialize, Clone, Debug, Default)]
537pub struct PrCommit {
538 #[serde(default)]
539 pub sha: String,
540 #[serde(default)]
541 pub html_url: Option<String>,
542 #[serde(default)]
543 pub commit: Option<GitCommitInfo>,
544 #[serde(default)]
545 pub author: Option<UserBasic>,
546 #[serde(default)]
547 pub committer: Option<UserBasic>,
548}
549
550impl PrCommit {
551 pub fn short_sha(&self) -> &str {
553 let n = self.sha.len().min(7);
554 &self.sha[..n]
555 }
556
557 pub fn subject(&self) -> &str {
559 self.commit
560 .as_ref()
561 .and_then(|c| c.message.as_deref())
562 .map(|m| m.lines().next().unwrap_or(""))
563 .unwrap_or("")
564 }
565
566 pub fn author_label(&self) -> &str {
568 if let Some(login) = self
569 .author
570 .as_ref()
571 .map(|u| u.login.as_str())
572 .filter(|s| !s.is_empty())
573 {
574 return login;
575 }
576 self.commit
577 .as_ref()
578 .and_then(|c| c.author.as_ref())
579 .and_then(|a| a.name.as_deref())
580 .unwrap_or("?")
581 }
582
583 pub fn date(&self) -> Option<&str> {
585 self.commit
586 .as_ref()
587 .and_then(|c| c.author.as_ref())
588 .and_then(|a| a.date.as_deref())
589 }
590}
591
592impl Serialize for PrCommit {
593 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
594 where
595 S: serde::Serializer,
596 {
597 use serde::ser::SerializeStruct;
598 let mut s = serializer.serialize_struct("PrCommit", 5)?;
599 s.serialize_field("sha", &self.sha)?;
600 s.serialize_field("html_url", &self.html_url)?;
601 s.serialize_field("author", &self.author)?;
602 s.serialize_field("date", &self.date())?;
603 s.serialize_field("commit", &self.commit)?;
604 s.end()
605 }
606}
607
608#[derive(Deserialize, Serialize, Clone, Debug, Default)]
609pub struct RepoDetails {
610 #[serde(default)]
611 pub id: i64,
612 #[serde(default)]
613 pub name: String,
614 #[serde(default)]
615 pub full_name: String,
616 #[serde(default)]
617 pub human_name: String,
618 #[serde(default)]
619 pub description: Option<String>,
620 #[serde(default)]
621 pub html_url: String,
622 #[serde(default)]
623 pub ssh_url: Option<String>,
624 #[serde(default)]
625 pub clone_url: Option<String>,
626 #[serde(default)]
627 pub default_branch: Option<String>,
628 #[serde(default)]
629 pub private: Option<bool>,
630 #[serde(default)]
631 pub stargazers_count: Option<i64>,
632 #[serde(default, skip_serializing_if = "Option::is_none")]
634 pub starred: Option<bool>,
635 #[serde(default, skip_serializing_if = "Option::is_none")]
637 pub watching: Option<bool>,
638 #[serde(default)]
639 pub fork_count: Option<i64>,
640 #[serde(default)]
641 pub open_issues_count: Option<i64>,
642 #[serde(default)]
643 pub owner: Option<UserBasic>,
644 #[serde(default)]
645 pub parent: Option<Box<RepoDetails>>,
646}
647
648impl RepoDetails {
649 pub fn preferred_url(&self, ssh: bool) -> String {
652 let pick = if ssh { &self.ssh_url } else { &self.clone_url };
653 if let Some(u) = pick {
654 if !u.is_empty() {
655 return u.clone();
656 }
657 }
658 self.html_url.clone()
659 }
660}
661
662#[derive(Deserialize, Serialize, Clone, Debug, Default)]
663pub struct RepoInfo {
664 #[serde(default)]
665 pub default_branch: Option<String>,
666}
667
668#[derive(Deserialize, Serialize, Clone, Debug, Default)]
669pub struct ReleaseAsset {
670 #[serde(default)]
671 pub name: String,
672 #[serde(default)]
673 pub browser_download_url: String,
674}
675
676#[derive(Deserialize, Serialize, Clone, Debug, Default)]
677pub struct Release {
678 #[serde(default)]
679 pub id: i64,
680 #[serde(default)]
681 pub tag_name: String,
682 #[serde(default)]
683 pub target_commitish: Option<String>,
684 #[serde(default)]
685 pub name: Option<String>,
686 #[serde(default)]
687 pub body: Option<String>,
688 #[serde(default)]
689 pub prerelease: Option<bool>,
690 #[serde(default)]
691 pub draft: Option<bool>,
692 #[serde(default)]
693 pub created_at: Option<String>,
694 #[serde(default)]
695 pub author: Option<UserBasic>,
696 #[serde(default)]
697 pub assets: Option<Vec<ReleaseAsset>>,
698}
699
700#[cfg(test)]
701mod webhook_tests {
702 use super::Webhook;
703
704 #[test]
705 fn password_omitted_from_json_output() {
706 let hook = Webhook {
707 id: 1,
708 password: Some("s3cret".into()),
709 url: Some("https://example.com/hook".into()),
710 ..Default::default()
711 };
712 let value = serde_json::to_value(&hook).expect("serialize");
713 assert!(value.get("password").is_none());
714 }
715}
716
717#[cfg(test)]
718mod state_tests {
719 use super::{
720 Issue, IssueState, MergeMethod, Milestone, PrComment, PrCommentKind, PrCommit, PrState,
721 PullRequest,
722 };
723 use serde_json;
724
725 #[test]
726 fn pr_state_deserializes_known_values_case_insensitive() {
727 assert_eq!(
728 serde_json::from_str::<PrState>(r#""open""#).unwrap(),
729 PrState::Open
730 );
731 assert_eq!(
732 serde_json::from_str::<PrState>(r#""CLOSED""#).unwrap(),
733 PrState::Closed
734 );
735 assert_eq!(
736 serde_json::from_str::<PrState>(r#""Merged""#).unwrap(),
737 PrState::Merged
738 );
739 }
740
741 #[test]
742 fn pr_state_unknown_string_deserializes_to_unknown() {
743 assert_eq!(
744 serde_json::from_str::<PrState>(r#""draft""#).unwrap(),
745 PrState::Unknown
746 );
747 }
748
749 #[test]
750 fn pr_state_serializes_canonical_lowercase() {
751 assert_eq!(serde_json::to_string(&PrState::Open).unwrap(), r#""open""#);
752 assert_eq!(
753 serde_json::to_string(&PrState::Closed).unwrap(),
754 r#""closed""#
755 );
756 assert_eq!(
757 serde_json::to_string(&PrState::Merged).unwrap(),
758 r#""merged""#
759 );
760 }
761
762 #[test]
763 fn issue_state_deserializes_known_values_case_insensitive() {
764 assert_eq!(
765 serde_json::from_str::<IssueState>(r#""open""#).unwrap(),
766 IssueState::Open
767 );
768 assert_eq!(
769 serde_json::from_str::<IssueState>(r#""PROGRESSING""#).unwrap(),
770 IssueState::Progressing
771 );
772 assert_eq!(
773 serde_json::from_str::<IssueState>(r#""Closed""#).unwrap(),
774 IssueState::Closed
775 );
776 assert_eq!(
777 serde_json::from_str::<IssueState>(r#""rejected""#).unwrap(),
778 IssueState::Rejected
779 );
780 }
781
782 #[test]
783 fn issue_state_unknown_string_deserializes_to_unknown() {
784 assert_eq!(
785 serde_json::from_str::<IssueState>(r#""archived""#).unwrap(),
786 IssueState::Unknown
787 );
788 }
789
790 #[test]
791 fn issue_state_serializes_canonical_lowercase() {
792 assert_eq!(
793 serde_json::to_string(&IssueState::Open).unwrap(),
794 r#""open""#
795 );
796 assert_eq!(
797 serde_json::to_string(&IssueState::Progressing).unwrap(),
798 r#""progressing""#
799 );
800 assert_eq!(
801 serde_json::to_string(&IssueState::Closed).unwrap(),
802 r#""closed""#
803 );
804 assert_eq!(
805 serde_json::to_string(&IssueState::Rejected).unwrap(),
806 r#""rejected""#
807 );
808 }
809
810 #[test]
811 fn milestone_resolve_by_id_or_title() {
812 let list = vec![
813 Milestone {
814 number: 7,
815 title: "v1.0".into(),
816 ..Default::default()
817 },
818 Milestone {
819 number: 9,
820 title: "v2.0".into(),
821 ..Default::default()
822 },
823 ];
824 assert_eq!(Milestone::resolve(&list, "42"), Some(42));
825 assert_eq!(Milestone::resolve(&list, "v2.0"), Some(9));
826 assert_eq!(Milestone::resolve(&list, "nope"), None);
827 }
828
829 #[test]
830 fn pr_deserializes_assignees_testers_milestone() {
831 let pr: PullRequest = serde_json::from_str(
832 r#"{
833 "number": 3,
834 "assignees": [{"login": "dev1", "accept": true}],
835 "testers": [{"login": "qa1"}],
836 "milestone": {"number": 7, "title": "v1.0"}
837 }"#,
838 )
839 .expect("pr json");
840 let assignees = pr.assignees.expect("assignees");
841 assert_eq!(assignees[0].login, "dev1");
842 assert_eq!(assignees[0].accept, Some(true));
843 let testers = pr.testers.expect("testers");
844 assert_eq!(testers[0].login, "qa1");
845 assert_eq!(testers[0].accept, None);
846 let ms = pr.milestone.expect("milestone");
847 assert_eq!(ms.number, 7);
848 assert_eq!(ms.title, "v1.0");
849 }
850
851 #[test]
852 fn pr_comment_kind_maps_cli_to_api_comment_type() {
853 assert_eq!(PrCommentKind::from_cli("diff"), Some(PrCommentKind::Diff));
854 assert_eq!(
855 PrCommentKind::from_cli("general"),
856 Some(PrCommentKind::General)
857 );
858 assert_eq!(PrCommentKind::from_cli("other"), None);
859 assert_eq!(PrCommentKind::Diff.as_api_str(), "diff_comment");
860 assert_eq!(PrCommentKind::General.as_api_str(), "pr_comment");
861 }
862
863 #[test]
864 fn pr_comment_deserializes_string_position_and_type() {
865 let c: PrComment = serde_json::from_str(
866 r#"{
867 "id": 100,
868 "body": "line note",
869 "path": "src/main.rs",
870 "position": "42",
871 "new_line": "10",
872 "commit_id": "abc123",
873 "comment_type": "diff_comment",
874 "user": {"login": "dev2"},
875 "created_at": "2026-01-02T00:00:00+08:00"
876 }"#,
877 )
878 .expect("pr comment json");
879 assert_eq!(c.id, 100);
880 assert_eq!(c.position.as_deref(), Some("42"));
881 assert_eq!(c.new_line.as_deref(), Some("10"));
882 assert_eq!(c.comment_type.as_deref(), Some("diff_comment"));
883 assert_eq!(c.path.as_deref(), Some("src/main.rs"));
884 }
885
886 #[test]
887 fn pr_commit_deserializes_nested_commit_object() {
888 let c: PrCommit = serde_json::from_str(
889 r#"{
890 "sha": "abc1234567890deadbeef00000000000000000000",
891 "html_url": "https://gitee.com/x/commit/abc123",
892 "commit": {
893 "message": "Add feature\n\nDetails.",
894 "author": {
895 "name": "Dev One",
896 "date": "2026-01-01T10:00:00+08:00"
897 }
898 },
899 "author": {"login": "dev1"}
900 }"#,
901 )
902 .expect("pr commit json");
903 assert_eq!(c.short_sha(), "abc1234");
904 assert_eq!(c.subject(), "Add feature");
905 assert_eq!(c.author_label(), "dev1");
906 assert_eq!(c.date(), Some("2026-01-01T10:00:00+08:00"));
907 let json = serde_json::to_value(&c).expect("serialize pr commit");
908 assert_eq!(json["sha"], "abc1234567890deadbeef00000000000000000000");
909 assert_eq!(json["date"], "2026-01-01T10:00:00+08:00");
910 assert_eq!(json["author"]["login"], "dev1");
911 }
912
913 #[test]
914 fn issue_deserializes_security_hole_and_milestone() {
915 let issue: Issue = serde_json::from_str(
916 r#"{
917 "number": "I1AB",
918 "title": "Leak",
919 "security_hole": true,
920 "milestone": {"number": 3, "title": "v1.0"}
921 }"#,
922 )
923 .expect("issue json");
924 assert_eq!(issue.security_hole, Some(true));
925 assert_eq!(issue.milestone.expect("milestone").number, 3);
926 }
927
928 #[test]
929 fn merge_method_as_str_values() {
930 assert_eq!(MergeMethod::Merge.as_str(), "merge");
931 assert_eq!(MergeMethod::Squash.as_str(), "squash");
932 assert_eq!(MergeMethod::Rebase.as_str(), "rebase");
933 }
934}