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/04/29 15:34:57 UTC
(tooling-trusted-releases) branch sbp updated: Make starting a vote serial and atomic
This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
The following commit(s) were added to refs/heads/sbp by this push:
new ecc34ec8 Make starting a vote serial and atomic
ecc34ec8 is described below
commit ecc34ec8b562b355fd193251fda6f4520b1ecb64
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Wed Apr 29 16:34:22 2026 +0100
Make starting a vote serial and atomic
---
atr/api/__init__.py | 18 +-
atr/db/interaction.py | 19 +++
atr/get/resolve.py | 2 +-
atr/get/vote.py | 2 +-
atr/models/args.py | 1 +
atr/post/manual.py | 5 +-
atr/storage/writers/release.py | 145 +++++++++++-----
atr/storage/writers/vote.py | 332 +++++++++++++++++++++----------------
atr/tasks/vote.py | 10 +-
tests/unit/test_vote_recipients.py | 100 ++++++++++-
tests/unit/test_vote_resolution.py | 13 +-
tests/unit/test_vote_serials.py | 250 ++++++++++++++++++++++++++++
12 files changed, 697 insertions(+), 200 deletions(-)
diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index 85c0702a..c068f9be 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -1573,15 +1573,17 @@ async def vote_start(
asf_uid = _jwt_asf_uid()
try:
- async with storage.write(asf_uid) as write:
- wacp = await write.as_project_committee_participant(data.project)
- async with db.session() as read_data:
- committee = await read_data.committee(key=wacp.committee_key).demand(
- storage.AccessError(f"Committee not found: {wacp.committee_key}")
- )
+ async with db.session() as db_data:
+ project = await db_data.project(key=str(data.project), _committee=True).demand(
+ storage.AccessError(f"Project not found: {data.project}")
+ )
+ committee = project.committee
+ if committee is None:
+ raise storage.AccessError("No committee found for project - Invalid state")
+ async with storage.write_as_committee_participant(committee.key, asf_uid) as wacp:
permitted_recipients = util.permitted_podling_first_round_recipients(
asf_uid,
- wacp.committee_key,
+ committee.key,
is_podling=committee.is_podling,
)
if data.email_to not in permitted_recipients:
@@ -1635,7 +1637,7 @@ async def vote_tabulate(
exceptions.NotFound(f"Release {release_key} not found"),
)
- latest_vote_task = await interaction.release_latest_vote_task(release)
+ latest_vote_task = await interaction.release_current_vote_task(release)
if latest_vote_task is None:
raise exceptions.NotFound("No vote task found")
task_mid = interaction.task_mid_get(latest_vote_task)
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 7575b15c..9f634163 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -265,6 +265,25 @@ async def previews(project: sql.Project) -> list[sql.Release]:
return await releases_by_phase(project, sql.ReleasePhase.RELEASE_PREVIEW)
+async def release_current_vote_task(release: sql.Release, caller_data: db.Session | None = None) -> sql.Task | None:
+ current_vote_seq = getattr(release, "current_vote_seq", None)
+ if current_vote_seq is None:
+ return await release_latest_vote_task(release, caller_data)
+ via = sql.validate_instrumented_attribute
+ async with db.ensure_session(caller_data) as data:
+ query = (
+ sqlmodel.select(sql.Task)
+ .where(sql.Task.project_key == release.project_key)
+ .where(sql.Task.version_key == release.version)
+ .where(sql.Task.task_type == sql.TaskType.VOTE_INITIATE)
+ .where(sqlalchemy.func.json_extract(sql.Task.task_args, "$.vote_seq") == current_vote_seq)
+ .order_by(via(sql.Task.added).desc())
+ .limit(1)
+ )
+ task = (await data.execute(query)).scalar_one_or_none()
+ return task
+
+
async def release_latest_vote_task(release: sql.Release, caller_data: db.Session | None = None) -> sql.Task | None:
"""Find the most recent VOTE_INITIATE task for this release."""
via = sql.validate_instrumented_attribute
diff --git a/atr/get/resolve.py b/atr/get/resolve.py
index b2878c7b..04a9b73e 100644
--- a/atr/get/resolve.py
+++ b/atr/get/resolve.py
@@ -64,7 +64,7 @@ async def selected( # noqa: C901
archive_url = None
fetch_error = None
- latest_vote_task = await interaction.release_latest_vote_task(release)
+ latest_vote_task = await interaction.release_current_vote_task(release)
if latest_vote_task is not None:
task_mid = interaction.task_mid_get(latest_vote_task)
task_recipient = interaction.task_recipient_get(latest_vote_task)
diff --git a/atr/get/vote.py b/atr/get/vote.py
index 4f8da2aa..8cccde4d 100644
--- a/atr/get/vote.py
+++ b/atr/get/vote.py
@@ -76,7 +76,7 @@ async def category_and_release(
if release.committee is None:
raise ValueError("Release has no committee")
- latest_vote_task = await interaction.release_latest_vote_task(release, data)
+ latest_vote_task = await interaction.release_current_vote_task(release, data)
vote_initiator_uid: str | None = None
if latest_vote_task is not None:
vote_initiator_uid = latest_vote_task.task_args.get("initiator_id")
diff --git a/atr/models/args.py b/atr/models/args.py
index 0a7713b0..d3ca8045 100644
--- a/atr/models/args.py
+++ b/atr/models/args.py
@@ -95,6 +95,7 @@ class Initiate(schema.Strict):
initiator_fullname: str = schema.description("Full name of the vote initiator")
subject: str = schema.description("Subject line for the vote email")
body: str = schema.description("Body content for the vote email")
+ vote_seq: int | None = None
email_cc: list[pydantic.EmailStr] = schema.factory(list)
email_bcc: list[pydantic.EmailStr] = schema.factory(list)
second_round_email_to: pydantic.EmailStr | None = pydantic.Field(
diff --git a/atr/post/manual.py b/atr/post/manual.py
index 00063864..5b8d51ac 100644
--- a/atr/post/manual.py
+++ b/atr/post/manual.py
@@ -108,11 +108,10 @@ async def start_selected_revision(
project_key=str(project_key),
version_key=str(version_key),
)
- case (release, _committee):
+ case (release, committee):
pass
- async with storage.write(session) as write:
- wacp = await write.as_project_committee_participant(release.safe_project_key)
+ async with storage.write_as_committee_participant(committee.key, session) as wacp:
error = await wacp.release.promote_to_candidate(
release.safe_key,
revision,
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index 49048f4a..203d3e43 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -26,12 +26,14 @@ from typing import TYPE_CHECKING, Any, Final
import aiofiles.os
import sqlalchemy
+import sqlalchemy.dialects.sqlite as sqlite
import sqlalchemy.engine as engine
import sqlmodel
import atr.analysis as analysis
import atr.config as config
import atr.db as db
+import atr.db.interaction as interaction
import atr.hashes as hashes
import atr.log as log
import atr.models.api as api
@@ -218,7 +220,11 @@ class CommitteeParticipant(FoundationCommitter):
via(sql.RevisionCounter.release_key) == release_key
)
await self.__data.execute(counter_delete_stmt)
- log.info(f"Deleted revision counter for test release: {release_key}")
+ vote_counter_delete_stmt = sqlmodel.delete(sql.VoteCounter).where(
+ via(sql.VoteCounter.release_key) == release_key
+ )
+ await self.__data.execute(vote_counter_delete_stmt)
+ log.info(f"Deleted revision and vote counters for test release: {release_key}")
# Filesystem deletions are more likely to have permission errors than database deletions
# Therefore we do filesystem deletions first
@@ -449,66 +455,129 @@ class CommitteeParticipant(FoundationCommitter):
allowed_vote_modes: frozenset[sql.VoteMode],
) -> str | None:
"""Promote a release candidate draft to a new phase."""
+ try:
+ await self.__data.begin_immediate()
+ release, vote_seq, vote_mode = await self._start_vote_no_commit(
+ release_key,
+ selected_revision_number,
+ allowed_vote_modes=allowed_vote_modes,
+ promote=True,
+ )
+ await self.__data.commit()
+ except storage.AccessError as e:
+ await self.__data.rollback()
+ return str(e)
+ except Exception:
+ await self.__data.rollback()
+ raise
+
+ self.__write_as.append_to_audit_log(
+ asf_uid=self.__asf_uid,
+ release_key=release.key,
+ selected_revision_number=str(selected_revision_number),
+ vote_seq=vote_seq,
+ vote_mode=vote_mode.value,
+ )
+ return None
+
+ async def _start_vote_no_commit( # noqa: C901
+ self,
+ release_key: safe.ReleaseKey,
+ selected_revision_number: safe.RevisionNumber,
+ *,
+ allowed_vote_modes: frozenset[sql.VoteMode],
+ promote: bool,
+ expected_podling_thread_id: str | None = None,
+ ) -> tuple[sql.Release, int, sql.VoteMode]:
release_for_pre_checks = await self.__data.release(
- key=str(release_key), _project=True, _project_release_policy=True
+ key=str(release_key), _project=True, _committee=True, _project_release_policy=True
).demand(storage.AccessError("Release candidate draft not found"))
project_key = release_for_pre_checks.safe_project_key
version_key = release_for_pre_checks.safe_version_key
revision_number = release_for_pre_checks.safe_latest_revision_number
- vote_mode = release_for_pre_checks.effective_vote_mode
+ if promote:
+ vote_mode = release_for_pre_checks.effective_vote_mode
+ else:
+ vote_mode = release_for_pre_checks.vote_mode
+ if vote_mode is None:
+ raise storage.AccessError("The release state has changed, please refresh and try again")
if vote_mode not in allowed_vote_modes:
- return "This release's vote mode does not allow that action"
+ raise storage.AccessError("This release's vote mode does not allow that action")
# Check for ongoing tasks
- ongoing_tasks = await self.__tasks_ongoing(project_key, version_key, selected_revision_number)
- if ongoing_tasks > 0:
- return "All checks must be completed before starting a vote"
+ if promote:
+ ongoing_tasks = await self.__tasks_ongoing(project_key, version_key, selected_revision_number)
+ if ongoing_tasks > 0:
+ raise storage.AccessError("All checks must be completed before starting a vote")
# Verify that it's in the correct phase
- if release_for_pre_checks.phase != sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
- return "This release is not in the candidate draft phase"
+ expected_phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+ if not promote:
+ expected_phase = sql.ReleasePhase.RELEASE_CANDIDATE
+ if release_for_pre_checks.phase != expected_phase:
+ if promote:
+ raise storage.AccessError("This release is not in the candidate draft phase")
+ raise storage.AccessError("The release state has changed, please refresh and try again")
# Check that the revision number is the latest
if revision_number != selected_revision_number:
- return "The selected revision number does not match the latest revision number"
+ raise storage.AccessError("The selected revision number does not match the latest revision number")
+
+ if await interaction.has_blocker_checks(
+ release_for_pre_checks, selected_revision_number, caller_data=self.__data
+ ):
+ raise storage.AccessError(
+ "This release candidate draft has blockers. Please fix the blockers before starting a vote."
+ )
# Check that there is at least one file in the draft
- file_count = await util.number_of_release_files(release_for_pre_checks)
- if file_count == 0:
- return "This candidate draft is empty, containing no files"
+ if promote:
+ file_count = await util.number_of_release_files(release_for_pre_checks)
+ if file_count == 0:
+ raise storage.AccessError("This candidate draft is empty, containing no files")
# Promote it to RELEASE_CANDIDATE
via = sql.validate_instrumented_attribute
- stmt = (
- sqlmodel.update(sql.Release)
- .where(
- via(sql.Release.key) == release_for_pre_checks.key,
- via(sql.Release.phase) == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
- sql.latest_revision_number_query() == str(selected_revision_number),
- )
- .values(
- phase=sql.ReleasePhase.RELEASE_CANDIDATE,
- vote_mode=vote_mode,
- vote_started=datetime.datetime.now(datetime.UTC),
- vote_resolved=None,
- )
+ vote_seq = await self.__vote_seq_allocate(release_for_pre_checks.key)
+ values: dict[str, object] = {
+ "vote_started": datetime.datetime.now(datetime.UTC),
+ "vote_resolved": None,
+ "current_vote_seq": vote_seq,
+ }
+ if promote:
+ values["phase"] = sql.ReleasePhase.RELEASE_CANDIDATE
+ values["vote_mode"] = vote_mode
+ stmt = sqlmodel.update(sql.Release).where(
+ via(sql.Release.key) == release_for_pre_checks.key,
+ via(sql.Release.phase) == expected_phase,
+ sql.latest_revision_number_query() == str(selected_revision_number),
)
-
- result = await self.__data.execute(stmt)
+ if not promote:
+ if expected_podling_thread_id is None:
+ stmt = stmt.where(via(sql.Release.podling_thread_id).is_(None))
+ else:
+ stmt = stmt.where(via(sql.Release.podling_thread_id) == expected_podling_thread_id)
+ result = await self.__data.execute(stmt.values(**values))
if not isinstance(result, engine.CursorResult):
log.error(f"Expected cursor result, got {type(result)}")
- return "An error occurred while promoting the release candidate"
+ raise storage.AccessError("An error occurred while promoting the release candidate")
if result.rowcount != 1:
- await self.__data.rollback()
- return "A newer revision appeared, please refresh and try again."
- await self.__data.commit()
- self.__write_as.append_to_audit_log(
- asf_uid=self.__asf_uid,
- release_key=str(release_key),
- selected_revision_number=str(selected_revision_number),
- vote_mode=vote_mode.value,
+ raise storage.AccessError("A newer revision appeared, please refresh and try again.")
+ await self.__data.refresh(release_for_pre_checks)
+ return release_for_pre_checks, vote_seq, vote_mode
+
+ async def __vote_seq_allocate(self, release_key: str) -> int:
+ upsert_stmt = (
+ sqlite.insert(sql.VoteCounter)
+ .values(release_key=release_key, last_allocated_number=1)
+ .on_conflict_do_update(
+ index_elements=["release_key"],
+ set_={"last_allocated_number": sqlalchemy.text("last_allocated_number + 1")},
+ )
+ .returning(sqlalchemy.literal_column("last_allocated_number"))
)
- return None
+ result = await self.__data.execute(upsert_stmt)
+ return int(result.scalar_one())
async def remove_rc_tags(
self, project_key: safe.ProjectKey, version_key: safe.VersionKey
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index 3fac6225..dd4ca44a 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -88,7 +88,7 @@ class CommitteeParticipant(FoundationCommitter):
is_binding: bool = False,
) -> tuple[list[str], str]:
# Get the email thread
- latest_vote_task = await interaction.release_latest_vote_task(release)
+ latest_vote_task = await interaction.release_current_vote_task(release)
if latest_vote_task is None:
return [], "No vote task found."
vote_thread_mid = interaction.task_mid_get(latest_vote_task)
@@ -159,85 +159,106 @@ class CommitteeParticipant(FoundationCommitter):
second_round_email_to: str | None = None,
expected_vote_mode: sql.VoteMode | None = None,
) -> sql.Task:
- if release is None:
- release = await self.__data.release(
- project_key=str(project_key),
- version=str(version_key),
- _project=True,
- _committee=True,
- ).demand(storage.AccessError("Release not found"))
- if release.committee is None:
- raise storage.AccessError("Release has no committee")
- 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,
- )
- all_addrs = [email_to] + (email_cc or []) + (email_bcc or [])
- for addr in all_addrs:
- if addr not in permitted_recipients:
- # This will be checked again by tasks/vote.py for extra safety
- log.info(f"Invalid mailing list choice: {addr} not in {permitted_recipients}")
- raise storage.AccessError("Invalid mailing list choice")
-
- if second_round_email_to is not None:
- second_round_permitted = util.permitted_podling_second_round_recipients(self.__asf_uid)
- if second_round_email_to not in second_round_permitted:
- log.info(
- f"Invalid second round mailing list choice: {second_round_email_to} not in {second_round_permitted}"
+ if promote:
+ await self.__data.begin_immediate()
+ try:
+ release_key = sql.release_key(project_key, version_key)
+ if promote is True:
+ # This verifies the state and sets the phase to RELEASE_CANDIDATE
+ allowed_vote_modes = (
+ frozenset({expected_vote_mode})
+ if expected_vote_mode is not None
+ else frozenset({sql.VoteMode.EMAIL, sql.VoteMode.TRUSTED})
)
- raise storage.AccessError("Invalid second round mailing list choice")
-
- if await interaction.has_blocker_checks(release, selected_revision_number, caller_data=self.__data):
- raise storage.AccessError(
- "This release candidate draft has blockers. Please fix the blockers before starting a vote."
- )
-
- if promote is True:
- # This verifies the state and sets the phase to RELEASE_CANDIDATE
- allowed_vote_modes = (
- frozenset({expected_vote_mode})
- if expected_vote_mode is not None
- else frozenset({sql.VoteMode.EMAIL, sql.VoteMode.TRUSTED})
- )
- error = await self.__write_as.release.promote_to_candidate(
- release.safe_key,
- selected_revision_number,
- allowed_vote_modes=allowed_vote_modes,
+ release, vote_seq, vote_mode = await self.__write_as.release._start_vote_no_commit(
+ release_key,
+ selected_revision_number,
+ allowed_vote_modes=allowed_vote_modes,
+ promote=True,
+ )
+ else:
+ if release is None:
+ release = await self.__data.release(
+ project_key=str(project_key),
+ version=str(version_key),
+ _project=True,
+ _committee=True,
+ ).demand(storage.AccessError("Release not found"))
+ release, vote_seq, vote_mode = await self.__write_as.release._start_vote_no_commit(
+ release.safe_key,
+ selected_revision_number,
+ allowed_vote_modes=frozenset({sql.VoteMode.EMAIL, sql.VoteMode.TRUSTED}),
+ promote=False,
+ expected_podling_thread_id=release.podling_thread_id,
+ )
+ if release.committee is None:
+ raise storage.AccessError("Release has no committee")
+ 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,
+ )
+ all_addrs = [email_to] + (email_cc or []) + (email_bcc or [])
+ for addr in all_addrs:
+ if addr not in permitted_recipients:
+ # This will be checked again by tasks/vote.py for extra safety
+ log.info(f"Invalid mailing list choice: {addr} not in {permitted_recipients}")
+ raise storage.AccessError("Invalid mailing list choice")
+
+ if second_round_email_to is not None:
+ second_round_permitted = util.permitted_podling_second_round_recipients(self.__asf_uid)
+ if second_round_email_to not in second_round_permitted:
+ log.info(
+ "Invalid second round mailing list choice: "
+ f"{second_round_email_to} not in {second_round_permitted}"
+ )
+ raise storage.AccessError("Invalid second round mailing list choice")
+
+ # TODO: We also need to store the duration of the vote
+ # We can't allow resolution of the vote until the duration has elapsed
+ # But we allow the user to specify in the form
+ # And yet we also have ReleasePolicy.min_hours
+ # Presumably this sets the default, and the form takes precedence?
+ # ReleasePolicy.min_hours can also be 0, though
+
+ # Create a task for vote initiation
+ task = sql.Task(
+ status=sql.TaskStatus.QUEUED,
+ task_type=sql.TaskType.VOTE_INITIATE,
+ task_args=args.Initiate(
+ release_key=release.key,
+ email_to=email_to,
+ vote_duration=vote_duration_choice,
+ initiator_id=self.__asf_uid,
+ initiator_fullname=asf_fullname,
+ subject=subject,
+ body=body_data,
+ vote_seq=vote_seq,
+ email_cc=email_cc or [],
+ email_bcc=email_bcc or [],
+ second_round_email_to=second_round_email_to,
+ ).model_dump(),
+ asf_uid=self.__asf_uid,
+ project_key=str(project_key),
+ version_key=str(version_key),
)
- if error:
- raise storage.AccessError(error)
-
- # TODO: We also need to store the duration of the vote
- # We can't allow resolution of the vote until the duration has elapsed
- # But we allow the user to specify in the form
- # And yet we also have ReleasePolicy.min_hours
- # Presumably this sets the default, and the form takes precedence?
- # ReleasePolicy.min_hours can also be 0, though
+ self.__data.add(task)
+ if promote:
+ await self.__data.commit()
+ except Exception:
+ if promote:
+ await self.__data.rollback()
+ raise
- # Create a task for vote initiation
- task = sql.Task(
- status=sql.TaskStatus.QUEUED,
- task_type=sql.TaskType.VOTE_INITIATE,
- task_args=args.Initiate(
+ if promote:
+ self.__write_as.append_to_audit_log(
+ asf_uid=self.__asf_uid,
release_key=release.key,
- email_to=email_to,
- vote_duration=vote_duration_choice,
- initiator_id=self.__asf_uid,
- initiator_fullname=asf_fullname,
- subject=subject,
- body=body_data,
- email_cc=email_cc or [],
- email_bcc=email_bcc or [],
- second_round_email_to=second_round_email_to,
- ).model_dump(),
- asf_uid=self.__asf_uid,
- project_key=str(project_key),
- version_key=str(version_key),
- )
- self.__data.add(task)
- await self.__data.commit()
+ selected_revision_number=str(selected_revision_number),
+ vote_seq=vote_seq,
+ vote_mode=vote_mode.value,
+ )
# TODO: We should log all outgoing email and the session so that users can confirm
# And can be warned if there was a failure
@@ -287,7 +308,7 @@ class CommitteeMember(CommitteeParticipant):
is_podling = release.project.committee.is_podling
podling_thread_id = release.podling_thread_id
- latest_vote_task = await interaction.release_latest_vote_task(release)
+ latest_vote_task = await interaction.release_current_vote_task(release)
if latest_vote_task is None:
raise RuntimeError("No vote task found, unable to send resolution message.")
@@ -386,7 +407,7 @@ class CommitteeMember(CommitteeParticipant):
)
return success_message
- async def resolve_release(
+ async def resolve_release( # noqa: C901
self,
project_key: safe.ProjectKey,
release: sql.Release,
@@ -396,67 +417,82 @@ class CommitteeMember(CommitteeParticipant):
asf_fullname: str,
resolution_body: str,
) -> tuple[sql.Release, int | None, str, str | None]:
+ if (voting_round == 1) and (vote_result == "passed"):
+ await self.__data.commit()
+ await self.__data.begin_immediate()
# Attach the existing release to the session
release = await self.__data.merge(release)
# Update the release phase based on vote result
extra_destination = None
+ second_round_vote_seq = None
+ second_round_vote_mode = None
if (voting_round == 1) and (vote_result == "passed"):
- # This is the first podling vote, by the PPMC and not the Incubator PMC
- # In this branch, we do not move to RELEASE_PREVIEW but keep everything the same
- # We only set the podling_thread_id to the thread_id of the vote thread
- # Then we automatically start the Incubator PMC vote
- # TODO: Note on the resolve vote page that resolving the Project PPMC vote starts the Incubator PMC vote
- task_mid = interaction.task_mid_get(latest_vote_task)
- task_recipient = interaction.task_recipient_get(latest_vote_task)
- archive_url = await self.__write_as.cache.get_message_archive_url(task_mid, task_recipient)
- if archive_url is None:
- raise ValueError("No archive URL found for podling vote")
- thread_id = archive_url.split("/")[-1]
- await self._resolve_transition(
- release,
- expected_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
- expected_podling_thread_id=None,
- new_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
- new_vote_mode=release.effective_vote_mode,
- new_vote_resolved=None,
- new_podling_thread_id=thread_id,
- )
- await self.__data.refresh(release)
- incubator_vote_address = (
- latest_vote_task.task_args.get("second_round_email_to") or util.INCUBATOR_GENERAL_ADDRESS
- )
- if not release.project.committee:
- raise ValueError("Project has no committee - Invalid state")
- revision_number = release.latest_revision_number
- if revision_number is None:
- raise ValueError("Release has no revision number - Invalid state")
- vote_duration = latest_vote_task.task_args["vote_duration"]
- subject_template = await construct.start_vote_subject_default(release.safe_project_key)
- body_template = await construct.start_vote_default(release.safe_project_key)
- options = construct.StartVoteOptions(
- asfuid=self.__asf_uid,
- fullname=asf_fullname,
- project_key=release.safe_project_key,
- version_key=release.safe_version_key,
- revision_number=release.safe_latest_revision_number,
- vote_duration=vote_duration,
- )
- subject_data, body_data = await construct.start_vote_subject_and_body(
- subject_template, body_template, options
- )
- await self.start(
- email_to=incubator_vote_address,
- permitted_recipients=[incubator_vote_address],
- project_key=release.safe_project_key,
- version_key=release.safe_version_key,
- selected_revision_number=release.safe_latest_revision_number,
- asf_fullname=asf_fullname,
- vote_duration_choice=vote_duration,
- subject=subject_data,
- body_data=body_data,
- release=release,
- promote=False,
- )
+ try:
+ # This is the first podling vote, by the PPMC and not the Incubator PMC
+ # In this branch, we do not move to RELEASE_PREVIEW but keep everything the same
+ # We only set the podling_thread_id to the thread_id of the vote thread
+ # Then we automatically start the Incubator PMC vote
+ # TODO: Note on the resolve vote page that resolving the Project PPMC vote starts the Incubator PMC vote
+ task_mid = interaction.task_mid_get(latest_vote_task)
+ task_recipient = interaction.task_recipient_get(latest_vote_task)
+ archive_url = await self.__write_as.cache.get_message_archive_url(task_mid, task_recipient)
+ if archive_url is None:
+ raise ValueError("No archive URL found for podling vote")
+ thread_id = archive_url.split("/")[-1]
+ await self._resolve_transition(
+ release,
+ expected_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+ expected_podling_thread_id=None,
+ new_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+ new_vote_mode=release.effective_vote_mode,
+ new_vote_resolved=None,
+ new_podling_thread_id=thread_id,
+ )
+ await self.__data.refresh(release)
+ incubator_vote_address = (
+ latest_vote_task.task_args.get("second_round_email_to") or util.INCUBATOR_GENERAL_ADDRESS
+ )
+ if not release.project.committee:
+ raise ValueError("Project has no committee - Invalid state")
+ revision_number = release.latest_revision_number
+ if revision_number is None:
+ raise ValueError("Release has no revision number - Invalid state")
+ vote_duration = latest_vote_task.task_args["vote_duration"]
+ subject_template = await construct.start_vote_subject_default(release.safe_project_key)
+ body_template = await construct.start_vote_default(release.safe_project_key)
+ options = construct.StartVoteOptions(
+ asfuid=self.__asf_uid,
+ fullname=asf_fullname,
+ project_key=release.safe_project_key,
+ version_key=release.safe_version_key,
+ revision_number=release.safe_latest_revision_number,
+ vote_duration=vote_duration,
+ )
+ subject_data, body_data = await construct.start_vote_subject_and_body(
+ subject_template, body_template, options
+ )
+ second_round_task = await self.start(
+ email_to=incubator_vote_address,
+ permitted_recipients=[incubator_vote_address],
+ project_key=release.safe_project_key,
+ version_key=release.safe_version_key,
+ selected_revision_number=release.safe_latest_revision_number,
+ asf_fullname=asf_fullname,
+ vote_duration_choice=vote_duration,
+ subject=subject_data,
+ body_data=body_data,
+ release=release,
+ promote=False,
+ )
+ second_round_vote_seq = second_round_task.task_args["vote_seq"]
+ if not isinstance(second_round_vote_seq, int):
+ raise ValueError("Second round vote sequence is invalid")
+ second_round_vote_mode = release.effective_vote_mode
+ await self.__data.commit()
+ await self.__data.refresh(release)
+ except Exception:
+ await self.__data.rollback()
+ raise
success_message = (
f"Project PPMC vote marked as passed, and Incubator PMC vote automatically started"
f" (sent to {incubator_vote_address})"
@@ -512,13 +548,27 @@ class CommitteeMember(CommitteeParticipant):
extra_destination=extra_destination,
)
# TODO: Could move this up before send_resolution
- self.__write_as.append_to_audit_log(
- asf_uid=self.__asf_uid,
- project_key=str(project_key),
- version_key=release.version,
- vote_result=vote_result,
- voting_round=voting_round,
- )
+ if (second_round_vote_seq is not None) and (second_round_vote_mode is not None):
+ self.__write_as.append_to_audit_log(
+ asf_uid=self.__asf_uid,
+ release_key=release.key,
+ project_key=str(project_key),
+ version_key=release.version,
+ selected_revision_number=str(release.safe_latest_revision_number),
+ vote_seq=second_round_vote_seq,
+ vote_mode=second_round_vote_mode.value,
+ vote_result=vote_result,
+ voting_round=voting_round,
+ second_round_vote_seq=second_round_vote_seq,
+ )
+ else:
+ self.__write_as.append_to_audit_log(
+ asf_uid=self.__asf_uid,
+ project_key=str(project_key),
+ version_key=release.version,
+ vote_result=vote_result,
+ voting_round=voting_round,
+ )
return release, voting_round, success_message, error_message
async def send_resolution(
diff --git a/atr/tasks/vote.py b/atr/tasks/vote.py
index 697cfa13..6e231c6c 100644
--- a/atr/tasks/vote.py
+++ b/atr/tasks/vote.py
@@ -24,6 +24,7 @@ import atr.mail as mail
import atr.models.args as args
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.tasks.checks as checks
import atr.util as util
@@ -47,7 +48,7 @@ async def initiate(task_args: args.Initiate) -> results.Results | None:
raise
-async def _initiate_core_logic(task_args: args.Initiate) -> results.Results | None:
+async def _initiate_core_logic(task_args: args.Initiate) -> results.Results | None: # noqa: C901
"""Get arguments, create an email, and then send it to the recipient."""
log.info("Starting initiate_core")
safe.ReleaseKey(task_args.release_key)
@@ -66,6 +67,13 @@ async def _initiate_core_logic(task_args: args.Initiate) -> results.Results | No
latest_revision_number = release.latest_revision_number
if latest_revision_number is None:
raise VoteInitiationError(f"No revisions found for release {task_args.release_key!s}")
+ if release.phase != sql.ReleasePhase.RELEASE_CANDIDATE:
+ raise VoteInitiationError(f"Vote task is stale for release {task_args.release_key!s}")
+ if task_args.vote_seq is None:
+ if release.current_vote_seq is not None:
+ raise VoteInitiationError(f"Vote task is stale for release {task_args.release_key!s}")
+ elif release.current_vote_seq != task_args.vote_seq:
+ raise VoteInitiationError(f"Vote task is stale for release {task_args.release_key!s}")
ongoing_tasks = await interaction.tasks_ongoing(
release.safe_project_key, release.safe_version_key, release.safe_latest_revision_number
diff --git a/tests/unit/test_vote_recipients.py b/tests/unit/test_vote_recipients.py
index fc1d347c..dcfa79a8 100644
--- a/tests/unit/test_vote_recipients.py
+++ b/tests/unit/test_vote_recipients.py
@@ -25,6 +25,7 @@ import atr.db.interaction as interaction
import atr.models.args as args
import atr.models.results as results
import atr.models.safe as safe
+import atr.models.sql as sql
import atr.storage.writers.vote as vote
import atr.tasks.vote
import atr.util as util
@@ -45,6 +46,21 @@ def test_initiate_args_accepts_second_round_email_to() -> None:
assert task_args.second_round_email_to == "general@incubator.apache.org"
+def test_initiate_args_accepts_vote_seq() -> None:
+ task_args = args.Initiate(
+ release_key="project-1.0.0",
+ email_to="dev@project.apache.org",
+ vote_duration=72,
+ initiator_id="testuser",
+ initiator_fullname="Test User",
+ subject="[VOTE] Release",
+ body="Please vote",
+ vote_seq=3,
+ )
+
+ assert task_args.vote_seq == 3
+
+
def test_initiate_args_backward_compatible_without_second_round_field() -> None:
raw = {
"release_key": "project-1.0.0",
@@ -61,6 +77,7 @@ def test_initiate_args_backward_compatible_without_second_round_field() -> None:
task_args = args.Initiate.model_validate(raw)
assert task_args.second_round_email_to is None
+ assert task_args.vote_seq is None
def test_permitted_podling_second_round_recipients_includes_incubator_address(
@@ -119,6 +136,45 @@ async def test_send_resolution_reuses_original_vote_recipients() -> None:
assert queued_task.task_args["email_bcc"] == ["secretary@project.apache.org"]
+@pytest.mark.asyncio
+async def test_start_email_vote_sets_vote_seq_on_task() -> None:
+ data = mock.MagicMock()
+ data.add = mock.MagicMock()
+ data.begin_immediate = mock.AsyncMock()
+ data.commit = mock.AsyncMock()
+ data.rollback = mock.AsyncMock()
+
+ release = SimpleNamespace(
+ key="project-1.0.0",
+ committee=SimpleNamespace(key="project", is_podling=False),
+ )
+ release_writer = SimpleNamespace(
+ _start_vote_no_commit=mock.AsyncMock(return_value=(release, 7, sql.VoteMode.EMAIL)),
+ )
+ write_as = SimpleNamespace(release=release_writer, append_to_audit_log=mock.MagicMock())
+ writer = _participant_writer_with_mocks(data, write_as)
+
+ task = await writer.start(
+ "dev@project.apache.org",
+ safe.ProjectKey("project"),
+ safe.VersionKey("1.0.0"),
+ safe.RevisionNumber("00001"),
+ 72,
+ "[VOTE] Release",
+ "Please vote",
+ "Project Chair",
+ permitted_recipients=["dev@project.apache.org"],
+ )
+
+ assert task.task_args["vote_seq"] == 7
+ data.add.assert_called_once_with(task)
+ data.begin_immediate.assert_awaited_once()
+ data.commit.assert_awaited_once()
+ data.rollback.assert_not_awaited()
+ write_as.append_to_audit_log.assert_called_once()
+ assert write_as.append_to_audit_log.call_args.kwargs["vote_seq"] == 7
+
+
def test_task_recipient_get_returns_full_vote_recipient() -> None:
latest_vote_task = _latest_vote_task()
@@ -142,6 +198,31 @@ async def test_worker_accepts_incubator_address_for_podling_round_two(
assert result.email_to == "general@incubator.apache.org"
+@pytest.mark.asyncio
+async def test_worker_rejects_stale_vote_seq(monkeypatch: pytest.MonkeyPatch) -> None:
+ release = _make_release(is_podling=False, podling_thread_id=None, current_vote_seq=2)
+ _patch_db_and_interaction(monkeypatch, release)
+ _patch_storage_write(monkeypatch)
+
+ task_args = _make_initiate_args(email_to="dev@project.apache.org")
+ task_args.vote_seq = 1
+
+ with pytest.raises(atr.tasks.vote.VoteInitiationError, match="stale"):
+ await atr.tasks.vote._initiate_core_logic(task_args)
+
+
+@pytest.mark.asyncio
+async def test_worker_rejects_vote_task_outside_candidate_phase(monkeypatch: pytest.MonkeyPatch) -> None:
+ release = _make_release(is_podling=False, podling_thread_id=None, phase=sql.ReleasePhase.RELEASE)
+ _patch_db_and_interaction(monkeypatch, release)
+ _patch_storage_write(monkeypatch)
+
+ task_args = _make_initiate_args(email_to="dev@project.apache.org")
+
+ with pytest.raises(atr.tasks.vote.VoteInitiationError, match="stale"):
+ await atr.tasks.vote._initiate_core_logic(task_args)
+
+
@pytest.mark.asyncio
async def test_worker_skips_recipient_check_for_podling_round_two(
monkeypatch: pytest.MonkeyPatch,
@@ -208,8 +289,16 @@ def _make_initiate_args(email_to: str, initiator_id: str = "testuser") -> args.I
)
-def _make_release(*, is_podling: bool, podling_thread_id: str | None) -> SimpleNamespace:
+def _make_release(
+ *,
+ is_podling: bool,
+ podling_thread_id: str | None,
+ current_vote_seq: int | None = None,
+ phase: sql.ReleasePhase = sql.ReleasePhase.RELEASE_CANDIDATE,
+) -> SimpleNamespace:
return SimpleNamespace(
+ phase=phase,
+ current_vote_seq=current_vote_seq,
latest_revision_number="00001",
safe_project_key=safe.ProjectKey("testproject"),
safe_version_key=safe.VersionKey("1.0.0"),
@@ -222,6 +311,15 @@ def _make_release(*, is_podling: bool, podling_thread_id: str | None) -> SimpleN
)
+def _participant_writer_with_mocks(data: mock.MagicMock, write_as: SimpleNamespace) -> vote.CommitteeParticipant:
+ writer = object.__new__(vote.CommitteeParticipant)
+ writer._CommitteeParticipant__data = data
+ writer._CommitteeParticipant__write_as = write_as
+ writer._CommitteeParticipant__asf_uid = "chair"
+ writer._CommitteeParticipant__committee_key = "project"
+ return writer
+
+
def _patch_db_and_interaction(monkeypatch: pytest.MonkeyPatch, release: SimpleNamespace) -> None:
query = mock.AsyncMock()
query.demand = mock.AsyncMock(return_value=release)
diff --git a/tests/unit/test_vote_resolution.py b/tests/unit/test_vote_resolution.py
index 92ce163e..f31a67a9 100644
--- a/tests/unit/test_vote_resolution.py
+++ b/tests/unit/test_vote_resolution.py
@@ -436,7 +436,7 @@ async def test_resolve_allows_cancelled_before_vote_end(monkeypatch: pytest.Monk
data.merge = mock.AsyncMock(return_value=release)
future_task = _latest_vote_task_with_end(24)
- monkeypatch.setattr(interaction, "release_latest_vote_task", mock.AsyncMock(return_value=future_task))
+ monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=future_task))
monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
writer.resolve_release = mock.AsyncMock(return_value=(release, None, "Vote marked as cancelled", None))
@@ -466,7 +466,7 @@ async def test_resolve_allows_early_passed_with_bypass(monkeypatch: pytest.Monke
data.merge = mock.AsyncMock(return_value=release)
future_task = _latest_vote_task_with_end(24)
- monkeypatch.setattr(interaction, "release_latest_vote_task", mock.AsyncMock(return_value=future_task))
+ monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=future_task))
monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: True)
writer.resolve_release = mock.AsyncMock(return_value=(release, None, "Vote marked as passed", None))
@@ -496,7 +496,7 @@ async def test_resolve_allows_passed_after_vote_end(monkeypatch: pytest.MonkeyPa
data.merge = mock.AsyncMock(return_value=release)
past_task = _latest_vote_task_with_end(-24)
- monkeypatch.setattr(interaction, "release_latest_vote_task", mock.AsyncMock(return_value=past_task))
+ monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=past_task))
monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
writer.resolve_release = mock.AsyncMock(return_value=(release, None, "Vote marked as passed", None))
@@ -600,7 +600,7 @@ async def test_resolve_rejects_early_failed(monkeypatch: pytest.MonkeyPatch) ->
data.merge = mock.AsyncMock(return_value=release)
future_task = _latest_vote_task_with_end(24)
- monkeypatch.setattr(interaction, "release_latest_vote_task", mock.AsyncMock(return_value=future_task))
+ monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=future_task))
monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
with pytest.raises(storage.AccessError, match="unless it is cancelled"):
@@ -626,7 +626,7 @@ async def test_resolve_rejects_early_passed(monkeypatch: pytest.MonkeyPatch) ->
data.release = mock.MagicMock(return_value=query)
future_task = _latest_vote_task_with_end(24)
- monkeypatch.setattr(interaction, "release_latest_vote_task", mock.AsyncMock(return_value=future_task))
+ monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=future_task))
monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
with pytest.raises(storage.AccessError, match="voting period"):
@@ -828,6 +828,7 @@ def _mock_cursor_result(rowcount: int = 1) -> mock.MagicMock:
def _mock_data() -> mock.MagicMock:
data = mock.MagicMock()
+ data.begin_immediate = mock.AsyncMock()
data.commit = mock.AsyncMock()
data.execute = mock.AsyncMock(return_value=_mock_cursor_result())
data.flush = mock.AsyncMock()
@@ -903,7 +904,7 @@ async def _render_standard_resolve_page(
monkeypatch.setattr(resolve.util, "as_url", lambda _endpoint, **_kwargs: "/resolve/project/1.0.0")
monkeypatch.setattr(
- resolve.interaction, "release_latest_vote_task", mock.AsyncMock(return_value=_latest_vote_task())
+ resolve.interaction, "release_current_vote_task", mock.AsyncMock(return_value=_latest_vote_task())
)
monkeypatch.setattr(resolve.interaction, "vote_duration_bypass", lambda: False)
monkeypatch.setattr(resolve.interaction, "vote_end_get", lambda _task: None)
diff --git a/tests/unit/test_vote_serials.py b/tests/unit/test_vote_serials.py
new file mode 100644
index 00000000..be2b8f6f
--- /dev/null
+++ b/tests/unit/test_vote_serials.py
@@ -0,0 +1,250 @@
+# 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 datetime
+import unittest.mock as mock
+from collections.abc import AsyncIterator
+from types import SimpleNamespace
+
+import pytest
+import sqlalchemy
+import sqlalchemy.ext.asyncio
+import sqlmodel
+
+import atr.db as db
+import atr.db.interaction as interaction
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.storage.writers.release as release
+import atr.storage.writers.vote as vote
+import atr.util as util
+
+
+@pytest.fixture
+async def sqlite_sessionmaker() -> AsyncIterator[sqlalchemy.ext.asyncio.async_sessionmaker[db.Session]]:
+ engine = sqlalchemy.ext.asyncio.create_async_engine(
+ "sqlite+aiosqlite:///:memory:",
+ connect_args={"check_same_thread": False},
+ poolclass=sqlalchemy.pool.StaticPool,
+ )
+ async with engine.begin() as conn:
+ await conn.run_sync(sqlmodel.SQLModel.metadata.create_all)
+
+ sessionmaker = sqlalchemy.ext.asyncio.async_sessionmaker(bind=engine, class_=db.Session, expire_on_commit=False)
+ yield sessionmaker
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_podling_second_round_rolls_back_with_task_creation(sqlite_sessionmaker) -> None:
+ async with sqlite_sessionmaker() as data:
+ seeded_release = await _seed_release(
+ data,
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+ current_vote_seq=1,
+ is_podling=True,
+ vote_mode=sql.VoteMode.EMAIL,
+ )
+ data.add(sql.VoteCounter(release_key=seeded_release.key, last_allocated_number=1))
+ await data.commit()
+
+ release_model = await data.release(
+ key="project-1.0.0",
+ _project=True,
+ _committee=True,
+ _project_release_policy=True,
+ ).demand(RuntimeError("release missing"))
+ release_participant = _release_writer_with_data(data)
+ write_as = SimpleNamespace(release=release_participant, append_to_audit_log=mock.MagicMock())
+ writer = _member_writer_with_data(data, write_as)
+
+ with pytest.raises(RuntimeError, match="task creation failed"):
+ try:
+ await data.begin_immediate()
+ await writer._resolve_transition(
+ release_model,
+ expected_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+ expected_podling_thread_id=None,
+ new_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+ new_vote_mode=sql.VoteMode.EMAIL,
+ new_vote_resolved=None,
+ new_podling_thread_id="thread",
+ )
+ await data.refresh(release_model)
+ task = await writer.start(
+ email_to=util.INCUBATOR_GENERAL_ADDRESS,
+ permitted_recipients=[util.INCUBATOR_GENERAL_ADDRESS],
+ project_key=release_model.safe_project_key,
+ version_key=release_model.safe_version_key,
+ selected_revision_number=release_model.safe_latest_revision_number,
+ asf_fullname="Chair",
+ vote_duration_choice=72,
+ subject="[VOTE] Release",
+ body_data="Please vote",
+ release=release_model,
+ promote=False,
+ )
+ assert task.task_args["vote_seq"] == 2
+ raise RuntimeError("task creation failed")
+ except Exception:
+ await data.rollback()
+ raise
+
+ async with sqlite_sessionmaker() as data:
+ release_model = await data.release(key="project-1.0.0").demand(RuntimeError("release missing"))
+ counter = await data.get(sql.VoteCounter, "project-1.0.0")
+ assert counter is not None
+ assert release_model.podling_thread_id is None
+ assert release_model.current_vote_seq == 1
+ assert counter.last_allocated_number == 1
+
+
+@pytest.mark.asyncio
+async def test_release_current_vote_task_matches_serial_and_legacy_fallback(sqlite_sessionmaker) -> None:
+ async with sqlite_sessionmaker() as data:
+ release_model = await _seed_release(data, phase=sql.ReleasePhase.RELEASE_CANDIDATE, current_vote_seq=2)
+ older = _vote_task(1, datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC))
+ current = _vote_task(2, datetime.datetime(2026, 1, 2, tzinfo=datetime.UTC))
+ data.add_all([older, current])
+ await data.commit()
+
+ matched = await interaction.release_current_vote_task(release_model, data)
+ assert matched is not None
+ assert matched.task_args["vote_seq"] == 2
+
+ release_model.current_vote_seq = 3
+ await data.commit()
+ assert await interaction.release_current_vote_task(release_model, data) is None
+
+ release_model.current_vote_seq = None
+ await data.commit()
+ legacy = await interaction.release_current_vote_task(release_model, data)
+ assert legacy is not None
+ assert legacy.task_args["vote_seq"] == 2
+
+
+@pytest.mark.asyncio
+async def test_vote_start_allocation_rolls_back_with_task_creation(sqlite_sessionmaker, monkeypatch) -> None:
+ monkeypatch.setattr(release.util, "number_of_release_files", mock.AsyncMock(return_value=1))
+ async with sqlite_sessionmaker() as data:
+ await _seed_release(data, phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT)
+ writer = _release_writer_with_data(data)
+
+ with pytest.raises(RuntimeError, match="task creation failed"):
+ try:
+ await data.begin_immediate()
+ _release, vote_seq, _vote_mode = await writer._start_vote_no_commit(
+ safe.ReleaseKey("project-1.0.0"),
+ safe.RevisionNumber("00001"),
+ allowed_vote_modes=frozenset({sql.VoteMode.EMAIL}),
+ promote=True,
+ )
+ data.add(_vote_task(vote_seq, datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)))
+ raise RuntimeError("task creation failed")
+ except Exception:
+ await data.rollback()
+ raise
+
+ async with sqlite_sessionmaker() as data:
+ release_model = await data.release(key="project-1.0.0").demand(RuntimeError("release missing"))
+ counter = await data.get(sql.VoteCounter, "project-1.0.0")
+ latest_task = await interaction.release_latest_vote_task(release_model, data)
+ assert release_model.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+ assert release_model.current_vote_seq is None
+ assert counter is None
+ assert latest_task is None
+
+
+def _member_writer_with_data(data: db.Session, write_as: SimpleNamespace) -> vote.CommitteeMember:
+ writer = object.__new__(vote.CommitteeMember)
+ writer._CommitteeParticipant__data = data
+ writer._CommitteeParticipant__write_as = write_as
+ writer._CommitteeParticipant__asf_uid = "chair"
+ writer._CommitteeParticipant__committee_key = "project"
+ writer._CommitteeMember__data = data
+ writer._CommitteeMember__write_as = write_as
+ writer._CommitteeMember__asf_uid = "chair"
+ writer._CommitteeMember__committee_key = "project"
+ return writer
+
+
+def _release_writer_with_data(data: db.Session) -> release.CommitteeParticipant:
+ writer = object.__new__(release.CommitteeParticipant)
+ writer._CommitteeParticipant__data = data
+ writer._CommitteeParticipant__asf_uid = "chair"
+ return writer
+
+
+async def _seed_release(
+ data: db.Session,
+ *,
+ phase: sql.ReleasePhase,
+ current_vote_seq: int | None = None,
+ is_podling: bool = False,
+ vote_mode: sql.VoteMode | None = None,
+) -> sql.Release:
+ committee = sql.Committee(
+ key="project",
+ name="Project",
+ is_podling=is_podling,
+ committee_members=["chair"],
+ committers=["chair"],
+ )
+ project = sql.Project(key="project", name="Project", committee=committee)
+ release = sql.Release(
+ key="project-1.0.0",
+ phase=phase,
+ project=project,
+ project_key=project.key,
+ version="1.0.0",
+ created=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC),
+ current_vote_seq=current_vote_seq,
+ vote_mode=vote_mode,
+ )
+ revision = sql.Revision(
+ key="project-1.0.0 00001",
+ release=release,
+ release_key=release.key,
+ seq=1,
+ number="00001",
+ asfuid="chair",
+ phase=phase,
+ )
+ data.add_all([committee, project, release, revision])
+ await data.commit()
+ return release
+
+
+def _vote_task(vote_seq: int, added: datetime.datetime) -> sql.Task:
+ return sql.Task(
+ status=sql.TaskStatus.QUEUED,
+ task_type=sql.TaskType.VOTE_INITIATE,
+ task_args={
+ "release_key": "project-1.0.0",
+ "email_to": "dev@project.apache.org",
+ "vote_duration": 72,
+ "initiator_id": "chair",
+ "initiator_fullname": "Chair",
+ "subject": "[VOTE] Release",
+ "body": "Please vote",
+ "vote_seq": vote_seq,
+ },
+ asf_uid="chair",
+ project_key="project",
+ version_key="1.0.0",
+ added=added,
+ )
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org