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 }
225}
226
227impl Complexity {
228 pub fn rank(self) -> u8 {
229 match self {
230 Complexity::S => 0,
231 Complexity::M => 1,
232 Complexity::L => 2,
233 }
234 }
235}
236
237impl Severity {
238 pub fn rank(self) -> u8 {
242 match self {
243 Severity::Nit => 0,
244 Severity::NonBlocking => 1,
245 Severity::Blocking => 2,
246 }
247 }
248
249 pub fn graver(self, other: Self) -> Self {
256 if self.rank() >= other.rank() {
257 self
258 } else {
259 other
260 }
261 }
262}
263
264impl Risk {
265 pub fn rank(self) -> u8 {
266 match self {
267 Risk::Low => 0,
268 Risk::Med => 1,
269 Risk::High => 2,
270 }
271 }
272}
273
274pub fn de_i64<'de, D: Deserializer<'de>>(d: D) -> Result<i64, D::Error> {
281 struct V;
282 impl<'de> Visitor<'de> for V {
283 type Value = i64;
284 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285 f.write_str("an issue number")
286 }
287 fn visit_i64<E: de::Error>(self, v: i64) -> Result<i64, E> {
288 Ok(v)
289 }
290 fn visit_u64<E: de::Error>(self, v: u64) -> Result<i64, E> {
291 Ok(v as i64)
292 }
293 fn visit_f64<E: de::Error>(self, v: f64) -> Result<i64, E> {
294 Ok(v as i64)
295 }
296 fn visit_str<E: de::Error>(self, v: &str) -> Result<i64, E> {
297 v.trim()
298 .trim_start_matches('#')
299 .parse()
300 .map_err(|_| E::custom(format!("{v} is not a number")))
301 }
302 }
303 d.deserialize_any(V)
304}
305
306fn de_i64_vec<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<i64>, D::Error> {
307 #[derive(Deserialize)]
308 struct One(#[serde(deserialize_with = "de_i64")] i64);
309 let raw = Option::<Vec<One>>::deserialize(d)?;
310 Ok(raw
311 .unwrap_or_default()
312 .into_iter()
313 .map(|One(n)| n)
314 .collect())
315}
316
317pub fn de_bool<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
319 struct V;
320 impl<'de> Visitor<'de> for V {
321 type Value = bool;
322 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
323 f.write_str("a boolean")
324 }
325 fn visit_bool<E: de::Error>(self, v: bool) -> Result<bool, E> {
326 Ok(v)
327 }
328 fn visit_i64<E: de::Error>(self, v: i64) -> Result<bool, E> {
329 Ok(v != 0)
330 }
331 fn visit_u64<E: de::Error>(self, v: u64) -> Result<bool, E> {
332 Ok(v != 0)
333 }
334 fn visit_str<E: de::Error>(self, v: &str) -> Result<bool, E> {
335 match norm_token(v).as_str() {
336 "true" | "yes" | "y" | "1" => Ok(true),
337 "false" | "no" | "n" | "0" => Ok(false),
338 other => Err(E::custom(format!("{other} is not a boolean"))),
339 }
340 }
341 }
342 d.deserialize_any(V)
343}
344
345pub fn de_opt_i64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<i64>, D::Error> {
351 Ok(match Option::<serde_json::Value>::deserialize(d)? {
352 Some(serde_json::Value::Number(n)) => n.as_i64(),
353 Some(serde_json::Value::String(s)) => s.trim().trim_start_matches('#').parse().ok(),
354 _ => None,
355 })
356}
357
358fn de_string_vec<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
360 Ok(Option::<Vec<String>>::deserialize(d)?.unwrap_or_default())
361}
362
363fn de_bool_default_true<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
364 #[derive(Deserialize)]
365 struct Wrap(#[serde(deserialize_with = "de_bool")] bool);
366 Ok(Option::<Wrap>::deserialize(d)?
367 .map(|Wrap(b)| b)
368 .unwrap_or(true))
369}
370
371fn de_string<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
372 Ok(Option::<String>::deserialize(d)?.unwrap_or_default())
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct TriageVerdict {
381 #[serde(deserialize_with = "de_i64")]
382 pub issue: i64,
383 #[serde(deserialize_with = "de_bool")]
384 pub worth_doing: bool,
385 #[serde(default, deserialize_with = "de_bool")]
388 pub tracker: bool,
389 #[serde(default, deserialize_with = "de_string")]
390 pub reason: String,
391 pub complexity: Complexity,
392 #[serde(default, deserialize_with = "de_i64_vec")]
393 pub depends_on: Vec<i64>,
394 pub risk: Risk,
395}
396
397#[derive(Debug, Clone, Serialize, Deserialize)]
398pub struct TriageResponse {
399 #[serde(default)]
400 pub issues: Vec<TriageVerdict>,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct ScreenVerdict {
406 #[serde(deserialize_with = "de_i64")]
411 pub entry: i64,
412 pub verdict: Screened,
413 #[serde(default, deserialize_with = "de_string")]
414 pub title: String,
415 #[serde(default, deserialize_with = "de_string")]
416 pub reason: String,
417 #[serde(default, deserialize_with = "de_opt_i64")]
423 pub duplicate_of_issue: Option<i64>,
424 #[serde(default, deserialize_with = "de_opt_i64")]
426 pub duplicate_of_entry: Option<i64>,
427}
428
429impl ScreenVerdict {
430 pub fn names_a_duplicate(&self) -> bool {
432 self.duplicate_of_issue.is_some() || self.duplicate_of_entry.is_some()
433 }
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct ScreenResponse {
438 #[serde(default)]
439 pub entries: Vec<ScreenVerdict>,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct SplitScreen {
449 #[serde(deserialize_with = "de_i64")]
452 pub item: i64,
453 #[serde(deserialize_with = "de_bool")]
454 pub split: bool,
455 #[serde(default, deserialize_with = "de_string")]
456 pub reason: String,
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize)]
460pub struct SplitScreenDoc {
461 #[serde(default)]
462 pub items: Vec<SplitScreen>,
463}
464
465#[derive(Debug, Clone, Default, Serialize, Deserialize)]
467pub struct SplitPart {
468 #[serde(default, deserialize_with = "de_string")]
469 pub title: String,
470 #[serde(default, deserialize_with = "de_string")]
472 pub body: String,
473 #[serde(default, deserialize_with = "de_string_vec")]
476 pub files: Vec<String>,
477}
478
479#[derive(Debug, Clone, Default, Serialize, Deserialize)]
485pub struct SplitProposal {
486 #[serde(default, deserialize_with = "de_bool")]
487 pub should_split: bool,
488 #[serde(default, deserialize_with = "de_string")]
489 pub reason: String,
490 #[serde(default, deserialize_with = "de_bool")]
494 pub stacked: bool,
495 #[serde(default)]
496 pub parts: Vec<SplitPart>,
497}
498
499#[derive(Debug, Clone, Default, Serialize, Deserialize)]
501pub struct SplitCheck {
502 #[serde(default, deserialize_with = "de_bool")]
503 pub accept: bool,
504 #[serde(default, deserialize_with = "de_bool")]
505 pub stacked: bool,
506 #[serde(default, deserialize_with = "de_i64_vec")]
508 pub strike: Vec<i64>,
509 #[serde(default, deserialize_with = "de_string")]
510 pub reasoning: String,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize)]
515pub struct CommentVerdict {
516 #[serde(default, deserialize_with = "de_string")]
521 pub ref_id: String,
522 pub ask: Ask,
523 #[serde(default, deserialize_with = "de_string")]
526 pub request: String,
527 #[serde(default, deserialize_with = "de_string")]
530 pub reasoning: String,
531 #[serde(deserialize_with = "de_bool")]
534 pub unambiguous: bool,
535 #[serde(default)]
536 pub new_issue_title: Option<String>,
537 #[serde(default)]
538 pub new_issue_body: Option<String>,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize)]
542pub struct CheckinDoc {
543 #[serde(default)]
544 pub verdicts: Vec<CommentVerdict>,
545}
546
547#[derive(Debug, Clone, Serialize, Deserialize)]
549pub struct CommentCheck {
550 #[serde(default, deserialize_with = "de_string")]
551 pub ref_id: String,
552 #[serde(deserialize_with = "de_bool")]
553 pub agrees: bool,
554 pub ask: Ask,
559 #[serde(deserialize_with = "de_bool")]
560 pub unambiguous: bool,
561 #[serde(default, deserialize_with = "de_string")]
562 pub reasoning: String,
563}
564
565#[derive(Debug, Clone, Serialize, Deserialize)]
566pub struct CheckDoc {
567 #[serde(default)]
568 pub checks: Vec<CommentCheck>,
569}
570
571#[derive(Debug, Clone, Serialize, Deserialize)]
573pub struct FixOutcome {
574 #[serde(default, deserialize_with = "de_string")]
575 pub ref_id: String,
576 #[serde(deserialize_with = "de_bool")]
580 pub changed: bool,
581 #[serde(default, deserialize_with = "de_string")]
584 pub summary: String,
585 #[serde(default)]
588 pub files: Vec<String>,
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct FixReport {
593 #[serde(default)]
594 pub done: Vec<FixOutcome>,
595}
596
597#[derive(Debug, Clone, Default, Serialize, Deserialize)]
609pub struct Answered {
610 #[serde(default)]
611 pub version: u32,
612 #[serde(default)]
613 pub seen: BTreeMap<String, String>,
614}
615
616#[derive(Debug, Clone, Serialize, Deserialize)]
617pub struct Finding {
618 pub severity: Severity,
619 #[serde(default, deserialize_with = "de_string")]
620 pub title: String,
621 #[serde(default, deserialize_with = "de_string")]
622 pub detail: String,
623 #[serde(default, deserialize_with = "de_string")]
624 pub file: String,
625 #[serde(default = "yes", deserialize_with = "de_bool_default_true")]
628 pub in_scope: bool,
629
630 #[serde(default)]
638 pub problem: Option<String>,
639 #[serde(default)]
641 pub reproduction: Option<String>,
642 #[serde(default)]
644 pub impact: Option<String>,
645 #[serde(default)]
647 pub expected: Option<String>,
648}
649
650impl Default for Finding {
651 fn default() -> Self {
658 Self {
659 severity: Severity::Nit,
660 title: String::new(),
661 detail: String::new(),
662 file: String::new(),
663 in_scope: true,
664 problem: None,
665 reproduction: None,
666 impact: None,
667 expected: None,
668 }
669 }
670}
671
672impl Finding {
673 pub fn report_sections(&self) -> Vec<(&'static str, &str)> {
676 [
677 ("Problem", self.problem.as_deref()),
678 ("Reproduction", self.reproduction.as_deref()),
679 ("Impact", self.impact.as_deref()),
680 ("Expected behavior", self.expected.as_deref()),
681 ]
682 .into_iter()
683 .filter_map(|(heading, text)| {
684 text.map(str::trim)
685 .filter(|t| !t.is_empty())
686 .map(|t| (heading, t))
687 })
688 .collect()
689 }
690}
691
692fn yes() -> bool {
693 true
694}
695
696impl Finding {
697 pub fn blocks(&self) -> bool {
698 self.severity == Severity::Blocking && self.in_scope
699 }
700
701 pub fn where_at(&self) -> &str {
702 if self.file.trim().is_empty() {
703 "general"
704 } else {
705 self.file.trim()
706 }
707 }
708}
709
710#[derive(Debug, Clone, Serialize, Deserialize)]
711pub struct Review {
712 pub verdict: Verdict,
713 pub next_action: NextAction,
714 #[serde(default, deserialize_with = "de_string")]
715 pub summary: String,
716 #[serde(default)]
717 pub findings: Vec<Finding>,
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize)]
721pub struct Disposition {
722 #[serde(default, deserialize_with = "de_string")]
723 pub title: String,
724 #[serde(default, deserialize_with = "de_string")]
728 pub file: String,
729 pub action: Action,
730 #[serde(default, deserialize_with = "de_string")]
731 pub reasoning: String,
732 #[serde(default)]
733 pub new_issue_title: Option<String>,
734 #[serde(default)]
735 pub new_issue_body: Option<String>,
736}
737
738#[derive(Debug, Clone, Serialize, Deserialize)]
746pub struct OwnFix {
747 #[serde(default, deserialize_with = "de_string")]
748 pub title: String,
749 #[serde(default, deserialize_with = "de_string")]
752 pub file: String,
753 #[serde(deserialize_with = "de_bool")]
754 pub fixed: bool,
755 #[serde(default, deserialize_with = "de_string")]
756 pub reason: String,
757}
758
759#[derive(Debug, Clone, Serialize, Deserialize)]
760pub struct OwnFixDoc {
761 #[serde(default, deserialize_with = "de_string")]
762 pub summary: String,
763 #[serde(default)]
764 pub fixes: Vec<OwnFix>,
765}
766
767#[derive(Debug, Clone, Serialize, Deserialize)]
774pub struct Adjudication {
775 #[serde(default, deserialize_with = "de_string")]
776 pub title: String,
777 #[serde(default, deserialize_with = "de_string")]
778 pub file: String,
779 #[serde(deserialize_with = "de_bool")]
782 pub agrees: bool,
783 pub severity: Severity,
785 #[serde(default, deserialize_with = "de_string")]
786 pub reasoning: String,
787}
788
789#[derive(Debug, Clone, Serialize, Deserialize)]
790pub struct AdjudicationDoc {
791 #[serde(default)]
792 pub verdicts: Vec<Adjudication>,
793}
794
795string_enum! {
796 pub enum Ruling {
798 Fixed = "fixed" | "resolved" | "addressed" | "done",
800 Rebutted = "rebutted" | "refuted" | "answered" | "withdrawn" | "not_a_bug",
803 Open = "open" | "still_open" | "unresolved" | "stands" | "unfixed",
806 }
807}
808
809#[derive(Debug, Clone, Serialize, Deserialize)]
811pub struct RecheckRuling {
812 #[serde(deserialize_with = "de_i64")]
816 pub index: i64,
817 pub outcome: Ruling,
818 #[serde(default, deserialize_with = "de_string")]
819 pub reasoning: String,
820}
821
822#[derive(Debug, Clone, Default, Serialize, Deserialize)]
825pub struct RecheckDoc {
826 #[serde(default, deserialize_with = "de_string")]
827 pub summary: String,
828 #[serde(default)]
829 pub rulings: Vec<RecheckRuling>,
830 #[serde(default)]
831 pub findings: Vec<Finding>,
832}
833
834#[derive(Debug, Clone, Default, Serialize, Deserialize)]
847pub struct Idea {
848 #[serde(default, deserialize_with = "de_string")]
849 pub title: String,
850 #[serde(default, deserialize_with = "de_string_vec")]
852 pub combines: Vec<String>,
853 #[serde(default, deserialize_with = "de_string")]
854 pub how_it_works: String,
855 #[serde(default, deserialize_with = "de_string")]
857 pub why_new: String,
858 #[serde(default, deserialize_with = "de_string")]
859 pub enables: String,
860 #[serde(default, deserialize_with = "de_string")]
861 pub risks: String,
862 #[serde(default, deserialize_with = "de_string")]
864 pub first_experiment: String,
865}
866
867#[derive(Debug, Clone, Default, Serialize, Deserialize)]
868pub struct IdeasDoc {
869 #[serde(default)]
870 pub ideas: Vec<Idea>,
871}
872
873string_enum! {
874 pub enum CrossKind {
876 Build = "build" | "extend" | "improve" | "combine",
877 Challenge = "challenge" | "reject" | "exists" | "object",
878 Keep = "keep" | "agree" | "accept" | "fine",
879 }
880}
881
882#[derive(Debug, Clone, Serialize, Deserialize)]
884pub struct CrossVerdict {
885 #[serde(default, deserialize_with = "de_i64")]
888 pub idea: i64,
889 pub verdict: CrossKind,
890 #[serde(default, deserialize_with = "de_string")]
891 pub reason: String,
892 #[serde(default)]
894 pub build: Option<Idea>,
895}
896
897#[derive(Debug, Clone, Default, Serialize, Deserialize)]
898pub struct CrossDoc {
899 #[serde(default)]
900 pub verdicts: Vec<CrossVerdict>,
901}
902
903#[derive(Debug, Clone, Serialize, Deserialize)]
905pub struct Defence {
906 #[serde(default, deserialize_with = "de_i64")]
907 pub idea: i64,
908 #[serde(deserialize_with = "de_bool")]
910 pub stands: bool,
911 #[serde(default, deserialize_with = "de_string")]
912 pub reply: String,
913}
914
915#[derive(Debug, Clone, Default, Serialize, Deserialize)]
918pub struct ConvergeDoc {
919 #[serde(default)]
920 pub defences: Vec<Defence>,
921 #[serde(default, deserialize_with = "de_i64_vec")]
923 pub ranking: Vec<i64>,
924}
925
926#[derive(Debug, Clone)]
928pub struct Judged {
929 pub finding: Finding,
930 pub raised_by: String,
932 pub standing: Standing,
934 pub counterpoint: Option<String>,
936 pub defence: Option<String>,
941}
942
943string_enum! {
944 pub enum Standing {
947 Corroborated = "corroborated" | "both",
949 Confirmed = "confirmed",
951 Disputed = "disputed",
954 Withdrawn = "withdrawn",
956 Unverified = "unverified" | "one_reviewer",
958 }
959}
960
961#[derive(Debug, Clone, Default, Serialize, Deserialize)]
970pub struct Implementation {
971 #[serde(default, deserialize_with = "de_bool")]
973 pub not_worth_doing: bool,
974 #[serde(default, deserialize_with = "de_string")]
977 pub reason: String,
978 #[serde(default, deserialize_with = "de_string")]
980 pub summary: String,
981 #[serde(default, deserialize_with = "de_string")]
984 pub problem: String,
985 #[serde(default)]
987 pub changes: Vec<String>,
988 #[serde(default)]
990 pub testing: Vec<String>,
991 #[serde(default)]
994 pub notes: Option<String>,
995}
996
997#[derive(Debug, Clone, Serialize, Deserialize)]
998pub struct ResponseDoc {
999 #[serde(default, deserialize_with = "de_string")]
1000 pub summary: String,
1001 #[serde(default)]
1002 pub dispositions: Vec<Disposition>,
1003}
1004
1005#[derive(Debug, Clone, Serialize, Deserialize)]
1010pub struct PlanItem {
1011 pub issue: i64,
1012 pub title: String,
1013 pub complexity: Complexity,
1014 pub risk: Risk,
1015 pub depends_on: Vec<i64>,
1016 pub reason: String,
1017}
1018
1019#[derive(Debug, Clone, Serialize, Deserialize)]
1020pub struct SkippedItem {
1021 pub issue: i64,
1022 pub title: String,
1023 pub reasons: BTreeMap<String, String>,
1025 #[serde(default)]
1033 pub tracker: bool,
1034}
1035
1036#[derive(Debug, Clone, Serialize, Deserialize)]
1037pub struct ContestedItem {
1038 pub issue: i64,
1039 pub title: String,
1040 pub positions: BTreeMap<String, String>,
1042 pub reasons: BTreeMap<String, String>,
1043 #[serde(default, skip_serializing_if = "Option::is_none")]
1044 pub note: Option<String>,
1045}
1046
1047#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1054pub struct Contested {
1055 #[serde(default)]
1056 pub title: String,
1057 #[serde(default)]
1059 pub positions: BTreeMap<String, String>,
1060 #[serde(default)]
1061 pub reasons: BTreeMap<String, String>,
1062}
1063
1064#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1065pub struct Plan {
1066 #[serde(default)]
1067 pub order: Vec<PlanItem>,
1068 #[serde(default)]
1069 pub skipped: Vec<SkippedItem>,
1070 #[serde(default)]
1071 pub contested: Vec<ContestedItem>,
1072}
1073
1074string_enum! {
1075 pub enum Settled {
1089 Refuted = "refuted" | "refute" | "rejected",
1090 Filed = "filed" | "filed_issue" | "filed-issue" | "out_of_scope",
1091 Dropped = "dropped" | "not_filed" | "not-filed" | "unfiled",
1092 Fixed = "fixed" | "fix" | "changed",
1093 }
1094}
1095
1096impl Default for Settled {
1097 fn default() -> Self {
1099 Settled::Refuted
1100 }
1101}
1102
1103#[derive(Debug, Clone, PartialEq, Eq)]
1110pub enum Followup {
1111 Recorded(String),
1114 Covered(String),
1118 Dropped(&'static str),
1121 Failed,
1124}
1125
1126impl Followup {
1127 pub fn url(&self) -> Option<&str> {
1132 match self {
1133 Followup::Recorded(url) => Some(url),
1134 _ => None,
1135 }
1136 }
1137}
1138
1139#[derive(Debug, Clone, Serialize, Deserialize)]
1140pub struct LedgerEntry {
1141 pub title: String,
1142 pub file: String,
1143 pub reasoning: String,
1144 pub round: u32,
1145 #[serde(default)]
1146 pub reraised: u32,
1147 #[serde(default)]
1148 pub outcome: Settled,
1149}
1150
1151pub type Ledger = BTreeMap<String, LedgerEntry>;
1155
1156#[derive(Debug, Clone, Serialize, Deserialize)]
1157pub struct Dispute {
1158 pub title: String,
1159 #[serde(default, deserialize_with = "de_string")]
1160 pub file: String,
1161 pub reasoning: String,
1162}
1163
1164#[derive(Debug, Clone, Serialize, Deserialize)]
1166pub struct IssueRun {
1167 pub issue: i64,
1168 pub title: String,
1169 pub status: Status,
1170 #[serde(default, skip_serializing_if = "Option::is_none")]
1171 pub pr: Option<String>,
1172 #[serde(default)]
1173 pub rounds: u32,
1174 #[serde(default)]
1175 pub disputes: Vec<Dispute>,
1176 #[serde(default)]
1177 pub filed: Vec<String>,
1178 #[serde(default)]
1187 pub noted: Vec<Finding>,
1188 #[serde(default)]
1189 pub notes: Vec<String>,
1190 #[serde(skip)]
1193 pub followup_writes_uncertain: bool,
1194}
1195
1196impl IssueRun {
1197 pub fn new(issue: i64, title: impl Into<String>) -> Self {
1198 Self {
1199 issue,
1200 title: title.into(),
1201 status: Status::Pending,
1202 pr: None,
1203 rounds: 0,
1204 disputes: Vec::new(),
1205 filed: Vec::new(),
1206 noted: Vec::new(),
1207 notes: Vec::new(),
1208 followup_writes_uncertain: false,
1209 }
1210 }
1211
1212 pub fn succeeded(&self) -> bool {
1217 matches!(
1218 self.status,
1219 Status::Merged
1220 | Status::Approved
1221 | Status::Abandoned
1222 | Status::Reviewed
1223 | Status::Clean
1224 | Status::Answered
1225 | Status::Split
1229 | Status::Whole
1230 )
1231 }
1232}
1233
1234#[derive(Debug, Clone, Serialize, Deserialize)]
1236pub struct PersistedState {
1237 pub version: u32,
1238 #[serde(default)]
1240 pub checkpoint: u64,
1241 pub round: u32,
1242 pub next_actor: String,
1243 pub status: Status,
1244 #[serde(default, skip_serializing_if = "String::is_empty")]
1246 pub pr_head: String,
1247 #[serde(default)]
1248 pub ledger: Ledger,
1249 #[serde(default)]
1250 pub filed: Vec<String>,
1251 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1252 pub open_findings: Vec<Finding>,
1253 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1254 pub disputes: Vec<Dispute>,
1255 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1256 pub noted: Vec<Finding>,
1257}
1258
1259pub const STATE_VERSION: u32 = 2;
1260
1261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1266pub struct RecordedFinding {
1267 pub title: String,
1268 #[serde(default)]
1269 pub file: String,
1270 pub severity: Severity,
1271 #[serde(default)]
1272 pub detail: String,
1273 #[serde(default = "yes")]
1274 pub in_scope: bool,
1275 #[serde(default)]
1276 pub raised_by: String,
1277 pub standing: Standing,
1278 #[serde(default, skip_serializing_if = "Option::is_none")]
1279 pub counterpoint: Option<String>,
1280 #[serde(default, skip_serializing_if = "Option::is_none")]
1281 pub defence: Option<String>,
1282}
1283
1284impl RecordedFinding {
1285 pub fn blocks(&self) -> bool {
1286 self.severity == Severity::Blocking && self.in_scope
1287 }
1288
1289 pub fn where_at(&self) -> &str {
1290 if self.file.trim().is_empty() {
1291 "general"
1292 } else {
1293 self.file.trim()
1294 }
1295 }
1296
1297 pub fn to_judged(&self) -> Judged {
1300 Judged {
1301 finding: Finding {
1302 severity: self.severity,
1303 title: self.title.clone(),
1304 detail: self.detail.clone(),
1305 file: self.file.clone(),
1306 in_scope: self.in_scope,
1307 ..Finding::default()
1308 },
1309 raised_by: self.raised_by.clone(),
1310 standing: self.standing,
1311 counterpoint: self.counterpoint.clone(),
1312 defence: self.defence.clone(),
1313 }
1314 }
1315}
1316
1317impl From<&Judged> for RecordedFinding {
1318 fn from(j: &Judged) -> Self {
1319 Self {
1320 title: j.finding.title.clone(),
1321 file: j.finding.file.clone(),
1322 severity: j.finding.severity,
1323 detail: j.finding.detail.clone(),
1324 in_scope: j.finding.in_scope,
1325 raised_by: j.raised_by.clone(),
1326 standing: j.standing,
1327 counterpoint: j.counterpoint.clone(),
1328 defence: j.defence.clone(),
1329 }
1330 }
1331}
1332
1333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1339pub struct ReviewRecord {
1340 pub version: u32,
1341 pub head: String,
1344 pub reviewed_at: String,
1349 #[serde(default)]
1350 pub findings: Vec<RecordedFinding>,
1351 #[serde(default)]
1353 pub rechecks: u32,
1354 #[serde(default, skip_serializing_if = "Option::is_none")]
1356 pub verdict: Option<ReviewVerdict>,
1357 #[serde(default)]
1361 pub posted: bool,
1362}
1363
1364pub const REVIEW_RECORD_VERSION: u32 = 1;
1365
1366impl ReviewRecord {
1367 pub fn short_head(&self) -> &str {
1368 let end = self.head.len().min(7);
1369 &self.head[..end]
1370 }
1371
1372 pub fn open_points(&self) -> impl Iterator<Item = &RecordedFinding> {
1374 self.findings
1375 .iter()
1376 .filter(|f| f.standing != Standing::Withdrawn)
1377 }
1378}
1379
1380#[derive(Debug, Clone, Deserialize)]
1385pub struct Label {
1386 #[serde(default)]
1387 pub name: String,
1388}
1389
1390#[derive(Debug, Clone, Deserialize, Default)]
1391pub struct Author {
1392 #[serde(default)]
1393 pub login: String,
1394}
1395
1396pub fn login_of(author: Option<&Author>) -> &str {
1399 match author.map(|a| a.login.trim()) {
1400 Some(login) if !login.is_empty() => login,
1401 _ => "ghost",
1402 }
1403}
1404
1405#[derive(Debug, Clone, Deserialize, Default)]
1408#[serde(rename_all = "camelCase")]
1409pub struct IssueComment {
1410 #[serde(default)]
1412 pub author: Option<Author>,
1413 #[serde(default)]
1414 pub author_association: String,
1415 #[serde(default)]
1416 pub body: String,
1417 #[serde(default)]
1418 pub created_at: String,
1419 #[serde(default)]
1420 pub is_minimized: bool,
1421}
1422
1423impl IssueComment {
1424 pub fn login(&self) -> &str {
1425 login_of(self.author.as_ref())
1426 }
1427}
1428
1429#[derive(Debug, Clone, Deserialize)]
1430pub struct Issue {
1431 pub number: i64,
1432 #[serde(default)]
1433 pub title: String,
1434 #[serde(default)]
1435 pub body: Option<String>,
1436 #[serde(default)]
1437 pub state: String,
1438 #[serde(default, rename = "stateReason")]
1446 pub state_reason: Option<String>,
1447 #[serde(default)]
1448 pub url: String,
1449 #[serde(default)]
1450 pub labels: Vec<Label>,
1451 #[serde(default)]
1453 pub author: Option<Author>,
1454 #[serde(default)]
1457 pub comments: Vec<IssueComment>,
1458}
1459
1460impl Issue {
1461 pub fn body_text(&self) -> &str {
1462 self.body.as_deref().unwrap_or("")
1463 }
1464
1465 pub fn body_for_prompt(&self, max: usize) -> (String, bool) {
1477 let body = self.body_text().trim();
1478 if body.chars().count() <= max {
1479 return (body.to_string(), false);
1480 }
1481 let clipped: String = body.chars().take(max).collect();
1482 let mut kept = match clipped.rfind('\n') {
1483 Some(at) => clipped[..at].to_string(),
1484 None => clipped,
1485 };
1486 if kept.matches("```").count() % 2 == 1 {
1487 kept.push_str("\n```");
1488 }
1489 kept.push_str("\n\n[Shortened to fit. The rest of this issue was not included.]");
1490 (kept, true)
1491 }
1492
1493 pub fn is_closed(&self) -> bool {
1494 self.state.eq_ignore_ascii_case("closed")
1495 }
1496
1497 pub fn closed_as_completed(&self) -> bool {
1504 self.is_closed() && !self.closed_as_not_planned()
1505 }
1506
1507 pub fn closed_as_not_planned(&self) -> bool {
1508 self.is_closed()
1509 && self
1510 .state_reason
1511 .as_deref()
1512 .is_some_and(|reason| reason.eq_ignore_ascii_case("not_planned"))
1513 }
1514}
1515
1516#[derive(Debug, Clone, Deserialize)]
1517pub struct PrRef {
1518 pub number: i64,
1519 #[serde(default)]
1520 pub url: String,
1521 #[serde(default)]
1522 pub title: String,
1523}
1524
1525#[derive(Debug, Clone, Deserialize)]
1526pub struct IssueRef {
1527 pub number: i64,
1528}
1529
1530#[derive(Debug, Clone, Default, Deserialize)]
1536#[serde(rename_all = "camelCase")]
1537pub struct PrRow {
1538 pub number: i64,
1539 #[serde(default)]
1540 pub title: String,
1541 #[serde(default)]
1542 pub changed_files: i64,
1543 #[serde(default)]
1544 pub additions: i64,
1545 #[serde(default)]
1546 pub deletions: i64,
1547}
1548
1549impl PrRow {
1550 pub fn size(&self) -> String {
1552 format!(
1553 "{} file(s), +{} -{}",
1554 self.changed_files, self.additions, self.deletions
1555 )
1556 }
1557}
1558
1559#[derive(Debug, Clone, Deserialize)]
1560#[serde(rename_all = "camelCase")]
1561pub struct PrView {
1562 pub number: i64,
1563 #[serde(default)]
1564 pub url: String,
1565 #[serde(default)]
1566 pub title: String,
1567 #[serde(default)]
1570 pub body: String,
1571 #[serde(default)]
1572 pub head_ref_name: String,
1573 #[serde(default)]
1574 pub base_ref_name: String,
1575 #[serde(default)]
1576 pub state: String,
1577 #[serde(default)]
1578 pub closing_issues_references: Vec<IssueRef>,
1579 #[serde(default)]
1582 pub is_cross_repository: bool,
1583}
1584
1585pub const PR_SWEEP_FIELDS: &str =
1588 "number,title,headRefOid,updatedAt,author,isDraft,isCrossRepository,reviewRequests,assignees,state";
1589
1590#[derive(Debug, Clone, Default, Deserialize)]
1593#[serde(rename_all = "camelCase")]
1594pub struct PrSweepRow {
1595 pub number: i64,
1596 #[serde(default)]
1597 pub title: String,
1598 #[serde(default)]
1599 pub head_ref_oid: String,
1600 #[serde(default)]
1601 pub updated_at: String,
1602 #[serde(default)]
1603 pub author: Option<Author>,
1604 #[serde(default)]
1605 pub is_draft: bool,
1606 #[serde(default)]
1607 pub is_cross_repository: bool,
1608 #[serde(default)]
1609 pub review_requests: Vec<Login>,
1610 #[serde(default)]
1611 pub assignees: Vec<Login>,
1612 #[serde(default)]
1614 pub state: String,
1615}
1616
1617#[derive(Debug, Clone, Default, Deserialize)]
1621pub struct Login {
1622 #[serde(default)]
1623 pub login: String,
1624}
1625
1626#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1629#[serde(rename_all = "lowercase")]
1630pub enum ItemKind {
1631 Issue,
1632 Pr,
1633}
1634
1635impl std::fmt::Display for ItemKind {
1636 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1637 f.write_str(match self {
1638 ItemKind::Issue => "issue",
1639 ItemKind::Pr => "pull request",
1640 })
1641 }
1642}
1643
1644impl PrView {
1645 pub fn is_open(&self) -> bool {
1646 self.state.eq_ignore_ascii_case("open")
1647 }
1648}
1649
1650#[cfg(test)]
1651mod tests {
1652 use super::*;
1653
1654 #[test]
1655 fn severity_accepts_the_canonical_spelling() {
1656 assert_eq!(
1657 Some(Severity::NonBlocking),
1658 Severity::parse_lenient("non-blocking")
1659 );
1660 }
1661
1662 #[test]
1663 fn severity_accepts_near_misses() {
1664 for text in ["NonBlocking", "non_blocking", " NON-BLOCKING ", "minor"] {
1665 assert_eq!(
1666 Some(Severity::NonBlocking),
1667 Severity::parse_lenient(text),
1668 "{text}"
1669 );
1670 }
1671 }
1672
1673 #[test]
1674 fn severity_rejects_nonsense() {
1675 assert_eq!(None, Severity::parse_lenient("catastrophic-ish"));
1676 }
1677
1678 #[test]
1679 fn complexity_ordering_is_cheapest_first() {
1680 assert!(Complexity::S.rank() < Complexity::M.rank());
1681 assert!(Complexity::M.rank() < Complexity::L.rank());
1682 }
1683
1684 #[test]
1685 fn finding_defaults_to_in_scope() {
1686 let f: Finding = serde_json::from_value(serde_json::json!({
1687 "severity": "blocking", "title": "t", "detail": "d", "file": "a.rs"
1688 }))
1689 .unwrap();
1690 assert!(f.in_scope);
1691 assert!(f.blocks());
1692 }
1693
1694 #[test]
1695 fn out_of_scope_blocking_does_not_block() {
1696 let f: Finding = serde_json::from_value(serde_json::json!({
1697 "severity": "blocking", "title": "t", "detail": "d",
1698 "file": "a.rs", "in_scope": false
1699 }))
1700 .unwrap();
1701 assert!(!f.blocks());
1702 }
1703
1704 #[test]
1705 fn triage_tolerates_a_quoted_issue_number() {
1706 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1707 "issue": "#42", "worth_doing": "yes", "reason": "r",
1708 "complexity": "medium", "depends_on": ["39"], "risk": "low"
1709 }))
1710 .unwrap();
1711 assert_eq!(42, v.issue);
1712 assert!(v.worth_doing);
1713 assert_eq!(Complexity::M, v.complexity);
1714 assert_eq!(vec![39], v.depends_on);
1715 }
1716
1717 #[test]
1718 fn triage_tolerates_a_missing_depends_on() {
1719 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1720 "issue": 1, "worth_doing": false, "reason": "r",
1721 "complexity": "s", "risk": "high"
1722 }))
1723 .unwrap();
1724 assert!(v.depends_on.is_empty());
1725 }
1726
1727 #[test]
1728 fn review_tolerates_a_missing_findings_array() {
1729 let r: Review = serde_json::from_value(serde_json::json!({
1730 "verdict": "approve", "next_action": "merge", "summary": "fine"
1731 }))
1732 .unwrap();
1733 assert!(r.findings.is_empty());
1734 }
1735
1736 #[test]
1737 fn a_null_reason_is_an_empty_string_not_a_failure() {
1738 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1739 "issue": 1, "worth_doing": true, "reason": null,
1740 "complexity": "s", "depends_on": [], "risk": "low"
1741 }))
1742 .unwrap();
1743 assert_eq!("", v.reason);
1744 }
1745
1746 #[test]
1747 fn unknown_severity_is_an_error_not_a_silent_downgrade() {
1748 let out: Result<Finding, _> = serde_json::from_value(serde_json::json!({
1749 "severity": "showstopper-maybe", "title": "t", "detail": "d", "file": "a.rs"
1750 }));
1751 assert!(out.is_err());
1752 }
1753
1754 #[test]
1758 fn deciding_not_to_split_is_not_a_failure() {
1759 for status in [Status::Split, Status::Whole] {
1760 let mut run = IssueRun::new(1, "t");
1761 run.status = status;
1762 assert!(run.succeeded(), "{status}");
1763 }
1764 }
1765
1766 #[test]
1767 fn status_round_trips_through_json() {
1768 let run = IssueRun::new(4, "t");
1769 let text = serde_json::to_string(&run).unwrap();
1770 let back: IssueRun = serde_json::from_str(&text).unwrap();
1771 assert_eq!(Status::Pending, back.status);
1772 }
1773}
1774
1775#[cfg(test)]
1776mod body_for_prompt_tests {
1777 use super::*;
1778
1779 fn issue(body: &str) -> Issue {
1780 let mut i: Issue = serde_json::from_value(serde_json::json!({
1781 "number": 1, "title": "t", "state": "open", "url": "u"
1782 }))
1783 .expect("an issue");
1784 i.body = Some(body.to_string());
1785 i
1786 }
1787
1788 #[test]
1791 fn an_issue_that_fits_is_handed_over_whole() {
1792 let (body, cut) = issue("The guard is inverted.").body_for_prompt(60_000);
1793 assert_eq!("The guard is inverted.", body);
1794 assert!(!cut);
1795 }
1796
1797 #[test]
1801 fn a_shortened_body_says_so_in_the_text() {
1802 let long = "line of text\n".repeat(500);
1803 let (body, cut) = issue(&long).body_for_prompt(200);
1804 assert!(cut);
1805 assert!(body.contains("Shortened to fit"), "{body}");
1806 assert!(body.len() < long.len());
1807 }
1808
1809 #[test]
1812 fn a_cut_never_leaves_a_code_fence_open() {
1813 let body = format!("intro\n\n```rust\n{}\n```\n", "let x = 1;\n".repeat(200));
1814 let (out, cut) = issue(&body).body_for_prompt(120);
1815 assert!(cut);
1816 assert_eq!(0, out.matches("```").count() % 2, "{out}");
1817 }
1818
1819 #[test]
1821 fn a_cut_lands_on_a_line_boundary() {
1822 let body = "aaaa bbbb cccc\n".repeat(100);
1823 let (out, _) = issue(&body).body_for_prompt(100);
1824 let kept = out.split("\n\n[Shortened").next().expect("the kept part");
1825 assert!(kept.ends_with("cccc"), "{kept:?}");
1826 }
1827}
1828
1829#[cfg(test)]
1830mod review_record_tests {
1831 use super::*;
1832
1833 fn judged() -> Judged {
1834 Judged {
1835 finding: Finding {
1836 severity: Severity::Blocking,
1837 title: "Retry loop never terminates".into(),
1838 detail: "the guard compares against Some(0)".into(),
1839 file: "src/net.rs:88".into(),
1840 in_scope: true,
1841 problem: Some("dropped on the way to the record".into()),
1842 ..Finding::default()
1843 },
1844 raised_by: "claude and codex".into(),
1845 standing: Standing::Corroborated,
1846 counterpoint: None,
1847 defence: Some("stands".into()),
1848 }
1849 }
1850
1851 #[test]
1852 fn a_review_record_round_trips_through_json_and_keeps_its_standing_as_a_word() {
1853 let record = ReviewRecord {
1854 version: REVIEW_RECORD_VERSION,
1855 head: "0123abcdef".into(),
1856 reviewed_at: "2026-10-20T18:04:05Z".into(),
1857 findings: vec![RecordedFinding::from(&judged())],
1858 rechecks: 2,
1859 verdict: Some(ReviewVerdict::RequestChanges),
1860 posted: true,
1861 };
1862 let text = serde_json::to_string_pretty(&record).unwrap();
1863 assert!(text.contains("\"standing\": \"corroborated\""), "{text}");
1864 assert!(text.contains("\"verdict\": \"request_changes\""), "{text}");
1865 assert!(text.contains("\"severity\": \"blocking\""), "{text}");
1866 assert!(!text.contains("problem"), "{text}");
1867 let back: ReviewRecord = serde_json::from_str(&text).unwrap();
1868 assert_eq!(record, back);
1869 assert_eq!("0123abc", back.short_head());
1870 }
1871
1872 #[test]
1873 fn a_record_missing_the_newer_fields_still_reads() {
1874 let text = r#"{"version":1,"head":"abc","reviewed_at":"2026-10-20T18:04:05Z",
1875 "findings":[{"title":"T","severity":"nit","standing":"one_reviewer"}]}"#;
1876 let back: ReviewRecord = serde_json::from_str(text).unwrap();
1877 assert_eq!(0, back.rechecks);
1878 assert!(!back.posted);
1879 assert_eq!(None, back.verdict);
1880 assert!(back.findings[0].in_scope);
1881 assert_eq!(Standing::Unverified, back.findings[0].standing);
1882 assert_eq!("general", back.findings[0].where_at());
1883 }
1884
1885 #[test]
1886 fn a_recorded_finding_turns_back_into_a_judged_finding() {
1887 let original = judged();
1888 let back = RecordedFinding::from(&original).to_judged();
1889 assert_eq!(original.finding.title, back.finding.title);
1890 assert_eq!(original.finding.file, back.finding.file);
1891 assert_eq!(original.finding.severity, back.finding.severity);
1892 assert_eq!(original.raised_by, back.raised_by);
1893 assert_eq!(original.standing, back.standing);
1894 assert_eq!(original.defence, back.defence);
1895 assert_eq!(None, back.finding.problem);
1896 assert!(back.finding.blocks());
1897 }
1898
1899 #[test]
1900 fn a_ruling_tolerates_a_quoted_index_and_a_synonym_outcome() {
1901 let doc: RecheckDoc = serde_json::from_str(
1902 r#"{"summary":"holds","rulings":[{"index":"2","outcome":"Resolved","reasoning":"line 4"}],"findings":[]}"#,
1903 )
1904 .unwrap();
1905 assert_eq!(2, doc.rulings[0].index);
1906 assert_eq!(Ruling::Fixed, doc.rulings[0].outcome);
1907 let doc: RecheckDoc =
1908 serde_json::from_str(r#"{"summary":"","rulings":[],"findings":[]}"#).unwrap();
1909 assert!(doc.rulings.is_empty());
1910 assert_eq!("APPROVE", ReviewVerdict::Approve.api_event());
1911 assert_eq!("changes requested", ReviewVerdict::RequestChanges.words());
1912 }
1913}