diff --git a/continuous_delivery_scripts/spdx_report/spdx_summary.py b/continuous_delivery_scripts/spdx_report/spdx_summary.py index 50c7c51..29a8cc2 100644 --- a/continuous_delivery_scripts/spdx_report/spdx_summary.py +++ b/continuous_delivery_scripts/spdx_report/spdx_summary.py @@ -15,6 +15,8 @@ get_package_manual_check, get_package_manual_licence, ) +from continuous_delivery_scripts.utils.git_helpers import get_project_remote_url +from continuous_delivery_scripts.utils.github_helpers import github_blob_url from continuous_delivery_scripts.spdx_report.spdx_package import SpdxPackage from continuous_delivery_scripts.spdx_report.licence_assessment import ( LicenceAssessment, @@ -104,6 +106,20 @@ def generate_file_based_on_template( output_filename.write_text(rendered, encoding="utf8") +def _project_licence_href() -> Optional[str]: + project_root = Path(str(configuration.get_value(ConfigurationVariable.PROJECT_ROOT))).resolve() + remote = get_project_remote_url(project_root) + if not remote: + return None + licence_path = Path(str(configuration.get_value(ConfigurationVariable.LICENCE_FILE_PATH))) + try: + relative_path = licence_path.relative_to(project_root) if licence_path.is_absolute() else licence_path + except ValueError: + return None + branch = str(configuration.get_value(ConfigurationVariable.MASTER_BRANCH)) + return github_blob_url(remote, branch, str(relative_path).replace("\\", "/")) + + class SummaryGenerator: """Licensing summary generator.""" @@ -154,6 +170,7 @@ def _generate_template_arguments(self) -> Dict[str, Any]: arguments["project"] = { "name": self.project.name, "licence": self.project.main_licence, + "licence_href": _project_licence_href(), "accepted_licences": _configured_licence_policy(), "compliance": global_compliance, "compliance_points": compliance_points, diff --git a/continuous_delivery_scripts/spdx_report/templates/third_party_IP_report.html.jinja2 b/continuous_delivery_scripts/spdx_report/templates/third_party_IP_report.html.jinja2 index 77428ca..49ff833 100644 --- a/continuous_delivery_scripts/spdx_report/templates/third_party_IP_report.html.jinja2 +++ b/continuous_delivery_scripts/spdx_report/templates/third_party_IP_report.html.jinja2 @@ -145,7 +145,11 @@ Not compliant {% endif %}
The project is licensed under the {{ project.licence }} licence.
+ {% else %}The project is licensed under the {{ project.licence }} licence.
+ {% endif %} {% for point in project.compliance_points %}{{ point }}
{% else %} diff --git a/continuous_delivery_scripts/utils/github_helpers.py b/continuous_delivery_scripts/utils/github_helpers.py index 66c73d1..755114c 100644 --- a/continuous_delivery_scripts/utils/github_helpers.py +++ b/continuous_delivery_scripts/utils/github_helpers.py @@ -7,7 +7,7 @@ import re from functools import lru_cache from urllib.error import HTTPError, URLError -from urllib.parse import urlsplit, urlunsplit +from urllib.parse import quote, urlsplit, urlunsplit from urllib.request import Request, urlopen @@ -73,3 +73,15 @@ def github_pages_url(remote: str) -> str | None: """Find a verified public Pages site, including a redirected custom domain.""" candidate = github_pages_candidate(remote) return _verified_pages_url(candidate) if candidate else None + + +def github_blob_url(remote: str, branch: str, path: str) -> str | None: + """Build a GitHub blob URL for a repository-relative file path.""" + repository = _github_repository_path(remote) + if not repository: + return None + cleaned = path.replace("\\", "/").strip("/") + if not cleaned: + return None + encoded_path = "/".join(quote(part, safe="") for part in cleaned.split("/")) + return f"https://github.com/{repository}/blob/{quote(branch, safe='')}/{encoded_path}" diff --git a/news/202610142015000000001.bugfix b/news/202610142015000000001.bugfix new file mode 100644 index 0000000..5a1d57d --- /dev/null +++ b/news/202610142015000000001.bugfix @@ -0,0 +1 @@ +:link: Link the project licence shown in the HTML third-party report to the configured repository licence file. diff --git a/tests/spdx/test_spdx_project.py b/tests/spdx/test_spdx_project.py index dc5466a..bdf270b 100644 --- a/tests/spdx/test_spdx_project.py +++ b/tests/spdx/test_spdx_project.py @@ -264,13 +264,49 @@ def test_generated_report_is_linked_from_documentation_index_once(self): self.assertEqual(html.count('href="third_party_IP_report.html"'), 1) self.assertLess(html.index("third-party-ip-report"), html.index("")) report = Path(output_dir, "third_party_IP_report.html").read_text(encoding="utf8") - self.assertIn("The project is licensed under the MIT licence.
", report) + expected_licence_link = ( + "The project is licensed under the " + '' + "MIT licence.
" + ) + self.assertIn( + expected_licence_link, + report, + ) self.assertIn("The project and all assessed dependencies meet the configured licence policy, ", report) text_report = Path(output_dir, "third_party_IP_report.txt").read_text(encoding="utf8") self.assertIn("The project is licensed under the MIT licence.", text_report) self.assertIn("Audit gaps highlight missing or uncertain dependency licence information", report) self.assertIn("No dependencies were included in this report.", report) + def test_html_report_links_project_licence_to_repository_file(self): + metadata = ProjectMetadata("test_package") + metadata.project_metadata = PackageMetadata({"Name": "test_package", "License": "MIT"}) + parser = Mock() + parser.project_metadata = metadata + get_value = configuration.get_value + + with TemporaryDirectory() as temporary_root: + root = Path(temporary_root) + (root / "docs").mkdir() + overrides = { + ConfigurationVariable.PROJECT_ROOT: root, + ConfigurationVariable.LICENCE_FILE_PATH: "legal/LICENSE.txt", + ConfigurationVariable.MASTER_BRANCH: "main", + } + with patch.object( + configuration, "get_value", side_effect=lambda key: overrides.get(key, get_value(key)) + ), patch( + "continuous_delivery_scripts.spdx_report.spdx_summary.get_project_remote_url", + return_value="https://github.com/acme/example.git", + ): + SpdxProject(parser).generate_licensing_summary(root / "docs") + html = Path(root / "docs" / "third_party_IP_report.html").read_text(encoding="utf8") + + self.assertIn( + 'MIT', html + ) + def test_missing_dependency_is_reported_and_can_fail_the_audit(self): metadata = ProjectMetadata("test_package") metadata.project_metadata = PackageMetadata({"Name": "test_package", "License": "MIT"})