octopeek 0.3.0

A fast, keyboard-driven TUI for your GitHub PR and issue inbox.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
//! GraphQL query string and response-to-domain conversion.
//!
//! Raw GraphQL response structs live here and are intentionally kept private;
//! callers receive [`super::types::Inbox`] from [`to_inbox`].

use std::collections::HashMap;
use std::sync::OnceLock;

use serde::Deserialize;

use super::types::{
    CheckRun, CheckState, Inbox, Issue, Label, MergeStateStatus, Mergeable, PullRequest, Review,
    ReviewDecision, ReviewState, Role,
};

// ── Shared GraphQL fragments ──────────────────────────────────────────────────
//
// Both query builders reference these two fragments. Keeping them as `const`
// strings (no `{...}` escaping for `format!`) removes the 90+-line copy that
// the agent audit flagged: a PR-field addition only has to land in one place.

const PR_FRAGMENT: &str = r"
fragment PullRequestFields on PullRequest {
  number
  title
  url
  isDraft
  mergeable
  mergeStateStatus
  reviewDecision
  repository { nameWithOwner }
  author { login }
  updatedAt
  baseRefName
  headRefName
  commits(last: 1) {
    totalCount
    nodes {
      commit {
        statusCheckRollup {
          state
          contexts(first: 20) {
            nodes {
              ... on CheckRun {
                name
                status
                conclusion
                checkSuite { workflowRun { workflow { name } } }
              }
              ... on StatusContext {
                context
                state
              }
            }
          }
        }
      }
    }
  }
  comments { totalCount }
  reviewRequests(first: 10) {
    nodes {
      requestedReviewer {
        ... on User { login }
        ... on Team { name }
      }
    }
  }
  reviewThreads(first: 30) {
    nodes {
      isResolved
      isOutdated
    }
  }
  latestReviews(first: 10) {
    nodes {
      author { login }
      state
    }
  }
}
";

const ISSUE_FRAGMENT: &str = r"
fragment IssueFields on Issue {
  number
  title
  url
  repository { nameWithOwner }
  author { login }
  updatedAt
  comments { totalCount }
  labels(first: 20) {
    nodes {
      name
      color
    }
  }
}
";

// ── Inbox query ───────────────────────────────────────────────────────────────

/// The single GraphQL document sent to `api.github.com/graphql` on startup
/// and refresh. Four aliased top-level fields are merged by [`to_inbox`]
/// into one [`Inbox`].
///
/// Built lazily from the shared [`PR_FRAGMENT`] / [`ISSUE_FRAGMENT`] so any
/// fragment change is picked up automatically without editing two places.
pub(super) fn inbox_query() -> &'static str {
    static Q: OnceLock<String> = OnceLock::new();
    Q.get_or_init(|| {
        let mut s = String::from(
            r#"
query InboxQuery {
  authored: viewer {
    login
    pullRequests(first: 50, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) {
      nodes {
        ...PullRequestFields
      }
    }
  }
  reviewRequested: search(query: "is:open is:pr review-requested:@me", type: ISSUE, first: 50) {
    nodes {
      ... on PullRequest {
        ...PullRequestFields
      }
    }
  }
  assignedPrs: search(query: "is:open is:pr assignee:@me", type: ISSUE, first: 50) {
    nodes {
      ... on PullRequest {
        ...PullRequestFields
      }
    }
  }
  assignedIssues: search(query: "is:open is:issue assignee:@me", type: ISSUE, first: 50) {
    nodes {
      ... on Issue {
        ...IssueFields
      }
    }
  }
}
"#,
        );
        s.push_str(PR_FRAGMENT);
        s.push_str(ISSUE_FRAGMENT);
        s
    })
    .as_str()
}

// ── Show-all query builder ────────────────────────────────────────────────────

/// Build a GraphQL query document that fetches every open PR and issue across
/// the given list of repositories.
///
/// The returned document has two top-level search fields (`allPrs` and
/// `allIssues`) whose queries are constructed by joining each repo as a
/// `repo:owner/name` qualifier. Both reuse the shared [`PR_FRAGMENT`] and
/// [`ISSUE_FRAGMENT`] constants so the response mapping can share
/// [`raw_pr_to_domain`] and [`raw_issue_to_domain`].
///
/// # Arguments
///
/// * `repos` - Slice of repo slugs in `owner/name` form. An empty slice
///   produces a valid query but returns no results.
pub(super) fn build_show_all_query(repos: &[String]) -> String {
    let repo_qualifiers: String =
        repos.iter().map(|r| format!("repo:{r}")).collect::<Vec<_>>().join(" ");

    let header = format!(
        r#"
query ShowAllQuery {{
  allPrs: search(query: "{repo_qualifiers} is:open is:pr", type: ISSUE, first: 50) {{
    nodes {{
      ... on PullRequest {{
        ...PullRequestFields
      }}
    }}
  }}
  allIssues: search(query: "{repo_qualifiers} is:open is:issue", type: ISSUE, first: 50) {{
    nodes {{
      ... on Issue {{
        ...IssueFields
      }}
    }}
  }}
  viewer {{ login }}
}}
"#,
    );

    let mut s = String::with_capacity(header.len() + PR_FRAGMENT.len() + ISSUE_FRAGMENT.len());
    s.push_str(&header);
    s.push_str(PR_FRAGMENT);
    s.push_str(ISSUE_FRAGMENT);
    s
}

// ── Raw GraphQL response types ────────────────────────────────────────────────

/// Generic top-level GraphQL response envelope: `{ data: Option<T>, errors: … }`.
///
/// Used by [`crate::github::client::Client::post_graphql`] to deserialize any
/// GraphQL response into its domain-specific inner shape while the transport-
/// layer `data`/`errors` plumbing is handled once.
#[derive(Debug, Deserialize)]
pub(super) struct GqlEnvelope<T> {
    pub data: Option<T>,
    pub errors: Option<Vec<GraphQlError>>,
}

/// One `errors[]` entry from the GraphQL response.
#[derive(Debug, Deserialize)]
pub(super) struct GraphQlError {
    pub message: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ResponseData {
    /// `viewer { login, pullRequests { nodes } }` — authored PRs.
    pub authored: AuthoredViewer,
    /// `search(...)` for PRs with review-requested.
    pub review_requested: SearchResult,
    /// `search(...)` for PRs assigned to viewer.
    pub assigned_prs: SearchResult,
    /// `search(...)` for issues assigned to viewer.
    pub assigned_issues: SearchResult,
}

/// Top-level response for the show-all query built by [`build_show_all_query`].
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ResponseDataAll {
    /// All open PRs across the tracked repos.
    pub all_prs: SearchResult,
    /// All open issues across the tracked repos.
    pub all_issues: SearchResult,
    /// Viewer login for role derivation.
    pub viewer: ViewerLogin,
}

/// Minimal viewer shape used by the show-all query.
#[derive(Debug, Deserialize)]
pub(super) struct ViewerLogin {
    pub login: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct AuthoredViewer {
    pub login: String,
    pub pull_requests: NodeList<RawPr>,
}

#[derive(Debug, Deserialize)]
pub(super) struct SearchResult {
    pub nodes: Vec<Option<SearchNode>>,
}

/// A node from an inline fragment — may be a PR or something else (ignored).
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(super) enum SearchNode {
    Pr(RawPr),
    Issue(RawIssue),
}

#[derive(Debug, Deserialize)]
pub(super) struct NodeList<T> {
    pub nodes: Vec<T>,
}

// ── Raw PR shape ───────────────────────────────────────────────────────────────

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawPr {
    pub number: u32,
    pub title: String,
    pub url: String,
    pub is_draft: bool,
    pub mergeable: Mergeable,
    pub merge_state_status: MergeStateStatus,
    pub review_decision: Option<ReviewDecision>,
    pub repository: RawRepo,
    pub author: Option<RawActor>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
    /// Base branch name, e.g. `"main"`.
    pub base_ref_name: String,
    /// Head branch name, e.g. `"feat/my-feature"`.
    pub head_ref_name: String,
    pub commits: RawCommits,
    pub comments: RawTotalCount,
    pub review_requests: NodeList<RawReviewRequest>,
    pub review_threads: NodeList<RawReviewThread>,
    pub latest_reviews: NodeList<RawReview>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawCommits {
    pub total_count: u32,
    pub nodes: Vec<RawCommitNode>,
}

#[derive(Debug, Deserialize)]
pub(super) struct RawCommitNode {
    pub commit: RawCommit,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawCommit {
    pub status_check_rollup: Option<RawStatusRollup>,
}

#[derive(Debug, Deserialize)]
pub(super) struct RawStatusRollup {
    pub state: CheckState,
    pub contexts: NodeList<RawCheckContext>,
}

/// Inline-fragment union: either a `CheckRun` or a `StatusContext`.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(super) enum RawCheckContext {
    CheckRun(RawCheckRun),
    /// Commit-status context — deserialized for the untagged enum discriminator;
    /// the inner data is intentionally unused (only `CheckRun` entries are surfaced).
    #[allow(dead_code)]
    StatusContext(RawStatusContext),
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawCheckRun {
    pub name: String,
    pub status: String,
    pub conclusion: Option<String>,
    /// Nested path: `checkSuite.workflowRun.workflow.name`
    pub check_suite: Option<RawCheckSuite>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawCheckSuite {
    pub workflow_run: Option<RawWorkflowRun>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawWorkflowRun {
    pub workflow: Option<RawWorkflow>,
}

#[derive(Debug, Deserialize)]
pub(super) struct RawWorkflow {
    pub name: String,
}

/// A legacy commit-status context (not a GitHub Actions check run).
///
/// Consumed by `raw_pr_to_domain` to surface external-status failures
/// (`Codecov`, `CircleCI`, etc.) into the same `failing_checks` vec as `CheckRun`s.
#[derive(Debug, Deserialize)]
pub(super) struct RawStatusContext {
    pub context: String,
    pub state: String,
}

#[derive(Debug, Deserialize)]
pub(super) struct RawTotalCount {
    #[serde(rename = "totalCount")]
    pub total_count: u32,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawReviewRequest {
    pub requested_reviewer: Option<RawReviewer>,
}

#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(super) enum RawReviewer {
    User { login: String },
    Team { name: String },
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawReviewThread {
    pub is_resolved: bool,
    pub is_outdated: bool,
}

#[derive(Debug, Deserialize)]
pub(super) struct RawReview {
    pub author: Option<RawActor>,
    pub state: ReviewState,
}

#[derive(Debug, Deserialize)]
pub(super) struct RawRepo {
    #[serde(rename = "nameWithOwner")]
    pub name_with_owner: String,
}

#[derive(Debug, Deserialize)]
pub(super) struct RawActor {
    pub login: String,
}

// ── Raw Issue shape ────────────────────────────────────────────────────────────

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawIssue {
    pub number: u32,
    pub title: String,
    pub url: String,
    pub repository: RawRepo,
    pub author: Option<RawActor>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
    pub comments: RawTotalCount,
    pub labels: NodeList<RawLabel>,
}

#[derive(Debug, Deserialize)]
pub(super) struct RawLabel {
    pub name: String,
    pub color: String,
}

// ── Conversion: raw → domain ───────────────────────────────────────────────────

/// Convert a raw GraphQL [`ResponseData`] into a normalised [`Inbox`].
///
/// # Deduplication
///
/// PRs that appear in more than one search bucket (e.g. the viewer authored a
/// PR and is also a requested reviewer) are merged: the `roles` field becomes
/// the union of all roles for that `(repo, number)` pair.
///
/// # Errors
///
/// Returns any errors embedded in the response; if both `data` and `errors`
/// are present, converts `data` and logs errors via the caller.
pub(super) fn to_inbox(viewer_login: String, data: ResponseData) -> Inbox {
    // Key: (repo nameWithOwner, pr number)
    type PrKey = (String, u32);
    let mut pr_map: HashMap<PrKey, (PullRequest, Vec<Role>)> = HashMap::new();

    // Helper closure: insert or merge a raw PR with a given role.
    let mut upsert = |raw: RawPr, role: Role| {
        let key = (raw.repository.name_with_owner.clone(), raw.number);
        let entry = pr_map.entry(key);
        match entry {
            std::collections::hash_map::Entry::Occupied(mut occ) => {
                // Already present — just union the role.
                let (_, roles) = occ.get_mut();
                if !roles.contains(&role) {
                    roles.push(role);
                }
            }
            std::collections::hash_map::Entry::Vacant(vac) => {
                let pr = raw_pr_to_domain(raw);
                vac.insert((pr, vec![role]));
            }
        }
    };

    // Authored PRs.
    for raw in data.authored.pull_requests.nodes {
        upsert(raw, Role::Author);
    }

    // Review-requested PRs.
    for node in data.review_requested.nodes.into_iter().flatten() {
        if let SearchNode::Pr(raw) = node {
            upsert(raw, Role::Reviewer);
        }
    }

    // Assigned PRs.
    for node in data.assigned_prs.nodes.into_iter().flatten() {
        if let SearchNode::Pr(raw) = node {
            upsert(raw, Role::Assignee);
        }
    }

    // Materialise PRs, attaching the final deduplicated roles.
    let mut prs: Vec<PullRequest> = pr_map
        .into_values()
        .map(|(mut pr, roles)| {
            pr.roles = roles;
            pr
        })
        .collect();
    // Sort by updated_at descending for a stable, predictable order.
    prs.sort_by_key(|p| std::cmp::Reverse(p.updated_at));

    // Issues.
    let mut issues: Vec<Issue> = data
        .assigned_issues
        .nodes
        .into_iter()
        .flatten()
        .filter_map(|node| {
            if let SearchNode::Issue(raw) = node { Some(raw_issue_to_domain(raw)) } else { None }
        })
        .collect();
    issues.sort_by_key(|i| std::cmp::Reverse(i.updated_at));

    Inbox { viewer_login, prs, issues }
}

/// Convert a [`ResponseDataAll`] (from the show-all query) into a normalised
/// [`Inbox`].
///
/// # Role derivation
///
/// In the standard inbox query roles come from *which bucket* a PR appears in.
/// In show-all mode every PR is in a single flat bucket, so roles are derived
/// from the PR's content fields:
/// - `Author` when `pr.author == viewer_login`.
/// - `Reviewer` when the viewer appears in `reviewRequests`.
/// - `Assignee` role derivation is not implemented here because the
///   `PullRequestFields` fragment does not include `assignees`.
///
/// TODO: add `assignees(first: 10)` to `PullRequestFields` so that `Assignee`
/// can be populated here (and in the regular query's dedup step).
pub(super) fn to_inbox_all(viewer_login: String, data: ResponseDataAll) -> Inbox {
    let mut prs: Vec<PullRequest> = data
        .all_prs
        .nodes
        .into_iter()
        .flatten()
        .filter_map(|node| if let SearchNode::Pr(raw) = node { Some(raw) } else { None })
        .map(|raw| {
            // Derive roles from the PR's data fields.
            let mut roles: Vec<Role> = Vec::new();
            if raw.author.as_ref().is_some_and(|a| a.login == viewer_login) {
                roles.push(Role::Author);
            }
            // Check if the viewer is among the requested reviewers.
            let viewer_is_reviewer = raw.review_requests.nodes.iter().any(|rr| {
                rr.requested_reviewer.as_ref().is_some_and(|rv| match rv {
                    RawReviewer::User { login } => login == &viewer_login,
                    RawReviewer::Team { .. } => false,
                })
            });
            if viewer_is_reviewer {
                roles.push(Role::Reviewer);
            }
            let mut pr = raw_pr_to_domain(raw);
            pr.roles = roles;
            pr
        })
        .collect();
    prs.sort_by_key(|p| std::cmp::Reverse(p.updated_at));

    let mut issues: Vec<Issue> = data
        .all_issues
        .nodes
        .into_iter()
        .flatten()
        .filter_map(|node| {
            if let SearchNode::Issue(raw) = node { Some(raw_issue_to_domain(raw)) } else { None }
        })
        .collect();
    issues.sort_by_key(|i| std::cmp::Reverse(i.updated_at));

    Inbox { viewer_login, prs, issues }
}

// ── Private helpers ────────────────────────────────────────────────────────────

fn raw_pr_to_domain(raw: RawPr) -> PullRequest {
    let rollup = raw.commits.nodes.into_iter().next().and_then(|n| n.commit.status_check_rollup);

    let check_state = rollup.as_ref().map(|r| r.state);

    let failing_checks = rollup
        .map(|r| {
            r.contexts
                .nodes
                .into_iter()
                .filter_map(|ctx| match ctx {
                    RawCheckContext::CheckRun(cr) => {
                        // GitHub's GraphQL API returns `conclusion` as a
                        // SCREAMING_SNAKE_CASE enum name (e.g. `FAILURE`, not
                        // `failure` as the REST API uses). `null` means the
                        // run is still in progress and is intentionally not
                        // surfaced as failing.
                        let is_failing = cr.conclusion.as_deref().is_some_and(|c| {
                            matches!(
                                c,
                                "FAILURE"
                                    | "ERROR"
                                    | "TIMED_OUT"
                                    | "ACTION_REQUIRED"
                                    | "CANCELLED"
                                    | "STARTUP_FAILURE"
                            )
                        });
                        if is_failing {
                            // Traverse: checkSuite → workflowRun → workflow → name
                            let workflow_name = cr
                                .check_suite
                                .as_ref()
                                .and_then(|cs| cs.workflow_run.as_ref())
                                .and_then(|wr| wr.workflow.as_ref())
                                .map(|w| w.name.clone());
                            Some(CheckRun {
                                name: cr.name,
                                workflow_name,
                                conclusion: cr.conclusion,
                                status: cr.status,
                            })
                        } else {
                            None
                        }
                    }
                    RawCheckContext::StatusContext(sc) => {
                        // Legacy commit statuses (Codecov, external CIs, etc.)
                        // expose a `state` field whose GraphQL enum is also
                        // uppercase (`FAILURE`, `ERROR`, `SUCCESS`, `PENDING`,
                        // `EXPECTED`). Surface failing ones as CheckRun-shaped
                        // domain values so the UI treats them uniformly.
                        if matches!(sc.state.as_str(), "FAILURE" | "ERROR") {
                            Some(CheckRun {
                                name: sc.context,
                                workflow_name: None,
                                conclusion: Some(sc.state),
                                status: "COMPLETED".to_owned(),
                            })
                        } else {
                            None
                        }
                    }
                })
                .collect()
        })
        .unwrap_or_default();

    // `count()` returns `usize`; review_threads is capped at 30 by the query
    // so truncation is impossible in practice.
    #[allow(clippy::cast_possible_truncation)]
    let unresolved_threads =
        raw.review_threads.nodes.iter().filter(|t| !t.is_resolved && !t.is_outdated).count() as u32;

    let requested_reviewers = raw
        .review_requests
        .nodes
        .into_iter()
        .filter_map(|rr| rr.requested_reviewer)
        .map(|rv| match rv {
            RawReviewer::User { login } => login,
            RawReviewer::Team { name } => name,
        })
        .collect();

    let reviews = raw
        .latest_reviews
        .nodes
        .into_iter()
        .filter_map(|r| r.author.map(|a| Review { author: a.login, state: r.state }))
        .collect();

    PullRequest {
        number: raw.number,
        title: raw.title,
        url: raw.url,
        repo: raw.repository.name_with_owner,
        author: super::author_or_deleted(raw.author.map(|a| a.login)),
        is_draft: raw.is_draft,
        mergeable: raw.mergeable,
        merge_state: raw.merge_state_status,
        review_decision: raw.review_decision,
        commits_count: raw.commits.total_count,
        comments_count: raw.comments.total_count,
        check_state,
        failing_checks,
        unresolved_threads,
        requested_reviewers,
        reviews,
        updated_at: raw.updated_at,
        roles: vec![], // populated by the dedup step in to_inbox
        base_ref: Some(raw.base_ref_name),
        head_ref: Some(raw.head_ref_name),
    }
}

fn raw_issue_to_domain(raw: RawIssue) -> Issue {
    Issue {
        number: raw.number,
        title: raw.title,
        url: raw.url,
        repo: raw.repository.name_with_owner,
        author: super::author_or_deleted(raw.author.map(|a| a.login)),
        comments_count: raw.comments.total_count,
        updated_at: raw.updated_at,
        labels: raw
            .labels
            .nodes
            .into_iter()
            .map(|l| Label { name: l.name, color: l.color })
            .collect(),
    }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
    use super::*;

    fn make_base_pr_json(
        number: u32,
        check_state: &str,
        conclusion: &str,
        review_decision: &str,
        is_draft: bool,
    ) -> serde_json::Value {
        serde_json::json!({
            "number": number,
            "title": "Test PR",
            "url": "https://github.com/owner/repo/pull/1",
            "isDraft": is_draft,
            "mergeable": "MERGEABLE",
            "mergeStateStatus": "CLEAN",
            "reviewDecision": review_decision,
            "repository": { "nameWithOwner": "owner/repo" },
            "author": { "login": "author-login" },
            "updatedAt": "2024-01-01T00:00:00Z",
            "baseRefName": "main",
            "headRefName": "feat/test-branch",
            "commits": {
                "totalCount": 1,
                "nodes": [{
                    "commit": {
                        "statusCheckRollup": {
                            "state": check_state,
                            "contexts": {
                                "nodes": [{
                                    "name": "CI",
                                    "status": "COMPLETED",
                                    "conclusion": conclusion,
                                    "checkSuite": null
                                }]
                            }
                        }
                    }
                }]
            },
            "comments": { "totalCount": 0 },
            "reviewRequests": { "nodes": [] },
            "reviewThreads": { "nodes": [] },
            "latestReviews": { "nodes": [] }
        })
    }

    /// A PR with failing CI and `CHANGES_REQUESTED` review decision must
    /// deserialize correctly and produce the right domain values.
    #[test]
    fn failing_ci_and_changes_requested() {
        // GraphQL returns enum values in SCREAMING_SNAKE_CASE, so the
        // conclusion is `FAILURE` (not lowercase as the REST API uses).
        let json = make_base_pr_json(1, "FAILURE", "FAILURE", "CHANGES_REQUESTED", false);
        let raw: RawPr = serde_json::from_value(json).expect("deserialize RawPr");
        let pr = raw_pr_to_domain(raw);

        assert_eq!(pr.check_state, Some(CheckState::Failure));
        assert_eq!(pr.review_decision, Some(ReviewDecision::ChangesRequested));
        assert_eq!(pr.failing_checks.len(), 1);
        assert_eq!(pr.failing_checks[0].name, "CI");
    }

    /// A clean, approved PR must have an empty `failing_checks` list.
    #[test]
    fn clean_approved_pr() {
        let json = make_base_pr_json(2, "SUCCESS", "success", "APPROVED", false);
        let raw: RawPr = serde_json::from_value(json).expect("deserialize RawPr");
        let pr = raw_pr_to_domain(raw);

        assert_eq!(pr.check_state, Some(CheckState::Success));
        assert_eq!(pr.review_decision, Some(ReviewDecision::Approved));
        assert!(pr.failing_checks.is_empty(), "clean PR should have no failing checks");
    }

    /// `build_show_all_query` must include each tracked repo as a `repo:`
    /// qualifier in both the PR and issue search strings.
    #[test]
    fn build_show_all_query_includes_repo_qualifiers() {
        let repos = vec!["owner/alpha".to_owned(), "owner/beta".to_owned()];
        let query = build_show_all_query(&repos);

        assert!(query.contains("repo:owner/alpha"), "must contain repo:owner/alpha");
        assert!(query.contains("repo:owner/beta"), "must contain repo:owner/beta");
        // Both qualifiers must appear in each search (PR and issue).
        assert!(
            query.matches("repo:owner/alpha").count() >= 2,
            "repo:owner/alpha must appear in both PR and issue searches"
        );
        assert!(
            query.matches("repo:owner/beta").count() >= 2,
            "repo:owner/beta must appear in both PR and issue searches"
        );
    }

    /// `build_show_all_query` with an empty repo list must still produce a
    /// syntactically valid query document (no repo qualifiers, zero results).
    #[test]
    fn build_show_all_query_empty_repos() {
        let query = build_show_all_query(&[]);
        // Must contain the required structural landmarks.
        assert!(query.contains("allPrs"), "must contain allPrs alias");
        assert!(query.contains("allIssues"), "must contain allIssues alias");
        assert!(query.contains("PullRequestFields"), "must reference PullRequestFields fragment");
        assert!(query.contains("IssueFields"), "must reference IssueFields fragment");
    }

    /// PRs with the same `(repo, number)` appearing in two buckets must be
    /// merged into one entry with both roles.
    #[test]
    fn dedup_unions_roles() {
        let pr_json = make_base_pr_json(1, "SUCCESS", "success", "APPROVED", false);
        let raw1: RawPr = serde_json::from_value(pr_json.clone()).expect("deserialize");
        let raw2: RawPr = serde_json::from_value(pr_json).expect("deserialize");

        let data = ResponseData {
            authored: AuthoredViewer {
                login: "viewer".to_owned(),
                pull_requests: NodeList { nodes: vec![raw1] },
            },
            review_requested: SearchResult { nodes: vec![Some(SearchNode::Pr(raw2))] },
            assigned_prs: SearchResult { nodes: vec![] },
            assigned_issues: SearchResult { nodes: vec![] },
        };

        let inbox = to_inbox("viewer".to_owned(), data);
        assert_eq!(inbox.prs.len(), 1, "duplicate PR must be merged");
        let roles = &inbox.prs[0].roles;
        assert!(roles.contains(&Role::Author), "Author role missing");
        assert!(roles.contains(&Role::Reviewer), "Reviewer role missing");
    }
}