You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/07/23 15:49:25 UTC

(tooling-trusted-releases) branch arm updated (83e34e32 -> 99267277)

This is an automated email from the ASF dual-hosted git repository.

alitheg pushed a change to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


 discard 83e34e32 Improve SBOM output usefulness - update artifact name/version
     add 592fc819 Move download path suffix to the top of the policy form
     add eae70edd Rename the disclaimer template variable for podlings
     add 2fcad662 Validate submitted template variable names
     new 99267277 Improve SBOM output usefulness - update artifact name/version

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (83e34e32)
            \
             N -- N -- N   refs/heads/arm (99267277)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 atr/construct.py                                   | 36 ++++++++----
 atr/form.py                                        | 16 +++---
 atr/get/projects.py                                | 42 ++++++++++----
 atr/models/sql.py                                  |  7 ++-
 atr/shared/projects.py                             | 53 +++++++++++++++---
 ....15_91de479d.py => 0112_2026.07.22_d374db02.py} | 25 +++++----
 .../unit/test_policy_announce_default.py           | 24 ++++----
 tests/unit/test_template_variables.py              | 64 ++++++++++++++++++++++
 8 files changed, 204 insertions(+), 63 deletions(-)
 copy migrations/versions/{0109_2026.07.15_91de479d.py => 0112_2026.07.22_d374db02.py} (62%)
 copy atr/tasks/task.py => tests/unit/test_policy_announce_default.py (54%)
 create mode 100644 tests/unit/test_template_variables.py


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org


(tooling-trusted-releases) 01/01: Improve SBOM output usefulness - update artifact name/version

Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

alitheg pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git

commit 992672773f37c0555ed5cf22ba087a97d04d5ad7
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Thu Jul 23 15:50:26 2026 +0100

    Improve SBOM output usefulness - update artifact name/version
---
 atr/get/checks.py                            |  6 ++--
 atr/get/sbom.py                              | 28 +++++++--------
 atr/models/args.py                           |  6 ++++
 atr/post/draft.py                            |  1 +
 atr/post/sbom.py                             | 10 +++---
 atr/storage/writers/sbom.py                  |  3 ++
 atr/tasks/sbom.py                            | 26 +++++++++++---
 atr/templates/check-selected-path-table.html |  4 +--
 tests/e2e/sbom/test_post.py                  |  6 ++--
 tests/unit/test_api_sbom_generate.py         | 26 ++++++++++++++
 tests/unit/test_get_sbom_tally.py            | 51 ++++++++++++++++++++++++++++
 11 files changed, 136 insertions(+), 31 deletions(-)

diff --git a/atr/get/checks.py b/atr/get/checks.py
index 45f1f21b..9a70a6a3 100644
--- a/atr/get/checks.py
+++ b/atr/get/checks.py
@@ -371,8 +371,8 @@ def _render_file_row(
         version_key=release.version,
         file_path=path_str,
     )
-    sbom_url = util.as_url(
-        sbom.report, project_key=release.project.key, version_key=release.version, file_path=path_str
+    quality_url = util.as_url(
+        sbom.quality, project_key=release.project.key, version_key=release.version, file_path=path_str
     )
 
     path_display = _path_display(path_str, severity, has_checks_before)
@@ -385,7 +385,7 @@ def _render_file_row(
 
     sbom_btn = None
     if analysis.is_cyclonedx_json(path.as_path().name):
-        sbom_btn = htpy.a(".btn.btn-sm.btn-outline-secondary", href=sbom_url)["SBOM report"]
+        sbom_btn = htpy.a(".btn.btn-sm.btn-outline-secondary", href=quality_url)["Quality report"]
 
     components_btn = None
     if (info is not None) and (path in info.sbom_paths):
diff --git a/atr/get/sbom.py b/atr/get/sbom.py
index 5bfa99a6..b1e1f10a 100644
--- a/atr/get/sbom.py
+++ b/atr/get/sbom.py
@@ -69,7 +69,7 @@ async def components(
 
     block = htm.Block()
     _phase_nav(block, release)
-    block.h1["SBOM contents"]
+    block.h1["SBOM report"]
     block.p[
         "The contents of ",
         htm.code[str(sbom_rel_path)],
@@ -99,19 +99,19 @@ async def components(
         and user.is_committee_member(release.committee, session.uid),
     )
 
-    return await template.blank("SBOM contents", content=block.collect())
+    return await template.blank("SBOM report", content=block.collect())
 
 
 @get.typed
-async def report(
+async def quality(
     session: web.Committer,
-    _sbom_report: Literal["sbom/report"],
+    _sbom_quality: Literal["sbom/quality"],
     project_key: safe.ProjectKey,
     version_key: safe.VersionKey,
     file_path: safe.RelPath,
 ) -> str:
     """
-    URL: /sbom/report/<project_key>/<version_key>/<file_path>
+    URL: /sbom/quality/<project_key>/<version_key>/<file_path>
     """
     release = await shared.sbom.release_in_phase(session, project_key, version_key, with_committee=True)
 
@@ -120,7 +120,7 @@ async def report(
     is_release_candidate = release.phase == sql.ReleasePhase.RELEASE_CANDIDATE
     _phase_nav(block, release)
 
-    block.h1["SBOM report"]
+    block.h1["SBOM quality report"]
 
     validated_path_str = str(file_path)
 
@@ -162,7 +162,7 @@ async def report(
 
     _cyclonedx_cli_errors(block, task_result)
 
-    return await template.blank("SBOM report", content=block.collect())
+    return await template.blank("SBOM quality report", content=block.collect())
 
 
 async def _augment_section(
@@ -437,8 +437,8 @@ def _license_tally(
 ) -> list[tuple[sbom.models.licenses.Category, int, int | None, int | None, list[str | None]]]:
     counts: dict[sbom.models.licenses.Category, int] = {}
     components: dict[sbom.models.licenses.Category, list[str | None]] = {}
-    new_count = 0
-    updated_count = 0
+    new_counts: dict[sbom.models.licenses.Category, int] = {}
+    updated_counts: dict[sbom.models.licenses.Category, int] = {}
     old_map = {lic.component_name: (lic.license_expression, lic.category) for lic in old_issues} if old_issues else None
     for item in items:
         key = item.category
@@ -446,10 +446,10 @@ def _license_tally(
         name = str(item).capitalize()
         if old_map is not None:
             if item.component_name not in old_map:
-                new_count = new_count + 1
+                new_counts[key] = new_counts.get(key, 0) + 1
                 name = f"{name} (new)"
             elif item.license_expression != old_map[item.component_name][0]:
-                updated_count = updated_count + 1
+                updated_counts[key] = updated_counts.get(key, 0) + 1
                 name = f"{name} (previously {old_map[item.component_name][0]} - Category {
                     str(old_map[item.component_name][1]).upper()
                 })"
@@ -462,8 +462,8 @@ def _license_tally(
             (
                 category,
                 count,
-                new_count if (old_issues is not None) else None,
-                updated_count if (old_issues is not None) else None,
+                new_counts.get(category, 0) if (old_issues is not None) else None,
+                updated_counts.get(category, 0) if (old_issues is not None) else None,
                 components.get(category, []),
             )
             for category, count in counts.items()
@@ -596,7 +596,7 @@ async def _report_task_results(block: htm.Block, task: sql.Task | None):
     if _score_result(task) is not None:
         return None
     block.p[_score_unavailable(task)]
-    return await template.blank("SBOM report", content=block.collect())
+    return await template.blank("SBOM quality report", content=block.collect())
 
 
 def _score_result(task: sql.Task | None) -> results.SBOMToolScore | None:
diff --git a/atr/models/args.py b/atr/models/args.py
index 98d29da9..9f15bb2e 100644
--- a/atr/models/args.py
+++ b/atr/models/args.py
@@ -80,6 +80,12 @@ class GenerateCycloneDX(schema.Strict):
 
     artifact_path: safe.StatePath = schema.description("Absolute path to the artifact")
     output_path: safe.StatePath = schema.description("Absolute path where the generated SBOM JSON should be written")
+    source_name: safe.RelPath | None = schema.Field(
+        default=None, description="Path of the artifact within the revision, used to name the root component"
+    )
+    source_version: safe.VersionKey | None = schema.Field(
+        default=None, description="Release version of the artifact, used to version the root component"
+    )
 
 
 class ImportFile(schema.Strict):
diff --git a/atr/post/draft.py b/atr/post/draft.py
index 7a0c0903..fe1324ff 100644
--- a/atr/post/draft.py
+++ b/atr/post/draft.py
@@ -382,6 +382,7 @@ async def sbomgen(
                     old_rev.safe_number,
                     path_in_new_revision,
                     sbom_path_in_new_revision,
+                    file_path,
                 )
                 success = await interaction.wait_for_task(sbom_task)
                 if not success:
diff --git a/atr/post/sbom.py b/atr/post/sbom.py
index 374752a9..ce29ad27 100644
--- a/atr/post/sbom.py
+++ b/atr/post/sbom.py
@@ -56,16 +56,16 @@ async def components(
 
 
 @post.typed
-async def report(
+async def quality(
     session: web.Committer,
-    _sbom_report: Literal["sbom/report"],
+    _sbom_quality: Literal["sbom/quality"],
     project_key: safe.ProjectKey,
     version_key: safe.VersionKey,
     file_path: safe.RelPath,
     _sbom_form: shared.sbom.AugmentSBOMForm,
 ) -> web.WerkzeugResponse:
     """
-    URL: /sbom/report/<project_key>/<version_key>/<path:file_path>
+    URL: /sbom/quality/<project_key>/<version_key>/<path:file_path>
     """
     return await _augment(session, project_key, version_key, file_path)
 
@@ -95,14 +95,14 @@ async def _augment(
         log.exception("Error augmenting SBOM:")
         await quart.flash(f"Error augmenting SBOM: {e!s}", "error")
         return await session.redirect(
-            get.sbom.report,
+            get.sbom.quality,
             project_key=str(project_key),
             version_key=str(version_key),
             file_path=str(rel_path),
         )
 
     return await session.redirect(
-        get.sbom.report,
+        get.sbom.quality,
         success=f"SBOM augmentation task queued for {file_name} (task ID: {util.unwrap(sbom_task.id)})",
         project_key=str(project_key),
         version_key=str(version_key),
diff --git a/atr/storage/writers/sbom.py b/atr/storage/writers/sbom.py
index c92f68bd..77055da2 100644
--- a/atr/storage/writers/sbom.py
+++ b/atr/storage/writers/sbom.py
@@ -147,6 +147,7 @@ class CommitteeParticipant(FoundationCommitter):
         revision_number: safe.RevisionNumber,
         path_in_new_revision: safe.StatePath,
         sbom_path_in_new_revision: safe.StatePath,
+        rel_path: safe.RelPath,
     ) -> sql.Task:
         release_key = sql.release_key(str(project_key), str(version_key))
         release = await self.__data.release(key=str(release_key)).demand(
@@ -163,6 +164,8 @@ class CommitteeParticipant(FoundationCommitter):
             task_args=args.GenerateCycloneDX(
                 artifact_path=artifact_path,
                 output_path=output_path,
+                source_name=rel_path,
+                source_version=version_key,
             ).model_dump(),
             asf_uid=util.unwrap(self.__asf_uid),
             added=datetime.datetime.now(datetime.UTC),
diff --git a/atr/tasks/sbom.py b/atr/tasks/sbom.py
index 43133c0c..811617eb 100644
--- a/atr/tasks/sbom.py
+++ b/atr/tasks/sbom.py
@@ -168,7 +168,7 @@ async def generate(args: args.FileArgs) -> results.Results | None:
             if await aiofiles.os.path.exists(output_path):
                 raise SBOMGenerationError("SBOM file already exists", {"file_path": sbom_rel_path})
             source_content_hash = await hashes.compute_file_hash(artifact_path)
-            await _generate_cyclonedx_core(artifact_path, output_path)
+            await _generate_cyclonedx_core(artifact_path, output_path, path_str, str(args.version_key))
             bundle = sbom.utilities.path_to_bundle(output_path.path)
             if not bundle:
                 raise SBOMGenerationError("Could not load bundle")
@@ -208,8 +208,11 @@ async def generate(args: args.FileArgs) -> results.Results | None:
 @checks.with_model(args.GenerateCycloneDX)
 async def generate_cyclonedx(args: args.GenerateCycloneDX) -> results.Results | None:
     """Generate a CycloneDX SBOM for the given artifact and write it to the output path."""
+    # Tasks queued before source_name existed fall back to the bare filename
+    source_name = str(args.source_name) if (args.source_name is not None) else args.artifact_path.name
+    source_version = str(args.source_version) if (args.source_version is not None) else None
     try:
-        result_data = await _generate_cyclonedx_core(args.artifact_path, args.output_path)
+        result_data = await _generate_cyclonedx_core(args.artifact_path, args.output_path, source_name, source_version)
         log.info(f"Successfully generated CycloneDX SBOM for {args.artifact_path}")
         msg = result_data["message"]
         if not isinstance(msg, str):
@@ -442,7 +445,9 @@ def _extracted_dir(temp_dir: str) -> str | None:
     return extract_dir
 
 
-async def _generate_cyclonedx_core(artifact_path: safe.StatePath, output_path: safe.StatePath) -> dict[str, Any]:
+async def _generate_cyclonedx_core(
+    artifact_path: safe.StatePath, output_path: safe.StatePath, source_name: str, source_version: str | None
+) -> dict[str, Any]:
     """Core logic to generate CycloneDX SBOM on failure."""
     log.info(f"Generating CycloneDX SBOM for {artifact_path} -> {output_path}")
 
@@ -486,7 +491,20 @@ async def _generate_cyclonedx_core(artifact_path: safe.StatePath, output_path: s
         log.info(f"Using root directory: {extract_dir}")
 
         # Run syft to generate the CycloneDX SBOM
-        syft_command = ["syft", extract_dir, "-o", "cyclonedx-json", "--enrich", "all", "--base-path", f"{temp_dir!s}"]
+        syft_command = [
+            "syft",
+            extract_dir,
+            "-o",
+            "cyclonedx-json",
+            "--enrich",
+            "all",
+            "--base-path",
+            f"{temp_dir!s}",
+            "--source-name",
+            source_name,
+        ]
+        if source_version is not None:
+            syft_command.extend(["--source-version", source_version])
         log.info(f"Running syft: {' '.join(syft_command)}")
 
         try:
diff --git a/atr/templates/check-selected-path-table.html b/atr/templates/check-selected-path-table.html
index acd39ea1..5e24306e 100644
--- a/atr/templates/check-selected-path-table.html
+++ b/atr/templates/check-selected-path-table.html
@@ -117,8 +117,8 @@
                 </form>
               {% endif %}
               {% if is_cyclonedx_json_fn(path.as_path().name) %}
-                <a href="{{ as_url(get.sbom.report, project_key=project_key|string, version_key=version_key|string, file_path=path) }}"
-                   class="btn btn-sm btn-outline-secondary">SBOM report</a>
+                <a href="{{ as_url(get.sbom.quality, project_key=project_key|string, version_key=version_key|string, file_path=path) }}"
+                   class="btn btn-sm btn-outline-secondary">Quality report</a>
               {% endif %}
               {% if info and (path in info.sbom_paths) %}
                 <a href="{{ as_url(get.sbom.components, project_key=project_key|string, version_key=version_key|string, file_path=path) }}"
diff --git a/tests/e2e/sbom/test_post.py b/tests/e2e/sbom/test_post.py
index abd5a2c1..213a9e1f 100644
--- a/tests/e2e/sbom/test_post.py
+++ b/tests/e2e/sbom/test_post.py
@@ -50,13 +50,13 @@ def test_sbom_generate(page_release_with_file: Page) -> None:
     expect(view_sbom).to_be_visible()
     view_sbom.click()
 
-    expect(page_release_with_file.get_by_role("heading", name="SBOM contents")).to_be_visible()
+    expect(page_release_with_file.get_by_role("heading", name="SBOM report")).to_be_visible()
     expect(page_release_with_file.get_by_text(f"{sbom_helpers.FILE_NAME}.cdx.json")).to_be_visible()
 
-    # What the SBOM says about itself lives here, and scanning returns to this same page
+    # What the SBOM says about the artifact lives here, and scanning returns to this same page
     expect(page_release_with_file.get_by_role("heading", name="Components")).to_be_visible()
     expect(page_release_with_file.get_by_role("heading", name="Licenses")).to_be_visible()
     expect(page_release_with_file.get_by_role("heading", name="Vulnerabilities")).to_be_visible()
     page_release_with_file.get_by_role("button", name="Scan file").click()
     page_release_with_file.wait_for_url("**/sbom/components/**")
-    expect(page_release_with_file.get_by_role("heading", name="SBOM contents")).to_be_visible()
+    expect(page_release_with_file.get_by_role("heading", name="SBOM report")).to_be_visible()
diff --git a/tests/unit/test_api_sbom_generate.py b/tests/unit/test_api_sbom_generate.py
index f65c2e1b..442d038c 100644
--- a/tests/unit/test_api_sbom_generate.py
+++ b/tests/unit/test_api_sbom_generate.py
@@ -15,6 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import pathlib
 import types
 import unittest.mock as mock
 
@@ -30,6 +31,31 @@ import atr.tasks as tasks
 import atr.tasks.sbom as sbom
 
 
+@pytest.mark.asyncio
+async def test_sbom_generate_cyclonedx_records_artifact_rel_path(tmp_path: pathlib.Path) -> None:
+    data = mock.MagicMock()
+    data.commit = mock.AsyncMock()
+    data.refresh = mock.AsyncMock()
+    release_query = mock.MagicMock()
+    release_query.demand = mock.AsyncMock(return_value=_draft_release())
+    data.release = mock.MagicMock(return_value=release_query)
+    writer = _writer_with(data)
+
+    revision = safe.StatePath(tmp_path)
+    rel_path = safe.RelPath("binaries/artifact.tar.gz")
+
+    task = await writer.generate_cyclonedx(
+        safe.ProjectKey("project"),
+        safe.VersionKey("1.0.0"),
+        safe.RevisionNumber("00001"),
+        revision / rel_path,
+        revision / safe.RelPath(f"{rel_path!s}.cdx.json"),
+        rel_path,
+    )
+
+    assert str(task.task_args["source_name"]) == "binaries/artifact.tar.gz"
+
+
 def test_sbom_generate_results_round_trip() -> None:
     task = sql.Task(task_type=sql.TaskType.SBOM_GENERATE, task_args={}, asf_uid="test")
     api_results = models.api.SbomGenerateResults(endpoint="/sbom/generate", task=task)
diff --git a/tests/unit/test_get_sbom_tally.py b/tests/unit/test_get_sbom_tally.py
new file mode 100644
index 00000000..3e4d0038
--- /dev/null
+++ b/tests/unit/test_get_sbom_tally.py
@@ -0,0 +1,51 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import atr.get.sbom as sbom
+import atr.sbom.models as models
+
+
+def test_license_tally_counts_new_and_changed_within_each_category() -> None:
+    old = [_issue("stable", "MIT", models.licenses.Category.B)]
+    items = [
+        _issue("fresh", "GPL-3.0-only", models.licenses.Category.X),
+        _issue("stable", "Apache-2.0", models.licenses.Category.B),
+    ]
+
+    tally = {row[0]: row for row in sbom._license_tally(items, old)}
+
+    # Category, count, new, changed
+    assert tally[models.licenses.Category.X][:4] == (models.licenses.Category.X, 1, 1, 0)
+    assert tally[models.licenses.Category.B][:4] == (models.licenses.Category.B, 1, 0, 1)
+
+
+def test_license_tally_omits_comparison_counts_without_a_previous_release() -> None:
+    items = [_issue("fresh", "GPL-3.0-only", models.licenses.Category.X)]
+
+    (row,) = sbom._license_tally(items, None)
+
+    assert row[2] is None
+    assert row[3] is None
+
+
+def _issue(name: str, expression: str, category: models.licenses.Category) -> models.licenses.Issue:
+    return models.licenses.Issue(
+        component_name=name,
+        component_version="1.0",
+        license_expression=expression,
+        category=category,
+    )


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org