You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/08/05 14:27:41 UTC

(tooling-trusted-releases) 01/01: Allow Tooling presentation votes to be resolved early

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

commit 05bc82a891c58b9119b1f2d754dcdef14481e0b8
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Wed Aug 5 15:27:16 2026 +0100

    Allow Tooling presentation votes to be resolved early
---
 atr/db/interaction.py                |   9 +--
 atr/get/resolve.py                   |   2 +-
 atr/storage/writers/vote.py          |   4 +-
 atr/templates/resolve-tabulated.html |   4 +-
 tests/unit/test_vote_resolution.py   | 118 ++++++++++++++++++++++++++++++-----
 5 files changed, 113 insertions(+), 24 deletions(-)

diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index bd126fd1..04dcb935 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -52,6 +52,7 @@ PENDING_QUARANTINE_VOTE_BLOCK_MESSAGE: Final[str] = (
 # audit_guidance required actor for ATR distribution workflows; must not be used for project TP workflows.
 _GITHUB_TRUSTED_ROLE_NID: Final[int] = 254436773
 _NO_EXPECTED_VOTE_ROUND: Final[object] = object()
+_PRODUCTION_VOTE_RESOLUTION_BYPASS_PROJECT_KEYS: Final[frozenset[str]] = frozenset({"tooling-presentations"})
 
 
 class ApacheUserMissingError(RuntimeError):
@@ -1037,10 +1038,6 @@ async def validate_trusted_jwt(publisher: str, jwt: str) -> tuple[github.Trusted
     return payload, asf_uid
 
 
-def vote_duration_bypass() -> bool:
-    return not config.is_production_mode()
-
-
 def vote_end_get(latest_vote_task: sql.Task | None) -> datetime.datetime | None:
     if latest_vote_task is None:
         return None
@@ -1061,6 +1058,10 @@ def vote_pass_fail_allowed(latest_vote_task: sql.Task | None) -> bool:
     return datetime.datetime.now(datetime.UTC) >= vote_end
 
 
+def vote_resolution_bypass(release: sql.Release) -> bool:
+    return (not config.is_production_mode()) or (release.project_key in _PRODUCTION_VOTE_RESOLUTION_BYPASS_PROJECT_KEYS)
+
+
 async def wait_for_task(
     task: sql.Task,
     caller_data: db.Session | None = None,
diff --git a/atr/get/resolve.py b/atr/get/resolve.py
index 77127d79..159694f2 100644
--- a/atr/get/resolve.py
+++ b/atr/get/resolve.py
@@ -152,7 +152,7 @@ async def selected(  # noqa: C901
         mail_warnings = latest_vote_task.result.mail_send_warnings
 
     pass_fail_allowed = interaction.vote_pass_fail_allowed(latest_vote_task)
-    bypass_active = interaction.vote_duration_bypass()
+    bypass_active = interaction.vote_resolution_bypass(release)
     vote_end = interaction.vote_end_get(latest_vote_task)
     is_trusted_mode = release.effective_vote_mode == sql.VoteMode.TRUSTED
     vote_round = interaction.trusted_vote_round(release)
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index 5080d299..bad4b873 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -577,7 +577,7 @@ class ReleaseManager(CommitteeParticipant):
         if (
             (vote_result != "cancelled")
             and (not interaction.vote_pass_fail_allowed(latest_vote_task))
-            and (not interaction.vote_duration_bypass())
+            and (not interaction.vote_resolution_bypass(release))
         ):
             raise storage.AccessError(
                 "The vote cannot be resolved before the voting period has ended unless it is cancelled.",
@@ -1005,7 +1005,7 @@ class ReleaseManager(CommitteeParticipant):
 
             latest_vote_task = await interaction.release_current_vote_task(release, self.__data)
             vote_end = interaction.vote_end_get(latest_vote_task)
-            resolution_bypass = interaction.vote_duration_bypass()
+            resolution_bypass = interaction.vote_resolution_bypass(release)
             if (
                 (vote_result != "cancelled")
                 and (vote_end is not None)
diff --git a/atr/templates/resolve-tabulated.html b/atr/templates/resolve-tabulated.html
index f2f30314..209d24cd 100644
--- a/atr/templates/resolve-tabulated.html
+++ b/atr/templates/resolve-tabulated.html
@@ -272,13 +272,13 @@
       {% if bypass_active %}
         <div class="border rounded bg-warning-subtle p-3 mb-3">
           <i class="bi bi-exclamation-triangle me-1"></i>
-          <strong>Debug bypass:</strong>
+          <strong>Early resolution enabled:</strong>
           {% if vote_end %}
             The voting period has not ended yet (ends {{ vote_end.strftime('%Y-%m-%d %H:%M:%S UTC') }}).
           {% else %}
             The end of the vote could not be determined.
           {% endif %}
-          Resolving as passed or failed is allowed because debug/test mode is active.
+          ATR allows this vote to be resolved as passed or failed now.
         </div>
       {% else %}
         <div class="border rounded bg-info-subtle p-3 mb-3">
diff --git a/tests/unit/test_vote_resolution.py b/tests/unit/test_vote_resolution.py
index 18d9b16f..4aff149e 100644
--- a/tests/unit/test_vote_resolution.py
+++ b/tests/unit/test_vote_resolution.py
@@ -20,6 +20,7 @@ import datetime
 import inspect
 import unittest.mock as mock
 from types import SimpleNamespace
+from typing import Literal
 
 import pytest
 import quart
@@ -514,7 +515,7 @@ async def test_resolve_allows_cancelled_before_vote_end(monkeypatch: pytest.Monk
 
     future_task = _latest_vote_task_with_end(24)
     monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=future_task))
-    monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
+    monkeypatch.setattr(interaction.config, "is_production_mode", lambda: True)
 
     writer._ReleaseManager__resolve_release = mock.AsyncMock(
         return_value=(release, None, "Vote marked as cancelled", None)
@@ -531,14 +532,18 @@ async def test_resolve_allows_cancelled_before_vote_end(monkeypatch: pytest.Monk
     assert success == "Vote marked as cancelled"
 
 
+@pytest.mark.parametrize("vote_result", ["failed", "passed"])
 @pytest.mark.asyncio
-async def test_resolve_allows_early_passed_with_bypass(monkeypatch: pytest.MonkeyPatch) -> None:
+async def test_resolve_allows_early_result_for_production_bypass_project(
+    monkeypatch: pytest.MonkeyPatch, vote_result: Literal["failed", "passed"]
+) -> None:
     """Writer allows Passed before the end of the vote when bypass is active."""
     data = _mock_data()
     write_as = _mock_write_as()
     writer = _writer_with_mocks(data, write_as)
 
     release = _candidate_release()
+    release.project_key = "tooling-presentations"
     query = mock.MagicMock()
     query.demand = mock.AsyncMock(return_value=release)
     data.release = mock.MagicMock(return_value=query)
@@ -546,21 +551,21 @@ async def test_resolve_allows_early_passed_with_bypass(monkeypatch: pytest.Monke
 
     future_task = _latest_vote_task_with_end(24)
     monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=future_task))
-    monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: True)
+    monkeypatch.setattr(interaction.config, "is_production_mode", lambda: True)
 
     writer._ReleaseManager__resolve_release = mock.AsyncMock(
-        return_value=(release, None, "Vote marked as passed", None)
+        return_value=(release, None, f"Vote marked as {vote_result}", None)
     )
 
     _release, _round, success, _error = await writer.resolve(
-        _project_key(),
+        safe.ProjectKey("tooling-presentations"),
         _version_key(),
-        "passed",
+        vote_result,
         "Chair",
-        "The vote has passed.",
+        f"The vote has {vote_result}.",
     )
 
-    assert success == "Vote marked as passed"
+    assert success == f"Vote marked as {vote_result}"
 
 
 @pytest.mark.asyncio
@@ -578,7 +583,7 @@ async def test_resolve_allows_passed_after_vote_end(monkeypatch: pytest.MonkeyPa
 
     past_task = _latest_vote_task_with_end(-24)
     monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=past_task))
-    monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
+    monkeypatch.setattr(interaction, "vote_resolution_bypass", lambda _release: False)
 
     writer._ReleaseManager__resolve_release = mock.AsyncMock(
         return_value=(release, None, "Vote marked as passed", None)
@@ -728,6 +733,25 @@ async def test_resolve_page_uses_cancel_form_before_vote_end(
     assert form_render.call_args.kwargs["submit_label"] == "Cancel vote"
 
 
+@pytest.mark.asyncio
+async def test_resolve_page_allows_early_result_when_bypass_is_active(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    vote_end = datetime.datetime(2026, 4, 1, 12, 0, 0, tzinfo=datetime.UTC)
+
+    context, form_render, _archive_lookup, _vote_committee, _vote_details = await _render_standard_resolve_page(
+        monkeypatch,
+        pass_fail_allowed=False,
+        resolution_bypass=True,
+        vote_end=vote_end,
+    )
+
+    assert context["cancel_only"] is False
+    assert form_render.call_args.kwargs["model_cls"] is shared.resolve.SubmitForm
+    assert form_render.call_args.kwargs["submit_classes"] == "btn-primary"
+    assert form_render.call_args.kwargs["submit_label"] == "Resolve vote"
+
+
 @pytest.mark.asyncio
 async def test_resolve_rejects_early_failed(monkeypatch: pytest.MonkeyPatch) -> None:
     """Writer rejects Failed when the end of the vote has not been reached and no bypass is active."""
@@ -736,6 +760,7 @@ async def test_resolve_rejects_early_failed(monkeypatch: pytest.MonkeyPatch) ->
     writer = _writer_with_mocks(data, write_as)
 
     release = _candidate_release()
+    release.project_key = "project"
     query = mock.MagicMock()
     query.demand = mock.AsyncMock(return_value=release)
     data.release = mock.MagicMock(return_value=query)
@@ -743,7 +768,7 @@ async def test_resolve_rejects_early_failed(monkeypatch: pytest.MonkeyPatch) ->
 
     future_task = _latest_vote_task_with_end(24)
     monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=future_task))
-    monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
+    monkeypatch.setattr(interaction.config, "is_production_mode", lambda: True)
 
     with pytest.raises(storage.AccessError, match="unless it is cancelled"):
         await writer.resolve(
@@ -763,13 +788,14 @@ async def test_resolve_rejects_early_passed(monkeypatch: pytest.MonkeyPatch) ->
     writer = _writer_with_mocks(data, write_as)
 
     release = _candidate_release()
+    release.project_key = "project"
     query = mock.MagicMock()
     query.demand = mock.AsyncMock(return_value=release)
     data.release = mock.MagicMock(return_value=query)
 
     future_task = _latest_vote_task_with_end(24)
     monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=future_task))
-    monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
+    monkeypatch.setattr(interaction.config, "is_production_mode", lambda: True)
 
     with pytest.raises(storage.AccessError, match="voting period"):
         await writer.resolve(
@@ -969,6 +995,7 @@ async def test_trusted_resolve_allows_insufficient_votes_with_bypass(monkeypatch
     writer._ReleaseManager__send_resolution = mock.AsyncMock(return_value=None)
 
     release = _candidate_release()
+    release.project_key = "tooling-presentations"
     release.vote_mode = sql.VoteMode.TRUSTED
     release.effective_vote_mode = sql.VoteMode.TRUSTED
     release.current_vote_seq = 1
@@ -977,14 +1004,14 @@ async def test_trusted_resolve_allows_insufficient_votes_with_bypass(monkeypatch
     data.release = mock.MagicMock(return_value=query)
 
     monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=_latest_vote_task()))
-    monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: True)
+    monkeypatch.setattr(interaction.config, "is_production_mode", lambda: True)
     monkeypatch.setattr(interaction, "ballots_for_resolution", mock.AsyncMock(return_value=[]))
     monkeypatch.setattr(
         interaction, "trusted_ballot_summary", mock.AsyncMock(return_value=interaction.TrustedVoteSummary())
     )
 
     _release, _round, success, _error = await writer.resolve(
-        _project_key(),
+        safe.ProjectKey("tooling-presentations"),
         _version_key(),
         "passed",
         "Chair",
@@ -997,6 +1024,42 @@ async def test_trusted_resolve_allows_insufficient_votes_with_bypass(monkeypatch
     write_as.revision.create_revision_with_quarantine.assert_awaited_once()
 
 
+@pytest.mark.asyncio
+async def test_trusted_resolve_rejects_insufficient_votes_for_other_production_project(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    data = _mock_data()
+    write_as = _mock_write_as()
+    writer = _writer_with_mocks(data, write_as)
+
+    release = _candidate_release()
+    release.project_key = "project"
+    release.vote_mode = sql.VoteMode.TRUSTED
+    release.effective_vote_mode = sql.VoteMode.TRUSTED
+    release.current_vote_seq = 1
+    query = mock.MagicMock()
+    query.demand = mock.AsyncMock(return_value=release)
+    data.release = mock.MagicMock(return_value=query)
+
+    monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=_latest_vote_task()))
+    monkeypatch.setattr(interaction.config, "is_production_mode", lambda: True)
+    monkeypatch.setattr(interaction, "ballots_for_resolution", mock.AsyncMock(return_value=[]))
+    monkeypatch.setattr(
+        interaction, "trusted_ballot_summary", mock.AsyncMock(return_value=interaction.TrustedVoteSummary())
+    )
+
+    with pytest.raises(storage.AccessError, match="does not have enough binding"):
+        await writer.resolve(
+            _project_key(),
+            _version_key(),
+            "passed",
+            "Chair",
+            "The vote has passed.",
+            expected_vote_seq=1,
+            expected_vote_mode=sql.VoteMode.TRUSTED,
+        )
+
+
 @pytest.mark.asyncio
 async def test_trusted_resolve_page_passes_authoritative_ballot_context(
     monkeypatch: pytest.MonkeyPatch,
@@ -1133,7 +1196,7 @@ async def test_trusted_resolve_passes_round_two_via_carried_ipmc_ballots(monkeyp
 
     past_task = _latest_vote_task_with_end(-24)
     monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=past_task))
-    monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
+    monkeypatch.setattr(interaction, "vote_resolution_bypass", lambda _release: False)
     effective_ballots = mock.AsyncMock(return_value=[mock.MagicMock(), mock.MagicMock(), mock.MagicMock()])
     monkeypatch.setattr(interaction, "effective_trusted_ballots", effective_ballots)
     monkeypatch.setattr(
@@ -1215,6 +1278,28 @@ def test_vote_pass_fail_allowed_returns_true_after_vote_end() -> None:
     assert interaction.vote_pass_fail_allowed(task) is True
 
 
+@pytest.mark.parametrize(
+    ("production_mode", "project_key", "expected"),
+    [
+        (False, "project", True),
+        (True, "tooling", False),
+        (True, "tooling-presentations", True),
+        (True, "tooling-presentations-demo", False),
+    ],
+)
+def test_vote_resolution_bypass(
+    monkeypatch: pytest.MonkeyPatch,
+    production_mode: bool,
+    project_key: str,
+    expected: bool,
+) -> None:
+    release = _candidate_release()
+    release.project_key = project_key
+    monkeypatch.setattr(interaction.config, "is_production_mode", lambda: production_mode)
+
+    assert interaction.vote_resolution_bypass(release) is expected
+
+
 @pytest.mark.asyncio
 async def test_vote_tabulate_api_passes_trusted_receipt_exclusions(monkeypatch: pytest.MonkeyPatch) -> None:
     release = _candidate_release()
@@ -1460,6 +1545,7 @@ async def _render_standard_resolve_page(
     ballot_receipt_message_ids: mock.AsyncMock | None = None,
     ballots_for_resolution: mock.AsyncMock | None = None,
     pass_fail_allowed: bool = True,
+    resolution_bypass: bool = False,
     vote_end: datetime.datetime | None = None,
 ) -> tuple[dict[str, object], mock.AsyncMock, mock.AsyncMock, mock.AsyncMock, mock.AsyncMock]:
     release = release or _candidate_release()
@@ -1473,6 +1559,7 @@ async def _render_standard_resolve_page(
     vote_committee = mock.AsyncMock(return_value=release.project.committee)
     vote_details = mock.AsyncMock()
     form_render = mock.AsyncMock(return_value="FORM")
+    resolution_bypass_check = mock.Mock(return_value=resolution_bypass)
     ballot_receipt_message_ids = ballot_receipt_message_ids or mock.AsyncMock(return_value=set())
     ballots_for_resolution = ballots_for_resolution or mock.AsyncMock(return_value=[])
     is_binding_for_release = mock.AsyncMock(return_value=(True, "Project"))
@@ -1505,7 +1592,7 @@ async def _render_standard_resolve_page(
     monkeypatch.setattr(
         resolve.interaction, "release_current_vote_task", mock.AsyncMock(return_value=_latest_vote_task())
     )
-    monkeypatch.setattr(resolve.interaction, "vote_duration_bypass", lambda: False)
+    monkeypatch.setattr(resolve.interaction, "vote_resolution_bypass", resolution_bypass_check)
     monkeypatch.setattr(resolve.interaction, "vote_end_get", lambda _task: vote_end)
     monkeypatch.setattr(resolve.interaction, "vote_pass_fail_allowed", lambda _task: pass_fail_allowed)
     monkeypatch.setattr(resolve.interaction, "ballot_receipt_message_ids", ballot_receipt_message_ids)
@@ -1520,6 +1607,7 @@ async def _render_standard_resolve_page(
     html = await _resolve_handler()(session, "resolve", _project_key(), _version_key())
 
     assert html == "HTML"
+    resolution_bypass_check.assert_called_once_with(release)
     return context, form_render, archive_lookup, vote_committee, vote_details
 
 


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