Skip to main content

remendo/models/
patchset.rs

1//! Patchset and patchset-detail domain types.
2
3use super::common::{Baseline, Severity};
4use super::message::ThreadMessage;
5use super::patch::Patch;
6use super::review::Review;
7use serde::{Deserialize, Deserializer};
8use std::fmt;
9
10/// Lifecycle status of a patchset.
11///
12/// Deserializes case-insensitively from API strings. Unknown values
13/// map to [`PatchsetStatus::Unknown`] with a warning log instead of
14/// failing deserialization.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
16pub enum PatchsetStatus {
17    /// Not all parts have been received yet.
18    #[default]
19    Incomplete,
20    /// Queued for review.
21    Pending,
22    /// Currently being reviewed.
23    InReview,
24    /// Review completed successfully.
25    Reviewed,
26    /// Review failed.
27    Failed,
28    /// Patch could not be applied to the baseline.
29    FailedToApply,
30    /// Review was cancelled.
31    Cancelled,
32    /// Patchset was skipped.
33    Skipped,
34    /// Unrecognized status from the API.
35    Unknown,
36}
37
38impl fmt::Display for PatchsetStatus {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        match self {
41            Self::Incomplete => write!(f, "Incomplete"),
42            Self::Pending => write!(f, "Pending"),
43            Self::InReview => write!(f, "In Review"),
44            Self::Reviewed => write!(f, "Reviewed"),
45            Self::Failed => write!(f, "Failed"),
46            Self::FailedToApply => write!(f, "Failed to Apply"),
47            Self::Cancelled => write!(f, "Cancelled"),
48            Self::Skipped => write!(f, "Skipped"),
49            Self::Unknown => write!(f, "Unknown"),
50        }
51    }
52}
53
54impl<'de> Deserialize<'de> for PatchsetStatus {
55    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
56    where
57        D: Deserializer<'de>,
58    {
59        let s = Option::<String>::deserialize(deserializer)?;
60        Ok(match s.as_deref().map(str::to_lowercase).as_deref() {
61            Some("pending") => Self::Pending,
62            Some("in review") => Self::InReview,
63            Some("reviewed") => Self::Reviewed,
64            Some("failed") => Self::Failed,
65            Some("failedtoapply" | "failed to apply") => Self::FailedToApply,
66            Some("cancelled") => Self::Cancelled,
67            Some("skipped") => Self::Skipped,
68            Some("incomplete") | None => Self::Incomplete,
69            Some(other) => {
70                tracing::warn!(status = other, "unknown patchset status, using Unknown");
71                Self::Unknown
72            }
73        })
74    }
75}
76
77/// Aggregated finding severity counts for a patchset.
78///
79/// Collapses the four separate `findings_*` fields from the API
80/// into a single struct with convenience methods.
81#[derive(Debug, Clone, PartialEq, Default)]
82pub struct FindingCounts {
83    /// Count of low-severity findings.
84    pub low: u32,
85    /// Count of medium-severity findings.
86    pub medium: u32,
87    /// Count of high-severity findings.
88    pub high: u32,
89    /// Count of critical-severity findings.
90    pub critical: u32,
91}
92
93impl FindingCounts {
94    /// Total number of findings across all severities.
95    #[must_use]
96    pub fn total(&self) -> u32 {
97        self.low + self.medium + self.high + self.critical
98    }
99
100    /// Returns the highest severity level that has a non-zero count.
101    #[must_use]
102    pub fn max_severity(&self) -> Option<Severity> {
103        if self.critical > 0 {
104            Some(Severity::Critical)
105        } else if self.high > 0 {
106            Some(Severity::High)
107        } else if self.medium > 0 {
108            Some(Severity::Medium)
109        } else if self.low > 0 {
110            Some(Severity::Low)
111        } else {
112            None
113        }
114    }
115
116    /// Whether all finding counts are zero.
117    #[must_use]
118    pub fn is_empty(&self) -> bool {
119        self.total() == 0
120    }
121}
122
123/// Helper for deserializing the flat `findings_*` API fields into
124/// a nested `FindingCounts` struct on `Patchset`.
125#[derive(Deserialize)]
126struct PatchsetRaw {
127    #[serde(default)]
128    id: i64,
129    #[serde(default)]
130    subject: Option<String>,
131    #[serde(default)]
132    status: PatchsetStatus,
133    #[serde(default)]
134    thread_id: Option<i64>,
135    #[serde(default)]
136    author: Option<String>,
137    #[serde(default)]
138    date: Option<i64>,
139    #[serde(default)]
140    message_id: Option<String>,
141    #[serde(default)]
142    total_parts: Option<u32>,
143    #[serde(default)]
144    received_parts: Option<u32>,
145    #[serde(default)]
146    subsystems: Vec<String>,
147    #[serde(default)]
148    findings_low: Option<i64>,
149    #[serde(default)]
150    findings_medium: Option<i64>,
151    #[serde(default)]
152    findings_high: Option<i64>,
153    #[serde(default)]
154    findings_critical: Option<i64>,
155    #[serde(default)]
156    baseline_id: Option<i64>,
157    #[serde(default)]
158    failed_reason: Option<String>,
159    #[serde(default)]
160    model_name: Option<String>,
161    #[serde(default)]
162    provider: Option<String>,
163}
164
165/// A patchset summary, as returned by `GET /api/patchsets`.
166///
167/// This is the list-view representation. For full detail including
168/// patches, reviews, and thread, see [`PatchsetDetail`].
169#[derive(Debug, Clone, PartialEq)]
170pub struct Patchset {
171    /// Database identifier.
172    pub id: i64,
173    /// Subject line of the cover letter or first patch.
174    pub subject: Option<String>,
175    /// Current lifecycle status.
176    pub status: PatchsetStatus,
177    /// Thread identifier.
178    pub thread_id: Option<i64>,
179    /// Author email or name.
180    pub author: Option<String>,
181    /// Unix timestamp of the patchset date.
182    pub date: Option<i64>,
183    /// RFC 2822 `Message-ID`.
184    pub message_id: Option<String>,
185    /// Total number of patches expected in the series.
186    pub total_parts: Option<u32>,
187    /// Number of patches received so far.
188    pub received_parts: Option<u32>,
189    /// Subsystem tags (e.g., `["LKML", "netdev"]`).
190    pub subsystems: Vec<String>,
191    /// Aggregated finding severity counts.
192    pub findings: FindingCounts,
193    /// Baseline identifier used for review.
194    pub baseline_id: Option<i64>,
195    /// Reason for failure, if applicable.
196    pub failed_reason: Option<String>,
197    /// LLM model used for review.
198    pub model_name: Option<String>,
199    /// LLM provider.
200    pub provider: Option<String>,
201}
202
203impl Patchset {
204    /// Author name, or `"(unknown)"` if not provided.
205    #[must_use]
206    pub fn author(&self) -> &str {
207        self.author.as_deref().unwrap_or("(unknown)")
208    }
209
210    /// Subject line, or `"(no subject)"` if not provided.
211    #[must_use]
212    pub fn subject(&self) -> &str {
213        self.subject.as_deref().unwrap_or("(no subject)")
214    }
215}
216
217impl<'de> Deserialize<'de> for Patchset {
218    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
219    where
220        D: Deserializer<'de>,
221    {
222        let raw = PatchsetRaw::deserialize(deserializer)?;
223        let clamp =
224            |v: Option<i64>| -> u32 { u32::try_from(v.unwrap_or(0).max(0)).unwrap_or(u32::MAX) };
225        Ok(Self {
226            id: raw.id,
227            subject: raw.subject,
228            status: raw.status,
229            thread_id: raw.thread_id,
230            author: raw.author,
231            date: raw.date,
232            message_id: raw.message_id,
233            total_parts: raw.total_parts,
234            received_parts: raw.received_parts,
235            subsystems: raw.subsystems,
236            findings: FindingCounts {
237                low: clamp(raw.findings_low),
238                medium: clamp(raw.findings_medium),
239                high: clamp(raw.findings_high),
240                critical: clamp(raw.findings_critical),
241            },
242            baseline_id: raw.baseline_id,
243            failed_reason: raw.failed_reason,
244            model_name: raw.model_name,
245            provider: raw.provider,
246        })
247    }
248}
249
250/// Full patchset detail, as returned by `GET /api/patch`.
251///
252/// Includes nested patches, reviews, thread messages, and baseline info.
253#[derive(Debug, Clone, PartialEq, Deserialize)]
254pub struct PatchsetDetail {
255    /// Database identifier.
256    #[serde(default)]
257    pub id: i64,
258    /// Subject line.
259    #[serde(default)]
260    pub subject: Option<String>,
261    /// Lifecycle status.
262    #[serde(default)]
263    pub status: PatchsetStatus,
264    /// Author email or name.
265    #[serde(default)]
266    pub author: Option<String>,
267    /// Unix timestamp.
268    #[serde(default)]
269    pub date: Option<i64>,
270    /// RFC 2822 `Message-ID`.
271    #[serde(default)]
272    pub message_id: Option<String>,
273    /// Total expected patches.
274    #[serde(default)]
275    pub total_parts: Option<u32>,
276    /// Patches received.
277    #[serde(default)]
278    pub received_parts: Option<u32>,
279    /// Subsystem tags.
280    #[serde(default)]
281    pub subsystems: Vec<String>,
282    /// Git baseline reference.
283    #[serde(default)]
284    pub baseline: Option<Baseline>,
285    /// Baseline application logs.
286    #[serde(default)]
287    pub baseline_logs: Option<String>,
288    /// Individual patches in this patchset.
289    #[serde(default)]
290    pub patches: Vec<Patch>,
291    /// AI reviews for patches in this patchset.
292    #[serde(default)]
293    pub reviews: Vec<Review>,
294    /// Email thread messages.
295    #[serde(default)]
296    pub thread: Vec<ThreadMessage>,
297    /// LLM model used.
298    #[serde(default)]
299    pub model_name: Option<String>,
300    /// LLM provider.
301    #[serde(default)]
302    pub provider: Option<String>,
303    /// Reason for failure, if applicable.
304    #[serde(default)]
305    pub failed_reason: Option<String>,
306}
307
308#[cfg(test)]
309#[allow(clippy::expect_used)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn patchset_status_default() {
315        assert_eq!(PatchsetStatus::default(), PatchsetStatus::Incomplete);
316    }
317
318    #[test]
319    fn patchset_status_display() {
320        assert_eq!(PatchsetStatus::InReview.to_string(), "In Review");
321        assert_eq!(PatchsetStatus::FailedToApply.to_string(), "Failed to Apply");
322        assert_eq!(PatchsetStatus::Unknown.to_string(), "Unknown");
323    }
324
325    #[test]
326    fn finding_counts_total() {
327        let fc = FindingCounts {
328            low: 2,
329            medium: 0,
330            high: 1,
331            critical: 0,
332        };
333        assert_eq!(fc.total(), 3);
334    }
335
336    #[test]
337    fn finding_counts_max_severity() {
338        let fc = FindingCounts {
339            low: 2,
340            medium: 0,
341            high: 1,
342            critical: 0,
343        };
344        assert_eq!(fc.max_severity(), Some(Severity::High));
345
346        let empty = FindingCounts::default();
347        assert_eq!(empty.max_severity(), None);
348    }
349
350    #[test]
351    fn finding_counts_is_empty() {
352        assert!(FindingCounts::default().is_empty());
353        assert!(
354            !FindingCounts {
355                low: 1,
356                ..Default::default()
357            }
358            .is_empty()
359        );
360    }
361
362    #[test]
363    fn patchset_accessor_defaults() {
364        let ps = Patchset {
365            id: 1,
366            subject: None,
367            status: PatchsetStatus::Pending,
368            thread_id: None,
369            author: None,
370            date: None,
371            message_id: None,
372            total_parts: None,
373            received_parts: None,
374            subsystems: vec![],
375            findings: FindingCounts::default(),
376            baseline_id: None,
377            failed_reason: None,
378            model_name: None,
379            provider: None,
380        };
381        assert_eq!(ps.author(), "(unknown)");
382        assert_eq!(ps.subject(), "(no subject)");
383    }
384
385    #[test]
386    fn patchset_accessor_with_values() {
387        let ps = Patchset {
388            id: 1,
389            subject: Some("fix null deref".to_string()),
390            status: PatchsetStatus::Reviewed,
391            thread_id: None,
392            author: Some("dev@example.com".to_string()),
393            date: None,
394            message_id: None,
395            total_parts: None,
396            received_parts: None,
397            subsystems: vec![],
398            findings: FindingCounts::default(),
399            baseline_id: None,
400            failed_reason: None,
401            model_name: None,
402            provider: None,
403        };
404        assert_eq!(ps.author(), "dev@example.com");
405        assert_eq!(ps.subject(), "fix null deref");
406    }
407
408    #[test]
409    fn deserialize_patchset_from_json() {
410        let json = r#"{
411            "id": 19555,
412            "subject": "[PATCH v2 0/4] iio: light: fix null pointer",
413            "status": "Pending",
414            "author": "dev@example.com",
415            "date": 1778690980,
416            "total_parts": 4,
417            "received_parts": 4,
418            "subsystems": ["LKML", "linux-iio"],
419            "findings_low": 0,
420            "findings_medium": 1,
421            "findings_high": 0,
422            "findings_critical": 0
423        }"#;
424        let ps: Patchset = serde_json::from_str(json).expect("deserialize patchset");
425        assert_eq!(ps.status, PatchsetStatus::Pending);
426        assert_eq!(ps.findings.medium, 1);
427        assert_eq!(ps.findings.total(), 1);
428        assert_eq!(ps.subsystems, vec!["LKML", "linux-iio"]);
429    }
430
431    #[test]
432    fn deserialize_unknown_status() {
433        let json = r#"{"id": 1, "status": "SomeNewStatus"}"#;
434        let ps: Patchset = serde_json::from_str(json).expect("deserialize unknown status");
435        assert_eq!(ps.status, PatchsetStatus::Unknown);
436    }
437
438    #[test]
439    fn deserialize_null_findings() {
440        let json = r#"{
441            "id": 1,
442            "findings_low": 2,
443            "findings_medium": null,
444            "findings_high": 1,
445            "findings_critical": 0
446        }"#;
447        let ps: Patchset = serde_json::from_str(json).expect("deserialize null findings");
448        assert_eq!(ps.findings.low, 2);
449        assert_eq!(ps.findings.medium, 0);
450        assert_eq!(ps.findings.high, 1);
451        assert_eq!(ps.findings.critical, 0);
452        assert_eq!(ps.findings.total(), 3);
453        assert_eq!(ps.findings.max_severity(), Some(Severity::High));
454    }
455
456    #[test]
457    fn deserialize_patchset_detail() {
458        let json = r#"{
459            "id": 123,
460            "subject": "test",
461            "status": "Reviewed",
462            "patches": [{"id": 1, "part_index": 1}],
463            "reviews": [{"id": 1, "patch_id": 1, "status": "Reviewed"}],
464            "thread": [{"id": 1, "message_id": "msg@example.com"}],
465            "baseline": {"branch": "main", "commit": "abc123"}
466        }"#;
467        let detail: PatchsetDetail =
468            serde_json::from_str(json).expect("deserialize patchset detail");
469        assert_eq!(detail.patches.len(), 1);
470        assert_eq!(detail.reviews.len(), 1);
471        assert_eq!(detail.thread.len(), 1);
472        assert!(detail.baseline.is_some());
473    }
474}