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/05/13 15:58:14 UTC

(tooling-trusted-releases) 01/01: Only automatically resolve successful votes, and include second rounds

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

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

commit 4a9e094f11c6144eae66d6c145c0c756f3925e45
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Wed May 13 16:57:18 2026 +0100

    Only automatically resolve successful votes, and include second rounds
---
 atr/get/resolve.py                   |  42 +++++++++
 atr/models/args.py                   |   2 +-
 atr/post/resolve.py                  |  17 ++++
 atr/post/voting.py                   |   2 +-
 atr/shared/resolve.py                |  10 +++
 atr/storage/writers/vote.py          |  20 ++++-
 atr/tasks/vote.py                    | 146 +++++++++++++++++++++++++-----
 tests/unit/test_vote_auto_resolve.py | 167 +++++++++++++++++++++++++++++++++++
 8 files changed, 379 insertions(+), 27 deletions(-)

diff --git a/atr/get/resolve.py b/atr/get/resolve.py
index e7b9abb7..6f45c3c6 100644
--- a/atr/get/resolve.py
+++ b/atr/get/resolve.py
@@ -229,6 +229,46 @@ async def selected(  # noqa: C901
     else:
         form_cls = shared.resolve.SubmitForm
 
+    is_podling_first_round = (
+        is_trusted_mode
+        and (release.committee is not None)
+        and release.committee.is_podling
+        and (release.podling_thread_id is None)
+    )
+    custom: dict[str, htm.Element | htm.VoidElement] = {}
+    skip: list[str] = []
+    if (form_cls is shared.resolve.SubmitForm) and is_podling_first_round:
+        defaults["automatic_resolve_when_finished"] = True
+        custom["automatic_resolve_when_finished"] = htm.div[
+            htpy.input(
+                type="checkbox",
+                name="automatic_resolve_when_finished",
+                id="automatic_resolve_when_finished",
+                value="on",
+                checked=True,
+                class_="form-check-input",
+            ),
+            htm.div(".form-text.text-muted.mt-1")[
+                "If enabled, ATR will resolve the second round vote automatically, "
+                "using only ATR ballots, when its voting period ends.",
+            ],
+        ]
+        custom["notify_when_finished"] = htm.div[
+            htpy.input(
+                type="checkbox",
+                name="notify_when_finished",
+                id="notify_when_finished",
+                value="on",
+                class_="form-check-input",
+            ),
+            htm.div(".form-text.text-muted.mt-1")[
+                f"If enabled, ATR will send an email to {session.uid}@apache.org when the second round vote finishes.",
+            ],
+        ]
+    else:
+        skip.append("automatic_resolve_when_finished")
+        skip.append("notify_when_finished")
+
     pre_submit: htm.Element | None = None
     if (not binding_sufficient) and (pass_fail_allowed or bypass_active):
         icon = htpy.i(class_="bi bi-exclamation-triangle me-1")
@@ -258,6 +298,8 @@ async def selected(  # noqa: C901
         submit_label=submit_label,
         textarea_rows=24,
         defaults=defaults,
+        custom=custom,
+        skip=skip,
         pre_submit=pre_submit,
     )
 
diff --git a/atr/models/args.py b/atr/models/args.py
index ef8352d8..f5909e40 100644
--- a/atr/models/args.py
+++ b/atr/models/args.py
@@ -108,7 +108,7 @@ class Initiate(schema.Strict):
     )
     automatic_resolve_when_finished: bool = pydantic.Field(
         default=False,
-        description="Automatically resolve a non-podling Trusted Vote when the voting period ends",
+        description="Automatically resolve a Trusted Vote when the voting period ends",
     )
 
 
diff --git a/atr/post/resolve.py b/atr/post/resolve.py
index 11112280..173899d0 100644
--- a/atr/post/resolve.py
+++ b/atr/post/resolve.py
@@ -49,6 +49,9 @@ async def selected(
         case "Cancelled":
             writer_result = "cancelled"
 
+    automatic_resolve_when_finished = _read_auto_resolve_flag(submit_form)
+    notify_when_finished = _read_notify_flag(submit_form)
+
     try:
         async with storage.write_as_project_committee_member(project_key) as wacm:
             _release, voting_round, success_message, error_message = await wacm.vote.resolve(
@@ -59,6 +62,8 @@ async def selected(
                 email_body,
                 expected_vote_seq=submit_form.vote_seq,
                 expected_vote_mode=submit_form.vote_mode,
+                automatic_resolve_when_finished=automatic_resolve_when_finished,
+                notify_when_finished=notify_when_finished,
             )
     except storage.AccessError as e:
         return await session.redirect(
@@ -84,3 +89,15 @@ async def selected(
     return await session.redirect(
         destination, project_key=str(project_key), version_key=str(version_key), success=success_message
     )
+
+
+def _read_auto_resolve_flag(submit_form: shared.resolve.ResolveForm) -> bool:
+    if isinstance(submit_form, shared.resolve.SubmitForm):
+        return bool(submit_form.automatic_resolve_when_finished)
+    return False
+
+
+def _read_notify_flag(submit_form: shared.resolve.ResolveForm) -> bool:
+    if isinstance(submit_form, shared.resolve.SubmitForm):
+        return bool(submit_form.notify_when_finished)
+    return False
diff --git a/atr/post/voting.py b/atr/post/voting.py
index 09b2a268..6cdcaea0 100644
--- a/atr/post/voting.py
+++ b/atr/post/voting.py
@@ -222,6 +222,6 @@ async def _notify_opt_in_error(
     if start_voting_form.automatic_resolve_when_finished and is_podling:
         return await session.form_error(
             "automatic_resolve_when_finished",
-            "Automatic vote resolution is not yet available for podling votes.",
+            "Automatic vote resolution is not available for the first round of podling votes.",
         )
     return None
diff --git a/atr/shared/resolve.py b/atr/shared/resolve.py
index bada4a28..499e3a24 100644
--- a/atr/shared/resolve.py
+++ b/atr/shared/resolve.py
@@ -49,6 +49,16 @@ class SubmitForm(form.Form):
     variant: SUBMIT = form.value(SUBMIT)
     email_body: str = form.label("Email body", widget=form.Widget.TEXTAREA, max_length=100_000)
     vote_result: Literal["Passed", "Failed", "Cancelled"] = form.label("Vote result", widget=form.Widget.RADIO)
+    automatic_resolve_when_finished: form.Bool = form.label(
+        "Automatically resolve the second round vote",
+        widget=form.Widget.CUSTOM,
+        default=False,
+    )
+    notify_when_finished: form.Bool = form.label(
+        "Notify me when the second round vote ends",
+        widget=form.Widget.CUSTOM,
+        default=False,
+    )
     vote_mode: sql.VoteMode | None = form.label("Vote mode", default=None, widget=form.Widget.HIDDEN)
     vote_seq: int | None = form.label("Vote serial", default=None, widget=form.Widget.HIDDEN)
 
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index e6b8a041..197aaaf3 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -350,9 +350,10 @@ class CommitteeParticipant(FoundationCommitter):
                 raise storage.AccessError(
                     "Automatic vote resolution is only available in Trusted Vote mode", status=403
                 )
-            if automatic_resolve_when_finished and committee.is_podling:
+            if automatic_resolve_when_finished and committee.is_podling and (release.podling_thread_id is None):
                 raise storage.AccessError(
-                    "Automatic vote resolution is not yet available for podling votes", status=403
+                    "Automatic vote resolution is not available for the first round of podling votes",
+                    status=403,
                 )
             if automatic_resolve_when_finished and (self.__asf_uid not in committee.committee_members):
                 # TODO: Maybe we should modularise all of this?
@@ -466,6 +467,8 @@ class CommitteeMember(CommitteeParticipant):
         resolution_body: str,
         expected_vote_seq: int | None = None,
         expected_vote_mode: sql.VoteMode | None = None,
+        automatic_resolve_when_finished: bool = False,
+        notify_when_finished: bool = False,
     ) -> tuple[sql.Release, int | None, str, str | None]:
         release = await self.__data.release(
             key=sql.release_key(str(project_key), str(version_key)),
@@ -504,6 +507,8 @@ class CommitteeMember(CommitteeParticipant):
                 expected_vote_seq,
                 expected_vote_mode,
                 podling_round_one_thread_id,
+                automatic_resolve_when_finished=automatic_resolve_when_finished,
+                notify_when_finished=notify_when_finished,
             )
         if (
             (expected_vote_seq is not None)
@@ -545,6 +550,8 @@ class CommitteeMember(CommitteeParticipant):
             latest_vote_task,
             asf_fullname,
             resolution_body,
+            automatic_resolve_when_finished=automatic_resolve_when_finished,
+            notify_when_finished=notify_when_finished,
         )
 
     async def resolve_manually(
@@ -626,6 +633,8 @@ class CommitteeMember(CommitteeParticipant):
         latest_vote_task: sql.Task,
         asf_fullname: str,
         resolution_body: str,
+        automatic_resolve_when_finished: bool = False,
+        notify_when_finished: bool = False,
     ) -> tuple[sql.Release, int | None, str, str | None]:
         if (voting_round == 1) and (vote_result == "passed"):
             await self.__data.commit()
@@ -693,6 +702,8 @@ class CommitteeMember(CommitteeParticipant):
                     body_data=body_data,
                     release=release,
                     promote=False,
+                    automatic_resolve_when_finished=automatic_resolve_when_finished,
+                    notify_when_finished=notify_when_finished,
                 )
                 second_round_vote_seq = second_round_task.task_args["vote_seq"]
                 if not isinstance(second_round_vote_seq, int):
@@ -868,6 +879,9 @@ class CommitteeMember(CommitteeParticipant):
         expected_vote_seq: int | None,
         expected_vote_mode: sql.VoteMode | None,
         podling_round_one_thread_id: str | None,
+        *,
+        automatic_resolve_when_finished: bool = False,
+        notify_when_finished: bool = False,
     ) -> tuple[sql.Release, int | None, str, str | None]:
         latest_vote_task: sql.Task | None = None
         second_round_vote_mode: sql.VoteMode | None = None
@@ -970,6 +984,8 @@ class CommitteeMember(CommitteeParticipant):
                     body_data=body_data,
                     release=release,
                     promote=False,
+                    automatic_resolve_when_finished=automatic_resolve_when_finished,
+                    notify_when_finished=notify_when_finished,
                 )
                 second_round_vote_seq = second_round_task.task_args["vote_seq"]
                 if not isinstance(second_round_vote_seq, int):
diff --git a/atr/tasks/vote.py b/atr/tasks/vote.py
index d57b147b..eb60cc2a 100644
--- a/atr/tasks/vote.py
+++ b/atr/tasks/vote.py
@@ -16,6 +16,7 @@
 # under the License.
 
 import datetime
+from typing import Final, Literal
 
 import atr.config as config
 import atr.db as db
@@ -32,6 +33,9 @@ import atr.tasks.checks as checks
 import atr.user as user
 import atr.util as util
 
+# Flip to True to restore the prior behaviour of resolving failed votes automatically
+AUTOMATICALLY_RESOLVE_ON_FAILURE: Final[bool] = False
+
 
 class VoteInitiationError(Exception):
     pass
@@ -39,7 +43,7 @@ class VoteInitiationError(Exception):
 
 @checks.with_model(args.VoteAutoResolve)
 async def auto_resolve(task_args: args.VoteAutoResolve) -> results.Results | None:  # noqa: C901
-    """Automatically resolve a non-podling Trusted Vote after its scheduled end."""
+    """Automatically resolve a Trusted Vote after its scheduled end."""
     async with db.session() as data:
         release = await data.release(
             key=task_args.release_key,
@@ -63,8 +67,8 @@ async def auto_resolve(task_args: args.VoteAutoResolve) -> results.Results | Non
             return _why("not_trusted_mode", "not in trusted mode")
         if release.committee is None:
             return _why("committee_missing", "committee missing")
-        if release.committee.is_podling:
-            return _why("podling_not_supported", "podling votes are not supported yet")
+        if not _auto_resolve_supported(release.committee, release.podling_thread_id):
+            return _why("podling_first_round_not_supported", "podling first round must be resolved manually")
         if release.current_vote_seq != task_args.vote_seq:
             return _why("vote_seq_changed", f"vote_seq changed ({release.current_vote_seq} != {task_args.vote_seq})")
 
@@ -91,6 +95,29 @@ async def auto_resolve(task_args: args.VoteAutoResolve) -> results.Results | Non
         version_key = release.safe_version_key
         task_mid = interaction.task_mid_get(latest_vote_task)
         task_recipient = interaction.task_recipient_get(latest_vote_task)
+        notify_when_finished = bool(latest_vote_task.task_args.get("notify_when_finished", False))
+
+    if (not passed) and (not AUTOMATICALLY_RESOLVE_ON_FAILURE):
+        log.info(
+            f"Vote automatic resolution skipped for {task_args.release_key}: "
+            "vote did not pass and AUTOMATICALLY_RESOLVE_ON_FAILURE is False"
+        )
+        if notify_when_finished:
+            await _send_auto_resolve_outcome_notify(
+                task_args.release_key,
+                str(project_key),
+                str(version_key),
+                task_args.resolver_id,
+                "failure_skipped",
+            )
+        return results.VoteAutoResolve(
+            kind="vote_auto_resolve",
+            resolved=False,
+            vote_result="failed",
+            skip_reason="vote_failed_manual_resolution_required",
+            success_message=None,
+            error_message=None,
+        )
 
     thread_id = await _thread_id_for_vote_task(task_mid, task_recipient, task_args.resolver_id)
     resolution_body = tabulate.trusted_vote_resolution(
@@ -117,6 +144,14 @@ async def auto_resolve(task_args: args.VoteAutoResolve) -> results.Results | Non
             )
     except storage.AccessError as e:
         log.info(f"Vote automatic resolution skipped for {task_args.release_key}: writer rejected resolution: {e}")
+        if notify_when_finished:
+            await _send_auto_resolve_outcome_notify(
+                task_args.release_key,
+                str(project_key),
+                str(version_key),
+                task_args.resolver_id,
+                "resolve_rejected",
+            )
         return results.VoteAutoResolve(
             kind="vote_auto_resolve",
             resolved=False,
@@ -126,6 +161,16 @@ async def auto_resolve(task_args: args.VoteAutoResolve) -> results.Results | Non
             error_message=str(e),
         )
 
+    if notify_when_finished:
+        outcome: Literal["passed", "failed"] = "passed" if passed else "failed"
+        await _send_auto_resolve_outcome_notify(
+            task_args.release_key,
+            str(project_key),
+            str(version_key),
+            task_args.resolver_id,
+            outcome,
+        )
+
     return results.VoteAutoResolve(
         kind="vote_auto_resolve",
         resolved=True,
@@ -161,27 +206,12 @@ async def end_notify(task_args: args.VoteEndNotify) -> results.Results | None:
             return _end_notify_skipped("not_trusted_mode")
         review_url = f"https://{config.get().APP_HOST}/vote/{release.project.key}/{release.version}"
 
-    sender_recipient = f"{task_args.recipient_id}@apache.org"
-    subject = f"[ATR] Vote ready to resolve: {task_args.release_key}"
+    subject = f"[ATR] Vote ended for {task_args.release_key}"
     body = (
-        f"The vote for {task_args.release_key} reached its scheduled end at {task_args.vote_end}.\n\n"
-        f"Please review the recorded ballots and resolve the vote in ATR:\n{review_url}"
+        f"The vote for {task_args.release_key} has ended. "
+        f"Please review the recorded ballots and resolve the vote in ATR at {review_url}"
     )
-    message = mail.Message(
-        email_sender=sender_recipient,
-        email_to=sender_recipient,
-        subject=subject,
-        body=body,
-    )
-
-    async with storage.write(task_args.recipient_id) as write:
-        wafc = write.as_foundation_committer()
-        mid, mail_errors = await wafc.mail.send(message, mail.MailFooterCategory.AUTO)
-
-    if mail_errors:
-        log.warning(f"Vote end notify mail to {sender_recipient} produced warnings: {mail_errors}")
-    else:
-        log.info(f"Vote end notify mail sent to {sender_recipient}")
+    mid, mail_errors = await _send_vote_end_notify_mail(task_args.recipient_id, subject, body)
 
     return results.VoteEndNotify(
         kind="vote_end_notify",
@@ -217,6 +247,14 @@ def _auto_resolve_skipped(reason: str) -> results.VoteAutoResolve:
     )
 
 
+def _auto_resolve_supported(committee: sql.Committee | None, podling_thread_id: str | None) -> bool:
+    if committee is None:
+        return False
+    if not committee.is_podling:
+        return True
+    return podling_thread_id is not None
+
+
 def _end_notify_skipped(reason: str) -> results.VoteEndNotify:
     return results.VoteEndNotify(
         kind="vote_end_notify",
@@ -339,7 +377,7 @@ async def _initiate_core_logic(task_args: args.Initiate) -> results.Results | No
             (task_args.vote_seq is not None)
             and (vote_duration_hours > 0)
             and (release.effective_vote_mode == sql.VoteMode.TRUSTED)
-            and (not release.committee.is_podling)
+            and _auto_resolve_supported(release.committee, release.podling_thread_id)
         ):
             try:
                 await _schedule_auto_resolve(
@@ -438,6 +476,68 @@ async def _schedule_end_notify(
     log.info(f"Vote end notify scheduled for {task_args.release_key} at {vote_end_str} (recipient={recipient_id})")
 
 
+async def _send_auto_resolve_outcome_notify(
+    release_key: str,
+    project_key: str,
+    version_key: str,
+    recipient_id: str,
+    outcome: Literal["passed", "failed", "failure_skipped", "resolve_rejected"],
+) -> None:
+    review_url = f"https://{config.get().APP_HOST}/vote/{project_key}/{version_key}"
+    subject = f"[ATR] Vote ended for {release_key}"
+    match outcome:
+        case "passed":
+            body = (
+                f"The vote for {release_key} has ended. "
+                "The vote passed and ATR has resolved it automatically. "
+                f"You can review the resolution at {review_url}"
+            )
+        case "failed":
+            body = (
+                f"The vote for {release_key} has ended. "
+                "The vote did not pass and ATR has resolved it automatically. "
+                f"You can review the resolution at {review_url}"
+            )
+        case "failure_skipped":
+            body = (
+                f"The vote for {release_key} has ended. "
+                "The vote did not pass and ATR has left it for you to resolve manually. "
+                f"You can resolve the vote at {review_url}"
+            )
+        case "resolve_rejected":
+            body = (
+                f"The vote for {release_key} has ended. "
+                "ATR could not resolve it automatically and has left it for you to resolve manually. "
+                f"You can resolve the vote at {review_url}"
+            )
+    try:
+        await _send_vote_end_notify_mail(recipient_id, subject, body)
+    except Exception as e:
+        log.warning(f"Vote automatic resolution notify mail failed for {release_key}: {e}")
+
+
+async def _send_vote_end_notify_mail(
+    recipient_id: str,
+    subject: str,
+    body: str,
+) -> tuple[str | None, list[str]]:
+    sender_recipient = f"{recipient_id}@apache.org"
+    message = mail.Message(
+        email_sender=sender_recipient,
+        email_to=sender_recipient,
+        subject=subject,
+        body=body,
+    )
+    async with storage.write(recipient_id) as write:
+        wafc = write.as_foundation_committer()
+        mid, mail_errors = await wafc.mail.send(message, mail.MailFooterCategory.AUTO)
+    if mail_errors:
+        log.warning(f"Vote end notify mail to {sender_recipient} produced warnings: {mail_errors}")
+    else:
+        log.info(f"Vote end notify mail sent to {sender_recipient}")
+    return mid, mail_errors
+
+
 async def _thread_id_for_vote_task(task_mid: str | None, task_recipient: str | None, resolver_id: str) -> str | None:
     if task_mid is None:
         return None
diff --git a/tests/unit/test_vote_auto_resolve.py b/tests/unit/test_vote_auto_resolve.py
new file mode 100644
index 00000000..4f9a0604
--- /dev/null
+++ b/tests/unit/test_vote_auto_resolve.py
@@ -0,0 +1,167 @@
+# 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 contextlib
+import datetime
+import unittest.mock as mock
+from types import SimpleNamespace
+
+import pytest
+
+import atr.models.args as args
+import atr.models.results as results
+import atr.models.sql as sql
+import atr.shared.resolve as resolve
+import atr.tasks.vote as vote
+
+
+@pytest.mark.asyncio
+async def test_auto_resolve_skips_when_vote_failed_and_flag_off(monkeypatch) -> None:
+    monkeypatch.setattr(vote, "AUTOMATICALLY_RESOLVE_ON_FAILURE", False)
+
+    release = SimpleNamespace(
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+        vote_resolved=None,
+        effective_vote_mode=sql.VoteMode.TRUSTED,
+        committee=SimpleNamespace(is_podling=False),
+        podling_thread_id=None,
+        current_vote_seq=1,
+        safe_project_key="project",
+        safe_version_key="1.0.0",
+    )
+    query = SimpleNamespace(get=mock.AsyncMock(return_value=release))
+    mock_data = SimpleNamespace(release=mock.MagicMock(return_value=query))
+
+    @contextlib.asynccontextmanager
+    async def _session():
+        yield mock_data
+
+    monkeypatch.setattr(vote.db, "session", _session)
+
+    latest_vote_task = SimpleNamespace(
+        result=results.VoteInitiate(
+            kind="vote_initiate",
+            message="ok",
+            email_to="dev@project.apache.org",
+            vote_end="2026-01-04 00:00:00 UTC",
+            subject="[VOTE] Release",
+            mid="mid@apache.org",
+            mail_send_warnings=[],
+        ),
+        task_args={"notify_when_finished": False},
+    )
+    monkeypatch.setattr(
+        vote.interaction,
+        "release_current_vote_task",
+        mock.AsyncMock(return_value=latest_vote_task),
+    )
+    monkeypatch.setattr(
+        vote.interaction,
+        "vote_end_get",
+        lambda _task: datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC),
+    )
+    monkeypatch.setattr(
+        vote.interaction,
+        "effective_trusted_ballots",
+        mock.AsyncMock(return_value=[]),
+    )
+    summary = SimpleNamespace(
+        binding_votes_yes=0,
+        binding_votes_no=0,
+        binding_votes_abstain=0,
+        non_binding_votes_yes=0,
+        non_binding_votes_no=0,
+        non_binding_votes_abstain=0,
+    )
+    monkeypatch.setattr(
+        vote.interaction,
+        "trusted_ballot_summary",
+        mock.AsyncMock(return_value=summary),
+    )
+    monkeypatch.setattr(vote.interaction, "trusted_vote_round", lambda _release: 1)
+    monkeypatch.setattr(vote.interaction, "task_mid_get", lambda _task: None)
+    monkeypatch.setattr(vote.interaction, "task_recipient_get", lambda _task: None)
+    monkeypatch.setattr(vote.tabulate, "binding_vote_passes", lambda yes, no: False)
+    monkeypatch.setattr(vote.user, "binding_terminology", lambda _round: ("Binding", "Non-binding"))
+
+    resolve_mock = mock.AsyncMock()
+
+    @contextlib.asynccontextmanager
+    async def _write_as_project_committee_member(*_args, **_kwargs):
+        yield SimpleNamespace(vote=SimpleNamespace(resolve=resolve_mock))
+
+    monkeypatch.setattr(
+        vote.storage,
+        "write_as_project_committee_member",
+        _write_as_project_committee_member,
+    )
+
+    result = await vote.auto_resolve(
+        args.VoteAutoResolve(
+            release_key="project-1.0.0",
+            vote_seq=1,
+            resolver_id="chair",
+            resolver_fullname="Chair",
+        ).model_dump()
+    )
+
+    assert isinstance(result, results.VoteAutoResolve)
+    assert result.resolved is False
+    assert result.vote_result == "failed"
+    assert result.skip_reason == "vote_failed_manual_resolution_required"
+    resolve_mock.assert_not_awaited()
+
+
+def test_auto_resolve_supported_handles_podling_rounds() -> None:
+    non_podling = sql.Committee(key="project", name="Project", is_podling=False)
+    podling = sql.Committee(key="podling", name="Podling", is_podling=True)
+
+    assert vote._auto_resolve_supported(non_podling, None) is True
+    assert vote._auto_resolve_supported(non_podling, "thread-abc") is True
+    assert vote._auto_resolve_supported(podling, None) is False
+    assert vote._auto_resolve_supported(podling, "thread-abc") is True
+    assert vote._auto_resolve_supported(None, None) is False
+
+
+def test_resolve_submit_form_carries_round_two_flags() -> None:
+    parsed = resolve.SubmitForm.model_validate(
+        {
+            "csrf_token": "token",
+            "variant": "submit",
+            "email_body": "body",
+            "vote_result": "Passed",
+            "automatic_resolve_when_finished": "on",
+            "notify_when_finished": "on",
+            "vote_mode": sql.VoteMode.TRUSTED,
+            "vote_seq": 1,
+        }
+    )
+    assert parsed.automatic_resolve_when_finished is True
+    assert parsed.notify_when_finished is True
+
+    default = resolve.SubmitForm.model_validate(
+        {
+            "csrf_token": "token",
+            "variant": "submit",
+            "email_body": "body",
+            "vote_result": "Passed",
+            "vote_mode": sql.VoteMode.TRUSTED,
+            "vote_seq": 1,
+        }
+    )
+    assert default.automatic_resolve_when_finished is False
+    assert default.notify_when_finished is False


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