9 Dashboard report generator for FOSSology CI scanners.
11 Generates a GitHub Actions job summary (Markdown) directly from scanner
17 from collections
import Counter
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)
34 Build a Markdown compliance dashboard from scanner result objects
35 already held by the ``Scanners`` instance.
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
42 3. ``write_report(path)`` writes the Markdown to *path* **and**,
43 when running in GitHub Actions, appends it to
44 ``$GITHUB_STEP_SUMMARY``.
47 def __init__(self, cli_options: CliOptions, scanner: Scanners):
48 super().
__init__(cli_options, scanner)
49 self._markdown: str =
''
50 self.
_stats_stats: dict = {}
53 self.
include_chartsinclude_charts = _parse_bool_env(
'DASHBOARD_CHARTS',
True)
54 self.
include_unknowninclude_unknown = _parse_bool_env(
'DASHBOARD_UNKNOWN',
True)
61 """Collect scanner results and assemble the Markdown dashboard."""
63 failed_results = self.
scannerscanner.results_are_allow_listed(whole=
False)
66 license_results, failed_results
68 logging.info(
"Dashboard document finalized.")
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.")
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}")
82 summary_path = os.getenv(
'GITHUB_STEP_SUMMARY')
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}")
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', []))
105 """Return (total_components, dependency_count)."""
106 dep_count = len(self.
scannerscanner.get_scan_packages().dependencies)
107 return 1 + dep_count, dep_count
115 license_results: list,
116 failed_results: list,
119 Compute all statistics from raw scanner results.
121 :return: A dict with keys: sorted_licenses, failed_by_license,
122 unknown_files, unique_licenses, total_files,
123 component_count, dep_count.
125 license_counter: Counter = Counter()
126 files_with_license: set[str] = set()
127 all_files: set[str] = set()
129 for res
in license_results:
130 all_files.add(res.file)
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))
143 license_counter[lic] += 1
144 files_with_license.add(res.file)
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)
152 component_count, dep_count = self.
_get_counts_get_counts()
154 'sorted_licenses': sorted(
155 license_counter.items(), key=
lambda x: x[1], reverse=
True
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,
167 Assemble the full Markdown string from pre-computed *data*.
169 :param data: Dict returned by :meth:`_finalise_data`.
170 :return: Markdown string.
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']
180 md =
"# License Compliance Dashboard\n\n"
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"
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'"
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"
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"
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
229 file_list =
', '.join(f
'`{f}`' for f
in files[:5])
231 file_list += f
' *+{len(files) - 5} more*'
232 md += f
"\n| `{lic}` | {len(files)} | {file_list} |"
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"
249 md += (
"\n---\n\n*Generated by "
250 "[FOSSology Action](https://github.com/fossology/fossology) "
251 "\u2014 direct scanner mode (nomos + ojo)*\n")
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)
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']),
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')
None finalize_document(self)
tuple[str, dict] _build_markdown(self, list license_results, list failed_results)
def __init__(self, CliOptions cli_options, Scanners scanner)
tuple[int, int] _get_counts(self)
str _markdown_builder(self, dict data)
list[ScanResult|ScanResultList] _collect_license_results(self)
dict _finalise_data(self, list license_results, list failed_results)
None write_report(self, str file_name)