1use crate::check_latest;
4use crate::gitlab;
5use serde::Serialize;
6use std::collections::HashSet;
7
8#[derive(Debug, Serialize)]
10pub struct IssueEntry {
11 pub package: String,
12 pub iid: u64,
13 pub title: String,
14 pub url: String,
15 pub status: String,
16 #[serde(skip_serializing_if = "Vec::is_empty")]
17 pub assignees: Vec<String>,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub in_manifest: Option<bool>,
20}
21
22pub fn entry_from_issue(
27 issue: &gitlab::Issue,
28 status: Option<String>,
29 manifest_names: Option<&HashSet<String>>,
30) -> Option<IssueEntry> {
31 let package =
32 gitlab::package_from_issue_url(&issue.web_url)?
33 .to_string();
34
35 let status =
36 status.unwrap_or_else(|| issue.state.clone());
37 let assignees: Vec<String> = issue
38 .assignees
39 .iter()
40 .map(|a| a.username.clone())
41 .collect();
42 let in_manifest =
43 manifest_names.map(|names| names.contains(&package));
44
45 Some(IssueEntry {
46 package,
47 iid: issue.iid,
48 title: issue.title.clone(),
49 url: issue.web_url.clone(),
50 status,
51 assignees,
52 in_manifest,
53 })
54}
55
56pub fn filter_and_sort(
58 entries: Vec<IssueEntry>,
59 filter_status: Option<&str>,
60 filter_assignee: Option<&str>,
61) -> Vec<IssueEntry> {
62 let mut filtered: Vec<_> = entries
63 .into_iter()
64 .filter(|e| {
65 check_latest::matches_filter(
66 &e.status,
67 &e.assignees,
68 filter_status,
69 filter_assignee,
70 )
71 })
72 .collect();
73 filtered.sort_by(|a, b| a.package.cmp(&b.package));
74 filtered
75}
76
77pub fn build_entries(
83 client: &gitlab::GroupClient,
84 issues: &[gitlab::Issue],
85 filter_status: Option<&str>,
86 filter_assignee: Option<&str>,
87 manifest_names: Option<&HashSet<String>>,
88) -> Vec<IssueEntry> {
89 let mut entries = Vec::new();
90 for issue in issues {
91 let project_path =
92 gitlab::project_path_from_issue_url(
93 &issue.web_url,
94 );
95 let status = project_path.as_deref().and_then(
96 |path| {
97 client
98 .get_work_item_status(path, issue.iid)
99 .ok()
100 .flatten()
101 },
102 );
103
104 match entry_from_issue(
105 issue,
106 status,
107 manifest_names,
108 ) {
109 Some(entry) => entries.push(entry),
110 None => {
111 eprintln!(
112 "warning: cannot extract package name \
113 from {}",
114 issue.web_url
115 );
116 }
117 }
118 }
119 filter_and_sort(entries, filter_status, filter_assignee)
120}
121
122pub fn print_json(
124 entries: &[IssueEntry],
125) -> Result<(), Box<dyn std::error::Error>> {
126 let mut buf = Vec::new();
127 write_json(&mut buf, entries)?;
128 print!("{}", String::from_utf8(buf)?);
129 Ok(())
130}
131
132pub fn write_json(
134 w: &mut dyn std::io::Write,
135 entries: &[IssueEntry],
136) -> Result<(), Box<dyn std::error::Error>> {
137 let json = serde_json::to_string_pretty(entries)?;
138 writeln!(w, "{json}")?;
139 Ok(())
140}
141
142pub fn print_table(entries: &[IssueEntry]) {
144 print!("{}", format_table(entries));
145}
146
147pub fn format_table(entries: &[IssueEntry]) -> String {
149 if entries.is_empty() {
150 return String::from("No matching issues found.\n");
151 }
152
153 let show_manifest = entries
154 .iter()
155 .any(|e| e.in_manifest.is_some());
156
157 let mut w_pkg = "Package".len();
159 let mut w_issue = "Issue".len();
160 let mut w_status = "Status".len();
161 let mut w_assignee = "Assignee".len();
162
163 for e in entries {
164 w_pkg = w_pkg.max(e.package.len());
165 w_issue = w_issue.max(format!("#{}", e.iid).len());
166 w_status = w_status.max(e.status.len());
167 let assignee_str = if e.assignees.is_empty() {
168 "(none)"
169 } else {
170 e.assignees.first().map(|s| s.as_str()).unwrap_or("")
172 };
173 w_assignee = w_assignee.max(assignee_str.len());
174 }
175
176 let mut out = String::new();
177
178 out.push_str(&format!(
180 " {:<w_pkg$} {:<w_issue$} {:<w_status$} {:<w_assignee$}",
181 "Package", "Issue", "Status", "Assignee",
182 ));
183 if show_manifest {
184 out.push_str(" Manifest");
185 }
186 out.push_str(" Title\n");
187
188 out.push_str(&format!(
190 " {:\u{2500}<w_pkg$} {:\u{2500}<w_issue$} {:\u{2500}<w_status$} {:\u{2500}<w_assignee$}",
191 "", "", "", "",
192 ));
193 if show_manifest {
194 out.push_str(&format!(
195 " {:\u{2500}<8}",
196 "",
197 ));
198 }
199 out.push_str(&format!(
200 " {:\u{2500}<30}\n",
201 "",
202 ));
203
204 for e in entries {
206 let issue_str = format!("#{}", e.iid);
207 let assignee_str = if e.assignees.is_empty() {
208 "(none)".to_string()
209 } else {
210 e.assignees.join(",")
211 };
212
213 out.push_str(&format!(
214 " {:<w_pkg$} {:<w_issue$} {:<w_status$} {:<w_assignee$}",
215 e.package, issue_str, e.status, assignee_str,
216 ));
217 if show_manifest {
218 let manifest_str = match e.in_manifest {
219 Some(true) => "yes",
220 Some(false) => "MISSING",
221 None => "",
222 };
223 out.push_str(&format!(" {:<8}", manifest_str));
224 }
225 out.push_str(&format!(" {}\n", e.title));
226 }
227
228 out
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 fn make_entry(
236 package: &str,
237 iid: u64,
238 status: &str,
239 assignees: Vec<&str>,
240 in_manifest: Option<bool>,
241 ) -> IssueEntry {
242 IssueEntry {
243 package: package.into(),
244 iid,
245 title: format!("{package}-1.0 is available"),
246 url: format!(
247 "https://gitlab.com/CentOS/Hyperscale/rpms/\
248 {package}/-/issues/{iid}"
249 ),
250 status: status.into(),
251 assignees: assignees
252 .into_iter()
253 .map(String::from)
254 .collect(),
255 in_manifest,
256 }
257 }
258
259 #[test]
260 fn test_json_serialization() {
261 let entries = vec![make_entry(
262 "ethtool",
263 1,
264 "To do",
265 vec!["alice"],
266 None,
267 )];
268 let mut buf = Vec::new();
269 write_json(&mut buf, &entries).unwrap();
270 let json: serde_json::Value =
271 serde_json::from_slice(&buf).unwrap();
272 let arr = json.as_array().unwrap();
273 assert_eq!(arr.len(), 1);
274 assert_eq!(arr[0]["package"], "ethtool");
275 assert_eq!(arr[0]["status"], "To do");
276 assert_eq!(arr[0]["assignees"][0], "alice");
277 assert!(arr[0].get("in_manifest").is_none());
279 }
280
281 #[test]
282 fn test_json_with_manifest() {
283 let entries = vec![
284 make_entry(
285 "ethtool",
286 1,
287 "To do",
288 vec![],
289 Some(true),
290 ),
291 make_entry(
292 "foobar",
293 2,
294 "To do",
295 vec![],
296 Some(false),
297 ),
298 ];
299 let mut buf = Vec::new();
300 write_json(&mut buf, &entries).unwrap();
301 let json: serde_json::Value =
302 serde_json::from_slice(&buf).unwrap();
303 let arr = json.as_array().unwrap();
304 assert_eq!(arr[0]["in_manifest"], true);
305 assert_eq!(arr[1]["in_manifest"], false);
306 }
307
308 #[test]
309 fn test_json_no_assignees_omitted() {
310 let entries = vec![make_entry(
311 "ethtool",
312 1,
313 "To do",
314 vec![],
315 None,
316 )];
317 let mut buf = Vec::new();
318 write_json(&mut buf, &entries).unwrap();
319 let json: serde_json::Value =
320 serde_json::from_slice(&buf).unwrap();
321 assert!(json[0].get("assignees").is_none());
322 }
323
324 #[test]
325 fn test_format_table_empty() {
326 let out = format_table(&[]);
327 assert_eq!(out, "No matching issues found.\n");
328 }
329
330 #[test]
331 fn test_format_table_basic() {
332 let entries = vec![make_entry(
333 "ethtool",
334 3,
335 "To do",
336 vec!["alice"],
337 None,
338 )];
339 let out = format_table(&entries);
340 assert!(out.contains("Package"));
341 assert!(out.contains("ethtool"));
342 assert!(out.contains("#3"));
343 assert!(out.contains("To do"));
344 assert!(out.contains("alice"));
345 assert!(out.contains("ethtool-1.0 is available"));
346 assert!(!out.contains("Manifest"));
348 }
349
350 #[test]
351 fn test_format_table_with_manifest() {
352 let entries = vec![
353 make_entry(
354 "ethtool",
355 1,
356 "To do",
357 vec!["alice"],
358 Some(true),
359 ),
360 make_entry(
361 "foobar",
362 2,
363 "To do",
364 vec![],
365 Some(false),
366 ),
367 ];
368 let out = format_table(&entries);
369 assert!(out.contains("Manifest"));
370 assert!(out.contains("yes"));
371 assert!(out.contains("MISSING"));
372 assert!(out.contains("(none)"));
373 }
374
375 #[test]
376 fn test_format_table_unassigned() {
377 let entries = vec![make_entry(
378 "pkg",
379 1,
380 "To do",
381 vec![],
382 None,
383 )];
384 let out = format_table(&entries);
385 assert!(out.contains("(none)"));
386 }
387
388 #[test]
389 fn test_format_table_multiple_assignees() {
390 let entries = vec![make_entry(
391 "pkg",
392 1,
393 "To do",
394 vec!["alice", "bob"],
395 None,
396 )];
397 let out = format_table(&entries);
398 assert!(out.contains("alice,bob"));
399 }
400
401 #[test]
402 fn test_format_table_manifest_column_alignment() {
403 let entries = vec![
404 make_entry(
405 "ethtool",
406 1,
407 "To do",
408 vec![],
409 Some(true),
410 ),
411 make_entry(
412 "systemd",
413 2,
414 "In progress",
415 vec!["bob"],
416 Some(true),
417 ),
418 make_entry(
419 "foobar",
420 3,
421 "To do",
422 vec![],
423 Some(false),
424 ),
425 ];
426 let out = format_table(&entries);
427 assert!(out.contains("Manifest"));
428 assert!(out.contains("yes"));
429 assert!(out.contains("MISSING"));
430 }
431
432 #[test]
433 fn test_format_table_sorts_by_package() {
434 let entries = vec![
435 make_entry("zzz", 2, "Done", vec![], None),
436 make_entry("aaa", 1, "To do", vec![], None),
437 ];
438 let out = format_table(&entries);
441 let zzz_pos = out.find("zzz").unwrap();
442 let aaa_pos = out.find("aaa").unwrap();
443 assert!(zzz_pos < aaa_pos);
444 }
445
446 fn make_gitlab_issue(
447 iid: u64,
448 package: &str,
449 state: &str,
450 assignees: Vec<&str>,
451 ) -> gitlab::Issue {
452 gitlab::Issue {
453 iid,
454 title: format!("{package}-1.0 is available"),
455 description: None,
456 state: state.into(),
457 web_url: format!(
458 "https://gitlab.com/CentOS/Hyperscale/\
459 rpms/{package}/-/issues/{iid}"
460 ),
461 assignees: assignees
462 .into_iter()
463 .map(|u| gitlab::Assignee {
464 username: u.into(),
465 })
466 .collect(),
467 }
468 }
469
470 #[test]
471 fn test_entry_from_issue_basic() {
472 let issue = make_gitlab_issue(
473 1,
474 "ethtool",
475 "opened",
476 vec!["alice"],
477 );
478 let entry = entry_from_issue(
479 &issue,
480 Some("To do".into()),
481 None,
482 )
483 .unwrap();
484 assert_eq!(entry.package, "ethtool");
485 assert_eq!(entry.iid, 1);
486 assert_eq!(entry.status, "To do");
487 assert_eq!(entry.assignees, vec!["alice"]);
488 assert!(entry.in_manifest.is_none());
489 }
490
491 #[test]
492 fn test_entry_from_issue_falls_back_to_state() {
493 let issue = make_gitlab_issue(
494 1,
495 "ethtool",
496 "opened",
497 vec![],
498 );
499 let entry =
500 entry_from_issue(&issue, None, None).unwrap();
501 assert_eq!(entry.status, "opened");
502 }
503
504 #[test]
505 fn test_entry_from_issue_with_manifest() {
506 let issue = make_gitlab_issue(
507 1,
508 "ethtool",
509 "opened",
510 vec![],
511 );
512 let mut names = HashSet::new();
513 names.insert("ethtool".to_string());
514 let entry = entry_from_issue(
515 &issue,
516 None,
517 Some(&names),
518 )
519 .unwrap();
520 assert_eq!(entry.in_manifest, Some(true));
521
522 let issue2 = make_gitlab_issue(
523 2,
524 "foobar",
525 "opened",
526 vec![],
527 );
528 let entry2 = entry_from_issue(
529 &issue2,
530 None,
531 Some(&names),
532 )
533 .unwrap();
534 assert_eq!(entry2.in_manifest, Some(false));
535 }
536
537 #[test]
538 fn test_entry_from_issue_bad_url() {
539 let issue = gitlab::Issue {
540 iid: 1,
541 title: "t".into(),
542 description: None,
543 state: "opened".into(),
544 web_url: "".into(),
545 assignees: vec![],
546 };
547 assert!(entry_from_issue(&issue, None, None).is_none());
548 }
549
550 #[test]
551 fn test_filter_and_sort_by_status() {
552 let entries = vec![
553 make_entry(
554 "b-pkg",
555 2,
556 "Done",
557 vec![],
558 None,
559 ),
560 make_entry(
561 "a-pkg",
562 1,
563 "To do",
564 vec!["alice"],
565 None,
566 ),
567 ];
568 let filtered = filter_and_sort(
569 entries,
570 Some("To do"),
571 None,
572 );
573 assert_eq!(filtered.len(), 1);
574 assert_eq!(filtered[0].package, "a-pkg");
575 }
576
577 #[test]
578 fn test_filter_and_sort_by_assignee() {
579 let entries = vec![
580 make_entry(
581 "pkg-a",
582 1,
583 "To do",
584 vec!["alice"],
585 None,
586 ),
587 make_entry(
588 "pkg-b",
589 2,
590 "To do",
591 vec![],
592 None,
593 ),
594 ];
595 let filtered = filter_and_sort(
596 entries,
597 None,
598 Some("none"),
599 );
600 assert_eq!(filtered.len(), 1);
601 assert_eq!(filtered[0].package, "pkg-b");
602 }
603
604 #[test]
605 fn test_filter_and_sort_sorts() {
606 let entries = vec![
607 make_entry("z", 2, "To do", vec![], None),
608 make_entry("a", 1, "To do", vec![], None),
609 ];
610 let sorted = filter_and_sort(entries, None, None);
611 assert_eq!(sorted[0].package, "a");
612 assert_eq!(sorted[1].package, "z");
613 }
614
615 #[test]
616 fn test_build_entries_sorting() {
617 let entries = vec![
618 make_entry("b-pkg", 2, "Done", vec![], None),
619 make_entry(
620 "a-pkg",
621 1,
622 "To do",
623 vec!["alice"],
624 None,
625 ),
626 ];
627 let mut sorted = entries;
628 sorted.sort_by(|a, b| a.package.cmp(&b.package));
629 assert_eq!(sorted[0].package, "a-pkg");
630 assert_eq!(sorted[1].package, "b-pkg");
631 }
632}