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 9ae748a6 Do not allow votes to be resolved, only cancelled, before 
they end
9ae748a6 is described below

commit 9ae748a6d110a9e7a14924dc3cec6ce3998fcb2f
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Mar 25 19:44:07 2026 +0000

    Do not allow votes to be resolved, only cancelled, before they end
---
 atr/api/__init__.py                  |  46 ++++----
 atr/db/interaction.py                |  30 ++++-
 atr/get/resolve.py                   |  17 ++-
 atr/post/resolve.py                  |  23 ++--
 atr/shared/resolve.py                |   5 +
 atr/storage/writers/vote.py          |   9 ++
 atr/templates/resolve-tabulated.html |  28 ++++-
 tests/e2e/announce/conftest.py       |   4 +-
 tests/unit/test_vote_resolution.py   | 218 +++++++++++++++++++++++++++++++++++
 9 files changed, 340 insertions(+), 40 deletions(-)

diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index 9912ba1e..7c704ca1 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -950,16 +950,19 @@ async def publisher_vote_resolve(
         data.jwt,
         interaction.TrustedProjectPhase.VOTE,
     )
-    async with storage.write_as_project_committee_member(project.safe_key, 
asf_uid) as wacm:
-        # TODO: Get fullname and use instead of asf_uid
-        # TODO: Add resolution templating to atr.construct
-        _release, _voting_round, _success_message, _error_message = await 
wacm.vote.resolve(
-            project.safe_key,
-            data.version,
-            data.resolution,
-            asf_uid,
-            f"The vote {data.resolution}.",
-        )
+    try:
+        async with storage.write_as_project_committee_member(project.safe_key, 
asf_uid) as wacm:
+            # TODO: Get fullname and use instead of asf_uid
+            # TODO: Add resolution templating to atr.construct
+            _release, _voting_round, _success_message, _error_message = await 
wacm.vote.resolve(
+                project.safe_key,
+                data.version,
+                data.resolution,
+                asf_uid,
+                f"The vote {data.resolution}.",
+            )
+    except storage.AccessError as e:
+        raise exceptions.BadRequest(str(e))
 
     return models.api.PublisherVoteResolveResults(
         endpoint="/publisher/vote/resolve",
@@ -1521,16 +1524,19 @@ async def vote_resolve(
     """
     asf_uid = _jwt_asf_uid()
     # try:
-    async with storage.write_as_project_committee_member(data.project, 
asf_uid) as wacm:
-        # TODO: Get fullname and use instead of asf_uid
-        # TODO: Add resolution templating to atr.construct
-        _release, _voting_round, _success_message, _error_message = await 
wacm.vote.resolve(
-            data.project,
-            data.version,
-            data.resolution,
-            asf_uid,
-            f"The vote {data.resolution}.",
-        )
+    try:
+        async with storage.write_as_project_committee_member(data.project, 
asf_uid) as wacm:
+            # TODO: Get fullname and use instead of asf_uid
+            # TODO: Add resolution templating to atr.construct
+            _release, _voting_round, _success_message, _error_message = await 
wacm.vote.resolve(
+                data.project,
+                data.version,
+                data.resolution,
+                asf_uid,
+                f"The vote {data.resolution}.",
+            )
+    except storage.AccessError as e:
+        raise exceptions.BadRequest(str(e))
     # except Exception as e:
     #     import atr.log as log
     #     import traceback
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 7f73f555..2520b136 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -27,6 +27,7 @@ import sqlalchemy.orm as orm
 import sqlmodel
 
 import atr.attestable as attestable
+import atr.config as config
 import atr.db as db
 import atr.jwtoken as jwtoken
 import atr.ldap as ldap
@@ -281,9 +282,6 @@ async def previews(project: sql.Project) -> 
list[sql.Release]:
 
 async def release_latest_vote_task(release: sql.Release, caller_data: 
db.Session | None = None) -> sql.Task | None:
     """Find the most recent VOTE_INITIATE task for this release."""
-    disallowed_statuses = [sql.TaskStatus.QUEUED, sql.TaskStatus.ACTIVE]
-    if util.is_dev_environment():
-        disallowed_statuses = []
     via = sql.validate_instrumented_attribute
     async with db.ensure_session(caller_data) as data:
         query = (
@@ -291,8 +289,6 @@ async def release_latest_vote_task(release: sql.Release, 
caller_data: db.Session
             .where(sql.Task.project_key == release.project_key)
             .where(sql.Task.version_key == release.version)
             .where(sql.Task.task_type == sql.TaskType.VOTE_INITIATE)
-            .where(via(sql.Task.status).notin_(disallowed_statuses))
-            .where(via(sql.Task.result).is_not(None))
             .order_by(via(sql.Task.added).desc())
             .limit(1)
         )
@@ -569,6 +565,30 @@ async def validate_trusted_jwt(publisher: str, jwt: str) 
-> tuple[github.Trusted
     return payload, asf_uid
 
 
+def vote_duration_bypass() -> bool:
+    return (config.get_mode() == config.Mode.Debug) or config.get().ALLOW_TESTS
+
+
+def vote_end_get(latest_vote_task: sql.Task | None) -> datetime.datetime | 
None:
+    if latest_vote_task is None:
+        return None
+    result = latest_vote_task.result
+    if not isinstance(result, results.VoteInitiate):
+        return None
+    try:
+        naive = datetime.datetime.strptime(result.vote_end, "%Y-%m-%d %H:%M:%S 
UTC")
+        return naive.replace(tzinfo=datetime.UTC)
+    except (ValueError, AttributeError):
+        return None
+
+
+def vote_pass_fail_allowed(latest_vote_task: sql.Task | None) -> bool:
+    vote_end = vote_end_get(latest_vote_task)
+    if vote_end is None:
+        return False
+    return datetime.datetime.now(datetime.UTC) >= vote_end
+
+
 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 063bf29c..cb6a89cd 100644
--- a/atr/get/resolve.py
+++ b/atr/get/resolve.py
@@ -83,6 +83,10 @@ async def selected(
     else:
         fetch_error = "The vote thread could not yet be found."
 
+    pass_fail_allowed = interaction.vote_pass_fail_allowed(latest_vote_task)
+    bypass_active = interaction.vote_duration_bypass()
+    vote_end = interaction.vote_end_get(latest_vote_task)
+
     defaults = {}
     if (committee is not None) and (details is not None) and (thread_id is not 
None):
         defaults["email_body"] = tabulate.vote_resolution(
@@ -98,10 +102,16 @@ async def selected(
         )
         defaults["vote_result"] = "Passed" if details.passed else "Failed"
 
+    submit_label = "Resolve vote"
+    if pass_fail_allowed or bypass_active:
+        form_cls = shared.resolve.SubmitForm
+    else:
+        form_cls = shared.resolve.CancelSubmitForm
+
     resolve_form = atr.form.render(
-        model_cls=shared.resolve.SubmitForm,
+        model_cls=form_cls,
         action=util.as_url(post.resolve.selected, 
project_key=release.project.key, version_key=release.version),
-        submit_label="Resolve vote",
+        submit_label=submit_label,
         textarea_rows=24,
         defaults=defaults,
     )
@@ -115,4 +125,7 @@ async def selected(
         resolve_form=resolve_form,
         fetch_error=fetch_error,
         archive_url=archive_url,
+        vote_end=vote_end,
+        pass_fail_allowed=pass_fail_allowed,
+        bypass_active=bypass_active,
     )
diff --git a/atr/post/resolve.py b/atr/post/resolve.py
index 51b74446..00f688a3 100644
--- a/atr/post/resolve.py
+++ b/atr/post/resolve.py
@@ -49,14 +49,23 @@ async def selected(
         case "Cancelled":
             writer_result = "cancelled"
 
-    async with storage.write_as_project_committee_member(project_key) as wacm:
-        _release, voting_round, success_message, error_message = await 
wacm.vote.resolve(
-            project_key,
-            version_key,
-            writer_result,
-            session.fullname,
-            email_body,
+    try:
+        async with storage.write_as_project_committee_member(project_key) as 
wacm:
+            _release, voting_round, success_message, error_message = await 
wacm.vote.resolve(
+                project_key,
+                version_key,
+                writer_result,
+                session.fullname,
+                email_body,
+            )
+    except storage.AccessError as e:
+        return await session.redirect(
+            get.resolve.selected,
+            error=str(e),
+            project_key=str(project_key),
+            version_key=str(version_key),
         )
+
     if error_message is not None:
         await quart.flash(error_message, "error")
 
diff --git a/atr/shared/resolve.py b/atr/shared/resolve.py
index 03d25a71..c3cf193f 100644
--- a/atr/shared/resolve.py
+++ b/atr/shared/resolve.py
@@ -20,6 +20,11 @@ from typing import Literal
 import atr.form as form
 
 
+class CancelSubmitForm(form.Form):
+    email_body: str = form.label("Email body", widget=form.Widget.TEXTAREA)
+    vote_result: Literal["Cancelled"] = form.label("Vote result", 
default="Cancelled", widget=form.Widget.HIDDEN)
+
+
 class SubmitForm(form.Form):
     email_body: str = form.label("Email body", widget=form.Widget.TEXTAREA)
     vote_result: Literal["Passed", "Failed", "Cancelled"] = form.label("Vote 
result", widget=form.Widget.RADIO)
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index e0a9e7a7..0c6aed3e 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -263,6 +263,15 @@ class CommitteeMember(CommitteeParticipant):
         if latest_vote_task is None:
             raise RuntimeError("No vote task found, unable to send resolution 
message.")
 
+        if (
+            (vote_result != "cancelled")
+            and (not interaction.vote_pass_fail_allowed(latest_vote_task))
+            and (not interaction.vote_duration_bypass())
+        ):
+            raise storage.AccessError(
+                "The vote cannot be resolved before the voting period has 
ended unless it is cancelled."
+            )
+
         voting_round = None
         if is_podling is True:
             voting_round = 1 if (podling_thread_id is None) else 2
diff --git a/atr/templates/resolve-tabulated.html 
b/atr/templates/resolve-tabulated.html
index 2f139a6e..04d82a9b 100644
--- a/atr/templates/resolve-tabulated.html
+++ b/atr/templates/resolve-tabulated.html
@@ -106,10 +106,30 @@
   {% endif %}
   {% if resolve_form %}
     <h2>Resolve vote</h2>
-    <div class="border rounded bg-warning-subtle p-3 mb-3">
-      <i class="bi bi-info-circle me-1"></i>
-      <strong>NOTE:</strong> We are allowing a vote to be resolved early in 
order to facilitate testing. This is not the final behaviour.
-    </div>
+    {% if not pass_fail_allowed %}
+      {% 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>
+          {% 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.
+        </div>
+      {% else %}
+        <div class="border rounded bg-info-subtle p-3 mb-3">
+          <i class="bi bi-clock me-1"></i>
+          {% if vote_end %}
+            The voting period has not ended yet. The vote ends <strong>{{ 
vote_end.strftime('%Y-%m-%d %H:%M:%S UTC') }}</strong>.
+          {% else %}
+            The end of the vote could not be determined.
+          {% endif %}
+          Resolving as passed or failed is not available. You may still cancel 
the vote.
+        </div>
+      {% endif %}
+    {% endif %}
     <p>
       If, after careful manual review of the information above, you concur 
with the automatically determined outcome of the vote, please enter the 
resolution email body here. Sending this will send the email to a new vote 
result thread, and the vote will be resolved.
     </p>
diff --git a/tests/e2e/announce/conftest.py b/tests/e2e/announce/conftest.py
index 0ba77bda..c693cd0e 100644
--- a/tests/e2e/announce/conftest.py
+++ b/tests/e2e/announce/conftest.py
@@ -74,14 +74,14 @@ def announce_context(browser: Browser) -> 
Generator[BrowserContext]:
     page.locator('a[title="Start a vote on this draft"]').click()
     page.wait_for_load_state()
 
+    page.locator("input#vote_duration").fill("0")
     page.get_by_role("button", name="Send vote email").click()
     page.wait_for_url(f"**/vote/{PROJECT_KEY}/{VERSION_KEY}")
 
     helpers.visit(page, f"/vote/{PROJECT_KEY}/{VERSION_KEY}")
     _poll_for_vote_thread_link(page)
 
-    resolve_form = 
page.locator(f'form[action="/resolve/{PROJECT_KEY}/{VERSION_KEY}"]')
-    resolve_form.get_by_role("button", name="Resolve vote").click()
+    page.get_by_role("link", name="Resolve vote").click()
     page.wait_for_url(f"**/resolve/{PROJECT_KEY}/{VERSION_KEY}")
 
     page.locator('input[name="vote_result"][value="Passed"]').check()
diff --git a/tests/unit/test_vote_resolution.py 
b/tests/unit/test_vote_resolution.py
index d1e8cb1a..d4e30df1 100644
--- a/tests/unit/test_vote_resolution.py
+++ b/tests/unit/test_vote_resolution.py
@@ -22,6 +22,7 @@ from types import SimpleNamespace
 import pytest
 import quart
 
+import atr.db.interaction as interaction
 import atr.get.manual as manual
 import atr.get.resolve as resolve
 import atr.get.vote
@@ -29,6 +30,7 @@ import atr.htm as htm
 import atr.models.results as results
 import atr.models.safe as safe
 import atr.models.sql as sql
+import atr.storage as storage
 import atr.storage.writers.vote as vote
 
 
@@ -288,6 +290,149 @@ def 
test_manual_vote_resolve_section_links_to_manual_resolve(monkeypatch: pytest
     assert 'href="/resolve/project/1.0.0"' not in html
 
 
[email protected]
+async def test_resolve_allows_cancelled_before_vote_end(monkeypatch: 
pytest.MonkeyPatch) -> None:
+    """Writer allows Cancelled even before the end of the vote."""
+    data = _mock_data()
+    write_as = _mock_write_as()
+    writer = _writer_with_mocks(data, write_as)
+
+    release = _candidate_release()
+    query = mock.MagicMock()
+    query.demand = mock.AsyncMock(return_value=release)
+    data.release = mock.MagicMock(return_value=query)
+    data.merge = mock.AsyncMock(return_value=release)
+
+    future_task = _latest_vote_task_with_end(24)
+    monkeypatch.setattr(interaction, "release_latest_vote_task", 
mock.AsyncMock(return_value=future_task))
+    monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
+
+    writer.resolve_release = mock.AsyncMock(return_value=(release, None, "Vote 
marked as cancelled", None))
+
+    _release, _round, success, _error = await writer.resolve(
+        _project_key(),
+        _version_key(),
+        "cancelled",
+        "Chair",
+        "The vote has been cancelled.",
+    )
+
+    assert success == "Vote marked as cancelled"
+
+
[email protected]
+async def test_resolve_allows_early_passed_with_bypass(monkeypatch: 
pytest.MonkeyPatch) -> 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()
+    query = mock.MagicMock()
+    query.demand = mock.AsyncMock(return_value=release)
+    data.release = mock.MagicMock(return_value=query)
+    data.merge = mock.AsyncMock(return_value=release)
+
+    future_task = _latest_vote_task_with_end(24)
+    monkeypatch.setattr(interaction, "release_latest_vote_task", 
mock.AsyncMock(return_value=future_task))
+    monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: True)
+
+    writer.resolve_release = mock.AsyncMock(return_value=(release, None, "Vote 
marked as passed", None))
+
+    _release, _round, success, _error = await writer.resolve(
+        _project_key(),
+        _version_key(),
+        "passed",
+        "Chair",
+        "The vote has passed.",
+    )
+
+    assert success == "Vote marked as passed"
+
+
[email protected]
+async def test_resolve_allows_passed_after_vote_end(monkeypatch: 
pytest.MonkeyPatch) -> None:
+    """Writer allows Passed after the end of the vote has elapsed."""
+    data = _mock_data()
+    write_as = _mock_write_as()
+    writer = _writer_with_mocks(data, write_as)
+
+    release = _candidate_release()
+    query = mock.MagicMock()
+    query.demand = mock.AsyncMock(return_value=release)
+    data.release = mock.MagicMock(return_value=query)
+    data.merge = mock.AsyncMock(return_value=release)
+
+    past_task = _latest_vote_task_with_end(-24)
+    monkeypatch.setattr(interaction, "release_latest_vote_task", 
mock.AsyncMock(return_value=past_task))
+    monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
+
+    writer.resolve_release = mock.AsyncMock(return_value=(release, None, "Vote 
marked as passed", None))
+
+    _release, _round, success, _error = await writer.resolve(
+        _project_key(),
+        _version_key(),
+        "passed",
+        "Chair",
+        "The vote has passed.",
+    )
+
+    assert success == "Vote marked as passed"
+
+
[email protected]
+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."""
+    data = _mock_data()
+    write_as = _mock_write_as()
+    writer = _writer_with_mocks(data, write_as)
+
+    release = _candidate_release()
+    query = mock.MagicMock()
+    query.demand = mock.AsyncMock(return_value=release)
+    data.release = mock.MagicMock(return_value=query)
+    data.merge = mock.AsyncMock(return_value=release)
+
+    future_task = _latest_vote_task_with_end(24)
+    monkeypatch.setattr(interaction, "release_latest_vote_task", 
mock.AsyncMock(return_value=future_task))
+    monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
+
+    with pytest.raises(storage.AccessError, match="unless it is cancelled"):
+        await writer.resolve(
+            _project_key(),
+            _version_key(),
+            "failed",
+            "Chair",
+            "The vote has failed.",
+        )
+
+
[email protected]
+async def test_resolve_rejects_early_passed(monkeypatch: pytest.MonkeyPatch) 
-> None:
+    """Writer rejects Passed when the end of the vote has not been reached and 
no bypass is active."""
+    data = _mock_data()
+    write_as = _mock_write_as()
+    writer = _writer_with_mocks(data, write_as)
+
+    release = _candidate_release()
+    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_latest_vote_task", 
mock.AsyncMock(return_value=future_task))
+    monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
+
+    with pytest.raises(storage.AccessError, match="voting period"):
+        await writer.resolve(
+            _project_key(),
+            _version_key(),
+            "passed",
+            "Chair",
+            "The vote has passed.",
+        )
+
+
 @pytest.mark.asyncio
 async def test_send_resolution_cancelled_builds_cancelled_subject() -> None:
     """send_resolution accepts cancelled and builds a CANCELLED subject."""
@@ -320,6 +465,59 @@ async def 
test_send_resolution_cancelled_builds_cancelled_subject() -> None:
     assert queued_task.task_args["email_bcc"] == 
["[email protected]"]
 
 
+def test_vote_end_get_returns_datetime_for_valid_task() -> None:
+    """vote_end_get returns a UTC datetime for a valid VoteInitiate task."""
+    task = _latest_vote_task()
+    vote_end = interaction.vote_end_get(task)
+    assert vote_end is not None
+    assert vote_end.tzinfo is datetime.UTC
+    assert vote_end == datetime.datetime(2026, 3, 31, 12, 0, 0, 
tzinfo=datetime.UTC)
+
+
+def test_vote_end_get_returns_none_for_malformed_date() -> None:
+    """vote_end_get returns None when the date string is malformed."""
+    task = SimpleNamespace(
+        result=results.VoteInitiate(
+            kind="vote_initiate",
+            message="ok",
+            email_to="[email protected]",
+            vote_end="not-a-date",
+            subject="[VOTE]",
+            mid=None,
+            mail_send_warnings=[],
+        ),
+    )
+    assert interaction.vote_end_get(task) is None
+
+
+def test_vote_end_get_returns_none_for_missing_task() -> None:
+    """vote_end_get returns None when given None."""
+    assert interaction.vote_end_get(None) is None
+
+
+def test_vote_end_get_returns_none_for_non_vote_initiate() -> None:
+    """vote_end_get returns None for a task with a non-VoteInitiate result."""
+    task = SimpleNamespace(result="not a VoteInitiate")
+    assert interaction.vote_end_get(task) is None
+
+
+def test_vote_pass_fail_allowed_returns_false_before_vote_end() -> None:
+    """vote_pass_fail_allowed returns False when the end of the vote is in the 
future."""
+    task = _latest_vote_task_with_end(24)
+    assert interaction.vote_pass_fail_allowed(task) is False
+
+
+def test_vote_pass_fail_allowed_returns_false_for_missing_task() -> None:
+    """vote_pass_fail_allowed returns False (fail closed) when given None."""
+    assert interaction.vote_pass_fail_allowed(None) is False
+
+
+def test_vote_pass_fail_allowed_returns_true_after_vote_end() -> None:
+    """vote_pass_fail_allowed returns True when the end of the vote has 
elapsed."""
+    task = _latest_vote_task_with_end(-24)
+    assert interaction.vote_pass_fail_allowed(task) is True
+
+
 def _candidate_release(podling_thread_id: str | None = None) -> 
SimpleNamespace:
     return SimpleNamespace(
         phase=sql.ReleasePhase.RELEASE_CANDIDATE,
@@ -369,6 +567,26 @@ def _latest_vote_task() -> SimpleNamespace:
     )
 
 
+def _latest_vote_task_with_end(offset_hours: int) -> SimpleNamespace:
+    vote_end = datetime.datetime.now(datetime.UTC) + 
datetime.timedelta(hours=offset_hours)
+    return SimpleNamespace(
+        result=results.VoteInitiate(
+            kind="vote_initiate",
+            message="Vote announcement email sent successfully",
+            email_to="[email protected]",
+            vote_end=vote_end.strftime("%Y-%m-%d %H:%M:%S UTC"),
+            subject="[VOTE] Release project 1.0.0",
+            mid="[email protected]",
+            mail_send_warnings=[],
+        ),
+        task_args={
+            "email_to": "[email protected]",
+            "email_cc": ["[email protected]"],
+            "email_bcc": ["[email protected]"],
+        },
+    )
+
+
 def _manual_candidate_release(podling_thread_id: str | None = None) -> 
SimpleNamespace:
     return SimpleNamespace(
         phase=sql.ReleasePhase.RELEASE_CANDIDATE,


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

Reply via email to