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 31b92b1 Move vote resolution code and templates to their appropriate
locations
31b92b1 is described below
commit 31b92b11829c5cf5922c68a066b1654701793b66
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Jul 2 15:56:09 2025 +0100
Move vote resolution code and templates to their appropriate locations
---
atr/routes/resolve.py | 156 ++++++-
atr/routes/vote.py | 492 ---------------------
atr/tabulate.py | 409 +++++++++++++++++
atr/templates/check-selected-release-info.html | 2 +-
...ote-resolve-manual.html => resolve-manual.html} | 0
.../{vote-resolve.html => resolve-tabulated.html} | 2 +-
playwright/test.py | 6 +-
7 files changed, 566 insertions(+), 501 deletions(-)
diff --git a/atr/routes/resolve.py b/atr/routes/resolve.py
index 6dd519d..809b9b6 100644
--- a/atr/routes/resolve.py
+++ b/atr/routes/resolve.py
@@ -19,6 +19,7 @@
import quart
import sqlmodel
import werkzeug.wrappers.response as response
+import wtforms
import atr.construct as construct
import atr.db as db
@@ -30,10 +31,38 @@ import atr.routes.compose as compose
import atr.routes.finish as finish
import atr.routes.vote as vote
import atr.routes.voting as voting
+import atr.tabulate as tabulate
import atr.tasks.message as message
+import atr.template as template
import atr.util as util
+class ResolveVoteForm(util.QuartFormTyped):
+ """Form for resolving a vote."""
+
+ email_body = wtforms.TextAreaField("Email body", render_kw={"rows": 24})
+ vote_result = wtforms.RadioField(
+ "Vote result",
+ choices=[("passed", "Passed"), ("failed", "Failed")],
+ validators=[wtforms.validators.InputRequired("Vote result is
required")],
+ )
+ submit = wtforms.SubmitField("Resolve vote")
+
+
+class ResolveVoteManualForm(util.QuartFormTyped):
+ """Form for resolving a vote manually."""
+
+ email_body = wtforms.TextAreaField("Email body", render_kw={"rows": 24})
+ vote_result = wtforms.RadioField(
+ "Vote result",
+ choices=[("passed", "Passed"), ("failed", "Failed")],
+ validators=[wtforms.validators.InputRequired("Vote result is
required")],
+ )
+ vote_thread_url = wtforms.StringField("Vote thread URL")
+ vote_result_url = wtforms.StringField("Vote result URL")
+ submit = wtforms.SubmitField("Resolve vote")
+
+
async def release_latest_vote_task(release: models.Release) -> models.Task |
None:
"""Find the most recent VOTE_INITIATE task for this release."""
via = models.validate_instrumented_attribute
@@ -52,8 +81,61 @@ async def release_latest_vote_task(release: models.Release)
-> models.Task | Non
return task
[email protected]("/resolve/<project_name>/<version_name>", methods=["POST"])
-async def selected_post(
[email protected]("/resolve/manual/<project_name>/<version_name>")
+async def manual_selected(session: routes.CommitterSession, project_name: str,
version_name: str) -> str:
+ """Get the manual vote resolution page."""
+ await session.check_access(project_name)
+
+ release = await session.release(
+ project_name,
+ version_name,
+ phase=models.ReleasePhase.RELEASE_CANDIDATE,
+ with_release_policy=True,
+ with_project_release_policy=True,
+ )
+ if not release.vote_manual:
+ raise RuntimeError("This page is for manual votes only")
+ resolve_form = await ResolveVoteManualForm.create_form()
+ return await template.render(
+ "resolve-manual.html",
+ release=release,
+ resolve_form=resolve_form,
+ )
+
+
[email protected]("/resolve/manual/<project_name>/<version_name>",
methods=["POST"])
+async def manual_selected_post(
+ session: routes.CommitterSession, project_name: str, version_name: str
+) -> response.Response | str:
+ """Post the manual vote resolution page."""
+ await session.check_access(project_name)
+ release = await session.release(
+ project_name,
+ version_name,
+ phase=models.ReleasePhase.RELEASE_CANDIDATE,
+ with_release_policy=True,
+ with_project_release_policy=True,
+ )
+ if not release.vote_manual:
+ raise RuntimeError("This page is for manual votes only")
+ resolve_form = await ResolveVoteManualForm.create_form()
+ if not resolve_form.validate_on_submit():
+ return await session.redirect(
+ manual_selected,
+ project_name=project_name,
+ version_name=version_name,
+ error="Invalid form submission.",
+ )
+ # email_body = util.unwrap(resolve_form.email_body.data)
+ return await session.redirect(
+ manual_selected,
+ project_name=project_name,
+ version_name=version_name,
+ )
+
+
[email protected]("/resolve/submit/<project_name>/<version_name>",
methods=["POST"])
+async def submit_selected(
session: routes.CommitterSession, project_name: str, version_name: str
) -> response.Response | str:
"""Resolve a vote."""
@@ -75,11 +157,11 @@ async def selected_post(
latest_vote_task = await release_latest_vote_task(release)
if latest_vote_task is None:
raise RuntimeError("No vote task found, unable to send resolution
message.")
- resolve_form = await vote.ResolveVoteForm.create_form()
+ resolve_form = await ResolveVoteForm.create_form()
if not resolve_form.validate_on_submit():
# TODO: Render the page again with errors
return await session.redirect(
- vote.selected_resolve,
+ vote.selected,
project_name=project_name,
version_name=version_name,
error="Invalid form submission.",
@@ -111,6 +193,72 @@ async def selected_post(
)
[email protected]("/resolve/tabulated/<project_name>/<version_name>",
methods=["POST"])
+async def tabulated_selected_post(session: routes.CommitterSession,
project_name: str, version_name: str) -> str:
+ """Tabulate votes."""
+ await session.check_access(project_name)
+ asf_uid = session.uid
+ full_name = session.fullname
+
+ release = await session.release(
+ project_name,
+ version_name,
+ phase=models.ReleasePhase.RELEASE_CANDIDATE,
+ with_release_policy=True,
+ with_project_release_policy=True,
+ )
+ if release.vote_manual:
+ raise RuntimeError("This page is for tabulated votes only")
+
+ hidden_form = await util.HiddenFieldForm.create_form()
+ tabulated_votes = None
+ summary = None
+ passed = None
+ outcome = None
+ committee = None
+ thread_id = None
+ fetch_error = None
+ if await hidden_form.validate_on_submit():
+ # TODO: Just pass the thread_id itself instead?
+ archive_url = hidden_form.hidden_field.data or ""
+ thread_id = archive_url.split("/")[-1]
+ if thread_id:
+ try:
+ committee = await tabulate.vote_committee(thread_id, release)
+ except util.FetchError as e:
+ fetch_error = f"Failed to fetch thread metadata: {e}"
+ else:
+ start_unixtime, tabulated_votes = await
tabulate.votes(committee, thread_id)
+ summary = tabulate.vote_summary(tabulated_votes)
+ passed, outcome = tabulate.vote_outcome(release,
start_unixtime, tabulated_votes)
+ else:
+ fetch_error = "The vote thread could not yet be found."
+ resolve_form = await ResolveVoteForm.create_form()
+ if (
+ (committee is None)
+ or (tabulated_votes is None)
+ or (summary is None)
+ or (passed is None)
+ or (outcome is None)
+ or (thread_id is None)
+ ):
+ resolve_form.email_body.render_kw = {"rows": 12}
+ else:
+ resolve_form.email_body.data = tabulate.vote_resolution(
+ committee, release, tabulated_votes, summary, passed, outcome,
full_name, asf_uid, thread_id
+ )
+ resolve_form.vote_result.data = "passed" if passed else "failed"
+ return await template.render(
+ "resolve-tabulated.html",
+ release=release,
+ tabulated_votes=tabulated_votes,
+ summary=summary,
+ outcome=outcome,
+ resolve_form=resolve_form,
+ fetch_error=fetch_error,
+ )
+
+
def task_mid_get(latest_vote_task: models.Task) -> str | None:
if util.is_dev_environment():
return vote.TEST_MID
diff --git a/atr/routes/vote.py b/atr/routes/vote.py
index 402537b..dc36227 100644
--- a/atr/routes/vote.py
+++ b/atr/routes/vote.py
@@ -15,10 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-import enum
import logging
-import time
-from collections.abc import Generator
from typing import Final
import aiohttp
@@ -32,9 +29,7 @@ import atr.results as results
import atr.routes as routes
import atr.routes.compose as compose
import atr.routes.resolve as resolve
-import atr.schema as schema
import atr.tasks.message as message
-import atr.template as template
import atr.util as util
# TEST_MID: Final[str | None] =
"CAH5JyZo8QnWmg9CwRSwWY=givhxw4nilyenjo71fkdk81j5...@mail.gmail.com"
@@ -64,57 +59,6 @@ class CastVoteForm(util.QuartFormTyped):
submit = wtforms.SubmitField("Submit vote")
-class ResolveVoteForm(util.QuartFormTyped):
- """Form for resolving a vote."""
-
- email_body = wtforms.TextAreaField("Email body", render_kw={"rows": 24})
- vote_result = wtforms.RadioField(
- "Vote result",
- choices=[("passed", "Passed"), ("failed", "Failed")],
- validators=[wtforms.validators.InputRequired("Vote result is
required")],
- )
- submit = wtforms.SubmitField("Resolve vote")
-
-
-class ResolveVoteManualForm(util.QuartFormTyped):
- """Form for resolving a vote manually."""
-
- email_body = wtforms.TextAreaField("Email body", render_kw={"rows": 24})
- vote_result = wtforms.RadioField(
- "Vote result",
- choices=[("passed", "Passed"), ("failed", "Failed")],
- validators=[wtforms.validators.InputRequired("Vote result is
required")],
- )
- vote_thread_url = wtforms.StringField("Vote thread URL")
- vote_result_url = wtforms.StringField("Vote result URL")
- submit = wtforms.SubmitField("Resolve vote")
-
-
-class Vote(enum.Enum):
- YES = "Yes"
- NO = "No"
- ABSTAIN = "-"
- UNKNOWN = "?"
-
-
-class VoteStatus(enum.Enum):
- BINDING = "Binding"
- COMMITTER = "Committer"
- CONTRIBUTOR = "Contributor"
- UNKNOWN = "Unknown"
-
-
-class VoteEmail(schema.Strict):
- asf_uid_or_email: str
- from_email: str
- status: VoteStatus
- asf_eid: str
- iso_datetime: str
- vote: Vote
- quotation: str
- updated: bool
-
-
@routes.committer("/vote/<project_name>/<version_name>")
async def selected(session: routes.CommitterSession, project_name: str,
version_name: str) -> response.Response | str:
"""Show the contents of the release candidate draft."""
@@ -198,78 +142,6 @@ async def selected_post(session: routes.CommitterSession,
project_name: str, ver
)
-# TODO: Improve this URL
[email protected]("/vote/<project_name>/<version_name>/resolve",
methods=["POST"])
-async def selected_resolve(session: routes.CommitterSession, project_name:
str, version_name: str) -> str:
- """Tabulate votes."""
- await session.check_access(project_name)
- asf_uid = session.uid
- full_name = session.fullname
-
- release = await session.release(
- project_name,
- version_name,
- phase=models.ReleasePhase.RELEASE_CANDIDATE,
- with_release_policy=True,
- with_project_release_policy=True,
- )
- if release.vote_manual:
- resolve_form = await ResolveVoteManualForm.create_form()
- return await template.render(
- "vote-resolve-manual.html",
- release=release,
- resolve_form=resolve_form,
- )
-
- hidden_form = await util.HiddenFieldForm.create_form()
- tabulated_votes = None
- summary = None
- passed = None
- outcome = None
- committee = None
- thread_id = None
- fetch_error = None
- if await hidden_form.validate_on_submit():
- # TODO: Just pass the thread_id itself instead?
- archive_url = hidden_form.hidden_field.data or ""
- thread_id = archive_url.split("/")[-1]
- if thread_id:
- try:
- committee = await _tabulate_vote_committee(thread_id, release)
- except util.FetchError as e:
- fetch_error = f"Failed to fetch thread metadata: {e}"
- else:
- start_unixtime, tabulated_votes = await
_tabulate_votes(committee, thread_id)
- summary = _tabulate_vote_summary(tabulated_votes)
- passed, outcome = _tabulate_vote_outcome(release,
start_unixtime, tabulated_votes)
- else:
- fetch_error = "The vote thread could not yet be found."
- resolve_form = await ResolveVoteForm.create_form()
- if (
- (committee is None)
- or (tabulated_votes is None)
- or (summary is None)
- or (passed is None)
- or (outcome is None)
- or (thread_id is None)
- ):
- resolve_form.email_body.render_kw = {"rows": 12}
- else:
- resolve_form.email_body.data = _tabulate_vote_resolution(
- committee, release, tabulated_votes, summary, passed, outcome,
full_name, asf_uid, thread_id
- )
- resolve_form.vote_result.data = "passed" if passed else "failed"
- return await template.render(
- "vote-resolve.html",
- release=release,
- tabulated_votes=tabulated_votes,
- summary=summary,
- outcome=outcome,
- resolve_form=resolve_form,
- fetch_error=fetch_error,
- )
-
-
async def task_archive_url_cached(task_mid: str | None) -> str | None:
if task_mid in _THREAD_URLS_FOR_DEVELOPMENT:
return _THREAD_URLS_FOR_DEVELOPMENT[task_mid]
@@ -348,370 +220,6 @@ async def _send_vote(
return email_recipient, ""
-async def _tabulate_votes(
- committee: models.Committee | None, thread_id: str
-) -> tuple[int | None, dict[str, VoteEmail]]:
- """Tabulate votes."""
- import logging
-
- start = time.perf_counter_ns()
- email_to_uid = await util.email_to_uid_map()
- end = time.perf_counter_ns()
- logging.info(f"LDAP search took {(end - start) / 1000000} ms")
- logging.info(f"Email addresses from LDAP: {len(email_to_uid)}")
-
- start = time.perf_counter_ns()
- tabulated_votes = {}
- start_unixtime = None
- async for _mid, msg in util.thread_messages(thread_id):
- from_raw = msg.get("from_raw", "")
- ok, from_email_lower, asf_uid = _tabulate_vote_identity(from_raw,
email_to_uid)
- if not ok:
- continue
-
- if asf_uid is not None:
- asf_uid_or_email = asf_uid
- list_raw = msg.get("list_raw", "")
- status = await _tabulate_vote_status(asf_uid, list_raw, committee)
- else:
- asf_uid_or_email = from_email_lower
- status = VoteStatus.UNKNOWN
-
- if start_unixtime is None:
- epoch = msg.get("epoch", "")
- if epoch:
- start_unixtime = int(epoch)
-
- subject = msg.get("subject", "")
- if "[RESULT]" in subject:
- break
-
- body = msg.get("body", "")
- if not body:
- continue
-
- castings = _tabulate_vote_castings(body)
- if not castings:
- continue
-
- if len(castings) == 1:
- vote_cast = castings[0][0]
- else:
- vote_cast = Vote.UNKNOWN
- quotation = " // ".join([c[1] for c in castings])
-
- vote_email = VoteEmail(
- 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,
- quotation=quotation,
- updated=asf_uid_or_email in tabulated_votes,
- )
- tabulated_votes[asf_uid_or_email] = vote_email
- end = time.perf_counter_ns()
- logging.info(f"Tabulated votes: {len(tabulated_votes)}")
- logging.info(f"Tabulation took {(end - start) / 1000000} ms")
-
- return start_unixtime, tabulated_votes
-
-
-def _tabulate_vote_break(line: str) -> bool:
- if line == "-- ":
- # Start of a signature
- return True
- if line.startswith("On ") and (line[6:8] == ", "):
- # Start of a quoted email
- return True
- if line.startswith("From: "):
- # Start of a quoted email
- return True
- if line.startswith("________"):
- # This is sometimes used as an "On " style quotation marker
- return True
- return False
-
-
-def _tabulate_vote_castings(body: str) -> list[tuple[Vote, str]]:
- castings = []
- for line in body.split("\n"):
- if _tabulate_vote_continue(line):
- continue
- if _tabulate_vote_break(line):
- break
-
- plus_one = line.startswith("+1") or " +1" in line
- minus_one = line.startswith("-1") or " -1" in line
- # We must be more stringent about zero votes, can't just check for "0"
in line
- zero = line in {"0", "-0", "+0"} or line.startswith("0 ") or
line.startswith("+0 ") or line.startswith("-0 ")
- if (plus_one and minus_one) or (plus_one and zero) or (minus_one and
zero):
- # Confusing result
- continue
- if plus_one:
- castings.append((Vote.YES, line))
- elif minus_one:
- castings.append((Vote.NO, line))
- elif zero:
- castings.append((Vote.ABSTAIN, line))
- 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",
- "[ ] -1",
- "binding +1 votes",
- "binding -1 votes",
- ]
- if any((indicator in line) for indicator in explanation_indicators):
- # These indicators are used by the [VOTE] OP to indicate how to vote
- return True
-
- if line.startswith(">"):
- # Used to quote other emails
- return True
- return False
-
-
-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]
-) -> tuple[bool, 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
-) -> tuple[bool, 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):
- rounded = round(duration_hours_remaining, 2)
- msg = f"The vote is still open for {rounded} hours, but it would
fail if closed now."
- elif duration_hours_remaining is None:
- msg = "The vote would fail if closed now."
- else:
- msg = "The vote failed."
- return False, msg
-
- if (duration_hours_remaining is not None) and (duration_hours_remaining >
0):
- rounded = round(duration_hours_remaining, 2)
- msg = f"The vote is still open for {rounded} hours, but it would pass
if closed now."
- else:
- msg = "The vote passed."
- return True, msg
-
-
-def _tabulate_vote_resolution(
- committee: models.Committee,
- release: models.Release,
- tabulated_votes: dict[str, VoteEmail],
- summary: dict[str, int],
- passed: bool,
- outcome: str,
- full_name: str,
- asf_uid: str,
- thread_id: str,
-) -> str:
- """Generate a resolution email body."""
- return "\n".join(
- _tabulate_vote_resolution_body(
- committee, release, tabulated_votes, summary, passed, outcome,
full_name, asf_uid, thread_id
- )
- )
-
-
-def _tabulate_vote_resolution_body(
- committee: models.Committee,
- release: models.Release,
- tabulated_votes: dict[str, VoteEmail],
- summary: dict[str, int],
- passed: bool,
- outcome: str,
- full_name: str,
- asf_uid: str,
- thread_id: str,
-) -> Generator[str]:
- committee_name = committee.display_name
- if release.podling_thread_id:
- committee_name = "Incubator"
- yield f"Dear {committee_name} participants,"
- yield ""
- outcome = "passed" if passed else "failed"
- yield f"The vote on {release.project.name} {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 ""
- yield "The current vote thread is archived at the following URL:"
- else:
- yield "The vote thread is archived at the following URL:"
- yield ""
- yield f"https://lists.apache.org/thread/{thread_id}"
- yield ""
-
- yield from _tabulate_vote_resolution_body_votes(tabulated_votes, summary)
- yield "Thank you for your participation."
- yield ""
- yield "Sincerely,"
- yield f"{full_name} ({asf_uid})"
-
-
-def _tabulate_vote_resolution_body_votes(
- tabulated_votes: dict[str, VoteEmail], summary: dict[str, int]
-) -> Generator[str]:
- yield from _tabulate_vote_resolution_votes(tabulated_votes,
{VoteStatus.BINDING})
-
- 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 ""
-
- binding_yes = summary["binding_votes_yes"]
- binding_no = summary["binding_votes_no"]
- binding_abstain = summary["binding_votes_abstain"]
- yield f"Of these binding votes, {binding_yes} were +1, {binding_no} were
-1, and {binding_abstain} were 0."
- yield ""
-
- yield from _tabulate_vote_resolution_votes(tabulated_votes,
{VoteStatus.COMMITTER})
- yield from _tabulate_vote_resolution_votes(tabulated_votes,
{VoteStatus.CONTRIBUTOR, VoteStatus.UNKNOWN})
-
-
-def _tabulate_vote_resolution_votes(tabulated_votes: dict[str, VoteEmail],
statuses: set[VoteStatus]) -> Generator[str]:
- header: str | None = f"The {' and '.join(status.value.lower() for status
in statuses)} votes were cast as follows:"
- for vote_email in tabulated_votes.values():
- if vote_email.status not in statuses:
- continue
- if header is not None:
- yield header
- yield ""
- header = None
- match vote_email.vote:
- case Vote.YES:
- symbol = "+1"
- case Vote.NO:
- symbol = "-1"
- case Vote.ABSTAIN:
- symbol = "0"
- case Vote.UNKNOWN:
- symbol = "?"
- user_info = vote_email.asf_uid_or_email
- status = vote_email.status.value.lower()
- if vote_email.updated:
- status += ", updated"
- yield f"{symbol} {user_info} ({status})"
- if header is None:
- yield ""
-
-
-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()
- if committee is not None:
- if asf_uid in committee.committee_members:
- status = VoteStatus.BINDING
- elif asf_uid in committee.committers:
- status = VoteStatus.COMMITTER
- else:
- status = VoteStatus.CONTRIBUTOR
- return status
-
-
-def _tabulate_vote_summary(tabulated_votes: dict[str, VoteEmail]) -> dict[str,
int]:
- result = {
- "binding_votes": 0,
- "binding_votes_yes": 0,
- "binding_votes_no": 0,
- "binding_votes_abstain": 0,
- "non_binding_votes": 0,
- "non_binding_votes_yes": 0,
- "non_binding_votes_no": 0,
- "non_binding_votes_abstain": 0,
- "unknown_votes": 0,
- "unknown_votes_yes": 0,
- "unknown_votes_no": 0,
- "unknown_votes_abstain": 0,
- }
-
- for vote_email in tabulated_votes.values():
- 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 {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
- result["non_binding_votes_abstain"] += 1 if (vote_email.vote.value
== "Abstain") else 0
- else:
- result["unknown_votes"] += 1
- result["unknown_votes_yes"] += 1 if (vote_email.vote.value ==
"Yes") else 0
- result["unknown_votes_no"] += 1 if (vote_email.vote.value == "No")
else 0
- result["unknown_votes_abstain"] += 1 if (vote_email.vote.value ==
"Abstain") else 0
-
- return result
-
-
async def _task_archive_url(task_mid: str) -> str | None:
if "@" not in task_mid:
return None
diff --git a/atr/tabulate.py b/atr/tabulate.py
new file mode 100644
index 0000000..3810df4
--- /dev/null
+++ b/atr/tabulate.py
@@ -0,0 +1,409 @@
+# 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 enum
+import logging
+import time
+from collections.abc import Generator
+
+import atr.db as db
+import atr.db.models as models
+import atr.schema as schema
+import atr.util as util
+
+
+class Vote(enum.Enum):
+ YES = "Yes"
+ NO = "No"
+ ABSTAIN = "-"
+ UNKNOWN = "?"
+
+
+class VoteStatus(enum.Enum):
+ BINDING = "Binding"
+ COMMITTER = "Committer"
+ CONTRIBUTOR = "Contributor"
+ UNKNOWN = "Unknown"
+
+
+class VoteEmail(schema.Strict):
+ asf_uid_or_email: str
+ from_email: str
+ status: VoteStatus
+ asf_eid: str
+ iso_datetime: str
+ vote: Vote
+ quotation: str
+ updated: bool
+
+
+async def votes(committee: models.Committee | None, thread_id: str) ->
tuple[int | None, dict[str, VoteEmail]]:
+ """Tabulate votes."""
+ start = time.perf_counter_ns()
+ email_to_uid = await util.email_to_uid_map()
+ end = time.perf_counter_ns()
+ logging.info(f"LDAP search took {(end - start) / 1000000} ms")
+ logging.info(f"Email addresses from LDAP: {len(email_to_uid)}")
+
+ start = time.perf_counter_ns()
+ tabulated_votes = {}
+ start_unixtime = None
+ async for _mid, msg in util.thread_messages(thread_id):
+ from_raw = msg.get("from_raw", "")
+ ok, from_email_lower, asf_uid = _vote_identity(from_raw, email_to_uid)
+ if not ok:
+ continue
+
+ if asf_uid is not None:
+ asf_uid_or_email = asf_uid
+ list_raw = msg.get("list_raw", "")
+ status = await _vote_status(asf_uid, list_raw, committee)
+ else:
+ asf_uid_or_email = from_email_lower
+ status = VoteStatus.UNKNOWN
+
+ if start_unixtime is None:
+ epoch = msg.get("epoch", "")
+ if epoch:
+ start_unixtime = int(epoch)
+
+ subject = msg.get("subject", "")
+ if "[RESULT]" in subject:
+ break
+
+ body = msg.get("body", "")
+ if not body:
+ continue
+
+ castings = _vote_castings(body)
+ if not castings:
+ continue
+
+ if len(castings) == 1:
+ vote_cast = castings[0][0]
+ else:
+ vote_cast = Vote.UNKNOWN
+ quotation = " // ".join([c[1] for c in castings])
+
+ vote_email = VoteEmail(
+ 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,
+ quotation=quotation,
+ updated=asf_uid_or_email in tabulated_votes,
+ )
+ tabulated_votes[asf_uid_or_email] = vote_email
+ end = time.perf_counter_ns()
+ logging.info(f"Tabulated votes: {len(tabulated_votes)}")
+ logging.info(f"Tabulation took {(end - start) / 1000000} ms")
+
+ return start_unixtime, tabulated_votes
+
+
+async def 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 vote_outcome(
+ release: models.Release, start_unixtime: int | None, tabulated_votes:
dict[str, VoteEmail]
+) -> tuple[bool, 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 _vote_outcome_format(duration_hours_remaining, binding_plus_one,
binding_minus_one)
+
+
+def vote_resolution(
+ committee: models.Committee,
+ release: models.Release,
+ tabulated_votes: dict[str, VoteEmail],
+ summary: dict[str, int],
+ passed: bool,
+ outcome: str,
+ full_name: str,
+ asf_uid: str,
+ thread_id: str,
+) -> 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
+ )
+ )
+
+
+def vote_summary(tabulated_votes: dict[str, VoteEmail]) -> dict[str, int]:
+ result = {
+ "binding_votes": 0,
+ "binding_votes_yes": 0,
+ "binding_votes_no": 0,
+ "binding_votes_abstain": 0,
+ "non_binding_votes": 0,
+ "non_binding_votes_yes": 0,
+ "non_binding_votes_no": 0,
+ "non_binding_votes_abstain": 0,
+ "unknown_votes": 0,
+ "unknown_votes_yes": 0,
+ "unknown_votes_no": 0,
+ "unknown_votes_abstain": 0,
+ }
+
+ for vote_email in tabulated_votes.values():
+ 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 {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
+ result["non_binding_votes_abstain"] += 1 if (vote_email.vote.value
== "Abstain") else 0
+ else:
+ result["unknown_votes"] += 1
+ result["unknown_votes_yes"] += 1 if (vote_email.vote.value ==
"Yes") else 0
+ result["unknown_votes_no"] += 1 if (vote_email.vote.value == "No")
else 0
+ result["unknown_votes_abstain"] += 1 if (vote_email.vote.value ==
"Abstain") else 0
+
+ return result
+
+
+def _vote_break(line: str) -> bool:
+ if line == "-- ":
+ # Start of a signature
+ return True
+ if line.startswith("On ") and (line[6:8] == ", "):
+ # Start of a quoted email
+ return True
+ if line.startswith("From: "):
+ # Start of a quoted email
+ return True
+ if line.startswith("________"):
+ # This is sometimes used as an "On " style quotation marker
+ return True
+ return False
+
+
+def _vote_castings(body: str) -> list[tuple[Vote, str]]:
+ castings = []
+ for line in body.split("\n"):
+ if _vote_continue(line):
+ continue
+ if _vote_break(line):
+ break
+
+ plus_one = line.startswith("+1") or " +1" in line
+ minus_one = line.startswith("-1") or " -1" in line
+ # We must be more stringent about zero votes, can't just check for "0"
in line
+ zero = line in {"0", "-0", "+0"} or line.startswith("0 ") or
line.startswith("+0 ") or line.startswith("-0 ")
+ if (plus_one and minus_one) or (plus_one and zero) or (minus_one and
zero):
+ # Confusing result
+ continue
+ if plus_one:
+ castings.append((Vote.YES, line))
+ elif minus_one:
+ castings.append((Vote.NO, line))
+ elif zero:
+ castings.append((Vote.ABSTAIN, line))
+ return castings
+
+
+def _vote_continue(line: str) -> bool:
+ explanation_indicators = [
+ "[ ] +1",
+ "[ ] -1",
+ "binding +1 votes",
+ "binding -1 votes",
+ ]
+ if any((indicator in line) for indicator in explanation_indicators):
+ # These indicators are used by the [VOTE] OP to indicate how to vote
+ return True
+
+ if line.startswith(">"):
+ # Used to quote other emails
+ return True
+ return False
+
+
+def _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 _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)
+ if not outcome_passed:
+ if (duration_hours_remaining is not None) and
(duration_hours_remaining > 0):
+ rounded = round(duration_hours_remaining, 2)
+ msg = f"The vote is still open for {rounded} hours, but it would
fail if closed now."
+ elif duration_hours_remaining is None:
+ msg = "The vote would fail if closed now."
+ else:
+ msg = "The vote failed."
+ return False, msg
+
+ if (duration_hours_remaining is not None) and (duration_hours_remaining >
0):
+ rounded = round(duration_hours_remaining, 2)
+ msg = f"The vote is still open for {rounded} hours, but it would pass
if closed now."
+ else:
+ msg = "The vote passed."
+ return True, msg
+
+
+def _vote_resolution_body(
+ committee: models.Committee,
+ release: models.Release,
+ tabulated_votes: dict[str, VoteEmail],
+ summary: dict[str, int],
+ passed: bool,
+ outcome: str,
+ full_name: str,
+ asf_uid: str,
+ thread_id: str,
+) -> Generator[str]:
+ committee_name = committee.display_name
+ if release.podling_thread_id:
+ committee_name = "Incubator"
+ yield f"Dear {committee_name} participants,"
+ yield ""
+ outcome = "passed" if passed else "failed"
+ yield f"The vote on {release.project.name} {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 ""
+ yield "The current vote thread is archived at the following URL:"
+ else:
+ yield "The vote thread is archived at the following URL:"
+ yield ""
+ yield f"https://lists.apache.org/thread/{thread_id}"
+ yield ""
+
+ yield from _vote_resolution_body_votes(tabulated_votes, summary)
+ yield "Thank you for your participation."
+ yield ""
+ yield "Sincerely,"
+ yield f"{full_name} ({asf_uid})"
+
+
+def _vote_resolution_body_votes(tabulated_votes: dict[str, VoteEmail],
summary: dict[str, int]) -> Generator[str]:
+ yield from _vote_resolution_votes(tabulated_votes, {VoteStatus.BINDING})
+
+ 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 ""
+
+ binding_yes = summary["binding_votes_yes"]
+ binding_no = summary["binding_votes_no"]
+ binding_abstain = summary["binding_votes_abstain"]
+ yield f"Of these binding votes, {binding_yes} were +1, {binding_no} were
-1, and {binding_abstain} were 0."
+ yield ""
+
+ yield from _vote_resolution_votes(tabulated_votes, {VoteStatus.COMMITTER})
+ yield from _vote_resolution_votes(tabulated_votes,
{VoteStatus.CONTRIBUTOR, VoteStatus.UNKNOWN})
+
+
+def _vote_resolution_votes(tabulated_votes: dict[str, VoteEmail], statuses:
set[VoteStatus]) -> Generator[str]:
+ header: str | None = f"The {' and '.join(status.value.lower() for status
in statuses)} votes were cast as follows:"
+ for vote_email in tabulated_votes.values():
+ if vote_email.status not in statuses:
+ continue
+ if header is not None:
+ yield header
+ yield ""
+ header = None
+ match vote_email.vote:
+ case Vote.YES:
+ symbol = "+1"
+ case Vote.NO:
+ symbol = "-1"
+ case Vote.ABSTAIN:
+ symbol = "0"
+ case Vote.UNKNOWN:
+ symbol = "?"
+ user_info = vote_email.asf_uid_or_email
+ status = vote_email.status.value.lower()
+ if vote_email.updated:
+ status += ", updated"
+ yield f"{symbol} {user_info} ({status})"
+ if header is None:
+ yield ""
+
+
+async def _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()
+ if committee is not None:
+ if asf_uid in committee.committee_members:
+ status = VoteStatus.BINDING
+ elif asf_uid in committee.committers:
+ status = VoteStatus.COMMITTER
+ else:
+ status = VoteStatus.CONTRIBUTOR
+ return status
diff --git a/atr/templates/check-selected-release-info.html
b/atr/templates/check-selected-release-info.html
index ef69b45..b70218b 100644
--- a/atr/templates/check-selected-release-info.html
+++ b/atr/templates/check-selected-release-info.html
@@ -83,7 +83,7 @@
class="btn btn-primary"><i class="bi bi-download me-1"></i>
Download files</a>
<a href="{{ as_url(routes.candidate.view,
project_name=release.project.name, version_name=release.version) }}"
class="btn btn-secondary"><i class="bi bi-eye me-1"></i> View
files</a>
- <form action="{{ as_url(routes.vote.selected_resolve,
project_name=release.project.name, version_name=release.version) }}"
+ <form action="{{ as_url(routes.resolve.tabulated_selected_post,
project_name=release.project.name, version_name=release.version) }}"
method="post"
class="mb-0">
{{ hidden_form.hidden_tag() }}
diff --git a/atr/templates/vote-resolve-manual.html
b/atr/templates/resolve-manual.html
similarity index 100%
rename from atr/templates/vote-resolve-manual.html
rename to atr/templates/resolve-manual.html
diff --git a/atr/templates/vote-resolve.html
b/atr/templates/resolve-tabulated.html
similarity index 97%
rename from atr/templates/vote-resolve.html
rename to atr/templates/resolve-tabulated.html
index bf5a229..9db8c1d 100644
--- a/atr/templates/vote-resolve.html
+++ b/atr/templates/resolve-tabulated.html
@@ -110,7 +110,7 @@
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>
<form class="atr-canary py-3 px-4 mb-4 border rounded"
- action="{{ as_url(routes.resolve.selected_post,
project_name=release.project.name, version_name=release.version) }}"
+ action="{{ as_url(routes.resolve.submit_selected,
project_name=release.project.name, version_name=release.version) }}"
method="post">
{{ forms.errors_summary(resolve_form) }}
{{ resolve_form.hidden_tag() }}
diff --git a/playwright/test.py b/playwright/test.py
index da5de65..60dd033 100644
--- a/playwright/test.py
+++ b/playwright/test.py
@@ -207,7 +207,7 @@ def lifecycle_05_resolve_vote(page: sync_api.Page,
credentials: Credentials, ver
logging.warning("Vote initiation banner not detected after 15s,
proceeding anyway")
logging.info("Locating the 'Resolve vote' button")
- tabulate_form_locator =
page.locator(f'form[action="/vote/tooling-test-example/{version_name}/resolve"]')
+ tabulate_form_locator =
page.locator(f'form[action="/resolve/tabulated/tooling-test-example/{version_name}"]')
sync_api.expect(tabulate_form_locator).to_be_visible()
tabulate_button_locator =
tabulate_form_locator.locator('button[type="submit"]:has-text("Resolve vote")')
@@ -216,10 +216,10 @@ def lifecycle_05_resolve_vote(page: sync_api.Page,
credentials: Credentials, ver
tabulate_button_locator.click()
logging.info("Waiting for navigation to tabulated votes page")
- wait_for_path(page, f"/vote/tooling-test-example/{version_name}/resolve")
+ wait_for_path(page,
f"/resolve/tabulated/tooling-test-example/{version_name}")
logging.info("Locating the resolve vote form on the tabulated votes page")
- resolve_form_locator =
page.locator(f'form[action="/resolve/tooling-test-example/{version_name}"]')
+ resolve_form_locator =
page.locator(f'form[action="/resolve/submit/tooling-test-example/{version_name}"]')
sync_api.expect(resolve_form_locator).to_be_visible()
logging.info("Selecting 'Passed' radio button in resolve form")
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]