interprex 2.0.0

Provider-neutral models and async interfaces for development platforms
Documentation
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
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::{ModelError, Repository, Result};

platform_number!(ChangeRequestNumber);
platform_number!(ReviewLine);

const BRANCH_REF_PREFIX: &str = "refs/heads/";

/// Characters `git check-ref-format` forbids anywhere in a ref.
const FORBIDDEN_BRANCH_CHARACTERS: [char; 7] = ['~', '^', ':', '?', '*', '[', '\\'];

/// Why a string names no branch a change request can propose.
#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
pub enum InvalidHeadRef {
    #[error("head ref must be fully qualified as refs/heads/<branch>")]
    NotABranchRef,
    #[error("head ref names no branch")]
    NoBranch,
    #[error("branch name is one git refuses to create")]
    InvalidBranchName,
}

/// The branch a change request proposes, and the repository holding it.
///
/// A change request belongs to the repository it targets, while its head
/// branch can live in a fork of that repository, so the two are separate
/// facts and a caller states both. Construction reads the branch out of a
/// fully qualified head ref, so a value of this type always names a branch
/// that repository could hold, and no provider has to guess either half.
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(try_from = "SerializedHead", into = "SerializedHead")]
pub struct ChangeRequestHead {
    repository: Repository,
    branch: String,
}

/// The serialized form of a head, which reads back through the same
/// validation a caller's construction passes.
#[derive(Clone, Deserialize, Serialize)]
struct SerializedHead {
    repository: Repository,
    head_ref: String,
}

impl TryFrom<SerializedHead> for ChangeRequestHead {
    type Error = InvalidHeadRef;

    fn try_from(value: SerializedHead) -> std::result::Result<Self, Self::Error> {
        Self::new(value.repository, &value.head_ref)
    }
}

impl From<ChangeRequestHead> for SerializedHead {
    fn from(value: ChangeRequestHead) -> Self {
        Self {
            head_ref: format!("{BRANCH_REF_PREFIX}{}", value.branch),
            repository: value.repository,
        }
    }
}

impl ChangeRequestHead {
    /// Reads a fully qualified `refs/heads/<branch>` ref in `repository`.
    ///
    /// One spelling rather than two keeps every branch addressable: accepting a
    /// bare branch name as well would leave a branch literally named
    /// `refs/heads/main` unreachable, because that string also qualifies
    /// branch `main`. Written this way it is `refs/heads/refs/heads/main` and
    /// stays distinct.
    pub fn new(
        repository: Repository,
        head_ref: &str,
    ) -> std::result::Result<Self, InvalidHeadRef> {
        Ok(Self {
            repository,
            branch: head_branch(head_ref)?.to_owned(),
        })
    }

    #[must_use]
    pub fn repository(&self) -> &Repository {
        &self.repository
    }

    /// The branch this head names, without its `refs/heads/` qualification.
    #[must_use]
    pub fn branch(&self) -> &str {
        &self.branch
    }
}

fn head_branch(head_ref: &str) -> std::result::Result<&str, InvalidHeadRef> {
    let branch = head_ref
        .strip_prefix(BRANCH_REF_PREFIX)
        .ok_or(InvalidHeadRef::NotABranchRef)?;
    if branch.is_empty() {
        return Err(InvalidHeadRef::NoBranch);
    }
    if creatable_branch_name(branch) {
        Ok(branch)
    } else {
        Err(InvalidHeadRef::InvalidBranchName)
    }
}

/// Whether git would create a branch of this name, by the rules
/// `git check-ref-format` applies to `refs/heads/<branch>`.
fn creatable_branch_name(branch: &str) -> bool {
    if branch == "@"
        || branch == "HEAD"
        || branch.starts_with('-')
        || branch.ends_with('.')
        || branch.contains("..")
        || branch.contains("@{")
    {
        return false;
    }
    if branch.chars().any(|character| {
        character.is_ascii_control()
            || character == ' '
            || FORBIDDEN_BRANCH_CHARACTERS.contains(&character)
    }) {
        return false;
    }
    branch.split('/').all(|component| {
        !component.is_empty() && !component.starts_with('.') && !component.ends_with(".lock")
    })
}

macro_rules! opaque_review_id {
    ($name:ident, $field:literal, $entity:literal) => {
        #[doc = concat!("Opaque provider identifier for a ", $entity, ".")]
        ///
        /// Consumers retain this value only to address the same entity
        /// through the provider that returned it. Its representation has no
        /// provider-neutral meaning.
        #[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
        #[serde(transparent)]
        pub struct $name(String);

        impl $name {
            pub fn new(value: impl Into<String>) -> std::result::Result<Self, crate::ModelError> {
                let value = value.into();
                if value.is_empty() {
                    return Err(crate::ModelError::Empty { field: $field });
                }
                Ok(Self(value))
            }

            #[must_use]
            pub fn as_str(&self) -> &str {
                &self.0
            }
        }
    };
}

opaque_review_id!(ReviewId, "review id", "review");
opaque_review_id!(ReviewThreadId, "review thread id", "review thread");
opaque_review_id!(ReviewCommentId, "review comment id", "review comment");
opaque_review_id!(ReviewRequestId, "review request id", "review request");
opaque_review_id!(ReviewActorId, "review actor id", "review actor");
opaque_review_id!(ReviewTeamId, "review team id", "review team");
opaque_review_id!(ProviderAppId, "provider app id", "provider application");

/// Two commit endpoints whose relationship is meaningful to the caller.
///
/// The endpoints do not assert ancestry; a force push can make them siblings.
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub struct CommitRange {
    pub base_sha: String,
    pub head_sha: String,
}

/// The exact code revision attached to a review.
///
/// Some providers, including GitHub, retain the reviewed head commit but not
/// the base commit as it existed when a historical review was submitted.
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub struct ReviewedRevision {
    pub head_sha: String,
}

#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewActorKind {
    User,
    Bot,
    Placeholder,
    Organization,
    EnterpriseUser,
}

#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub struct ReviewActor {
    pub id: ReviewActorId,
    pub login: String,
    pub kind: ReviewActorKind,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewTeamKind {
    Organization,
    Enterprise,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ReviewTeam {
    pub id: ReviewTeamId,
    pub slug: String,
    pub name: String,
    pub kind: ReviewTeamKind,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewTarget {
    Actor(ReviewActor),
    Team(ReviewTeam),
    Unavailable,
}

/// One provider address to add to the outstanding reviewer set.
///
/// User and bot values are logins. A team value is its canonical provider
/// identifier, such as `organization/team-slug` on GitHub. Targets observed
/// in a read can contain richer facts or unavailable identities, so writes use
/// this deliberately narrower shape.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewRequestTarget {
    User(String),
    Bot(String),
    Team(String),
}

/// One outstanding request, including one whose target became unavailable.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ReviewRequest {
    pub id: ReviewRequestId,
    pub target: ReviewTarget,
    /// The provider address that can request this target again, when one is
    /// available. This is independent of the target's observed actor or team
    /// category.
    pub request_target: Option<ReviewRequestTarget>,
    /// When the platform recorded the request that is still outstanding.
    ///
    /// A platform can list its outstanding requests without timing them, so a
    /// provider reads the request events separately and matches each
    /// outstanding request to the event that created it. A provider reports
    /// `None` when that match fails: the request predates the retained event
    /// history, or the target carries no identity to match against, which is
    /// the case for every [`ReviewTarget::Unavailable`] a provider returns. A
    /// provider never substitutes a nearby timestamp, so a caller measuring
    /// how long a request has been outstanding reads `None` as no measurement
    /// rather than an approximate one.
    ///
    /// Where the outstanding requests and the request events are separate
    /// reads with no snapshot across them, this is the time on the target's
    /// latest surviving request event when the events were read: a target
    /// re-requested between the two reads reports the newer request's time.
    pub requested_at: Option<DateTime<Utc>>,
    pub as_code_owner: bool,
}

/// The GitHub App or equivalent provider application that produced a review or
/// published a check.
///
/// This is attribution. It is neither the actor a review is credited to nor
/// the identity a provider authenticated as.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ProviderApp {
    pub id: ProviderAppId,
    pub slug: String,
    pub name: String,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewDisposition {
    Approved,
    ChangesRequested,
    Commented,
    Dismissed,
}

/// What the provider can establish about a review author's relationship to
/// the change request.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewRelationship {
    ChangeAuthor,
    Other,
    Unknown,
}

/// The author of a review and the provider's knowledge of that actor's
/// relationship to the change request.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewAuthor {
    ChangeAuthor,
    Other(ReviewActor),
    Unknown(ReviewActor),
}

impl ReviewAuthor {
    #[must_use]
    pub const fn relationship(&self) -> ReviewRelationship {
        match self {
            Self::ChangeAuthor => ReviewRelationship::ChangeAuthor,
            Self::Other(_) => ReviewRelationship::Other,
            Self::Unknown(_) => ReviewRelationship::Unknown,
        }
    }

    #[must_use]
    pub fn actor<'a>(&'a self, change_author: &'a ReviewActor) -> &'a ReviewActor {
        match self {
            Self::ChangeAuthor => change_author,
            Self::Other(actor) | Self::Unknown(actor) => actor,
        }
    }
}

/// Whether a review is still a draft or has been submitted.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewState {
    Draft,
    Submitted {
        disposition: ReviewDisposition,
        submitted_at: DateTime<Utc>,
    },
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewThreadStatus {
    Open,
    Resolved,
}

/// The addressing user's assessment of a finding's effect on the change.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FindingSeverity {
    Critical,
    Major,
    Minor,
    Nit,
}

/// Why the addressing user considers a finding complete.
///
/// The variants and serialized spellings match GitHub's
/// `PullRequestReviewThreadResolutionReason` enum.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum FindingResolutionReason {
    /// The review comment was addressed.
    Addressed,
    /// The review comment is invalid.
    Invalid,
    /// The review comment will not be addressed.
    WontFix,
}

/// The addressing user's recorded conclusion for one finding.
///
/// This is distinct from [`ReviewThreadStatus`]. A platform thread can have no
/// Interprex resolution because it was resolved outside this interface, and a
/// failed multi-request provider operation can leave a recorded conclusion on
/// a thread the platform still reports as open.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct FindingResolution {
    pub reason: FindingResolutionReason,
    /// The severity assigned by the user addressing the finding. It need not
    /// match a severity stated by the reviewer.
    pub addressing_severity: FindingSeverity,
}

/// The nonblank visible explanation attached to a finding resolution.
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(try_from = "String", into = "String")]
pub struct FindingResolutionReply(String);

impl FindingResolutionReply {
    pub fn new(value: impl Into<String>) -> std::result::Result<Self, ModelError> {
        let value = value.into();
        if value.trim().is_empty() {
            return Err(ModelError::Empty {
                field: "finding resolution reply",
            });
        }
        Ok(Self(value))
    }

    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl TryFrom<String> for FindingResolutionReply {
    type Error = ModelError;

    fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
        Self::new(value)
    }
}

impl From<FindingResolutionReply> for String {
    fn from(value: FindingResolutionReply) -> Self {
        value.0
    }
}

/// One observed finding resolution and the reply that recorded it.
///
/// The source reply identifier links to the addressing actor, explanation and
/// platform timestamps in the containing thread's replies.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case", tag = "compatibility")]
pub enum FindingResolutionRecord {
    Supported {
        resolution: FindingResolution,
        source_reply_id: ReviewCommentId,
    },
    Unsupported {
        metadata_format: String,
        source_reply_id: ReviewCommentId,
    },
}

impl FindingResolutionRecord {
    #[must_use]
    pub fn supported_resolution(&self) -> Option<FindingResolution> {
        match self {
            Self::Supported { resolution, .. } => Some(*resolution),
            Self::Unsupported { .. } => None,
        }
    }

    #[must_use]
    pub fn source_reply_id(&self) -> &ReviewCommentId {
        match self {
            Self::Supported {
                source_reply_id, ..
            }
            | Self::Unsupported {
                source_reply_id, ..
            } => source_reply_id,
        }
    }
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ReviewComment {
    pub id: ReviewCommentId,
    pub author: ReviewActor,
    pub body: String,
    pub created_at: DateTime<Utc>,
    /// The last known edit time, when the provider supplies one.
    pub updated_at: Option<DateTime<Utc>>,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ReviewLineRange {
    pub start: Option<ReviewLine>,
    pub end: ReviewLine,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewDiffSide {
    Left,
    Right,
}

/// The stable source anchor within the file containing an inline review
/// thread. A line range records the location at which the thread began.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewAnchor {
    File,
    Lines {
        side: ReviewDiffSide,
        original: ReviewLineRange,
        current: Option<ReviewLineRange>,
    },
}

/// The file and anchor of an inline review thread.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ReviewLocation {
    pub path: String,
    pub anchor: ReviewAnchor,
}

/// The facts shared by findings and standalone inline threads.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ReviewThread {
    pub id: ReviewThreadId,
    pub location: ReviewLocation,
    pub outdated: bool,
    pub status: ReviewThreadStatus,
    pub comment: ReviewComment,
    pub replies: Vec<ReviewComment>,
}

/// One inline thread attached to the review in which it originated.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ReviewFinding {
    #[serde(flatten)]
    pub thread: ReviewThread,
    pub resolution: Option<FindingResolutionRecord>,
}

impl std::ops::Deref for ReviewFinding {
    type Target = ReviewThread;

    fn deref(&self) -> &Self::Target {
        &self.thread
    }
}

impl std::ops::DerefMut for ReviewFinding {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.thread
    }
}

impl ReviewFinding {
    /// Returns the reply that records this finding's resolution.
    #[must_use]
    pub fn resolution_reply(&self) -> Option<&ReviewComment> {
        let reply_id = self.resolution.as_ref()?.source_reply_id();
        self.replies.iter().find(|reply| &reply.id == reply_id)
    }
}

/// One platform review, including drafts and reviews by the change author.
///
/// Multiple reviews by the same actor remain independent. Relationship is an
/// observed fact, not a decision about whether the review counts as independent
/// evidence.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Review {
    pub id: ReviewId,
    pub author: ReviewAuthor,
    pub via_app: Option<ProviderApp>,
    pub revision: ReviewedRevision,
    pub state: ReviewState,
    pub summary: Option<String>,
    pub findings: Vec<ReviewFinding>,
}

/// Whether a change request is open, closed without merging, or merged.
///
/// Platforms report merging separately from closing, so `Closed` states that
/// the change did not land and `Merged` carries the merge time the platform
/// recorded. A state Interprex does not model, such as a locked merge request,
/// is unrepresentable rather than reported as the nearest variant.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangeRequestState {
    Open,
    Closed,
    Merged { merged_at: DateTime<Utc> },
}

/// Whether the platform can currently merge the change request's source into
/// its target branch.
///
/// This reports the platform's merge computation and nothing else. Required
/// checks, approvals and branch rules are separate facts, so a mergeable
/// change request can still be one the platform refuses to merge.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Mergeability {
    /// The platform reports no conflict between the source and the target.
    Mergeable,
    /// The platform reports a conflict that a person must resolve.
    Conflicted,
    /// The platform published no answer. GitHub computes the merge after the
    /// read arrives and reports nothing until that finishes, so this is an
    /// observed platform state rather than a failure to read the fact.
    Unknown,
}

/// One complete observation of a change request and its code-review data.
///
/// The provider completely paginates every declared collection and never
/// silently drops an entity it cannot normalize. Platforms need not provide a
/// transactional snapshot across independently mutable collections.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ChangeRequest {
    pub number: ChangeRequestNumber,
    pub title: String,
    pub state: ChangeRequestState,
    pub draft: bool,
    pub commit_range: CommitRange,
    /// The branch this change request targets, whose tip at observation time
    /// is `commit_range.base_sha`.
    ///
    /// The branch is named because a sha cannot identify it: branches share
    /// tips and advance between observations. Two open change requests
    /// proposing the same head differ by this branch, so a caller choosing
    /// among them reads a fact rather than inferring one.
    pub base_branch: String,
    /// The branch this change request proposes and the repository holding it,
    /// which is this repository or a fork of it.
    ///
    /// `None` when the provider no longer identifies where the branch lived,
    /// as GitHub reports for a change request whose fork was deleted. A branch
    /// name alone is not a head, so it is absent rather than paired with a
    /// guessed repository.
    pub head: Option<ChangeRequestHead>,
    pub mergeability: Mergeability,
    pub author: ReviewActor,
    pub updated_at: DateTime<Utc>,
    /// Platform reviews. Collection order carries no policy meaning.
    pub reviews: Vec<Review>,
    /// Inline threads that did not originate in a review.
    pub standalone_threads: Vec<ReviewThread>,
    /// Comments with no source location, in chronological order.
    pub unanchored_comments: Vec<ReviewComment>,
    /// The currently outstanding reviewer requests.
    pub outstanding_requests: Vec<ReviewRequest>,
}

/// The conclusion an observed check reached once it finished.
///
/// The variants cover the conclusions GitHub reports for a check run, so a
/// read never has to discard one. That set is wider by one than the set a
/// client may write, because GitHub sets `stale` itself. Publishing uses the
/// narrower [`PublishedCheckConclusion`].
///
/// `startup_failure` is absent deliberately: GitHub reports it for a check
/// suite that failed before its runs began and states that it does not apply
/// to check runs. The jobs domain models it as
/// `RunConclusion::StartupFailure`, where it is observable.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckConclusion {
    Success,
    Failure,
    Neutral,
    Cancelled,
    TimedOut,
    ActionRequired,
    Skipped,
    Stale,
}

/// The conclusion a published check can report.
///
/// This is narrower than the observed [`CheckConclusion`] by one variant:
/// GitHub sets `stale` on a check run itself and refuses it from a client, so
/// no value here can produce that request.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PublishedCheckConclusion {
    Success,
    Failure,
    Neutral,
    Cancelled,
    TimedOut,
    ActionRequired,
    Skipped,
}

/// Where an observed check stands, and what it concluded once it has
/// finished.
///
/// A check that has not finished has no conclusion, so the two facts stay in
/// one value rather than in an optional field that could contradict a status.
/// The variants before `Completed` are the platform's own, one for each status
/// GitHub reports on a check run, because a stalled check and a running one
/// call for different reporting and Interprex does not decide which
/// distinctions a caller needs.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckStatus {
    /// The check exists and has not been queued yet.
    Requested,
    /// The check is queued to run.
    Queued,
    /// The check is held back, on GitHub because a concurrency limit is
    /// reached.
    Pending,
    /// The check is held back until a deployment protection rule is
    /// satisfied.
    Waiting,
    /// The check is running.
    InProgress,
    Completed {
        conclusion: CheckConclusion,
        completed_at: DateTime<Utc>,
    },
}

impl CheckStatus {
    /// The conclusion this check reached, and `None` while it has not
    /// finished.
    #[must_use]
    pub const fn conclusion(&self) -> Option<CheckConclusion> {
        match self {
            Self::Completed { conclusion, .. } => Some(*conclusion),
            Self::Requested | Self::Queued | Self::Pending | Self::Waiting | Self::InProgress => {
                None
            }
        }
    }
}

/// One check the platform recorded against a commit.
///
/// A required-check rule names the check it requires by `name`, which is
/// `RequiredCheck::context` on the code-hosting side, and may also name the
/// application that must publish it, which is `RequiredCheck::integration_id`.
/// `via_app` carries that application as the platform reported it. The two
/// identifiers hold the same GitHub app identifier in different types: an
/// integer in the rule, and its decimal spelling in `ProviderAppId`, which is
/// opaque because other providers need not use integers. A caller comparing
/// them today compares `via_app.id.as_str()` against
/// `integration_id.to_string()`. Interprex performs no part of that
/// comparison.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct CheckRun {
    pub name: String,
    pub head_sha: String,
    /// The application that published the check, when the platform names one.
    pub via_app: Option<ProviderApp>,
    pub status: CheckStatus,
    /// The check's published summary text, when it published nonblank text.
    pub summary: Option<String>,
    /// Where a person can read the check on the platform, when it published a
    /// location.
    pub html_url: Option<String>,
}

/// A finished check result to publish.
///
/// This write shape is deliberately narrower than the observed [`CheckRun`]:
/// Interprex publishes only a result that has concluded, so the conclusion and
/// the summary are both required, and only conclusions a client may set are
/// representable.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct CheckOutcome {
    pub name: String,
    pub head_sha: String,
    pub conclusion: PublishedCheckConclusion,
    pub summary: String,
}

#[async_trait]
pub trait CodeReviewsProvider: Send + Sync {
    /// Reads one complete observation of the change request.
    async fn change_request(
        &self,
        repository: &Repository,
        number: ChangeRequestNumber,
    ) -> Result<ChangeRequest>;
    /// Reads the number of every open change request in `repository` that
    /// proposes `head`.
    ///
    /// A change request belongs to the repository it targets, and its head
    /// branch can live in a fork of that repository, so `repository` and
    /// `head.repository()` are stated separately and can differ. A caller
    /// working from a git checkout names the repository the change request
    /// targets and the branch it pushed, wherever that branch lives.
    ///
    /// A branch can be proposed by more than one open change request against
    /// different bases, so every match is returned; choosing among them is the
    /// caller's policy, made from `ChangeRequest::base_branch` after reading
    /// each candidate through `change_request`. Order carries no policy
    /// meaning, and an empty result means no open change request in
    /// `repository` proposes that head.
    ///
    /// A match proposes exactly `head`: both the repository holding the branch
    /// and the branch itself, so heads differing only by repository name are
    /// different heads. `ChangeRequest::head` reports the same fact for a
    /// change request read by number.
    async fn open_change_requests(
        &self,
        repository: &Repository,
        head: &ChangeRequestHead,
    ) -> Result<Vec<ChangeRequestNumber>>;
    async fn resolve_thread(
        &self,
        repository: &Repository,
        number: ChangeRequestNumber,
        thread_id: &ReviewThreadId,
    ) -> Result<()>;
    /// Records why a finding is complete, records its assessed severity and
    /// marks its platform thread resolved.
    ///
    /// `reply` contains validated visible explanatory text. Providers may add
    /// their own visible or machine-readable representation around it.
    /// Providers whose platforms require more than one request can return an
    /// error after a partial write; a later observation preserves the platform
    /// thread state and any valid resolution record independently.
    ///
    /// Repeating an already recorded resolution does not add another reply. If
    /// that record exists while the platform thread is open, the repeated call
    /// only resolves the thread.
    async fn resolve_finding(
        &self,
        repository: &Repository,
        number: ChangeRequestNumber,
        thread_id: &ReviewThreadId,
        resolution: FindingResolution,
        reply: &FindingResolutionReply,
    ) -> Result<()>;
    /// Adds each target to the outstanding reviewer set.
    ///
    /// A target already present remains one request, so repeating the same call
    /// reaches the same observable state.
    async fn request_reviewers(
        &self,
        repository: &Repository,
        number: ChangeRequestNumber,
        reviewers: &[ReviewRequestTarget],
    ) -> Result<()>;
    async fn mark_ready(&self, repository: &Repository, number: ChangeRequestNumber) -> Result<()>;
    /// Reads the current checks on one commit, completely paginated.
    ///
    /// A check name identifies no more than one run only within a run of
    /// checks that the platform grouped together, which GitHub calls a check
    /// suite. One commit can carry several runs of the same name, published by
    /// several applications or by one application whose workflow was
    /// triggered more than once, and every one of them is returned. Deciding
    /// which of them answers for that name is the caller's, using `via_app`,
    /// the status and the completion time; Interprex discards none of them.
    /// Within one such group a rerun does replace the run it repeated, so no
    /// superseded run is reported.
    ///
    /// A check that has not concluded is returned with the platform's own
    /// status rather than omitted, so a caller can tell a missing check from a
    /// running or a stalled one. Collection order carries no meaning.
    ///
    /// A platform can report less than it holds: GitHub answers from at most
    /// the 1,000 most recent check suites on a commit and gives no signal that
    /// it stopped there, so a commit past that limit is reported short.
    ///
    /// Which of these checks a merge requires comes from the repository's
    /// rulesets, and what a failing required check means for the change
    /// request is the caller's policy. Interprex performs neither step.
    ///
    /// A platform that also keeps a separate legacy commit-status mechanism,
    /// as GitHub does, does not report those statuses here.
    async fn checks(&self, repository: &Repository, head_sha: &str) -> Result<Vec<CheckRun>>;
    async fn publish_check(
        &self,
        repository: &Repository,
        app_name: &str,
        outcome: &CheckOutcome,
    ) -> Result<()>;
}

#[cfg(test)]
mod tests {
    use chrono::TimeZone;

    use super::*;

    fn actor(login: &str) -> ReviewActor {
        ReviewActor {
            id: ReviewActorId::new(format!("actor-{login}")).expect("actor id"),
            login: login.to_owned(),
            kind: ReviewActorKind::Bot,
        }
    }

    fn comment(id: &str, author: ReviewActor) -> ReviewComment {
        ReviewComment {
            id: ReviewCommentId::new(id).expect("comment id"),
            author,
            body: "comment".to_owned(),
            created_at: Utc.timestamp_opt(1, 0).single().expect("timestamp"),
            updated_at: Some(Utc.timestamp_opt(1, 0).single().expect("timestamp")),
        }
    }

    fn thread(id: &str, author: ReviewActor) -> ReviewThread {
        ReviewThread {
            id: ReviewThreadId::new(id).expect("thread id"),
            location: ReviewLocation {
                path: "src/lib.rs".to_owned(),
                anchor: ReviewAnchor::Lines {
                    side: ReviewDiffSide::Right,
                    original: ReviewLineRange {
                        start: None,
                        end: ReviewLine::new(10).expect("line"),
                    },
                    current: Some(ReviewLineRange {
                        start: None,
                        end: ReviewLine::new(10).expect("line"),
                    }),
                },
            },
            outdated: false,
            status: ReviewThreadStatus::Open,
            comment: comment(&format!("comment-{id}"), author),
            replies: Vec::new(),
        }
    }

    fn review(id: &str, author: ReviewActor, findings: Vec<ReviewFinding>) -> Review {
        Review {
            id: ReviewId::new(id).expect("review id"),
            author: ReviewAuthor::Other(author),
            via_app: None,
            revision: ReviewedRevision {
                head_sha: "head".to_owned(),
            },
            state: ReviewState::Submitted {
                disposition: ReviewDisposition::Commented,
                submitted_at: Utc.timestamp_opt(1, 0).single().expect("timestamp"),
            },
            summary: None,
            findings,
        }
    }

    fn sandbox() -> Repository {
        Repository::new("civitas-forge", "sandbox").expect("repository")
    }

    #[test]
    fn head_reads_one_ref_spelling_so_every_branch_stays_addressable() {
        for (head_ref, branch) in [
            ("refs/heads/main", "main"),
            ("refs/heads/feat/open-request", "feat/open-request"),
            ("refs/heads/refs/heads/main", "refs/heads/main"),
        ] {
            let head = ChangeRequestHead::new(sandbox(), head_ref).expect("branch ref");
            assert_eq!(head.branch(), branch);
            assert_eq!(head.repository(), &sandbox());
        }
    }

    #[test]
    fn head_states_why_a_string_names_no_branch() {
        for unqualified in ["", "main", "refs/tags/v1.1.0", "refs/remotes/origin/main"] {
            assert_eq!(
                ChangeRequestHead::new(sandbox(), unqualified),
                Err(InvalidHeadRef::NotABranchRef),
                "{unqualified:?}"
            );
        }
        assert_eq!(
            ChangeRequestHead::new(sandbox(), "refs/heads/"),
            Err(InvalidHeadRef::NoBranch)
        );
        for uncreatable in [
            "refs/heads/@",
            "refs/heads/HEAD",
            "refs/heads/-topic",
            "refs/heads/main.",
            "refs/heads/ma..in",
            "refs/heads/ma@{in",
            "refs/heads/ma:in",
            "refs/heads/ma in",
            "refs/heads/main\n",
            "refs/heads/ma~in",
            "refs/heads/ma[in",
            "refs/heads/ma\\in",
            "refs/heads/feat//open",
            "refs/heads/feat/",
            "refs/heads//feat",
            "refs/heads/feat/.hidden",
            "refs/heads/feat/open.lock",
        ] {
            assert_eq!(
                ChangeRequestHead::new(sandbox(), uncreatable),
                Err(InvalidHeadRef::InvalidBranchName),
                "{uncreatable:?}"
            );
        }
    }

    #[test]
    fn head_keeps_the_branch_characters_git_permits() {
        for permitted in [
            "refs/heads/mai\u{00a0}n",
            "refs/heads/feature.lockfile",
            "refs/heads/rele.ase",
            "refs/heads/ma@in",
            "refs/heads/feat/-topic",
            "refs/heads/feat/HEAD",
        ] {
            assert!(
                ChangeRequestHead::new(sandbox(), permitted).is_ok(),
                "{permitted:?}"
            );
        }
    }

    #[test]
    fn findings_and_standalone_threads_remain_structurally_distinct() {
        let reviewer = actor("reviewer");
        let author = ReviewActor {
            id: ReviewActorId::new("actor-author").expect("actor id"),
            login: "author".to_owned(),
            kind: ReviewActorKind::User,
        };
        let change_request = ChangeRequest {
            number: ChangeRequestNumber::new(1).expect("number"),
            title: "Author threads".to_owned(),
            state: ChangeRequestState::Open,
            draft: false,
            commit_range: CommitRange {
                base_sha: "base".to_owned(),
                head_sha: "head".to_owned(),
            },
            base_branch: "main".to_owned(),
            head: Some(
                ChangeRequestHead::new(
                    Repository::new("civitas-forge", "sandbox").expect("repository"),
                    "refs/heads/author-threads",
                )
                .expect("head"),
            ),
            mergeability: Mergeability::Mergeable,
            author: author.clone(),
            updated_at: Utc.timestamp_opt(2, 0).single().expect("timestamp"),
            reviews: vec![review(
                "review-1",
                reviewer.clone(),
                vec![ReviewFinding {
                    thread: thread("finding", reviewer),
                    resolution: None,
                }],
            )],
            standalone_threads: vec![thread("standalone", author)],
            unanchored_comments: Vec::new(),
            outstanding_requests: Vec::new(),
        };

        assert_eq!(change_request.reviews[0].findings.len(), 1);
        assert_eq!(change_request.standalone_threads.len(), 1);
    }

    #[test]
    fn only_merged_change_requests_carry_a_merge_time() {
        let merged_at = Utc.timestamp_opt(3, 0).single().expect("timestamp");

        assert_eq!(
            serde_json::to_value(ChangeRequestState::Open).expect("serializes open state"),
            serde_json::json!("open")
        );
        assert_eq!(
            serde_json::to_value(ChangeRequestState::Closed).expect("serializes closed state"),
            serde_json::json!("closed")
        );
        assert_eq!(
            serde_json::to_value(ChangeRequestState::Merged { merged_at })
                .expect("serializes merged state"),
            serde_json::json!({ "merged": { "merged_at": "1970-01-01T00:00:03Z" } })
        );
        assert_ne!(
            ChangeRequestState::Closed,
            ChangeRequestState::Merged { merged_at }
        );
    }

    #[test]
    fn finding_resolution_reasons_use_githubs_enum_spellings() {
        for (reason, expected) in [
            (FindingResolutionReason::Addressed, "ADDRESSED"),
            (FindingResolutionReason::Invalid, "INVALID"),
            (FindingResolutionReason::WontFix, "WONT_FIX"),
        ] {
            let resolution = FindingResolution {
                reason,
                addressing_severity: FindingSeverity::Major,
            };

            assert_eq!(
                serde_json::to_value(resolution).expect("serializes resolution"),
                serde_json::json!({
                    "reason": expected,
                    "addressing_severity": "major"
                })
            );
        }
    }

    #[test]
    fn finding_resolution_replies_require_visible_explanatory_text() {
        assert!(FindingResolutionReply::new("\n\t").is_err());
        let reply = FindingResolutionReply::new("Addressed in the current revision.")
            .expect("visible explanation");
        assert_eq!(reply.as_str(), "Addressed in the current revision.");
    }

    #[test]
    fn mergeability_keeps_an_uncomputed_merge_distinct_from_a_conflicted_one() {
        for (mergeability, expected) in [
            (Mergeability::Mergeable, "mergeable"),
            (Mergeability::Conflicted, "conflicted"),
            (Mergeability::Unknown, "unknown"),
        ] {
            assert_eq!(
                serde_json::to_value(mergeability).expect("serializes mergeability"),
                serde_json::json!(expected)
            );
        }
        assert_ne!(Mergeability::Unknown, Mergeability::Conflicted);
    }

    #[test]
    fn every_status_before_completion_carries_no_conclusion() {
        for status in [
            CheckStatus::Requested,
            CheckStatus::Queued,
            CheckStatus::Pending,
            CheckStatus::Waiting,
            CheckStatus::InProgress,
        ] {
            assert_eq!(status.conclusion(), None);
        }
    }

    #[test]
    fn an_observed_check_carries_a_conclusion_only_once_it_has_completed() {
        let running = CheckRun {
            name: "quality".to_owned(),
            head_sha: "head".to_owned(),
            via_app: None,
            status: CheckStatus::InProgress,
            summary: None,
            html_url: None,
        };
        let completed = CheckRun {
            status: CheckStatus::Completed {
                conclusion: CheckConclusion::TimedOut,
                completed_at: Utc.timestamp_opt(3, 0).single().expect("timestamp"),
            },
            summary: Some("The job exceeded its limit.".to_owned()),
            ..running.clone()
        };

        assert_eq!(running.status.conclusion(), None);
        assert_eq!(
            completed.status.conclusion(),
            Some(CheckConclusion::TimedOut)
        );
        assert_eq!(
            serde_json::to_value(&running.status).expect("serializes running status"),
            serde_json::json!("in_progress")
        );
        assert_eq!(
            serde_json::to_value(&completed.status).expect("serializes completed status"),
            serde_json::json!({
                "completed": {
                    "conclusion": "timed_out",
                    "completed_at": "1970-01-01T00:00:03Z"
                }
            })
        );
    }

    #[test]
    fn check_conclusions_cover_every_conclusion_a_check_run_reports() {
        for (conclusion, expected) in [
            (CheckConclusion::Success, "success"),
            (CheckConclusion::Failure, "failure"),
            (CheckConclusion::Neutral, "neutral"),
            (CheckConclusion::Cancelled, "cancelled"),
            (CheckConclusion::TimedOut, "timed_out"),
            (CheckConclusion::ActionRequired, "action_required"),
            (CheckConclusion::Skipped, "skipped"),
            (CheckConclusion::Stale, "stale"),
        ] {
            assert_eq!(
                serde_json::to_value(conclusion).expect("serializes conclusion"),
                serde_json::json!(expected)
            );
        }
    }

    #[test]
    fn observed_target_kind_and_request_address_are_independent() {
        let organization_team = ReviewRequest {
            id: ReviewRequestId::new("request-organization").expect("request id"),
            target: ReviewTarget::Team(ReviewTeam {
                id: ReviewTeamId::new("team-organization").expect("team id"),
                slug: "maintainers".to_owned(),
                name: "Maintainers".to_owned(),
                kind: ReviewTeamKind::Organization,
            }),
            request_target: None,
            requested_at: None,
            as_code_owner: false,
        };
        let enterprise_team = ReviewRequest {
            id: ReviewRequestId::new("request-enterprise").expect("request id"),
            target: ReviewTarget::Team(ReviewTeam {
                id: ReviewTeamId::new("team-enterprise").expect("team id"),
                slug: "security".to_owned(),
                name: "Security".to_owned(),
                kind: ReviewTeamKind::Enterprise,
            }),
            request_target: Some(ReviewRequestTarget::Team("security".to_owned())),
            requested_at: Utc.timestamp_opt(3, 0).single(),
            as_code_owner: false,
        };

        assert_eq!(organization_team.request_target, None);
        assert_eq!(
            enterprise_team.request_target,
            Some(ReviewRequestTarget::Team("security".to_owned()))
        );
        assert_eq!(organization_team.requested_at, None);
        assert_eq!(
            enterprise_team.requested_at,
            Utc.timestamp_opt(3, 0).single()
        );
    }
}