1use std::collections::BTreeMap;
11use std::fmt;
12
13use serde::de::{self, Deserializer, Visitor};
14use serde::{Deserialize, Serialize, Serializer};
15
16fn norm_token(text: &str) -> String {
17 text.trim()
18 .to_lowercase()
19 .chars()
20 .filter(|c| c.is_ascii_alphanumeric())
21 .collect()
22}
23
24macro_rules! string_enum {
25 (
26 $(#[$meta:meta])*
27 pub enum $name:ident {
28 $( $(#[$vmeta:meta])* $variant:ident = $canonical:literal $( | $alias:literal )* ),+ $(,)?
29 }
30 ) => {
31 $(#[$meta])*
32 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
33 pub enum $name { $( $(#[$vmeta])* $variant ),+ }
34
35 impl $name {
36 pub fn as_str(self) -> &'static str {
37 match self { $( $name::$variant => $canonical ),+ }
38 }
39
40 pub fn parse_lenient(text: &str) -> Option<Self> {
43 let got = norm_token(text);
44 $(
45 if got == norm_token($canonical) $( || got == norm_token($alias) )* {
46 return Some($name::$variant);
47 }
48 )+
49 None
50 }
51
52 pub fn valid_values() -> String {
53 [$( $canonical ),+].join(", ")
54 }
55 }
56
57 impl fmt::Display for $name {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 f.write_str(self.as_str())
60 }
61 }
62
63 impl Serialize for $name {
64 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
65 s.serialize_str(self.as_str())
66 }
67 }
68
69 impl<'de> Deserialize<'de> for $name {
70 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
71 let raw = String::deserialize(d)?;
72 $name::parse_lenient(&raw).ok_or_else(|| {
73 de::Error::custom(format!(
74 "{} is not one of: {}",
75 raw,
76 $name::valid_values()
77 ))
78 })
79 }
80 }
81 };
82}
83
84string_enum! {
85 pub enum Complexity {
87 S = "s" | "small" | "sm" | "xs" | "trivial",
88 M = "m" | "medium" | "med" | "moderate",
89 L = "l" | "large" | "lg" | "xl" | "big" | "huge",
90 }
91}
92
93string_enum! {
94 pub enum Risk {
96 Low = "low" | "l" | "minimal" | "none",
97 Med = "med" | "medium" | "m" | "moderate",
98 High = "high" | "h" | "severe" | "critical",
99 }
100}
101
102string_enum! {
103 pub enum Severity {
108 Blocking = "blocking" | "block" | "major" | "critical",
109 NonBlocking = "non-blocking" | "nonblocking" | "non_blocking" | "minor" | "suggestion",
110 Nit = "nit" | "nitpick" | "style" | "trivial",
111 }
112}
113
114string_enum! {
115 pub enum Verdict {
116 Approve = "approve" | "approved" | "lgtm",
117 ChangesRequested = "changes_requested" | "changes-requested" | "request_changes" | "reject",
118 }
119}
120
121string_enum! {
122 pub enum ReviewVerdict {
127 Approve = "approve" | "approved",
128 RequestChanges = "request_changes" | "changes_requested" | "request-changes",
129 }
130}
131
132impl ReviewVerdict {
133 pub fn api_event(self) -> &'static str {
135 match self {
136 ReviewVerdict::Approve => "APPROVE",
137 ReviewVerdict::RequestChanges => "REQUEST_CHANGES",
138 }
139 }
140
141 pub fn words(self) -> &'static str {
144 match self {
145 ReviewVerdict::Approve => "approve",
146 ReviewVerdict::RequestChanges => "changes requested",
147 }
148 }
149}
150
151string_enum! {
152 pub enum NextAction {
153 Merge = "merge" | "approve" | "ship",
154 FixMyself = "fix_myself" | "fix-myself" | "fix" | "self_fix",
155 HandBack = "hand_back" | "hand-back" | "handback" | "return",
156 }
157}
158
159string_enum! {
160 pub enum Action {
164 Fixed = "fixed" | "fix" | "accepted" | "done",
165 Refuted = "refuted" | "refute" | "rejected" | "disagree" | "wontfix",
166 FiledIssue = "filed_issue" | "filed-issue" | "filed" | "deferred" | "out_of_scope",
167 }
168}
169
170string_enum! {
171 pub enum Ask {
177 Implement = "implement" | "do" | "accept" | "fix",
180 Defer = "defer" | "file_issue" | "filed_issue" | "out_of_scope" | "followup",
182 Decline = "decline" | "refute" | "reject" | "disagree" | "wontfix",
184 Answer = "answer" | "question" | "reply" | "clarify",
186 Nothing = "nothing" | "none" | "no_request" | "noop" | "skip",
188 }
189}
190
191string_enum! {
192 pub enum Screened {
198 StillRelevant = "still_relevant" | "still-relevant" | "relevant" | "keep" | "file",
199 AlreadyFixed = "already_fixed" | "already-fixed" | "fixed" | "done" | "resolved",
200 NotWorthIt = "not_worth_it" | "not-worth-it" | "not_worth_doing" | "skip" | "drop" | "wontfix",
201 Duplicate = "duplicate" | "dupe" | "dup",
202 }
203}
204
205string_enum! {
206 pub enum Status {
208 Pending = "pending",
209 Abandoned = "abandoned",
210 Approved = "approved",
211 Merged = "merged",
212 Escalated = "escalated",
213 Error = "error",
214 Reviewed = "reviewed",
216 Clean = "clean",
218 Answered = "answered",
220 Split = "split",
222 Whole = "whole",
224 Moved = "moved",
228 }
229}
230
231impl Complexity {
232 pub fn rank(self) -> u8 {
233 match self {
234 Complexity::S => 0,
235 Complexity::M => 1,
236 Complexity::L => 2,
237 }
238 }
239}
240
241impl Severity {
242 pub fn rank(self) -> u8 {
246 match self {
247 Severity::Nit => 0,
248 Severity::NonBlocking => 1,
249 Severity::Blocking => 2,
250 }
251 }
252
253 pub fn graver(self, other: Self) -> Self {
259 if self.rank() >= other.rank() {
260 self
261 } else {
262 other
263 }
264 }
265}
266
267impl Risk {
268 pub fn rank(self) -> u8 {
269 match self {
270 Risk::Low => 0,
271 Risk::Med => 1,
272 Risk::High => 2,
273 }
274 }
275}
276
277pub fn de_i64<'de, D: Deserializer<'de>>(d: D) -> Result<i64, D::Error> {
284 struct V;
285 impl<'de> Visitor<'de> for V {
286 type Value = i64;
287 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288 f.write_str("an issue number")
289 }
290 fn visit_i64<E: de::Error>(self, v: i64) -> Result<i64, E> {
291 Ok(v)
292 }
293 fn visit_u64<E: de::Error>(self, v: u64) -> Result<i64, E> {
294 Ok(v as i64)
295 }
296 fn visit_f64<E: de::Error>(self, v: f64) -> Result<i64, E> {
297 Ok(v as i64)
298 }
299 fn visit_str<E: de::Error>(self, v: &str) -> Result<i64, E> {
300 v.trim()
301 .trim_start_matches('#')
302 .parse()
303 .map_err(|_| E::custom(format!("{v} is not a number")))
304 }
305 }
306 d.deserialize_any(V)
307}
308
309fn de_i64_vec<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<i64>, D::Error> {
310 #[derive(Deserialize)]
311 struct One(#[serde(deserialize_with = "de_i64")] i64);
312 let raw = Option::<Vec<One>>::deserialize(d)?;
313 Ok(raw
314 .unwrap_or_default()
315 .into_iter()
316 .map(|One(n)| n)
317 .collect())
318}
319
320pub fn de_bool<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
322 struct V;
323 impl<'de> Visitor<'de> for V {
324 type Value = bool;
325 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326 f.write_str("a boolean")
327 }
328 fn visit_bool<E: de::Error>(self, v: bool) -> Result<bool, E> {
329 Ok(v)
330 }
331 fn visit_i64<E: de::Error>(self, v: i64) -> Result<bool, E> {
332 Ok(v != 0)
333 }
334 fn visit_u64<E: de::Error>(self, v: u64) -> Result<bool, E> {
335 Ok(v != 0)
336 }
337 fn visit_str<E: de::Error>(self, v: &str) -> Result<bool, E> {
338 match norm_token(v).as_str() {
339 "true" | "yes" | "y" | "1" => Ok(true),
340 "false" | "no" | "n" | "0" => Ok(false),
341 other => Err(E::custom(format!("{other} is not a boolean"))),
342 }
343 }
344 }
345 d.deserialize_any(V)
346}
347
348pub fn de_opt_i64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<i64>, D::Error> {
354 Ok(match Option::<serde_json::Value>::deserialize(d)? {
355 Some(serde_json::Value::Number(n)) => n.as_i64(),
356 Some(serde_json::Value::String(s)) => s.trim().trim_start_matches('#').parse().ok(),
357 _ => None,
358 })
359}
360
361fn de_string_vec<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
363 Ok(Option::<Vec<String>>::deserialize(d)?.unwrap_or_default())
364}
365
366fn de_bool_default_true<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
367 #[derive(Deserialize)]
368 struct Wrap(#[serde(deserialize_with = "de_bool")] bool);
369 Ok(Option::<Wrap>::deserialize(d)?
370 .map(|Wrap(b)| b)
371 .unwrap_or(true))
372}
373
374fn de_string<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
375 Ok(Option::<String>::deserialize(d)?.unwrap_or_default())
376}
377
378#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct TriageVerdict {
384 #[serde(deserialize_with = "de_i64")]
385 pub issue: i64,
386 #[serde(deserialize_with = "de_bool")]
387 pub worth_doing: bool,
388 #[serde(default, deserialize_with = "de_bool")]
391 pub tracker: bool,
392 #[serde(default, deserialize_with = "de_string")]
393 pub reason: String,
394 pub complexity: Complexity,
395 #[serde(default, deserialize_with = "de_i64_vec")]
396 pub depends_on: Vec<i64>,
397 pub risk: Risk,
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct TriageResponse {
402 #[serde(default)]
403 pub issues: Vec<TriageVerdict>,
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct ScreenVerdict {
409 #[serde(deserialize_with = "de_i64")]
414 pub entry: i64,
415 pub verdict: Screened,
416 #[serde(default, deserialize_with = "de_string")]
417 pub title: String,
418 #[serde(default, deserialize_with = "de_string")]
419 pub reason: String,
420 #[serde(default, deserialize_with = "de_opt_i64")]
426 pub duplicate_of_issue: Option<i64>,
427 #[serde(default, deserialize_with = "de_opt_i64")]
429 pub duplicate_of_entry: Option<i64>,
430}
431
432impl ScreenVerdict {
433 pub fn names_a_duplicate(&self) -> bool {
435 self.duplicate_of_issue.is_some() || self.duplicate_of_entry.is_some()
436 }
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct ScreenResponse {
441 #[serde(default)]
442 pub entries: Vec<ScreenVerdict>,
443}
444
445#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct SplitScreen {
452 #[serde(deserialize_with = "de_i64")]
455 pub item: i64,
456 #[serde(deserialize_with = "de_bool")]
457 pub split: bool,
458 #[serde(default, deserialize_with = "de_string")]
459 pub reason: String,
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct SplitScreenDoc {
464 #[serde(default)]
465 pub items: Vec<SplitScreen>,
466}
467
468#[derive(Debug, Clone, Default, Serialize, Deserialize)]
470pub struct SplitPart {
471 #[serde(default, deserialize_with = "de_string")]
472 pub title: String,
473 #[serde(default, deserialize_with = "de_string")]
475 pub body: String,
476 #[serde(default, deserialize_with = "de_string_vec")]
479 pub files: Vec<String>,
480}
481
482#[derive(Debug, Clone, Default, Serialize, Deserialize)]
488pub struct SplitProposal {
489 #[serde(default, deserialize_with = "de_bool")]
490 pub should_split: bool,
491 #[serde(default, deserialize_with = "de_string")]
492 pub reason: String,
493 #[serde(default, deserialize_with = "de_bool")]
497 pub stacked: bool,
498 #[serde(default)]
499 pub parts: Vec<SplitPart>,
500}
501
502#[derive(Debug, Clone, Default, Serialize, Deserialize)]
504pub struct SplitCheck {
505 #[serde(default, deserialize_with = "de_bool")]
506 pub accept: bool,
507 #[serde(default, deserialize_with = "de_bool")]
508 pub stacked: bool,
509 #[serde(default, deserialize_with = "de_i64_vec")]
511 pub strike: Vec<i64>,
512 #[serde(default, deserialize_with = "de_string")]
513 pub reasoning: String,
514}
515
516#[derive(Debug, Clone, Serialize, Deserialize)]
518pub struct CommentVerdict {
519 #[serde(default, deserialize_with = "de_string")]
524 pub ref_id: String,
525 pub ask: Ask,
526 #[serde(default, deserialize_with = "de_string")]
529 pub request: String,
530 #[serde(default, deserialize_with = "de_string")]
533 pub reasoning: String,
534 #[serde(deserialize_with = "de_bool")]
537 pub unambiguous: bool,
538 #[serde(default)]
539 pub new_issue_title: Option<String>,
540 #[serde(default)]
541 pub new_issue_body: Option<String>,
542}
543
544#[derive(Debug, Clone, Serialize, Deserialize)]
545pub struct CheckinDoc {
546 #[serde(default)]
547 pub verdicts: Vec<CommentVerdict>,
548}
549
550#[derive(Debug, Clone, Serialize, Deserialize)]
552pub struct CommentCheck {
553 #[serde(default, deserialize_with = "de_string")]
554 pub ref_id: String,
555 #[serde(deserialize_with = "de_bool")]
556 pub agrees: bool,
557 pub ask: Ask,
562 #[serde(deserialize_with = "de_bool")]
563 pub unambiguous: bool,
564 #[serde(default, deserialize_with = "de_string")]
565 pub reasoning: String,
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize)]
569pub struct CheckDoc {
570 #[serde(default)]
571 pub checks: Vec<CommentCheck>,
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize)]
576pub struct FixOutcome {
577 #[serde(default, deserialize_with = "de_string")]
578 pub ref_id: String,
579 #[serde(deserialize_with = "de_bool")]
583 pub changed: bool,
584 #[serde(default, deserialize_with = "de_string")]
587 pub summary: String,
588 #[serde(default)]
591 pub files: Vec<String>,
592}
593
594#[derive(Debug, Clone, Serialize, Deserialize)]
595pub struct FixReport {
596 #[serde(default)]
597 pub done: Vec<FixOutcome>,
598}
599
600#[derive(Debug, Clone, Default, Serialize, Deserialize)]
612pub struct Answered {
613 #[serde(default)]
614 pub version: u32,
615 #[serde(default)]
616 pub seen: BTreeMap<String, String>,
617}
618
619string_enum! {
620 pub enum QuestionKind {
622 Answer = "answer" | "factual" | "fact" | "spar",
625 Yours = "yours" | "person" | "decision" | "human" | "unverified",
628 }
629}
630
631#[derive(Debug, Clone, Serialize, Deserialize)]
633pub struct Question {
634 #[serde(default, deserialize_with = "de_string")]
636 pub ref_id: String,
637 #[serde(default, deserialize_with = "de_string")]
639 pub question: String,
640 pub kind: QuestionKind,
641 #[serde(default, deserialize_with = "de_string")]
644 pub answer: String,
645 #[serde(default, deserialize_with = "de_string")]
647 pub evidence: String,
648 #[serde(default, deserialize_with = "de_string")]
649 pub reasoning: String,
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize)]
653pub struct AnswerDoc {
654 #[serde(default)]
655 pub questions: Vec<Question>,
656}
657
658#[derive(Debug, Clone, Serialize, Deserialize)]
660pub struct QuestionCheck {
661 #[serde(default, deserialize_with = "de_string")]
663 pub id: String,
664 #[serde(deserialize_with = "de_bool")]
667 pub agrees: bool,
668 pub kind: QuestionKind,
671 #[serde(default, deserialize_with = "de_string")]
672 pub reasoning: String,
673}
674
675#[derive(Debug, Clone, Serialize, Deserialize)]
676pub struct AnswerCheckDoc {
677 #[serde(default)]
678 pub checks: Vec<QuestionCheck>,
679 #[serde(default, deserialize_with = "de_string_vec")]
681 pub missed: Vec<String>,
682}
683
684#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
692pub struct AnswerRecord {
693 #[serde(default)]
694 pub version: u32,
695 #[serde(default)]
697 pub judged: BTreeMap<String, String>,
698 #[serde(default)]
700 pub automatic: u32,
701}
702
703#[derive(Debug, Clone, Serialize, Deserialize)]
704pub struct Finding {
705 pub severity: Severity,
706 #[serde(default, deserialize_with = "de_string")]
707 pub title: String,
708 #[serde(default, deserialize_with = "de_string")]
709 pub detail: String,
710 #[serde(default, deserialize_with = "de_string")]
711 pub file: String,
712 #[serde(default = "yes", deserialize_with = "de_bool_default_true")]
715 pub in_scope: bool,
716
717 #[serde(default)]
725 pub problem: Option<String>,
726 #[serde(default)]
728 pub reproduction: Option<String>,
729 #[serde(default)]
731 pub impact: Option<String>,
732 #[serde(default)]
734 pub expected: Option<String>,
735}
736
737impl Default for Finding {
738 fn default() -> Self {
745 Self {
746 severity: Severity::Nit,
747 title: String::new(),
748 detail: String::new(),
749 file: String::new(),
750 in_scope: true,
751 problem: None,
752 reproduction: None,
753 impact: None,
754 expected: None,
755 }
756 }
757}
758
759impl Finding {
760 pub fn report_sections(&self) -> Vec<(&'static str, &str)> {
763 [
764 ("Problem", self.problem.as_deref()),
765 ("Reproduction", self.reproduction.as_deref()),
766 ("Impact", self.impact.as_deref()),
767 ("Expected behavior", self.expected.as_deref()),
768 ]
769 .into_iter()
770 .filter_map(|(heading, text)| {
771 text.map(str::trim)
772 .filter(|t| !t.is_empty())
773 .map(|t| (heading, t))
774 })
775 .collect()
776 }
777}
778
779fn yes() -> bool {
780 true
781}
782
783impl Finding {
784 pub fn blocks(&self) -> bool {
785 self.severity == Severity::Blocking && self.in_scope
786 }
787
788 pub fn where_at(&self) -> &str {
789 if self.file.trim().is_empty() {
790 "general"
791 } else {
792 self.file.trim()
793 }
794 }
795}
796
797#[derive(Debug, Clone, Serialize, Deserialize)]
798pub struct Review {
799 pub verdict: Verdict,
800 pub next_action: NextAction,
801 #[serde(default, deserialize_with = "de_string")]
802 pub summary: String,
803 #[serde(default)]
804 pub findings: Vec<Finding>,
805 #[serde(default, skip_serializing_if = "Vec::is_empty")]
808 pub criteria: Vec<CriterionStatus>,
809}
810
811string_enum! {
812 pub enum Mark {
815 Met = "met" | "done",
816 NotMet = "not_met" | "not_done" | "unmet",
817 NeedsPerson = "needs_person" | "needs_a_person" | "manual",
818 }
819}
820
821#[derive(Debug, Clone, Serialize, Deserialize)]
823pub struct CriterionStatus {
824 pub number: u32,
825 pub status: Mark,
826 #[serde(default, deserialize_with = "de_string")]
827 pub reason: String,
828}
829
830#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
832pub struct Ruled {
833 pub raw: String,
835 #[serde(default)]
838 pub line: usize,
839 pub text: String,
840 pub status: Option<Mark>,
842 #[serde(default)]
843 pub reason: String,
844}
845
846#[derive(Debug, Clone, Serialize, Deserialize)]
847pub struct Disposition {
848 #[serde(default, deserialize_with = "de_string")]
849 pub title: String,
850 #[serde(default, deserialize_with = "de_string")]
854 pub file: String,
855 pub action: Action,
856 #[serde(default, deserialize_with = "de_string")]
857 pub reasoning: String,
858 #[serde(default)]
859 pub new_issue_title: Option<String>,
860 #[serde(default)]
861 pub new_issue_body: Option<String>,
862}
863
864#[derive(Debug, Clone, Serialize, Deserialize)]
872pub struct OwnFix {
873 #[serde(default, deserialize_with = "de_string")]
874 pub title: String,
875 #[serde(default, deserialize_with = "de_string")]
878 pub file: String,
879 #[serde(deserialize_with = "de_bool")]
880 pub fixed: bool,
881 #[serde(default, deserialize_with = "de_string")]
882 pub reason: String,
883}
884
885#[derive(Debug, Clone, Serialize, Deserialize)]
886pub struct OwnFixDoc {
887 #[serde(default, deserialize_with = "de_string")]
888 pub summary: String,
889 #[serde(default)]
890 pub fixes: Vec<OwnFix>,
891}
892
893#[derive(Debug, Clone, Serialize, Deserialize)]
900pub struct Adjudication {
901 #[serde(default, deserialize_with = "de_string")]
902 pub title: String,
903 #[serde(default, deserialize_with = "de_string")]
904 pub file: String,
905 #[serde(deserialize_with = "de_bool")]
908 pub agrees: bool,
909 pub severity: Severity,
911 #[serde(default, deserialize_with = "de_string")]
912 pub reasoning: String,
913}
914
915#[derive(Debug, Clone, Serialize, Deserialize)]
916pub struct AdjudicationDoc {
917 #[serde(default)]
918 pub verdicts: Vec<Adjudication>,
919}
920
921string_enum! {
922 pub enum Ruling {
924 Fixed = "fixed" | "resolved" | "addressed" | "done",
926 Rebutted = "rebutted" | "refuted" | "answered" | "withdrawn" | "not_a_bug",
929 Open = "open" | "still_open" | "unresolved" | "stands" | "unfixed",
932 }
933}
934
935#[derive(Debug, Clone, Serialize, Deserialize)]
937pub struct RecheckRuling {
938 #[serde(deserialize_with = "de_i64")]
942 pub index: i64,
943 pub outcome: Ruling,
944 #[serde(default, deserialize_with = "de_string")]
945 pub reasoning: String,
946}
947
948#[derive(Debug, Clone, Default, Serialize, Deserialize)]
951pub struct RecheckDoc {
952 #[serde(default, deserialize_with = "de_string")]
953 pub summary: String,
954 #[serde(default)]
955 pub rulings: Vec<RecheckRuling>,
956 #[serde(default)]
957 pub findings: Vec<Finding>,
958}
959
960#[derive(Debug, Clone, Default, Serialize, Deserialize)]
973pub struct Idea {
974 #[serde(default, deserialize_with = "de_string")]
975 pub title: String,
976 #[serde(default, deserialize_with = "de_string_vec")]
978 pub combines: Vec<String>,
979 #[serde(default, deserialize_with = "de_string")]
980 pub how_it_works: String,
981 #[serde(default, deserialize_with = "de_string")]
983 pub why_new: String,
984 #[serde(default, deserialize_with = "de_string")]
985 pub enables: String,
986 #[serde(default, deserialize_with = "de_string")]
987 pub risks: String,
988 #[serde(default, deserialize_with = "de_string")]
990 pub first_experiment: String,
991}
992
993#[derive(Debug, Clone, Default, Serialize, Deserialize)]
994pub struct IdeasDoc {
995 #[serde(default)]
996 pub ideas: Vec<Idea>,
997}
998
999string_enum! {
1000 pub enum CrossKind {
1002 Build = "build" | "extend" | "improve" | "combine",
1003 Challenge = "challenge" | "reject" | "exists" | "object",
1004 Keep = "keep" | "agree" | "accept" | "fine",
1005 }
1006}
1007
1008#[derive(Debug, Clone, Serialize, Deserialize)]
1010pub struct CrossVerdict {
1011 #[serde(default, deserialize_with = "de_i64")]
1014 pub idea: i64,
1015 pub verdict: CrossKind,
1016 #[serde(default, deserialize_with = "de_string")]
1017 pub reason: String,
1018 #[serde(default)]
1020 pub build: Option<Idea>,
1021}
1022
1023#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1024pub struct CrossDoc {
1025 #[serde(default)]
1026 pub verdicts: Vec<CrossVerdict>,
1027}
1028
1029#[derive(Debug, Clone, Serialize, Deserialize)]
1031pub struct Defence {
1032 #[serde(default, deserialize_with = "de_i64")]
1033 pub idea: i64,
1034 #[serde(deserialize_with = "de_bool")]
1036 pub stands: bool,
1037 #[serde(default, deserialize_with = "de_string")]
1038 pub reply: String,
1039}
1040
1041#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1044pub struct ConvergeDoc {
1045 #[serde(default)]
1046 pub defences: Vec<Defence>,
1047 #[serde(default, deserialize_with = "de_i64_vec")]
1049 pub ranking: Vec<i64>,
1050}
1051
1052#[derive(Debug, Clone)]
1054pub struct Judged {
1055 pub finding: Finding,
1056 pub raised_by: String,
1058 pub standing: Standing,
1060 pub counterpoint: Option<String>,
1062 pub defence: Option<String>,
1067 pub rated: Option<Severity>,
1071}
1072
1073string_enum! {
1074 pub enum Standing {
1077 Corroborated = "corroborated" | "both",
1079 Confirmed = "confirmed",
1081 Disputed = "disputed",
1085 Withdrawn = "withdrawn",
1087 Unverified = "unverified" | "one_reviewer",
1089 }
1090}
1091
1092#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1101pub struct Implementation {
1102 #[serde(default, deserialize_with = "de_bool")]
1104 pub not_worth_doing: bool,
1105 #[serde(default, deserialize_with = "de_string")]
1108 pub reason: String,
1109 #[serde(default, deserialize_with = "de_string")]
1111 pub summary: String,
1112 #[serde(default, deserialize_with = "de_string")]
1115 pub problem: String,
1116 #[serde(default)]
1118 pub changes: Vec<String>,
1119 #[serde(default)]
1121 pub testing: Vec<String>,
1122 #[serde(default)]
1125 pub notes: Option<String>,
1126 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1130 pub criteria: Vec<CriterionStatus>,
1131}
1132
1133#[derive(Debug, Clone, Serialize, Deserialize)]
1134pub struct ResponseDoc {
1135 #[serde(default, deserialize_with = "de_string")]
1136 pub summary: String,
1137 #[serde(default)]
1138 pub dispositions: Vec<Disposition>,
1139}
1140
1141#[derive(Debug, Clone, Serialize, Deserialize)]
1146pub struct PlanItem {
1147 pub issue: i64,
1148 pub title: String,
1149 pub complexity: Complexity,
1150 pub risk: Risk,
1151 pub depends_on: Vec<i64>,
1152 pub reason: String,
1153}
1154
1155#[derive(Debug, Clone, Serialize, Deserialize)]
1156pub struct SkippedItem {
1157 pub issue: i64,
1158 pub title: String,
1159 pub reasons: BTreeMap<String, String>,
1161 #[serde(default)]
1169 pub tracker: bool,
1170}
1171
1172#[derive(Debug, Clone, Serialize, Deserialize)]
1173pub struct ContestedItem {
1174 pub issue: i64,
1175 pub title: String,
1176 pub positions: BTreeMap<String, String>,
1178 pub reasons: BTreeMap<String, String>,
1179 #[serde(default, skip_serializing_if = "Option::is_none")]
1180 pub note: Option<String>,
1181}
1182
1183#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1190pub struct Contested {
1191 #[serde(default)]
1192 pub title: String,
1193 #[serde(default)]
1195 pub positions: BTreeMap<String, String>,
1196 #[serde(default)]
1197 pub reasons: BTreeMap<String, String>,
1198}
1199
1200#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1201pub struct Plan {
1202 #[serde(default)]
1203 pub order: Vec<PlanItem>,
1204 #[serde(default)]
1205 pub skipped: Vec<SkippedItem>,
1206 #[serde(default)]
1207 pub contested: Vec<ContestedItem>,
1208}
1209
1210string_enum! {
1211 pub enum Settled {
1225 Refuted = "refuted" | "refute" | "rejected",
1226 Filed = "filed" | "filed_issue" | "filed-issue" | "out_of_scope",
1227 Dropped = "dropped" | "not_filed" | "not-filed" | "unfiled",
1228 Fixed = "fixed" | "fix" | "changed",
1229 }
1230}
1231
1232impl Default for Settled {
1233 fn default() -> Self {
1235 Settled::Refuted
1236 }
1237}
1238
1239#[derive(Debug, Clone, PartialEq, Eq)]
1246pub enum Followup {
1247 Recorded(String),
1250 Covered(String),
1254 Dropped(&'static str),
1257 Failed,
1260}
1261
1262impl Followup {
1263 pub fn url(&self) -> Option<&str> {
1268 match self {
1269 Followup::Recorded(url) => Some(url),
1270 _ => None,
1271 }
1272 }
1273}
1274
1275#[derive(Debug, Clone, Serialize, Deserialize)]
1276pub struct LedgerEntry {
1277 pub title: String,
1278 pub file: String,
1279 pub reasoning: String,
1280 pub round: u32,
1281 #[serde(default)]
1282 pub reraised: u32,
1283 #[serde(default)]
1284 pub outcome: Settled,
1285}
1286
1287pub type Ledger = BTreeMap<String, LedgerEntry>;
1291
1292#[derive(Debug, Clone, Serialize, Deserialize)]
1293pub struct Dispute {
1294 pub title: String,
1295 #[serde(default, deserialize_with = "de_string")]
1296 pub file: String,
1297 pub reasoning: String,
1298}
1299
1300#[derive(Debug, Clone, Serialize, Deserialize)]
1302pub struct IssueRun {
1303 pub issue: i64,
1304 pub title: String,
1305 pub status: Status,
1306 #[serde(default, skip_serializing_if = "Option::is_none")]
1307 pub pr: Option<String>,
1308 #[serde(default)]
1309 pub rounds: u32,
1310 #[serde(default)]
1311 pub disputes: Vec<Dispute>,
1312 #[serde(default)]
1313 pub filed: Vec<String>,
1314 #[serde(default, skip_serializing_if = "Option::is_none")]
1317 pub posted: Option<String>,
1318 #[serde(default)]
1327 pub noted: Vec<Finding>,
1328 #[serde(default)]
1329 pub notes: Vec<String>,
1330 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1334 pub criteria: Vec<Ruled>,
1335 #[serde(skip)]
1338 pub ticks_criteria: bool,
1339 #[serde(skip)]
1342 pub followup_writes_uncertain: bool,
1343}
1344
1345impl IssueRun {
1346 pub fn new(issue: i64, title: impl Into<String>) -> Self {
1347 Self {
1348 issue,
1349 title: title.into(),
1350 status: Status::Pending,
1351 pr: None,
1352 rounds: 0,
1353 disputes: Vec::new(),
1354 filed: Vec::new(),
1355 posted: None,
1356 noted: Vec::new(),
1357 notes: Vec::new(),
1358 criteria: Vec::new(),
1359 ticks_criteria: false,
1360 followup_writes_uncertain: false,
1361 }
1362 }
1363
1364 pub fn succeeded(&self) -> bool {
1369 matches!(
1370 self.status,
1371 Status::Merged
1372 | Status::Approved
1373 | Status::Abandoned
1374 | Status::Reviewed
1375 | Status::Clean
1376 | Status::Answered
1377 | Status::Split
1381 | Status::Whole
1382 )
1383 }
1384}
1385
1386#[derive(Debug, Clone, Serialize, Deserialize)]
1388pub struct PersistedState {
1389 pub version: u32,
1390 #[serde(default)]
1392 pub checkpoint: u64,
1393 pub round: u32,
1394 pub next_actor: String,
1395 pub status: Status,
1396 #[serde(default, skip_serializing_if = "String::is_empty")]
1398 pub pr_head: String,
1399 #[serde(default)]
1400 pub ledger: Ledger,
1401 #[serde(default)]
1402 pub filed: Vec<String>,
1403 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1404 pub open_findings: Vec<Finding>,
1405 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1406 pub disputes: Vec<Dispute>,
1407 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1408 pub noted: Vec<Finding>,
1409}
1410
1411pub const STATE_VERSION: u32 = 2;
1412
1413#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1418pub struct RecordedFinding {
1419 pub title: String,
1420 #[serde(default)]
1421 pub file: String,
1422 pub severity: Severity,
1423 #[serde(default)]
1424 pub detail: String,
1425 #[serde(default = "yes")]
1426 pub in_scope: bool,
1427 #[serde(default)]
1428 pub raised_by: String,
1429 pub standing: Standing,
1430 #[serde(default, skip_serializing_if = "Option::is_none")]
1431 pub counterpoint: Option<String>,
1432 #[serde(default, skip_serializing_if = "Option::is_none")]
1433 pub defence: Option<String>,
1434}
1435
1436impl RecordedFinding {
1437 pub fn blocks(&self) -> bool {
1438 self.severity == Severity::Blocking && self.in_scope
1439 }
1440
1441 pub fn where_at(&self) -> &str {
1442 if self.file.trim().is_empty() {
1443 "general"
1444 } else {
1445 self.file.trim()
1446 }
1447 }
1448
1449 pub fn to_judged(&self) -> Judged {
1452 Judged {
1453 finding: Finding {
1454 severity: self.severity,
1455 title: self.title.clone(),
1456 detail: self.detail.clone(),
1457 file: self.file.clone(),
1458 in_scope: self.in_scope,
1459 ..Finding::default()
1460 },
1461 raised_by: self.raised_by.clone(),
1462 standing: self.standing,
1463 counterpoint: self.counterpoint.clone(),
1464 defence: self.defence.clone(),
1465 rated: None,
1466 }
1467 }
1468}
1469
1470impl From<&Judged> for RecordedFinding {
1471 fn from(j: &Judged) -> Self {
1472 Self {
1473 title: j.finding.title.clone(),
1474 file: j.finding.file.clone(),
1475 severity: j.finding.severity,
1476 detail: j.finding.detail.clone(),
1477 in_scope: j.finding.in_scope,
1478 raised_by: j.raised_by.clone(),
1479 standing: j.standing,
1480 counterpoint: j.counterpoint.clone(),
1481 defence: j.defence.clone(),
1482 }
1483 }
1484}
1485
1486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1492pub struct ReviewRecord {
1493 pub version: u32,
1494 pub head: String,
1497 pub reviewed_at: String,
1502 #[serde(default)]
1503 pub findings: Vec<RecordedFinding>,
1504 #[serde(default)]
1506 pub rechecks: u32,
1507 #[serde(default, skip_serializing_if = "Option::is_none")]
1509 pub verdict: Option<ReviewVerdict>,
1510 #[serde(default)]
1514 pub posted: bool,
1515}
1516
1517pub const REVIEW_RECORD_VERSION: u32 = 1;
1518
1519impl ReviewRecord {
1520 pub fn short_head(&self) -> &str {
1521 let end = self.head.len().min(7);
1522 &self.head[..end]
1523 }
1524
1525 pub fn open_points(&self) -> impl Iterator<Item = &RecordedFinding> {
1527 self.findings
1528 .iter()
1529 .filter(|f| f.standing != Standing::Withdrawn)
1530 }
1531}
1532
1533#[derive(Debug, Clone, PartialEq, Eq)]
1539pub struct InlineComment {
1540 pub path: String,
1541 pub line: u32,
1542 pub start_line: Option<u32>,
1543 pub body: String,
1544}
1545
1546#[derive(Debug, Clone, Deserialize)]
1551pub struct Label {
1552 #[serde(default)]
1553 pub name: String,
1554}
1555
1556#[derive(Debug, Clone, Deserialize, Default)]
1557pub struct Author {
1558 #[serde(default)]
1559 pub login: String,
1560}
1561
1562pub fn login_of(author: Option<&Author>) -> &str {
1565 match author.map(|a| a.login.trim()) {
1566 Some(login) if !login.is_empty() => login,
1567 _ => "ghost",
1568 }
1569}
1570
1571#[derive(Debug, Clone, Deserialize, Default)]
1574#[serde(rename_all = "camelCase")]
1575pub struct IssueComment {
1576 #[serde(default)]
1578 pub author: Option<Author>,
1579 #[serde(default)]
1580 pub author_association: String,
1581 #[serde(default)]
1583 pub viewer_did_author: bool,
1584 #[serde(default)]
1585 pub body: String,
1586 #[serde(default)]
1587 pub created_at: String,
1588 #[serde(default)]
1589 pub is_minimized: bool,
1590}
1591
1592impl IssueComment {
1593 pub fn login(&self) -> &str {
1594 login_of(self.author.as_ref())
1595 }
1596}
1597
1598#[derive(Debug, Clone, Deserialize)]
1599pub struct Issue {
1600 pub number: i64,
1601 #[serde(default)]
1602 pub title: String,
1603 #[serde(default)]
1604 pub body: Option<String>,
1605 #[serde(default)]
1606 pub state: String,
1607 #[serde(default, rename = "stateReason")]
1615 pub state_reason: Option<String>,
1616 #[serde(default)]
1617 pub url: String,
1618 #[serde(default)]
1619 pub labels: Vec<Label>,
1620 #[serde(default)]
1622 pub author: Option<Author>,
1623 #[serde(default)]
1626 pub comments: Vec<IssueComment>,
1627}
1628
1629impl Issue {
1630 pub fn body_text(&self) -> &str {
1631 self.body.as_deref().unwrap_or("")
1632 }
1633
1634 pub fn body_for_prompt(&self, max: usize) -> (String, bool) {
1646 let body = self.body_text().trim();
1647 if body.chars().count() <= max {
1648 return (body.to_string(), false);
1649 }
1650 let clipped: String = body.chars().take(max).collect();
1651 let mut kept = match clipped.rfind('\n') {
1652 Some(at) => clipped[..at].to_string(),
1653 None => clipped,
1654 };
1655 if kept.matches("```").count() % 2 == 1 {
1656 kept.push_str("\n```");
1657 }
1658 kept.push_str("\n\n[Shortened to fit. The rest of this issue was not included.]");
1659 (kept, true)
1660 }
1661
1662 pub fn is_closed(&self) -> bool {
1663 self.state.eq_ignore_ascii_case("closed")
1664 }
1665
1666 pub fn closed_as_completed(&self) -> bool {
1673 self.is_closed() && !self.closed_as_not_planned()
1674 }
1675
1676 pub fn closed_as_not_planned(&self) -> bool {
1677 self.is_closed()
1678 && self
1679 .state_reason
1680 .as_deref()
1681 .is_some_and(|reason| reason.eq_ignore_ascii_case("not_planned"))
1682 }
1683}
1684
1685#[derive(Debug, Clone, Deserialize)]
1686pub struct PrRef {
1687 pub number: i64,
1688 #[serde(default)]
1689 pub url: String,
1690 #[serde(default)]
1691 pub title: String,
1692}
1693
1694#[derive(Debug, Clone, Deserialize)]
1695pub struct IssueRef {
1696 pub number: i64,
1697 #[serde(default)]
1700 pub url: String,
1701}
1702
1703#[derive(Debug, Clone, Default, Deserialize)]
1709#[serde(rename_all = "camelCase")]
1710pub struct PrRow {
1711 pub number: i64,
1712 #[serde(default)]
1713 pub title: String,
1714 #[serde(default)]
1715 pub changed_files: i64,
1716 #[serde(default)]
1717 pub additions: i64,
1718 #[serde(default)]
1719 pub deletions: i64,
1720}
1721
1722impl PrRow {
1723 pub fn size(&self) -> String {
1725 format!(
1726 "{} file(s), +{} -{}",
1727 self.changed_files, self.additions, self.deletions
1728 )
1729 }
1730}
1731
1732#[derive(Debug, Clone, Deserialize)]
1733#[serde(rename_all = "camelCase")]
1734pub struct PrView {
1735 pub number: i64,
1736 #[serde(default)]
1737 pub url: String,
1738 #[serde(default)]
1739 pub title: String,
1740 #[serde(default)]
1743 pub body: String,
1744 #[serde(default)]
1745 pub head_ref_name: String,
1746 #[serde(default)]
1747 pub base_ref_name: String,
1748 #[serde(default)]
1749 pub state: String,
1750 #[serde(default)]
1751 pub closing_issues_references: Vec<IssueRef>,
1752 #[serde(default)]
1755 pub is_cross_repository: bool,
1756 #[serde(default)]
1760 pub head_repository_owner: Option<Author>,
1761 #[serde(default)]
1763 pub head_repository: Option<HeadRepository>,
1764}
1765
1766#[derive(Debug, Clone, Default, Deserialize)]
1767pub struct HeadRepository {
1768 #[serde(default)]
1769 pub name: String,
1770}
1771
1772pub const PR_SWEEP_FIELDS: &str =
1775 "number,title,headRefOid,updatedAt,author,isDraft,isCrossRepository,reviewRequests,assignees,state";
1776
1777#[derive(Debug, Clone, Default, Deserialize)]
1780#[serde(rename_all = "camelCase")]
1781pub struct PrSweepRow {
1782 pub number: i64,
1783 #[serde(default)]
1784 pub title: String,
1785 #[serde(default)]
1786 pub head_ref_oid: String,
1787 #[serde(default)]
1788 pub updated_at: String,
1789 #[serde(default)]
1790 pub author: Option<Author>,
1791 #[serde(default)]
1792 pub is_draft: bool,
1793 #[serde(default)]
1794 pub is_cross_repository: bool,
1795 #[serde(default)]
1796 pub review_requests: Vec<Login>,
1797 #[serde(default)]
1798 pub assignees: Vec<Login>,
1799 #[serde(default)]
1801 pub state: String,
1802}
1803
1804#[derive(Debug, Clone, Default, Deserialize)]
1808pub struct Login {
1809 #[serde(default)]
1810 pub login: String,
1811 #[serde(default)]
1813 pub slug: String,
1814}
1815
1816#[derive(Debug, Clone, Default)]
1819pub struct ReviewRequested {
1820 pub at: String,
1821 pub who: Login,
1822}
1823
1824#[derive(Debug, Clone, Default, PartialEq, Eq)]
1828pub struct AssignedIssue {
1829 pub number: i64,
1830 pub title: String,
1831 pub assignees: Vec<String>,
1833 pub assigned: Vec<(String, String)>,
1835}
1836
1837impl AssignedIssue {
1838 pub fn newest_for(&self, viewer: &str) -> Option<String> {
1842 let same = |login: &str| crate::comments::same_login(login, viewer);
1843 if !self.assignees.iter().any(|a| same(a)) {
1844 return None;
1845 }
1846 self.assigned
1847 .iter()
1848 .filter(|(_, who)| same(who))
1849 .filter_map(|(at, _)| crate::clock::parse_rfc3339(at).map(|t| (t, at)))
1850 .max_by_key(|(t, _)| *t)
1851 .map(|(_, at)| at.clone())
1852 }
1853}
1854
1855#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1858#[serde(rename_all = "lowercase")]
1859pub enum ItemKind {
1860 Issue,
1861 Pr,
1862}
1863
1864impl std::fmt::Display for ItemKind {
1865 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1866 f.write_str(match self {
1867 ItemKind::Issue => "issue",
1868 ItemKind::Pr => "pull request",
1869 })
1870 }
1871}
1872
1873impl PrView {
1874 pub fn is_open(&self) -> bool {
1875 self.state.eq_ignore_ascii_case("open")
1876 }
1877}
1878
1879#[derive(Debug, Clone, PartialEq, Eq)]
1887pub struct LiveHead {
1888 pub pull: String,
1890 pub branch: Option<String>,
1893}
1894
1895impl LiveHead {
1896 pub fn settled(&self) -> Option<&str> {
1898 match &self.branch {
1899 Some(branch) if !branch.eq_ignore_ascii_case(&self.pull) => None,
1900 _ => Some(&self.pull),
1901 }
1902 }
1903
1904 pub fn moved_from(&self, reviewed: &str) -> Option<&str> {
1907 self.branch
1908 .as_deref()
1909 .filter(|branch| !branch.eq_ignore_ascii_case(reviewed))
1910 .or_else(|| (!self.pull.eq_ignore_ascii_case(reviewed)).then_some(self.pull.as_str()))
1911 }
1912}
1913
1914#[cfg(test)]
1915mod tests {
1916 use super::*;
1917
1918 #[test]
1919 fn severity_accepts_the_canonical_spelling() {
1920 assert_eq!(
1921 Some(Severity::NonBlocking),
1922 Severity::parse_lenient("non-blocking")
1923 );
1924 }
1925
1926 #[test]
1927 fn severity_accepts_near_misses() {
1928 for text in ["NonBlocking", "non_blocking", " NON-BLOCKING ", "minor"] {
1929 assert_eq!(
1930 Some(Severity::NonBlocking),
1931 Severity::parse_lenient(text),
1932 "{text}"
1933 );
1934 }
1935 }
1936
1937 #[test]
1938 fn severity_rejects_nonsense() {
1939 assert_eq!(None, Severity::parse_lenient("catastrophic-ish"));
1940 }
1941
1942 #[test]
1943 fn complexity_ordering_is_cheapest_first() {
1944 assert!(Complexity::S.rank() < Complexity::M.rank());
1945 assert!(Complexity::M.rank() < Complexity::L.rank());
1946 }
1947
1948 #[test]
1949 fn finding_defaults_to_in_scope() {
1950 let f: Finding = serde_json::from_value(serde_json::json!({
1951 "severity": "blocking", "title": "t", "detail": "d", "file": "a.rs"
1952 }))
1953 .unwrap();
1954 assert!(f.in_scope);
1955 assert!(f.blocks());
1956 }
1957
1958 #[test]
1959 fn out_of_scope_blocking_does_not_block() {
1960 let f: Finding = serde_json::from_value(serde_json::json!({
1961 "severity": "blocking", "title": "t", "detail": "d",
1962 "file": "a.rs", "in_scope": false
1963 }))
1964 .unwrap();
1965 assert!(!f.blocks());
1966 }
1967
1968 #[test]
1969 fn triage_tolerates_a_quoted_issue_number() {
1970 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1971 "issue": "#42", "worth_doing": "yes", "reason": "r",
1972 "complexity": "medium", "depends_on": ["39"], "risk": "low"
1973 }))
1974 .unwrap();
1975 assert_eq!(42, v.issue);
1976 assert!(v.worth_doing);
1977 assert_eq!(Complexity::M, v.complexity);
1978 assert_eq!(vec![39], v.depends_on);
1979 }
1980
1981 #[test]
1982 fn triage_tolerates_a_missing_depends_on() {
1983 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1984 "issue": 1, "worth_doing": false, "reason": "r",
1985 "complexity": "s", "risk": "high"
1986 }))
1987 .unwrap();
1988 assert!(v.depends_on.is_empty());
1989 }
1990
1991 #[test]
1992 fn review_tolerates_a_missing_findings_array() {
1993 let r: Review = serde_json::from_value(serde_json::json!({
1994 "verdict": "approve", "next_action": "merge", "summary": "fine"
1995 }))
1996 .unwrap();
1997 assert!(r.findings.is_empty());
1998 }
1999
2000 #[test]
2001 fn a_null_reason_is_an_empty_string_not_a_failure() {
2002 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
2003 "issue": 1, "worth_doing": true, "reason": null,
2004 "complexity": "s", "depends_on": [], "risk": "low"
2005 }))
2006 .unwrap();
2007 assert_eq!("", v.reason);
2008 }
2009
2010 #[test]
2011 fn unknown_severity_is_an_error_not_a_silent_downgrade() {
2012 let out: Result<Finding, _> = serde_json::from_value(serde_json::json!({
2013 "severity": "showstopper-maybe", "title": "t", "detail": "d", "file": "a.rs"
2014 }));
2015 assert!(out.is_err());
2016 }
2017
2018 #[test]
2022 fn deciding_not_to_split_is_not_a_failure() {
2023 for status in [Status::Split, Status::Whole] {
2024 let mut run = IssueRun::new(1, "t");
2025 run.status = status;
2026 assert!(run.succeeded(), "{status}");
2027 }
2028 }
2029
2030 #[test]
2031 fn status_round_trips_through_json() {
2032 let run = IssueRun::new(4, "t");
2033 let text = serde_json::to_string(&run).unwrap();
2034 let back: IssueRun = serde_json::from_str(&text).unwrap();
2035 assert_eq!(Status::Pending, back.status);
2036 }
2037
2038 #[test]
2041 fn a_look_whose_head_moved_did_not_do_its_job() {
2042 let mut run = IssueRun::new(4, "t");
2043 run.status = Status::Moved;
2044 assert!(!run.succeeded());
2045 let text = serde_json::to_string(&run).unwrap();
2046 assert!(text.contains("\"moved\""), "{text}");
2047 let back: IssueRun = serde_json::from_str(&text).unwrap();
2048 assert_eq!(Status::Moved, back.status);
2049 }
2050
2051 fn live(pull: &str, branch: Option<&str>) -> LiveHead {
2052 LiveHead {
2053 pull: pull.into(),
2054 branch: branch.map(String::from),
2055 }
2056 }
2057
2058 #[test]
2061 fn a_head_is_settled_only_once_the_pull_request_has_caught_up_with_its_branch() {
2062 assert_eq!(None, live("fb3a7ab", Some("2d5e2b5")).settled());
2063 assert_eq!(Some("2d5e2b5"), live("2d5e2b5", Some("2d5e2b5")).settled());
2064 assert_eq!(Some("2d5e2b5"), live("2d5e2b5", Some("2D5E2B5")).settled());
2065 assert_eq!(Some("fb3a7ab"), live("fb3a7ab", None).settled());
2067 }
2068
2069 #[test]
2070 fn a_head_moved_from_the_reviewed_one_names_the_newer_commit() {
2071 assert_eq!(None, live("fb3a7ab", Some("fb3a7ab")).moved_from("fb3a7ab"));
2072 assert_eq!(None, live("FB3A7AB", None).moved_from("fb3a7ab"));
2073 assert_eq!(
2075 Some("c493635"),
2076 live("2d5e2b5", Some("c493635")).moved_from("fb3a7ab")
2077 );
2078 assert_eq!(
2079 Some("c493635"),
2080 live("fb3a7ab", Some("c493635")).moved_from("fb3a7ab")
2081 );
2082 assert_eq!(Some("2d5e2b5"), live("2d5e2b5", None).moved_from("fb3a7ab"));
2083 assert_eq!(
2084 Some("2d5e2b5"),
2085 live("2d5e2b5", Some("fb3a7ab")).moved_from("fb3a7ab")
2086 );
2087 }
2088}
2089
2090#[cfg(test)]
2091mod body_for_prompt_tests {
2092 use super::*;
2093
2094 fn issue(body: &str) -> Issue {
2095 let mut i: Issue = serde_json::from_value(serde_json::json!({
2096 "number": 1, "title": "t", "state": "open", "url": "u"
2097 }))
2098 .expect("an issue");
2099 i.body = Some(body.to_string());
2100 i
2101 }
2102
2103 #[test]
2106 fn an_issue_that_fits_is_handed_over_whole() {
2107 let (body, cut) = issue("The guard is inverted.").body_for_prompt(60_000);
2108 assert_eq!("The guard is inverted.", body);
2109 assert!(!cut);
2110 }
2111
2112 #[test]
2116 fn a_shortened_body_says_so_in_the_text() {
2117 let long = "line of text\n".repeat(500);
2118 let (body, cut) = issue(&long).body_for_prompt(200);
2119 assert!(cut);
2120 assert!(body.contains("Shortened to fit"), "{body}");
2121 assert!(body.len() < long.len());
2122 }
2123
2124 #[test]
2127 fn a_cut_never_leaves_a_code_fence_open() {
2128 let body = format!("intro\n\n```rust\n{}\n```\n", "let x = 1;\n".repeat(200));
2129 let (out, cut) = issue(&body).body_for_prompt(120);
2130 assert!(cut);
2131 assert_eq!(0, out.matches("```").count() % 2, "{out}");
2132 }
2133
2134 #[test]
2136 fn a_cut_lands_on_a_line_boundary() {
2137 let body = "aaaa bbbb cccc\n".repeat(100);
2138 let (out, _) = issue(&body).body_for_prompt(100);
2139 let kept = out.split("\n\n[Shortened").next().expect("the kept part");
2140 assert!(kept.ends_with("cccc"), "{kept:?}");
2141 }
2142}
2143
2144#[cfg(test)]
2145mod review_record_tests {
2146 use super::*;
2147
2148 fn judged() -> Judged {
2149 Judged {
2150 finding: Finding {
2151 severity: Severity::Blocking,
2152 title: "Retry loop never terminates".into(),
2153 detail: "the guard compares against Some(0)".into(),
2154 file: "src/net.rs:88".into(),
2155 in_scope: true,
2156 problem: Some("dropped on the way to the record".into()),
2157 ..Finding::default()
2158 },
2159 raised_by: "claude and codex".into(),
2160 standing: Standing::Corroborated,
2161 counterpoint: None,
2162 defence: Some("stands".into()),
2163 rated: None,
2164 }
2165 }
2166
2167 #[test]
2168 fn a_review_record_round_trips_through_json_and_keeps_its_standing_as_a_word() {
2169 let record = ReviewRecord {
2170 version: REVIEW_RECORD_VERSION,
2171 head: "0123abcdef".into(),
2172 reviewed_at: "2026-10-20T18:04:05Z".into(),
2173 findings: vec![RecordedFinding::from(&judged())],
2174 rechecks: 2,
2175 verdict: Some(ReviewVerdict::RequestChanges),
2176 posted: true,
2177 };
2178 let text = serde_json::to_string_pretty(&record).unwrap();
2179 assert!(text.contains("\"standing\": \"corroborated\""), "{text}");
2180 assert!(text.contains("\"verdict\": \"request_changes\""), "{text}");
2181 assert!(text.contains("\"severity\": \"blocking\""), "{text}");
2182 assert!(!text.contains("problem"), "{text}");
2183 let back: ReviewRecord = serde_json::from_str(&text).unwrap();
2184 assert_eq!(record, back);
2185 assert_eq!("0123abc", back.short_head());
2186 }
2187
2188 #[test]
2189 fn a_record_missing_the_newer_fields_still_reads() {
2190 let text = r#"{"version":1,"head":"abc","reviewed_at":"2026-10-20T18:04:05Z",
2191 "findings":[{"title":"T","severity":"nit","standing":"one_reviewer"}]}"#;
2192 let back: ReviewRecord = serde_json::from_str(text).unwrap();
2193 assert_eq!(0, back.rechecks);
2194 assert!(!back.posted);
2195 assert_eq!(None, back.verdict);
2196 assert!(back.findings[0].in_scope);
2197 assert_eq!(Standing::Unverified, back.findings[0].standing);
2198 assert_eq!("general", back.findings[0].where_at());
2199 }
2200
2201 #[test]
2202 fn a_recorded_finding_turns_back_into_a_judged_finding() {
2203 let original = judged();
2204 let back = RecordedFinding::from(&original).to_judged();
2205 assert_eq!(original.finding.title, back.finding.title);
2206 assert_eq!(original.finding.file, back.finding.file);
2207 assert_eq!(original.finding.severity, back.finding.severity);
2208 assert_eq!(original.raised_by, back.raised_by);
2209 assert_eq!(original.standing, back.standing);
2210 assert_eq!(original.defence, back.defence);
2211 assert_eq!(None, back.finding.problem);
2212 assert!(back.finding.blocks());
2213 }
2214
2215 #[test]
2216 fn a_ruling_tolerates_a_quoted_index_and_a_synonym_outcome() {
2217 let doc: RecheckDoc = serde_json::from_str(
2218 r#"{"summary":"holds","rulings":[{"index":"2","outcome":"Resolved","reasoning":"line 4"}],"findings":[]}"#,
2219 )
2220 .unwrap();
2221 assert_eq!(2, doc.rulings[0].index);
2222 assert_eq!(Ruling::Fixed, doc.rulings[0].outcome);
2223 let doc: RecheckDoc =
2224 serde_json::from_str(r#"{"summary":"","rulings":[],"findings":[]}"#).unwrap();
2225 assert!(doc.rulings.is_empty());
2226 assert_eq!("APPROVE", ReviewVerdict::Approve.api_event());
2227 assert_eq!("changes requested", ReviewVerdict::RequestChanges.words());
2228 }
2229}
2230
2231#[cfg(test)]
2232mod assigned_issue_tests {
2233 use super::*;
2234
2235 fn issue(assignees: &[&str], assigned: &[(&str, &str)]) -> AssignedIssue {
2236 AssignedIssue {
2237 number: 42,
2238 title: "t".into(),
2239 assignees: assignees.iter().map(|s| s.to_string()).collect(),
2240 assigned: assigned
2241 .iter()
2242 .map(|(at, who)| (at.to_string(), who.to_string()))
2243 .collect(),
2244 }
2245 }
2246
2247 #[test]
2248 fn the_newest_assignment_counts_only_for_the_viewer_and_only_while_assigned() {
2249 let both = issue(
2250 &["somebody", "Viewer"],
2251 &[
2252 ("2026-11-01T10:00:00Z", "viewer"),
2253 ("2026-11-03T09:00:00Z", "somebody"),
2254 ("2026-11-02T10:00:00Z", "VIEWER"),
2255 ("not a time", "viewer"),
2256 ],
2257 );
2258 assert_eq!(
2259 Some("2026-11-02T10:00:00Z".to_string()),
2260 both.newest_for("viewer")
2261 );
2262 assert_eq!(
2263 Some("2026-11-03T09:00:00Z".to_string()),
2264 both.newest_for("somebody")
2265 );
2266 let dropped = issue(&["somebody"], &[("2026-11-01T10:00:00Z", "viewer")]);
2268 assert_eq!(None, dropped.newest_for("viewer"));
2269 let silent = issue(&["viewer"], &[]);
2271 assert_eq!(None, silent.newest_for("viewer"));
2272 }
2273}