1use serde::{Deserialize, Serialize};
4
5use crate::model::PrState;
6
7#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
9pub struct IssueLabel {
10 #[serde(default)]
12 pub name: String,
13}
14
15#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
18pub struct IssueType {
19 #[serde(default)]
21 pub name: String,
22}
23
24#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
26pub struct IssueMilestone {
27 #[serde(default)]
29 pub title: String,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
34pub struct IssueSummary {
35 pub number: u64,
37 pub title: String,
39 pub state: String,
41 #[serde(default)]
43 pub labels: Vec<IssueLabel>,
44 #[serde(rename = "issueType", default)]
46 pub issue_type: Option<IssueType>,
47 #[serde(default)]
49 pub milestone: Option<IssueMilestone>,
50 #[serde(rename = "createdAt", default)]
52 pub created_at: String,
53 #[serde(default)]
55 pub url: String,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
64pub struct IssueView {
65 pub number: u64,
67 pub title: String,
69 #[serde(default)]
71 pub body: String,
72 pub state: String,
74 #[serde(default)]
76 pub labels: Vec<IssueLabel>,
77 #[serde(rename = "issueType", default)]
79 pub issue_type: Option<IssueType>,
80 #[serde(default)]
82 pub milestone: Option<IssueMilestone>,
83 #[serde(rename = "createdAt", default)]
85 pub created_at: String,
86 #[serde(rename = "updatedAt", default)]
88 pub updated_at: String,
89 #[serde(default)]
91 pub url: String,
92}
93
94#[derive(Debug, Clone, Default, Deserialize)]
96pub struct Author {
97 #[serde(default)]
99 pub login: String,
100}
101
102#[derive(Debug, Clone, Deserialize)]
104pub struct PrSummary {
105 pub number: u64,
107 pub title: String,
109 #[serde(default)]
111 pub author: Author,
112 pub state: String,
114 #[serde(rename = "isDraft", default)]
116 pub is_draft: bool,
117 #[serde(rename = "headRefName", default)]
119 pub head_ref_name: String,
120 #[serde(rename = "createdAt", default)]
122 pub created_at: String,
123}
124
125impl PrSummary {
126 pub fn pr_state(&self) -> PrState {
128 pr_state(&self.state, self.is_draft)
129 }
130}
131
132#[derive(Debug, Clone, Deserialize)]
134pub struct PrView {
135 pub number: u64,
137 pub title: String,
139 pub state: String,
141 #[serde(rename = "isDraft", default)]
143 pub is_draft: bool,
144 #[serde(rename = "headRefName")]
146 pub head_ref_name: String,
147 #[serde(rename = "baseRefName")]
149 pub base_ref_name: String,
150 #[serde(default)]
152 pub url: String,
153}
154
155impl PrView {
156 pub fn pr_state(&self) -> PrState {
158 pr_state(&self.state, self.is_draft)
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
168pub struct OpenPr {
169 pub number: u64,
171 #[serde(default)]
173 pub url: String,
174 pub state: String,
176 #[serde(rename = "isDraft", default)]
178 pub is_draft: bool,
179}
180
181pub(crate) fn parse_default_branch(json: &str) -> Option<String> {
185 #[derive(Deserialize)]
186 struct Ref {
187 name: String,
188 }
189 #[derive(Deserialize)]
190 struct View {
191 #[serde(rename = "defaultBranchRef")]
192 default_branch_ref: Option<Ref>,
193 }
194 let view: View = serde_json::from_str(json).ok()?;
195 view.default_branch_ref.map(|r| r.name)
196}
197
198pub fn pr_state(state: &str, is_draft: bool) -> PrState {
200 if is_draft && state.eq_ignore_ascii_case("open") {
201 return PrState::Draft;
202 }
203 match state.to_ascii_lowercase().as_str() {
204 "closed" => PrState::Closed,
205 "merged" => PrState::Merged,
206 _ => PrState::Open,
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn parses_issue_list_json() {
216 let json = r#"[
217 {"number": 12, "title": "Broken login", "state": "OPEN",
218 "labels": [{"name": "bug"}], "issueType": {"name": "Bug"},
219 "milestone": {"title": "v2"}, "createdAt": "2024-01-15T10:30:00Z",
220 "url": "https://github.com/o/r/issues/12"},
221 {"number": 13, "title": "Bare", "state": "OPEN"}
222 ]"#;
223 let issues: Vec<IssueSummary> = serde_json::from_str(json).unwrap();
224 assert_eq!(issues.len(), 2);
225 assert_eq!(issues[0].number, 12);
226 assert_eq!(issues[0].labels[0].name, "bug");
227 assert_eq!(issues[0].issue_type.as_ref().unwrap().name, "Bug");
228 assert_eq!(issues[0].milestone.as_ref().unwrap().title, "v2");
229 assert!(issues[1].labels.is_empty());
231 assert_eq!(issues[1].issue_type, None);
232 assert_eq!(issues[1].milestone, None);
233 assert_eq!(issues[1].url, "");
234 }
235
236 #[test]
237 fn parses_issue_view_json() {
238 let json = r#"{"number": 7, "title": "Add login", "body": "Please add it.",
239 "state": "OPEN", "labels": [{"name": "enhancement"}],
240 "issueType": null, "milestone": null,
241 "createdAt": "2024-01-15T10:30:00Z", "updatedAt": "2024-02-01T09:00:00Z",
242 "url": "https://github.com/o/r/issues/7"}"#;
243 let issue: IssueView = serde_json::from_str(json).unwrap();
244 assert_eq!(issue.number, 7);
245 assert_eq!(issue.body, "Please add it.");
246 assert_eq!(issue.labels[0].name, "enhancement");
247 assert_eq!(issue.issue_type, None);
248 assert_eq!(issue.url, "https://github.com/o/r/issues/7");
249 }
250
251 #[test]
252 fn issue_view_tolerates_a_body_only_response() {
253 let issue: IssueView =
256 serde_json::from_str(r#"{"number": 1, "title": "T", "state": "OPEN"}"#).unwrap();
257 assert_eq!(issue.body, "");
258 assert_eq!(issue.updated_at, "");
259 }
260
261 #[test]
262 fn parses_pr_list_json() {
263 let json = r#"[
264 {"number": 42, "title": "Add login", "author": {"login": "alice"},
265 "state": "OPEN", "isDraft": false, "headRefName": "feature/login",
266 "createdAt": "2024-01-15T10:30:00Z"},
267 {"number": 7, "title": "WIP", "author": {"login": "bob"},
268 "state": "OPEN", "isDraft": true, "headRefName": "wip"}
269 ]"#;
270 let prs: Vec<PrSummary> = serde_json::from_str(json).unwrap();
271 assert_eq!(prs.len(), 2);
272 assert_eq!(prs[0].number, 42);
273 assert_eq!(prs[0].author.login, "alice");
274 assert_eq!(prs[0].pr_state(), PrState::Open);
275 assert_eq!(prs[1].pr_state(), PrState::Draft); }
277
278 #[test]
279 fn parses_pr_view_json() {
280 let json = r#"{"number": 5, "title": "Fix", "state": "MERGED", "isDraft": false,
281 "headRefName": "fork-branch", "baseRefName": "main"}"#;
282 let view: PrView = serde_json::from_str(json).unwrap();
283 assert_eq!(view.number, 5);
284 assert_eq!(view.head_ref_name, "fork-branch");
285 assert_eq!(view.base_ref_name, "main");
286 assert_eq!(view.pr_state(), PrState::Merged);
287 }
288
289 #[test]
290 fn state_mapping() {
291 assert_eq!(pr_state("OPEN", false), PrState::Open);
292 assert_eq!(pr_state("OPEN", true), PrState::Draft);
293 assert_eq!(pr_state("CLOSED", false), PrState::Closed);
294 assert_eq!(pr_state("MERGED", false), PrState::Merged);
295 assert_eq!(pr_state("CLOSED", true), PrState::Closed); }
297
298 #[test]
299 fn parses_open_pr_list() {
300 let json = r#"[{"number": 77, "url": "https://github.com/o/r/pull/77",
301 "state": "OPEN", "isDraft": true}]"#;
302 let prs: Vec<OpenPr> = serde_json::from_str(json).unwrap();
303 assert_eq!(prs.len(), 1);
304 assert_eq!(prs[0].number, 77);
305 assert_eq!(prs[0].url, "https://github.com/o/r/pull/77");
306 assert!(prs[0].is_draft);
307 }
308
309 #[test]
310 fn parses_default_branch() {
311 assert_eq!(
312 parse_default_branch(r#"{"defaultBranchRef": {"name": "main"}}"#),
313 Some("main".to_string())
314 );
315 assert_eq!(parse_default_branch(r#"{"defaultBranchRef": null}"#), None);
317 assert_eq!(parse_default_branch("not json"), None);
318 }
319}