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

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


The following commit(s) were added to refs/heads/sbp by this push:
     new c7d06743 Add forms to allow quarantine error messages to be dismissed
c7d06743 is described below

commit c7d06743c1c974ddf93c4859ccc45c65541babd2
Author: Sean B. Palmer <[email protected]>
AuthorDate: Sun Mar 8 15:19:08 2026 +0000

    Add forms to allow quarantine error messages to be dismissed
---
 atr/models/sql.py                               |   1 +
 atr/post/draft.py                               |  21 ++++
 atr/shared/draft.py                             |   4 +
 atr/shared/web.py                               | 126 ++++++++++++++----------
 atr/storage/writers/revision.py                 |  21 ++++
 atr/templates/check-selected.html               |  41 +++++---
 migrations/versions/0056_2026.03.08_427a333e.py |  38 +++++++
 tests/unit/test_quarantine_task.py              |  42 ++++++++
 8 files changed, 228 insertions(+), 66 deletions(-)

diff --git a/atr/models/sql.py b/atr/models/sql.py
index b39171cc..3b288602 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -168,6 +168,7 @@ class QuarantineStatus(enum.Enum):
     STAGING = "STAGING"
     PENDING = "PENDING"
     FAILED = "FAILED"
+    ACKNOWLEDGED = "ACKNOWLEDGED"
 
 
 class ReleasePhase(enum.StrEnum):
diff --git a/atr/post/draft.py b/atr/post/draft.py
index a47ccddf..675d4ef8 100644
--- a/atr/post/draft.py
+++ b/atr/post/draft.py
@@ -186,6 +186,27 @@ async def hashgen(
     )
 
 
[email protected]
+async def quarantine_clear(
+    session: web.Committer,
+    _draft_quarantine_clear: Literal["draft/quarantine/clear"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    clear_form: shared.draft.ClearQuarantineForm,
+) -> web.WerkzeugResponse:
+    """URL: /draft/quarantine/clear/<project_name>/<version_name>"""
+    async with storage.write(session) as write:
+        wacp = await write.as_project_committee_participant(project_name)
+        await wacp.revision.clear_quarantine(project_name, version_name, 
clear_form.quarantined_id)
+
+    return await session.redirect(
+        get.compose.selected,
+        project_name=str(project_name),
+        version_name=str(version_name),
+        success="Quarantine failure dismissed",
+    )
+
+
 @post.typed
 async def recheck(
     session: web.Committer,
diff --git a/atr/shared/draft.py b/atr/shared/draft.py
index a52a9bf5..4be74573 100644
--- a/atr/shared/draft.py
+++ b/atr/shared/draft.py
@@ -18,6 +18,10 @@
 import atr.form as form
 
 
+class ClearQuarantineForm(form.Form):
+    quarantined_id: form.Int = form.label("Quarantine ID", 
widget=form.Widget.HIDDEN)
+
+
 class DeleteFileForm(form.Form):
     file_path: form.RelPath = form.label("File path", 
widget=form.Widget.HIDDEN)
 
diff --git a/atr/shared/web.py b/atr/shared/web.py
index ab770a33..0ed866d8 100644
--- a/atr/shared/web.py
+++ b/atr/shared/web.py
@@ -39,59 +39,6 @@ if TYPE_CHECKING:
     from collections.abc import Sequence
 
 
-def render_checks_summary(
-    info: types.PathInfo | None, project_name: safe.ProjectName, version_name: 
safe.VersionName
-) -> htm.Element | None:
-    if (info is None) or (not info.checker_stats):
-        return None
-
-    card = htm.Block(htm.div, classes=".card.mb-4")
-    card.div(".card-header")[htpy.h5(".mb-0")["Checks summary"]]
-
-    body = htm.Block(htm.div, classes=".card-body")
-    for i, stat in enumerate(info.checker_stats):
-        stripe_class = ".atr-stripe-odd" if ((i % 2) == 0) else 
".atr-stripe-even"
-        details = htm.Block(htm.details, classes=f".mb-0.p-2{stripe_class}")
-
-        summary_content: list[htm.Element | str] = []
-        if stat.warning_count > 0:
-            
summary_content.append(htpy.span(".badge.bg-warning.text-dark.me-2")[str(stat.warning_count)])
-        if stat.failure_count > 0:
-            
summary_content.append(htpy.span(".badge.bg-danger.me-2")[str(stat.failure_count)])
-        if stat.blocker_count > 0:
-            
summary_content.append(htpy.span(".badge.atr-bg-blocker.me-2")[str(stat.blocker_count)])
-        
summary_content.append(htpy.strong[_checker_display_name(stat.checker)])
-
-        details.summary[*summary_content]
-
-        files_div = htm.Block(htm.div, classes=".mt-2.atr-checks-files")
-        all_files = set(stat.failure_files.keys()) | 
set(stat.warning_files.keys()) | set(stat.blocker_files.keys())
-        for file_path in sorted(all_files):
-            report_url = 
f"/report/{project_name!s}/{version_name!s}/{file_path}"
-            error_count = stat.failure_files.get(file_path, 0)
-            blocker_count = stat.blocker_files.get(file_path, 0)
-            warning_count = stat.warning_files.get(file_path, 0)
-
-            file_content: list[htm.Element | str] = []
-            if error_count > 0:
-                
file_content.append(htpy.span(".badge.bg-danger.me-2")[util.plural(error_count, 
"error")])
-            if blocker_count > 0:
-                
file_content.append(htpy.span(".badge.atr-bg-blocker.me-2")[util.plural(blocker_count,
 "blocker")])
-            if warning_count > 0:
-                file_content.append(
-                    
htpy.span(".badge.bg-warning.text-dark.me-2")[util.plural(warning_count, 
"warning")]
-                )
-            
file_content.append(htpy.a(href=report_url)[htpy.strong[htpy.code[file_path]]])
-
-            files_div.div[*file_content]
-
-        details.append(files_div.collect())
-        body.append(details.collect())
-
-    card.append(body.collect())
-    return card.collect()
-
-
 async def check(
     session: web.Committer | None,
     release: sql.Release,
@@ -132,6 +79,25 @@ async def check(
         ).all()
         quarantined_failed = await data.quarantined(release_name=release.name, 
status=sql.QuarantineStatus.FAILED).all()
 
+    clear_quarantine_forms: dict[int, htm.Element] = {}
+    if session is not None:
+        for q in quarantined_failed:
+            if q.id is None:
+                continue
+            clear_quarantine_forms[q.id] = form.render(
+                model_cls=draft.ClearQuarantineForm,
+                action=util.as_url(
+                    post.draft.quarantine_clear,
+                    project_name=release.safe_project_name,
+                    version_name=release.safe_version_name,
+                ),
+                form_classes=".d-inline-block.m-0",
+                submit_classes="btn-sm btn-outline-secondary",
+                submit_label="Dismiss",
+                empty=True,
+                defaults={"quarantined_id": str(q.id)},
+            )
+
     # Get the number of ongoing tasks for the current revision
     ongoing_tasks_count = 0
     match await interaction.latest_info(release.safe_project_name, 
release.safe_version_name):
@@ -223,6 +189,7 @@ async def check(
         ongoing_tasks_count=ongoing_tasks_count,
         quarantined_pending=quarantined_pending,
         quarantined_failed=quarantined_failed,
+        clear_quarantine_forms=clear_quarantine_forms,
         delete_form=delete_form,
         delete_file_forms=delete_file_forms,
         asf_id=asf_id,
@@ -250,6 +217,59 @@ async def check(
     )
 
 
+def render_checks_summary(
+    info: types.PathInfo | None, project_name: safe.ProjectName, version_name: 
safe.VersionName
+) -> htm.Element | None:
+    if (info is None) or (not info.checker_stats):
+        return None
+
+    card = htm.Block(htm.div, classes=".card.mb-4")
+    card.div(".card-header")[htpy.h5(".mb-0")["Checks summary"]]
+
+    body = htm.Block(htm.div, classes=".card-body")
+    for i, stat in enumerate(info.checker_stats):
+        stripe_class = ".atr-stripe-odd" if ((i % 2) == 0) else 
".atr-stripe-even"
+        details = htm.Block(htm.details, classes=f".mb-0.p-2{stripe_class}")
+
+        summary_content: list[htm.Element | str] = []
+        if stat.warning_count > 0:
+            
summary_content.append(htpy.span(".badge.bg-warning.text-dark.me-2")[str(stat.warning_count)])
+        if stat.failure_count > 0:
+            
summary_content.append(htpy.span(".badge.bg-danger.me-2")[str(stat.failure_count)])
+        if stat.blocker_count > 0:
+            
summary_content.append(htpy.span(".badge.atr-bg-blocker.me-2")[str(stat.blocker_count)])
+        
summary_content.append(htpy.strong[_checker_display_name(stat.checker)])
+
+        details.summary[*summary_content]
+
+        files_div = htm.Block(htm.div, classes=".mt-2.atr-checks-files")
+        all_files = set(stat.failure_files.keys()) | 
set(stat.warning_files.keys()) | set(stat.blocker_files.keys())
+        for file_path in sorted(all_files):
+            report_url = 
f"/report/{project_name!s}/{version_name!s}/{file_path}"
+            error_count = stat.failure_files.get(file_path, 0)
+            blocker_count = stat.blocker_files.get(file_path, 0)
+            warning_count = stat.warning_files.get(file_path, 0)
+
+            file_content: list[htm.Element | str] = []
+            if error_count > 0:
+                
file_content.append(htpy.span(".badge.bg-danger.me-2")[util.plural(error_count, 
"error")])
+            if blocker_count > 0:
+                
file_content.append(htpy.span(".badge.atr-bg-blocker.me-2")[util.plural(blocker_count,
 "blocker")])
+            if warning_count > 0:
+                file_content.append(
+                    
htpy.span(".badge.bg-warning.text-dark.me-2")[util.plural(warning_count, 
"warning")]
+                )
+            
file_content.append(htpy.a(href=report_url)[htpy.strong[htpy.code[file_path]]])
+
+            files_div.div[*file_content]
+
+        details.append(files_div.collect())
+        body.append(details.collect())
+
+    card.append(body.collect())
+    return card.collect()
+
+
 def _checker_display_name(checker: str) -> str:
     return checker.removeprefix("atr.tasks.checks.").replace("_", " 
").replace(".", " ").title()
 
diff --git a/atr/storage/writers/revision.py b/atr/storage/writers/revision.py
index cba937ec..0f9394d6 100644
--- a/atr/storage/writers/revision.py
+++ b/atr/storage/writers/revision.py
@@ -329,6 +329,27 @@ class CommitteeParticipant(FoundationCommitter):
         self.__asf_uid = asf_uid
         self.__committee_name = committee_name
 
+    async def clear_quarantine(
+        self,
+        project_name: safe.ProjectName,
+        version_name: safe.VersionName,
+        quarantined_id: int,
+    ) -> None:
+        release_name = sql.release_name(str(project_name), str(version_name))
+        quarantined = await self.__data.quarantined(
+            id=quarantined_id, release_name=release_name, 
status=sql.QuarantineStatus.FAILED
+        ).get()
+        if quarantined is None:
+            raise RuntimeError("Quarantine failure not found or not in FAILED 
state")
+        quarantined.status = sql.QuarantineStatus.ACKNOWLEDGED
+        await self.__data.commit()
+        self.__write_as.append_to_audit_log(
+            asf_uid=self.__asf_uid,
+            project_name=str(project_name),
+            version_name=str(version_name),
+            quarantined_id=quarantined_id,
+        )
+
     async def create_revision_with_quarantine(  # noqa: C901
         self,
         project_name: safe.ProjectName,
diff --git a/atr/templates/check-selected.html 
b/atr/templates/check-selected.html
index c912ca19..ef25c692 100644
--- a/atr/templates/check-selected.html
+++ b/atr/templates/check-selected.html
@@ -90,20 +90,35 @@
 
   {% for quarantined in quarantined_failed %}
     <div class="alert alert-danger" role="alert">
-      <i class="bi bi-exclamation-octagon me-2"></i>
-      <strong>Archive validation failed.</strong>
-      {% if quarantined.file_metadata %}
-        The following issues were found:
-        <ul class="mb-0 mt-2">
-          {% for entry in quarantined.file_metadata %}
-            {% if entry.errors %}
-              {% for error in entry.errors %}
-                <li><code>{{ entry.rel_path }}</code>: {{ error }}</li>
-              {% endfor %}
+      <div class="d-flex justify-content-between align-items-start">
+        <div>
+          <i class="bi bi-exclamation-octagon me-2"></i>
+          <strong>Archive validation failed.</strong>
+          <span class="text-muted ms-2">
+            Uploaded by {{ quarantined.asf_uid }}
+            {% if quarantined.completed %}
+              at {{ format_datetime(quarantined.completed) }}
             {% endif %}
-          {% endfor %}
-        </ul>
-      {% endif %}
+            {% if quarantined.description %}
+              &mdash; {{ quarantined.description }}
+            {% endif %}
+          </span>
+          {% if quarantined.file_metadata %}
+            <ul class="mb-0 mt-2">
+              {% for entry in quarantined.file_metadata %}
+                {% if entry.errors %}
+                  {% for error in entry.errors %}
+                    <li><code>{{ entry.rel_path }}</code>: {{ error }}</li>
+                  {% endfor %}
+                {% endif %}
+              {% endfor %}
+            </ul>
+          {% endif %}
+        </div>
+        {% if quarantined.id in clear_quarantine_forms %}
+          {{ clear_quarantine_forms[quarantined.id]|safe }}
+        {% endif %}
+      </div>
     </div>
   {% endfor %}
 
diff --git a/migrations/versions/0056_2026.03.08_427a333e.py 
b/migrations/versions/0056_2026.03.08_427a333e.py
new file mode 100644
index 00000000..0641538c
--- /dev/null
+++ b/migrations/versions/0056_2026.03.08_427a333e.py
@@ -0,0 +1,38 @@
+"""Add a quarantine status to reflect RM acknowledgement
+
+Revision ID: 0056_2026.03.08_427a333e
+Revises: 0055_2026.03.06_522f2417
+Create Date: 2026-03-08 15:12:21.172571+00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# Revision identifiers, used by Alembic
+revision: str = "0056_2026.03.08_427a333e"
+down_revision: str | None = "0055_2026.03.06_522f2417"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    with op.batch_alter_table("quarantined", schema=None) as batch_op:
+        batch_op.alter_column(
+            "status",
+            existing_type=sa.VARCHAR(length=7),
+            type_=sa.Enum("STAGING", "PENDING", "FAILED", "ACKNOWLEDGED", 
name="quarantinestatus"),
+            existing_nullable=False,
+        )
+
+
+def downgrade() -> None:
+    op.execute("UPDATE quarantined SET status='FAILED' WHERE 
status='ACKNOWLEDGED'")
+    with op.batch_alter_table("quarantined", schema=None) as batch_op:
+        batch_op.alter_column(
+            "status",
+            existing_type=sa.Enum("STAGING", "PENDING", "FAILED", 
"ACKNOWLEDGED", name="quarantinestatus"),
+            type_=sa.Enum("STAGING", "PENDING", "FAILED", 
name="quarantinestatus"),
+            existing_nullable=False,
+        )
diff --git a/tests/unit/test_quarantine_task.py 
b/tests/unit/test_quarantine_task.py
index 6be95e3d..c78600cf 100644
--- a/tests/unit/test_quarantine_task.py
+++ b/tests/unit/test_quarantine_task.py
@@ -26,10 +26,45 @@ import pytest
 
 import atr.models.safe as safe
 import atr.models.sql as sql
+import atr.storage as storage
+import atr.storage.writers.revision as revision
 import atr.tasks as tasks
 import atr.tasks.quarantine as quarantine
 
 
[email protected]
+async def test_clear_quarantine_raises_when_not_found():
+    mock_data = mock.AsyncMock()
+    mock_query = mock.MagicMock()
+    mock_query.get = mock.AsyncMock(return_value=None)
+    mock_data.quarantined = mock.MagicMock(return_value=mock_query)
+
+    writer = _make_revision_writer(mock_data)
+    with pytest.raises(RuntimeError, match="not found"):
+        await writer.clear_quarantine(safe.ProjectName("proj"), 
safe.VersionName("1.0"), 999)
+
+    mock_data.commit.assert_not_awaited()
+
+
[email protected]
+async def test_clear_quarantine_transitions_failed_to_acknowledged():
+    quarantined_row = mock.MagicMock(spec=sql.Quarantined)
+    quarantined_row.id = 7
+    quarantined_row.status = sql.QuarantineStatus.FAILED
+
+    mock_data = mock.AsyncMock()
+    mock_query = mock.MagicMock()
+    mock_query.get = mock.AsyncMock(return_value=quarantined_row)
+    mock_data.quarantined = mock.MagicMock(return_value=mock_query)
+
+    writer = _make_revision_writer(mock_data)
+    await writer.clear_quarantine(safe.ProjectName("proj"), 
safe.VersionName("1.0"), 7)
+
+    assert quarantined_row.status == sql.QuarantineStatus.ACKNOWLEDGED
+    mock_data.commit.assert_awaited_once()
+    mock_data.quarantined.assert_called_once_with(id=7, 
release_name="proj-1.0", status=sql.QuarantineStatus.FAILED)
+
+
 @pytest.mark.asyncio
 async def 
test_extract_archives_to_cache_discards_staging_dir_on_enotempty_collision(
     monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
@@ -494,6 +529,13 @@ def _make_quarantined_row() -> mock.MagicMock:
     return row
 
 
+def _make_revision_writer(mock_data: mock.AsyncMock) -> 
revision.CommitteeParticipant:
+    mock_write = mock.MagicMock(spec=storage.Write)
+    mock_write.authorisation.asf_uid = "testuser"
+    mock_write_as = mock.MagicMock(spec=storage.WriteAsCommitteeParticipant)
+    return revision.CommitteeParticipant(mock_write, mock_write_as, mock_data, 
"committee")
+
+
 def _make_session_returning(quarantined_row: mock.MagicMock) -> mock.AsyncMock:
     mock_data = mock.AsyncMock()
     mock_query = mock.MagicMock()


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to