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
619#[derive(Debug, Clone, Serialize, Deserialize)]
620pub struct Finding {
621 pub severity: Severity,
622 #[serde(default, deserialize_with = "de_string")]
623 pub title: String,
624 #[serde(default, deserialize_with = "de_string")]
625 pub detail: String,
626 #[serde(default, deserialize_with = "de_string")]
627 pub file: String,
628 #[serde(default = "yes", deserialize_with = "de_bool_default_true")]
631 pub in_scope: bool,
632
633 #[serde(default)]
641 pub problem: Option<String>,
642 #[serde(default)]
644 pub reproduction: Option<String>,
645 #[serde(default)]
647 pub impact: Option<String>,
648 #[serde(default)]
650 pub expected: Option<String>,
651}
652
653impl Default for Finding {
654 fn default() -> Self {
661 Self {
662 severity: Severity::Nit,
663 title: String::new(),
664 detail: String::new(),
665 file: String::new(),
666 in_scope: true,
667 problem: None,
668 reproduction: None,
669 impact: None,
670 expected: None,
671 }
672 }
673}
674
675impl Finding {
676 pub fn report_sections(&self) -> Vec<(&'static str, &str)> {
679 [
680 ("Problem", self.problem.as_deref()),
681 ("Reproduction", self.reproduction.as_deref()),
682 ("Impact", self.impact.as_deref()),
683 ("Expected behavior", self.expected.as_deref()),
684 ]
685 .into_iter()
686 .filter_map(|(heading, text)| {
687 text.map(str::trim)
688 .filter(|t| !t.is_empty())
689 .map(|t| (heading, t))
690 })
691 .collect()
692 }
693}
694
695fn yes() -> bool {
696 true
697}
698
699impl Finding {
700 pub fn blocks(&self) -> bool {
701 self.severity == Severity::Blocking && self.in_scope
702 }
703
704 pub fn where_at(&self) -> &str {
705 if self.file.trim().is_empty() {
706 "general"
707 } else {
708 self.file.trim()
709 }
710 }
711}
712
713#[derive(Debug, Clone, Serialize, Deserialize)]
714pub struct Review {
715 pub verdict: Verdict,
716 pub next_action: NextAction,
717 #[serde(default, deserialize_with = "de_string")]
718 pub summary: String,
719 #[serde(default)]
720 pub findings: Vec<Finding>,
721}
722
723#[derive(Debug, Clone, Serialize, Deserialize)]
724pub struct Disposition {
725 #[serde(default, deserialize_with = "de_string")]
726 pub title: String,
727 #[serde(default, deserialize_with = "de_string")]
731 pub file: String,
732 pub action: Action,
733 #[serde(default, deserialize_with = "de_string")]
734 pub reasoning: String,
735 #[serde(default)]
736 pub new_issue_title: Option<String>,
737 #[serde(default)]
738 pub new_issue_body: Option<String>,
739}
740
741#[derive(Debug, Clone, Serialize, Deserialize)]
749pub struct OwnFix {
750 #[serde(default, deserialize_with = "de_string")]
751 pub title: String,
752 #[serde(default, deserialize_with = "de_string")]
755 pub file: String,
756 #[serde(deserialize_with = "de_bool")]
757 pub fixed: bool,
758 #[serde(default, deserialize_with = "de_string")]
759 pub reason: String,
760}
761
762#[derive(Debug, Clone, Serialize, Deserialize)]
763pub struct OwnFixDoc {
764 #[serde(default, deserialize_with = "de_string")]
765 pub summary: String,
766 #[serde(default)]
767 pub fixes: Vec<OwnFix>,
768}
769
770#[derive(Debug, Clone, Serialize, Deserialize)]
777pub struct Adjudication {
778 #[serde(default, deserialize_with = "de_string")]
779 pub title: String,
780 #[serde(default, deserialize_with = "de_string")]
781 pub file: String,
782 #[serde(deserialize_with = "de_bool")]
785 pub agrees: bool,
786 pub severity: Severity,
788 #[serde(default, deserialize_with = "de_string")]
789 pub reasoning: String,
790}
791
792#[derive(Debug, Clone, Serialize, Deserialize)]
793pub struct AdjudicationDoc {
794 #[serde(default)]
795 pub verdicts: Vec<Adjudication>,
796}
797
798string_enum! {
799 pub enum Ruling {
801 Fixed = "fixed" | "resolved" | "addressed" | "done",
803 Rebutted = "rebutted" | "refuted" | "answered" | "withdrawn" | "not_a_bug",
806 Open = "open" | "still_open" | "unresolved" | "stands" | "unfixed",
809 }
810}
811
812#[derive(Debug, Clone, Serialize, Deserialize)]
814pub struct RecheckRuling {
815 #[serde(deserialize_with = "de_i64")]
819 pub index: i64,
820 pub outcome: Ruling,
821 #[serde(default, deserialize_with = "de_string")]
822 pub reasoning: String,
823}
824
825#[derive(Debug, Clone, Default, Serialize, Deserialize)]
828pub struct RecheckDoc {
829 #[serde(default, deserialize_with = "de_string")]
830 pub summary: String,
831 #[serde(default)]
832 pub rulings: Vec<RecheckRuling>,
833 #[serde(default)]
834 pub findings: Vec<Finding>,
835}
836
837#[derive(Debug, Clone, Default, Serialize, Deserialize)]
850pub struct Idea {
851 #[serde(default, deserialize_with = "de_string")]
852 pub title: String,
853 #[serde(default, deserialize_with = "de_string_vec")]
855 pub combines: Vec<String>,
856 #[serde(default, deserialize_with = "de_string")]
857 pub how_it_works: String,
858 #[serde(default, deserialize_with = "de_string")]
860 pub why_new: String,
861 #[serde(default, deserialize_with = "de_string")]
862 pub enables: String,
863 #[serde(default, deserialize_with = "de_string")]
864 pub risks: String,
865 #[serde(default, deserialize_with = "de_string")]
867 pub first_experiment: String,
868}
869
870#[derive(Debug, Clone, Default, Serialize, Deserialize)]
871pub struct IdeasDoc {
872 #[serde(default)]
873 pub ideas: Vec<Idea>,
874}
875
876string_enum! {
877 pub enum CrossKind {
879 Build = "build" | "extend" | "improve" | "combine",
880 Challenge = "challenge" | "reject" | "exists" | "object",
881 Keep = "keep" | "agree" | "accept" | "fine",
882 }
883}
884
885#[derive(Debug, Clone, Serialize, Deserialize)]
887pub struct CrossVerdict {
888 #[serde(default, deserialize_with = "de_i64")]
891 pub idea: i64,
892 pub verdict: CrossKind,
893 #[serde(default, deserialize_with = "de_string")]
894 pub reason: String,
895 #[serde(default)]
897 pub build: Option<Idea>,
898}
899
900#[derive(Debug, Clone, Default, Serialize, Deserialize)]
901pub struct CrossDoc {
902 #[serde(default)]
903 pub verdicts: Vec<CrossVerdict>,
904}
905
906#[derive(Debug, Clone, Serialize, Deserialize)]
908pub struct Defence {
909 #[serde(default, deserialize_with = "de_i64")]
910 pub idea: i64,
911 #[serde(deserialize_with = "de_bool")]
913 pub stands: bool,
914 #[serde(default, deserialize_with = "de_string")]
915 pub reply: String,
916}
917
918#[derive(Debug, Clone, Default, Serialize, Deserialize)]
921pub struct ConvergeDoc {
922 #[serde(default)]
923 pub defences: Vec<Defence>,
924 #[serde(default, deserialize_with = "de_i64_vec")]
926 pub ranking: Vec<i64>,
927}
928
929#[derive(Debug, Clone)]
931pub struct Judged {
932 pub finding: Finding,
933 pub raised_by: String,
935 pub standing: Standing,
937 pub counterpoint: Option<String>,
939 pub defence: Option<String>,
944 pub rated: Option<Severity>,
948}
949
950string_enum! {
951 pub enum Standing {
954 Corroborated = "corroborated" | "both",
956 Confirmed = "confirmed",
958 Disputed = "disputed",
962 Withdrawn = "withdrawn",
964 Unverified = "unverified" | "one_reviewer",
966 }
967}
968
969#[derive(Debug, Clone, Default, Serialize, Deserialize)]
978pub struct Implementation {
979 #[serde(default, deserialize_with = "de_bool")]
981 pub not_worth_doing: bool,
982 #[serde(default, deserialize_with = "de_string")]
985 pub reason: String,
986 #[serde(default, deserialize_with = "de_string")]
988 pub summary: String,
989 #[serde(default, deserialize_with = "de_string")]
992 pub problem: String,
993 #[serde(default)]
995 pub changes: Vec<String>,
996 #[serde(default)]
998 pub testing: Vec<String>,
999 #[serde(default)]
1002 pub notes: Option<String>,
1003}
1004
1005#[derive(Debug, Clone, Serialize, Deserialize)]
1006pub struct ResponseDoc {
1007 #[serde(default, deserialize_with = "de_string")]
1008 pub summary: String,
1009 #[serde(default)]
1010 pub dispositions: Vec<Disposition>,
1011}
1012
1013#[derive(Debug, Clone, Serialize, Deserialize)]
1018pub struct PlanItem {
1019 pub issue: i64,
1020 pub title: String,
1021 pub complexity: Complexity,
1022 pub risk: Risk,
1023 pub depends_on: Vec<i64>,
1024 pub reason: String,
1025}
1026
1027#[derive(Debug, Clone, Serialize, Deserialize)]
1028pub struct SkippedItem {
1029 pub issue: i64,
1030 pub title: String,
1031 pub reasons: BTreeMap<String, String>,
1033 #[serde(default)]
1041 pub tracker: bool,
1042}
1043
1044#[derive(Debug, Clone, Serialize, Deserialize)]
1045pub struct ContestedItem {
1046 pub issue: i64,
1047 pub title: String,
1048 pub positions: BTreeMap<String, String>,
1050 pub reasons: BTreeMap<String, String>,
1051 #[serde(default, skip_serializing_if = "Option::is_none")]
1052 pub note: Option<String>,
1053}
1054
1055#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1062pub struct Contested {
1063 #[serde(default)]
1064 pub title: String,
1065 #[serde(default)]
1067 pub positions: BTreeMap<String, String>,
1068 #[serde(default)]
1069 pub reasons: BTreeMap<String, String>,
1070}
1071
1072#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1073pub struct Plan {
1074 #[serde(default)]
1075 pub order: Vec<PlanItem>,
1076 #[serde(default)]
1077 pub skipped: Vec<SkippedItem>,
1078 #[serde(default)]
1079 pub contested: Vec<ContestedItem>,
1080}
1081
1082string_enum! {
1083 pub enum Settled {
1097 Refuted = "refuted" | "refute" | "rejected",
1098 Filed = "filed" | "filed_issue" | "filed-issue" | "out_of_scope",
1099 Dropped = "dropped" | "not_filed" | "not-filed" | "unfiled",
1100 Fixed = "fixed" | "fix" | "changed",
1101 }
1102}
1103
1104impl Default for Settled {
1105 fn default() -> Self {
1107 Settled::Refuted
1108 }
1109}
1110
1111#[derive(Debug, Clone, PartialEq, Eq)]
1118pub enum Followup {
1119 Recorded(String),
1122 Covered(String),
1126 Dropped(&'static str),
1129 Failed,
1132}
1133
1134impl Followup {
1135 pub fn url(&self) -> Option<&str> {
1140 match self {
1141 Followup::Recorded(url) => Some(url),
1142 _ => None,
1143 }
1144 }
1145}
1146
1147#[derive(Debug, Clone, Serialize, Deserialize)]
1148pub struct LedgerEntry {
1149 pub title: String,
1150 pub file: String,
1151 pub reasoning: String,
1152 pub round: u32,
1153 #[serde(default)]
1154 pub reraised: u32,
1155 #[serde(default)]
1156 pub outcome: Settled,
1157}
1158
1159pub type Ledger = BTreeMap<String, LedgerEntry>;
1163
1164#[derive(Debug, Clone, Serialize, Deserialize)]
1165pub struct Dispute {
1166 pub title: String,
1167 #[serde(default, deserialize_with = "de_string")]
1168 pub file: String,
1169 pub reasoning: String,
1170}
1171
1172#[derive(Debug, Clone, Serialize, Deserialize)]
1174pub struct IssueRun {
1175 pub issue: i64,
1176 pub title: String,
1177 pub status: Status,
1178 #[serde(default, skip_serializing_if = "Option::is_none")]
1179 pub pr: Option<String>,
1180 #[serde(default)]
1181 pub rounds: u32,
1182 #[serde(default)]
1183 pub disputes: Vec<Dispute>,
1184 #[serde(default)]
1185 pub filed: Vec<String>,
1186 #[serde(default)]
1195 pub noted: Vec<Finding>,
1196 #[serde(default)]
1197 pub notes: Vec<String>,
1198 #[serde(skip)]
1201 pub followup_writes_uncertain: bool,
1202}
1203
1204impl IssueRun {
1205 pub fn new(issue: i64, title: impl Into<String>) -> Self {
1206 Self {
1207 issue,
1208 title: title.into(),
1209 status: Status::Pending,
1210 pr: None,
1211 rounds: 0,
1212 disputes: Vec::new(),
1213 filed: Vec::new(),
1214 noted: Vec::new(),
1215 notes: Vec::new(),
1216 followup_writes_uncertain: false,
1217 }
1218 }
1219
1220 pub fn succeeded(&self) -> bool {
1225 matches!(
1226 self.status,
1227 Status::Merged
1228 | Status::Approved
1229 | Status::Abandoned
1230 | Status::Reviewed
1231 | Status::Clean
1232 | Status::Answered
1233 | Status::Split
1237 | Status::Whole
1238 )
1239 }
1240}
1241
1242#[derive(Debug, Clone, Serialize, Deserialize)]
1244pub struct PersistedState {
1245 pub version: u32,
1246 #[serde(default)]
1248 pub checkpoint: u64,
1249 pub round: u32,
1250 pub next_actor: String,
1251 pub status: Status,
1252 #[serde(default, skip_serializing_if = "String::is_empty")]
1254 pub pr_head: String,
1255 #[serde(default)]
1256 pub ledger: Ledger,
1257 #[serde(default)]
1258 pub filed: Vec<String>,
1259 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1260 pub open_findings: Vec<Finding>,
1261 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1262 pub disputes: Vec<Dispute>,
1263 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1264 pub noted: Vec<Finding>,
1265}
1266
1267pub const STATE_VERSION: u32 = 2;
1268
1269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1274pub struct RecordedFinding {
1275 pub title: String,
1276 #[serde(default)]
1277 pub file: String,
1278 pub severity: Severity,
1279 #[serde(default)]
1280 pub detail: String,
1281 #[serde(default = "yes")]
1282 pub in_scope: bool,
1283 #[serde(default)]
1284 pub raised_by: String,
1285 pub standing: Standing,
1286 #[serde(default, skip_serializing_if = "Option::is_none")]
1287 pub counterpoint: Option<String>,
1288 #[serde(default, skip_serializing_if = "Option::is_none")]
1289 pub defence: Option<String>,
1290}
1291
1292impl RecordedFinding {
1293 pub fn blocks(&self) -> bool {
1294 self.severity == Severity::Blocking && self.in_scope
1295 }
1296
1297 pub fn where_at(&self) -> &str {
1298 if self.file.trim().is_empty() {
1299 "general"
1300 } else {
1301 self.file.trim()
1302 }
1303 }
1304
1305 pub fn to_judged(&self) -> Judged {
1308 Judged {
1309 finding: Finding {
1310 severity: self.severity,
1311 title: self.title.clone(),
1312 detail: self.detail.clone(),
1313 file: self.file.clone(),
1314 in_scope: self.in_scope,
1315 ..Finding::default()
1316 },
1317 raised_by: self.raised_by.clone(),
1318 standing: self.standing,
1319 counterpoint: self.counterpoint.clone(),
1320 defence: self.defence.clone(),
1321 rated: None,
1322 }
1323 }
1324}
1325
1326impl From<&Judged> for RecordedFinding {
1327 fn from(j: &Judged) -> Self {
1328 Self {
1329 title: j.finding.title.clone(),
1330 file: j.finding.file.clone(),
1331 severity: j.finding.severity,
1332 detail: j.finding.detail.clone(),
1333 in_scope: j.finding.in_scope,
1334 raised_by: j.raised_by.clone(),
1335 standing: j.standing,
1336 counterpoint: j.counterpoint.clone(),
1337 defence: j.defence.clone(),
1338 }
1339 }
1340}
1341
1342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1348pub struct ReviewRecord {
1349 pub version: u32,
1350 pub head: String,
1353 pub reviewed_at: String,
1358 #[serde(default)]
1359 pub findings: Vec<RecordedFinding>,
1360 #[serde(default)]
1362 pub rechecks: u32,
1363 #[serde(default, skip_serializing_if = "Option::is_none")]
1365 pub verdict: Option<ReviewVerdict>,
1366 #[serde(default)]
1370 pub posted: bool,
1371}
1372
1373pub const REVIEW_RECORD_VERSION: u32 = 1;
1374
1375impl ReviewRecord {
1376 pub fn short_head(&self) -> &str {
1377 let end = self.head.len().min(7);
1378 &self.head[..end]
1379 }
1380
1381 pub fn open_points(&self) -> impl Iterator<Item = &RecordedFinding> {
1383 self.findings
1384 .iter()
1385 .filter(|f| f.standing != Standing::Withdrawn)
1386 }
1387}
1388
1389#[derive(Debug, Clone, PartialEq, Eq)]
1395pub struct InlineComment {
1396 pub path: String,
1397 pub line: u32,
1398 pub start_line: Option<u32>,
1399 pub body: String,
1400}
1401
1402#[derive(Debug, Clone, Deserialize)]
1407pub struct Label {
1408 #[serde(default)]
1409 pub name: String,
1410}
1411
1412#[derive(Debug, Clone, Deserialize, Default)]
1413pub struct Author {
1414 #[serde(default)]
1415 pub login: String,
1416}
1417
1418pub fn login_of(author: Option<&Author>) -> &str {
1421 match author.map(|a| a.login.trim()) {
1422 Some(login) if !login.is_empty() => login,
1423 _ => "ghost",
1424 }
1425}
1426
1427#[derive(Debug, Clone, Deserialize, Default)]
1430#[serde(rename_all = "camelCase")]
1431pub struct IssueComment {
1432 #[serde(default)]
1434 pub author: Option<Author>,
1435 #[serde(default)]
1436 pub author_association: String,
1437 #[serde(default)]
1439 pub viewer_did_author: bool,
1440 #[serde(default)]
1441 pub body: String,
1442 #[serde(default)]
1443 pub created_at: String,
1444 #[serde(default)]
1445 pub is_minimized: bool,
1446}
1447
1448impl IssueComment {
1449 pub fn login(&self) -> &str {
1450 login_of(self.author.as_ref())
1451 }
1452}
1453
1454#[derive(Debug, Clone, Deserialize)]
1455pub struct Issue {
1456 pub number: i64,
1457 #[serde(default)]
1458 pub title: String,
1459 #[serde(default)]
1460 pub body: Option<String>,
1461 #[serde(default)]
1462 pub state: String,
1463 #[serde(default, rename = "stateReason")]
1471 pub state_reason: Option<String>,
1472 #[serde(default)]
1473 pub url: String,
1474 #[serde(default)]
1475 pub labels: Vec<Label>,
1476 #[serde(default)]
1478 pub author: Option<Author>,
1479 #[serde(default)]
1482 pub comments: Vec<IssueComment>,
1483}
1484
1485impl Issue {
1486 pub fn body_text(&self) -> &str {
1487 self.body.as_deref().unwrap_or("")
1488 }
1489
1490 pub fn body_for_prompt(&self, max: usize) -> (String, bool) {
1502 let body = self.body_text().trim();
1503 if body.chars().count() <= max {
1504 return (body.to_string(), false);
1505 }
1506 let clipped: String = body.chars().take(max).collect();
1507 let mut kept = match clipped.rfind('\n') {
1508 Some(at) => clipped[..at].to_string(),
1509 None => clipped,
1510 };
1511 if kept.matches("```").count() % 2 == 1 {
1512 kept.push_str("\n```");
1513 }
1514 kept.push_str("\n\n[Shortened to fit. The rest of this issue was not included.]");
1515 (kept, true)
1516 }
1517
1518 pub fn is_closed(&self) -> bool {
1519 self.state.eq_ignore_ascii_case("closed")
1520 }
1521
1522 pub fn closed_as_completed(&self) -> bool {
1529 self.is_closed() && !self.closed_as_not_planned()
1530 }
1531
1532 pub fn closed_as_not_planned(&self) -> bool {
1533 self.is_closed()
1534 && self
1535 .state_reason
1536 .as_deref()
1537 .is_some_and(|reason| reason.eq_ignore_ascii_case("not_planned"))
1538 }
1539}
1540
1541#[derive(Debug, Clone, Deserialize)]
1542pub struct PrRef {
1543 pub number: i64,
1544 #[serde(default)]
1545 pub url: String,
1546 #[serde(default)]
1547 pub title: String,
1548}
1549
1550#[derive(Debug, Clone, Deserialize)]
1551pub struct IssueRef {
1552 pub number: i64,
1553}
1554
1555#[derive(Debug, Clone, Default, Deserialize)]
1561#[serde(rename_all = "camelCase")]
1562pub struct PrRow {
1563 pub number: i64,
1564 #[serde(default)]
1565 pub title: String,
1566 #[serde(default)]
1567 pub changed_files: i64,
1568 #[serde(default)]
1569 pub additions: i64,
1570 #[serde(default)]
1571 pub deletions: i64,
1572}
1573
1574impl PrRow {
1575 pub fn size(&self) -> String {
1577 format!(
1578 "{} file(s), +{} -{}",
1579 self.changed_files, self.additions, self.deletions
1580 )
1581 }
1582}
1583
1584#[derive(Debug, Clone, Deserialize)]
1585#[serde(rename_all = "camelCase")]
1586pub struct PrView {
1587 pub number: i64,
1588 #[serde(default)]
1589 pub url: String,
1590 #[serde(default)]
1591 pub title: String,
1592 #[serde(default)]
1595 pub body: String,
1596 #[serde(default)]
1597 pub head_ref_name: String,
1598 #[serde(default)]
1599 pub base_ref_name: String,
1600 #[serde(default)]
1601 pub state: String,
1602 #[serde(default)]
1603 pub closing_issues_references: Vec<IssueRef>,
1604 #[serde(default)]
1607 pub is_cross_repository: bool,
1608 #[serde(default)]
1612 pub head_repository_owner: Option<Author>,
1613 #[serde(default)]
1615 pub head_repository: Option<HeadRepository>,
1616}
1617
1618#[derive(Debug, Clone, Default, Deserialize)]
1619pub struct HeadRepository {
1620 #[serde(default)]
1621 pub name: String,
1622}
1623
1624pub const PR_SWEEP_FIELDS: &str =
1627 "number,title,headRefOid,updatedAt,author,isDraft,isCrossRepository,reviewRequests,assignees,state";
1628
1629#[derive(Debug, Clone, Default, Deserialize)]
1632#[serde(rename_all = "camelCase")]
1633pub struct PrSweepRow {
1634 pub number: i64,
1635 #[serde(default)]
1636 pub title: String,
1637 #[serde(default)]
1638 pub head_ref_oid: String,
1639 #[serde(default)]
1640 pub updated_at: String,
1641 #[serde(default)]
1642 pub author: Option<Author>,
1643 #[serde(default)]
1644 pub is_draft: bool,
1645 #[serde(default)]
1646 pub is_cross_repository: bool,
1647 #[serde(default)]
1648 pub review_requests: Vec<Login>,
1649 #[serde(default)]
1650 pub assignees: Vec<Login>,
1651 #[serde(default)]
1653 pub state: String,
1654}
1655
1656#[derive(Debug, Clone, Default, Deserialize)]
1660pub struct Login {
1661 #[serde(default)]
1662 pub login: String,
1663 #[serde(default)]
1665 pub slug: String,
1666}
1667
1668#[derive(Debug, Clone, Default)]
1671pub struct ReviewRequested {
1672 pub at: String,
1673 pub who: Login,
1674}
1675
1676#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1679#[serde(rename_all = "lowercase")]
1680pub enum ItemKind {
1681 Issue,
1682 Pr,
1683}
1684
1685impl std::fmt::Display for ItemKind {
1686 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1687 f.write_str(match self {
1688 ItemKind::Issue => "issue",
1689 ItemKind::Pr => "pull request",
1690 })
1691 }
1692}
1693
1694impl PrView {
1695 pub fn is_open(&self) -> bool {
1696 self.state.eq_ignore_ascii_case("open")
1697 }
1698}
1699
1700#[derive(Debug, Clone, PartialEq, Eq)]
1708pub struct LiveHead {
1709 pub pull: String,
1711 pub branch: Option<String>,
1714}
1715
1716impl LiveHead {
1717 pub fn settled(&self) -> Option<&str> {
1719 match &self.branch {
1720 Some(branch) if !branch.eq_ignore_ascii_case(&self.pull) => None,
1721 _ => Some(&self.pull),
1722 }
1723 }
1724
1725 pub fn moved_from(&self, reviewed: &str) -> Option<&str> {
1728 self.branch
1729 .as_deref()
1730 .filter(|branch| !branch.eq_ignore_ascii_case(reviewed))
1731 .or_else(|| (!self.pull.eq_ignore_ascii_case(reviewed)).then_some(self.pull.as_str()))
1732 }
1733}
1734
1735#[cfg(test)]
1736mod tests {
1737 use super::*;
1738
1739 #[test]
1740 fn severity_accepts_the_canonical_spelling() {
1741 assert_eq!(
1742 Some(Severity::NonBlocking),
1743 Severity::parse_lenient("non-blocking")
1744 );
1745 }
1746
1747 #[test]
1748 fn severity_accepts_near_misses() {
1749 for text in ["NonBlocking", "non_blocking", " NON-BLOCKING ", "minor"] {
1750 assert_eq!(
1751 Some(Severity::NonBlocking),
1752 Severity::parse_lenient(text),
1753 "{text}"
1754 );
1755 }
1756 }
1757
1758 #[test]
1759 fn severity_rejects_nonsense() {
1760 assert_eq!(None, Severity::parse_lenient("catastrophic-ish"));
1761 }
1762
1763 #[test]
1764 fn complexity_ordering_is_cheapest_first() {
1765 assert!(Complexity::S.rank() < Complexity::M.rank());
1766 assert!(Complexity::M.rank() < Complexity::L.rank());
1767 }
1768
1769 #[test]
1770 fn finding_defaults_to_in_scope() {
1771 let f: Finding = serde_json::from_value(serde_json::json!({
1772 "severity": "blocking", "title": "t", "detail": "d", "file": "a.rs"
1773 }))
1774 .unwrap();
1775 assert!(f.in_scope);
1776 assert!(f.blocks());
1777 }
1778
1779 #[test]
1780 fn out_of_scope_blocking_does_not_block() {
1781 let f: Finding = serde_json::from_value(serde_json::json!({
1782 "severity": "blocking", "title": "t", "detail": "d",
1783 "file": "a.rs", "in_scope": false
1784 }))
1785 .unwrap();
1786 assert!(!f.blocks());
1787 }
1788
1789 #[test]
1790 fn triage_tolerates_a_quoted_issue_number() {
1791 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1792 "issue": "#42", "worth_doing": "yes", "reason": "r",
1793 "complexity": "medium", "depends_on": ["39"], "risk": "low"
1794 }))
1795 .unwrap();
1796 assert_eq!(42, v.issue);
1797 assert!(v.worth_doing);
1798 assert_eq!(Complexity::M, v.complexity);
1799 assert_eq!(vec![39], v.depends_on);
1800 }
1801
1802 #[test]
1803 fn triage_tolerates_a_missing_depends_on() {
1804 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1805 "issue": 1, "worth_doing": false, "reason": "r",
1806 "complexity": "s", "risk": "high"
1807 }))
1808 .unwrap();
1809 assert!(v.depends_on.is_empty());
1810 }
1811
1812 #[test]
1813 fn review_tolerates_a_missing_findings_array() {
1814 let r: Review = serde_json::from_value(serde_json::json!({
1815 "verdict": "approve", "next_action": "merge", "summary": "fine"
1816 }))
1817 .unwrap();
1818 assert!(r.findings.is_empty());
1819 }
1820
1821 #[test]
1822 fn a_null_reason_is_an_empty_string_not_a_failure() {
1823 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1824 "issue": 1, "worth_doing": true, "reason": null,
1825 "complexity": "s", "depends_on": [], "risk": "low"
1826 }))
1827 .unwrap();
1828 assert_eq!("", v.reason);
1829 }
1830
1831 #[test]
1832 fn unknown_severity_is_an_error_not_a_silent_downgrade() {
1833 let out: Result<Finding, _> = serde_json::from_value(serde_json::json!({
1834 "severity": "showstopper-maybe", "title": "t", "detail": "d", "file": "a.rs"
1835 }));
1836 assert!(out.is_err());
1837 }
1838
1839 #[test]
1843 fn deciding_not_to_split_is_not_a_failure() {
1844 for status in [Status::Split, Status::Whole] {
1845 let mut run = IssueRun::new(1, "t");
1846 run.status = status;
1847 assert!(run.succeeded(), "{status}");
1848 }
1849 }
1850
1851 #[test]
1852 fn status_round_trips_through_json() {
1853 let run = IssueRun::new(4, "t");
1854 let text = serde_json::to_string(&run).unwrap();
1855 let back: IssueRun = serde_json::from_str(&text).unwrap();
1856 assert_eq!(Status::Pending, back.status);
1857 }
1858
1859 #[test]
1862 fn a_look_whose_head_moved_did_not_do_its_job() {
1863 let mut run = IssueRun::new(4, "t");
1864 run.status = Status::Moved;
1865 assert!(!run.succeeded());
1866 let text = serde_json::to_string(&run).unwrap();
1867 assert!(text.contains("\"moved\""), "{text}");
1868 let back: IssueRun = serde_json::from_str(&text).unwrap();
1869 assert_eq!(Status::Moved, back.status);
1870 }
1871
1872 fn live(pull: &str, branch: Option<&str>) -> LiveHead {
1873 LiveHead {
1874 pull: pull.into(),
1875 branch: branch.map(String::from),
1876 }
1877 }
1878
1879 #[test]
1882 fn a_head_is_settled_only_once_the_pull_request_has_caught_up_with_its_branch() {
1883 assert_eq!(None, live("fb3a7ab", Some("2d5e2b5")).settled());
1884 assert_eq!(Some("2d5e2b5"), live("2d5e2b5", Some("2d5e2b5")).settled());
1885 assert_eq!(Some("2d5e2b5"), live("2d5e2b5", Some("2D5E2B5")).settled());
1886 assert_eq!(Some("fb3a7ab"), live("fb3a7ab", None).settled());
1888 }
1889
1890 #[test]
1891 fn a_head_moved_from_the_reviewed_one_names_the_newer_commit() {
1892 assert_eq!(None, live("fb3a7ab", Some("fb3a7ab")).moved_from("fb3a7ab"));
1893 assert_eq!(None, live("FB3A7AB", None).moved_from("fb3a7ab"));
1894 assert_eq!(
1896 Some("c493635"),
1897 live("2d5e2b5", Some("c493635")).moved_from("fb3a7ab")
1898 );
1899 assert_eq!(
1900 Some("c493635"),
1901 live("fb3a7ab", Some("c493635")).moved_from("fb3a7ab")
1902 );
1903 assert_eq!(Some("2d5e2b5"), live("2d5e2b5", None).moved_from("fb3a7ab"));
1904 assert_eq!(
1905 Some("2d5e2b5"),
1906 live("2d5e2b5", Some("fb3a7ab")).moved_from("fb3a7ab")
1907 );
1908 }
1909}
1910
1911#[cfg(test)]
1912mod body_for_prompt_tests {
1913 use super::*;
1914
1915 fn issue(body: &str) -> Issue {
1916 let mut i: Issue = serde_json::from_value(serde_json::json!({
1917 "number": 1, "title": "t", "state": "open", "url": "u"
1918 }))
1919 .expect("an issue");
1920 i.body = Some(body.to_string());
1921 i
1922 }
1923
1924 #[test]
1927 fn an_issue_that_fits_is_handed_over_whole() {
1928 let (body, cut) = issue("The guard is inverted.").body_for_prompt(60_000);
1929 assert_eq!("The guard is inverted.", body);
1930 assert!(!cut);
1931 }
1932
1933 #[test]
1937 fn a_shortened_body_says_so_in_the_text() {
1938 let long = "line of text\n".repeat(500);
1939 let (body, cut) = issue(&long).body_for_prompt(200);
1940 assert!(cut);
1941 assert!(body.contains("Shortened to fit"), "{body}");
1942 assert!(body.len() < long.len());
1943 }
1944
1945 #[test]
1948 fn a_cut_never_leaves_a_code_fence_open() {
1949 let body = format!("intro\n\n```rust\n{}\n```\n", "let x = 1;\n".repeat(200));
1950 let (out, cut) = issue(&body).body_for_prompt(120);
1951 assert!(cut);
1952 assert_eq!(0, out.matches("```").count() % 2, "{out}");
1953 }
1954
1955 #[test]
1957 fn a_cut_lands_on_a_line_boundary() {
1958 let body = "aaaa bbbb cccc\n".repeat(100);
1959 let (out, _) = issue(&body).body_for_prompt(100);
1960 let kept = out.split("\n\n[Shortened").next().expect("the kept part");
1961 assert!(kept.ends_with("cccc"), "{kept:?}");
1962 }
1963}
1964
1965#[cfg(test)]
1966mod review_record_tests {
1967 use super::*;
1968
1969 fn judged() -> Judged {
1970 Judged {
1971 finding: Finding {
1972 severity: Severity::Blocking,
1973 title: "Retry loop never terminates".into(),
1974 detail: "the guard compares against Some(0)".into(),
1975 file: "src/net.rs:88".into(),
1976 in_scope: true,
1977 problem: Some("dropped on the way to the record".into()),
1978 ..Finding::default()
1979 },
1980 raised_by: "claude and codex".into(),
1981 standing: Standing::Corroborated,
1982 counterpoint: None,
1983 defence: Some("stands".into()),
1984 rated: None,
1985 }
1986 }
1987
1988 #[test]
1989 fn a_review_record_round_trips_through_json_and_keeps_its_standing_as_a_word() {
1990 let record = ReviewRecord {
1991 version: REVIEW_RECORD_VERSION,
1992 head: "0123abcdef".into(),
1993 reviewed_at: "2026-10-20T18:04:05Z".into(),
1994 findings: vec![RecordedFinding::from(&judged())],
1995 rechecks: 2,
1996 verdict: Some(ReviewVerdict::RequestChanges),
1997 posted: true,
1998 };
1999 let text = serde_json::to_string_pretty(&record).unwrap();
2000 assert!(text.contains("\"standing\": \"corroborated\""), "{text}");
2001 assert!(text.contains("\"verdict\": \"request_changes\""), "{text}");
2002 assert!(text.contains("\"severity\": \"blocking\""), "{text}");
2003 assert!(!text.contains("problem"), "{text}");
2004 let back: ReviewRecord = serde_json::from_str(&text).unwrap();
2005 assert_eq!(record, back);
2006 assert_eq!("0123abc", back.short_head());
2007 }
2008
2009 #[test]
2010 fn a_record_missing_the_newer_fields_still_reads() {
2011 let text = r#"{"version":1,"head":"abc","reviewed_at":"2026-10-20T18:04:05Z",
2012 "findings":[{"title":"T","severity":"nit","standing":"one_reviewer"}]}"#;
2013 let back: ReviewRecord = serde_json::from_str(text).unwrap();
2014 assert_eq!(0, back.rechecks);
2015 assert!(!back.posted);
2016 assert_eq!(None, back.verdict);
2017 assert!(back.findings[0].in_scope);
2018 assert_eq!(Standing::Unverified, back.findings[0].standing);
2019 assert_eq!("general", back.findings[0].where_at());
2020 }
2021
2022 #[test]
2023 fn a_recorded_finding_turns_back_into_a_judged_finding() {
2024 let original = judged();
2025 let back = RecordedFinding::from(&original).to_judged();
2026 assert_eq!(original.finding.title, back.finding.title);
2027 assert_eq!(original.finding.file, back.finding.file);
2028 assert_eq!(original.finding.severity, back.finding.severity);
2029 assert_eq!(original.raised_by, back.raised_by);
2030 assert_eq!(original.standing, back.standing);
2031 assert_eq!(original.defence, back.defence);
2032 assert_eq!(None, back.finding.problem);
2033 assert!(back.finding.blocks());
2034 }
2035
2036 #[test]
2037 fn a_ruling_tolerates_a_quoted_index_and_a_synonym_outcome() {
2038 let doc: RecheckDoc = serde_json::from_str(
2039 r#"{"summary":"holds","rulings":[{"index":"2","outcome":"Resolved","reasoning":"line 4"}],"findings":[]}"#,
2040 )
2041 .unwrap();
2042 assert_eq!(2, doc.rulings[0].index);
2043 assert_eq!(Ruling::Fixed, doc.rulings[0].outcome);
2044 let doc: RecheckDoc =
2045 serde_json::from_str(r#"{"summary":"","rulings":[],"findings":[]}"#).unwrap();
2046 assert!(doc.rulings.is_empty());
2047 assert_eq!("APPROVE", ReviewVerdict::Approve.api_event());
2048 assert_eq!("changes requested", ReviewVerdict::RequestChanges.words());
2049 }
2050}