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 }
191}
192
193impl Complexity {
194 pub fn rank(self) -> u8 {
195 match self {
196 Complexity::S => 0,
197 Complexity::M => 1,
198 Complexity::L => 2,
199 }
200 }
201}
202
203impl Severity {
204 pub fn rank(self) -> u8 {
208 match self {
209 Severity::Nit => 0,
210 Severity::NonBlocking => 1,
211 Severity::Blocking => 2,
212 }
213 }
214
215 pub fn graver(self, other: Self) -> Self {
222 if self.rank() >= other.rank() {
223 self
224 } else {
225 other
226 }
227 }
228}
229
230impl Risk {
231 pub fn rank(self) -> u8 {
232 match self {
233 Risk::Low => 0,
234 Risk::Med => 1,
235 Risk::High => 2,
236 }
237 }
238}
239
240pub fn de_i64<'de, D: Deserializer<'de>>(d: D) -> Result<i64, D::Error> {
247 struct V;
248 impl<'de> Visitor<'de> for V {
249 type Value = i64;
250 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251 f.write_str("an issue number")
252 }
253 fn visit_i64<E: de::Error>(self, v: i64) -> Result<i64, E> {
254 Ok(v)
255 }
256 fn visit_u64<E: de::Error>(self, v: u64) -> Result<i64, E> {
257 Ok(v as i64)
258 }
259 fn visit_f64<E: de::Error>(self, v: f64) -> Result<i64, E> {
260 Ok(v as i64)
261 }
262 fn visit_str<E: de::Error>(self, v: &str) -> Result<i64, E> {
263 v.trim()
264 .trim_start_matches('#')
265 .parse()
266 .map_err(|_| E::custom(format!("{v} is not a number")))
267 }
268 }
269 d.deserialize_any(V)
270}
271
272fn de_i64_vec<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<i64>, D::Error> {
273 #[derive(Deserialize)]
274 struct One(#[serde(deserialize_with = "de_i64")] i64);
275 let raw = Option::<Vec<One>>::deserialize(d)?;
276 Ok(raw
277 .unwrap_or_default()
278 .into_iter()
279 .map(|One(n)| n)
280 .collect())
281}
282
283pub fn de_bool<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
285 struct V;
286 impl<'de> Visitor<'de> for V {
287 type Value = bool;
288 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289 f.write_str("a boolean")
290 }
291 fn visit_bool<E: de::Error>(self, v: bool) -> Result<bool, E> {
292 Ok(v)
293 }
294 fn visit_i64<E: de::Error>(self, v: i64) -> Result<bool, E> {
295 Ok(v != 0)
296 }
297 fn visit_u64<E: de::Error>(self, v: u64) -> Result<bool, E> {
298 Ok(v != 0)
299 }
300 fn visit_str<E: de::Error>(self, v: &str) -> Result<bool, E> {
301 match norm_token(v).as_str() {
302 "true" | "yes" | "y" | "1" => Ok(true),
303 "false" | "no" | "n" | "0" => Ok(false),
304 other => Err(E::custom(format!("{other} is not a boolean"))),
305 }
306 }
307 }
308 d.deserialize_any(V)
309}
310
311pub fn de_opt_i64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<i64>, D::Error> {
317 Ok(match Option::<serde_json::Value>::deserialize(d)? {
318 Some(serde_json::Value::Number(n)) => n.as_i64(),
319 Some(serde_json::Value::String(s)) => s.trim().trim_start_matches('#').parse().ok(),
320 _ => None,
321 })
322}
323
324fn de_bool_default_true<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
325 #[derive(Deserialize)]
326 struct Wrap(#[serde(deserialize_with = "de_bool")] bool);
327 Ok(Option::<Wrap>::deserialize(d)?
328 .map(|Wrap(b)| b)
329 .unwrap_or(true))
330}
331
332fn de_string<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
333 Ok(Option::<String>::deserialize(d)?.unwrap_or_default())
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct TriageVerdict {
342 #[serde(deserialize_with = "de_i64")]
343 pub issue: i64,
344 #[serde(deserialize_with = "de_bool")]
345 pub worth_doing: bool,
346 #[serde(default, deserialize_with = "de_bool")]
349 pub tracker: bool,
350 #[serde(default, deserialize_with = "de_string")]
351 pub reason: String,
352 pub complexity: Complexity,
353 #[serde(default, deserialize_with = "de_i64_vec")]
354 pub depends_on: Vec<i64>,
355 pub risk: Risk,
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct TriageResponse {
360 #[serde(default)]
361 pub issues: Vec<TriageVerdict>,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct ScreenVerdict {
367 #[serde(deserialize_with = "de_i64")]
372 pub entry: i64,
373 pub verdict: Screened,
374 #[serde(default, deserialize_with = "de_string")]
375 pub title: String,
376 #[serde(default, deserialize_with = "de_string")]
377 pub reason: String,
378 #[serde(default, deserialize_with = "de_opt_i64")]
380 pub duplicate_of: Option<i64>,
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct ScreenResponse {
385 #[serde(default)]
386 pub entries: Vec<ScreenVerdict>,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct CommentVerdict {
392 #[serde(default, deserialize_with = "de_string")]
397 pub ref_id: String,
398 pub ask: Ask,
399 #[serde(default, deserialize_with = "de_string")]
402 pub request: String,
403 #[serde(default, deserialize_with = "de_string")]
406 pub reasoning: String,
407 #[serde(deserialize_with = "de_bool")]
410 pub unambiguous: bool,
411 #[serde(default)]
412 pub new_issue_title: Option<String>,
413 #[serde(default)]
414 pub new_issue_body: Option<String>,
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct CheckinDoc {
419 #[serde(default)]
420 pub verdicts: Vec<CommentVerdict>,
421}
422
423#[derive(Debug, Clone, Serialize, Deserialize)]
425pub struct CommentCheck {
426 #[serde(default, deserialize_with = "de_string")]
427 pub ref_id: String,
428 #[serde(deserialize_with = "de_bool")]
429 pub agrees: bool,
430 pub ask: Ask,
432 #[serde(deserialize_with = "de_bool")]
433 pub unambiguous: bool,
434 #[serde(default, deserialize_with = "de_string")]
435 pub reasoning: String,
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct CheckDoc {
440 #[serde(default)]
441 pub checks: Vec<CommentCheck>,
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize)]
446pub struct FixOutcome {
447 #[serde(default, deserialize_with = "de_string")]
448 pub ref_id: String,
449 #[serde(deserialize_with = "de_bool")]
453 pub changed: bool,
454 #[serde(default, deserialize_with = "de_string")]
457 pub summary: String,
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct FixReport {
462 #[serde(default)]
463 pub done: Vec<FixOutcome>,
464}
465
466#[derive(Debug, Clone, Default, Serialize, Deserialize)]
478pub struct Answered {
479 #[serde(default)]
480 pub version: u32,
481 #[serde(default)]
482 pub seen: BTreeMap<String, String>,
483}
484
485#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct Finding {
487 pub severity: Severity,
488 #[serde(default, deserialize_with = "de_string")]
489 pub title: String,
490 #[serde(default, deserialize_with = "de_string")]
491 pub detail: String,
492 #[serde(default, deserialize_with = "de_string")]
493 pub file: String,
494 #[serde(default = "yes", deserialize_with = "de_bool_default_true")]
497 pub in_scope: bool,
498
499 #[serde(default)]
507 pub problem: Option<String>,
508 #[serde(default)]
510 pub reproduction: Option<String>,
511 #[serde(default)]
513 pub impact: Option<String>,
514 #[serde(default)]
516 pub expected: Option<String>,
517}
518
519impl Default for Finding {
520 fn default() -> Self {
527 Self {
528 severity: Severity::Nit,
529 title: String::new(),
530 detail: String::new(),
531 file: String::new(),
532 in_scope: true,
533 problem: None,
534 reproduction: None,
535 impact: None,
536 expected: None,
537 }
538 }
539}
540
541impl Finding {
542 pub fn report_sections(&self) -> Vec<(&'static str, &str)> {
545 [
546 ("Problem", self.problem.as_deref()),
547 ("Reproduction", self.reproduction.as_deref()),
548 ("Impact", self.impact.as_deref()),
549 ("Expected behavior", self.expected.as_deref()),
550 ]
551 .into_iter()
552 .filter_map(|(heading, text)| {
553 text.map(str::trim)
554 .filter(|t| !t.is_empty())
555 .map(|t| (heading, t))
556 })
557 .collect()
558 }
559}
560
561fn yes() -> bool {
562 true
563}
564
565impl Finding {
566 pub fn blocks(&self) -> bool {
567 self.severity == Severity::Blocking && self.in_scope
568 }
569
570 pub fn where_at(&self) -> &str {
571 if self.file.trim().is_empty() {
572 "general"
573 } else {
574 self.file.trim()
575 }
576 }
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct Review {
581 pub verdict: Verdict,
582 pub next_action: NextAction,
583 #[serde(default, deserialize_with = "de_string")]
584 pub summary: String,
585 #[serde(default)]
586 pub findings: Vec<Finding>,
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize)]
590pub struct Disposition {
591 #[serde(default, deserialize_with = "de_string")]
592 pub title: String,
593 #[serde(default, deserialize_with = "de_string")]
597 pub file: String,
598 pub action: Action,
599 #[serde(default, deserialize_with = "de_string")]
600 pub reasoning: String,
601 #[serde(default)]
602 pub new_issue_title: Option<String>,
603 #[serde(default)]
604 pub new_issue_body: Option<String>,
605}
606
607#[derive(Debug, Clone, Serialize, Deserialize)]
614pub struct Adjudication {
615 #[serde(default, deserialize_with = "de_string")]
616 pub title: String,
617 #[serde(default, deserialize_with = "de_string")]
618 pub file: String,
619 #[serde(deserialize_with = "de_bool")]
622 pub agrees: bool,
623 pub severity: Severity,
625 #[serde(default, deserialize_with = "de_string")]
626 pub reasoning: String,
627}
628
629#[derive(Debug, Clone, Serialize, Deserialize)]
630pub struct AdjudicationDoc {
631 #[serde(default)]
632 pub verdicts: Vec<Adjudication>,
633}
634
635#[derive(Debug, Clone)]
637pub struct Judged {
638 pub finding: Finding,
639 pub raised_by: String,
641 pub standing: Standing,
643 pub counterpoint: Option<String>,
645 pub defence: Option<String>,
650}
651
652#[derive(Debug, Clone, Copy, PartialEq, Eq)]
653pub enum Standing {
654 Corroborated,
656 Confirmed,
658 Disputed,
661 Withdrawn,
663 Unverified,
665}
666
667#[derive(Debug, Clone, Default, Serialize, Deserialize)]
676pub struct Implementation {
677 #[serde(default, deserialize_with = "de_bool")]
679 pub not_worth_doing: bool,
680 #[serde(default, deserialize_with = "de_string")]
683 pub reason: String,
684 #[serde(default, deserialize_with = "de_string")]
686 pub summary: String,
687 #[serde(default, deserialize_with = "de_string")]
690 pub problem: String,
691 #[serde(default)]
693 pub changes: Vec<String>,
694 #[serde(default)]
696 pub testing: Vec<String>,
697 #[serde(default)]
700 pub notes: Option<String>,
701}
702
703#[derive(Debug, Clone, Serialize, Deserialize)]
704pub struct ResponseDoc {
705 #[serde(default, deserialize_with = "de_string")]
706 pub summary: String,
707 #[serde(default)]
708 pub dispositions: Vec<Disposition>,
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct PlanItem {
717 pub issue: i64,
718 pub title: String,
719 pub complexity: Complexity,
720 pub risk: Risk,
721 pub depends_on: Vec<i64>,
722 pub reason: String,
723}
724
725#[derive(Debug, Clone, Serialize, Deserialize)]
726pub struct SkippedItem {
727 pub issue: i64,
728 pub title: String,
729 pub reasons: BTreeMap<String, String>,
731 #[serde(default)]
739 pub tracker: bool,
740}
741
742#[derive(Debug, Clone, Serialize, Deserialize)]
743pub struct ContestedItem {
744 pub issue: i64,
745 pub title: String,
746 pub positions: BTreeMap<String, String>,
748 pub reasons: BTreeMap<String, String>,
749 #[serde(default, skip_serializing_if = "Option::is_none")]
750 pub note: Option<String>,
751}
752
753#[derive(Debug, Clone, Default, Serialize, Deserialize)]
754pub struct Plan {
755 #[serde(default)]
756 pub order: Vec<PlanItem>,
757 #[serde(default)]
758 pub skipped: Vec<SkippedItem>,
759 #[serde(default)]
760 pub contested: Vec<ContestedItem>,
761}
762
763string_enum! {
764 pub enum Settled {
770 Refuted = "refuted" | "refute" | "rejected",
771 Filed = "filed" | "filed_issue" | "filed-issue" | "out_of_scope",
772 Dropped = "dropped" | "not_filed" | "not-filed" | "unfiled",
773 }
774}
775
776impl Default for Settled {
777 fn default() -> Self {
779 Settled::Refuted
780 }
781}
782
783#[derive(Debug, Clone, PartialEq, Eq)]
790pub enum Followup {
791 Recorded(String),
794 Covered(String),
798 Dropped(&'static str),
801 Failed,
804}
805
806impl Followup {
807 pub fn url(&self) -> Option<&str> {
812 match self {
813 Followup::Recorded(url) => Some(url),
814 _ => None,
815 }
816 }
817}
818
819#[derive(Debug, Clone, Serialize, Deserialize)]
820pub struct LedgerEntry {
821 pub title: String,
822 pub file: String,
823 pub reasoning: String,
824 pub round: u32,
825 #[serde(default)]
826 pub reraised: u32,
827 #[serde(default)]
828 pub outcome: Settled,
829}
830
831pub type Ledger = BTreeMap<String, LedgerEntry>;
835
836#[derive(Debug, Clone, Serialize, Deserialize)]
837pub struct Dispute {
838 pub title: String,
839 pub reasoning: String,
840}
841
842#[derive(Debug, Clone, Serialize, Deserialize)]
844pub struct IssueRun {
845 pub issue: i64,
846 pub title: String,
847 pub status: Status,
848 #[serde(default, skip_serializing_if = "Option::is_none")]
849 pub pr: Option<String>,
850 #[serde(default)]
851 pub rounds: u32,
852 #[serde(default)]
853 pub disputes: Vec<Dispute>,
854 #[serde(default)]
855 pub filed: Vec<String>,
856 #[serde(default)]
857 pub notes: Vec<String>,
858}
859
860impl IssueRun {
861 pub fn new(issue: i64, title: impl Into<String>) -> Self {
862 Self {
863 issue,
864 title: title.into(),
865 status: Status::Pending,
866 pr: None,
867 rounds: 0,
868 disputes: Vec::new(),
869 filed: Vec::new(),
870 notes: Vec::new(),
871 }
872 }
873
874 pub fn succeeded(&self) -> bool {
879 matches!(
880 self.status,
881 Status::Merged
882 | Status::Approved
883 | Status::Abandoned
884 | Status::Reviewed
885 | Status::Clean
886 | Status::Answered
887 )
888 }
889}
890
891#[derive(Debug, Clone, Serialize, Deserialize)]
893pub struct PersistedState {
894 pub version: u32,
895 pub round: u32,
896 pub next_actor: String,
897 pub status: Status,
898 #[serde(default)]
899 pub ledger: Ledger,
900 #[serde(default)]
901 pub filed: Vec<String>,
902}
903
904pub const STATE_VERSION: u32 = 1;
905
906#[derive(Debug, Clone, Deserialize)]
911pub struct Label {
912 #[serde(default)]
913 pub name: String,
914}
915
916#[derive(Debug, Clone, Deserialize)]
917pub struct Issue {
918 pub number: i64,
919 #[serde(default)]
920 pub title: String,
921 #[serde(default)]
922 pub body: Option<String>,
923 #[serde(default)]
924 pub state: String,
925 #[serde(default)]
926 pub url: String,
927 #[serde(default)]
928 pub labels: Vec<Label>,
929}
930
931impl Issue {
932 pub fn body_text(&self) -> &str {
933 self.body.as_deref().unwrap_or("")
934 }
935
936 pub fn body_for_prompt(&self, max: usize) -> (String, bool) {
948 let body = self.body_text().trim();
949 if body.chars().count() <= max {
950 return (body.to_string(), false);
951 }
952 let clipped: String = body.chars().take(max).collect();
953 let mut kept = match clipped.rfind('\n') {
954 Some(at) => clipped[..at].to_string(),
955 None => clipped,
956 };
957 if kept.matches("```").count() % 2 == 1 {
958 kept.push_str("\n```");
959 }
960 kept.push_str("\n\n[Shortened to fit. The rest of this issue was not included.]");
961 (kept, true)
962 }
963
964 pub fn is_closed(&self) -> bool {
965 self.state.eq_ignore_ascii_case("closed")
966 }
967}
968
969#[derive(Debug, Clone, Deserialize)]
970pub struct PrRef {
971 pub number: i64,
972 #[serde(default)]
973 pub url: String,
974 #[serde(default)]
975 pub title: String,
976}
977
978#[derive(Debug, Clone, Deserialize)]
979pub struct IssueRef {
980 pub number: i64,
981}
982
983#[derive(Debug, Clone, Deserialize)]
984#[serde(rename_all = "camelCase")]
985pub struct PrView {
986 pub number: i64,
987 #[serde(default)]
988 pub url: String,
989 #[serde(default)]
990 pub title: String,
991 #[serde(default)]
992 pub head_ref_name: String,
993 #[serde(default)]
994 pub base_ref_name: String,
995 #[serde(default)]
996 pub state: String,
997 #[serde(default)]
998 pub closing_issues_references: Vec<IssueRef>,
999 #[serde(default)]
1002 pub is_cross_repository: bool,
1003}
1004
1005#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1008pub enum ItemKind {
1009 Issue,
1010 Pr,
1011}
1012
1013impl std::fmt::Display for ItemKind {
1014 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1015 f.write_str(match self {
1016 ItemKind::Issue => "issue",
1017 ItemKind::Pr => "pull request",
1018 })
1019 }
1020}
1021
1022impl PrView {
1023 pub fn is_open(&self) -> bool {
1024 self.state.eq_ignore_ascii_case("open")
1025 }
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031
1032 #[test]
1033 fn severity_accepts_the_canonical_spelling() {
1034 assert_eq!(
1035 Some(Severity::NonBlocking),
1036 Severity::parse_lenient("non-blocking")
1037 );
1038 }
1039
1040 #[test]
1041 fn severity_accepts_near_misses() {
1042 for text in ["NonBlocking", "non_blocking", " NON-BLOCKING ", "minor"] {
1043 assert_eq!(
1044 Some(Severity::NonBlocking),
1045 Severity::parse_lenient(text),
1046 "{text}"
1047 );
1048 }
1049 }
1050
1051 #[test]
1052 fn severity_rejects_nonsense() {
1053 assert_eq!(None, Severity::parse_lenient("catastrophic-ish"));
1054 }
1055
1056 #[test]
1057 fn complexity_ordering_is_cheapest_first() {
1058 assert!(Complexity::S.rank() < Complexity::M.rank());
1059 assert!(Complexity::M.rank() < Complexity::L.rank());
1060 }
1061
1062 #[test]
1063 fn finding_defaults_to_in_scope() {
1064 let f: Finding = serde_json::from_value(serde_json::json!({
1065 "severity": "blocking", "title": "t", "detail": "d", "file": "a.rs"
1066 }))
1067 .unwrap();
1068 assert!(f.in_scope);
1069 assert!(f.blocks());
1070 }
1071
1072 #[test]
1073 fn out_of_scope_blocking_does_not_block() {
1074 let f: Finding = serde_json::from_value(serde_json::json!({
1075 "severity": "blocking", "title": "t", "detail": "d",
1076 "file": "a.rs", "in_scope": false
1077 }))
1078 .unwrap();
1079 assert!(!f.blocks());
1080 }
1081
1082 #[test]
1083 fn triage_tolerates_a_quoted_issue_number() {
1084 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1085 "issue": "#42", "worth_doing": "yes", "reason": "r",
1086 "complexity": "medium", "depends_on": ["39"], "risk": "low"
1087 }))
1088 .unwrap();
1089 assert_eq!(42, v.issue);
1090 assert!(v.worth_doing);
1091 assert_eq!(Complexity::M, v.complexity);
1092 assert_eq!(vec![39], v.depends_on);
1093 }
1094
1095 #[test]
1096 fn triage_tolerates_a_missing_depends_on() {
1097 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1098 "issue": 1, "worth_doing": false, "reason": "r",
1099 "complexity": "s", "risk": "high"
1100 }))
1101 .unwrap();
1102 assert!(v.depends_on.is_empty());
1103 }
1104
1105 #[test]
1106 fn review_tolerates_a_missing_findings_array() {
1107 let r: Review = serde_json::from_value(serde_json::json!({
1108 "verdict": "approve", "next_action": "merge", "summary": "fine"
1109 }))
1110 .unwrap();
1111 assert!(r.findings.is_empty());
1112 }
1113
1114 #[test]
1115 fn a_null_reason_is_an_empty_string_not_a_failure() {
1116 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1117 "issue": 1, "worth_doing": true, "reason": null,
1118 "complexity": "s", "depends_on": [], "risk": "low"
1119 }))
1120 .unwrap();
1121 assert_eq!("", v.reason);
1122 }
1123
1124 #[test]
1125 fn unknown_severity_is_an_error_not_a_silent_downgrade() {
1126 let out: Result<Finding, _> = serde_json::from_value(serde_json::json!({
1127 "severity": "showstopper-maybe", "title": "t", "detail": "d", "file": "a.rs"
1128 }));
1129 assert!(out.is_err());
1130 }
1131
1132 #[test]
1133 fn status_round_trips_through_json() {
1134 let run = IssueRun::new(4, "t");
1135 let text = serde_json::to_string(&run).unwrap();
1136 let back: IssueRun = serde_json::from_str(&text).unwrap();
1137 assert_eq!(Status::Pending, back.status);
1138 }
1139}
1140
1141#[cfg(test)]
1142mod body_for_prompt_tests {
1143 use super::*;
1144
1145 fn issue(body: &str) -> Issue {
1146 let mut i: Issue = serde_json::from_value(serde_json::json!({
1147 "number": 1, "title": "t", "state": "open", "url": "u"
1148 }))
1149 .expect("an issue");
1150 i.body = Some(body.to_string());
1151 i
1152 }
1153
1154 #[test]
1157 fn an_issue_that_fits_is_handed_over_whole() {
1158 let (body, cut) = issue("The guard is inverted.").body_for_prompt(60_000);
1159 assert_eq!("The guard is inverted.", body);
1160 assert!(!cut);
1161 }
1162
1163 #[test]
1167 fn a_shortened_body_says_so_in_the_text() {
1168 let long = "line of text\n".repeat(500);
1169 let (body, cut) = issue(&long).body_for_prompt(200);
1170 assert!(cut);
1171 assert!(body.contains("Shortened to fit"), "{body}");
1172 assert!(body.len() < long.len());
1173 }
1174
1175 #[test]
1178 fn a_cut_never_leaves_a_code_fence_open() {
1179 let body = format!("intro\n\n```rust\n{}\n```\n", "let x = 1;\n".repeat(200));
1180 let (out, cut) = issue(&body).body_for_prompt(120);
1181 assert!(cut);
1182 assert_eq!(0, out.matches("```").count() % 2, "{out}");
1183 }
1184
1185 #[test]
1187 fn a_cut_lands_on_a_line_boundary() {
1188 let body = "aaaa bbbb cccc\n".repeat(100);
1189 let (out, _) = issue(&body).body_for_prompt(100);
1190 let kept = out.split("\n\n[Shortened").next().expect("the kept part");
1191 assert!(kept.ends_with("cccc"), "{kept:?}");
1192 }
1193}