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/30 16:40:35 UTC
(tooling-trusted-releases) branch sbp updated: Tabulate trusted votes separately for vote resolution
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 9d00bf9e Tabulate trusted votes separately for vote resolution
9d00bf9e is described below
commit 9d00bf9eab4df501368564d65c2180e6c3a18dd2
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Thu Apr 30 17:39:51 2026 +0100
Tabulate trusted votes separately for vote resolution
---
atr/db/interaction.py | 80 ++++++++++
atr/get/projects.py | 2 -
atr/get/resolve.py | 82 +++++++---
atr/get/vote.py | 8 +-
atr/post/manual.py | 12 +-
atr/post/resolve.py | 2 +
atr/shared/resolve.py | 21 +++
atr/storage/writers/policy.py | 3 -
atr/storage/writers/vote.py | 280 +++++++++++++++++++++++++++++++++--
atr/tabulate.py | 143 ++++++++++++++++--
atr/templates/resolve-tabulated.html | 114 ++++++++++----
atr/user.py | 6 +
tests/unit/test_tabulate.py | 27 ++++
tests/unit/test_vote.py | 24 +++
tests/unit/test_vote_rendering.py | 51 +++++++
tests/unit/test_vote_resolution.py | 73 ++++++++-
16 files changed, 850 insertions(+), 78 deletions(-)
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index d4c6ae1e..b11b5044 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
import asyncio
+import dataclasses
import datetime
import enum
from collections.abc import Sequence
@@ -69,6 +70,24 @@ class TrustedProjectPhase(enum.Enum):
FINISH = "finish"
+@dataclasses.dataclass
+class TrustedVoteSummary:
+ binding_votes_yes: int = 0
+ binding_votes_no: int = 0
+ binding_votes_abstain: int = 0
+ non_binding_votes_yes: int = 0
+ non_binding_votes_no: int = 0
+ non_binding_votes_abstain: int = 0
+
+ @property
+ def binding_votes(self) -> int:
+ return self.binding_votes_yes + self.binding_votes_no + self.binding_votes_abstain
+
+ @property
+ def non_binding_votes(self) -> int:
+ return self.non_binding_votes_yes + self.non_binding_votes_no + self.non_binding_votes_abstain
+
+
async def all_releases(project: sql.Project) -> list[sql.Release]:
"""Get all releases for the project, sorted by version."""
query = sqlmodel.select(sql.Release).where(sql.Release.project_key == project.key)
@@ -173,6 +192,33 @@ async def ballot_receipt_message_ids(
return {message_id for message_id in result.scalars().all() if message_id}
+async def ballots_for_resolution(
+ release_key: str,
+ vote_seq: int,
+ caller_data: db.Session | None = None,
+) -> list[sql.BallotPaper]:
+ via = sql.validate_instrumented_attribute
+ async with db.ensure_session(caller_data) as data:
+ row_number = sqlalchemy.func.row_number().over(
+ partition_by=(via(sql.BallotPaper.vote_round), via(sql.BallotPaper.voter_asf_uid)),
+ order_by=via(sql.BallotPaper.id).desc(),
+ )
+ latest_ids = (
+ sqlmodel.select(via(sql.BallotPaper.id).label("ballot_id"), row_number.label("row_number"))
+ .where(sql.BallotPaper.release_key == release_key)
+ .where(sql.BallotPaper.vote_seq == vote_seq)
+ .subquery()
+ )
+ query = (
+ sqlmodel.select(sql.BallotPaper)
+ .join(latest_ids, via(sql.BallotPaper.id) == latest_ids.c.ballot_id)
+ .where(latest_ids.c.row_number == 1)
+ .order_by(via(sql.BallotPaper.vote_round).asc(), via(sql.BallotPaper.voter_asf_uid).asc())
+ )
+ result = await data.execute(query)
+ return list(result.scalars().all())
+
+
async def candidate_drafts(project: sql.Project) -> list[sql.Release]:
"""Get the candidate drafts for the project."""
return await releases_by_phase(project, sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT)
@@ -489,6 +535,40 @@ async def tasks_ongoing_revision(
return task_count, latest_revision
+async def trusted_ballot_summary(
+ release: sql.Release,
+ ballots: Sequence[sql.BallotPaper],
+ caller_data: db.Session | None = None,
+) -> TrustedVoteSummary:
+ if release.committee is None:
+ raise ValueError("Release has no committee")
+ summary = TrustedVoteSummary()
+ for ballot in ballots:
+ is_binding, _binding_committee = await user.is_binding_for_release(
+ release.committee,
+ ballot.voter_asf_uid,
+ ballot.vote_round,
+ caller_data=caller_data,
+ )
+ match ballot.choice:
+ case sql.VoteChoice.YES:
+ if is_binding:
+ summary.binding_votes_yes += 1
+ else:
+ summary.non_binding_votes_yes += 1
+ case sql.VoteChoice.ABSTAIN:
+ if is_binding:
+ summary.binding_votes_abstain += 1
+ else:
+ summary.non_binding_votes_abstain += 1
+ case sql.VoteChoice.NO:
+ if is_binding:
+ summary.binding_votes_no += 1
+ else:
+ summary.non_binding_votes_no += 1
+ return summary
+
+
async def trusted_jwt(
publisher: str, jwt: str, phase: TrustedProjectPhase
) -> tuple[github.TrustedPublisherPayload, str, sql.Project]:
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 72f5d69f..6c219a7e 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -759,8 +759,6 @@ def _vote_mode_label(mode: sql.VoteMode) -> str:
def _vote_mode_radios(project: sql.Project) -> htm.Element:
choices = list(sql.VoteMode)
- if not config.is_dev_environment():
- choices = [mode for mode in choices if mode != sql.VoteMode.TRUSTED]
if project.committee and project.committee.is_podling:
choices = [mode for mode in choices if mode != sql.VoteMode.MANUAL]
elements: list[htm.Element | htm.VoidElement] = []
diff --git a/atr/get/resolve.py b/atr/get/resolve.py
index 7f396b8c..5f1c29f3 100644
--- a/atr/get/resolve.py
+++ b/atr/get/resolve.py
@@ -31,16 +31,11 @@ import atr.shared as shared
import atr.storage as storage
import atr.tabulate as tabulate
import atr.template as template
+import atr.user as user
import atr.util as util
import atr.web as web
-def binding_terminology(vote_round: int | None) -> tuple[str, str]:
- if vote_round == 1:
- return "Formal", "Informal"
- return "Binding", "Non-binding"
-
-
@get.typed
async def selected( # noqa: C901
session: web.Committer,
@@ -107,9 +102,42 @@ async def selected( # noqa: C901
pass_fail_allowed = interaction.vote_pass_fail_allowed(latest_vote_task)
bypass_active = interaction.vote_duration_bypass()
vote_end = interaction.vote_end_get(latest_vote_task)
+ is_trusted_mode = release.effective_vote_mode == sql.VoteMode.TRUSTED
+ trusted_ballot_count = 0
+ trusted_summary = None
+ trusted_passed = False
+ if is_trusted_mode and (release.current_vote_seq is not None):
+ trusted_ballots = await interaction.ballots_for_resolution(release.key, release.current_vote_seq)
+ trusted_ballot_count = len(trusted_ballots)
+ trusted_summary = await interaction.trusted_ballot_summary(release, trusted_ballots)
+ trusted_passed = tabulate.binding_vote_passes(
+ trusted_summary.binding_votes_yes, trusted_summary.binding_votes_no
+ )
- defaults = {}
- if (committee is not None) and (details is not None) and (thread_id is not None):
+ defaults: dict[str, object] = {
+ "vote_mode": release.effective_vote_mode,
+ "vote_seq": release.current_vote_seq,
+ }
+ if trusted_summary is not None:
+ vote_round: int | None = None
+ if (release.committee is not None) and release.committee.is_podling:
+ vote_round = 2 if (release.podling_thread_id is not None) else 1
+ binding_label, non_binding_label = user.binding_terminology(vote_round)
+ defaults["email_body"] = tabulate.trusted_vote_resolution(
+ release,
+ trusted_summary,
+ trusted_passed,
+ full_name,
+ asf_uid,
+ thread_id,
+ binding_label,
+ non_binding_label,
+ )
+ elif (committee is not None) and (details is not None) and (thread_id is not None):
+ vote_round = None
+ if (release.committee is not None) and release.committee.is_podling:
+ vote_round = 2 if (release.podling_thread_id is not None) else 1
+ binding_label, non_binding_label = user.binding_terminology(vote_round)
defaults["email_body"] = tabulate.vote_resolution(
committee,
release,
@@ -120,22 +148,34 @@ async def selected( # noqa: C901
full_name,
asf_uid,
thread_id,
+ binding_label,
+ non_binding_label,
)
defaults["vote_result"] = "Passed" if details.passed else "Failed"
- binding_sufficient = (
- (details is not None)
- and (details.summary["binding_votes_yes"] >= 3)
- and (details.summary["binding_votes_yes"] > details.summary["binding_votes_no"])
- )
+ if is_trusted_mode:
+ binding_sufficient = trusted_passed
+ else:
+ binding_sufficient = (details is not None) and tabulate.binding_vote_passes(
+ details.summary["binding_votes_yes"], details.summary["binding_votes_no"]
+ )
- vote_round: int | None = None
+ vote_round = None
if (release.committee is not None) and release.committee.is_podling:
vote_round = 2 if (release.podling_thread_id is not None) else 1
- binding_label, non_binding_label = binding_terminology(vote_round)
+ binding_label, non_binding_label = user.binding_terminology(vote_round)
submit_label = "Resolve vote"
- if pass_fail_allowed or bypass_active:
+ trusted_duration_blocks_result = is_trusted_mode and (vote_end is not None) and (not pass_fail_allowed)
+ if trusted_duration_blocks_result and (not bypass_active):
+ form_cls = shared.resolve.CancelSubmitForm
+ elif is_trusted_mode:
+ form_cls = shared.resolve.SubmitForm
+ vote_result_choices = [("Failed", "Failed"), ("Cancelled", "Cancelled")]
+ if binding_sufficient or bypass_active:
+ vote_result_choices.insert(0, ("Passed", "Passed"))
+ defaults["vote_result"] = vote_result_choices
+ elif pass_fail_allowed or bypass_active:
form_cls = shared.resolve.SubmitForm
else:
form_cls = shared.resolve.CancelSubmitForm
@@ -143,7 +183,12 @@ async def selected( # noqa: C901
pre_submit: htm.Element | None = None
if (not binding_sufficient) and (pass_fail_allowed or bypass_active):
icon = htpy.i(class_="bi bi-exclamation-triangle me-1")
- if details is not None:
+ if is_trusted_mode:
+ message = (
+ f"The trusted ballot record does not contain sufficient {binding_label.lower()} +1 votes to"
+ f" pass (at least 3 {binding_label.lower()} +1 votes are required, with more +1 than -1)."
+ )
+ elif details is not None:
message = (
f"The automated tabulation did not find sufficient {binding_label.lower()} +1 votes to"
f" pass (at least 3 {binding_label.lower()} +1 votes are required, with more +1 than -1)."
@@ -175,6 +220,9 @@ async def selected( # noqa: C901
resolve_form=resolve_form,
fetch_error=fetch_error,
archive_url=archive_url,
+ trusted_ballot_count=trusted_ballot_count,
+ trusted_summary=trusted_summary,
+ trusted_mode=is_trusted_mode,
vote_end=vote_end,
pass_fail_allowed=pass_fail_allowed,
bypass_active=bypass_active,
diff --git a/atr/get/vote.py b/atr/get/vote.py
index 52f49bd4..f779dc9c 100644
--- a/atr/get/vote.py
+++ b/atr/get/vote.py
@@ -380,7 +380,7 @@ def _message_id_source_archive_url(message_id: str, vote_recipient: str) -> str:
def _render_binding_status(page: htm.Block, is_binding: bool, binding_committee: str, vote_round: int | None) -> None:
- binding_word, non_binding_word = resolve.binding_terminology(vote_round)
+ binding_word, non_binding_word = user.binding_terminology(vote_round)
if is_binding:
page.p[
f"As a member of the {binding_committee} committee, your vote is ",
@@ -633,7 +633,7 @@ async def _render_trusted_vote_authenticated(
vote_round = _vote_round(release)
is_binding, binding_committee = await user.is_binding_for_release(release.committee, session.uid, vote_round)
- binding_word, non_binding_word = resolve.binding_terminology(vote_round)
+ binding_word, non_binding_word = user.binding_terminology(vote_round)
potency = binding_word if is_binding else non_binding_word
_render_binding_status(page, is_binding, binding_committee, vote_round)
@@ -643,7 +643,7 @@ async def _render_trusted_vote_authenticated(
"Submitting again records a new ballot. New ballots during the voting period always replace old ballots."
]
- task_mid = interaction.task_mid_get(latest_vote_task) if latest_vote_task is not None else None
+ task_mid = interaction.task_mid_get(latest_vote_task) if (latest_vote_task is not None) else None
casting_enabled = (release.current_vote_seq is not None) and (task_mid is not None)
vote_widget = _vote_decision_widget(potency, disabled=not casting_enabled)
submit_label = "Resubmit vote" if (latest_ballot is not None) else "Submit vote"
@@ -690,7 +690,7 @@ async def _render_vote_authenticated(
vote_round = _vote_round(release)
is_binding, binding_committee = await user.is_binding_for_release(release.committee, session.uid, vote_round)
- binding_word, non_binding_word = resolve.binding_terminology(vote_round)
+ binding_word, non_binding_word = user.binding_terminology(vote_round)
potency = binding_word if is_binding else non_binding_word
_render_binding_status(page, is_binding, binding_committee, vote_round)
diff --git a/atr/post/manual.py b/atr/post/manual.py
index 5b8d51ac..3f99dc7a 100644
--- a/atr/post/manual.py
+++ b/atr/post/manual.py
@@ -73,8 +73,16 @@ async def resolve_selected(
vote_result = "cancelled"
destination = get.compose.selected
- async with storage.write_as_project_committee_member(project_key) as wacm:
- success_message = await wacm.vote.resolve_manually(project_key, version_key, vote_result)
+ try:
+ async with storage.write_as_project_committee_member(project_key) as wacm:
+ success_message = await wacm.vote.resolve_manually(project_key, version_key, vote_result)
+ except storage.AccessError as e:
+ return await session.redirect(
+ get.manual.resolve_selected,
+ project_key=str(project_key),
+ version_key=str(version_key),
+ error=str(e),
+ )
return await session.redirect(
destination,
diff --git a/atr/post/resolve.py b/atr/post/resolve.py
index 00f688a3..873ca8e3 100644
--- a/atr/post/resolve.py
+++ b/atr/post/resolve.py
@@ -57,6 +57,8 @@ async def selected(
writer_result,
session.fullname,
email_body,
+ expected_vote_seq=submit_form.vote_seq,
+ expected_vote_mode=submit_form.vote_mode,
)
except storage.AccessError as e:
return await session.redirect(
diff --git a/atr/shared/resolve.py b/atr/shared/resolve.py
index 5682085c..debd4a32 100644
--- a/atr/shared/resolve.py
+++ b/atr/shared/resolve.py
@@ -17,14 +17,35 @@
from typing import Literal
+import pydantic
+
import atr.form as form
+import atr.models.sql as sql
class CancelSubmitForm(form.Form):
email_body: str = form.label("Email body", widget=form.Widget.TEXTAREA, max_length=100_000)
vote_result: Literal["Cancelled"] = form.label("Vote result", default="Cancelled", widget=form.Widget.HIDDEN)
+ vote_mode: sql.VoteMode | None = form.label("Vote mode", default=None, widget=form.Widget.HIDDEN)
+ vote_seq: int | None = form.label("Vote serial", default=None, widget=form.Widget.HIDDEN)
+
+ @pydantic.field_validator("vote_mode", "vote_seq", mode="before")
+ @classmethod
+ def empty_hidden_value(cls, value: object) -> object:
+ if value == "":
+ return None
+ return value
class SubmitForm(form.Form):
email_body: str = form.label("Email body", widget=form.Widget.TEXTAREA, max_length=100_000)
vote_result: Literal["Passed", "Failed", "Cancelled"] = form.label("Vote result", widget=form.Widget.RADIO)
+ vote_mode: sql.VoteMode | None = form.label("Vote mode", default=None, widget=form.Widget.HIDDEN)
+ vote_seq: int | None = form.label("Vote serial", default=None, widget=form.Widget.HIDDEN)
+
+ @pydantic.field_validator("vote_mode", "vote_seq", mode="before")
+ @classmethod
+ def empty_hidden_value(cls, value: object) -> object:
+ if value == "":
+ return None
+ return value
diff --git a/atr/storage/writers/policy.py b/atr/storage/writers/policy.py
index 9b3c1805..3a9e9cc0 100644
--- a/atr/storage/writers/policy.py
+++ b/atr/storage/writers/policy.py
@@ -23,7 +23,6 @@ from typing import TYPE_CHECKING, Any, Final
import strictyaml
import strictyaml.ruamel.error as error
-import atr.config as config
import atr.db as db
import atr.hashes as hashes
import atr.models as models
@@ -198,8 +197,6 @@ class CommitteeMember(CommitteeParticipant):
project_key = form.project_key
project, release_policy = await self.__get_or_create_policy(project_key)
vote_mode = form.vote_mode
- if (vote_mode == models.sql.VoteMode.TRUSTED) and (not config.is_dev_environment()):
- raise storage.AccessError("Trusted voting is only available in development mode.")
if (vote_mode == models.sql.VoteMode.MANUAL) and (project.committee and project.committee.is_podling):
raise storage.AccessError("Manual voting is not allowed for podlings.")
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index 43a897ec..2ff542bd 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -33,6 +33,7 @@ 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.user as user
import atr.util as util
@@ -111,6 +112,10 @@ class FoundationCommitter(GeneralPublic):
vote_round,
caller_data=self.__data,
)
+ binding_word, non_binding_word = user.binding_terminology(vote_round)
+ potency_label = binding_word.lower() if is_binding else None
+ if (vote_round == 1) and (not is_binding):
+ potency_label = non_binding_word.lower()
start_task = await interaction.release_current_vote_task(release, self.__data)
if start_task is None:
@@ -138,6 +143,7 @@ class FoundationCommitter(GeneralPublic):
fullname=fullname,
is_binding=is_binding,
comment=comment,
+ potency_label=potency_label,
)
previous_ballot_query = (
@@ -197,7 +203,7 @@ class FoundationCommitter(GeneralPublic):
choice=choice.value,
is_binding_at_cast=is_binding,
receipt_message_id=receipt_message_id,
- replaced_ballot_id=previous_ballot.id if previous_ballot is not None else None,
+ replaced_ballot_id=previous_ballot.id if (previous_ballot is not None) else None,
)
return [email_to], ""
@@ -256,6 +262,7 @@ class CommitteeParticipant(FoundationCommitter):
fullname=fullname,
is_binding=is_binding,
comment=comment,
+ potency_label=_vote_potency_label(release, is_binding),
)
in_reply_to = vote_thread_mid
@@ -308,7 +315,7 @@ class CommitteeParticipant(FoundationCommitter):
# 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
+ if (expected_vote_mode is not None)
else frozenset({sql.VoteMode.EMAIL, sql.VoteMode.TRUSTED})
)
release, vote_seq, vote_mode = await self.__write_as.release._start_vote_no_commit(
@@ -425,13 +432,15 @@ class CommitteeMember(CommitteeParticipant):
self.__asf_uid = asf_uid
self.__committee_key = committee_key
- async def resolve(
+ async def resolve( # noqa: C901
self,
project_key: safe.ProjectKey,
version_key: safe.VersionKey,
vote_result: Literal["passed", "failed", "cancelled"],
asf_fullname: str,
resolution_body: str,
+ expected_vote_seq: int | None = None,
+ expected_vote_mode: sql.VoteMode | None = None,
) -> tuple[sql.Release, int | None, str, str | None]:
release = await self.__data.release(
key=sql.release_key(str(project_key), str(version_key)),
@@ -441,8 +450,42 @@ class CommitteeMember(CommitteeParticipant):
_release_policy=True,
_project_release_policy=True,
).demand(storage.AccessError("Release not found"))
+ if (expected_vote_mode is not None) and (release.effective_vote_mode != expected_vote_mode):
+ raise storage.AccessError("The resolve form is stale, please refresh and try again")
if release.effective_vote_mode == sql.VoteMode.MANUAL:
- raise ValueError("Release is configured for manual voting")
+ raise storage.AccessError("Release is configured for manual voting")
+ podling_round_one_thread_id = None
+ if release.effective_vote_mode == sql.VoteMode.TRUSTED:
+ if (
+ (vote_result == "passed")
+ and (release.committee is not None)
+ and release.committee.is_podling
+ and (release.podling_thread_id is None)
+ ):
+ latest_vote_task = await interaction.release_current_vote_task(release, self.__data)
+ 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)
+ archive_url = await self.__write_as.cache.get_message_archive_url(task_mid, task_recipient)
+ if archive_url is not None:
+ podling_round_one_thread_id = archive_url.split("/")[-1]
+ await self.__data.rollback()
+ return await self._resolve_trusted(
+ project_key,
+ version_key,
+ vote_result,
+ asf_fullname,
+ resolution_body,
+ expected_vote_seq,
+ expected_vote_mode,
+ podling_round_one_thread_id,
+ )
+ if (
+ (expected_vote_seq is not None)
+ and (release.current_vote_seq is not None)
+ and (release.current_vote_seq != expected_vote_seq)
+ ):
+ raise storage.AccessError("The resolve form is stale, please refresh and try again")
is_podling = False
if release.project.committee is not None:
@@ -451,7 +494,7 @@ class CommitteeMember(CommitteeParticipant):
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.")
+ raise storage.AccessError("No vote task found, unable to send resolution message.")
if (
(vote_result != "cancelled")
@@ -466,7 +509,7 @@ class CommitteeMember(CommitteeParticipant):
if is_podling is True:
voting_round = 1 if (podling_thread_id is None) else 2
if release.committee is None:
- raise ValueError("Project has no committee - Invalid state")
+ raise storage.AccessError("Project has no committee - Invalid state")
return await self.resolve_release(
project_key,
@@ -494,13 +537,13 @@ class CommitteeMember(CommitteeParticipant):
).demand(storage.AccessError("Release not found"))
if release.effective_vote_mode != sql.VoteMode.MANUAL:
- raise ValueError("Release is not configured for manual voting")
+ raise storage.AccessError("Release is not configured for manual voting")
if release.vote_started is None:
- raise ValueError("Vote has not been started")
+ raise storage.AccessError("Vote has not been started")
if (release.project.committee is not None) and release.project.committee.is_podling:
- raise ValueError("Podling releases require the standard two round vote process")
+ raise storage.AccessError("Podling releases require the standard two round vote process")
match vote_result:
case "passed":
@@ -789,6 +832,208 @@ class CommitteeMember(CommitteeParticipant):
await self.__data.commit()
return None
+ async def _resolve_trusted( # noqa: C901
+ self,
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+ vote_result: Literal["passed", "failed", "cancelled"],
+ asf_fullname: str,
+ resolution_body: str,
+ expected_vote_seq: int | None,
+ expected_vote_mode: sql.VoteMode | None,
+ podling_round_one_thread_id: str | None,
+ ) -> tuple[sql.Release, int | None, str, str | None]:
+ latest_vote_task: sql.Task | None = None
+ second_round_vote_mode: sql.VoteMode | None = None
+ second_round_vote_seq: int | None = None
+ await self.__data.begin_immediate()
+ try:
+ release = await self.__data.release(
+ key=sql.release_key(str(project_key), str(version_key)),
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+ _project=True,
+ _committee=True,
+ _release_policy=True,
+ _project_release_policy=True,
+ ).demand(storage.AccessError("Release not found"))
+ if (expected_vote_mode is not None) and (expected_vote_mode != sql.VoteMode.TRUSTED):
+ raise storage.AccessError("The resolve form is stale, please refresh and try again")
+ if release.effective_vote_mode != sql.VoteMode.TRUSTED:
+ raise storage.AccessError("Release is not configured for trusted voting")
+ if release.current_vote_seq is None:
+ raise storage.AccessError("Vote serial is missing, please refresh and try again")
+ if (expected_vote_seq is not None) and (release.current_vote_seq != expected_vote_seq):
+ raise storage.AccessError("The resolve form is stale, please refresh and try again")
+ if release.committee is None:
+ raise storage.AccessError("Project has no committee - Invalid state")
+
+ vote_seq = release.current_vote_seq
+ voting_round = None
+ if release.committee.is_podling:
+ voting_round = 1 if (release.podling_thread_id is None) else 2
+
+ latest_vote_task = await interaction.release_current_vote_task(release, self.__data)
+ vote_end = interaction.vote_end_get(latest_vote_task)
+ resolution_bypass = interaction.vote_duration_bypass()
+ if (
+ (vote_result != "cancelled")
+ and (vote_end is not None)
+ and (not interaction.vote_pass_fail_allowed(latest_vote_task))
+ and (not resolution_bypass)
+ ):
+ raise storage.AccessError(
+ "The vote cannot be resolved before the voting period has ended unless it is cancelled."
+ )
+
+ ballots = await interaction.ballots_for_resolution(release.key, vote_seq, self.__data)
+ summary = await interaction.trusted_ballot_summary(release, ballots, self.__data)
+ if (
+ (vote_result == "passed")
+ and (not tabulate.binding_vote_passes(summary.binding_votes_yes, summary.binding_votes_no))
+ and (not resolution_bypass)
+ ):
+ binding_label, _non_binding_label = user.binding_terminology(voting_round)
+ raise storage.AccessError(
+ f"The trusted ballot record does not have enough {binding_label.lower()} +1 votes to pass."
+ )
+
+ if (voting_round == 1) and (vote_result == "passed"):
+ if latest_vote_task is None:
+ raise storage.AccessError("No vote task found, unable to start the Incubator vote.")
+ if podling_round_one_thread_id is None:
+ raise storage.AccessError("No archive URL found for podling vote")
+ 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=podling_round_one_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
+ )
+ revision_number = release.latest_revision_number
+ if revision_number is None:
+ raise storage.AccessError("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 storage.AccessError("Second round vote sequence is invalid")
+ second_round_vote_mode = release.effective_vote_mode
+ await self.__data.commit()
+ await self.__data.refresh(release)
+ success_message = (
+ f"Project PPMC vote marked as passed, and Incubator PMC vote automatically started"
+ f" (sent to {incubator_vote_address})"
+ )
+ elif vote_result == "passed":
+ await self._resolve_transition(
+ release,
+ expected_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+ expected_podling_thread_id=release.podling_thread_id,
+ new_phase=sql.ReleasePhase.RELEASE_PREVIEW,
+ new_vote_mode=release.effective_vote_mode,
+ new_vote_resolved=datetime.datetime.now(datetime.UTC),
+ new_podling_thread_id=release.podling_thread_id,
+ )
+ await self.__data.commit()
+ await self.__data.refresh(release)
+ success_message = "Vote marked as passed"
+ else:
+ await self._resolve_transition(
+ release,
+ expected_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+ expected_podling_thread_id=release.podling_thread_id,
+ new_phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ new_vote_mode=None,
+ new_vote_resolved=None,
+ new_podling_thread_id=None,
+ )
+ await self.__data.commit()
+ await self.__data.refresh(release)
+ success_message = f"Vote marked as {vote_result}"
+ except Exception:
+ await self.__data.rollback()
+ raise
+
+ extra_destination = None
+ if (vote_result == "passed") and (voting_round != 1):
+ description = "Create a preview revision from the last candidate draft"
+ await self.__write_as.revision.create_revision_with_quarantine(
+ project_key,
+ release.safe_version_key,
+ self.__asf_uid,
+ allowed_phases=frozenset({sql.ReleasePhase.RELEASE_PREVIEW}),
+ description=description,
+ )
+ if (voting_round == 2) and (release.podling_thread_id is not None):
+ round_one_email_address, round_one_message_id = await util.email_mid_from_thread_id(
+ release.podling_thread_id
+ )
+ extra_destination = (round_one_email_address, round_one_message_id)
+
+ if latest_vote_task is None:
+ error_message = "No vote task found, unable to send resolution message."
+ else:
+ error_message = await self.send_resolution(
+ release,
+ vote_result,
+ resolution_body,
+ asf_fullname,
+ latest_vote_task,
+ extra_destination=extra_destination,
+ )
+
+ 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=release.latest_revision_number,
+ vote_result=vote_result,
+ voting_round=voting_round,
+ vote_seq=vote_seq,
+ vote_mode=release.effective_vote_mode.value,
+ trusted_binding_yes=summary.binding_votes_yes,
+ trusted_binding_no=summary.binding_votes_no,
+ trusted_binding_abstain=summary.binding_votes_abstain,
+ trusted_non_binding_yes=summary.non_binding_votes_yes,
+ trusted_non_binding_no=summary.non_binding_votes_no,
+ trusted_non_binding_abstain=summary.non_binding_votes_abstain,
+ second_round_vote_seq=second_round_vote_seq,
+ second_round_vote_mode=second_round_vote_mode.value if (second_round_vote_mode is not None) else None,
+ )
+ return release, voting_round, success_message, error_message
+
async def _resolve_transition(
self,
release: sql.Release,
@@ -832,6 +1077,7 @@ def format_vote_email_body(
fullname: str,
is_binding: bool,
comment: str = "",
+ potency_label: str | None = None,
) -> str:
"""Format the body of a vote email.
@@ -846,7 +1092,9 @@ def format_vote_email_body(
The formatted email body text
"""
# audit_guidance all email is sent through `atr.mail` which handles validation
- if is_binding:
+ if potency_label is not None:
+ body = [f"{vote} ({potency_label}) ({asf_uid}) {fullname}"]
+ elif is_binding:
body = [f"{vote} (binding) ({asf_uid}) {fullname}"]
else:
body = [f"{vote} ({asf_uid}) {fullname}"]
@@ -855,3 +1103,15 @@ def format_vote_email_body(
# Only include the signature if there is a comment
body.append(f"-- \n{fullname} ({asf_uid})")
return "\n\n".join(body)
+
+
+def _vote_potency_label(release: sql.Release, is_binding: bool) -> str | None:
+ vote_round = None
+ if (release.committee is not None) and release.committee.is_podling:
+ vote_round = 2 if (release.podling_thread_id is not None) else 1
+ binding_word, non_binding_word = user.binding_terminology(vote_round)
+ if is_binding:
+ return binding_word.lower()
+ if vote_round == 1:
+ return non_binding_word.lower()
+ return None
diff --git a/atr/tabulate.py b/atr/tabulate.py
index edea5594..969848f7 100644
--- a/atr/tabulate.py
+++ b/atr/tabulate.py
@@ -17,6 +17,7 @@
import re
import time
from collections.abc import Generator
+from typing import Protocol
import atr.config as config
import atr.db as db
@@ -28,6 +29,42 @@ import atr.util as util
MAX_THREAD_MESSAGES = 10000
+class TrustedVoteSummaryData(Protocol):
+ binding_votes_abstain: int
+ binding_votes_no: int
+ binding_votes_yes: int
+ non_binding_votes_abstain: int
+ non_binding_votes_no: int
+ non_binding_votes_yes: int
+
+ @property
+ def binding_votes(self) -> int: ...
+
+ @property
+ def non_binding_votes(self) -> int: ...
+
+
+def binding_vote_passes(binding_plus_one: int, binding_minus_one: int) -> bool:
+ return (binding_plus_one >= 3) and (binding_plus_one > binding_minus_one)
+
+
+def trusted_vote_resolution(
+ release: sql.Release,
+ summary: TrustedVoteSummaryData,
+ passed: bool,
+ full_name: str,
+ asf_uid: str,
+ thread_id: str | None,
+ binding_label: str = "Binding",
+ non_binding_label: str = "Non-binding",
+) -> str:
+ return "\n".join(
+ _trusted_vote_resolution_body(
+ release, summary, passed, full_name, asf_uid, thread_id, binding_label, non_binding_label
+ )
+ )
+
+
async def vote_committee(thread_id: str, release: sql.Release) -> sql.Committee | None:
committee = release.project.committee
if config.is_dev_environment():
@@ -104,11 +141,23 @@ def vote_resolution(
full_name: str,
asf_uid: str,
thread_id: str,
+ binding_label: str = "Binding",
+ non_binding_label: str = "Non-binding",
) -> str:
"""Generate a resolution email body."""
return "\n".join(
_vote_resolution_body(
- committee, release, tabulated_votes, summary, passed, outcome, full_name, asf_uid, thread_id
+ committee,
+ release,
+ tabulated_votes,
+ summary,
+ passed,
+ outcome,
+ full_name,
+ asf_uid,
+ thread_id,
+ binding_label,
+ non_binding_label,
)
)
@@ -268,6 +317,53 @@ def _name_from_raw(from_raw: str) -> str:
return name.strip()
+def _trusted_vote_resolution_body(
+ release: sql.Release,
+ summary: TrustedVoteSummaryData,
+ passed: bool,
+ full_name: str,
+ asf_uid: str,
+ thread_id: str | None,
+ binding_label: str,
+ non_binding_label: str,
+) -> Generator[str]:
+ committee_key = release.project.key
+ if release.podling_thread_id:
+ committee_key = "Incubator"
+ yield f"Dear {committee_key} participants,"
+ yield ""
+ outcome = "passed" if passed else "failed"
+ yield f"The vote on {release.project.key} {release.version} {outcome}."
+ yield ""
+
+ if release.podling_thread_id:
+ yield "The previous round of voting is archived at the following URL:"
+ yield ""
+ yield f"https://lists.apache.org/thread/{release.podling_thread_id}"
+ yield ""
+
+ if thread_id is not None:
+ yield "The vote thread is archived at the following URL:"
+ yield ""
+ yield f"https://lists.apache.org/thread/{thread_id}"
+ yield ""
+
+ yield f"There were {summary.binding_votes} {binding_label.lower()} votes."
+ yield (
+ f"{binding_label} votes: +1: {summary.binding_votes_yes}, "
+ f"0: {summary.binding_votes_abstain}, -1: {summary.binding_votes_no}."
+ )
+ yield (
+ f"{non_binding_label} votes: +1: {summary.non_binding_votes_yes}, "
+ f"0: {summary.non_binding_votes_abstain}, -1: {summary.non_binding_votes_no}."
+ )
+ yield ""
+ yield "Thank you for your participation."
+ yield ""
+ yield "Sincerely,"
+ yield f"{full_name} ({asf_uid})"
+
+
def _validate_thread_id(thread_id: str) -> None:
if not thread_id or not isinstance(thread_id, str):
raise ValueError(f"Invalid thread_id: {thread_id}")
@@ -359,7 +455,7 @@ def _vote_identity(
def _vote_outcome_format(
duration_hours_remaining: float | int | None, binding_plus_one: int, binding_minus_one: int
) -> tuple[bool, str]:
- outcome_passed = (binding_plus_one >= 3) and (binding_plus_one > binding_minus_one)
+ outcome_passed = binding_vote_passes(binding_plus_one, binding_minus_one)
if not outcome_passed:
if (duration_hours_remaining is not None) and (duration_hours_remaining > 0):
duration_str = _format_duration(duration_hours_remaining)
@@ -388,6 +484,8 @@ def _vote_resolution_body(
full_name: str,
asf_uid: str,
thread_id: str,
+ binding_label: str,
+ non_binding_label: str,
) -> Generator[str]:
committee_key = committee.display_name
if release.podling_thread_id:
@@ -410,7 +508,7 @@ def _vote_resolution_body(
yield f"https://lists.apache.org/thread/{thread_id}"
yield ""
- yield from _vote_resolution_body_votes(tabulated_votes, summary)
+ yield from _vote_resolution_body_votes(tabulated_votes, summary, binding_label, non_binding_label)
yield "Thank you for your participation."
yield ""
yield "Sincerely,"
@@ -418,14 +516,19 @@ def _vote_resolution_body(
def _vote_resolution_body_votes(
- tabulated_votes: dict[str, models.tabulate.VoteEmail], summary: dict[str, int]
+ tabulated_votes: dict[str, models.tabulate.VoteEmail],
+ summary: dict[str, int],
+ binding_label: str = "Binding",
+ non_binding_label: str = "Non-binding",
) -> Generator[str]:
- yield from _vote_resolution_votes(tabulated_votes, {models.tabulate.VoteStatus.BINDING})
+ yield from _vote_resolution_votes(
+ tabulated_votes, {models.tabulate.VoteStatus.BINDING}, binding_label, non_binding_label
+ )
binding_total = summary["binding_votes"]
were_word = "was" if (binding_total == 1) else "were"
votes_word = "vote" if (binding_total == 1) else "votes"
- yield f"There {were_word} {binding_total} binding {votes_word}."
+ yield f"There {were_word} {binding_total} {binding_label.lower()} {votes_word}."
yield ""
binding_yes = summary["binding_votes_yes"]
@@ -436,12 +539,17 @@ def _vote_resolution_body_votes(
_vote_resolution_count(binding_no, "-1"),
_vote_resolution_count(binding_abstain, "0"),
]
- yield f"Of these binding votes, {', '.join(binding_vote_counts[:2])}, and {binding_vote_counts[2]}."
+ yield f"Of these {binding_label.lower()} votes, {', '.join(binding_vote_counts[:2])}, and {binding_vote_counts[2]}."
yield ""
- yield from _vote_resolution_votes(tabulated_votes, {models.tabulate.VoteStatus.COMMITTER})
yield from _vote_resolution_votes(
- tabulated_votes, {models.tabulate.VoteStatus.CONTRIBUTOR, models.tabulate.VoteStatus.UNKNOWN}
+ tabulated_votes, {models.tabulate.VoteStatus.COMMITTER}, binding_label, non_binding_label
+ )
+ yield from _vote_resolution_votes(
+ tabulated_votes,
+ {models.tabulate.VoteStatus.CONTRIBUTOR, models.tabulate.VoteStatus.UNKNOWN},
+ binding_label,
+ non_binding_label,
)
@@ -450,10 +558,21 @@ def _vote_resolution_count(count: int, symbol: str) -> str:
return f"{count} {were_word} {symbol}"
-def _vote_resolution_votes(
- tabulated_votes: dict[str, models.tabulate.VoteEmail], statuses: set[models.tabulate.VoteStatus]
+def _vote_resolution_votes( # noqa: C901
+ tabulated_votes: dict[str, models.tabulate.VoteEmail],
+ statuses: set[models.tabulate.VoteStatus],
+ binding_label: str = "Binding",
+ non_binding_label: str = "Non-binding",
) -> Generator[str]:
- header: str | None = f"The {' and '.join(status.value.lower() for status in statuses)} votes were cast as follows:"
+ status_labels = []
+ for status in statuses:
+ if status == models.tabulate.VoteStatus.BINDING:
+ status_labels.append(binding_label.lower())
+ elif status in {models.tabulate.VoteStatus.COMMITTER, models.tabulate.VoteStatus.CONTRIBUTOR}:
+ status_labels.append(status.value.lower())
+ else:
+ status_labels.append(non_binding_label.lower())
+ header: str | None = f"The {' and '.join(status_labels)} votes were cast as follows:"
for vote_email in tabulated_votes.values():
if vote_email.status not in statuses:
continue
diff --git a/atr/templates/resolve-tabulated.html b/atr/templates/resolve-tabulated.html
index 0b259e39..920f3c5d 100644
--- a/atr/templates/resolve-tabulated.html
+++ b/atr/templates/resolve-tabulated.html
@@ -12,16 +12,61 @@
<h1>Resolve vote for {{ release.short_display_name }}</h1>
- <h2>Tabulated votes</h2>
- <p>
- This page attempts to automatically tabulate votes from
- {% if archive_url %}
- <a href="{{ archive_url }}" target="_blank">the vote email thread</a>
+ {% if trusted_mode %}
+ <h2>Authoritative trusted ballots</h2>
+ <p>Trusted votes are resolved from ATR ballot records. Email tabulation below is non-authoritative context only.</p>
+ {% if trusted_ballot_count > 0 %}
+ <table class="table table-striped">
+ <thead>
+ <tr>
+ <th>Vote type</th>
+ <th>+1</th>
+ <th>-1</th>
+ <th>0</th>
+ <th>Total</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td>{{ binding_label }} votes</td>
+ <td>{{ trusted_summary.binding_votes_yes }}</td>
+ <td>{{ trusted_summary.binding_votes_no }}</td>
+ <td>{{ trusted_summary.binding_votes_abstain }}</td>
+ <td>{{ trusted_summary.binding_votes }}</td>
+ </tr>
+ <tr>
+ <td>{{ non_binding_label }} votes</td>
+ <td>{{ trusted_summary.non_binding_votes_yes }}</td>
+ <td>{{ trusted_summary.non_binding_votes_no }}</td>
+ <td>{{ trusted_summary.non_binding_votes_abstain }}</td>
+ <td>{{ trusted_summary.non_binding_votes }}</td>
+ </tr>
+ </tbody>
+ </table>
+ {% elif release.current_vote_seq is none %}
+ <p>No trusted vote serial is active yet.</p>
{% else %}
- the vote email thread
+ <p>No trusted ballots were found for the current vote serial.</p>
{% endif %}
- for you. It is not always possible to parse the votes accurately, so you should check the results carefully. <strong>Do not use this page to decide how to resolve the vote without manual review.</strong>
- </p>
+ <h2>Email thread context</h2>
+ {% else %}
+ <h2>Tabulated votes</h2>
+ {% endif %}
+ {% if trusted_mode %}
+ <p>
+ ATR can still show email thread context when the vote thread is available. This context is useful for review, but the trusted outcome is calculated from ATR ballot records. Votes sent from committee members to the mailing list directly are never counted as {{ binding_label.lower() }} votes.
+ </p>
+ {% else %}
+ <p>
+ This page attempts to automatically tabulate votes from
+ {% if archive_url %}
+ <a href="{{ archive_url }}" target="_blank">the vote email thread</a>
+ {% else %}
+ the vote email thread
+ {% endif %}
+ for you. It is not always possible to parse the votes accurately, so you should check the results carefully. <strong>Do not use this page to decide how to resolve the vote without manual review.</strong>
+ </p>
+ {% endif %}
{% if tabulated_votes %}
<table class="table table-striped">
<thead>
@@ -39,11 +84,19 @@
<tr>
<td class="atr-nowrap">{{ vote_detail.asf_uid_or_email }}</td>
<td class="atr-nowrap">{{ vote_detail.name }}</td>
- <td class="atr-nowrap text-center {% if vote_detail.status.value == 'Binding' %}fw-bold{% endif %} {% if vote_detail.vote.value == 'Yes' %}atr-green{% elif vote_detail.vote.value == 'No' %}atr-red{% endif %}">
+ <td class="atr-nowrap text-center {% if (not trusted_mode) and vote_detail.status.value == 'Binding' %}fw-bold{% endif %} {% if vote_detail.vote.value == 'Yes' %}atr-green{% elif vote_detail.vote.value == 'No' %}atr-red{% endif %}">
{{ vote_detail.vote.value }}
</td>
- <td class="atr-nowrap text-center {% if vote_detail.status.value == 'Binding' %}fw-bold{% endif %}">
- {% if vote_detail.status.value == 'Binding' %}{{ binding_label }}{% else %}{{ vote_detail.status.value }}{% endif %}
+ <td class="atr-nowrap text-center {% if (not trusted_mode) and vote_detail.status.value == 'Binding' %}fw-bold{% endif %}">
+ {% if trusted_mode and vote_detail.status.value == 'Binding' %}
+ Would be {{ binding_label.lower() }} in ATR
+ {% elif trusted_mode and (vote_detail.status.value == 'Committer' or vote_detail.status.value == 'Contributor') %}
+ Would be {{ non_binding_label.lower() }} in ATR
+ {% elif vote_detail.status.value == 'Binding' %}
+ {{ binding_label }}
+ {% else %}
+ {{ vote_detail.status.value }}
+ {% endif %}
</td>
<td class="atr-nowrap text-center">
<a href="https://lists.apache.org/thread/{{ vote_detail.asf_eid }}"
@@ -55,7 +108,7 @@
</tbody>
</table>
{% if summary %}
- <h2>Vote summary</h2>
+ <h2>{% if trusted_mode %}Email context summary{% else %}Vote summary{% endif %}</h2>
<table class="table table-striped">
<thead>
<tr>
@@ -68,14 +121,14 @@
</thead>
<tbody>
<tr>
- <td>{{ binding_label }} votes</td>
+ <td>{% if trusted_mode %}Would be {{ binding_label.lower() }} in ATR{% else %}{{ binding_label }} votes{% endif %}</td>
<td>{{ summary.binding_votes_yes }}</td>
<td>{{ summary.binding_votes_no }}</td>
<td>{{ summary.binding_votes_abstain }}</td>
<td>{{ summary.binding_votes }}</td>
</tr>
<tr>
- <td>{{ non_binding_label }} votes</td>
+ <td>{% if trusted_mode %}Would be {{ non_binding_label.lower() }} in ATR{% else %}{{ non_binding_label }} votes{% endif %}</td>
<td>{{ summary.non_binding_votes_yes }}</td>
<td>{{ summary.non_binding_votes_no }}</td>
<td>{{ summary.non_binding_votes_abstain }}</td>
@@ -91,14 +144,19 @@
</tbody>
</table>
{% endif %}
- <h2>Vote outcome</h2>
- <p>
- {% if outcome %}
- {{ outcome }}
- {% else %}
- No outcome yet.
- {% endif %}
- </p>
+ {% if trusted_mode %}
+ <h2>Email context outcome</h2>
+ <p>Email votes are not binding in trusted mode and do not determine the trusted outcome.</p>
+ {% else %}
+ <h2>Vote outcome</h2>
+ <p>
+ {% if outcome %}
+ {{ outcome }}
+ {% else %}
+ No outcome yet.
+ {% endif %}
+ </p>
+ {% endif %}
{% elif fetch_error %}
<div class="alert alert-danger">{{ fetch_error }}</div>
{% else %}
@@ -130,9 +188,15 @@
</div>
{% endif %}
{% endif %}
- <p>
- If, after careful manual review of the information above, you concur with the automatically determined outcome of the vote, please enter the resolution email body here. Sending this will send the email to a new vote result thread, and the vote will be resolved.
- </p>
+ {% if trusted_mode %}
+ <p>
+ After reviewing the trusted ballot record and any email thread context, please enter the resolution email body here. Sending this will send the email to a new vote result thread, and the vote will be resolved from the trusted ballot record.
+ </p>
+ {% else %}
+ <p>
+ If, after careful manual review of the information above, you concur with the automatically determined outcome of the vote, please enter the resolution email body here. Sending this will send the email to a new vote result thread, and the vote will be resolved.
+ </p>
+ {% endif %}
{{ resolve_form }}
{% endif %}
{% endblock content %}
diff --git a/atr/user.py b/atr/user.py
index 4a92d5a0..771fd093 100644
--- a/atr/user.py
+++ b/atr/user.py
@@ -28,6 +28,12 @@ import atr.models.sql as sql
import atr.util as util
+def binding_terminology(vote_round: int | None) -> tuple[str, str]:
+ if vote_round == 1:
+ return "Formal", "Informal"
+ return "Binding", "Non-binding"
+
+
async def candidate_drafts(uid: str, user_projects: list[sql.Project] | None = None) -> list[sql.Release]:
# Must be imported here, to avoid a circular import
import atr.db.interaction as interaction
diff --git a/tests/unit/test_tabulate.py b/tests/unit/test_tabulate.py
index 88e0ef09..fa67503e 100644
--- a/tests/unit/test_tabulate.py
+++ b/tests/unit/test_tabulate.py
@@ -26,6 +26,19 @@ import atr.models.tabulate as models_tabulate
import atr.tabulate as tabulate
+@pytest.mark.parametrize(
+ ("binding_plus_one", "binding_minus_one", "expected"),
+ [
+ (2, 0, False),
+ (3, 3, False),
+ (3, 2, True),
+ (4, 0, True),
+ ],
+)
+def test_binding_vote_passes(binding_plus_one: int, binding_minus_one: int, expected: bool) -> None:
+ assert tabulate.binding_vote_passes(binding_plus_one, binding_minus_one) is expected
+
+
@pytest.mark.asyncio
async def test_vote_committee_returns_incubator_for_podling_round_two(
monkeypatch: pytest.MonkeyPatch,
@@ -125,6 +138,20 @@ def test_vote_resolution_body_votes_formats_singular_binding_summary() -> None:
assert body_lines[2] == "Of these binding votes, 8 were +1, 1 was -1, and 0 were 0."
+def test_vote_resolution_body_votes_uses_formal_label() -> None:
+ summary = {
+ "binding_votes": 3,
+ "binding_votes_yes": 3,
+ "binding_votes_no": 0,
+ "binding_votes_abstain": 0,
+ }
+
+ body_lines = list(tabulate._vote_resolution_body_votes({}, summary, "Formal", "Informal"))
+
+ assert body_lines[0] == "There were 3 formal votes."
+ assert body_lines[2] == "Of these formal votes, 3 were +1, 0 were -1, and 0 were 0."
+
+
@pytest.mark.asyncio
async def test_votes_excludes_receipts_by_rfc_message_id_only(monkeypatch: pytest.MonkeyPatch) -> None:
async def _thread_messages(
diff --git a/tests/unit/test_vote.py b/tests/unit/test_vote.py
index 595d9c94..85664554 100644
--- a/tests/unit/test_vote.py
+++ b/tests/unit/test_vote.py
@@ -127,6 +127,30 @@ def test_empty_comment_no_signature() -> None:
assert "-- \n" not in body
+def test_formal_vote_body_format() -> None:
+ body = vote_writer.format_vote_email_body(
+ vote="+1",
+ asf_uid="ppmc",
+ fullname="PPMC Member",
+ is_binding=True,
+ potency_label="formal",
+ )
+
+ assert body == "+1 (formal) (ppmc) PPMC Member"
+
+
+def test_informal_vote_body_format() -> None:
+ body = vote_writer.format_vote_email_body(
+ vote="+1",
+ asf_uid="contributor",
+ fullname="A Contributor",
+ is_binding=False,
+ potency_label="informal",
+ )
+
+ assert body == "+1 (informal) (contributor) A Contributor"
+
+
def test_negative_binding_vote_body_format() -> None:
"""Negative binding votes are formatted correctly."""
body = vote_writer.format_vote_email_body(
diff --git a/tests/unit/test_vote_rendering.py b/tests/unit/test_vote_rendering.py
index eb5e9d45..08124108 100644
--- a/tests/unit/test_vote_rendering.py
+++ b/tests/unit/test_vote_rendering.py
@@ -115,6 +115,57 @@ async def test_ballot_receipt_message_ids_returns_all_receipts_for_vote(sqlite_s
}
+@pytest.mark.asyncio
+async def test_ballots_for_resolution_returns_latest_per_round_and_voter(sqlite_sessionmaker) -> None:
+ async with sqlite_sessionmaker() as data:
+ older = _ballot(
+ release_key="project-1.0.0",
+ vote_seq=1,
+ voter_asf_uid="voter",
+ choice=sql.VoteChoice.YES,
+ receipt_message_id="older@apache.org",
+ )
+ newest = _ballot(
+ release_key="project-1.0.0",
+ vote_seq=1,
+ voter_asf_uid="voter",
+ choice=sql.VoteChoice.NO,
+ receipt_message_id="newest@apache.org",
+ )
+ other_round = _ballot(
+ release_key="project-1.0.0",
+ vote_seq=1,
+ voter_asf_uid="voter",
+ choice=sql.VoteChoice.ABSTAIN,
+ receipt_message_id="other-round@apache.org",
+ )
+ other_round.vote_round = 2
+ other_voter = _ballot(
+ release_key="project-1.0.0",
+ vote_seq=1,
+ voter_asf_uid="someone",
+ choice=sql.VoteChoice.YES,
+ receipt_message_id="other-voter@apache.org",
+ )
+ other_seq = _ballot(
+ release_key="project-1.0.0",
+ vote_seq=2,
+ voter_asf_uid="voter",
+ choice=sql.VoteChoice.YES,
+ receipt_message_id="other-seq@apache.org",
+ )
+ data.add_all([older, newest, other_round, other_voter, other_seq])
+ await data.commit()
+
+ found = await interaction.ballots_for_resolution("project-1.0.0", 1, data)
+
+ assert {ballot.receipt_message_id for ballot in found} == {
+ "newest@apache.org",
+ "other-round@apache.org",
+ "other-voter@apache.org",
+ }
+
+
@pytest.mark.asyncio
async def test_email_rendering_keeps_hidden_vote_defaults_and_no_trusted_state(
render_app: quart.Quart, monkeypatch: pytest.MonkeyPatch
diff --git a/tests/unit/test_vote_resolution.py b/tests/unit/test_vote_resolution.py
index d99c4a1d..dbcadfbd 100644
--- a/tests/unit/test_vote_resolution.py
+++ b/tests/unit/test_vote_resolution.py
@@ -547,7 +547,7 @@ async def test_resolve_page_allows_manual_continuation_when_archive_lookup_fails
"Please review the vote manually and continue below."
)
assert context["resolve_form"] == "FORM"
- assert form_render.call_args.kwargs["defaults"] == {}
+ assert form_render.call_args.kwargs["defaults"] == {"vote_mode": sql.VoteMode.EMAIL, "vote_seq": None}
assert form_render.call_args.kwargs["pre_submit"] is not None
assert archive_lookup.await_args.kwargs["strict"] is True
vote_committee.assert_not_awaited()
@@ -568,7 +568,7 @@ async def test_resolve_page_allows_manual_continuation_when_tabulation_is_invali
"Please review the vote manually and continue below."
)
assert context["resolve_form"] == "FORM"
- assert form_render.call_args.kwargs["defaults"] == {}
+ assert form_render.call_args.kwargs["defaults"] == {"vote_mode": sql.VoteMode.EMAIL, "vote_seq": None}
assert form_render.call_args.kwargs["pre_submit"] is not None
assert archive_lookup.await_args.kwargs["strict"] is True
vote_committee.assert_awaited_once()
@@ -595,7 +595,7 @@ async def test_resolve_page_allows_manual_continuation_when_thread_fetch_fails(
"and continue below."
)
assert context["resolve_form"] == "FORM"
- assert form_render.call_args.kwargs["defaults"] == {}
+ assert form_render.call_args.kwargs["defaults"] == {"vote_mode": sql.VoteMode.EMAIL, "vote_seq": None}
assert form_render.call_args.kwargs["pre_submit"] is not None
assert archive_lookup.await_args.kwargs["strict"] is True
vote_committee.assert_awaited_once()
@@ -655,6 +655,32 @@ async def test_resolve_rejects_early_passed(monkeypatch: pytest.MonkeyPatch) ->
)
+@pytest.mark.asyncio
+async def test_resolve_rejects_stale_email_vote_serial(monkeypatch: pytest.MonkeyPatch) -> None:
+ data = _mock_data()
+ write_as = _mock_write_as()
+ writer = _writer_with_mocks(data, write_as)
+
+ release = _candidate_release()
+ release.current_vote_seq = 2
+ query = mock.MagicMock()
+ query.demand = mock.AsyncMock(return_value=release)
+ data.release = mock.MagicMock(return_value=query)
+
+ monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=_latest_vote_task()))
+
+ with pytest.raises(storage.AccessError, match="resolve form is stale"):
+ await writer.resolve(
+ _project_key(),
+ _version_key(),
+ "failed",
+ "Chair",
+ "The vote has failed.",
+ expected_vote_seq=1,
+ expected_vote_mode=sql.VoteMode.EMAIL,
+ )
+
+
@pytest.mark.asyncio
async def test_send_resolution_cancelled_builds_cancelled_subject() -> None:
"""send_resolution accepts cancelled and builds a CANCELLED subject."""
@@ -686,6 +712,42 @@ async def test_send_resolution_cancelled_builds_cancelled_subject() -> None:
assert queued_task.task_args["email_bcc"] == ["secretary@project.apache.org"]
+@pytest.mark.asyncio
+async def test_trusted_resolve_allows_insufficient_votes_with_bypass(monkeypatch: pytest.MonkeyPatch) -> None:
+ data = _mock_data()
+ write_as = _mock_write_as()
+ writer = _writer_with_mocks(data, write_as)
+ writer.send_resolution = mock.AsyncMock(return_value=None)
+
+ release = _candidate_release()
+ release.vote_mode = sql.VoteMode.TRUSTED
+ release.effective_vote_mode = sql.VoteMode.TRUSTED
+ release.current_vote_seq = 1
+ query = mock.MagicMock()
+ query.demand = mock.AsyncMock(return_value=release)
+ data.release = mock.MagicMock(return_value=query)
+
+ monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=_latest_vote_task()))
+ monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: True)
+ monkeypatch.setattr(interaction, "ballots_for_resolution", mock.AsyncMock(return_value=[]))
+ monkeypatch.setattr(
+ interaction, "trusted_ballot_summary", mock.AsyncMock(return_value=interaction.TrustedVoteSummary())
+ )
+
+ _release, _round, success, _error = await writer.resolve(
+ _project_key(),
+ _version_key(),
+ "passed",
+ "Chair",
+ "The vote has passed.",
+ expected_vote_seq=1,
+ expected_vote_mode=sql.VoteMode.TRUSTED,
+ )
+
+ assert success == "Vote marked as passed"
+ write_as.revision.create_revision_with_quarantine.assert_awaited_once()
+
+
@pytest.mark.asyncio
async def test_trusted_resolve_page_passes_receipt_exclusions_to_tabulation(
monkeypatch: pytest.MonkeyPatch,
@@ -844,6 +906,7 @@ def _candidate_release(podling_thread_id: str | None = None) -> SimpleNamespace:
effective_vote_mode=sql.VoteMode.EMAIL,
release_policy=SimpleNamespace(vote_mode=sql.VoteMode.EMAIL),
vote_resolved=datetime.datetime.now(datetime.UTC),
+ current_vote_seq=None,
podling_thread_id=podling_thread_id,
version="1.0.0",
latest_revision_number="00001",
@@ -1008,6 +1071,8 @@ async def _render_standard_resolve_page(
vote_details = mock.AsyncMock()
form_render = mock.AsyncMock(return_value="FORM")
ballot_receipt_message_ids = ballot_receipt_message_ids or mock.AsyncMock(return_value=set())
+ ballots_for_resolution = mock.AsyncMock(return_value=[])
+ trusted_ballot_summary = mock.AsyncMock(return_value=interaction.TrustedVoteSummary())
if archive_error is not None:
archive_lookup.side_effect = archive_error
@@ -1041,6 +1106,8 @@ async def _render_standard_resolve_page(
monkeypatch.setattr(resolve.interaction, "vote_end_get", lambda _task: None)
monkeypatch.setattr(resolve.interaction, "vote_pass_fail_allowed", lambda _task: True)
monkeypatch.setattr(resolve.interaction, "ballot_receipt_message_ids", ballot_receipt_message_ids)
+ monkeypatch.setattr(resolve.interaction, "ballots_for_resolution", ballots_for_resolution)
+ monkeypatch.setattr(resolve.interaction, "trusted_ballot_summary", trusted_ballot_summary)
monkeypatch.setattr(resolve.storage, "write", _write_context)
monkeypatch.setattr(resolve.tabulate, "vote_committee", vote_committee)
monkeypatch.setattr(resolve.tabulate, "vote_details", vote_details)
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org