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 {
260 if self.rank() >= other.rank() {
261 self
262 } else {
263 other
264 }
265 }
266}
267
268impl Risk {
269 pub fn rank(self) -> u8 {
270 match self {
271 Risk::Low => 0,
272 Risk::Med => 1,
273 Risk::High => 2,
274 }
275 }
276}
277
278pub fn de_i64<'de, D: Deserializer<'de>>(d: D) -> Result<i64, D::Error> {
285 struct V;
286 impl<'de> Visitor<'de> for V {
287 type Value = i64;
288 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289 f.write_str("an issue number")
290 }
291 fn visit_i64<E: de::Error>(self, v: i64) -> Result<i64, E> {
292 Ok(v)
293 }
294 fn visit_u64<E: de::Error>(self, v: u64) -> Result<i64, E> {
295 Ok(v as i64)
296 }
297 fn visit_f64<E: de::Error>(self, v: f64) -> Result<i64, E> {
298 Ok(v as i64)
299 }
300 fn visit_str<E: de::Error>(self, v: &str) -> Result<i64, E> {
301 v.trim()
302 .trim_start_matches('#')
303 .parse()
304 .map_err(|_| E::custom(format!("{v} is not a number")))
305 }
306 }
307 d.deserialize_any(V)
308}
309
310fn de_i64_vec<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<i64>, D::Error> {
311 #[derive(Deserialize)]
312 struct One(#[serde(deserialize_with = "de_i64")] i64);
313 let raw = Option::<Vec<One>>::deserialize(d)?;
314 Ok(raw
315 .unwrap_or_default()
316 .into_iter()
317 .map(|One(n)| n)
318 .collect())
319}
320
321pub fn de_bool<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
323 struct V;
324 impl<'de> Visitor<'de> for V {
325 type Value = bool;
326 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
327 f.write_str("a boolean")
328 }
329 fn visit_bool<E: de::Error>(self, v: bool) -> Result<bool, E> {
330 Ok(v)
331 }
332 fn visit_i64<E: de::Error>(self, v: i64) -> Result<bool, E> {
333 Ok(v != 0)
334 }
335 fn visit_u64<E: de::Error>(self, v: u64) -> Result<bool, E> {
336 Ok(v != 0)
337 }
338 fn visit_str<E: de::Error>(self, v: &str) -> Result<bool, E> {
339 match norm_token(v).as_str() {
340 "true" | "yes" | "y" | "1" => Ok(true),
341 "false" | "no" | "n" | "0" => Ok(false),
342 other => Err(E::custom(format!("{other} is not a boolean"))),
343 }
344 }
345 }
346 d.deserialize_any(V)
347}
348
349pub fn de_opt_i64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<i64>, D::Error> {
355 Ok(match Option::<serde_json::Value>::deserialize(d)? {
356 Some(serde_json::Value::Number(n)) => n.as_i64(),
357 Some(serde_json::Value::String(s)) => s.trim().trim_start_matches('#').parse().ok(),
358 _ => None,
359 })
360}
361
362fn de_string_vec<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
364 Ok(Option::<Vec<String>>::deserialize(d)?.unwrap_or_default())
365}
366
367fn de_bool_default_true<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
368 #[derive(Deserialize)]
369 struct Wrap(#[serde(deserialize_with = "de_bool")] bool);
370 Ok(Option::<Wrap>::deserialize(d)?
371 .map(|Wrap(b)| b)
372 .unwrap_or(true))
373}
374
375fn de_string<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
376 Ok(Option::<String>::deserialize(d)?.unwrap_or_default())
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct TriageVerdict {
385 #[serde(deserialize_with = "de_i64")]
386 pub issue: i64,
387 #[serde(deserialize_with = "de_bool")]
388 pub worth_doing: bool,
389 #[serde(default, deserialize_with = "de_bool")]
392 pub tracker: bool,
393 #[serde(default, deserialize_with = "de_string")]
394 pub reason: String,
395 pub complexity: Complexity,
396 #[serde(default, deserialize_with = "de_i64_vec")]
397 pub depends_on: Vec<i64>,
398 pub risk: Risk,
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct TriageResponse {
403 #[serde(default)]
404 pub issues: Vec<TriageVerdict>,
405}
406
407#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct ScreenVerdict {
410 #[serde(deserialize_with = "de_i64")]
415 pub entry: i64,
416 pub verdict: Screened,
417 #[serde(default, deserialize_with = "de_string")]
418 pub title: String,
419 #[serde(default, deserialize_with = "de_string")]
420 pub reason: String,
421 #[serde(default, deserialize_with = "de_opt_i64")]
427 pub duplicate_of_issue: Option<i64>,
428 #[serde(default, deserialize_with = "de_opt_i64")]
430 pub duplicate_of_entry: Option<i64>,
431}
432
433impl ScreenVerdict {
434 pub fn names_a_duplicate(&self) -> bool {
436 self.duplicate_of_issue.is_some() || self.duplicate_of_entry.is_some()
437 }
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct ScreenResponse {
442 #[serde(default)]
443 pub entries: Vec<ScreenVerdict>,
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize)]
452pub struct SplitScreen {
453 #[serde(deserialize_with = "de_i64")]
456 pub item: i64,
457 #[serde(deserialize_with = "de_bool")]
458 pub split: bool,
459 #[serde(default, deserialize_with = "de_string")]
460 pub reason: String,
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct SplitScreenDoc {
465 #[serde(default)]
466 pub items: Vec<SplitScreen>,
467}
468
469#[derive(Debug, Clone, Default, Serialize, Deserialize)]
471pub struct SplitPart {
472 #[serde(default, deserialize_with = "de_string")]
473 pub title: String,
474 #[serde(default, deserialize_with = "de_string")]
476 pub body: String,
477 #[serde(default, deserialize_with = "de_string_vec")]
480 pub files: Vec<String>,
481}
482
483#[derive(Debug, Clone, Default, Serialize, Deserialize)]
489pub struct SplitProposal {
490 #[serde(default, deserialize_with = "de_bool")]
491 pub should_split: bool,
492 #[serde(default, deserialize_with = "de_string")]
493 pub reason: String,
494 #[serde(default, deserialize_with = "de_bool")]
498 pub stacked: bool,
499 #[serde(default)]
500 pub parts: Vec<SplitPart>,
501}
502
503#[derive(Debug, Clone, Default, Serialize, Deserialize)]
505pub struct SplitCheck {
506 #[serde(default, deserialize_with = "de_bool")]
507 pub accept: bool,
508 #[serde(default, deserialize_with = "de_bool")]
509 pub stacked: bool,
510 #[serde(default, deserialize_with = "de_i64_vec")]
512 pub strike: Vec<i64>,
513 #[serde(default, deserialize_with = "de_string")]
514 pub reasoning: String,
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct CommentVerdict {
520 #[serde(default, deserialize_with = "de_string")]
525 pub ref_id: String,
526 pub ask: Ask,
527 #[serde(default, deserialize_with = "de_string")]
530 pub request: String,
531 #[serde(default, deserialize_with = "de_string")]
534 pub reasoning: String,
535 #[serde(deserialize_with = "de_bool")]
538 pub unambiguous: bool,
539 #[serde(default)]
540 pub new_issue_title: Option<String>,
541 #[serde(default)]
542 pub new_issue_body: Option<String>,
543}
544
545#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct CheckinDoc {
547 #[serde(default)]
548 pub verdicts: Vec<CommentVerdict>,
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct CommentCheck {
554 #[serde(default, deserialize_with = "de_string")]
555 pub ref_id: String,
556 #[serde(deserialize_with = "de_bool")]
557 pub agrees: bool,
558 pub ask: Ask,
563 #[serde(deserialize_with = "de_bool")]
564 pub unambiguous: bool,
565 #[serde(default, deserialize_with = "de_string")]
566 pub reasoning: String,
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize)]
570pub struct CheckDoc {
571 #[serde(default)]
572 pub checks: Vec<CommentCheck>,
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize)]
577pub struct FixOutcome {
578 #[serde(default, deserialize_with = "de_string")]
579 pub ref_id: String,
580 #[serde(deserialize_with = "de_bool")]
584 pub changed: bool,
585 #[serde(default, deserialize_with = "de_string")]
588 pub summary: String,
589 #[serde(default)]
592 pub files: Vec<String>,
593}
594
595#[derive(Debug, Clone, Serialize, Deserialize)]
596pub struct FixReport {
597 #[serde(default)]
598 pub done: Vec<FixOutcome>,
599}
600
601#[derive(Debug, Clone, Default, Serialize, Deserialize)]
613pub struct Answered {
614 #[serde(default)]
615 pub version: u32,
616 #[serde(default)]
617 pub seen: BTreeMap<String, String>,
618}
619
620#[derive(Debug, Clone, Serialize, Deserialize)]
621pub struct Finding {
622 pub severity: Severity,
623 #[serde(default, deserialize_with = "de_string")]
624 pub title: String,
625 #[serde(default, deserialize_with = "de_string")]
626 pub detail: String,
627 #[serde(default, deserialize_with = "de_string")]
628 pub file: String,
629 #[serde(default = "yes", deserialize_with = "de_bool_default_true")]
632 pub in_scope: bool,
633
634 #[serde(default)]
642 pub problem: Option<String>,
643 #[serde(default)]
645 pub reproduction: Option<String>,
646 #[serde(default)]
648 pub impact: Option<String>,
649 #[serde(default)]
651 pub expected: Option<String>,
652}
653
654impl Default for Finding {
655 fn default() -> Self {
662 Self {
663 severity: Severity::Nit,
664 title: String::new(),
665 detail: String::new(),
666 file: String::new(),
667 in_scope: true,
668 problem: None,
669 reproduction: None,
670 impact: None,
671 expected: None,
672 }
673 }
674}
675
676impl Finding {
677 pub fn report_sections(&self) -> Vec<(&'static str, &str)> {
680 [
681 ("Problem", self.problem.as_deref()),
682 ("Reproduction", self.reproduction.as_deref()),
683 ("Impact", self.impact.as_deref()),
684 ("Expected behavior", self.expected.as_deref()),
685 ]
686 .into_iter()
687 .filter_map(|(heading, text)| {
688 text.map(str::trim)
689 .filter(|t| !t.is_empty())
690 .map(|t| (heading, t))
691 })
692 .collect()
693 }
694}
695
696fn yes() -> bool {
697 true
698}
699
700impl Finding {
701 pub fn blocks(&self) -> bool {
702 self.severity == Severity::Blocking && self.in_scope
703 }
704
705 pub fn where_at(&self) -> &str {
706 if self.file.trim().is_empty() {
707 "general"
708 } else {
709 self.file.trim()
710 }
711 }
712}
713
714#[derive(Debug, Clone, Serialize, Deserialize)]
715pub struct Review {
716 pub verdict: Verdict,
717 pub next_action: NextAction,
718 #[serde(default, deserialize_with = "de_string")]
719 pub summary: String,
720 #[serde(default)]
721 pub findings: Vec<Finding>,
722}
723
724#[derive(Debug, Clone, Serialize, Deserialize)]
725pub struct Disposition {
726 #[serde(default, deserialize_with = "de_string")]
727 pub title: String,
728 #[serde(default, deserialize_with = "de_string")]
732 pub file: String,
733 pub action: Action,
734 #[serde(default, deserialize_with = "de_string")]
735 pub reasoning: String,
736 #[serde(default)]
737 pub new_issue_title: Option<String>,
738 #[serde(default)]
739 pub new_issue_body: Option<String>,
740}
741
742#[derive(Debug, Clone, Serialize, Deserialize)]
750pub struct OwnFix {
751 #[serde(default, deserialize_with = "de_string")]
752 pub title: String,
753 #[serde(default, deserialize_with = "de_string")]
756 pub file: String,
757 #[serde(deserialize_with = "de_bool")]
758 pub fixed: bool,
759 #[serde(default, deserialize_with = "de_string")]
760 pub reason: String,
761}
762
763#[derive(Debug, Clone, Serialize, Deserialize)]
764pub struct OwnFixDoc {
765 #[serde(default, deserialize_with = "de_string")]
766 pub summary: String,
767 #[serde(default)]
768 pub fixes: Vec<OwnFix>,
769}
770
771#[derive(Debug, Clone, Serialize, Deserialize)]
778pub struct Adjudication {
779 #[serde(default, deserialize_with = "de_string")]
780 pub title: String,
781 #[serde(default, deserialize_with = "de_string")]
782 pub file: String,
783 #[serde(deserialize_with = "de_bool")]
786 pub agrees: bool,
787 pub severity: Severity,
789 #[serde(default, deserialize_with = "de_string")]
790 pub reasoning: String,
791}
792
793#[derive(Debug, Clone, Serialize, Deserialize)]
794pub struct AdjudicationDoc {
795 #[serde(default)]
796 pub verdicts: Vec<Adjudication>,
797}
798
799string_enum! {
800 pub enum Ruling {
802 Fixed = "fixed" | "resolved" | "addressed" | "done",
804 Rebutted = "rebutted" | "refuted" | "answered" | "withdrawn" | "not_a_bug",
807 Open = "open" | "still_open" | "unresolved" | "stands" | "unfixed",
810 }
811}
812
813#[derive(Debug, Clone, Serialize, Deserialize)]
815pub struct RecheckRuling {
816 #[serde(deserialize_with = "de_i64")]
820 pub index: i64,
821 pub outcome: Ruling,
822 #[serde(default, deserialize_with = "de_string")]
823 pub reasoning: String,
824}
825
826#[derive(Debug, Clone, Default, Serialize, Deserialize)]
829pub struct RecheckDoc {
830 #[serde(default, deserialize_with = "de_string")]
831 pub summary: String,
832 #[serde(default)]
833 pub rulings: Vec<RecheckRuling>,
834 #[serde(default)]
835 pub findings: Vec<Finding>,
836}
837
838#[derive(Debug, Clone, Default, Serialize, Deserialize)]
851pub struct Idea {
852 #[serde(default, deserialize_with = "de_string")]
853 pub title: String,
854 #[serde(default, deserialize_with = "de_string_vec")]
856 pub combines: Vec<String>,
857 #[serde(default, deserialize_with = "de_string")]
858 pub how_it_works: String,
859 #[serde(default, deserialize_with = "de_string")]
861 pub why_new: String,
862 #[serde(default, deserialize_with = "de_string")]
863 pub enables: String,
864 #[serde(default, deserialize_with = "de_string")]
865 pub risks: String,
866 #[serde(default, deserialize_with = "de_string")]
868 pub first_experiment: String,
869}
870
871#[derive(Debug, Clone, Default, Serialize, Deserialize)]
872pub struct IdeasDoc {
873 #[serde(default)]
874 pub ideas: Vec<Idea>,
875}
876
877string_enum! {
878 pub enum CrossKind {
880 Build = "build" | "extend" | "improve" | "combine",
881 Challenge = "challenge" | "reject" | "exists" | "object",
882 Keep = "keep" | "agree" | "accept" | "fine",
883 }
884}
885
886#[derive(Debug, Clone, Serialize, Deserialize)]
888pub struct CrossVerdict {
889 #[serde(default, deserialize_with = "de_i64")]
892 pub idea: i64,
893 pub verdict: CrossKind,
894 #[serde(default, deserialize_with = "de_string")]
895 pub reason: String,
896 #[serde(default)]
898 pub build: Option<Idea>,
899}
900
901#[derive(Debug, Clone, Default, Serialize, Deserialize)]
902pub struct CrossDoc {
903 #[serde(default)]
904 pub verdicts: Vec<CrossVerdict>,
905}
906
907#[derive(Debug, Clone, Serialize, Deserialize)]
909pub struct Defence {
910 #[serde(default, deserialize_with = "de_i64")]
911 pub idea: i64,
912 #[serde(deserialize_with = "de_bool")]
914 pub stands: bool,
915 #[serde(default, deserialize_with = "de_string")]
916 pub reply: String,
917}
918
919#[derive(Debug, Clone, Default, Serialize, Deserialize)]
922pub struct ConvergeDoc {
923 #[serde(default)]
924 pub defences: Vec<Defence>,
925 #[serde(default, deserialize_with = "de_i64_vec")]
927 pub ranking: Vec<i64>,
928}
929
930#[derive(Debug, Clone)]
932pub struct Judged {
933 pub finding: Finding,
934 pub raised_by: String,
936 pub standing: Standing,
938 pub counterpoint: Option<String>,
940 pub defence: Option<String>,
945}
946
947string_enum! {
948 pub enum Standing {
951 Corroborated = "corroborated" | "both",
953 Confirmed = "confirmed",
955 Disputed = "disputed",
958 Withdrawn = "withdrawn",
960 Unverified = "unverified" | "one_reviewer",
962 }
963}
964
965#[derive(Debug, Clone, Default, Serialize, Deserialize)]
974pub struct Implementation {
975 #[serde(default, deserialize_with = "de_bool")]
977 pub not_worth_doing: bool,
978 #[serde(default, deserialize_with = "de_string")]
981 pub reason: String,
982 #[serde(default, deserialize_with = "de_string")]
984 pub summary: String,
985 #[serde(default, deserialize_with = "de_string")]
988 pub problem: String,
989 #[serde(default)]
991 pub changes: Vec<String>,
992 #[serde(default)]
994 pub testing: Vec<String>,
995 #[serde(default)]
998 pub notes: Option<String>,
999}
1000
1001#[derive(Debug, Clone, Serialize, Deserialize)]
1002pub struct ResponseDoc {
1003 #[serde(default, deserialize_with = "de_string")]
1004 pub summary: String,
1005 #[serde(default)]
1006 pub dispositions: Vec<Disposition>,
1007}
1008
1009#[derive(Debug, Clone, Serialize, Deserialize)]
1014pub struct PlanItem {
1015 pub issue: i64,
1016 pub title: String,
1017 pub complexity: Complexity,
1018 pub risk: Risk,
1019 pub depends_on: Vec<i64>,
1020 pub reason: String,
1021}
1022
1023#[derive(Debug, Clone, Serialize, Deserialize)]
1024pub struct SkippedItem {
1025 pub issue: i64,
1026 pub title: String,
1027 pub reasons: BTreeMap<String, String>,
1029 #[serde(default)]
1037 pub tracker: bool,
1038}
1039
1040#[derive(Debug, Clone, Serialize, Deserialize)]
1041pub struct ContestedItem {
1042 pub issue: i64,
1043 pub title: String,
1044 pub positions: BTreeMap<String, String>,
1046 pub reasons: BTreeMap<String, String>,
1047 #[serde(default, skip_serializing_if = "Option::is_none")]
1048 pub note: Option<String>,
1049}
1050
1051#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1058pub struct Contested {
1059 #[serde(default)]
1060 pub title: String,
1061 #[serde(default)]
1063 pub positions: BTreeMap<String, String>,
1064 #[serde(default)]
1065 pub reasons: BTreeMap<String, String>,
1066}
1067
1068#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1069pub struct Plan {
1070 #[serde(default)]
1071 pub order: Vec<PlanItem>,
1072 #[serde(default)]
1073 pub skipped: Vec<SkippedItem>,
1074 #[serde(default)]
1075 pub contested: Vec<ContestedItem>,
1076}
1077
1078string_enum! {
1079 pub enum Settled {
1093 Refuted = "refuted" | "refute" | "rejected",
1094 Filed = "filed" | "filed_issue" | "filed-issue" | "out_of_scope",
1095 Dropped = "dropped" | "not_filed" | "not-filed" | "unfiled",
1096 Fixed = "fixed" | "fix" | "changed",
1097 }
1098}
1099
1100impl Default for Settled {
1101 fn default() -> Self {
1103 Settled::Refuted
1104 }
1105}
1106
1107#[derive(Debug, Clone, PartialEq, Eq)]
1114pub enum Followup {
1115 Recorded(String),
1118 Covered(String),
1122 Dropped(&'static str),
1125 Failed,
1128}
1129
1130impl Followup {
1131 pub fn url(&self) -> Option<&str> {
1136 match self {
1137 Followup::Recorded(url) => Some(url),
1138 _ => None,
1139 }
1140 }
1141}
1142
1143#[derive(Debug, Clone, Serialize, Deserialize)]
1144pub struct LedgerEntry {
1145 pub title: String,
1146 pub file: String,
1147 pub reasoning: String,
1148 pub round: u32,
1149 #[serde(default)]
1150 pub reraised: u32,
1151 #[serde(default)]
1152 pub outcome: Settled,
1153}
1154
1155pub type Ledger = BTreeMap<String, LedgerEntry>;
1159
1160#[derive(Debug, Clone, Serialize, Deserialize)]
1161pub struct Dispute {
1162 pub title: String,
1163 #[serde(default, deserialize_with = "de_string")]
1164 pub file: String,
1165 pub reasoning: String,
1166}
1167
1168#[derive(Debug, Clone, Serialize, Deserialize)]
1170pub struct IssueRun {
1171 pub issue: i64,
1172 pub title: String,
1173 pub status: Status,
1174 #[serde(default, skip_serializing_if = "Option::is_none")]
1175 pub pr: Option<String>,
1176 #[serde(default)]
1177 pub rounds: u32,
1178 #[serde(default)]
1179 pub disputes: Vec<Dispute>,
1180 #[serde(default)]
1181 pub filed: Vec<String>,
1182 #[serde(default)]
1191 pub noted: Vec<Finding>,
1192 #[serde(default)]
1193 pub notes: Vec<String>,
1194 #[serde(skip)]
1197 pub followup_writes_uncertain: bool,
1198}
1199
1200impl IssueRun {
1201 pub fn new(issue: i64, title: impl Into<String>) -> Self {
1202 Self {
1203 issue,
1204 title: title.into(),
1205 status: Status::Pending,
1206 pr: None,
1207 rounds: 0,
1208 disputes: Vec::new(),
1209 filed: Vec::new(),
1210 noted: Vec::new(),
1211 notes: Vec::new(),
1212 followup_writes_uncertain: false,
1213 }
1214 }
1215
1216 pub fn succeeded(&self) -> bool {
1221 matches!(
1222 self.status,
1223 Status::Merged
1224 | Status::Approved
1225 | Status::Abandoned
1226 | Status::Reviewed
1227 | Status::Clean
1228 | Status::Answered
1229 | Status::Split
1233 | Status::Whole
1234 )
1235 }
1236}
1237
1238#[derive(Debug, Clone, Serialize, Deserialize)]
1240pub struct PersistedState {
1241 pub version: u32,
1242 #[serde(default)]
1244 pub checkpoint: u64,
1245 pub round: u32,
1246 pub next_actor: String,
1247 pub status: Status,
1248 #[serde(default, skip_serializing_if = "String::is_empty")]
1250 pub pr_head: String,
1251 #[serde(default)]
1252 pub ledger: Ledger,
1253 #[serde(default)]
1254 pub filed: Vec<String>,
1255 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1256 pub open_findings: Vec<Finding>,
1257 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1258 pub disputes: Vec<Dispute>,
1259 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1260 pub noted: Vec<Finding>,
1261}
1262
1263pub const STATE_VERSION: u32 = 2;
1264
1265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1270pub struct RecordedFinding {
1271 pub title: String,
1272 #[serde(default)]
1273 pub file: String,
1274 pub severity: Severity,
1275 #[serde(default)]
1276 pub detail: String,
1277 #[serde(default = "yes")]
1278 pub in_scope: bool,
1279 #[serde(default)]
1280 pub raised_by: String,
1281 pub standing: Standing,
1282 #[serde(default, skip_serializing_if = "Option::is_none")]
1283 pub counterpoint: Option<String>,
1284 #[serde(default, skip_serializing_if = "Option::is_none")]
1285 pub defence: Option<String>,
1286}
1287
1288impl RecordedFinding {
1289 pub fn blocks(&self) -> bool {
1290 self.severity == Severity::Blocking && self.in_scope
1291 }
1292
1293 pub fn where_at(&self) -> &str {
1294 if self.file.trim().is_empty() {
1295 "general"
1296 } else {
1297 self.file.trim()
1298 }
1299 }
1300
1301 pub fn to_judged(&self) -> Judged {
1304 Judged {
1305 finding: Finding {
1306 severity: self.severity,
1307 title: self.title.clone(),
1308 detail: self.detail.clone(),
1309 file: self.file.clone(),
1310 in_scope: self.in_scope,
1311 ..Finding::default()
1312 },
1313 raised_by: self.raised_by.clone(),
1314 standing: self.standing,
1315 counterpoint: self.counterpoint.clone(),
1316 defence: self.defence.clone(),
1317 }
1318 }
1319}
1320
1321impl From<&Judged> for RecordedFinding {
1322 fn from(j: &Judged) -> Self {
1323 Self {
1324 title: j.finding.title.clone(),
1325 file: j.finding.file.clone(),
1326 severity: j.finding.severity,
1327 detail: j.finding.detail.clone(),
1328 in_scope: j.finding.in_scope,
1329 raised_by: j.raised_by.clone(),
1330 standing: j.standing,
1331 counterpoint: j.counterpoint.clone(),
1332 defence: j.defence.clone(),
1333 }
1334 }
1335}
1336
1337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1343pub struct ReviewRecord {
1344 pub version: u32,
1345 pub head: String,
1348 pub reviewed_at: String,
1353 #[serde(default)]
1354 pub findings: Vec<RecordedFinding>,
1355 #[serde(default)]
1357 pub rechecks: u32,
1358 #[serde(default, skip_serializing_if = "Option::is_none")]
1360 pub verdict: Option<ReviewVerdict>,
1361 #[serde(default)]
1365 pub posted: bool,
1366}
1367
1368pub const REVIEW_RECORD_VERSION: u32 = 1;
1369
1370impl ReviewRecord {
1371 pub fn short_head(&self) -> &str {
1372 let end = self.head.len().min(7);
1373 &self.head[..end]
1374 }
1375
1376 pub fn open_points(&self) -> impl Iterator<Item = &RecordedFinding> {
1378 self.findings
1379 .iter()
1380 .filter(|f| f.standing != Standing::Withdrawn)
1381 }
1382}
1383
1384#[derive(Debug, Clone, Deserialize)]
1389pub struct Label {
1390 #[serde(default)]
1391 pub name: String,
1392}
1393
1394#[derive(Debug, Clone, Deserialize, Default)]
1395pub struct Author {
1396 #[serde(default)]
1397 pub login: String,
1398}
1399
1400pub fn login_of(author: Option<&Author>) -> &str {
1403 match author.map(|a| a.login.trim()) {
1404 Some(login) if !login.is_empty() => login,
1405 _ => "ghost",
1406 }
1407}
1408
1409#[derive(Debug, Clone, Deserialize, Default)]
1412#[serde(rename_all = "camelCase")]
1413pub struct IssueComment {
1414 #[serde(default)]
1416 pub author: Option<Author>,
1417 #[serde(default)]
1418 pub author_association: String,
1419 #[serde(default)]
1420 pub body: String,
1421 #[serde(default)]
1422 pub created_at: String,
1423 #[serde(default)]
1424 pub is_minimized: bool,
1425}
1426
1427impl IssueComment {
1428 pub fn login(&self) -> &str {
1429 login_of(self.author.as_ref())
1430 }
1431}
1432
1433#[derive(Debug, Clone, Deserialize)]
1434pub struct Issue {
1435 pub number: i64,
1436 #[serde(default)]
1437 pub title: String,
1438 #[serde(default)]
1439 pub body: Option<String>,
1440 #[serde(default)]
1441 pub state: String,
1442 #[serde(default, rename = "stateReason")]
1450 pub state_reason: Option<String>,
1451 #[serde(default)]
1452 pub url: String,
1453 #[serde(default)]
1454 pub labels: Vec<Label>,
1455 #[serde(default)]
1457 pub author: Option<Author>,
1458 #[serde(default)]
1461 pub comments: Vec<IssueComment>,
1462}
1463
1464impl Issue {
1465 pub fn body_text(&self) -> &str {
1466 self.body.as_deref().unwrap_or("")
1467 }
1468
1469 pub fn body_for_prompt(&self, max: usize) -> (String, bool) {
1481 let body = self.body_text().trim();
1482 if body.chars().count() <= max {
1483 return (body.to_string(), false);
1484 }
1485 let clipped: String = body.chars().take(max).collect();
1486 let mut kept = match clipped.rfind('\n') {
1487 Some(at) => clipped[..at].to_string(),
1488 None => clipped,
1489 };
1490 if kept.matches("```").count() % 2 == 1 {
1491 kept.push_str("\n```");
1492 }
1493 kept.push_str("\n\n[Shortened to fit. The rest of this issue was not included.]");
1494 (kept, true)
1495 }
1496
1497 pub fn is_closed(&self) -> bool {
1498 self.state.eq_ignore_ascii_case("closed")
1499 }
1500
1501 pub fn closed_as_completed(&self) -> bool {
1508 self.is_closed() && !self.closed_as_not_planned()
1509 }
1510
1511 pub fn closed_as_not_planned(&self) -> bool {
1512 self.is_closed()
1513 && self
1514 .state_reason
1515 .as_deref()
1516 .is_some_and(|reason| reason.eq_ignore_ascii_case("not_planned"))
1517 }
1518}
1519
1520#[derive(Debug, Clone, Deserialize)]
1521pub struct PrRef {
1522 pub number: i64,
1523 #[serde(default)]
1524 pub url: String,
1525 #[serde(default)]
1526 pub title: String,
1527}
1528
1529#[derive(Debug, Clone, Deserialize)]
1530pub struct IssueRef {
1531 pub number: i64,
1532}
1533
1534#[derive(Debug, Clone, Default, Deserialize)]
1540#[serde(rename_all = "camelCase")]
1541pub struct PrRow {
1542 pub number: i64,
1543 #[serde(default)]
1544 pub title: String,
1545 #[serde(default)]
1546 pub changed_files: i64,
1547 #[serde(default)]
1548 pub additions: i64,
1549 #[serde(default)]
1550 pub deletions: i64,
1551}
1552
1553impl PrRow {
1554 pub fn size(&self) -> String {
1556 format!(
1557 "{} file(s), +{} -{}",
1558 self.changed_files, self.additions, self.deletions
1559 )
1560 }
1561}
1562
1563#[derive(Debug, Clone, Deserialize)]
1564#[serde(rename_all = "camelCase")]
1565pub struct PrView {
1566 pub number: i64,
1567 #[serde(default)]
1568 pub url: String,
1569 #[serde(default)]
1570 pub title: String,
1571 #[serde(default)]
1574 pub body: String,
1575 #[serde(default)]
1576 pub head_ref_name: String,
1577 #[serde(default)]
1578 pub base_ref_name: String,
1579 #[serde(default)]
1580 pub state: String,
1581 #[serde(default)]
1582 pub closing_issues_references: Vec<IssueRef>,
1583 #[serde(default)]
1586 pub is_cross_repository: bool,
1587}
1588
1589pub const PR_SWEEP_FIELDS: &str =
1592 "number,title,headRefOid,updatedAt,author,isDraft,isCrossRepository,reviewRequests,assignees,state";
1593
1594#[derive(Debug, Clone, Default, Deserialize)]
1597#[serde(rename_all = "camelCase")]
1598pub struct PrSweepRow {
1599 pub number: i64,
1600 #[serde(default)]
1601 pub title: String,
1602 #[serde(default)]
1603 pub head_ref_oid: String,
1604 #[serde(default)]
1605 pub updated_at: String,
1606 #[serde(default)]
1607 pub author: Option<Author>,
1608 #[serde(default)]
1609 pub is_draft: bool,
1610 #[serde(default)]
1611 pub is_cross_repository: bool,
1612 #[serde(default)]
1613 pub review_requests: Vec<Login>,
1614 #[serde(default)]
1615 pub assignees: Vec<Login>,
1616 #[serde(default)]
1618 pub state: String,
1619}
1620
1621#[derive(Debug, Clone, Default, Deserialize)]
1625pub struct Login {
1626 #[serde(default)]
1627 pub login: String,
1628 #[serde(default)]
1630 pub slug: String,
1631}
1632
1633#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1636#[serde(rename_all = "lowercase")]
1637pub enum ItemKind {
1638 Issue,
1639 Pr,
1640}
1641
1642impl std::fmt::Display for ItemKind {
1643 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1644 f.write_str(match self {
1645 ItemKind::Issue => "issue",
1646 ItemKind::Pr => "pull request",
1647 })
1648 }
1649}
1650
1651impl PrView {
1652 pub fn is_open(&self) -> bool {
1653 self.state.eq_ignore_ascii_case("open")
1654 }
1655}
1656
1657#[derive(Debug, Clone, PartialEq, Eq)]
1665pub struct LiveHead {
1666 pub pull: String,
1668 pub branch: Option<String>,
1671}
1672
1673impl LiveHead {
1674 pub fn settled(&self) -> Option<&str> {
1676 match &self.branch {
1677 Some(branch) if !branch.eq_ignore_ascii_case(&self.pull) => None,
1678 _ => Some(&self.pull),
1679 }
1680 }
1681
1682 pub fn moved_from(&self, reviewed: &str) -> Option<&str> {
1685 self.branch
1686 .as_deref()
1687 .filter(|branch| !branch.eq_ignore_ascii_case(reviewed))
1688 .or_else(|| (!self.pull.eq_ignore_ascii_case(reviewed)).then_some(self.pull.as_str()))
1689 }
1690}
1691
1692#[cfg(test)]
1693mod tests {
1694 use super::*;
1695
1696 #[test]
1697 fn severity_accepts_the_canonical_spelling() {
1698 assert_eq!(
1699 Some(Severity::NonBlocking),
1700 Severity::parse_lenient("non-blocking")
1701 );
1702 }
1703
1704 #[test]
1705 fn severity_accepts_near_misses() {
1706 for text in ["NonBlocking", "non_blocking", " NON-BLOCKING ", "minor"] {
1707 assert_eq!(
1708 Some(Severity::NonBlocking),
1709 Severity::parse_lenient(text),
1710 "{text}"
1711 );
1712 }
1713 }
1714
1715 #[test]
1716 fn severity_rejects_nonsense() {
1717 assert_eq!(None, Severity::parse_lenient("catastrophic-ish"));
1718 }
1719
1720 #[test]
1721 fn complexity_ordering_is_cheapest_first() {
1722 assert!(Complexity::S.rank() < Complexity::M.rank());
1723 assert!(Complexity::M.rank() < Complexity::L.rank());
1724 }
1725
1726 #[test]
1727 fn finding_defaults_to_in_scope() {
1728 let f: Finding = serde_json::from_value(serde_json::json!({
1729 "severity": "blocking", "title": "t", "detail": "d", "file": "a.rs"
1730 }))
1731 .unwrap();
1732 assert!(f.in_scope);
1733 assert!(f.blocks());
1734 }
1735
1736 #[test]
1737 fn out_of_scope_blocking_does_not_block() {
1738 let f: Finding = serde_json::from_value(serde_json::json!({
1739 "severity": "blocking", "title": "t", "detail": "d",
1740 "file": "a.rs", "in_scope": false
1741 }))
1742 .unwrap();
1743 assert!(!f.blocks());
1744 }
1745
1746 #[test]
1747 fn triage_tolerates_a_quoted_issue_number() {
1748 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1749 "issue": "#42", "worth_doing": "yes", "reason": "r",
1750 "complexity": "medium", "depends_on": ["39"], "risk": "low"
1751 }))
1752 .unwrap();
1753 assert_eq!(42, v.issue);
1754 assert!(v.worth_doing);
1755 assert_eq!(Complexity::M, v.complexity);
1756 assert_eq!(vec![39], v.depends_on);
1757 }
1758
1759 #[test]
1760 fn triage_tolerates_a_missing_depends_on() {
1761 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1762 "issue": 1, "worth_doing": false, "reason": "r",
1763 "complexity": "s", "risk": "high"
1764 }))
1765 .unwrap();
1766 assert!(v.depends_on.is_empty());
1767 }
1768
1769 #[test]
1770 fn review_tolerates_a_missing_findings_array() {
1771 let r: Review = serde_json::from_value(serde_json::json!({
1772 "verdict": "approve", "next_action": "merge", "summary": "fine"
1773 }))
1774 .unwrap();
1775 assert!(r.findings.is_empty());
1776 }
1777
1778 #[test]
1779 fn a_null_reason_is_an_empty_string_not_a_failure() {
1780 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1781 "issue": 1, "worth_doing": true, "reason": null,
1782 "complexity": "s", "depends_on": [], "risk": "low"
1783 }))
1784 .unwrap();
1785 assert_eq!("", v.reason);
1786 }
1787
1788 #[test]
1789 fn unknown_severity_is_an_error_not_a_silent_downgrade() {
1790 let out: Result<Finding, _> = serde_json::from_value(serde_json::json!({
1791 "severity": "showstopper-maybe", "title": "t", "detail": "d", "file": "a.rs"
1792 }));
1793 assert!(out.is_err());
1794 }
1795
1796 #[test]
1800 fn deciding_not_to_split_is_not_a_failure() {
1801 for status in [Status::Split, Status::Whole] {
1802 let mut run = IssueRun::new(1, "t");
1803 run.status = status;
1804 assert!(run.succeeded(), "{status}");
1805 }
1806 }
1807
1808 #[test]
1809 fn status_round_trips_through_json() {
1810 let run = IssueRun::new(4, "t");
1811 let text = serde_json::to_string(&run).unwrap();
1812 let back: IssueRun = serde_json::from_str(&text).unwrap();
1813 assert_eq!(Status::Pending, back.status);
1814 }
1815
1816 #[test]
1819 fn a_look_whose_head_moved_did_not_do_its_job() {
1820 let mut run = IssueRun::new(4, "t");
1821 run.status = Status::Moved;
1822 assert!(!run.succeeded());
1823 let text = serde_json::to_string(&run).unwrap();
1824 assert!(text.contains("\"moved\""), "{text}");
1825 let back: IssueRun = serde_json::from_str(&text).unwrap();
1826 assert_eq!(Status::Moved, back.status);
1827 }
1828
1829 fn live(pull: &str, branch: Option<&str>) -> LiveHead {
1830 LiveHead {
1831 pull: pull.into(),
1832 branch: branch.map(String::from),
1833 }
1834 }
1835
1836 #[test]
1839 fn a_head_is_settled_only_once_the_pull_request_has_caught_up_with_its_branch() {
1840 assert_eq!(None, live("fb3a7ab", Some("2d5e2b5")).settled());
1841 assert_eq!(Some("2d5e2b5"), live("2d5e2b5", Some("2d5e2b5")).settled());
1842 assert_eq!(Some("2d5e2b5"), live("2d5e2b5", Some("2D5E2B5")).settled());
1843 assert_eq!(Some("fb3a7ab"), live("fb3a7ab", None).settled());
1845 }
1846
1847 #[test]
1848 fn a_head_moved_from_the_reviewed_one_names_the_newer_commit() {
1849 assert_eq!(None, live("fb3a7ab", Some("fb3a7ab")).moved_from("fb3a7ab"));
1850 assert_eq!(None, live("FB3A7AB", None).moved_from("fb3a7ab"));
1851 assert_eq!(
1853 Some("c493635"),
1854 live("2d5e2b5", Some("c493635")).moved_from("fb3a7ab")
1855 );
1856 assert_eq!(
1857 Some("c493635"),
1858 live("fb3a7ab", Some("c493635")).moved_from("fb3a7ab")
1859 );
1860 assert_eq!(Some("2d5e2b5"), live("2d5e2b5", None).moved_from("fb3a7ab"));
1861 assert_eq!(
1862 Some("2d5e2b5"),
1863 live("2d5e2b5", Some("fb3a7ab")).moved_from("fb3a7ab")
1864 );
1865 }
1866}
1867
1868#[cfg(test)]
1869mod body_for_prompt_tests {
1870 use super::*;
1871
1872 fn issue(body: &str) -> Issue {
1873 let mut i: Issue = serde_json::from_value(serde_json::json!({
1874 "number": 1, "title": "t", "state": "open", "url": "u"
1875 }))
1876 .expect("an issue");
1877 i.body = Some(body.to_string());
1878 i
1879 }
1880
1881 #[test]
1884 fn an_issue_that_fits_is_handed_over_whole() {
1885 let (body, cut) = issue("The guard is inverted.").body_for_prompt(60_000);
1886 assert_eq!("The guard is inverted.", body);
1887 assert!(!cut);
1888 }
1889
1890 #[test]
1894 fn a_shortened_body_says_so_in_the_text() {
1895 let long = "line of text\n".repeat(500);
1896 let (body, cut) = issue(&long).body_for_prompt(200);
1897 assert!(cut);
1898 assert!(body.contains("Shortened to fit"), "{body}");
1899 assert!(body.len() < long.len());
1900 }
1901
1902 #[test]
1905 fn a_cut_never_leaves_a_code_fence_open() {
1906 let body = format!("intro\n\n```rust\n{}\n```\n", "let x = 1;\n".repeat(200));
1907 let (out, cut) = issue(&body).body_for_prompt(120);
1908 assert!(cut);
1909 assert_eq!(0, out.matches("```").count() % 2, "{out}");
1910 }
1911
1912 #[test]
1914 fn a_cut_lands_on_a_line_boundary() {
1915 let body = "aaaa bbbb cccc\n".repeat(100);
1916 let (out, _) = issue(&body).body_for_prompt(100);
1917 let kept = out.split("\n\n[Shortened").next().expect("the kept part");
1918 assert!(kept.ends_with("cccc"), "{kept:?}");
1919 }
1920}
1921
1922#[cfg(test)]
1923mod review_record_tests {
1924 use super::*;
1925
1926 fn judged() -> Judged {
1927 Judged {
1928 finding: Finding {
1929 severity: Severity::Blocking,
1930 title: "Retry loop never terminates".into(),
1931 detail: "the guard compares against Some(0)".into(),
1932 file: "src/net.rs:88".into(),
1933 in_scope: true,
1934 problem: Some("dropped on the way to the record".into()),
1935 ..Finding::default()
1936 },
1937 raised_by: "claude and codex".into(),
1938 standing: Standing::Corroborated,
1939 counterpoint: None,
1940 defence: Some("stands".into()),
1941 }
1942 }
1943
1944 #[test]
1945 fn a_review_record_round_trips_through_json_and_keeps_its_standing_as_a_word() {
1946 let record = ReviewRecord {
1947 version: REVIEW_RECORD_VERSION,
1948 head: "0123abcdef".into(),
1949 reviewed_at: "2026-10-20T18:04:05Z".into(),
1950 findings: vec![RecordedFinding::from(&judged())],
1951 rechecks: 2,
1952 verdict: Some(ReviewVerdict::RequestChanges),
1953 posted: true,
1954 };
1955 let text = serde_json::to_string_pretty(&record).unwrap();
1956 assert!(text.contains("\"standing\": \"corroborated\""), "{text}");
1957 assert!(text.contains("\"verdict\": \"request_changes\""), "{text}");
1958 assert!(text.contains("\"severity\": \"blocking\""), "{text}");
1959 assert!(!text.contains("problem"), "{text}");
1960 let back: ReviewRecord = serde_json::from_str(&text).unwrap();
1961 assert_eq!(record, back);
1962 assert_eq!("0123abc", back.short_head());
1963 }
1964
1965 #[test]
1966 fn a_record_missing_the_newer_fields_still_reads() {
1967 let text = r#"{"version":1,"head":"abc","reviewed_at":"2026-10-20T18:04:05Z",
1968 "findings":[{"title":"T","severity":"nit","standing":"one_reviewer"}]}"#;
1969 let back: ReviewRecord = serde_json::from_str(text).unwrap();
1970 assert_eq!(0, back.rechecks);
1971 assert!(!back.posted);
1972 assert_eq!(None, back.verdict);
1973 assert!(back.findings[0].in_scope);
1974 assert_eq!(Standing::Unverified, back.findings[0].standing);
1975 assert_eq!("general", back.findings[0].where_at());
1976 }
1977
1978 #[test]
1979 fn a_recorded_finding_turns_back_into_a_judged_finding() {
1980 let original = judged();
1981 let back = RecordedFinding::from(&original).to_judged();
1982 assert_eq!(original.finding.title, back.finding.title);
1983 assert_eq!(original.finding.file, back.finding.file);
1984 assert_eq!(original.finding.severity, back.finding.severity);
1985 assert_eq!(original.raised_by, back.raised_by);
1986 assert_eq!(original.standing, back.standing);
1987 assert_eq!(original.defence, back.defence);
1988 assert_eq!(None, back.finding.problem);
1989 assert!(back.finding.blocks());
1990 }
1991
1992 #[test]
1993 fn a_ruling_tolerates_a_quoted_index_and_a_synonym_outcome() {
1994 let doc: RecheckDoc = serde_json::from_str(
1995 r#"{"summary":"holds","rulings":[{"index":"2","outcome":"Resolved","reasoning":"line 4"}],"findings":[]}"#,
1996 )
1997 .unwrap();
1998 assert_eq!(2, doc.rulings[0].index);
1999 assert_eq!(Ruling::Fixed, doc.rulings[0].outcome);
2000 let doc: RecheckDoc =
2001 serde_json::from_str(r#"{"summary":"","rulings":[],"findings":[]}"#).unwrap();
2002 assert!(doc.rulings.is_empty());
2003 assert_eq!("APPROVE", ReviewVerdict::Approve.api_event());
2004 assert_eq!("changes requested", ReviewVerdict::RequestChanges.words());
2005 }
2006}