This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git
The following commit(s) were added to refs/heads/main by this push:
new b8c9632 Calculate the vote outcome, and improve the code and report
design
b8c9632 is described below
commit b8c96324a56a53d31df7fb7bf20fdc61f368032c
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Jun 27 19:31:22 2025 +0100
Calculate the vote outcome, and improve the code and report design
---
atr/db/__init__.py | 3 +
atr/routes/__init__.py | 6 ++
atr/routes/vote.py | 153 +++++++++++++++++++++------
atr/templates/check-selected-vote-email.html | 2 +-
atr/templates/vote-tabulate.html | 20 ++--
5 files changed, 145 insertions(+), 39 deletions(-)
diff --git a/atr/db/__init__.py b/atr/db/__init__.py
index f7ac3b5..0d6ad66 100644
--- a/atr/db/__init__.py
+++ b/atr/db/__init__.py
@@ -390,6 +390,7 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
_project: bool = True,
_committee: bool = True,
_release_policy: bool = False,
+ _project_release_policy: bool = False,
_revisions: bool = False,
) -> Query[models.Release]:
query = sqlmodel.select(models.Release)
@@ -426,6 +427,8 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
if _release_policy:
query = query.options(joined_load(models.Release.release_policy))
+ if _project_release_policy:
+ query = query.options(joined_load_nested(models.Release.project,
models.Project.release_policy))
if _revisions:
query = query.options(select_in_load(models.Release.revisions))
diff --git a/atr/routes/__init__.py b/atr/routes/__init__.py
index 7c8ff40..a0126be 100644
--- a/atr/routes/__init__.py
+++ b/atr/routes/__init__.py
@@ -232,6 +232,8 @@ class CommitterSession:
data: db.Session | None = None,
with_committee: bool = True,
with_project: bool = True,
+ with_release_policy: bool = False,
+ with_project_release_policy: bool = False,
with_revisions: bool = False,
) -> models.Release:
# We reuse db.NOT_SET as an entirely different sentinel
@@ -251,6 +253,8 @@ class CommitterSession:
latest_revision_number=latest_revision_number,
_committee=with_committee,
_project=with_project,
+ _release_policy=with_release_policy,
+ _project_release_policy=with_project_release_policy,
_revisions=with_revisions,
).demand(base.ASFQuartException("Release does not exist",
errorcode=404))
else:
@@ -260,6 +264,8 @@ class CommitterSession:
latest_revision_number=latest_revision_number,
_committee=with_committee,
_project=with_project,
+ _release_policy=with_release_policy,
+ _project_release_policy=with_project_release_policy,
_revisions=with_revisions,
).demand(base.ASFQuartException("Release does not exist",
errorcode=404))
return release
diff --git a/atr/routes/vote.py b/atr/routes/vote.py
index 3900ae1..b261a09 100644
--- a/atr/routes/vote.py
+++ b/atr/routes/vote.py
@@ -57,14 +57,21 @@ class Vote(enum.Enum):
UNKNOWN = "?"
+class VoteStatus(enum.Enum):
+ BINDING = "Binding"
+ COMMITTER = "Committer"
+ CONTRIBUTOR = "Contributor"
+ UNKNOWN = "Unknown"
+
+
class VoteEmail(schema.Strict):
- asf_uid: str
+ asf_uid_or_email: str
from_email: str
- status: str
+ status: VoteStatus
asf_eid: str
iso_datetime: str
vote: Vote
- context: str
+ quotation: str
@routes.committer("/vote/<project_name>/<version_name>")
@@ -143,16 +150,24 @@ async def tabulate(session: routes.CommitterSession,
project_name: str, version_
"""Tabulate votes."""
await session.check_access(project_name)
- release = await session.release(project_name, version_name,
phase=models.ReleasePhase.RELEASE_CANDIDATE)
+ release = await session.release(
+ project_name,
+ version_name,
+ phase=models.ReleasePhase.RELEASE_CANDIDATE,
+ with_release_policy=True,
+ with_project_release_policy=True,
+ )
hidden_form = await util.HiddenFieldForm.create_form()
tabulated_votes = None
summary = None
+ outcome = None
if await hidden_form.validate_on_submit():
archive_url = hidden_form.hidden_field.data or ""
- tabulated_votes = await _tabulate_votes(release, archive_url)
+ start_unixtime, tabulated_votes = await _tabulate_votes(release,
archive_url)
summary = _tabulate_vote_summary(tabulated_votes)
+ outcome = _tabulate_vote_outcome(release, start_unixtime,
tabulated_votes)
return await template.render(
- "vote-tabulate.html", release=release,
tabulated_votes=tabulated_votes, summary=summary
+ "vote-tabulate.html", release=release,
tabulated_votes=tabulated_votes, summary=summary, outcome=outcome
)
@@ -206,7 +221,7 @@ async def _send_vote(
return email_recipient, ""
-async def _tabulate_votes(release: models.Release, archive_url: str) ->
dict[str, VoteEmail]:
+async def _tabulate_votes(release: models.Release, archive_url: str) ->
tuple[int | None, dict[str, VoteEmail]]:
"""Tabulate votes."""
import logging
@@ -219,24 +234,26 @@ async def _tabulate_votes(release: models.Release,
archive_url: str) -> dict[str
start = time.perf_counter_ns()
tabulated_votes = {}
thread_id = archive_url.split("/")[-1]
+ committee = await _tabulate_vote_committee(thread_id, release)
+ start_unixtime = None
async for _mid, msg in util.thread_messages(thread_id):
from_raw = msg.get("from_raw", "")
- from_email_lower = util.email_from_uid(from_raw)
- if not from_email_lower:
+ ok, from_email_lower, asf_uid = _tabulate_vote_identity(from_raw,
email_to_uid)
+ if not ok:
continue
- from_email_lower = from_email_lower.removesuffix(".invalid")
- asf_uid = None
- if from_email_lower.endswith("@apache.org"):
- asf_uid = from_email_lower.split("@")[0]
- elif from_email_lower in email_to_uid:
- asf_uid = email_to_uid[from_email_lower]
if asf_uid is None:
- asf_uid = from_email_lower
- status = "Unknown"
+ asf_uid_or_email = from_email_lower
+ status = VoteStatus.UNKNOWN
else:
+ asf_uid_or_email = asf_uid
list_raw = msg.get("list_raw", "")
- status = await _tabulate_vote_status(asf_uid, list_raw, release)
+ status = await _tabulate_vote_status(asf_uid, list_raw, committee)
+
+ if start_unixtime is None:
+ epoch = msg.get("epoch", "")
+ if epoch:
+ start_unixtime = int(epoch)
subject = msg.get("subject", "")
if "[RESULT]" in subject:
@@ -254,16 +271,16 @@ async def _tabulate_votes(release: models.Release,
archive_url: str) -> dict[str
vote_cast = castings[0][0]
else:
vote_cast = Vote.UNKNOWN
- context = " // ".join([c[1] for c in castings])
+ quotation = " // ".join([c[1] for c in castings])
vote_email = VoteEmail(
- asf_uid=asf_uid,
+ asf_uid_or_email=asf_uid_or_email,
from_email=from_email_lower,
status=status,
asf_eid=msg.get("mid", ""),
iso_datetime=msg.get("date", ""),
vote=vote_cast,
- context=context,
+ quotation=quotation,
)
tabulated_votes[asf_uid] = vote_email
@@ -271,7 +288,7 @@ async def _tabulate_votes(release: models.Release,
archive_url: str) -> dict[str
logging.info(f"Tabulated votes: {len(tabulated_votes)}")
logging.info(f"Tabulation took {(end - start) / 1000000} ms")
- return tabulated_votes
+ return start_unixtime, tabulated_votes
def _tabulate_vote_break(line: str) -> bool:
@@ -314,6 +331,20 @@ def _tabulate_vote_castings(body: str) -> list[tuple[Vote,
str]]:
return castings
+async def _tabulate_vote_committee(thread_id: str, release: models.Release) ->
models.Committee | None:
+ committee = None
+ if release.project is not None:
+ committee = release.project.committee
+ if util.is_dev_environment():
+ async for _mid, msg in util.thread_messages(thread_id):
+ list_raw = msg.get("list_raw", "")
+ committee_label = list_raw.split(".apache.org", 1)[0].split(".",
1)[-1]
+ async with db.session() as data:
+ committee = await data.committee(name=committee_label).get()
+ break
+ return committee
+
+
def _tabulate_vote_continue(line: str) -> bool:
explanation_indicators = [
"[ ] +1",
@@ -331,22 +362,80 @@ def _tabulate_vote_continue(line: str) -> bool:
return False
-async def _tabulate_vote_status(asf_uid: str, list_raw: str, release:
models.Release) -> str:
- status = "Unknown"
- committee = None
+def _tabulate_vote_identity(from_raw: str, email_to_uid: dict[str, str]) ->
tuple[bool, str, str | None]:
+ from_email_lower = util.email_from_uid(from_raw)
+ if not from_email_lower:
+ return False, "", None
+ from_email_lower = from_email_lower.removesuffix(".invalid")
+ asf_uid = None
+ if from_email_lower.endswith("@apache.org"):
+ asf_uid = from_email_lower.split("@")[0]
+ elif from_email_lower in email_to_uid:
+ asf_uid = email_to_uid[from_email_lower]
+ return True, from_email_lower, asf_uid
+
+
+def _tabulate_vote_outcome(
+ release: models.Release, start_unixtime: int | None, tabulated_votes:
dict[str, VoteEmail]
+) -> str:
+ now = int(time.time())
+ duration_hours = 0
+ if start_unixtime is not None:
+ duration_hours = (now - start_unixtime) / 3600
+
+ min_duration_hours = 72
+ if release.project is not None:
+ if release.project.release_policy is not None:
+ min_duration_hours = release.project.release_policy.min_hours or
None
+ duration_hours_remaining = None
+ if min_duration_hours is not None:
+ duration_hours_remaining = min_duration_hours - duration_hours
+
+ binding_plus_one = 0
+ binding_minus_one = 0
+ for vote_email in tabulated_votes.values():
+ if vote_email.status != VoteStatus.BINDING:
+ continue
+ if vote_email.vote == Vote.YES:
+ binding_plus_one += 1
+ elif vote_email.vote == Vote.NO:
+ binding_minus_one += 1
+
+ return _tabulate_vote_outcome_format(duration_hours_remaining,
binding_plus_one, binding_minus_one)
+
+
+def _tabulate_vote_outcome_format(
+ duration_hours_remaining: float | int | None, binding_plus_one: int,
binding_minus_one: int
+) -> str:
+ outcome_passed = (binding_plus_one >= 3) and (binding_plus_one >
binding_minus_one)
+ if not outcome_passed:
+ if (duration_hours_remaining is not None) and
(duration_hours_remaining > 0):
+ return (
+ f"The vote is still open for {duration_hours_remaining} hours,
but the vote would fail if closed now."
+ )
+ elif duration_hours_remaining is None:
+ return "The vote would fail if closed now."
+ return "The vote failed."
+
+ if (duration_hours_remaining is not None) and (duration_hours_remaining >
0):
+ return f"The vote is still open for {duration_hours_remaining} hours,
but the vote would pass if closed now."
+ return "The vote passed."
+
+
+async def _tabulate_vote_status(asf_uid: str, list_raw: str, committee:
models.Committee | None) -> VoteStatus:
+ status = VoteStatus.UNKNOWN
+
if util.is_dev_environment():
committee_label = list_raw.split(".apache.org", 1)[0].split(".", 1)[-1]
async with db.session() as data:
committee = await data.committee(name=committee_label).get()
- elif release.project is not None:
- committee = release.project.committee
if committee is not None:
if asf_uid in committee.committee_members:
- status = "Binding"
+ status = VoteStatus.BINDING
elif asf_uid in committee.committers:
- status = "Committer"
+ status = VoteStatus.COMMITTER
else:
- status = "Contributor"
+ status = VoteStatus.CONTRIBUTOR
return status
@@ -367,12 +456,12 @@ def _tabulate_vote_summary(tabulated_votes: dict[str,
VoteEmail]) -> dict[str, i
}
for vote_email in tabulated_votes.values():
- if vote_email.status == "Binding":
+ if vote_email.status == VoteStatus.BINDING:
result["binding_votes"] += 1
result["binding_votes_yes"] += 1 if (vote_email.vote.value ==
"Yes") else 0
result["binding_votes_no"] += 1 if (vote_email.vote.value == "No")
else 0
result["binding_votes_abstain"] += 1 if (vote_email.vote.value ==
"Abstain") else 0
- elif vote_email.status in {"Committer", "Contributor"}:
+ elif vote_email.status in {VoteStatus.COMMITTER,
VoteStatus.CONTRIBUTOR}:
result["non_binding_votes"] += 1
result["non_binding_votes_yes"] += 1 if (vote_email.vote.value ==
"Yes") else 0
result["non_binding_votes_no"] += 1 if (vote_email.vote.value ==
"No") else 0
diff --git a/atr/templates/check-selected-vote-email.html
b/atr/templates/check-selected-vote-email.html
index 9b8a95d..c2ab0ce 100644
--- a/atr/templates/check-selected-vote-email.html
+++ b/atr/templates/check-selected-vote-email.html
@@ -39,7 +39,7 @@
</p>
{% endif %}
{% if archive_url %}
- <div class="mt-2 mb-0 ps-4 d-flex gap-2 align-items-center">
+ <div class="mt-2 mb-0 d-flex gap-2 align-items-center">
<a class="btn btn-sm btn-outline-secondary me-2"
href="{{ archive_url }}"
rel="noopener"
diff --git a/atr/templates/vote-tabulate.html b/atr/templates/vote-tabulate.html
index 88bd74a..4fec611 100644
--- a/atr/templates/vote-tabulate.html
+++ b/atr/templates/vote-tabulate.html
@@ -28,24 +28,24 @@
<th class="text-center">Vote</th>
<th class="text-center">Status</th>
<th class="text-center">Link</th>
- <th>Context</th>
+ <th>Quotation</th>
</tr>
</thead>
<tbody>
{% for asf_uid, vote_email in tabulated_votes.items() %}
<tr>
- <td class="atr-nowrap">{{ vote_email.asf_uid }}</td>
- <td class="atr-nowrap text-center {% if vote_email.status ==
'Binding' %}fw-bold{% endif %} {% if vote_email.vote.value == 'Yes'
%}atr-green{% elif vote_email.vote.value == 'No' %}atr-red{% endif %}">
+ <td class="atr-nowrap">{{ vote_email.asf_uid_or_email }}</td>
+ <td class="atr-nowrap text-center {% if vote_email.status.value ==
'Binding' %}fw-bold{% endif %} {% if vote_email.vote.value == 'Yes'
%}atr-green{% elif vote_email.vote.value == 'No' %}atr-red{% endif %}">
{{ vote_email.vote.value }}
</td>
- <td class="atr-nowrap text-center {% if vote_email.status ==
'Binding' %}fw-bold{% endif %}">
- {{ vote_email.status }}
+ <td class="atr-nowrap text-center {% if vote_email.status.value ==
'Binding' %}fw-bold{% endif %}">
+ {{ vote_email.status.value }}
</td>
<td class="atr-nowrap text-center">
<a href="https://lists.apache.org/thread/{{ vote_email.asf_eid
}}"
target="_blank">Email</a>
</td>
- <td>{{ vote_email.context }}</td>
+ <td>{{ vote_email.quotation }}</td>
</tr>
{% endfor %}
</tbody>
@@ -87,6 +87,14 @@
</tbody>
</table>
{% endif %}
+ <h2>Vote outcome</h2>
+ <p>
+ {% if outcome %}
+ {{ outcome }}
+ {% else %}
+ No outcome yet.
+ {% endif %}
+ </p>
{% else %}
<p>No votes tabulated yet.</p>
{% endif %}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]