Skip to main content

gitee_cli_rs/
models.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3use std::fmt;
4
5macro_rules! state_enum {
6    ($name:ident { $($variant:ident => $s:literal),+ $(,)? }) => {
7        /// Gitee lifecycle state. Unknown values deserialize to `Unknown`
8        /// (forward-compatible); serialization always emits the API string,
9        /// so `--json` output is unchanged for known states.
10        #[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" });
51// Issue lifecycle. Writable on Gitee v5 PATCH: open | progressing | closed only.
52// `Rejected` is kept for deserialize/forward-compat if a host ever returns it;
53// writing `rejected` is rejected by the live API (HTTP 400). Closed issues
54// surface Chinese board state 已完成 — not 拒绝; use labels for wontfix intent.
55state_enum!(IssueState {
56    Open => "open",
57    Progressing => "progressing",
58    Closed => "closed",
59    Rejected => "rejected",
60});
61
62/// Gitee merge methods for PR merge (form field `merge_method`).
63#[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/// CLI `--type` for `pr comment list`. Ops maps to Gitee `comment_type`
82/// (`diff_comment` | `pr_comment`).
83#[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    /// Gitee `comment_type` query value.
99    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/// PR reviewer/tester membership (`UserAssignee` in Gitee v5 swagger).
120/// Extends the basic user shape with optional `accept` (审查通过).
121#[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    /// Whether the reviewer has accepted (审查通过). Absent on plain user payloads.
132    #[serde(default)]
133    pub accept: Option<bool>,
134}
135
136/// Organization from GET /user/orgs (swagger `Group`).
137/// List payload has `login` and `description` only — not `name` or membership `role`.
138#[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/// Gitee milestone. `number` is the id the v5 mutation endpoints take
240/// (`milestone_number`); `title` is what users type, so CLI flags accept
241/// either and resolve via `Milestone::resolve`.
242#[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    /// Resolve an `--milestone` flag value: a bare integer is used as-is,
264    /// otherwise match by exact title. `None` means "no such milestone".
265    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/// Minimal repo reference embedded in PR `head`/`base` (includes clone URLs on live API).
286#[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    /// Best URL for fetching this repo's refs: SSH, then HTTPS clone, then web URL.
300    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/// Gitee returns PR `head`/`base` as objects `{ ref, label, sha, repo, user }`,
310/// not plain strings (the swagger model is wrong on this point).
311#[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/// Gitee nests the unified diff under `patch.diff` (not a plain string).
324#[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/// One changed file from GET /repos/{owner}/{repo}/pulls/{number}/files.
346///
347/// `path` is the canonical JSON field name (matches `gh pr view --json files`).
348/// Gitee's v5 API returns `filename`, so deserialization also accepts that
349/// alias for forward-compatibility with the live response shape.
350#[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    /// Total changed lines (additions + deletions). Gitee doesn't always
369    /// return it, hence `Option`; populated when present.
370    #[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    /// Set by `pr view` via GET .../pulls/{n}/merge (not from pull payload).
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub merged: Option<bool>,
419    /// Per-file diff list. Not returned by `GET /repos/.../pulls/{n}` —
420    /// populated by `pr view` via the separate `/pulls/{n}/files` endpoint.
421    #[serde(default)]
422    pub files: Option<Vec<FileDiff>>,
423}
424
425/// Minimal repo reference embedded in user-level issues (`GET /user/issues`).
426#[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    /// Present on user-level lists (`GET /user/issues`); absent on repo lists.
463    #[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/// PR comment (`PullRequestComments` in Gitee v5 swagger). Richer than issue
482/// `Comment`/`Note`: carries optional positional fields and `comment_type`
483/// (`diff_comment` | `pr_comment`). `position` / `new_line` are strings in the
484/// swagger response even though create takes an int position.
485#[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    /// `diff_comment` or `pr_comment`.
508    #[serde(default)]
509    pub comment_type: Option<String>,
510}
511
512/// Nested git commit payload on PR commits (`GitCommit` in Gitee v5 swagger).
513#[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/// Author/committer on a git commit (`GitUser` in Gitee v5 swagger).
524#[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/// Commit in a pull request (`PullRequestCommits` in Gitee v5 swagger). The
535/// swagger may describe `commit` as a string; live responses use a nested object.
536#[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    /// Short SHA for human output (git-log style).
552    pub fn short_sha(&self) -> &str {
553        let n = self.sha.len().min(7);
554        &self.sha[..n]
555    }
556
557    /// First line of the commit message.
558    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    /// Author login preferred; falls back to git author name.
567    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    /// Commit date from nested git author (for `--json` / `--jq`).
584    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    /// Set by `repo view` via GET /user/starred/... (not from repo payload).
633    #[serde(default, skip_serializing_if = "Option::is_none")]
634    pub starred: Option<bool>,
635    /// Set by `repo view` via GET /user/subscriptions/... (not from repo payload).
636    #[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    /// Clone URL policy: SSH when `ssh` is set, HTTPS otherwise; falls back to
650    /// the web URL when the preferred URL is missing.
651    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}