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