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 NextAction {
123 Merge = "merge" | "approve" | "ship",
124 FixMyself = "fix_myself" | "fix-myself" | "fix" | "self_fix",
125 HandBack = "hand_back" | "hand-back" | "handback" | "return",
126 }
127}
128
129string_enum! {
130 pub enum Action {
134 Fixed = "fixed" | "fix" | "accepted" | "done",
135 Refuted = "refuted" | "refute" | "rejected" | "disagree" | "wontfix",
136 FiledIssue = "filed_issue" | "filed-issue" | "filed" | "deferred" | "out_of_scope",
137 }
138}
139
140string_enum! {
141 pub enum Ask {
147 Implement = "implement" | "do" | "accept" | "fix",
150 Defer = "defer" | "file_issue" | "filed_issue" | "out_of_scope" | "followup",
152 Decline = "decline" | "refute" | "reject" | "disagree" | "wontfix",
154 Answer = "answer" | "question" | "reply" | "clarify",
156 Nothing = "nothing" | "none" | "no_request" | "noop" | "skip",
158 }
159}
160
161string_enum! {
162 pub enum Screened {
168 StillRelevant = "still_relevant" | "still-relevant" | "relevant" | "keep" | "file",
169 AlreadyFixed = "already_fixed" | "already-fixed" | "fixed" | "done" | "resolved",
170 NotWorthIt = "not_worth_it" | "not-worth-it" | "not_worth_doing" | "skip" | "drop" | "wontfix",
171 Duplicate = "duplicate" | "dupe" | "dup",
172 }
173}
174
175string_enum! {
176 pub enum Status {
178 Pending = "pending",
179 Abandoned = "abandoned",
180 Approved = "approved",
181 Merged = "merged",
182 Escalated = "escalated",
183 Error = "error",
184 Reviewed = "reviewed",
186 Clean = "clean",
188 Answered = "answered",
190 Split = "split",
192 Whole = "whole",
194 }
195}
196
197impl Complexity {
198 pub fn rank(self) -> u8 {
199 match self {
200 Complexity::S => 0,
201 Complexity::M => 1,
202 Complexity::L => 2,
203 }
204 }
205}
206
207impl Severity {
208 pub fn rank(self) -> u8 {
212 match self {
213 Severity::Nit => 0,
214 Severity::NonBlocking => 1,
215 Severity::Blocking => 2,
216 }
217 }
218
219 pub fn graver(self, other: Self) -> Self {
226 if self.rank() >= other.rank() {
227 self
228 } else {
229 other
230 }
231 }
232}
233
234impl Risk {
235 pub fn rank(self) -> u8 {
236 match self {
237 Risk::Low => 0,
238 Risk::Med => 1,
239 Risk::High => 2,
240 }
241 }
242}
243
244pub fn de_i64<'de, D: Deserializer<'de>>(d: D) -> Result<i64, D::Error> {
251 struct V;
252 impl<'de> Visitor<'de> for V {
253 type Value = i64;
254 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255 f.write_str("an issue number")
256 }
257 fn visit_i64<E: de::Error>(self, v: i64) -> Result<i64, E> {
258 Ok(v)
259 }
260 fn visit_u64<E: de::Error>(self, v: u64) -> Result<i64, E> {
261 Ok(v as i64)
262 }
263 fn visit_f64<E: de::Error>(self, v: f64) -> Result<i64, E> {
264 Ok(v as i64)
265 }
266 fn visit_str<E: de::Error>(self, v: &str) -> Result<i64, E> {
267 v.trim()
268 .trim_start_matches('#')
269 .parse()
270 .map_err(|_| E::custom(format!("{v} is not a number")))
271 }
272 }
273 d.deserialize_any(V)
274}
275
276fn de_i64_vec<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<i64>, D::Error> {
277 #[derive(Deserialize)]
278 struct One(#[serde(deserialize_with = "de_i64")] i64);
279 let raw = Option::<Vec<One>>::deserialize(d)?;
280 Ok(raw
281 .unwrap_or_default()
282 .into_iter()
283 .map(|One(n)| n)
284 .collect())
285}
286
287pub fn de_bool<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
289 struct V;
290 impl<'de> Visitor<'de> for V {
291 type Value = bool;
292 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293 f.write_str("a boolean")
294 }
295 fn visit_bool<E: de::Error>(self, v: bool) -> Result<bool, E> {
296 Ok(v)
297 }
298 fn visit_i64<E: de::Error>(self, v: i64) -> Result<bool, E> {
299 Ok(v != 0)
300 }
301 fn visit_u64<E: de::Error>(self, v: u64) -> Result<bool, E> {
302 Ok(v != 0)
303 }
304 fn visit_str<E: de::Error>(self, v: &str) -> Result<bool, E> {
305 match norm_token(v).as_str() {
306 "true" | "yes" | "y" | "1" => Ok(true),
307 "false" | "no" | "n" | "0" => Ok(false),
308 other => Err(E::custom(format!("{other} is not a boolean"))),
309 }
310 }
311 }
312 d.deserialize_any(V)
313}
314
315pub fn de_opt_i64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<i64>, D::Error> {
321 Ok(match Option::<serde_json::Value>::deserialize(d)? {
322 Some(serde_json::Value::Number(n)) => n.as_i64(),
323 Some(serde_json::Value::String(s)) => s.trim().trim_start_matches('#').parse().ok(),
324 _ => None,
325 })
326}
327
328fn de_string_vec<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
330 Ok(Option::<Vec<String>>::deserialize(d)?.unwrap_or_default())
331}
332
333fn de_bool_default_true<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
334 #[derive(Deserialize)]
335 struct Wrap(#[serde(deserialize_with = "de_bool")] bool);
336 Ok(Option::<Wrap>::deserialize(d)?
337 .map(|Wrap(b)| b)
338 .unwrap_or(true))
339}
340
341fn de_string<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
342 Ok(Option::<String>::deserialize(d)?.unwrap_or_default())
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct TriageVerdict {
351 #[serde(deserialize_with = "de_i64")]
352 pub issue: i64,
353 #[serde(deserialize_with = "de_bool")]
354 pub worth_doing: bool,
355 #[serde(default, deserialize_with = "de_bool")]
358 pub tracker: bool,
359 #[serde(default, deserialize_with = "de_string")]
360 pub reason: String,
361 pub complexity: Complexity,
362 #[serde(default, deserialize_with = "de_i64_vec")]
363 pub depends_on: Vec<i64>,
364 pub risk: Risk,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct TriageResponse {
369 #[serde(default)]
370 pub issues: Vec<TriageVerdict>,
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct ScreenVerdict {
376 #[serde(deserialize_with = "de_i64")]
381 pub entry: i64,
382 pub verdict: Screened,
383 #[serde(default, deserialize_with = "de_string")]
384 pub title: String,
385 #[serde(default, deserialize_with = "de_string")]
386 pub reason: String,
387 #[serde(default, deserialize_with = "de_opt_i64")]
393 pub duplicate_of_issue: Option<i64>,
394 #[serde(default, deserialize_with = "de_opt_i64")]
396 pub duplicate_of_entry: Option<i64>,
397}
398
399impl ScreenVerdict {
400 pub fn names_a_duplicate(&self) -> bool {
402 self.duplicate_of_issue.is_some() || self.duplicate_of_entry.is_some()
403 }
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct ScreenResponse {
408 #[serde(default)]
409 pub entries: Vec<ScreenVerdict>,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct SplitScreen {
419 #[serde(deserialize_with = "de_i64")]
422 pub item: i64,
423 #[serde(deserialize_with = "de_bool")]
424 pub split: bool,
425 #[serde(default, deserialize_with = "de_string")]
426 pub reason: String,
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct SplitScreenDoc {
431 #[serde(default)]
432 pub items: Vec<SplitScreen>,
433}
434
435#[derive(Debug, Clone, Default, Serialize, Deserialize)]
437pub struct SplitPart {
438 #[serde(default, deserialize_with = "de_string")]
439 pub title: String,
440 #[serde(default, deserialize_with = "de_string")]
442 pub body: String,
443 #[serde(default, deserialize_with = "de_string_vec")]
446 pub files: Vec<String>,
447}
448
449#[derive(Debug, Clone, Default, Serialize, Deserialize)]
455pub struct SplitProposal {
456 #[serde(default, deserialize_with = "de_bool")]
457 pub should_split: bool,
458 #[serde(default, deserialize_with = "de_string")]
459 pub reason: String,
460 #[serde(default, deserialize_with = "de_bool")]
464 pub stacked: bool,
465 #[serde(default)]
466 pub parts: Vec<SplitPart>,
467}
468
469#[derive(Debug, Clone, Default, Serialize, Deserialize)]
471pub struct SplitCheck {
472 #[serde(default, deserialize_with = "de_bool")]
473 pub accept: bool,
474 #[serde(default, deserialize_with = "de_bool")]
475 pub stacked: bool,
476 #[serde(default, deserialize_with = "de_i64_vec")]
478 pub strike: Vec<i64>,
479 #[serde(default, deserialize_with = "de_string")]
480 pub reasoning: String,
481}
482
483#[derive(Debug, Clone, Serialize, Deserialize)]
485pub struct CommentVerdict {
486 #[serde(default, deserialize_with = "de_string")]
491 pub ref_id: String,
492 pub ask: Ask,
493 #[serde(default, deserialize_with = "de_string")]
496 pub request: String,
497 #[serde(default, deserialize_with = "de_string")]
500 pub reasoning: String,
501 #[serde(deserialize_with = "de_bool")]
504 pub unambiguous: bool,
505 #[serde(default)]
506 pub new_issue_title: Option<String>,
507 #[serde(default)]
508 pub new_issue_body: Option<String>,
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
512pub struct CheckinDoc {
513 #[serde(default)]
514 pub verdicts: Vec<CommentVerdict>,
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct CommentCheck {
520 #[serde(default, deserialize_with = "de_string")]
521 pub ref_id: String,
522 #[serde(deserialize_with = "de_bool")]
523 pub agrees: bool,
524 pub ask: Ask,
529 #[serde(deserialize_with = "de_bool")]
530 pub unambiguous: bool,
531 #[serde(default, deserialize_with = "de_string")]
532 pub reasoning: String,
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize)]
536pub struct CheckDoc {
537 #[serde(default)]
538 pub checks: Vec<CommentCheck>,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct FixOutcome {
544 #[serde(default, deserialize_with = "de_string")]
545 pub ref_id: String,
546 #[serde(deserialize_with = "de_bool")]
550 pub changed: bool,
551 #[serde(default, deserialize_with = "de_string")]
554 pub summary: String,
555 #[serde(default)]
558 pub files: Vec<String>,
559}
560
561#[derive(Debug, Clone, Serialize, Deserialize)]
562pub struct FixReport {
563 #[serde(default)]
564 pub done: Vec<FixOutcome>,
565}
566
567#[derive(Debug, Clone, Default, Serialize, Deserialize)]
579pub struct Answered {
580 #[serde(default)]
581 pub version: u32,
582 #[serde(default)]
583 pub seen: BTreeMap<String, String>,
584}
585
586#[derive(Debug, Clone, Serialize, Deserialize)]
587pub struct Finding {
588 pub severity: Severity,
589 #[serde(default, deserialize_with = "de_string")]
590 pub title: String,
591 #[serde(default, deserialize_with = "de_string")]
592 pub detail: String,
593 #[serde(default, deserialize_with = "de_string")]
594 pub file: String,
595 #[serde(default = "yes", deserialize_with = "de_bool_default_true")]
598 pub in_scope: bool,
599
600 #[serde(default)]
608 pub problem: Option<String>,
609 #[serde(default)]
611 pub reproduction: Option<String>,
612 #[serde(default)]
614 pub impact: Option<String>,
615 #[serde(default)]
617 pub expected: Option<String>,
618}
619
620impl Default for Finding {
621 fn default() -> Self {
628 Self {
629 severity: Severity::Nit,
630 title: String::new(),
631 detail: String::new(),
632 file: String::new(),
633 in_scope: true,
634 problem: None,
635 reproduction: None,
636 impact: None,
637 expected: None,
638 }
639 }
640}
641
642impl Finding {
643 pub fn report_sections(&self) -> Vec<(&'static str, &str)> {
646 [
647 ("Problem", self.problem.as_deref()),
648 ("Reproduction", self.reproduction.as_deref()),
649 ("Impact", self.impact.as_deref()),
650 ("Expected behavior", self.expected.as_deref()),
651 ]
652 .into_iter()
653 .filter_map(|(heading, text)| {
654 text.map(str::trim)
655 .filter(|t| !t.is_empty())
656 .map(|t| (heading, t))
657 })
658 .collect()
659 }
660}
661
662fn yes() -> bool {
663 true
664}
665
666impl Finding {
667 pub fn blocks(&self) -> bool {
668 self.severity == Severity::Blocking && self.in_scope
669 }
670
671 pub fn where_at(&self) -> &str {
672 if self.file.trim().is_empty() {
673 "general"
674 } else {
675 self.file.trim()
676 }
677 }
678}
679
680#[derive(Debug, Clone, Serialize, Deserialize)]
681pub struct Review {
682 pub verdict: Verdict,
683 pub next_action: NextAction,
684 #[serde(default, deserialize_with = "de_string")]
685 pub summary: String,
686 #[serde(default)]
687 pub findings: Vec<Finding>,
688}
689
690#[derive(Debug, Clone, Serialize, Deserialize)]
691pub struct Disposition {
692 #[serde(default, deserialize_with = "de_string")]
693 pub title: String,
694 #[serde(default, deserialize_with = "de_string")]
698 pub file: String,
699 pub action: Action,
700 #[serde(default, deserialize_with = "de_string")]
701 pub reasoning: String,
702 #[serde(default)]
703 pub new_issue_title: Option<String>,
704 #[serde(default)]
705 pub new_issue_body: Option<String>,
706}
707
708#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct OwnFix {
717 #[serde(default, deserialize_with = "de_string")]
718 pub title: String,
719 #[serde(default, deserialize_with = "de_string")]
722 pub file: String,
723 #[serde(deserialize_with = "de_bool")]
724 pub fixed: bool,
725 #[serde(default, deserialize_with = "de_string")]
726 pub reason: String,
727}
728
729#[derive(Debug, Clone, Serialize, Deserialize)]
730pub struct OwnFixDoc {
731 #[serde(default, deserialize_with = "de_string")]
732 pub summary: String,
733 #[serde(default)]
734 pub fixes: Vec<OwnFix>,
735}
736
737#[derive(Debug, Clone, Serialize, Deserialize)]
744pub struct Adjudication {
745 #[serde(default, deserialize_with = "de_string")]
746 pub title: String,
747 #[serde(default, deserialize_with = "de_string")]
748 pub file: String,
749 #[serde(deserialize_with = "de_bool")]
752 pub agrees: bool,
753 pub severity: Severity,
755 #[serde(default, deserialize_with = "de_string")]
756 pub reasoning: String,
757}
758
759#[derive(Debug, Clone, Serialize, Deserialize)]
760pub struct AdjudicationDoc {
761 #[serde(default)]
762 pub verdicts: Vec<Adjudication>,
763}
764
765#[derive(Debug, Clone, Default, Serialize, Deserialize)]
778pub struct Idea {
779 #[serde(default, deserialize_with = "de_string")]
780 pub title: String,
781 #[serde(default, deserialize_with = "de_string_vec")]
783 pub combines: Vec<String>,
784 #[serde(default, deserialize_with = "de_string")]
785 pub how_it_works: String,
786 #[serde(default, deserialize_with = "de_string")]
788 pub why_new: String,
789 #[serde(default, deserialize_with = "de_string")]
790 pub enables: String,
791 #[serde(default, deserialize_with = "de_string")]
792 pub risks: String,
793 #[serde(default, deserialize_with = "de_string")]
795 pub first_experiment: String,
796}
797
798#[derive(Debug, Clone, Default, Serialize, Deserialize)]
799pub struct IdeasDoc {
800 #[serde(default)]
801 pub ideas: Vec<Idea>,
802}
803
804string_enum! {
805 pub enum CrossKind {
807 Build = "build" | "extend" | "improve" | "combine",
808 Challenge = "challenge" | "reject" | "exists" | "object",
809 Keep = "keep" | "agree" | "accept" | "fine",
810 }
811}
812
813#[derive(Debug, Clone, Serialize, Deserialize)]
815pub struct CrossVerdict {
816 #[serde(default, deserialize_with = "de_i64")]
819 pub idea: i64,
820 pub verdict: CrossKind,
821 #[serde(default, deserialize_with = "de_string")]
822 pub reason: String,
823 #[serde(default)]
825 pub build: Option<Idea>,
826}
827
828#[derive(Debug, Clone, Default, Serialize, Deserialize)]
829pub struct CrossDoc {
830 #[serde(default)]
831 pub verdicts: Vec<CrossVerdict>,
832}
833
834#[derive(Debug, Clone, Serialize, Deserialize)]
836pub struct Defence {
837 #[serde(default, deserialize_with = "de_i64")]
838 pub idea: i64,
839 #[serde(deserialize_with = "de_bool")]
841 pub stands: bool,
842 #[serde(default, deserialize_with = "de_string")]
843 pub reply: String,
844}
845
846#[derive(Debug, Clone, Default, Serialize, Deserialize)]
849pub struct ConvergeDoc {
850 #[serde(default)]
851 pub defences: Vec<Defence>,
852 #[serde(default, deserialize_with = "de_i64_vec")]
854 pub ranking: Vec<i64>,
855}
856
857#[derive(Debug, Clone)]
859pub struct Judged {
860 pub finding: Finding,
861 pub raised_by: String,
863 pub standing: Standing,
865 pub counterpoint: Option<String>,
867 pub defence: Option<String>,
872}
873
874#[derive(Debug, Clone, Copy, PartialEq, Eq)]
875pub enum Standing {
876 Corroborated,
878 Confirmed,
880 Disputed,
883 Withdrawn,
885 Unverified,
887}
888
889#[derive(Debug, Clone, Default, Serialize, Deserialize)]
898pub struct Implementation {
899 #[serde(default, deserialize_with = "de_bool")]
901 pub not_worth_doing: bool,
902 #[serde(default, deserialize_with = "de_string")]
905 pub reason: String,
906 #[serde(default, deserialize_with = "de_string")]
908 pub summary: String,
909 #[serde(default, deserialize_with = "de_string")]
912 pub problem: String,
913 #[serde(default)]
915 pub changes: Vec<String>,
916 #[serde(default)]
918 pub testing: Vec<String>,
919 #[serde(default)]
922 pub notes: Option<String>,
923}
924
925#[derive(Debug, Clone, Serialize, Deserialize)]
926pub struct ResponseDoc {
927 #[serde(default, deserialize_with = "de_string")]
928 pub summary: String,
929 #[serde(default)]
930 pub dispositions: Vec<Disposition>,
931}
932
933#[derive(Debug, Clone, Serialize, Deserialize)]
938pub struct PlanItem {
939 pub issue: i64,
940 pub title: String,
941 pub complexity: Complexity,
942 pub risk: Risk,
943 pub depends_on: Vec<i64>,
944 pub reason: String,
945}
946
947#[derive(Debug, Clone, Serialize, Deserialize)]
948pub struct SkippedItem {
949 pub issue: i64,
950 pub title: String,
951 pub reasons: BTreeMap<String, String>,
953 #[serde(default)]
961 pub tracker: bool,
962}
963
964#[derive(Debug, Clone, Serialize, Deserialize)]
965pub struct ContestedItem {
966 pub issue: i64,
967 pub title: String,
968 pub positions: BTreeMap<String, String>,
970 pub reasons: BTreeMap<String, String>,
971 #[serde(default, skip_serializing_if = "Option::is_none")]
972 pub note: Option<String>,
973}
974
975#[derive(Debug, Clone, Default, Serialize, Deserialize)]
982pub struct Contested {
983 #[serde(default)]
984 pub title: String,
985 #[serde(default)]
987 pub positions: BTreeMap<String, String>,
988 #[serde(default)]
989 pub reasons: BTreeMap<String, String>,
990}
991
992#[derive(Debug, Clone, Default, Serialize, Deserialize)]
993pub struct Plan {
994 #[serde(default)]
995 pub order: Vec<PlanItem>,
996 #[serde(default)]
997 pub skipped: Vec<SkippedItem>,
998 #[serde(default)]
999 pub contested: Vec<ContestedItem>,
1000}
1001
1002string_enum! {
1003 pub enum Settled {
1017 Refuted = "refuted" | "refute" | "rejected",
1018 Filed = "filed" | "filed_issue" | "filed-issue" | "out_of_scope",
1019 Dropped = "dropped" | "not_filed" | "not-filed" | "unfiled",
1020 Fixed = "fixed" | "fix" | "changed",
1021 }
1022}
1023
1024impl Default for Settled {
1025 fn default() -> Self {
1027 Settled::Refuted
1028 }
1029}
1030
1031#[derive(Debug, Clone, PartialEq, Eq)]
1038pub enum Followup {
1039 Recorded(String),
1042 Covered(String),
1046 Dropped(&'static str),
1049 Failed,
1052}
1053
1054impl Followup {
1055 pub fn url(&self) -> Option<&str> {
1060 match self {
1061 Followup::Recorded(url) => Some(url),
1062 _ => None,
1063 }
1064 }
1065}
1066
1067#[derive(Debug, Clone, Serialize, Deserialize)]
1068pub struct LedgerEntry {
1069 pub title: String,
1070 pub file: String,
1071 pub reasoning: String,
1072 pub round: u32,
1073 #[serde(default)]
1074 pub reraised: u32,
1075 #[serde(default)]
1076 pub outcome: Settled,
1077}
1078
1079pub type Ledger = BTreeMap<String, LedgerEntry>;
1083
1084#[derive(Debug, Clone, Serialize, Deserialize)]
1085pub struct Dispute {
1086 pub title: String,
1087 #[serde(default, deserialize_with = "de_string")]
1088 pub file: String,
1089 pub reasoning: String,
1090}
1091
1092#[derive(Debug, Clone, Serialize, Deserialize)]
1094pub struct IssueRun {
1095 pub issue: i64,
1096 pub title: String,
1097 pub status: Status,
1098 #[serde(default, skip_serializing_if = "Option::is_none")]
1099 pub pr: Option<String>,
1100 #[serde(default)]
1101 pub rounds: u32,
1102 #[serde(default)]
1103 pub disputes: Vec<Dispute>,
1104 #[serde(default)]
1105 pub filed: Vec<String>,
1106 #[serde(default)]
1115 pub noted: Vec<Finding>,
1116 #[serde(default)]
1117 pub notes: Vec<String>,
1118 #[serde(skip)]
1121 pub followup_writes_uncertain: bool,
1122}
1123
1124impl IssueRun {
1125 pub fn new(issue: i64, title: impl Into<String>) -> Self {
1126 Self {
1127 issue,
1128 title: title.into(),
1129 status: Status::Pending,
1130 pr: None,
1131 rounds: 0,
1132 disputes: Vec::new(),
1133 filed: Vec::new(),
1134 noted: Vec::new(),
1135 notes: Vec::new(),
1136 followup_writes_uncertain: false,
1137 }
1138 }
1139
1140 pub fn succeeded(&self) -> bool {
1145 matches!(
1146 self.status,
1147 Status::Merged
1148 | Status::Approved
1149 | Status::Abandoned
1150 | Status::Reviewed
1151 | Status::Clean
1152 | Status::Answered
1153 | Status::Split
1157 | Status::Whole
1158 )
1159 }
1160}
1161
1162#[derive(Debug, Clone, Serialize, Deserialize)]
1164pub struct PersistedState {
1165 pub version: u32,
1166 #[serde(default)]
1168 pub checkpoint: u64,
1169 pub round: u32,
1170 pub next_actor: String,
1171 pub status: Status,
1172 #[serde(default, skip_serializing_if = "String::is_empty")]
1174 pub pr_head: String,
1175 #[serde(default)]
1176 pub ledger: Ledger,
1177 #[serde(default)]
1178 pub filed: Vec<String>,
1179 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1180 pub open_findings: Vec<Finding>,
1181 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1182 pub disputes: Vec<Dispute>,
1183 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1184 pub noted: Vec<Finding>,
1185}
1186
1187pub const STATE_VERSION: u32 = 2;
1188
1189#[derive(Debug, Clone, Deserialize)]
1194pub struct Label {
1195 #[serde(default)]
1196 pub name: String,
1197}
1198
1199#[derive(Debug, Clone, Deserialize, Default)]
1200pub struct Author {
1201 #[serde(default)]
1202 pub login: String,
1203}
1204
1205pub fn login_of(author: Option<&Author>) -> &str {
1208 match author.map(|a| a.login.trim()) {
1209 Some(login) if !login.is_empty() => login,
1210 _ => "ghost",
1211 }
1212}
1213
1214#[derive(Debug, Clone, Deserialize, Default)]
1217#[serde(rename_all = "camelCase")]
1218pub struct IssueComment {
1219 #[serde(default)]
1221 pub author: Option<Author>,
1222 #[serde(default)]
1223 pub author_association: String,
1224 #[serde(default)]
1225 pub body: String,
1226 #[serde(default)]
1227 pub created_at: String,
1228 #[serde(default)]
1229 pub is_minimized: bool,
1230}
1231
1232impl IssueComment {
1233 pub fn login(&self) -> &str {
1234 login_of(self.author.as_ref())
1235 }
1236}
1237
1238#[derive(Debug, Clone, Deserialize)]
1239pub struct Issue {
1240 pub number: i64,
1241 #[serde(default)]
1242 pub title: String,
1243 #[serde(default)]
1244 pub body: Option<String>,
1245 #[serde(default)]
1246 pub state: String,
1247 #[serde(default, rename = "stateReason")]
1255 pub state_reason: Option<String>,
1256 #[serde(default)]
1257 pub url: String,
1258 #[serde(default)]
1259 pub labels: Vec<Label>,
1260 #[serde(default)]
1262 pub author: Option<Author>,
1263 #[serde(default)]
1266 pub comments: Vec<IssueComment>,
1267}
1268
1269impl Issue {
1270 pub fn body_text(&self) -> &str {
1271 self.body.as_deref().unwrap_or("")
1272 }
1273
1274 pub fn body_for_prompt(&self, max: usize) -> (String, bool) {
1286 let body = self.body_text().trim();
1287 if body.chars().count() <= max {
1288 return (body.to_string(), false);
1289 }
1290 let clipped: String = body.chars().take(max).collect();
1291 let mut kept = match clipped.rfind('\n') {
1292 Some(at) => clipped[..at].to_string(),
1293 None => clipped,
1294 };
1295 if kept.matches("```").count() % 2 == 1 {
1296 kept.push_str("\n```");
1297 }
1298 kept.push_str("\n\n[Shortened to fit. The rest of this issue was not included.]");
1299 (kept, true)
1300 }
1301
1302 pub fn is_closed(&self) -> bool {
1303 self.state.eq_ignore_ascii_case("closed")
1304 }
1305
1306 pub fn closed_as_completed(&self) -> bool {
1313 self.is_closed() && !self.closed_as_not_planned()
1314 }
1315
1316 pub fn closed_as_not_planned(&self) -> bool {
1317 self.is_closed()
1318 && self
1319 .state_reason
1320 .as_deref()
1321 .is_some_and(|reason| reason.eq_ignore_ascii_case("not_planned"))
1322 }
1323}
1324
1325#[derive(Debug, Clone, Deserialize)]
1326pub struct PrRef {
1327 pub number: i64,
1328 #[serde(default)]
1329 pub url: String,
1330 #[serde(default)]
1331 pub title: String,
1332}
1333
1334#[derive(Debug, Clone, Deserialize)]
1335pub struct IssueRef {
1336 pub number: i64,
1337}
1338
1339#[derive(Debug, Clone, Default, Deserialize)]
1345#[serde(rename_all = "camelCase")]
1346pub struct PrRow {
1347 pub number: i64,
1348 #[serde(default)]
1349 pub title: String,
1350 #[serde(default)]
1351 pub changed_files: i64,
1352 #[serde(default)]
1353 pub additions: i64,
1354 #[serde(default)]
1355 pub deletions: i64,
1356}
1357
1358impl PrRow {
1359 pub fn size(&self) -> String {
1361 format!(
1362 "{} file(s), +{} -{}",
1363 self.changed_files, self.additions, self.deletions
1364 )
1365 }
1366}
1367
1368#[derive(Debug, Clone, Deserialize)]
1369#[serde(rename_all = "camelCase")]
1370pub struct PrView {
1371 pub number: i64,
1372 #[serde(default)]
1373 pub url: String,
1374 #[serde(default)]
1375 pub title: String,
1376 #[serde(default)]
1379 pub body: String,
1380 #[serde(default)]
1381 pub head_ref_name: String,
1382 #[serde(default)]
1383 pub base_ref_name: String,
1384 #[serde(default)]
1385 pub state: String,
1386 #[serde(default)]
1387 pub closing_issues_references: Vec<IssueRef>,
1388 #[serde(default)]
1391 pub is_cross_repository: bool,
1392}
1393
1394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1397pub enum ItemKind {
1398 Issue,
1399 Pr,
1400}
1401
1402impl std::fmt::Display for ItemKind {
1403 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1404 f.write_str(match self {
1405 ItemKind::Issue => "issue",
1406 ItemKind::Pr => "pull request",
1407 })
1408 }
1409}
1410
1411impl PrView {
1412 pub fn is_open(&self) -> bool {
1413 self.state.eq_ignore_ascii_case("open")
1414 }
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419 use super::*;
1420
1421 #[test]
1422 fn severity_accepts_the_canonical_spelling() {
1423 assert_eq!(
1424 Some(Severity::NonBlocking),
1425 Severity::parse_lenient("non-blocking")
1426 );
1427 }
1428
1429 #[test]
1430 fn severity_accepts_near_misses() {
1431 for text in ["NonBlocking", "non_blocking", " NON-BLOCKING ", "minor"] {
1432 assert_eq!(
1433 Some(Severity::NonBlocking),
1434 Severity::parse_lenient(text),
1435 "{text}"
1436 );
1437 }
1438 }
1439
1440 #[test]
1441 fn severity_rejects_nonsense() {
1442 assert_eq!(None, Severity::parse_lenient("catastrophic-ish"));
1443 }
1444
1445 #[test]
1446 fn complexity_ordering_is_cheapest_first() {
1447 assert!(Complexity::S.rank() < Complexity::M.rank());
1448 assert!(Complexity::M.rank() < Complexity::L.rank());
1449 }
1450
1451 #[test]
1452 fn finding_defaults_to_in_scope() {
1453 let f: Finding = serde_json::from_value(serde_json::json!({
1454 "severity": "blocking", "title": "t", "detail": "d", "file": "a.rs"
1455 }))
1456 .unwrap();
1457 assert!(f.in_scope);
1458 assert!(f.blocks());
1459 }
1460
1461 #[test]
1462 fn out_of_scope_blocking_does_not_block() {
1463 let f: Finding = serde_json::from_value(serde_json::json!({
1464 "severity": "blocking", "title": "t", "detail": "d",
1465 "file": "a.rs", "in_scope": false
1466 }))
1467 .unwrap();
1468 assert!(!f.blocks());
1469 }
1470
1471 #[test]
1472 fn triage_tolerates_a_quoted_issue_number() {
1473 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1474 "issue": "#42", "worth_doing": "yes", "reason": "r",
1475 "complexity": "medium", "depends_on": ["39"], "risk": "low"
1476 }))
1477 .unwrap();
1478 assert_eq!(42, v.issue);
1479 assert!(v.worth_doing);
1480 assert_eq!(Complexity::M, v.complexity);
1481 assert_eq!(vec![39], v.depends_on);
1482 }
1483
1484 #[test]
1485 fn triage_tolerates_a_missing_depends_on() {
1486 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1487 "issue": 1, "worth_doing": false, "reason": "r",
1488 "complexity": "s", "risk": "high"
1489 }))
1490 .unwrap();
1491 assert!(v.depends_on.is_empty());
1492 }
1493
1494 #[test]
1495 fn review_tolerates_a_missing_findings_array() {
1496 let r: Review = serde_json::from_value(serde_json::json!({
1497 "verdict": "approve", "next_action": "merge", "summary": "fine"
1498 }))
1499 .unwrap();
1500 assert!(r.findings.is_empty());
1501 }
1502
1503 #[test]
1504 fn a_null_reason_is_an_empty_string_not_a_failure() {
1505 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1506 "issue": 1, "worth_doing": true, "reason": null,
1507 "complexity": "s", "depends_on": [], "risk": "low"
1508 }))
1509 .unwrap();
1510 assert_eq!("", v.reason);
1511 }
1512
1513 #[test]
1514 fn unknown_severity_is_an_error_not_a_silent_downgrade() {
1515 let out: Result<Finding, _> = serde_json::from_value(serde_json::json!({
1516 "severity": "showstopper-maybe", "title": "t", "detail": "d", "file": "a.rs"
1517 }));
1518 assert!(out.is_err());
1519 }
1520
1521 #[test]
1525 fn deciding_not_to_split_is_not_a_failure() {
1526 for status in [Status::Split, Status::Whole] {
1527 let mut run = IssueRun::new(1, "t");
1528 run.status = status;
1529 assert!(run.succeeded(), "{status}");
1530 }
1531 }
1532
1533 #[test]
1534 fn status_round_trips_through_json() {
1535 let run = IssueRun::new(4, "t");
1536 let text = serde_json::to_string(&run).unwrap();
1537 let back: IssueRun = serde_json::from_str(&text).unwrap();
1538 assert_eq!(Status::Pending, back.status);
1539 }
1540}
1541
1542#[cfg(test)]
1543mod body_for_prompt_tests {
1544 use super::*;
1545
1546 fn issue(body: &str) -> Issue {
1547 let mut i: Issue = serde_json::from_value(serde_json::json!({
1548 "number": 1, "title": "t", "state": "open", "url": "u"
1549 }))
1550 .expect("an issue");
1551 i.body = Some(body.to_string());
1552 i
1553 }
1554
1555 #[test]
1558 fn an_issue_that_fits_is_handed_over_whole() {
1559 let (body, cut) = issue("The guard is inverted.").body_for_prompt(60_000);
1560 assert_eq!("The guard is inverted.", body);
1561 assert!(!cut);
1562 }
1563
1564 #[test]
1568 fn a_shortened_body_says_so_in_the_text() {
1569 let long = "line of text\n".repeat(500);
1570 let (body, cut) = issue(&long).body_for_prompt(200);
1571 assert!(cut);
1572 assert!(body.contains("Shortened to fit"), "{body}");
1573 assert!(body.len() < long.len());
1574 }
1575
1576 #[test]
1579 fn a_cut_never_leaves_a_code_fence_open() {
1580 let body = format!("intro\n\n```rust\n{}\n```\n", "let x = 1;\n".repeat(200));
1581 let (out, cut) = issue(&body).body_for_prompt(120);
1582 assert!(cut);
1583 assert_eq!(0, out.matches("```").count() % 2, "{out}");
1584 }
1585
1586 #[test]
1588 fn a_cut_lands_on_a_line_boundary() {
1589 let body = "aaaa bbbb cccc\n".repeat(100);
1590 let (out, _) = issue(&body).body_for_prompt(100);
1591 let kept = out.split("\n\n[Shortened").next().expect("the kept part");
1592 assert!(kept.ends_with("cccc"), "{kept:?}");
1593 }
1594}