FOSSology  4.7.1
Open Source License Compliance by Open Source Software
DashboardReport.py
1 #!/usr/bin/env python3
2 
3 # SPDX-FileCopyrightText: © 2026 Siemens AG
4 # SPDX-FileContributor: Shatakshi Tiwari <shatakshi.tiwari@siemens.com>
5 #
6 # SPDX-License-Identifier: GPL-2.0-only
7 
8 """
9 Dashboard report generator for FOSSology CI scanners.
10 
11 Generates a GitHub Actions job summary (Markdown) directly from scanner
12 results (nomos, ojo)
13 """
14 
15 import logging
16 import os
17 from collections import Counter
18 
19 from .CliOptions import CliOptions
20 from .ReportBase import ReportBase
21 from .Scanners import Scanners, ScanResult, ScanResultList, _NO_LICENSE
22 def _license_display_name(license_id: str) -> str:
23  """Return human-readable license name for dashboard output."""
24  return (license_id.removeprefix('LicenseRef-')
25  if license_id.startswith('LicenseRef-') else license_id)
26 
27 
28 # ---------------------------------------------------------------------------
29 # DashboardReport
30 # ---------------------------------------------------------------------------
31 
33  """
34  Build a Markdown compliance dashboard from scanner result objects
35  already held by the ``Scanners`` instance.
36 
37  Lifecycle (same as SpdxReport / Spdx3Report):
38  1. ``fossologyscanner.perform_scans()`` populates
39  ``scanner.scan_packages`` with NOMOS/OJO results.
40  2. ``finalize_document()`` reads those results and builds the
41  Markdown string.
42  3. ``write_report(path)`` writes the Markdown to *path* **and**,
43  when running in GitHub Actions, appends it to
44  ``$GITHUB_STEP_SUMMARY``.
45  """
46 
47  def __init__(self, cli_options: CliOptions, scanner: Scanners):
48  super().__init__(cli_options, scanner)
49  self._markdown: str = ''
50  self._stats_stats: dict = {}
51 
52  # Dashboard feature flags (env-driven, all default to True)
53  self.include_chartsinclude_charts = _parse_bool_env('DASHBOARD_CHARTS', True)
54  self.include_unknowninclude_unknown = _parse_bool_env('DASHBOARD_UNKNOWN', True)
55 
56  # -----------------------------------------------------------------
57  # ReportBase interface
58  # -----------------------------------------------------------------
59 
60  def finalize_document(self) -> None:
61  """Collect scanner results and assemble the Markdown dashboard."""
62  license_results = self._collect_license_results_collect_license_results()
63  failed_results = self.scannerscanner.results_are_allow_listed(whole=False)
64 
65  self._markdown, self._stats_stats = self._build_markdown_build_markdown(
66  license_results, failed_results
67  )
68  logging.info("Dashboard document finalized.")
69 
70  def write_report(self, file_name: str) -> None:
71  """Write the dashboard Markdown to *file_name* and GITHUB_STEP_SUMMARY."""
72  if not self._markdown:
73  logging.warning("Dashboard is empty — nothing to write.")
74  return
75 
76  # Write to file
77  with open(file_name, 'w', encoding='utf-8') as fh:
78  fh.write(self._markdown)
79  logging.info(f"Dashboard written to {file_name}")
80 
81  # Append to GitHub step summary when running in Actions
82  summary_path = os.getenv('GITHUB_STEP_SUMMARY')
83  if summary_path:
84  try:
85  with open(summary_path, 'a', encoding='utf-8') as fh:
86  fh.write(self._markdown)
87  logging.info("Dashboard appended to GITHUB_STEP_SUMMARY.")
88  except OSError as exc:
89  logging.warning(f"Could not write to GITHUB_STEP_SUMMARY: {exc}")
90 
91  # -----------------------------------------------------------------
92  # Result collection helpers
93  # -----------------------------------------------------------------
94 
95  def _collect_license_results(self) -> list[ScanResult | ScanResultList]:
96  """Return merged nomos+ojo results from all packages."""
97  results: list[ScanResult | ScanResultList] = []
98  parent = self.scannerscanner.get_scan_packages().parent_package
99  results.extend(parent.get('SCANNER_RESULTS', []))
100  for dep in self.scannerscanner.get_scan_packages().dependencies.values():
101  results.extend(dep.get('SCANNER_RESULTS', []))
102  return results
103 
104  def _get_counts(self) -> tuple[int, int]:
105  """Return (total_components, dependency_count)."""
106  dep_count = len(self.scannerscanner.get_scan_packages().dependencies)
107  return 1 + dep_count, dep_count
108 
109  # -----------------------------------------------------------------
110  # Markdown builder
111  # -----------------------------------------------------------------
112 
114  self,
115  license_results: list,
116  failed_results: list,
117  ) -> dict:
118  """
119  Compute all statistics from raw scanner results.
120 
121  :return: A dict with keys: sorted_licenses, failed_by_license,
122  unknown_files, unique_licenses, total_files,
123  component_count, dep_count.
124  """
125  license_counter: Counter = Counter()
126  files_with_license: set[str] = set()
127  all_files: set[str] = set()
128 
129  for res in license_results:
130  all_files.add(res.file)
131  licenses = set()
132  if isinstance(res, ScanResultList):
133  for item in res.result:
134  lic_raw = item.get('license', '') if isinstance(item, dict) else str(item)
135  if lic_raw and lic_raw not in _NO_LICENSE:
136  licenses.add(_license_display_name(lic_raw))
137  elif isinstance(res, ScanResult):
138  for lic_raw in res.result:
139  if lic_raw and lic_raw not in _NO_LICENSE:
140  licenses.add(_license_display_name(lic_raw))
141 
142  for lic in licenses:
143  license_counter[lic] += 1
144  files_with_license.add(res.file)
145 
146  failed_by_license: dict[str, list[str]] = {}
147  for res in failed_results:
148  for lic_raw in res.result:
149  lic = _license_display_name(lic_raw)
150  failed_by_license.setdefault(lic, []).append(res.file)
151 
152  component_count, dep_count = self._get_counts_get_counts()
153  return {
154  'sorted_licenses': sorted(
155  license_counter.items(), key=lambda x: x[1], reverse=True
156  ),
157  'failed_by_license': failed_by_license,
158  'unknown_files': sorted(all_files - files_with_license),
159  'unique_licenses': len(license_counter),
160  'total_files': len(all_files),
161  'component_count': component_count,
162  'dep_count': dep_count,
163  }
164 
165  def _markdown_builder(self, data: dict) -> str:
166  """
167  Assemble the full Markdown string from pre-computed *data*.
168 
169  :param data: Dict returned by :meth:`_finalise_data`.
170  :return: Markdown string.
171  """
172  sorted_licenses = data['sorted_licenses']
173  failed_by_license = data['failed_by_license']
174  unknown_files = data['unknown_files']
175  component_count = data['component_count']
176  dep_count = data['dep_count']
177  total_files = data['total_files']
178  unique_licenses = data['unique_licenses']
179 
180  md = "# License Compliance Dashboard\n\n"
181 
182  # KPI table
183  md += "## Summary\n\n"
184  md += "| Metric | Value |\n|--------|-------|\n"
185  md += f"| Components Scanned | {component_count} (1 parent + {dep_count} dependencies) |\n"
186  md += f"| Total Files Scanned | {total_files} |\n"
187  md += f"| Unique Licenses Found | {unique_licenses} |\n"
188  md += f"| Licenses with Violations | {len(failed_by_license)} |\n"
189  md += f"| Files Without License | {len(unknown_files)} |\n"
190  md += "\n---\n\n"
191 
192  # Charts
193  if self.include_chartsinclude_charts and sorted_licenses:
194  top_10 = sorted_licenses[:10]
195  md += "## License Distribution\n\n"
196  md += ("```mermaid\n"
197  "%%{init: {'theme': 'base', 'themeVariables': {"
198  "'pie1': '#E63946', 'pie2': '#457B9D', "
199  "'pie3': '#2A9D8F', 'pie4': '#E9C46A', "
200  "'pie5': '#F4A261', 'pie6': '#264653', "
201  "'pie7': '#6A0572', 'pie8': '#1B998B', "
202  "'pie9': '#FF6B6B', 'pie10': '#4ECDC4', "
203  "'pieTitleTextSize': '18px', "
204  "'pieSectionTextSize': '14px'"
205  "}}}%%\n"
206  "pie showData title License Distribution\n")
207  for lic, count in top_10:
208  md += f' "{lic}" : {count}\n'
209  md += "```\n\n---\n\n"
210 
211  # License inventory
212  md += "## License Inventory\n\n"
213  md += "| # | License | Files |\n|---|---------|-------|\n"
214  for i, (lic, count) in enumerate(sorted_licenses[:30], 1):
215  md += f"| {i} | `{lic}` | {count} |\n"
216  if len(sorted_licenses) > 30:
217  md += f"\n*Showing top 30 of {len(sorted_licenses)} unique licenses*\n"
218  md += "\n---\n\n"
219 
220  # License violations (not in allowlist)
221  if failed_by_license:
222  md += "## License Violations\n\n"
223  md += "The following licenses are not in the allowlist.\n\n"
224  md += "| License | Violation Count | Files |\n"
225  md += "|---------|-----------------|-------|"
226  for lic, files in sorted(
227  failed_by_license.items(), key=lambda x: len(x[1]), reverse=True
228  ):
229  file_list = ', '.join(f'`{f}`' for f in files[:5])
230  if len(files) > 5:
231  file_list += f' *+{len(files) - 5} more*'
232  md += f"\n| `{lic}` | {len(files)} | {file_list} |"
233  md += "\n\n---\n\n"
234 
235  # Unknown-license files
236  if self.include_unknowninclude_unknown and unknown_files:
237  md += "## Files Without License\n\n"
238  md += (f"Found **{len(unknown_files)}** files with no license "
239  "detected by any scanner.\n\n")
240  md += "<details>\n<summary>Click to expand file list</summary>\n\n"
241  md += "| File |\n|------|\n"
242  for fp in unknown_files[:100]:
243  md += f"| `{fp}` |\n"
244  if len(unknown_files) > 100:
245  md += f"\n*...and {len(unknown_files) - 100} more files*\n"
246  md += "\n</details>\n\n---\n\n"
247 
248  # Footer
249  md += ("\n---\n\n*Generated by "
250  "[FOSSology Action](https://github.com/fossology/fossology) "
251  "\u2014 direct scanner mode (nomos + ojo)*\n")
252  return md
253 
255  self,
256  license_results: list,
257  failed_results: list,
258  ) -> tuple[str, dict]:
259  """Finalise data, assemble the Markdown dashboard, and return both."""
260  data = self._finalise_data_finalise_data(license_results, failed_results)
261  md = self._markdown_builder_markdown_builder(data)
262  stats = {
263  'components_scanned': data['component_count'],
264  'dependencies': data['dep_count'],
265  'files_scanned': data['total_files'],
266  'unique_licenses': data['unique_licenses'],
267  'license_violations': len(data['failed_by_license']),
268  'files_without_license': len(data['unknown_files']),
269  }
270  return md, stats
271 
272 
273 # ---------------------------------------------------------------------------
274 # Helpers
275 # ---------------------------------------------------------------------------
276 
277 def _parse_bool_env(name: str, default: bool = True) -> bool:
278  value = os.getenv(name, str(default)).lower()
279  return value in ('true', '1', 'yes', 'on')
tuple[str, dict] _build_markdown(self, list license_results, list failed_results)
def __init__(self, CliOptions cli_options, Scanners scanner)
list[ScanResult|ScanResultList] _collect_license_results(self)
dict _finalise_data(self, list license_results, list failed_results)
None write_report(self, str file_name)