Skip to main content

atomcode_core/atomgit/
models.rs

1//! JSON shapes returned by AtomGit's issue endpoints. Fields we don't use
2//! are omitted — serde silently ignores unknown keys.
3
4use serde::{Deserialize, Deserializer};
5
6/// AtomGit returns numeric IDs as JSON strings (e.g. `"number": "140"`).
7/// Accept both shapes so we don't brittle-fail on a server-side type change.
8pub(crate) fn deserialize_u64_from_string_or_int<'de, D>(deserializer: D) -> Result<u64, D::Error>
9where
10    D: Deserializer<'de>,
11{
12    #[derive(Deserialize)]
13    #[serde(untagged)]
14    enum StringOrInt {
15        Int(u64),
16        Str(String),
17    }
18    match StringOrInt::deserialize(deserializer)? {
19        StringOrInt::Int(n) => Ok(n),
20        StringOrInt::Str(s) => s
21            .parse::<u64>()
22            .map_err(|_| serde::de::Error::custom(format!("not a u64: {:?}", s))),
23    }
24}
25
26/// Shape returned by `GET /repos/{owner}/{repo}/labels` — the repo's
27/// label definitions. We only keep `id` (needed to attach to an issue)
28/// and `name` (for matching by user-visible name).
29#[derive(Debug, Deserialize, Clone)]
30pub struct RepoLabel {
31    #[serde(deserialize_with = "deserialize_u64_from_string_or_int")]
32    pub id: u64,
33    pub name: String,
34}
35
36#[derive(Debug, Deserialize, Clone)]
37pub struct User {
38    pub login: String,
39}
40
41#[derive(Debug, Deserialize, Clone)]
42pub struct Label {
43    pub name: String,
44}
45
46#[derive(Debug, Deserialize)]
47pub struct Issue {
48    #[serde(deserialize_with = "deserialize_u64_from_string_or_int")]
49    pub number: u64,
50    pub title: String,
51    #[serde(default)]
52    pub body: Option<String>,
53    pub state: String,
54    #[serde(default)]
55    pub html_url: Option<String>,
56    #[serde(default)]
57    pub user: Option<User>,
58    /// Primary assignee. Can be null if nobody is assigned.
59    #[serde(default)]
60    pub assignee: Option<User>,
61    /// Multi-assignee field (Gitea compat). Some deployments return just
62    /// `assignee`; others populate both — we check both.
63    #[serde(default)]
64    pub assignees: Vec<User>,
65    #[serde(default)]
66    pub labels: Vec<Label>,
67}
68
69impl Issue {
70    /// True if `username` is in either `assignee` or `assignees[]`.
71    pub fn is_assigned_to(&self, username: &str) -> bool {
72        if let Some(a) = &self.assignee {
73            if a.login == username {
74                return true;
75            }
76        }
77        self.assignees.iter().any(|a| a.login == username)
78    }
79
80    /// Human-readable list of all assignees. "(unassigned)" when empty.
81    pub fn assignee_list(&self) -> String {
82        let mut names: Vec<&str> = Vec::new();
83        if let Some(a) = &self.assignee {
84            names.push(&a.login);
85        }
86        for a in &self.assignees {
87            if !names.iter().any(|n| *n == a.login) {
88                names.push(&a.login);
89            }
90        }
91        if names.is_empty() {
92            "(unassigned)".to_string()
93        } else {
94            names.join(", ")
95        }
96    }
97}
98
99#[derive(Debug, Deserialize)]
100pub struct Comment {
101    #[serde(default)]
102    pub user: Option<User>,
103    #[serde(default)]
104    pub body: Option<String>,
105}
106
107/// Slimmed response shape for `POST /repos/{owner}/{repo}/issues`. We only
108/// use `number` (for logging) and `html_url` (to show the user where the
109/// new issue lives); everything else the API returns is discarded.
110#[derive(Debug, Deserialize)]
111pub struct CreatedIssue {
112    #[serde(deserialize_with = "deserialize_u64_from_string_or_int")]
113    pub number: u64,
114    #[serde(default)]
115    pub title: String,
116    #[serde(default)]
117    pub html_url: Option<String>,
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn parses_number_as_string() {
126        // AtomGit's current behavior.
127        let raw = r#"{"number":"140","title":"t","state":"open"}"#;
128        let issue: Issue = serde_json::from_str(raw).unwrap();
129        assert_eq!(issue.number, 140);
130    }
131
132    #[test]
133    fn parses_number_as_int() {
134        // Defensive: works even if AtomGit switches to numeric.
135        let raw = r#"{"number":42,"title":"t","state":"open"}"#;
136        let issue: Issue = serde_json::from_str(raw).unwrap();
137        assert_eq!(issue.number, 42);
138    }
139
140    #[test]
141    fn is_assigned_to_checks_both_fields() {
142        let raw = r#"{"number":1,"title":"t","state":"open","assignee":{"login":"alice"},"assignees":[{"login":"bob"}]}"#;
143        let issue: Issue = serde_json::from_str(raw).unwrap();
144        assert!(issue.is_assigned_to("alice"));
145        assert!(issue.is_assigned_to("bob"));
146        assert!(!issue.is_assigned_to("carol"));
147    }
148}