Skip to main content

hs_relmon/
list_issues.rs

1// SPDX-License-Identifier: MPL-2.0
2
3use crate::check_latest;
4use crate::gitlab;
5use serde::Serialize;
6use std::collections::HashSet;
7
8/// A single issue entry for the list-issues output.
9#[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
22/// Build an `IssueEntry` from a GitLab issue.
23///
24/// Returns `None` if the package name cannot be extracted
25/// from the issue URL.
26pub 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
56/// Filter and sort issue entries.
57pub 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
77/// Build a list of issue entries from GitLab group issues.
78///
79/// Resolves work-item status via GraphQL for each issue.
80/// Applies status/assignee filters. When `manifest_names`
81/// is provided, sets `in_manifest` on each entry.
82pub 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
122/// Print issue entries as a JSON array.
123pub 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
132/// Write issue entries as a JSON array to a writer.
133pub 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
142/// Print issue entries as a table.
143pub fn print_table(entries: &[IssueEntry]) {
144    print!("{}", format_table(entries));
145}
146
147/// Format issue entries as a table string.
148pub 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    // Compute column widths.
158    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            // Use first assignee for width calculation
171            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    // Header
179    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    // Separator
189    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    // Rows
205    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        // in_manifest should be absent when None
278        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        // No Manifest column
347        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        // build_entries sorts, but format_table takes
439        // whatever order it receives. Just verify output.
440        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}