1use processkit::{Error, Result};
5use serde::Deserialize;
6use serde::de::DeserializeOwned;
7
8use crate::BINARY;
9
10fn null_to_empty<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
17where
18 D: serde::Deserializer<'de>,
19{
20 Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
25#[non_exhaustive]
26pub struct PullRequest {
27 pub number: u64,
29 pub title: String,
31 pub state: String,
33 #[serde(rename = "headRefName", default, deserialize_with = "null_to_empty")]
35 pub head_ref_name: String,
36 #[serde(rename = "baseRefName", default, deserialize_with = "null_to_empty")]
38 pub base_ref_name: String,
39 #[serde(default, deserialize_with = "null_to_empty")]
41 pub url: String,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
47#[non_exhaustive]
48pub struct Issue {
49 pub number: u64,
51 pub title: String,
53 pub state: String,
55 #[serde(default, deserialize_with = "null_to_empty")]
57 pub body: String,
58 #[serde(default, deserialize_with = "null_to_empty")]
60 pub url: String,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
65#[non_exhaustive]
66pub struct WorkflowRun {
67 #[serde(rename = "databaseId")]
69 pub database_id: u64,
70 #[serde(default, deserialize_with = "null_to_empty")]
72 pub name: String,
73 #[serde(rename = "displayTitle", default, deserialize_with = "null_to_empty")]
75 pub display_title: String,
76 #[serde(default, deserialize_with = "null_to_empty")]
78 pub status: String,
79 #[serde(default, deserialize_with = "null_to_empty")]
82 pub conclusion: String,
83 #[serde(rename = "workflowName", default, deserialize_with = "null_to_empty")]
85 pub workflow_name: String,
86 #[serde(rename = "headBranch", default, deserialize_with = "null_to_empty")]
88 pub head_branch: String,
89 #[serde(default, deserialize_with = "null_to_empty")]
91 pub event: String,
92 #[serde(default, deserialize_with = "null_to_empty")]
94 pub url: String,
95 #[serde(rename = "createdAt", default, deserialize_with = "null_to_empty")]
97 pub created_at: String,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
109#[serde(rename_all = "lowercase")]
110#[non_exhaustive]
111pub enum CheckBucket {
112 Pass,
114 Fail,
116 Pending,
118 Skipping,
120 Cancel,
122 #[default]
124 #[serde(other)]
125 Unknown,
126}
127
128impl CheckBucket {
129 pub fn is_failing(self) -> bool {
132 matches!(self, CheckBucket::Fail | CheckBucket::Cancel)
133 }
134
135 pub fn is_pending(self) -> bool {
137 matches!(self, CheckBucket::Pending)
138 }
139
140 pub fn is_passing(self) -> bool {
142 matches!(self, CheckBucket::Pass)
143 }
144
145 pub fn is_unknown(self) -> bool {
151 matches!(self, CheckBucket::Unknown)
152 }
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
157#[non_exhaustive]
158pub struct CheckRun {
159 pub name: String,
161 #[serde(default, deserialize_with = "null_to_empty")]
163 pub state: String,
164 #[serde(default)]
166 pub bucket: CheckBucket,
167 #[serde(default, deserialize_with = "null_to_empty")]
169 pub workflow: String,
170 #[serde(default, deserialize_with = "null_to_empty")]
172 pub link: String,
173 #[serde(rename = "startedAt", default, deserialize_with = "null_to_empty")]
175 pub started_at: String,
176 #[serde(rename = "completedAt", default, deserialize_with = "null_to_empty")]
178 pub completed_at: String,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
183#[non_exhaustive]
184pub struct Release {
185 #[serde(rename = "tagName")]
187 pub tag_name: String,
188 #[serde(default, deserialize_with = "null_to_empty")]
190 pub name: String,
191 #[serde(default, deserialize_with = "null_to_empty")]
194 pub body: String,
195 #[serde(default, deserialize_with = "null_to_empty")]
197 pub url: String,
198 #[serde(rename = "publishedAt", default, deserialize_with = "null_to_empty")]
200 pub published_at: String,
201 #[serde(rename = "isDraft", default)]
203 pub is_draft: bool,
204 #[serde(rename = "isPrerelease", default)]
206 pub is_prerelease: bool,
207 #[serde(rename = "isLatest", default)]
210 pub is_latest: bool,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
215#[non_exhaustive]
216pub struct Review {
217 pub author: String,
219 pub state: String,
222 pub body: String,
224 pub submitted_at: String,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
230#[non_exhaustive]
231pub struct Comment {
232 pub author: String,
234 pub body: String,
236 pub url: String,
238 pub created_at: String,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
244#[non_exhaustive]
245pub struct PrFeedback {
246 pub reviews: Vec<Review>,
248 pub comments: Vec<Comment>,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
254#[non_exhaustive]
255pub struct Repo {
256 pub name: String,
258 pub owner: String,
260 pub description: Option<String>,
262 pub url: String,
264 pub is_private: bool,
266 pub default_branch: String,
268}
269
270#[derive(Deserialize)]
273struct RepoJson {
274 name: String,
275 owner: OwnerJson,
276 #[serde(default)]
277 description: Option<String>,
278 url: String,
279 #[serde(rename = "isPrivate")]
280 is_private: bool,
281 #[serde(rename = "defaultBranchRef", default)]
282 default_branch_ref: Option<BranchRefJson>,
283}
284
285#[derive(Deserialize)]
286struct OwnerJson {
287 login: String,
288}
289
290#[derive(Deserialize)]
291struct BranchRefJson {
292 name: String,
293}
294
295pub(crate) fn from_json<T: DeserializeOwned>(json: &str) -> Result<T> {
298 serde_json::from_str(json).map_err(|e| Error::Parse {
299 program: BINARY.to_string(),
300 message: e.to_string(),
301 })
302}
303
304pub(crate) fn parse_repo(json: &str) -> Result<Repo> {
306 let raw: RepoJson = from_json(json)?;
307 Ok(Repo {
308 name: raw.name,
309 owner: raw.owner.login,
310 description: raw.description,
311 url: raw.url,
312 is_private: raw.is_private,
313 default_branch: raw.default_branch_ref.map(|b| b.name).unwrap_or_default(),
314 })
315}
316
317#[derive(Deserialize)]
320struct FeedbackJson {
321 #[serde(default)]
322 reviews: Vec<ReviewJson>,
323 #[serde(default)]
324 comments: Vec<CommentJson>,
325}
326
327#[derive(Deserialize)]
328struct ReviewJson {
329 #[serde(default)]
330 author: Option<AuthorJson>,
331 #[serde(default)]
332 state: String,
333 #[serde(default)]
334 body: String,
335 #[serde(rename = "submittedAt", default)]
336 submitted_at: String,
337}
338
339#[derive(Deserialize)]
340struct CommentJson {
341 #[serde(default)]
342 author: Option<AuthorJson>,
343 #[serde(default)]
344 body: String,
345 #[serde(default)]
346 url: String,
347 #[serde(rename = "createdAt", default)]
348 created_at: String,
349}
350
351#[derive(Deserialize)]
352struct AuthorJson {
353 #[serde(default)]
354 login: String,
355}
356
357pub(crate) fn parse_feedback(json: &str) -> Result<PrFeedback> {
360 let raw: FeedbackJson = from_json(json)?;
361 Ok(PrFeedback {
362 reviews: raw
363 .reviews
364 .into_iter()
365 .map(|r| Review {
366 author: r.author.map(|a| a.login).unwrap_or_default(),
367 state: r.state,
368 body: r.body,
369 submitted_at: r.submitted_at,
370 })
371 .collect(),
372 comments: raw
373 .comments
374 .into_iter()
375 .map(|c| Comment {
376 author: c.author.map(|a| a.login).unwrap_or_default(),
377 body: c.body,
378 url: c.url,
379 created_at: c.created_at,
380 })
381 .collect(),
382 })
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
390 fn parses_pr_list() {
391 let json = r#"[
392 {"number": 12, "title": "Add feature", "state": "OPEN",
393 "headRefName": "feat/x", "baseRefName": "main", "url": "https://gh/pr/12"}
394 ]"#;
395 let prs: Vec<PullRequest> = from_json(json).expect("parse prs");
396 assert_eq!(prs.len(), 1);
397 assert_eq!(
398 prs[0],
399 PullRequest {
400 number: 12,
401 title: "Add feature".into(),
402 state: "OPEN".into(),
403 head_ref_name: "feat/x".into(),
404 base_ref_name: "main".into(),
405 url: "https://gh/pr/12".into(),
406 }
407 );
408 }
409
410 #[test]
411 fn parses_issue_list() {
412 let json = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
413 let issues: Vec<Issue> = from_json(json).expect("parse issues");
414 assert_eq!(issues[0].number, 3);
415 }
416
417 #[test]
422 fn null_optional_fields_parse_to_empty() {
423 let pr: PullRequest = from_json(
424 r#"{"number": 1, "title": "t", "state": "CLOSED",
425 "headRefName": null, "baseRefName": null, "url": null}"#,
426 )
427 .expect("PR with null head/base/url (deleted-branch PR)");
428 assert_eq!(pr.head_ref_name, "");
429 assert_eq!(pr.base_ref_name, "");
430 assert_eq!(pr.url, "");
431
432 let issue: Issue =
433 from_json(r#"{"number": 2, "title": "t", "state": "OPEN", "body": null, "url": null}"#)
434 .expect("issue with null body/url");
435 assert_eq!(issue.body, "");
436 assert_eq!(issue.url, "");
437
438 let release: Release = from_json(
439 r#"{"tagName": "v1", "name": null, "body": null, "url": null, "publishedAt": null}"#,
440 )
441 .expect("release with null name/body/url/publishedAt");
442 assert_eq!(release.name, "");
443 assert_eq!(release.body, "");
444 }
445
446 #[test]
447 fn parses_repo_flattening_nested_objects() {
448 let json = r#"{
449 "name": "vcs-toolkit-rs",
450 "owner": {"login": "ZelAnton"},
451 "description": null,
452 "url": "https://gh/repo",
453 "isPrivate": false,
454 "defaultBranchRef": {"name": "main"}
455 }"#;
456 let repo = parse_repo(json).expect("parse repo");
457 assert_eq!(repo.name, "vcs-toolkit-rs");
458 assert_eq!(repo.owner, "ZelAnton");
459 assert_eq!(repo.description, None);
460 assert_eq!(repo.default_branch, "main");
461 assert!(!repo.is_private);
462 }
463
464 #[test]
465 fn empty_repo_has_blank_default_branch() {
466 let json = r#"{"name":"e","owner":{"login":"o"},"url":"u","isPrivate":true,"defaultBranchRef":null}"#;
467 let repo = parse_repo(json).expect("parse repo");
468 assert_eq!(repo.default_branch, "");
469 assert!(repo.is_private);
470 }
471
472 #[test]
473 fn malformed_json_is_a_parse_error() {
474 match from_json::<Vec<Issue>>("not json").unwrap_err() {
475 Error::Parse { .. } => {}
476 other => panic!("expected Parse, got {other:?}"),
477 }
478 }
479
480 #[test]
483 fn parses_run_list_with_blank_in_progress_conclusion() {
484 let json = r#"[
485 {"databaseId": 27023111945, "name": "CI", "displayTitle": "fix: x",
486 "status": "in_progress", "conclusion": "", "workflowName": "CI",
487 "headBranch": "main", "event": "push",
488 "url": "https://gh/runs/27023111945",
489 "createdAt": "2026-06-05T10:00:00Z"}
490 ]"#;
491 let runs: Vec<WorkflowRun> = from_json(json).expect("parse runs");
492 assert_eq!(runs[0].database_id, 27023111945);
493 assert_eq!(runs[0].status, "in_progress");
494 assert_eq!(runs[0].conclusion, "");
495 assert_eq!(runs[0].workflow_name, "CI");
496 }
497
498 #[test]
499 fn parses_check_runs_across_buckets() {
500 let json = r#"[
501 {"name": "build", "state": "SUCCESS", "bucket": "pass",
502 "workflow": "CI", "link": "https://gh/c/1",
503 "startedAt": "2026-06-05T10:00:00Z", "completedAt": "2026-06-05T10:05:00Z"},
504 {"name": "lint", "state": "FAILURE", "bucket": "fail",
505 "workflow": "CI", "link": "", "startedAt": "", "completedAt": ""},
506 {"name": "deploy", "state": "IN_PROGRESS", "bucket": "pending",
507 "workflow": "CD", "link": "", "startedAt": "", "completedAt": ""},
508 {"name": "docs", "state": "SKIPPED", "bucket": "skipping",
509 "workflow": "", "link": "", "startedAt": "", "completedAt": ""},
510 {"name": "bench", "state": "CANCELLED", "bucket": "cancel",
511 "workflow": "", "link": "", "startedAt": "", "completedAt": ""}
512 ]"#;
513 let checks: Vec<CheckRun> = from_json(json).expect("parse checks");
514 let buckets: Vec<CheckBucket> = checks.iter().map(|c| c.bucket).collect();
515 assert_eq!(
516 buckets,
517 [
518 CheckBucket::Pass,
519 CheckBucket::Fail,
520 CheckBucket::Pending,
521 CheckBucket::Skipping,
522 CheckBucket::Cancel,
523 ]
524 );
525 let exotic: CheckRun =
527 serde_json::from_str(r#"{"name":"x","bucket":"teleport"}"#).expect("parse");
528 assert_eq!(exotic.bucket, CheckBucket::Unknown);
529 assert_eq!(checks[0].name, "build");
530 }
531
532 #[test]
535 fn parses_release_list_and_view_shapes() {
536 let list = r#"[
537 {"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
538 "isLatest": true, "isDraft": false, "isPrerelease": false,
539 "publishedAt": "2026-06-04T12:00:00Z"}
540 ]"#;
541 let releases: Vec<Release> = from_json(list).expect("parse list");
542 assert!(releases[0].is_latest);
543 assert_eq!(releases[0].tag_name, "vcs-git-v0.4.0");
544 assert_eq!(releases[0].body, "", "list doesn't fetch the body");
545
546 let view = r#"{"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
547 "body": "Added\n- stuff", "url": "https://gh/releases/1",
548 "publishedAt": "2026-06-04T12:00:00Z",
549 "isDraft": false, "isPrerelease": false}"#;
550 let release: Release = from_json(view).expect("parse view");
551 assert!(!release.is_latest, "view has no isLatest → default false");
552 assert_eq!(release.body, "Added\n- stuff");
553 assert_eq!(release.url, "https://gh/releases/1");
554 }
555
556 #[test]
557 fn parses_feedback_flattening_nested_authors() {
558 let json = r#"{
559 "reviews": [
560 {"author": {"login": "steiza"}, "state": "APPROVED",
561 "body": "LGTM", "submittedAt": "2026-06-01T00:00:00Z"},
562 {"author": null, "state": "COMMENTED", "body": "ghost",
563 "submittedAt": ""}
564 ],
565 "comments": [
566 {"author": {"login": "andyfeller"}, "body": "nice",
567 "url": "https://gh/c/9", "createdAt": "2026-06-02T00:00:00Z"}
568 ]
569 }"#;
570 let feedback = parse_feedback(json).expect("parse feedback");
571 assert_eq!(feedback.reviews.len(), 2);
572 assert_eq!(feedback.reviews[0].author, "steiza");
573 assert_eq!(feedback.reviews[0].state, "APPROVED");
574 assert_eq!(feedback.reviews[1].author, "", "deleted account → empty");
575 assert_eq!(feedback.comments[0].author, "andyfeller");
576 assert_eq!(feedback.comments[0].url, "https://gh/c/9");
577 }
578
579 #[test]
582 fn issue_parses_with_and_without_view_fields() {
583 let list = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
584 let issues: Vec<Issue> = from_json(list).expect("parse list");
585 assert_eq!(issues[0].body, "");
586 assert_eq!(issues[0].url, "");
587
588 let view = r#"{"number": 3, "title": "Docs", "state": "OPEN",
589 "body": "Write them.", "url": "https://gh/issues/3"}"#;
590 let issue: Issue = from_json(view).expect("parse view");
591 assert_eq!(issue.body, "Write them.");
592 assert_eq!(issue.url, "https://gh/issues/3");
593 }
594}