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 Status {
143 Pending = "pending",
144 Abandoned = "abandoned",
145 Approved = "approved",
146 Merged = "merged",
147 Escalated = "escalated",
148 Error = "error",
149 Reviewed = "reviewed",
151 Clean = "clean",
153 }
154}
155
156impl Complexity {
157 pub fn rank(self) -> u8 {
158 match self {
159 Complexity::S => 0,
160 Complexity::M => 1,
161 Complexity::L => 2,
162 }
163 }
164}
165
166impl Severity {
167 pub fn rank(self) -> u8 {
171 match self {
172 Severity::Nit => 0,
173 Severity::NonBlocking => 1,
174 Severity::Blocking => 2,
175 }
176 }
177
178 pub fn graver(self, other: Self) -> Self {
185 if self.rank() >= other.rank() {
186 self
187 } else {
188 other
189 }
190 }
191}
192
193impl Risk {
194 pub fn rank(self) -> u8 {
195 match self {
196 Risk::Low => 0,
197 Risk::Med => 1,
198 Risk::High => 2,
199 }
200 }
201}
202
203pub fn de_i64<'de, D: Deserializer<'de>>(d: D) -> Result<i64, D::Error> {
210 struct V;
211 impl<'de> Visitor<'de> for V {
212 type Value = i64;
213 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214 f.write_str("an issue number")
215 }
216 fn visit_i64<E: de::Error>(self, v: i64) -> Result<i64, E> {
217 Ok(v)
218 }
219 fn visit_u64<E: de::Error>(self, v: u64) -> Result<i64, E> {
220 Ok(v as i64)
221 }
222 fn visit_f64<E: de::Error>(self, v: f64) -> Result<i64, E> {
223 Ok(v as i64)
224 }
225 fn visit_str<E: de::Error>(self, v: &str) -> Result<i64, E> {
226 v.trim()
227 .trim_start_matches('#')
228 .parse()
229 .map_err(|_| E::custom(format!("{v} is not a number")))
230 }
231 }
232 d.deserialize_any(V)
233}
234
235fn de_i64_vec<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<i64>, D::Error> {
236 #[derive(Deserialize)]
237 struct One(#[serde(deserialize_with = "de_i64")] i64);
238 let raw = Option::<Vec<One>>::deserialize(d)?;
239 Ok(raw
240 .unwrap_or_default()
241 .into_iter()
242 .map(|One(n)| n)
243 .collect())
244}
245
246pub fn de_bool<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
248 struct V;
249 impl<'de> Visitor<'de> for V {
250 type Value = bool;
251 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252 f.write_str("a boolean")
253 }
254 fn visit_bool<E: de::Error>(self, v: bool) -> Result<bool, E> {
255 Ok(v)
256 }
257 fn visit_i64<E: de::Error>(self, v: i64) -> Result<bool, E> {
258 Ok(v != 0)
259 }
260 fn visit_u64<E: de::Error>(self, v: u64) -> Result<bool, E> {
261 Ok(v != 0)
262 }
263 fn visit_str<E: de::Error>(self, v: &str) -> Result<bool, E> {
264 match norm_token(v).as_str() {
265 "true" | "yes" | "y" | "1" => Ok(true),
266 "false" | "no" | "n" | "0" => Ok(false),
267 other => Err(E::custom(format!("{other} is not a boolean"))),
268 }
269 }
270 }
271 d.deserialize_any(V)
272}
273
274fn de_bool_default_true<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
275 #[derive(Deserialize)]
276 struct Wrap(#[serde(deserialize_with = "de_bool")] bool);
277 Ok(Option::<Wrap>::deserialize(d)?
278 .map(|Wrap(b)| b)
279 .unwrap_or(true))
280}
281
282fn de_string<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
283 Ok(Option::<String>::deserialize(d)?.unwrap_or_default())
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct TriageVerdict {
292 #[serde(deserialize_with = "de_i64")]
293 pub issue: i64,
294 #[serde(deserialize_with = "de_bool")]
295 pub worth_doing: bool,
296 #[serde(default, deserialize_with = "de_string")]
297 pub reason: String,
298 pub complexity: Complexity,
299 #[serde(default, deserialize_with = "de_i64_vec")]
300 pub depends_on: Vec<i64>,
301 pub risk: Risk,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct TriageResponse {
306 #[serde(default)]
307 pub issues: Vec<TriageVerdict>,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct Finding {
312 pub severity: Severity,
313 #[serde(default, deserialize_with = "de_string")]
314 pub title: String,
315 #[serde(default, deserialize_with = "de_string")]
316 pub detail: String,
317 #[serde(default, deserialize_with = "de_string")]
318 pub file: String,
319 #[serde(default = "yes", deserialize_with = "de_bool_default_true")]
322 pub in_scope: bool,
323}
324
325fn yes() -> bool {
326 true
327}
328
329impl Finding {
330 pub fn blocks(&self) -> bool {
331 self.severity == Severity::Blocking && self.in_scope
332 }
333
334 pub fn where_at(&self) -> &str {
335 if self.file.trim().is_empty() {
336 "general"
337 } else {
338 self.file.trim()
339 }
340 }
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct Review {
345 pub verdict: Verdict,
346 pub next_action: NextAction,
347 #[serde(default, deserialize_with = "de_string")]
348 pub summary: String,
349 #[serde(default)]
350 pub findings: Vec<Finding>,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct Disposition {
355 #[serde(default, deserialize_with = "de_string")]
356 pub title: String,
357 #[serde(default, deserialize_with = "de_string")]
361 pub file: String,
362 pub action: Action,
363 #[serde(default, deserialize_with = "de_string")]
364 pub reasoning: String,
365 #[serde(default)]
366 pub new_issue_title: Option<String>,
367 #[serde(default)]
368 pub new_issue_body: Option<String>,
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct Adjudication {
379 #[serde(default, deserialize_with = "de_string")]
380 pub title: String,
381 #[serde(default, deserialize_with = "de_string")]
382 pub file: String,
383 #[serde(deserialize_with = "de_bool")]
386 pub agrees: bool,
387 pub severity: Severity,
389 #[serde(default, deserialize_with = "de_string")]
390 pub reasoning: String,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize)]
394pub struct AdjudicationDoc {
395 #[serde(default)]
396 pub verdicts: Vec<Adjudication>,
397}
398
399#[derive(Debug, Clone)]
401pub struct Judged {
402 pub finding: Finding,
403 pub raised_by: String,
405 pub standing: Standing,
407 pub counterpoint: Option<String>,
409 pub defence: Option<String>,
414}
415
416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
417pub enum Standing {
418 Corroborated,
420 Confirmed,
422 Disputed,
425 Withdrawn,
427 Unverified,
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct ResponseDoc {
433 #[serde(default, deserialize_with = "de_string")]
434 pub summary: String,
435 #[serde(default)]
436 pub dispositions: Vec<Disposition>,
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct PlanItem {
445 pub issue: i64,
446 pub title: String,
447 pub complexity: Complexity,
448 pub risk: Risk,
449 pub depends_on: Vec<i64>,
450 pub reason: String,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct SkippedItem {
455 pub issue: i64,
456 pub title: String,
457 pub reasons: BTreeMap<String, String>,
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize)]
462pub struct ContestedItem {
463 pub issue: i64,
464 pub title: String,
465 pub positions: BTreeMap<String, String>,
467 pub reasons: BTreeMap<String, String>,
468 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub note: Option<String>,
470}
471
472#[derive(Debug, Clone, Default, Serialize, Deserialize)]
473pub struct Plan {
474 #[serde(default)]
475 pub order: Vec<PlanItem>,
476 #[serde(default)]
477 pub skipped: Vec<SkippedItem>,
478 #[serde(default)]
479 pub contested: Vec<ContestedItem>,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct LedgerEntry {
484 pub title: String,
485 pub file: String,
486 pub reasoning: String,
487 pub round: u32,
488 #[serde(default)]
489 pub reraised: u32,
490}
491
492pub type Ledger = BTreeMap<String, LedgerEntry>;
496
497#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct Dispute {
499 pub title: String,
500 pub reasoning: String,
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct IssueRun {
506 pub issue: i64,
507 pub title: String,
508 pub status: Status,
509 #[serde(default, skip_serializing_if = "Option::is_none")]
510 pub pr: Option<String>,
511 #[serde(default)]
512 pub rounds: u32,
513 #[serde(default)]
514 pub disputes: Vec<Dispute>,
515 #[serde(default)]
516 pub filed: Vec<String>,
517 #[serde(default)]
518 pub notes: Vec<String>,
519}
520
521impl IssueRun {
522 pub fn new(issue: i64, title: impl Into<String>) -> Self {
523 Self {
524 issue,
525 title: title.into(),
526 status: Status::Pending,
527 pr: None,
528 rounds: 0,
529 disputes: Vec::new(),
530 filed: Vec::new(),
531 notes: Vec::new(),
532 }
533 }
534
535 pub fn succeeded(&self) -> bool {
540 matches!(
541 self.status,
542 Status::Merged
543 | Status::Approved
544 | Status::Abandoned
545 | Status::Reviewed
546 | Status::Clean
547 )
548 }
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct PersistedState {
554 pub version: u32,
555 pub round: u32,
556 pub next_actor: String,
557 pub status: Status,
558 #[serde(default)]
559 pub ledger: Ledger,
560 #[serde(default)]
561 pub filed: Vec<String>,
562}
563
564pub const STATE_VERSION: u32 = 1;
565
566#[derive(Debug, Clone, Deserialize)]
571pub struct Label {
572 #[serde(default)]
573 pub name: String,
574}
575
576#[derive(Debug, Clone, Deserialize)]
577pub struct Issue {
578 pub number: i64,
579 #[serde(default)]
580 pub title: String,
581 #[serde(default)]
582 pub body: Option<String>,
583 #[serde(default)]
584 pub state: String,
585 #[serde(default)]
586 pub url: String,
587 #[serde(default)]
588 pub labels: Vec<Label>,
589}
590
591impl Issue {
592 pub fn body_text(&self) -> &str {
593 self.body.as_deref().unwrap_or("")
594 }
595
596 pub fn is_closed(&self) -> bool {
597 self.state.eq_ignore_ascii_case("closed")
598 }
599}
600
601#[derive(Debug, Clone, Deserialize)]
602pub struct PrRef {
603 pub number: i64,
604 #[serde(default)]
605 pub url: String,
606 #[serde(default)]
607 pub title: String,
608}
609
610#[derive(Debug, Clone, Deserialize)]
611pub struct IssueRef {
612 pub number: i64,
613}
614
615#[derive(Debug, Clone, Deserialize)]
616#[serde(rename_all = "camelCase")]
617pub struct PrView {
618 pub number: i64,
619 #[serde(default)]
620 pub url: String,
621 #[serde(default)]
622 pub title: String,
623 #[serde(default)]
624 pub head_ref_name: String,
625 #[serde(default)]
626 pub base_ref_name: String,
627 #[serde(default)]
628 pub state: String,
629 #[serde(default)]
630 pub closing_issues_references: Vec<IssueRef>,
631 #[serde(default)]
634 pub is_cross_repository: bool,
635}
636
637#[derive(Debug, Clone, Copy, PartialEq, Eq)]
640pub enum ItemKind {
641 Issue,
642 Pr,
643}
644
645impl std::fmt::Display for ItemKind {
646 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
647 f.write_str(match self {
648 ItemKind::Issue => "issue",
649 ItemKind::Pr => "pull request",
650 })
651 }
652}
653
654impl PrView {
655 pub fn is_open(&self) -> bool {
656 self.state.eq_ignore_ascii_case("open")
657 }
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663
664 #[test]
665 fn severity_accepts_the_canonical_spelling() {
666 assert_eq!(
667 Some(Severity::NonBlocking),
668 Severity::parse_lenient("non-blocking")
669 );
670 }
671
672 #[test]
673 fn severity_accepts_near_misses() {
674 for text in ["NonBlocking", "non_blocking", " NON-BLOCKING ", "minor"] {
675 assert_eq!(
676 Some(Severity::NonBlocking),
677 Severity::parse_lenient(text),
678 "{text}"
679 );
680 }
681 }
682
683 #[test]
684 fn severity_rejects_nonsense() {
685 assert_eq!(None, Severity::parse_lenient("catastrophic-ish"));
686 }
687
688 #[test]
689 fn complexity_ordering_is_cheapest_first() {
690 assert!(Complexity::S.rank() < Complexity::M.rank());
691 assert!(Complexity::M.rank() < Complexity::L.rank());
692 }
693
694 #[test]
695 fn finding_defaults_to_in_scope() {
696 let f: Finding = serde_json::from_value(serde_json::json!({
697 "severity": "blocking", "title": "t", "detail": "d", "file": "a.rs"
698 }))
699 .unwrap();
700 assert!(f.in_scope);
701 assert!(f.blocks());
702 }
703
704 #[test]
705 fn out_of_scope_blocking_does_not_block() {
706 let f: Finding = serde_json::from_value(serde_json::json!({
707 "severity": "blocking", "title": "t", "detail": "d",
708 "file": "a.rs", "in_scope": false
709 }))
710 .unwrap();
711 assert!(!f.blocks());
712 }
713
714 #[test]
715 fn triage_tolerates_a_quoted_issue_number() {
716 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
717 "issue": "#42", "worth_doing": "yes", "reason": "r",
718 "complexity": "medium", "depends_on": ["39"], "risk": "low"
719 }))
720 .unwrap();
721 assert_eq!(42, v.issue);
722 assert!(v.worth_doing);
723 assert_eq!(Complexity::M, v.complexity);
724 assert_eq!(vec![39], v.depends_on);
725 }
726
727 #[test]
728 fn triage_tolerates_a_missing_depends_on() {
729 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
730 "issue": 1, "worth_doing": false, "reason": "r",
731 "complexity": "s", "risk": "high"
732 }))
733 .unwrap();
734 assert!(v.depends_on.is_empty());
735 }
736
737 #[test]
738 fn review_tolerates_a_missing_findings_array() {
739 let r: Review = serde_json::from_value(serde_json::json!({
740 "verdict": "approve", "next_action": "merge", "summary": "fine"
741 }))
742 .unwrap();
743 assert!(r.findings.is_empty());
744 }
745
746 #[test]
747 fn a_null_reason_is_an_empty_string_not_a_failure() {
748 let v: TriageVerdict = serde_json::from_value(serde_json::json!({
749 "issue": 1, "worth_doing": true, "reason": null,
750 "complexity": "s", "depends_on": [], "risk": "low"
751 }))
752 .unwrap();
753 assert_eq!("", v.reason);
754 }
755
756 #[test]
757 fn unknown_severity_is_an_error_not_a_silent_downgrade() {
758 let out: Result<Finding, _> = serde_json::from_value(serde_json::json!({
759 "severity": "showstopper-maybe", "title": "t", "detail": "d", "file": "a.rs"
760 }));
761 assert!(out.is_err());
762 }
763
764 #[test]
765 fn status_round_trips_through_json() {
766 let run = IssueRun::new(4, "t");
767 let text = serde_json::to_string(&run).unwrap();
768 let back: IssueRun = serde_json::from_str(&text).unwrap();
769 assert_eq!(Status::Pending, back.status);
770 }
771}