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 %}
+ — {{ 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]