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/08 19:16:29 UTC

(tooling-trusted-releases) 01/01: Automatically resolve trusted votes by default

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 2da7807a0a3ddf5a98ea3c38ff2e7e52e715e48b
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Fri May 8 20:04:57 2026 +0100

    Automatically resolve trusted votes by default
---
 atr/api/__init__.py                |   1 +
 atr/get/voting.py                  |  18 ++++
 atr/models/api.py                  |   1 +
 atr/models/args.py                 |  13 +++
 atr/models/results.py              |  12 +++
 atr/models/sql.py                  |   1 +
 atr/post/voting.py                 |  20 ++++-
 atr/shared/voting.py               |   5 ++
 atr/storage/writers/vote.py        |  52 +++++++++--
 atr/tasks/__init__.py              |   2 +
 atr/tasks/vote.py                  | 179 ++++++++++++++++++++++++++++++++++++-
 tests/unit/test_vote_end_notify.py |  19 ++--
 tests/unit/test_vote_recipients.py |   6 +-
 13 files changed, 312 insertions(+), 17 deletions(-)

diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index 2e74555a..bd5da39c 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -1738,6 +1738,7 @@ async def vote_start(
                 email_bcc=data.email_bcc,
                 second_round_email_to=data.second_round_email_to,
                 notify_when_finished=data.notify_when_finished,
+                automatic_resolve_when_finished=data.automatic_resolve_when_finished,
             )
     # except Exception as e:
     #     import traceback
diff --git a/atr/get/voting.py b/atr/get/voting.py
index 246c00b2..1fc7af8b 100644
--- a/atr/get/voting.py
+++ b/atr/get/voting.py
@@ -274,8 +274,26 @@ async def _render_page(
                 f"If enabled, ATR will send a reminder to {asf_uid}@apache.org when the {vote_label} finishes.",
             ],
         ]
+        if podling_vote_round is None:
+            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")[
+                    f"If enabled, ATR will resolve the {vote_label} automatically, "
+                    "using only ATR ballots, when the voting period ends.",
+                ],
+            ]
+        else:
+            skip.append("automatic_resolve_when_finished")
     else:
         skip.append("notify_when_finished")
+        skip.append("automatic_resolve_when_finished")
 
     vote_form = await form.render(
         model_cls=shared.voting.StartVotingForm,
diff --git a/atr/models/api.py b/atr/models/api.py
index 1c513ef7..1d632aab 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -683,6 +683,7 @@ class VoteStartArgs(schema.Strict):
     body: str = schema.example("The Apache Example team is pleased to announce the release of Example 0.0.1...")
     second_round_email_to: str | None = schema.default_example(None, "general@incubator.apache.org")
     notify_when_finished: bool = schema.default_example(False, False)
+    automatic_resolve_when_finished: bool = schema.default_example(False, False)
 
 
 class VoteStartResults(schema.Strict):
diff --git a/atr/models/args.py b/atr/models/args.py
index 9461cb89..6fe5c5b1 100644
--- a/atr/models/args.py
+++ b/atr/models/args.py
@@ -106,6 +106,10 @@ class Initiate(schema.Strict):
         default=False,
         description="Send a self addressed reminder email when the vote ends",
     )
+    automatic_resolve_when_finished: bool = pydantic.Field(
+        default=False,
+        description="Automatically resolve a non-podling Trusted Vote when the voting period ends",
+    )
 
 
 class MaintenanceArgs(schema.Strict):
@@ -186,6 +190,15 @@ class Update(schema.Strict):
     next_schedule_seconds: int = pydantic.Field(default=0, description="The next scheduled time")
 
 
+class VoteAutoResolve(schema.Strict):
+    """Arguments for the task to automatically resolve a vote."""
+
+    release_key: str = schema.description("The release key for the release that the vote belongs to")
+    vote_seq: int = schema.description("The vote sequence at the time of scheduling")
+    resolver_id: str = schema.description("ASF UID of the user who selected automatic resolution")
+    resolver_fullname: str = schema.description("Full name of the resolver, used in the resolution email")
+
+
 class VoteEndNotify(schema.Strict):
     """Arguments for the task to notify the user that a vote has ended."""
 
diff --git a/atr/models/results.py b/atr/models/results.py
index 87cbcb98..8a92a590 100644
--- a/atr/models/results.py
+++ b/atr/models/results.py
@@ -223,6 +223,17 @@ class SvnImportFiles(schema.Strict):
     msg: str = schema.description("The message from the SVN import")
 
 
+class VoteAutoResolve(schema.Strict):
+    """Result of the task to automatically resolve a vote."""
+
+    kind: Literal["vote_auto_resolve"] = schema.Field(alias="kind")
+    resolved: bool = schema.description("Whether the vote was actually resolved by this task")
+    vote_result: Literal["passed", "failed"] | None = schema.description("The decision posted")
+    skip_reason: str | None = schema.description("The reason why the resolution was skipped")
+    success_message: str | None = schema.description("Writer success message")
+    error_message: str | None = schema.description("Writer error message")
+
+
 class VoteEndNotify(schema.Strict):
     """Result of the task to notify the initiator that a vote has ended."""
 
@@ -274,6 +285,7 @@ Results = Annotated[
     | SBOMQsScore
     | SBOMToolScore
     | SvnImportFiles
+    | VoteAutoResolve
     | VoteEndNotify
     | VoteInitiate,
     schema.Field(discriminator="kind"),
diff --git a/atr/models/sql.py b/atr/models/sql.py
index bb472d0e..8627e48e 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -224,6 +224,7 @@ class TaskType(enum.StrEnum):
     SVN_IMPORT_FILES = "svn_import_files"
     TARGZ_INTEGRITY = "targz_integrity"
     TARGZ_STRUCTURE = "targz_structure"
+    VOTE_AUTO_RESOLVE = "vote_auto_resolve"
     VOTE_END_NOTIFY = "vote_end_notify"
     VOTE_INITIATE = "vote_initiate"
     WORKFLOW_STATUS = "workflow_status"
diff --git a/atr/post/voting.py b/atr/post/voting.py
index 325838d9..09b2a268 100644
--- a/atr/post/voting.py
+++ b/atr/post/voting.py
@@ -122,7 +122,13 @@ async def selected_revision(
                 "The vote mode has changed since you loaded the form. Please reload and try again.",
             )
 
-        if (notify_error := await _notify_opt_in_error(session, start_voting_form, vote_mode)) is not None:
+        notify_error = await _notify_opt_in_error(
+            session,
+            start_voting_form,
+            vote_mode,
+            committee.is_podling,
+        )
+        if notify_error is not None:
             return notify_error
 
         permitted_recipients = util.permitted_podling_first_round_recipients(
@@ -185,6 +191,7 @@ async def selected_revision(
                 second_round_email_to=second_round_email_to,
                 expected_vote_mode=start_voting_form.vote_mode,
                 notify_when_finished=start_voting_form.notify_when_finished,
+                automatic_resolve_when_finished=start_voting_form.automatic_resolve_when_finished,
             )
 
         log.info(f"Vote email will be sent to: {all_addrs}")
@@ -200,10 +207,21 @@ async def _notify_opt_in_error(
     session: web.Committer,
     start_voting_form: shared.voting.StartVotingForm,
     vote_mode: sql.VoteMode,
+    is_podling: bool,
 ) -> web.WerkzeugResponse | None:
     if start_voting_form.notify_when_finished and (vote_mode != sql.VoteMode.TRUSTED):
         return await session.form_error(
             "notify_when_finished",
             "Vote end reminders are only available in Trusted Vote mode.",
         )
+    if start_voting_form.automatic_resolve_when_finished and (vote_mode != sql.VoteMode.TRUSTED):
+        return await session.form_error(
+            "automatic_resolve_when_finished",
+            "Automatic vote resolution is only available in Trusted Vote mode.",
+        )
+    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.",
+        )
     return None
diff --git a/atr/shared/voting.py b/atr/shared/voting.py
index 2398e845..821510fe 100644
--- a/atr/shared/voting.py
+++ b/atr/shared/voting.py
@@ -44,6 +44,11 @@ class StartVotingForm(form.Form):
         widget=form.Widget.CUSTOM,
         default=False,
     )
+    automatic_resolve_when_finished: form.Bool = form.label(
+        "Automatically resolve this vote",
+        widget=form.Widget.CUSTOM,
+        default=False,
+    )
     vote_mode: sql.VoteMode = form.label("Vote mode", widget=form.Widget.HIDDEN)
 
     @pydantic.field_validator("vote_duration")
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index 98fcc05b..e6b8a041 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -307,6 +307,7 @@ class CommitteeParticipant(FoundationCommitter):
         second_round_email_to: str | None = None,
         expected_vote_mode: sql.VoteMode | None = None,
         notify_when_finished: bool = False,
+        automatic_resolve_when_finished: bool = False,
     ) -> sql.Task:
         if promote:
             await self.__data.begin_immediate()
@@ -340,15 +341,28 @@ class CommitteeParticipant(FoundationCommitter):
                     promote=False,
                     expected_podling_thread_id=release.podling_thread_id,
                 )
-            if release.committee is None:
+            committee = await self._committee_for_release(release)
+            if committee is None:
                 raise storage.AccessError("Release has no committee", status=500)
             if notify_when_finished and (vote_mode != sql.VoteMode.TRUSTED):
                 raise storage.AccessError("Vote end reminders are only available in Trusted Vote mode", status=403)
+            if automatic_resolve_when_finished and (vote_mode != sql.VoteMode.TRUSTED):
+                raise storage.AccessError(
+                    "Automatic vote resolution is only available in Trusted Vote mode", status=403
+                )
+            if automatic_resolve_when_finished and committee.is_podling:
+                raise storage.AccessError(
+                    "Automatic vote resolution is not yet available for 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?
+                # Then we could use the relevant permissions class
+                raise storage.AccessError("Automatic vote resolution requires a committee member initiator", status=403)
             if permitted_recipients is None:
                 permitted_recipients = util.permitted_podling_first_round_recipients(
                     self.__asf_uid,
-                    release.committee.key,
-                    is_podling=release.committee.is_podling,
+                    committee.key,
+                    is_podling=committee.is_podling,
                 )
             all_addrs = [email_to] + (email_cc or []) + (email_bcc or [])
             for addr in all_addrs:
@@ -390,6 +404,7 @@ class CommitteeParticipant(FoundationCommitter):
                     email_bcc=email_bcc or [],
                     second_round_email_to=second_round_email_to,
                     notify_when_finished=notify_when_finished,
+                    automatic_resolve_when_finished=automatic_resolve_when_finished,
                 ).model_dump(),
                 asf_uid=self.__asf_uid,
                 project_key=str(project_key),
@@ -417,6 +432,12 @@ class CommitteeParticipant(FoundationCommitter):
         # (The message should be shown on the vote resolution page)
         return task
 
+    async def _committee_for_release(self, release: sql.Release) -> sql.Committee | None:
+        project = await self.__data.project(key=release.project_key, _committee=True).get()
+        if project is None:
+            return None
+        return project.committee
+
 
 class CommitteeMember(CommitteeParticipant):
     def __init__(
@@ -1070,13 +1091,13 @@ class CommitteeMember(CommitteeParticipant):
         if getattr(result, "rowcount", 0) != 1:
             await self.__data.rollback()
             raise storage.AccessError("The release state has changed, please refresh and try again", status=409)
-        await self._cancel_pending_end_notify(release)
+        await self._cancel_pending_vote_followups(release)
 
-    async def _cancel_pending_end_notify(self, release: sql.Release) -> None:
+    async def _cancel_pending_vote_followups(self, release: sql.Release) -> None:
         # There is no CANCELLED status for tasks
-        # The best alternatives are to either delete it, or mark it as FAILED
-        # Deleting it is simpler, but then we have no record of the task
-        # Therefore, although it's not exactly correct, we mark it as FAILED
+        # The best alternatives are to either delete them, or mark them as FAILED
+        # Deleting them is simpler, but then we have no record of the tasks
+        # Therefore, although it's not exactly correct, we mark them as FAILED
         via = sql.validate_instrumented_attribute
         stmt = (
             sqlmodel.update(sql.Task)
@@ -1093,6 +1114,21 @@ class CommitteeMember(CommitteeParticipant):
             )
         )
         await self.__data.execute(stmt)
+        auto_resolve_stmt = (
+            sqlmodel.update(sql.Task)
+            .where(
+                via(sql.Task.task_type) == sql.TaskType.VOTE_AUTO_RESOLVE,
+                via(sql.Task.status) == sql.TaskStatus.QUEUED,
+                via(sql.Task.project_key) == release.project.key,
+                via(sql.Task.version_key) == release.version,
+            )
+            .values(
+                status=sql.TaskStatus.FAILED,
+                completed=datetime.datetime.now(datetime.UTC),
+                error="Vote resolved before auto-resolution fired",
+            )
+        )
+        await self.__data.execute(auto_resolve_stmt)
 
     # def __committee_member_or_admin(self, committee: sql.Committee, asf_uid: str) -> None:
     #     if not (user.is_committee_member(committee, asf_uid) or user.is_admin(asf_uid)):
diff --git a/atr/tasks/__init__.py b/atr/tasks/__init__.py
index 8c42f50c..3b1b8974 100644
--- a/atr/tasks/__init__.py
+++ b/atr/tasks/__init__.py
@@ -353,6 +353,8 @@ def resolve(task_type: sql.TaskType) -> Callable[..., Awaitable[results.Results
             raise ValueError("TARGZ_INTEGRITY check has been removed; quarantine extraction validates integrity")
         case sql.TaskType.TARGZ_STRUCTURE:
             return targz.structure
+        case sql.TaskType.VOTE_AUTO_RESOLVE:
+            return vote.auto_resolve
         case sql.TaskType.VOTE_END_NOTIFY:
             return vote.end_notify
         case sql.TaskType.VOTE_INITIATE:
diff --git a/atr/tasks/vote.py b/atr/tasks/vote.py
index 7827b81b..d57b147b 100644
--- a/atr/tasks/vote.py
+++ b/atr/tasks/vote.py
@@ -27,7 +27,9 @@ 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.tabulate as tabulate
 import atr.tasks.checks as checks
+import atr.user as user
 import atr.util as util
 
 
@@ -35,6 +37,105 @@ class VoteInitiationError(Exception):
     pass
 
 
+@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."""
+    async with db.session() as data:
+        release = await data.release(
+            key=task_args.release_key,
+            _project=True,
+            _committee=True,
+            _release_policy=True,
+            _project_release_policy=True,
+        ).get()
+
+        def _why(label: str, reason: str) -> results.VoteAutoResolve:
+            log.info(f"Vote automatic resolution skipped for {task_args.release_key}: {reason}")
+            return _auto_resolve_skipped(label)
+
+        if release is None:
+            return _why("release_not_found", "release not found")
+        if release.vote_resolved is not None:
+            return _why("already_resolved", "already resolved")
+        if release.phase != sql.ReleasePhase.RELEASE_CANDIDATE:
+            return _why("not_in_candidate_phase", "not in candidate phase")
+        if release.effective_vote_mode != sql.VoteMode.TRUSTED:
+            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 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})")
+
+        latest_vote_task = await interaction.release_current_vote_task(release, data)
+        if latest_vote_task is None:
+            return _why("vote_task_missing", "vote task missing")
+        if not isinstance(latest_vote_task.result, results.VoteInitiate):
+            return _why("vote_task_result_unavailable", "vote task result unavailable")
+        vote_end = interaction.vote_end_get(latest_vote_task)
+        if vote_end is None:
+            return _why("vote_end_unavailable", "vote end unavailable")
+        if datetime.datetime.now(datetime.UTC) < vote_end:
+            return _why("vote_not_ended", "vote has not ended")
+
+        vote_seq = task_args.vote_seq
+
+        ballots = await interaction.effective_trusted_ballots(release, vote_seq, data)
+        summary = await interaction.trusted_ballot_summary(release, ballots, data)
+        passed = tabulate.binding_vote_passes(summary.binding_votes_yes, summary.binding_votes_no)
+        vote_result = "passed" if passed else "failed"
+        vote_round = interaction.trusted_vote_round(release)
+        binding_label, non_binding_label = user.binding_terminology(vote_round)
+        project_key = release.safe_project_key
+        version_key = release.safe_version_key
+        task_mid = interaction.task_mid_get(latest_vote_task)
+        task_recipient = interaction.task_recipient_get(latest_vote_task)
+
+    thread_id = await _thread_id_for_vote_task(task_mid, task_recipient, task_args.resolver_id)
+    resolution_body = tabulate.trusted_vote_resolution(
+        release,
+        summary,
+        passed,
+        task_args.resolver_fullname,
+        task_args.resolver_id,
+        thread_id,
+        binding_label,
+        non_binding_label,
+    )
+
+    try:
+        async with storage.write_as_project_committee_member(project_key, task_args.resolver_id) as wacm:
+            _release, _voting_round, success_message, error_message = await wacm.vote.resolve(
+                project_key,
+                version_key,
+                vote_result,
+                task_args.resolver_fullname,
+                resolution_body,
+                expected_vote_seq=vote_seq,
+                expected_vote_mode=sql.VoteMode.TRUSTED,
+            )
+    except storage.AccessError as e:
+        log.info(f"Vote automatic resolution skipped for {task_args.release_key}: writer rejected resolution: {e}")
+        return results.VoteAutoResolve(
+            kind="vote_auto_resolve",
+            resolved=False,
+            vote_result=vote_result,
+            skip_reason="resolve_rejected",
+            success_message=None,
+            error_message=str(e),
+        )
+
+    return results.VoteAutoResolve(
+        kind="vote_auto_resolve",
+        resolved=True,
+        vote_result=vote_result,
+        skip_reason=None,
+        success_message=success_message,
+        error_message=error_message,
+    )
+
+
 @checks.with_model(args.VoteEndNotify)
 async def end_notify(task_args: args.VoteEndNotify) -> results.Results | None:
     """Send a self addressed reminder when a vote ends, if it has not been resolved."""
@@ -105,6 +206,17 @@ async def initiate(task_args: args.Initiate) -> results.Results | None:
         raise
 
 
+def _auto_resolve_skipped(reason: str) -> results.VoteAutoResolve:
+    return results.VoteAutoResolve(
+        kind="vote_auto_resolve",
+        resolved=False,
+        vote_result=None,
+        skip_reason=reason,
+        success_message=None,
+        error_message=None,
+    )
+
+
 def _end_notify_skipped(reason: str) -> results.VoteEndNotify:
     return results.VoteEndNotify(
         kind="vote_end_notify",
@@ -222,7 +334,27 @@ async def _initiate_core_logic(task_args: args.Initiate) -> results.Results | No
     else:
         log.info(f"Vote email sent successfully to {all_destinations}")
 
-    if (
+    if task_args.automatic_resolve_when_finished:
+        if (
+            (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)
+        ):
+            try:
+                await _schedule_auto_resolve(
+                    task_args=task_args,
+                    project_key=release.project.key,
+                    version_key=release.version,
+                    vote_end=vote_end,
+                )
+            except Exception as schedule_error:
+                log.exception(
+                    f"Vote automatic resolution could not be scheduled for {task_args.release_key}: {schedule_error}"
+                )
+        else:
+            log.info(f"Vote automatic resolution not scheduled for {task_args.release_key}: unsupported vote state")
+    elif (
         task_args.notify_when_finished
         and (task_args.vote_seq is not None)
         and (vote_duration_hours > 0)
@@ -244,6 +376,36 @@ async def _initiate_core_logic(task_args: args.Initiate) -> results.Results | No
     return result
 
 
+async def _schedule_auto_resolve(
+    *,
+    task_args: args.Initiate,
+    project_key: str,
+    version_key: str,
+    vote_end: datetime.datetime,
+) -> None:
+    if task_args.vote_seq is None:
+        return
+    resolve_args = args.VoteAutoResolve(
+        release_key=task_args.release_key,
+        vote_seq=task_args.vote_seq,
+        resolver_id=task_args.initiator_id,
+        resolver_fullname=task_args.initiator_fullname,
+    )
+    resolve_task = sql.Task(
+        status=sql.TaskStatus.QUEUED,
+        task_type=sql.TaskType.VOTE_AUTO_RESOLVE,
+        task_args=resolve_args.model_dump(),
+        asf_uid=task_args.initiator_id,
+        project_key=project_key,
+        version_key=version_key,
+    )
+    resolve_task.scheduled = vote_end
+    async with db.session() as data:
+        data.add(resolve_task)
+        await data.commit()
+    log.info(f"Vote automatic resolution scheduled for {task_args.release_key} at {vote_end.isoformat()}")
+
+
 async def _schedule_end_notify(
     *,
     task_args: args.Initiate,
@@ -274,3 +436,18 @@ async def _schedule_end_notify(
         data.add(notify_task)
         await data.commit()
     log.info(f"Vote end notify scheduled for {task_args.release_key} at {vote_end_str} (recipient={recipient_id})")
+
+
+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
+    try:
+        async with storage.write(resolver_id) as write:
+            wagp = write.as_general_public()
+            archive_url = await wagp.cache.get_message_archive_url(task_mid, task_recipient)
+    except Exception as e:
+        log.warning(f"Vote automatic resolution could not find vote thread URL for {task_mid}: {e}")
+        return None
+    if archive_url is None:
+        return None
+    return archive_url.split("/")[-1]
diff --git a/tests/unit/test_vote_end_notify.py b/tests/unit/test_vote_end_notify.py
index be5acbf0..299124be 100644
--- a/tests/unit/test_vote_end_notify.py
+++ b/tests/unit/test_vote_end_notify.py
@@ -54,7 +54,7 @@ async def sqlite_sessionmaker() -> AsyncIterator[sqlalchemy.ext.asyncio.async_se
 
 
 @pytest.mark.asyncio
-async def test_cancel_pending_end_notify_marks_queued_tasks_failed(sqlite_sessionmaker) -> None:
+async def test_cancel_pending_vote_followups_marks_queued_tasks_failed(sqlite_sessionmaker) -> None:
     async with sqlite_sessionmaker() as data:
         seeded = await _seed_release(
             data,
@@ -83,7 +83,7 @@ async def test_cancel_pending_end_notify_marks_queued_tasks_failed(sqlite_sessio
 
         writer = object.__new__(vote_writer.CommitteeMember)
         writer._CommitteeMember__data = data
-        await writer._cancel_pending_end_notify(seeded)
+        await writer._cancel_pending_vote_followups(seeded)
         await data.commit()
 
     async with sqlite_sessionmaker() as data:
@@ -517,10 +517,13 @@ async def test_writer_start_rejects_notify_outside_trusted_mode() -> None:
     data.begin_immediate = mock.AsyncMock()
     data.commit = mock.AsyncMock()
     data.rollback = mock.AsyncMock()
+    committee = SimpleNamespace(key="project", is_podling=False, committee_members=["chair"])
+    data.project = mock.MagicMock(
+        return_value=SimpleNamespace(get=mock.AsyncMock(return_value=SimpleNamespace(committee=committee)))
+    )
 
     release_for_start = SimpleNamespace(
-        committee=SimpleNamespace(key="project", is_podling=False),
-        project=SimpleNamespace(key="project"),
+        project_key="project",
         version="1.0.0",
         key="project-1.0.0",
     )
@@ -552,7 +555,11 @@ async def test_writer_start_rejects_notify_outside_trusted_mode() -> None:
     data.commit.assert_not_awaited()
 
 
-def _initiate_release_namespace(vote_mode: sql.VoteMode = sql.VoteMode.TRUSTED) -> SimpleNamespace:
+def _initiate_release_namespace(
+    vote_mode: sql.VoteMode = sql.VoteMode.TRUSTED,
+    *,
+    committee_key: str = "project",
+) -> SimpleNamespace:
     return SimpleNamespace(
         phase=sql.ReleasePhase.RELEASE_CANDIDATE,
         current_vote_seq=1,
@@ -562,7 +569,7 @@ def _initiate_release_namespace(vote_mode: sql.VoteMode = sql.VoteMode.TRUSTED)
         safe_project_key=SimpleNamespace(__str__=lambda self: "project"),
         safe_version_key=SimpleNamespace(__str__=lambda self: "1.0.0"),
         safe_latest_revision_number=SimpleNamespace(__str__=lambda self: "00001"),
-        committee=SimpleNamespace(is_podling=False, key="project"),
+        committee=SimpleNamespace(is_podling=False, key=committee_key),
         podling_thread_id=None,
         effective_vote_mode=vote_mode,
     )
diff --git a/tests/unit/test_vote_recipients.py b/tests/unit/test_vote_recipients.py
index dcfa79a8..9876d98f 100644
--- a/tests/unit/test_vote_recipients.py
+++ b/tests/unit/test_vote_recipients.py
@@ -146,7 +146,11 @@ async def test_start_email_vote_sets_vote_seq_on_task() -> None:
 
     release = SimpleNamespace(
         key="project-1.0.0",
-        committee=SimpleNamespace(key="project", is_podling=False),
+        project_key="project",
+    )
+    committee = SimpleNamespace(key="project", is_podling=False, committee_members=["chair"])
+    data.project = mock.MagicMock(
+        return_value=SimpleNamespace(get=mock.AsyncMock(return_value=SimpleNamespace(committee=committee)))
     )
     release_writer = SimpleNamespace(
         _start_vote_no_commit=mock.AsyncMock(return_value=(release, 7, sql.VoteMode.EMAIL)),


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