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")]
389 pub duplicate_of: Option<i64>,
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct ScreenResponse {
394 #[serde(default)]
395 pub entries: Vec<ScreenVerdict>,
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct SplitScreen {
405 #[serde(deserialize_with = "de_i64")]
408 pub item: i64,
409 #[serde(deserialize_with = "de_bool")]
410 pub split: bool,
411 #[serde(default, deserialize_with = "de_string")]
412 pub reason: String,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct SplitScreenDoc {
417 #[serde(default)]
418 pub items: Vec<SplitScreen>,
419}
420
421#[derive(Debug, Clone, Default, Serialize, Deserialize)]
423pub struct SplitPart {
424 #[serde(default, deserialize_with = "de_string")]
425 pub title: String,
426 #[serde(default, deserialize_with = "de_string")]
428 pub body: String,
429 #[serde(default, deserialize_with = "de_string_vec")]
432 pub files: Vec<String>,
433}
434
435#[derive(Debug, Clone, Default, Serialize, Deserialize)]
441pub struct SplitProposal {
442 #[serde(default, deserialize_with = "de_bool")]
443 pub should_split: bool,
444 #[serde(default, deserialize_with = "de_string")]
445 pub reason: String,
446 #[serde(default, deserialize_with = "de_bool")]
450 pub stacked: bool,
451 #[serde(default)]
452 pub parts: Vec<SplitPart>,
453}
454
455#[derive(Debug, Clone, Default, Serialize, Deserialize)]
457pub struct SplitCheck {
458 #[serde(default, deserialize_with = "de_bool")]
459 pub accept: bool,
460 #[serde(default, deserialize_with = "de_bool")]
461 pub stacked: bool,
462 #[serde(default, deserialize_with = "de_i64_vec")]
464 pub strike: Vec<i64>,
465 #[serde(default, deserialize_with = "de_string")]
466 pub reasoning: String,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct CommentVerdict {
472 #[serde(default, deserialize_with = "de_string")]
477 pub ref_id: String,
478 pub ask: Ask,
479 #[serde(default, deserialize_with = "de_string")]
482 pub request: String,
483 #[serde(default, deserialize_with = "de_string")]
486 pub reasoning: String,
487 #[serde(deserialize_with = "de_bool")]
490 pub unambiguous: bool,
491 #[serde(default)]
492 pub new_issue_title: Option<String>,
493 #[serde(default)]
494 pub new_issue_body: Option<String>,
495}
496
497#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct CheckinDoc {
499 #[serde(default)]
500 pub verdicts: Vec<CommentVerdict>,
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct CommentCheck {
506 #[serde(default, deserialize_with = "de_string")]
507 pub ref_id: String,
508 #[serde(deserialize_with = "de_bool")]
509 pub agrees: bool,
510 pub ask: Ask,
512 #[serde(deserialize_with = "de_bool")]
513 pub unambiguous: bool,
514 #[serde(default, deserialize_with = "de_string")]
515 pub reasoning: String,
516}
517
518#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct CheckDoc {
520 #[serde(default)]
521 pub checks: Vec<CommentCheck>,
522}
523
524#[derive(Debug, Clone, Serialize, Deserialize)]
526pub struct FixOutcome {
527 #[serde(default, deserialize_with = "de_string")]
528 pub ref_id: String,
529 #[serde(deserialize_with = "de_bool")]
533 pub changed: bool,
534 #[serde(default, deserialize_with = "de_string")]
537 pub summary: String,
538}
539
540#[derive(Debug, Clone, Serialize, Deserialize)]
541pub struct FixReport {
542 #[serde(default)]
543 pub done: Vec<FixOutcome>,
544}
545
546#[derive(Debug, Clone, Default, Serialize, Deserialize)]
558pub struct Answered {
559 #[serde(default)]
560 pub version: u32,
561 #[serde(default)]
562 pub seen: BTreeMap<String, String>,
563}
564
565#[derive(Debug, Clone, Serialize, Deserialize)]
566pub struct Finding {
567 pub severity: Severity,
568 #[serde(default, deserialize_with = "de_string")]
569 pub title: String,
570 #[serde(default, deserialize_with = "de_string")]
571 pub detail: String,
572 #[serde(default, deserialize_with = "de_string")]
573 pub file: String,
574 #[serde(default = "yes", deserialize_with = "de_bool_default_true")]
577 pub in_scope: bool,
578
579 #[serde(default)]
587 pub problem: Option<String>,
588 #[serde(default)]
590 pub reproduction: Option<String>,
591 #[serde(default)]
593 pub impact: Option<String>,
594 #[serde(default)]
596 pub expected: Option<String>,
597}
598
599impl Default for Finding {
600 fn default() -> Self {
607 Self {
608 severity: Severity::Nit,
609 title: String::new(),
610 detail: String::new(),
611 file: String::new(),
612 in_scope: true,
613 problem: None,
614 reproduction: None,
615 impact: None,
616 expected: None,
617 }
618 }
619}
620
621impl Finding {
622 pub fn report_sections(&self) -> Vec<(&'static str, &str)> {
625 [
626 ("Problem", self.problem.as_deref()),
627 ("Reproduction", self.reproduction.as_deref()),
628 ("Impact", self.impact.as_deref()),
629 ("Expected behavior", self.expected.as_deref()),
630 ]
631 .into_iter()
632 .filter_map(|(heading, text)| {
633 text.map(str::trim)
634 .filter(|t| !t.is_empty())
635 .map(|t| (heading, t))
636 })
637 .collect()
638 }
639}
640
641fn yes() -> bool {
642 true
643}
644
645impl Finding {
646 pub fn blocks(&self) -> bool {
647 self.severity == Severity::Blocking && self.in_scope
648 }
649
650 pub fn where_at(&self) -> &str {
651 if self.file.trim().is_empty() {
652 "general"
653 } else {
654 self.file.trim()
655 }
656 }
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize)]
660pub struct Review {
661 pub verdict: Verdict,
662 pub next_action: NextAction,
663 #[serde(default, deserialize_with = "de_string")]
664 pub summary: String,
665 #[serde(default)]
666 pub findings: Vec<Finding>,
667}
668
669#[derive(Debug, Clone, Serialize, Deserialize)]
670pub struct Disposition {
671 #[serde(default, deserialize_with = "de_string")]
672 pub title: String,
673 #[serde(default, deserialize_with = "de_string")]
677 pub file: String,
678 pub action: Action,
679 #[serde(default, deserialize_with = "de_string")]
680 pub reasoning: String,
681 #[serde(default)]
682 pub new_issue_title: Option<String>,
683 #[serde(default)]
684 pub new_issue_body: Option<String>,
685}
686
687#[derive(Debug, Clone, Serialize, Deserialize)]
694pub struct Adjudication {
695 #[serde(default, deserialize_with = "de_string")]
696 pub title: String,
697 #[serde(default, deserialize_with = "de_string")]
698 pub file: String,
699 #[serde(deserialize_with = "de_bool")]
702 pub agrees: bool,
703 pub severity: Severity,
705 #[serde(default, deserialize_with = "de_string")]
706 pub reasoning: String,
707}
708
709#[derive(Debug, Clone, Serialize, Deserialize)]
710pub struct AdjudicationDoc {
711 #[serde(default)]
712 pub verdicts: Vec<Adjudication>,
713}
714
715#[derive(Debug, Clone)]
717pub struct Judged {
718 pub finding: Finding,
719 pub raised_by: String,
721 pub standing: Standing,
723 pub counterpoint: Option<String>,
725 pub defence: Option<String>,
730}
731
732#[derive(Debug, Clone, Copy, PartialEq, Eq)]
733pub enum Standing {
734 Corroborated,
736 Confirmed,
738 Disputed,
741 Withdrawn,
743 Unverified,
745}
746
747#[derive(Debug, Clone, Default, Serialize, Deserialize)]
756pub struct Implementation {
757 #[serde(default, deserialize_with = "de_bool")]
759 pub not_worth_doing: bool,
760 #[serde(default, deserialize_with = "de_string")]
763 pub reason: String,
764 #[serde(default, deserialize_with = "de_string")]
766 pub summary: String,
767 #[serde(default, deserialize_with = "de_string")]
770 pub problem: String,
771 #[serde(default)]
773 pub changes: Vec<String>,
774 #[serde(default)]
776 pub testing: Vec<String>,
777 #[serde(default)]
780 pub notes: Option<String>,
781}
782
783#[derive(Debug, Clone, Serialize, Deserialize)]
784pub struct ResponseDoc {
785 #[serde(default, deserialize_with = "de_string")]
786 pub summary: String,
787 #[serde(default)]
788 pub dispositions: Vec<Disposition>,
789}
790
791#[derive(Debug, Clone, Serialize, Deserialize)]
796pub struct PlanItem {
797 pub issue: i64,
798 pub title: String,
799 pub complexity: Complexity,
800 pub risk: Risk,
801 pub depends_on: Vec<i64>,
802 pub reason: String,
803}
804
805#[derive(Debug, Clone, Serialize, Deserialize)]
806pub struct SkippedItem {
807 pub issue: i64,
808 pub title: String,
809 pub reasons: BTreeMap<String, String>,
811 #[serde(default)]
819 pub tracker: bool,
820}
821
822#[derive(Debug, Clone, Serialize, Deserialize)]
823pub struct ContestedItem {
824 pub issue: i64,
825 pub title: String,
826 pub positions: BTreeMap<String, String>,
828 pub reasons: BTreeMap<String, String>,
829 #[serde(default, skip_serializing_if = "Option::is_none")]
830 pub note: Option<String>,
831}
832
833#[derive(Debug, Clone, Default, Serialize, Deserialize)]
834pub struct Plan {
835 #[serde(default)]
836 pub order: Vec<PlanItem>,
837 #[serde(default)]
838 pub skipped: Vec<SkippedItem>,
839 #[serde(default)]
840 pub contested: Vec<ContestedItem>,
841}
842
843string_enum! {
844 pub enum Settled {
858 Refuted = "refuted" | "refute" | "rejected",
859 Filed = "filed" | "filed_issue" | "filed-issue" | "out_of_scope",
860 Dropped = "dropped" | "not_filed" | "not-filed" | "unfiled",
861 Fixed = "fixed" | "fix" | "changed",
862 }
863}
864
865impl Default for Settled {
866 fn default() -> Self {
868 Settled::Refuted
869 }
870}
871
872#[derive(Debug, Clone, PartialEq, Eq)]
879pub enum Followup {
880 Recorded(String),
883 Covered(String),
887 Dropped(&'static str),
890 Failed,
893}
894
895impl Followup {
896 pub fn url(&self) -> Option<&str> {
901 match self {
902 Followup::Recorded(url) => Some(url),
903 _ => None,
904 }
905 }
906}
907
908#[derive(Debug, Clone, Serialize, Deserialize)]
909pub struct LedgerEntry {
910 pub title: String,
911 pub file: String,
912 pub reasoning: String,
913 pub round: u32,
914 #[serde(default)]
915 pub reraised: u32,
916 #[serde(default)]
917 pub outcome: Settled,
918}
919
920pub type Ledger = BTreeMap<String, LedgerEntry>;
924
925#[derive(Debug, Clone, Serialize, Deserialize)]
926pub struct Dispute {
927 pub title: String,
928 #[serde(default, deserialize_with = "de_string")]
929 pub file: String,
930 pub reasoning: String,
931}
932
933#[derive(Debug, Clone, Serialize, Deserialize)]
935pub struct IssueRun {
936 pub issue: i64,
937 pub title: String,
938 pub status: Status,
939 #[serde(default, skip_serializing_if = "Option::is_none")]
940 pub pr: Option<String>,
941 #[serde(default)]
942 pub rounds: u32,
943 #[serde(default)]
944 pub disputes: Vec<Dispute>,
945 #[serde(default)]
946 pub filed: Vec<String>,
947 #[serde(default)]
956 pub noted: Vec<Finding>,
957 #[serde(default)]
958 pub notes: Vec<String>,
959 #[serde(skip)]
962 pub followup_writes_uncertain: bool,
963}
964
965impl IssueRun {
966 pub fn new(issue: i64, title: impl Into<String>) -> Self {
967 Self {
968 issue,
969 title: title.into(),
970 status: Status::Pending,
971 pr: None,
972 rounds: 0,
973 disputes: Vec::new(),
974 filed: Vec::new(),
975 noted: Vec::new(),
976 notes: Vec::new(),
977 followup_writes_uncertain: false,
978 }
979 }
980
981 pub fn succeeded(&self) -> bool {
986 matches!(
987 self.status,
988 Status::Merged
989 | Status::Approved
990 | Status::Abandoned
991 | Status::Reviewed
992 | Status::Clean
993 | Status::Answered
994 | Status::Split
998 | Status::Whole
999 )
1000 }
1001}
1002
1003#[derive(Debug, Clone, Serialize, Deserialize)]
1005pub struct PersistedState {
1006 pub version: u32,
1007 #[serde(default)]
1009 pub checkpoint: u64,
1010 pub round: u32,
1011 pub next_actor: String,
1012 pub status: Status,
1013 #[serde(default, skip_serializing_if = "String::is_empty")]
1015 pub pr_head: String,
1016 #[serde(default)]
1017 pub ledger: Ledger,
1018 #[serde(default)]
1019 pub filed: Vec<String>,
1020 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1021 pub open_findings: Vec<Finding>,
1022 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1023 pub disputes: Vec<Dispute>,
1024 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1025 pub noted: Vec<Finding>,
1026}
1027
1028pub const STATE_VERSION: u32 = 2;
1029
1030#[derive(Debug, Clone, Deserialize)]
1035pub struct Label {
1036 #[serde(default)]
1037 pub name: String,
1038}
1039
1040#[derive(Debug, Clone, Deserialize)]
1041pub struct Issue {
1042 pub number: i64,
1043 #[serde(default)]
1044 pub title: String,
1045 #[serde(default)]
1046 pub body: Option<String>,
1047 #[serde(default)]
1048 pub state: String,
1049 #[serde(default)]
1050 pub url: String,
1051 #[serde(default)]
1052 pub labels: Vec<Label>,
1053}
1054
1055impl Issue {
1056 pub fn body_text(&self) -> &str {
1057 self.body.as_deref().unwrap_or("")
1058 }
1059
1060 pub fn body_for_prompt(&self, max: usize) -> (String, bool) {
1072 let body = self.body_text().trim();
1073 if body.chars().count() <= max {
1074 return (body.to_string(), false);
1075 }
1076 let clipped: String = body.chars().take(max).collect();
1077 let mut kept = match clipped.rfind('\n') {
1078 Some(at) => clipped[..at].to_string(),
1079 None => clipped,
1080 };
1081 if kept.matches("```").count() % 2 == 1 {
1082 kept.push_str("\n```");
1083 }
1084 kept.push_str("\n\n[Shortened to fit. The rest of this issue was not included.]");
1085 (kept, true)
1086 }
1087
1088 pub fn is_closed(&self) -> bool {
1089 self.state.eq_ignore_ascii_case("closed")
1090 }
1091}
1092
1093#[derive(Debug, Clone, Deserialize)]
1094pub struct PrRef {
1095 pub number: i64,
1096 #[serde(default)]
1097 pub url: String,
1098 #[serde(default)]
1099 pub title: String,
1100}
1101
1102#[derive(Debug, Clone, Deserialize)]
1103pub struct IssueRef {
1104 pub number: i64,
1105}
1106
1107#[derive(Debug, Clone, Default, Deserialize)]
1113#[serde(rename_all = "camelCase")]
1114pub struct PrRow {
1115 pub number: i64,
1116 #[serde(default)]
1117 pub title: String,
1118 #[serde(default)]
1119 pub changed_files: i64,
1120 #[serde(default)]
1121 pub additions: i64,
1122 #[serde(default)]
1123 pub deletions: i64,
1124}
1125
1126impl PrRow {
1127 pub fn size(&self) -> String {
1129 format!(
1130 "{} file(s), +{} -{}",
1131 self.changed_files, self.additions, self.deletions
1132 )
1133 }
1134}
1135
1136#[derive(Debug, Clone, Deserialize)]
1137#[serde(rename_all = "camelCase")]
1138pub struct PrView {
1139 pub number: i64,
1140 #[serde(default)]
1141 pub url: String,
1142 #[serde(default)]
1143 pub title: String,
1144 #[serde(default)]
1145 pub head_ref_name: String,
1146 #[serde(default)]
1147 pub base_ref_name: String,
1148 #[serde(default)]
1149 pub state: String,
1150 #[serde(default)]
1151 pub closing_issues_references: Vec<IssueRef>,
1152 #[serde(default)]
1155 pub is_cross_repository: bool,
1156}
1157
1158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1161pub enum ItemKind {
1162 Issue,
1163 Pr,
1164}
1165
1166impl std::fmt::Display for ItemKind {
1167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1168 f.write_str(match self {
1169 ItemKind::Issue => "issue",
1170 ItemKind::Pr => "pull request",
1171 })
1172 }
1173}
1174
1175impl PrView {
1176 pub fn is_open(&self) -> bool {
1177 self.state.eq_ignore_ascii_case("open")
1178 }
1179}
1180
1181#[cfg(test)]
1182mod tests {
1183 use super::*;
1184
1185 #[test]
1186 fn severity_accepts_the_canonical_spelling() {
1187 assert_eq!(
1188 Some(Severity::NonBlocking),
1189 Severity::parse_lenient("non-blocking")
1190 );
1191 }
1192
1193 #[test]
1194 fn severity_accepts_near_misses() {
1195 for text in ["NonBlocking", "non_blocking", " NON-BLOCKING ", "minor"] {
1196 assert_eq!(
1197 Some(Severity::NonBlocking),
1198 Severity::parse_lenient(text),
1199 "{text}"
1200 );
1201 }
1202 }
1203
1204 #[test]
1205 fn severity_rejects_nonsense() {
1206 assert_eq!(None, Severity::parse_lenient("catastrophic-ish"));
1207 }
1208
1209 #[test]
1210 fn complexity_ordering_is_cheapest_first() {
1211 assert!(Complexity::S.rank() < Complexity::M.rank());
1212 assert!(Complexity::M.rank() < Complexity::L.rank());
1213 }
1214
1215 #[test]
1216 fn finding_defaults_to_in_scope() {
1217 let f: Finding = serde_json::from_value(serde_json::json!({
1218 "severity": "blocking", "title": "t", "detail": "d", "file": "a.rs"
1219 }))
1220 .unwrap();
1221 assert!(f.in_scope);
1222 assert!(f.blocks());
1223 }
1224
1225 #[test]
1226 fn out_of_scope_blocking_does_not_block() {
1227 let f: Finding = serde_json::from_value(serde_json::json!({
1228 "severity": "blocking", "title": "t", "detail": "d",
1229 "file": "a.rs", "in_scope": false
1230 }))
1231 .unwrap();
1232 assert!(!f.blocks());
1233 }
1234
1235 #[test]
1236 fn triage_tolerates_a_quoted_issue_number() {
1237 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1238 "issue": "#42", "worth_doing": "yes", "reason": "r",
1239 "complexity": "medium", "depends_on": ["39"], "risk": "low"
1240 }))
1241 .unwrap();
1242 assert_eq!(42, v.issue);
1243 assert!(v.worth_doing);
1244 assert_eq!(Complexity::M, v.complexity);
1245 assert_eq!(vec![39], v.depends_on);
1246 }
1247
1248 #[test]
1249 fn triage_tolerates_a_missing_depends_on() {
1250 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1251 "issue": 1, "worth_doing": false, "reason": "r",
1252 "complexity": "s", "risk": "high"
1253 }))
1254 .unwrap();
1255 assert!(v.depends_on.is_empty());
1256 }
1257
1258 #[test]
1259 fn review_tolerates_a_missing_findings_array() {
1260 let r: Review = serde_json::from_value(serde_json::json!({
1261 "verdict": "approve", "next_action": "merge", "summary": "fine"
1262 }))
1263 .unwrap();
1264 assert!(r.findings.is_empty());
1265 }
1266
1267 #[test]
1268 fn a_null_reason_is_an_empty_string_not_a_failure() {
1269 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1270 "issue": 1, "worth_doing": true, "reason": null,
1271 "complexity": "s", "depends_on": [], "risk": "low"
1272 }))
1273 .unwrap();
1274 assert_eq!("", v.reason);
1275 }
1276
1277 #[test]
1278 fn unknown_severity_is_an_error_not_a_silent_downgrade() {
1279 let out: Result<Finding, _> = serde_json::from_value(serde_json::json!({
1280 "severity": "showstopper-maybe", "title": "t", "detail": "d", "file": "a.rs"
1281 }));
1282 assert!(out.is_err());
1283 }
1284
1285 #[test]
1289 fn deciding_not_to_split_is_not_a_failure() {
1290 for status in [Status::Split, Status::Whole] {
1291 let mut run = IssueRun::new(1, "t");
1292 run.status = status;
1293 assert!(run.succeeded(), "{status}");
1294 }
1295 }
1296
1297 #[test]
1298 fn status_round_trips_through_json() {
1299 let run = IssueRun::new(4, "t");
1300 let text = serde_json::to_string(&run).unwrap();
1301 let back: IssueRun = serde_json::from_str(&text).unwrap();
1302 assert_eq!(Status::Pending, back.status);
1303 }
1304}
1305
1306#[cfg(test)]
1307mod body_for_prompt_tests {
1308 use super::*;
1309
1310 fn issue(body: &str) -> Issue {
1311 let mut i: Issue = serde_json::from_value(serde_json::json!({
1312 "number": 1, "title": "t", "state": "open", "url": "u"
1313 }))
1314 .expect("an issue");
1315 i.body = Some(body.to_string());
1316 i
1317 }
1318
1319 #[test]
1322 fn an_issue_that_fits_is_handed_over_whole() {
1323 let (body, cut) = issue("The guard is inverted.").body_for_prompt(60_000);
1324 assert_eq!("The guard is inverted.", body);
1325 assert!(!cut);
1326 }
1327
1328 #[test]
1332 fn a_shortened_body_says_so_in_the_text() {
1333 let long = "line of text\n".repeat(500);
1334 let (body, cut) = issue(&long).body_for_prompt(200);
1335 assert!(cut);
1336 assert!(body.contains("Shortened to fit"), "{body}");
1337 assert!(body.len() < long.len());
1338 }
1339
1340 #[test]
1343 fn a_cut_never_leaves_a_code_fence_open() {
1344 let body = format!("intro\n\n```rust\n{}\n```\n", "let x = 1;\n".repeat(200));
1345 let (out, cut) = issue(&body).body_for_prompt(120);
1346 assert!(cut);
1347 assert_eq!(0, out.matches("```").count() % 2, "{out}");
1348 }
1349
1350 #[test]
1352 fn a_cut_lands_on_a_line_boundary() {
1353 let body = "aaaa bbbb cccc\n".repeat(100);
1354 let (out, _) = issue(&body).body_for_prompt(100);
1355 let kept = out.split("\n\n[Shortened").next().expect("the kept part");
1356 assert!(kept.ends_with("cccc"), "{kept:?}");
1357 }
1358}