1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
16pub enum PatchsetStatus {
17 #[default]
19 Incomplete,
20 Pending,
22 InReview,
24 Reviewed,
26 Failed,
28 FailedToApply,
30 Cancelled,
32 Skipped,
34 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#[derive(Debug, Clone, PartialEq, Default)]
82pub struct FindingCounts {
83 pub low: u32,
85 pub medium: u32,
87 pub high: u32,
89 pub critical: u32,
91}
92
93impl FindingCounts {
94 #[must_use]
96 pub fn total(&self) -> u32 {
97 self.low + self.medium + self.high + self.critical
98 }
99
100 #[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 #[must_use]
118 pub fn is_empty(&self) -> bool {
119 self.total() == 0
120 }
121}
122
123#[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#[derive(Debug, Clone, PartialEq)]
170pub struct Patchset {
171 pub id: i64,
173 pub subject: Option<String>,
175 pub status: PatchsetStatus,
177 pub thread_id: Option<i64>,
179 pub author: Option<String>,
181 pub date: Option<i64>,
183 pub message_id: Option<String>,
185 pub total_parts: Option<u32>,
187 pub received_parts: Option<u32>,
189 pub subsystems: Vec<String>,
191 pub findings: FindingCounts,
193 pub baseline_id: Option<i64>,
195 pub failed_reason: Option<String>,
197 pub model_name: Option<String>,
199 pub provider: Option<String>,
201}
202
203impl Patchset {
204 #[must_use]
206 pub fn author(&self) -> &str {
207 self.author.as_deref().unwrap_or("(unknown)")
208 }
209
210 #[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#[derive(Debug, Clone, PartialEq, Deserialize)]
254pub struct PatchsetDetail {
255 #[serde(default)]
257 pub id: i64,
258 #[serde(default)]
260 pub subject: Option<String>,
261 #[serde(default)]
263 pub status: PatchsetStatus,
264 #[serde(default)]
266 pub author: Option<String>,
267 #[serde(default)]
269 pub date: Option<i64>,
270 #[serde(default)]
272 pub message_id: Option<String>,
273 #[serde(default)]
275 pub total_parts: Option<u32>,
276 #[serde(default)]
278 pub received_parts: Option<u32>,
279 #[serde(default)]
281 pub subsystems: Vec<String>,
282 #[serde(default)]
284 pub baseline: Option<Baseline>,
285 #[serde(default)]
287 pub baseline_logs: Option<String>,
288 #[serde(default)]
290 pub patches: Vec<Patch>,
291 #[serde(default)]
293 pub reviews: Vec<Review>,
294 #[serde(default)]
296 pub thread: Vec<ThreadMessage>,
297 #[serde(default)]
299 pub model_name: Option<String>,
300 #[serde(default)]
302 pub provider: Option<String>,
303 #[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}