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